Merge remote-tracking branch 'origin/main' into jif/remove-list-agents-last-task-message

# Conflicts:
#	codex-rs/core/src/agent/control.rs
#	codex-rs/core/src/agent/control_tests.rs
This commit is contained in:
jif-oai
2026-07-08 12:09:37 +01:00
716 changed files with 29934 additions and 6390 deletions
+3 -1
View File
@@ -1,5 +1,5 @@
[advisories]
# Reviewed 2026-06-07. Keep this list in sync with ../deny.toml.
# Reviewed 2026-07-02. Keep this list in sync with ../deny.toml.
ignore = [
"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
@@ -10,4 +10,6 @@ ignore = [
"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
"RUSTSEC-2026-0194", # quick-xml via plist/syntect and wayland-scanner; trusted inputs only; remove when rust-plist#191 and wayland-rs#938 are released
"RUSTSEC-2026-0195", # quick-xml via plist/syntect and wayland-scanner; trusted inputs only; remove when rust-plist#191 and wayland-rs#938 are released
]
+83 -38
View File
@@ -412,6 +412,7 @@ dependencies = [
"serde",
"serde_json",
"shlex",
"tempfile",
"tokio",
"uuid",
"wiremock",
@@ -1936,6 +1937,7 @@ dependencies = [
"bytes",
"chrono",
"codex-client",
"codex-http-client",
"codex-protocol",
"codex-utils-rustls-provider",
"eventsource-stream",
@@ -2106,6 +2108,7 @@ dependencies = [
"anyhow",
"clap",
"codex-experimental-api-macros",
"codex-extension-items",
"codex-protocol",
"codex-shell-command",
"codex-utils-absolute-path",
@@ -2173,9 +2176,11 @@ dependencies = [
"futures",
"gethostname",
"hmac 0.12.1",
"httpdate",
"jsonwebtoken",
"owo-colors",
"pretty_assertions",
"rand 0.9.3",
"serde",
"serde_json",
"sha2 0.10.9",
@@ -2260,7 +2265,7 @@ dependencies = [
"anyhow",
"codex-api",
"codex-backend-openapi-models",
"codex-client",
"codex-http-client",
"codex-login",
"codex-model-provider",
"codex-protocol",
@@ -2314,6 +2319,7 @@ name = "codex-cli"
version = "0.0.0"
dependencies = [
"anyhow",
"app_test_support",
"assert_cmd",
"assert_matches",
"clap",
@@ -2389,33 +2395,12 @@ dependencies = [
name = "codex-client"
version = "0.0.0"
dependencies = [
"bytes",
"codex-utils-cargo-bin",
"codex-utils-rustls-provider",
"codex-http-client",
"eventsource-stream",
"futures",
"http 1.4.0",
"opentelemetry",
"opentelemetry_sdk",
"pretty_assertions",
"rand 0.9.3",
"rcgen",
"reqwest 0.12.28",
"rustls",
"rustls-native-certs",
"rustls-pki-types",
"serde",
"serde_json",
"sha2 0.10.9",
"system-configuration",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tracing",
"tracing-opentelemetry",
"tracing-subscriber",
"windows-sys 0.52.0",
"zstd 0.13.3",
]
[[package]]
@@ -2449,11 +2434,11 @@ dependencies = [
"anyhow",
"chrono",
"clap",
"codex-client",
"codex-cloud-tasks-client",
"codex-cloud-tasks-mock-client",
"codex-core",
"codex-git-utils",
"codex-http-client",
"codex-login",
"codex-model-provider",
"codex-tui",
@@ -2523,6 +2508,7 @@ dependencies = [
"codex-utils-cargo-bin",
"pretty_assertions",
"serde_json",
"tempfile",
"tokio",
"tokio-util",
]
@@ -2657,12 +2643,14 @@ dependencies = [
"codex-exec-server",
"codex-execpolicy",
"codex-extension-api",
"codex-extension-items",
"codex-features",
"codex-feedback",
"codex-file-system",
"codex-git-utils",
"codex-home",
"codex-hooks",
"codex-http-client",
"codex-image-generation-extension",
"codex-install-context",
"codex-login",
@@ -2912,9 +2900,9 @@ dependencies = [
"bytes",
"clatter",
"codex-api",
"codex-client",
"codex-exec-server-protocol",
"codex-file-system",
"codex-http-client",
"codex-network-proxy",
"codex-otel",
"codex-protocol",
@@ -3028,6 +3016,18 @@ dependencies = [
"tokio",
]
[[package]]
name = "codex-extension-items"
version = "0.0.0"
dependencies = [
"codex-utils-absolute-path",
"pretty_assertions",
"schemars 0.8.22",
"serde",
"serde_json",
"ts-rs",
]
[[package]]
name = "codex-external-agent-migration"
version = "0.0.0"
@@ -3074,6 +3074,7 @@ dependencies = [
"anyhow",
"codex-login",
"codex-protocol",
"mime_guess",
"pretty_assertions",
"sentry",
"tracing",
@@ -3209,6 +3210,37 @@ dependencies = [
"uuid",
]
[[package]]
name = "codex-http-client"
version = "0.0.0"
dependencies = [
"bytes",
"codex-utils-cargo-bin",
"codex-utils-rustls-provider",
"futures",
"http 1.4.0",
"opentelemetry",
"opentelemetry_sdk",
"pretty_assertions",
"rcgen",
"reqwest 0.12.28",
"rustls",
"rustls-native-certs",
"rustls-pki-types",
"serde",
"serde_json",
"sha2 0.10.9",
"system-configuration",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tracing",
"tracing-opentelemetry",
"tracing-subscriber",
"windows-sys 0.52.0",
"zstd 0.13.3",
]
[[package]]
name = "codex-image-generation-extension"
version = "0.0.0"
@@ -3218,6 +3250,7 @@ dependencies = [
"codex-core",
"codex-exec-server",
"codex-extension-api",
"codex-extension-items",
"codex-login",
"codex-model-provider",
"codex-model-provider-info",
@@ -3260,6 +3293,7 @@ dependencies = [
"clap",
"codex-core",
"codex-install-context",
"codex-network-proxy",
"codex-process-hardening",
"codex-protocol",
"codex-sandboxing",
@@ -3299,8 +3333,8 @@ dependencies = [
"base64 0.22.1",
"chrono",
"codex-agent-identity",
"codex-client",
"codex-config",
"codex-http-client",
"codex-keyring-store",
"codex-model-provider-info",
"codex-otel",
@@ -3514,8 +3548,8 @@ dependencies = [
"codex-agent-identity",
"codex-api",
"codex-aws-auth",
"codex-client",
"codex-feedback",
"codex-http-client",
"codex-login",
"codex-model-provider-info",
"codex-models-manager",
@@ -3696,6 +3730,7 @@ dependencies = [
"chrono",
"codex-async-utils",
"codex-execpolicy",
"codex-extension-items",
"codex-network-proxy",
"codex-utils-absolute-path",
"codex-utils-image",
@@ -3709,7 +3744,7 @@ dependencies = [
"icu_provider",
"landlock",
"pretty_assertions",
"quick-xml",
"quick-xml 0.41.0",
"reqwest 0.12.28",
"schemars 0.8.22",
"seccompiler",
@@ -4053,6 +4088,7 @@ version = "0.0.0"
dependencies = [
"codex-code-mode",
"codex-connectors",
"codex-extension-items",
"codex-features",
"codex-file-system",
"codex-protocol",
@@ -4817,9 +4853,9 @@ dependencies = [
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]
@@ -10268,13 +10304,13 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "plist"
version = "1.8.0"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
dependencies = [
"base64 0.22.1",
"indexmap 2.14.0",
"quick-xml",
"quick-xml 0.39.4",
"serde",
"time",
]
@@ -10707,9 +10743,18 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.38.4"
version = "0.39.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
"memchr",
"serde",
@@ -14670,12 +14715,12 @@ dependencies = [
[[package]]
name = "wayland-scanner"
version = "0.31.8"
version = "0.31.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3"
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
dependencies = [
"proc-macro2",
"quick-xml",
"quick-xml 0.39.4",
"quote",
]
+6 -1
View File
@@ -41,6 +41,7 @@ members = [
"core-plugins",
"core-skills",
"hooks",
"http-client",
"secrets",
"exec",
"file-system",
@@ -53,6 +54,7 @@ members = [
"ext/goal",
"ext/guardian",
"ext/image-generation",
"ext/items",
"ext/memories",
"ext/mcp",
"ext/skills",
@@ -164,6 +166,7 @@ codex-cloud-tasks-mock-client = { path = "cloud-tasks-mock-client" }
codex-code-mode = { path = "code-mode" }
codex-code-mode-protocol = { path = "code-mode-protocol" }
codex-home = { path = "codex-home" }
codex-http-client = { path = "http-client" }
codex-config = { path = "config" }
codex-connectors = { path = "connectors" }
codex-connectors-extension = { path = "ext/connectors" }
@@ -178,6 +181,7 @@ codex-exec-server-protocol = { path = "exec-server-protocol" }
codex-exec-server = { path = "exec-server" }
codex-execpolicy = { path = "execpolicy" }
codex-extension-api = { path = "ext/extension-api" }
codex-extension-items = { path = "ext/items" }
codex-goal-extension = { path = "ext/goal" }
codex-guardian = { path = "ext/guardian" }
codex-image-generation-extension = { path = "ext/image-generation" }
@@ -318,6 +322,7 @@ glob = "0.3"
globset = "0.4"
hmac = "0.12.1"
http = "1.3.1"
httpdate = "1.0.3"
iana-time-zone = "0.1.64"
icu_decimal = "2.1"
icu_locale_core = "2.1"
@@ -361,7 +366,7 @@ portable-pty = "0.9.0"
predicates = "3"
pretty_assertions = "1.4.1"
pulldown-cmark = "0.10"
quick-xml = "0.38.4"
quick-xml = "0.41.0"
rand = "0.9"
ratatui = "0.29.0"
ratatui-macros = "0.6.0"
@@ -103,6 +103,7 @@ use codex_app_server_protocol::GuardianApprovalReview;
use codex_app_server_protocol::GuardianApprovalReviewAction;
use codex_app_server_protocol::GuardianApprovalReviewStatus;
use codex_app_server_protocol::GuardianCommandSource as AppServerGuardianCommandSource;
use codex_app_server_protocol::ImageGenerationItem;
use codex_app_server_protocol::InitializeCapabilities;
use codex_app_server_protocol::InitializeParams;
use codex_app_server_protocol::ItemCompletedNotification;
@@ -198,6 +199,7 @@ fn sample_thread_with_metadata(
parent_thread_id,
preview: "first prompt".to_string(),
ephemeral,
history_mode: Default::default(),
model_provider: "openai".to_string(),
created_at: 1,
updated_at: 2,
@@ -4404,13 +4406,13 @@ async fn turn_event_counts_completed_tool_items() {
query: "codex".to_string(),
action: None,
},
ThreadItem::ImageGeneration {
ThreadItem::ImageGeneration(ImageGenerationItem {
id: "image-1".to_string(),
status: "completed".to_string(),
revised_prompt: None,
result: "ok".to_string(),
saved_path: None,
},
}),
];
for item in completed_tool_items {
+1
View File
@@ -283,6 +283,7 @@ fn sample_thread(thread_id: &str) -> Thread {
parent_thread_id: None,
preview: "first prompt".to_string(),
ephemeral: false,
history_mode: Default::default(),
model_provider: "openai".to_string(),
created_at: 1,
updated_at: 2,
+8 -14
View File
@@ -403,7 +403,7 @@ impl TurnToolCounts {
self.subagent_tool_call += 1;
}
ThreadItem::WebSearch { .. } => self.web_search += 1,
ThreadItem::ImageGeneration { .. } => self.image_generation += 1,
ThreadItem::ImageGeneration(_) => self.image_generation += 1,
ThreadItem::UserMessage { .. }
| ThreadItem::HookPrompt { .. }
| ThreadItem::AgentMessage { .. }
@@ -1733,8 +1733,8 @@ fn tracked_tool_item_id(item: &ThreadItem) -> Option<&str> {
| ThreadItem::McpToolCall { id, .. }
| ThreadItem::DynamicToolCall { id, .. }
| ThreadItem::CollabAgentToolCall { id, .. }
| ThreadItem::WebSearch { id, .. }
| ThreadItem::ImageGeneration { id, .. } => Some(id),
| ThreadItem::WebSearch { id, .. } => Some(id),
ThreadItem::ImageGeneration(item) => Some(&item.id),
ThreadItem::UserMessage { .. }
| ThreadItem::HookPrompt { .. }
| ThreadItem::AgentMessage { .. }
@@ -2057,18 +2057,12 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option<TrackEventRequest> {
},
}))
}
ThreadItem::ImageGeneration {
id,
status,
revised_prompt,
saved_path,
..
} => {
let (terminal_status, failure_kind) = image_generation_outcome(status.as_str());
ThreadItem::ImageGeneration(item) => {
let (terminal_status, failure_kind) = image_generation_outcome(item.status.as_str());
let base = tool_item_base(
thread_id,
turn_id,
id.clone(),
item.id.clone(),
"image_generation".to_string(),
ToolItemOutcome {
terminal_status,
@@ -2089,8 +2083,8 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option<TrackEventRequest> {
event_type: "codex_image_generation_event",
event_params: CodexImageGenerationEventParams {
base,
revised_prompt_present: revised_prompt.is_some(),
saved_path_present: saved_path.is_some(),
revised_prompt_present: item.revised_prompt.is_some(),
saved_path_present: item.saved_path.is_some(),
},
},
))
-3
View File
@@ -78,9 +78,6 @@ pub use crate::remote::RemoteAppServerEndpoint;
/// module exists so clients can remove a direct `codex-core` dependency
/// while legacy startup/config paths are migrated to RPCs.
pub mod legacy_core {
pub use codex_core::check_execpolicy_for_warnings;
pub use codex_core::format_exec_policy_error_with_source;
pub mod config {
pub use codex_core::config::*;
+1
View File
@@ -16,6 +16,7 @@ workspace = true
anyhow = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-experimental-api-macros = { workspace = true }
codex-extension-items = { workspace = true }
codex-protocol = { workspace = true }
codex-shell-command = { workspace = true }
codex-utils-absolute-path = { workspace = true }
+71 -7
View File
@@ -556,6 +556,13 @@
},
"ConsumeAccountRateLimitResetCreditParams": {
"properties": {
"creditId": {
"description": "Opaque reset-credit identifier to redeem. When omitted, the backend selects the next available credit.",
"type": [
"string",
"null"
]
},
"idempotencyKey": {
"description": "Identifies one logical reset attempt. A UUID is recommended; reuse the same value when retrying that attempt.",
"type": "string"
@@ -1456,6 +1463,17 @@
},
{
"properties": {
"appBrand": {
"anyOf": [
{
"$ref": "#/definitions/LoginAppBrand"
},
{
"type": "null"
}
],
"default": null
},
"codexStreamlinedLogin": {
"type": "boolean"
},
@@ -1465,6 +1483,9 @@
],
"title": "ChatgptLoginAccountParamsType",
"type": "string"
},
"useHostedLoginSuccessPage": {
"type": "boolean"
}
},
"required": [
@@ -1525,6 +1546,13 @@
}
]
},
"LoginAppBrand": {
"enum": [
"codex",
"chatgpt"
],
"type": "string"
},
"MarketplaceAddParams": {
"properties": {
"refName": {
@@ -1786,13 +1814,29 @@
"type": "object"
},
"MultiAgentMode": {
"description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.",
"enum": [
"none",
"explicitRequestOnly",
"proactive"
],
"type": "string"
"description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.",
"oneOf": [
{
"enum": [
"explicitRequestOnly",
"proactive"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"custom": {
"type": "string"
}
},
"required": [
"custom"
],
"title": "CustomMultiAgentMode",
"type": "object"
}
]
},
"NetworkAccess": {
"enum": [
@@ -2683,6 +2727,12 @@
"name": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"status": {
"type": [
"string",
@@ -3631,6 +3681,13 @@
"ephemeral": {
"type": "boolean"
},
"lastTurnId": {
"description": "Optional last turn id to fork through, inclusive.\n\nWhen specified, turns after `last_turn_id` are omitted from the fork. The referenced turn cannot be in progress.",
"type": [
"string",
"null"
]
},
"model": {
"description": "Configuration overrides for the forked thread, if any.",
"type": [
@@ -3747,6 +3804,13 @@
],
"type": "string"
},
"ThreadHistoryMode": {
"enum": [
"legacy",
"paginated"
],
"type": "string"
},
"ThreadInjectItemsParams": {
"properties": {
"items": {
@@ -536,6 +536,13 @@
],
"type": "string"
},
{
"description": "Backend auth supplied as request headers.",
"enum": [
"headers"
],
"type": "string"
},
{
"description": "Programmatic Codex auth backed by a registered Agent Identity.",
"enum": [
@@ -2713,13 +2720,29 @@
"type": "object"
},
"MultiAgentMode": {
"description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.",
"enum": [
"none",
"explicitRequestOnly",
"proactive"
],
"type": "string"
"description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.",
"oneOf": [
{
"enum": [
"explicitRequestOnly",
"proactive"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"custom": {
"type": "string"
}
},
"required": [
"custom"
],
"title": "CustomMultiAgentMode",
"type": "object"
}
]
},
"NetworkAccess": {
"enum": [
@@ -3917,6 +3940,13 @@
],
"type": "object"
},
"ThreadHistoryMode": {
"enum": [
"legacy",
"paginated"
],
"type": "string"
},
"ThreadId": {
"type": "string"
},
@@ -6728,6 +6728,7 @@
"enum": [
"auto",
"prompt",
"writes",
"approve"
],
"type": "string"
@@ -6950,6 +6951,13 @@
],
"type": "string"
},
{
"description": "Backend auth supplied as request headers.",
"enum": [
"headers"
],
"type": "string"
},
{
"description": "Programmatic Codex auth backed by a registered Agent Identity.",
"enum": [
@@ -8414,6 +8422,16 @@
"object",
"null"
]
},
"models": {
"anyOf": [
{
"$ref": "#/definitions/v2/ModelsRequirements"
},
{
"type": "null"
}
]
}
},
"type": "object"
@@ -8678,6 +8696,13 @@
"ConsumeAccountRateLimitResetCreditParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"creditId": {
"description": "Opaque reset-credit identifier to redeem. When omitted, the backend selects the next available credit.",
"type": [
"string",
"null"
]
},
"idempotencyKey": {
"description": "Identifies one logical reset attempt. A UUID is recommended; reuse the same value when retrying that attempt.",
"type": "string"
@@ -11486,6 +11511,17 @@
},
{
"properties": {
"appBrand": {
"anyOf": [
{
"$ref": "#/definitions/v2/LoginAppBrand"
},
{
"type": "null"
}
],
"default": null
},
"codexStreamlinedLogin": {
"type": "boolean"
},
@@ -11495,6 +11531,9 @@
],
"title": "Chatgptv2::LoginAccountParamsType",
"type": "string"
},
"useHostedLoginSuccessPage": {
"type": "boolean"
}
},
"required": [
@@ -11649,6 +11688,13 @@
],
"title": "LoginAccountResponse"
},
"LoginAppBrand": {
"enum": [
"codex",
"chatgpt"
],
"type": "string"
},
"LogoutAccountResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "LogoutAccountResponse",
@@ -12834,14 +12880,45 @@
"title": "ModelVerificationNotification",
"type": "object"
},
"ModelsRequirements": {
"properties": {
"newThread": {
"anyOf": [
{
"$ref": "#/definitions/v2/NewThreadModelDefaults"
},
{
"type": "null"
}
]
}
},
"type": "object"
},
"MultiAgentMode": {
"description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.",
"enum": [
"none",
"explicitRequestOnly",
"proactive"
],
"type": "string"
"description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.",
"oneOf": [
{
"enum": [
"explicitRequestOnly",
"proactive"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"custom": {
"type": "string"
}
},
"required": [
"custom"
],
"title": "CustomMultiAgentMode",
"type": "object"
}
]
},
"NetworkAccess": {
"enum": [
@@ -12981,6 +13058,33 @@
],
"type": "string"
},
"NewThreadModelDefaults": {
"properties": {
"model": {
"type": [
"string",
"null"
]
},
"modelReasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/v2/ReasoningEffort"
},
{
"type": "null"
}
]
},
"serviceTier": {
"type": [
"string",
"null"
]
}
},
"type": "object"
},
"NonSteerableTurnKind": {
"enum": [
"review",
@@ -13348,6 +13452,13 @@
],
"type": "string"
},
"PluginInstallPolicySource": {
"enum": [
"WORKSPACE_SETTING",
"IMPLICIT_CANONICAL_APP"
],
"type": "string"
},
"PluginInstallResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -14155,6 +14266,40 @@
"title": "GitPluginSource",
"type": "object"
},
{
"properties": {
"package": {
"type": "string"
},
"registry": {
"description": "Optional HTTPS registry URL. Authentication stays in the user's npm config.",
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"npm"
],
"title": "NpmPluginSourceType",
"type": "string"
},
"version": {
"description": "Optional npm version or version range.",
"type": [
"string",
"null"
]
}
},
"required": [
"package",
"type"
],
"title": "NpmPluginSource",
"type": "object"
},
{
"description": "The plugin is available in the remote catalog. Download metadata is kept server-side and is not exposed through the app-server API.",
"properties": {
@@ -14197,6 +14342,16 @@
"installPolicy": {
"$ref": "#/definitions/v2/PluginInstallPolicy"
},
"installPolicySource": {
"anyOf": [
{
"$ref": "#/definitions/v2/PluginInstallPolicySource"
},
{
"type": "null"
}
]
},
"installed": {
"type": "boolean"
},
@@ -14248,6 +14403,14 @@
},
"source": {
"$ref": "#/definitions/v2/PluginSource"
},
"version": {
"default": null,
"description": "Version advertised by the remote marketplace backend when available.",
"type": [
"string",
"null"
]
}
},
"required": [
@@ -14423,11 +14586,78 @@
],
"type": "string"
},
"RateLimitResetCredit": {
"properties": {
"description": {
"description": "Backend-provided display description for this credit, or `null` when unavailable.",
"type": [
"string",
"null"
]
},
"expiresAt": {
"description": "Unix timestamp in seconds when the credit expires, or `null` if it does not expire.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"grantedAt": {
"description": "Unix timestamp in seconds when the credit was granted.",
"format": "int64",
"type": "integer"
},
"id": {
"description": "Opaque backend identifier for this reset credit.",
"type": "string"
},
"resetType": {
"$ref": "#/definitions/v2/RateLimitResetType"
},
"status": {
"$ref": "#/definitions/v2/RateLimitResetCreditStatus"
},
"title": {
"description": "Backend-provided display title for this credit, or `null` when unavailable.",
"type": [
"string",
"null"
]
}
},
"required": [
"grantedAt",
"id",
"resetType",
"status"
],
"type": "object"
},
"RateLimitResetCreditStatus": {
"enum": [
"available",
"redeeming",
"redeemed",
"unknown"
],
"type": "string"
},
"RateLimitResetCreditsSummary": {
"properties": {
"availableCount": {
"format": "int64",
"type": "integer"
},
"credits": {
"description": "Detail rows for available reset credits, when the backend provides them.\n\n`null` means only `availableCount` is known, while an empty array means details were fetched and no available credits were returned. The backend may cap this list, so its length can be less than `availableCount`.",
"items": {
"$ref": "#/definitions/v2/RateLimitResetCredit"
},
"type": [
"array",
"null"
]
}
},
"required": [
@@ -14435,6 +14665,13 @@
],
"type": "object"
},
"RateLimitResetType": {
"enum": [
"codexRateLimits",
"unknown"
],
"type": "string"
},
"RateLimitSnapshot": {
"properties": {
"credits": {
@@ -15424,6 +15661,12 @@
"name": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"status": {
"type": [
"string",
@@ -17193,6 +17436,13 @@
"ephemeral": {
"type": "boolean"
},
"lastTurnId": {
"description": "Optional last turn id to fork through, inclusive.\n\nWhen specified, turns after `last_turn_id` are omitted from the fork. The referenced turn cannot be in progress.",
"type": [
"string",
"null"
]
},
"model": {
"description": "Configuration overrides for the forked thread, if any.",
"type": [
@@ -17512,6 +17762,13 @@
"title": "ThreadGoalUpdatedNotification",
"type": "object"
},
"ThreadHistoryMode": {
"enum": [
"legacy",
"paginated"
],
"type": "string"
},
"ThreadId": {
"type": "string"
},
@@ -854,6 +854,7 @@
"enum": [
"auto",
"prompt",
"writes",
"approve"
],
"type": "string"
@@ -1076,6 +1077,13 @@
],
"type": "string"
},
{
"description": "Backend auth supplied as request headers.",
"enum": [
"headers"
],
"type": "string"
},
{
"description": "Programmatic Codex auth backed by a registered Agent Identity.",
"enum": [
@@ -4654,6 +4662,16 @@
"object",
"null"
]
},
"models": {
"anyOf": [
{
"$ref": "#/definitions/ModelsRequirements"
},
{
"type": "null"
}
]
}
},
"type": "object"
@@ -4918,6 +4936,13 @@
"ConsumeAccountRateLimitResetCreditParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"creditId": {
"description": "Opaque reset-credit identifier to redeem. When omitted, the backend selects the next available credit.",
"type": [
"string",
"null"
]
},
"idempotencyKey": {
"description": "Identifies one logical reset attempt. A UUID is recommended; reuse the same value when retrying that attempt.",
"type": "string"
@@ -7890,6 +7915,17 @@
},
{
"properties": {
"appBrand": {
"anyOf": [
{
"$ref": "#/definitions/LoginAppBrand"
},
{
"type": "null"
}
],
"default": null
},
"codexStreamlinedLogin": {
"type": "boolean"
},
@@ -7899,6 +7935,9 @@
],
"title": "Chatgptv2::LoginAccountParamsType",
"type": "string"
},
"useHostedLoginSuccessPage": {
"type": "boolean"
}
},
"required": [
@@ -8053,6 +8092,13 @@
],
"title": "LoginAccountResponse"
},
"LoginAppBrand": {
"enum": [
"codex",
"chatgpt"
],
"type": "string"
},
"LogoutAccountResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "LogoutAccountResponse",
@@ -9238,14 +9284,45 @@
"title": "ModelVerificationNotification",
"type": "object"
},
"ModelsRequirements": {
"properties": {
"newThread": {
"anyOf": [
{
"$ref": "#/definitions/NewThreadModelDefaults"
},
{
"type": "null"
}
]
}
},
"type": "object"
},
"MultiAgentMode": {
"description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.",
"enum": [
"none",
"explicitRequestOnly",
"proactive"
],
"type": "string"
"description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.",
"oneOf": [
{
"enum": [
"explicitRequestOnly",
"proactive"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"custom": {
"type": "string"
}
},
"required": [
"custom"
],
"title": "CustomMultiAgentMode",
"type": "object"
}
]
},
"NetworkAccess": {
"enum": [
@@ -9385,6 +9462,33 @@
],
"type": "string"
},
"NewThreadModelDefaults": {
"properties": {
"model": {
"type": [
"string",
"null"
]
},
"modelReasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
]
},
"serviceTier": {
"type": [
"string",
"null"
]
}
},
"type": "object"
},
"NonSteerableTurnKind": {
"enum": [
"review",
@@ -9752,6 +9856,13 @@
],
"type": "string"
},
"PluginInstallPolicySource": {
"enum": [
"WORKSPACE_SETTING",
"IMPLICIT_CANONICAL_APP"
],
"type": "string"
},
"PluginInstallResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -10559,6 +10670,40 @@
"title": "GitPluginSource",
"type": "object"
},
{
"properties": {
"package": {
"type": "string"
},
"registry": {
"description": "Optional HTTPS registry URL. Authentication stays in the user's npm config.",
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"npm"
],
"title": "NpmPluginSourceType",
"type": "string"
},
"version": {
"description": "Optional npm version or version range.",
"type": [
"string",
"null"
]
}
},
"required": [
"package",
"type"
],
"title": "NpmPluginSource",
"type": "object"
},
{
"description": "The plugin is available in the remote catalog. Download metadata is kept server-side and is not exposed through the app-server API.",
"properties": {
@@ -10601,6 +10746,16 @@
"installPolicy": {
"$ref": "#/definitions/PluginInstallPolicy"
},
"installPolicySource": {
"anyOf": [
{
"$ref": "#/definitions/PluginInstallPolicySource"
},
{
"type": "null"
}
]
},
"installed": {
"type": "boolean"
},
@@ -10652,6 +10807,14 @@
},
"source": {
"$ref": "#/definitions/PluginSource"
},
"version": {
"default": null,
"description": "Version advertised by the remote marketplace backend when available.",
"type": [
"string",
"null"
]
}
},
"required": [
@@ -10827,11 +10990,78 @@
],
"type": "string"
},
"RateLimitResetCredit": {
"properties": {
"description": {
"description": "Backend-provided display description for this credit, or `null` when unavailable.",
"type": [
"string",
"null"
]
},
"expiresAt": {
"description": "Unix timestamp in seconds when the credit expires, or `null` if it does not expire.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"grantedAt": {
"description": "Unix timestamp in seconds when the credit was granted.",
"format": "int64",
"type": "integer"
},
"id": {
"description": "Opaque backend identifier for this reset credit.",
"type": "string"
},
"resetType": {
"$ref": "#/definitions/RateLimitResetType"
},
"status": {
"$ref": "#/definitions/RateLimitResetCreditStatus"
},
"title": {
"description": "Backend-provided display title for this credit, or `null` when unavailable.",
"type": [
"string",
"null"
]
}
},
"required": [
"grantedAt",
"id",
"resetType",
"status"
],
"type": "object"
},
"RateLimitResetCreditStatus": {
"enum": [
"available",
"redeeming",
"redeemed",
"unknown"
],
"type": "string"
},
"RateLimitResetCreditsSummary": {
"properties": {
"availableCount": {
"format": "int64",
"type": "integer"
},
"credits": {
"description": "Detail rows for available reset credits, when the backend provides them.\n\n`null` means only `availableCount` is known, while an empty array means details were fetched and no available credits were returned. The backend may cap this list, so its length can be less than `availableCount`.",
"items": {
"$ref": "#/definitions/RateLimitResetCredit"
},
"type": [
"array",
"null"
]
}
},
"required": [
@@ -10839,6 +11069,13 @@
],
"type": "object"
},
"RateLimitResetType": {
"enum": [
"codexRateLimits",
"unknown"
],
"type": "string"
},
"RateLimitSnapshot": {
"properties": {
"credits": {
@@ -11828,6 +12065,12 @@
"name": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"status": {
"type": [
"string",
@@ -14972,6 +15215,13 @@
"ephemeral": {
"type": "boolean"
},
"lastTurnId": {
"description": "Optional last turn id to fork through, inclusive.\n\nWhen specified, turns after `last_turn_id` are omitted from the fork. The referenced turn cannot be in progress.",
"type": [
"string",
"null"
]
},
"model": {
"description": "Configuration overrides for the forked thread, if any.",
"type": [
@@ -15291,6 +15541,13 @@
"title": "ThreadGoalUpdatedNotification",
"type": "object"
},
"ThreadHistoryMode": {
"enum": [
"legacy",
"paginated"
],
"type": "string"
},
"ThreadId": {
"type": "string"
},
@@ -25,6 +25,13 @@
],
"type": "string"
},
{
"description": "Backend auth supplied as request headers.",
"enum": [
"headers"
],
"type": "string"
},
{
"description": "Programmatic Codex auth backed by a registered Agent Identity.",
"enum": [
@@ -78,6 +78,7 @@
"enum": [
"auto",
"prompt",
"writes",
"approve"
],
"type": "string"
@@ -169,6 +169,16 @@
"object",
"null"
]
},
"models": {
"anyOf": [
{
"$ref": "#/definitions/ModelsRequirements"
},
{
"type": "null"
}
]
}
},
"type": "object"
@@ -362,6 +372,21 @@
],
"type": "object"
},
"ModelsRequirements": {
"properties": {
"newThread": {
"anyOf": [
{
"$ref": "#/definitions/NewThreadModelDefaults"
},
{
"type": "null"
}
]
}
},
"type": "object"
},
"NetworkDomainPermission": {
"enum": [
"allow",
@@ -484,6 +509,38 @@
],
"type": "string"
},
"NewThreadModelDefaults": {
"properties": {
"model": {
"type": [
"string",
"null"
]
},
"modelReasoningEffort": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
]
},
"serviceTier": {
"type": [
"string",
"null"
]
}
},
"type": "object"
},
"ReasoningEffort": {
"description": "A non-empty reasoning effort value advertised by the model.",
"minLength": 1,
"type": "string"
},
"ResidencyRequirement": {
"enum": [
"us"
@@ -1,6 +1,13 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"creditId": {
"description": "Opaque reset-credit identifier to redeem. When omitted, the backend selects the next available credit.",
"type": [
"string",
"null"
]
},
"idempotencyKey": {
"description": "Identifies one logical reset attempt. A UUID is recommended; reuse the same value when retrying that attempt.",
"type": "string"
@@ -49,11 +49,78 @@
],
"type": "string"
},
"RateLimitResetCredit": {
"properties": {
"description": {
"description": "Backend-provided display description for this credit, or `null` when unavailable.",
"type": [
"string",
"null"
]
},
"expiresAt": {
"description": "Unix timestamp in seconds when the credit expires, or `null` if it does not expire.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"grantedAt": {
"description": "Unix timestamp in seconds when the credit was granted.",
"format": "int64",
"type": "integer"
},
"id": {
"description": "Opaque backend identifier for this reset credit.",
"type": "string"
},
"resetType": {
"$ref": "#/definitions/RateLimitResetType"
},
"status": {
"$ref": "#/definitions/RateLimitResetCreditStatus"
},
"title": {
"description": "Backend-provided display title for this credit, or `null` when unavailable.",
"type": [
"string",
"null"
]
}
},
"required": [
"grantedAt",
"id",
"resetType",
"status"
],
"type": "object"
},
"RateLimitResetCreditStatus": {
"enum": [
"available",
"redeeming",
"redeemed",
"unknown"
],
"type": "string"
},
"RateLimitResetCreditsSummary": {
"properties": {
"availableCount": {
"format": "int64",
"type": "integer"
},
"credits": {
"description": "Detail rows for available reset credits, when the backend provides them.\n\n`null` means only `availableCount` is known, while an empty array means details were fetched and no available credits were returned. The backend may cap this list, so its length can be less than `availableCount`.",
"items": {
"$ref": "#/definitions/RateLimitResetCredit"
},
"type": [
"array",
"null"
]
}
},
"required": [
@@ -61,6 +128,13 @@
],
"type": "object"
},
"RateLimitResetType": {
"enum": [
"codexRateLimits",
"unknown"
],
"type": "string"
},
"RateLimitSnapshot": {
"properties": {
"credits": {
@@ -1,5 +1,14 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"LoginAppBrand": {
"enum": [
"codex",
"chatgpt"
],
"type": "string"
}
},
"oneOf": [
{
"properties": {
@@ -23,6 +32,17 @@
},
{
"properties": {
"appBrand": {
"anyOf": [
{
"$ref": "#/definitions/LoginAppBrand"
},
{
"type": "null"
}
],
"default": null
},
"codexStreamlinedLogin": {
"type": "boolean"
},
@@ -32,6 +52,9 @@
],
"title": "Chatgptv2::LoginAccountParamsType",
"type": "string"
},
"useHostedLoginSuccessPage": {
"type": "boolean"
}
},
"required": [
@@ -63,6 +63,13 @@
],
"type": "string"
},
"PluginInstallPolicySource": {
"enum": [
"WORKSPACE_SETTING",
"IMPLICIT_CANONICAL_APP"
],
"type": "string"
},
"PluginInterface": {
"properties": {
"brandColor": {
@@ -413,6 +420,40 @@
"title": "GitPluginSource",
"type": "object"
},
{
"properties": {
"package": {
"type": "string"
},
"registry": {
"description": "Optional HTTPS registry URL. Authentication stays in the user's npm config.",
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"npm"
],
"title": "NpmPluginSourceType",
"type": "string"
},
"version": {
"description": "Optional npm version or version range.",
"type": [
"string",
"null"
]
}
},
"required": [
"package",
"type"
],
"title": "NpmPluginSource",
"type": "object"
},
{
"description": "The plugin is available in the remote catalog. Download metadata is kept server-side and is not exposed through the app-server API.",
"properties": {
@@ -455,6 +496,16 @@
"installPolicy": {
"$ref": "#/definitions/PluginInstallPolicy"
},
"installPolicySource": {
"anyOf": [
{
"$ref": "#/definitions/PluginInstallPolicySource"
},
{
"type": "null"
}
]
},
"installed": {
"type": "boolean"
},
@@ -506,6 +557,14 @@
},
"source": {
"$ref": "#/definitions/PluginSource"
},
"version": {
"default": null,
"description": "Version advertised by the remote marketplace backend when available.",
"type": [
"string",
"null"
]
}
},
"required": [
@@ -63,6 +63,13 @@
],
"type": "string"
},
"PluginInstallPolicySource": {
"enum": [
"WORKSPACE_SETTING",
"IMPLICIT_CANONICAL_APP"
],
"type": "string"
},
"PluginInterface": {
"properties": {
"brandColor": {
@@ -413,6 +420,40 @@
"title": "GitPluginSource",
"type": "object"
},
{
"properties": {
"package": {
"type": "string"
},
"registry": {
"description": "Optional HTTPS registry URL. Authentication stays in the user's npm config.",
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"npm"
],
"title": "NpmPluginSourceType",
"type": "string"
},
"version": {
"description": "Optional npm version or version range.",
"type": [
"string",
"null"
]
}
},
"required": [
"package",
"type"
],
"title": "NpmPluginSource",
"type": "object"
},
{
"description": "The plugin is available in the remote catalog. Download metadata is kept server-side and is not exposed through the app-server API.",
"properties": {
@@ -455,6 +496,16 @@
"installPolicy": {
"$ref": "#/definitions/PluginInstallPolicy"
},
"installPolicySource": {
"anyOf": [
{
"$ref": "#/definitions/PluginInstallPolicySource"
},
{
"type": "null"
}
]
},
"installed": {
"type": "boolean"
},
@@ -506,6 +557,14 @@
},
"source": {
"$ref": "#/definitions/PluginSource"
},
"version": {
"default": null,
"description": "Version advertised by the remote marketplace backend when available.",
"type": [
"string",
"null"
]
}
},
"required": [
@@ -242,6 +242,13 @@
],
"type": "string"
},
"PluginInstallPolicySource": {
"enum": [
"WORKSPACE_SETTING",
"IMPLICIT_CANONICAL_APP"
],
"type": "string"
},
"PluginInterface": {
"properties": {
"brandColor": {
@@ -553,6 +560,40 @@
"title": "GitPluginSource",
"type": "object"
},
{
"properties": {
"package": {
"type": "string"
},
"registry": {
"description": "Optional HTTPS registry URL. Authentication stays in the user's npm config.",
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"npm"
],
"title": "NpmPluginSourceType",
"type": "string"
},
"version": {
"description": "Optional npm version or version range.",
"type": [
"string",
"null"
]
}
},
"required": [
"package",
"type"
],
"title": "NpmPluginSource",
"type": "object"
},
{
"description": "The plugin is available in the remote catalog. Download metadata is kept server-side and is not exposed through the app-server API.",
"properties": {
@@ -595,6 +636,16 @@
"installPolicy": {
"$ref": "#/definitions/PluginInstallPolicy"
},
"installPolicySource": {
"anyOf": [
{
"$ref": "#/definitions/PluginInstallPolicySource"
},
{
"type": "null"
}
]
},
"installed": {
"type": "boolean"
},
@@ -646,6 +697,14 @@
},
"source": {
"$ref": "#/definitions/PluginSource"
},
"version": {
"default": null,
"description": "Version advertised by the remote marketplace backend when available.",
"type": [
"string",
"null"
]
}
},
"required": [
@@ -37,6 +37,13 @@
],
"type": "string"
},
"PluginInstallPolicySource": {
"enum": [
"WORKSPACE_SETTING",
"IMPLICIT_CANONICAL_APP"
],
"type": "string"
},
"PluginInterface": {
"properties": {
"brandColor": {
@@ -369,6 +376,40 @@
"title": "GitPluginSource",
"type": "object"
},
{
"properties": {
"package": {
"type": "string"
},
"registry": {
"description": "Optional HTTPS registry URL. Authentication stays in the user's npm config.",
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"npm"
],
"title": "NpmPluginSourceType",
"type": "string"
},
"version": {
"description": "Optional npm version or version range.",
"type": [
"string",
"null"
]
}
},
"required": [
"package",
"type"
],
"title": "NpmPluginSource",
"type": "object"
},
{
"description": "The plugin is available in the remote catalog. Download metadata is kept server-side and is not exposed through the app-server API.",
"properties": {
@@ -411,6 +452,16 @@
"installPolicy": {
"$ref": "#/definitions/PluginInstallPolicy"
},
"installPolicySource": {
"anyOf": [
{
"$ref": "#/definitions/PluginInstallPolicySource"
},
{
"type": "null"
}
]
},
"installed": {
"type": "boolean"
},
@@ -462,6 +513,14 @@
},
"source": {
"$ref": "#/definitions/PluginSource"
},
"version": {
"default": null,
"description": "Version advertised by the remote marketplace backend when available.",
"type": [
"string",
"null"
]
}
},
"required": [
@@ -751,6 +751,12 @@
"name": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"status": {
"type": [
"string",
@@ -126,6 +126,13 @@
"ephemeral": {
"type": "boolean"
},
"lastTurnId": {
"description": "Optional last turn id to fork through, inclusive.\n\nWhen specified, turns after `last_turn_id` are omitted from the fork. The referenced turn cannot be in progress.",
"type": [
"string",
"null"
]
},
"model": {
"description": "Configuration overrides for the forked thread, if any.",
"type": [
@@ -681,13 +681,29 @@
]
},
"MultiAgentMode": {
"description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.",
"enum": [
"none",
"explicitRequestOnly",
"proactive"
],
"type": "string"
"description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.",
"oneOf": [
{
"enum": [
"explicitRequestOnly",
"proactive"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"custom": {
"type": "string"
}
},
"required": [
"custom"
],
"title": "CustomMultiAgentMode",
"type": "object"
}
]
},
"NetworkAccess": {
"enum": [
@@ -1188,6 +1204,13 @@
"description": "Extra app-server data for a thread.",
"type": "object"
},
"ThreadHistoryMode": {
"enum": [
"legacy",
"paginated"
],
"type": "string"
},
"ThreadId": {
"type": "string"
},
@@ -995,6 +995,13 @@
"description": "Extra app-server data for a thread.",
"type": "object"
},
"ThreadHistoryMode": {
"enum": [
"legacy",
"paginated"
],
"type": "string"
},
"ThreadId": {
"type": "string"
},
@@ -995,6 +995,13 @@
"description": "Extra app-server data for a thread.",
"type": "object"
},
"ThreadHistoryMode": {
"enum": [
"legacy",
"paginated"
],
"type": "string"
},
"ThreadId": {
"type": "string"
},
@@ -995,6 +995,13 @@
"description": "Extra app-server data for a thread.",
"type": "object"
},
"ThreadHistoryMode": {
"enum": [
"legacy",
"paginated"
],
"type": "string"
},
"ThreadId": {
"type": "string"
},
@@ -821,6 +821,12 @@
"name": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"status": {
"type": [
"string",
@@ -681,13 +681,29 @@
]
},
"MultiAgentMode": {
"description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.",
"enum": [
"none",
"explicitRequestOnly",
"proactive"
],
"type": "string"
"description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.",
"oneOf": [
{
"enum": [
"explicitRequestOnly",
"proactive"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"custom": {
"type": "string"
}
},
"required": [
"custom"
],
"title": "CustomMultiAgentMode",
"type": "object"
}
]
},
"NetworkAccess": {
"enum": [
@@ -1188,6 +1204,13 @@
"description": "Extra app-server data for a thread.",
"type": "object"
},
"ThreadHistoryMode": {
"enum": [
"legacy",
"paginated"
],
"type": "string"
},
"ThreadId": {
"type": "string"
},
@@ -995,6 +995,13 @@
"description": "Extra app-server data for a thread.",
"type": "object"
},
"ThreadHistoryMode": {
"enum": [
"legacy",
"paginated"
],
"type": "string"
},
"ThreadId": {
"type": "string"
},
@@ -108,13 +108,29 @@
"type": "string"
},
"MultiAgentMode": {
"description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.",
"enum": [
"none",
"explicitRequestOnly",
"proactive"
],
"type": "string"
"description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.",
"oneOf": [
{
"enum": [
"explicitRequestOnly",
"proactive"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"custom": {
"type": "string"
}
},
"required": [
"custom"
],
"title": "CustomMultiAgentMode",
"type": "object"
}
]
},
"NetworkAccess": {
"enum": [
@@ -195,13 +195,29 @@
"type": "string"
},
"MultiAgentMode": {
"description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.",
"enum": [
"none",
"explicitRequestOnly",
"proactive"
],
"type": "string"
"description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.",
"oneOf": [
{
"enum": [
"explicitRequestOnly",
"proactive"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"custom": {
"type": "string"
}
},
"required": [
"custom"
],
"title": "CustomMultiAgentMode",
"type": "object"
}
]
},
"Personality": {
"enum": [
@@ -241,6 +257,13 @@
],
"type": "object"
},
"ThreadHistoryMode": {
"enum": [
"legacy",
"paginated"
],
"type": "string"
},
"ThreadSource": {
"type": "string"
},
@@ -681,13 +681,29 @@
]
},
"MultiAgentMode": {
"description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.",
"enum": [
"none",
"explicitRequestOnly",
"proactive"
],
"type": "string"
"description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.",
"oneOf": [
{
"enum": [
"explicitRequestOnly",
"proactive"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"custom": {
"type": "string"
}
},
"required": [
"custom"
],
"title": "CustomMultiAgentMode",
"type": "object"
}
]
},
"NetworkAccess": {
"enum": [
@@ -1188,6 +1204,13 @@
"description": "Extra app-server data for a thread.",
"type": "object"
},
"ThreadHistoryMode": {
"enum": [
"legacy",
"paginated"
],
"type": "string"
},
"ThreadId": {
"type": "string"
},
@@ -995,6 +995,13 @@
"description": "Extra app-server data for a thread.",
"type": "object"
},
"ThreadHistoryMode": {
"enum": [
"legacy",
"paginated"
],
"type": "string"
},
"ThreadId": {
"type": "string"
},
@@ -995,6 +995,13 @@
"description": "Extra app-server data for a thread.",
"type": "object"
},
"ThreadHistoryMode": {
"enum": [
"legacy",
"paginated"
],
"type": "string"
},
"ThreadId": {
"type": "string"
},
@@ -141,13 +141,29 @@
"type": "string"
},
"MultiAgentMode": {
"description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.",
"enum": [
"none",
"explicitRequestOnly",
"proactive"
],
"type": "string"
"description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.",
"oneOf": [
{
"enum": [
"explicitRequestOnly",
"proactive"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"custom": {
"type": "string"
}
},
"required": [
"custom"
],
"title": "CustomMultiAgentMode",
"type": "object"
}
]
},
"NetworkAccess": {
"enum": [
+1 -1
View File
@@ -5,4 +5,4 @@
/**
* Authentication mode for OpenAI-backed providers.
*/
export type AuthMode = "apikey" | "chatgpt" | "chatgptAuthTokens" | "agentIdentity" | "personalAccessToken" | "bedrockApiKey";
export type AuthMode = "apikey" | "chatgpt" | "chatgptAuthTokens" | "headers" | "agentIdentity" | "personalAccessToken" | "bedrockApiKey";
@@ -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 { AbsolutePathBuf } from "./AbsolutePathBuf";
export type ImageGenerationItem = { id: string, status: string, revisedPrompt: string | null, result: string, savedPath?: AbsolutePathBuf, };
@@ -3,9 +3,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Controls whether the model receives multi-agent delegation instructions and,
* when it does, whether it should only spawn sub-agents after an explicit user
* request or may delegate proactively when doing so would help. `none` leaves
* the multi-agent tools available without injecting delegation instructions.
* Controls the effective multi-agent delegation instructions for a turn. `custom` means the
* configured mode hint defines the policy instead of a built-in policy.
*/
export type MultiAgentMode = "none" | "explicitRequestOnly" | "proactive";
export type MultiAgentMode = { "custom": string } | "explicitRequestOnly" | "proactive";
@@ -20,4 +20,4 @@ id?: string,
/**
* Set when using the Responses API.
*/
call_id: string | null, status: LocalShellStatus, action: LocalShellAction, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "function_call", id?: string, name: string, namespace?: string, arguments: string, call_id: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "tool_search_call", id?: string, call_id: string | null, status?: string, execution: string, arguments: unknown, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "function_call_output", id?: string, call_id: string, output: FunctionCallOutputBody, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "custom_tool_call", id?: string, status?: string, call_id: string, name: string, input: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "custom_tool_call_output", id?: string, call_id: string, name?: string, output: FunctionCallOutputBody, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "tool_search_output", id?: string, call_id: string | null, status: string, execution: string, tools: unknown[], internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "web_search_call", id?: string, status?: string, action?: WebSearchAction, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "image_generation_call", id?: string, status: string, revised_prompt?: string, result: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "compaction", id?: string, encrypted_content: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "compaction_trigger", } | { "type": "context_compaction", id?: string, encrypted_content?: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "other" };
call_id: string | null, status: LocalShellStatus, action: LocalShellAction, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "function_call", id?: string, name: string, namespace?: string, arguments: string, call_id: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "tool_search_call", id?: string, call_id: string | null, status?: string, execution: string, arguments: unknown, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "function_call_output", id?: string, call_id: string, output: FunctionCallOutputBody, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "custom_tool_call", id?: string, status?: string, call_id: string, name: string, namespace?: string, input: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "custom_tool_call_output", id?: string, call_id: string, name?: string, output: FunctionCallOutputBody, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "tool_search_output", id?: string, call_id: string | null, status: string, execution: string, tools: unknown[], internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "web_search_call", id?: string, status?: string, action?: WebSearchAction, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "image_generation_call", id?: string, status: string, revised_prompt?: string, result: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "compaction", id?: string, encrypted_content: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "compaction_trigger", } | { "type": "context_compaction", id?: string, encrypted_content?: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "other" };
+1
View File
@@ -37,6 +37,7 @@ export type { GitDiffToRemoteParams } from "./GitDiffToRemoteParams";
export type { GitDiffToRemoteResponse } from "./GitDiffToRemoteResponse";
export type { GitSha } from "./GitSha";
export type { ImageDetail } from "./ImageDetail";
export type { ImageGenerationItem } from "./ImageGenerationItem";
export type { InitializeCapabilities } from "./InitializeCapabilities";
export type { InitializeParams } from "./InitializeParams";
export type { InitializeResponse } from "./InitializeResponse";
@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type AppToolApproval = "auto" | "prompt" | "approve";
export type AppToolApproval = "auto" | "prompt" | "writes" | "approve";
@@ -4,8 +4,9 @@
import type { WebSearchMode } from "../WebSearchMode";
import type { AskForApproval } from "./AskForApproval";
import type { ComputerUseRequirements } from "./ComputerUseRequirements";
import type { ModelsRequirements } from "./ModelsRequirements";
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, allowedPermissionProfiles: { [key in string]?: boolean } | null, defaultPermissions: string | null, allowedWebSearchModes: Array<WebSearchMode> | null, allowManagedHooksOnly: boolean | null, allowAppshots: boolean | null, allowRemoteControl: 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, allowRemoteControl: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null, models: ModelsRequirements | null};
@@ -7,4 +7,9 @@ export type ConsumeAccountRateLimitResetCreditParams = {
* Identifies one logical reset attempt. A UUID is recommended; reuse the same value when
* retrying that attempt.
*/
idempotencyKey: string, };
idempotencyKey: string,
/**
* Opaque reset-credit identifier to redeem. When omitted, the backend selects the next
* available credit.
*/
creditId?: string | null, };
@@ -1,8 +1,9 @@
// 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 { LoginAppBrand } from "./LoginAppBrand";
export type LoginAccountParams = { "type": "apiKey", apiKey: string, } | { "type": "chatgpt", codexStreamlinedLogin?: boolean, } | { "type": "chatgptDeviceCode" } | { "type": "chatgptAuthTokens",
export type LoginAccountParams = { "type": "apiKey", apiKey: string, } | { "type": "chatgpt", codexStreamlinedLogin?: boolean, useHostedLoginSuccessPage?: boolean, appBrand?: LoginAppBrand | null, } | { "type": "chatgptDeviceCode" } | { "type": "chatgptAuthTokens",
/**
* Access token (JWT) supplied by the client.
* This token is used for backend API requests and email extraction.
@@ -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 LoginAppBrand = "codex" | "chatgpt";
@@ -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 { NewThreadModelDefaults } from "./NewThreadModelDefaults";
export type ModelsRequirements = { newThread: NewThreadModelDefaults | 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 { ReasoningEffort } from "../ReasoningEffort";
export type NewThreadModelDefaults = { model: string | null, modelReasoningEffort: ReasoningEffort | null, serviceTier: string | null, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type PluginInstallPolicySource = "WORKSPACE_SETTING" | "IMPLICIT_CANONICAL_APP";
@@ -3,4 +3,12 @@
// 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";
export type PluginSource = { "type": "local", path: AbsolutePathBuf, } | { "type": "git", url: string, path: string | null, refName: string | null, sha: string | null, } | { "type": "remote" };
export type PluginSource = { "type": "local", path: AbsolutePathBuf, } | { "type": "git", url: string, path: string | null, refName: string | null, sha: string | null, } | { "type": "npm", package: string,
/**
* Optional npm version or version range.
*/
version: string | null,
/**
* Optional HTTPS registry URL. Authentication stays in the user's npm config.
*/
registry: string | null, } | { "type": "remote" };
@@ -4,6 +4,7 @@
import type { PluginAuthPolicy } from "./PluginAuthPolicy";
import type { PluginAvailability } from "./PluginAvailability";
import type { PluginInstallPolicy } from "./PluginInstallPolicy";
import type { PluginInstallPolicySource } from "./PluginInstallPolicySource";
import type { PluginInterface } from "./PluginInterface";
import type { PluginShareContext } from "./PluginShareContext";
import type { PluginSource } from "./PluginSource";
@@ -13,6 +14,10 @@ export type PluginSummary = { id: string,
* Backend remote plugin identifier when available.
*/
remotePluginId: string | null,
/**
* Version advertised by the remote marketplace backend when available.
*/
version: string | null,
/**
* Version of the locally materialized plugin package when available.
*/
@@ -20,7 +25,7 @@ localVersion: string | null, name: string,
/**
* Remote sharing context associated with this plugin when available.
*/
shareContext: PluginShareContext | null, source: PluginSource, installed: boolean, enabled: boolean, installPolicy: PluginInstallPolicy, authPolicy: PluginAuthPolicy,
shareContext: PluginShareContext | null, source: PluginSource, installed: boolean, enabled: boolean, installPolicy: PluginInstallPolicy, installPolicySource: PluginInstallPolicySource | null, authPolicy: PluginAuthPolicy,
/**
* Availability state for installing and using the plugin.
*/
@@ -0,0 +1,27 @@
// 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 { RateLimitResetCreditStatus } from "./RateLimitResetCreditStatus";
import type { RateLimitResetType } from "./RateLimitResetType";
export type RateLimitResetCredit = {
/**
* Opaque backend identifier for this reset credit.
*/
id: string, resetType: RateLimitResetType, status: RateLimitResetCreditStatus,
/**
* Unix timestamp in seconds when the credit was granted.
*/
grantedAt: number,
/**
* Unix timestamp in seconds when the credit expires, or `null` if it does not expire.
*/
expiresAt: number | null,
/**
* Backend-provided display title for this credit, or `null` when unavailable.
*/
title: string | null,
/**
* Backend-provided display description for this credit, or `null` when unavailable.
*/
description: string | null, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type RateLimitResetCreditStatus = "available" | "redeeming" | "redeemed" | "unknown";
@@ -1,5 +1,14 @@
// 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 { RateLimitResetCredit } from "./RateLimitResetCredit";
export type RateLimitResetCreditsSummary = { availableCount: bigint, };
export type RateLimitResetCreditsSummary = { availableCount: bigint,
/**
* Detail rows for available reset credits, when the backend provides them.
*
* `null` means only `availableCount` is known, while an empty array means details were fetched
* and no available credits were returned. The backend may cap this list, so its length can be
* less than `availableCount`.
*/
credits: Array<RateLimitResetCredit> | null, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type RateLimitResetType = "codexRateLimits" | "unknown";
@@ -18,6 +18,12 @@ import type { ThreadSource } from "./ThreadSource";
* Prefer using thread_id whenever possible.
*/
export type ThreadForkParams = {threadId: string, /**
* Optional last turn id to fork through, inclusive.
*
* When specified, turns after `last_turn_id` are omitted from the fork.
* The referenced turn cannot be in progress.
*/
lastTurnId?: string | null, /**
* Configuration overrides for the forked thread, if any.
*/
model?: string | null, modelProvider?: string | null, serviceTier?: string | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /**
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ThreadHistoryMode = "legacy" | "paginated";
@@ -1,7 +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 { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { ImageGenerationItem } from "../ImageGenerationItem";
import type { LegacyAppPathString } from "../LegacyAppPathString";
import type { MessagePhase } from "../MessagePhase";
import type { ReasoningEffort } from "../ReasoningEffort";
@@ -105,4 +105,4 @@ reasoningEffort: ReasoningEffort | null,
/**
* Last known status of the target agents, when available.
*/
agentsStates: { [key in string]?: CollabAgentState }, } | { "type": "subAgentActivity", id: string, kind: SubAgentActivityKind, agentThreadId: string, agentPath: string, } | { "type": "webSearch", id: string, query: string, action: WebSearchAction | null, } | { "type": "imageView", id: string, path: LegacyAppPathString, } | { "type": "sleep", id: string, durationMs: number, } | { "type": "imageGeneration", id: string, status: string, revisedPrompt: string | null, result: string, savedPath?: AbsolutePathBuf, } | { "type": "enteredReviewMode", id: string, review: string, } | { "type": "exitedReviewMode", id: string, review: string, } | { "type": "contextCompaction", id: string, };
agentsStates: { [key in string]?: CollabAgentState }, } | { "type": "subAgentActivity", id: string, kind: SubAgentActivityKind, agentThreadId: string, agentPath: string, } | { "type": "webSearch", id: string, query: string, action: WebSearchAction | null, } | { "type": "imageView", id: string, path: LegacyAppPathString, } | { "type": "sleep", id: string, durationMs: number, } | { "type": "imageGeneration" } & ImageGenerationItem | { "type": "enteredReviewMode", id: string, review: string, } | { "type": "exitedReviewMode", id: string, review: string, } | { "type": "contextCompaction", id: string, };
@@ -194,6 +194,7 @@ export type { ListMcpServerStatusParams } from "./ListMcpServerStatusParams";
export type { ListMcpServerStatusResponse } from "./ListMcpServerStatusResponse";
export type { LoginAccountParams } from "./LoginAccountParams";
export type { LoginAccountResponse } from "./LoginAccountResponse";
export type { LoginAppBrand } from "./LoginAppBrand";
export type { LogoutAccountResponse } from "./LogoutAccountResponse";
export type { ManagedHooksRequirements } from "./ManagedHooksRequirements";
export type { MarketplaceAddParams } from "./MarketplaceAddParams";
@@ -267,6 +268,7 @@ export type { ModelServiceTier } from "./ModelServiceTier";
export type { ModelUpgradeInfo } from "./ModelUpgradeInfo";
export type { ModelVerification } from "./ModelVerification";
export type { ModelVerificationNotification } from "./ModelVerificationNotification";
export type { ModelsRequirements } from "./ModelsRequirements";
export type { NetworkAccess } from "./NetworkAccess";
export type { NetworkApprovalContext } from "./NetworkApprovalContext";
export type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol";
@@ -275,6 +277,7 @@ export type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment";
export type { NetworkPolicyRuleAction } from "./NetworkPolicyRuleAction";
export type { NetworkRequirements } from "./NetworkRequirements";
export type { NetworkUnixSocketPermission } from "./NetworkUnixSocketPermission";
export type { NewThreadModelDefaults } from "./NewThreadModelDefaults";
export type { NonSteerableTurnKind } from "./NonSteerableTurnKind";
export type { OverriddenMetadata } from "./OverriddenMetadata";
export type { PatchApplyStatus } from "./PatchApplyStatus";
@@ -292,6 +295,7 @@ export type { PluginDetail } from "./PluginDetail";
export type { PluginHookSummary } from "./PluginHookSummary";
export type { PluginInstallParams } from "./PluginInstallParams";
export type { PluginInstallPolicy } from "./PluginInstallPolicy";
export type { PluginInstallPolicySource } from "./PluginInstallPolicySource";
export type { PluginInstallResponse } from "./PluginInstallResponse";
export type { PluginInstalledParams } from "./PluginInstalledParams";
export type { PluginInstalledResponse } from "./PluginInstalledResponse";
@@ -333,7 +337,10 @@ export type { ProcessOutputDeltaNotification } from "./ProcessOutputDeltaNotific
export type { ProcessOutputStream } from "./ProcessOutputStream";
export type { ProcessTerminalSize } from "./ProcessTerminalSize";
export type { RateLimitReachedType } from "./RateLimitReachedType";
export type { RateLimitResetCredit } from "./RateLimitResetCredit";
export type { RateLimitResetCreditStatus } from "./RateLimitResetCreditStatus";
export type { RateLimitResetCreditsSummary } from "./RateLimitResetCreditsSummary";
export type { RateLimitResetType } from "./RateLimitResetType";
export type { RateLimitSnapshot } from "./RateLimitSnapshot";
export type { RateLimitWindow } from "./RateLimitWindow";
export type { RawResponseItemCompletedNotification } from "./RawResponseItemCompletedNotification";
@@ -410,6 +417,7 @@ export type { ThreadGoalSetParams } from "./ThreadGoalSetParams";
export type { ThreadGoalSetResponse } from "./ThreadGoalSetResponse";
export type { ThreadGoalStatus } from "./ThreadGoalStatus";
export type { ThreadGoalUpdatedNotification } from "./ThreadGoalUpdatedNotification";
export type { ThreadHistoryMode } from "./ThreadHistoryMode";
export type { ThreadInjectItemsParams } from "./ThreadInjectItemsParams";
export type { ThreadInjectItemsResponse } from "./ThreadInjectItemsResponse";
export type { ThreadItem } from "./ThreadItem";
@@ -43,6 +43,8 @@ pub(crate) const GENERATED_TS_HEADER: &str = "// GENERATED CODE! DO NOT MODIFY B
const IGNORED_DEFINITIONS: &[&str] = &["Option<()>"];
const JSON_V1_ALLOWLIST: &[&str] = &["InitializeParams", "InitializeResponse"];
const EXPERIMENTAL_CLIENT_METHOD_DEPENDENCY_TYPES: &[&str] = &[
"EnvironmentShellInfo",
"PathUri",
"RemoteControlClient",
"RemoteControlClientsListOrder",
"ThreadBackgroundTerminal",
@@ -31,6 +31,11 @@ pub enum AuthMode {
#[ts(rename = "chatgptAuthTokens")]
#[strum(serialize = "chatgptAuthTokens")]
ChatgptAuthTokens,
/// Backend auth supplied as request headers.
#[serde(rename = "headers")]
#[ts(rename = "headers")]
#[strum(serialize = "headers")]
Headers,
/// Programmatic Codex auth backed by a registered Agent Identity.
#[serde(rename = "agentIdentity")]
#[ts(rename = "agentIdentity")]
@@ -53,7 +58,7 @@ impl AuthMode {
pub fn has_chatgpt_account(self) -> bool {
match self {
Self::Chatgpt | Self::ChatgptAuthTokens | Self::PersonalAccessToken => true,
Self::ApiKey | Self::AgentIdentity | Self::BedrockApiKey => false,
Self::ApiKey | Self::Headers | Self::AgentIdentity | Self::BedrockApiKey => false,
}
}
@@ -62,6 +67,7 @@ impl AuthMode {
match self {
Self::Chatgpt
| Self::ChatgptAuthTokens
| Self::Headers
| Self::AgentIdentity
| Self::PersonalAccessToken => true,
Self::ApiKey | Self::BedrockApiKey => false,
@@ -943,6 +949,13 @@ client_request_definitions! {
serialization: global("environment"),
response: v2::EnvironmentAddResponse,
},
#[experimental("environment/info")]
/// Reads information from a configured execution environment.
EnvironmentInfo => "environment/info" {
params: v2::EnvironmentInfoParams,
serialization: global_shared_read("environment"),
response: v2::EnvironmentInfoResponse,
},
McpServerOauthLogin => "mcpServer/oauth/login" {
params: v2::McpServerOauthLoginParams,
@@ -2580,6 +2593,7 @@ mod tests {
parent_thread_id: None,
preview: "first prompt".to_string(),
ephemeral: true,
history_mode: Default::default(),
model_provider: "openai".to_string(),
created_at: 1,
updated_at: 2,
@@ -2630,6 +2644,7 @@ mod tests {
"parentThreadId": null,
"preview": "first prompt",
"ephemeral": true,
"historyMode": "legacy",
"modelProvider": "openai",
"createdAt": 1,
"updatedAt": 2,
@@ -2712,7 +2727,9 @@ mod tests {
let request = ClientRequest::LoginAccount {
request_id: RequestId::Integer(3),
params: v2::LoginAccountParams::Chatgpt {
app_brand: None,
codex_streamlined_login: false,
use_hosted_login_success_page: false,
},
};
assert_eq!(
@@ -2720,7 +2737,8 @@ mod tests {
"method": "account/login/start",
"id": 3,
"params": {
"type": "chatgpt"
"type": "chatgpt",
"appBrand": null
}
}),
serde_json::to_value(&request)?,
@@ -2733,7 +2751,9 @@ mod tests {
let request = ClientRequest::LoginAccount {
request_id: RequestId::Integer(3),
params: v2::LoginAccountParams::Chatgpt {
app_brand: None,
codex_streamlined_login: true,
use_hosted_login_success_page: false,
},
};
assert_eq!(
@@ -2742,6 +2762,7 @@ mod tests {
"id": 3,
"params": {
"type": "chatgpt",
"appBrand": null,
"codexStreamlinedLogin": true
}
}),
@@ -2750,6 +2771,32 @@ mod tests {
Ok(())
}
#[test]
fn serialize_account_login_chatgpt_with_hosted_success_page() -> Result<()> {
let request = ClientRequest::LoginAccount {
request_id: RequestId::Integer(3),
params: v2::LoginAccountParams::Chatgpt {
app_brand: Some(v2::LoginAppBrand::Chatgpt),
codex_streamlined_login: true,
use_hosted_login_success_page: true,
},
};
assert_eq!(
json!({
"method": "account/login/start",
"id": 3,
"params": {
"type": "chatgpt",
"appBrand": "chatgpt",
"codexStreamlinedLogin": true,
"useHostedLoginSuccessPage": true
}
}),
serde_json::to_value(&request)?,
);
Ok(())
}
#[test]
fn serialize_account_login_chatgpt_device_code() -> Result<()> {
let request = ClientRequest::LoginAccount {
@@ -3186,6 +3233,7 @@ mod tests {
request_id: RequestId::Integer(9),
params: v2::ThreadRealtimeStartParams {
client_managed_handoffs: Some(true),
flush_transcript_tail_on_session_end: Some(true),
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_prefix: Some("silent context".to_string()),
@@ -3207,6 +3255,7 @@ mod tests {
"params": {
"threadId": "thr_123",
"clientManagedHandoffs": true,
"flushTranscriptTailOnSessionEnd": true,
"codexResponsesAsItems": null,
"codexResponseItemPrefix": null,
"codexResponseHandoffPrefix": "silent context",
@@ -3231,6 +3280,7 @@ mod tests {
request_id: RequestId::Integer(9),
params: v2::ThreadRealtimeStartParams {
client_managed_handoffs: None,
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -3252,6 +3302,7 @@ mod tests {
"params": {
"threadId": "thr_123",
"clientManagedHandoffs": null,
"flushTranscriptTailOnSessionEnd": null,
"codexResponsesAsItems": null,
"codexResponseItemPrefix": null,
"codexResponseHandoffPrefix": null,
@@ -3271,6 +3322,7 @@ mod tests {
request_id: RequestId::Integer(9),
params: v2::ThreadRealtimeStartParams {
client_managed_handoffs: None,
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -3292,6 +3344,7 @@ mod tests {
"params": {
"threadId": "thr_123",
"clientManagedHandoffs": null,
"flushTranscriptTailOnSessionEnd": null,
"codexResponsesAsItems": null,
"codexResponseItemPrefix": null,
"codexResponseHandoffPrefix": null,
@@ -3509,6 +3562,7 @@ mod tests {
request_id: RequestId::Integer(1),
params: v2::ThreadRealtimeStartParams {
client_managed_handoffs: None,
flush_transcript_tail_on_session_end: None,
codex_responses_as_items: None,
codex_response_item_prefix: None,
codex_response_handoff_prefix: None,
@@ -128,7 +128,10 @@ pub fn build_command_execution_end_item(payload: &ExecCommandEndEvent) -> Thread
}
}
fn command_actions_for_path_uri(parsed_cmd: &[ParsedCommand], cwd: &PathUri) -> Vec<CommandAction> {
pub(crate) fn command_actions_for_path_uri(
parsed_cmd: &[ParsedCommand],
cwd: &PathUri,
) -> Vec<CommandAction> {
// TODO(anp): Carry PathUri into CommandAction so foreign Read actions retain resolved paths.
// Until then, omit those actions rather than project a foreign cwd onto the host.
let native_cwd = if cwd.infer_path_convention() == Some(PathConvention::native()) {
@@ -22,6 +22,7 @@ use crate::protocol::v2::TurnItemsView;
use crate::protocol::v2::TurnStatus;
use crate::protocol::v2::UserInput;
use crate::protocol::v2::WebSearchAction;
use codex_extension_items::image_generation::ImageGenerationItem;
use codex_protocol::items::parse_hook_prompt_message;
use codex_protocol::models::MessagePhase;
use codex_protocol::protocol::AgentReasoningEvent;
@@ -574,52 +575,26 @@ impl ThreadHistoryBuilder {
}
fn handle_item_started(&mut self, payload: &ItemStartedEvent) {
match &payload.item {
codex_protocol::items::TurnItem::Plan(plan) => {
if plan.text.is_empty() {
return;
}
self.upsert_item_in_turn_id(
&payload.turn_id,
ThreadItem::from(payload.item.clone()),
);
}
codex_protocol::items::TurnItem::Sleep(_) => {
self.upsert_item_in_turn_id(
&payload.turn_id,
ThreadItem::from(payload.item.clone()),
);
}
codex_protocol::items::TurnItem::UserMessage(_)
| codex_protocol::items::TurnItem::HookPrompt(_)
| codex_protocol::items::TurnItem::AgentMessage(_)
| codex_protocol::items::TurnItem::Reasoning(_)
| codex_protocol::items::TurnItem::WebSearch(_)
| codex_protocol::items::TurnItem::ImageView(_)
| codex_protocol::items::TurnItem::ImageGeneration(_)
| codex_protocol::items::TurnItem::FileChange(_)
| codex_protocol::items::TurnItem::McpToolCall(_)
| codex_protocol::items::TurnItem::ContextCompaction(_) => {}
}
self.handle_materialized_item_lifecycle(&payload.turn_id, &payload.item);
}
fn handle_item_completed(&mut self, payload: &ItemCompletedEvent) {
match &payload.item {
codex_protocol::items::TurnItem::Plan(plan) => {
if plan.text.is_empty() {
return;
}
self.upsert_item_in_turn_id(
&payload.turn_id,
ThreadItem::from(payload.item.clone()),
);
}
codex_protocol::items::TurnItem::Sleep(_) => {
self.upsert_item_in_turn_id(
&payload.turn_id,
ThreadItem::from(payload.item.clone()),
);
}
self.handle_materialized_item_lifecycle(&payload.turn_id, &payload.item);
}
fn handle_materialized_item_lifecycle(
&mut self,
turn_id: &str,
item: &codex_protocol::items::TurnItem,
) {
let should_upsert = match item {
codex_protocol::items::TurnItem::Plan(plan) => !plan.text.is_empty(),
codex_protocol::items::TurnItem::Sleep(_)
| codex_protocol::items::TurnItem::CommandExecution(_)
| codex_protocol::items::TurnItem::DynamicToolCall(_)
| codex_protocol::items::TurnItem::CollabAgentToolCall(_)
| codex_protocol::items::TurnItem::SubAgentActivity(_)
| codex_protocol::items::TurnItem::Extension(_) => true,
codex_protocol::items::TurnItem::UserMessage(_)
| codex_protocol::items::TurnItem::HookPrompt(_)
| codex_protocol::items::TurnItem::AgentMessage(_)
@@ -629,7 +604,11 @@ impl ThreadHistoryBuilder {
| codex_protocol::items::TurnItem::ImageGeneration(_)
| codex_protocol::items::TurnItem::FileChange(_)
| codex_protocol::items::TurnItem::McpToolCall(_)
| codex_protocol::items::TurnItem::ContextCompaction(_) => {}
| codex_protocol::items::TurnItem::ContextCompaction(_) => false,
};
if should_upsert {
self.upsert_item_in_turn_id(turn_id, ThreadItem::from(item.clone()));
}
}
@@ -850,24 +829,24 @@ impl ThreadHistoryBuilder {
}
fn handle_image_generation_begin(&mut self, payload: &ImageGenerationBeginEvent) {
let item = ThreadItem::ImageGeneration {
let item = ThreadItem::ImageGeneration(ImageGenerationItem {
id: payload.call_id.clone(),
status: String::new(),
revised_prompt: None,
result: String::new(),
saved_path: None,
};
});
self.upsert_item_in_current_turn(item);
}
fn handle_image_generation_end(&mut self, payload: &ImageGenerationEndEvent) {
let item = ThreadItem::ImageGeneration {
let item = ThreadItem::ImageGeneration(ImageGenerationItem {
id: payload.call_id.clone(),
status: payload.status.clone(),
revised_prompt: payload.revised_prompt.clone(),
result: payload.result.clone(),
saved_path: payload.saved_path.clone(),
};
});
self.upsert_item_in_current_turn(item);
}
@@ -1571,8 +1550,11 @@ impl From<&PendingTurn> for Turn {
mod tests {
use super::*;
use crate::protocol::v2::CommandExecutionSource;
use codex_extension_items::ExtensionItem as CoreExtensionItem;
use codex_protocol::ThreadId;
use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynamicToolCallOutputContentItem;
use codex_protocol::items::CommandExecutionItem as CoreCommandExecutionItem;
use codex_protocol::items::CommandExecutionStatus as CoreCommandExecutionStatus;
use codex_protocol::items::HookPromptFragment as CoreHookPromptFragment;
use codex_protocol::items::SleepItem as CoreSleepItem;
use codex_protocol::items::TurnItem as CoreTurnItem;
@@ -1867,6 +1849,131 @@ mod tests {
);
}
#[test]
fn rebuilds_extension_image_generation_item_from_persisted_completion() {
let turn_id = "turn-1";
let thread_id = ThreadId::new();
let saved_path = test_path_buf("/tmp/image-1.png").abs();
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: turn_id.to_string(),
trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
}),
EventMsg::ItemCompleted(ItemCompletedEvent {
thread_id,
turn_id: turn_id.to_string(),
item: CoreTurnItem::Extension(CoreExtensionItem::ImageGeneration(
ImageGenerationItem {
id: "image-1".to_string(),
status: "completed".to_string(),
revised_prompt: Some("A blue square".to_string()),
result: "cG5n".to_string(),
saved_path: Some(saved_path.clone()),
},
)),
completed_at_ms: 1_000,
}),
EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: turn_id.to_string(),
last_agent_message: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
}),
];
let items = events
.into_iter()
.map(RolloutItem::EventMsg)
.collect::<Vec<_>>();
let turns = build_turns_from_rollout_items(&items);
assert_eq!(
turns[0].items,
vec![ThreadItem::ImageGeneration(ImageGenerationItem {
id: "image-1".to_string(),
status: "completed".to_string(),
revised_prompt: Some("A blue square".to_string()),
result: "cG5n".to_string(),
saved_path: Some(saved_path),
})]
);
}
#[test]
fn rebuilds_command_execution_item_from_persisted_completion() {
let turn_id = "turn-1";
let thread_id = ThreadId::new();
let command_item = CoreTurnItem::CommandExecution(CoreCommandExecutionItem {
id: "exec-1".to_string(),
process_id: Some("pid-1".to_string()),
command: vec!["echo".to_string(), "hello world".to_string()],
cwd: test_path_buf("/tmp").abs().into(),
parsed_cmd: vec![ParsedCommand::Unknown {
cmd: "echo hello world".to_string(),
}],
source: ExecCommandSource::Agent,
interaction_input: None,
status: CoreCommandExecutionStatus::Completed,
stdout: Some("hello world\n".to_string()),
stderr: Some(String::new()),
aggregated_output: Some("hello world\n".to_string()),
exit_code: Some(0),
duration: Some(Duration::from_millis(12)),
formatted_output: Some("hello world\n".to_string()),
});
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: turn_id.to_string(),
trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
}),
EventMsg::ItemCompleted(ItemCompletedEvent {
thread_id,
turn_id: turn_id.to_string(),
item: command_item,
completed_at_ms: 1_000,
}),
EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: turn_id.to_string(),
last_agent_message: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
}),
];
let items = events
.into_iter()
.map(RolloutItem::EventMsg)
.collect::<Vec<_>>();
let turns = build_turns_from_rollout_items(&items);
assert_eq!(turns.len(), 1);
assert_eq!(
turns[0].items,
vec![ThreadItem::CommandExecution {
id: "exec-1".to_string(),
command: "echo 'hello world'".to_string(),
cwd: test_path_buf("/tmp").abs().into(),
process_id: Some("pid-1".to_string()),
source: CommandExecutionSource::Agent,
status: CommandExecutionStatus::Completed,
command_actions: vec![CommandAction::Unknown {
command: "echo hello world".to_string(),
}],
aggregated_output: Some("hello world\n".to_string()),
exit_code: Some(0),
duration_ms: Some(12),
}]
);
}
#[test]
fn preserves_user_message_client_id_from_legacy_event() {
let turn_id = "turn-1";
@@ -2008,13 +2115,13 @@ mod tests {
text_elements: Vec::new(),
}],
},
ThreadItem::ImageGeneration {
ThreadItem::ImageGeneration(ImageGenerationItem {
id: "ig_123".into(),
status: "completed".into(),
revised_prompt: Some("final prompt".into()),
result: "Zm9v".into(),
saved_path: Some(test_path_buf("/tmp/ig_123.png").abs()),
},
}),
],
}
);
@@ -78,6 +78,11 @@ pub enum LoginAccountParams {
Chatgpt {
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
codex_streamlined_login: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
use_hosted_login_success_page: bool,
#[serde(default)]
#[ts(optional = nullable)]
app_brand: Option<LoginAppBrand>,
},
#[serde(rename = "chatgptDeviceCode")]
#[ts(rename = "chatgptDeviceCode")]
@@ -102,6 +107,16 @@ pub enum LoginAccountParams {
},
}
#[derive(Serialize, Deserialize, Debug, Default, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "lowercase")]
#[ts(rename_all = "lowercase")]
#[ts(export_to = "v2/")]
pub enum LoginAppBrand {
#[default]
Codex,
Chatgpt,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "camelCase")]
#[ts(tag = "type")]
@@ -284,6 +299,52 @@ pub struct GetAccountRateLimitsResponse {
#[ts(export_to = "v2/")]
pub struct RateLimitResetCreditsSummary {
pub available_count: i64,
/// Detail rows for available reset credits, when the backend provides them.
///
/// `null` means only `availableCount` is known, while an empty array means details were fetched
/// and no available credits were returned. The backend may cap this list, so its length can be
/// less than `availableCount`.
pub credits: Option<Vec<RateLimitResetCredit>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RateLimitResetCredit {
/// Opaque backend identifier for this reset credit.
pub id: String,
pub reset_type: RateLimitResetType,
pub status: RateLimitResetCreditStatus,
/// Unix timestamp in seconds when the credit was granted.
#[ts(type = "number")]
pub granted_at: i64,
/// Unix timestamp in seconds when the credit expires, or `null` if it does not expire.
#[ts(type = "number | null")]
pub expires_at: Option<i64>,
/// Backend-provided display title for this credit, or `null` when unavailable.
pub title: Option<String>,
/// Backend-provided display description for this credit, or `null` when unavailable.
pub description: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/", rename_all = "camelCase")]
pub enum RateLimitResetType {
CodexRateLimits,
#[serde(other)]
Unknown,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/", rename_all = "camelCase")]
pub enum RateLimitResetCreditStatus {
Available,
Redeeming,
Redeemed,
#[serde(other)]
Unknown,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
@@ -293,6 +354,10 @@ pub struct ConsumeAccountRateLimitResetCreditParams {
/// Identifies one logical reset attempt. A UUID is recommended; reuse the same value when
/// retrying that attempt.
pub idempotency_key: String,
/// Opaque reset-credit identifier to redeem. When omitted, the backend selects the next
/// available credit.
#[ts(optional = nullable)]
pub credit_id: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
@@ -163,6 +163,7 @@ pub struct AnalyticsConfig {
pub enum AppToolApproval {
Auto,
Prompt,
Writes,
Approve,
}
@@ -390,6 +391,23 @@ pub struct ConfigRequirements {
pub enforce_residency: Option<ResidencyRequirement>,
#[experimental("configRequirements/read.network")]
pub network: Option<NetworkRequirements>,
pub models: Option<ModelsRequirements>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ModelsRequirements {
pub new_thread: Option<NewThreadModelDefaults>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct NewThreadModelDefaults {
pub model: Option<String>,
pub model_reasoning_effort: Option<ReasoningEffort>,
pub service_tier: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
@@ -1,3 +1,4 @@
use codex_utils_path_uri::PathUri;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
@@ -19,3 +20,29 @@ pub struct EnvironmentAddParams {
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct EnvironmentAddResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct EnvironmentInfoParams {
pub environment_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct EnvironmentInfoResponse {
pub shell: EnvironmentShellInfo,
/// Default working directory reported by the environment, as a canonical file URI.
pub cwd: Option<PathUri>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct EnvironmentShellInfo {
/// Stable shell name, for example `zsh`, `bash`, `powershell`, `sh`, or `cmd`.
pub name: String,
/// Target-native shell executable path or command name.
pub path: String,
}
@@ -8,12 +8,19 @@ use super::NetworkPolicyAmendment;
use super::RequestPermissionProfile;
use super::UserInput;
use super::shared::v2_enum_from_core;
use crate::protocol::item_builders::command_actions_for_path_uri;
use crate::protocol::item_builders::convert_patch_changes;
use codex_experimental_api_macros::ExperimentalApi;
use codex_extension_items::ExtensionItem;
pub use codex_extension_items::image_generation::ImageGenerationItem;
use codex_protocol::approvals::GuardianAssessmentAction as CoreGuardianAssessmentAction;
use codex_protocol::approvals::GuardianAssessmentDecisionSource as CoreGuardianAssessmentDecisionSource;
use codex_protocol::approvals::GuardianCommandSource as CoreGuardianCommandSource;
use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent;
use codex_protocol::items::CollabAgentTool as CoreCollabAgentTool;
use codex_protocol::items::CollabAgentToolCallStatus as CoreCollabAgentToolCallStatus;
use codex_protocol::items::CommandExecutionStatus as CoreCommandExecutionStatus;
use codex_protocol::items::DynamicToolCallStatus as CoreDynamicToolCallStatus;
use codex_protocol::items::McpToolCallStatus as CoreMcpToolCallStatus;
use codex_protocol::items::TurnItem as CoreTurnItem;
use codex_protocol::memory_citation::MemoryCitation as CoreMemoryCitation;
@@ -30,6 +37,7 @@ use codex_protocol::protocol::GuardianUserAuthorization as CoreGuardianUserAutho
use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus;
use codex_protocol::protocol::ReviewDecision as CoreReviewDecision;
use codex_protocol::protocol::SubAgentActivityKind as CoreSubAgentActivityKind;
use codex_shell_command::parse_command::shlex_join;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::LegacyAppPathString;
use schemars::JsonSchema;
@@ -240,7 +248,10 @@ pub enum ThreadItem {
#[ts(rename_all = "camelCase")]
/// EXPERIMENTAL - proposed plan item content. The completed plan item is
/// authoritative and may not match the concatenation of `PlanDelta` text.
Plan { id: String, text: String },
Plan {
id: String,
text: String,
},
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
Reasoning {
@@ -367,26 +378,24 @@ pub enum ThreadItem {
#[ts(type = "number")]
duration_ms: u64,
},
ImageGeneration(ImageGenerationItem),
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
ImageGeneration {
EnteredReviewMode {
id: String,
status: String,
revised_prompt: Option<String>,
result: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
saved_path: Option<AbsolutePathBuf>,
review: String,
},
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
EnteredReviewMode { id: String, review: String },
ExitedReviewMode {
id: String,
review: String,
},
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
ExitedReviewMode { id: String, review: String },
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
ContextCompaction { id: String },
ContextCompaction {
id: String,
},
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
@@ -426,10 +435,10 @@ impl ThreadItem {
| ThreadItem::WebSearch { id, .. }
| ThreadItem::ImageView { id, .. }
| ThreadItem::Sleep { id, .. }
| ThreadItem::ImageGeneration { id, .. }
| ThreadItem::EnteredReviewMode { id, .. }
| ThreadItem::ExitedReviewMode { id, .. }
| ThreadItem::ContextCompaction { id, .. } => id,
ThreadItem::ImageGeneration(item) => &item.id,
}
}
}
@@ -854,6 +863,64 @@ impl From<CoreTurnItem> for ThreadItem {
summary: reasoning.summary_text,
content: reasoning.raw_content,
},
CoreTurnItem::CommandExecution(command) => ThreadItem::CommandExecution {
id: command.id,
command: shlex_join(&command.command),
cwd: command.cwd.clone().into(),
process_id: command.process_id,
source: command.source.into(),
status: command.status.into(),
command_actions: command_actions_for_path_uri(&command.parsed_cmd, &command.cwd),
aggregated_output: command
.aggregated_output
.filter(|output| !output.is_empty()),
exit_code: command.exit_code,
duration_ms: command
.duration
.and_then(|duration| i64::try_from(duration.as_millis()).ok()),
},
CoreTurnItem::DynamicToolCall(call) => ThreadItem::DynamicToolCall {
id: call.id,
namespace: call.namespace,
tool: call.tool,
arguments: call.arguments,
status: call.status.into(),
content_items: call.content_items.map(|items| {
items
.into_iter()
.map(DynamicToolCallOutputContentItem::from)
.collect()
}),
success: call.success,
duration_ms: call
.duration
.and_then(|duration| i64::try_from(duration.as_millis()).ok()),
},
CoreTurnItem::CollabAgentToolCall(call) => ThreadItem::CollabAgentToolCall {
id: call.id,
tool: call.tool.into(),
status: call.status.into(),
sender_thread_id: call.sender_thread_id.to_string(),
receiver_thread_ids: call
.receiver_thread_ids
.into_iter()
.map(String::from)
.collect(),
prompt: call.prompt,
model: call.model,
reasoning_effort: call.reasoning_effort,
agents_states: call
.agents_states
.into_iter()
.map(|(thread_id, status)| (thread_id.to_string(), status.into()))
.collect(),
},
CoreTurnItem::SubAgentActivity(activity) => ThreadItem::SubAgentActivity {
id: activity.id,
kind: activity.kind.into(),
agent_thread_id: activity.agent_thread_id.to_string(),
agent_path: String::from(activity.agent_path),
},
CoreTurnItem::WebSearch(search) => ThreadItem::WebSearch {
id: search.id,
query: search.query,
@@ -867,13 +934,18 @@ impl From<CoreTurnItem> for ThreadItem {
id: sleep.id,
duration_ms: sleep.duration_ms,
},
CoreTurnItem::ImageGeneration(image) => ThreadItem::ImageGeneration {
id: image.id,
status: image.status,
revised_prompt: image.revised_prompt,
result: image.result,
saved_path: image.saved_path,
CoreTurnItem::Extension(extension) => match extension {
ExtensionItem::ImageGeneration(item) => ThreadItem::ImageGeneration(item),
},
CoreTurnItem::ImageGeneration(image) => {
ThreadItem::ImageGeneration(ImageGenerationItem {
id: image.id,
status: image.status,
revised_prompt: image.revised_prompt,
result: image.result,
saved_path: image.saved_path,
})
}
CoreTurnItem::FileChange(file_change) => ThreadItem::FileChange {
id: file_change.id,
changes: convert_patch_changes(&file_change.changes),
@@ -941,6 +1013,17 @@ impl From<CoreExecCommandStatus> for CommandExecutionStatus {
}
}
impl From<CoreCommandExecutionStatus> for CommandExecutionStatus {
fn from(value: CoreCommandExecutionStatus) -> Self {
match value {
CoreCommandExecutionStatus::InProgress => Self::InProgress,
CoreCommandExecutionStatus::Completed => Self::Completed,
CoreCommandExecutionStatus::Failed => Self::Failed,
CoreCommandExecutionStatus::Declined => Self::Declined,
}
}
}
impl From<&CoreExecCommandStatus> for CommandExecutionStatus {
fn from(value: &CoreExecCommandStatus) -> Self {
match value {
@@ -1028,6 +1111,16 @@ impl From<CoreMcpToolCallStatus> for McpToolCallStatus {
}
}
impl From<CoreDynamicToolCallStatus> for DynamicToolCallStatus {
fn from(value: CoreDynamicToolCallStatus) -> Self {
match value {
CoreDynamicToolCallStatus::InProgress => Self::InProgress,
CoreDynamicToolCallStatus::Completed => Self::Completed,
CoreDynamicToolCallStatus::Failed => Self::Failed,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -1055,6 +1148,28 @@ pub enum CollabAgentToolCallStatus {
Failed,
}
impl From<CoreCollabAgentTool> for CollabAgentTool {
fn from(value: CoreCollabAgentTool) -> Self {
match value {
CoreCollabAgentTool::SpawnAgent => Self::SpawnAgent,
CoreCollabAgentTool::SendInput => Self::SendInput,
CoreCollabAgentTool::ResumeAgent => Self::ResumeAgent,
CoreCollabAgentTool::Wait => Self::Wait,
CoreCollabAgentTool::CloseAgent => Self::CloseAgent,
}
}
}
impl From<CoreCollabAgentToolCallStatus> for CollabAgentToolCallStatus {
fn from(value: CoreCollabAgentToolCallStatus) -> Self {
match value {
CoreCollabAgentToolCallStatus::InProgress => Self::InProgress,
CoreCollabAgentToolCallStatus::Completed => Self::Completed,
CoreCollabAgentToolCallStatus::Failed => Self::Failed,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -1462,6 +1577,21 @@ pub enum DynamicToolCallOutputContentItem {
InputImage { image_url: String },
}
impl From<codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem>
for DynamicToolCallOutputContentItem
{
fn from(item: codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem) -> Self {
match item {
codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem::InputText { text } => {
Self::InputText { text }
}
codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem::InputImage {
image_url,
} => Self::InputImage { image_url },
}
}
}
impl From<DynamicToolCallOutputContentItem>
for codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem
{
@@ -567,6 +567,17 @@ pub enum PluginInstallPolicy {
InstalledByDefault,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[ts(export_to = "v2/")]
pub enum PluginInstallPolicySource {
#[serde(rename = "WORKSPACE_SETTING")]
#[ts(rename = "WORKSPACE_SETTING")]
WorkspaceSetting,
#[serde(rename = "IMPLICIT_CANONICAL_APP")]
#[ts(rename = "IMPLICIT_CANONICAL_APP")]
ImplicitCanonicalApp,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[ts(export_to = "v2/")]
pub enum PluginAuthPolicy {
@@ -600,6 +611,9 @@ pub struct PluginSummary {
pub id: String,
/// Backend remote plugin identifier when available.
pub remote_plugin_id: Option<String>,
/// Version advertised by the remote marketplace backend when available.
#[serde(default)]
pub version: Option<String>,
/// Version of the locally materialized plugin package when available.
#[serde(default)]
pub local_version: Option<String>,
@@ -610,6 +624,7 @@ pub struct PluginSummary {
pub installed: bool,
pub enabled: bool,
pub install_policy: PluginInstallPolicy,
pub install_policy_source: Option<PluginInstallPolicySource>,
pub auth_policy: PluginAuthPolicy,
/// Availability state for installing and using the plugin.
#[serde(default)]
@@ -744,6 +759,15 @@ pub enum PluginSource {
ref_name: Option<String>,
sha: Option<String>,
},
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
Npm {
package: String,
/// Optional npm version or version range.
version: Option<String>,
/// Optional HTTPS registry URL. Authentication stays in the user's npm config.
registry: Option<String>,
},
/// The plugin is available in the remote catalog. Download metadata is
/// kept server-side and is not exposed through the app-server API.
Remote,
@@ -70,6 +70,10 @@ pub struct ThreadRealtimeStartParams {
/// them automatically. Defaults to false.
#[ts(optional = nullable)]
pub client_managed_handoffs: Option<bool>,
/// Routes any transcript tail remaining at session end through Codex. Defaults to false.
/// TODO: Remove this rollout knob once transcript-tail flushing is always enabled.
#[ts(optional = nullable)]
pub flush_transcript_tail_on_session_end: Option<bool>,
/// Sends automatic Codex responses as realtime conversation items instead of handoff appends.
#[ts(optional = nullable)]
pub codex_responses_as_items: Option<bool>,
@@ -4,11 +4,19 @@ use codex_protocol::approvals::ElicitationRequest as CoreElicitationRequest;
use codex_protocol::config_types::MultiAgentMode;
use codex_protocol::items::AgentMessageContent;
use codex_protocol::items::AgentMessageItem;
use codex_protocol::items::CollabAgentTool as CoreCollabAgentTool;
use codex_protocol::items::CollabAgentToolCallItem;
use codex_protocol::items::CollabAgentToolCallStatus as CoreCollabAgentToolCallStatus;
use codex_protocol::items::CommandExecutionItem;
use codex_protocol::items::CommandExecutionStatus as CoreCommandExecutionStatus;
use codex_protocol::items::DynamicToolCallItem;
use codex_protocol::items::DynamicToolCallStatus as CoreDynamicToolCallStatus;
use codex_protocol::items::FileChangeItem;
use codex_protocol::items::ImageViewItem;
use codex_protocol::items::McpToolCallItem;
use codex_protocol::items::McpToolCallStatus as CoreMcpToolCallStatus;
use codex_protocol::items::ReasoningItem;
use codex_protocol::items::SubAgentActivityItem;
use codex_protocol::items::TurnItem;
use codex_protocol::items::UserMessageItem;
use codex_protocol::items::WebSearchItem;
@@ -30,8 +38,10 @@ use codex_protocol::permissions::FileSystemSpecialPath as CoreFileSystemSpecialP
use codex_protocol::protocol::AgentStatus as CoreAgentStatus;
use codex_protocol::protocol::AskForApproval as CoreAskForApproval;
use codex_protocol::protocol::ConversationTextRole;
use codex_protocol::protocol::ExecCommandSource as CoreExecCommandSource;
use codex_protocol::protocol::GranularApprovalConfig as CoreGranularApprovalConfig;
use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess;
use codex_protocol::protocol::SubAgentActivityKind as CoreSubAgentActivityKind;
use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile;
use codex_protocol::user_input::UserInput as CoreUserInput;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -175,6 +185,7 @@ fn thread_resume_response_round_trips_initial_turns_page() {
parent_thread_id: None,
preview: String::new(),
ephemeral: false,
history_mode: Default::default(),
model_provider: "openai".to_string(),
created_at: 1,
updated_at: 1,
@@ -838,6 +849,30 @@ fn thread_path_params_deserialize_empty_path_as_none() {
);
}
#[test]
fn thread_fork_last_turn_id_round_trips() {
let params: ThreadForkParams = serde_json::from_value(json!({
"threadId": "thread-1",
"lastTurnId": "turn-2",
}))
.expect("thread/fork params deserialize");
assert_eq!(params.last_turn_id, Some("turn-2".to_string()));
let serialized = serde_json::to_value(params).expect("thread/fork params serialize");
assert_eq!(serialized["lastTurnId"], json!("turn-2"));
let omitted = serde_json::to_value(ThreadForkParams {
thread_id: "thread-1".to_string(),
..Default::default()
})
.expect("thread/fork params without last turn id serialize");
assert_eq!(
omitted["lastTurnId"],
serde_json::Value::Null,
"optional lastTurnId should serialize as null when omitted"
);
}
#[test]
fn fs_get_metadata_response_round_trips_minimal_fields() {
let response = FsGetMetadataResponse {
@@ -1752,6 +1787,7 @@ fn config_requirements_granular_allowed_approval_policy_is_marked_experimental()
hooks: None,
enforce_residency: None,
network: None,
models: None,
});
assert_eq!(reason, Some("askForApproval.granular"));
@@ -2606,6 +2642,134 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
}
);
let command_item = TurnItem::CommandExecution(CommandExecutionItem {
id: "exec-1".to_string(),
process_id: Some("pid-1".to_string()),
command: vec!["echo".to_string(), "done".to_string()],
cwd: PathUri::from_abs_path(&test_path_buf("/tmp").abs()),
parsed_cmd: vec![codex_protocol::parse_command::ParsedCommand::Unknown {
cmd: "echo done".to_string(),
}],
source: CoreExecCommandSource::Agent,
interaction_input: None,
status: CoreCommandExecutionStatus::Completed,
stdout: Some("done\n".to_string()),
stderr: Some(String::new()),
aggregated_output: Some("done\n".to_string()),
exit_code: Some(0),
duration: Some(Duration::from_millis(5)),
formatted_output: Some("done\n".to_string()),
});
assert_eq!(
ThreadItem::from(command_item),
ThreadItem::CommandExecution {
id: "exec-1".to_string(),
command: "echo done".to_string(),
cwd: LegacyAppPathString::from_abs_path(&test_path_buf("/tmp").abs()),
process_id: Some("pid-1".to_string()),
source: CommandExecutionSource::Agent,
status: CommandExecutionStatus::Completed,
command_actions: vec![CommandAction::Unknown {
command: "echo done".to_string(),
}],
aggregated_output: Some("done\n".to_string()),
exit_code: Some(0),
duration_ms: Some(5),
}
);
let dynamic_tool_call_item = TurnItem::DynamicToolCall(DynamicToolCallItem {
id: "dynamic-1".to_string(),
namespace: Some("apps".to_string()),
tool: "lookup".to_string(),
arguments: json!({"id": "123"}),
status: CoreDynamicToolCallStatus::Completed,
content_items: Some(vec![
codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem::InputText {
text: "ok".to_string(),
},
]),
success: Some(true),
error: None,
duration: Some(Duration::from_millis(5)),
});
assert_eq!(
ThreadItem::from(dynamic_tool_call_item),
ThreadItem::DynamicToolCall {
id: "dynamic-1".to_string(),
namespace: Some("apps".to_string()),
tool: "lookup".to_string(),
arguments: json!({"id": "123"}),
status: DynamicToolCallStatus::Completed,
content_items: Some(vec![DynamicToolCallOutputContentItem::InputText {
text: "ok".to_string(),
}]),
success: Some(true),
duration_ms: Some(5),
}
);
let sender_thread_id = codex_protocol::ThreadId::default();
let receiver_thread_id = codex_protocol::ThreadId::default();
let collab_item = TurnItem::CollabAgentToolCall(CollabAgentToolCallItem {
id: "collab-1".to_string(),
tool: CoreCollabAgentTool::SendInput,
status: CoreCollabAgentToolCallStatus::Completed,
sender_thread_id,
receiver_thread_ids: vec![receiver_thread_id],
receiver_agents: Vec::new(),
prompt: Some("continue".to_string()),
model: None,
reasoning_effort: None,
agents_states: [(receiver_thread_id, CoreAgentStatus::Completed(None))]
.into_iter()
.collect(),
});
assert_eq!(
ThreadItem::from(collab_item),
ThreadItem::CollabAgentToolCall {
id: "collab-1".to_string(),
tool: CollabAgentTool::SendInput,
status: CollabAgentToolCallStatus::Completed,
sender_thread_id: sender_thread_id.to_string(),
receiver_thread_ids: vec![receiver_thread_id.to_string()],
prompt: Some("continue".to_string()),
model: None,
reasoning_effort: None,
agents_states: [(
receiver_thread_id.to_string(),
CollabAgentState {
status: CollabAgentStatus::Completed,
message: None,
},
)]
.into_iter()
.collect(),
}
);
let sub_agent_activity_item = TurnItem::SubAgentActivity(SubAgentActivityItem {
id: "activity-1".to_string(),
kind: CoreSubAgentActivityKind::Interrupted,
agent_thread_id: receiver_thread_id,
agent_path: codex_protocol::AgentPath::root()
.join("worker")
.expect("worker path"),
});
assert_eq!(
ThreadItem::from(sub_agent_activity_item),
ThreadItem::SubAgentActivity {
id: "activity-1".to_string(),
kind: SubAgentActivityKind::Interrupted,
agent_thread_id: receiver_thread_id.to_string(),
agent_path: "/root/worker".to_string(),
}
);
let search_item = TurnItem::WebSearch(WebSearchItem {
id: "search-1".to_string(),
query: "docs".to_string(),
@@ -2901,7 +3065,7 @@ fn skills_extra_roots_set_params_rejects_relative_roots() {
}
#[test]
fn plugin_source_serializes_local_git_and_remote_variants() {
fn plugin_source_serializes_local_git_npm_and_remote_variants() {
let local_path = if cfg!(windows) {
r"C:\plugins\linear"
} else {
@@ -2935,6 +3099,21 @@ fn plugin_source_serializes_local_git_and_remote_variants() {
}),
);
assert_eq!(
serde_json::to_value(PluginSource::Npm {
package: "@acme/plugin".to_string(),
version: Some("^1.2.0".to_string()),
registry: Some("https://npm.example.com".to_string()),
})
.unwrap(),
json!({
"type": "npm",
"package": "@acme/plugin",
"version": "^1.2.0",
"registry": "https://npm.example.com",
}),
);
assert_eq!(
serde_json::to_value(PluginSource::Remote).unwrap(),
json!({
@@ -3463,6 +3642,7 @@ fn plugin_share_list_response_serializes_share_items() {
remote_plugin_id: Some(
"plugins~Plugin_00000000000000000000000000000000".to_string(),
),
version: None,
local_version: None,
name: "gmail".to_string(),
share_context: None,
@@ -3470,6 +3650,7 @@ fn plugin_share_list_response_serializes_share_items() {
installed: false,
enabled: false,
install_policy: PluginInstallPolicy::Available,
install_policy_source: Some(PluginInstallPolicySource::WorkspaceSetting),
auth_policy: PluginAuthPolicy::OnUse,
availability: PluginAvailability::Available,
interface: None,
@@ -3484,6 +3665,7 @@ fn plugin_share_list_response_serializes_share_items() {
"plugin": {
"id": "gmail@openai-curated-remote",
"remotePluginId": "plugins~Plugin_00000000000000000000000000000000",
"version": null,
"localVersion": null,
"name": "gmail",
"shareContext": null,
@@ -3491,6 +3673,7 @@ fn plugin_share_list_response_serializes_share_items() {
"installed": false,
"enabled": false,
"installPolicy": "AVAILABLE",
"installPolicySource": "WORKSPACE_SETTING",
"authPolicy": "ON_USE",
"availability": "AVAILABLE",
"interface": null,
@@ -4,6 +4,7 @@ use super::AskForApproval;
use super::SandboxMode;
use super::SandboxPolicy;
use super::Thread;
use super::ThreadHistoryMode;
use super::ThreadItem;
use super::ThreadSource;
use super::Turn;
@@ -105,6 +106,10 @@ pub struct ThreadStartParams {
pub multi_agent_mode: Option<MultiAgentMode>,
#[ts(optional = nullable)]
pub ephemeral: Option<bool>,
/// Persisted thread history contract to use for this new thread.
#[experimental("thread/start.historyMode")]
#[ts(optional = nullable)]
pub history_mode: Option<ThreadHistoryMode>,
#[ts(optional = nullable)]
pub session_start_source: Option<ThreadStartSource>,
/// Optional client-supplied analytics source classification for this thread.
@@ -489,6 +494,13 @@ impl From<ThreadTurnsListResponse> for TurnsPage {
pub struct ThreadForkParams {
pub thread_id: String,
/// Optional last turn id to fork through, inclusive.
///
/// When specified, turns after `last_turn_id` are omitted from the fork.
/// The referenced turn cannot be in progress.
#[ts(optional = nullable)]
pub last_turn_id: Option<String>,
/// [UNSTABLE] Specify the rollout path to fork from.
/// If specified, the thread_id param will be ignored.
#[experimental("thread/fork.path")]
@@ -673,7 +685,7 @@ pub struct ThreadIncrementElicitationParams {
#[ts(export_to = "v2/")]
pub struct ThreadIncrementElicitationResponse {
/// Current out-of-band elicitation count after the increment.
pub count: u64,
pub count: i64,
/// Whether timeout accounting is paused after applying the increment.
pub paused: bool,
}
@@ -693,7 +705,7 @@ pub struct ThreadDecrementElicitationParams {
#[ts(export_to = "v2/")]
pub struct ThreadDecrementElicitationResponse {
/// Current out-of-band elicitation count after the decrement.
pub count: u64,
pub count: i64,
/// Whether timeout accounting remains paused after applying the decrement.
pub paused: bool,
}
@@ -5,6 +5,7 @@ use super::TurnStatus;
use codex_experimental_api_macros::ExperimentalApi;
use codex_protocol::protocol::SessionSource as CoreSessionSource;
use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource;
use codex_protocol::protocol::ThreadHistoryMode as CoreThreadHistoryMode;
use codex_protocol::protocol::ThreadSource as CoreThreadSource;
use codex_utils_absolute_path::AbsolutePathBuf;
use schemars::JsonSchema;
@@ -64,6 +65,33 @@ impl From<SessionSource> for CoreSessionSource {
}
}
#[derive(Default, Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "lowercase")]
#[ts(rename_all = "lowercase", export_to = "v2/")]
pub enum ThreadHistoryMode {
#[default]
Legacy,
Paginated,
}
impl From<CoreThreadHistoryMode> for ThreadHistoryMode {
fn from(value: CoreThreadHistoryMode) -> Self {
match value {
CoreThreadHistoryMode::Legacy => Self::Legacy,
CoreThreadHistoryMode::Paginated => Self::Paginated,
}
}
}
impl From<ThreadHistoryMode> for CoreThreadHistoryMode {
fn from(value: ThreadHistoryMode) -> Self {
match value {
ThreadHistoryMode::Legacy => Self::Legacy,
ThreadHistoryMode::Paginated => Self::Paginated,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, TS)]
#[serde(try_from = "String", into = "String")]
#[ts(type = "string")]
@@ -155,6 +183,10 @@ pub struct Thread {
pub preview: String,
/// Whether the thread is ephemeral and should not be materialized on disk.
pub ephemeral: bool,
/// Persisted thread history contract selected when this thread was created.
#[experimental("thread.historyMode")]
#[serde(default)]
pub history_mode: ThreadHistoryMode,
/// Model provider used for this thread (for example, 'openai').
pub model_provider: String,
/// Unix timestamp (in seconds) when the thread was created.
@@ -1722,7 +1722,9 @@ impl CodexClient {
let request = ClientRequest::LoginAccount {
request_id: request_id.clone(),
params: codex_app_server_protocol::LoginAccountParams::Chatgpt {
app_brand: None,
codex_streamlined_login: false,
use_hosted_login_success_page: false,
},
};
@@ -124,7 +124,6 @@ pub(super) fn run_cleanup(
overrides.extend([
"analytics.enabled=false".to_string(),
"features.plugins=true".to_string(),
"features.remote_plugin=true".to_string(),
]);
let mut client = CodexClient::spawn_stdio(codex_bin, &overrides)?;
client.initialize()?;
@@ -199,7 +198,6 @@ fn spawn_client(
overrides.extend([
"analytics.enabled=true".to_string(),
"features.plugins=true".to_string(),
"features.remote_plugin=true".to_string(),
]);
let environment = vec![(
OsString::from(ANALYTICS_CAPTURE_ENV_VAR),
@@ -263,7 +263,6 @@ fn smoke_config_overrides(responses_base_url: &str) -> Result<Vec<String>> {
Ok(vec![
"analytics.enabled=true".to_string(),
"features.plugins=true".to_string(),
"features.remote_plugin=true".to_string(),
format!("model={}", quoted(MOCK_MODEL_SLUG)?),
format!("model_provider={}", quoted(MOCK_PROVIDER_ID)?),
format!(
+2
View File
@@ -35,8 +35,10 @@ constant_time_eq = { workspace = true }
futures = { workspace = true }
gethostname = { workspace = true }
hmac = { workspace = true }
httpdate = { workspace = true }
jsonwebtoken = { workspace = true }
owo-colors = { workspace = true, features = ["supports-colors"] }
rand = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }
@@ -1,8 +1,4 @@
use super::auth::RemoteControlConnectionAuth;
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;
@@ -14,8 +10,6 @@ use codex_app_server_protocol::RemoteControlPairingStatusResponse;
use codex_login::default_client::build_reqwest_client;
use codex_state::RemoteControlEnrollmentRecord;
use codex_state::StateRuntime;
use serde::Serialize;
use serde::de::DeserializeOwned;
use std::io;
use std::io::ErrorKind;
use time::OffsetDateTime;
@@ -23,15 +17,13 @@ use time::format_description::well_known::Rfc3339;
use tracing::info;
use tracing::warn;
const REMOTE_CONTROL_ENROLL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
const REMOTE_CONTROL_PAIRING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
const REMOTE_CONTROL_RESPONSE_BODY_MAX_BYTES: usize = 4096;
const REMOTE_CONTROL_SERVER_TOKEN_REFRESH_SKEW_SECS: i64 = 30;
const REMOTE_CONTROL_SERVER_TOKEN_REFRESH_SKEW_SECS: i64 = 5 * 60;
const REQUEST_ID_HEADER: &str = "x-request-id";
const OAI_REQUEST_ID_HEADER: &str = "x-oai-request-id";
const CF_RAY_HEADER: &str = "cf-ray";
pub(super) const REMOTE_CONTROL_INSTALLATION_ID_HEADER: &str = "x-codex-installation-id";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct RemoteControlEnrollment {
@@ -42,6 +34,14 @@ pub(super) struct RemoteControlEnrollment {
pub(super) server_name: String,
pub(super) remote_control_token: Option<String>,
pub(super) expires_at: Option<OffsetDateTime>,
pub(super) next_refresh_at: Option<OffsetDateTime>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum RemoteControlServerTokenRefreshRequirement {
Required,
Proactive,
NotNeeded,
}
impl RemoteControlEnrollment {
@@ -49,7 +49,9 @@ impl RemoteControlEnrollment {
&self,
request: StartRemoteControlPairingRequest,
) -> io::Result<RemoteControlPairingStartResponse> {
if self.should_refresh_server_token() {
if self.server_token_refresh_requirement()
== RemoteControlServerTokenRefreshRequirement::Required
{
return Err(pairing_unavailable_error());
}
let remote_control_token = self
@@ -142,7 +144,9 @@ impl RemoteControlEnrollment {
&self,
request: RemoteControlPairingStatusRequest,
) -> io::Result<RemoteControlPairingStatusResponse> {
if self.should_refresh_server_token() {
if self.server_token_refresh_requirement()
== RemoteControlServerTokenRefreshRequirement::Required
{
return Err(pairing_unavailable_error());
}
let remote_control_token = self
@@ -201,13 +205,35 @@ impl RemoteControlEnrollment {
})
}
pub(super) fn server_token_refresh_requirement(
&self,
) -> RemoteControlServerTokenRefreshRequirement {
self.server_token_refresh_requirement_at(OffsetDateTime::now_utc())
}
pub(super) fn should_refresh_server_token(&self) -> bool {
self.remote_control_token.is_none()
|| self.expires_at.is_none_or(|expires_at| {
expires_at.unix_timestamp()
<= OffsetDateTime::now_utc().unix_timestamp()
+ REMOTE_CONTROL_SERVER_TOKEN_REFRESH_SKEW_SECS
})
self.server_token_refresh_requirement()
!= RemoteControlServerTokenRefreshRequirement::NotNeeded
}
pub(super) fn server_token_refresh_requirement_at(
&self,
now: OffsetDateTime,
) -> RemoteControlServerTokenRefreshRequirement {
let Some(expires_at) = self.remote_control_token.as_ref().and(self.expires_at) else {
return RemoteControlServerTokenRefreshRequirement::Required;
};
if expires_at <= now {
return RemoteControlServerTokenRefreshRequirement::Required;
}
if expires_at > now + time::Duration::seconds(REMOTE_CONTROL_SERVER_TOKEN_REFRESH_SKEW_SECS)
|| self
.next_refresh_at
.is_some_and(|next_refresh_at| next_refresh_at > now)
{
return RemoteControlServerTokenRefreshRequirement::NotNeeded;
}
RemoteControlServerTokenRefreshRequirement::Proactive
}
pub(super) fn clear_server_token(&mut self) {
@@ -267,6 +293,7 @@ pub(super) async fn load_persisted_remote_control_enrollment(
server_name: enrollment.server_name,
remote_control_token: None,
expires_at: None,
next_refresh_at: None,
}))
}
None => {
@@ -398,164 +425,12 @@ pub(crate) fn format_headers(headers: &HeaderMap) -> String {
format!("request-id: {request_id_str}, cf-ray: {cf_ray_str}")
}
pub(super) async fn enroll_remote_control_server(
remote_control_target: &RemoteControlTarget,
auth: &RemoteControlConnectionAuth,
installation_id: &str,
server_name: &str,
) -> io::Result<RemoteControlEnrollment> {
let enroll_url = &remote_control_target.enroll_url;
let request = EnrollRemoteServerRequest {
name: server_name.to_string(),
os: std::env::consts::OS,
arch: std::env::consts::ARCH,
app_server_version: env!("CARGO_PKG_VERSION"),
installation_id: installation_id.to_string(),
};
let enrollment_response = send_remote_control_server_request::<_, EnrollRemoteServerResponse>(
enroll_url,
auth,
installation_id,
&request,
"enroll",
"server enrollment",
)
.await?;
let mut enrollment = RemoteControlEnrollment {
remote_control_target: remote_control_target.clone(),
account_id: auth.account_id.clone(),
environment_id: enrollment_response.environment_id,
server_id: enrollment_response.server_id,
server_name: server_name.to_string(),
remote_control_token: None,
expires_at: None,
};
update_remote_control_server_token(
&mut enrollment,
enroll_url,
enrollment_response.remote_control_token,
enrollment_response.expires_at,
)?;
Ok(enrollment)
}
pub(super) async fn refresh_remote_control_server(
auth: &RemoteControlConnectionAuth,
installation_id: &str,
enrollment: &mut RemoteControlEnrollment,
) -> io::Result<()> {
let refresh_url = enrollment.remote_control_target.refresh_url.clone();
let request = RefreshRemoteServerRequest {
server_id: enrollment.server_id.clone(),
installation_id: installation_id.to_string(),
};
let refreshed = send_remote_control_server_request::<_, EnrollRemoteServerResponse>(
&refresh_url,
auth,
installation_id,
&request,
"refresh",
"server refresh",
)
.await?;
if refreshed.server_id != enrollment.server_id
|| refreshed.environment_id != enrollment.environment_id
{
return Err(io::Error::other(format!(
"remote control server refresh returned mismatched enrollment: expected server_id={}, environment_id={}; got server_id={}, environment_id={}",
enrollment.server_id,
enrollment.environment_id,
refreshed.server_id,
refreshed.environment_id
)));
}
update_remote_control_server_token(
enrollment,
&refresh_url,
refreshed.remote_control_token,
refreshed.expires_at,
)
}
async fn send_remote_control_server_request<Request, Response>(
url: &str,
auth: &RemoteControlConnectionAuth,
installation_id: &str,
request: &Request,
action: &str,
response_kind: &str,
) -> io::Result<Response>
where
Request: Serialize,
Response: DeserializeOwned,
{
let client = build_reqwest_client();
let auth_headers = auth.request_headers()?;
let response = client
.post(url)
.timeout(REMOTE_CONTROL_ENROLL_TIMEOUT)
.headers(auth_headers)
.header(REMOTE_CONTROL_INSTALLATION_ID_HEADER, installation_id)
.json(request)
.send()
.await
.map_err(|err| {
io::Error::other(format!(
"failed to {action} remote control server at `{url}`: {err}"
))
})?;
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 {response_kind} response from `{url}`: {err}"
))
})?;
let body_preview = preview_remote_control_response_body(&body);
if !status.is_success() {
let headers_str = format_headers(&headers);
let error_kind = match status.as_u16() {
401 | 403 => ErrorKind::PermissionDenied,
404 => ErrorKind::NotFound,
_ => ErrorKind::Other,
};
return Err(io::Error::new(
error_kind,
format!(
"remote control {response_kind} failed at `{url}`: HTTP {status}, {headers_str}, body: {body_preview}"
),
));
}
serde_json::from_slice::<Response>(&body).map_err(|err| {
let headers_str = format_headers(&headers);
io::Error::other(format!(
"failed to parse remote control {response_kind} response from `{url}`: HTTP {status}, {headers_str}, body: {body_preview}, decode error: {err}"
))
})
}
fn update_remote_control_server_token(
enrollment: &mut RemoteControlEnrollment,
url: &str,
token: String,
expires_at: String,
) -> io::Result<()> {
let expires_at = OffsetDateTime::parse(&expires_at, &Rfc3339).map_err(|err| {
io::Error::other(format!(
"failed to parse remote control server token expiry from `{url}`: {err}"
))
})?;
enrollment.remote_control_token = Some(token);
enrollment.expires_at = Some(expires_at);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::transport::remote_control::auth::RemoteControlConnectionAuth;
use crate::transport::remote_control::protocol::normalize_remote_control_url;
use crate::transport::remote_control::server_api::enroll_remote_control_server;
use codex_state::StateRuntime;
use pretty_assertions::assert_eq;
use serde_json::json;
@@ -575,28 +450,6 @@ mod tests {
.expect("state runtime should initialize")
}
#[test]
fn remote_control_enrollment_refreshes_server_token_before_expiry() {
let expires_soon = RemoteControlEnrollment {
remote_control_target: normalize_remote_control_url("http://localhost/backend-api/")
.expect("target should normalize"),
account_id: "account-a".to_string(),
environment_id: "env_first".to_string(),
server_id: "srv_e_first".to_string(),
server_name: "first-server".to_string(),
remote_control_token: Some("expires-soon".to_string()),
expires_at: Some(OffsetDateTime::now_utc() + time::Duration::seconds(29)),
};
let expires_later = RemoteControlEnrollment {
expires_at: Some(OffsetDateTime::now_utc() + time::Duration::seconds(31)),
remote_control_token: Some("expires-later".to_string()),
..expires_soon.clone()
};
assert!(expires_soon.should_refresh_server_token());
assert!(!expires_later.should_refresh_server_token());
}
#[test]
fn preview_remote_control_response_body_redacts_server_token() {
assert_eq!(
@@ -630,6 +483,7 @@ mod tests {
server_name: "first-server".to_string(),
remote_control_token: None,
expires_at: None,
next_refresh_at: None,
};
let second_enrollment = RemoteControlEnrollment {
remote_control_target: second_target.clone(),
@@ -639,6 +493,7 @@ mod tests {
server_name: "second-server".to_string(),
remote_control_token: None,
expires_at: None,
next_refresh_at: None,
};
update_persisted_remote_control_enrollment(
@@ -714,6 +569,7 @@ mod tests {
server_name: "first-server".to_string(),
remote_control_token: None,
expires_at: None,
next_refresh_at: None,
};
let second_enrollment = RemoteControlEnrollment {
remote_control_target: second_target.clone(),
@@ -723,6 +579,7 @@ mod tests {
server_name: "second-server".to_string(),
remote_control_token: None,
expires_at: None,
next_refresh_at: None,
};
update_persisted_remote_control_enrollment(
@@ -5,6 +5,7 @@ mod desired_state;
mod enroll;
mod protocol;
mod segment;
mod server_api;
mod websocket;
use self::auth::load_remote_control_auth;
@@ -12,10 +13,10 @@ use self::auth::recover_remote_control_auth;
use self::desired_state::RemoteControlDesiredState;
use self::desired_state::acquire_persistence_lock;
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 self::server_api::enroll_remote_control_server;
use self::server_api::refresh_remote_control_server;
use crate::transport::remote_control::websocket::RemoteControlChannels;
use crate::transport::remote_control::websocket::RemoteControlStatusPublisher;
use crate::transport::remote_control::websocket::RemoteControlWebsocket;
@@ -825,27 +826,39 @@ async fn refresh_pairing_enrollment(
installation_id: &str,
enrollment: &mut RemoteControlEnrollment,
) -> io::Result<()> {
if let Err(err) = refresh_remote_control_server(auth, installation_id, enrollment).await {
if err.kind() != io::ErrorKind::PermissionDenied {
return Err(err);
}
let mut refresh_result = refresh_remote_control_server(auth, installation_id, enrollment).await;
if refresh_result
.as_ref()
.is_err_and(|err| 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);
if recover_remote_control_auth(&mut auth_recovery, &mut auth_change_rx).await {
match load_remote_control_auth(auth_manager).await {
Ok(recovered_auth) if recovered_auth.account_id == enrollment.account_id => {
*auth = recovered_auth;
refresh_result =
refresh_remote_control_server(auth, installation_id, enrollment).await;
}
Ok(_) | Err(_) => {
enrollment.clear_server_token();
refresh_result = Err(pairing_unavailable_error());
}
}
} else {
enrollment.clear_server_token();
}
*auth = load_remote_control_auth(auth_manager)
.await
.map_err(|_| pairing_unavailable_error())?;
if auth.account_id != enrollment.account_id {
return Err(pairing_unavailable_error());
}
refresh_remote_control_server(auth, installation_id, enrollment).await?
}
if replace_current_enrollment(current_enrollment, enrollment) {
Ok(())
} else {
if refresh_result
.as_ref()
.is_err_and(|err| err.kind() == io::ErrorKind::PermissionDenied)
{
enrollment.clear_server_token();
}
if !replace_current_enrollment(current_enrollment, enrollment) {
Err(pairing_unavailable_error())
} else {
refresh_result
}
}
@@ -0,0 +1,339 @@
use super::auth::RemoteControlConnectionAuth;
use super::enroll::RemoteControlEnrollment;
use super::enroll::RemoteControlServerTokenRefreshRequirement;
use super::enroll::format_headers;
use super::enroll::preview_remote_control_response_body;
use super::protocol::EnrollRemoteServerRequest;
use super::protocol::EnrollRemoteServerResponse;
use super::protocol::RefreshRemoteServerRequest;
use super::protocol::RemoteControlTarget;
use axum::http::HeaderMap;
use axum::http::StatusCode;
use codex_login::default_client::build_reqwest_client;
use rand::Rng;
use serde::Serialize;
use serde::de::DeserializeOwned;
use std::fmt;
use std::io;
use std::io::ErrorKind;
use std::time::Duration;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use tracing::warn;
const REMOTE_CONTROL_ENROLL_TIMEOUT: Duration = Duration::from_secs(30);
const REMOTE_CONTROL_SERVER_TOKEN_REFRESH_BACKOFF_MIN_SECS: u64 = 24;
const REMOTE_CONTROL_SERVER_TOKEN_REFRESH_BACKOFF_MAX_SECS: u64 = 36;
pub(super) const REMOTE_CONTROL_INSTALLATION_ID_HEADER: &str = "x-codex-installation-id";
#[derive(Debug)]
struct RemoteControlServerRequestError {
message: String,
status: Option<StatusCode>,
retry_at: Option<OffsetDateTime>,
}
impl RemoteControlServerRequestError {
fn io_error(
message: String,
status: Option<StatusCode>,
retry_at: Option<OffsetDateTime>,
timed_out: bool,
) -> io::Error {
let kind = match status {
Some(StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) => ErrorKind::PermissionDenied,
Some(StatusCode::NOT_FOUND) => ErrorKind::NotFound,
Some(status) if timed_out && !status.is_client_error() => ErrorKind::TimedOut,
None if timed_out => ErrorKind::TimedOut,
Some(_) | None => ErrorKind::Other,
};
io::Error::new(
kind,
Self {
message,
status,
retry_at,
},
)
}
fn is_transient(&self, kind: ErrorKind) -> bool {
kind == ErrorKind::TimedOut
|| self.status.is_none()
|| self.status.is_some_and(|status| {
status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error()
})
}
}
impl fmt::Display for RemoteControlServerRequestError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for RemoteControlServerRequestError {}
pub(super) async fn enroll_remote_control_server(
remote_control_target: &RemoteControlTarget,
auth: &RemoteControlConnectionAuth,
installation_id: &str,
server_name: &str,
) -> io::Result<RemoteControlEnrollment> {
let enroll_url = &remote_control_target.enroll_url;
let request = EnrollRemoteServerRequest {
name: server_name.to_string(),
os: std::env::consts::OS,
arch: std::env::consts::ARCH,
app_server_version: env!("CARGO_PKG_VERSION"),
installation_id: installation_id.to_string(),
};
let enrollment_response = send_remote_control_server_request::<_, EnrollRemoteServerResponse>(
enroll_url,
auth,
installation_id,
&request,
"enroll",
"server enrollment",
REMOTE_CONTROL_ENROLL_TIMEOUT,
)
.await?;
let mut enrollment = RemoteControlEnrollment {
remote_control_target: remote_control_target.clone(),
account_id: auth.account_id.clone(),
environment_id: enrollment_response.environment_id,
server_id: enrollment_response.server_id,
server_name: server_name.to_string(),
remote_control_token: None,
expires_at: None,
next_refresh_at: None,
};
update_remote_control_server_token(
&mut enrollment,
enroll_url,
enrollment_response.remote_control_token,
enrollment_response.expires_at,
)?;
Ok(enrollment)
}
pub(super) async fn refresh_remote_control_server(
auth: &RemoteControlConnectionAuth,
installation_id: &str,
enrollment: &mut RemoteControlEnrollment,
) -> io::Result<()> {
let now = OffsetDateTime::now_utc();
let refresh_requirement = enrollment.server_token_refresh_requirement_at(now);
if refresh_requirement == RemoteControlServerTokenRefreshRequirement::NotNeeded {
return Ok(());
}
if refresh_requirement == RemoteControlServerTokenRefreshRequirement::Required
&& let Some(next_refresh_at) = enrollment.next_refresh_at
&& next_refresh_at > now
{
return Err(io::Error::new(
ErrorKind::WouldBlock,
format!("remote control server token refresh deferred until {next_refresh_at}"),
));
}
let refresh_url = enrollment.remote_control_target.refresh_url.clone();
let request = RefreshRemoteServerRequest {
server_id: enrollment.server_id.clone(),
installation_id: installation_id.to_string(),
};
let refreshed = match send_remote_control_server_request::<_, EnrollRemoteServerResponse>(
&refresh_url,
auth,
installation_id,
&request,
"refresh",
"server refresh",
REMOTE_CONTROL_ENROLL_TIMEOUT,
)
.await
{
Ok(refreshed) => refreshed,
Err(err) => {
let Some(refresh_error) = remote_control_server_request_error(&err) else {
return Err(err);
};
if !refresh_error.is_transient(err.kind()) {
return Err(err);
}
let now = OffsetDateTime::now_utc();
let refresh_is_required = enrollment.server_token_refresh_requirement_at(now)
== RemoteControlServerTokenRefreshRequirement::Required;
let (refresh_delay, next_refresh_at) = refresh_deferral(refresh_error.retry_at, now);
enrollment.next_refresh_at = Some(next_refresh_at);
if refresh_is_required {
warn!(
refresh_url,
server_id = %enrollment.server_id,
environment_id = %enrollment.environment_id,
error = %err,
?refresh_delay,
%next_refresh_at,
"required remote control server token refresh failed; deferring next attempt"
);
return Err(err);
}
warn!(
refresh_url,
server_id = %enrollment.server_id,
environment_id = %enrollment.environment_id,
error = %err,
?refresh_delay,
%next_refresh_at,
"proactive remote control server token refresh failed; continuing with valid token"
);
return Ok(());
}
};
if refreshed.server_id != enrollment.server_id
|| refreshed.environment_id != enrollment.environment_id
{
return Err(io::Error::other(format!(
"remote control server refresh returned mismatched enrollment: expected server_id={}, environment_id={}; got server_id={}, environment_id={}",
enrollment.server_id,
enrollment.environment_id,
refreshed.server_id,
refreshed.environment_id
)));
}
update_remote_control_server_token(
enrollment,
&refresh_url,
refreshed.remote_control_token,
refreshed.expires_at,
)
}
async fn send_remote_control_server_request<Request, Response>(
url: &str,
auth: &RemoteControlConnectionAuth,
installation_id: &str,
request: &Request,
action: &str,
response_kind: &str,
timeout: Duration,
) -> io::Result<Response>
where
Request: Serialize,
Response: DeserializeOwned,
{
let client = build_reqwest_client();
let auth_headers = auth.request_headers()?;
let response = client
.post(url)
.timeout(timeout)
.headers(auth_headers)
.header(REMOTE_CONTROL_INSTALLATION_ID_HEADER, installation_id)
.json(request)
.send()
.await
.map_err(|err| {
let timed_out = err.is_timeout();
RemoteControlServerRequestError::io_error(
format!("failed to {action} remote control server at `{url}`: {err}"),
/*status*/ None,
/*retry_at*/ None,
timed_out,
)
})?;
let headers = response.headers().clone();
let status = response.status();
let retry_at = parse_retry_after(&headers, OffsetDateTime::now_utc());
let body = response.bytes().await.map_err(|err| {
let timed_out = err.is_timeout();
RemoteControlServerRequestError::io_error(
format!("failed to read remote control {response_kind} response from `{url}`: {err}"),
Some(status),
retry_at,
timed_out,
)
})?;
let body_preview = preview_remote_control_response_body(&body);
if !status.is_success() {
let headers_str = format_headers(&headers);
return Err(RemoteControlServerRequestError::io_error(
format!(
"remote control {response_kind} failed at `{url}`: HTTP {status}, {headers_str}, body: {body_preview}"
),
Some(status),
retry_at,
/*timed_out*/ false,
));
}
serde_json::from_slice::<Response>(&body).map_err(|err| {
let headers_str = format_headers(&headers);
io::Error::other(format!(
"failed to parse remote control {response_kind} response from `{url}`: HTTP {status}, {headers_str}, body: {body_preview}, decode error: {err}"
))
})
}
fn update_remote_control_server_token(
enrollment: &mut RemoteControlEnrollment,
url: &str,
token: String,
expires_at: String,
) -> io::Result<()> {
let expires_at = OffsetDateTime::parse(&expires_at, &Rfc3339).map_err(|err| {
io::Error::other(format!(
"failed to parse remote control server token expiry from `{url}`: {err}"
))
})?;
enrollment.remote_control_token = Some(token);
enrollment.expires_at = Some(expires_at);
enrollment.next_refresh_at = None;
Ok(())
}
fn remote_control_server_request_error(
err: &io::Error,
) -> Option<&RemoteControlServerRequestError> {
err.get_ref()?.downcast_ref()
}
fn parse_retry_after(headers: &HeaderMap, received_at: OffsetDateTime) -> Option<OffsetDateTime> {
let retry_after = headers
.get(axum::http::header::RETRY_AFTER)?
.to_str()
.ok()?;
let retry_at = if let Ok(seconds) = retry_after.parse::<u64>() {
let seconds = i64::try_from(seconds).ok()?;
received_at.checked_add(time::Duration::seconds(seconds))?
} else {
OffsetDateTime::from(httpdate::parse_http_date(retry_after).ok()?)
};
(retry_at > received_at).then_some(retry_at)
}
fn refresh_deferral(
retry_at: Option<OffsetDateTime>,
now: OffsetDateTime,
) -> (Duration, OffsetDateTime) {
if let Some(retry_at) = retry_at
&& let Ok(delay) = Duration::try_from(retry_at - now)
&& !delay.is_zero()
{
return (delay, retry_at);
}
let delay = remote_control_server_token_refresh_backoff();
let next_refresh_at = now + time::Duration::seconds(delay.as_secs() as i64);
(delay, next_refresh_at)
}
fn remote_control_server_token_refresh_backoff() -> Duration {
Duration::from_secs(rand::rng().random_range(
REMOTE_CONTROL_SERVER_TOKEN_REFRESH_BACKOFF_MIN_SECS
..=REMOTE_CONTROL_SERVER_TOKEN_REFRESH_BACKOFF_MAX_SECS,
))
}
#[cfg(test)]
#[path = "server_api_tests.rs"]
mod tests;
@@ -0,0 +1,284 @@
use super::*;
use crate::transport::remote_control::protocol::normalize_remote_control_url;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::time::SystemTime;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpListener;
use tokio::sync::oneshot;
const TEST_REQUEST_TIMEOUT: Duration = Duration::from_millis(100);
fn auth() -> RemoteControlConnectionAuth {
RemoteControlConnectionAuth {
auth_provider: codex_model_provider::unauthenticated_auth_provider(),
account_id: "account-a".to_string(),
}
}
fn assert_transient_timeout(err: &io::Error, expected_status: Option<StatusCode>) {
let request_error = remote_control_server_request_error(err)
.expect("request error should preserve refresh metadata");
assert_eq!(
(
err.kind(),
request_error.status,
request_error.is_transient(err.kind()),
),
(ErrorKind::TimedOut, expected_status, true)
);
}
async fn timed_out_request(partial_response: Option<&'static [u8]>) -> io::Error {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let url = format!(
"http://{}/backend-api/wham/remote/control/server/refresh",
listener
.local_addr()
.expect("listener should have a local address")
);
let (request_done_tx, request_done_rx) = oneshot::channel();
let server_task = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.expect("request should connect");
if let Some(partial_response) = partial_response {
stream
.write_all(partial_response)
.await
.expect("partial response should write");
}
request_done_rx
.await
.expect("test should report request completion");
});
let err = send_remote_control_server_request::<_, serde_json::Value>(
&url,
&auth(),
"installation-id",
&json!({"server_id": "server-id"}),
"refresh",
"server refresh",
TEST_REQUEST_TIMEOUT,
)
.await
.expect_err("incomplete response should time out");
request_done_tx
.send(())
.expect("server should wait for request completion");
server_task.await.expect("server task should finish");
err
}
fn enrollment(now: OffsetDateTime) -> RemoteControlEnrollment {
RemoteControlEnrollment {
remote_control_target: normalize_remote_control_url("http://localhost/backend-api/")
.expect("target should normalize"),
account_id: "account-a".to_string(),
environment_id: "env_first".to_string(),
server_id: "srv_e_first".to_string(),
server_name: "first-server".to_string(),
remote_control_token: Some("token".to_string()),
expires_at: Some(now + time::Duration::seconds(300)),
next_refresh_at: None,
}
}
#[test]
fn remote_control_enrollment_classifies_server_token_refresh_requirement() {
let now =
OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("test timestamp should parse");
let enrollment = enrollment(now);
let cases = [
(
enrollment.clone(),
RemoteControlServerTokenRefreshRequirement::Proactive,
),
(
RemoteControlEnrollment {
expires_at: Some(now + time::Duration::seconds(301)),
..enrollment.clone()
},
RemoteControlServerTokenRefreshRequirement::NotNeeded,
),
(
RemoteControlEnrollment {
next_refresh_at: Some(now + time::Duration::seconds(30)),
..enrollment.clone()
},
RemoteControlServerTokenRefreshRequirement::NotNeeded,
),
(
RemoteControlEnrollment {
next_refresh_at: Some(now),
..enrollment.clone()
},
RemoteControlServerTokenRefreshRequirement::Proactive,
),
(
RemoteControlEnrollment {
remote_control_token: None,
..enrollment.clone()
},
RemoteControlServerTokenRefreshRequirement::Required,
),
(
RemoteControlEnrollment {
expires_at: None,
..enrollment.clone()
},
RemoteControlServerTokenRefreshRequirement::Required,
),
(
RemoteControlEnrollment {
expires_at: Some(now),
next_refresh_at: Some(now + time::Duration::hours(1)),
..enrollment
},
RemoteControlServerTokenRefreshRequirement::Required,
),
];
for (enrollment, expected) in cases {
assert_eq!(
enrollment.server_token_refresh_requirement_at(now),
expected
);
}
}
#[test]
fn remote_control_server_request_error_classifies_status_before_timeout() {
let cases = [
(None, true, ErrorKind::TimedOut, true),
(Some(StatusCode::OK), true, ErrorKind::TimedOut, true),
(
Some(StatusCode::TOO_MANY_REQUESTS),
false,
ErrorKind::Other,
true,
),
(Some(StatusCode::BAD_GATEWAY), false, ErrorKind::Other, true),
(
Some(StatusCode::UNAUTHORIZED),
true,
ErrorKind::PermissionDenied,
false,
),
(
Some(StatusCode::FORBIDDEN),
true,
ErrorKind::PermissionDenied,
false,
),
(
Some(StatusCode::NOT_FOUND),
true,
ErrorKind::NotFound,
false,
),
(Some(StatusCode::BAD_REQUEST), true, ErrorKind::Other, false),
(None, false, ErrorKind::Other, true),
];
for (status, timed_out, expected_kind, expected_transient) in cases {
let err = RemoteControlServerRequestError::io_error(
String::new(),
status,
/*retry_at*/ None,
timed_out,
);
let request_error = remote_control_server_request_error(&err)
.expect("request error should preserve refresh metadata");
assert_eq!(
(err.kind(), request_error.is_transient(err.kind())),
(expected_kind, expected_transient)
);
}
}
#[tokio::test]
async fn request_timeout_before_response_headers_is_transient() {
let err = timed_out_request(/*partial_response*/ None).await;
assert_transient_timeout(&err, /*expected_status*/ None);
}
#[tokio::test]
async fn response_body_timeout_is_transient() {
let err = timed_out_request(Some(b"HTTP/1.1 200 OK\r\nContent-Length: 20\r\n\r\n{")).await;
assert_transient_timeout(&err, Some(StatusCode::OK));
}
#[test]
fn retry_after_supports_delta_seconds_and_http_dates() {
let now =
OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("test timestamp should parse");
let mut headers = HeaderMap::new();
headers.insert(
axum::http::header::RETRY_AFTER,
axum::http::HeaderValue::from_static("120"),
);
assert_eq!(
parse_retry_after(&headers, now),
Some(now + time::Duration::seconds(120))
);
let retry_at = now + time::Duration::seconds(90);
let retry_at_system = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_090);
headers.insert(
axum::http::header::RETRY_AFTER,
httpdate::fmt_http_date(retry_at_system)
.parse()
.expect("HTTP date should be a valid header value"),
);
assert_eq!(parse_retry_after(&headers, now), Some(retry_at));
}
#[test]
fn invalid_or_expired_retry_after_uses_bounded_fallback() {
let now =
OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("test timestamp should parse");
let mut headers = HeaderMap::new();
headers.insert(
axum::http::header::RETRY_AFTER,
axum::http::HeaderValue::from_static("invalid"),
);
assert_eq!(parse_retry_after(&headers, now), None);
headers.insert(
axum::http::header::RETRY_AFTER,
httpdate::fmt_http_date(SystemTime::UNIX_EPOCH + Duration::from_secs(1_699_999_999))
.parse()
.expect("HTTP date should be a valid header value"),
);
assert_eq!(parse_retry_after(&headers, now), None);
let expired_while_reading_body = Some(now + time::Duration::seconds(1));
for retry_at in [None, expired_while_reading_body] {
let deferred_at = now + time::Duration::seconds(2);
let (delay, next_refresh_at) = refresh_deferral(retry_at, deferred_at);
assert!(
(Duration::from_secs(REMOTE_CONTROL_SERVER_TOKEN_REFRESH_BACKOFF_MIN_SECS)
..=Duration::from_secs(REMOTE_CONTROL_SERVER_TOKEN_REFRESH_BACKOFF_MAX_SECS,))
.contains(&delay)
);
assert_eq!(
next_refresh_at,
deferred_at + time::Duration::seconds(delay.as_secs() as i64)
);
}
}
#[test]
fn http_date_retry_after_preserves_absolute_deadline() {
let received_at =
OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("test timestamp should parse");
let retry_at = received_at + time::Duration::seconds(120);
let body_read_at = received_at + time::Duration::seconds(30);
assert_eq!(
refresh_deferral(Some(retry_at), body_read_at),
(Duration::from_secs(90), retry_at)
);
}
@@ -1,5 +1,4 @@
use super::auth::REMOTE_CONTROL_ACCOUNT_ID_HEADER;
use super::enroll::REMOTE_CONTROL_INSTALLATION_ID_HEADER;
use super::enroll::RemoteControlEnrollment;
use super::enroll::load_persisted_remote_control_enrollment;
use super::enroll::update_persisted_remote_control_enrollment;
@@ -8,6 +7,7 @@ use super::protocol::ClientEvent;
use super::protocol::ClientId;
use super::protocol::StreamId;
use super::protocol::normalize_remote_control_url;
use super::server_api::REMOTE_CONTROL_INSTALLATION_ID_HEADER;
use super::websocket::REMOTE_CONTROL_PROTOCOL_VERSION;
use super::websocket::RemoteControlWebsocket;
use super::websocket::RemoteControlWebsocketConfig;
@@ -372,7 +372,7 @@ fn test_server_name() -> String {
gethostname().to_string_lossy().trim().to_string()
}
fn remote_control_handle_with_current_enrollment(
pub(super) fn remote_control_handle_with_current_enrollment(
remote_control_url: &str,
auth_manager: Arc<AuthManager>,
) -> RemoteControlHandle {
@@ -400,6 +400,7 @@ fn remote_control_handle_with_current_enrollment(
OffsetDateTime::from_unix_timestamp(33_336_362_096)
.expect("future timestamp should parse"),
),
next_refresh_at: None,
},
)));
RemoteControlHandle {
@@ -1682,6 +1683,7 @@ async fn remote_control_http_mode_refreshes_persisted_enrollment_before_connecti
server_name: "persisted-server".to_string(),
remote_control_token: None,
expires_at: None,
next_refresh_at: None,
};
update_persisted_remote_control_enrollment(
Some(state_db.as_ref()),
@@ -1802,6 +1804,7 @@ async fn remote_control_stdio_mode_waits_for_client_name_before_connecting() {
server_name: "persisted-server".to_string(),
remote_control_token: None,
expires_at: None,
next_refresh_at: None,
};
update_persisted_remote_control_enrollment(
Some(state_db.as_ref()),
@@ -1899,6 +1902,7 @@ async fn remote_control_waits_for_account_id_before_enrolling() {
server_name: expected_server_name,
remote_control_token: None,
expires_at: None,
next_refresh_at: None,
};
let (transport_event_tx, _transport_event_rx) =
@@ -1995,6 +1999,7 @@ async fn persisted_enable_does_not_follow_auth_to_an_account_without_a_preferenc
server_name: "server-a".to_string(),
remote_control_token: None,
expires_at: None,
next_refresh_at: None,
};
update_persisted_remote_control_enrollment(
Some(state_db.as_ref()),
@@ -2102,6 +2107,7 @@ async fn remote_control_http_mode_reenrolls_when_refresh_reports_stale_enrollmen
server_name: "stale-server".to_string(),
remote_control_token: None,
expires_at: None,
next_refresh_at: None,
};
let refreshed_enrollment = RemoteControlEnrollment {
remote_control_target: remote_control_target.clone(),
@@ -2111,6 +2117,7 @@ async fn remote_control_http_mode_reenrolls_when_refresh_reports_stale_enrollmen
server_name: expected_server_name,
remote_control_token: None,
expires_at: None,
next_refresh_at: None,
};
update_persisted_remote_control_enrollment(
Some(state_db.as_ref()),
@@ -2225,6 +2232,7 @@ async fn remote_control_http_mode_reenrolls_after_explicit_missing_server_404()
server_name: "stale-server".to_string(),
remote_control_token: None,
expires_at: None,
next_refresh_at: None,
};
let refreshed_enrollment = RemoteControlEnrollment {
remote_control_target: remote_control_target.clone(),
@@ -2234,6 +2242,7 @@ async fn remote_control_http_mode_reenrolls_after_explicit_missing_server_404()
server_name: expected_server_name,
remote_control_token: None,
expires_at: None,
next_refresh_at: None,
};
update_persisted_remote_control_enrollment(
Some(state_db.as_ref()),
@@ -2371,6 +2380,7 @@ async fn remote_control_http_mode_preserves_stale_enrollment_when_reenrollment_f
server_name: test_server_name(),
remote_control_token: None,
expires_at: None,
next_refresh_at: None,
};
update_persisted_remote_control_enrollment(
Some(state_db.as_ref()),
@@ -2421,6 +2431,7 @@ async fn remote_control_http_mode_preserves_stale_enrollment_when_reenrollment_f
retry_refresh_request.request_line,
"POST /backend-api/wham/remote/control/server/refresh HTTP/1.1"
);
let refresh_failed_at = OffsetDateTime::now_utc();
respond_with_status(
retry_refresh_request.stream,
"500 Internal Server Error",
@@ -2428,9 +2439,26 @@ async fn remote_control_http_mode_preserves_stale_enrollment_when_reenrollment_f
)
.await;
let current_enrollment = remote_handle
.current_enrollment
.lock()
.await
.clone()
.expect("stale enrollment should remain available");
let next_refresh_at = current_enrollment
.next_refresh_at
.expect("required refresh failure should set a retry deadline");
assert!(
(refresh_failed_at + time::Duration::seconds(24)
..=OffsetDateTime::now_utc() + time::Duration::seconds(36))
.contains(&next_refresh_at)
);
assert_eq!(
*remote_handle.current_enrollment.lock().await,
Some(stale_enrollment.clone())
current_enrollment,
RemoteControlEnrollment {
next_refresh_at: Some(next_refresh_at),
..stale_enrollment.clone()
}
);
assert_eq!(
state_db
@@ -2474,6 +2502,7 @@ async fn remote_control_http_mode_preserves_enrollment_after_generic_websocket_4
server_name: "stale-server".to_string(),
remote_control_token: None,
expires_at: None,
next_refresh_at: None,
};
update_persisted_remote_control_enrollment(
Some(state_db.as_ref()),
@@ -21,6 +21,69 @@ fn remote_control_enrollment(
OffsetDateTime::from_unix_timestamp(33_336_362_096)
.expect("future timestamp should parse"),
),
next_refresh_at: None,
}
}
async fn auth_manager_with_replacement(
codex_home: &TempDir,
replacement_account_id: &str,
) -> Arc<AuthManager> {
let mut stale_auth = remote_control_auth_dot_json(Some("account_id"));
stale_auth
.tokens
.as_mut()
.expect("stale auth should include tokens")
.access_token = "stale-token".to_string();
save_auth(
codex_home.path(),
&stale_auth,
AuthCredentialsStoreMode::File,
AuthKeyringBackendKind::default(),
)
.expect("stale auth should save");
let auth_manager = AuthManager::shared(
codex_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await;
let mut replacement_auth = remote_control_auth_dot_json(Some(replacement_account_id));
replacement_auth
.tokens
.as_mut()
.expect("replacement auth should include tokens")
.access_token = "fresh-token".to_string();
save_auth(
codex_home.path(),
&replacement_auth,
AuthCredentialsStoreMode::File,
AuthKeyringBackendKind::default(),
)
.expect("replacement auth should save");
auth_manager
}
fn pairing_response_json(server_id: &str, environment_id: &str) -> serde_json::Value {
json!({
"pairing_code": "pairing-code",
"manual_pairing_code": "ABCD-EFGH",
"server_id": server_id,
"environment_id": environment_id,
"expires_at": "3026-05-22T12:34:56Z",
})
}
fn pairing_response(environment_id: &str) -> RemoteControlPairingStartResponse {
RemoteControlPairingStartResponse {
pairing_code: "pairing-code".to_string(),
manual_pairing_code: Some("ABCD-EFGH".to_string()),
environment_id: environment_id.to_string(),
expires_at: 33_336_362_096,
}
}
@@ -147,13 +210,7 @@ async fn remote_control_handle_starts_pairing_before_websocket_connects() {
);
respond_with_json(
pairing_request.stream,
json!({
"pairing_code": "pairing-code",
"manual_pairing_code": "ABCD-EFGH",
"server_id": "srv_e_test",
"environment_id": "env_test",
"expires_at": "3026-05-22T12:34:56Z",
}),
pairing_response_json("srv_e_test", "env_test"),
)
.await;
});
@@ -178,15 +235,139 @@ async fn remote_control_handle_starts_pairing_before_websocket_connects() {
.expect("pairing should use the current server before websocket connect");
server_task.await.expect("server task should finish");
assert_eq!(
response,
RemoteControlPairingStartResponse {
pairing_code: "pairing-code".to_string(),
manual_pairing_code: Some("ABCD-EFGH".to_string()),
environment_id: "env_test".to_string(),
expires_at: 33_336_362_096,
}
assert_eq!(response, pairing_response("env_test"));
}
#[tokio::test]
async fn proactive_refresh_rate_limit_uses_valid_token_for_pairing() {
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 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_status_and_headers(
refresh_request.stream,
"429 Too Many Requests",
&[],
"rate limited",
)
.await;
let pairing_request = accept_http_request(&listener).await;
assert_eq!(
pairing_request.request_line,
"POST /backend-api/wham/remote/control/server/pair HTTP/1.1"
);
assert_eq!(
pairing_request.headers.get("authorization"),
Some(&format!("Bearer {TEST_REMOTE_CONTROL_SERVER_TOKEN}"))
);
respond_with_json(
pairing_request.stream,
pairing_response_json("srv_e_test", "env_test"),
)
.await;
});
let remote_handle = remote_control_handle_with_current_enrollment(
&remote_control_url,
remote_control_auth_manager(),
);
remote_handle
.current_enrollment
.lock()
.await
.as_mut()
.expect("current enrollment should exist")
.expires_at = Some(OffsetDateTime::now_utc() + time::Duration::minutes(4));
let response = remote_handle
.start_pairing(
RemoteControlPairingStartParams::default(),
/*app_server_client_name*/ None,
)
.await
.expect("valid token should allow pairing after proactive refresh failure");
server_task.await.expect("server task should finish");
assert_eq!(response, pairing_response("env_test"));
assert!(
remote_handle
.current_enrollment
.snapshot()
.and_then(|enrollment| enrollment.next_refresh_at)
.is_some()
);
}
#[tokio::test]
async fn required_refresh_deadline_blocks_pairing_without_request() {
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 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_status_and_headers(
refresh_request.stream,
"502 Bad Gateway",
&[("retry-after", "120")],
"upstream unavailable",
)
.await;
listener
});
let remote_handle = remote_control_handle_with_current_enrollment(
&remote_control_url,
remote_control_auth_manager(),
);
remote_handle
.current_enrollment
.lock()
.await
.as_mut()
.expect("current enrollment should exist")
.expires_at = Some(OffsetDateTime::now_utc() - time::Duration::seconds(1));
let refresh_err = remote_handle
.start_pairing(
RemoteControlPairingStartParams::default(),
/*app_server_client_name*/ None,
)
.await
.expect_err("required refresh failure should block pairing");
let listener = server_task.await.expect("server task should finish");
let next_refresh_at = remote_handle
.current_enrollment
.snapshot()
.and_then(|enrollment| enrollment.next_refresh_at)
.expect("required pairing refresh should preserve the retry deadline");
let deferred_err = remote_handle
.start_pairing(
RemoteControlPairingStartParams::default(),
/*app_server_client_name*/ None,
)
.await
.expect_err("required refresh deadline should block pairing");
assert!(refresh_err.to_string().contains("HTTP 502 Bad Gateway"));
assert_eq!(deferred_err.kind(), io::ErrorKind::WouldBlock);
assert!(
deferred_err
.to_string()
.contains(&next_refresh_at.to_string())
);
timeout(Duration::from_millis(100), listener.accept())
.await
.expect_err("pairing should not issue a request before the refresh deadline");
}
#[tokio::test]
@@ -423,13 +604,7 @@ async fn remote_control_handle_refreshes_after_pairing_auth_failure() {
);
respond_with_json(
refreshed_pairing_request.stream,
json!({
"pairing_code": "pairing-code",
"manual_pairing_code": "ABCD-EFGH",
"server_id": "srv_e_test",
"environment_id": "env_test",
"expires_at": "3026-05-22T12:34:56Z",
}),
pairing_response_json("srv_e_test", "env_test"),
)
.await;
});
@@ -447,14 +622,54 @@ async fn remote_control_handle_refreshes_after_pairing_auth_failure() {
.expect("pairing should refresh after server token auth failure");
server_task.await.expect("server task should finish");
assert_eq!(response, pairing_response("env_test"));
}
#[tokio::test]
async fn pairing_auth_failure_preserves_refresh_deadline() {
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 pairing_request = accept_http_request(&listener).await;
assert_eq!(
pairing_request.request_line,
"POST /backend-api/wham/remote/control/server/pair HTTP/1.1"
);
respond_with_status(pairing_request.stream, "401 Unauthorized", "").await;
});
let remote_handle = remote_control_handle_with_current_enrollment(
&remote_control_url,
remote_control_auth_manager(),
);
let next_refresh_at = OffsetDateTime::now_utc() + time::Duration::minutes(2);
remote_handle
.current_enrollment
.lock()
.await
.as_mut()
.expect("current enrollment should exist")
.next_refresh_at = Some(next_refresh_at);
let mut expected_enrollment = remote_handle
.current_enrollment
.snapshot()
.expect("current enrollment should exist");
expected_enrollment.clear_server_token();
let err = remote_handle
.start_pairing(
RemoteControlPairingStartParams::default(),
/*app_server_client_name*/ None,
)
.await
.expect_err("refresh deadline should throttle recovery after token rejection");
server_task.await.expect("server task should finish");
assert_eq!(err.kind(), io::ErrorKind::WouldBlock);
assert_eq!(
response,
RemoteControlPairingStartResponse {
pairing_code: "pairing-code".to_string(),
manual_pairing_code: Some("ABCD-EFGH".to_string()),
environment_id: "env_test".to_string(),
expires_at: 33_336_362_096,
}
remote_handle.current_enrollment.snapshot(),
Some(expected_enrollment)
);
}
@@ -508,53 +723,12 @@ async fn remote_control_handle_recovers_auth_before_refreshing_pairing() {
);
respond_with_json(
pairing_request.stream,
json!({
"pairing_code": "pairing-code",
"manual_pairing_code": "ABCD-EFGH",
"server_id": "srv_e_test",
"environment_id": "env_test",
"expires_at": "3026-05-22T12:34:56Z",
}),
pairing_response_json("srv_e_test", "env_test"),
)
.await;
});
let codex_home = TempDir::new().expect("temp dir should create");
let mut stale_auth = remote_control_auth_dot_json(Some("account_id"));
stale_auth
.tokens
.as_mut()
.expect("stale auth should include tokens")
.access_token = "stale-token".to_string();
save_auth(
codex_home.path(),
&stale_auth,
AuthCredentialsStoreMode::File,
AuthKeyringBackendKind::default(),
)
.expect("stale auth should save");
let auth_manager = AuthManager::shared(
codex_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await;
let mut fresh_auth = remote_control_auth_dot_json(Some("account_id"));
fresh_auth
.tokens
.as_mut()
.expect("fresh auth should include tokens")
.access_token = "fresh-token".to_string();
save_auth(
codex_home.path(),
&fresh_auth,
AuthCredentialsStoreMode::File,
AuthKeyringBackendKind::default(),
)
.expect("fresh auth should save");
let auth_manager = auth_manager_with_replacement(&codex_home, "account_id").await;
let remote_handle =
remote_control_handle_with_current_enrollment(&remote_control_url, auth_manager);
remote_handle
@@ -574,14 +748,128 @@ async fn remote_control_handle_recovers_auth_before_refreshing_pairing() {
.expect("pairing should refresh after auth recovery");
server_task.await.expect("server task should finish");
assert_eq!(response, pairing_response("env_test"));
}
#[tokio::test]
async fn pairing_publishes_refresh_deferral_after_auth_recovery() {
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_refresh_request = accept_http_request(&listener).await;
assert_eq!(
stale_refresh_request.headers.get("authorization"),
Some(&"Bearer stale-token".to_string())
);
respond_with_status(stale_refresh_request.stream, "401 Unauthorized", "").await;
let recovered_refresh_request = accept_http_request(&listener).await;
assert_eq!(
recovered_refresh_request.headers.get("authorization"),
Some(&"Bearer fresh-token".to_string())
);
respond_with_status_and_headers(
recovered_refresh_request.stream,
"502 Bad Gateway",
&[("retry-after", "120")],
"upstream unavailable",
)
.await;
});
let codex_home = TempDir::new().expect("temp dir should create");
let auth_manager = auth_manager_with_replacement(&codex_home, "account_id").await;
let remote_handle =
remote_control_handle_with_current_enrollment(&remote_control_url, auth_manager);
remote_handle
.current_enrollment
.lock()
.await
.as_mut()
.expect("current enrollment should exist")
.expires_at = Some(OffsetDateTime::now_utc() - time::Duration::seconds(1));
let refresh_started_at = OffsetDateTime::now_utc();
let refresh_err = remote_handle
.start_pairing(
RemoteControlPairingStartParams::default(),
/*app_server_client_name*/ None,
)
.await
.expect_err("required refresh should remain strict after auth recovery");
let refresh_completed_at = OffsetDateTime::now_utc();
let deferred_err = remote_handle
.start_pairing(
RemoteControlPairingStartParams::default(),
/*app_server_client_name*/ None,
)
.await
.expect_err("published deadline should throttle the next pairing refresh");
server_task.await.expect("server task should finish");
assert!(refresh_err.to_string().contains("HTTP 502 Bad Gateway"));
assert_eq!(deferred_err.kind(), io::ErrorKind::WouldBlock);
let next_refresh_at = remote_handle
.current_enrollment
.snapshot()
.and_then(|enrollment| enrollment.next_refresh_at)
.expect("required refresh failure should publish its retry deadline");
assert!(
(refresh_started_at + time::Duration::seconds(120)
..=refresh_completed_at + time::Duration::seconds(120))
.contains(&next_refresh_at)
);
}
#[tokio::test]
async fn pairing_auth_recovery_failure_publishes_cleared_server_token() {
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_refresh_request = accept_http_request(&listener).await;
assert_eq!(
stale_refresh_request.request_line,
"POST /backend-api/wham/remote/control/server/refresh HTTP/1.1"
);
assert_eq!(
stale_refresh_request.headers.get("authorization"),
Some(&"Bearer stale-token".to_string())
);
respond_with_status(stale_refresh_request.stream, "401 Unauthorized", "").await;
});
let codex_home = TempDir::new().expect("temp dir should create");
let auth_manager = auth_manager_with_replacement(&codex_home, "different_account_id").await;
let remote_handle =
remote_control_handle_with_current_enrollment(&remote_control_url, auth_manager);
remote_handle
.current_enrollment
.lock()
.await
.as_mut()
.expect("current enrollment should exist")
.expires_at = Some(OffsetDateTime::now_utc() + time::Duration::seconds(29));
let mut expected_enrollment = remote_handle
.current_enrollment
.snapshot()
.expect("current enrollment should exist");
expected_enrollment.clear_server_token();
let err = remote_handle
.start_pairing(
RemoteControlPairingStartParams::default(),
/*app_server_client_name*/ None,
)
.await
.expect_err("pairing should fail after auth changes account");
server_task.await.expect("server task should finish");
assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
assert_eq!(
response,
RemoteControlPairingStartResponse {
pairing_code: "pairing-code".to_string(),
manual_pairing_code: Some("ABCD-EFGH".to_string()),
environment_id: "env_test".to_string(),
expires_at: 33_336_362_096,
}
remote_handle.current_enrollment.snapshot(),
Some(expected_enrollment)
);
}
@@ -688,6 +976,7 @@ async fn remote_control_handle_reenrolls_after_stale_pairing_enrollment() {
server_name: test_server_name(),
remote_control_token: None,
expires_at: None,
next_refresh_at: None,
};
update_persisted_remote_control_enrollment(
Some(state_db.as_ref()),
@@ -745,13 +1034,10 @@ async fn remote_control_handle_reenrolls_after_stale_pairing_enrollment() {
);
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",
}),
pairing_response_json(
&server_refreshed_enrollment.server_id,
&server_refreshed_enrollment.environment_id,
),
)
.await;
});
@@ -764,15 +1050,7 @@ async fn remote_control_handle_reenrolls_after_stale_pairing_enrollment() {
.expect("pairing should re-enroll after stale enrollment");
server_task.await.expect("server task should finish");
assert_eq!(
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!(response, pairing_response("env_refreshed"));
assert_eq!(
state_db
.get_remote_control_enrollment(
@@ -23,12 +23,12 @@ use crate::transport::remote_control::auth::recover_remote_control_auth;
use crate::transport::remote_control::client_tracker::ClientTracker;
use crate::transport::remote_control::client_tracker::REMOTE_CONTROL_IDLE_SWEEP_INTERVAL;
use crate::transport::remote_control::enroll::RemoteControlEnrollment;
use crate::transport::remote_control::enroll::enroll_remote_control_server;
use crate::transport::remote_control::enroll::format_headers;
use crate::transport::remote_control::enroll::load_persisted_remote_control_enrollment;
use crate::transport::remote_control::enroll::preview_remote_control_response_body;
use crate::transport::remote_control::enroll::refresh_remote_control_server;
use crate::transport::remote_control::enroll::update_persisted_remote_control_enrollment;
use crate::transport::remote_control::server_api::enroll_remote_control_server;
use crate::transport::remote_control::server_api::refresh_remote_control_server;
use axum::http::HeaderValue;
use base64::Engine;
use codex_app_server_protocol::RemoteControlConnectionStatus;
@@ -1566,17 +1566,19 @@ async fn prepare_remote_control_enrollment(
)
.await?;
}
Err(err)
if err.kind() == ErrorKind::PermissionDenied
&& recover_remote_control_auth(
auth_context.auth_recovery,
auth_context.auth_change_rx,
)
.await =>
{
return Err(io::Error::other(format!(
"{err}; retrying after auth recovery"
)));
Err(err) if err.kind() == ErrorKind::PermissionDenied => {
if recover_remote_control_auth(
auth_context.auth_recovery,
auth_context.auth_change_rx,
)
.await
{
return Err(io::Error::other(format!(
"{err}; retrying after auth recovery"
)));
}
enrollment_ref.clear_server_token();
return Err(err);
}
Err(err) => return Err(err),
}
@@ -1681,13 +1683,15 @@ async fn clear_remote_control_server_token_if_matches(
enrollment: &RemoteControlEnrollment,
) -> io::Result<()> {
let mut current_enrollment = current_enrollment.lock().await;
current_enrollment
let current_enrollment = 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();
})?;
if current_enrollment.remote_control_token == enrollment.remote_control_token {
current_enrollment.clear_server_token();
}
Ok(())
}
@@ -1804,6 +1808,10 @@ fn format_remote_control_websocket_connect_error(
message
}
#[cfg(test)]
#[path = "websocket_refresh_tests.rs"]
mod refresh_tests;
#[cfg(test)]
mod tests {
use super::*;
@@ -1844,13 +1852,15 @@ mod tests {
// Windows Bazel CI can take longer than a few seconds for the websocket
// client connection attempt to reach the local test listener.
#[cfg(windows)]
const TEST_HTTP_ACCEPT_TIMEOUT: Duration = Duration::from_secs(30);
pub(super) const TEST_HTTP_ACCEPT_TIMEOUT: Duration = Duration::from_secs(30);
#[cfg(not(windows))]
const TEST_HTTP_ACCEPT_TIMEOUT: Duration = Duration::from_secs(5);
const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111";
const TEST_REMOTE_CONTROL_SERVER_TOKEN: &str = "Remote Control Token";
pub(super) const TEST_HTTP_ACCEPT_TIMEOUT: Duration = Duration::from_secs(5);
pub(super) const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111";
pub(super) const TEST_REMOTE_CONTROL_SERVER_TOKEN: &str = "Remote Control Token";
fn remote_control_enrollment(remote_control_token: Option<&str>) -> RemoteControlEnrollment {
pub(super) fn remote_control_enrollment(
remote_control_token: Option<&str>,
) -> RemoteControlEnrollment {
RemoteControlEnrollment {
remote_control_target: normalize_remote_control_url("http://localhost/backend-api/")
.expect("target should normalize"),
@@ -1861,10 +1871,11 @@ mod tests {
remote_control_token: remote_control_token.map(str::to_string),
expires_at: remote_control_token
.map(|_| time::OffsetDateTime::now_utc() + time::Duration::hours(1)),
next_refresh_at: None,
}
}
fn test_current_enrollment(
pub(super) fn test_current_enrollment(
enrollment: Option<RemoteControlEnrollment>,
) -> CurrentRemoteControlEnrollment {
Arc::new(RemoteControlEnrollmentState::new(enrollment))
@@ -1930,7 +1941,7 @@ mod tests {
));
}
fn remote_control_status_channel() -> (
pub(super) fn remote_control_status_channel() -> (
RemoteControlStatusPublisher,
watch::Receiver<RemoteControlStatusChangedNotification>,
) {
@@ -1943,7 +1954,7 @@ mod tests {
(RemoteControlStatusPublisher::new(status_tx), status_rx)
}
fn enabled_desired_state_sender() -> watch::Sender<RemoteControlDesiredState> {
pub(super) fn enabled_desired_state_sender() -> watch::Sender<RemoteControlDesiredState> {
watch::channel(RemoteControlDesiredState::Enabled {
persistence_preference: None,
})
@@ -1981,17 +1992,17 @@ mod tests {
);
}
async fn remote_control_state_runtime(codex_home: &TempDir) -> Arc<StateRuntime> {
pub(super) async fn remote_control_state_runtime(codex_home: &TempDir) -> Arc<StateRuntime> {
StateRuntime::init(codex_home.path().to_path_buf(), "test-provider".to_string())
.await
.expect("state runtime should initialize")
}
fn remote_control_auth_manager() -> Arc<AuthManager> {
pub(super) fn remote_control_auth_manager() -> Arc<AuthManager> {
auth_manager_from_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
}
fn remote_control_url_for_listener(listener: &TcpListener) -> String {
pub(super) fn remote_control_url_for_listener(listener: &TcpListener) -> String {
let addr = listener
.local_addr()
.expect("listener should have a local addr");
@@ -2126,9 +2137,10 @@ 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 current_enrollment = test_current_enrollment(Some(remote_control_enrollment(Some(
TEST_REMOTE_CONTROL_SERVER_TOKEN,
))));
let next_refresh_at = time::OffsetDateTime::now_utc() + time::Duration::minutes(2);
let mut enrollment = remote_control_enrollment(Some(TEST_REMOTE_CONTROL_SERVER_TOKEN));
enrollment.next_refresh_at = Some(next_refresh_at);
let current_enrollment = test_current_enrollment(Some(enrollment));
let (status_publisher, status_rx) = remote_control_status_channel();
let server_task = tokio::spawn(async move {
@@ -2178,6 +2190,7 @@ mod tests {
);
let mut expected_enrollment = remote_control_enrollment(/*remote_control_token*/ None);
expected_enrollment.remote_control_target = remote_control_target;
expected_enrollment.next_refresh_at = Some(next_refresh_at);
assert_eq!(*current_enrollment.lock().await, Some(expected_enrollment));
}
@@ -2318,9 +2331,12 @@ mod tests {
.await;
let mut auth_recovery = auth_manager.unauthorized_recovery();
let mut auth_change_rx = auth_manager.auth_change_receiver();
let current_enrollment = test_current_enrollment(Some(remote_control_enrollment(
/*remote_control_token*/ None,
)));
let mut expected_enrollment =
remote_control_enrollment(Some(TEST_REMOTE_CONTROL_SERVER_TOKEN));
expected_enrollment.remote_control_target = remote_control_target.clone();
expected_enrollment.expires_at =
Some(time::OffsetDateTime::now_utc() + time::Duration::minutes(4));
let current_enrollment = test_current_enrollment(Some(expected_enrollment.clone()));
let (status_publisher, status_rx) = remote_control_status_channel();
save_auth(
codex_home.path(),
@@ -2377,6 +2393,7 @@ mod tests {
.expect("token should be readable"),
"fresh-token"
);
assert_eq!(current_enrollment.snapshot(), Some(expected_enrollment));
assert!(
!auth_change_rx
.has_changed()
@@ -3411,7 +3428,7 @@ mod tests {
state.observe_client_message(envelope, wire_size_bytes)
}
async fn accept_http_request(listener: &TcpListener) -> (TcpStream, String) {
pub(super) async fn accept_http_request(listener: &TcpListener) -> (TcpStream, String) {
let (stream, _) = timeout(TEST_HTTP_ACCEPT_TIMEOUT, listener.accept())
.await
.expect("HTTP request should arrive in time")
@@ -3482,7 +3499,7 @@ mod tests {
serde_json::from_str(text.as_ref()).expect("server event should deserialize")
}
async fn respond_with_status_and_headers(
pub(super) async fn respond_with_status_and_headers(
mut stream: TcpStream,
status: &str,
headers: &[(&str, &str)],
@@ -0,0 +1,467 @@
use super::tests::TEST_HTTP_ACCEPT_TIMEOUT;
use super::tests::TEST_INSTALLATION_ID;
use super::tests::TEST_REMOTE_CONTROL_SERVER_TOKEN;
use super::tests::accept_http_request;
use super::tests::enabled_desired_state_sender;
use super::tests::remote_control_auth_manager;
use super::tests::remote_control_enrollment;
use super::tests::remote_control_state_runtime;
use super::tests::remote_control_status_channel;
use super::tests::remote_control_url_for_listener;
use super::tests::respond_with_status_and_headers;
use super::tests::test_current_enrollment;
use super::*;
use crate::transport::remote_control::protocol::normalize_remote_control_url;
use crate::transport::remote_control::tests::remote_control_handle_with_current_enrollment;
use codex_app_server_protocol::RemoteControlPairingStartParams;
use codex_app_server_protocol::RemoteControlPairingStartResponse;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::net::TcpListener;
use tokio::net::TcpStream;
use tokio::time::Duration;
use tokio::time::timeout;
use tokio_tungstenite::WebSocketStream;
use tokio_tungstenite::accept_async;
async fn connect_test_websocket(
remote_control_target: &RemoteControlTarget,
state_db: &StateRuntime,
auth_manager: &Arc<AuthManager>,
current_enrollment: &CurrentRemoteControlEnrollment,
) -> io::Result<()> {
let mut auth_recovery = auth_manager.unauthorized_recovery();
let mut auth_change_rx = auth_manager.auth_change_receiver();
let (status_publisher, _) = remote_control_status_channel();
let desired_state_tx = enabled_desired_state_sender();
let desired_state_persistence_lock = Semaphore::new(1);
connect_remote_control_websocket(
remote_control_target,
Some(state_db),
RemoteControlAuthContext {
auth_manager,
auth_recovery: &mut auth_recovery,
auth_change_rx: &mut auth_change_rx,
},
current_enrollment,
RemoteControlConnectOptions {
installation_id: TEST_INSTALLATION_ID,
server_name: "test-server",
subscribe_cursor: None,
app_server_client_name: None,
desired_state_tx: &desired_state_tx,
desired_state_persistence_lock: &desired_state_persistence_lock,
},
&status_publisher,
)
.await
.map(|_| ())
}
#[tokio::test]
async fn proactive_refresh_failure_uses_valid_token_for_websocket_connect() {
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 remote_control_target =
normalize_remote_control_url(&remote_control_url).expect("target should parse");
let server_task = tokio::spawn(async move {
let (stream, request_line) = accept_http_request(&listener).await;
assert_eq!(
request_line,
"POST /backend-api/wham/remote/control/server/refresh HTTP/1.1"
);
respond_with_status_and_headers(stream, "502 Bad Gateway", &[], "upstream unavailable")
.await;
accept_test_websocket(&listener).await
});
let codex_home = TempDir::new().expect("temp dir should create");
let state_db = remote_control_state_runtime(&codex_home).await;
let auth_manager = remote_control_auth_manager();
let mut enrollment = remote_control_enrollment(Some(TEST_REMOTE_CONTROL_SERVER_TOKEN));
enrollment.expires_at = Some(time::OffsetDateTime::now_utc() + time::Duration::minutes(4));
let current_enrollment = test_current_enrollment(Some(enrollment));
let refresh_started_at = time::OffsetDateTime::now_utc();
connect_test_websocket(
&remote_control_target,
state_db.as_ref(),
&auth_manager,
&current_enrollment,
)
.await
.expect("valid token should allow websocket connect after proactive refresh failure");
let refresh_completed_at = time::OffsetDateTime::now_utc();
let server_websocket = server_task.await.expect("server task should succeed");
let enrollment = current_enrollment
.lock()
.await
.clone()
.expect("enrollment should remain available");
assert_eq!(
enrollment.remote_control_token.as_deref(),
Some(TEST_REMOTE_CONTROL_SERVER_TOKEN)
);
let next_refresh_at = enrollment
.next_refresh_at
.expect("transient refresh should set a retry deadline");
assert!(
(refresh_started_at + time::Duration::seconds(24)
..=refresh_completed_at + time::Duration::seconds(36))
.contains(&next_refresh_at)
);
drop(server_websocket);
}
#[tokio::test]
async fn proactive_refresh_connection_failure_uses_valid_token_for_websocket_connect() {
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 remote_control_target =
normalize_remote_control_url(&remote_control_url).expect("target should parse");
let server_task = tokio::spawn(async move {
let (stream, request_line) = accept_http_request(&listener).await;
assert_eq!(
request_line,
"POST /backend-api/wham/remote/control/server/refresh HTTP/1.1"
);
drop(stream);
accept_test_websocket(&listener).await
});
let codex_home = TempDir::new().expect("temp dir should create");
let state_db = remote_control_state_runtime(&codex_home).await;
let auth_manager = remote_control_auth_manager();
let mut enrollment = remote_control_enrollment(Some(TEST_REMOTE_CONTROL_SERVER_TOKEN));
enrollment.expires_at = Some(time::OffsetDateTime::now_utc() + time::Duration::minutes(4));
let current_enrollment = test_current_enrollment(Some(enrollment));
connect_test_websocket(
&remote_control_target,
state_db.as_ref(),
&auth_manager,
&current_enrollment,
)
.await
.expect("valid token should allow websocket connect after refresh connection failure");
let server_websocket = server_task.await.expect("server task should succeed");
assert!(
current_enrollment
.snapshot()
.and_then(|enrollment| enrollment.next_refresh_at)
.is_some(),
"connection failure should set a retry deadline"
);
drop(server_websocket);
}
#[tokio::test]
async fn websocket_retry_after_throttles_pairing_refresh() {
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 remote_control_target =
normalize_remote_control_url(&remote_control_url).expect("target should parse");
let server_task = tokio::spawn(async move {
let (stream, request_line) = accept_http_request(&listener).await;
assert_eq!(
request_line,
"POST /backend-api/wham/remote/control/server/refresh HTTP/1.1"
);
respond_with_status_and_headers(
stream,
"502 Bad Gateway",
&[("retry-after", "120")],
"upstream unavailable",
)
.await;
let first_websocket = accept_test_websocket(&listener).await;
let (pairing_stream, request_line) = accept_http_request(&listener).await;
assert_eq!(
request_line,
"POST /backend-api/wham/remote/control/server/pair HTTP/1.1"
);
respond_with_status_and_headers(
pairing_stream,
"200 OK",
&[],
r#"{"pairing_code":"pairing-code","manual_pairing_code":"ABCD-EFGH","server_id":"srv_e_test","environment_id":"env_test","expires_at":"3026-05-22T12:34:56Z"}"#,
)
.await;
first_websocket
});
let codex_home = TempDir::new().expect("temp dir should create");
let state_db = remote_control_state_runtime(&codex_home).await;
let auth_manager = remote_control_auth_manager();
let mut remote_handle =
remote_control_handle_with_current_enrollment(&remote_control_url, auth_manager.clone());
remote_handle.state_db = Some(state_db.clone());
remote_handle
.current_enrollment
.lock()
.await
.as_mut()
.expect("current enrollment should exist")
.expires_at = Some(time::OffsetDateTime::now_utc() + time::Duration::minutes(4));
let current_enrollment = remote_handle.current_enrollment.clone();
let refresh_started_at = time::OffsetDateTime::now_utc();
connect_test_websocket(
&remote_control_target,
state_db.as_ref(),
&auth_manager,
&current_enrollment,
)
.await
.expect("first websocket should connect after deferred refresh");
let refresh_completed_at = time::OffsetDateTime::now_utc();
let next_refresh_at = current_enrollment
.snapshot()
.and_then(|enrollment| enrollment.next_refresh_at)
.expect("Retry-After should set a retry deadline");
assert!(
(refresh_started_at + time::Duration::seconds(120)
..=refresh_completed_at + time::Duration::seconds(120))
.contains(&next_refresh_at)
);
let pairing_response = remote_handle
.start_pairing(
RemoteControlPairingStartParams::default(),
/*app_server_client_name*/ None,
)
.await
.expect("websocket Retry-After should throttle pairing refresh");
let first_server_websocket = server_task.await.expect("server task should succeed");
assert_eq!(
pairing_response,
RemoteControlPairingStartResponse {
pairing_code: "pairing-code".to_string(),
manual_pairing_code: Some("ABCD-EFGH".to_string()),
environment_id: "env_test".to_string(),
expires_at: 33_336_362_096,
}
);
drop(first_server_websocket);
}
#[tokio::test]
async fn pairing_http_date_retry_after_throttles_websocket_refresh() {
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 remote_control_target =
normalize_remote_control_url(&remote_control_url).expect("target should parse");
let retry_after =
httpdate::fmt_http_date(std::time::SystemTime::now() + Duration::from_secs(120));
let expected_next_refresh_at = time::OffsetDateTime::from(
httpdate::parse_http_date(&retry_after).expect("Retry-After date should parse"),
);
let server_task = tokio::spawn(async move {
let (refresh_stream, request_line) = accept_http_request(&listener).await;
assert_eq!(
request_line,
"POST /backend-api/wham/remote/control/server/refresh HTTP/1.1"
);
respond_with_status_and_headers(
refresh_stream,
"502 Bad Gateway",
&[("retry-after", &retry_after)],
"upstream unavailable",
)
.await;
let (pairing_stream, request_line) = accept_http_request(&listener).await;
assert_eq!(
request_line,
"POST /backend-api/wham/remote/control/server/pair HTTP/1.1"
);
respond_with_status_and_headers(
pairing_stream,
"200 OK",
&[],
r#"{"pairing_code":"pairing-code","manual_pairing_code":"ABCD-EFGH","server_id":"srv_e_test","environment_id":"env_test","expires_at":"3026-05-22T12:34:56Z"}"#,
)
.await;
accept_test_websocket(&listener).await
});
let codex_home = TempDir::new().expect("temp dir should create");
let state_db = remote_control_state_runtime(&codex_home).await;
let auth_manager = remote_control_auth_manager();
let mut remote_handle =
remote_control_handle_with_current_enrollment(&remote_control_url, auth_manager.clone());
remote_handle.state_db = Some(state_db.clone());
remote_handle
.current_enrollment
.lock()
.await
.as_mut()
.expect("current enrollment should exist")
.expires_at = Some(time::OffsetDateTime::now_utc() + time::Duration::minutes(4));
let current_enrollment = remote_handle.current_enrollment.clone();
let pairing_response = remote_handle
.start_pairing(
RemoteControlPairingStartParams::default(),
/*app_server_client_name*/ None,
)
.await
.expect("pairing should continue after proactive refresh failure");
assert_eq!(
current_enrollment
.snapshot()
.and_then(|enrollment| enrollment.next_refresh_at),
Some(expected_next_refresh_at)
);
connect_test_websocket(
&remote_control_target,
state_db.as_ref(),
&auth_manager,
&current_enrollment,
)
.await
.expect("pairing Retry-After should throttle websocket refresh");
let server_websocket = server_task.await.expect("server task should succeed");
assert_eq!(
pairing_response,
RemoteControlPairingStartResponse {
pairing_code: "pairing-code".to_string(),
manual_pairing_code: Some("ABCD-EFGH".to_string()),
environment_id: "env_test".to_string(),
expires_at: 33_336_362_096,
}
);
drop(server_websocket);
}
async fn assert_refresh_failure_blocks_websocket(
expires_in: time::Duration,
response_delay: Duration,
) {
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 remote_control_target =
normalize_remote_control_url(&remote_control_url).expect("target should parse");
let (connects_done_tx, connects_done_rx) = oneshot::channel();
let server_task = tokio::spawn(async move {
let (stream, request_line) = accept_http_request(&listener).await;
assert_eq!(
request_line,
"POST /backend-api/wham/remote/control/server/refresh HTTP/1.1"
);
tokio::time::sleep(response_delay).await;
respond_with_status_and_headers(
stream,
"502 Bad Gateway",
&[("retry-after", "120")],
"upstream unavailable",
)
.await;
assert_no_connection_until_connect_finishes(&listener, connects_done_rx).await;
});
let codex_home = TempDir::new().expect("temp dir should create");
let state_db = remote_control_state_runtime(&codex_home).await;
let auth_manager = remote_control_auth_manager();
let mut enrollment = remote_control_enrollment(Some(TEST_REMOTE_CONTROL_SERVER_TOKEN));
enrollment.expires_at = Some(time::OffsetDateTime::now_utc() + expires_in);
let current_enrollment = test_current_enrollment(Some(enrollment));
let refresh_started_at = time::OffsetDateTime::now_utc();
let refresh_err = connect_test_websocket(
&remote_control_target,
state_db.as_ref(),
&auth_manager,
&current_enrollment,
)
.await
.expect_err("required refresh failure should block websocket connect");
let refresh_completed_at = time::OffsetDateTime::now_utc();
let deferred_err = connect_test_websocket(
&remote_control_target,
state_db.as_ref(),
&auth_manager,
&current_enrollment,
)
.await
.expect_err("required refresh deadline should block websocket reconnect");
connects_done_tx
.send(())
.expect("server should wait for connect attempts to finish");
server_task.await.expect("server task should succeed");
assert!(refresh_err.to_string().contains("HTTP 502 Bad Gateway"));
assert_eq!(deferred_err.kind(), io::ErrorKind::WouldBlock);
assert!(deferred_err.to_string().contains("refresh deferred until"));
let next_refresh_at = current_enrollment
.snapshot()
.and_then(|enrollment| enrollment.next_refresh_at)
.expect("required refresh failure should set a retry deadline");
assert!(
(refresh_started_at + time::Duration::seconds(120)
..=refresh_completed_at + time::Duration::seconds(120))
.contains(&next_refresh_at)
);
}
#[tokio::test]
async fn expired_token_refresh_failure_throttles_reconnect_without_websocket() {
assert_refresh_failure_blocks_websocket(-time::Duration::seconds(1), Duration::ZERO).await;
}
#[tokio::test]
async fn token_expiring_during_refresh_failure_throttles_reconnect_without_websocket() {
assert_refresh_failure_blocks_websocket(
time::Duration::seconds(1),
Duration::from_millis(1_200),
)
.await;
}
#[tokio::test]
async fn websocket_auth_failure_does_not_clear_rotated_server_token() {
let attempted_enrollment = remote_control_enrollment(Some("old-token"));
let mut rotated_enrollment = attempted_enrollment.clone();
rotated_enrollment.remote_control_token = Some("new-token".to_string());
rotated_enrollment.expires_at =
Some(time::OffsetDateTime::now_utc() + time::Duration::hours(1));
let current_enrollment = test_current_enrollment(Some(rotated_enrollment.clone()));
clear_remote_control_server_token_if_matches(&current_enrollment, &attempted_enrollment)
.await
.expect("matching enrollment identity should remain available");
assert_eq!(current_enrollment.snapshot(), Some(rotated_enrollment));
}
async fn accept_test_websocket(listener: &TcpListener) -> WebSocketStream<TcpStream> {
let (stream, _) = timeout(TEST_HTTP_ACCEPT_TIMEOUT, listener.accept())
.await
.expect("websocket request should arrive in time")
.expect("listener accept should succeed");
accept_async(stream)
.await
.expect("websocket handshake should succeed")
}
async fn assert_no_connection_until_connect_finishes(
listener: &TcpListener,
mut connect_done_rx: oneshot::Receiver<()>,
) {
tokio::select! {
accepted = listener.accept() => {
accepted.expect("unexpected websocket connection should be accepted");
panic!("required refresh failure must not proceed to websocket connect");
}
connect_done = &mut connect_done_rx => {
connect_done.expect("connect completion should be reported");
}
}
}
+47 -12
View File
@@ -139,7 +139,7 @@ Example with notification opt-out:
- `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 `allowProviderModelFallback` lets providers backed by an authoritative static model catalog replace an unavailable requested `model` with the catalog default; dynamic or cached catalogs preserve the requested model. 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`. Deprecated experimental `multiAgentMode` is ignored; use Ultra reasoning effort for proactive multi-agent behavior. 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`. Experimental `selectedCapabilityRoots` selects environment-owned plugin or standalone-skill roots using environment-native absolute paths. Skills found below those roots are listed and read through the owning environment. Stdio MCP servers declared by selected plugins are started in that environment, and HTTP MCP connections use that environment's HTTP client.
- `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/fork` — fork an existing thread into a new thread id by copying the stored history; pass an optional `lastTurnId` to copy history only through that turn, inclusive, and drop later turns from the fork. An in-progress `lastTurnId` is rejected. If `lastTurnId` is null while the source thread is 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. `instructionSources` lists loaded instruction files using each source environment's native absolute path syntax, including files loaded from remote environments. 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. Their deprecated experimental `multiAgentMode` field, and the corresponding thread setting, always report `explicitRequestOnly`; Ultra reasoning effort is the source of proactive multi-agent behavior.
- `thread/list` — page through stored threads; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Experimental clients can use `parentThreadId` for direct spawned children or `ancestorThreadId` for spawned descendants at any depth; the two filters are mutually exclusive. Review and Guardian threads are not included because they do not participate in that spawn-edge lifecycle. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. Subagent threads also include `parentThreadId` when the immediate parent is known.
- `thread/loaded/list` — list the thread ids currently loaded in memory.
@@ -205,6 +205,7 @@ Example with notification opt-out:
- `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text and an `allowed` flag reflecting effective requirements, 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. 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`; optional `connectTimeoutMs` overrides the WebSocket connection timeout; returns `{}` and does not change the default environment.
- `environment/info` — experimental; connect to a configured environment by `environmentId` and return its detected `shell` plus its default `cwd` as a canonical environment-native `file:` URI. Connection failures are returned as request errors.
- `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`).
- `skills/extraRoots/set` — replace the app-server process runtime extra standalone skill roots. The roots are not persisted; missing directories are accepted and simply load no skills.
@@ -212,8 +213,8 @@ Example with notification opt-out:
- `marketplace/add` — add a remote plugin marketplace from an HTTP(S) Git URL, SSH Git URL, or GitHub `owner/repo` shorthand, then persist it into the user marketplace config. Returns the installed root path plus whether the marketplace was already present.
- `marketplace/remove` — remove a configured marketplace by name from the user marketplace config, and delete its installed marketplace root when one exists.
- `marketplace/upgrade` — upgrade all configured Git plugin marketplaces, or one named marketplace when `marketplaceName` is provided. Returns selected marketplace names, upgraded roots, and per-marketplace errors.
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. Clients can explicitly request the remote `workspace-directory`, `shared-with-me`, or `created-by-me-remote` marketplace kinds. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**).
- `plugin/installed` — list installed plugin rows plus any explicitly requested local install-suggestion plugin names, without fetching the broader remote catalog. Mention surfaces can use this narrower view when they need plugin mention payloads rather than plugin-page discovery data (**under development; do not call from production clients yet**).
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, nullable remote install-policy provenance in `installPolicySource` (`WORKSPACE_SETTING` or `IMPLICIT_CANONICAL_APP`), the remote marketplace `version` and locally materialized `localVersion` when available, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. Clients can explicitly request the remote `workspace-directory`, `shared-with-me`, or `created-by-me-remote` marketplace kinds. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**).
- `plugin/installed` — list installed plugin rows plus any explicitly requested local install-suggestion plugin names, without fetching the broader remote catalog. Remote rows include nullable `installPolicySource`; local rows return `null`. Mention surfaces can use this narrower view when they need plugin mention payloads rather than plugin-page discovery data (**under development; do not call from production clients yet**).
- `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/hooks/apps/MCP server names. Remote plugin details expose the canonical `shareUrl` supplied by the remote catalog when available; it is `null` for local plugins or when the catalog omits it. This field is separate from `summary.shareContext`, which continues to describe user and workspace sharing state. Returned plugin skills include their current `enabled` state after local config filtering; bundled hooks are returned as lightweight declaration summaries keyed for correlation with `hooks/list`. Use `plugin/install`'s `appsNeedingAuth` to drive post-install authentication and `app/list`'s `isAccessible` to determine current connector accessibility (**under development; do not call from production clients yet**).
- `plugin/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle.
- `skills/changed` — notification emitted when watched local skill files change.
@@ -242,7 +243,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 `details` returned by detect. Callers may pass `source` to identify the product that initiated the import; omitted or `null` means unspecified. The response acknowledges the synchronous import phase with an `importId`. Expected migration failures are reported as per-item failures rather than JSON-RPC errors, so the server still returns that `importId` and emits `externalAgentConfig/import/completed` with the same ID once all synchronous and background work finishes. The completion notification contains type-level `itemTypeResults` with successes and failures, including raw failure messages for the client to report separately.
- `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`), the layered permission-profile allow map (`allowedPermissionProfiles`), the managed permission-profile default (`defaultPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), remote-control policy (`allowRemoteControl`; `false` force-disables remote control while `true` or `null` preserves existing behavior), 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`), remote-control policy (`allowRemoteControl`; `false` force-disables remote control while `true` or `null` preserves existing behavior), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, managed new-thread defaults (`models.newThread.model`, `models.newThread.modelReasoningEffort`, and `models.newThread.serviceTier`), and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`.
### Example: Start or resume a thread
@@ -1300,7 +1301,7 @@ Event notifications are the server-initiated event stream for thread lifecycles,
Thread realtime uses a separate thread-scoped notification surface. `thread/realtime/*` notifications are ephemeral transport events, not `ThreadItem`s, and are not returned by `thread/read`, `thread/resume`, or `thread/fork`.
Recoverable configuration and initialization warnings use the existing `configWarning` notification: `{ summary, details?, path?, range? }`. App-server may emit it during initialization for config parsing and related setup diagnostics.
Recoverable configuration and initialization warnings use the existing `configWarning` notification: `{ summary, details?, path?, range? }`. App-server may emit it during initialization for config parsing and related setup diagnostics, or to the requesting connection during `thread/start` when that thread's exec-policy rules fail to parse.
Generic runtime warnings use the `warning` notification: `{ threadId?, message }`. App-server emits this for non-fatal warnings from the core event stream, including cases where not all enabled skills are included in the model-visible skills list for a session.
@@ -1878,7 +1879,9 @@ review when allowed by configuration requirements.
Use `apps._default.default_tools_approval_mode` to set the approval mode for
tools without a per-app or per-tool override. Supported values are `"auto"`,
`"prompt"`, and `"approve"`. Tool-level `approval_mode` takes precedence over
`"prompt"`, `"writes"`, and `"approve"`. The `"writes"` mode prompts for tools
that do not advertise `readOnlyHint = true` and skips declared read-only tools.
Tool-level `approval_mode` takes precedence over
the per-app `default_tools_approval_mode`, which takes precedence over the
`apps._default` value. Managed tool requirements take precedence over all of
these settings. When none are configured, the mode defaults to `"auto"`.
@@ -1928,8 +1931,8 @@ Codex supports these authentication modes. The current mode is surfaced in `acco
- `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`, `personalAccessToken`, or `null`) and includes the current ChatGPT `planType` when available.
- `account/rateLimits/read` — fetch ChatGPT rate limits, an optional effective monthly credit limit, and the number of earned rate-limit resets currently available. Rate-limit updates arrive via `account/rateLimits/updated` (notify); the reset count is snapshot-only.
- `account/rateLimitResetCredit/consume` — consume one earned reset using a caller-provided idempotency key.
- `account/rateLimits/read` — fetch ChatGPT rate limits, an optional effective monthly credit limit, and the earned rate-limit resets currently available, including expiry details when provided by the backend. Rate-limit updates arrive via `account/rateLimits/updated` (notify); reset-credit data is snapshot-only.
- `account/rateLimitResetCredit/consume` — consume one earned reset using a caller-provided idempotency key, optionally selecting a reset-credit ID returned by `account/rateLimits/read`.
- `account/usage/read` — fetch ChatGPT account token-activity summary and daily buckets.
- `account/workspaceMessages/read` — fetch active workspace messages, including workspace notification headlines when available.
- `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.
@@ -1992,6 +1995,11 @@ Field notes:
{ "id": 3, "result": { "type": "chatgpt", "loginId": "<uuid>", "authUrl": "https://chatgpt.com/…&redirect_uri=http%3A%2F%2Flocalhost%3A<port>%2Fauth%2Fcallback" } }
```
2. Open `authUrl` in a browser; the app-server hosts the local callback.
By default, a successful callback redirects to the local success page. Clients may set
`useHostedLoginSuccessPage: true` to redirect successful callbacks that do not require
organization setup to the hosted Codex success page instead. When hosted login success is
enabled, clients may set `appBrand` to `"codex"` or `"chatgpt"` to select the matching hosted
page artwork; omitted or `null` values default to `"codex"`.
3. Wait for notifications:
```json
{ "method": "account/login/completed", "params": { "loginId": "<uuid>", "success": true, "error": null } }
@@ -2031,7 +2039,30 @@ Field notes:
```json
{ "method": "account/rateLimits/read", "id": 7 }
{ "id": 7, "result": { "rateLimits": { "primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 }, "secondary": null, "rateLimitReachedType": null }, "rateLimitResetCredits": { "availableCount": 2 } } }
{
"id": 7,
"result": {
"rateLimits": {
"primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 },
"secondary": null,
"rateLimitReachedType": null
},
"rateLimitResetCredits": {
"availableCount": 2,
"credits": [
{
"id": "RateLimitResetCredit_1",
"resetType": "codexRateLimits",
"status": "available",
"grantedAt": 1781654400,
"expiresAt": 1784246400,
"title": "Full reset (Weekly + 5 hr)",
"description": "Ready to redeem"
}
]
}
}
}
{ "method": "account/rateLimits/updated", "params": { "rateLimits": { … } } }
```
@@ -2042,23 +2073,27 @@ Field notes:
- `resetsAt` is a Unix timestamp (seconds) for the next reset.
- `rateLimitReachedType` identifies the backend-classified limit state when one has been reached.
- `individualLimit` describes the effective monthly credit limit when available. In an `account/rateLimits/read` response, `null` means no monthly limit is available. In a sparse `account/rateLimits/updated` notification, nullable account metadata may be unavailable and does not clear a previously observed value.
- `rateLimitResetCredits` contains the available earned-reset count when the backend provides it; otherwise it is `null`. Refetch `account/rateLimits/read` after consuming a reset.
- `rateLimitResetCredits` contains the available earned-reset count when the backend provides it; otherwise it is `null`.
- `rateLimitResetCredits.credits` is `null` when only the count is available. An empty array means details were fetched and no available credits were returned.
- The backend may cap `rateLimitResetCredits.credits`, so `availableCount` is the authoritative total and can be greater than the number of detail rows.
- Refetch `account/rateLimits/read` after consuming a reset.
### 8) Earned rate-limit resets (ChatGPT)
```json
{ "method": "account/rateLimitResetCredit/consume", "id": 8, "params": { "idempotencyKey": "8ae96ff3-3425-4f4c-8772-b6fd61502868" } }
{ "method": "account/rateLimitResetCredit/consume", "id": 8, "params": { "idempotencyKey": "8ae96ff3-3425-4f4c-8772-b6fd61502868", "creditId": "RateLimitResetCredit_1" } }
{ "id": 8, "result": { "outcome": "reset" } }
```
Field notes:
- `idempotencyKey` must be non-empty. A UUID is recommended for each logical redemption attempt; reuse the same value when retrying that attempt.
- `creditId` is optional. When provided, it must be a non-empty opaque ID returned by `account/rateLimits/read`; when omitted, the backend selects the next available credit.
- `reset` means a credit was consumed.
- `alreadyRedeemed` means the same redemption completed previously. Treat it as an idempotent success and refresh account limits.
- `nothingToReset` means there is no eligible rate-limit window to reset.
- `noCredit` means the account has no earned reset credits available.
- Refetch `account/rateLimits/read` after consuming a reset instead of inferring updated windows from this response.
- Refetch `account/rateLimits/read` after consuming a reset instead of inferring updated state from this response.
### 9) Workspace messages (ChatGPT)
+1
View File
@@ -12,6 +12,7 @@ pub(crate) fn auth_mode_to_api(auth_mode: AuthMode) -> ApiAuthMode {
AuthMode::ApiKey => ApiAuthMode::ApiKey,
AuthMode::Chatgpt => ApiAuthMode::Chatgpt,
AuthMode::ChatgptAuthTokens => ApiAuthMode::ChatgptAuthTokens,
AuthMode::Headers => ApiAuthMode::Headers,
AuthMode::AgentIdentity => ApiAuthMode::AgentIdentity,
AuthMode::PersonalAccessToken => ApiAuthMode::PersonalAccessToken,
AuthMode::BedrockApiKey => ApiAuthMode::BedrockApiKey,
+231 -148
View File
@@ -22,7 +22,6 @@ use codex_app_server_protocol::CommandExecutionSource;
use codex_app_server_protocol::CommandExecutionStatus;
use codex_app_server_protocol::DeprecationNoticeNotification;
use codex_app_server_protocol::DynamicToolCallParams;
use codex_app_server_protocol::DynamicToolCallStatus;
use codex_app_server_protocol::ErrorNotification;
use codex_app_server_protocol::ExecPolicyAmendment as V2ExecPolicyAmendment;
use codex_app_server_protocol::FileChangeApprovalDecision;
@@ -90,6 +89,8 @@ use codex_core::ThreadManager;
use codex_core::review_format::format_review_findings_block;
use codex_core::review_prompts;
use codex_protocol::ThreadId;
use codex_protocol::items::CollabAgentTool as CoreCollabAgentTool;
use codex_protocol::items::TurnItem as CoreTurnItem;
use codex_protocol::items::parse_hook_prompt_message;
use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile;
use codex_protocol::plan_tool::UpdatePlanArgs;
@@ -822,51 +823,8 @@ pub(crate) async fn apply_bespoke_event_handling(
on_request_permissions_response(pending_response, conversation, thread_state).await;
});
}
EventMsg::DynamicToolCallRequest(request) => {
let call_id = request.call_id;
let turn_id = request.turn_id;
let namespace = request.namespace;
let tool = request.tool;
let arguments = request.arguments;
let item = ThreadItem::DynamicToolCall {
id: call_id.clone(),
namespace: namespace.clone(),
tool: tool.clone(),
arguments: arguments.clone(),
status: DynamicToolCallStatus::InProgress,
content_items: None,
success: None,
duration_ms: None,
};
let notification = ItemStartedNotification {
thread_id: conversation_id.to_string(),
turn_id: turn_id.clone(),
started_at_ms: request.started_at_ms,
item,
};
outgoing
.send_server_notification(ServerNotification::ItemStarted(notification))
.await;
let params = DynamicToolCallParams {
thread_id: conversation_id.to_string(),
turn_id: turn_id.clone(),
call_id: call_id.clone(),
namespace,
tool: tool.clone(),
arguments: arguments.clone(),
};
let (_pending_request_id, rx) = outgoing
.send_request(ServerRequestPayload::DynamicToolCall(params))
.await;
tokio::spawn(async move {
crate::dynamic_tools::on_call_response(call_id, rx, conversation).await;
});
}
EventMsg::McpToolCallBegin(_) | EventMsg::McpToolCallEnd(_) => {
// Deprecated MCP tool-call events are still fanned out for legacy clients.
// App-server v2 receives the canonical TurnItem::McpToolCall lifecycle instead.
}
msg @ (EventMsg::DynamicToolCallResponse(_)
EventMsg::DynamicToolCallRequest(_)
| EventMsg::DynamicToolCallResponse(_)
| EventMsg::CollabAgentSpawnBegin(_)
| EventMsg::CollabAgentSpawnEnd(_)
| EventMsg::CollabAgentInteractionBegin(_)
@@ -874,9 +832,23 @@ pub(crate) async fn apply_bespoke_event_handling(
| EventMsg::CollabWaitingBegin(_)
| EventMsg::CollabWaitingEnd(_)
| EventMsg::CollabCloseBegin(_)
| EventMsg::CollabCloseEnd(_)
| EventMsg::CollabResumeBegin(_)
| EventMsg::CollabResumeEnd(_)
| EventMsg::AgentMessageContentDelta(_)
| EventMsg::SubAgentActivity(_)
| EventMsg::ExecCommandBegin(_)
| EventMsg::ExecCommandEnd(_) => {
// Deprecated item lifecycle events are still fanned out for raw-event and rollout
// compatibility consumers.
// App-server v2 receives canonical TurnItem lifecycle instead, and dispatches
// dynamic tool requests from canonical DynamicToolCall starts.
}
EventMsg::McpToolCallBegin(_) | EventMsg::McpToolCallEnd(_) => {
// Deprecated MCP tool-call events are still fanned out for raw-event and rollout
// compatibility consumers.
// App-server v2 receives the canonical TurnItem::McpToolCall lifecycle instead.
}
msg @ (EventMsg::AgentMessageContentDelta(_)
| EventMsg::PlanDelta(_)
| EventMsg::ReasoningContentDelta(_)
| EventMsg::ReasoningRawContentDelta(_)
@@ -888,43 +860,9 @@ pub(crate) async fn apply_bespoke_event_handling(
);
outgoing.send_server_notification(notification).await;
}
EventMsg::SubAgentActivity(activity) => {
if activity.kind == SubAgentActivityKind::Interrupted
&& thread_manager
.get_thread(activity.agent_thread_id)
.await
.is_err()
{
thread_watch_manager
.remove_thread(&activity.agent_thread_id.to_string())
.await;
}
let notification = item_event_to_server_notification(
EventMsg::SubAgentActivity(activity),
&conversation_id.to_string(),
&event_turn_id,
);
outgoing.send_server_notification(notification).await;
}
EventMsg::CollabCloseEnd(end_event) => {
if thread_manager
.get_thread(end_event.receiver_thread_id)
.await
.is_err()
{
thread_watch_manager
.remove_thread(&end_event.receiver_thread_id.to_string())
.await;
}
let notification = item_event_to_server_notification(
EventMsg::CollabCloseEnd(end_event),
&conversation_id.to_string(),
&event_turn_id,
);
outgoing.send_server_notification(notification).await;
}
EventMsg::ContextCompacted(..) => {
// Core still fans out this deprecated event for legacy clients;
// Core still fans out this deprecated event for raw-event and rollout compatibility
// consumers;
// v2 clients receive the canonical ContextCompaction item instead.
}
EventMsg::DeprecationNotice(event) => {
@@ -1027,10 +965,63 @@ pub(crate) async fn apply_bespoke_event_handling(
.send_server_notification(ServerNotification::ItemCompleted(completed))
.await;
}
msg @ (EventMsg::ItemStarted(_)
| EventMsg::ItemCompleted(_)
| EventMsg::PatchApplyUpdated(_)
| EventMsg::TerminalInteraction(_)) => {
EventMsg::ItemStarted(event) => {
let should_emit = match &event.item {
// Approval and guardian flows can emit the command start notification before core
// emits the canonical item. Reuse the same set to suppress that duplicate.
CoreTurnItem::CommandExecution(item) => thread_state
.lock()
.await
.turn_summary
.command_execution_started
.insert(item.id.clone()),
_ => true,
};
let dynamic_tool_call_params = match &event.item {
CoreTurnItem::DynamicToolCall(item) => Some(DynamicToolCallParams {
thread_id: conversation_id.to_string(),
turn_id: event.turn_id.clone(),
call_id: item.id.clone(),
namespace: item.namespace.clone(),
tool: item.tool.clone(),
arguments: item.arguments.clone(),
}),
_ => None,
};
if should_emit {
let notification = item_event_to_server_notification(
EventMsg::ItemStarted(event),
&conversation_id.to_string(),
&event_turn_id,
);
outgoing.send_server_notification(notification).await;
}
if let Some(params) = dynamic_tool_call_params {
let call_id = params.call_id.clone();
let (_pending_request_id, rx) = outgoing
.send_request(ServerRequestPayload::DynamicToolCall(params))
.await;
tokio::spawn(async move {
crate::dynamic_tools::on_call_response(call_id, rx, conversation).await;
});
}
}
EventMsg::ItemCompleted(event) => {
apply_canonical_item_completed_side_effects(
&thread_manager,
&thread_watch_manager,
&thread_state,
&event.item,
)
.await;
let notification = item_event_to_server_notification(
EventMsg::ItemCompleted(event),
&conversation_id.to_string(),
&event_turn_id,
);
outgoing.send_server_notification(notification).await;
}
msg @ (EventMsg::PatchApplyUpdated(_) | EventMsg::TerminalInteraction(_)) => {
let notification = item_event_to_server_notification(
msg,
&conversation_id.to_string(),
@@ -1103,36 +1094,10 @@ pub(crate) async fn apply_bespoke_event_handling(
.await;
}
EventMsg::PatchApplyBegin(_) | EventMsg::PatchApplyEnd(_) => {
// Core still fans out these deprecated events for legacy clients;
// Core still fans out these deprecated events for raw-event and rollout compatibility
// consumers;
// v2 clients receive the canonical FileChange item instead.
}
EventMsg::ExecCommandBegin(exec_command_begin_event) => {
if matches!(
exec_command_begin_event.source,
codex_protocol::protocol::ExecCommandSource::UnifiedExecInteraction
) {
// TerminalInteraction is the v2 surface for unified exec
// stdin/poll events. Suppress the legacy CommandExecution
// item so clients do not render the same wait twice.
return;
}
let item_id = exec_command_begin_event.call_id.clone();
let first_start = {
let mut state = thread_state.lock().await;
state
.turn_summary
.command_execution_started
.insert(item_id.clone())
};
if first_start {
let notification = item_event_to_server_notification(
EventMsg::ExecCommandBegin(exec_command_begin_event),
&conversation_id.to_string(),
&event_turn_id,
);
outgoing.send_server_notification(notification).await;
}
}
EventMsg::ExecCommandOutputDelta(exec_command_output_delta_event) => {
let notification = item_event_to_server_notification(
EventMsg::ExecCommandOutputDelta(exec_command_output_delta_event),
@@ -1141,31 +1106,6 @@ pub(crate) async fn apply_bespoke_event_handling(
);
outgoing.send_server_notification(notification).await;
}
EventMsg::ExecCommandEnd(exec_command_end_event) => {
let call_id = exec_command_end_event.call_id.clone();
{
let mut state = thread_state.lock().await;
state
.turn_summary
.command_execution_started
.remove(&call_id);
}
if matches!(
exec_command_end_event.source,
codex_protocol::protocol::ExecCommandSource::UnifiedExecInteraction
) {
// The paired begin event is suppressed above; keep the
// completion out of v2 as well so no orphan legacy item is
// emitted for unified exec interactions.
return;
}
let notification = item_event_to_server_notification(
EventMsg::ExecCommandEnd(exec_command_end_event),
&conversation_id.to_string(),
&event_turn_id,
);
outgoing.send_server_notification(notification).await;
}
// If this is a TurnAborted, reply to any pending interrupt requests.
EventMsg::TurnAborted(turn_aborted_event) => {
// All per-thread requests are bound to a turn, so abort them.
@@ -1369,6 +1309,52 @@ async fn emit_turn_completed_with_status(
.await;
}
async fn apply_canonical_item_completed_side_effects(
thread_manager: &Arc<ThreadManager>,
thread_watch_manager: &ThreadWatchManager,
thread_state: &Arc<Mutex<ThreadState>>,
item: &CoreTurnItem,
) {
match item {
CoreTurnItem::CommandExecution(item) => {
thread_state
.lock()
.await
.turn_summary
.command_execution_started
.remove(&item.id);
}
CoreTurnItem::SubAgentActivity(activity)
if activity.kind == SubAgentActivityKind::Interrupted =>
{
remove_missing_thread_watch(
thread_manager,
thread_watch_manager,
activity.agent_thread_id,
)
.await;
}
CoreTurnItem::CollabAgentToolCall(item) if item.tool == CoreCollabAgentTool::CloseAgent => {
for thread_id in &item.receiver_thread_ids {
remove_missing_thread_watch(thread_manager, thread_watch_manager, *thread_id).await;
}
}
_ => {}
}
}
async fn remove_missing_thread_watch(
thread_manager: &Arc<ThreadManager>,
thread_watch_manager: &ThreadWatchManager,
thread_id: ThreadId,
) {
if thread_manager.get_thread(thread_id).await.is_err() {
thread_watch_manager
.remove_thread(&thread_id.to_string())
.await;
}
}
#[allow(clippy::too_many_arguments)]
async fn start_command_execution_item(
conversation_id: &ThreadId,
@@ -2190,10 +2176,15 @@ mod tests {
use codex_app_server_protocol::AutoReviewDecisionSource;
use codex_app_server_protocol::GuardianApprovalReviewStatus;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::TurnPlanStepStatus;
use codex_login::CodexAuth;
use codex_protocol::AgentPath;
use codex_protocol::items::DynamicToolCallItem;
use codex_protocol::items::DynamicToolCallStatus as CoreDynamicToolCallStatus;
use codex_protocol::items::HookPromptFragment;
use codex_protocol::items::SubAgentActivityItem;
use codex_protocol::items::TurnItem as CoreTurnItem;
use codex_protocol::items::build_hook_prompt_message;
use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions;
use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions;
@@ -2210,11 +2201,12 @@ mod tests {
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::GuardianAssessmentEvent;
use codex_protocol::protocol::GuardianAssessmentStatus;
use codex_protocol::protocol::ItemCompletedEvent;
use codex_protocol::protocol::ItemStartedEvent;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentActivityEvent;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TokenUsageInfo;
use codex_protocol::protocol::UserMessageEvent;
@@ -2287,6 +2279,7 @@ mod tests {
cwd: test_path_buf("/tmp").abs().into(),
cli_version: "0.0.0".to_string(),
source: SessionSource::Cli,
history_mode: Default::default(),
thread_source: None,
agent_nickname: None,
agent_role: None,
@@ -3436,13 +3429,17 @@ mod tests {
apply_bespoke_event_handling(
Event {
id: "turn-1".to_string(),
msg: EventMsg::SubAgentActivity(SubAgentActivityEvent {
event_id: "activity-1".to_string(),
occurred_at_ms: 42,
agent_thread_id: child_thread_id,
agent_path: AgentPath::try_from("/root/worker")
.expect("agent path should parse"),
kind: SubAgentActivityKind::Interrupted,
msg: EventMsg::ItemCompleted(ItemCompletedEvent {
thread_id: conversation_id,
turn_id: "turn-1".to_string(),
item: CoreTurnItem::SubAgentActivity(SubAgentActivityItem {
id: "activity-1".to_string(),
kind: SubAgentActivityKind::Interrupted,
agent_thread_id: child_thread_id,
agent_path: AgentPath::try_from("/root/worker")
.expect("agent path should parse"),
}),
completed_at_ms: 42,
}),
},
conversation_id,
@@ -3486,6 +3483,92 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn canonical_dynamic_tool_start_emits_item_and_requests_client() -> Result<()> {
let codex_home = TempDir::new()?;
let config = load_default_config_for_test(&codex_home).await;
let thread_manager = Arc::new(
codex_core::test_support::thread_manager_with_models_provider_and_home(
CodexAuth::create_dummy_chatgpt_auth_for_testing(),
config.model_provider.clone(),
config.codex_home.to_path_buf(),
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
),
);
let codex_core::NewThread {
thread_id: conversation_id,
thread: conversation,
..
} = thread_manager.start_thread(config).await?;
let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY);
let outgoing = Arc::new(OutgoingMessageSender::new(
tx,
codex_analytics::AnalyticsEventsClient::disabled(),
));
let outgoing = ThreadScopedOutgoingMessageSender::new(
outgoing,
vec![ConnectionId(1)],
conversation_id,
);
apply_bespoke_event_handling(
Event {
id: "turn-1".to_string(),
msg: EventMsg::ItemStarted(ItemStartedEvent {
thread_id: conversation_id,
turn_id: "turn-1".to_string(),
item: CoreTurnItem::DynamicToolCall(DynamicToolCallItem {
id: "dynamic-1".to_string(),
namespace: Some("apps".to_string()),
tool: "lookup".to_string(),
arguments: json!({"id": "123"}),
status: CoreDynamicToolCallStatus::InProgress,
content_items: None,
success: None,
error: None,
duration: None,
}),
started_at_ms: 42,
}),
},
conversation_id,
conversation,
thread_manager,
outgoing,
new_thread_state(),
ThreadWatchManager::new(),
Arc::new(tokio::sync::Semaphore::new(/*permits*/ 1)),
"test-provider".to_string(),
)
.await;
let item_started = recv_broadcast_message(&mut rx).await?;
let OutgoingMessage::AppServerNotification(ServerNotification::ItemStarted(payload)) =
item_started
else {
bail!("unexpected message: {item_started:?}");
};
assert_eq!(payload.item.id(), "dynamic-1");
let request = recv_broadcast_message(&mut rx).await?;
let OutgoingMessage::Request(ServerRequest::DynamicToolCall { params, .. }) = request
else {
bail!("unexpected message: {request:?}");
};
assert_eq!(
params,
DynamicToolCallParams {
thread_id: conversation_id.to_string(),
turn_id: "turn-1".to_string(),
call_id: "dynamic-1".to_string(),
namespace: Some("apps".to_string()),
tool: "lookup".to_string(),
arguments: json!({"id": "123"}),
}
);
Ok(())
}
#[tokio::test]
async fn test_handle_turn_complete_emits_completed_without_error() -> Result<()> {
let conversation_id = ThreadId::new();
+1 -1
View File
@@ -22,7 +22,7 @@ use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::OutgoingMessageSender;
use crate::thread_state::ThreadStateManager;
const CURRENT_TIME_REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
const CURRENT_TIME_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
const CURRENT_TIME_POLL_INTERVAL: Duration = Duration::from_secs(1);
pub(crate) fn app_server_time_provider(
+95
View File
@@ -0,0 +1,95 @@
use std::sync::Arc;
use std::sync::RwLock;
use codex_app_server_protocol::ChatgptAuthTokensRefreshParams;
use codex_app_server_protocol::ChatgptAuthTokensRefreshReason;
use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse;
use codex_app_server_protocol::ServerRequestPayload;
use codex_login::CodexAuth;
use codex_login::ExternalAuthFuture;
use codex_login::auth::ExternalAuth;
use codex_login::auth::ExternalAuthRefreshContext;
use codex_login::auth::ExternalAuthRefreshReason;
use tokio::time::Duration;
use tokio::time::timeout;
use crate::outgoing_message::OutgoingMessageSender;
const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10);
pub(crate) struct ExternalAuthBridge {
outgoing: Arc<OutgoingMessageSender>,
auth: RwLock<CodexAuth>,
}
impl ExternalAuthBridge {
pub(crate) fn new(outgoing: Arc<OutgoingMessageSender>, auth: CodexAuth) -> Self {
Self {
outgoing,
auth: RwLock::new(auth),
}
}
async fn refresh(&self, context: ExternalAuthRefreshContext) -> std::io::Result<CodexAuth> {
let reason = match context.reason {
ExternalAuthRefreshReason::Unauthorized => ChatgptAuthTokensRefreshReason::Unauthorized,
};
let params = ChatgptAuthTokensRefreshParams {
reason,
previous_account_id: context.previous_account_id,
};
let (request_id, rx) = self
.outgoing
.send_request(ServerRequestPayload::ChatgptAuthTokensRefresh(params))
.await;
let result = match timeout(EXTERNAL_AUTH_REFRESH_TIMEOUT, rx).await {
Ok(result) => {
let result = result.map_err(|err| {
std::io::Error::other(format!("auth refresh request canceled: {err}"))
})?;
result.map_err(|err| {
std::io::Error::other(format!(
"auth refresh request failed: code={} message={}",
err.code, err.message
))
})?
}
Err(_) => {
let _canceled = self.outgoing.cancel_request(&request_id).await;
return Err(std::io::Error::other(format!(
"auth refresh request timed out after {}s",
EXTERNAL_AUTH_REFRESH_TIMEOUT.as_secs()
)));
}
};
let response: ChatgptAuthTokensRefreshResponse =
serde_json::from_value(result).map_err(std::io::Error::other)?;
let auth = CodexAuth::from_external_chatgpt_tokens(
response.access_token.as_str(),
response.chatgpt_account_id.as_str(),
response.chatgpt_plan_type.as_deref(),
)?;
*self
.auth
.write()
.map_err(|_| std::io::Error::other("external auth lock is poisoned"))? = auth.clone();
Ok(auth)
}
}
impl ExternalAuth for ExternalAuthBridge {
fn resolve(&self) -> ExternalAuthFuture<'_, CodexAuth> {
Box::pin(async {
self.auth
.read()
.map(|auth| auth.clone())
.map_err(|_| std::io::Error::other("external auth lock is poisoned"))
})
}
fn refresh(&self, context: ExternalAuthRefreshContext) -> ExternalAuthFuture<'_, CodexAuth> {
Box::pin(ExternalAuthBridge::refresh(self, context))
}
}
+11 -1
View File
@@ -80,6 +80,7 @@ use codex_arg0::Arg0DispatchPaths;
use codex_config::CloudConfigBundleLoader;
use codex_config::LoaderOverrides;
use codex_config::ThreadConfigLoader;
use codex_core::check_execpolicy_for_warnings;
use codex_core::config::Config;
use codex_core::resolve_installation_id;
use codex_exec_server::EnvironmentManager;
@@ -349,7 +350,16 @@ impl InProcessClientHandle {
/// This function sends `initialize` followed by `initialized` before returning
/// the handle, so callers receive a ready-to-use runtime. If initialize fails,
/// the runtime is shut down and an `InvalidData` error is returned.
pub async fn start(args: InProcessStartArgs) -> IoResult<InProcessClientHandle> {
pub async fn start(mut args: InProcessStartArgs) -> IoResult<InProcessClientHandle> {
if let Ok(Some(err)) = check_execpolicy_for_warnings(&args.config.config_layer_stack).await {
let (path, range) = crate::exec_policy_warning_location(&err);
args.config_warnings.push(ConfigWarningNotification {
summary: "Error parsing rules; custom rules not applied.".to_string(),
details: Some(err.to_string()),
path,
range,
});
}
let initialize = args.initialize.clone();
let client = start_uninitialized(args).await?;
+25 -16
View File
@@ -96,6 +96,7 @@ mod current_time;
mod dynamic_tools;
mod error_code;
mod extensions;
mod external_auth;
mod filters;
mod fs_watch;
mod fuzzy_file_search;
@@ -320,6 +321,16 @@ fn exec_policy_warning_location(err: &ExecPolicyError) -> (Option<String>, Optio
}
}
fn exec_policy_config_warning(err: &ExecPolicyError) -> ConfigWarningNotification {
let (path, range) = exec_policy_warning_location(err);
ConfigWarningNotification {
summary: "Error parsing rules; custom rules not applied.".to_string(),
details: Some(err.to_string()),
path,
range,
}
}
fn app_text_range(range: &CoreTextRange) -> AppTextRange {
AppTextRange {
start: AppTextPosition {
@@ -612,14 +623,7 @@ pub async fn run_main_with_transport_options(
}
if let Ok(Some(err)) = check_execpolicy_for_warnings(&config.config_layer_stack).await {
let (path, range) = exec_policy_warning_location(&err);
let message = ConfigWarningNotification {
summary: "Error parsing rules; custom rules not applied.".to_string(),
details: Some(err.to_string()),
path,
range,
};
config_warnings.push(message);
config_warnings.push(exec_policy_config_warning(&err));
}
if let Some(warning) = project_config_warning(&config) {
@@ -912,7 +916,7 @@ pub async fn run_main_with_transport_options(
async move {
let mut listen_for_threads = true;
let mut shutdown_state = ShutdownState::default();
loop {
let exit_reason = loop {
let running_turn_count = {
let running_turn_count = running_turn_count_rx.borrow();
*running_turn_count
@@ -925,7 +929,7 @@ pub async fn run_main_with_transport_options(
let _ = outbound_control_tx
.send(OutboundControlEvent::DisconnectAll)
.await;
break;
break "shutdown_requested";
}
tokio::select! {
@@ -947,7 +951,7 @@ pub async fn run_main_with_transport_options(
}
event = transport_event_rx.recv() => {
let Some(event) = event else {
break;
break "transport_channel_closed";
};
match event {
TransportEvent::ConnectionOpened {
@@ -977,7 +981,7 @@ pub async fn run_main_with_transport_options(
.await
.is_err()
{
break;
break "outbound_router_closed";
}
connections.insert(
connection_id,
@@ -1005,10 +1009,10 @@ pub async fn run_main_with_transport_options(
.await;
});
if !outbound_closed {
break;
break "outbound_router_closed";
}
if shutdown_when_no_connections && connections.is_empty() {
break;
break "last_connection_closed";
}
}
TransportEvent::IncomingMessage { connection_id, message } => {
@@ -1147,7 +1151,7 @@ pub async fn run_main_with_transport_options(
}
}
}
}
};
if !shutdown_state.forced() {
futures::future::join_all(
@@ -1162,7 +1166,12 @@ pub async fn run_main_with_transport_options(
} else {
connection_cleanup_tasks.abort();
}
info!("processor task exited (channel closed)");
info!(
exit_reason,
remaining_connection_count = connections.len(),
shutdown_forced = shutdown_state.forced(),
"processor task exited"
);
}
});
+5 -88
View File
@@ -50,9 +50,6 @@ use crate::transport::AppServerTransport;
use crate::transport::RemoteControlHandle;
use codex_analytics::AnalyticsEventsClient;
use codex_analytics::AppServerRpcTransport;
use codex_app_server_protocol::ChatgptAuthTokensRefreshParams;
use codex_app_server_protocol::ChatgptAuthTokensRefreshReason;
use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse;
use codex_app_server_protocol::ClientNotification;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ClientResponsePayload;
@@ -63,7 +60,6 @@ use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCRequest;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::ServerRequestPayload;
use codex_app_server_protocol::experimental_required_message;
use codex_arg0::Arg0DispatchPaths;
use codex_chatgpt::workspace_settings;
@@ -74,12 +70,7 @@ use codex_feedback::CodexFeedback;
use codex_goal_extension::GoalService;
use codex_home::CodexHomeUserInstructionsProvider;
use codex_login::AuthManager;
use codex_login::auth::ExternalAuth;
use codex_login::auth::ExternalAuthRefreshContext;
use codex_login::auth::ExternalAuthRefreshReason;
use codex_login::auth::ExternalAuthTokens;
use codex_protocol::ThreadId;
use codex_protocol::auth::AuthMode as LoginAuthMode;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::W3cTraceContext;
use codex_rollout::StateDbHandle;
@@ -95,7 +86,6 @@ use tracing::Instrument;
use crate::models_refresh_worker::ModelsRefreshWorker;
const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10);
const CONNECTION_RPC_DRAIN_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 30);
fn deserialize_client_request(
@@ -109,80 +99,6 @@ fn deserialize_client_request(
})
}
#[derive(Clone)]
struct ExternalAuthRefreshBridge {
outgoing: Arc<OutgoingMessageSender>,
}
impl ExternalAuthRefreshBridge {
fn map_reason(reason: ExternalAuthRefreshReason) -> ChatgptAuthTokensRefreshReason {
match reason {
ExternalAuthRefreshReason::Unauthorized => ChatgptAuthTokensRefreshReason::Unauthorized,
}
}
async fn refresh(
&self,
context: ExternalAuthRefreshContext,
) -> std::io::Result<ExternalAuthTokens> {
let params = ChatgptAuthTokensRefreshParams {
reason: Self::map_reason(context.reason),
previous_account_id: context.previous_account_id,
};
let (request_id, rx) = self
.outgoing
.send_request(ServerRequestPayload::ChatgptAuthTokensRefresh(params))
.await;
let result = match timeout(EXTERNAL_AUTH_REFRESH_TIMEOUT, rx).await {
Ok(result) => {
// Two failure scenarios:
// 1) `oneshot::Receiver` failed (sender dropped) => request canceled/channel closed.
// 2) client answered with JSON-RPC error payload => propagate code/message.
let result = result.map_err(|err| {
std::io::Error::other(format!("auth refresh request canceled: {err}"))
})?;
result.map_err(|err| {
std::io::Error::other(format!(
"auth refresh request failed: code={} message={}",
err.code, err.message
))
})?
}
Err(_) => {
let _canceled = self.outgoing.cancel_request(&request_id).await;
return Err(std::io::Error::other(format!(
"auth refresh request timed out after {}s",
EXTERNAL_AUTH_REFRESH_TIMEOUT.as_secs()
)));
}
};
let response: ChatgptAuthTokensRefreshResponse =
serde_json::from_value(result).map_err(std::io::Error::other)?;
Ok(ExternalAuthTokens::chatgpt(
response.access_token,
response.chatgpt_account_id,
response.chatgpt_plan_type,
))
}
}
impl ExternalAuth for ExternalAuthRefreshBridge {
fn auth_mode(&self) -> LoginAuthMode {
LoginAuthMode::Chatgpt
}
fn refresh(
&self,
context: ExternalAuthRefreshContext,
) -> codex_login::ExternalAuthFuture<'_, ExternalAuthTokens> {
Box::pin(ExternalAuthRefreshBridge::refresh(self, context))
}
}
pub(crate) struct MessageProcessor {
outgoing: Arc<OutgoingMessageSender>,
models_refresh_worker: ModelsRefreshWorker,
@@ -327,9 +243,6 @@ impl MessageProcessor {
remote_control_handle,
plugin_startup_tasks,
} = args;
auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge {
outgoing: outgoing.clone(),
}));
let thread_state_manager = ThreadStateManager::new();
// The thread store is intentionally process-scoped. Config reloads can
// affect per-thread behavior, but they must not move newly started,
@@ -447,7 +360,7 @@ impl MessageProcessor {
outgoing.clone(),
analytics_events_client.clone(),
Arc::clone(&config),
config_warnings,
config_warnings.clone(),
rpc_transport,
);
let marketplace_processor = MarketplaceRequestProcessor::new(
@@ -495,6 +408,7 @@ impl MessageProcessor {
state_db.clone(),
log_db,
Arc::clone(&skills_watcher),
config_warnings,
);
let turn_processor = TurnRequestProcessor::new(
auth_manager.clone(),
@@ -1037,6 +951,9 @@ impl MessageProcessor {
ClientRequest::EnvironmentAdd { params, .. } => {
self.environment_processor.environment_add(params).await
}
ClientRequest::EnvironmentInfo { params, .. } => {
self.environment_processor.environment_info(params).await
}
ClientRequest::FsReadFile { params, .. } => self
.fs_processor
.read_file(params)
+15 -1
View File
@@ -62,6 +62,9 @@ use codex_app_server_protocol::DynamicToolNamespaceTool;
use codex_app_server_protocol::DynamicToolSpec;
use codex_app_server_protocol::EnvironmentAddParams;
use codex_app_server_protocol::EnvironmentAddResponse;
use codex_app_server_protocol::EnvironmentInfoParams;
use codex_app_server_protocol::EnvironmentInfoResponse;
use codex_app_server_protocol::EnvironmentShellInfo;
use codex_app_server_protocol::ExperimentalFeature as ApiExperimentalFeature;
use codex_app_server_protocol::ExperimentalFeatureListParams;
use codex_app_server_protocol::ExperimentalFeatureListResponse;
@@ -91,6 +94,7 @@ use codex_app_server_protocol::ListMcpServerStatusResponse;
use codex_app_server_protocol::LoginAccountParams;
use codex_app_server_protocol::LoginAccountResponse;
use codex_app_server_protocol::LoginApiKeyParams;
use codex_app_server_protocol::LoginAppBrand;
use codex_app_server_protocol::LogoutAccountResponse;
use codex_app_server_protocol::MarketplaceAddParams;
use codex_app_server_protocol::MarketplaceAddResponse;
@@ -153,7 +157,10 @@ use codex_app_server_protocol::PluginSource;
use codex_app_server_protocol::PluginSummary;
use codex_app_server_protocol::PluginUninstallParams;
use codex_app_server_protocol::PluginUninstallResponse;
use codex_app_server_protocol::RateLimitResetCredit;
use codex_app_server_protocol::RateLimitResetCreditStatus;
use codex_app_server_protocol::RateLimitResetCreditsSummary;
use codex_app_server_protocol::RateLimitResetType;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ReviewDelivery as ApiReviewDelivery;
use codex_app_server_protocol::ReviewStartParams;
@@ -206,6 +213,8 @@ use codex_app_server_protocol::ThreadGoalSetResponse;
use codex_app_server_protocol::ThreadGoalStatus;
use codex_app_server_protocol::ThreadGoalUpdatedNotification;
use codex_app_server_protocol::ThreadHistoryBuilder;
#[cfg(test)]
use codex_app_server_protocol::ThreadHistoryMode;
use codex_app_server_protocol::ThreadIncrementElicitationParams;
use codex_app_server_protocol::ThreadIncrementElicitationResponse;
use codex_app_server_protocol::ThreadInjectItemsParams;
@@ -293,6 +302,8 @@ use codex_backend_client::CodexWorkspaceMessage as BackendWorkspaceMessage;
use codex_backend_client::CodexWorkspaceMessageType as BackendWorkspaceMessageType;
use codex_backend_client::CodexWorkspaceMessagesResponse as BackendWorkspaceMessagesResponse;
use codex_backend_client::ConsumeRateLimitResetCreditCode as BackendConsumeRateLimitResetCreditCode;
use codex_backend_client::RateLimitResetCreditDetails as BackendRateLimitResetCreditDetails;
use codex_backend_client::RateLimitResetCreditsDetails as BackendRateLimitResetCreditsDetails;
use codex_backend_client::RequestError as BackendRequestError;
use codex_backend_client::TokenUsageProfile;
use codex_chatgpt::connectors;
@@ -328,6 +339,7 @@ use codex_core::path_utils;
#[cfg(test)]
use codex_core::read_head_for_summary;
use codex_core::sandboxing::SandboxPermissions;
use codex_core::truncate_rollout_after_turn_id;
use codex_core::windows_sandbox::WindowsSandboxLevelExt;
use codex_core::windows_sandbox::WindowsSandboxSetupMode as CoreWindowsSandboxSetupMode;
use codex_core::windows_sandbox::WindowsSandboxSetupRequest;
@@ -368,10 +380,12 @@ use codex_feedback::FeedbackUploadOptions;
use codex_git_utils::git_diff_to_remote;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_login::AuthManager;
use codex_login::CODEX_OPEN_APP_URL;
use codex_login::CodexAuth;
use codex_login::LoginSuccessPage;
use codex_login::LoginSuccessPageBrand;
use codex_login::ServerOptions as LoginServerOptions;
use codex_login::ShutdownHandle;
use codex_login::auth::login_with_chatgpt_auth_tokens;
use codex_login::complete_device_code_login;
use codex_login::login_with_api_key;
use codex_login::oauth_client_id;
@@ -1,5 +1,6 @@
use super::*;
use crate::auth_mode::auth_mode_to_api;
use crate::external_auth::ExternalAuthBridge;
use chrono::DateTime;
mod rate_limit_resets;
@@ -9,9 +10,11 @@ const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60);
const ACCOUNT_TOKEN_USAGE_FETCH_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 10);
const ACCOUNT_WORKSPACE_MESSAGES_FETCH_TIMEOUT: Duration =
Duration::from_millis(/*millis*/ 1000);
// The override is intentionally available only in debug builds, matching the login path below.
// Login overrides are intentionally available only in debug builds.
#[cfg(debug_assertions)]
const LOGIN_ISSUER_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER";
#[cfg(debug_assertions)]
const LOGIN_OPEN_APP_URL_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_DEV_OPEN_APP_URL";
enum ActiveLogin {
Browser {
@@ -253,9 +256,25 @@ impl AccountRequestProcessor {
.await;
}
LoginAccountParams::Chatgpt {
app_brand,
codex_streamlined_login,
use_hosted_login_success_page,
} => {
self.login_chatgpt_v2(request_id, codex_streamlined_login)
let login_success_page = if use_hosted_login_success_page {
let app_brand = match app_brand.unwrap_or_default() {
LoginAppBrand::Codex => LoginSuccessPageBrand::Codex,
LoginAppBrand::Chatgpt => LoginSuccessPageBrand::Chatgpt,
};
LoginSuccessPage::Hosted {
url: CODEX_OPEN_APP_URL.parse().map_err(|err| {
internal_error(format!("invalid Codex open app URL: {err}"))
})?,
app_brand,
}
} else {
LoginSuccessPage::default()
};
self.login_chatgpt_v2(request_id, codex_streamlined_login, login_success_page)
.await;
}
LoginAccountParams::ChatgptDeviceCode => {
@@ -341,6 +360,7 @@ impl AccountRequestProcessor {
async fn login_chatgpt_common(
&self,
codex_streamlined_login: bool,
login_success_page: LoginSuccessPage,
) -> std::result::Result<LoginServerOptions, JSONRPCErrorError> {
let config = self.config.as_ref();
@@ -357,6 +377,7 @@ impl AccountRequestProcessor {
let opts = LoginServerOptions {
open_browser: false,
codex_streamlined_login,
login_success_page,
..LoginServerOptions::new(
config.codex_home.to_path_buf(),
oauth_client_id(),
@@ -374,6 +395,14 @@ impl AccountRequestProcessor {
{
opts.issuer = issuer;
}
if let LoginSuccessPage::Hosted { url, .. } = &mut opts.login_success_page
&& let Ok(open_app_url) = std::env::var(LOGIN_OPEN_APP_URL_OVERRIDE_ENV_VAR)
&& !open_app_url.trim().is_empty()
{
*url = open_app_url
.parse()
.map_err(|err| internal_error(format!("invalid Codex open app URL: {err}")))?;
}
opts
};
@@ -393,16 +422,22 @@ impl AccountRequestProcessor {
&self,
request_id: ConnectionRequestId,
codex_streamlined_login: bool,
login_success_page: LoginSuccessPage,
) {
let result = self.login_chatgpt_response(codex_streamlined_login).await;
let result = self
.login_chatgpt_response(codex_streamlined_login, login_success_page)
.await;
self.outgoing.send_result(request_id, result).await;
}
async fn login_chatgpt_response(
&self,
codex_streamlined_login: bool,
login_success_page: LoginSuccessPage,
) -> Result<LoginAccountResponse, JSONRPCErrorError> {
let opts = self.login_chatgpt_common(codex_streamlined_login).await?;
let opts = self
.login_chatgpt_common(codex_streamlined_login, login_success_page)
.await?;
let server = run_login_server(opts)
.map_err(|err| internal_error(format!("failed to start login server: {err}")))?;
let login_id = Uuid::new_v4();
@@ -474,7 +509,10 @@ impl AccountRequestProcessor {
&self,
) -> Result<LoginAccountResponse, JSONRPCErrorError> {
let opts = self
.login_chatgpt_common(/*codex_streamlined_login*/ false)
.login_chatgpt_common(
/*codex_streamlined_login*/ false,
LoginSuccessPage::default(),
)
.await?;
let device_code = request_device_code(&opts)
.await
@@ -617,14 +655,19 @@ impl AccountRequestProcessor {
)));
}
login_with_chatgpt_auth_tokens(
&self.config.codex_home,
let auth = CodexAuth::from_external_chatgpt_tokens(
&access_token,
&chatgpt_account_id,
chatgpt_plan_type.as_deref(),
)
.map_err(|err| internal_error(format!("failed to set external auth: {err}")))?;
self.auth_manager.reload().await;
self.auth_manager
.set_external_auth(Arc::new(ExternalAuthBridge::new(
Arc::clone(&self.outgoing),
auth,
)))
.await
.map_err(|err| internal_error(format!("failed to set external auth: {err}")))?;
self.config_manager.replace_cloud_config_bundle_loader(
self.auth_manager.clone(),
self.config.chatgpt_base_url.clone(),
@@ -812,7 +855,9 @@ impl AccountRequestProcessor {
let auth_mode = auth_mode_to_api(auth.api_auth_mode());
let (reported_auth_method, token_opt) = if matches!(
auth,
CodexAuth::AgentIdentity(_) | CodexAuth::PersonalAccessToken(_)
CodexAuth::Headers(_)
| CodexAuth::AgentIdentity(_)
| CodexAuth::PersonalAccessToken(_)
) || include_token
&& permanent_refresh_failure
{
@@ -891,9 +936,11 @@ impl AccountRequestProcessor {
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 response = client
.get_rate_limits_with_reset_credits()
.await
let (response, detailed_rate_limit_reset_credits) = tokio::join!(
client.get_rate_limits_with_reset_credits(),
Self::detailed_rate_limit_reset_credits(&client),
);
let response = response
.map_err(|err| internal_error(format!("failed to fetch codex rate limits: {err}")))?;
if response.rate_limits.is_empty() {
return Err(internal_error(
@@ -920,6 +967,15 @@ impl AccountRequestProcessor {
.cloned()
.unwrap_or_else(|| response.rate_limits[0].clone());
let rate_limit_reset_credits = detailed_rate_limit_reset_credits.or_else(|| {
response
.rate_limit_reset_credits
.map(|summary| RateLimitResetCreditsSummary {
available_count: summary.available_count,
credits: None,
})
});
Ok(GetAccountRateLimitsResponse {
rate_limits: rate_limits.into(),
rate_limits_by_limit_id: Some(
@@ -928,11 +984,7 @@ impl AccountRequestProcessor {
.map(|(limit_id, snapshot)| (limit_id, snapshot.into()))
.collect(),
),
rate_limit_reset_credits: response.rate_limit_reset_credits.map(|summary| {
RateLimitResetCreditsSummary {
available_count: summary.available_count,
}
}),
rate_limit_reset_credits,
})
}
@@ -1,11 +1,47 @@
use super::*;
const RATE_LIMIT_RESET_REQUEST_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 10);
const RATE_LIMIT_RESET_DETAILS_REQUEST_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 5);
#[cfg(debug_assertions)]
const RATE_LIMIT_RESET_REQUEST_TIMEOUT_ENV_VAR: &str =
"CODEX_TEST_RATE_LIMIT_RESET_REQUEST_TIMEOUT_MS";
impl AccountRequestProcessor {
pub(super) async fn detailed_rate_limit_reset_credits(
client: &BackendClient,
) -> Option<RateLimitResetCreditsSummary> {
let details = match tokio::time::timeout(
RATE_LIMIT_RESET_DETAILS_REQUEST_TIMEOUT,
client.list_rate_limit_reset_credits(),
)
.await
{
Ok(Ok(details)) => details,
Ok(Err(err)) => {
tracing::warn!(
"failed to fetch rate limit reset credit details; falling back to the usage response: {err}"
);
return None;
}
Err(_) => {
tracing::warn!(
"rate limit reset credit detail request timed out; falling back to the usage response"
);
return None;
}
};
match rate_limit_reset_credits_from_backend(details) {
Ok(summary) => Some(summary),
Err(err) => {
tracing::warn!(
"failed to parse rate limit reset credit details; falling back to the usage response: {err}"
);
None
}
}
}
pub(crate) async fn consume_account_rate_limit_reset_credit(
&self,
params: ConsumeAccountRateLimitResetCreditParams,
@@ -13,6 +49,9 @@ impl AccountRequestProcessor {
if params.idempotency_key.is_empty() {
return Err(invalid_request("idempotencyKey must not be empty"));
}
if params.credit_id.as_deref().is_some_and(str::is_empty) {
return Err(invalid_request("creditId must not be empty"));
}
let client = self.rate_limit_reset_backend_client().await?;
let request_timeout = RATE_LIMIT_RESET_REQUEST_TIMEOUT;
@@ -22,10 +61,20 @@ impl AccountRequestProcessor {
.and_then(|value| value.parse::<u64>().ok())
.map(Duration::from_millis)
.unwrap_or(request_timeout);
let response = tokio::time::timeout(
request_timeout,
client.consume_rate_limit_reset_credit(&params.idempotency_key),
)
let response = tokio::time::timeout(request_timeout, async {
match params.credit_id.as_deref() {
Some(credit_id) => {
client
.consume_rate_limit_reset_credit_by_id(&params.idempotency_key, credit_id)
.await
}
None => {
client
.consume_rate_limit_reset_credit(&params.idempotency_key)
.await
}
}
})
.await
.map_err(|_| internal_error("rate limit reset consume timed out"))?
.map_err(|err| internal_error(format!("failed to consume rate limit reset: {err}")))?;
@@ -64,3 +113,56 @@ impl AccountRequestProcessor {
.map_err(|err| internal_error(format!("failed to construct backend client: {err}")))
}
}
fn rate_limit_reset_credits_from_backend(
details: BackendRateLimitResetCreditsDetails,
) -> Result<RateLimitResetCreditsSummary, String> {
let credits = details
.credits
.into_iter()
.map(rate_limit_reset_credit_from_backend)
.collect::<Result<Vec<_>, _>>()?;
Ok(RateLimitResetCreditsSummary {
available_count: details.available_count,
credits: Some(credits),
})
}
fn rate_limit_reset_credit_from_backend(
credit: BackendRateLimitResetCreditDetails,
) -> Result<RateLimitResetCredit, String> {
let reset_type = match credit.reset_type.as_str() {
"codex_rate_limits" => RateLimitResetType::CodexRateLimits,
_ => RateLimitResetType::Unknown,
};
let status = match credit.status.as_str() {
"available" => RateLimitResetCreditStatus::Available,
"redeeming" => RateLimitResetCreditStatus::Redeeming,
"redeemed" => RateLimitResetCreditStatus::Redeemed,
_ => RateLimitResetCreditStatus::Unknown,
};
let granted_at = rate_limit_reset_credit_timestamp(&credit.granted_at)
.map_err(|err| format!("invalid granted_at for credit `{}`: {err}", credit.id))?;
let expires_at = credit
.expires_at
.as_deref()
.map(rate_limit_reset_credit_timestamp)
.transpose()
.map_err(|err| format!("invalid expires_at for credit `{}`: {err}", credit.id))?;
Ok(RateLimitResetCredit {
id: credit.id,
reset_type,
status,
granted_at,
expires_at,
title: credit.title,
description: credit.description,
})
}
fn rate_limit_reset_credit_timestamp(timestamp: &str) -> Result<i64, String> {
DateTime::parse_from_rfc3339(timestamp)
.map(|timestamp| timestamp.timestamp())
.map_err(|err| format!("failed to parse timestamp `{timestamp}`: {err}"))
}
@@ -47,6 +47,8 @@ impl AppsRequestProcessor {
request_id: &ConnectionRequestId,
params: AppsListParams,
) -> Result<Option<AppsListResponse>, JSONRPCErrorError> {
let installed_start = Instant::now();
let reload = params.force_refetch;
let thread = if let Some(thread_id) = params.thread_id.as_deref() {
let (_, loaded_thread) = self.load_thread(thread_id).await?;
Some(loaded_thread)
@@ -70,20 +72,24 @@ impl AppsRequestProcessor {
.features
.apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::uses_codex_backend))
{
return Ok(Some(AppsListResponse {
let response = AppsListResponse {
data: Vec::new(),
next_cursor: None,
}));
};
record_legacy_apps_installed_duration(installed_start, reload);
return Ok(Some(response));
}
if !self
.workspace_codex_plugins_enabled(&config, auth.as_ref())
.await
{
return Ok(Some(AppsListResponse {
let response = AppsListResponse {
data: Vec::new(),
next_cursor: None,
}));
};
record_legacy_apps_installed_duration(installed_start, reload);
return Ok(Some(response));
}
let request = request_id.clone();
@@ -103,6 +109,7 @@ impl AppsRequestProcessor {
environment_manager,
mcp_manager,
plugins_manager,
installed_start,
) => {}
}
});
@@ -113,6 +120,7 @@ impl AppsRequestProcessor {
self.shutdown_token.cancel();
}
#[allow(clippy::too_many_arguments)]
async fn apps_list_task(
outgoing: Arc<OutgoingMessageSender>,
request_id: ConnectionRequestId,
@@ -121,7 +129,9 @@ impl AppsRequestProcessor {
environment_manager: Arc<EnvironmentManager>,
mcp_manager: Arc<McpManager>,
plugins_manager: Arc<PluginsManager>,
installed_start: Instant,
) {
let reload = params.force_refetch;
let retry_params = params.clone();
let retry_config = config.clone();
let retry_environment_manager = Arc::clone(&environment_manager);
@@ -136,6 +146,9 @@ impl AppsRequestProcessor {
plugins_manager,
)
.await;
if result.is_ok() {
record_legacy_apps_installed_duration(installed_start, reload);
}
let should_retry = result
.as_ref()
.is_ok_and(|(_, codex_apps_ready)| !codex_apps_ready);
@@ -363,6 +376,20 @@ impl AppsRequestProcessor {
}
const APP_LIST_LOAD_TIMEOUT: Duration = Duration::from_secs(90);
// `app/list` is the legacy request-path baseline for the future `app/installed` endpoint;
// `path=legacy` keeps it separate from the new snapshot-backed implementation in dashboards.
const APPS_INSTALLED_DURATION_METRIC: &str = "codex.apps.installed.duration_ms";
fn record_legacy_apps_installed_duration(started_at: Instant, reload: bool) {
let reload = if reload { "true" } else { "false" };
if let Some(metrics) = codex_otel::global() {
let _ = metrics.record_duration(
APPS_INSTALLED_DURATION_METRIC,
started_at.elapsed(),
&[("path", "legacy"), ("reload", reload)],
);
}
}
enum AppListLoadResult {
Accessible(Result<AccessibleConnectorsStatus, String>),
@@ -24,9 +24,11 @@ use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::ManagedHooksRequirements;
use codex_app_server_protocol::ModelProviderCapabilitiesReadResponse;
use codex_app_server_protocol::ModelsRequirements;
use codex_app_server_protocol::NetworkDomainPermission;
use codex_app_server_protocol::NetworkRequirements;
use codex_app_server_protocol::NetworkUnixSocketPermission;
use codex_app_server_protocol::NewThreadModelDefaults;
use codex_app_server_protocol::SandboxMode;
use codex_app_server_protocol::WindowsSandboxSetupMode;
use codex_config::ConfigRequirementsToml;
@@ -375,6 +377,13 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR
.enforce_residency
.map(map_residency_requirement_to_api),
network: requirements.network.map(map_network_requirements_to_api),
models: requirements.models.map(|models| ModelsRequirements {
new_thread: models.new_thread.map(|new_thread| NewThreadModelDefaults {
model: new_thread.model,
model_reasoning_effort: new_thread.model_reasoning_effort,
service_tier: new_thread.service_tier,
}),
}),
}
}
@@ -568,7 +577,10 @@ mod tests {
use codex_app_server_protocol::WindowsSandboxSetupMode;
use codex_config::ComputerUseRequirementsToml;
use codex_config::ConfigRequirementsToml;
use codex_config::ModelsRequirementsToml;
use codex_config::NewThreadModelDefaultsToml;
use codex_config::WindowsRequirementsToml;
use codex_protocol::openai_models::ReasoningEffort;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
@@ -628,6 +640,31 @@ mod tests {
assert_eq!(mapped.allow_remote_control, Some(false));
}
#[test]
fn requirements_api_includes_new_thread_model_defaults() {
let mapped = map_requirements_toml_to_api(ConfigRequirementsToml {
models: Some(ModelsRequirementsToml {
new_thread: Some(NewThreadModelDefaultsToml {
model: Some("gpt-managed".to_string()),
model_reasoning_effort: Some(ReasoningEffort::Medium),
service_tier: Some("fast".to_string()),
}),
}),
..ConfigRequirementsToml::default()
});
let defaults = mapped
.models
.and_then(|models| models.new_thread)
.expect("new-thread defaults");
assert_eq!(defaults.model.as_deref(), Some("gpt-managed"));
assert_eq!(
defaults.model_reasoning_effort,
Some(ReasoningEffort::Medium)
);
assert_eq!(defaults.service_tier.as_deref(), Some("fast"));
}
#[test]
fn requirements_api_includes_computer_use_requirements() {
let mapped = map_requirements_toml_to_api(ConfigRequirementsToml {
@@ -26,4 +26,30 @@ impl EnvironmentRequestProcessor {
.map_err(|err| invalid_request(err.to_string()))?;
Ok(Some(EnvironmentAddResponse {}.into()))
}
pub(crate) async fn environment_info(
&self,
params: EnvironmentInfoParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
let environment_id = params.environment_id;
let environment = self
.environment_manager
.get_environment(&environment_id)
.ok_or_else(|| invalid_request(format!("unknown environment id `{environment_id}`")))?;
let info = environment.info().await.map_err(|err| {
internal_error(format!(
"failed to get info for environment `{environment_id}`: {err}"
))
})?;
Ok(Some(
EnvironmentInfoResponse {
shell: EnvironmentShellInfo {
name: info.shell.name,
path: info.shell.path,
},
cwd: info.cwd,
}
.into(),
))
}
}
@@ -15,6 +15,7 @@ use codex_models_manager::manager::RefreshStrategy;
use codex_protocol::ThreadId;
use codex_protocol::models::BaseInstructions;
use codex_protocol::protocol::MultiAgentVersion;
use codex_protocol::protocol::ThreadHistoryMode;
use codex_protocol::protocol::ThreadMemoryMode;
use codex_rollout::is_persisted_rollout_item;
use codex_thread_store::AppendThreadItemsParams;
@@ -221,6 +222,7 @@ impl ExternalAgentSessionImporter {
dynamic_tools: Vec::new(),
selected_capability_roots: Vec::new(),
multi_agent_version: Some(MultiAgentVersion::V1),
history_mode: ThreadHistoryMode::Legacy,
initial_window_id: uuid::Uuid::now_v7().to_string(),
metadata: ThreadPersistenceMetadata {
cwd: Some(cwd.clone()),

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