Merge remote-tracking branch 'origin/main' into codex/viyatb/guardian-reviewer-retries

This commit is contained in:
viyatb-oai
2026-06-08 21:24:47 -07:00
653 changed files with 29189 additions and 14221 deletions
+5 -3
View File
@@ -1,11 +1,13 @@
[advisories]
# Reviewed 2026-04-15. Keep this list in sync with ../deny.toml.
# Reviewed 2026-06-07. Keep this list in sync with ../deny.toml.
ignore = [
"RUSTSEC-2024-0388", # derivative 2.2.0 via starlark; upstream crate is unmaintained
"RUSTSEC-2025-0057", # fxhash 0.2.1 via starlark_map; upstream crate is unmaintained
"RUSTSEC-2024-0388", # derivative 2.2.0 via starlark/starlark_syntax; upstream crate is unmaintained
"RUSTSEC-2025-0057", # fxhash 0.2.1 via starlark_map/bm25; upstream crate is unmaintained
"RUSTSEC-2024-0436", # paste 1.0.15 via starlark/ratatui; upstream crate is unmaintained
"RUSTSEC-2023-0089", # atomic-polyfill via postcard/heapless/pagable; upstream crate is unmaintained
"RUSTSEC-2024-0320", # yaml-rust via syntect; remove when syntect drops or updates it
"RUSTSEC-2025-0141", # bincode via syntect; remove when syntect drops or updates it
"RUSTSEC-2026-0118", # hickory-proto via rama-dns/rama-tcp; remove when rama updates to hickory 0.26.1 or hickory-net
"RUSTSEC-2026-0119", # hickory-proto via rama-dns/rama-tcp; remove when rama updates to hickory 0.26.1 or hickory-net
"RUSTSEC-2026-0173", # proc-macro-error2 via i18n-embed-fl/age/codex-secrets; remove when local secrets storage migrates off age or age drops i18n-embed-fl
]
+557 -356
View File
File diff suppressed because it is too large Load Diff
+9 -7
View File
@@ -83,6 +83,7 @@ members = [
"tools",
"v8-poc",
"utils/absolute-path",
"utils/path-uri",
"utils/cargo-bin",
"git-utils",
"utils/cache",
@@ -318,6 +319,7 @@ libc = "0.2.182"
log = "0.4"
lru = "0.16.3"
maplit = "1.0.2"
memchr = "2.7.6"
mime_guess = "2.0.5"
multimap = "0.10.0"
notify = "8.2.0"
@@ -383,7 +385,7 @@ sqlx = { version = "0.9.0", default-features = false, features = [
"time",
"uuid",
] }
starlark = "0.13.0"
starlark = { version = "0.14.2", default-features = false }
strum = "0.27.2"
strum_macros = "0.28.0"
supports-color = "3.0.2"
@@ -423,7 +425,7 @@ unicode-width = "0.2"
url = "2"
urlencoding = "2.1"
uuid = "1"
v8 = "=147.4.0"
v8 = "=149.2.0"
vt100 = "0.16.2"
walkdir = "2.5.0"
webbrowser = "1.0"
@@ -482,7 +484,6 @@ unwrap_used = "deny"
[workspace.metadata.cargo-shear]
ignored = [
"codex-agent-graph-store",
"codex-goal-extension",
"icu_provider",
"openssl-sys",
"codex-v8-poc",
@@ -500,11 +501,12 @@ debug = "none"
strip = "symbols"
[profile.release]
lto = "fat"
lto = "thin"
debug = "line-tables-only"
split-debuginfo = "off"
# Because we bundle some of these executables with the TypeScript CLI, we
# remove everything to make the binary as small as possible.
strip = "symbols"
# Keep release binaries symbolicateable until packaging has archived the
# sidecar symbols and stripped the binaries.
strip = false
# See https://github.com/openai/codex/issues/1411 for details.
codegen-units = 1
+2 -98
View File
@@ -1,99 +1,3 @@
# Codex CLI (Rust Implementation)
# Codex CLI
We provide Codex CLI as a standalone executable to ensure a zero-dependency install.
## Installing Codex
Today, the easiest way to install Codex is via `npm`:
```shell
npm i -g @openai/codex
codex
```
You can also install via Homebrew (`brew install --cask codex`) or download a platform-specific release directly from our [GitHub Releases](https://github.com/openai/codex/releases).
## Documentation quickstart
- First run with Codex? Start with [`docs/getting-started.md`](../docs/getting-started.md) (links to the walkthrough for prompts, keyboard shortcuts, and session management).
- Want deeper control? See [`docs/config.md`](../docs/config.md) and [`docs/install.md`](../docs/install.md).
## What's new in the Rust CLI
The Rust implementation is now the maintained Codex CLI and serves as the default experience. It includes a number of features that the legacy TypeScript CLI never supported.
### Config
Codex supports a rich set of configuration options. Note that the Rust CLI uses `config.toml` instead of `config.json`. See [`docs/config.md`](../docs/config.md) for details.
### Model Context Protocol Support
#### MCP client
Codex CLI functions as an MCP client that allows the Codex CLI and IDE extension to connect to MCP servers on startup. See the [`configuration documentation`](../docs/config.md#connecting-to-mcp-servers) for details.
#### MCP server (experimental)
Codex can be launched as an MCP _server_ by running `codex mcp-server`. This allows _other_ MCP clients to use Codex as a tool for another agent.
Use the [`@modelcontextprotocol/inspector`](https://github.com/modelcontextprotocol/inspector) to try it out:
```shell
npx @modelcontextprotocol/inspector codex mcp-server
```
Use `codex mcp` to add/list/get/remove MCP server launchers defined in `config.toml`, and `codex mcp-server` to run the MCP server directly.
### Notifications
You can enable notifications by configuring a script that is run whenever the agent finishes a turn. The [notify documentation](../docs/config.md#notify) includes a detailed example that explains how to get desktop notifications via [terminal-notifier](https://github.com/julienXX/terminal-notifier) on macOS. When Codex detects that it is running under WSL 2 inside Windows Terminal (`WT_SESSION` is set), the TUI automatically falls back to native Windows toast notifications so approval prompts and completed turns surface even though Windows Terminal does not implement OSC 9.
### `codex exec` to run Codex programmatically/non-interactively
To run Codex non-interactively, run `codex exec PROMPT` (you can also pass the prompt via `stdin`) and Codex will work on your task until it decides that it is done and exits. If you provide both a prompt argument and piped stdin, Codex appends stdin as a `<stdin>` block after the prompt so patterns like `echo "my output" | codex exec "Summarize this concisely"` work naturally. Output is printed to the terminal directly. You can set the `RUST_LOG` environment variable to see more about what's going on.
Use `codex exec --ephemeral ...` to run without persisting session rollout files to disk.
### Experimenting with the Codex Sandbox
To test to see what happens when a command is run under the sandbox provided by Codex, use the `sandbox` subcommand in Codex CLI:
```
# Uses the sandbox implementation for the current host OS:
# Seatbelt on macOS, the Linux sandbox on Linux, and Windows restricted token on Windows.
codex sandbox [COMMAND]...
# macOS-only diagnostic option
codex sandbox --log-denials [COMMAND]...
```
`codex sandbox` also accepts `--profile NAME` (`-p NAME`) to layer
`$CODEX_HOME/NAME.config.toml` onto the base user config for the sandboxed
command.
### Selecting a sandbox policy via `--sandbox`
The Rust CLI exposes a dedicated `--sandbox` (`-s`) flag that lets you pick the sandbox policy **without** having to reach for the generic `-c/--config` option:
```shell
# Run Codex with the default, read-only sandbox
codex --sandbox read-only
# Allow the agent to write within the current workspace while still blocking network access
codex --sandbox workspace-write
# Danger! Disable sandboxing entirely (only do this if you are already running in a container or other isolated env)
codex --sandbox danger-full-access
```
In `workspace-write`, Codex also includes `~/.codex/memories` in its writable roots so memory maintenance does not require an extra approval.
## Code Organization
This folder is the root of a Cargo workspace. It contains quite a bit of experimental code, but here are the key crates:
- [`core/`](./core) contains the business logic for Codex. Ultimately, we hope this becomes a library crate that is generally useful for building other Rust/native applications that use Codex.
- [`exec/`](./exec) "headless" CLI for use in automation.
- [`tui/`](./tui) CLI that launches a fullscreen TUI built with [Ratatui](https://ratatui.rs/).
- [`cli/`](./cli) CLI multitool that provides the aforementioned CLIs via subcommands.
If you want to contribute or inspect behavior in detail, start by reading the module-level `README.md` files under each crate and run the project workspace from the top-level `codex-rs` directory so shared config, features, and build scripts stay aligned.
[**Codex CLI Documentation**](https://developers.openai.com/codex/cli)
@@ -63,6 +63,8 @@ use crate::facts::SubAgentThreadStartedInput;
use crate::facts::ThreadInitializationMode;
use crate::facts::TrackEventsContext;
use crate::facts::TurnCodexErrorFact;
use crate::facts::TurnProfile;
use crate::facts::TurnProfileFact;
use crate::facts::TurnResolvedConfigFact;
use crate::facts::TurnStatus;
use crate::facts::TurnSteerRequestError;
@@ -396,6 +398,18 @@ fn sample_turn_resolved_config(thread_id: &str, turn_id: &str) -> TurnResolvedCo
}
}
fn sample_turn_profile() -> TurnProfile {
TurnProfile {
before_first_sampling_ms: 100,
sampling_ms: 700,
between_sampling_overhead_ms: 50,
tool_blocking_ms: 250,
after_last_sampling_ms: 134,
sampling_request_count: 2,
sampling_retry_count: 1,
}
}
fn sample_turn_steer_request(
thread_id: &str,
expected_turn_id: &str,
@@ -649,6 +663,18 @@ async fn ingest_turn_prerequisites(
)
.await;
}
reducer
.ingest(
AnalyticsFact::Custom(CustomAnalyticsFact::TurnProfile(Box::new(
TurnProfileFact {
turn_id: "turn-2".to_string(),
profile: sample_turn_profile(),
},
))),
out,
)
.await;
}
async fn ingest_review_prerequisites(
@@ -1227,6 +1253,8 @@ fn compaction_event_serializes_expected_shape() {
error: None,
active_context_tokens_before: 120_000,
active_context_tokens_after: 18_000,
retained_image_count: None,
compaction_summary_tokens: None,
started_at: 100,
completed_at: 106,
duration_ms: Some(6543),
@@ -1275,6 +1303,8 @@ fn compaction_event_serializes_expected_shape() {
"error": null,
"active_context_tokens_before": 120000,
"active_context_tokens_after": 18000,
"retained_image_count": null,
"compaction_summary_tokens": null,
"started_at": 100,
"completed_at": 106,
"duration_ms": 6543
@@ -1347,6 +1377,7 @@ fn thread_initialized_event_serializes_expected_shape() {
initialization_mode: ThreadInitializationMode::New,
subagent_source: None,
parent_thread_id: None,
forked_from_thread_id: None,
created_at: 1,
},
});
@@ -1379,6 +1410,7 @@ fn thread_initialized_event_serializes_expected_shape() {
"initialization_mode": "new",
"subagent_source": null,
"parent_thread_id": null,
"forked_from_thread_id": null,
"created_at": 1
}
})
@@ -1793,6 +1825,8 @@ async fn compaction_event_ingests_custom_fact() {
error: Some("context limit exceeded".to_string()),
active_context_tokens_before: 131_000,
active_context_tokens_after: 131_000,
retained_image_count: None,
compaction_summary_tokens: None,
started_at: 100,
completed_at: 101,
duration_ms: Some(1200),
@@ -2429,6 +2463,7 @@ fn subagent_thread_started_review_serializes_expected_shape() {
session_id: "session-root".to_string(),
thread_id: "thread-review".to_string(),
parent_thread_id: None,
forked_from_thread_id: None,
product_client_id: "codex-tui".to_string(),
client_name: "codex-tui".to_string(),
client_version: "1.0.0".to_string(),
@@ -2461,18 +2496,26 @@ fn subagent_thread_started_review_serializes_expected_shape() {
assert_eq!(payload["event_params"]["initialization_mode"], "new");
assert_eq!(payload["event_params"]["subagent_source"], "review");
assert_eq!(payload["event_params"]["parent_thread_id"], json!(null));
assert_eq!(
payload["event_params"]["forked_from_thread_id"],
json!(null)
);
}
#[test]
fn subagent_thread_started_thread_spawn_serializes_parent_thread_id() {
fn subagent_thread_started_thread_spawn_serializes_thread_lineage() {
let parent_thread_id =
codex_protocol::ThreadId::from_string("11111111-1111-1111-1111-111111111111")
.expect("valid thread id");
let forked_from_thread_id =
codex_protocol::ThreadId::from_string("22222222-2222-4222-8222-222222222222")
.expect("valid thread id");
let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request(
SubAgentThreadStartedInput {
session_id: "session-root".to_string(),
thread_id: "thread-spawn".to_string(),
parent_thread_id: Some(parent_thread_id.to_string()),
forked_from_thread_id: Some(forked_from_thread_id.to_string()),
product_client_id: "codex-tui".to_string(),
client_name: "codex-tui".to_string(),
client_version: "1.0.0".to_string(),
@@ -2497,6 +2540,10 @@ fn subagent_thread_started_thread_spawn_serializes_parent_thread_id() {
payload["event_params"]["parent_thread_id"],
"11111111-1111-1111-1111-111111111111"
);
assert_eq!(
payload["event_params"]["forked_from_thread_id"],
"22222222-2222-4222-8222-222222222222"
);
assert_eq!(payload["event_params"]["session_id"], "session-root");
}
@@ -2507,6 +2554,7 @@ fn subagent_thread_started_memory_consolidation_serializes_expected_shape() {
session_id: "session-root".to_string(),
thread_id: "thread-memory".to_string(),
parent_thread_id: None,
forked_from_thread_id: None,
product_client_id: "codex-tui".to_string(),
client_name: "codex-tui".to_string(),
client_version: "1.0.0".to_string(),
@@ -2533,6 +2581,7 @@ fn subagent_thread_started_other_serializes_expected_shape() {
session_id: "session-root".to_string(),
thread_id: "thread-guardian".to_string(),
parent_thread_id: None,
forked_from_thread_id: None,
product_client_id: "codex-tui".to_string(),
client_name: "codex-tui".to_string(),
client_version: "1.0.0".to_string(),
@@ -2558,6 +2607,7 @@ fn subagent_thread_started_other_serializes_explicit_parent_thread_id() {
session_id: "session-root".to_string(),
thread_id: "thread-guardian".to_string(),
parent_thread_id: Some(parent_thread_id.to_string()),
forked_from_thread_id: None,
product_client_id: "codex-tui".to_string(),
client_name: "codex-tui".to_string(),
client_version: "1.0.0".to_string(),
@@ -2588,6 +2638,7 @@ async fn subagent_thread_started_publishes_without_initialize() {
session_id: "session-root".to_string(),
thread_id: "thread-review".to_string(),
parent_thread_id: None,
forked_from_thread_id: None,
product_client_id: "codex-tui".to_string(),
client_name: "codex-tui".to_string(),
client_version: "1.0.0".to_string(),
@@ -2662,6 +2713,7 @@ async fn subagent_thread_started_inherits_parent_connection_for_new_thread() {
session_id: "session-root".to_string(),
thread_id: "thread-review".to_string(),
parent_thread_id: Some(parent_thread_id.to_string()),
forked_from_thread_id: None,
product_client_id: "parent-client".to_string(),
client_name: "parent-client".to_string(),
client_version: "1.0.0".to_string(),
@@ -2697,6 +2749,8 @@ async fn subagent_thread_started_inherits_parent_connection_for_new_thread() {
error: None,
active_context_tokens_before: 131_000,
active_context_tokens_after: 64_000,
retained_image_count: None,
compaction_summary_tokens: None,
started_at: 100,
completed_at: 101,
duration_ms: Some(1200),
@@ -2732,6 +2786,7 @@ async fn subagent_tool_items_inherit_parent_connection_metadata() {
session_id: "session-root".to_string(),
thread_id: "thread-subagent".to_string(),
parent_thread_id: Some("thread-1".to_string()),
forked_from_thread_id: None,
product_client_id: "codex-tui".to_string(),
client_name: "codex-tui".to_string(),
client_version: "1.0.0".to_string(),
@@ -2828,6 +2883,7 @@ fn plugin_used_event_serializes_expected_shape() {
"mcp_server_count": 2,
"connector_ids": ["calendar", "drive"],
"product_client_id": originator().value,
"mcp_server_names": ["mcp-1", "mcp-2"],
"thread_id": "thread-3",
"turn_id": "turn-3",
"model_slug": "gpt-5"
@@ -3262,7 +3318,6 @@ fn turn_event_serializes_expected_shape() {
status: Some(TurnStatus::Completed),
turn_error: None,
codex_error_kind: None,
codex_error_subreason: None,
codex_error_http_status_code: None,
steer_count: Some(0),
total_tool_call_count: None,
@@ -3278,6 +3333,13 @@ fn turn_event_serializes_expected_shape() {
output_tokens: None,
reasoning_output_tokens: None,
total_tokens: None,
before_first_sampling_ms: 100,
sampling_ms: 700,
between_sampling_overhead_ms: 50,
tool_blocking_ms: 250,
after_last_sampling_ms: 134,
sampling_request_count: 2,
sampling_retry_count: 1,
duration_ms: Some(1234),
started_at: Some(455),
completed_at: Some(456),
@@ -3328,7 +3390,6 @@ fn turn_event_serializes_expected_shape() {
"status": "completed",
"turn_error": null,
"codex_error_kind": null,
"codex_error_subreason": null,
"codex_error_http_status_code": null,
"steer_count": 0,
"total_tool_call_count": null,
@@ -3344,6 +3405,13 @@ fn turn_event_serializes_expected_shape() {
"output_tokens": null,
"reasoning_output_tokens": null,
"total_tokens": null,
"before_first_sampling_ms": 100,
"sampling_ms": 700,
"between_sampling_overhead_ms": 50,
"tool_blocking_ms": 250,
"after_last_sampling_ms": 134,
"sampling_request_count": 2,
"sampling_retry_count": 1,
"duration_ms": 1234,
"started_at": 455,
"completed_at": 456
@@ -4028,10 +4096,6 @@ async fn turn_lifecycle_emits_failed_turn_event() {
payload["event_params"]["codex_error_kind"],
json!("invalid_request")
);
assert_eq!(
payload["event_params"]["codex_error_subreason"],
json!("unknown turn environment id `env-2`")
);
assert_eq!(
payload["event_params"]["codex_error_http_status_code"],
json!(null)
+7
View File
@@ -19,6 +19,7 @@ use crate::facts::SkillInvokedInput;
use crate::facts::SubAgentThreadStartedInput;
use crate::facts::TrackEventsContext;
use crate::facts::TurnCodexErrorFact;
use crate::facts::TurnProfileFact;
use crate::facts::TurnResolvedConfigFact;
use crate::facts::TurnTokenUsageFact;
use crate::reducer::AnalyticsReducer;
@@ -257,6 +258,12 @@ impl AnalyticsEventsClient {
)));
}
pub fn track_turn_profile(&self, fact: TurnProfileFact) {
self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnProfile(
Box::new(fact),
)));
}
pub fn track_turn_codex_error(&self, fact: TurnCodexErrorFact) {
self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnCodexError(
Box::new(fact),
+19 -1
View File
@@ -157,6 +157,7 @@ pub(crate) struct ThreadInitializedEventParams {
pub(crate) initialization_mode: ThreadInitializationMode,
pub(crate) subagent_source: Option<String>,
pub(crate) parent_thread_id: Option<String>,
pub(crate) forked_from_thread_id: Option<String>,
pub(crate) created_at: u64,
}
@@ -757,6 +758,8 @@ pub(crate) struct CodexCompactionEventParams {
pub(crate) error: Option<String>,
pub(crate) active_context_tokens_before: i64,
pub(crate) active_context_tokens_after: i64,
pub(crate) retained_image_count: Option<usize>,
pub(crate) compaction_summary_tokens: Option<i64>,
pub(crate) started_at: u64,
pub(crate) completed_at: u64,
pub(crate) duration_ms: Option<u64>,
@@ -800,7 +803,6 @@ pub(crate) struct CodexTurnEventParams {
pub(crate) status: Option<TurnStatus>,
pub(crate) turn_error: Option<CodexErrorInfo>,
pub(crate) codex_error_kind: Option<CodexErrKind>,
pub(crate) codex_error_subreason: Option<String>,
pub(crate) codex_error_http_status_code: Option<u16>,
pub(crate) steer_count: Option<usize>,
pub(crate) total_tool_call_count: Option<usize>,
@@ -816,6 +818,13 @@ pub(crate) struct CodexTurnEventParams {
pub(crate) output_tokens: Option<i64>,
pub(crate) reasoning_output_tokens: Option<i64>,
pub(crate) total_tokens: Option<i64>,
pub(crate) before_first_sampling_ms: u64,
pub(crate) sampling_ms: u64,
pub(crate) between_sampling_overhead_ms: u64,
pub(crate) tool_blocking_ms: u64,
pub(crate) after_last_sampling_ms: u64,
pub(crate) sampling_request_count: u32,
pub(crate) sampling_retry_count: u32,
pub(crate) duration_ms: Option<u64>,
pub(crate) started_at: Option<u64>,
pub(crate) completed_at: Option<u64>,
@@ -865,6 +874,7 @@ pub(crate) struct CodexPluginMetadata {
pub(crate) struct CodexPluginUsedMetadata {
#[serde(flatten)]
pub(crate) plugin: CodexPluginMetadata,
pub(crate) mcp_server_names: Option<Vec<String>>,
pub(crate) thread_id: Option<String>,
pub(crate) turn_id: Option<String>,
pub(crate) model_slug: Option<String>,
@@ -961,6 +971,8 @@ pub(crate) fn codex_compaction_event_params(
error: input.error,
active_context_tokens_before: input.active_context_tokens_before,
active_context_tokens_after: input.active_context_tokens_after,
retained_image_count: input.retained_image_count,
compaction_summary_tokens: input.compaction_summary_tokens,
started_at: input.started_at,
completed_at: input.completed_at,
duration_ms: input.duration_ms,
@@ -971,8 +983,13 @@ pub(crate) fn codex_plugin_used_metadata(
tracking: &TrackEventsContext,
plugin: PluginTelemetryMetadata,
) -> CodexPluginUsedMetadata {
let mcp_server_names = plugin
.capability_summary
.as_ref()
.map(|summary| summary.mcp_server_names.clone());
CodexPluginUsedMetadata {
plugin: codex_plugin_metadata(plugin),
mcp_server_names,
thread_id: Some(tracking.thread_id.clone()),
turn_id: Some(tracking.turn_id.clone()),
model_slug: Some(tracking.model_slug.clone()),
@@ -1054,6 +1071,7 @@ pub(crate) fn subagent_thread_started_event_request(
initialization_mode: ThreadInitializationMode::New,
subagent_source: Some(subagent_source_name(&input.subagent_source)),
parent_thread_id: input.parent_thread_id,
forked_from_thread_id: input.forked_from_thread_id,
created_at: input.created_at,
};
ThreadInitializedEvent {
+21 -24
View File
@@ -30,9 +30,6 @@ use codex_protocol::request_permissions::RequestPermissionsResponse;
use serde::Serialize;
use std::path::PathBuf;
const INVALID_REQUEST_SUBREASON_MAX_BYTES: usize = 512;
const INVALID_REQUEST_SUBREASON_TRUNCATION_SUFFIX: &str = "...";
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AcceptedLineFingerprint {
pub path_hash: String,
@@ -104,6 +101,23 @@ pub struct TurnTokenUsageFact {
pub token_usage: TokenUsage,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct TurnProfile {
pub before_first_sampling_ms: u64,
pub sampling_ms: u64,
pub between_sampling_overhead_ms: u64,
pub tool_blocking_ms: u64,
pub after_last_sampling_ms: u64,
pub sampling_request_count: u32,
pub sampling_retry_count: u32,
}
#[derive(Clone)]
pub struct TurnProfileFact {
pub turn_id: String,
pub profile: TurnProfile,
}
#[derive(Clone)]
pub struct TurnCodexErrorFact {
pub(crate) turn_id: String,
@@ -165,7 +179,6 @@ pub(crate) enum CodexErrKind {
#[derive(Clone)]
pub(crate) struct TurnCodexError {
pub(crate) kind: CodexErrKind,
pub(crate) subreason: Option<String>,
pub(crate) http_status_code: Option<u16>,
}
@@ -173,26 +186,6 @@ impl TurnCodexError {
fn from_codex_err(error: &CodexErr) -> Self {
Self {
kind: error.into(),
subreason: match error {
CodexErr::InvalidRequest(message) => {
// InvalidRequest can contain raw provider response bodies, so bound the
// analytics copy without changing the source CodexErr.
let subreason = if message.len() <= INVALID_REQUEST_SUBREASON_MAX_BYTES {
message.clone()
} else {
let truncated_len = message.floor_char_boundary(
INVALID_REQUEST_SUBREASON_MAX_BYTES
.saturating_sub(INVALID_REQUEST_SUBREASON_TRUNCATION_SUFFIX.len()),
);
format!(
"{}{INVALID_REQUEST_SUBREASON_TRUNCATION_SUFFIX}",
&message[..truncated_len]
)
};
Some(subreason)
}
_ => None,
},
http_status_code: error.http_status_code_value(),
}
}
@@ -348,6 +341,7 @@ pub struct SubAgentThreadStartedInput {
pub session_id: String,
pub thread_id: String,
pub parent_thread_id: Option<String>,
pub forked_from_thread_id: Option<String>,
pub product_client_id: String,
pub client_name: String,
pub client_version: String,
@@ -416,6 +410,8 @@ pub struct CodexCompactionEvent {
pub error: Option<String>,
pub active_context_tokens_before: i64,
pub active_context_tokens_after: i64,
pub retained_image_count: Option<usize>,
pub compaction_summary_tokens: Option<i64>,
pub started_at: u64,
pub completed_at: u64,
pub duration_ms: Option<u64>,
@@ -475,6 +471,7 @@ pub(crate) enum CustomAnalyticsFact {
GuardianReview(Box<GuardianReviewEventParams>),
TurnResolvedConfig(Box<TurnResolvedConfigFact>),
TurnTokenUsage(Box<TurnTokenUsageFact>),
TurnProfile(Box<TurnProfileFact>),
TurnCodexError(Box<TurnCodexErrorFact>),
SkillInvoked(SkillInvokedInput),
AppMentioned(AppMentionedInput),
+2
View File
@@ -39,6 +39,8 @@ pub use facts::SubAgentThreadStartedInput;
pub use facts::ThreadInitializationMode;
pub use facts::TrackEventsContext;
pub use facts::TurnCodexErrorFact;
pub use facts::TurnProfile;
pub use facts::TurnProfileFact;
pub use facts::TurnResolvedConfigFact;
pub use facts::TurnStatus;
pub use facts::TurnSteerRejectionReason;
+54 -100
View File
@@ -72,6 +72,8 @@ use crate::facts::SubAgentThreadStartedInput;
use crate::facts::ThreadInitializationMode;
use crate::facts::TurnCodexError;
use crate::facts::TurnCodexErrorFact;
use crate::facts::TurnProfile;
use crate::facts::TurnProfileFact;
use crate::facts::TurnResolvedConfigFact;
use crate::facts::TurnStatus;
use crate::facts::TurnSteerRejectionReason;
@@ -316,6 +318,7 @@ struct CompletedTurnState {
duration_ms: Option<u64>,
}
#[derive(Default)]
struct TurnState {
connection_id: Option<u64>,
thread_id: Option<String>,
@@ -323,6 +326,7 @@ struct TurnState {
resolved_config: Option<TurnResolvedConfigFact>,
started_at: Option<u64>,
token_usage: Option<TokenUsage>,
profile: Option<TurnProfile>,
completed: Option<CompletedTurnState>,
codex_error: Option<TurnCodexError>,
latest_diff: Option<String>,
@@ -464,6 +468,9 @@ impl AnalyticsReducer {
CustomAnalyticsFact::TurnTokenUsage(input) => {
self.ingest_turn_token_usage(*input, out).await;
}
CustomAnalyticsFact::TurnProfile(input) => {
self.ingest_turn_profile(*input, out).await;
}
CustomAnalyticsFact::TurnCodexError(input) => {
self.ingest_turn_codex_error(*input);
}
@@ -604,19 +611,7 @@ impl AnalyticsReducer {
let turn_id = input.turn_id.clone();
let thread_id = input.thread_id.clone();
let num_input_images = input.num_input_images;
let turn_state = self.turns.entry(turn_id.clone()).or_insert(TurnState {
connection_id: None,
thread_id: None,
num_input_images: None,
resolved_config: None,
started_at: None,
token_usage: None,
completed: None,
codex_error: None,
latest_diff: None,
steer_count: 0,
tool_counts: TurnToolCounts::default(),
});
let turn_state = self.turns.entry(turn_id.clone()).or_default();
turn_state.thread_id = Some(thread_id);
turn_state.num_input_images = Some(num_input_images);
turn_state.resolved_config = Some(input);
@@ -629,43 +624,30 @@ impl AnalyticsReducer {
out: &mut Vec<TrackEventRequest>,
) {
let turn_id = input.turn_id.clone();
let turn_state = self.turns.entry(turn_id.clone()).or_insert(TurnState {
connection_id: None,
thread_id: None,
num_input_images: None,
resolved_config: None,
started_at: None,
token_usage: None,
completed: None,
codex_error: None,
latest_diff: None,
steer_count: 0,
tool_counts: TurnToolCounts::default(),
});
let turn_state = self.turns.entry(turn_id.clone()).or_default();
turn_state.thread_id = Some(input.thread_id);
turn_state.token_usage = Some(input.token_usage);
self.maybe_emit_turn_event(&turn_id, out).await;
}
async fn ingest_turn_profile(
&mut self,
input: TurnProfileFact,
out: &mut Vec<TrackEventRequest>,
) {
let TurnProfileFact { turn_id, profile } = input;
let turn_state = self.turns.entry(turn_id.clone()).or_default();
turn_state.profile = Some(profile);
self.maybe_emit_turn_event(&turn_id, out).await;
}
fn ingest_turn_codex_error(&mut self, input: TurnCodexErrorFact) {
let TurnCodexErrorFact {
turn_id,
thread_id,
error,
} = input;
let turn_state = self.turns.entry(turn_id).or_insert(TurnState {
connection_id: None,
thread_id: None,
num_input_images: None,
resolved_config: None,
started_at: None,
token_usage: None,
completed: None,
codex_error: None,
latest_diff: None,
steer_count: 0,
tool_counts: TurnToolCounts::default(),
});
let turn_state = self.turns.entry(turn_id).or_default();
turn_state.thread_id.get_or_insert(thread_id);
turn_state.codex_error = Some(error);
}
@@ -818,19 +800,7 @@ impl AnalyticsReducer {
else {
return;
};
let turn_state = self.turns.entry(turn_id.clone()).or_insert(TurnState {
connection_id: None,
thread_id: None,
num_input_images: None,
resolved_config: None,
started_at: None,
token_usage: None,
completed: None,
codex_error: None,
latest_diff: None,
steer_count: 0,
tool_counts: TurnToolCounts::default(),
});
let turn_state = self.turns.entry(turn_id.clone()).or_default();
turn_state.connection_id = Some(connection_id);
turn_state.thread_id = Some(pending_request.thread_id);
turn_state.num_input_images = Some(pending_request.num_input_images);
@@ -1178,61 +1148,19 @@ impl AnalyticsReducer {
self.ingest_guardian_review_completed(notification, out);
}
ServerNotification::TurnStarted(notification) => {
let turn_state = self.turns.entry(notification.turn.id).or_insert(TurnState {
connection_id: None,
thread_id: None,
num_input_images: None,
resolved_config: None,
started_at: None,
token_usage: None,
completed: None,
codex_error: None,
latest_diff: None,
steer_count: 0,
tool_counts: TurnToolCounts::default(),
});
let turn_state = self.turns.entry(notification.turn.id).or_default();
turn_state.started_at = notification
.turn
.started_at
.and_then(|started_at| u64::try_from(started_at).ok());
}
ServerNotification::TurnDiffUpdated(notification) => {
let turn_state =
self.turns
.entry(notification.turn_id.clone())
.or_insert(TurnState {
connection_id: None,
thread_id: None,
num_input_images: None,
resolved_config: None,
started_at: None,
token_usage: None,
completed: None,
codex_error: None,
latest_diff: None,
steer_count: 0,
tool_counts: TurnToolCounts::default(),
});
let turn_state = self.turns.entry(notification.turn_id.clone()).or_default();
turn_state.thread_id = Some(notification.thread_id);
turn_state.latest_diff = Some(notification.diff);
}
ServerNotification::TurnCompleted(notification) => {
let turn_state =
self.turns
.entry(notification.turn.id.clone())
.or_insert(TurnState {
connection_id: None,
thread_id: None,
num_input_images: None,
resolved_config: None,
started_at: None,
token_usage: None,
completed: None,
codex_error: None,
latest_diff: None,
steer_count: 0,
tool_counts: TurnToolCounts::default(),
});
let turn_state = self.turns.entry(notification.turn.id.clone()).or_default();
turn_state.completed = Some(CompletedTurnState {
status: analytics_turn_status(notification.turn.status),
turn_error: notification
@@ -1268,6 +1196,7 @@ impl AnalyticsReducer {
let session_id = thread.session_id;
let thread_id = thread.id;
let parent_thread_id = thread.parent_thread_id;
let forked_from_thread_id = thread.forked_from_id;
let Some(connection_state) = self.connections.get(&connection_id) else {
return;
};
@@ -1299,6 +1228,7 @@ impl AnalyticsReducer {
initialization_mode,
subagent_source: thread_metadata.subagent_source.clone(),
parent_thread_id: thread_metadata.parent_thread_id,
forked_from_thread_id,
created_at: u64::try_from(thread.created_at).unwrap_or_default(),
},
},
@@ -1509,6 +1439,7 @@ impl AnalyticsReducer {
if turn_state.thread_id.is_none()
|| turn_state.num_input_images.is_none()
|| turn_state.resolved_config.is_none()
|| turn_state.profile.is_none()
|| turn_state.completed.is_none()
{
return;
@@ -2455,12 +2386,20 @@ fn codex_turn_event_params(
turn_state: &TurnState,
thread_metadata: &ThreadMetadataState,
) -> CodexTurnEventParams {
let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = (
let (
Some(thread_id),
Some(num_input_images),
Some(resolved_config),
Some(profile),
Some(completed),
) = (
turn_state.thread_id.clone(),
turn_state.num_input_images,
turn_state.resolved_config.clone(),
turn_state.profile.clone(),
turn_state.completed.clone(),
) else {
)
else {
unreachable!("turn event params require a fully populated turn state");
};
let started_at = turn_state.started_at;
@@ -2486,6 +2425,15 @@ fn codex_turn_event_params(
workspace_kind,
is_first_turn,
} = resolved_config;
let TurnProfile {
before_first_sampling_ms,
sampling_ms,
between_sampling_overhead_ms,
tool_blocking_ms,
after_last_sampling_ms,
sampling_request_count,
sampling_retry_count,
} = profile;
let token_usage = turn_state.token_usage.clone();
let codex_error = turn_state.codex_error.as_ref();
CodexTurnEventParams {
@@ -2522,7 +2470,6 @@ fn codex_turn_event_params(
status: completed.status,
turn_error: completed.turn_error,
codex_error_kind: codex_error.map(|error| error.kind),
codex_error_subreason: codex_error.and_then(|error| error.subreason.clone()),
codex_error_http_status_code: codex_error.and_then(|error| error.http_status_code),
steer_count: Some(turn_state.steer_count),
total_tool_call_count: Some(turn_state.tool_counts.total),
@@ -2548,6 +2495,13 @@ fn codex_turn_event_params(
total_tokens: token_usage
.as_ref()
.map(|token_usage| token_usage.total_tokens),
before_first_sampling_ms,
sampling_ms,
between_sampling_overhead_ms,
tool_blocking_ms,
after_last_sampling_ms,
sampling_request_count,
sampling_retry_count,
duration_ms: completed.duration_ms,
started_at,
completed_at: Some(completed.completed_at),
+80 -9
View File
@@ -34,6 +34,30 @@
],
"type": "string"
},
"AgentMessageInputContent": {
"oneOf": [
{
"properties": {
"encrypted_content": {
"type": "string"
},
"type": {
"enum": [
"encrypted_content"
],
"title": "EncryptedContentAgentMessageInputContentType",
"type": "string"
}
},
"required": [
"encrypted_content",
"type"
],
"title": "EncryptedContentAgentMessageInputContent",
"type": "object"
}
]
},
"ApprovalsReviewer": {
"description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.",
"enum": [
@@ -1971,15 +1995,8 @@
"type": "string"
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"ReasoningItemContent": {
@@ -2127,6 +2144,37 @@
"title": "MessageResponseItem",
"type": "object"
},
{
"properties": {
"author": {
"type": "string"
},
"content": {
"items": {
"$ref": "#/definitions/AgentMessageInputContent"
},
"type": "array"
},
"recipient": {
"type": "string"
},
"type": {
"enum": [
"agent_message"
],
"title": "AgentMessageResponseItemType",
"type": "string"
}
},
"required": [
"author",
"content",
"recipient",
"type"
],
"title": "AgentMessageResponseItem",
"type": "object"
},
{
"properties": {
"content": {
@@ -5921,6 +5969,29 @@
"title": "Account/rateLimits/readRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"account/usage/read"
],
"title": "Account/usage/readRequestMethod",
"type": "string"
},
"params": {
"type": "null"
}
},
"required": [
"id",
"method"
],
"title": "Account/usage/readRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -288,8 +288,8 @@
"environmentId": {
"default": null,
"type": [
"null",
"string"
"string",
"null"
]
},
"itemId": {
@@ -326,4 +326,4 @@
],
"title": "PermissionsRequestApprovalParams",
"type": "object"
}
}
@@ -525,6 +525,13 @@
"agentIdentity"
],
"type": "string"
},
{
"description": "Programmatic Codex auth backed by a personal access token.",
"enum": [
"personalAccessToken"
],
"type": "string"
}
]
},
@@ -2227,6 +2234,12 @@
},
"status": {
"$ref": "#/definitions/McpServerStartupState"
},
"threadId": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -2777,15 +2790,8 @@
"type": "string"
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"ReasoningSummary": {
@@ -4916,6 +4922,23 @@
}
]
},
"TurnModerationMetadataNotification": {
"properties": {
"metadata": true,
"threadId": {
"type": "string"
},
"turnId": {
"type": "string"
}
},
"required": [
"metadata",
"threadId",
"turnId"
],
"type": "object"
},
"TurnPlanStep": {
"properties": {
"status": {
@@ -6255,6 +6278,26 @@
"title": "Model/verificationNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"turn/moderationMetadata"
],
"title": "Turn/moderationMetadataNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/TurnModerationMetadataNotification"
}
},
"required": [
"method",
"params"
],
"title": "Turn/moderationMetadataNotification",
"type": "object"
},
{
"properties": {
"method": {
+3 -3
View File
@@ -1593,8 +1593,8 @@
"environmentId": {
"default": null,
"type": [
"null",
"string"
"string",
"null"
]
},
"itemId": {
@@ -2005,4 +2005,4 @@
}
],
"title": "ServerRequest"
}
}
@@ -1833,6 +1833,29 @@
"title": "Account/rateLimits/readRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"account/usage/read"
],
"title": "Account/usage/readRequestMethod",
"type": "string"
},
"params": {
"type": "null"
}
},
"required": [
"id",
"method"
],
"title": "Account/usage/readRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -3786,8 +3809,8 @@
"environmentId": {
"default": null,
"type": [
"null",
"string"
"string",
"null"
]
},
"itemId": {
@@ -4904,6 +4927,26 @@
"title": "Model/verificationNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"turn/moderationMetadata"
],
"title": "Turn/moderationMetadataNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/TurnModerationMetadataNotification"
}
},
"required": [
"method",
"params"
],
"title": "Turn/moderationMetadataNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -5739,6 +5782,62 @@
"title": "AccountRateLimitsUpdatedNotification",
"type": "object"
},
"AccountTokenUsageDailyBucket": {
"properties": {
"startDate": {
"type": "string"
},
"tokens": {
"format": "int64",
"type": "integer"
}
},
"required": [
"startDate",
"tokens"
],
"type": "object"
},
"AccountTokenUsageSummary": {
"properties": {
"currentStreakDays": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"lifetimeTokens": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"longestRunningTurnSec": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"longestStreakDays": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"peakDailyTokens": {
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"type": "object"
},
"AccountUpdatedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -5900,6 +5999,30 @@
"title": "AgentMessageDeltaNotification",
"type": "object"
},
"AgentMessageInputContent": {
"oneOf": [
{
"properties": {
"encrypted_content": {
"type": "string"
},
"type": {
"enum": [
"encrypted_content"
],
"title": "EncryptedContentAgentMessageInputContentType",
"type": "string"
}
},
"required": [
"encrypted_content",
"type"
],
"title": "EncryptedContentAgentMessageInputContent",
"type": "object"
}
]
},
"AgentPath": {
"type": "string"
},
@@ -6278,6 +6401,69 @@
],
"type": "object"
},
"AppTemplateSummary": {
"properties": {
"canonicalConnectorId": {
"type": [
"string",
"null"
]
},
"description": {
"type": [
"string",
"null"
]
},
"logoUrl": {
"type": [
"string",
"null"
]
},
"logoUrlDark": {
"type": [
"string",
"null"
]
},
"materializedAppIds": {
"items": {
"type": "string"
},
"type": "array"
},
"name": {
"type": "string"
},
"reason": {
"anyOf": [
{
"$ref": "#/definitions/v2/AppTemplateUnavailableReason"
},
{
"type": "null"
}
]
},
"templateId": {
"type": "string"
}
},
"required": [
"materializedAppIds",
"name",
"templateId"
],
"type": "object"
},
"AppTemplateUnavailableReason": {
"enum": [
"NOT_CONFIGURED_FOR_WORKSPACE",
"NO_ACTIVE_WORKSPACE"
],
"type": "string"
},
"AppToolApproval": {
"enum": [
"auto",
@@ -6491,6 +6677,13 @@
"agentIdentity"
],
"type": "string"
},
{
"description": "Programmatic Codex auth backed by a personal access token.",
"enum": [
"personalAccessToken"
],
"type": "string"
}
]
},
@@ -7827,12 +8020,12 @@
"null"
]
},
"allowedPermissions": {
"items": {
"type": "string"
"allowedPermissionProfiles": {
"additionalProperties": {
"type": "boolean"
},
"type": [
"array",
"object",
"null"
]
},
@@ -7873,6 +8066,12 @@
}
]
},
"defaultPermissions": {
"type": [
"string",
"null"
]
},
"enforceResidency": {
"anyOf": [
{
@@ -9519,6 +9718,28 @@
"title": "GetAccountResponse",
"type": "object"
},
"GetAccountTokenUsageResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"dailyUsageBuckets": {
"items": {
"$ref": "#/definitions/v2/AccountTokenUsageDailyBucket"
},
"type": [
"array",
"null"
]
},
"summary": {
"$ref": "#/definitions/v2/AccountTokenUsageSummary"
}
},
"required": [
"summary"
],
"title": "GetAccountTokenUsageResponse",
"type": "object"
},
"GitInfo": {
"properties": {
"branch": {
@@ -11241,6 +11462,12 @@
},
"status": {
"$ref": "#/definitions/v2/McpServerStartupState"
},
"threadId": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -12168,6 +12395,12 @@
},
"PluginDetail": {
"properties": {
"appTemplates": {
"items": {
"$ref": "#/definitions/v2/AppTemplateSummary"
},
"type": "array"
},
"apps": {
"items": {
"$ref": "#/definitions/v2/AppSummary"
@@ -12216,6 +12449,7 @@
}
},
"required": [
"appTemplates",
"apps",
"hooks",
"marketplaceName",
@@ -13525,15 +13759,8 @@
"type": "object"
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"ReasoningEffortOption": {
@@ -13987,6 +14214,37 @@
"title": "MessageResponseItem",
"type": "object"
},
{
"properties": {
"author": {
"type": "string"
},
"content": {
"items": {
"$ref": "#/definitions/v2/AgentMessageInputContent"
},
"type": "array"
},
"recipient": {
"type": "string"
},
"type": {
"enum": [
"agent_message"
],
"title": "AgentMessageResponseItemType",
"type": "string"
}
},
"required": [
"author",
"content",
"recipient",
"type"
],
"title": "AgentMessageResponseItem",
"type": "object"
},
{
"properties": {
"content": {
@@ -18483,6 +18741,25 @@
}
]
},
"TurnModerationMetadataNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"metadata": true,
"threadId": {
"type": "string"
},
"turnId": {
"type": "string"
}
},
"required": [
"metadata",
"threadId",
"turnId"
],
"title": "TurnModerationMetadataNotification",
"type": "object"
},
"TurnPlanStep": {
"properties": {
"status": {
@@ -19239,4 +19516,4 @@
},
"title": "CodexAppServerProtocol",
"type": "object"
}
}
@@ -104,6 +104,62 @@
"title": "AccountRateLimitsUpdatedNotification",
"type": "object"
},
"AccountTokenUsageDailyBucket": {
"properties": {
"startDate": {
"type": "string"
},
"tokens": {
"format": "int64",
"type": "integer"
}
},
"required": [
"startDate",
"tokens"
],
"type": "object"
},
"AccountTokenUsageSummary": {
"properties": {
"currentStreakDays": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"lifetimeTokens": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"longestRunningTurnSec": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"longestStreakDays": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"peakDailyTokens": {
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"type": "object"
},
"AccountUpdatedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -265,6 +321,30 @@
"title": "AgentMessageDeltaNotification",
"type": "object"
},
"AgentMessageInputContent": {
"oneOf": [
{
"properties": {
"encrypted_content": {
"type": "string"
},
"type": {
"enum": [
"encrypted_content"
],
"title": "EncryptedContentAgentMessageInputContentType",
"type": "string"
}
},
"required": [
"encrypted_content",
"type"
],
"title": "EncryptedContentAgentMessageInputContent",
"type": "object"
}
]
},
"AgentPath": {
"type": "string"
},
@@ -643,6 +723,69 @@
],
"type": "object"
},
"AppTemplateSummary": {
"properties": {
"canonicalConnectorId": {
"type": [
"string",
"null"
]
},
"description": {
"type": [
"string",
"null"
]
},
"logoUrl": {
"type": [
"string",
"null"
]
},
"logoUrlDark": {
"type": [
"string",
"null"
]
},
"materializedAppIds": {
"items": {
"type": "string"
},
"type": "array"
},
"name": {
"type": "string"
},
"reason": {
"anyOf": [
{
"$ref": "#/definitions/AppTemplateUnavailableReason"
},
{
"type": "null"
}
]
},
"templateId": {
"type": "string"
}
},
"required": [
"materializedAppIds",
"name",
"templateId"
],
"type": "object"
},
"AppTemplateUnavailableReason": {
"enum": [
"NOT_CONFIGURED_FOR_WORKSPACE",
"NO_ACTIVE_WORKSPACE"
],
"type": "string"
},
"AppToolApproval": {
"enum": [
"auto",
@@ -856,6 +999,13 @@
"agentIdentity"
],
"type": "string"
},
{
"description": "Programmatic Codex auth backed by a personal access token.",
"enum": [
"personalAccessToken"
],
"type": "string"
}
]
},
@@ -2592,6 +2742,29 @@
"title": "Account/rateLimits/readRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"account/usage/read"
],
"title": "Account/usage/readRequestMethod",
"type": "string"
},
"params": {
"type": "null"
}
},
"required": [
"id",
"method"
],
"title": "Account/usage/readRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -4189,12 +4362,12 @@
"null"
]
},
"allowedPermissions": {
"items": {
"type": "string"
"allowedPermissionProfiles": {
"additionalProperties": {
"type": "boolean"
},
"type": [
"array",
"object",
"null"
]
},
@@ -4235,6 +4408,12 @@
}
]
},
"defaultPermissions": {
"type": [
"string",
"null"
]
},
"enforceResidency": {
"anyOf": [
{
@@ -5992,6 +6171,28 @@
"title": "GetAccountResponse",
"type": "object"
},
"GetAccountTokenUsageResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"dailyUsageBuckets": {
"items": {
"$ref": "#/definitions/AccountTokenUsageDailyBucket"
},
"type": [
"array",
"null"
]
},
"summary": {
"$ref": "#/definitions/AccountTokenUsageSummary"
}
},
"required": [
"summary"
],
"title": "GetAccountTokenUsageResponse",
"type": "object"
},
"GitInfo": {
"properties": {
"branch": {
@@ -7763,6 +7964,12 @@
},
"status": {
"$ref": "#/definitions/McpServerStartupState"
},
"threadId": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -8690,6 +8897,12 @@
},
"PluginDetail": {
"properties": {
"appTemplates": {
"items": {
"$ref": "#/definitions/AppTemplateSummary"
},
"type": "array"
},
"apps": {
"items": {
"$ref": "#/definitions/AppSummary"
@@ -8738,6 +8951,7 @@
}
},
"required": [
"appTemplates",
"apps",
"hooks",
"marketplaceName",
@@ -10047,15 +10261,8 @@
"type": "object"
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"ReasoningEffortOption": {
@@ -10509,6 +10716,37 @@
"title": "MessageResponseItem",
"type": "object"
},
{
"properties": {
"author": {
"type": "string"
},
"content": {
"items": {
"$ref": "#/definitions/AgentMessageInputContent"
},
"type": "array"
},
"recipient": {
"type": "string"
},
"type": {
"enum": [
"agent_message"
],
"title": "AgentMessageResponseItemType",
"type": "string"
}
},
"required": [
"author",
"content",
"recipient",
"type"
],
"title": "AgentMessageResponseItem",
"type": "object"
},
{
"properties": {
"content": {
@@ -12302,6 +12540,26 @@
"title": "Model/verificationNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"turn/moderationMetadata"
],
"title": "Turn/moderationMetadataNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/TurnModerationMetadataNotification"
}
},
"required": [
"method",
"params"
],
"title": "Turn/moderationMetadataNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -16300,6 +16558,25 @@
}
]
},
"TurnModerationMetadataNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"metadata": true,
"threadId": {
"type": "string"
},
"turnId": {
"type": "string"
}
},
"required": [
"metadata",
"threadId",
"turnId"
],
"title": "TurnModerationMetadataNotification",
"type": "object"
},
"TurnPlanStep": {
"properties": {
"status": {
@@ -31,6 +31,13 @@
"agentIdentity"
],
"type": "string"
},
{
"description": "Programmatic Codex auth backed by a personal access token.",
"enum": [
"personalAccessToken"
],
"type": "string"
}
]
},
@@ -667,15 +667,8 @@
"type": "string"
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"ReasoningSummary": {
@@ -94,12 +94,12 @@
"null"
]
},
"allowedPermissions": {
"items": {
"type": "string"
"allowedPermissionProfiles": {
"additionalProperties": {
"type": "boolean"
},
"type": [
"array",
"object",
"null"
]
},
@@ -140,6 +140,12 @@
}
]
},
"defaultPermissions": {
"type": [
"string",
"null"
]
},
"enforceResidency": {
"anyOf": [
{
@@ -0,0 +1,80 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AccountTokenUsageDailyBucket": {
"properties": {
"startDate": {
"type": "string"
},
"tokens": {
"format": "int64",
"type": "integer"
}
},
"required": [
"startDate",
"tokens"
],
"type": "object"
},
"AccountTokenUsageSummary": {
"properties": {
"currentStreakDays": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"lifetimeTokens": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"longestRunningTurnSec": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"longestStreakDays": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"peakDailyTokens": {
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"type": "object"
}
},
"properties": {
"dailyUsageBuckets": {
"items": {
"$ref": "#/definitions/AccountTokenUsageDailyBucket"
},
"type": [
"array",
"null"
]
},
"summary": {
"$ref": "#/definitions/AccountTokenUsageSummary"
}
},
"required": [
"summary"
],
"title": "GetAccountTokenUsageResponse",
"type": "object"
}
@@ -462,15 +462,8 @@
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"TextElement": {
@@ -462,15 +462,8 @@
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"TextElement": {
@@ -23,6 +23,12 @@
},
"status": {
"$ref": "#/definitions/McpServerStartupState"
},
"threadId": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -185,15 +185,8 @@
"type": "object"
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"ReasoningEffortOption": {
@@ -37,6 +37,69 @@
],
"type": "object"
},
"AppTemplateSummary": {
"properties": {
"canonicalConnectorId": {
"type": [
"string",
"null"
]
},
"description": {
"type": [
"string",
"null"
]
},
"logoUrl": {
"type": [
"string",
"null"
]
},
"logoUrlDark": {
"type": [
"string",
"null"
]
},
"materializedAppIds": {
"items": {
"type": "string"
},
"type": "array"
},
"name": {
"type": "string"
},
"reason": {
"anyOf": [
{
"$ref": "#/definitions/AppTemplateUnavailableReason"
},
{
"type": "null"
}
]
},
"templateId": {
"type": "string"
}
},
"required": [
"materializedAppIds",
"name",
"templateId"
],
"type": "object"
},
"AppTemplateUnavailableReason": {
"enum": [
"NOT_CONFIGURED_FOR_WORKSPACE",
"NO_ACTIVE_WORKSPACE"
],
"type": "string"
},
"HookEventName": {
"enum": [
"preToolUse",
@@ -78,6 +141,12 @@
},
"PluginDetail": {
"properties": {
"appTemplates": {
"items": {
"$ref": "#/definitions/AppTemplateSummary"
},
"type": "array"
},
"apps": {
"items": {
"$ref": "#/definitions/AppSummary"
@@ -126,6 +195,7 @@
}
},
"required": [
"appTemplates",
"apps",
"hooks",
"marketplaceName",
@@ -1,6 +1,30 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AgentMessageInputContent": {
"oneOf": [
{
"properties": {
"encrypted_content": {
"type": "string"
},
"type": {
"enum": [
"encrypted_content"
],
"title": "EncryptedContentAgentMessageInputContentType",
"type": "string"
}
},
"required": [
"encrypted_content",
"type"
],
"title": "EncryptedContentAgentMessageInputContent",
"type": "object"
}
]
},
"ContentItem": {
"oneOf": [
{
@@ -369,6 +393,37 @@
"title": "MessageResponseItem",
"type": "object"
},
{
"properties": {
"author": {
"type": "string"
},
"content": {
"items": {
"$ref": "#/definitions/AgentMessageInputContent"
},
"type": "array"
},
"recipient": {
"type": "string"
},
"type": {
"enum": [
"agent_message"
],
"title": "AgentMessageResponseItemType",
"type": "string"
}
},
"required": [
"author",
"content",
"recipient",
"type"
],
"title": "AgentMessageResponseItem",
"type": "object"
},
{
"properties": {
"content": {
@@ -606,15 +606,8 @@
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"TextElement": {
@@ -1,6 +1,10 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"ApprovalsReviewer": {
"description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.",
"enum": [
@@ -718,15 +718,8 @@
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"SandboxPolicy": {
@@ -632,15 +632,8 @@
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"SessionSource": {
@@ -632,15 +632,8 @@
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"SessionSource": {
@@ -632,15 +632,8 @@
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"SessionSource": {
@@ -1,6 +1,34 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"AgentMessageInputContent": {
"oneOf": [
{
"properties": {
"encrypted_content": {
"type": "string"
},
"type": {
"enum": [
"encrypted_content"
],
"title": "EncryptedContentAgentMessageInputContentType",
"type": "string"
}
},
"required": [
"encrypted_content",
"type"
],
"title": "EncryptedContentAgentMessageInputContent",
"type": "object"
}
]
},
"ApprovalsReviewer": {
"description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.",
"enum": [
@@ -436,6 +464,37 @@
"title": "MessageResponseItem",
"type": "object"
},
{
"properties": {
"author": {
"type": "string"
},
"content": {
"items": {
"$ref": "#/definitions/AgentMessageInputContent"
},
"type": "array"
},
"recipient": {
"type": "string"
},
"type": {
"enum": [
"agent_message"
],
"title": "AgentMessageResponseItemType",
"type": "string"
}
},
"required": [
"author",
"content",
"recipient",
"type"
],
"title": "AgentMessageResponseItem",
"type": "object"
},
{
"properties": {
"content": {
@@ -718,15 +718,8 @@
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"SandboxPolicy": {
@@ -632,15 +632,8 @@
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"SessionSource": {
@@ -124,15 +124,8 @@
"type": "string"
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"ReasoningSummary": {
@@ -718,15 +718,8 @@
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"SandboxPolicy": {
@@ -632,15 +632,8 @@
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"SessionSource": {
@@ -632,15 +632,8 @@
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"SessionSource": {
@@ -606,15 +606,8 @@
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"TextElement": {
@@ -0,0 +1,19 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"metadata": true,
"threadId": {
"type": "string"
},
"turnId": {
"type": "string"
}
},
"required": [
"metadata",
"threadId",
"turnId"
],
"title": "TurnModerationMetadataNotification",
"type": "object"
}
@@ -154,15 +154,8 @@
"type": "string"
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"ReasoningSummary": {
@@ -606,15 +606,8 @@
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"TextElement": {
@@ -606,15 +606,8 @@
]
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh"
],
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"TextElement": {
@@ -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 AgentMessageInputContent = { "type": "encrypted_content", encrypted_content: string, };
+1 -1
View File
@@ -5,4 +5,4 @@
/**
* Authentication mode for OpenAI-backed providers.
*/
export type AuthMode = "apikey" | "chatgpt" | "chatgptAuthTokens" | "agentIdentity";
export type AuthMode = "apikey" | "chatgpt" | "chatgptAuthTokens" | "agentIdentity" | "personalAccessToken";
File diff suppressed because one or more lines are too long
@@ -5,4 +5,4 @@
/**
* See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning
*/
export type ReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh";
export type ReasoningEffort = string;
@@ -1,6 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AgentMessageInputContent } from "./AgentMessageInputContent";
import type { ContentItem } from "./ContentItem";
import type { FunctionCallOutputBody } from "./FunctionCallOutputBody";
import type { LocalShellAction } from "./LocalShellAction";
@@ -10,7 +11,7 @@ import type { ReasoningItemContent } from "./ReasoningItemContent";
import type { ReasoningItemReasoningSummary } from "./ReasoningItemReasoningSummary";
import type { WebSearchAction } from "./WebSearchAction";
export type ResponseItem = { "type": "message", role: string, content: Array<ContentItem>, phase?: MessagePhase, } | { "type": "reasoning", summary: Array<ReasoningItemReasoningSummary>, content?: Array<ReasoningItemContent>, encrypted_content: string | null, } | { "type": "local_shell_call",
export type ResponseItem = { "type": "message", role: string, content: Array<ContentItem>, phase?: MessagePhase, } | { "type": "agent_message", author: string, recipient: string, content: Array<AgentMessageInputContent>, } | { "type": "reasoning", summary: Array<ReasoningItemReasoningSummary>, content?: Array<ReasoningItemContent>, encrypted_content: string | null, } | { "type": "local_shell_call",
/**
* Set when using the Responses API.
*/
File diff suppressed because one or more lines are too long
+1
View File
@@ -1,6 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
export type { AbsolutePathBuf } from "./AbsolutePathBuf";
export type { AgentMessageInputContent } from "./AgentMessageInputContent";
export type { AgentPath } from "./AgentPath";
export type { ApplyPatchApprovalParams } from "./ApplyPatchApprovalParams";
export type { ApplyPatchApprovalResponse } from "./ApplyPatchApprovalResponse";
@@ -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 AccountTokenUsageDailyBucket = { startDate: string, tokens: bigint, };
@@ -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 AccountTokenUsageSummary = { lifetimeTokens: bigint | null, peakDailyTokens: bigint | null, longestRunningTurnSec: bigint | null, currentStreakDays: bigint | null, longestStreakDays: bigint | null, };
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AppTemplateUnavailableReason } from "./AppTemplateUnavailableReason";
export type AppTemplateSummary = { templateId: string, name: string, description: string | null, canonicalConnectorId: string | null, logoUrl: string | null, logoUrlDark: string | null, materializedAppIds: Array<string>, reason: AppTemplateUnavailableReason | null, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type AppTemplateUnavailableReason = "NOT_CONFIGURED_FOR_WORKSPACE" | "NO_ACTIVE_WORKSPACE";
@@ -8,4 +8,4 @@ import type { ResidencyRequirement } from "./ResidencyRequirement";
import type { SandboxMode } from "./SandboxMode";
import type { WindowsSandboxSetupMode } from "./WindowsSandboxSetupMode";
export type ConfigRequirements = {allowedApprovalPolicies: Array<AskForApproval> | null, allowedSandboxModes: Array<SandboxMode> | null, allowedWindowsSandboxImplementations: Array<WindowsSandboxSetupMode> | null, allowedPermissions: Array<string> | null, allowedWebSearchModes: Array<WebSearchMode> | null, allowManagedHooksOnly: boolean | null, allowAppshots: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null};
export type ConfigRequirements = {allowedApprovalPolicies: Array<AskForApproval> | null, allowedSandboxModes: Array<SandboxMode> | null, allowedWindowsSandboxImplementations: Array<WindowsSandboxSetupMode> | null, allowedPermissionProfiles: { [key in string]?: boolean } | null, defaultPermissions: string | null, allowedWebSearchModes: Array<WebSearchMode> | null, allowManagedHooksOnly: boolean | null, allowAppshots: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null};
@@ -0,0 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AccountTokenUsageDailyBucket } from "./AccountTokenUsageDailyBucket";
import type { AccountTokenUsageSummary } from "./AccountTokenUsageSummary";
export type GetAccountTokenUsageResponse = { summary: AccountTokenUsageSummary, dailyUsageBuckets: Array<AccountTokenUsageDailyBucket> | null, };
@@ -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 { McpServerStartupState } from "./McpServerStartupState";
export type McpServerStatusUpdatedNotification = { name: string, status: McpServerStartupState, error: string | null, };
export type McpServerStatusUpdatedNotification = { threadId: string | null, name: string, status: McpServerStartupState, error: string | null, };
@@ -3,8 +3,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { AppSummary } from "./AppSummary";
import type { AppTemplateSummary } from "./AppTemplateSummary";
import type { PluginHookSummary } from "./PluginHookSummary";
import type { PluginSummary } from "./PluginSummary";
import type { SkillSummary } from "./SkillSummary";
export type PluginDetail = { marketplaceName: string, marketplacePath: AbsolutePathBuf | null, summary: PluginSummary, description: string | null, skills: Array<SkillSummary>, hooks: Array<PluginHookSummary>, apps: Array<AppSummary>, mcpServers: Array<string>, };
export type PluginDetail = { marketplaceName: string, marketplacePath: AbsolutePathBuf | null, summary: PluginSummary, description: string | null, skills: Array<SkillSummary>, hooks: Array<PluginHookSummary>, apps: Array<AppSummary>, appTemplates: Array<AppTemplateSummary>, mcpServers: Array<string>, };
@@ -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 { JsonValue } from "../serde_json/JsonValue";
export type TurnModerationMetadataNotification = { threadId: string, turnId: string, metadata: JsonValue, };
@@ -3,6 +3,8 @@
export type { Account } from "./Account";
export type { AccountLoginCompletedNotification } from "./AccountLoginCompletedNotification";
export type { AccountRateLimitsUpdatedNotification } from "./AccountRateLimitsUpdatedNotification";
export type { AccountTokenUsageDailyBucket } from "./AccountTokenUsageDailyBucket";
export type { AccountTokenUsageSummary } from "./AccountTokenUsageSummary";
export type { AccountUpdatedNotification } from "./AccountUpdatedNotification";
export type { ActivePermissionProfile } from "./ActivePermissionProfile";
export type { AddCreditsNudgeCreditType } from "./AddCreditsNudgeCreditType";
@@ -21,6 +23,8 @@ export type { AppMetadata } from "./AppMetadata";
export type { AppReview } from "./AppReview";
export type { AppScreenshot } from "./AppScreenshot";
export type { AppSummary } from "./AppSummary";
export type { AppTemplateSummary } from "./AppTemplateSummary";
export type { AppTemplateUnavailableReason } from "./AppTemplateUnavailableReason";
export type { AppToolApproval } from "./AppToolApproval";
export type { AppToolsConfig } from "./AppToolsConfig";
export type { ApprovalsReviewer } from "./ApprovalsReviewer";
@@ -139,6 +143,7 @@ export type { FsWriteFileResponse } from "./FsWriteFileResponse";
export type { GetAccountParams } from "./GetAccountParams";
export type { GetAccountRateLimitsResponse } from "./GetAccountRateLimitsResponse";
export type { GetAccountResponse } from "./GetAccountResponse";
export type { GetAccountTokenUsageResponse } from "./GetAccountTokenUsageResponse";
export type { GitInfo } from "./GitInfo";
export type { GrantedPermissionProfile } from "./GrantedPermissionProfile";
export type { GuardianApprovalReview } from "./GuardianApprovalReview";
@@ -445,6 +450,7 @@ export type { TurnError } from "./TurnError";
export type { TurnInterruptParams } from "./TurnInterruptParams";
export type { TurnInterruptResponse } from "./TurnInterruptResponse";
export type { TurnItemsView } from "./TurnItemsView";
export type { TurnModerationMetadataNotification } from "./TurnModerationMetadataNotification";
export type { TurnPlanStep } from "./TurnPlanStep";
export type { TurnPlanStepStatus } from "./TurnPlanStepStatus";
export type { TurnPlanUpdatedNotification } from "./TurnPlanUpdatedNotification";
@@ -2963,11 +2963,14 @@ permissionProfile?: string | null};
let client_request_json = fs::read_to_string(output_dir.join("ClientRequest.json"))?;
assert!(client_request_json.contains("remoteControl/pairing/start"));
assert!(client_request_json.contains("remoteControl/pairing/status"));
assert!(client_request_json.contains("remoteControl/client/list"));
assert!(client_request_json.contains("remoteControl/client/revoke"));
for schema in [
"RemoteControlPairingStartParams.json",
"RemoteControlPairingStartResponse.json",
"RemoteControlPairingStatusParams.json",
"RemoteControlPairingStatusResponse.json",
"RemoteControlClientsListParams.json",
"RemoteControlClientsListResponse.json",
"RemoteControlClientsRevokeParams.json",
@@ -36,6 +36,21 @@ pub enum AuthMode {
#[ts(rename = "agentIdentity")]
#[strum(serialize = "agentIdentity")]
AgentIdentity,
/// Programmatic Codex auth backed by a personal access token.
#[serde(rename = "personalAccessToken")]
#[ts(rename = "personalAccessToken")]
#[strum(serialize = "personalAccessToken")]
PersonalAccessToken,
}
impl AuthMode {
/// Returns whether this mode represents an authenticated human ChatGPT account.
pub fn has_chatgpt_account(self) -> bool {
match self {
Self::Chatgpt | Self::ChatgptAuthTokens | Self::PersonalAccessToken => true,
Self::ApiKey | Self::AgentIdentity => false,
}
}
}
macro_rules! experimental_reason_expr {
@@ -849,6 +864,12 @@ client_request_definitions! {
serialization: global("remote-control-pairing"),
response: v2::RemoteControlPairingStartResponse,
},
#[experimental("remoteControl/pairing/status")]
RemoteControlPairingStatus => "remoteControl/pairing/status" {
params: v2::RemoteControlPairingStatusParams,
serialization: global_shared_read("remote-control-pairing"),
response: v2::RemoteControlPairingStatusResponse,
},
#[experimental("remoteControl/client/list")]
RemoteControlClientsList => "remoteControl/client/list" {
params: v2::RemoteControlClientsListParams,
@@ -949,6 +970,12 @@ client_request_definitions! {
response: v2::GetAccountRateLimitsResponse,
},
GetAccountTokenUsage => "account/usage/read" {
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
serialization: None,
response: v2::GetAccountTokenUsageResponse,
},
SendAddCreditsNudgeEmail => "account/sendAddCreditsNudgeEmail" {
params: v2::SendAddCreditsNudgeEmailParams,
serialization: global("account-auth"),
@@ -1549,6 +1576,8 @@ server_notification_definitions! {
ContextCompacted => "thread/compacted" (v2::ContextCompactedNotification),
ModelRerouted => "model/rerouted" (v2::ModelReroutedNotification),
ModelVerification => "model/verification" (v2::ModelVerificationNotification),
#[experimental("turn/moderationMetadata")]
TurnModerationMetadata => "turn/moderationMetadata" (v2::TurnModerationMetadataNotification),
Warning => "warning" (v2::WarningNotification),
GuardianWarning => "guardianWarning" (v2::GuardianWarningNotification),
DeprecationNotice => "deprecationNotice" (v2::DeprecationNoticeNotification),
@@ -2006,6 +2035,19 @@ mod tests {
"remote-control-pairing"
))
);
let remote_control_pairing_status = ClientRequest::RemoteControlPairingStatus {
request_id: request_id(),
params: v2::RemoteControlPairingStatusParams {
pairing_code: Some("pairing-code".to_string()),
manual_pairing_code: None,
},
};
assert_eq!(
remote_control_pairing_status.serialization_scope(),
Some(ClientRequestSerializationScope::GlobalSharedRead(
"remote-control-pairing"
))
);
let remote_control_clients_list = ClientRequest::RemoteControlClientsList {
request_id: request_id(),
params: v2::RemoteControlClientsListParams::default(),
@@ -2370,6 +2412,24 @@ mod tests {
Ok(())
}
#[test]
fn serialize_get_account_token_usage() -> Result<()> {
let request = ClientRequest::GetAccountTokenUsage {
request_id: RequestId::Integer(1),
params: None,
};
assert_eq!(request.id(), &RequestId::Integer(1));
assert_eq!(request.method(), "account/usage/read");
assert_eq!(
json!({
"method": "account/usage/read",
"id": 1,
}),
serde_json::to_value(&request)?,
);
Ok(())
}
#[test]
fn serialize_client_response() -> Result<()> {
let cwd = absolute_path("/tmp");
@@ -3222,6 +3282,21 @@ mod tests {
);
}
#[test]
fn turn_moderation_metadata_notification_is_marked_experimental() {
let notification =
ServerNotification::TurnModerationMetadata(v2::TurnModerationMetadataNotification {
thread_id: "thr_123".to_string(),
turn_id: "turn_123".to_string(),
metadata: json!({"presentation": "inline"}),
});
assert_eq!(
crate::experimental_api::ExperimentalApi::experimental_reason(&notification),
Some("turn/moderationMetadata")
);
}
#[test]
fn thread_realtime_started_notification_is_marked_experimental() {
let notification =
@@ -621,7 +621,7 @@ impl ThreadHistoryBuilder {
receiver_thread_ids: Vec::new(),
prompt: Some(payload.prompt.clone()),
model: Some(payload.model.clone()),
reasoning_effort: Some(payload.reasoning_effort),
reasoning_effort: Some(payload.reasoning_effort.clone()),
agents_states: HashMap::new(),
};
self.upsert_item_in_current_turn(item);
@@ -656,7 +656,7 @@ impl ThreadHistoryBuilder {
receiver_thread_ids,
prompt: Some(payload.prompt.clone()),
model: Some(payload.model.clone()),
reasoning_effort: Some(payload.reasoning_effort),
reasoning_effort: Some(payload.reasoning_effort.clone()),
agents_states,
});
}
@@ -138,6 +138,78 @@ pub struct CancelLoginAccountResponse {
pub status: CancelLoginAccountStatus,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct AccountSessionsAddParams {
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub switch_to_added_account: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct AccountSessionsListParams {
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub refresh_workspace_metadata: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct AccountSessionsLogoutParams {
pub session_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct AccountSessionsSwitchParams {
pub session_id: String,
pub account_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct AccountSessionsResponse {
pub active_session_id: Option<String>,
pub sessions: Vec<AccountSession>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct AccountSession {
pub session_id: String,
pub email: Option<String>,
pub user_id: Option<String>,
pub display_name: Option<String>,
pub image_url: Option<String>,
pub last_used_at: i64,
pub is_active: bool,
pub selected_workspace_account_id: Option<String>,
pub workspaces: Vec<AccountSessionWorkspace>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct AccountSessionWorkspace {
pub account_id: String,
pub name: Option<String>,
pub image_url: Option<String>,
pub kind: Option<AccountSessionWorkspaceKind>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub enum AccountSessionWorkspaceKind {
Personal,
Workspace,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -186,6 +258,33 @@ pub struct GetAccountRateLimitsResponse {
pub rate_limits_by_limit_id: Option<HashMap<String, RateLimitSnapshot>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct GetAccountTokenUsageResponse {
pub summary: AccountTokenUsageSummary,
pub daily_usage_buckets: Option<Vec<AccountTokenUsageDailyBucket>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct AccountTokenUsageSummary {
pub lifetime_tokens: Option<i64>,
pub peak_daily_tokens: Option<i64>,
pub longest_running_turn_sec: Option<i64>,
pub current_streak_days: Option<i64>,
pub longest_streak_days: Option<i64>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct AccountTokenUsageDailyBucket {
pub start_date: String,
pub tokens: i64,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -375,7 +375,8 @@ pub struct ConfigRequirements {
pub allowed_approvals_reviewers: Option<Vec<ApprovalsReviewer>>,
pub allowed_sandbox_modes: Option<Vec<SandboxMode>>,
pub allowed_windows_sandbox_implementations: Option<Vec<WindowsSandboxSetupMode>>,
pub allowed_permissions: Option<Vec<String>>,
pub allowed_permission_profiles: Option<BTreeMap<String, bool>>,
pub default_permissions: Option<String>,
pub allowed_web_search_modes: Option<Vec<WebSearchMode>>,
pub allow_managed_hooks_only: Option<bool>,
pub allow_appshots: Option<bool>,
@@ -235,6 +235,7 @@ pub enum McpServerStartupState {
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct McpServerStatusUpdatedNotification {
pub thread_id: Option<String>,
pub name: String,
pub status: McpServerStartupState,
pub error: Option<String>,
@@ -8,6 +8,7 @@ use codex_protocol::protocol::ModelVerification as CoreModelVerification;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;
use ts_rs::TS;
v2_enum_from_core!(
@@ -152,3 +153,12 @@ pub struct ModelVerificationNotification {
pub turn_id: String,
pub verifications: Vec<ModelVerification>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct TurnModerationMetadataNotification {
pub thread_id: String,
pub turn_id: String,
pub metadata: JsonValue,
}
@@ -642,9 +642,32 @@ pub struct PluginDetail {
pub skills: Vec<SkillSummary>,
pub hooks: Vec<PluginHookSummary>,
pub apps: Vec<AppSummary>,
pub app_templates: Vec<AppTemplateSummary>,
pub mcp_servers: Vec<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[ts(export_to = "v2/")]
pub enum AppTemplateUnavailableReason {
NotConfiguredForWorkspace,
NoActiveWorkspace,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct AppTemplateSummary {
pub template_id: String,
pub name: String,
pub description: Option<String>,
pub canonical_connector_id: Option<String>,
pub logo_url: Option<String>,
pub logo_url_dark: Option<String>,
pub materialized_app_ids: Vec<String>,
pub reason: Option<AppTemplateUnavailableReason>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -62,6 +62,23 @@ pub struct RemoteControlPairingStartResponse {
pub expires_at: i64,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RemoteControlPairingStatusParams {
#[ts(optional = nullable)]
pub pairing_code: Option<String>,
#[ts(optional = nullable)]
pub manual_pairing_code: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RemoteControlPairingStatusResponse {
pub claimed: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -233,7 +233,7 @@ impl From<CoreAskForApproval> for AskForApproval {
pub enum ApprovalsReviewer {
#[serde(rename = "user")]
User,
#[serde(rename = "guardian_subagent", alias = "auto_review")]
#[serde(rename = "auto_review", alias = "guardian_subagent")]
AutoReview,
}
@@ -65,7 +65,7 @@ fn approvals_reviewer_serializes_auto_review_and_accepts_legacy_guardian_subagen
);
assert_eq!(
serde_json::to_string(&ApprovalsReviewer::AutoReview).expect("serialize reviewer"),
"\"guardian_subagent\""
"\"auto_review\""
);
for value in ["user", "auto_review", "guardian_subagent"] {
@@ -1674,7 +1674,8 @@ fn config_requirements_granular_allowed_approval_policy_is_marked_experimental()
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_windows_sandbox_implementations: None,
allowed_permissions: None,
allowed_permission_profiles: None,
default_permissions: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
allow_appshots: None,
@@ -2039,6 +2040,33 @@ fn mcp_server_status_serializes_absent_server_info_as_null() {
);
}
#[test]
fn mcp_server_status_updated_accepts_missing_thread_id() {
let notification: McpServerStatusUpdatedNotification = serde_json::from_value(json!({
"name": "optional_broken",
"status": "failed",
"error": "handshake failed",
}))
.expect("notification without threadId should deserialize");
let expected = McpServerStatusUpdatedNotification {
thread_id: None,
name: "optional_broken".to_string(),
status: McpServerStartupState::Failed,
error: Some("handshake failed".to_string()),
};
assert_eq!(notification, expected);
assert_eq!(
serde_json::to_value(notification).expect("notification should serialize"),
json!({
"threadId": null,
"name": "optional_broken",
"status": "failed",
"error": "handshake failed",
})
);
}
#[test]
fn mcp_server_status_serializes_absent_server_info_metadata_as_null() {
let response = ListMcpServerStatusResponse {
@@ -107,11 +107,10 @@ pub struct ThreadStartParams {
pub service_tier: Option<Option<String>>,
#[ts(optional = nullable)]
pub cwd: Option<String>,
/// Replace the thread's runtime workspace roots. Relative paths are
/// resolved against the effective cwd for the thread.
/// Replace the thread's runtime workspace roots. Paths must be absolute.
#[experimental("thread/start.runtimeWorkspaceRoots")]
#[ts(optional = nullable)]
pub runtime_workspace_roots: Option<Vec<PathBuf>>,
pub runtime_workspace_roots: Option<Vec<AbsolutePathBuf>>,
#[experimental(nested)]
#[ts(optional = nullable)]
pub approval_policy: Option<AskForApproval>,
@@ -358,11 +357,10 @@ pub struct ThreadResumeParams {
pub service_tier: Option<Option<String>>,
#[ts(optional = nullable)]
pub cwd: Option<String>,
/// Replace the thread's runtime workspace roots. Relative paths are
/// resolved against the effective cwd for the thread.
/// Replace the thread's runtime workspace roots. Paths must be absolute.
#[experimental("thread/resume.runtimeWorkspaceRoots")]
#[ts(optional = nullable)]
pub runtime_workspace_roots: Option<Vec<PathBuf>>,
pub runtime_workspace_roots: Option<Vec<AbsolutePathBuf>>,
#[experimental(nested)]
#[ts(optional = nullable)]
pub approval_policy: Option<AskForApproval>,
@@ -509,11 +507,10 @@ pub struct ThreadForkParams {
pub service_tier: Option<Option<String>>,
#[ts(optional = nullable)]
pub cwd: Option<String>,
/// Replace the thread's runtime workspace roots. Relative paths are
/// resolved against the effective cwd for the thread.
/// Replace the thread's runtime workspace roots. Paths must be absolute.
#[experimental("thread/fork.runtimeWorkspaceRoots")]
#[ts(optional = nullable)]
pub runtime_workspace_roots: Option<Vec<PathBuf>>,
pub runtime_workspace_roots: Option<Vec<AbsolutePathBuf>>,
#[experimental(nested)]
#[ts(optional = nullable)]
pub approval_policy: Option<AskForApproval>,
@@ -76,7 +76,7 @@ pub struct TurnStartParams {
#[experimental("turn/start.additionalContext")]
#[ts(optional = nullable)]
pub additional_context: Option<HashMap<String, AdditionalContextEntry>>,
/// Optional turn-scoped environments.
/// Optional environments for this turn and subsequent turns.
///
/// Omitted uses the thread sticky environments. Empty disables
/// environment access for this turn. Non-empty selects the first
@@ -88,11 +88,10 @@ pub struct TurnStartParams {
#[ts(optional = nullable)]
pub cwd: Option<PathBuf>,
/// Replace the thread's runtime workspace roots for this turn and
/// subsequent turns. Relative paths are resolved against the effective
/// cwd for the turn.
/// subsequent turns. Paths must be absolute.
#[experimental("turn/start.runtimeWorkspaceRoots")]
#[ts(optional = nullable)]
pub runtime_workspace_roots: Option<Vec<PathBuf>>,
pub runtime_workspace_roots: Option<Vec<AbsolutePathBuf>>,
/// Override the approval policy for this turn and subsequent turns.
#[experimental(nested)]
#[ts(optional = nullable)]
@@ -3,11 +3,14 @@ use super::pairing_unavailable_error;
use super::protocol::EnrollRemoteServerRequest;
use super::protocol::EnrollRemoteServerResponse;
use super::protocol::RefreshRemoteServerRequest;
use super::protocol::RemoteControlPairingStatusRequest;
use super::protocol::RemoteControlPairingStatusResponse as BackendRemoteControlPairingStatusResponse;
use super::protocol::RemoteControlTarget;
use super::protocol::StartRemoteControlPairingRequest;
use super::protocol::StartRemoteControlPairingResponse;
use axum::http::HeaderMap;
use codex_app_server_protocol::RemoteControlPairingStartResponse;
use codex_app_server_protocol::RemoteControlPairingStatusResponse;
use codex_login::default_client::build_reqwest_client;
use codex_state::RemoteControlEnrollmentRecord;
use codex_state::StateRuntime;
@@ -136,6 +139,69 @@ impl RemoteControlEnrollment {
})
}
pub(super) async fn pairing_status(
&self,
request: RemoteControlPairingStatusRequest,
) -> io::Result<RemoteControlPairingStatusResponse> {
if self.should_refresh_server_token() {
return Err(pairing_unavailable_error());
}
let remote_control_token = self
.remote_control_token
.as_deref()
.ok_or_else(pairing_unavailable_error)?;
let response = build_reqwest_client()
.post(&self.remote_control_target.pair_status_url)
.timeout(REMOTE_CONTROL_PAIRING_TIMEOUT)
.bearer_auth(remote_control_token)
.json(&request)
.send()
.await
.map_err(|err| {
io::Error::other(format!(
"failed to check remote control pairing status at `{}`: {err}",
self.remote_control_target.pair_status_url
))
})?;
let headers = response.headers().clone();
let status = response.status();
let body = response.bytes().await.map_err(|err| {
io::Error::other(format!(
"failed to read remote control pairing status response from `{}`: {err}",
self.remote_control_target.pair_status_url
))
})?;
let body_preview = preview_remote_control_response_body(&body);
if !status.is_success() {
let error_kind = match status.as_u16() {
401 | 403 => ErrorKind::PermissionDenied,
404 | 410 => ErrorKind::InvalidInput,
_ => ErrorKind::Other,
};
return Err(io::Error::new(
error_kind,
format!(
"remote control pairing status failed at `{}`: HTTP {status}, {}, body: {body_preview}",
self.remote_control_target.pair_status_url,
format_headers(&headers)
),
));
}
let response = serde_json::from_slice::<BackendRemoteControlPairingStatusResponse>(&body)
.map_err(|err| {
io::Error::other(format!(
"failed to parse remote control pairing status response from `{}`: HTTP {status}, {}, body: {body_preview}, decode error: {err}",
self.remote_control_target.pair_status_url,
format_headers(&headers)
))
})?;
Ok(RemoteControlPairingStatusResponse {
claimed: response.claimed,
})
}
pub(super) fn should_refresh_server_token(&self) -> bool {
self.remote_control_token.is_none()
|| self.expires_at.is_none_or(|expires_at| {
@@ -9,12 +9,16 @@ mod websocket;
use self::auth::load_remote_control_auth;
use self::auth::recover_remote_control_auth;
use self::enroll::RemoteControlEnrollment;
use self::enroll::enroll_remote_control_server;
use self::enroll::load_persisted_remote_control_enrollment;
use self::enroll::refresh_remote_control_server;
use self::enroll::update_persisted_remote_control_enrollment;
use crate::transport::remote_control::websocket::RemoteControlChannels;
use crate::transport::remote_control::websocket::RemoteControlStatusPublisher;
use crate::transport::remote_control::websocket::RemoteControlWebsocket;
pub use self::protocol::ClientId;
use self::protocol::RemoteControlPairingStatusCode;
use self::protocol::ServerEvent;
use self::protocol::StreamId;
use self::protocol::normalize_remote_control_url;
@@ -28,6 +32,8 @@ use codex_app_server_protocol::RemoteControlClientsRevokeResponse;
use codex_app_server_protocol::RemoteControlConnectionStatus;
use codex_app_server_protocol::RemoteControlPairingStartParams;
use codex_app_server_protocol::RemoteControlPairingStartResponse;
use codex_app_server_protocol::RemoteControlPairingStatusParams;
use codex_app_server_protocol::RemoteControlPairingStatusResponse;
use codex_app_server_protocol::RemoteControlStatusChangedNotification;
use codex_login::AuthManager;
use codex_state::StateRuntime;
@@ -36,9 +42,13 @@ use gethostname::gethostname;
use std::error::Error;
use std::fmt;
use std::io;
use std::ops::Deref;
use std::ops::DerefMut;
use std::panic::AssertUnwindSafe;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use tokio::sync::Semaphore;
use tokio::sync::SemaphorePermit;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tokio::sync::watch;
@@ -65,12 +75,81 @@ pub struct RemoteControlHandle {
enabled_tx: Arc<watch::Sender<bool>>,
status_tx: Arc<watch::Sender<RemoteControlStatusChangedNotification>>,
state_db_available: bool,
state_db: Option<Arc<StateRuntime>>,
remote_control_url: String,
current_enrollment: CurrentRemoteControlEnrollment,
pairing_persistence_key: RemoteControlPairingPersistenceKey,
pairing_persistence_key_required: bool,
auth_manager: Arc<AuthManager>,
}
type CurrentRemoteControlEnrollment = Arc<StdMutex<Option<RemoteControlEnrollment>>>;
// Pairing and websocket connect share one selected server so they cannot enroll or clear
// different persisted rows while either path is awaiting backend I/O.
type CurrentRemoteControlEnrollment = Arc<RemoteControlEnrollmentState>;
type RemoteControlPairingPersistenceKey = watch::Sender<Option<String>>;
struct RemoteControlEnrollmentState {
enrollment: StdMutex<Option<RemoteControlEnrollment>>,
lock: Semaphore,
}
impl RemoteControlEnrollmentState {
fn new(enrollment: Option<RemoteControlEnrollment>) -> Self {
Self {
enrollment: StdMutex::new(enrollment),
lock: Semaphore::new(1),
}
}
async fn lock(&self) -> RemoteControlEnrollmentLease<'_> {
let permit = match self.lock.acquire().await {
Ok(permit) => permit,
Err(_) => unreachable!("remote control enrollment lock should stay open"),
};
RemoteControlEnrollmentLease {
state: self,
enrollment: self.snapshot(),
_permit: permit,
}
}
fn snapshot(&self) -> Option<RemoteControlEnrollment> {
self.enrollment
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
}
struct RemoteControlEnrollmentLease<'a> {
state: &'a RemoteControlEnrollmentState,
enrollment: Option<RemoteControlEnrollment>,
_permit: SemaphorePermit<'a>,
}
impl Deref for RemoteControlEnrollmentLease<'_> {
type Target = Option<RemoteControlEnrollment>;
fn deref(&self) -> &Self::Target {
&self.enrollment
}
}
impl DerefMut for RemoteControlEnrollmentLease<'_> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.enrollment
}
}
impl Drop for RemoteControlEnrollmentLease<'_> {
fn drop(&mut self) {
*self
.state
.enrollment
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = self.enrollment.take();
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RemoteControlUnavailable;
@@ -126,8 +205,6 @@ impl RemoteControlHandle {
*state = false;
changed
});
clear_current_enrollment(&self.current_enrollment);
let status = self.status();
info!(
enabled_changed,
@@ -151,28 +228,30 @@ impl RemoteControlHandle {
pub async fn start_pairing(
&self,
params: RemoteControlPairingStartParams,
app_server_client_name: Option<&str>,
) -> io::Result<RemoteControlPairingStartResponse> {
if !*self.enabled_tx.borrow() {
return Err(Self::pairing_disabled_error());
}
let mut auth = load_remote_control_auth(&self.auth_manager)
.await
.map_err(|_| pairing_unavailable_error())?;
let mut enrollment = {
let current_enrollment = self
.current_enrollment
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
current_enrollment
.as_ref()
.filter(|enrollment| enrollment.account_id == auth.account_id)
.cloned()
}
.ok_or_else(pairing_unavailable_error)?;
let installation_id = self.status().installation_id;
let status = self.status();
let installation_id = status.installation_id;
let app_server_client_name = self.pairing_persistence_key(app_server_client_name)?;
let app_server_client_name = app_server_client_name.as_deref();
let mut current_enrollment = self.current_enrollment.lock().await;
let mut enrollment = self
.load_or_enroll_pairing_server(
&mut current_enrollment,
&mut auth,
&installation_id,
&status.server_name,
app_server_client_name,
)
.await?;
if enrollment.should_refresh_server_token() {
refresh_pairing_enrollment(
&self.current_enrollment,
&mut current_enrollment,
self.state_db.as_deref(),
app_server_client_name,
&self.auth_manager,
&mut auth,
&installation_id,
@@ -185,9 +264,11 @@ impl RemoteControlHandle {
};
let pairing_response = match enrollment.start_pairing(pairing_request()).await {
Err(err) if err.kind() == io::ErrorKind::PermissionDenied => {
clear_pairing_server_token(&self.current_enrollment, &mut enrollment)?;
clear_pairing_server_token(&mut current_enrollment, &mut enrollment)?;
refresh_pairing_enrollment(
&self.current_enrollment,
&mut current_enrollment,
self.state_db.as_deref(),
app_server_client_name,
&self.auth_manager,
&mut auth,
&installation_id,
@@ -196,16 +277,189 @@ impl RemoteControlHandle {
.await?;
enrollment.start_pairing(pairing_request()).await
}
Err(err) if err.kind() == io::ErrorKind::NotFound => {
clear_pairing_enrollment(
&mut current_enrollment,
self.state_db.as_deref(),
app_server_client_name,
&enrollment,
)
.await;
enrollment = self
.load_or_enroll_pairing_server(
&mut current_enrollment,
&mut auth,
&installation_id,
&status.server_name,
app_server_client_name,
)
.await?;
enrollment.start_pairing(pairing_request()).await
}
pairing_response => pairing_response,
};
if let Err(err) = &pairing_response {
match err.kind() {
io::ErrorKind::NotFound => {
clear_current_enrollment_if_matches(&self.current_enrollment, &enrollment);
clear_pairing_enrollment(
&mut current_enrollment,
self.state_db.as_deref(),
app_server_client_name,
&enrollment,
)
.await;
return Err(pairing_unavailable_error());
}
io::ErrorKind::PermissionDenied => {
clear_pairing_server_token(&self.current_enrollment, &mut enrollment)?;
clear_pairing_server_token(&mut current_enrollment, &mut enrollment)?;
return Err(pairing_unavailable_error());
}
_ => {}
}
}
let current_auth = load_remote_control_auth(&self.auth_manager)
.await
.map_err(|_| pairing_unavailable_error())?;
if current_auth.account_id != auth.account_id {
return Err(pairing_unavailable_error());
}
pairing_response
}
async fn load_or_enroll_pairing_server(
&self,
current_enrollment: &mut Option<RemoteControlEnrollment>,
auth: &mut auth::RemoteControlConnectionAuth,
installation_id: &str,
server_name: &str,
app_server_client_name: Option<&str>,
) -> io::Result<RemoteControlEnrollment> {
if let Some(enrollment) = current_enrollment
.as_ref()
.filter(|enrollment| enrollment.account_id == auth.account_id)
.cloned()
{
return Ok(enrollment);
}
let remote_control_target = normalize_remote_control_url(&self.remote_control_url)?;
let state_db = self
.state_db
.as_deref()
.ok_or_else(pairing_unavailable_error)?;
if let Some(mut enrollment) = load_persisted_remote_control_enrollment(
Some(state_db),
&remote_control_target,
&auth.account_id,
app_server_client_name,
)
.await?
{
enrollment.server_name = server_name.to_string();
publish_current_enrollment(current_enrollment, &enrollment);
return Ok(enrollment);
}
let enrollment = enroll_pairing_server(
&self.auth_manager,
auth,
&remote_control_target,
installation_id,
server_name,
)
.await?;
update_persisted_remote_control_enrollment(
Some(state_db),
&remote_control_target,
&auth.account_id,
app_server_client_name,
Some(&enrollment),
)
.await?;
publish_current_enrollment(current_enrollment, &enrollment);
Ok(enrollment)
}
fn pairing_persistence_key(
&self,
app_server_client_name: Option<&str>,
) -> io::Result<Option<String>> {
if self.pairing_persistence_key_required && self.pairing_persistence_key.borrow().is_none()
{
let app_server_client_name =
app_server_client_name.ok_or_else(pairing_unavailable_error)?;
self.pairing_persistence_key
.send_replace(Some(app_server_client_name.to_string()));
}
Ok(self.pairing_persistence_key.borrow().clone())
}
pub async fn pairing_status(
&self,
params: RemoteControlPairingStatusParams,
) -> io::Result<RemoteControlPairingStatusResponse> {
if !*self.enabled_tx.borrow() {
return Err(Self::pairing_disabled_error());
}
let mut auth = load_remote_control_auth(&self.auth_manager)
.await
.map_err(|_| pairing_unavailable_error())?;
let app_server_client_name = self.pairing_persistence_key.borrow().clone();
let app_server_client_name = app_server_client_name.as_deref();
let mut current_enrollment = self.current_enrollment.lock().await;
let mut enrollment = current_enrollment
.as_ref()
.filter(|enrollment| enrollment.account_id == auth.account_id)
.cloned()
.ok_or_else(pairing_unavailable_error)?;
let installation_id = self.status().installation_id;
if enrollment.should_refresh_server_token() {
refresh_pairing_enrollment(
&mut current_enrollment,
self.state_db.as_deref(),
app_server_client_name,
&self.auth_manager,
&mut auth,
&installation_id,
&mut enrollment,
)
.await?;
}
let status_code = remote_control_pairing_status_code(&params)?;
let pairing_status_request =
|| protocol::RemoteControlPairingStatusRequest::from(status_code.clone());
let pairing_status_response =
match enrollment.pairing_status(pairing_status_request()).await {
Err(err) if err.kind() == io::ErrorKind::PermissionDenied => {
clear_pairing_server_token(&mut current_enrollment, &mut enrollment)?;
refresh_pairing_enrollment(
&mut current_enrollment,
self.state_db.as_deref(),
app_server_client_name,
&self.auth_manager,
&mut auth,
&installation_id,
&mut enrollment,
)
.await?;
enrollment.pairing_status(pairing_status_request()).await
}
pairing_status_response => pairing_status_response,
};
if let Err(err) = &pairing_status_response {
match err.kind() {
io::ErrorKind::NotFound => {
clear_pairing_enrollment(
&mut current_enrollment,
self.state_db.as_deref(),
app_server_client_name,
&enrollment,
)
.await;
return Err(pairing_unavailable_error());
}
io::ErrorKind::PermissionDenied => {
clear_pairing_server_token(&mut current_enrollment, &mut enrollment)?;
return Err(pairing_unavailable_error());
}
_ => {}
@@ -220,7 +474,7 @@ impl RemoteControlHandle {
if current_auth.account_id != auth.account_id {
return Err(pairing_unavailable_error());
}
pairing_response
pairing_status_response
}
pub async fn list_clients(
@@ -277,8 +531,57 @@ impl RemoteControlHandle {
}
}
async fn enroll_pairing_server(
auth_manager: &Arc<AuthManager>,
auth: &mut auth::RemoteControlConnectionAuth,
remote_control_target: &protocol::RemoteControlTarget,
installation_id: &str,
server_name: &str,
) -> io::Result<RemoteControlEnrollment> {
match enroll_remote_control_server(remote_control_target, auth, installation_id, server_name)
.await
{
Ok(enrollment) => return Ok(enrollment),
Err(err) if err.kind() == io::ErrorKind::PermissionDenied => {
let mut auth_recovery = auth_manager.unauthorized_recovery();
let mut auth_change_rx = auth_manager.auth_change_receiver();
if !recover_remote_control_auth(&mut auth_recovery, &mut auth_change_rx).await {
return Err(err);
}
*auth = load_remote_control_auth(auth_manager)
.await
.map_err(|_| pairing_unavailable_error())?;
}
Err(err) => return Err(err),
}
enroll_remote_control_server(remote_control_target, auth, installation_id, server_name).await
}
fn remote_control_pairing_status_code(
params: &RemoteControlPairingStatusParams,
) -> io::Result<RemoteControlPairingStatusCode> {
match (&params.pairing_code, &params.manual_pairing_code) {
(Some(pairing_code), None) => Ok(RemoteControlPairingStatusCode::PairingCode(
pairing_code.clone(),
)),
(None, Some(manual_pairing_code)) => Ok(RemoteControlPairingStatusCode::ManualPairingCode(
manual_pairing_code.clone(),
)),
(Some(_), Some(_)) => Err(io::Error::new(
io::ErrorKind::InvalidInput,
"remote control pairing status accepts either pairingCode or manualPairingCode, not both",
)),
(None, None) => Err(io::Error::new(
io::ErrorKind::InvalidInput,
"remote control pairing status requires pairingCode or manualPairingCode",
)),
}
}
async fn refresh_pairing_enrollment(
current_enrollment: &CurrentRemoteControlEnrollment,
current_enrollment: &mut Option<RemoteControlEnrollment>,
state_db: Option<&StateRuntime>,
app_server_client_name: Option<&str>,
auth_manager: &Arc<AuthManager>,
auth: &mut auth::RemoteControlConnectionAuth,
installation_id: &str,
@@ -286,7 +589,14 @@ async fn refresh_pairing_enrollment(
) -> io::Result<()> {
if let Err(err) = refresh_remote_control_server(auth, installation_id, enrollment).await {
if err.kind() != io::ErrorKind::PermissionDenied {
return handle_pairing_refresh_error(current_enrollment, enrollment, err);
return handle_pairing_refresh_error(
current_enrollment,
state_db,
app_server_client_name,
enrollment,
err,
)
.await;
}
let mut auth_recovery = auth_manager.unauthorized_recovery();
let mut auth_change_rx = auth_manager.auth_change_receiver();
@@ -300,7 +610,14 @@ async fn refresh_pairing_enrollment(
return Err(pairing_unavailable_error());
}
if let Err(err) = refresh_remote_control_server(auth, installation_id, enrollment).await {
return handle_pairing_refresh_error(current_enrollment, enrollment, err);
return handle_pairing_refresh_error(
current_enrollment,
state_db,
app_server_client_name,
enrollment,
err,
)
.await;
}
}
if replace_current_enrollment(current_enrollment, enrollment) {
@@ -310,21 +627,54 @@ async fn refresh_pairing_enrollment(
}
}
fn handle_pairing_refresh_error(
current_enrollment: &CurrentRemoteControlEnrollment,
async fn handle_pairing_refresh_error(
current_enrollment: &mut Option<RemoteControlEnrollment>,
state_db: Option<&StateRuntime>,
app_server_client_name: Option<&str>,
enrollment: &RemoteControlEnrollment,
err: io::Error,
) -> io::Result<()> {
if err.kind() == io::ErrorKind::NotFound {
clear_current_enrollment_if_matches(current_enrollment, enrollment);
clear_pairing_enrollment(
current_enrollment,
state_db,
app_server_client_name,
enrollment,
)
.await;
Err(pairing_unavailable_error())
} else {
Err(err)
}
}
async fn clear_pairing_enrollment(
current_enrollment: &mut Option<RemoteControlEnrollment>,
state_db: Option<&StateRuntime>,
app_server_client_name: Option<&str>,
enrollment: &RemoteControlEnrollment,
) {
if !clear_current_enrollment_if_matches(current_enrollment, enrollment) {
return;
}
let Some(state_db) = state_db else {
return;
};
if let Err(err) = update_persisted_remote_control_enrollment(
Some(state_db),
&enrollment.remote_control_target,
&enrollment.account_id,
app_server_client_name,
/*enrollment*/ None,
)
.await
{
warn!("failed to clear stale pairing enrollment: {err}");
}
}
fn clear_pairing_server_token(
current_enrollment: &CurrentRemoteControlEnrollment,
current_enrollment: &mut Option<RemoteControlEnrollment>,
enrollment: &mut RemoteControlEnrollment,
) -> io::Result<()> {
enrollment.clear_server_token();
@@ -359,27 +709,16 @@ fn remote_control_status_with_connection_status(
}
fn publish_current_enrollment(
current_enrollment: &CurrentRemoteControlEnrollment,
current_enrollment: &mut Option<RemoteControlEnrollment>,
enrollment: &RemoteControlEnrollment,
) {
*current_enrollment
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(enrollment.clone());
}
fn clear_current_enrollment(current_enrollment: &CurrentRemoteControlEnrollment) {
*current_enrollment
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
*current_enrollment = Some(enrollment.clone());
}
fn replace_current_enrollment(
current_enrollment: &CurrentRemoteControlEnrollment,
current_enrollment: &mut Option<RemoteControlEnrollment>,
enrollment: &RemoteControlEnrollment,
) -> bool {
let mut current_enrollment = current_enrollment
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if !current_enrollment
.as_ref()
.is_some_and(|current| same_remote_control_enrollment(current, enrollment))
@@ -391,17 +730,17 @@ fn replace_current_enrollment(
}
fn clear_current_enrollment_if_matches(
current_enrollment: &CurrentRemoteControlEnrollment,
current_enrollment: &mut Option<RemoteControlEnrollment>,
enrollment: &RemoteControlEnrollment,
) {
let mut current_enrollment = current_enrollment
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
) -> bool {
if current_enrollment
.as_ref()
.is_some_and(|current| same_remote_control_enrollment(current, enrollment))
{
*current_enrollment = None;
true
} else {
false
}
}
@@ -438,9 +777,13 @@ pub async fn start_remote_control(
};
let (enabled_tx, enabled_rx) = watch::channel(initial_enabled);
let current_enrollment = Arc::new(StdMutex::new(None));
let current_enrollment = Arc::new(RemoteControlEnrollmentState::new(/*enrollment*/ None));
let websocket_current_enrollment = current_enrollment.clone();
let pairing_persistence_key_required = app_server_client_name_rx.is_some();
let (pairing_persistence_key, _pairing_persistence_key_rx) = watch::channel(None);
let websocket_pairing_persistence_key = pairing_persistence_key.clone();
let handle_auth_manager = auth_manager.clone();
let handle_state_db = state_db.clone();
let server_name = gethostname().to_string_lossy().trim().to_string();
let remote_control_url = config.remote_control_url;
let installation_id = config.installation_id;
@@ -490,6 +833,7 @@ pub async fn start_remote_control(
transport_event_tx,
status_publisher,
current_enrollment: websocket_current_enrollment,
pairing_persistence_key: websocket_pairing_persistence_key,
},
shutdown_token,
enabled_rx,
@@ -534,8 +878,11 @@ pub async fn start_remote_control(
enabled_tx: Arc::new(enabled_tx),
status_tx: Arc::new(status_tx),
state_db_available,
state_db: handle_state_db,
remote_control_url: handle_remote_control_url,
current_enrollment,
pairing_persistence_key,
pairing_persistence_key_required,
auth_manager: handle_auth_manager,
},
))
@@ -13,6 +13,7 @@ pub(super) struct RemoteControlTarget {
pub(super) enroll_url: String,
pub(super) refresh_url: String,
pub(super) pair_url: String,
pub(super) pair_status_url: String,
}
#[derive(Debug, Serialize)]
@@ -52,6 +53,40 @@ pub(super) struct StartRemoteControlPairingResponse {
pub(super) expires_at: String,
}
#[derive(Debug, Serialize)]
pub(super) struct RemoteControlPairingStatusRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) pairing_code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) manual_pairing_code: Option<String>,
}
#[derive(Clone)]
pub(super) enum RemoteControlPairingStatusCode {
PairingCode(String),
ManualPairingCode(String),
}
impl From<RemoteControlPairingStatusCode> for RemoteControlPairingStatusRequest {
fn from(code: RemoteControlPairingStatusCode) -> Self {
match code {
RemoteControlPairingStatusCode::PairingCode(pairing_code) => Self {
pairing_code: Some(pairing_code),
manual_pairing_code: None,
},
RemoteControlPairingStatusCode::ManualPairingCode(manual_pairing_code) => Self {
pairing_code: None,
manual_pairing_code: Some(manual_pairing_code),
},
}
}
}
#[derive(Debug, Deserialize)]
pub(super) struct RemoteControlPairingStatusResponse {
pub(super) claimed: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ClientId(pub String);
@@ -194,6 +229,9 @@ pub(super) fn normalize_remote_control_url(
let pair_url = remote_control_url
.join("wham/remote/control/server/pair")
.map_err(map_url_parse_error)?;
let pair_status_url = remote_control_url
.join("wham/remote/control/server/pair/status")
.map_err(map_url_parse_error)?;
let mut websocket_url = remote_control_url
.join("wham/remote/control/server")
.map_err(map_url_parse_error)?;
@@ -215,6 +253,7 @@ pub(super) fn normalize_remote_control_url(
enroll_url: enroll_url.to_string(),
refresh_url: refresh_url.to_string(),
pair_url: pair_url.to_string(),
pair_status_url: pair_status_url.to_string(),
})
}
@@ -269,6 +308,9 @@ mod tests {
.to_string(),
pair_url: "https://chatgpt.com/backend-api/wham/remote/control/server/pair"
.to_string(),
pair_status_url:
"https://chatgpt.com/backend-api/wham/remote/control/server/pair/status"
.to_string(),
}
);
assert_eq!(
@@ -287,6 +329,9 @@ mod tests {
pair_url:
"https://api.chatgpt-staging.com/backend-api/wham/remote/control/server/pair"
.to_string(),
pair_status_url:
"https://api.chatgpt-staging.com/backend-api/wham/remote/control/server/pair/status"
.to_string(),
}
);
}
@@ -305,6 +350,9 @@ mod tests {
.to_string(),
pair_url: "http://localhost:8080/backend-api/wham/remote/control/server/pair"
.to_string(),
pair_status_url:
"http://localhost:8080/backend-api/wham/remote/control/server/pair/status"
.to_string(),
}
);
assert_eq!(
@@ -320,6 +368,9 @@ mod tests {
.to_string(),
pair_url: "https://localhost:8443/backend-api/wham/remote/control/server/pair"
.to_string(),
pair_status_url:
"https://localhost:8443/backend-api/wham/remote/control/server/pair/status"
.to_string(),
}
);
}
@@ -21,6 +21,7 @@ use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::RemoteControlConnectionStatus;
use codex_app_server_protocol::RemoteControlPairingStartParams;
use codex_app_server_protocol::RemoteControlPairingStatusParams;
use codex_app_server_protocol::RemoteControlStatusChangedNotification;
use codex_app_server_protocol::ServerNotification;
use codex_config::types::AuthCredentialsStoreMode;
@@ -40,7 +41,6 @@ use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::BTreeMap;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use tempfile::TempDir;
use time::OffsetDateTime;
use tokio::io::AsyncBufReadExt;
@@ -115,6 +115,7 @@ fn remote_control_auth_dot_json(account_id: Option<&str>) -> AuthDotJson {
}),
last_refresh: Some(chrono::Utc::now()),
agent_identity: None,
personal_access_token: None,
}
}
@@ -148,24 +149,29 @@ fn remote_control_handle_with_current_enrollment(
});
let remote_control_target = normalize_remote_control_url(remote_control_url)
.expect("remote control target should normalize");
let current_enrollment = Arc::new(StdMutex::new(Some(RemoteControlEnrollment {
remote_control_target,
account_id: "account_id".to_string(),
environment_id: "env_test".to_string(),
server_id: "srv_e_test".to_string(),
server_name: test_server_name(),
remote_control_token: Some(TEST_REMOTE_CONTROL_SERVER_TOKEN.to_string()),
expires_at: Some(
OffsetDateTime::from_unix_timestamp(33_336_362_096)
.expect("future timestamp should parse"),
),
})));
let current_enrollment = Arc::new(RemoteControlEnrollmentState::new(Some(
RemoteControlEnrollment {
remote_control_target,
account_id: "account_id".to_string(),
environment_id: "env_test".to_string(),
server_id: "srv_e_test".to_string(),
server_name: test_server_name(),
remote_control_token: Some(TEST_REMOTE_CONTROL_SERVER_TOKEN.to_string()),
expires_at: Some(
OffsetDateTime::from_unix_timestamp(33_336_362_096)
.expect("future timestamp should parse"),
),
},
)));
RemoteControlHandle {
enabled_tx: Arc::new(enabled_tx),
status_tx: Arc::new(status_tx),
state_db_available: true,
state_db: None,
remote_control_url: remote_control_url.to_string(),
current_enrollment,
pairing_persistence_key: watch::channel(None).0,
pairing_persistence_key_required: false,
auth_manager,
}
}
@@ -1738,7 +1744,7 @@ async fn remote_control_http_mode_reenrolls_when_refresh_reports_stale_enrollmen
}
#[tokio::test]
async fn remote_control_http_mode_clears_stale_persisted_enrollment_after_404() {
async fn remote_control_http_mode_reenrolls_after_explicit_missing_server_404() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
@@ -1825,7 +1831,12 @@ async fn remote_control_http_mode_clears_stale_persisted_enrollment_after_404()
Some("env_stale"),
)
.await;
respond_with_status(websocket_request.stream, "404 Not Found", "").await;
respond_with_status(
websocket_request.stream,
"404 Not Found",
&json!({"detail": "Remote app server not found"}).to_string(),
)
.await;
expect_remote_control_status(
&mut status_rx,
/*expected_status*/ None,
@@ -1875,6 +1886,136 @@ async fn remote_control_http_mode_clears_stale_persisted_enrollment_after_404()
let _ = remote_task.await;
}
#[tokio::test]
async fn remote_control_http_mode_preserves_enrollment_after_generic_websocket_404() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let remote_control_url = remote_control_url_for_listener(&listener);
let codex_home = TempDir::new().expect("temp dir should create");
let state_db = remote_control_state_runtime(&codex_home).await;
let remote_control_target =
normalize_remote_control_url(&remote_control_url).expect("target should parse");
let stale_enrollment = RemoteControlEnrollment {
remote_control_target: remote_control_target.clone(),
account_id: "account_id".to_string(),
environment_id: "env_stale".to_string(),
server_id: "srv_e_stale".to_string(),
server_name: "stale-server".to_string(),
remote_control_token: None,
expires_at: None,
};
update_persisted_remote_control_enrollment(
Some(state_db.as_ref()),
&remote_control_target,
"account_id",
/*app_server_client_name*/ None,
Some(&stale_enrollment),
)
.await
.expect("stale enrollment should save");
let (transport_event_tx, _transport_event_rx) =
mpsc::channel::<TransportEvent>(CHANNEL_CAPACITY);
let shutdown_token = CancellationToken::new();
let (remote_task, remote_handle) = start_remote_control(
RemoteControlStartConfig {
remote_control_url,
installation_id: TEST_INSTALLATION_ID.to_string(),
},
Some(state_db.clone()),
remote_control_auth_manager_with_home(&codex_home),
transport_event_tx,
shutdown_token.clone(),
/*app_server_client_name_rx*/ None,
/*initial_enabled*/ true,
)
.await
.expect("remote control should start");
let mut status_rx = remote_handle.status_receiver();
let refresh_request = accept_http_request(&listener).await;
assert_eq!(
refresh_request.request_line,
"POST /backend-api/wham/remote/control/server/refresh HTTP/1.1"
);
respond_with_json(
refresh_request.stream,
remote_control_server_token_response(
&stale_enrollment.server_id,
&stale_enrollment.environment_id,
TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN,
),
)
.await;
let websocket_request = accept_http_request(&listener).await;
assert_eq!(
websocket_request.request_line,
"GET /backend-api/wham/remote/control/server HTTP/1.1"
);
assert_eq!(
websocket_request.headers.get("x-codex-server-id"),
Some(&stale_enrollment.server_id)
);
assert_eq!(
websocket_request.headers.get("authorization"),
Some(&format!(
"Bearer {TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN}"
))
);
expect_remote_control_status(
&mut status_rx,
/*expected_status*/ None,
Some("env_stale"),
)
.await;
respond_with_status_and_headers(
websocket_request.stream,
"404 Not Found",
&[("x-request-id", "request-404"), ("cf-ray", "ray-404")],
"Not Found",
)
.await;
assert_eq!(
load_persisted_remote_control_enrollment(
Some(state_db.as_ref()),
&remote_control_target,
"account_id",
/*app_server_client_name*/ None,
)
.await
.expect("stale enrollment should load"),
Some(stale_enrollment.clone())
);
let (handshake_request, _websocket) = accept_remote_control_backend_connection(&listener).await;
assert_eq!(
handshake_request.headers.get("x-codex-server-id"),
Some(&stale_enrollment.server_id)
);
assert_eq!(
handshake_request.headers.get("authorization"),
Some(&format!(
"Bearer {TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN}"
))
);
expect_remote_control_status_snapshot(
&mut status_rx,
RemoteControlStatusChangedNotification {
status: RemoteControlConnectionStatus::Connected,
server_name: test_server_name(),
installation_id: TEST_INSTALLATION_ID.to_string(),
environment_id: Some("env_stale".to_string()),
},
)
.await;
shutdown_token.cancel();
let _ = remote_task.await;
}
#[derive(Debug)]
struct CapturedHttpRequest {
stream: TcpStream,
@@ -24,8 +24,11 @@ fn client_management_handle(
enabled_tx: Arc::new(enabled_tx),
status_tx: Arc::new(status_tx),
state_db_available: false,
state_db: None,
remote_control_url,
current_enrollment: Arc::new(StdMutex::new(None)),
current_enrollment: Arc::new(RemoteControlEnrollmentState::new(/*enrollment*/ None)),
pairing_persistence_key: watch::channel(None).0,
pairing_persistence_key_required: false,
auth_manager,
}
}
@@ -1,6 +1,8 @@
use super::super::protocol::RemoteControlPairingStatusRequest;
use super::super::protocol::StartRemoteControlPairingRequest;
use super::*;
use pretty_assertions::assert_eq;
use std::io;
fn remote_control_enrollment(
remote_control_url: &str,
@@ -66,6 +68,36 @@ async fn pairing_response_error(body: serde_json::Value) -> String {
err.to_string()
}
async fn pairing_status_error(status: &'static str, body: &'static str) -> (io::Error, String) {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let remote_control_url = remote_control_url_for_listener(&listener);
let expected_status_url = normalize_remote_control_url(&remote_control_url)
.expect("target should normalize")
.pair_status_url;
let server_task = tokio::spawn(async move {
let status_request = accept_http_request(&listener).await;
respond_with_status_and_headers(
status_request.stream,
status,
&[("x-request-id", "request-123"), ("cf-ray", "ray-123")],
body,
)
.await;
});
let err = remote_control_enrollment(&remote_control_url, "remote-control-token")
.pairing_status(RemoteControlPairingStatusRequest {
pairing_code: Some("pairing-code".to_string()),
manual_pairing_code: None,
})
.await
.expect_err("pairing status should fail");
server_task.await.expect("server task should finish");
(err, expected_status_url)
}
#[tokio::test]
async fn remote_control_handle_starts_pairing_before_websocket_connects() {
let listener = TcpListener::bind("127.0.0.1:0")
@@ -131,13 +163,16 @@ async fn remote_control_handle_starts_pairing_before_websocket_connects() {
remote_handle
.current_enrollment
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.await
.as_mut()
.expect("current enrollment should exist")
.expires_at = Some(OffsetDateTime::now_utc() + time::Duration::seconds(29));
let response = remote_handle
.start_pairing(RemoteControlPairingStartParams { manual_code: true })
.start_pairing(
RemoteControlPairingStartParams { manual_code: true },
/*app_server_client_name*/ None,
)
.await
.expect("pairing should use the current server before websocket connect");
server_task.await.expect("server task should finish");
@@ -153,6 +188,190 @@ async fn remote_control_handle_starts_pairing_before_websocket_connects() {
);
}
#[tokio::test]
async fn remote_control_pairing_status_returns_pending() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let remote_control_url = remote_control_url_for_listener(&listener);
let server_task = tokio::spawn(async move {
let status_request = accept_http_request(&listener).await;
assert_eq!(
status_request.request_line,
"POST /backend-api/wham/remote/control/server/pair/status HTTP/1.1"
);
assert_eq!(
status_request.headers.get("authorization"),
Some(&"Bearer remote-control-token".to_string())
);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&status_request.body)
.expect("status request body should deserialize"),
json!({ "pairing_code": "pairing-code" })
);
respond_with_json(status_request.stream, json!({ "claimed": false })).await;
});
let response = remote_control_enrollment(&remote_control_url, "remote-control-token")
.pairing_status(RemoteControlPairingStatusRequest {
pairing_code: Some("pairing-code".to_string()),
manual_pairing_code: None,
})
.await
.expect("pairing status should succeed");
server_task.await.expect("server task should finish");
assert!(!response.claimed);
}
#[tokio::test]
async fn remote_control_pairing_status_accepts_manual_pairing_code() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let remote_control_url = remote_control_url_for_listener(&listener);
let server_task = tokio::spawn(async move {
let status_request = accept_http_request(&listener).await;
assert_eq!(
status_request.request_line,
"POST /backend-api/wham/remote/control/server/pair/status HTTP/1.1"
);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&status_request.body)
.expect("status request body should deserialize"),
json!({ "manual_pairing_code": "ABCD-EFGH" })
);
respond_with_json(status_request.stream, json!({ "claimed": false })).await;
});
let response = remote_control_enrollment(&remote_control_url, "remote-control-token")
.pairing_status(RemoteControlPairingStatusRequest {
pairing_code: None,
manual_pairing_code: Some("ABCD-EFGH".to_string()),
})
.await
.expect("pairing status should succeed");
server_task.await.expect("server task should finish");
assert!(!response.claimed);
}
#[tokio::test]
async fn remote_control_pairing_status_returns_claimed() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let remote_control_url = remote_control_url_for_listener(&listener);
let server_task = tokio::spawn(async move {
let status_request = accept_http_request(&listener).await;
assert_eq!(
status_request.request_line,
"POST /backend-api/wham/remote/control/server/pair/status HTTP/1.1"
);
respond_with_json(status_request.stream, json!({ "claimed": true })).await;
});
let response = remote_control_enrollment(&remote_control_url, "remote-control-token")
.pairing_status(RemoteControlPairingStatusRequest {
pairing_code: Some("pairing-code".to_string()),
manual_pairing_code: None,
})
.await
.expect("pairing status should succeed");
server_task.await.expect("server task should finish");
assert!(response.claimed);
}
#[tokio::test]
async fn remote_control_handle_refreshes_after_pairing_status_auth_failure() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let remote_control_url = remote_control_url_for_listener(&listener);
let server_task = tokio::spawn(async move {
let stale_status_request = accept_http_request(&listener).await;
assert_eq!(
stale_status_request.request_line,
"POST /backend-api/wham/remote/control/server/pair/status HTTP/1.1"
);
assert_eq!(
stale_status_request.headers.get("authorization"),
Some(&format!("Bearer {TEST_REMOTE_CONTROL_SERVER_TOKEN}"))
);
respond_with_status(stale_status_request.stream, "401 Unauthorized", "").await;
let refresh_request = accept_http_request(&listener).await;
assert_eq!(
refresh_request.request_line,
"POST /backend-api/wham/remote/control/server/refresh HTTP/1.1"
);
respond_with_json(
refresh_request.stream,
remote_control_server_token_response(
"srv_e_test",
"env_test",
TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN,
),
)
.await;
let refreshed_status_request = accept_http_request(&listener).await;
assert_eq!(
refreshed_status_request.request_line,
"POST /backend-api/wham/remote/control/server/pair/status HTTP/1.1"
);
assert_eq!(
refreshed_status_request.headers.get("authorization"),
Some(&format!(
"Bearer {TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN}"
))
);
respond_with_json(refreshed_status_request.stream, json!({ "claimed": true })).await;
});
let remote_handle = remote_control_handle_with_current_enrollment(
&remote_control_url,
remote_control_auth_manager(),
);
let response = remote_handle
.pairing_status(RemoteControlPairingStatusParams {
pairing_code: Some("pairing-code".to_string()),
manual_pairing_code: None,
})
.await
.expect("pairing status should refresh after server token auth failure");
server_task.await.expect("server task should finish");
assert!(response.claimed);
}
#[tokio::test]
async fn remote_control_pairing_status_maps_user_actionable_backend_errors() {
for (status, expected_kind) in [
("403 Forbidden", io::ErrorKind::PermissionDenied),
("404 Not Found", io::ErrorKind::InvalidInput),
("410 Gone", io::ErrorKind::InvalidInput),
] {
let (err, _expected_status_url) = pairing_status_error(status, "not available").await;
assert_eq!(err.kind(), expected_kind);
}
}
#[tokio::test]
async fn remote_control_pairing_status_preserves_decode_error_context() {
let (err, expected_status_url) = pairing_status_error("200 OK", "{").await;
let err = err.to_string();
assert!(err.contains(&format!(
"failed to parse remote control pairing status response from `{expected_status_url}`: HTTP 200 OK"
)));
assert!(err.contains("request-id: request-123"));
assert!(err.contains("cf-ray: ray-123"));
assert!(err.contains("body: {"));
assert!(err.contains("decode error:"));
}
#[tokio::test]
async fn remote_control_handle_refreshes_after_pairing_auth_failure() {
let listener = TcpListener::bind("127.0.0.1:0")
@@ -219,7 +438,10 @@ async fn remote_control_handle_refreshes_after_pairing_auth_failure() {
);
let response = remote_handle
.start_pairing(RemoteControlPairingStartParams::default())
.start_pairing(
RemoteControlPairingStartParams::default(),
/*app_server_client_name*/ None,
)
.await
.expect("pairing should refresh after server token auth failure");
server_task.await.expect("server task should finish");
@@ -332,13 +554,16 @@ async fn remote_control_handle_recovers_auth_before_refreshing_pairing() {
remote_handle
.current_enrollment
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.await
.as_mut()
.expect("current enrollment should exist")
.expires_at = Some(OffsetDateTime::now_utc() + time::Duration::seconds(29));
let response = remote_handle
.start_pairing(RemoteControlPairingStartParams::default())
.start_pairing(
RemoteControlPairingStartParams::default(),
/*app_server_client_name*/ None,
)
.await
.expect("pairing should refresh after auth recovery");
server_task.await.expect("server task should finish");
@@ -414,21 +639,137 @@ async fn start_remote_control_pairing_preserves_expiry_parse_error_context() {
}
#[tokio::test]
async fn remote_control_handle_disable_clears_current_enrollment() {
async fn remote_control_handle_disable_keeps_current_enrollment() {
let remote_handle = remote_control_handle_with_current_enrollment(
TEST_REMOTE_CONTROL_URL,
remote_control_auth_manager(),
);
remote_handle.disable();
remote_handle.enable().expect("enable should succeed");
assert!(
remote_handle.current_enrollment.lock().await.is_some(),
"disabled remote control should keep the selected pairing server"
);
}
#[tokio::test]
async fn remote_control_handle_reenrolls_after_stale_pairing_enrollment() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let remote_control_url = remote_control_url_for_listener(&listener);
let codex_home = TempDir::new().expect("temp dir should create");
let state_db = remote_control_state_runtime(&codex_home).await;
let mut remote_handle = remote_control_handle_with_current_enrollment(
&remote_control_url,
remote_control_auth_manager_with_home(&codex_home),
);
remote_handle.state_db = Some(state_db.clone());
remote_handle.disable();
let stale_enrollment = remote_handle
.current_enrollment
.lock()
.await
.clone()
.expect("current enrollment should exist");
let remote_control_target = stale_enrollment.remote_control_target.clone();
let refreshed_enrollment = RemoteControlEnrollment {
remote_control_target: remote_control_target.clone(),
account_id: "account_id".to_string(),
environment_id: "env_refreshed".to_string(),
server_id: "srv_e_refreshed".to_string(),
server_name: test_server_name(),
remote_control_token: None,
expires_at: None,
};
update_persisted_remote_control_enrollment(
Some(state_db.as_ref()),
&remote_control_target,
"account_id",
/*app_server_client_name*/ None,
Some(&stale_enrollment),
)
.await
.expect("stale enrollment should save");
let server_refreshed_enrollment = refreshed_enrollment.clone();
let server_task = tokio::spawn(async move {
let stale_pairing_request = accept_http_request(&listener).await;
assert_eq!(
stale_pairing_request.request_line,
"POST /backend-api/wham/remote/control/server/pair HTTP/1.1"
);
assert_eq!(
stale_pairing_request.headers.get("authorization"),
Some(&format!("Bearer {TEST_REMOTE_CONTROL_SERVER_TOKEN}"))
);
respond_with_status(stale_pairing_request.stream, "404 Not Found", "").await;
let enroll_request = accept_http_request(&listener).await;
assert_eq!(
enroll_request.request_line,
"POST /backend-api/wham/remote/control/server/enroll HTTP/1.1"
);
respond_with_json(
enroll_request.stream,
remote_control_server_token_response(
&server_refreshed_enrollment.server_id,
&server_refreshed_enrollment.environment_id,
TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN,
),
)
.await;
let refreshed_pairing_request = accept_http_request(&listener).await;
assert_eq!(
refreshed_pairing_request.request_line,
"POST /backend-api/wham/remote/control/server/pair HTTP/1.1"
);
assert_eq!(
refreshed_pairing_request.headers.get("authorization"),
Some(&format!(
"Bearer {TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN}"
))
);
respond_with_json(
refreshed_pairing_request.stream,
json!({
"pairing_code": "pairing-code",
"manual_pairing_code": "ABCD-EFGH",
"server_id": server_refreshed_enrollment.server_id,
"environment_id": server_refreshed_enrollment.environment_id,
"expires_at": "3026-05-22T12:34:56Z",
}),
)
.await;
});
let response = remote_handle
.start_pairing(
RemoteControlPairingStartParams::default(),
/*app_server_client_name*/ None,
)
.await
.expect("pairing should re-enroll after stale enrollment");
server_task.await.expect("server task should finish");
assert_eq!(
remote_handle
.start_pairing(RemoteControlPairingStartParams::default())
.await
.expect_err("re-enabled remote control should wait for a current server")
.to_string(),
"remote control pairing is unavailable until enrollment completes"
response,
RemoteControlPairingStartResponse {
pairing_code: "pairing-code".to_string(),
manual_pairing_code: Some("ABCD-EFGH".to_string()),
environment_id: "env_refreshed".to_string(),
expires_at: 33_336_362_096,
}
);
assert_eq!(
load_persisted_remote_control_enrollment(
Some(state_db.as_ref()),
&remote_control_target,
"account_id",
/*app_server_client_name*/ None,
)
.await
.expect("refreshed enrollment should load"),
Some(refreshed_enrollment)
);
}
@@ -458,7 +799,10 @@ async fn remote_control_handle_discards_pairing_response_after_auth_change() {
let remote_handle = remote_handle.clone();
async move {
remote_handle
.start_pairing(RemoteControlPairingStartParams::default())
.start_pairing(
RemoteControlPairingStartParams::default(),
/*app_server_client_name*/ None,
)
.await
}
});
@@ -1,13 +1,13 @@
use super::CurrentRemoteControlEnrollment;
use super::clear_current_enrollment;
use super::RemoteControlPairingPersistenceKey;
use super::protocol::ClientEnvelope;
use super::protocol::ClientEvent;
use super::protocol::ClientId;
use super::protocol::RemoteControlTarget;
use super::protocol::ServerEnvelope;
use super::protocol::StreamId;
use super::publish_current_enrollment;
use super::remote_control_status_with_connection_status;
use super::same_remote_control_enrollment;
use super::segment::ClientSegmentObservation;
use super::segment::ClientSegmentReassembler;
use super::segment::REMOTE_CONTROL_SEGMENT_MAX_BYTES;
@@ -55,6 +55,9 @@ use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_util::sync::CancellationToken;
#[cfg(test)]
use super::RemoteControlEnrollmentState;
use tracing::error;
use tracing::info;
use tracing::warn;
@@ -74,6 +77,7 @@ const REMOTE_CONTROL_WEBSOCKET_CONNECT_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(30);
const REMOTE_CONTROL_CONNECTION_SHUTDOWN_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(5);
const REMOTE_APP_SERVER_NOT_FOUND_DETAIL: &str = "Remote app server not found";
struct BoundedOutboundBuffer {
buffer_by_stream: HashMap<(ClientId, StreamId), VecDeque<ServerEnvelope>>,
@@ -252,10 +256,10 @@ pub(crate) struct RemoteControlWebsocket {
status_publisher: RemoteControlStatusPublisher,
shutdown_token: CancellationToken,
reconnect_attempt: u64,
enrollment: Option<RemoteControlEnrollment>,
auth_recovery: UnauthorizedRecovery,
auth_change_rx: watch::Receiver<u64>,
current_enrollment: CurrentRemoteControlEnrollment,
pairing_persistence_key: RemoteControlPairingPersistenceKey,
client_tracker: Arc<Mutex<ClientTracker>>,
state: Arc<Mutex<WebsocketState>>,
server_event_rx: Arc<Mutex<mpsc::Receiver<super::QueuedServerEnvelope>>>,
@@ -294,6 +298,7 @@ pub(super) struct RemoteControlChannels {
pub(super) transport_event_tx: mpsc::Sender<TransportEvent>,
pub(super) status_publisher: RemoteControlStatusPublisher,
pub(super) current_enrollment: CurrentRemoteControlEnrollment,
pub(super) pairing_persistence_key: RemoteControlPairingPersistenceKey,
}
#[derive(Clone)]
@@ -407,10 +412,10 @@ impl RemoteControlWebsocket {
status_publisher: channels.status_publisher,
shutdown_token,
reconnect_attempt: 0,
enrollment: None,
auth_recovery,
auth_change_rx,
current_enrollment: channels.current_enrollment,
pairing_persistence_key: channels.pairing_persistence_key,
client_tracker: Arc::new(Mutex::new(client_tracker)),
state: Arc::new(Mutex::new(WebsocketState {
outbound_buffer,
@@ -457,6 +462,8 @@ impl RemoteControlWebsocket {
return;
}
};
self.pairing_persistence_key
.send_replace(app_server_client_name.clone());
loop {
if !self.wait_until_enabled().await {
@@ -579,15 +586,15 @@ impl RemoteControlWebsocket {
loop {
let subscribe_cursor = self.state.lock().await.subscribe_cursor.clone();
let enrollment = self.enrollment.as_ref();
let enrollment = self.current_enrollment.snapshot();
info!(
websocket_url = %remote_control_target.websocket_url,
installation_id = %self.installation_id,
server_name = %self.server_name,
reconnect_attempt = self.reconnect_attempt.saturating_add(1),
has_enrollment = enrollment.is_some(),
server_id = ?enrollment.map(|enrollment| enrollment.server_id.as_str()),
environment_id = ?enrollment.map(|enrollment| enrollment.environment_id.as_str()),
server_id = ?enrollment.as_ref().map(|enrollment| enrollment.server_id.as_str()),
environment_id = ?enrollment.as_ref().map(|enrollment| enrollment.environment_id.as_str()),
subscribe_cursor_present = subscribe_cursor.is_some(),
app_server_client_name = ?app_server_client_name,
"connecting to app-server remote control websocket"
@@ -611,15 +618,17 @@ impl RemoteControlWebsocket {
}
return ConnectOutcome::Disabled;
}
connect_result = connect_remote_control_websocket(
&remote_control_target,
self.state_db.as_deref(),
auth_context,
&mut self.enrollment,
connect_options,
&self.status_publisher,
&self.current_enrollment,
) => connect_result,
connect_result = async {
connect_remote_control_websocket(
&remote_control_target,
self.state_db.as_deref(),
auth_context,
&self.current_enrollment,
connect_options,
&self.status_publisher,
)
.await
} => connect_result,
};
match connect_result {
@@ -631,13 +640,13 @@ impl RemoteControlWebsocket {
self.auth_recovery = self.auth_manager.unauthorized_recovery();
self.status_publisher
.publish_status(RemoteControlConnectionStatus::Connected);
let enrollment = self.enrollment.as_ref();
let enrollment = self.current_enrollment.snapshot();
info!(
websocket_url = %remote_control_target.websocket_url,
installation_id = %self.installation_id,
server_name = %self.server_name,
server_id = ?enrollment.map(|enrollment| enrollment.server_id.as_str()),
environment_id = ?enrollment.map(|enrollment| enrollment.environment_id.as_str()),
server_id = ?enrollment.as_ref().map(|enrollment| enrollment.server_id.as_str()),
environment_id = ?enrollment.as_ref().map(|enrollment| enrollment.environment_id.as_str()),
subscribe_cursor_present = subscribe_cursor.is_some(),
response_headers = %format_headers(response.headers()),
"connected to app-server remote control websocket"
@@ -656,7 +665,7 @@ impl RemoteControlWebsocket {
let reconnect_attempt = self.reconnect_attempt.saturating_add(1);
let (reconnect_delay, reconnect_backoff_reset) =
next_reconnect_delay(&mut self.reconnect_attempt);
let enrollment = self.enrollment.as_ref();
let enrollment = self.current_enrollment.snapshot();
warn!(
websocket_url = %remote_control_target.websocket_url,
installation_id = %self.installation_id,
@@ -667,8 +676,8 @@ impl RemoteControlWebsocket {
reconnect_delay = ?reconnect_delay,
reconnect_backoff_reset,
has_enrollment = enrollment.is_some(),
server_id = ?enrollment.map(|enrollment| enrollment.server_id.as_str()),
environment_id = ?enrollment.map(|enrollment| enrollment.environment_id.as_str()),
server_id = ?enrollment.as_ref().map(|enrollment| enrollment.server_id.as_str()),
environment_id = ?enrollment.as_ref().map(|enrollment| enrollment.environment_id.as_str()),
subscribe_cursor_present = subscribe_cursor.is_some(),
"failed to connect to app-server remote control websocket"
);
@@ -1189,19 +1198,127 @@ pub(super) async fn connect_remote_control_websocket(
remote_control_target: &RemoteControlTarget,
state_db: Option<&StateRuntime>,
mut auth_context: RemoteControlAuthContext<'_>,
enrollment: &mut Option<RemoteControlEnrollment>,
current_enrollment: &CurrentRemoteControlEnrollment,
connect_options: RemoteControlConnectOptions<'_>,
status_publisher: &RemoteControlStatusPublisher,
current_enrollment: &CurrentRemoteControlEnrollment,
) -> io::Result<(
WebSocketStream<MaybeTlsStream<TcpStream>>,
tungstenite::http::Response<()>,
)> {
ensure_rustls_crypto_provider();
let (auth, enrollment) = {
let mut current_enrollment = current_enrollment.lock().await;
let auth = prepare_remote_control_enrollment(
remote_control_target,
state_db,
&mut auth_context,
&mut current_enrollment,
connect_options,
status_publisher,
)
.await?;
let enrollment = current_enrollment.as_ref().cloned().ok_or_else(|| {
io::Error::other("missing remote control enrollment after enrollment step")
})?;
(auth, enrollment)
};
let request = build_remote_control_websocket_request(
&remote_control_target.websocket_url,
&enrollment,
connect_options.installation_id,
connect_options.subscribe_cursor,
)?;
let websocket_connect_result = tokio::time::timeout(
REMOTE_CONTROL_WEBSOCKET_CONNECT_TIMEOUT,
connect_async(request),
)
.await
.map_err(|_| {
io::Error::new(
ErrorKind::TimedOut,
format!(
"timed out connecting to remote control websocket at `{}` after {:?}",
remote_control_target.websocket_url, REMOTE_CONTROL_WEBSOCKET_CONNECT_TIMEOUT
),
)
})?;
match websocket_connect_result {
Ok((websocket_stream, response)) => Ok((websocket_stream, response.map(|_| ()))),
Err(err) => {
match &err {
tungstenite::Error::Http(response)
if websocket_response_reports_missing_remote_app_server(response) =>
{
info!(
"remote control websocket returned HTTP 404; clearing stale enrollment before re-enrolling: websocket_url={}, account_id={}, server_id={}, environment_id={}",
remote_control_target.websocket_url,
auth.account_id,
enrollment.server_id,
enrollment.environment_id
);
clear_remote_control_enrollment_if_matches(
state_db,
remote_control_target,
&auth.account_id,
connect_options.app_server_client_name,
current_enrollment,
&enrollment,
status_publisher,
)
.await;
}
tungstenite::Error::Http(response) if response.status().as_u16() == 404 => {
let response_body = response
.body()
.as_deref()
.map(preview_remote_control_response_body)
.unwrap_or_else(|| "<missing>".to_string());
warn!(
websocket_url = %remote_control_target.websocket_url,
account_id = %auth.account_id,
server_id = %enrollment.server_id,
environment_id = %enrollment.environment_id,
response_status = %response.status(),
response_headers = %format_headers(response.headers()),
response_body = %response_body,
"remote control websocket returned unrecognized HTTP 404; preserving enrollment before retry"
);
}
tungstenite::Error::Http(response)
if matches!(response.status().as_u16(), 401 | 403) =>
{
clear_remote_control_server_token_if_matches(current_enrollment, &enrollment)
.await?;
return Err(io::Error::other(format!(
"remote control websocket auth failed with HTTP {}; refreshing server token before reconnect",
response.status()
)));
}
_ => {}
}
Err(io::Error::other(
format_remote_control_websocket_connect_error(
&remote_control_target.websocket_url,
&err,
),
))
}
}
}
async fn prepare_remote_control_enrollment(
remote_control_target: &RemoteControlTarget,
state_db: Option<&StateRuntime>,
auth_context: &mut RemoteControlAuthContext<'_>,
enrollment: &mut Option<RemoteControlEnrollment>,
connect_options: RemoteControlConnectOptions<'_>,
status_publisher: &RemoteControlStatusPublisher,
) -> io::Result<RemoteControlConnectionAuth> {
let Some(state_db) = state_db else {
*enrollment = None;
clear_current_enrollment(current_enrollment);
return Err(io::Error::new(
ErrorKind::NotFound,
"remote control requires sqlite state db",
@@ -1214,7 +1331,6 @@ pub(super) async fn connect_remote_control_websocket(
if err.kind() == ErrorKind::PermissionDenied {
*enrollment = None;
status_publisher.publish_environment_id(/*environment_id*/ None);
clear_current_enrollment(current_enrollment);
}
return Err(err);
}
@@ -1231,7 +1347,6 @@ pub(super) async fn connect_remote_control_websocket(
);
*enrollment = None;
status_publisher.publish_environment_id(/*environment_id*/ None);
clear_current_enrollment(current_enrollment);
}
if let Some(enrollment) = enrollment.as_mut() {
enrollment.remote_control_target = remote_control_target.clone();
@@ -1262,7 +1377,7 @@ pub(super) async fn connect_remote_control_websocket(
remote_control_target,
state_db,
&auth,
&mut auth_context,
auth_context,
enrollment,
connect_options,
status_publisher,
@@ -1307,14 +1422,13 @@ pub(super) async fn connect_remote_control_websocket(
connect_options.app_server_client_name,
enrollment,
status_publisher,
current_enrollment,
)
.await;
enroll_remote_control_server_if_missing(
remote_control_target,
state_db,
&auth,
&mut auth_context,
auth_context,
enrollment,
connect_options,
status_publisher,
@@ -1337,82 +1451,19 @@ pub(super) async fn connect_remote_control_websocket(
}
}
let enrollment_ref = enrollment.as_ref().ok_or_else(|| {
io::Error::other("missing remote control enrollment after enrollment step")
})?;
publish_current_enrollment(current_enrollment, enrollment_ref);
let request = build_remote_control_websocket_request(
&remote_control_target.websocket_url,
enrollment_ref,
connect_options.installation_id,
connect_options.subscribe_cursor,
)?;
Ok(auth)
}
let websocket_connect_result = tokio::time::timeout(
REMOTE_CONTROL_WEBSOCKET_CONNECT_TIMEOUT,
connect_async(request),
)
.await
.map_err(|_| {
io::Error::new(
ErrorKind::TimedOut,
format!(
"timed out connecting to remote control websocket at `{}` after {:?}",
remote_control_target.websocket_url, REMOTE_CONTROL_WEBSOCKET_CONNECT_TIMEOUT
),
)
})?;
match websocket_connect_result {
Ok((websocket_stream, response)) => Ok((websocket_stream, response.map(|_| ()))),
Err(err) => {
match &err {
tungstenite::Error::Http(response) if response.status().as_u16() == 404 => {
info!(
"remote control websocket returned HTTP 404; clearing stale enrollment before re-enrolling: websocket_url={}, account_id={}, server_id={}, environment_id={}",
remote_control_target.websocket_url,
auth.account_id,
enrollment_ref.server_id,
enrollment_ref.environment_id
);
clear_remote_control_enrollment(
state_db,
remote_control_target,
&auth.account_id,
connect_options.app_server_client_name,
enrollment,
status_publisher,
current_enrollment,
)
.await;
}
tungstenite::Error::Http(response)
if matches!(response.status().as_u16(), 401 | 403) =>
{
enrollment
.as_mut()
.ok_or_else(|| {
io::Error::other(
"missing remote control enrollment after websocket auth failure",
)
})?
.clear_server_token();
clear_current_enrollment(current_enrollment);
return Err(io::Error::other(format!(
"remote control websocket auth failed with HTTP {}; refreshing server token before reconnect",
response.status()
)));
}
_ => {}
}
Err(io::Error::other(
format_remote_control_websocket_connect_error(
&remote_control_target.websocket_url,
&err,
),
))
}
}
fn websocket_response_reports_missing_remote_app_server(
response: &tungstenite::http::Response<Option<Vec<u8>>>,
) -> bool {
response.status().as_u16() == 404
&& response.body().as_deref().is_some_and(|body| {
serde_json::from_slice::<serde_json::Value>(body).is_ok_and(|body| {
body.get("detail").and_then(serde_json::Value::as_str)
== Some(REMOTE_APP_SERVER_NOT_FOUND_DETAIL)
})
})
}
async fn clear_remote_control_enrollment(
@@ -1422,7 +1473,6 @@ async fn clear_remote_control_enrollment(
app_server_client_name: Option<&str>,
enrollment: &mut Option<RemoteControlEnrollment>,
status_publisher: &RemoteControlStatusPublisher,
current_enrollment: &CurrentRemoteControlEnrollment,
) {
if let Err(clear_err) = update_persisted_remote_control_enrollment(
Some(state_db),
@@ -1437,7 +1487,51 @@ async fn clear_remote_control_enrollment(
}
*enrollment = None;
status_publisher.publish_environment_id(/*environment_id*/ None);
clear_current_enrollment(current_enrollment);
}
async fn clear_remote_control_enrollment_if_matches(
state_db: Option<&StateRuntime>,
remote_control_target: &RemoteControlTarget,
account_id: &str,
app_server_client_name: Option<&str>,
current_enrollment: &CurrentRemoteControlEnrollment,
enrollment: &RemoteControlEnrollment,
status_publisher: &RemoteControlStatusPublisher,
) {
let Some(state_db) = state_db else {
return;
};
let mut current_enrollment = current_enrollment.lock().await;
if !current_enrollment
.as_ref()
.is_some_and(|current| same_remote_control_enrollment(current, enrollment))
{
return;
}
clear_remote_control_enrollment(
state_db,
remote_control_target,
account_id,
app_server_client_name,
&mut current_enrollment,
status_publisher,
)
.await;
}
async fn clear_remote_control_server_token_if_matches(
current_enrollment: &CurrentRemoteControlEnrollment,
enrollment: &RemoteControlEnrollment,
) -> io::Result<()> {
let mut current_enrollment = current_enrollment.lock().await;
current_enrollment
.as_mut()
.filter(|current| same_remote_control_enrollment(current, enrollment))
.ok_or_else(|| {
io::Error::other("missing remote control enrollment after websocket auth failure")
})?
.clear_server_token();
Ok(())
}
async fn enroll_remote_control_server_if_missing(
@@ -1585,8 +1679,10 @@ mod tests {
}
}
fn test_current_enrollment() -> CurrentRemoteControlEnrollment {
Arc::new(std::sync::Mutex::new(None))
fn test_current_enrollment(
enrollment: Option<RemoteControlEnrollment>,
) -> CurrentRemoteControlEnrollment {
Arc::new(RemoteControlEnrollmentState::new(enrollment))
}
#[test]
@@ -1609,6 +1705,46 @@ mod tests {
assert_eq!(reconnect_attempt, 1);
}
#[test]
fn websocket_404_only_reports_explicit_missing_remote_app_server() {
let cases = [
(
Some(br#"{"detail":"Remote app server not found"}"#.to_vec()),
true,
),
(
Some(br#" { "detail": "Remote app server not found", "extra": true } "#.to_vec()),
true,
),
(Some(br#"{"detail":"Not Found"}"#.to_vec()), false),
(Some(b"Not Found".to_vec()), false),
(Some(b"{".to_vec()), false),
(Some(Vec::new()), false),
(None, false),
];
for (body, expected) in cases {
let response = tungstenite::http::Response::builder()
.status(/*status*/ 404)
.body(body)
.expect("response should build");
assert_eq!(
websocket_response_reports_missing_remote_app_server(&response),
expected
);
}
let response = tungstenite::http::Response::builder()
.status(/*status*/ 503)
.body(Some(
br#"{"detail":"Remote app server not found"}"#.to_vec(),
))
.expect("response should build");
assert!(!websocket_response_reports_missing_remote_app_server(
&response
));
}
fn remote_control_status_channel() -> (
RemoteControlStatusPublisher,
watch::Receiver<RemoteControlStatusChangedNotification>,
@@ -1705,6 +1841,7 @@ mod tests {
}),
last_refresh: Some(Utc::now()),
agent_identity: None,
personal_access_token: None,
}
}
@@ -1739,10 +1876,9 @@ mod tests {
let auth_manager = remote_control_auth_manager();
let mut auth_recovery = auth_manager.unauthorized_recovery();
let mut auth_change_rx = auth_manager.auth_change_receiver();
let mut enrollment = Some(remote_control_enrollment(Some(
let current_enrollment = test_current_enrollment(Some(remote_control_enrollment(Some(
TEST_REMOTE_CONTROL_SERVER_TOKEN,
)));
let current_enrollment = test_current_enrollment();
))));
let (status_publisher, status_rx) = remote_control_status_channel();
let err = match connect_remote_control_websocket(
@@ -1753,7 +1889,7 @@ mod tests {
auth_recovery: &mut auth_recovery,
auth_change_rx: &mut auth_change_rx,
},
&mut enrollment,
&current_enrollment,
RemoteControlConnectOptions {
installation_id: TEST_INSTALLATION_ID,
server_name: "test-server",
@@ -1761,7 +1897,6 @@ mod tests {
app_server_client_name: None,
},
&status_publisher,
&current_enrollment,
)
.await
{
@@ -1771,12 +1906,7 @@ mod tests {
server_task.await.expect("server task should succeed");
assert_eq!(err.to_string(), expected_error);
assert!(
current_enrollment
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_some()
);
assert!(current_enrollment.lock().await.is_some());
assert_eq!(
status_rx.borrow().clone(),
RemoteControlStatusChangedNotification {
@@ -1801,10 +1931,9 @@ mod tests {
let auth_manager = remote_control_auth_manager();
let mut auth_recovery = auth_manager.unauthorized_recovery();
let mut auth_change_rx = auth_manager.auth_change_receiver();
let mut enrollment = Some(remote_control_enrollment(Some(
let current_enrollment = test_current_enrollment(Some(remote_control_enrollment(Some(
TEST_REMOTE_CONTROL_SERVER_TOKEN,
)));
let current_enrollment = test_current_enrollment();
))));
let (status_publisher, status_rx) = remote_control_status_channel();
let server_task = tokio::spawn(async move {
@@ -1824,7 +1953,7 @@ mod tests {
auth_recovery: &mut auth_recovery,
auth_change_rx: &mut auth_change_rx,
},
&mut enrollment,
&current_enrollment,
RemoteControlConnectOptions {
installation_id: TEST_INSTALLATION_ID,
server_name: "test-server",
@@ -1832,7 +1961,6 @@ mod tests {
app_server_client_name: None,
},
&status_publisher,
&current_enrollment,
)
.await
.expect_err("unauthorized response should fail the websocket connect");
@@ -1853,13 +1981,7 @@ mod tests {
);
let mut expected_enrollment = remote_control_enrollment(/*remote_control_token*/ None);
expected_enrollment.remote_control_target = remote_control_target;
assert_eq!(enrollment, Some(expected_enrollment));
assert!(
current_enrollment
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_none()
);
assert_eq!(*current_enrollment.lock().await, Some(expected_enrollment));
}
#[tokio::test]
@@ -1896,7 +2018,7 @@ mod tests {
.await;
let mut auth_recovery = auth_manager.unauthorized_recovery();
let mut auth_change_rx = auth_manager.auth_change_receiver();
let mut enrollment = None;
let current_enrollment = test_current_enrollment(/*enrollment*/ None);
let (status_publisher, status_rx) = remote_control_status_channel();
save_auth(
codex_home.path(),
@@ -1913,7 +2035,7 @@ mod tests {
auth_recovery: &mut auth_recovery,
auth_change_rx: &mut auth_change_rx,
},
&mut enrollment,
&current_enrollment,
RemoteControlConnectOptions {
installation_id: TEST_INSTALLATION_ID,
server_name: "test-server",
@@ -1921,7 +2043,6 @@ mod tests {
app_server_client_name: None,
},
&status_publisher,
&test_current_enrollment(),
)
.await
.expect_err("unauthorized enrollment should fail the websocket connect");
@@ -1989,9 +2110,9 @@ mod tests {
.await;
let mut auth_recovery = auth_manager.unauthorized_recovery();
let mut auth_change_rx = auth_manager.auth_change_receiver();
let mut enrollment = Some(remote_control_enrollment(
let current_enrollment = test_current_enrollment(Some(remote_control_enrollment(
/*remote_control_token*/ None,
));
)));
let (status_publisher, status_rx) = remote_control_status_channel();
save_auth(
codex_home.path(),
@@ -2008,7 +2129,7 @@ mod tests {
auth_recovery: &mut auth_recovery,
auth_change_rx: &mut auth_change_rx,
},
&mut enrollment,
&current_enrollment,
RemoteControlConnectOptions {
installation_id: TEST_INSTALLATION_ID,
server_name: "test-server",
@@ -2016,7 +2137,6 @@ mod tests {
app_server_client_name: None,
},
&status_publisher,
&test_current_enrollment(),
)
.await
.expect_err("unauthorized refresh should fail the websocket connect");
@@ -2061,9 +2181,9 @@ mod tests {
let auth_manager = remote_control_auth_manager();
let mut auth_recovery = auth_manager.unauthorized_recovery();
let mut auth_change_rx = auth_manager.auth_change_receiver();
let mut enrollment = Some(remote_control_enrollment(Some(
let current_enrollment = test_current_enrollment(Some(remote_control_enrollment(Some(
TEST_REMOTE_CONTROL_SERVER_TOKEN,
)));
))));
let (status_publisher, _status_rx) = remote_control_status_channel();
let err = connect_remote_control_websocket(
@@ -2074,7 +2194,7 @@ mod tests {
auth_recovery: &mut auth_recovery,
auth_change_rx: &mut auth_change_rx,
},
&mut enrollment,
&current_enrollment,
RemoteControlConnectOptions {
installation_id: TEST_INSTALLATION_ID,
server_name: "test-server",
@@ -2082,14 +2202,13 @@ mod tests {
app_server_client_name: None,
},
&status_publisher,
&test_current_enrollment(),
)
.await
.expect_err("missing sqlite state db should fail remote control");
assert_eq!(err.kind(), ErrorKind::NotFound);
assert_eq!(err.to_string(), "remote control requires sqlite state db");
assert_eq!(enrollment, None);
assert_eq!(*current_enrollment.lock().await, None);
}
#[tokio::test]
@@ -2107,9 +2226,9 @@ mod tests {
.await;
let mut auth_recovery = auth_manager.unauthorized_recovery();
let mut auth_change_rx = auth_manager.auth_change_receiver();
let mut enrollment = Some(remote_control_enrollment(Some(
let current_enrollment = test_current_enrollment(Some(remote_control_enrollment(Some(
TEST_REMOTE_CONTROL_SERVER_TOKEN,
)));
))));
let (status_publisher, mut status_rx) = remote_control_status_channel();
status_publisher.publish_environment_id(Some("env_test".to_string()));
status_rx
@@ -2125,7 +2244,7 @@ mod tests {
auth_recovery: &mut auth_recovery,
auth_change_rx: &mut auth_change_rx,
},
&mut enrollment,
&current_enrollment,
RemoteControlConnectOptions {
installation_id: TEST_INSTALLATION_ID,
server_name: "test-server",
@@ -2133,7 +2252,6 @@ mod tests {
app_server_client_name: None,
},
&status_publisher,
&test_current_enrollment(),
)
.await
.expect_err("missing auth should fail remote control");
@@ -2143,7 +2261,7 @@ mod tests {
err.to_string(),
"remote control requires ChatGPT authentication"
);
assert_eq!(enrollment, None);
assert_eq!(*current_enrollment.lock().await, None);
assert_eq!(
status_rx.borrow().clone(),
RemoteControlStatusChangedNotification {
@@ -2185,7 +2303,8 @@ mod tests {
RemoteControlChannels {
transport_event_tx,
status_publisher,
current_enrollment: test_current_enrollment(),
current_enrollment: test_current_enrollment(/*enrollment*/ None),
pairing_persistence_key: watch::channel(None).0,
},
shutdown_token,
enabled_rx,
+1
View File
@@ -41,6 +41,7 @@ codex-extension-api = { workspace = true }
codex-external-agent-migration = { workspace = true }
codex-external-agent-sessions = { workspace = true }
codex-features = { workspace = true }
codex-goal-extension = { workspace = true }
codex-guardian = { workspace = true }
codex-git-utils = { workspace = true }
codex-file-watcher = { workspace = true }
+12 -8
View File
@@ -130,7 +130,7 @@ Example with notification opt-out:
## API Overview
- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; relative paths resolve against the effective thread cwd. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`.
- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`.
- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`.
- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`.
- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known.
@@ -158,7 +158,7 @@ Example with notification opt-out:
- `thread/shellCommand` — run a user-initiated `!` shell command against a thread; this runs unsandboxed with full access rather than inheriting the thread sandbox policy. Returns `{}` immediately while progress streams through standard turn/item notifications and any active turn receives the formatted output in its message stream.
- `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted.
- `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success.
- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; relative paths resolve against the effective turn cwd. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode".
- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode".
- `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success.
- `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Review and manual compaction turns reject `turn/steer`.
- `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"`.
@@ -188,11 +188,11 @@ Example with notification opt-out:
- `fs/watch` — subscribe this connection to filesystem change notifications for an absolute file or directory path and caller-provided `watchId`; returns the canonicalized `path`.
- `fs/unwatch` — stop sending notifications for a prior `fs/watch`; returns `{}`.
- `fs/changed` — notification emitted when watched paths change, including the `watchId` and `changedPaths`.
- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, `additionalSpeedTiers`, `serviceTiers`, optional `defaultServiceTier`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata.
- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with model-advertised string reasoning effort options in the catalog's intended progression order, `additionalSpeedTiers`, `serviceTiers`, optional `defaultServiceTier`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. Clients should preserve the `supportedReasoningEfforts` array order rather than deriving order from the effort names.
- `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider.
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
- `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.<id>]` entries to be included in the current catalog view.
- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for currently supported feature keys. For each feature, precedence is: cloud requirements > --enable <feature_name> > config.toml > experimentalFeature/enablement/set (new) > code default.
- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for currently supported feature keys. For each feature, precedence is: cloud requirements > --enable <feature_name> > config.toml > experimentalFeature/enablement/set (new) > code default. Invalid keys will be ignored.
- `environment/add` — experimental; add or replace a named remote environment by `environmentId` and `execServerUrl` for later selection by `thread/start` or `turn/start`; returns `{}` and does not change the default environment.
- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly.
- `skills/list` — list skills for one or more `cwd` values (optional `forceReload`).
@@ -211,6 +211,7 @@ Example with notification opt-out:
- `remoteControl/disable` — experimental; disable remote control for the current app-server process and return the current remote-control status snapshot. This does not revoke already enrolled controller devices.
- `remoteControl/status/read` — experimental; read the current remote-control status snapshot. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `serverName` is the local machine name used by this app-server process; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled.
- `remoteControl/pairing/start` — experimental; start a short-lived remote-control pairing artifact for the current app-server process. Pass `manualCode: true` to also request a manual pairing code. Returns `pairingCode`, `manualPairingCode`, `environmentId`, and Unix-seconds `expiresAt`; app-server intentionally does not expose the backend `serverId`.
- `remoteControl/pairing/status` — experimental; poll whether a remote-control `pairingCode` or `manualPairingCode` has been claimed. Pass exactly one of the two fields. Returns `claimed`.
- `remoteControl/client/list` — experimental; list controller devices granted access to an environment. Pass `environmentId` and optional `cursor`, `limit`, and `order`; returns picker-oriented client metadata plus `nextCursor`. This signed-in account-management operation works while the local relay is disabled or unenrolled.
- `remoteControl/client/revoke` — experimental; revoke one controller device's grant for an environment. Pass `environmentId` and `clientId`; returns an empty object. This signed-in account-management operation works while the local relay is disabled or unenrolled.
- `remoteControl/status/changed` — notification emitted when the remote-control status or client-visible environment id changes. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `serverName` is the local machine name used by this app-server process; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. Newly initialized app-server clients always receive the current status snapshot.
@@ -230,7 +231,7 @@ Example with notification opt-out:
- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin/session `details` returned by detect. When a request includes migration items, the server emits `externalAgentConfig/import/completed` once after the full import finishes (immediately after the response when everything completed synchronously, or after background imports finish).
- `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface.
- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads, including multiple `desktop.*` edits.
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`, `allowedPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`.
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), the layered permission-profile allow map (`allowedPermissionProfiles`), the managed permission-profile default (`defaultPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`.
### Example: Start or resume a thread
@@ -1232,7 +1233,7 @@ Because audio is intentionally separate from `ThreadItem`, clients can opt out o
### MCP server startup events
- `mcpServer/startupStatus/updated` — `{ name, status, error }` when app-server observes an MCP server startup transition. `status` is one of `starting`, `ready`, `failed`, or `cancelled`. `error` is `null` except for `failed`.
- `mcpServer/startupStatus/updated` — `{ threadId, name, status, error }` when app-server observes an MCP server startup transition. `threadId` identifies the owning thread when startup is thread-scoped and is `null` when startup is app-scoped. `status` is one of `starting`, `ready`, `failed`, or `cancelled`. `error` is `null` except for `failed`.
### Turn events
@@ -1244,6 +1245,7 @@ The app-server streams JSON-RPC notifications while a turn is running. Each turn
- `turn/plan/updated` — `{ turnId, explanation?, plan }` whenever the agent shares or changes its plan; each `plan` entry is `{ step, status }` with `status` in `pending`, `inProgress`, or `completed`.
- `model/rerouted` — `{ threadId, turnId, fromModel, toModel, reason }` when the backend reroutes a request to a different model (for example, due to high-risk cyber safety checks).
- `model/verification` — `{ threadId, turnId, verifications }` when the backend flags additional account verification, such as `trustedAccessForCyber`.
- `turn/moderationMetadata` — experimental; `{ threadId, turnId, metadata }` when a first-party backend supplies turn-scoped moderation metadata for client-side presentation.
Today both notifications carry an empty `items` array even when item events were streamed; rely on `item/*` notifications for the canonical item list until this is fixed.
@@ -1768,6 +1770,7 @@ Codex supports these authentication modes. The current mode is surfaced in `acco
- **API key (`apiKey`)**: Caller supplies an OpenAI API key via `account/login/start` with `type: "apiKey"`. The API key is saved and used for API requests.
- **ChatGPT managed (`chatgpt`)** (recommended): Codex owns the ChatGPT OAuth flow and refresh tokens. Start via `account/login/start` with `type: "chatgpt"` for the browser flow or `type: "chatgptDeviceCode"` for device code; Codex persists tokens to disk and refreshes them automatically.
- **Personal access token (`personalAccessToken`)**: Codex uses a ChatGPT-backed personal access token loaded outside the app-server login RPCs, such as with `codex login --with-access-token` or `CODEX_ACCESS_TOKEN`.
### API Overview
@@ -1776,12 +1779,13 @@ Codex supports these authentication modes. The current mode is surfaced in `acco
- `account/login/completed` (notify) — emitted when a login attempt finishes (success or error).
- `account/login/cancel` — cancel a pending managed ChatGPT login by `loginId`.
- `account/logout` — sign out; triggers `account/updated`.
- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, or `null`) and includes the current ChatGPT `planType` when available.
- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, `personalAccessToken`, or `null`) and includes the current ChatGPT `planType` when available.
- `account/rateLimits/read` — fetch ChatGPT rate limits and an optional effective monthly credit limit; updates arrive via `account/rateLimits/updated` (notify).
- `account/usage/read` — fetch ChatGPT account token-activity summary and daily buckets.
- `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. This is a sparse rolling update; merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot.
- `account/sendAddCreditsNudgeEmail` — ask ChatGPT to email the workspace owner about depleted credits or a reached usage limit.
- `mcpServer/oauthLogin/completed` (notify) — emitted after a `mcpServer/oauth/login` flow finishes for a server; payload includes `{ name, success, error? }`.
- `mcpServer/startupStatus/updated` (notify) — emitted when a configured MCP server's startup status changes for a loaded thread; payload includes `{ name, status, error }` where `status` is `starting`, `ready`, `failed`, or `cancelled`.
- `mcpServer/startupStatus/updated` (notify) — emitted when a configured MCP server's startup status changes; payload includes `{ threadId, name, status, error }`, where `threadId` is the owning thread when startup is thread-scoped and `null` when it is app-scoped, and `status` is `starting`, `ready`, `failed`, or `cancelled`.
### 1) Check auth state
@@ -75,6 +75,7 @@ use codex_app_server_protocol::TurnDiffUpdatedNotification;
use codex_app_server_protocol::TurnError;
use codex_app_server_protocol::TurnInterruptResponse;
use codex_app_server_protocol::TurnItemsView;
use codex_app_server_protocol::TurnModerationMetadataNotification;
use codex_app_server_protocol::TurnPlanStep;
use codex_app_server_protocol::TurnPlanUpdatedNotification;
use codex_app_server_protocol::TurnStartedNotification;
@@ -211,6 +212,7 @@ pub(crate) async fn apply_bespoke_event_handling(
}
};
let notification = McpServerStatusUpdatedNotification {
thread_id: Some(conversation_id.to_string()),
name: update.server,
status,
error,
@@ -337,6 +339,16 @@ pub(crate) async fn apply_bespoke_event_handling(
.send_server_notification(ServerNotification::ModelVerification(notification))
.await;
}
EventMsg::TurnModerationMetadata(event) => {
let notification = TurnModerationMetadataNotification {
thread_id: conversation_id.to_string(),
turn_id: event_turn_id.clone(),
metadata: event.metadata,
};
outgoing
.send_server_notification(ServerNotification::TurnModerationMetadata(notification))
.await;
}
EventMsg::RealtimeConversationStarted(event) => {
let notification = ThreadRealtimeStartedNotification {
thread_id: conversation_id.to_string(),
@@ -755,7 +767,7 @@ pub(crate) async fn apply_bespoke_event_handling(
let requested_permissions = request.permissions.clone();
let request_cwd = match request.cwd.clone() {
Some(cwd) => cwd,
None => conversation.config_snapshot().await.cwd,
None => conversation.config_snapshot().await.cwd().clone(),
};
let params = PermissionsRequestApprovalParams {
thread_id: conversation_id.to_string(),
@@ -1149,7 +1161,7 @@ pub(crate) async fn apply_bespoke_event_handling(
return;
}
};
let fallback_cwd = conversation.config_snapshot().await.cwd;
let fallback_cwd = conversation.config_snapshot().await.cwd().clone();
let stored_thread = match conversation
.read_thread(
/*include_archived*/ true, /*include_history*/ true,
@@ -2169,6 +2181,7 @@ mod tests {
];
let stored_thread = StoredThread {
thread_id,
extra_config: None,
rollout_path: None,
forked_from_id: None,
parent_thread_id: None,
@@ -316,9 +316,8 @@ impl ExternalAgentConfigService {
Some(self.external_agent_home.as_path()),
mcp_settings.as_ref(),
)?;
let mcp_server_names = migrated_mcp_server_names(&migrated_mcp);
let mut mcp_server_names = migrated_mcp_server_names(&migrated_mcp);
if !is_empty_toml_table(&migrated_mcp) {
let mut should_include = true;
if target_config.exists() {
let existing_raw = fs::read_to_string(&target_config)?;
let mut existing = if existing_raw.trim().is_empty() {
@@ -328,10 +327,10 @@ impl ExternalAgentConfigService {
invalid_data_error(format!("invalid existing config.toml: {err}"))
})?
};
should_include = merge_missing_toml_values(&mut existing, &migrated_mcp)?;
mcp_server_names = merge_missing_mcp_servers(&mut existing, &migrated_mcp)?;
}
if should_include {
if !mcp_server_names.is_empty() {
items.push(ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::McpServerConfig,
description: format!(
@@ -341,7 +340,7 @@ impl ExternalAgentConfigService {
),
cwd: cwd.clone(),
details: Some(MigrationDetails {
mcp_servers: named_migrations(mcp_server_names.clone()),
mcp_servers: named_migrations(mcp_server_names),
..Default::default()
}),
});
@@ -863,7 +862,7 @@ impl ExternalAgentConfigService {
toml::from_str::<TomlValue>(&existing_raw)
.map_err(|err| invalid_data_error(format!("invalid existing config.toml: {err}")))?
};
if merge_missing_toml_values(&mut existing, &migrated)? {
if !merge_missing_mcp_servers(&mut existing, &migrated)?.is_empty() {
write_toml_file(&target_config, &existing)?;
}
Ok(())
@@ -1538,6 +1537,43 @@ fn merge_missing_toml_values(existing: &mut TomlValue, incoming: &TomlValue) ->
}
}
fn merge_missing_mcp_servers(
existing: &mut TomlValue,
incoming: &TomlValue,
) -> io::Result<Vec<String>> {
let existing_root = existing
.as_table_mut()
.ok_or_else(|| invalid_data_error("expected existing config to be a TOML table"))?;
let incoming_root = incoming
.as_table()
.ok_or_else(|| invalid_data_error("expected migrated MCP config to be a TOML table"))?;
let Some(incoming_servers) = incoming_root.get("mcp_servers") else {
return Ok(Vec::new());
};
let incoming_servers = incoming_servers
.as_table()
.ok_or_else(|| invalid_data_error("expected migrated MCP servers to be a TOML table"))?;
let Some(existing_servers) = existing_root.get_mut("mcp_servers") else {
existing_root.insert(
"mcp_servers".to_string(),
TomlValue::Table(incoming_servers.clone()),
);
return Ok(incoming_servers.keys().cloned().collect());
};
let Some(existing_servers) = existing_servers.as_table_mut() else {
return Ok(Vec::new());
};
let mut merged_server_names = Vec::new();
for (server_name, incoming_server) in incoming_servers {
if !existing_servers.contains_key(server_name) {
existing_servers.insert(server_name.clone(), incoming_server.clone());
merged_server_names.push(server_name.clone());
}
}
Ok(merged_server_names)
}
fn write_toml_file(path: &Path, value: &TomlValue) -> io::Result<()> {
let serialized = toml::to_string_pretty(value)
.map_err(|err| invalid_data_error(format!("failed to serialize config.toml: {err}")))?;
@@ -638,6 +638,124 @@ Research with Codex carefully."""
assert_eq!(agent, expected_agent);
}
#[tokio::test]
async fn import_repo_mcp_preserves_existing_same_named_server() {
let root = TempDir::new().expect("create tempdir");
let repo_root = root.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
fs::write(
repo_root.join(".mcp.json"),
r#"{
"mcpServers": {
"mixedTransport": {
"command": "mcp-remote-proxy",
"args": [
"https://example.com/mixed-transport",
"--transport",
"http"
],
"url": "https://example.com/mixed-transport"
}
}
}"#,
)
.expect("write mcp");
fs::create_dir_all(repo_root.join(".codex")).expect("create codex dir");
let existing_config = r#"[mcp_servers.mixedTransport]
url = "https://example.com/mixed-transport"
"#;
fs::write(
repo_root.join(".codex").join("config.toml"),
existing_config,
)
.expect("write config");
let service = service_for_paths(
root.path().join(EXTERNAL_AGENT_DIR),
root.path().join(".codex"),
);
assert_eq!(
service
.detect(ExternalAgentConfigDetectOptions {
include_home: false,
cwds: Some(vec![repo_root.clone()]),
})
.await
.expect("detect"),
Vec::<ExternalAgentConfigMigrationItem>::new()
);
service
.import(vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::McpServerConfig,
description: String::new(),
cwd: Some(repo_root.clone()),
details: None,
}])
.await
.expect("import");
assert_eq!(
fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"),
existing_config
);
}
#[tokio::test]
async fn detect_repo_mcp_lists_only_missing_servers() {
let root = TempDir::new().expect("create tempdir");
let repo_root = root.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
fs::write(
repo_root.join(".mcp.json"),
r#"{
"mcpServers": {
"docs": {"command": "docs-server"},
"mixedTransport": {"command": "mcp-remote-proxy"}
}
}"#,
)
.expect("write mcp");
fs::create_dir_all(repo_root.join(".codex")).expect("create codex dir");
fs::write(
repo_root.join(".codex").join("config.toml"),
r#"[mcp_servers.mixedTransport]
url = "https://example.com/mixed-transport"
"#,
)
.expect("write config");
let items = service_for_paths(
root.path().join(EXTERNAL_AGENT_DIR),
root.path().join(".codex"),
)
.detect(ExternalAgentConfigDetectOptions {
include_home: false,
cwds: Some(vec![repo_root.clone()]),
})
.await
.expect("detect");
assert_eq!(
items,
vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::McpServerConfig,
description: format!(
"Migrate MCP servers from {} into {}",
repo_root.display(),
repo_root.join(".codex").join("config.toml").display()
),
cwd: Some(repo_root),
details: Some(MigrationDetails {
mcp_servers: vec![NamedMigration {
name: "docs".to_string(),
}],
..Default::default()
}),
}]
);
}
#[tokio::test]
async fn import_home_migrates_supported_config_fields_skills_and_agents_md() {
let (_root, external_agent_home, codex_home) = fixture_paths();
@@ -0,0 +1,49 @@
use std::future::Future;
use std::future::pending;
use tokio::task::JoinError;
use tokio::task::JoinSet;
use tracing::warn;
pub(crate) struct ConnectionCleanupTasks {
tasks: JoinSet<()>,
}
impl ConnectionCleanupTasks {
pub(crate) fn new() -> Self {
Self {
tasks: JoinSet::new(),
}
}
pub(crate) fn spawn(&mut self, future: impl Future<Output = ()> + Send + 'static) {
self.tasks.spawn(future);
}
pub(crate) async fn reap_next(&mut self) {
if self.tasks.is_empty() {
pending::<()>().await;
}
if let Some(result) = self.tasks.join_next().await {
log_cleanup_result(result);
}
}
pub(crate) async fn drain(&mut self) {
while let Some(result) = self.tasks.join_next().await {
log_cleanup_result(result);
}
}
pub(crate) fn abort(&mut self) {
self.tasks.abort_all();
}
}
fn log_cleanup_result(result: Result<(), JoinError>) {
if let Err(err) = result
&& !err.is_cancelled()
{
warn!("connection cleanup task failed: {err}");
}
}
+36 -7
View File
@@ -38,12 +38,14 @@ impl ConnectionRpcGate {
drop(token);
}
pub(crate) async fn close(&self) {
let mut accepting = self.accepting.lock().await;
*accepting = false;
self.tasks.close();
}
pub(crate) async fn shutdown(&self) {
{
let mut accepting = self.accepting.lock().await;
*accepting = false;
self.tasks.close();
}
self.close().await;
self.tasks.wait().await;
}
@@ -90,9 +92,9 @@ mod tests {
}
#[tokio::test]
async fn run_drops_future_without_polling_after_shutdown() {
async fn run_drops_future_without_polling_after_close() {
let gate = ConnectionRpcGate::new();
gate.shutdown().await;
gate.close().await;
let polled = Arc::new(AtomicBool::new(/*v*/ false));
let polled_clone = Arc::clone(&polled);
@@ -105,6 +107,33 @@ mod tests {
assert!(!gate.is_accepting().await);
}
#[tokio::test]
async fn close_returns_while_started_run_remains_active() {
let gate = Arc::new(ConnectionRpcGate::new());
let (started_tx, started_rx) = oneshot::channel();
let (finish_tx, finish_rx) = oneshot::channel();
let gate_for_run = Arc::clone(&gate);
let run_task = tokio::spawn(async move {
gate_for_run
.run(async move {
started_tx.send(()).expect("receiver should be open");
let _ = finish_rx.await;
})
.await;
});
started_rx.await.expect("run should start");
gate.close().await;
assert!(!gate.is_accepting().await);
assert_eq!(gate.inflight_count(), 1);
finish_tx
.send(())
.expect("running future should be waiting");
run_task.await.expect("run task should complete");
gate.shutdown().await;
}
#[tokio::test]
async fn shutdown_waits_for_started_run_to_finish() {
let gate = Arc::new(ConnectionRpcGate::new());
+101 -55
View File
@@ -2,6 +2,7 @@ use std::sync::Arc;
use std::sync::Weak;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadGoal;
use codex_app_server_protocol::ThreadGoalUpdatedNotification;
use codex_core::NewThread;
use codex_core::StartThreadOptions;
@@ -12,23 +13,40 @@ use codex_extension_api::AgentSpawner;
use codex_extension_api::ExtensionEventSink;
use codex_extension_api::ExtensionRegistry;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_goal_extension::GoalService;
use codex_login::AuthManager;
use codex_protocol::ThreadId;
use codex_protocol::error::CodexErr;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_rollout::state_db::StateDbHandle;
use crate::outgoing_message::OutgoingMessageSender;
use crate::thread_state::ThreadListenerCommand;
use crate::thread_state::ThreadStateManager;
pub(crate) fn thread_extensions<S>(
guardian_agent_spawner: S,
event_sink: Arc<dyn ExtensionEventSink>,
auth_manager: Arc<AuthManager>,
state_db: Option<StateDbHandle>,
thread_manager: Weak<ThreadManager>,
goal_service: Arc<GoalService>,
) -> Arc<ExtensionRegistry<Config>>
where
S: AgentSpawner<StartThreadOptions, Spawned = NewThread, Error = CodexErr> + 'static,
{
let mut builder = ExtensionRegistryBuilder::<Config>::with_event_sink(event_sink);
if let Some(state_db) = state_db {
codex_goal_extension::install_with_backend(
&mut builder,
state_db,
codex_otel::global(),
thread_manager,
goal_service,
|config: &Config| config.features.enabled(codex_features::Feature::Goals),
);
}
codex_guardian::install(&mut builder, guardian_agent_spawner);
codex_memories_extension::install(&mut builder, codex_otel::global());
codex_web_search_extension::install(&mut builder, auth_manager.clone());
@@ -38,26 +56,53 @@ where
pub(crate) fn app_server_extension_event_sink(
outgoing: Arc<OutgoingMessageSender>,
thread_state_manager: ThreadStateManager,
) -> Arc<dyn ExtensionEventSink> {
Arc::new(AppServerExtensionEventSink { outgoing })
Arc::new(AppServerExtensionEventSink {
outgoing,
thread_state_manager,
})
}
struct AppServerExtensionEventSink {
outgoing: Arc<OutgoingMessageSender>,
thread_state_manager: ThreadStateManager,
}
impl ExtensionEventSink for AppServerExtensionEventSink {
fn emit(&self, event: Event) {
match event.msg {
EventMsg::ThreadGoalUpdated(thread_goal_event) => {
self.outgoing
.try_send_server_notification(ServerNotification::ThreadGoalUpdated(
ThreadGoalUpdatedNotification {
thread_id: thread_goal_event.thread_id.to_string(),
turn_id: thread_goal_event.turn_id,
goal: thread_goal_event.goal.into(),
},
));
let thread_id = thread_goal_event.thread_id;
let turn_id = thread_goal_event.turn_id;
let goal: ThreadGoal = thread_goal_event.goal.into();
if let Some(listener_command_tx) = self
.thread_state_manager
.current_listener_command_tx(thread_id)
{
let command = ThreadListenerCommand::EmitThreadGoalUpdated {
turn_id: turn_id.clone(),
goal: goal.clone(),
};
if listener_command_tx.send(command).is_ok() {
return;
}
tracing::warn!(
"failed to enqueue extension goal update for {thread_id}: listener command channel is closed"
);
}
let outgoing = Arc::clone(&self.outgoing);
tokio::spawn(async move {
outgoing
.send_server_notification(ServerNotification::ThreadGoalUpdated(
ThreadGoalUpdatedNotification {
thread_id: thread_id.to_string(),
turn_id,
goal,
},
))
.await;
});
}
msg => {
tracing::debug!(event_id = %event.id, ?msg, "dropping unsupported extension event");
@@ -89,10 +134,7 @@ mod tests {
use std::time::Duration;
use codex_analytics::AnalyticsEventsClient;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadGoal as AppServerThreadGoal;
use codex_app_server_protocol::ThreadGoalStatus as AppServerThreadGoalStatus;
use codex_protocol::protocol::ThreadGoal;
use codex_protocol::protocol::ThreadGoal as CoreThreadGoal;
use codex_protocol::protocol::ThreadGoalStatus;
use codex_protocol::protocol::ThreadGoalUpdatedEvent;
use pretty_assertions::assert_eq;
@@ -100,25 +142,61 @@ mod tests {
use tokio::time::timeout;
use super::*;
use crate::outgoing_message::OutgoingEnvelope;
use crate::outgoing_message::OutgoingMessage;
#[tokio::test]
async fn app_server_event_sink_forwards_thread_goal_updates() {
let (outgoing_tx, mut outgoing_rx) = mpsc::channel(4);
async fn app_server_event_sink_uses_listener_fifo_for_goal_updates_and_clears() {
let (outgoing_tx, _outgoing_rx) = mpsc::channel(4);
let outgoing = Arc::new(OutgoingMessageSender::new(
outgoing_tx,
AnalyticsEventsClient::disabled(),
));
let sink = app_server_extension_event_sink(outgoing);
let thread_state_manager = ThreadStateManager::new();
let thread_id = ThreadId::default();
let (listener_command_tx, mut listener_command_rx) = mpsc::unbounded_channel();
thread_state_manager.register_listener_command_tx(thread_id, listener_command_tx.clone());
let sink = app_server_extension_event_sink(outgoing, thread_state_manager);
sink.emit(Event {
id: "call-1".to_string(),
for turn_id in ["turn-1", "turn-2"] {
sink.emit(thread_goal_updated_event(thread_id, turn_id));
}
listener_command_tx
.send(ThreadListenerCommand::EmitThreadGoalCleared)
.expect("listener command channel should be open");
let mut observed = Vec::new();
for _ in 0..3 {
let command = timeout(Duration::from_secs(1), listener_command_rx.recv())
.await
.expect("timed out waiting for listener command")
.expect("listener command channel closed unexpectedly");
match command {
ThreadListenerCommand::EmitThreadGoalUpdated { turn_id, .. } => {
observed.push(turn_id.expect("extension goal updates should include turn ids"));
}
ThreadListenerCommand::EmitThreadGoalCleared => {
observed.push("cleared".to_string())
}
_ => panic!("unexpected listener command"),
}
}
assert_eq!(
vec![
"turn-1".to_string(),
"turn-2".to_string(),
"cleared".to_string()
],
observed
);
}
fn thread_goal_updated_event(thread_id: ThreadId, turn_id: &str) -> Event {
Event {
id: turn_id.to_string(),
msg: EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent {
thread_id,
turn_id: Some("turn-1".to_string()),
goal: ThreadGoal {
turn_id: Some(turn_id.to_string()),
goal: CoreThreadGoal {
thread_id,
objective: "wire extension events".to_string(),
status: ThreadGoalStatus::Active,
@@ -129,38 +207,6 @@ mod tests {
updated_at: 8,
},
}),
});
let envelope = timeout(Duration::from_secs(1), outgoing_rx.recv())
.await
.expect("timed out waiting for forwarded extension event")
.expect("outgoing channel closed unexpectedly");
let OutgoingEnvelope::Broadcast { message } = envelope else {
panic!("expected broadcast notification");
};
let OutgoingMessage::AppServerNotification(ServerNotification::ThreadGoalUpdated(
notification,
)) = message
else {
panic!("expected thread goal updated notification");
};
assert_eq!(
ThreadGoalUpdatedNotification {
thread_id: thread_id.to_string(),
turn_id: Some("turn-1".to_string()),
goal: AppServerThreadGoal {
thread_id: thread_id.to_string(),
objective: "wire extension events".to_string(),
status: AppServerThreadGoalStatus::Active,
token_budget: Some(123),
tokens_used: 45,
time_used_seconds: 6,
created_at: 7,
updated_at: 8,
},
},
notification
);
}
}
}
+17 -4
View File
@@ -20,6 +20,7 @@ use std::sync::atomic::AtomicBool;
use crate::analytics_utils::analytics_events_client_from_config;
use crate::config_manager::ConfigManager;
use crate::connection_cleanup::ConnectionCleanupTasks;
use crate::message_processor::MessageProcessor;
use crate::message_processor::MessageProcessorArgs;
use crate::outgoing_message::ConnectionId;
@@ -81,6 +82,7 @@ mod command_exec;
mod config;
mod config_manager;
mod config_manager_service;
mod connection_cleanup;
mod connection_rpc_gate;
mod dynamic_tools;
mod error_code;
@@ -819,6 +821,7 @@ pub async fn run_main_with_transport_options(
let mut thread_created_rx = processor.thread_created_receiver();
let mut running_turn_count_rx = processor.subscribe_running_assistant_turn_count();
let mut connections = HashMap::<ConnectionId, ConnectionState>::new();
let mut connection_cleanup_tasks = ConnectionCleanupTasks::new();
let mut remote_control_status_rx = remote_control_handle.status_receiver();
let mut remote_control_status = remote_control_status_rx.borrow().clone();
let transport_shutdown_token = transport_shutdown_token.clone();
@@ -906,14 +909,20 @@ pub async fn run_main_with_transport_options(
let Some(connection_state) = connections.remove(&connection_id) else {
continue;
};
if outbound_control_tx
connection_state.session.rpc_gate.close().await;
let outbound_closed = outbound_control_tx
.send(OutboundControlEvent::Closed { connection_id })
.await
.is_err()
{
.is_ok();
let processor = Arc::clone(&processor);
connection_cleanup_tasks.spawn(async move {
processor
.connection_closed(connection_id, &connection_state.session)
.await;
});
if !outbound_closed {
break;
}
processor.connection_closed(connection_id, &connection_state.session).await;
if shutdown_when_no_connections && connections.is_empty() {
break;
}
@@ -1010,6 +1019,7 @@ pub async fn run_main_with_transport_options(
}
}
}
_ = connection_cleanup_tasks.reap_next() => {}
changed = remote_control_status_rx.changed() => {
if changed.is_err() {
continue;
@@ -1062,8 +1072,11 @@ pub async fn run_main_with_transport_options(
.map(|connection_state| connection_state.session.rpc_gate.shutdown()),
)
.await;
connection_cleanup_tasks.drain().await;
processor.drain_background_tasks().await;
processor.shutdown_threads().await;
} else {
connection_cleanup_tasks.abort();
}
info!("processor task exited (channel closed)");
}
+9 -1
View File
@@ -19,6 +19,9 @@ const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_C
#[derive(Debug, Parser)]
#[command(version)]
struct AppServerArgs {
#[command(flatten)]
config_overrides: CliConfigOverrides,
/// Transport endpoint URL. Supported values: `stdio://` (default),
/// `unix://`, `unix://PATH`, `ws://IP:PORT`, `off`.
#[arg(
@@ -58,6 +61,7 @@ struct AppServerArgs {
fn main() -> anyhow::Result<()> {
arg0_dispatch_or_else(|arg0_paths: Arg0DispatchPaths| async move {
let AppServerArgs {
config_overrides,
listen,
session_source,
auth,
@@ -84,7 +88,7 @@ fn main() -> anyhow::Result<()> {
run_main_with_transport_options(
arg0_paths,
CliConfigOverrides::default(),
config_overrides,
loader_overrides,
strict_config,
/*default_analytics_enabled*/ false,
@@ -123,3 +127,7 @@ fn managed_config_path_from_debug_env() -> Option<PathBuf> {
None
}
#[cfg(test)]
#[path = "main_tests.rs"]
mod tests;
+37
View File
@@ -0,0 +1,37 @@
use super::AppServerArgs;
use clap::Parser;
use pretty_assertions::assert_eq;
use toml::Value as TomlValue;
#[test]
fn app_server_accepts_cli_config_overrides() {
let args = AppServerArgs::try_parse_from([
"codex-app-server",
"-c",
"model=\"gpt-5-codex\"",
"--config",
"sandbox_mode=\"read-only\"",
"--listen",
"off",
])
.expect("parse app-server args");
let parsed_overrides = args
.config_overrides
.parse_overrides()
.expect("parse config overrides");
assert_eq!(
parsed_overrides,
vec![
(
"model".to_string(),
TomlValue::String("gpt-5-codex".to_string()),
),
(
"sandbox_mode".to_string(),
TomlValue::String("read-only".to_string()),
),
]
);
}
+3
View File
@@ -191,6 +191,9 @@ mod tests {
guardian_agent_spawner(thread_manager.clone()),
Arc::new(NoopExtensionEventSink),
auth_manager.clone(),
Some(state_db.clone()),
thread_manager.clone(),
Arc::new(codex_goal_extension::GoalService::new()),
),
/*analytics_events_client*/ None,
Arc::clone(&thread_store),
+32 -5
View File
@@ -70,6 +70,7 @@ use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_exec_server::EnvironmentManager;
use codex_feedback::CodexFeedback;
use codex_goal_extension::GoalService;
use codex_login::AuthManager;
use codex_login::auth::ExternalAuth;
use codex_login::auth::ExternalAuthRefreshContext;
@@ -90,6 +91,7 @@ use tokio_util::sync::CancellationToken;
use tracing::Instrument;
const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10);
const CONNECTION_RPC_DRAIN_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 30);
#[derive(Clone)]
struct ExternalAuthRefreshBridge {
@@ -305,6 +307,7 @@ impl MessageProcessor {
// resumed, or forked threads to a different persistence backend/root.
let thread_store = codex_core::thread_store_from_config(config.as_ref(), state_db.clone());
let environment_manager_for_requests = Arc::clone(&environment_manager);
let goal_service = Arc::new(GoalService::new());
let thread_manager = Arc::new_cyclic(|thread_manager| {
ThreadManager::new(
config.as_ref(),
@@ -313,8 +316,11 @@ impl MessageProcessor {
environment_manager,
thread_extensions(
guardian_agent_spawner(thread_manager.clone()),
app_server_extension_event_sink(outgoing.clone()),
app_server_extension_event_sink(outgoing.clone(), thread_state_manager.clone()),
auth_manager.clone(),
state_db.clone(),
thread_manager.clone(),
Arc::clone(&goal_service),
),
Some(analytics_events_client.clone()),
Arc::clone(&thread_store),
@@ -416,6 +422,7 @@ impl MessageProcessor {
Arc::clone(&config),
thread_state_manager.clone(),
state_db.clone(),
Arc::clone(&goal_service),
);
let thread_processor = ThreadRequestProcessor::new(
auth_manager.clone(),
@@ -455,20 +462,20 @@ impl MessageProcessor {
.plugins_manager()
.maybe_start_plugin_startup_tasks_for_config(
&config.plugins_config_input(),
auth_manager.clone(),
auth_manager,
Some(on_effective_plugins_changed),
);
}
let config_processor = ConfigRequestProcessor::new(
outgoing.clone(),
config_manager.clone(),
auth_manager,
thread_manager.clone(),
analytics_events_client,
);
let external_agent_config_processor = ExternalAgentConfigRequestProcessor::new(
outgoing.clone(),
Arc::clone(&thread_manager),
Arc::clone(&thread_store),
config_manager.clone(),
config_processor.clone(),
arg0_paths,
@@ -718,7 +725,19 @@ impl MessageProcessor {
connection_id: ConnectionId,
session_state: &ConnectionSessionState,
) {
session_state.rpc_gate.shutdown().await;
if timeout(
CONNECTION_RPC_DRAIN_TIMEOUT,
session_state.rpc_gate.shutdown(),
)
.await
.is_err()
{
tracing::warn!(
?connection_id,
timeout_seconds = CONNECTION_RPC_DRAIN_TIMEOUT.as_secs(),
"timed out waiting for connection RPCs to drain"
);
}
self.outgoing.connection_closed(connection_id).await;
self.fs_processor.connection_closed(connection_id).await;
self.command_exec_processor
@@ -919,7 +938,12 @@ impl MessageProcessor {
.map(|response| Some(response.into())),
ClientRequest::RemoteControlPairingStart { params, .. } => self
.remote_control_processor
.pairing_start(params)
.pairing_start(params, app_server_client_name.as_deref())
.await
.map(|response| Some(response.into())),
ClientRequest::RemoteControlPairingStatus { params, .. } => self
.remote_control_processor
.pairing_status(params)
.await
.map(|response| Some(response.into())),
ClientRequest::RemoteControlClientsList { params, .. } => self
@@ -1295,6 +1319,9 @@ impl MessageProcessor {
ClientRequest::GetAccountRateLimits { .. } => {
self.account_processor.get_account_rate_limits().await
}
ClientRequest::GetAccountTokenUsage { .. } => {
self.account_processor.get_account_token_usage().await
}
ClientRequest::SendAddCreditsNudgeEmail { params, .. } => {
self.account_processor
.send_add_credits_nudge_email(params)
+2 -2
View File
@@ -62,10 +62,10 @@ fn reasoning_efforts_from_preset(
efforts: Vec<ReasoningEffortPreset>,
) -> Vec<ReasoningEffortOption> {
efforts
.iter()
.into_iter()
.map(|preset| ReasoningEffortOption {
reasoning_effort: preset.effort,
description: preset.description.to_string(),
description: preset.description,
})
.collect()
}
+26 -10
View File
@@ -555,16 +555,6 @@ impl OutgoingMessageSender {
.await;
}
pub(crate) fn try_send_server_notification(&self, notification: ServerNotification) {
tracing::trace!("app-server event: {notification}");
let outgoing_message = OutgoingMessage::AppServerNotification(notification);
if let Err(err) = self.sender.try_send(OutgoingEnvelope::Broadcast {
message: outgoing_message,
}) {
warn!("failed to send server notification to client without waiting: {err:?}");
}
}
pub(crate) async fn send_server_notification_to_connections(
&self,
connection_ids: &[ConnectionId],
@@ -725,6 +715,7 @@ mod tests {
use codex_app_server_protocol::RateLimitWindow;
use codex_app_server_protocol::ServerResponse;
use codex_app_server_protocol::ToolRequestUserInputParams;
use codex_app_server_protocol::TurnModerationMetadataNotification;
use codex_protocol::ThreadId;
use pretty_assertions::assert_eq;
use serde_json::json;
@@ -951,6 +942,31 @@ mod tests {
);
}
#[test]
fn verify_turn_moderation_metadata_notification_serialization() {
let notification =
ServerNotification::TurnModerationMetadata(TurnModerationMetadataNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
metadata: json!({"presentation": "inline"}),
});
let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification);
assert_eq!(
json!({
"method": "turn/moderationMetadata",
"params": {
"threadId": "thread-1",
"turnId": "turn-1",
"metadata": {"presentation": "inline"},
},
}),
serde_json::to_value(jsonrpc_notification)
.expect("ensure the notification serializes correctly"),
"ensure the notification serializes correctly"
);
}
#[test]
fn server_request_response_from_result_decodes_typed_response() {
let request = ServerRequest::CommandExecutionRequestApproval {
+27 -4
View File
@@ -22,6 +22,8 @@ use codex_analytics::InputError;
use codex_analytics::TurnSteerRequestError;
use codex_app_server_protocol::Account;
use codex_app_server_protocol::AccountLoginCompletedNotification;
use codex_app_server_protocol::AccountTokenUsageDailyBucket;
use codex_app_server_protocol::AccountTokenUsageSummary;
use codex_app_server_protocol::AccountUpdatedNotification;
use codex_app_server_protocol::AddCreditsNudgeCreditType;
use codex_app_server_protocol::AddCreditsNudgeEmailStatus;
@@ -30,6 +32,8 @@ use codex_app_server_protocol::AdditionalContextKind;
use codex_app_server_protocol::AppInfo;
use codex_app_server_protocol::AppListUpdatedNotification;
use codex_app_server_protocol::AppSummary;
use codex_app_server_protocol::AppTemplateSummary;
use codex_app_server_protocol::AppTemplateUnavailableReason;
use codex_app_server_protocol::AppsListParams;
use codex_app_server_protocol::AppsListResponse;
use codex_app_server_protocol::AskForApproval;
@@ -62,6 +66,7 @@ use codex_app_server_protocol::FeedbackUploadResponse;
use codex_app_server_protocol::GetAccountParams;
use codex_app_server_protocol::GetAccountRateLimitsResponse;
use codex_app_server_protocol::GetAccountResponse;
use codex_app_server_protocol::GetAccountTokenUsageResponse;
use codex_app_server_protocol::GetAuthStatusParams;
use codex_app_server_protocol::GetAuthStatusResponse;
use codex_app_server_protocol::GetConversationSummaryParams;
@@ -264,6 +269,7 @@ use codex_app_server_protocol::WindowsSandboxSetupStartResponse;
use codex_arg0::Arg0DispatchPaths;
use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCreditType;
use codex_backend_client::Client as BackendClient;
use codex_backend_client::TokenUsageProfile;
use codex_chatgpt::connectors;
use codex_chatgpt::workspace_settings;
use codex_config::CloudConfigBundleLoadError;
@@ -273,8 +279,6 @@ use codex_config::loader::project_trust_key;
use codex_config::types::McpServerTransportConfig;
use codex_core::CodexThread;
use codex_core::CodexThreadSettingsOverrides;
use codex_core::ExternalGoalPreviousStatus;
use codex_core::ExternalGoalSet;
use codex_core::ForkSnapshot;
use codex_core::NewThread;
#[cfg(test)]
@@ -303,7 +307,6 @@ use codex_core::windows_sandbox::WindowsSandboxSetupRequest;
use codex_core::windows_sandbox::sandbox_setup_is_complete;
use codex_core_plugins::PluginInstallError as CorePluginInstallError;
use codex_core_plugins::PluginInstallRequest;
use codex_core_plugins::PluginLoadOutcome;
use codex_core_plugins::PluginReadRequest;
use codex_core_plugins::PluginUninstallError as CorePluginUninstallError;
use codex_core_plugins::loader::load_plugin_apps;
@@ -327,6 +330,7 @@ use codex_core_plugins::remote::RemotePluginShareContext as RemoteCatalogPluginS
use codex_core_plugins::remote::RemotePluginShareSummary as RemoteCatalogPluginShareSummary;
use codex_core_plugins::remote::RemotePluginSummary as RemoteCatalogPluginSummary;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::LOCAL_ENVIRONMENT_ID;
use codex_exec_server::LOCAL_FS;
use codex_features::FEATURES;
use codex_features::Feature;
@@ -354,7 +358,6 @@ use codex_mcp::discover_supported_scopes;
use codex_mcp::read_mcp_resource as read_mcp_resource_without_thread;
use codex_mcp::resolve_oauth_scopes;
use codex_memories_write::clear_memory_roots_contents;
use codex_model_provider::ProviderAccountError;
use codex_model_provider::create_model_provider;
use codex_models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets;
use codex_protocol::ThreadId;
@@ -398,6 +401,7 @@ use codex_protocol::protocol::SessionConfiguredEvent;
#[cfg(test)]
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_protocol::protocol::TurnEnvironmentSelections;
use codex_protocol::protocol::USER_MESSAGE_BEGIN;
use codex_protocol::protocol::W3cTraceContext;
use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS;
@@ -459,6 +463,7 @@ mod command_exec_processor;
mod config_processor;
mod environment_processor;
mod external_agent_config_processor;
mod external_agent_session_import;
mod feedback_doctor_report;
mod feedback_processor;
mod fs_processor;
@@ -508,6 +513,24 @@ use crate::thread_state::ThreadStateManager;
use token_usage_replay::latest_token_usage_turn_id_from_rollout_items;
use token_usage_replay::send_thread_token_usage_update_to_connection;
fn resolve_request_cwd(cwd: Option<PathBuf>) -> Result<Option<AbsolutePathBuf>, JSONRPCErrorError> {
cwd.map(|cwd| {
AbsolutePathBuf::relative_to_current_dir(path_utils::normalize_for_native_workdir(cwd))
.map_err(|err| invalid_request(format!("invalid cwd: {err}")))
})
.transpose()
}
fn resolve_runtime_workspace_roots(workspace_roots: Vec<AbsolutePathBuf>) -> Vec<AbsolutePathBuf> {
let mut resolved_roots = Vec::new();
for root in workspace_roots {
if !resolved_roots.iter().any(|existing| existing == &root) {
resolved_roots.push(root);
}
}
resolved_roots
}
mod config_errors;
mod request_errors;
mod thread_goal_processor;
@@ -2,6 +2,7 @@ use super::*;
// Duration before a browser ChatGPT login attempt is abandoned.
const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60);
const ACCOUNT_TOKEN_USAGE_FETCH_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 10);
// The override is intentionally available only in debug builds, matching the login path below.
#[cfg(debug_assertions)]
const LOGIN_ISSUER_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER";
@@ -131,6 +132,14 @@ impl AccountRequestProcessor {
.map(|response| Some(response.into()))
}
pub(crate) async fn get_account_token_usage(
&self,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.get_account_token_usage_response()
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn send_add_credits_nudge_email(
&self,
params: SendAddCreditsNudgeEmailParams,
@@ -767,24 +776,28 @@ impl AccountRequestProcessor {
let permanent_refresh_failure =
self.auth_manager.refresh_failure_for_auth(&auth).is_some();
let auth_mode = auth.api_auth_mode();
let (reported_auth_method, token_opt) =
if matches!(auth, CodexAuth::AgentIdentity(_))
|| include_token && permanent_refresh_failure
{
(Some(auth_mode), None)
} else {
match auth.get_token() {
Ok(token) if !token.is_empty() => {
let tok = if include_token { Some(token) } else { None };
(Some(auth_mode), tok)
}
Ok(_) => (None, None),
Err(err) => {
tracing::warn!("failed to get token for auth status: {err}");
(None, None)
}
let (reported_auth_method, token_opt) = if matches!(
auth,
CodexAuth::AgentIdentity(_) | CodexAuth::PersonalAccessToken(_)
) || include_token
&& permanent_refresh_failure
{
// This response cannot represent the metadata needed to reuse these
// credentials.
(Some(auth_mode), None)
} else {
match auth.get_token() {
Ok(token) if !token.is_empty() => {
let tok = if include_token { Some(token) } else { None };
(Some(auth_mode), tok)
}
};
Ok(_) => (None, None),
Err(err) => {
tracing::warn!("failed to get token for auth status: {err}");
(None, None)
}
}
};
GetAuthStatusResponse {
auth_method: reported_auth_method,
auth_token: token_opt,
@@ -816,11 +829,7 @@ impl AccountRequestProcessor {
);
let account_state = match provider.account_state() {
Ok(account_state) => account_state,
Err(ProviderAccountError::MissingChatgptAccountDetails) => {
return Err(invalid_request(
"email and plan type are required for chatgpt authentication",
));
}
Err(err) => return Err(invalid_request(err.to_string())),
};
let account = account_state.account.map(Account::from);
@@ -848,6 +857,55 @@ impl AccountRequestProcessor {
)
}
async fn get_account_token_usage_response(
&self,
) -> Result<GetAccountTokenUsageResponse, JSONRPCErrorError> {
let Some(auth) = self.auth_manager.auth().await else {
return Err(invalid_request(
"codex account authentication required to read token usage",
));
};
if !auth.uses_codex_backend() {
return Err(invalid_request(
"chatgpt authentication required to read token usage",
));
}
let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth)
.map_err(|err| internal_error(format!("failed to construct backend client: {err}")))?;
let profile = tokio::time::timeout(
ACCOUNT_TOKEN_USAGE_FETCH_TIMEOUT,
client.get_token_usage_profile(),
)
.await
.map_err(|_| internal_error("token usage profile fetch timed out"))?
.map_err(|err| internal_error(format!("failed to fetch token usage profile: {err}")))?;
Ok(Self::account_token_usage_response(profile))
}
fn account_token_usage_response(profile: TokenUsageProfile) -> GetAccountTokenUsageResponse {
let stats = profile.stats;
GetAccountTokenUsageResponse {
summary: AccountTokenUsageSummary {
lifetime_tokens: stats.lifetime_tokens,
peak_daily_tokens: stats.peak_daily_tokens,
longest_running_turn_sec: stats.longest_running_turn_sec,
current_streak_days: stats.current_streak_days,
longest_streak_days: stats.longest_streak_days,
},
daily_usage_buckets: stats.daily_usage_buckets.map(|buckets| {
buckets
.into_iter()
.map(|bucket| AccountTokenUsageDailyBucket {
start_date: bucket.start_date,
tokens: bucket.tokens,
})
.collect()
}),
}
}
async fn send_add_credits_nudge_email_response(
&self,
params: SendAddCreditsNudgeEmailParams,
@@ -952,3 +1010,45 @@ impl AccountRequestProcessor {
Ok((primary, rate_limits_by_limit_id))
}
}
#[cfg(test)]
mod tests {
use super::*;
use codex_backend_client::TokenUsageProfileDailyBucket;
use codex_backend_client::TokenUsageProfileStats;
use pretty_assertions::assert_eq;
#[test]
fn account_token_usage_response_maps_profile_stats_and_daily_buckets() {
let response = AccountRequestProcessor::account_token_usage_response(TokenUsageProfile {
stats: TokenUsageProfileStats {
lifetime_tokens: Some(123),
peak_daily_tokens: Some(45),
longest_running_turn_sec: Some(67),
current_streak_days: Some(8),
longest_streak_days: Some(9),
daily_usage_buckets: Some(vec![TokenUsageProfileDailyBucket {
start_date: "2026-05-29".to_string(),
tokens: 10,
}]),
},
});
assert_eq!(
response,
GetAccountTokenUsageResponse {
summary: AccountTokenUsageSummary {
lifetime_tokens: Some(123),
peak_daily_tokens: Some(45),
longest_running_turn_sec: Some(67),
current_streak_days: Some(8),
longest_streak_days: Some(9),
},
daily_usage_buckets: Some(vec![AccountTokenUsageDailyBucket {
start_date: "2026-05-29".to_string(),
tokens: 10,
}]),
}
);
}
}
@@ -53,7 +53,7 @@ impl AppsRequestProcessor {
None
};
let fallback_cwd = match thread.as_ref() {
Some(thread) => Some(thread.config_snapshot().await.cwd.to_path_buf()),
Some(thread) => Some(thread.config_snapshot().await.cwd().to_path_buf()),
None => None,
};
let mut config = self.load_latest_config(fallback_cwd).await?;
@@ -645,20 +645,22 @@ impl CatalogRequestProcessor {
.await;
let plugins_enabled =
config.features.enabled(Feature::Plugins) && workspace_codex_plugins_enabled;
let plugin_outcome = if plugins_enabled {
let plugin_hooks = if plugins_enabled {
let plugins_input = config.plugins_config_input();
plugins_manager
.plugins_for_layer_stack(&config.config_layer_stack, &plugins_input)
.await
let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await;
codex_core_plugins::PluginHookLoadOutcome {
hook_sources: plugin_outcome.effective_plugin_hook_sources(),
hook_load_warnings: plugin_outcome.effective_plugin_hook_warnings(),
}
} else {
PluginLoadOutcome::default()
codex_core_plugins::PluginHookLoadOutcome::default()
};
let hooks = codex_hooks::list_hooks(codex_hooks::HooksConfig {
feature_enabled: config.features.enabled(Feature::CodexHooks),
bypass_hook_trust: config.bypass_hook_trust,
config_layer_stack: Some(config.config_layer_stack),
plugin_hook_sources: plugin_outcome.effective_plugin_hook_sources(),
plugin_hook_load_warnings: plugin_outcome.effective_plugin_hook_warnings(),
plugin_hook_sources: plugin_hooks.hook_sources,
plugin_hook_load_warnings: plugin_hooks.hook_load_warnings,
..Default::default()
});
data.push(codex_app_server_protocol::HooksListEntry {

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