mirror of
https://github.com/openai/codex.git
synced 2026-10-21 17:48:44 +00:00
Merge remote-tracking branch 'origin/main' into codex/viyatb/guardian-reviewer-retries
This commit is contained in:
@@ -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
|
||||
]
|
||||
|
||||
Generated
+557
-356
File diff suppressed because it is too large
Load Diff
+9
-7
@@ -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
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
+3
-3
@@ -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
@@ -1593,8 +1593,8 @@
|
||||
"environmentId": {
|
||||
"default": null,
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"itemId": {
|
||||
@@ -2005,4 +2005,4 @@
|
||||
}
|
||||
],
|
||||
"title": "ServerRequest"
|
||||
}
|
||||
}
|
||||
+293
-16
@@ -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"
|
||||
}
|
||||
}
|
||||
+290
-13
@@ -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": {
|
||||
|
||||
+10
-4
@@ -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": [
|
||||
{
|
||||
|
||||
+80
@@ -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"
|
||||
}
|
||||
+2
-9
@@ -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": {
|
||||
|
||||
+6
@@ -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",
|
||||
|
||||
+55
@@ -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": {
|
||||
|
||||
+2
-9
@@ -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": {
|
||||
|
||||
+2
-9
@@ -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": {
|
||||
|
||||
+2
-9
@@ -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": {
|
||||
|
||||
+2
-9
@@ -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": {
|
||||
|
||||
+19
@@ -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
@@ -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,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";
|
||||
|
||||
+5
@@ -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, };
|
||||
+5
@@ -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, };
|
||||
+5
@@ -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};
|
||||
|
||||
+7
@@ -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, };
|
||||
+1
-1
@@ -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>, };
|
||||
|
||||
+6
@@ -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(¬ification),
|
||||
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(¶ms)?;
|
||||
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 (¶ms.pairing_code, ¶ms.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,
|
||||
}
|
||||
}
|
||||
|
||||
+358
-14
@@ -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,
|
||||
¤t_enrollment,
|
||||
RemoteControlConnectOptions {
|
||||
installation_id: TEST_INSTALLATION_ID,
|
||||
server_name: "test-server",
|
||||
@@ -1761,7 +1897,6 @@ mod tests {
|
||||
app_server_client_name: None,
|
||||
},
|
||||
&status_publisher,
|
||||
¤t_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,
|
||||
¤t_enrollment,
|
||||
RemoteControlConnectOptions {
|
||||
installation_id: TEST_INSTALLATION_ID,
|
||||
server_name: "test-server",
|
||||
@@ -1832,7 +1961,6 @@ mod tests {
|
||||
app_server_client_name: None,
|
||||
},
|
||||
&status_publisher,
|
||||
¤t_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,
|
||||
¤t_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,
|
||||
¤t_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,
|
||||
¤t_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,
|
||||
¤t_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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()),
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user