diff --git a/.github/dotslash-config.json b/.github/dotslash-config.json index 801e0eb662..a0297c269a 100644 --- a/.github/dotslash-config.json +++ b/.github/dotslash-config.json @@ -11,11 +11,11 @@ "path": "codex" }, "linux-x86_64": { - "regex": "^codex-x86_64-unknown-linux-musl\\.zst$", + "regex": "^codex-x86_64-unknown-linux-musl-bundle\\.tar\\.zst$", "path": "codex" }, "linux-aarch64": { - "regex": "^codex-aarch64-unknown-linux-musl\\.zst$", + "regex": "^codex-aarch64-unknown-linux-musl-bundle\\.tar\\.zst$", "path": "codex" }, "windows-x86_64": { diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index c1d3aff907..a7700ac924 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -379,6 +379,17 @@ jobs: fi done + if [[ "${{ matrix.target }}" == *linux* && "${{ matrix.bundle }}" == "primary" ]]; then + bundle_root="${RUNNER_TEMP}/codex-${{ matrix.target }}-bundle" + rm -rf "$bundle_root" + mkdir -p "$bundle_root/codex-resources" + cp "$dest/codex-${{ matrix.target }}" "$bundle_root/codex" + cp "$dest/bwrap-${{ matrix.target }}" "$bundle_root/codex-resources/bwrap" + chmod 0755 "$bundle_root/codex" "$bundle_root/codex-resources/bwrap" + tar -C "$bundle_root" -cf - codex codex-resources/bwrap | + zstd -T0 -19 -o "$dest/codex-${{ matrix.target }}-bundle.tar.zst" + fi + if [[ "${{ matrix.build_dmg }}" == "true" ]]; then cp target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg "$dest/codex-${{ matrix.target }}.dmg" fi @@ -402,7 +413,7 @@ jobs: base="$(basename "$f")" # Skip files that are already archives (shouldn't happen, but be # safe). - if [[ "$base" == *.tar.gz || "$base" == *.zip || "$base" == *.dmg ]]; then + if [[ "$base" == *.tar.gz || "$base" == *.tar.zst || "$base" == *.zip || "$base" == *.dmg ]]; then continue fi @@ -422,8 +433,8 @@ jobs: - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: ${{ matrix.artifact_name }} - # Upload the per-binary .zst files as well as the new .tar.gz - # equivalents we generated in the previous step. + # Upload the per-binary .zst files, .tar.gz equivalents, and any + # prebuilt archives staged above. path: | codex-rs/dist/${{ matrix.target }}/* diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 1b92b5afd0..f78f27ffa3 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1944,6 +1944,7 @@ dependencies = [ "pretty_assertions", "serde", "serde_json", + "tempfile", "tokio", "tokio-tungstenite", "toml 0.9.11+spec-1.1.0", @@ -2125,6 +2126,17 @@ dependencies = [ "serde_with", ] +[[package]] +name = "codex-builtin-mcps" +version = "0.0.0" +dependencies = [ + "anyhow", + "codex-config", + "codex-memories-mcp", + "codex-utils-absolute-path", + "pretty_assertions", +] + [[package]] name = "codex-bwrap" version = "0.0.0" @@ -2170,6 +2182,7 @@ dependencies = [ "codex-app-server-protocol", "codex-app-server-test-client", "codex-arg0", + "codex-builtin-mcps", "codex-chatgpt", "codex-cloud-tasks", "codex-config", @@ -2997,6 +3010,7 @@ dependencies = [ "async-channel", "codex-api", "codex-async-utils", + "codex-builtin-mcps", "codex-config", "codex-exec-server", "codex-login", @@ -3118,6 +3132,19 @@ dependencies = [ "wiremock", ] +[[package]] +name = "codex-message-history" +version = "0.0.0" +dependencies = [ + "codex-config", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", +] + [[package]] name = "codex-model-provider" version = "0.0.0" @@ -3660,6 +3687,7 @@ dependencies = [ "codex-install-context", "codex-login", "codex-mcp", + "codex-message-history", "codex-model-provider", "codex-model-provider-info", "codex-models-manager", @@ -4247,6 +4275,7 @@ dependencies = [ "reqwest", "serde_json", "shlex", + "similar", "tempfile", "tokio", "tokio-tungstenite", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index a58655ed20..f1673d3bab 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -5,6 +5,7 @@ members = [ "agent-graph-store", "agent-identity", "backend-client", + "builtin-mcps", "bwrap", "ansi-escape", "async-utils", @@ -138,6 +139,7 @@ codex-apply-patch = { path = "apply-patch" } codex-arg0 = { path = "arg0" } codex-async-utils = { path = "async-utils" } codex-backend-client = { path = "backend-client" } +codex-builtin-mcps = { path = "builtin-mcps" } codex-chatgpt = { path = "chatgpt" } codex-cli = { path = "cli" } codex-client = { path = "codex-client" } @@ -170,6 +172,7 @@ codex-keyring-store = { path = "keyring-store" } codex-linux-sandbox = { path = "linux-sandbox" } codex-lmstudio = { path = "lmstudio" } codex-login = { path = "login" } +codex-message-history = { path = "message-history" } codex-memories-mcp = { path = "memories/mcp" } codex-memories-read = { path = "memories/read" } codex-memories-write = { path = "memories/write" } diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 823dcace91..121a378566 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -128,6 +128,7 @@ fn sample_thread_with_metadata( ) -> Thread { Thread { id: thread_id.to_string(), + session_id: format!("session-{thread_id}"), forked_from_id: None, preview: "first prompt".to_string(), ephemeral, diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index c36b5cf9a7..3021d558d6 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -76,6 +76,7 @@ fn sample_thread_archive_request() -> ClientRequest { fn sample_thread(thread_id: &str) -> Thread { Thread { id: thread_id.to_string(), + session_id: format!("session-{thread_id}"), forked_from_id: None, preview: "first prompt".to_string(), ephemeral: false, diff --git a/codex-rs/app-server-client/Cargo.toml b/codex-rs/app-server-client/Cargo.toml index d9c1ade097..fee29db47e 100644 --- a/codex-rs/app-server-client/Cargo.toml +++ b/codex-rs/app-server-client/Cargo.toml @@ -33,4 +33,5 @@ url = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } serde_json = { workspace = true } +tempfile = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 0911cc448d..6e10cef255 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -72,12 +72,9 @@ pub mod legacy_core { pub use codex_core::DEFAULT_AGENTS_MD_FILENAME; pub use codex_core::LOCAL_AGENTS_MD_FILENAME; pub use codex_core::McpManager; - pub use codex_core::append_message_history_entry; pub use codex_core::check_execpolicy_for_warnings; pub use codex_core::format_exec_policy_error_with_source; pub use codex_core::grant_read_root_non_elevated; - pub use codex_core::lookup_message_history_entry; - pub use codex_core::message_history_metadata; pub use codex_core::web_search_detail; pub mod config { @@ -954,9 +951,13 @@ mod tests { use codex_app_server_protocol::ToolRequestUserInputParams; use codex_app_server_protocol::ToolRequestUserInputQuestion; use codex_core::config::ConfigBuilder; + use codex_core::init_state_db_from_config; use futures::SinkExt; use futures::StreamExt; use pretty_assertions::assert_eq; + use std::ops::Deref; + use std::path::Path; + use tempfile::TempDir; use tokio::net::TcpListener; use tokio::time::Duration; use tokio::time::timeout; @@ -975,19 +976,59 @@ mod tests { } } + async fn build_test_config_for_codex_home(codex_home: &Path) -> Config { + match ConfigBuilder::default() + .codex_home(codex_home.to_path_buf()) + .build() + .await + { + Ok(config) => config, + Err(_) => Config::load_default_with_cli_overrides_for_codex_home( + codex_home.to_path_buf(), + Vec::new(), + ) + .await + .expect("default config should load"), + } + } + + struct TestClient { + _codex_home: TempDir, + client: InProcessAppServerClient, + } + + impl Deref for TestClient { + type Target = InProcessAppServerClient; + + fn deref(&self) -> &Self::Target { + &self.client + } + } + + impl TestClient { + async fn shutdown(self) -> IoResult<()> { + self.client.shutdown().await + } + } + async fn start_test_client_with_capacity( session_source: SessionSource, channel_capacity: usize, - ) -> InProcessAppServerClient { - InProcessAppServerClient::start(InProcessClientStartArgs { + ) -> TestClient { + let codex_home = TempDir::new().expect("temp dir"); + let config = Arc::new(build_test_config_for_codex_home(codex_home.path()).await); + let state_db = init_state_db_from_config(config.as_ref()) + .await + .expect("state db should initialize for in-process test"); + let client = InProcessAppServerClient::start(InProcessClientStartArgs { arg0_paths: Arg0DispatchPaths::default(), - config: Arc::new(build_test_config().await), + config, cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), cloud_requirements: CloudRequirementsLoader::default(), feedback: CodexFeedback::new(), log_db: None, - state_db: None, + state_db: Some(state_db), environment_manager: Arc::new(EnvironmentManager::default_for_tests()), config_warnings: Vec::new(), session_source, @@ -999,10 +1040,15 @@ mod tests { channel_capacity, }) .await - .expect("in-process app-server client should start") + .expect("in-process app-server client should start"); + + TestClient { + _codex_home: codex_home, + client, + } } - async fn start_test_client(session_source: SessionSource) -> InProcessAppServerClient { + async fn start_test_client(session_source: SessionSource) -> TestClient { start_test_client_with_capacity(session_source, DEFAULT_IN_PROCESS_CHANNEL_CAPACITY).await } diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 75df89211d..36596807ba 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2197,11 +2197,37 @@ ], "type": "object" }, + "PluginShareDiscoverability": { + "enum": [ + "LISTED", + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, "PluginShareListParams": { "type": "object" }, + "PluginSharePrincipalType": { + "enum": [ + "user", + "group", + "workspace" + ], + "type": "string" + }, "PluginShareSaveParams": { "properties": { + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, "pluginPath": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -2210,6 +2236,15 @@ "string", "null" ] + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/PluginShareTarget" + }, + "type": [ + "array", + "null" + ] } }, "required": [ @@ -2217,6 +2252,39 @@ ], "type": "object" }, + "PluginShareTarget": { + "properties": { + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + } + }, + "required": [ + "principalId", + "principalType" + ], + "type": "object" + }, + "PluginShareUpdateTargetsParams": { + "properties": { + "remotePluginId": { + "type": "string" + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/PluginShareTarget" + }, + "type": "array" + } + }, + "required": [ + "remotePluginId", + "shareTargets" + ], + "type": "object" + }, "PluginSkillReadParams": { "properties": { "remoteMarketplaceName": { @@ -3256,13 +3324,6 @@ ], "type": "object" }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" - }, "SessionMigration": { "properties": { "cwd": { @@ -3540,20 +3601,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "threadId": { @@ -3962,20 +4012,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "threadId": { @@ -4159,20 +4198,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "sessionStartSource": { @@ -4339,22 +4367,11 @@ "description": "Override the sandbox policy for this turn and subsequent turns." }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } - ], - "description": "Override the service tier for this turn and subsequent turns." + "description": "Override the service tier for this turn and subsequent turns.", + "type": [ + "string", + "null" + ] }, "summary": { "anyOf": [ @@ -5177,6 +5194,30 @@ "title": "Plugin/share/saveRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/share/updateTargets" + ], + "title": "Plugin/share/updateTargetsRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginShareUpdateTargetsParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/share/updateTargetsRequest", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index f5de1f456c..883ab8e68a 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3072,6 +3072,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -3120,6 +3124,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 922ca49a5c..acb4b16d93 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -810,6 +810,30 @@ "title": "Plugin/share/saveRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "plugin/share/updateTargets" + ], + "title": "Plugin/share/updateTargetsRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/PluginShareUpdateTargetsParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/share/updateTargetsRequest", + "type": "object" + }, { "properties": { "id": { @@ -7200,13 +7224,9 @@ ] }, "service_tier": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "tools": { @@ -12479,6 +12499,14 @@ "title": "PluginShareDeleteResponse", "type": "object" }, + "PluginShareDiscoverability": { + "enum": [ + "LISTED", + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, "PluginShareListItem": { "properties": { "localPluginPath": { @@ -12525,9 +12553,46 @@ "title": "PluginShareListResponse", "type": "object" }, + "PluginSharePrincipal": { + "properties": { + "name": { + "type": "string" + }, + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/v2/PluginSharePrincipalType" + } + }, + "required": [ + "name", + "principalId", + "principalType" + ], + "type": "object" + }, + "PluginSharePrincipalType": { + "enum": [ + "user", + "group", + "workspace" + ], + "type": "string" + }, "PluginShareSaveParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/v2/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, "pluginPath": { "$ref": "#/definitions/v2/AbsolutePathBuf" }, @@ -12536,6 +12601,15 @@ "string", "null" ] + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/v2/PluginShareTarget" + }, + "type": [ + "array", + "null" + ] } }, "required": [ @@ -12561,6 +12635,57 @@ "title": "PluginShareSaveResponse", "type": "object" }, + "PluginShareTarget": { + "properties": { + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/v2/PluginSharePrincipalType" + } + }, + "required": [ + "principalId", + "principalType" + ], + "type": "object" + }, + "PluginShareUpdateTargetsParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "remotePluginId": { + "type": "string" + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/v2/PluginShareTarget" + }, + "type": "array" + } + }, + "required": [ + "remotePluginId", + "shareTargets" + ], + "title": "PluginShareUpdateTargetsParams", + "type": "object" + }, + "PluginShareUpdateTargetsResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "principals": { + "items": { + "$ref": "#/definitions/v2/PluginSharePrincipal" + }, + "type": "array" + } + }, + "required": [ + "principals" + ], + "title": "PluginShareUpdateTargetsResponse", + "type": "object" + }, "PluginSkillReadParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12960,13 +13085,9 @@ ] }, "service_tier": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "tools": { @@ -14495,13 +14616,6 @@ "title": "ServerRequestResolvedNotification", "type": "object" }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" - }, "SessionMigration": { "properties": { "cwd": { @@ -15216,6 +15330,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -15264,6 +15382,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", @@ -15439,20 +15558,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/v2/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "threadId": { @@ -15526,13 +15634,9 @@ "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "thread": { @@ -16957,20 +17061,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/v2/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "threadId": { @@ -17033,13 +17126,9 @@ "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "thread": { @@ -17265,20 +17354,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/v2/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "sessionStartSource": { @@ -17356,13 +17434,9 @@ "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "thread": { @@ -18029,22 +18103,11 @@ "description": "Override the sandbox policy for this turn and subsequent turns." }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/v2/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } - ], - "description": "Override the service tier for this turn and subsequent turns." + "description": "Override the service tier for this turn and subsequent turns.", + "type": [ + "string", + "null" + ] }, "summary": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 8382944c8e..ded3dc2b87 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -1569,6 +1569,30 @@ "title": "Plugin/share/saveRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/share/updateTargets" + ], + "title": "Plugin/share/updateTargetsRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginShareUpdateTargetsParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/share/updateTargetsRequest", + "type": "object" + }, { "properties": { "id": { @@ -3656,13 +3680,9 @@ ] }, "service_tier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "tools": { @@ -9090,6 +9110,14 @@ "title": "PluginShareDeleteResponse", "type": "object" }, + "PluginShareDiscoverability": { + "enum": [ + "LISTED", + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, "PluginShareListItem": { "properties": { "localPluginPath": { @@ -9136,9 +9164,46 @@ "title": "PluginShareListResponse", "type": "object" }, + "PluginSharePrincipal": { + "properties": { + "name": { + "type": "string" + }, + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + } + }, + "required": [ + "name", + "principalId", + "principalType" + ], + "type": "object" + }, + "PluginSharePrincipalType": { + "enum": [ + "user", + "group", + "workspace" + ], + "type": "string" + }, "PluginShareSaveParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, "pluginPath": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -9147,6 +9212,15 @@ "string", "null" ] + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/PluginShareTarget" + }, + "type": [ + "array", + "null" + ] } }, "required": [ @@ -9172,6 +9246,57 @@ "title": "PluginShareSaveResponse", "type": "object" }, + "PluginShareTarget": { + "properties": { + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + } + }, + "required": [ + "principalId", + "principalType" + ], + "type": "object" + }, + "PluginShareUpdateTargetsParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "remotePluginId": { + "type": "string" + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/PluginShareTarget" + }, + "type": "array" + } + }, + "required": [ + "remotePluginId", + "shareTargets" + ], + "title": "PluginShareUpdateTargetsParams", + "type": "object" + }, + "PluginShareUpdateTargetsResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "principals": { + "items": { + "$ref": "#/definitions/PluginSharePrincipal" + }, + "type": "array" + } + }, + "required": [ + "principals" + ], + "title": "PluginShareUpdateTargetsResponse", + "type": "object" + }, "PluginSkillReadParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -9571,13 +9696,9 @@ ] }, "service_tier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "tools": { @@ -12381,13 +12502,6 @@ "title": "ServerRequestResolvedNotification", "type": "object" }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" - }, "SessionMigration": { "properties": { "cwd": { @@ -13102,6 +13216,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -13150,6 +13268,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", @@ -13325,20 +13444,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "threadId": { @@ -13412,13 +13520,9 @@ "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "thread": { @@ -14843,20 +14947,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "threadId": { @@ -14919,13 +15012,9 @@ "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "thread": { @@ -15151,20 +15240,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "sessionStartSource": { @@ -15242,13 +15320,9 @@ "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "thread": { @@ -15915,22 +15989,11 @@ "description": "Override the sandbox policy for this turn and subsequent turns." }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } - ], - "description": "Override the service tier for this turn and subsequent turns." + "description": "Override the service tier for this turn and subsequent turns.", + "type": [ + "string", + "null" + ] }, "summary": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index c348e7d955..87a826e5af 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -352,13 +352,9 @@ ] }, "service_tier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "tools": { @@ -658,13 +654,9 @@ ] }, "service_tier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "tools": { @@ -754,13 +746,6 @@ }, "type": "object" }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" - }, "ToolsV2": { "properties": { "view_image": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareSaveParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareSaveParams.json index ee1ae48730..c269223068 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginShareSaveParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareSaveParams.json @@ -4,9 +4,50 @@ "AbsolutePathBuf": { "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" + }, + "PluginShareDiscoverability": { + "enum": [ + "LISTED", + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, + "PluginSharePrincipalType": { + "enum": [ + "user", + "group", + "workspace" + ], + "type": "string" + }, + "PluginShareTarget": { + "properties": { + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + } + }, + "required": [ + "principalId", + "principalType" + ], + "type": "object" } }, "properties": { + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, "pluginPath": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -15,6 +56,15 @@ "string", "null" ] + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/PluginShareTarget" + }, + "type": [ + "array", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsParams.json new file mode 100644 index 0000000000..080ec980a1 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsParams.json @@ -0,0 +1,45 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "PluginSharePrincipalType": { + "enum": [ + "user", + "group", + "workspace" + ], + "type": "string" + }, + "PluginShareTarget": { + "properties": { + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + } + }, + "required": [ + "principalId", + "principalType" + ], + "type": "object" + } + }, + "properties": { + "remotePluginId": { + "type": "string" + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/PluginShareTarget" + }, + "type": "array" + } + }, + "required": [ + "remotePluginId", + "shareTargets" + ], + "title": "PluginShareUpdateTargetsParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsResponse.json new file mode 100644 index 0000000000..28fcb665ba --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsResponse.json @@ -0,0 +1,45 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "PluginSharePrincipal": { + "properties": { + "name": { + "type": "string" + }, + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + } + }, + "required": [ + "name", + "principalId", + "principalType" + ], + "type": "object" + }, + "PluginSharePrincipalType": { + "enum": [ + "user", + "group", + "workspace" + ], + "type": "string" + } + }, + "properties": { + "principals": { + "items": { + "$ref": "#/definitions/PluginSharePrincipal" + }, + "type": "array" + } + }, + "required": [ + "principals" + ], + "title": "PluginShareUpdateTargetsResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json index 6419bc9422..29d67403cd 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json @@ -131,13 +131,6 @@ ], "type": "string" }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" - }, "ThreadSource": { "enum": [ "user", @@ -222,20 +215,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "threadId": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 05f9849df3..6e74ab4ac8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1177,13 +1177,6 @@ } ] }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" - }, "SessionSource": { "oneOf": [ { @@ -1403,6 +1396,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -1451,6 +1448,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", @@ -2610,13 +2608,9 @@ "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "thread": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index aaeb3a5ab2..f78fbaf27e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -853,6 +853,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -901,6 +905,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 6237f8b746..4268ad203a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -853,6 +853,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -901,6 +905,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 4a64c50252..fb0d80a047 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -853,6 +853,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -901,6 +905,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index 9fe5c7f47f..5f07fe0149 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -1010,13 +1010,6 @@ "danger-full-access" ], "type": "string" - }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" } }, "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nThe precedence is: history > path > thread_id. If using history or path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", @@ -1101,20 +1094,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "threadId": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 81eff83400..727b7a3fb2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1177,13 +1177,6 @@ } ] }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" - }, "SessionSource": { "oneOf": [ { @@ -1403,6 +1396,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -1451,6 +1448,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", @@ -2610,13 +2608,9 @@ "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "thread": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index f495e4cdfe..204828c732 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -853,6 +853,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -901,6 +905,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json index aa5029afd9..9a60049a61 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -165,13 +165,6 @@ ], "type": "string" }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" - }, "ThreadSource": { "enum": [ "user", @@ -295,20 +288,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "sessionStartSource": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index b2d5cbfa08..bf03f0fb55 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1177,13 +1177,6 @@ } ] }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" - }, "SessionSource": { "oneOf": [ { @@ -1403,6 +1396,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -1451,6 +1448,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", @@ -2610,13 +2608,9 @@ "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "thread": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index 98c163a41e..759b5990be 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -853,6 +853,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -901,6 +905,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 5e26982d51..f64400129a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -853,6 +853,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -901,6 +905,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index da1320a796..1ef33d4301 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -312,13 +312,6 @@ } ] }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" - }, "Settings": { "description": "Settings for a collaboration mode.", "properties": { @@ -586,22 +579,11 @@ "description": "Override the sandbox policy for this turn and subsequent turns." }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } - ], - "description": "Override the service tier for this turn and subsequent turns." + "description": "Override the service tier for this turn and subsequent turns.", + "type": [ + "string", + "null" + ] }, "summary": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index 3484819a45..1c03d2eb37 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -51,6 +51,7 @@ import type { PluginReadParams } from "./v2/PluginReadParams"; import type { PluginShareDeleteParams } from "./v2/PluginShareDeleteParams"; import type { PluginShareListParams } from "./v2/PluginShareListParams"; import type { PluginShareSaveParams } from "./v2/PluginShareSaveParams"; +import type { PluginShareUpdateTargetsParams } from "./v2/PluginShareUpdateTargetsParams"; import type { PluginSkillReadParams } from "./v2/PluginSkillReadParams"; import type { PluginUninstallParams } from "./v2/PluginUninstallParams"; import type { ReviewStartParams } from "./v2/ReviewStartParams"; @@ -81,4 +82,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "device/key/create", id: RequestId, params: DeviceKeyCreateParams, } | { "method": "device/key/public", id: RequestId, params: DeviceKeyPublicParams, } | { "method": "device/key/sign", id: RequestId, params: DeviceKeySignParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "device/key/create", id: RequestId, params: DeviceKeyCreateParams, } | { "method": "device/key/public", id: RequestId, params: DeviceKeyPublicParams, } | { "method": "device/key/sign", id: RequestId, params: DeviceKeySignParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index a082e045fa..97ea435601 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -64,7 +64,6 @@ export type { ResponseItem } from "./ResponseItem"; export type { ReviewDecision } from "./ReviewDecision"; export type { ServerNotification } from "./ServerNotification"; export type { ServerRequest } from "./ServerRequest"; -export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts index 508fe84e92..cc7e340ea3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts @@ -4,7 +4,6 @@ import type { ForcedLoginMethod } from "../ForcedLoginMethod"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; -import type { ServiceTier } from "../ServiceTier"; import type { Verbosity } from "../Verbosity"; import type { WebSearchMode } from "../WebSearchMode"; import type { JsonValue } from "../serde_json/JsonValue"; @@ -20,4 +19,4 @@ export type Config = {model: string | null, review_model: string | null, model_c * [UNSTABLE] Optional default for where approval requests are routed for * review. */ -approvals_reviewer: ApprovalsReviewer | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: string | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, service_tier: ServiceTier | null, analytics: AnalyticsConfig | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); +approvals_reviewer: ApprovalsReviewer | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: string | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, service_tier: string | null, analytics: AnalyticsConfig | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareDiscoverability.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareDiscoverability.ts new file mode 100644 index 0000000000..8c2242163b --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareDiscoverability.ts @@ -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 PluginShareDiscoverability = "LISTED" | "UNLISTED" | "PRIVATE"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipal.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipal.ts new file mode 100644 index 0000000000..9e0ecc48e7 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipal.ts @@ -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 { PluginSharePrincipalType } from "./PluginSharePrincipalType"; + +export type PluginSharePrincipal = { principalType: PluginSharePrincipalType, principalId: string, name: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ServiceTier.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipalType.ts similarity index 68% rename from codex-rs/app-server-protocol/schema/typescript/ServiceTier.ts rename to codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipalType.ts index ce11286dbd..e54c129cbf 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServiceTier.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipalType.ts @@ -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 ServiceTier = "fast" | "flex"; +export type PluginSharePrincipalType = "user" | "group" | "workspace"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareSaveParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareSaveParams.ts index d2011984e3..c8df0d6c1c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareSaveParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareSaveParams.ts @@ -2,5 +2,7 @@ // 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 { PluginShareDiscoverability } from "./PluginShareDiscoverability"; +import type { PluginShareTarget } from "./PluginShareTarget"; -export type PluginShareSaveParams = { pluginPath: AbsolutePathBuf, remotePluginId?: string | null, }; +export type PluginShareSaveParams = { pluginPath: AbsolutePathBuf, remotePluginId?: string | null, discoverability?: PluginShareDiscoverability | null, shareTargets?: Array | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareTarget.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareTarget.ts new file mode 100644 index 0000000000..fd1969087f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareTarget.ts @@ -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 { PluginSharePrincipalType } from "./PluginSharePrincipalType"; + +export type PluginShareTarget = { principalType: PluginSharePrincipalType, principalId: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateTargetsParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateTargetsParams.ts new file mode 100644 index 0000000000..53ef2b3025 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateTargetsParams.ts @@ -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 { PluginShareTarget } from "./PluginShareTarget"; + +export type PluginShareUpdateTargetsParams = { remotePluginId: string, shareTargets: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateTargetsResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateTargetsResponse.ts new file mode 100644 index 0000000000..3d6f6e9ee0 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateTargetsResponse.ts @@ -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 { PluginSharePrincipal } from "./PluginSharePrincipal"; + +export type PluginShareUpdateTargetsResponse = { principals: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts index 7afe3e0c54..d05038701c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts @@ -3,7 +3,6 @@ // 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"; import type { ReasoningSummary } from "../ReasoningSummary"; -import type { ServiceTier } from "../ServiceTier"; import type { Verbosity } from "../Verbosity"; import type { WebSearchMode } from "../WebSearchMode"; import type { JsonValue } from "../serde_json/JsonValue"; @@ -16,4 +15,4 @@ export type ProfileV2 = {model: string | null, model_provider: string | null, ap * are routed for review. If omitted, the enclosing config default is * used. */ -approvals_reviewer: ApprovalsReviewer | null, service_tier: ServiceTier | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, chatgpt_base_url: string | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); +approvals_reviewer: ApprovalsReviewer | null, service_tier: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, chatgpt_base_url: string | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts index 99e622565a..d917094e36 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts @@ -9,6 +9,10 @@ import type { ThreadStatus } from "./ThreadStatus"; import type { Turn } from "./Turn"; export type Thread = { id: string, +/** + * Session id shared by threads that belong to the same session tree. + */ +sessionId: string, /** * Source thread id when this thread was created by forking another thread. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts index ea67b491ad..6076a4bb14 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts @@ -1,7 +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 { ServiceTier } from "../ServiceTier"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -20,7 +19,7 @@ import type { ThreadSource } from "./ThreadSource"; export type ThreadForkParams = {threadId: string, /** * Configuration overrides for the forked thread, if any. */ -model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /** +model?: string | null, modelProvider?: string | null, serviceTier?: string | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /** * Override where approval requests are routed for review on this thread * and subsequent turns. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts index ddcef104e9..c44533ec1a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts @@ -3,13 +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"; import type { ReasoningEffort } from "../ReasoningEffort"; -import type { ServiceTier } from "../ServiceTier"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; -export type ThreadForkResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: AbsolutePathBuf, /** +export type ThreadForkResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: string | null, cwd: AbsolutePathBuf, /** * Instruction source files currently loaded for this thread. */ instructionSources: Array, approvalPolicy: AskForApproval, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts index ac8b1e293b..6d1dbdca4f 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts @@ -2,7 +2,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { Personality } from "../Personality"; -import type { ServiceTier } from "../ServiceTier"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -22,7 +21,7 @@ import type { SandboxMode } from "./SandboxMode"; export type ThreadResumeParams = {threadId: string, /** * Configuration overrides for the resumed thread, if any. */ -model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /** +model?: string | null, modelProvider?: string | null, serviceTier?: string | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /** * Override where approval requests are routed for review on this thread * and subsequent turns. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts index f7627c07ae..f91756c7c6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts @@ -3,13 +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"; import type { ReasoningEffort } from "../ReasoningEffort"; -import type { ServiceTier } from "../ServiceTier"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; -export type ThreadResumeResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: AbsolutePathBuf, /** +export type ThreadResumeResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: string | null, cwd: AbsolutePathBuf, /** * Instruction source files currently loaded for this thread. */ instructionSources: Array, approvalPolicy: AskForApproval, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts index cecc183f92..30509ef6cb 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts @@ -2,7 +2,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { Personality } from "../Personality"; -import type { ServiceTier } from "../ServiceTier"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -10,7 +9,7 @@ import type { SandboxMode } from "./SandboxMode"; import type { ThreadSource } from "./ThreadSource"; import type { ThreadStartSource } from "./ThreadStartSource"; -export type ThreadStartParams = {model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /** +export type ThreadStartParams = {model?: string | null, modelProvider?: string | null, serviceTier?: string | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /** * Override where approval requests are routed for review on this thread * and subsequent turns. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts index ce28a4a1d7..9573bd7dee 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts @@ -3,13 +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"; import type { ReasoningEffort } from "../ReasoningEffort"; -import type { ServiceTier } from "../ServiceTier"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; -export type ThreadStartResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: AbsolutePathBuf, /** +export type ThreadStartResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: string | null, cwd: AbsolutePathBuf, /** * Instruction source files currently loaded for this thread. */ instructionSources: Array, approvalPolicy: AskForApproval, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 4af17115c8..b04919d86b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -4,7 +4,6 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; -import type { ServiceTier } from "../ServiceTier"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -30,7 +29,7 @@ sandboxPolicy?: SandboxPolicy | null, /** model?: string | null, /** * Override the service tier for this turn and subsequent turns. */ -serviceTier?: ServiceTier | null | null, /** +serviceTier?: string | null | null, /** * Override the reasoning effort for this turn and subsequent turns. */ effort?: ReasoningEffort | null, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 4998cdc710..6901e2a040 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -285,11 +285,17 @@ export type { PluginReadParams } from "./PluginReadParams"; export type { PluginReadResponse } from "./PluginReadResponse"; export type { PluginShareDeleteParams } from "./PluginShareDeleteParams"; export type { PluginShareDeleteResponse } from "./PluginShareDeleteResponse"; +export type { PluginShareDiscoverability } from "./PluginShareDiscoverability"; export type { PluginShareListItem } from "./PluginShareListItem"; export type { PluginShareListParams } from "./PluginShareListParams"; export type { PluginShareListResponse } from "./PluginShareListResponse"; +export type { PluginSharePrincipal } from "./PluginSharePrincipal"; +export type { PluginSharePrincipalType } from "./PluginSharePrincipalType"; export type { PluginShareSaveParams } from "./PluginShareSaveParams"; export type { PluginShareSaveResponse } from "./PluginShareSaveResponse"; +export type { PluginShareTarget } from "./PluginShareTarget"; +export type { PluginShareUpdateTargetsParams } from "./PluginShareUpdateTargetsParams"; +export type { PluginShareUpdateTargetsResponse } from "./PluginShareUpdateTargetsResponse"; export type { PluginSkillReadParams } from "./PluginSkillReadParams"; export type { PluginSkillReadResponse } from "./PluginSkillReadResponse"; export type { PluginSource } from "./PluginSource"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 9d425d8ab4..682556b976 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -628,6 +628,11 @@ client_request_definitions! { serialization: global("config"), response: v2::PluginShareSaveResponse, }, + PluginShareUpdateTargets => "plugin/share/updateTargets" { + params: v2::PluginShareUpdateTargetsParams, + serialization: global("config"), + response: v2::PluginShareUpdateTargetsResponse, + }, PluginShareList => "plugin/share/list" { params: v2::PluginShareListParams, serialization: global("config"), @@ -2173,6 +2178,7 @@ mod tests { response: v2::ThreadStartResponse { thread: v2::Thread { id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(), + session_id: "67e55044-10b1-426f-9247-bb680e5fe0c7".to_string(), forked_from_id: None, preview: "first prompt".to_string(), ephemeral: true, @@ -2214,6 +2220,7 @@ mod tests { "response": { "thread": { "id": "67e55044-10b1-426f-9247-bb680e5fe0c8", + "sessionId": "67e55044-10b1-426f-9247-bb680e5fe0c7", "forkedFromId": null, "preview": "first prompt", "ephemeral": true, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index 1c1ef35d62..da4f493d77 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -5,7 +5,6 @@ use super::shared::default_enabled; use codex_experimental_api_macros::ExperimentalApi; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ReasoningSummary; -use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::Verbosity; use codex_protocol::config_types::WebSearchMode; use codex_protocol::config_types::WebSearchToolConfig; @@ -137,7 +136,7 @@ pub struct ProfileV2 { /// used. #[experimental("config/read.approvalsReviewer")] pub approvals_reviewer: Option, - pub service_tier: Option, + pub service_tier: Option, pub model_reasoning_effort: Option, pub model_reasoning_summary: Option, pub model_verbosity: Option, @@ -248,7 +247,7 @@ pub struct Config { pub model_reasoning_effort: Option, pub model_reasoning_summary: Option, pub model_verbosity: Option, - pub service_tier: Option, + pub service_tier: Option, pub analytics: Option, #[experimental("config/read.apps")] #[serde(default)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs index a6c22bfc9f..3a7651724d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -194,6 +194,10 @@ pub struct PluginShareSaveParams { pub plugin_path: AbsolutePathBuf, #[ts(optional = nullable)] pub remote_plugin_id: Option, + #[ts(optional = nullable)] + pub discoverability: Option, + #[ts(optional = nullable)] + pub share_targets: Option>, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -204,6 +208,21 @@ pub struct PluginShareSaveResponse { pub share_url: String, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareUpdateTargetsParams { + pub remote_plugin_id: String, + pub share_targets: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareUpdateTargetsResponse { + pub principals: Vec, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -237,6 +256,51 @@ pub struct PluginShareListItem { pub local_plugin_path: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub enum PluginShareDiscoverability { + #[serde(rename = "LISTED")] + #[ts(rename = "LISTED")] + Listed, + #[serde(rename = "UNLISTED")] + #[ts(rename = "UNLISTED")] + Unlisted, + #[serde(rename = "PRIVATE")] + #[ts(rename = "PRIVATE")] + Private, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub enum PluginSharePrincipalType { + #[serde(rename = "user")] + #[ts(rename = "user")] + User, + #[serde(rename = "group")] + #[ts(rename = "group")] + Group, + #[serde(rename = "workspace")] + #[ts(rename = "workspace")] + Workspace, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareTarget { + pub principal_type: PluginSharePrincipalType, + pub principal_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginSharePrincipal { + pub principal_type: PluginSharePrincipalType, + pub principal_id: String, + pub name: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] #[ts(rename_all = "snake_case")] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 719a933f22..9b82120c93 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -2981,11 +2981,15 @@ fn plugin_share_params_and_response_serialization_use_camel_case_fields() { serde_json::to_value(PluginShareSaveParams { plugin_path: plugin_path.clone(), remote_plugin_id: None, + discoverability: None, + share_targets: None, }) .unwrap(), json!({ "pluginPath": plugin_path_json, "remotePluginId": null, + "discoverability": null, + "shareTargets": null, }), ); @@ -2993,11 +2997,33 @@ fn plugin_share_params_and_response_serialization_use_camel_case_fields() { serde_json::to_value(PluginShareSaveParams { plugin_path, remote_plugin_id: Some("plugins~Plugin_00000000000000000000000000000000".to_string(),), + discoverability: Some(PluginShareDiscoverability::Private), + share_targets: Some(vec![ + PluginShareTarget { + principal_type: PluginSharePrincipalType::User, + principal_id: "user-1".to_string(), + }, + PluginShareTarget { + principal_type: PluginSharePrincipalType::Workspace, + principal_id: "workspace-1".to_string(), + }, + ]), }) .unwrap(), json!({ "pluginPath": plugin_path_json, "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", + "discoverability": "PRIVATE", + "shareTargets": [ + { + "principalType": "user", + "principalId": "user-1", + }, + { + "principalType": "workspace", + "principalId": "workspace-1", + }, + ], }), ); @@ -3013,6 +3039,42 @@ fn plugin_share_params_and_response_serialization_use_camel_case_fields() { }), ); + assert_eq!( + serde_json::to_value(PluginShareUpdateTargetsParams { + remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), + share_targets: vec![PluginShareTarget { + principal_type: PluginSharePrincipalType::Group, + principal_id: "group-1".to_string(), + }], + }) + .unwrap(), + json!({ + "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", + "shareTargets": [{ + "principalType": "group", + "principalId": "group-1", + }], + }), + ); + + assert_eq!( + serde_json::to_value(PluginShareUpdateTargetsResponse { + principals: vec![PluginSharePrincipal { + principal_type: PluginSharePrincipalType::User, + principal_id: "user-1".to_string(), + name: "Gavin".to_string(), + }], + }) + .unwrap(), + json!({ + "principals": [{ + "principalType": "user", + "principalId": "user-1", + "name": "Gavin", + }], + }), + ); + assert_eq!( serde_json::from_value::(json!({})).unwrap(), PluginShareListParams {}, @@ -3375,10 +3437,11 @@ fn thread_start_params_preserve_explicit_null_service_tier() { } #[test] -fn thread_lifecycle_responses_default_missing_compat_fields() { +fn thread_lifecycle_responses_default_missing_optional_fields() { let response = json!({ "thread": { "id": "thread-id", + "sessionId": "thread-id", "forkedFromId": null, "preview": "", "ephemeral": false, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index 578ef9193f..5f293ff8f6 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -12,7 +12,6 @@ use super::TurnEnvironmentParams; use super::shared::v2_enum_from_core; use codex_experimental_api_macros::ExperimentalApi; use codex_protocol::config_types::Personality; -use codex_protocol::config_types::ServiceTier; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::ThreadGoalStatus as CoreThreadGoalStatus; @@ -103,7 +102,7 @@ pub struct ThreadStartParams { skip_serializing_if = "Option::is_none" )] #[ts(optional = nullable)] - pub service_tier: Option>, + pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, #[experimental(nested)] @@ -192,7 +191,7 @@ pub struct ThreadStartResponse { pub thread: Thread, pub model: String, pub model_provider: String, - pub service_tier: Option, + pub service_tier: Option, pub cwd: AbsolutePathBuf, /// Instruction source files currently loaded for this thread. #[serde(default)] @@ -260,7 +259,7 @@ pub struct ThreadResumeParams { skip_serializing_if = "Option::is_none" )] #[ts(optional = nullable)] - pub service_tier: Option>, + pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, #[experimental(nested)] @@ -307,7 +306,7 @@ pub struct ThreadResumeResponse { pub thread: Thread, pub model: String, pub model_provider: String, - pub service_tier: Option, + pub service_tier: Option, pub cwd: AbsolutePathBuf, /// Instruction source files currently loaded for this thread. #[serde(default)] @@ -366,7 +365,7 @@ pub struct ThreadForkParams { skip_serializing_if = "Option::is_none" )] #[ts(optional = nullable)] - pub service_tier: Option>, + pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, #[experimental(nested)] @@ -416,7 +415,7 @@ pub struct ThreadForkResponse { pub thread: Thread, pub model: String, pub model_provider: String, - pub service_tier: Option, + pub service_tier: Option, pub cwd: AbsolutePathBuf, /// Instruction source files currently loaded for this thread. #[serde(default)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs index cb02705849..f0c518adf8 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs @@ -104,6 +104,8 @@ pub struct GitInfo { #[ts(export_to = "v2/")] pub struct Thread { pub id: String, + /// Session id shared by threads that belong to the same session tree. + pub session_id: String, /// Source thread id when this thread was created by forking another thread. pub forked_from_id: Option, /// Usually the first user message in the thread, if available. diff --git a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs index 38118ab15e..61a09bfbf5 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -7,7 +7,6 @@ use codex_experimental_api_macros::ExperimentalApi; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; -use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::plan_tool::PlanItemArg as CorePlanItemArg; use codex_protocol::plan_tool::StepStatus as CorePlanStepStatus; @@ -94,7 +93,7 @@ pub struct TurnStartParams { skip_serializing_if = "Option::is_none" )] #[ts(optional = nullable)] - pub service_tier: Option>, + pub service_tier: Option>, /// Override the reasoning effort for this turn and subsequent turns. #[ts(optional = nullable)] pub effort: Option, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 115fe1c1d2..ddc3817952 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -303,11 +303,11 @@ Example: { "id": 12, "result": { "thread": { "id": "thr_123", "turns": [], … } } } ``` -To branch from a stored session, call `thread/fork` with the `thread.id`. This creates a new thread id and emits a `thread/started` notification for it. When the source history includes persisted token usage, the server also emits `thread/tokenUsage/updated` for the new thread immediately after the response. If the source thread is actively running, the fork snapshots it as if the current turn had been interrupted first. Pass `ephemeral: true` when the fork should stay in-memory only: +To branch from a stored session, call `thread/fork` with the `thread.id`. This creates a new thread id and emits a `thread/started` notification for it. The returned `thread.sessionId` identifies the current live session tree root. Root threads use their own `thread.id` as `thread.sessionId`; stored threads that are not loaded also report their own `thread.id`, because resuming one makes it the root of a new live session tree. When the source history includes persisted token usage, the server also emits `thread/tokenUsage/updated` for the new thread immediately after the response. If the source thread is actively running, the fork snapshots it as if the current turn had been interrupted first. Pass `ephemeral: true` when the fork should stay in-memory only: ```json { "method": "thread/fork", "id": 12, "params": { "threadId": "thr_123", "ephemeral": true } } -{ "id": 12, "result": { "thread": { "id": "thr_456", … } } } +{ "id": 12, "result": { "thread": { "id": "thr_456", "sessionId": "thr_456", … } } } { "method": "thread/started", "params": { "thread": { … } } } ``` @@ -1393,6 +1393,12 @@ If the session approval policy uses `Granular` with `request_permissions: false` `dynamicTools` on `thread/start` and the corresponding `item/tool/call` request/response flow are experimental APIs. To enable them, set `initialize.params.capabilities.experimentalApi = true`. +Dynamic tool identifiers follow the same constraints as Responses function tools: + +- `name` must match `^[a-zA-Z0-9_-]+$` and be between 1 and 128 characters. +- `namespace`, when present, must match `^[a-zA-Z0-9_-]+$` and be between 1 and 64 characters. +- `namespace` must not collide with reserved Responses runtime namespaces such as `functions`, `multi_tool_use`, `file_search`, `web`, `browser`, `image_gen`, `computer`, `container`, `terminal`, `python`, `python_user_visible`, `api_tool`, `tool_search`, or `submodel_delegator`. + Each dynamic tool may set `deferLoading`. When omitted, it defaults to `false`. Set it to `true` to keep the tool registered and callable by runtime features such as `code_mode`, while excluding it from the model-facing tool list sent on ordinary turns. When `tool_search` is available, deferred dynamic tools are searchable and can be exposed by a matching search result. When a dynamic tool is invoked during a turn, the server sends an `item/tool/call` JSON-RPC request to the client: diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 6ff7f5f03f..e2d8fd587d 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1190,6 +1190,7 @@ pub(crate) async fn apply_bespoke_event_handling( .await; let response = match thread_rollback_response_from_stored_thread( stored_thread, + conversation.session_configured().session_id.to_string(), fallback_model_provider.as_str(), &fallback_cwd, loaded_status, @@ -1543,6 +1544,7 @@ async fn handle_thread_rollback_failed( fn thread_rollback_response_from_stored_thread( stored_thread: codex_thread_store::StoredThread, + session_id: String, fallback_model_provider: &str, fallback_cwd: &AbsolutePathBuf, loaded_status: ThreadStatus, @@ -1550,6 +1552,7 @@ fn thread_rollback_response_from_stored_thread( let thread_id = stored_thread.thread_id; let (mut thread, history) = thread_from_stored_thread(stored_thread, fallback_model_provider, fallback_cwd); + thread.session_id = session_id; let Some(history) = history else { return Err(format!( "thread {thread_id} did not include persisted history after rollback" @@ -2200,6 +2203,7 @@ mod tests { let response = thread_rollback_response_from_stored_thread( stored_thread, + thread_id.to_string(), "fallback-provider", &fallback_cwd, ThreadStatus::NotLoaded, diff --git a/codex-rs/app-server/src/config_manager.rs b/codex-rs/app-server/src/config_manager.rs index ba11205b7a..030829fa4b 100644 --- a/codex-rs/app-server/src/config_manager.rs +++ b/codex-rs/app-server/src/config_manager.rs @@ -140,6 +140,21 @@ impl ConfigManager { .await } + pub(crate) async fn load_latest_config_for_thread( + &self, + thread_config: &Config, + ) -> std::io::Result { + let refreshed_config = self + .load_latest_config(Some(thread_config.cwd.to_path_buf())) + .await?; + let mut config = thread_config + .rebuild_preserving_session_layers(&refreshed_config) + .await?; + self.apply_runtime_feature_enablement(&mut config); + self.apply_arg0_paths(&mut config); + Ok(config) + } + pub(crate) async fn load_default_config(&self) -> std::io::Result { let mut config = Config::load_default_with_cli_overrides_for_codex_home( self.codex_home.clone(), diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 73cfe75a54..35b92548b8 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -83,6 +83,7 @@ use codex_config::LoaderOverrides; use codex_config::ThreadConfigLoader; use codex_core::config::Config; use codex_core::init_state_db_from_config; +use codex_core::resolve_installation_id; use codex_exec_server::EnvironmentManager; use codex_feedback::CodexFeedback; use codex_login::AuthManager; @@ -345,7 +346,7 @@ impl InProcessClientHandle { /// the runtime is shut down and an `InvalidData` error is returned. pub async fn start(args: InProcessStartArgs) -> IoResult { let initialize = args.initialize.clone(); - let client = start_uninitialized(args).await; + let client = start_uninitialized(args).await?; let initialize_response = client .request(ClientRequest::Initialize { @@ -365,12 +366,13 @@ pub async fn start(args: InProcessStartArgs) -> IoResult Ok(client) } -async fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { +async fn start_uninitialized(args: InProcessStartArgs) -> IoResult { let channel_capacity = args.channel_capacity.max(1); let state_db = match args.state_db.clone() { Some(state_db) => Some(state_db), None => init_state_db_from_config(args.config.as_ref()).await, }; + let installation_id = resolve_installation_id(&args.config.codex_home).await?; let (client_tx, mut client_rx) = mpsc::channel::(channel_capacity); let (event_tx, event_rx) = mpsc::channel::(channel_capacity); @@ -438,6 +440,7 @@ async fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle config_warnings: args.config_warnings, session_source: args.session_source, auth_manager, + installation_id, rpc_transport: AppServerRpcTransport::InProcess, remote_control_handle: None, plugin_startup_tasks: crate::PluginStartupTasks::Start, @@ -718,13 +721,13 @@ async fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle } }); - InProcessClientHandle { + Ok(InProcessClientHandle { client: InProcessClientSender { client_tx }, event_rx, runtime_handle, #[cfg(test)] _test_codex_home: None, - } + }) } #[cfg(test)] diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 4013bbe76b..c7efae69c6 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -7,6 +7,7 @@ use codex_config::NoopThreadConfigLoader; use codex_config::RemoteThreadConfigLoader; use codex_config::ThreadConfigLoader; use codex_core::config::Config; +use codex_core::resolve_installation_id; use codex_exec_server::EnvironmentManagerArgs; use codex_features::Feature; use codex_login::AuthManager; @@ -85,6 +86,7 @@ mod filters; mod fs_watch; mod fuzzy_file_search; pub mod in_process; +mod mcp_refresh; mod message_processor; mod models; mod outgoing_message; @@ -620,6 +622,7 @@ pub async fn run_main_with_transport_options( None => error!("{}", warning.summary), } } + let installation_id = resolve_installation_id(&config.codex_home).await?; let transport_shutdown_token = CancellationToken::new(); let mut transport_accept_handles = Vec::>::new(); @@ -763,6 +766,7 @@ pub async fn run_main_with_transport_options( config_warnings, session_source, auth_manager, + installation_id, rpc_transport: analytics_rpc_transport(&transport), remote_control_handle: Some(remote_control_handle.clone()), plugin_startup_tasks: runtime_options.plugin_startup_tasks, diff --git a/codex-rs/app-server/src/mcp_refresh.rs b/codex-rs/app-server/src/mcp_refresh.rs new file mode 100644 index 0000000000..b1471d88fc --- /dev/null +++ b/codex-rs/app-server/src/mcp_refresh.rs @@ -0,0 +1,242 @@ +use crate::config_manager::ConfigManager; +use codex_core::CodexThread; +use codex_core::ThreadManager; +use codex_core::config::Config; +use codex_protocol::ThreadId; +use codex_protocol::protocol::McpServerRefreshConfig; +use codex_protocol::protocol::Op; +use std::io; +use std::sync::Arc; +use tracing::warn; + +pub(crate) async fn queue_strict_refresh( + thread_manager: &Arc, + config_manager: &ConfigManager, +) -> io::Result<()> { + config_manager + .load_latest_config(/*fallback_cwd*/ None) + .await?; + let mut refreshes = Vec::new(); + for thread_id in thread_manager.list_thread_ids().await { + let thread = thread_manager + .get_thread(thread_id) + .await + .map_err(|err| io::Error::other(format!("failed to load thread {thread_id}: {err}")))?; + let config = + build_refresh_config(thread_manager, config_manager, thread.config().await).await?; + refreshes.push((thread_id, thread, config)); + } + for (thread_id, thread, config) in refreshes { + queue_refresh(thread_id, thread, config).await?; + } + Ok(()) +} + +pub(crate) async fn queue_best_effort_refresh( + thread_manager: &Arc, + config_manager: &ConfigManager, +) { + for thread_id in thread_manager.list_thread_ids().await { + let thread = match thread_manager.get_thread(thread_id).await { + Ok(thread) => thread, + Err(err) => { + warn!("failed to load thread {thread_id} for MCP refresh: {err}"); + continue; + } + }; + let config = + match build_refresh_config(thread_manager, config_manager, thread.config().await).await + { + Ok(config) => config, + Err(err) => { + warn!("failed to build MCP refresh config for thread {thread_id}: {err}"); + continue; + } + }; + if let Err(err) = queue_refresh(thread_id, thread, config).await { + warn!("{err}"); + } + } +} + +async fn build_refresh_config( + thread_manager: &ThreadManager, + config_manager: &ConfigManager, + thread_config: Arc, +) -> io::Result { + let config = config_manager + .load_latest_config_for_thread(thread_config.as_ref()) + .await?; + let mcp_servers = thread_manager + .mcp_manager() + .configured_servers(&config) + .await; + Ok(McpServerRefreshConfig { + mcp_servers: serde_json::to_value(mcp_servers).map_err(io::Error::other)?, + mcp_oauth_credentials_store_mode: serde_json::to_value( + config.mcp_oauth_credentials_store_mode, + ) + .map_err(io::Error::other)?, + }) +} + +async fn queue_refresh( + thread_id: ThreadId, + thread: Arc, + config: McpServerRefreshConfig, +) -> io::Result<()> { + thread + .submit(Op::RefreshMcpServers { config }) + .await + .map(|_| ()) + .map_err(|err| { + io::Error::other(format!( + "failed to queue MCP refresh for thread {thread_id}: {err}" + )) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use codex_arg0::Arg0DispatchPaths; + use codex_config::CloudRequirementsLoader; + use codex_config::LoaderOverrides; + use codex_config::ThreadConfigContext; + use codex_config::ThreadConfigLoadError; + use codex_config::ThreadConfigLoadErrorCode; + use codex_config::ThreadConfigLoader; + use codex_config::ThreadConfigSource; + use codex_core::agent_graph_store_from_state_db; + use codex_core::config::ConfigOverrides; + use codex_core::init_state_db_from_config; + use codex_core::thread_store_from_config; + use codex_exec_server::EnvironmentManager; + use codex_login::AuthManager; + use codex_login::CodexAuth; + use codex_protocol::protocol::SessionSource; + use codex_utils_absolute_path::AbsolutePathBuf; + use pretty_assertions::assert_eq; + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + use tempfile::TempDir; + + #[tokio::test] + async fn strict_refresh_reports_thread_planning_failures() -> anyhow::Result<()> { + let (_temp_dir, thread_manager, config_manager, _loader) = refresh_test_state().await?; + + let err = queue_strict_refresh(&thread_manager, &config_manager) + .await + .expect_err("strict refresh should fail"); + + assert_eq!(err.to_string(), "failed to load refresh config"); + Ok(()) + } + + #[tokio::test] + async fn best_effort_refresh_attempts_every_loaded_thread() -> anyhow::Result<()> { + let (_temp_dir, thread_manager, config_manager, loader) = refresh_test_state().await?; + + queue_best_effort_refresh(&thread_manager, &config_manager).await; + + assert_eq!(loader.good_loads.load(Ordering::Relaxed), 1); + assert_eq!(loader.bad_loads.load(Ordering::Relaxed), 1); + Ok(()) + } + + async fn refresh_test_state() -> anyhow::Result<( + TempDir, + Arc, + ConfigManager, + Arc, + )> { + let temp_dir = TempDir::new()?; + let good_cwd = temp_dir.path().join("good"); + let bad_cwd = temp_dir.path().join("bad"); + std::fs::create_dir_all(&good_cwd)?; + std::fs::create_dir_all(&bad_cwd)?; + + let initial_config_manager = + ConfigManager::without_managed_config_for_tests(temp_dir.path().to_path_buf()); + let good_config = initial_config_manager + .load_for_cwd( + /*request_overrides*/ None, + ConfigOverrides::default(), + Some(good_cwd.clone()), + ) + .await?; + let bad_config = initial_config_manager + .load_for_cwd( + /*request_overrides*/ None, + ConfigOverrides::default(), + Some(bad_cwd.clone()), + ) + .await?; + + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy")); + let state_db = init_state_db_from_config(&good_config) + .await + .expect("refresh tests require state db"); + let thread_store = thread_store_from_config(&good_config, state_db.clone()); + let agent_graph_store = agent_graph_store_from_state_db(state_db.clone()); + let thread_manager = Arc::new(ThreadManager::new( + &good_config, + auth_manager, + SessionSource::Exec, + Arc::new(EnvironmentManager::default_for_tests()), + /*analytics_events_client*/ None, + state_db, + thread_store, + agent_graph_store, + "11111111-1111-4111-8111-111111111111".to_string(), + )); + thread_manager.start_thread(good_config).await?; + thread_manager.start_thread(bad_config).await?; + + let loader = Arc::new(CountingThreadConfigLoader { + good_cwd: AbsolutePathBuf::try_from(good_cwd)?, + bad_cwd: AbsolutePathBuf::try_from(bad_cwd)?, + good_loads: AtomicUsize::new(0), + bad_loads: AtomicUsize::new(0), + }); + let config_manager = ConfigManager::new( + temp_dir.path().to_path_buf(), + Vec::new(), + LoaderOverrides::without_managed_config_for_tests(), + CloudRequirementsLoader::default(), + Arg0DispatchPaths::default(), + loader.clone(), + ); + + Ok((temp_dir, thread_manager, config_manager, loader)) + } + + struct CountingThreadConfigLoader { + good_cwd: AbsolutePathBuf, + bad_cwd: AbsolutePathBuf, + good_loads: AtomicUsize, + bad_loads: AtomicUsize, + } + + #[async_trait] + impl ThreadConfigLoader for CountingThreadConfigLoader { + async fn load( + &self, + context: ThreadConfigContext, + ) -> Result, ThreadConfigLoadError> { + if context.cwd.as_ref() == Some(&self.good_cwd) { + self.good_loads.fetch_add(1, Ordering::Relaxed); + } + if context.cwd.as_ref() == Some(&self.bad_cwd) { + self.bad_loads.fetch_add(1, Ordering::Relaxed); + return Err(ThreadConfigLoadError::new( + ThreadConfigLoadErrorCode::Internal, + /*status_code*/ None, + "failed to load refresh config", + )); + } + Ok(Vec::new()) + } + } +} diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 4c1d16eeac..a3c3877fdc 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -259,6 +259,7 @@ pub(crate) struct MessageProcessorArgs { pub(crate) config_warnings: Vec, pub(crate) session_source: SessionSource, pub(crate) auth_manager: Arc, + pub(crate) installation_id: String, pub(crate) rpc_transport: AppServerRpcTransport, pub(crate) remote_control_handle: Option, pub(crate) plugin_startup_tasks: crate::PluginStartupTasks, @@ -281,6 +282,7 @@ impl MessageProcessor { config_warnings, session_source, auth_manager, + installation_id, rpc_transport, remote_control_handle, plugin_startup_tasks, @@ -302,6 +304,7 @@ impl MessageProcessor { state_db.clone(), Arc::clone(&thread_store), agent_graph_store.clone(), + installation_id, )); thread_manager .plugins_manager() @@ -416,7 +419,7 @@ impl MessageProcessor { if matches!(plugin_startup_tasks, crate::PluginStartupTasks::Start) { // Keep plugin startup warmups aligned at app-server startup. let on_effective_plugins_changed = - plugin_processor.effective_plugins_changed_callback((*config).clone()); + plugin_processor.effective_plugins_changed_callback(); thread_manager .plugins_manager() .maybe_start_plugin_startup_tasks_for_config( @@ -1087,6 +1090,11 @@ impl MessageProcessor { ClientRequest::PluginShareSave { params, .. } => { self.plugin_processor.plugin_share_save(params).await } + ClientRequest::PluginShareUpdateTargets { params, .. } => { + self.plugin_processor + .plugin_share_update_targets(params) + .await + } ClientRequest::PluginShareList { params, .. } => { self.plugin_processor.plugin_share_list(params).await } diff --git a/codex-rs/app-server/src/message_processor_tracing_tests.rs b/codex-rs/app-server/src/message_processor_tracing_tests.rs index 67b668bafc..27e2c2f473 100644 --- a/codex-rs/app-server/src/message_processor_tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor_tracing_tests.rs @@ -298,6 +298,7 @@ async fn build_test_processor( config_warnings: Vec::new(), session_source: SessionSource::VSCode, auth_manager, + installation_id: "11111111-1111-4111-8111-111111111111".to_string(), rpc_transport: AppServerRpcTransport::Stdio, remote_control_handle: None, plugin_startup_tasks: crate::PluginStartupTasks::Start, diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index be6d559866..9f419d7006 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -115,11 +115,17 @@ use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginShareDeleteParams; use codex_app_server_protocol::PluginShareDeleteResponse; +use codex_app_server_protocol::PluginShareDiscoverability; use codex_app_server_protocol::PluginShareListItem; use codex_app_server_protocol::PluginShareListParams; use codex_app_server_protocol::PluginShareListResponse; +use codex_app_server_protocol::PluginSharePrincipal; +use codex_app_server_protocol::PluginSharePrincipalType; use codex_app_server_protocol::PluginShareSaveParams; use codex_app_server_protocol::PluginShareSaveResponse; +use codex_app_server_protocol::PluginShareTarget; +use codex_app_server_protocol::PluginShareUpdateTargetsParams; +use codex_app_server_protocol::PluginShareUpdateTargetsResponse; use codex_app_server_protocol::PluginSkillReadParams; use codex_app_server_protocol::PluginSkillReadResponse; use codex_app_server_protocol::PluginSource; @@ -355,7 +361,6 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::GitInfo as CoreGitInfo; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::McpAuthStatus as CoreMcpAuthStatus; -use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::Op; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; use codex_protocol::protocol::RealtimeVoicesList; diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index 2609e25ac4..c73d6700e7 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -168,7 +168,7 @@ impl AccountRequestProcessor { { Ok(config) => { let refresh_thread_manager = Arc::clone(thread_manager); - let refresh_config = config.clone(); + let refresh_config_manager = config_manager.clone(); thread_manager .plugins_manager() .maybe_start_remote_installed_plugins_cache_refresh( @@ -177,7 +177,7 @@ impl AccountRequestProcessor { Some(Arc::new(move || { Self::spawn_effective_plugins_changed_task( Arc::clone(&refresh_thread_manager), - refresh_config.clone(), + refresh_config_manager.clone(), ); })), ); @@ -190,19 +190,17 @@ impl AccountRequestProcessor { } } - fn spawn_effective_plugins_changed_task(thread_manager: Arc, config: Config) { + fn spawn_effective_plugins_changed_task( + thread_manager: Arc, + config_manager: ConfigManager, + ) { tokio::spawn(async move { thread_manager.plugins_manager().clear_cache(); thread_manager.skills_manager().clear_cache(); if thread_manager.list_thread_ids().await.is_empty() { return; } - if let Err(err) = - McpRequestProcessor::queue_mcp_server_refresh_for_config(&thread_manager, &config) - .await - { - warn!("failed to queue MCP refresh after effective plugins changed: {err:?}"); - } + crate::mcp_refresh::queue_best_effort_refresh(&thread_manager, &config_manager).await; }); } diff --git a/codex-rs/app-server/src/request_processors/mcp_processor.rs b/codex-rs/app-server/src/request_processors/mcp_processor.rs index 22d43d87a2..243506f6af 100644 --- a/codex-rs/app-server/src/request_processors/mcp_processor.rs +++ b/codex-rs/app-server/src/request_processors/mcp_processor.rs @@ -77,8 +77,9 @@ impl McpRequestProcessor { &self, _params: Option<()>, ) -> Result { - let config = self.load_latest_config(/*fallback_cwd*/ None).await?; - Self::queue_mcp_server_refresh_for_config(&self.thread_manager, &config).await?; + crate::mcp_refresh::queue_strict_refresh(&self.thread_manager, &self.config_manager) + .await + .map_err(|err| internal_error(format!("failed to refresh MCP servers: {err}")))?; Ok(McpServerRefreshResponse {}) } @@ -108,44 +109,6 @@ impl McpRequestProcessor { Ok((thread_id, thread)) } - pub(super) async fn queue_mcp_server_refresh_for_config( - thread_manager: &Arc, - config: &Config, - ) -> Result<(), JSONRPCErrorError> { - let configured_servers = thread_manager - .mcp_manager() - .configured_servers(config) - .await; - let mcp_servers = match serde_json::to_value(configured_servers) { - Ok(value) => value, - Err(err) => { - return Err(internal_error(format!( - "failed to serialize MCP servers: {err}" - ))); - } - }; - - let mcp_oauth_credentials_store_mode = - match serde_json::to_value(config.mcp_oauth_credentials_store_mode) { - Ok(value) => value, - Err(err) => { - return Err(internal_error(format!( - "failed to serialize MCP OAuth credentials store mode: {err}" - ))); - } - }; - - let refresh_config = McpServerRefreshConfig { - mcp_servers, - mcp_oauth_credentials_store_mode, - }; - - // Refresh requests are queued per thread; each thread rebuilds MCP connections on its next - // active turn to avoid work for threads that never resume. - thread_manager.refresh_mcp_servers(refresh_config).await; - Ok(()) - } - async fn mcp_server_oauth_login_response( &self, params: McpServerOauthLoginParams, diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index d9f66d65ce..7d542edf39 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -86,6 +86,66 @@ fn marketplace_plugin_source_to_info(source: MarketplacePluginSource) -> PluginS } } +fn remote_plugin_share_discoverability( + discoverability: PluginShareDiscoverability, +) -> codex_core_plugins::remote::RemotePluginShareDiscoverability { + match discoverability { + PluginShareDiscoverability::Listed => { + codex_core_plugins::remote::RemotePluginShareDiscoverability::Listed + } + PluginShareDiscoverability::Unlisted => { + codex_core_plugins::remote::RemotePluginShareDiscoverability::Unlisted + } + PluginShareDiscoverability::Private => { + codex_core_plugins::remote::RemotePluginShareDiscoverability::Private + } + } +} + +fn remote_plugin_share_targets( + targets: Vec, +) -> Vec { + targets + .into_iter() + .map( + |target| codex_core_plugins::remote::RemotePluginShareTarget { + principal_type: match target.principal_type { + PluginSharePrincipalType::User => { + codex_core_plugins::remote::RemotePluginSharePrincipalType::User + } + PluginSharePrincipalType::Group => { + codex_core_plugins::remote::RemotePluginSharePrincipalType::Group + } + PluginSharePrincipalType::Workspace => { + codex_core_plugins::remote::RemotePluginSharePrincipalType::Workspace + } + }, + principal_id: target.principal_id, + }, + ) + .collect() +} + +fn plugin_share_principal_from_remote( + principal: codex_core_plugins::remote::RemotePluginSharePrincipal, +) -> PluginSharePrincipal { + PluginSharePrincipal { + principal_type: match principal.principal_type { + codex_core_plugins::remote::RemotePluginSharePrincipalType::User => { + PluginSharePrincipalType::User + } + codex_core_plugins::remote::RemotePluginSharePrincipalType::Group => { + PluginSharePrincipalType::Group + } + codex_core_plugins::remote::RemotePluginSharePrincipalType::Workspace => { + PluginSharePrincipalType::Workspace + } + }, + principal_id: principal.principal_id, + name: principal.name, + } +} + impl PluginRequestProcessor { pub(crate) fn new( auth_manager: Arc, @@ -141,6 +201,15 @@ impl PluginRequestProcessor { .map(|response| Some(response.into())) } + pub(crate) async fn plugin_share_update_targets( + &self, + params: PluginShareUpdateTargetsParams, + ) -> Result, JSONRPCErrorError> { + self.plugin_share_update_targets_response(params) + .await + .map(|response| Some(response.into())) + } + pub(crate) async fn plugin_share_list( &self, params: PluginShareListParams, @@ -177,33 +246,35 @@ impl PluginRequestProcessor { .map(|response| Some(response.into())) } - pub(crate) fn effective_plugins_changed_callback( - &self, - config: Config, - ) -> Arc { + pub(crate) fn effective_plugins_changed_callback(&self) -> Arc { let thread_manager = Arc::clone(&self.thread_manager); + let config_manager = self.config_manager.clone(); Arc::new(move || { - Self::spawn_effective_plugins_changed_task(Arc::clone(&thread_manager), config.clone()); + Self::spawn_effective_plugins_changed_task( + Arc::clone(&thread_manager), + config_manager.clone(), + ); }) } - fn on_effective_plugins_changed(&self, config: Config) { - Self::spawn_effective_plugins_changed_task(Arc::clone(&self.thread_manager), config); + fn on_effective_plugins_changed(&self) { + Self::spawn_effective_plugins_changed_task( + Arc::clone(&self.thread_manager), + self.config_manager.clone(), + ); } - fn spawn_effective_plugins_changed_task(thread_manager: Arc, config: Config) { + fn spawn_effective_plugins_changed_task( + thread_manager: Arc, + config_manager: ConfigManager, + ) { tokio::spawn(async move { thread_manager.plugins_manager().clear_cache(); thread_manager.skills_manager().clear_cache(); if thread_manager.list_thread_ids().await.is_empty() { return; } - if let Err(err) = - McpRequestProcessor::queue_mcp_server_refresh_for_config(&thread_manager, &config) - .await - { - warn!("failed to queue MCP refresh after effective plugins changed: {err:?}"); - } + crate::mcp_refresh::queue_best_effort_refresh(&thread_manager, &config_manager).await; }); } @@ -273,7 +344,7 @@ impl PluginRequestProcessor { &plugins_input, auth.clone(), &roots, - Some(self.effective_plugins_changed_callback(config.clone())), + Some(self.effective_plugins_changed_callback()), ); let config_for_marketplace_listing = plugins_input.clone(); @@ -570,22 +641,34 @@ impl PluginRequestProcessor { let PluginShareSaveParams { plugin_path, remote_plugin_id, + discoverability, + share_targets, } = params; if let Some(remote_plugin_id) = remote_plugin_id.as_ref() && (remote_plugin_id.is_empty() || !is_valid_remote_plugin_id(remote_plugin_id)) { return Err(invalid_request("invalid remote plugin id")); } + if remote_plugin_id.is_some() && (discoverability.is_some() || share_targets.is_some()) { + return Err(invalid_request( + "discoverability and shareTargets are only supported when creating a plugin share; use plugin/share/updateTargets to update share targets", + )); + } let remote_plugin_service_config = RemotePluginServiceConfig { chatgpt_base_url: config.chatgpt_base_url.clone(), }; + let access_policy = codex_core_plugins::remote::RemotePluginShareAccessPolicy { + discoverability: discoverability.map(remote_plugin_share_discoverability), + share_targets: share_targets.map(remote_plugin_share_targets), + }; let result = codex_core_plugins::remote::save_remote_plugin_share( &remote_plugin_service_config, auth.as_ref(), config.codex_home.as_path(), &plugin_path, remote_plugin_id.as_deref(), + access_policy, ) .await .map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "save remote plugin share"))?; @@ -597,6 +680,42 @@ impl PluginRequestProcessor { }) } + async fn plugin_share_update_targets_response( + &self, + params: PluginShareUpdateTargetsParams, + ) -> Result { + let (config, auth) = self.load_plugin_share_config_and_auth().await?; + let PluginShareUpdateTargetsParams { + remote_plugin_id, + share_targets, + } = params; + if remote_plugin_id.is_empty() || !is_valid_remote_plugin_id(&remote_plugin_id) { + return Err(invalid_request("invalid remote plugin id")); + } + + let remote_plugin_service_config = RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + }; + let result = codex_core_plugins::remote::update_remote_plugin_share_targets( + &remote_plugin_service_config, + auth.as_ref(), + &remote_plugin_id, + remote_plugin_share_targets(share_targets), + ) + .await + .map_err(|err| { + remote_plugin_catalog_error_to_jsonrpc(err, "update remote plugin share targets") + })?; + self.clear_plugin_related_caches(); + Ok(PluginShareUpdateTargetsResponse { + principals: result + .principals + .into_iter() + .map(plugin_share_principal_from_remote) + .collect(), + }) + } + async fn plugin_share_list_response( &self, _params: PluginShareListParams, @@ -723,7 +842,7 @@ impl PluginRequestProcessor { } }; - self.on_effective_plugins_changed(config.clone()); + self.on_effective_plugins_changed(); let plugin_mcp_servers = load_plugin_mcp_servers(result.installed_path.as_path()).await; if !plugin_mcp_servers.is_empty() { @@ -834,7 +953,7 @@ impl PluginRequestProcessor { .maybe_start_remote_installed_plugins_cache_refresh_after_mutation( &config.plugins_config_input(), auth.clone(), - Some(self.effective_plugins_changed_callback(config.clone())), + Some(self.effective_plugins_changed_callback()), ); let mut plugin_metadata = @@ -1027,7 +1146,7 @@ impl PluginRequestProcessor { .await .map_err(Self::plugin_uninstall_error)?; match self.load_latest_config(/*fallback_cwd*/ None).await { - Ok(config) => self.on_effective_plugins_changed(config), + Ok(_) => self.on_effective_plugins_changed(), Err(err) => { warn!( "failed to reload config after plugin uninstall, clearing plugin-related caches only: {err:?}" @@ -1128,12 +1247,12 @@ impl PluginRequestProcessor { ) { let plugins_manager = self.thread_manager.plugins_manager(); if plugins_manager.clear_remote_installed_plugins_cache() { - self.on_effective_plugins_changed(config.clone()); + self.on_effective_plugins_changed(); } plugins_manager.maybe_start_remote_installed_plugins_cache_refresh_after_mutation( &config.plugins_config_input(), auth.clone(), - Some(self.effective_plugins_changed_callback(config.clone())), + Some(self.effective_plugins_changed_callback()), ); } diff --git a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs index 4a677d91ab..81e09c7d5c 100644 --- a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs +++ b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs @@ -598,6 +598,8 @@ pub(super) async fn handle_pending_thread_resume_request( let sandbox = thread_response_sandbox_policy(&permission_profile, cwd.as_path()); let active_permission_profile = thread_response_active_permission_profile(active_permission_profile); + let session_id = conversation.session_configured().session_id.to_string(); + thread.session_id = session_id; let response = ThreadResumeResponse { thread, diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 321d1703dc..783e53ccb0 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -184,6 +184,53 @@ fn has_model_resume_override( } fn validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String> { + const DYNAMIC_TOOL_NAME_MAX_LEN: usize = 128; + const DYNAMIC_TOOL_NAMESPACE_MAX_LEN: usize = 64; + const DYNAMIC_TOOL_IDENTIFIER_PATTERN: &str = "^[a-zA-Z0-9_-]+$"; + const RESERVED_RESPONSES_NAMESPACES: &[&str] = &[ + "api_tool", + "browser", + "computer", + "container", + "file_search", + "functions", + "image_gen", + "multi_tool_use", + "python", + "python_user_visible", + "submodel_delegator", + "terminal", + "tool_search", + "web", + ]; + + fn escape_identifier_for_error(value: &str) -> String { + value.escape_default().to_string() + } + + fn validate_dynamic_tool_identifier( + value: &str, + label: &str, + max_len: usize, + ) -> Result<(), String> { + if !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return Err(format!( + "{label} must match {DYNAMIC_TOOL_IDENTIFIER_PATTERN} to match Responses API: {}", + escape_identifier_for_error(value), + )); + } + if value.chars().count() > max_len { + return Err(format!( + "{label} must be at most {max_len} characters to match Responses API: {}", + escape_identifier_for_error(value), + )); + } + Ok(()) + } + let mut seen = HashSet::new(); for tool in tools { let name = tool.name.trim(); @@ -193,9 +240,10 @@ fn validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String> { if name != tool.name { return Err(format!( "dynamic tool name has leading/trailing whitespace: {}", - tool.name + escape_identifier_for_error(&tool.name), )); } + validate_dynamic_tool_identifier(name, "dynamic tool name", DYNAMIC_TOOL_NAME_MAX_LEN)?; if name == "mcp" || name.starts_with("mcp__") { return Err(format!("dynamic tool name is reserved: {name}")); } @@ -209,13 +257,25 @@ fn validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String> { if Some(namespace) != tool.namespace.as_deref() { return Err(format!( "dynamic tool namespace has leading/trailing whitespace for {name}: {namespace}", + name = escape_identifier_for_error(name), + namespace = escape_identifier_for_error(namespace), )); } + validate_dynamic_tool_identifier( + namespace, + "dynamic tool namespace", + DYNAMIC_TOOL_NAMESPACE_MAX_LEN, + )?; if namespace == "mcp" || namespace.starts_with("mcp__") { return Err(format!( "dynamic tool namespace is reserved for {name}: {namespace}" )); } + if RESERVED_RESPONSES_NAMESPACES.contains(&namespace) { + return Err(format!( + "dynamic tool namespace collides with a reserved Responses API namespace for {name}: {namespace}", + )); + } } if !seen.insert((namespace, name)) { if let Some(namespace) = namespace { @@ -1036,6 +1096,7 @@ impl ThreadRequestProcessor { .await; let mut thread = build_thread_from_snapshot( thread_id, + session_configured.session_id.to_string(), &config_snapshot, session_configured.rollout_path.clone(), ); @@ -1127,7 +1188,7 @@ impl ThreadRequestProcessor { &self, model: Option, model_provider: Option, - service_tier: Option>, + service_tier: Option>, cwd: Option, approval_policy: Option, approvals_reviewer: Option, @@ -1468,9 +1529,9 @@ impl ThreadRequestProcessor { ..Default::default() }; + let loaded_thread = self.thread_manager.get_thread(thread_uuid).await.ok(); let updated_thread = { let _thread_list_state_permit = self.acquire_thread_list_state_permit().await?; - let loaded_thread = self.thread_manager.get_thread(thread_uuid).await.ok(); if let Some(loaded_thread) = loaded_thread.as_ref() { if loaded_thread.config_snapshot().await.ephemeral { return Err(invalid_request(format!( @@ -1491,12 +1552,14 @@ impl ThreadRequestProcessor { } .map_err(|err| thread_store_write_error("update thread metadata", err))? }; - let (mut thread, _) = thread_from_stored_thread( updated_thread, self.config.model_provider_id.as_str(), &self.config.cwd, ); + if let Some(loaded_thread) = loaded_thread.as_ref() { + thread.session_id = loaded_thread.session_configured().session_id.to_string(); + } self.attach_thread_name(thread_uuid, &mut thread).await; thread.status = resolve_thread_status( self.thread_watch_manager @@ -1542,18 +1605,16 @@ impl ThreadRequestProcessor { .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; let fallback_provider = self.config.model_provider_id.clone(); - let mut thread = self + let stored_thread = self .thread_store .unarchive_thread(StoreArchiveThreadParams { thread_id }) .await - .map_err(|err| thread_store_archive_error("unarchive", err)) - .and_then(|stored_thread| { - summary_from_stored_thread(stored_thread, fallback_provider.as_str()) - .map(|summary| summary_to_thread(summary, &self.config.cwd)) - .ok_or_else(|| { - internal_error(format!("failed to read unarchived thread {thread_id}")) - }) + .map_err(|err| thread_store_archive_error("unarchive", err))?; + let summary = summary_from_stored_thread(stored_thread, fallback_provider.as_str()) + .ok_or_else(|| { + internal_error(format!("failed to read unarchived thread {thread_id}")) })?; + let mut thread = summary_to_thread(summary, &self.config.cwd); thread.status = resolve_thread_status( self.thread_watch_manager @@ -1979,6 +2040,7 @@ impl ThreadRequestProcessor { if thread.path.is_none() { thread.path = fallback_thread.path.clone(); } + thread.session_id.clone_from(&fallback_thread.session_id); thread.ephemeral = fallback_thread.ephemeral; thread } else { @@ -2162,8 +2224,12 @@ impl ThreadRequestProcessor { ) { if let Ok(thread) = self.thread_manager.get_thread(thread_id).await { let config_snapshot = thread.config_snapshot().await; - let loaded_thread = - build_thread_from_snapshot(thread_id, &config_snapshot, thread.rollout_path()); + let loaded_thread = build_thread_from_snapshot( + thread_id, + thread.session_configured().session_id.to_string(), + &config_snapshot, + thread.rollout_path(), + ); self.thread_watch_manager.upsert_thread(loaded_thread).await; } @@ -2589,11 +2655,12 @@ impl ThreadRequestProcessor { } let mut summary_source_thread = source_thread; summary_source_thread.history = None; - let thread_summary = self.stored_thread_to_api_thread( + let mut thread_summary = self.stored_thread_to_api_thread( summary_source_thread, config_snapshot.model_provider_id.as_str(), /*include_turns*/ false, ); + thread_summary.session_id = existing_thread.session_configured().session_id.to_string(); let mut config_for_instruction_sources = self.config.as_ref().clone(); config_for_instruction_sources.cwd = config_snapshot.cwd.clone(); let instruction_sources = @@ -2762,6 +2829,7 @@ impl ThreadRequestProcessor { include_turns: bool, ) -> std::result::Result { let config_snapshot = thread.config_snapshot().await; + let session_id = thread.session_configured().session_id.to_string(); let thread = match thread_history { InitialHistory::Resumed(resumed) => { let fallback_provider = config_snapshot.model_provider_id.as_str(); @@ -2823,6 +2891,7 @@ impl ThreadRequestProcessor { InitialHistory::Forked(items) => { let mut thread = build_thread_from_snapshot( thread_id, + session_id.clone(), &config_snapshot, Some(rollout_path.into()), ); @@ -2835,6 +2904,7 @@ impl ThreadRequestProcessor { }; let mut thread = thread?; thread.id = thread_id.to_string(); + thread.session_id = session_id; thread.path = Some(rollout_path.to_path_buf()); if include_turns { let history_items = thread_history.get_rollout_items(); @@ -3016,8 +3086,12 @@ impl ThreadRequestProcessor { } else { let config_snapshot = forked_thread.config_snapshot().await; // forked thread names do not inherit the source thread name - let mut thread = - build_thread_from_snapshot(thread_id, &config_snapshot, /*path*/ None); + let mut thread = build_thread_from_snapshot( + thread_id, + session_configured.session_id.to_string(), + &config_snapshot, + /*path*/ None, + ); thread.preview = preview_from_rollout_items(&history_items); thread.forked_from_id = Some(source_thread_id.to_string()); if include_turns { @@ -3029,6 +3103,7 @@ impl ThreadRequestProcessor { } thread }; + thread.session_id = session_configured.session_id.to_string(); thread.thread_source = forked_thread .config_snapshot() .await @@ -3617,8 +3692,10 @@ pub(crate) fn thread_from_stored_thread( thread.agent_role.clone(), ); let history = thread.history; + let thread_id = thread.thread_id.to_string(); let thread = Thread { - id: thread.thread_id.to_string(), + id: thread_id.clone(), + session_id: thread_id, forked_from_id: thread.forked_from_id.map(|id| id.to_string()), preview: thread.first_user_message.unwrap_or(thread.preview), ephemeral: false, @@ -3815,12 +3892,14 @@ fn permission_profile_trusts_project( fn build_thread_from_snapshot( thread_id: ThreadId, + session_id: String, config_snapshot: &ThreadConfigSnapshot, path: Option, ) -> Thread { let now = time::OffsetDateTime::now_utc().unix_timestamp(); Thread { id: thread_id.to_string(), + session_id, forked_from_id: None, preview: String::new(), ephemeral: config_snapshot.ephemeral, @@ -3846,7 +3925,12 @@ fn build_thread_from_loaded_snapshot( config_snapshot: &ThreadConfigSnapshot, loaded_thread: &CodexThread, ) -> Thread { - build_thread_from_snapshot(thread_id, config_snapshot, loaded_thread.rollout_path()) + build_thread_from_snapshot( + thread_id, + loaded_thread.session_configured().session_id.to_string(), + config_snapshot, + loaded_thread.rollout_path(), + ) } #[cfg(test)] diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index 6bcbda478c..5a18c09db9 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -156,6 +156,22 @@ mod thread_processor_behavior_tests { validate_dynamic_tools(&tools).expect("valid schema"); } + #[test] + fn validate_dynamic_tools_accepts_responses_compatible_identifiers() { + let tools = vec![ApiDynamicToolSpec { + namespace: Some("Codex-App_2".to_string()), + name: "lookup-ticket_2".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: true, + }]; + validate_dynamic_tools(&tools).expect("valid schema"); + } + #[test] fn validate_dynamic_tools_rejects_duplicate_name_in_same_namespace() { let tools = vec![ @@ -260,6 +276,104 @@ mod thread_processor_behavior_tests { assert!(err.contains("reserved"), "unexpected error: {err}"); } + #[test] + fn validate_dynamic_tools_rejects_name_not_supported_by_responses() { + let tools = vec![ApiDynamicToolSpec { + namespace: None, + name: "lookup.ticket".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: false, + }]; + let err = validate_dynamic_tools(&tools).expect_err("invalid name"); + assert!(err.contains("lookup.ticket"), "unexpected error: {err}"); + assert!( + err.contains("Responses API") && err.contains("^[a-zA-Z0-9_-]+$"), + "unexpected error: {err}" + ); + } + + #[test] + fn validate_dynamic_tools_rejects_namespace_not_supported_by_responses() { + let tools = vec![ApiDynamicToolSpec { + namespace: Some("codex.app".to_string()), + name: "lookup_ticket".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: true, + }]; + let err = validate_dynamic_tools(&tools).expect_err("invalid namespace"); + assert!(err.contains("codex.app"), "unexpected error: {err}"); + assert!( + err.contains("Responses API") && err.contains("^[a-zA-Z0-9_-]+$"), + "unexpected error: {err}" + ); + } + + #[test] + fn validate_dynamic_tools_rejects_name_longer_than_responses_limit() { + let long_name = "a".repeat(129); + let tools = vec![ApiDynamicToolSpec { + namespace: None, + name: long_name.clone(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: false, + }]; + let err = validate_dynamic_tools(&tools).expect_err("name too long"); + assert!(err.contains("at most 128"), "unexpected error: {err}"); + assert!(err.contains(&long_name), "unexpected error: {err}"); + } + + #[test] + fn validate_dynamic_tools_rejects_namespace_longer_than_responses_limit() { + let long_namespace = "a".repeat(65); + let tools = vec![ApiDynamicToolSpec { + namespace: Some(long_namespace.clone()), + name: "lookup_ticket".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: true, + }]; + let err = validate_dynamic_tools(&tools).expect_err("namespace too long"); + assert!(err.contains("at most 64"), "unexpected error: {err}"); + assert!(err.contains(&long_namespace), "unexpected error: {err}"); + } + + #[test] + fn validate_dynamic_tools_rejects_reserved_responses_namespace() { + let tools = vec![ApiDynamicToolSpec { + namespace: Some("functions".to_string()), + name: "lookup_ticket".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: true, + }]; + let err = validate_dynamic_tools(&tools).expect_err("reserved Responses namespace"); + assert!(err.contains("functions"), "unexpected error: {err}"); + assert!(err.contains("Responses API"), "unexpected error: {err}"); + } + #[test] fn summary_from_stored_thread_preserves_millisecond_precision() { let created_at = @@ -515,7 +629,7 @@ mod thread_processor_behavior_tests { path: None, model: None, model_provider: None, - service_tier: Some(Some(codex_protocol::config_types::ServiceTier::Fast)), + service_tier: Some(Some("priority".to_string())), cwd: None, approval_policy: None, approvals_reviewer: None, @@ -531,7 +645,7 @@ mod thread_processor_behavior_tests { let config_snapshot = ThreadConfigSnapshot { model: "gpt-5".to_string(), model_provider_id: "openai".to_string(), - service_tier: Some(codex_protocol::config_types::ServiceTier::Flex), + service_tier: Some("flex".to_string()), approval_policy: codex_protocol::protocol::AskForApproval::OnRequest, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, permission_profile: codex_protocol::models::PermissionProfile::Disabled, @@ -546,7 +660,7 @@ mod thread_processor_behavior_tests { assert_eq!( collect_resume_override_mismatches(&request, &config_snapshot), - vec!["service_tier requested=Some(Fast) active=Some(Flex)".to_string()] + vec!["service_tier requested=Some(\"priority\") active=Some(\"flex\")".to_string()] ); } diff --git a/codex-rs/app-server/src/request_processors/thread_summary.rs b/codex-rs/app-server/src/request_processors/thread_summary.rs index be3000e36a..f2de590a9a 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -263,8 +263,10 @@ pub(crate) fn summary_to_thread( fallback_cwd.clone() }); + let thread_id = conversation_id.to_string(); Thread { - id: conversation_id.to_string(), + id: thread_id.clone(), + session_id: thread_id, forked_from_id: None, preview, ephemeral: false, diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index a3783f995d..b207fde940 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -438,7 +438,7 @@ impl TurnRequestProcessor { model: model.clone(), effort, summary, - service_tier, + service_tier: service_tier.clone(), collaboration_mode: collaboration_mode.clone(), personality, }) @@ -935,6 +935,7 @@ impl TurnRequestProcessor { Ok(stored_thread) => { let (mut thread, _) = thread_from_stored_thread(stored_thread, fallback_provider, &self.config.cwd); + thread.session_id = review_thread.session_configured().session_id.to_string(); self.thread_watch_manager .upsert_thread_silently(thread.clone()) .await; diff --git a/codex-rs/app-server/src/thread_status.rs b/codex-rs/app-server/src/thread_status.rs index 47da7e8cad..7315a13c02 100644 --- a/codex-rs/app-server/src/thread_status.rs +++ b/codex-rs/app-server/src/thread_status.rs @@ -889,6 +889,7 @@ mod tests { fn test_thread(thread_id: &str, source: codex_app_server_protocol::SessionSource) -> Thread { Thread { id: thread_id.to_string(), + session_id: thread_id.to_string(), forked_from_id: None, preview: String::new(), ephemeral: false, diff --git a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs index 7ee21a2068..b357e139db 100644 --- a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs +++ b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs @@ -239,6 +239,46 @@ async fn thread_start_rejects_hidden_dynamic_tools_without_namespace() -> Result Ok(()) } +#[tokio::test] +async fn thread_start_rejects_dynamic_tools_not_supported_by_responses() -> Result<()> { + let server = MockServer::start().await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let dynamic_tool = DynamicToolSpec { + namespace: Some("codex.app".to_string()), + name: "lookup.ticket".to_string(), + description: "Invalid dynamic tool".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false, + }), + defer_loading: false, + }; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + dynamic_tools: Some(vec![dynamic_tool]), + ..Default::default() + }) + .await?; + let error = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(thread_req)), + ) + .await??; + assert_eq!(error.error.code, -32600); + assert!(error.error.message.contains("Responses API")); + assert!(error.error.message.contains("lookup.ticket")); + + Ok(()) +} + /// Exercises the full dynamic tool call path (server request, client response, model output). #[tokio::test] async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Result<()> { diff --git a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs index 141761e88a..b805f75ba7 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs @@ -65,6 +65,9 @@ const TEST_TOOL_NAME: &str = "echo_tool"; const LARGE_RESPONSE_MESSAGE: &str = "large"; const ELICITATION_TRIGGER_MESSAGE: &str = "confirm"; const ELICITATION_MESSAGE: &str = "Allow this request?"; +const URL_ELICITATION_TRIGGER_MESSAGE: &str = "auth"; +const URL_ELICITATION_MESSAGE: &str = "Sign in to GitHub to continue."; +const URL_ELICITATION_URL: &str = "https://github.example/login/device"; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn mcp_server_tool_call_returns_tool_result() -> Result<()> { @@ -294,6 +297,109 @@ url = "{mcp_server_url}/mcp" Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mcp_server_tool_call_forwards_url_elicitation() -> Result<()> { + let responses_server = responses::start_mock_server().await; + let (mcp_server_url, mcp_server_handle) = start_mcp_server().await?; + let codex_home = TempDir::new()?; + write_mock_responses_config_toml( + codex_home.path(), + &responses_server.uri(), + &BTreeMap::new(), + /*auto_compact_limit*/ 1024, + /*requires_openai_auth*/ None, + "mock_provider", + "compact", + )?; + + let config_path = codex_home.path().join("config.toml"); + let mut config_toml = std::fs::read_to_string(&config_path)?; + config_toml.push_str(&format!( + r#" +[mcp_servers.{TEST_SERVER_NAME}] +url = "{mcp_server_url}/mcp" +"# + )); + std::fs::write(config_path, config_toml)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + approval_policy: Some(codex_app_server_protocol::AskForApproval::UnlessTrusted), + ..Default::default() + }) + .await?; + let thread_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?; + + let tool_call_request_id = mcp + .send_mcp_server_tool_call_request(McpServerToolCallParams { + thread_id: thread.id.clone(), + server: TEST_SERVER_NAME.to_string(), + tool: TEST_TOOL_NAME.to_string(), + arguments: Some(json!({ + "message": URL_ELICITATION_TRIGGER_MESSAGE, + })), + meta: None, + }) + .await?; + + let server_req = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::McpServerElicitationRequest { request_id, params } = server_req else { + panic!("expected McpServerElicitationRequest request, got: {server_req:?}"); + }; + assert_eq!( + params, + McpServerElicitationRequestParams { + thread_id: thread.id, + turn_id: None, + server_name: TEST_SERVER_NAME.to_string(), + request: McpServerElicitationRequest::Url { + meta: None, + message: URL_ELICITATION_MESSAGE.to_string(), + url: URL_ELICITATION_URL.to_string(), + elicitation_id: "github-auth-123".to_string(), + }, + } + ); + + mcp.send_response( + request_id, + serde_json::to_value(McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Accept, + content: None, + meta: None, + })?, + ) + .await?; + + let tool_call_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(tool_call_request_id)), + ) + .await??; + let response: McpServerToolCallResponse = to_response(tool_call_response)?; + assert_eq!(response.content.len(), 1); + assert_eq!(response.content[0].get("type"), Some(&json!("text"))); + assert_eq!(response.content[0].get("text"), Some(&json!("accepted"))); + + mcp_server_handle.abort(); + let _ = mcp_server_handle.await; + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn mcp_tool_call_completion_notification_contains_truncated_large_result() -> Result<()> { let call_id = "call-large-mcp"; @@ -528,6 +634,28 @@ impl ServerHandler for ToolAppsMcpServer { return Ok(CallToolResult::success(vec![Content::text(output)])); } + if message == URL_ELICITATION_TRIGGER_MESSAGE { + let result = context + .peer + .create_elicitation(CreateElicitationRequestParams::UrlElicitationParams { + meta: None, + message: URL_ELICITATION_MESSAGE.to_string(), + url: URL_ELICITATION_URL.to_string(), + elicitation_id: "github-auth-123".to_string(), + }) + .await + .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; + let output = match result.action { + ElicitationAction::Accept => { + assert_eq!(result.content, Some(json!({}))); + "accepted" + } + ElicitationAction::Decline => "declined", + ElicitationAction::Cancel => "cancelled", + }; + return Ok(CallToolResult::success(vec![Content::text(output)])); + } + let mut result = CallToolResult::structured(json!({ "echoed": message, "threadId": thread_id, diff --git a/codex-rs/app-server/tests/suite/v2/plugin_share.rs b/codex-rs/app-server/tests/suite/v2/plugin_share.rs index 604935b6e5..8175ecd459 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_share.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_share.rs @@ -7,6 +7,7 @@ use app_test_support::ChatGptAuthFixture; use app_test_support::McpProcess; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginInstallPolicy; @@ -14,7 +15,10 @@ use codex_app_server_protocol::PluginInterface; use codex_app_server_protocol::PluginShareDeleteResponse; use codex_app_server_protocol::PluginShareListItem; use codex_app_server_protocol::PluginShareListResponse; +use codex_app_server_protocol::PluginSharePrincipal; +use codex_app_server_protocol::PluginSharePrincipalType; use codex_app_server_protocol::PluginShareSaveResponse; +use codex_app_server_protocol::PluginShareUpdateTargetsResponse; use codex_app_server_protocol::PluginSource; use codex_app_server_protocol::PluginSummary; use codex_app_server_protocol::RequestId; @@ -174,6 +178,154 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_share_save_forwards_access_policy() -> Result<()> { + let codex_home = TempDir::new()?; + let plugin_root = TempDir::new()?; + let plugin_path = write_test_plugin(plugin_root.path(), "demo-plugin")?; + let server = MockServer::start().await; + write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", server.uri()))?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + Mock::given(method("POST")) + .and(path("/backend-api/public/plugins/workspace/upload-url")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "file_id": "file_123", + "upload_url": format!("{}/upload/file_123", server.uri()), + "etag": "\"upload_etag_123\"", + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("PUT")) + .and(path("/upload/file_123")) + .respond_with(ResponseTemplate::new(201).insert_header("etag", "\"blob_etag_123\"")) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/backend-api/public/plugins/workspace")) + .and(body_json(json!({ + "file_id": "file_123", + "etag": "\"upload_etag_123\"", + "discoverability": "PRIVATE", + "share_targets": [ + { + "principal_type": "user", + "principal_id": "user-1", + }, + { + "principal_type": "workspace", + "principal_id": "workspace-1", + }, + ], + }))) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "plugin_id": "plugins_123", + "share_url": "https://chatgpt.example/plugins/share/share-key-1", + }))) + .expect(1) + .mount(&server) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let expected_plugin_path = AbsolutePathBuf::try_from(plugin_path)?; + let request_id = mcp + .send_raw_request( + "plugin/share/save", + Some(json!({ + "pluginPath": expected_plugin_path, + "discoverability": "PRIVATE", + "shareTargets": [ + { + "principalType": "user", + "principalId": "user-1", + }, + { + "principalType": "workspace", + "principalId": "workspace-1", + }, + ], + })), + ) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginShareSaveResponse = to_response(response)?; + + assert_eq!( + response, + PluginShareSaveResponse { + remote_plugin_id: "plugins_123".to_string(), + share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(), + } + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_share_save_rejects_access_policy_for_existing_plugin() -> Result<()> { + let codex_home = TempDir::new()?; + let plugin_root = TempDir::new()?; + let plugin_path = write_test_plugin(plugin_root.path(), "demo-plugin")?; + let server = MockServer::start().await; + write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", server.uri()))?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let request_id = mcp + .send_raw_request( + "plugin/share/save", + Some(json!({ + "pluginPath": AbsolutePathBuf::try_from(plugin_path)?, + "remotePluginId": "plugins_123", + "discoverability": "PRIVATE", + "shareTargets": [ + { + "principalType": "user", + "principalId": "user-1", + }, + ], + })), + ) + .await?; + + let error: JSONRPCError = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.error.code, -32600); + assert_eq!( + error.error.message, + "discoverability and shareTargets are only supported when creating a plugin share; use plugin/share/updateTargets to update share targets" + ); + Ok(()) +} + #[tokio::test] async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> { let codex_home = TempDir::new()?; @@ -250,6 +402,82 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_share_update_targets_updates_share_targets() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", server.uri()))?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + Mock::given(method("PUT")) + .and(path("/backend-api/public/plugins/plugins_123/shares")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .and(body_json(json!({ + "targets": [ + { + "principal_type": "user", + "principal_id": "user-1", + }, + ], + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "principals": [ + { + "principal_type": "user", + "principal_id": "user-1", + "name": "Gavin", + }, + ], + }))) + .expect(1) + .mount(&server) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let request_id = mcp + .send_raw_request( + "plugin/share/updateTargets", + Some(json!({ + "remotePluginId": "plugins_123", + "shareTargets": [ + { + "principalType": "user", + "principalId": "user-1", + }, + ], + })), + ) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginShareUpdateTargetsResponse = to_response(response)?; + + assert_eq!( + response, + PluginShareUpdateTargetsResponse { + principals: vec![PluginSharePrincipal { + principal_type: PluginSharePrincipalType::User, + principal_id: "user-1".to_string(), + name: "Gavin".to_string(), + }], + } + ); + Ok(()) +} + #[tokio::test] async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/app-server/tests/suite/v2/review.rs b/codex-rs/app-server/tests/suite/v2/review.rs index 277885c5a8..bf0271f821 100644 --- a/codex-rs/app-server/tests/suite/v2/review.rs +++ b/codex-rs/app-server/tests/suite/v2/review.rs @@ -363,6 +363,7 @@ async fn review_start_with_detached_delivery_returns_new_thread_id() -> Result<( let started: ThreadStartedNotification = serde_json::from_value(notification.params.expect("params must be present"))?; assert_eq!(started.thread.id, review_thread_id); + assert_eq!(started.thread.session_id, review_thread_id); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 5c4ba3a3e3..8394f9839e 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -108,11 +108,21 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { .get("thread") .and_then(Value::as_object) .expect("thread/fork result.thread must be an object"); + assert_eq!( + thread_json.get("sessionId").and_then(Value::as_str), + Some(thread.session_id.as_str()), + "forked threads should serialize `sessionId` on the thread object" + ); assert_eq!( thread_json.get("name"), Some(&Value::Null), "forked threads do not inherit a name; expected `name: null`" ); + assert_eq!( + fork_result.get("sessionId"), + None, + "thread/fork should not serialize a top-level `sessionId`" + ); let after_contents = std::fs::read_to_string(&original_path)?; assert_eq!( @@ -121,6 +131,7 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { ); assert_ne!(thread.id, conversation_id); + assert_eq!(thread.session_id, thread.id); assert_eq!(thread.forked_from_id, Some(conversation_id.clone())); assert_eq!(thread.preview, preview); assert_eq!(thread.model_provider, "mock_provider"); diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index ebaba81852..80254d8f47 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -879,6 +879,7 @@ async fn thread_list_filters_by_source_kind_subagent_thread_spawn() -> Result<() assert_eq!(ids, vec![subagent_id.as_str()]); assert_ne!(cli_id, subagent_id); assert!(matches!(data[0].source, SessionSource::SubAgent(_))); + assert_eq!(data[0].session_id, subagent_id); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs b/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs index 430f8e5392..c78e9b8152 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs @@ -77,6 +77,7 @@ async fn thread_metadata_update_patches_git_branch_and_returns_updated_thread() to_response::(update_resp)?; assert_eq!(updated.id, thread.id); + assert_eq!(updated.session_id, thread.session_id); assert_eq!( updated.git_info, Some(GitInfo { @@ -90,6 +91,10 @@ async fn thread_metadata_update_patches_git_branch_and_returns_updated_thread() .get("thread") .and_then(Value::as_object) .expect("thread/metadata/update result.thread must be an object"); + assert_eq!( + updated_thread_json.get("sessionId").and_then(Value::as_str), + Some(thread.session_id.as_str()) + ); let updated_git_info_json = updated_thread_json .get("gitInfo") .and_then(Value::as_object) diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 4014e6f975..7d7de25515 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -271,6 +271,10 @@ async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> { ) .await??; let ThreadResumeResponse { thread, .. } = to_response::(resume_resp)?; + assert!( + !thread.session_id.is_empty(), + "session id should not be empty" + ); assert_eq!(thread.thread_source, Some(ThreadSource::User)); let payload = wait_for_analytics_payload(&server, DEFAULT_READ_TIMEOUT).await?; diff --git a/codex-rs/app-server/tests/suite/v2/thread_rollback.rs b/codex-rs/app-server/tests/suite/v2/thread_rollback.rs index 3487b9e36a..5f79db0e26 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_rollback.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_rollback.rs @@ -119,11 +119,16 @@ async fn thread_rollback_drops_last_turns_and_persists_to_rollout() -> Result<() .and_then(Value::as_object) .expect("thread/rollback result.thread must be an object"); assert_eq!(rolled_back_thread.name, None); + assert_eq!(rolled_back_thread.session_id, thread.session_id); assert_eq!( thread_json.get("name"), Some(&Value::Null), "thread/rollback must serialize `name: null` when unset" ); + assert_eq!( + thread_json.get("sessionId").and_then(Value::as_str), + Some(thread.session_id.as_str()) + ); assert_eq!(rolled_back_thread.turns.len(), 1); assert_eq!(rolled_back_thread.status, ThreadStatus::Idle); diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 68fc818ec2..78155d8c9a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -28,7 +28,6 @@ use codex_core::config::set_project_trust_level; use codex_exec_server::LOCAL_FS; use codex_git_utils::resolve_root_git_project_for_trust; use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; -use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::TrustLevel; use codex_protocol::openai_models::ReasoningEffort; use pretty_assertions::assert_eq; @@ -125,6 +124,10 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { model_provider, .. } = to_response::(resp)?; + assert!( + !thread.session_id.is_empty(), + "session id should not be empty" + ); assert!(!thread.id.is_empty(), "thread id should not be empty"); assert!( thread.preview.is_empty(), @@ -153,11 +156,21 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { .get("thread") .and_then(Value::as_object) .expect("thread/start result.thread must be an object"); + assert_eq!( + thread_json.get("sessionId").and_then(Value::as_str), + Some(thread.session_id.as_str()), + "new threads should serialize `sessionId` on the thread object" + ); assert_eq!( thread_json.get("name"), Some(&Value::Null), "new threads should serialize `name: null`" ); + assert_eq!( + resp_result.get("sessionId"), + None, + "thread/start should not serialize a top-level `sessionId`" + ); assert_eq!( thread_json.get("ephemeral").and_then(Value::as_bool), Some(false), @@ -388,7 +401,7 @@ model_reasoning_effort = "high" } #[tokio::test] -async fn thread_start_accepts_flex_service_tier() -> Result<()> { +async fn thread_start_accepts_arbitrary_service_tier_id() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; @@ -397,9 +410,10 @@ async fn thread_start_accepts_flex_service_tier() -> Result<()> { let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let service_tier_id = "experimental-tier-id".to_string(); let req_id = mcp .send_thread_start_request(ThreadStartParams { - service_tier: Some(Some(ServiceTier::Flex)), + service_tier: Some(Some(service_tier_id.clone())), ..Default::default() }) .await?; @@ -411,7 +425,7 @@ async fn thread_start_accepts_flex_service_tier() -> Result<()> { .await??; let ThreadStartResponse { service_tier, .. } = to_response::(resp)?; - assert_eq!(service_tier, Some(ServiceTier::Flex)); + assert_eq!(service_tier, Some(service_tier_id)); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 971b081ae5..e5c5c5adbb 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -358,6 +358,71 @@ async fn turn_start_emits_thread_scoped_warning_notification_for_trimmed_skills( Ok(()) } +#[tokio::test] +async fn turn_start_sends_service_tier_id_to_model_request() -> Result<()> { + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let response_mock = responses::mount_sse_once(&server, body).await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::default(), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let service_tier_id = "experimental-tier-id".to_string(); + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + service_tier: Some(Some(service_tier_id.clone())), + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + assert_eq!( + response_mock.single_request().body_json()["service_tier"], + json!(service_tier_id) + ); + + Ok(()) +} + #[tokio::test] async fn thread_start_omits_empty_instruction_overrides_from_model_request() -> Result<()> { let server = responses::start_mock_server().await; diff --git a/codex-rs/builtin-mcps/BUILD.bazel b/codex-rs/builtin-mcps/BUILD.bazel new file mode 100644 index 0000000000..9c738d636b --- /dev/null +++ b/codex-rs/builtin-mcps/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "builtin-mcps", + crate_name = "codex_builtin_mcps", +) diff --git a/codex-rs/builtin-mcps/Cargo.toml b/codex-rs/builtin-mcps/Cargo.toml new file mode 100644 index 0000000000..de64ec7033 --- /dev/null +++ b/codex-rs/builtin-mcps/Cargo.toml @@ -0,0 +1,21 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-builtin-mcps" +version.workspace = true + +[lib] +name = "codex_builtin_mcps" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +codex-config = { workspace = true } +codex-memories-mcp = { workspace = true } +codex-utils-absolute-path = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/builtin-mcps/src/lib.rs b/codex-rs/builtin-mcps/src/lib.rs new file mode 100644 index 0000000000..abecc994d6 --- /dev/null +++ b/codex-rs/builtin-mcps/src/lib.rs @@ -0,0 +1,132 @@ +//! Built-in MCP servers shipped with Codex. +//! +//! Built-ins use the same stdio MCP path as user-configured servers, but are +//! declared here so product-owned MCPs do not need to live in `codex-core`. + +use codex_config::McpServerConfig; +use codex_config::McpServerTransportConfig; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::collections::HashMap; +use std::path::Path; + +pub const MEMORIES_MCP_SERVER_NAME: &str = "memories"; +const BUILTIN_MCP_SUBCOMMAND: &str = "builtin-mcp"; + +#[derive(Debug, Clone, Copy)] +pub struct BuiltinMcpServerOptions<'a> { + pub codex_self_exe: Option<&'a Path>, + pub codex_home: &'a Path, + pub memories_enabled: bool, +} + +pub fn configured_builtin_mcp_servers( + options: BuiltinMcpServerOptions<'_>, +) -> HashMap { + let Some(codex_self_exe) = options.codex_self_exe else { + return HashMap::new(); + }; + + let mut servers = HashMap::new(); + if options.memories_enabled { + servers.insert( + MEMORIES_MCP_SERVER_NAME.to_string(), + builtin_stdio_server_config( + codex_self_exe, + options.codex_home, + MEMORIES_MCP_SERVER_NAME, + ), + ); + } + servers +} + +pub async fn run_builtin_mcp_server( + name: &str, + codex_home: &AbsolutePathBuf, +) -> anyhow::Result<()> { + match name { + MEMORIES_MCP_SERVER_NAME => codex_memories_mcp::run_stdio_server(codex_home).await, + _ => anyhow::bail!("unknown built-in MCP server: {name}"), + } +} + +fn builtin_stdio_server_config( + codex_self_exe: &Path, + codex_home: &Path, + name: &str, +) -> McpServerConfig { + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: codex_self_exe.to_string_lossy().into_owned(), + args: vec![ + BUILTIN_MCP_SUBCOMMAND.to_string(), + name.to_string(), + "--codex-home".to_string(), + codex_home.to_string_lossy().into_owned(), + ], + env: None, + env_vars: Vec::new(), + cwd: None, + }, + experimental_environment: None, + enabled: true, + required: false, + supports_parallel_tool_calls: true, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + default_tools_approval_mode: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + tools: HashMap::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn configured_builtin_mcp_servers_adds_memories_when_enabled() { + let codex_home = AbsolutePathBuf::try_from("/tmp/codex-home").expect("absolute codex home"); + let servers = configured_builtin_mcp_servers(BuiltinMcpServerOptions { + codex_self_exe: Some(Path::new("/tmp/codex")), + codex_home: codex_home.as_path(), + memories_enabled: true, + }); + + let server = servers + .get(MEMORIES_MCP_SERVER_NAME) + .expect("memories server should exist"); + assert_eq!( + server.transport, + McpServerTransportConfig::Stdio { + command: "/tmp/codex".to_string(), + args: vec![ + "builtin-mcp".to_string(), + "memories".to_string(), + "--codex-home".to_string(), + codex_home.as_path().to_string_lossy().into_owned(), + ], + env: None, + env_vars: Vec::new(), + cwd: None, + } + ); + } + + #[test] + fn configured_builtin_mcp_servers_requires_reexec_path() { + let codex_home = AbsolutePathBuf::try_from("/tmp/codex-home").expect("absolute codex home"); + let servers = configured_builtin_mcp_servers(BuiltinMcpServerOptions { + codex_self_exe: None, + codex_home: codex_home.as_path(), + memories_enabled: true, + }); + + assert!(servers.is_empty()); + } +} diff --git a/codex-rs/bwrap/build.rs b/codex-rs/bwrap/build.rs index 40c2712839..d9d87932b2 100644 --- a/codex-rs/bwrap/build.rs +++ b/codex-rs/bwrap/build.rs @@ -35,6 +35,7 @@ fn try_build_bwrap() -> Result<(), String> { let out_dir = PathBuf::from(env::var("OUT_DIR").map_err(|err| err.to_string())?); let src_dir = resolve_bwrap_source_dir(&manifest_dir)?; let libcap = pkg_config::Config::new() + .cargo_metadata(false) .probe("libcap") .map_err(|err| format!("libcap not available via pkg-config: {err}"))?; @@ -65,6 +66,12 @@ fn try_build_bwrap() -> Result<(), String> { } build.compile("standalone_bwrap"); + for link_path in libcap.link_paths { + println!("cargo:rustc-link-search=native={}", link_path.display()); + } + for lib in libcap.libs { + println!("cargo:rustc-link-lib={lib}"); + } println!("cargo:rustc-cfg=bwrap_available"); Ok(()) } diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index cdee241b42..b644734f4c 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -24,6 +24,7 @@ codex-app-server = { workspace = true } codex-app-server-protocol = { workspace = true } codex-app-server-test-client = { workspace = true } codex-arg0 = { workspace = true } +codex-builtin-mcps = { workspace = true } codex-chatgpt = { workspace = true } codex-cloud-tasks = { path = "../cloud-tasks" } codex-utils-cli = { workspace = true } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 5a8ffab36a..f3545629c1 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -123,6 +123,10 @@ enum Subcommand { /// Start Codex as an MCP server (stdio). McpServer, + /// Internal: start a Codex-shipped MCP server (stdio). + #[clap(hide = true, name = "builtin-mcp")] + BuiltinMcp(BuiltinMcpCommand), + /// [experimental] Run the app server or related tooling. AppServer(AppServerCommand), @@ -175,6 +179,13 @@ enum Subcommand { Features(FeaturesCli), } +#[derive(Debug, Args)] +struct BuiltinMcpCommand { + name: String, + #[arg(long)] + codex_home: PathBuf, +} + #[derive(Debug, Parser)] #[command(bin_name = "codex plugin")] struct PluginCli { @@ -809,6 +820,15 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { )?; codex_mcp_server::run_main(arg0_paths.clone(), root_config_overrides).await?; } + Some(Subcommand::BuiltinMcp(command)) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "builtin-mcp", + )?; + let codex_home = AbsolutePathBuf::try_from(command.codex_home)?; + codex_builtin_mcps::run_builtin_mcp_server(&command.name, &codex_home).await?; + } Some(Subcommand::Mcp(mut mcp_cli)) => { reject_remote_mode_for_subcommand( root_remote.as_deref(), diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index e2d2ed3c3c..91b251c41f 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -32,6 +32,10 @@ pub struct CompactionInput<'a> { #[serde(skip_serializing_if = "Option::is_none")] pub reasoning: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub service_tier: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt_cache_key: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] pub text: Option, } @@ -235,6 +239,11 @@ pub struct ResponseCreateWsRequest { pub client_metadata: Option>, } +#[derive(Debug, Serialize)] +pub struct ResponseProcessedWsRequest { + pub response_id: String, +} + pub fn response_create_client_metadata( client_metadata: Option>, trace: Option<&W3cTraceContext>, @@ -263,6 +272,8 @@ pub fn response_create_client_metadata( pub enum ResponsesWsRequest { #[serde(rename = "response.create")] ResponseCreate(ResponseCreateWsRequest), + #[serde(rename = "response.processed")] + ResponseProcessed(ResponseProcessedWsRequest), } pub fn create_text_param_for_request( diff --git a/codex-rs/codex-api/src/endpoint/responses.rs b/codex-rs/codex-api/src/endpoint/responses.rs index 17b478d1fd..cc1be2846a 100644 --- a/codex-rs/codex-api/src/endpoint/responses.rs +++ b/codex-rs/codex-api/src/endpoint/responses.rs @@ -6,7 +6,7 @@ use crate::error::ApiError; use crate::provider::Provider; use crate::requests::Compression; use crate::requests::attach_item_ids; -use crate::requests::headers::build_conversation_headers; +use crate::requests::headers::build_session_headers; use crate::requests::headers::insert_header; use crate::requests::headers::subagent_header; use crate::sse::spawn_response_stream; @@ -30,7 +30,8 @@ pub struct ResponsesClient { #[derive(Default)] pub struct ResponsesOptions { - pub conversation_id: Option, + pub session_id: Option, + pub thread_id: Option, pub session_source: Option, pub extra_headers: HeaderMap, pub compression: Compression, @@ -72,7 +73,8 @@ impl ResponsesClient { options: ResponsesOptions, ) -> Result { let ResponsesOptions { - conversation_id, + session_id, + thread_id, session_source, extra_headers, compression, @@ -86,10 +88,10 @@ impl ResponsesClient { } let mut headers = extra_headers; - if let Some(ref conv_id) = conversation_id { - insert_header(&mut headers, "x-client-request-id", conv_id); + if let Some(ref thread_id) = thread_id { + insert_header(&mut headers, "x-client-request-id", thread_id); } - headers.extend(build_conversation_headers(conversation_id)); + headers.extend(build_session_headers(session_id, thread_id)); if let Some(subagent) = subagent_header(&session_source) { insert_header(&mut headers, "x-openai-subagent", &subagent); } diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index dd947a9dd7..c5a682b328 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -1,5 +1,6 @@ use crate::auth::SharedAuthProvider; use crate::common::ResponseEvent; +use crate::common::ResponseProcessedWsRequest; use crate::common::ResponseStream; use crate::common::ResponsesWsRequest; use crate::error::ApiError; @@ -204,6 +205,40 @@ impl ResponsesWebsocketConnection { self.stream.lock().await.is_none() } + #[instrument( + name = "responses_websocket.send_response_processed", + level = "info", + skip_all, + fields(transport = "responses_websocket", api.path = "responses") + )] + #[expect( + clippy::await_holding_invalid_type, + reason = "the guard serializes exclusive use of the websocket while sending a request frame" + )] + pub async fn send_response_processed(&self, response_id: String) -> Result<(), ApiError> { + let request = + ResponsesWsRequest::ResponseProcessed(ResponseProcessedWsRequest { response_id }); + let request_body = serde_json::to_value(&request).map_err(|err| { + ApiError::Stream(format!("failed to encode websocket request: {err}")) + })?; + + let mut guard = self.stream.lock().await; + let Some(ws_stream) = guard.as_mut() else { + return Err(ApiError::Stream( + "websocket connection is closed".to_string(), + )); + }; + + send_websocket_request( + ws_stream, + request_body, + self.idle_timeout, + self.telemetry.as_ref(), + /*connection_reused*/ true, + ) + .await + } + #[instrument( name = "responses_websocket.stream_request", level = "info", @@ -545,36 +580,14 @@ async fn run_websocket_response_stream( connection_reused: bool, ) -> Result<(), ApiError> { let mut last_server_model: Option = None; - let request_text = match serde_json::to_string(&request_body) { - Ok(text) => text, - Err(err) => { - return Err(ApiError::Stream(format!( - "failed to encode websocket request: {err}" - ))); - } - }; - trace!("websocket request: {request_text}"); - - let request_start = Instant::now(); - let result = tokio::time::timeout( + send_websocket_request( + ws_stream, + request_body, idle_timeout, - ws_stream.send(Message::Text(request_text.into())), + telemetry.as_ref(), + connection_reused, ) - .await - .map_err(|_| ApiError::Stream("idle timeout sending websocket request".into())) - .and_then(|result| { - result.map_err(|err| ApiError::Stream(format!("failed to send websocket request: {err}"))) - }); - - if let Some(t) = telemetry.as_ref() { - t.on_ws_request( - request_start.elapsed(), - result.as_ref().err(), - connection_reused, - ); - } - - result?; + .await?; loop { let poll_start = Instant::now(); @@ -671,6 +684,47 @@ async fn run_websocket_response_stream( Ok(()) } +async fn send_websocket_request( + ws_stream: &WsStream, + request_body: Value, + idle_timeout: Duration, + telemetry: Option<&Arc>, + connection_reused: bool, +) -> Result<(), ApiError> { + let request_text = match serde_json::to_string(&request_body) { + Ok(text) => text, + Err(err) => { + return Err(ApiError::Stream(format!( + "failed to encode websocket request: {err}" + ))); + } + }; + trace!("websocket request: {request_text}"); + + let request_start = Instant::now(); + let result = tokio::time::timeout( + idle_timeout, + ws_stream.send(Message::Text(request_text.into())), + ) + .await + .map_err(|_| ApiError::Stream("idle timeout sending websocket request".into())) + .and_then(|result| { + result.map_err(|err| ApiError::Stream(format!("failed to send websocket request: {err}"))) + }); + + if let Some(t) = telemetry.as_ref() { + t.on_ws_request( + request_start.elapsed(), + result.as_ref().err(), + connection_reused, + ); + } + + result?; + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index 0b8aee266b..e6f097db38 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -10,7 +10,7 @@ pub(crate) mod requests; pub(crate) mod sse; pub(crate) mod telemetry; -pub use crate::requests::headers::build_conversation_headers; +pub use crate::requests::headers::build_session_headers; pub use codex_client::RequestTelemetry; pub use codex_client::ReqwestTransport; pub use codex_client::TransportError; @@ -30,6 +30,7 @@ pub use crate::common::RawMemoryMetadata; pub use crate::common::Reasoning; pub use crate::common::ResponseCreateWsRequest; pub use crate::common::ResponseEvent; +pub use crate::common::ResponseProcessedWsRequest; pub use crate::common::ResponseStream; pub use crate::common::ResponsesApiRequest; pub use crate::common::ResponsesWsRequest; diff --git a/codex-rs/codex-api/src/requests/headers.rs b/codex-rs/codex-api/src/requests/headers.rs index d1ab834109..d91d2a2bf1 100644 --- a/codex-rs/codex-api/src/requests/headers.rs +++ b/codex-rs/codex-api/src/requests/headers.rs @@ -2,11 +2,14 @@ use codex_protocol::protocol::SessionSource; use http::HeaderMap; use http::HeaderValue; -pub fn build_conversation_headers(conversation_id: Option) -> HeaderMap { +pub fn build_session_headers(session_id: Option, thread_id: Option) -> HeaderMap { let mut headers = HeaderMap::new(); - if let Some(id) = conversation_id { + if let Some(id) = session_id { insert_header(&mut headers, "session_id", &id); } + if let Some(id) = thread_id { + insert_header(&mut headers, "thread_id", &id); + } headers } diff --git a/codex-rs/codex-api/tests/clients.rs b/codex-rs/codex-api/tests/clients.rs index 218a99f9b2..a2a29ba16d 100644 --- a/codex-rs/codex-api/tests/clients.rs +++ b/codex-rs/codex-api/tests/clients.rs @@ -444,7 +444,8 @@ async fn azure_default_store_attaches_ids_and_headers() -> Result<()> { .stream_request( request, ResponsesOptions { - conversation_id: Some("sess_123".into()), + session_id: Some("sess_123".into()), + thread_id: Some("thread_123".into()), session_source: Some(SessionSource::SubAgent(SubAgentSource::Review)), extra_headers, compression: Compression::None, @@ -461,6 +462,16 @@ async fn azure_default_store_attaches_ids_and_headers() -> Result<()> { req.headers.get("session_id").and_then(|v| v.to_str().ok()), Some("sess_123") ); + assert_eq!( + req.headers.get("thread_id").and_then(|v| v.to_str().ok()), + Some("thread_123") + ); + assert_eq!( + req.headers + .get("x-client-request-id") + .and_then(|v| v.to_str().ok()), + Some("thread_123") + ); assert_eq!( req.headers .get("x-openai-subagent") diff --git a/codex-rs/codex-mcp/Cargo.toml b/codex-rs/codex-mcp/Cargo.toml index c3061adca9..c47bd947e6 100644 --- a/codex-rs/codex-mcp/Cargo.toml +++ b/codex-rs/codex-mcp/Cargo.toml @@ -16,6 +16,7 @@ anyhow = { workspace = true } async-channel = { workspace = true } codex-async-utils = { workspace = true } codex-api = { workspace = true } +codex-builtin-mcps = { workspace = true } codex-config = { workspace = true } codex-exec-server = { workspace = true } codex-login = { workspace = true } diff --git a/codex-rs/codex-mcp/src/auth_elicitation.rs b/codex-rs/codex-mcp/src/auth_elicitation.rs new file mode 100644 index 0000000000..77c7b78c55 --- /dev/null +++ b/codex-rs/codex-mcp/src/auth_elicitation.rs @@ -0,0 +1,347 @@ +//! Auth elicitation helpers. +//! +//! This module owns protocol-neutral auth elicitation parsing and payload shaping. +//! Session orchestration stays in `codex-core`. + +use codex_protocol::mcp::CallToolResult; +use serde::Serialize; + +pub const MCP_TOOL_CODEX_APPS_META_KEY: &str = "_codex_apps"; +pub const CONNECTOR_AUTH_FAILURE_META_KEY: &str = "connector_auth_failure"; +pub const CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: &str = "is_auth_failure"; +pub const CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY: &str = "auth_reason"; +pub const CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: &str = "connector_id"; +pub const CONNECTOR_AUTH_FAILURE_LINK_ID_KEY: &str = "link_id"; +pub const CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY: &str = "error_code"; +pub const CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY: &str = "error_http_status_code"; +pub const CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY: &str = "error_action"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CodexAppsConnectorAuthFailure { + pub connector_id: String, + pub connector_name: String, + pub install_url: String, + pub auth_reason: Option, + pub link_id: Option, + pub error_code: Option, + pub error_http_status_code: Option, + pub error_action: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CodexAppsAuthElicitation { + pub meta: serde_json::Value, + pub message: String, + pub url: String, + pub elicitation_id: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CodexAppsAuthElicitationPlan { + pub auth_failure: CodexAppsConnectorAuthFailure, + pub elicitation: CodexAppsAuthElicitation, +} + +#[derive(Serialize)] +struct CodexAppsConnectorAuthFailureMeta<'a> { + is_auth_failure: bool, + connector_id: &'a str, + connector_name: &'a str, + install_url: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + auth_reason: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + link_id: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + error_code: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + error_http_status_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error_action: Option<&'a str>, +} + +pub fn connector_auth_failure_from_tool_result( + result: &CallToolResult, + connector_id: Option<&str>, + connector_name: Option<&str>, + install_url: Option, +) -> Option { + if result.is_error != Some(true) { + return None; + } + + let auth_failure = result + .meta + .as_ref()? + .as_object()? + .get(MCP_TOOL_CODEX_APPS_META_KEY)? + .as_object()? + .get(CONNECTOR_AUTH_FAILURE_META_KEY)? + .as_object()?; + if auth_failure + .get(CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY) + .and_then(serde_json::Value::as_bool) + != Some(true) + { + return None; + } + + let connector_id = connector_id + .map(str::trim) + .filter(|connector_id| !connector_id.is_empty())?; + if let Some(auth_failure_connector_id) = + string_auth_failure_field(auth_failure, CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY) + && auth_failure_connector_id != connector_id + { + return None; + } + let connector_name = connector_name + .map(str::trim) + .filter(|name| !name.is_empty()) + .unwrap_or(connector_id) + .to_string(); + + Some(CodexAppsConnectorAuthFailure { + connector_id: connector_id.to_string(), + connector_name, + install_url: install_url?, + auth_reason: string_auth_failure_field( + auth_failure, + CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY, + ), + link_id: string_auth_failure_field(auth_failure, CONNECTOR_AUTH_FAILURE_LINK_ID_KEY), + error_code: string_auth_failure_field(auth_failure, CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY), + error_http_status_code: auth_failure + .get(CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY) + .and_then(serde_json::Value::as_i64), + error_action: string_auth_failure_field( + auth_failure, + CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY, + ), + }) +} + +pub fn build_auth_elicitation_plan( + call_id: &str, + result: &CallToolResult, + connector_id: Option<&str>, + connector_name: Option<&str>, + install_url: Option, +) -> Option { + let auth_failure = + connector_auth_failure_from_tool_result(result, connector_id, connector_name, install_url)?; + let elicitation = build_auth_elicitation(call_id, &auth_failure); + Some(CodexAppsAuthElicitationPlan { + auth_failure, + elicitation, + }) +} + +pub fn build_auth_elicitation( + call_id: &str, + auth_failure: &CodexAppsConnectorAuthFailure, +) -> CodexAppsAuthElicitation { + CodexAppsAuthElicitation { + meta: serde_json::json!({ + MCP_TOOL_CODEX_APPS_META_KEY: { + CONNECTOR_AUTH_FAILURE_META_KEY: CodexAppsConnectorAuthFailureMeta { + is_auth_failure: true, + connector_id: &auth_failure.connector_id, + connector_name: &auth_failure.connector_name, + install_url: &auth_failure.install_url, + auth_reason: auth_failure.auth_reason.as_deref(), + link_id: auth_failure.link_id.as_deref(), + error_code: auth_failure.error_code.as_deref(), + error_http_status_code: auth_failure.error_http_status_code, + error_action: auth_failure.error_action.as_deref(), + }, + }, + }), + message: auth_elicitation_message(auth_failure), + url: auth_failure.install_url.clone(), + elicitation_id: auth_elicitation_id(call_id), + } +} + +pub fn auth_elicitation_completed_result( + auth_failure: &CodexAppsConnectorAuthFailure, + meta: Option, +) -> CallToolResult { + CallToolResult { + content: vec![serde_json::json!({ + "type": "text", + "text": format!( + "Authentication for {} was requested and accepted. Retry this tool call now.", + auth_failure.connector_name + ), + })], + structured_content: None, + is_error: Some(true), + meta, + } +} + +pub fn auth_elicitation_id(call_id: &str) -> String { + format!("codex_apps_auth_{call_id}") +} + +fn string_auth_failure_field( + auth_failure: &serde_json::Map, + key: &str, +) -> Option { + auth_failure + .get(key) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +fn auth_elicitation_message(auth_failure: &CodexAppsConnectorAuthFailure) -> String { + match auth_failure.auth_reason.as_deref() { + Some("oauth_upgrade_required") => format!( + "Reconnect {} on ChatGPT to grant the permissions needed for this request.", + auth_failure.connector_name + ), + Some("reauthentication_required") => format!( + "Reconnect {} on ChatGPT to restore access for this request.", + auth_failure.connector_name + ), + Some("missing_link") => format!( + "Sign in to {} on ChatGPT to use it in Codex.", + auth_failure.connector_name + ), + _ => format!( + "Sign in to {} on ChatGPT to continue.", + auth_failure.connector_name + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn auth_failure_result() -> CallToolResult { + CallToolResult { + content: vec![serde_json::json!({ + "type": "text", + "text": "Connector reauthentication required", + })], + structured_content: None, + is_error: Some(true), + meta: Some(serde_json::json!({ + MCP_TOOL_CODEX_APPS_META_KEY: { + CONNECTOR_AUTH_FAILURE_META_KEY: { + CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: true, + CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY: "reauthentication_required", + CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: "connector_calendar", + "connector_name": "Untrusted Calendar", + CONNECTOR_AUTH_FAILURE_LINK_ID_KEY: "link_123", + CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY: "UNAUTHORIZED", + CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY: 401, + CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY: "TRIGGER_REAUTHENTICATION", + }, + }, + })), + } + } + + #[test] + fn parses_auth_failure_from_trusted_connector_metadata() { + assert_eq!( + connector_auth_failure_from_tool_result( + &auth_failure_result(), + Some("connector_calendar"), + Some("Google Calendar"), + Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()), + ), + Some(CodexAppsConnectorAuthFailure { + connector_id: "connector_calendar".to_string(), + connector_name: "Google Calendar".to_string(), + install_url: "https://chatgpt.com/apps/google-calendar/connector_calendar" + .to_string(), + auth_reason: Some("reauthentication_required".to_string()), + link_id: Some("link_123".to_string()), + error_code: Some("UNAUTHORIZED".to_string()), + error_http_status_code: Some(401), + error_action: Some("TRIGGER_REAUTHENTICATION".to_string()), + }) + ); + } + + #[test] + fn rejects_missing_or_mismatched_connector_ids() { + assert_eq!( + connector_auth_failure_from_tool_result( + &auth_failure_result(), + /*connector_id*/ None, + Some("Google Calendar"), + Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()), + ), + None + ); + assert_eq!( + connector_auth_failure_from_tool_result( + &auth_failure_result(), + Some("connector_drive"), + Some("Google Drive"), + Some("https://chatgpt.com/apps/google-drive/connector_drive".to_string()), + ), + None + ); + } + + #[test] + fn builds_url_elicitation_payload() { + let auth_failure = connector_auth_failure_from_tool_result( + &auth_failure_result(), + Some("connector_calendar"), + Some("Google Calendar"), + Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()), + ) + .expect("auth failure"); + + assert_eq!( + build_auth_elicitation("call_123", &auth_failure), + CodexAppsAuthElicitation { + meta: serde_json::json!({ + MCP_TOOL_CODEX_APPS_META_KEY: { + CONNECTOR_AUTH_FAILURE_META_KEY: { + CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: true, + CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: "connector_calendar", + "connector_name": "Google Calendar", + "install_url": + "https://chatgpt.com/apps/google-calendar/connector_calendar", + CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY: "reauthentication_required", + CONNECTOR_AUTH_FAILURE_LINK_ID_KEY: "link_123", + CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY: "UNAUTHORIZED", + CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY: 401, + CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY: "TRIGGER_REAUTHENTICATION", + }, + }, + }), + message: "Reconnect Google Calendar on ChatGPT to restore access for this request." + .to_string(), + url: "https://chatgpt.com/apps/google-calendar/connector_calendar".to_string(), + elicitation_id: "codex_apps_auth_call_123".to_string(), + } + ); + } + + #[test] + fn builds_auth_elicitation_plan() { + let plan = build_auth_elicitation_plan( + "call_123", + &auth_failure_result(), + Some("connector_calendar"), + Some("Google Calendar"), + Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()), + ) + .expect("auth elicitation plan"); + + assert_eq!(plan.auth_failure.connector_name, "Google Calendar"); + assert_eq!(plan.elicitation.elicitation_id, "codex_apps_auth_call_123"); + } +} diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 53992b927b..58b5566e23 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -70,6 +70,7 @@ use url::Url; pub struct McpConnectionManager { clients: HashMap, server_origins: HashMap, + host_owned_codex_apps_enabled: bool, elicitation_requests: ElicitationRequestManager, startup_cancellation_token: CancellationToken, } @@ -82,6 +83,7 @@ impl McpConnectionManager { Self { clients: HashMap::new(), server_origins: HashMap::new(), + host_owned_codex_apps_enabled: false, elicitation_requests: ElicitationRequestManager::new( approval_policy.value(), permission_profile.get().clone(), @@ -116,6 +118,10 @@ impl McpConnectionManager { self.server_origins.get(server_name).map(String::as_str) } + pub fn is_host_owned_codex_apps_server(&self, server_name: &str) -> bool { + self.host_owned_codex_apps_enabled && server_name == CODEX_APPS_MCP_SERVER_NAME + } + pub fn set_approval_policy(&self, approval_policy: &Constrained) { if let Ok(mut policy) = self.elicitation_requests.approval_policy.lock() { *policy = approval_policy.value(); @@ -148,6 +154,7 @@ impl McpConnectionManager { runtime_environment: McpRuntimeEnvironment, codex_home: PathBuf, codex_apps_tools_cache_key: CodexAppsToolsCacheKey, + host_owned_codex_apps_enabled: bool, tool_plugin_provenance: ToolPluginProvenance, auth: Option<&CodexAuth>, ) -> (Self, CancellationToken) { @@ -248,6 +255,7 @@ impl McpConnectionManager { let manager = Self { clients, server_origins, + host_owned_codex_apps_enabled, elicitation_requests: elicitation_requests.clone(), startup_cancellation_token: cancel_token.clone(), }; diff --git a/codex-rs/codex-mcp/src/lib.rs b/codex-rs/codex-mcp/src/lib.rs index 9d4ee60e89..2080a5c319 100644 --- a/codex-rs/codex-mcp/src/lib.rs +++ b/codex-rs/codex-mcp/src/lib.rs @@ -8,18 +8,30 @@ pub use mcp::CODEX_APPS_MCP_SERVER_NAME; pub use mcp::McpConfig; pub use mcp::ToolPluginProvenance; +pub use auth_elicitation::CodexAppsAuthElicitation; +pub use auth_elicitation::CodexAppsAuthElicitationPlan; +pub use auth_elicitation::CodexAppsConnectorAuthFailure; +pub use auth_elicitation::MCP_TOOL_CODEX_APPS_META_KEY; +pub use auth_elicitation::auth_elicitation_completed_result; +pub use auth_elicitation::auth_elicitation_id; +pub use auth_elicitation::build_auth_elicitation; +pub use auth_elicitation::build_auth_elicitation_plan; +pub use auth_elicitation::connector_auth_failure_from_tool_result; pub use codex_apps::CodexAppsToolsCacheKey; pub use codex_apps::codex_apps_tools_cache_key; +pub use codex_builtin_mcps::BuiltinMcpServerOptions; +pub use codex_builtin_mcps::MEMORIES_MCP_SERVER_NAME; +pub use codex_builtin_mcps::configured_builtin_mcp_servers; pub use mcp::configured_mcp_servers; pub use mcp::effective_mcp_servers; +pub use mcp::host_owned_codex_apps_enabled; pub use mcp::tool_plugin_provenance; pub use mcp::with_codex_apps_mcp; pub use mcp::McpServerStatusSnapshot; pub use mcp::McpSnapshotDetail; pub use mcp::collect_mcp_server_status_snapshot_with_detail; -pub use mcp::collect_mcp_snapshot_from_manager; pub use mcp::read_mcp_resource; pub use mcp::McpAuthStatusEntry; @@ -39,6 +51,7 @@ pub use mcp::mcp_permission_prompt_is_auto_approved; pub use mcp::qualified_mcp_tool_name_prefix; pub use tools::declared_openai_file_input_param_names; +pub(crate) mod auth_elicitation; pub(crate) mod codex_apps; pub(crate) mod connection_manager; pub(crate) mod elicitation; diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index d689ea9043..03a5d1a068 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -31,7 +31,6 @@ use codex_protocol::mcp::Tool; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::McpAuthStatus; -use codex_protocol::protocol::McpListToolsResponseEvent; use rmcp::model::ReadResourceRequestParams; use rmcp::model::ReadResourceResult; use serde_json::Value; @@ -129,7 +128,11 @@ pub struct McpConfig { /// ChatGPT auth is checked separately at runtime before the built-in apps /// MCP server is added. pub apps_enabled: bool, - /// User-configured and plugin-provided MCP servers keyed by server name. + /// Configured MCP servers keyed by server name. + /// + /// This includes product-owned built-ins, user-configured servers, and + /// plugin-provided servers. Runtime-only additions belong in + /// [`effective_mcp_servers`]. pub configured_mcp_servers: HashMap, /// Plugin metadata used to attribute MCP tools/connectors to plugin display names. pub plugin_capability_summaries: Vec, @@ -198,7 +201,7 @@ pub fn with_codex_apps_mcp( auth: Option<&CodexAuth>, config: &McpConfig, ) -> HashMap { - if config.apps_enabled && auth.is_some_and(CodexAuth::uses_codex_backend) { + if host_owned_codex_apps_enabled(config, auth) { servers.insert( CODEX_APPS_MCP_SERVER_NAME.to_string(), codex_apps_mcp_server_config(config), @@ -209,6 +212,10 @@ pub fn with_codex_apps_mcp( servers } +pub fn host_owned_codex_apps_enabled(config: &McpConfig, auth: Option<&CodexAuth>) -> bool { + config.apps_enabled && auth.is_some_and(CodexAuth::uses_codex_backend) +} + pub fn configured_mcp_servers(config: &McpConfig) -> HashMap { config.configured_mcp_servers.clone() } @@ -233,6 +240,7 @@ pub async fn read_mcp_resource( uri: &str, ) -> anyhow::Result { let mut mcp_servers = effective_mcp_servers(config, auth); + let host_owned_codex_apps_enabled = host_owned_codex_apps_enabled(config, auth); mcp_servers.retain(|name, _| name == server); let auth_statuses = compute_auth_statuses( mcp_servers.iter(), @@ -253,6 +261,7 @@ pub async fn read_mcp_resource( runtime_environment, config.codex_home.clone(), codex_apps_tools_cache_key(auth), + host_owned_codex_apps_enabled, tool_plugin_provenance(config), auth, ) @@ -287,6 +296,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( detail: McpSnapshotDetail, ) -> McpServerStatusSnapshot { let mcp_servers = effective_mcp_servers(config, auth); + let host_owned_codex_apps_enabled = host_owned_codex_apps_enabled(config, auth); let tool_plugin_provenance = tool_plugin_provenance(config); if mcp_servers.is_empty() { return McpServerStatusSnapshot { @@ -318,6 +328,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( runtime_environment, config.codex_home.clone(), codex_apps_tools_cache_key(auth), + host_owned_codex_apps_enabled, tool_plugin_provenance, auth, ) @@ -335,18 +346,6 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( snapshot } -pub async fn collect_mcp_snapshot_from_manager( - mcp_connection_manager: &McpConnectionManager, - auth_status_entries: HashMap, -) -> McpListToolsResponseEvent { - collect_mcp_snapshot_from_manager_with_detail( - mcp_connection_manager, - auth_status_entries, - McpSnapshotDetail::Full, - ) - .await -} - pub(crate) fn codex_apps_mcp_url(config: &McpConfig) -> String { codex_apps_mcp_url_for_base_url( &config.chatgpt_base_url, @@ -583,44 +582,6 @@ async fn collect_mcp_server_status_snapshot_from_manager( } } -async fn collect_mcp_snapshot_from_manager_with_detail( - mcp_connection_manager: &McpConnectionManager, - auth_status_entries: HashMap, - detail: McpSnapshotDetail, -) -> McpListToolsResponseEvent { - let (tools, resources, resource_templates) = tokio::join!( - mcp_connection_manager.list_all_tools(), - async { - if detail.include_resources() { - mcp_connection_manager.list_all_resources().await - } else { - HashMap::new() - } - }, - async { - if detail.include_resources() { - mcp_connection_manager.list_all_resource_templates().await - } else { - HashMap::new() - } - }, - ); - - let tools = tools - .into_iter() - .filter_map(|(name, tool)| { - protocol_tool_from_rmcp_tool(&name, &tool.tool).map(|tool| (name, tool)) - }) - .collect::>(); - - McpListToolsResponseEvent { - tools, - resources: convert_mcp_resources(resources), - resource_templates: convert_mcp_resource_templates(resource_templates), - auth_statuses: auth_statuses_from_entries(&auth_status_entries), - } -} - #[cfg(test)] #[path = "mod_tests.rs"] mod tests; diff --git a/codex-rs/core-api/src/lib.rs b/codex-rs/core-api/src/lib.rs index 13c87df9e0..9af459830a 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -42,6 +42,7 @@ pub use codex_core::config::ThreadStoreConfig; pub use codex_core::config::find_codex_home; pub use codex_core::init_state_db; pub use codex_core::init_state_db_from_config; +pub use codex_core::resolve_installation_id; pub use codex_core::skills::SkillsManager; pub use codex_core::thread_store_from_config; pub use codex_exec_server::EnvironmentManager; diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 331362d626..397f0ad747 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -29,10 +29,17 @@ pub use remote_installed_plugin_sync::RemotePluginCacheMutationGuard; pub use remote_installed_plugin_sync::mark_remote_plugin_cache_mutation_in_flight; pub use remote_installed_plugin_sync::maybe_start_remote_installed_plugin_bundle_sync; pub use remote_installed_plugin_sync::sync_remote_installed_plugin_bundles_once; +pub use share::RemotePluginShareAccessPolicy; +pub use share::RemotePluginShareDiscoverability; +pub use share::RemotePluginSharePrincipal; +pub use share::RemotePluginSharePrincipalType; pub use share::RemotePluginShareSaveResult; +pub use share::RemotePluginShareTarget; +pub use share::RemotePluginShareUpdateTargetsResult; pub use share::delete_remote_plugin_share; pub use share::list_remote_plugin_shares; pub use share::save_remote_plugin_share; +pub use share::update_remote_plugin_share_targets; pub const REMOTE_GLOBAL_MARKETPLACE_NAME: &str = "chatgpt-global"; pub const REMOTE_WORKSPACE_MARKETPLACE_NAME: &str = "chatgpt-workspace"; diff --git a/codex-rs/core-plugins/src/remote/share.rs b/codex-rs/core-plugins/src/remote/share.rs index 58df033cfb..0a254a1736 100644 --- a/codex-rs/core-plugins/src/remote/share.rs +++ b/codex-rs/core-plugins/src/remote/share.rs @@ -26,6 +26,46 @@ pub struct RemotePluginShareSaveResult { pub share_url: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct RemotePluginShareAccessPolicy { + pub discoverability: Option, + pub share_targets: Option>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum RemotePluginShareDiscoverability { + Listed, + Unlisted, + Private, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RemotePluginSharePrincipalType { + User, + Group, + Workspace, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RemotePluginShareTarget { + pub principal_type: RemotePluginSharePrincipalType, + pub principal_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct RemotePluginSharePrincipal { + pub principal_type: RemotePluginSharePrincipalType, + pub principal_id: String, + pub name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemotePluginShareUpdateTargetsResult { + pub principals: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] struct RemoteWorkspacePluginUploadUrlRequest<'a> { filename: &'a str, @@ -46,6 +86,10 @@ struct RemoteWorkspacePluginUploadUrlResponse { struct RemoteWorkspacePluginCreateRequest { file_id: String, etag: String, + #[serde(skip_serializing_if = "Option::is_none")] + discoverability: Option, + #[serde(skip_serializing_if = "Option::is_none")] + share_targets: Option>, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] @@ -54,12 +98,23 @@ struct RemoteWorkspacePluginCreateResponse { share_url: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct RemotePluginShareUpdateTargetsRequest { + targets: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemotePluginShareUpdateTargetsResponse { + principals: Vec, +} + pub async fn save_remote_plugin_share( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, codex_home: &Path, plugin_path: &AbsolutePathBuf, remote_plugin_id: Option<&str>, + access_policy: RemotePluginShareAccessPolicy, ) -> Result { let auth = ensure_chatgpt_auth(auth)?; let plugin_path_for_archive = plugin_path.as_path().to_path_buf(); @@ -89,6 +144,8 @@ pub async fn save_remote_plugin_share( RemoteWorkspacePluginCreateRequest { file_id: upload.file_id, etag, + discoverability: access_policy.discoverability, + share_targets: access_policy.share_targets, }, ) .await?; @@ -173,6 +230,24 @@ pub async fn delete_remote_plugin_share( Ok(()) } +pub async fn update_remote_plugin_share_targets( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + remote_plugin_id: &str, + targets: Vec, +) -> Result { + let auth = ensure_chatgpt_auth(auth)?; + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = format!("{base_url}/public/plugins/{remote_plugin_id}/shares"); + let client = build_reqwest_client(); + let request = authenticated_request(client.put(&url), auth)? + .json(&RemotePluginShareUpdateTargetsRequest { targets }); + let response: RemotePluginShareUpdateTargetsResponse = send_and_decode(request, &url).await?; + Ok(RemotePluginShareUpdateTargetsResult { + principals: response.principals, + }) +} + async fn fetch_created_workspace_plugins( config: &RemotePluginServiceConfig, auth: &CodexAuth, diff --git a/codex-rs/core-plugins/src/remote/share/tests.rs b/codex-rs/core-plugins/src/remote/share/tests.rs index 65a989c26e..31ad5dd3fc 100644 --- a/codex-rs/core-plugins/src/remote/share/tests.rs +++ b/codex-rs/core-plugins/src/remote/share/tests.rs @@ -202,6 +202,17 @@ async fn save_remote_plugin_share_creates_workspace_plugin() { .and(body_json(json!({ "file_id": "file_123", "etag": "\"upload_etag_123\"", + "discoverability": "PRIVATE", + "share_targets": [ + { + "principal_type": "user", + "principal_id": "user-1", + }, + { + "principal_type": "workspace", + "principal_id": "workspace-1", + }, + ], }))) .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "plugin_id": "plugins_123", @@ -217,6 +228,19 @@ async fn save_remote_plugin_share_creates_workspace_plugin() { codex_home.path(), &plugin_path, /*remote_plugin_id*/ None, + RemotePluginShareAccessPolicy { + discoverability: Some(RemotePluginShareDiscoverability::Private), + share_targets: Some(vec![ + RemotePluginShareTarget { + principal_type: RemotePluginSharePrincipalType::User, + principal_id: "user-1".to_string(), + }, + RemotePluginShareTarget { + principal_type: RemotePluginSharePrincipalType::Workspace, + principal_id: "workspace-1".to_string(), + }, + ]), + }, ) .await .unwrap(); @@ -354,6 +378,7 @@ async fn save_remote_plugin_share_updates_existing_workspace_plugin() { codex_home.path(), &plugin_path, Some("plugins_123"), + RemotePluginShareAccessPolicy::default(), ) .await .unwrap(); @@ -367,6 +392,83 @@ async fn save_remote_plugin_share_updates_existing_workspace_plugin() { ); } +#[tokio::test] +async fn update_remote_plugin_share_targets_updates_targets() { + let server = MockServer::start().await; + let config = test_config(&server); + let auth = test_auth(); + + Mock::given(method("PUT")) + .and(path("/backend-api/public/plugins/plugins_123/shares")) + .and(header("authorization", "Bearer Access Token")) + .and(header("chatgpt-account-id", "account_id")) + .and(body_json(json!({ + "targets": [ + { + "principal_type": "user", + "principal_id": "user-1", + }, + { + "principal_type": "group", + "principal_id": "group-1", + }, + ], + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "principals": [ + { + "principal_type": "user", + "principal_id": "user-1", + "name": "Gavin", + }, + { + "principal_type": "group", + "principal_id": "group-1", + "name": "Engineering", + }, + ], + }))) + .expect(1) + .mount(&server) + .await; + + let result = update_remote_plugin_share_targets( + &config, + Some(&auth), + "plugins_123", + vec![ + RemotePluginShareTarget { + principal_type: RemotePluginSharePrincipalType::User, + principal_id: "user-1".to_string(), + }, + RemotePluginShareTarget { + principal_type: RemotePluginSharePrincipalType::Group, + principal_id: "group-1".to_string(), + }, + ], + ) + .await + .unwrap(); + + assert_eq!( + result, + RemotePluginShareUpdateTargetsResult { + principals: vec![ + RemotePluginSharePrincipal { + principal_type: RemotePluginSharePrincipalType::User, + principal_id: "user-1".to_string(), + name: "Gavin".to_string(), + }, + RemotePluginSharePrincipal { + principal_type: RemotePluginSharePrincipalType::Group, + principal_id: "group-1".to_string(), + name: "Engineering".to_string(), + }, + ], + } + ); +} + #[tokio::test] async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { let codex_home = TempDir::new().unwrap(); diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index c40b7654ab..17343ed86f 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -370,12 +370,18 @@ "apps_mcp_path_override": { "$ref": "#/definitions/FeatureToml_for_AppsMcpPathOverrideConfigToml" }, + "auth_elicitation": { + "type": "boolean" + }, "browser_use": { "type": "boolean" }, "browser_use_external": { "type": "boolean" }, + "builtin_mcp": { + "type": "boolean" + }, "child_agents_md": { "type": "boolean" }, @@ -517,6 +523,9 @@ "request_rule": { "type": "boolean" }, + "responses_websocket_response_processed": { + "type": "boolean" + }, "responses_websockets": { "type": "boolean" }, @@ -3919,12 +3928,18 @@ "apps_mcp_path_override": { "$ref": "#/definitions/FeatureToml_for_AppsMcpPathOverrideConfigToml" }, + "auth_elicitation": { + "type": "boolean" + }, "browser_use": { "type": "boolean" }, "browser_use_external": { "type": "boolean" }, + "builtin_mcp": { + "type": "boolean" + }, "child_agents_md": { "type": "boolean" }, @@ -4066,6 +4081,9 @@ "request_rule": { "type": "boolean" }, + "responses_websocket_response_processed": { + "type": "boolean" + }, "responses_websockets": { "type": "boolean" }, diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index e5967a6ed2..8c8114324c 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -14,6 +14,7 @@ use crate::thread_manager::ThreadManagerState; use crate::thread_rollout_truncation::truncate_rollout_to_last_n_fork_turns; use codex_features::Feature; use codex_protocol::AgentPath; +use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; @@ -132,6 +133,9 @@ fn keep_forked_rollout_item(item: &RolloutItem) -> bool { /// which keeps the registry scoped to that root thread rather than the entire `ThreadManager`. #[derive(Clone, Default)] pub(crate) struct AgentControl { + /// ID shared by the whole agent control session. This means every sub-agents from a common + /// root share the same session ID. + session_id: SessionId, /// Weak handle back to the global thread registry/state. /// This is `Weak` to avoid reference cycles and shadow persistence of the form /// `ThreadManagerState -> CodexThread -> Session -> SessionServices -> ThreadManagerState`. @@ -148,6 +152,15 @@ impl AgentControl { } } + pub(crate) fn with_session_id(mut self, session_id: SessionId) -> Self { + self.session_id = session_id; + self + } + + pub(crate) fn session_id(&self) -> SessionId { + self.session_id + } + /// Spawn a new agent thread and submit the initial prompt. #[cfg(test)] pub(crate) async fn spawn_agent( diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index d11fda47dc..39e6e85e20 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -58,7 +58,7 @@ use codex_api::SseTelemetry; use codex_api::TransportError; use codex_api::WebsocketTelemetry; use codex_api::auth_header_telemetry; -use codex_api::build_conversation_headers; +use codex_api::build_session_headers; use codex_api::create_text_param_for_request; use codex_api::response_create_client_metadata; use codex_app_server_protocol::AuthMode; @@ -70,9 +70,9 @@ use codex_login::default_client::build_reqwest_client; use codex_otel::SessionTelemetry; use codex_otel::current_span_w3c_trace_context; +use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; -use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::Verbosity as VerbosityConfig; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelInfo; @@ -100,6 +100,7 @@ use tokio::sync::oneshot::error::TryRecvError; use tokio_tungstenite::tungstenite::Error; use tokio_tungstenite::tungstenite::Message; use tokio_util::sync::CancellationToken; +use tracing::debug; use tracing::instrument; use tracing::trace; use tracing::warn; @@ -146,13 +147,20 @@ const MEMORIES_SUMMARIZE_ENDPOINT: &str = "/memories/trace_summarize"; pub(crate) const WEBSOCKET_CONNECT_TIMEOUT: Duration = Duration::from_millis(DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS); +pub(crate) struct CompactConversationRequestSettings { + pub(crate) effort: Option, + pub(crate) summary: ReasoningSummaryConfig, + pub(crate) service_tier: Option, +} + /// Session-scoped state shared by all [`ModelClient`] clones. /// /// This is intentionally kept minimal so `ModelClient` does not need to hold a full `Config`. Most /// configuration is per turn and is passed explicitly to streaming/unary methods. #[derive(Debug)] struct ModelClientState { - conversation_id: ThreadId, + session_id: SessionId, + thread_id: ThreadId, window_generation: AtomicU64, installation_id: String, provider: SharedModelProvider, @@ -190,7 +198,7 @@ impl RequestRouteTelemetry { /// A session-scoped client for model-provider API calls. /// /// This holds configuration and state that should be shared across turns within a Codex session -/// (auth, provider selection, conversation id, and transport fallback state). +/// (auth, provider selection, thread id, and transport fallback state). /// /// WebSocket fallback is session-scoped: once a turn activates the HTTP fallback, subsequent turns /// will also use HTTP for the remainder of the session. @@ -297,7 +305,8 @@ impl ModelClient { /// are passed to [`ModelClientSession::stream`] (and other turn-scoped methods) explicitly. pub fn new( auth_manager: Option>, - conversation_id: ThreadId, + session_id: SessionId, + thread_id: ThreadId, installation_id: String, provider_info: ModelProviderInfo, session_source: SessionSource, @@ -315,7 +324,8 @@ impl ModelClient { collect_auth_env_telemetry(model_provider.info(), codex_api_key_env_enabled); Self { state: Arc::new(ModelClientState { - conversation_id, + session_id, + thread_id, window_generation: AtomicU64::new(0), installation_id, provider: model_provider, @@ -360,9 +370,9 @@ impl ModelClient { } fn current_window_id(&self) -> String { - let conversation_id = self.state.conversation_id; + let thread_id = self.state.thread_id; let window_generation = self.state.window_generation.load(Ordering::Relaxed); - format!("{conversation_id}:{window_generation}") + format!("{thread_id}:{window_generation}") } fn take_cached_websocket_session(&self) -> WebsocketSession { @@ -410,12 +420,11 @@ impl ModelClient { /// /// The model selection and telemetry context are passed explicitly to keep `ModelClient` /// session-scoped. - pub async fn compact_conversation_history( + pub(crate) async fn compact_conversation_history( &self, prompt: &Prompt, model_info: &ModelInfo, - effort: Option, - summary: ReasoningSummaryConfig, + settings: CompactConversationRequestSettings, session_telemetry: &SessionTelemetry, compaction_trace: &CompactionTraceContext, ) -> Result> { @@ -438,9 +447,9 @@ impl ModelClient { &client_setup.api_provider, prompt, model_info, - effort, - summary, - /*service_tier*/ None, + settings.effort, + settings.summary, + settings.service_tier, )?; let ResponsesApiRequest { model, @@ -449,6 +458,8 @@ impl ModelClient { tools, parallel_tool_calls, reasoning, + service_tier, + prompt_cache_key, text, .. } = request; @@ -462,6 +473,8 @@ impl ModelClient { tools, parallel_tool_calls, reasoning, + service_tier: service_tier.as_deref(), + prompt_cache_key: prompt_cache_key.as_deref(), text, }; @@ -475,9 +488,10 @@ impl ModelClient { /*turn_metadata_header*/ None, )); extra_headers.extend(self.build_responses_identity_headers()); - extra_headers.extend(build_conversation_headers(Some( - self.state.conversation_id.to_string(), - ))); + extra_headers.extend(build_session_headers( + Some(self.state.session_id.to_string()), + Some(self.state.thread_id.to_string()), + )); let trace_attempt = compaction_trace.start_attempt(&payload); let result = client .compact_input(&payload, extra_headers) @@ -669,7 +683,7 @@ impl ModelClient { model_info: &ModelInfo, effort: Option, summary: ReasoningSummaryConfig, - service_tier: Option, + service_tier: Option, ) -> Result { let instructions = &prompt.base_instructions.text; let input = prompt.get_formatted_input(); @@ -696,7 +710,7 @@ impl ModelClient { &prompt.output_schema, prompt.output_schema_strict, ); - let prompt_cache_key = Some(self.state.conversation_id.to_string()); + let prompt_cache_key = Some(self.state.thread_id.to_string()); let request = ResponsesApiRequest { model: model_info.slug.clone(), instructions: instructions.clone(), @@ -708,11 +722,7 @@ impl ModelClient { store: provider.is_azure_responses_endpoint(), stream: true, include, - service_tier: match service_tier { - Some(ServiceTier::Fast) => Some("priority".to_string()), - Some(service_tier) => Some(service_tier.to_string()), - None => None, - }, + service_tier, prompt_cache_key, text, client_metadata: Some(HashMap::from([( @@ -850,16 +860,17 @@ impl ModelClient { turn_metadata_header: Option<&str>, ) -> ApiHeaderMap { let turn_metadata_header = parse_turn_metadata_header(turn_metadata_header); - let conversation_id = self.state.conversation_id.to_string(); + let session_id = self.state.session_id.to_string(); + let thread_id = self.state.thread_id.to_string(); let mut headers = build_responses_headers( self.state.beta_features_header.as_deref(), turn_state, turn_metadata_header.as_ref(), ); - if let Ok(header_value) = HeaderValue::from_str(&conversation_id) { + if let Ok(header_value) = HeaderValue::from_str(&thread_id) { headers.insert("x-client-request-id", header_value); } - headers.extend(build_conversation_headers(Some(conversation_id))); + headers.extend(build_session_headers(Some(session_id), Some(thread_id))); headers.extend(self.build_responses_identity_headers()); headers.insert( OPENAI_BETA_HEADER, @@ -892,6 +903,18 @@ impl ModelClientSession { .set_connection_reused(/*connection_reused*/ false); } + pub(crate) async fn send_response_processed(&self, response_id: &str) { + let Some(connection) = self.websocket_session.connection.as_ref() else { + return; + }; + if let Err(err) = connection + .send_response_processed(response_id.to_string()) + .await + { + debug!("failed to send response.processed websocket request: {err}"); + } + } + #[allow(clippy::too_many_arguments)] /// Builds shared Responses API transport options and request-body options. /// @@ -903,9 +926,11 @@ impl ModelClientSession { compression: Compression, ) -> ApiResponsesOptions { let turn_metadata_header = parse_turn_metadata_header(turn_metadata_header); - let conversation_id = self.client.state.conversation_id.to_string(); + let session_id = self.client.state.session_id.to_string(); + let thread_id = self.client.state.thread_id.to_string(); ApiResponsesOptions { - conversation_id: Some(conversation_id), + session_id: Some(session_id), + thread_id: Some(thread_id), session_source: Some(self.client.state.session_source.clone()), extra_headers: { let mut headers = build_responses_headers( @@ -1151,7 +1176,7 @@ impl ModelClientSession { session_telemetry: &SessionTelemetry, effort: Option, summary: ReasoningSummaryConfig, - service_tier: Option, + service_tier: Option, turn_metadata_header: Option<&str>, inference_trace: &InferenceTraceContext, ) -> Result { @@ -1198,7 +1223,7 @@ impl ModelClientSession { model_info, effort, summary, - service_tier, + service_tier.clone(), )?; let inference_trace_attempt = inference_trace.start_attempt(); inference_trace_attempt.record_started(&request); @@ -1276,7 +1301,7 @@ impl ModelClientSession { session_telemetry: &SessionTelemetry, effort: Option, summary: ReasoningSummaryConfig, - service_tier: Option, + service_tier: Option, turn_metadata_header: Option<&str>, warmup: bool, request_trace: Option, @@ -1304,7 +1329,7 @@ impl ModelClientSession { model_info, effort, summary, - service_tier, + service_tier.clone(), )?; let mut ws_payload = ResponseCreateWsRequest { client_metadata: response_create_client_metadata( @@ -1436,7 +1461,7 @@ impl ModelClientSession { session_telemetry: &SessionTelemetry, effort: Option, summary: ReasoningSummaryConfig, - service_tier: Option, + service_tier: Option, turn_metadata_header: Option<&str>, ) -> Result<()> { if !self.client.responses_websocket_enabled() { @@ -1497,7 +1522,7 @@ impl ModelClientSession { session_telemetry: &SessionTelemetry, effort: Option, summary: ReasoningSummaryConfig, - service_tier: Option, + service_tier: Option, turn_metadata_header: Option<&str>, inference_trace: &InferenceTraceContext, ) -> Result { @@ -1513,7 +1538,7 @@ impl ModelClientSession { session_telemetry, effort, summary, - service_tier, + service_tier.clone(), turn_metadata_header, /*warmup*/ false, request_trace, diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index 13c5603baa..2ba65d7c45 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -52,9 +52,11 @@ use tracing_subscriber::util::SubscriberInitExt; fn test_model_client(session_source: SessionSource) -> ModelClient { let provider = create_oss_provider_with_base_url("https://example.com/v1", WireApi::Responses); + let thread_id = ThreadId::new(); ModelClient::new( /*auth_manager*/ None, - ThreadId::new(), + thread_id.into(), + thread_id, /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), provider, session_source, @@ -270,7 +272,7 @@ fn build_ws_client_metadata_includes_window_lineage_and_turn_metadata() { client.advance_window_generation(); let client_metadata = client.build_ws_client_metadata(Some(r#"{"turn_id":"turn-123"}"#)); - let conversation_id = client.state.conversation_id; + let thread_id = client.state.thread_id; assert_eq!( client_metadata, std::collections::HashMap::from([ @@ -280,7 +282,7 @@ fn build_ws_client_metadata_includes_window_lineage_and_turn_metadata() { ), ( X_CODEX_WINDOW_ID_HEADER.to_string(), - format!("{conversation_id}:1"), + format!("{thread_id}:1"), ), ( X_OPENAI_SUBAGENT_HEADER.to_string(), diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 572cffcabe..3ead350dfa 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -76,6 +76,7 @@ pub(crate) async fn run_codex_thread_interactive( let (tx_ops, rx_ops) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let CodexSpawnOk { codex, .. } = Box::pin(Codex::spawn(CodexSpawnArgs { config, + installation_id: parent_session.installation_id.clone(), auth_manager, models_manager, environment_manager: Arc::clone(&parent_session.services.environment_manager), diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 2f74bd48b4..24a976637b 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -11,7 +11,6 @@ use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; -use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; @@ -53,7 +52,7 @@ use codex_rollout::state_db::StateDbHandle; pub struct ThreadConfigSnapshot { pub model: String, pub model_provider_id: String, - pub service_tier: Option, + pub service_tier: Option, pub approval_policy: AskForApproval, pub approvals_reviewer: ApprovalsReviewer, pub permission_profile: PermissionProfile, @@ -91,7 +90,7 @@ pub struct CodexThreadTurnContextOverrides { pub model: Option, pub effort: Option>, pub summary: Option, - pub service_tier: Option>, + pub service_tier: Option>, pub collaboration_mode: Option, pub personality: Option, } @@ -391,7 +390,7 @@ impl CodexThread { self.rollout_path.clone() } - pub(crate) fn session_configured(&self) -> SessionConfiguredEvent { + pub fn session_configured(&self) -> SessionConfiguredEvent { self.session_configured.clone() } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index a4197acccd..38eb19f1ba 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -524,7 +524,7 @@ async fn drain_to_completed( &turn_context.session_telemetry, turn_context.reasoning_effort, turn_context.reasoning_summary, - turn_context.config.service_tier, + turn_context.config.service_tier.clone(), turn_metadata_header, // Rollout tracing currently models remote compaction only; local compaction streams // are left untraced until the reducer has a first-class local compaction lifecycle. diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 1a3f0d4a7a..bc0516ce01 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -2,6 +2,7 @@ use std::collections::HashSet; use std::sync::Arc; use crate::Prompt; +use crate::client::CompactConversationRequestSettings; use crate::compact::CompactionAnalyticsAttempt; use crate::compact::InitialContextInjection; use crate::compact::compaction_status_from_result; @@ -170,8 +171,11 @@ async fn run_remote_compact_task_inner_impl( .compact_conversation_history( &prompt, &turn_context.model_info, - turn_context.reasoning_effort, - turn_context.reasoning_summary, + CompactConversationRequestSettings { + effort: turn_context.reasoning_effort, + summary: turn_context.reasoning_summary, + service_tier: turn_context.config.service_tier.clone(), + }, &turn_context.session_telemetry, &compaction_trace, ) diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index f461dd9ce1..7705616d37 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -252,7 +252,7 @@ async fn run_remote_compaction_request_v2( &turn_context.session_telemetry, turn_context.reasoning_effort, turn_context.reasoning_summary, - turn_context.config.service_tier, + turn_context.config.service_tier.clone(), turn_metadata_header, &InferenceTraceContext::disabled(), ) diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index c35a3767cb..4c6d1a87e0 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -6,6 +6,7 @@ use crate::config::edit::ConfigEditsBuilder; use crate::config::edit::apply_blocking; use assert_matches::assert_matches; use codex_config::CONFIG_TOML_FILE; +use codex_config::ConfigLayerEntry; use codex_config::RequirementSource; use codex_config::config_toml::AgentRoleToml; use codex_config::config_toml::AgentsToml; @@ -61,6 +62,7 @@ use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID; use codex_model_provider_info::WireApi; use codex_models_manager::bundled_models_response; +use codex_protocol::config_types::ServiceTier; use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::ManagedFileSystemPermissions; @@ -194,12 +196,13 @@ async fn load_config_loads_global_agents_instructions() -> std::io::Result<()> { "\n global instructions \n", )?; - let config = Config::load_from_base_config_with_overrides( + let mut config = Config::load_from_base_config_with_overrides( ConfigToml::default(), ConfigOverrides::default(), codex_home.abs(), ) .await?; + let _ = config.features.enable(Feature::MemoryTool); assert_eq!( config.user_instructions.as_deref(), @@ -2845,6 +2848,307 @@ fn filter_plugin_mcp_servers_by_allowlist_blocks_unlisted_plugin() { ); } +#[tokio::test] +async fn rebuild_preserving_session_layers_refreshes_requirements() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home.path()); + let project_dot_codex = + AbsolutePathBuf::resolve_path_against_base("project/.codex", codex_home.path()); + let mcp_requirements = BTreeMap::from([ + ( + "session_overrides_user".to_string(), + McpServerRequirement { + identity: McpServerIdentity::Command { + command: "session-command".to_string(), + }, + }, + ), + ( + "managed_overrides_session".to_string(), + McpServerRequirement { + identity: McpServerIdentity::Command { + command: "managed-command".to_string(), + }, + }, + ), + ( + "fresh_global".to_string(), + McpServerRequirement { + identity: McpServerIdentity::Command { + command: "fresh-global-command".to_string(), + }, + }, + ), + ( + "fresh_project".to_string(), + McpServerRequirement { + identity: McpServerIdentity::Command { + command: "fresh-project-command".to_string(), + }, + }, + ), + ]); + let requirements_toml = codex_config::ConfigRequirementsToml { + mcp_servers: Some(mcp_requirements.clone()), + ..Default::default() + }; + let requirements = codex_config::ConfigRequirements { + mcp_servers: Some(Sourced::new(mcp_requirements, RequirementSource::Unknown)), + ..Default::default() + }; + let refreshed_layer_stack = ConfigLayerStack::new( + vec![ + ConfigLayerEntry::new( + codex_app_server_protocol::ConfigLayerSource::User { + file: user_file.clone(), + }, + toml::toml! { + [mcp_servers.session_overrides_user] + command = "new-user-command" + [mcp_servers.managed_overrides_session] + command = "new-user-command" + [mcp_servers.fresh_global] + command = "fresh-global-command" + } + .into(), + ), + ConfigLayerEntry::new( + codex_app_server_protocol::ConfigLayerSource::Project { + dot_codex_folder: project_dot_codex.clone(), + }, + toml::toml! { + [mcp_servers.fresh_project] + command = "fresh-project-command" + } + .into(), + ), + ConfigLayerEntry::new( + codex_app_server_protocol::ConfigLayerSource::LegacyManagedConfigTomlFromMdm, + toml::toml! { + [mcp_servers.managed_overrides_session] + command = "managed-command" + } + .into(), + ), + ], + requirements, + requirements_toml, + ) + .map_err(std::io::Error::other)?; + let refreshed_toml = refreshed_layer_stack + .effective_config() + .try_into() + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; + let refreshed_config = Config::load_config_with_layer_stack( + LOCAL_FS.as_ref(), + refreshed_toml, + ConfigOverrides { + cwd: Some(codex_home.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + refreshed_layer_stack, + ) + .await?; + let thread_layer_stack = ConfigLayerStack::new( + vec![ + ConfigLayerEntry::new( + codex_app_server_protocol::ConfigLayerSource::User { + file: user_file.clone(), + }, + toml::toml! { + [mcp_servers.session_overrides_user] + command = "old-user-command" + [mcp_servers.managed_overrides_session] + command = "old-user-command" + [mcp_servers.fresh_global] + command = "old-global-command" + } + .into(), + ), + ConfigLayerEntry::new( + codex_app_server_protocol::ConfigLayerSource::Project { + dot_codex_folder: project_dot_codex, + }, + toml::toml! { + [mcp_servers.fresh_project] + command = "old-project-command" + } + .into(), + ), + ConfigLayerEntry::new( + codex_app_server_protocol::ConfigLayerSource::SessionFlags, + toml::toml! { + [mcp_servers.session_overrides_user] + command = "session-command" + [mcp_servers.managed_overrides_session] + command = "session-command" + [mcp_servers.blocked_session] + command = "blocked-session-command" + } + .into(), + ), + ConfigLayerEntry::new( + codex_app_server_protocol::ConfigLayerSource::LegacyManagedConfigTomlFromMdm, + toml::toml! { + [mcp_servers.managed_overrides_session] + command = "old-managed-command" + } + .into(), + ), + ], + Default::default(), + Default::default(), + ) + .map_err(std::io::Error::other)?; + let thread_toml = thread_layer_stack + .effective_config() + .try_into() + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; + let thread_config = Config::load_config_with_layer_stack( + LOCAL_FS.as_ref(), + thread_toml, + ConfigOverrides { + cwd: Some(codex_home.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + thread_layer_stack, + ) + .await?; + let config = thread_config + .rebuild_preserving_session_layers(&refreshed_config) + .await?; + + assert_eq!( + config.mcp_servers.get(), + &HashMap::from([ + ( + "session_overrides_user".to_string(), + stdio_mcp("session-command"), + ), + ( + "managed_overrides_session".to_string(), + stdio_mcp("managed-command"), + ), + ( + "fresh_global".to_string(), + stdio_mcp("fresh-global-command"), + ), + ( + "fresh_project".to_string(), + stdio_mcp("fresh-project-command"), + ), + ( + "blocked_session".to_string(), + McpServerConfig { + enabled: false, + disabled_reason: Some(McpServerDisabledReason::Requirements { + source: RequirementSource::Unknown, + }), + ..stdio_mcp("blocked-session-command") + }, + ), + ]) + ); + + Ok(()) +} + +#[tokio::test] +async fn rebuild_preserving_session_layers_refreshes_plugin_derived_mcp_config() +-> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + )?; + std::fs::write( + plugin_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "sample": { + "type": "http", + "url": "https://sample.example/mcp" + } + } +}"#, + )?; + + let user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home.path()); + let refreshed_layer_stack = ConfigLayerStack::new( + vec![ConfigLayerEntry::new( + codex_app_server_protocol::ConfigLayerSource::User { + file: user_file.clone(), + }, + toml::toml! { + [features] + plugins = true + + [plugins."sample@test"] + enabled = true + } + .into(), + )], + Default::default(), + Default::default(), + )?; + let refreshed_config = Config::load_config_with_layer_stack( + LOCAL_FS.as_ref(), + refreshed_layer_stack.effective_config().try_into()?, + ConfigOverrides { + cwd: Some(codex_home.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + refreshed_layer_stack, + ) + .await?; + let thread_layer_stack = ConfigLayerStack::new( + vec![ConfigLayerEntry::new( + codex_app_server_protocol::ConfigLayerSource::User { file: user_file }, + toml::toml! { + [features] + plugins = false + + [plugins."sample@test"] + enabled = true + } + .into(), + )], + Default::default(), + Default::default(), + )?; + let thread_config = Config::load_config_with_layer_stack( + LOCAL_FS.as_ref(), + thread_layer_stack.effective_config().try_into()?, + ConfigOverrides { + cwd: Some(codex_home.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + thread_layer_stack, + ) + .await?; + let config = thread_config + .rebuild_preserving_session_layers(&refreshed_config) + .await?; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + let mcp_config = config.to_mcp_config(&plugins_manager).await; + + assert_eq!( + mcp_config.configured_mcp_servers.get("sample"), + Some(&http_mcp("https://sample.example/mcp")) + ); + + Ok(()) +} + #[tokio::test] async fn to_mcp_config_applies_plugin_mcp_cloud_requirements() -> anyhow::Result<()> { let codex_home = TempDir::new()?; @@ -3042,6 +3346,77 @@ async fn add_dir_override_extends_workspace_writable_roots() -> std::io::Result< Ok(()) } +#[tokio::test] +async fn to_mcp_config_empty_mcp_requirements_preserve_builtin_mcps() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let requirements = codex_config::ConfigRequirementsToml { + mcp_servers: Some(BTreeMap::new()), + ..Default::default() + }; + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .cloud_requirements(CloudRequirementsLoader::new(async move { + Ok(Some(requirements)) + })) + .build() + .await?; + config.codex_self_exe = Some(PathBuf::from("/tmp/codex")); + let _ = config.features.enable(Feature::BuiltInMcp); + let _ = config.features.enable(Feature::MemoryTool); + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + + let mcp_config = config.to_mcp_config(&plugins_manager).await; + + assert_eq!( + mcp_config + .configured_mcp_servers + .get(codex_mcp::MEMORIES_MCP_SERVER_NAME) + .map(|server| (server.enabled, server.disabled_reason.clone())), + Some((true, None)) + ); + + Ok(()) +} + +#[tokio::test] +async fn to_mcp_config_nonempty_mcp_requirements_preserve_builtin_mcps() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let requirements = codex_config::ConfigRequirementsToml { + mcp_servers: Some(BTreeMap::from([( + "docs".to_string(), + McpServerRequirement { + identity: McpServerIdentity::Command { + command: "docs-mcp".to_string(), + }, + }, + )])), + ..Default::default() + }; + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .cloud_requirements(CloudRequirementsLoader::new(async move { + Ok(Some(requirements)) + })) + .build() + .await?; + config.codex_self_exe = Some(PathBuf::from("/tmp/codex")); + let _ = config.features.enable(Feature::BuiltInMcp); + let _ = config.features.enable(Feature::MemoryTool); + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + + let mcp_config = config.to_mcp_config(&plugins_manager).await; + + assert_eq!( + mcp_config + .configured_mcp_servers + .get(codex_mcp::MEMORIES_MCP_SERVER_NAME) + .map(|server| (server.enabled, server.disabled_reason.clone())), + Some((true, None)) + ); + + Ok(()) +} + #[tokio::test] async fn sqlite_home_defaults_to_codex_home_for_workspace_write() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -3864,6 +4239,107 @@ async fn to_mcp_config_preserves_apps_feature_from_config() -> std::io::Result<( Ok(()) } +#[tokio::test] +async fn to_mcp_config_includes_enabled_builtin_mcps() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let mut config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides { + codex_self_exe: Some(PathBuf::from("/tmp/codex")), + ..ConfigOverrides::default() + }, + codex_home.abs(), + ) + .await?; + let _ = config.features.enable(Feature::BuiltInMcp); + let _ = config.features.enable(Feature::MemoryTool); + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + + let mcp_config = config.to_mcp_config(&plugins_manager).await; + + assert_eq!( + mcp_config + .configured_mcp_servers + .get(codex_mcp::MEMORIES_MCP_SERVER_NAME) + .map(|server| &server.transport), + Some(&McpServerTransportConfig::Stdio { + command: "/tmp/codex".to_string(), + args: vec![ + "builtin-mcp".to_string(), + "memories".to_string(), + "--codex-home".to_string(), + codex_home.path().display().to_string(), + ], + env: None, + env_vars: Vec::new(), + cwd: None, + }) + ); + + Ok(()) +} + +#[tokio::test] +async fn to_mcp_config_omits_builtin_mcps_when_feature_is_disabled() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let mut config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides { + codex_self_exe: Some(PathBuf::from("/tmp/codex")), + ..ConfigOverrides::default() + }, + codex_home.abs(), + ) + .await?; + let _ = config.features.enable(Feature::MemoryTool); + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + + let mcp_config = config.to_mcp_config(&plugins_manager).await; + + assert!( + !mcp_config + .configured_mcp_servers + .contains_key(codex_mcp::MEMORIES_MCP_SERVER_NAME) + ); + + Ok(()) +} + +#[tokio::test] +async fn to_mcp_config_reserves_enabled_builtin_mcp_names() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let mut config = Config::load_from_base_config_with_overrides( + ConfigToml { + mcp_servers: HashMap::from([( + codex_mcp::MEMORIES_MCP_SERVER_NAME.to_string(), + http_mcp("https://user.example/memories"), + )]), + ..ConfigToml::default() + }, + ConfigOverrides { + codex_self_exe: Some(PathBuf::from("/tmp/codex")), + ..ConfigOverrides::default() + }, + codex_home.abs(), + ) + .await?; + let _ = config.features.enable(Feature::BuiltInMcp); + let _ = config.features.enable(Feature::MemoryTool); + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + + let mcp_config = config.to_mcp_config(&plugins_manager).await; + + assert!(matches!( + mcp_config + .configured_mcp_servers + .get(codex_mcp::MEMORIES_MCP_SERVER_NAME) + .map(|server| &server.transport), + Some(McpServerTransportConfig::Stdio { .. }) + )); + + Ok(()) +} + #[tokio::test] async fn load_global_mcp_servers_rejects_inline_bearer_token() -> anyhow::Result<()> { let codex_home = TempDir::new()?; @@ -6657,6 +7133,28 @@ async fn explicit_null_service_tier_override_sets_fast_default_opt_out() -> std: Ok(()) } +#[tokio::test] +async fn legacy_fast_service_tier_override_uses_priority_request_value() -> std::io::Result<()> { + let fixture = create_test_fixture()?; + + let config = Config::load_from_base_config_with_overrides( + fixture.cfg.clone(), + ConfigOverrides { + cwd: Some(fixture.cwd_path()), + service_tier: Some(Some("fast".to_string())), + ..Default::default() + }, + fixture.codex_home(), + ) + .await?; + + assert_eq!( + config.service_tier, + Some(ServiceTier::Fast.request_value().to_string()) + ); + Ok(()) +} + #[tokio::test] async fn fast_default_opt_out_notice_config_is_respected() -> std::io::Result<()> { let fixture = create_test_fixture()?; @@ -8625,68 +9123,6 @@ hide_spawn_agent_metadata = true Ok(()) } -#[tokio::test] -async fn multi_agent_v2_usage_hint_templates_use_materialized_config_values() -> std::io::Result<()> -{ - let codex_home = TempDir::new()?; - std::fs::write( - codex_home.path().join(CONFIG_TOML_FILE), - r#"[features.multi_agent_v2] -enabled = true -usage_hint_text = "Limit {{ features.multi_agent_v2.max_concurrent_threads_per_session }}" -root_agent_usage_hint_text = "Root {{ features.multi_agent_v2.max_concurrent_threads_per_session }}" -subagent_usage_hint_text = "Subagent {{ features.multi_agent_v2.max_concurrent_threads_per_session }}" -"#, - )?; - - let config = ConfigBuilder::without_managed_config_for_tests() - .codex_home(codex_home.path().to_path_buf()) - .fallback_cwd(Some(codex_home.path().to_path_buf())) - .build() - .await?; - - assert_eq!( - config.multi_agent_v2.usage_hint_text.as_deref(), - Some("Limit 4") - ); - assert_eq!( - config.multi_agent_v2.root_agent_usage_hint_text.as_deref(), - Some("Root 4") - ); - assert_eq!( - config.multi_agent_v2.subagent_usage_hint_text.as_deref(), - Some("Subagent 4") - ); - - Ok(()) -} - -#[tokio::test] -async fn multi_agent_v2_usage_hint_templates_fail_when_placeholder_is_missing() { - let codex_home = TempDir::new().expect("create codex home"); - std::fs::write( - codex_home.path().join(CONFIG_TOML_FILE), - r#"[features.multi_agent_v2] -enabled = true -usage_hint_text = "{{ features.multi_agent_v2.does_not_exist }}" -"#, - ) - .expect("write config"); - - let err = ConfigBuilder::without_managed_config_for_tests() - .codex_home(codex_home.path().to_path_buf()) - .fallback_cwd(Some(codex_home.path().to_path_buf())) - .build() - .await - .expect_err("config load should fail"); - - assert!( - err.to_string() - .contains("features.multi_agent_v2.does_not_exist"), - "unexpected error: {err}", - ); -} - #[tokio::test] async fn profile_multi_agent_v2_config_overrides_base() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index cfe2100d49..6dbc9519a9 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -70,7 +70,9 @@ use codex_features::FeaturesToml; use codex_features::MultiAgentV2ConfigToml; use codex_git_utils::resolve_root_git_project_for_trust; use codex_login::AuthManagerConfig; +use codex_mcp::BuiltinMcpServerOptions; use codex_mcp::McpConfig; +use codex_mcp::configured_builtin_mcp_servers; use codex_memories_read::memory_root; use codex_model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID; use codex_model_provider_info::ModelProviderInfo; @@ -133,7 +135,6 @@ mod network_proxy_spec; mod permissions; #[cfg(test)] mod schema; -pub(crate) mod template_interpolation; pub use codex_config::Constrained; pub use codex_config::ConstraintError; pub use codex_config::ConstraintResult; @@ -401,8 +402,8 @@ pub struct Config { /// Optional override of model selection. pub model: Option, - /// Effective service tier preference for new turns (`fast` or `flex`). - pub service_tier: Option, + /// Effective service tier request id preference for new turns. + pub service_tier: Option, /// Model used specifically for review sessions. pub review_model: Option, @@ -1088,6 +1089,13 @@ impl Config { ) -> McpConfig { let plugins_input = self.plugins_config_input(); let loaded_plugins = plugins_manager.plugins_for_config(&plugins_input).await; + let builtin_mcp_servers = configured_builtin_mcp_servers(BuiltinMcpServerOptions { + codex_self_exe: self.codex_self_exe.as_deref(), + codex_home: self.codex_home.as_path(), + memories_enabled: self.features.enabled(Feature::BuiltInMcp) + && self.features.enabled(Feature::MemoryTool) + && self.memories.use_memories, + }); let mut configured_mcp_servers = self.mcp_servers.get().clone(); for plugin in loaded_plugins .plugins() @@ -1107,9 +1115,12 @@ impl Config { if let Some(mcp_requirements) = self.config_layer_stack.requirements().mcp_servers.as_ref() && mcp_requirements.value.is_empty() { - // A present empty allowlist bans all MCPs, including plugin MCPs merged above. + // A present empty allowlist bans configurable MCPs, including plugin MCPs merged + // above. Built-ins are product-owned and stay available regardless of admin + // allowlists. filter_mcp_servers_by_requirements(&mut configured_mcp_servers, Some(mcp_requirements)); } + configured_mcp_servers.extend(builtin_mcp_servers); McpConfig { chatgpt_base_url: self.chatgpt_base_url.clone(), @@ -1130,6 +1141,62 @@ impl Config { } } + pub async fn rebuild_preserving_session_layers( + &self, + refreshed_config: &Config, + ) -> std::io::Result { + let mut layers = refreshed_config + .config_layer_stack + .get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ true, + ) + .into_iter() + .filter(|layer| !is_session_layer(&layer.name)) + .cloned() + .collect::>(); + layers.extend( + self.config_layer_stack + .get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ true, + ) + .into_iter() + .filter(|layer| is_session_layer(&layer.name)) + .cloned(), + ); + layers.sort_by_key(|layer| layer.name.precedence()); + + let config_layer_stack = ConfigLayerStack::new( + layers, + refreshed_config.config_layer_stack.requirements().clone(), + refreshed_config + .config_layer_stack + .requirements_toml() + .clone(), + )? + .with_user_and_project_exec_policy_rules_ignored( + refreshed_config + .config_layer_stack + .ignore_user_and_project_exec_policy_rules(), + ); + let cfg: ConfigToml = config_layer_stack + .effective_config() + .try_into() + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; + Self::load_config_with_layer_stack( + LOCAL_FS.as_ref(), + cfg, + ConfigOverrides { + cwd: Some(self.cwd.to_path_buf()), + ..Default::default() + }, + refreshed_config.codex_home.clone(), + config_layer_stack, + ) + .await + } + /// This is the preferred way to create an instance of [Config]. pub async fn load_with_cli_overrides( cli_overrides: Vec<(String, TomlValue)>, @@ -1680,6 +1747,10 @@ fn thread_store_config( } } +fn is_session_layer(source: &ConfigLayerSource) -> bool { + matches!(source, ConfigLayerSource::SessionFlags) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PermissionConfigSyntax { Legacy, @@ -1796,7 +1867,7 @@ pub struct ConfigOverrides { pub permission_profile: Option, pub default_permissions: Option, pub model_provider: Option, - pub service_tier: Option>, + pub service_tier: Option>, pub config_profile: Option, pub codex_self_exe: Option, pub codex_linux_sandbox_exe: Option, @@ -2023,63 +2094,6 @@ impl Config { overrides: ConfigOverrides, codex_home: AbsolutePathBuf, config_layer_stack: ConfigLayerStack, - ) -> std::io::Result { - let config = Self::build_config_with_layer_stack( - fs, - cfg.clone(), - overrides.clone(), - codex_home.clone(), - config_layer_stack.clone(), - ) - .await?; - let mut interpolation_source_cfg = cfg.clone(); - template_interpolation::apply_resolved_config_fields( - &config, - &mut interpolation_source_cfg, - ) - .map_err(|err| { - std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("failed to materialize config for interpolation: {err}"), - ) - })?; - let interpolation_source = - toml::Value::try_from(interpolation_source_cfg).map_err(|err| { - std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("failed to serialize config for interpolation: {err}"), - ) - })?; - let mut interpolated_cfg = cfg; - let interpolated = template_interpolation::interpolate_config_string_fields( - &mut interpolated_cfg, - &interpolation_source, - ) - .map_err(|err| { - std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("failed to interpolate config template fields: {err}"), - ) - })?; - if interpolated { - return Self::build_config_with_layer_stack( - fs, - interpolated_cfg, - overrides, - codex_home, - config_layer_stack, - ) - .await; - } - Ok(config) - } - - async fn build_config_with_layer_stack( - fs: &dyn ExecutorFileSystem, - cfg: ConfigToml, - overrides: ConfigOverrides, - codex_home: AbsolutePathBuf, - config_layer_stack: ConfigLayerStack, ) -> std::io::Result { // Keep the large config-construction future off small test thread stacks. Box::pin(async move { @@ -2721,16 +2735,20 @@ impl Config { notices.fast_default_opt_out = Some(true); None } - None => config_profile.service_tier.or(cfg.service_tier), + None => config_profile + .service_tier + .or(cfg.service_tier) + .map(|service_tier| service_tier.request_value().to_string()), }; - let service_tier = match service_tier { - Some(ServiceTier::Fast) if features.enabled(Feature::FastMode) => { - Some(ServiceTier::Fast) + let service_tier = service_tier.and_then(|service_tier| { + match ServiceTier::from_request_value(&service_tier) { + Some(ServiceTier::Fast) => features + .enabled(Feature::FastMode) + .then(|| ServiceTier::Fast.request_value().to_string()), + Some(ServiceTier::Flex) => Some(ServiceTier::Flex.request_value().to_string()), + None => Some(service_tier), } - Some(ServiceTier::Fast) => None, - Some(ServiceTier::Flex) => Some(ServiceTier::Flex), - None => None, - }; + }); let compact_prompt = compact_prompt.or(cfg.compact_prompt).and_then(|value| { let trimmed = value.trim(); diff --git a/codex-rs/core/src/config/template_interpolation.rs b/codex-rs/core/src/config/template_interpolation.rs deleted file mode 100644 index 7e38fed86f..0000000000 --- a/codex-rs/core/src/config/template_interpolation.rs +++ /dev/null @@ -1,189 +0,0 @@ -use anyhow::Context; -use anyhow::bail; -use codex_config::config_toml::ConfigToml; -use codex_config::types::MemoriesToml; -use codex_features::AppsMcpPathOverrideConfigToml; -use codex_features::Feature; -use codex_features::FeatureToml; -use codex_features::FeaturesToml; -use codex_features::MultiAgentV2ConfigToml; -use codex_utils_template::Template; -use toml::Value as TomlValue; - -use super::Config; - -const INTERPOLATED_CONFIG_STRING_FIELDS: &[&str] = &[ - "features.multi_agent_v2.usage_hint_text", - "features.multi_agent_v2.root_agent_usage_hint_text", - "features.multi_agent_v2.subagent_usage_hint_text", -]; - -pub(crate) fn materialized_config_toml(config: &Config) -> anyhow::Result { - let mut materialized: ConfigToml = config - .config_layer_stack - .effective_config() - .try_into() - .context("failed to deserialize effective config for config interpolation")?; - apply_resolved_config_fields(config, &mut materialized)?; - Ok(materialized) -} - -pub(crate) fn interpolate_config_string_fields( - config_toml: &mut ConfigToml, - interpolation_source: &TomlValue, -) -> anyhow::Result { - let mut target_value = TomlValue::try_from(config_toml.clone()) - .context("failed to serialize config for interpolation")?; - let mut changed = false; - - for field_path in INTERPOLATED_CONFIG_STRING_FIELDS { - let Some(value) = value_mut_at_path(&mut target_value, field_path) else { - continue; - }; - let Some(template_source) = value.as_str() else { - bail!("interpolated config field `{field_path}` must be a string"); - }; - let template = Template::parse(template_source) - .with_context(|| format!("failed to parse template in config field `{field_path}`"))?; - let rendered = render_template(&template, interpolation_source, field_path)?; - if rendered != template_source { - *value = TomlValue::String(rendered); - changed = true; - } - } - - if changed { - *config_toml = target_value - .try_into() - .context("failed to deserialize interpolated config")?; - } - - Ok(changed) -} - -pub(crate) fn apply_resolved_config_fields( - config: &Config, - config_toml: &mut ConfigToml, -) -> anyhow::Result<()> { - config_toml.web_search = Some(config.web_search_mode.value()); - config_toml.model_provider = Some(config.model_provider_id.clone()); - config_toml.plan_mode_reasoning_effort = config.plan_mode_reasoning_effort; - config_toml.model_verbosity = config.model_verbosity; - config_toml.include_permissions_instructions = Some(config.include_permissions_instructions); - config_toml.include_apps_instructions = Some(config.include_apps_instructions); - config_toml.include_environment_context = Some(config.include_environment_context); - config_toml.background_terminal_max_timeout = Some(config.background_terminal_max_timeout); - - // Feature aliases and feature configs need to be written in their resolved - // form; otherwise replay can drift when a legacy key maps to the same - // runtime feature. - let features = config_toml - .features - .get_or_insert_with(FeaturesToml::default); - features.materialize_resolved_enabled(config.features.get()); - let mut multi_agent_v2: MultiAgentV2ConfigToml = - resolved_config_to_toml(&config.multi_agent_v2, "features.multi_agent_v2")?; - multi_agent_v2.enabled = Some(config.features.enabled(Feature::MultiAgentV2)); - features.multi_agent_v2 = Some(FeatureToml::Config(multi_agent_v2)); - features.apps_mcp_path_override = Some(FeatureToml::Config(AppsMcpPathOverrideConfigToml { - enabled: Some(config.features.enabled(Feature::AppsMcpPathOverride)), - path: config.apps_mcp_path_override.clone(), - })); - - config_toml.memories = Some(resolved_config_to_toml::( - &config.memories, - "memories", - )?); - - let agents = config_toml.agents.get_or_insert_with(Default::default); - // Multi-agent v2 owns thread fanout through its feature config. Preserve - // the legacy agents.max_threads setting only when v2 is disabled. - agents.max_threads = if config.features.enabled(Feature::MultiAgentV2) { - None - } else { - config.agent_max_threads - }; - agents.max_depth = Some(config.agent_max_depth); - agents.job_max_runtime_seconds = config.agent_job_max_runtime_seconds; - agents.interrupt_message = Some(config.agent_interrupt_message_enabled); - - config_toml - .skills - .get_or_insert_with(Default::default) - .include_instructions = Some(config.include_skill_instructions); - - Ok(()) -} - -fn render_template( - template: &Template, - interpolation_source: &TomlValue, - field_path: &str, -) -> anyhow::Result { - let variables = - template - .placeholders() - .map(|placeholder| { - let value = lookup_scalar_path(interpolation_source, placeholder).with_context(|| { - format!("failed to render config field `{field_path}` placeholder `{placeholder}`") - })?; - Ok((placeholder.to_string(), value)) - }) - .collect::>>()?; - - template - .render( - variables - .iter() - .map(|(name, value)| (name.as_str(), value.as_str())), - ) - .with_context(|| format!("failed to render config field `{field_path}`")) -} - -fn lookup_scalar_path(value: &TomlValue, path: &str) -> anyhow::Result { - let resolved = value_at_path(value, path) - .with_context(|| format!("template placeholder `{path}` does not exist"))?; - - match resolved { - TomlValue::String(value) => Ok(value.clone()), - TomlValue::Integer(value) => Ok(value.to_string()), - TomlValue::Float(value) => Ok(value.to_string()), - TomlValue::Boolean(value) => Ok(value.to_string()), - _ => bail!( - "template placeholder `{path}` must resolve to a scalar string, integer, float, or boolean" - ), - } -} - -fn value_at_path<'a>(value: &'a TomlValue, path: &str) -> Option<&'a TomlValue> { - let mut current = value; - for segment in path.split('.') { - current = current.as_table()?.get(segment)?; - } - Some(current) -} - -fn value_mut_at_path<'a>(value: &'a mut TomlValue, path: &str) -> Option<&'a mut TomlValue> { - let mut current = value; - let mut segments = path.split('.').peekable(); - - while let Some(segment) = segments.next() { - let table = current.as_table_mut()?; - if segments.peek().is_none() { - return table.get_mut(segment); - } - current = table.get_mut(segment)?; - } - - Some(current) -} - -fn resolved_config_to_toml( - value: &impl serde::Serialize, - label: &'static str, -) -> anyhow::Result -where - Toml: serde::de::DeserializeOwned + serde::Serialize, -{ - crate::config_lock::toml_round_trip(value, label).map_err(anyhow::Error::from) -} diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 9f0381f53a..e2154db17e 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -45,6 +45,7 @@ use codex_mcp::ToolInfo; use codex_mcp::ToolPluginProvenance; use codex_mcp::codex_apps_tools_cache_key; use codex_mcp::compute_auth_statuses; +use codex_mcp::host_owned_codex_apps_enabled; use codex_mcp::with_codex_apps_mcp; const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30); @@ -246,6 +247,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager( let mcp_config = config.to_mcp_config(plugins_manager.as_ref()).await; let mcp_servers = with_codex_apps_mcp(HashMap::new(), auth.as_ref(), &mcp_config); + let host_owned_codex_apps_enabled = host_owned_codex_apps_enabled(&mcp_config, auth.as_ref()); if mcp_servers.is_empty() { return Ok(AccessibleConnectorsStatus { connectors: Vec::new(), @@ -278,6 +280,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager( McpRuntimeEnvironment::new(environment, config.cwd.to_path_buf()), config.codex_home.to_path_buf(), codex_apps_tools_cache_key(auth.as_ref()), + host_owned_codex_apps_enabled, ToolPluginProvenance::default(), auth.as_ref(), ) diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 78362b6f89..6d03dfa813 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -1894,7 +1894,7 @@ async fn guardian_review_surfaces_responses_api_errors_in_rejection_reason() -> #[tokio::test] async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> anyhow::Result<()> { - const TEST_STACK_SIZE_BYTES: usize = 2 * 1024 * 1024; + const TEST_STACK_SIZE_BYTES: usize = 4 * 1024 * 1024; let handle = std::thread::Builder::new() diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 4cdfc5ea23..1a754d1ece 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -61,14 +61,9 @@ pub use codex_mcp::SandboxState; mod mcp_openai_file; mod mcp_tool_call; pub(crate) mod mention_syntax; -pub(crate) mod message_history; pub(crate) mod utils; pub use mention_syntax::PLUGIN_TEXT_MENTION_SIGIL; pub use mention_syntax::TOOL_MENTION_SIGIL; -pub use message_history::HistoryEntry as MessageHistoryEntry; -pub use message_history::append_entry as append_message_history_entry; -pub use message_history::history_metadata as message_history_metadata; -pub use message_history::lookup as lookup_message_history_entry; pub use utils::path_utils; pub mod personality_migration; pub(crate) mod plugins; diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index f76d67d3f3..f1fc054d0f 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -42,8 +42,11 @@ use codex_config::types::AppToolApproval; use codex_features::Feature; use codex_hooks::PermissionRequestDecision; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; +use codex_mcp::MCP_TOOL_CODEX_APPS_META_KEY; use codex_mcp::McpPermissionPromptAutoApproveContext; use codex_mcp::SandboxState; +use codex_mcp::auth_elicitation_completed_result; +use codex_mcp::build_auth_elicitation_plan; use codex_mcp::declared_openai_file_input_param_names; use codex_mcp::mcp_permission_prompt_is_auto_approved; use codex_otel::sanitize_metric_tag_value; @@ -53,6 +56,7 @@ use codex_protocol::items::McpToolCallStatus; use codex_protocol::items::TurnItem; use codex_protocol::mcp::CallToolResult; use codex_protocol::openai_models::InputModality; +use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::McpInvocation; use codex_protocol::protocol::ReviewDecision; use codex_protocol::request_user_input::RequestUserInputAnswer; @@ -367,9 +371,10 @@ async fn handle_approved_mcp_tool_call( let result = execute_mcp_tool_call( sess, turn_context, - &server, - &tool_name, + call_id, + &invocation, rewritten_arguments, + metadata, request_meta, ) .await; @@ -568,28 +573,145 @@ fn truncate_str_to_char_boundary(value: &str, max_chars: usize) -> &str { async fn execute_mcp_tool_call( sess: &Session, turn_context: &TurnContext, - server: &str, - tool_name: &str, + call_id: &str, + invocation: &McpInvocation, rewritten_arguments: Option, + metadata: Option<&McpToolApprovalMetadata>, request_meta: Option, ) -> Result { let request_meta = with_mcp_tool_call_thread_id_meta(request_meta, &sess.conversation_id.to_string()); - let request_meta = - augment_mcp_tool_request_meta_with_sandbox_state(sess, turn_context, server, request_meta) - .await - .map_err(|e| format!("failed to build MCP tool request metadata: {e:#}"))?; + let request_meta = augment_mcp_tool_request_meta_with_sandbox_state( + sess, + turn_context, + &invocation.server, + request_meta, + ) + .await + .map_err(|e| format!("failed to build MCP tool request metadata: {e:#}"))?; let result = sess - .call_tool(server, tool_name, rewritten_arguments, request_meta) + .call_tool( + &invocation.server, + &invocation.tool, + rewritten_arguments, + request_meta, + ) .await .map_err(|e| format!("tool call error: {e:?}"))?; - sanitize_mcp_tool_result_for_model( + let result = sanitize_mcp_tool_result_for_model( turn_context .model_info .input_modalities .contains(&InputModality::Image), Ok(result), + )?; + Ok(maybe_request_codex_apps_auth_elicitation( + sess, + turn_context, + call_id, + &invocation.server, + metadata, + result, ) + .await) +} + +async fn maybe_request_codex_apps_auth_elicitation( + sess: &Session, + turn_context: &TurnContext, + call_id: &str, + server: &str, + metadata: Option<&McpToolApprovalMetadata>, + result: CallToolResult, +) -> CallToolResult { + if !sess + .services + .mcp_connection_manager + .read() + .await + .is_host_owned_codex_apps_server(server) + { + return result; + } + + if !turn_context.features.enabled(Feature::AuthElicitation) { + return result; + } + + match turn_context.approval_policy.value() { + AskForApproval::Never => return result, + AskForApproval::Granular(granular_config) if !granular_config.allows_mcp_elicitations() => { + return result; + } + AskForApproval::OnFailure + | AskForApproval::OnRequest + | AskForApproval::UnlessTrusted + | AskForApproval::Granular(_) => {} + } + + let connector_id = metadata.and_then(|metadata| metadata.connector_id.as_deref()); + let connector_name = metadata.and_then(|metadata| metadata.connector_name.as_deref()); + let install_url = connector_id.map(|connector_id| { + codex_connectors::metadata::connector_install_url( + connector_name.unwrap_or(connector_id), + connector_id, + ) + }); + let Some(plan) = + build_auth_elicitation_plan(call_id, &result, connector_id, connector_name, install_url) + else { + return result; + }; + + let request_id = rmcp::model::RequestId::String(plan.elicitation.elicitation_id.clone().into()); + let params = McpServerElicitationRequestParams { + thread_id: sess.conversation_id.to_string(), + turn_id: Some(turn_context.sub_id.clone()), + server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), + request: McpServerElicitationRequest::Url { + meta: Some(plan.elicitation.meta), + message: plan.elicitation.message, + url: plan.elicitation.url, + elicitation_id: plan.elicitation.elicitation_id, + }, + }; + let response = sess + .request_mcp_server_elicitation(turn_context, request_id, params) + .await; + if !response + .as_ref() + .is_some_and(|response| response.action == ElicitationAction::Accept) + { + return result; + } + + refresh_codex_apps_after_connector_auth(sess, turn_context).await; + auth_elicitation_completed_result(&plan.auth_failure, result.meta) +} + +#[expect( + clippy::await_holding_invalid_type, + reason = "Codex Apps cache refresh reads through the session-owned manager guard" +)] +async fn refresh_codex_apps_after_connector_auth(sess: &Session, turn_context: &TurnContext) { + let mcp_tools_result = { + let manager = sess.services.mcp_connection_manager.read().await; + manager.hard_refresh_codex_apps_tools_cache().await + }; + + match mcp_tools_result { + Ok(mcp_tools) => { + let auth = sess.services.auth_manager.auth().await; + connectors::refresh_accessible_connectors_cache_from_mcp_tools( + &turn_context.config, + auth.as_ref(), + &mcp_tools, + ); + } + Err(err) => { + tracing::warn!("failed to refresh Codex Apps tools after connector auth: {err:#}"); + } + } } #[expect( @@ -862,7 +984,6 @@ pub(crate) struct McpToolApprovalMetadata { openai_file_input_params: Option>, } -const MCP_TOOL_CODEX_APPS_META_KEY: &str = "_codex_apps"; const MCP_TOOL_OPENAI_OUTPUT_TEMPLATE_META_KEY: &str = "openai/outputTemplate"; const MCP_TOOL_UI_RESOURCE_URI_META_KEY: &str = "ui/resourceUri"; const MCP_TOOL_THREAD_ID_META_KEY: &str = "threadId"; diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 8a4b30755b..77813c1a23 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::config::ConfigBuilder; +use crate::config::ManagedFeatures; use crate::session::tests::make_session_and_context; use crate::session::tests::make_session_and_context_with_rx; use crate::state::ActiveTurn; @@ -14,11 +15,13 @@ use codex_config::types::ApprovalsReviewer; use codex_config::types::AppsConfigToml; use codex_config::types::McpServerConfig; use codex_config::types::McpServerToolConfig; +use codex_features::Features; use codex_hooks::Hooks; use codex_hooks::HooksConfig; use codex_model_provider::create_model_provider; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::GranularApprovalConfig; use core_test_support::PathExt; use core_test_support::hooks::trusted_config_layer_stack; @@ -1065,6 +1068,250 @@ async fn codex_apps_tool_call_request_meta_includes_call_id_without_existing_cod ); } +fn codex_apps_auth_failure_result() -> CallToolResult { + CallToolResult { + content: vec![serde_json::json!({ + "type": "text", + "text": "Connector reauthentication required", + })], + structured_content: None, + is_error: Some(true), + meta: Some(serde_json::json!({ + MCP_TOOL_CODEX_APPS_META_KEY: { + "connector_auth_failure": { + "is_auth_failure": true, + "auth_reason": "reauthentication_required", + "connector_id": "connector_calendar", + "connector_name": "Untrusted Calendar", + "link_id": "link_123", + "error_code": "UNAUTHORIZED", + "error_http_status_code": 401, + "error_action": "TRIGGER_REAUTHENTICATION", + }, + }, + })), + } +} + +fn codex_apps_auth_failure_metadata() -> McpToolApprovalMetadata { + approval_metadata( + Some("connector_calendar"), + Some("Google Calendar"), + Some("Manage events and schedules."), + Some("Create Event"), + Some("Create a calendar event."), + ) +} + +async fn install_host_owned_codex_apps_manager(session: &Session, turn_context: &TurnContext) { + let auth = session.services.auth_manager.auth().await; + let environment = session + .services + .environment_manager + .default_environment() + .unwrap_or_else(|| session.services.environment_manager.local_environment()); + let (manager, _cancel_token) = codex_mcp::McpConnectionManager::new( + &HashMap::new(), + turn_context.config.mcp_oauth_credentials_store_mode, + HashMap::new(), + &turn_context.approval_policy, + turn_context.sub_id.clone(), + session.get_tx_event(), + turn_context.permission_profile(), + codex_mcp::McpRuntimeEnvironment::new(environment, turn_context.cwd.to_path_buf()), + turn_context.config.codex_home.to_path_buf(), + codex_mcp::codex_apps_tools_cache_key(auth.as_ref()), + /*host_owned_codex_apps_enabled*/ true, + codex_mcp::ToolPluginProvenance::default(), + auth.as_ref(), + ) + .await; + *session.services.mcp_connection_manager.write().await = manager; +} + +#[tokio::test] +async fn codex_apps_auth_elicitation_feature_disabled_returns_original_result() { + let (session, turn_context, rx_event) = make_session_and_context_with_rx().await; + install_host_owned_codex_apps_manager(&session, &turn_context).await; + let result = codex_apps_auth_failure_result(); + let metadata = codex_apps_auth_failure_metadata(); + + let returned = maybe_request_codex_apps_auth_elicitation( + &session, + &turn_context, + "call_123", + CODEX_APPS_MCP_SERVER_NAME, + Some(&metadata), + result.clone(), + ) + .await; + + assert_eq!(returned, result); + assert!(rx_event.try_recv().is_err()); +} + +#[tokio::test] +async fn codex_apps_auth_elicitation_non_host_owned_server_returns_original_result() { + let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await; + let mut features = Features::with_defaults(); + features.enable(Feature::AuthElicitation); + Arc::get_mut(&mut turn_context) + .expect("single turn context ref") + .features = ManagedFeatures::from(features); + let result = codex_apps_auth_failure_result(); + let metadata = codex_apps_auth_failure_metadata(); + + let returned = maybe_request_codex_apps_auth_elicitation( + &session, + &turn_context, + "call_123", + CODEX_APPS_MCP_SERVER_NAME, + Some(&metadata), + result.clone(), + ) + .await; + + assert_eq!(returned, result); + assert!(rx_event.try_recv().is_err()); +} + +#[tokio::test] +async fn codex_apps_auth_elicitation_disallowed_by_policy_returns_original_result() { + let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await; + install_host_owned_codex_apps_manager(&session, &turn_context).await; + let mut features = Features::with_defaults(); + features.enable(Feature::AuthElicitation); + let turn_context = Arc::get_mut(&mut turn_context).expect("single turn context ref"); + turn_context.features = ManagedFeatures::from(features); + turn_context + .approval_policy + .set(AskForApproval::Never) + .expect("test setup should allow updating approval policy"); + let result = codex_apps_auth_failure_result(); + let metadata = codex_apps_auth_failure_metadata(); + + let returned = maybe_request_codex_apps_auth_elicitation( + &session, + turn_context, + "call_123", + CODEX_APPS_MCP_SERVER_NAME, + Some(&metadata), + result.clone(), + ) + .await; + + assert_eq!(returned, result); + assert!(rx_event.try_recv().is_err()); +} + +#[tokio::test] +async fn codex_apps_auth_elicitation_granular_mcp_disabled_returns_original_result() { + let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await; + install_host_owned_codex_apps_manager(&session, &turn_context).await; + let mut features = Features::with_defaults(); + features.enable(Feature::AuthElicitation); + let turn_context = Arc::get_mut(&mut turn_context).expect("single turn context ref"); + turn_context.features = ManagedFeatures::from(features); + turn_context + .approval_policy + .set(AskForApproval::Granular(GranularApprovalConfig { + sandbox_approval: true, + rules: true, + skill_approval: true, + request_permissions: true, + mcp_elicitations: false, + })) + .expect("test setup should allow updating approval policy"); + let result = codex_apps_auth_failure_result(); + let metadata = codex_apps_auth_failure_metadata(); + + let returned = maybe_request_codex_apps_auth_elicitation( + &session, + turn_context, + "call_123", + CODEX_APPS_MCP_SERVER_NAME, + Some(&metadata), + result.clone(), + ) + .await; + + assert_eq!(returned, result); + assert!(rx_event.try_recv().is_err()); +} + +#[tokio::test] +async fn codex_apps_auth_elicitation_feature_enabled_requests_elicitation() { + let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await; + install_host_owned_codex_apps_manager(&session, &turn_context).await; + *session.active_turn.lock().await = Some(ActiveTurn::default()); + let mut features = Features::with_defaults(); + features.enable(Feature::AuthElicitation); + Arc::get_mut(&mut turn_context) + .expect("single turn context ref") + .features = ManagedFeatures::from(features); + let result = codex_apps_auth_failure_result(); + let metadata = codex_apps_auth_failure_metadata(); + + let request_task = tokio::spawn({ + let session = Arc::clone(&session); + let turn_context = Arc::clone(&turn_context); + async move { + maybe_request_codex_apps_auth_elicitation( + &session, + &turn_context, + "call_123", + CODEX_APPS_MCP_SERVER_NAME, + Some(&metadata), + result, + ) + .await + } + }); + + let request = loop { + let event = tokio::time::timeout(std::time::Duration::from_secs(1), rx_event.recv()) + .await + .expect("elicitation event timed out") + .expect("expected elicitation event"); + if let EventMsg::ElicitationRequest(request) = event.msg { + break request; + } + }; + assert_eq!(request.server_name, CODEX_APPS_MCP_SERVER_NAME); + assert_eq!( + request.id, + codex_protocol::mcp::RequestId::String("codex_apps_auth_call_123".to_string()) + ); + assert!(matches!( + request.request, + codex_protocol::approvals::ElicitationRequest::Url { .. } + )); + + session + .resolve_elicitation( + CODEX_APPS_MCP_SERVER_NAME.to_string(), + rmcp::model::RequestId::String("codex_apps_auth_call_123".into()), + ElicitationResponse { + action: ElicitationAction::Accept, + content: None, + meta: None, + }, + ) + .await + .expect("elicitation should resolve"); + let returned = tokio::time::timeout(std::time::Duration::from_secs(1), request_task) + .await + .expect("auth elicitation task timed out") + .expect("auth elicitation task failed"); + assert_eq!( + returned.content, + vec![serde_json::json!({ + "type": "text", + "text": "Authentication for Google Calendar was requested and accepted. Retry this tool call now.", + })] + ); +} + #[test] fn mcp_tool_call_thread_id_meta_is_added_to_request_meta() { assert_eq!( diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index a40fd27942..1b0c75230c 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -13,6 +13,7 @@ use codex_protocol::user_input::UserInput; use tokio_util::sync::CancellationToken; use crate::config::Config; +use crate::resolve_installation_id; use crate::session::session::Session; use crate::session::turn::build_prompt; use crate::session::turn::built_tools; @@ -42,6 +43,7 @@ pub async fn build_prompt_input( .ok_or_else(|| std::io::Error::other("prompt debug requires state db"))?; let thread_store = thread_store_from_config(&config, state_db.clone()); let agent_graph_store = agent_graph_store_from_state_db(state_db.clone()); + let installation_id = resolve_installation_id(&config.codex_home).await?; let thread_manager = ThreadManager::new( &config, Arc::clone(&auth_manager), @@ -51,6 +53,7 @@ pub async fn build_prompt_input( state_db, thread_store, agent_graph_store, + installation_id, ); let thread = thread_manager.start_thread(config).await?; diff --git a/codex-rs/core/src/session/config_lock.rs b/codex-rs/core/src/session/config_lock.rs index f0a8d209d3..10f2242647 100644 --- a/codex-rs/core/src/session/config_lock.rs +++ b/codex-rs/core/src/session/config_lock.rs @@ -1,12 +1,19 @@ use anyhow::Context; use codex_config::config_toml::ConfigLockfileToml; use codex_config::config_toml::ConfigToml; +use codex_config::types::MemoriesToml; +use codex_features::AppsMcpPathOverrideConfigToml; +use codex_features::Feature; +use codex_features::FeatureToml; +use codex_features::FeaturesToml; +use codex_features::MultiAgentV2ConfigToml; use codex_protocol::ThreadId; -use crate::config::template_interpolation::materialized_config_toml; +use crate::config::Config; use crate::config_lock::ConfigLockReplayOptions; use crate::config_lock::clear_config_lock_debug_controls; use crate::config_lock::config_lockfile; +use crate::config_lock::toml_round_trip; use crate::config_lock::validate_config_lock_replay; use super::SessionConfiguration; @@ -74,12 +81,20 @@ fn session_configuration_to_lock_config_toml( sc: &SessionConfiguration, ) -> anyhow::Result { let config = sc.original_config_do_not_use.as_ref(); - let mut lock_config = materialized_config_toml(config)?; + // Start from the resolved layer stack, then patch in values that are only + // known after session setup. Export and replay validation both use this + // path, so every field here is part of the lockfile contract. + let mut lock_config: ConfigToml = config + .config_layer_stack + .effective_config() + .try_into() + .context("failed to deserialize effective config for config lock")?; if config.config_lock_save_fields_resolved_from_model_catalog { save_session_resolved_fields(sc, &mut lock_config); } + save_config_resolved_fields(config, &mut lock_config)?; drop_lockfile_inputs(&mut lock_config); Ok(lock_config) @@ -94,7 +109,10 @@ fn save_session_resolved_fields(sc: &SessionConfiguration, lock_config: &mut Con lock_config.model = Some(sc.collaboration_mode.model().to_string()); lock_config.model_reasoning_effort = sc.collaboration_mode.reasoning_effort(); lock_config.model_reasoning_summary = sc.model_reasoning_summary; - lock_config.service_tier = sc.service_tier; + lock_config.service_tier = sc + .service_tier + .as_deref() + .and_then(codex_protocol::config_types::ServiceTier::from_request_value); lock_config.instructions = Some(sc.base_instructions.clone()); lock_config.developer_instructions = sc.developer_instructions.clone(); lock_config.compact_prompt = sc.compact_prompt.clone(); @@ -103,6 +121,64 @@ fn save_session_resolved_fields(sc: &SessionConfiguration, lock_config: &mut Con lock_config.approvals_reviewer = Some(sc.approvals_reviewer); } +/// Saves values stored on `Config` after higher-level resolution, +/// normalization, defaulting, or feature materialization. +/// +/// Persist the resolved representation so replay compares against the behavior +/// Codex actually ran with, not only the user-authored TOML inputs. +fn save_config_resolved_fields( + config: &Config, + lock_config: &mut ConfigToml, +) -> anyhow::Result<()> { + lock_config.web_search = Some(config.web_search_mode.value()); + lock_config.model_provider = Some(config.model_provider_id.clone()); + lock_config.plan_mode_reasoning_effort = config.plan_mode_reasoning_effort; + lock_config.model_verbosity = config.model_verbosity; + lock_config.include_permissions_instructions = Some(config.include_permissions_instructions); + lock_config.include_apps_instructions = Some(config.include_apps_instructions); + lock_config.include_environment_context = Some(config.include_environment_context); + lock_config.background_terminal_max_timeout = Some(config.background_terminal_max_timeout); + + // Feature aliases and feature configs need to be written in their resolved + // form; otherwise replay can drift when a legacy key maps to the same + // runtime feature. + let features = lock_config + .features + .get_or_insert_with(FeaturesToml::default); + features.materialize_resolved_enabled(config.features.get()); + let mut multi_agent_v2: MultiAgentV2ConfigToml = + resolved_config_to_toml(&config.multi_agent_v2, "features.multi_agent_v2")?; + multi_agent_v2.enabled = Some(config.features.enabled(Feature::MultiAgentV2)); + features.multi_agent_v2 = Some(FeatureToml::Config(multi_agent_v2)); + features.apps_mcp_path_override = Some(FeatureToml::Config(AppsMcpPathOverrideConfigToml { + enabled: Some(config.features.enabled(Feature::AppsMcpPathOverride)), + path: config.apps_mcp_path_override.clone(), + })); + lock_config.memories = Some(resolved_config_to_toml::( + &config.memories, + "memories", + )?); + + let agents = lock_config.agents.get_or_insert_with(Default::default); + // Multi-agent v2 owns thread fanout through its feature config. Preserve + // the legacy agents.max_threads setting only when v2 is disabled. + agents.max_threads = if config.features.enabled(Feature::MultiAgentV2) { + None + } else { + config.agent_max_threads + }; + agents.max_depth = Some(config.agent_max_depth); + agents.job_max_runtime_seconds = config.agent_job_max_runtime_seconds; + agents.interrupt_message = Some(config.agent_interrupt_message_enabled); + + lock_config + .skills + .get_or_insert_with(Default::default) + .include_instructions = Some(config.include_skill_instructions); + + Ok(()) +} + fn drop_lockfile_inputs(lock_config: &mut ConfigToml) { // The lockfile should contain replayable values, not the profile, // debug-control, file-include, and environment-specific inputs that @@ -122,11 +198,19 @@ fn drop_lockfile_inputs(lock_config: &mut ConfigToml) { lock_config.experimental_use_freeform_apply_patch = None; } +fn resolved_config_to_toml( + value: &impl serde::Serialize, + label: &'static str, +) -> anyhow::Result +where + Toml: serde::de::DeserializeOwned + serde::Serialize, +{ + toml_round_trip(value, label).map_err(anyhow::Error::from) +} + #[cfg(test)] mod tests { use super::*; - use codex_features::FeatureToml; - use codex_features::MultiAgentV2ConfigToml; use pretty_assertions::assert_eq; use std::sync::Arc; @@ -199,7 +283,7 @@ mod tests { sc.base_instructions = "catalog instructions".to_string(); sc.developer_instructions = Some("catalog developer instructions".to_string()); sc.compact_prompt = Some("catalog compact prompt".to_string()); - sc.service_tier = Some(codex_protocol::config_types::ServiceTier::Flex); + sc.service_tier = Some("flex".to_string()); let lockfile = sc.to_config_lockfile_toml().expect("lock should serialize"); let lock = &lockfile.config; diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index d2ba747aff..1dc68a69f3 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -24,8 +24,6 @@ use crate::tasks::CompactTask; use crate::tasks::UserShellCommandMode; use crate::tasks::UserShellCommandTask; use crate::tasks::execute_user_shell_command; -use codex_mcp::collect_mcp_snapshot_from_manager; -use codex_mcp::compute_auth_statuses; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseInputItem; use codex_protocol::protocol::CodexErrorInfo; @@ -460,53 +458,6 @@ pub async fn dynamic_tool_response(sess: &Arc, id: String, response: Dy sess.notify_dynamic_tool_response(&id, response).await; } -pub async fn add_to_history(sess: &Arc, config: &Arc, text: String) { - let id = sess.conversation_id; - let config = Arc::clone(config); - tokio::spawn(async move { - if let Err(e) = crate::message_history::append_entry(&text, &id, &config).await { - warn!("failed to append to message history: {e}"); - } - }); -} - -pub async fn get_history_entry_request( - sess: &Arc, - config: &Arc, - sub_id: String, - offset: usize, - log_id: u64, -) { - let config = Arc::clone(config); - let sess_clone = Arc::clone(sess); - - tokio::spawn(async move { - // Run lookup in blocking thread because it does file IO + locking. - let entry_opt = tokio::task::spawn_blocking(move || { - crate::message_history::lookup(log_id, offset, &config) - }) - .await - .unwrap_or(None); - - let event = Event { - id: sub_id, - msg: EventMsg::GetHistoryEntryResponse( - codex_protocol::protocol::GetHistoryEntryResponseEvent { - offset, - log_id, - entry: entry_opt.map(|e| codex_protocol::message_history::HistoryEntry { - conversation_id: e.session_id, - ts: e.ts, - text: e.text, - }), - }, - ), - }; - - sess_clone.send_event_raw(event).await; - }); -} - pub async fn refresh_mcp_servers(sess: &Arc, refresh_config: McpServerRefreshConfig) { let mut guard = sess.pending_mcp_server_refresh_config.lock().await; *guard = Some(refresh_config); @@ -516,35 +467,6 @@ pub async fn reload_user_config(sess: &Arc) { sess.reload_user_config_layer().await; } -#[expect( - clippy::await_holding_invalid_type, - reason = "MCP tool listing reads through the session-owned manager guard" -)] -pub async fn list_mcp_tools(sess: &Session, config: &Arc, sub_id: String) { - let mcp_connection_manager = sess.services.mcp_connection_manager.read().await; - let auth = sess.services.auth_manager.auth().await; - let mcp_servers = sess - .services - .mcp_manager - .effective_servers(config, auth.as_ref()) - .await; - let snapshot = collect_mcp_snapshot_from_manager( - &mcp_connection_manager, - compute_auth_statuses( - mcp_servers.iter(), - config.mcp_oauth_credentials_store_mode, - auth.as_ref(), - ) - .await, - ) - .await; - let event = Event { - id: sub_id, - msg: EventMsg::McpListToolsResponse(snapshot), - }; - sess.send_event_raw(event).await; -} - pub async fn compact(sess: &Arc, sub_id: String) { let turn_context = sess.new_default_turn_with_sub_id(sub_id).await; @@ -910,18 +832,6 @@ pub(super) async fn submission_loop( dynamic_tool_response(&sess, id, response).await; false } - Op::AddToHistory { text } => { - add_to_history(&sess, &config, text).await; - false - } - Op::GetHistoryEntryRequest { offset, log_id } => { - get_history_entry_request(&sess, &config, sub.id.clone(), offset, log_id).await; - false - } - Op::ListMcpTools => { - list_mcp_tools(&sess, &config, sub.id.clone()).await; - false - } Op::RefreshMcpServers { config } => { refresh_mcp_servers(&sess, config).await; false diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index 59bb4f189a..38ade77ca3 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -11,6 +11,20 @@ impl Session { request_id: RequestId, params: McpServerElicitationRequestParams, ) -> Option { + if self + .services + .mcp_connection_manager + .read() + .await + .elicitations_auto_deny() + { + return Some(ElicitationResponse { + action: codex_rmcp_client::ElicitationAction::Accept, + content: Some(serde_json::json!({})), + meta: None, + }); + } + let server_name = params.server_name.clone(); let request = match params.request { McpServerElicitationRequest::Form { @@ -219,6 +233,8 @@ impl Session { .tool_plugin_provenance(config.as_ref()) .await; let mcp_servers = with_codex_apps_mcp(mcp_servers, auth.as_ref(), &mcp_config); + let host_owned_codex_apps_enabled = + host_owned_codex_apps_enabled(&mcp_config, auth.as_ref()); let auth_statuses = compute_auth_statuses(mcp_servers.iter(), store_mode, auth.as_ref()).await; let mcp_runtime_environment = match turn_context.environments.primary() { @@ -250,6 +266,7 @@ impl Session { mcp_runtime_environment, config.codex_home.to_path_buf(), codex_apps_tools_cache_key(auth.as_ref()), + host_owned_codex_apps_enabled, tool_plugin_provenance, auth.as_ref(), ) diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 948678067e..bacd1708eb 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -32,7 +32,6 @@ use crate::context::PersonalitySpecInstructions; use crate::default_skill_metadata_budget; use crate::environment_selection::ResolvedTurnEnvironments; use crate::exec_policy::ExecPolicyManager; -use crate::installation_id::resolve_installation_id; use crate::parse_turn_item; use crate::path_utils::normalize_for_native_workdir; use crate::realtime_conversation::RealtimeConversationManager; @@ -307,6 +306,7 @@ use crate::windows_sandbox::WindowsSandboxLevelExt; use codex_core_plugins::PluginsManager; use codex_git_utils::get_git_repo_root; use codex_mcp::compute_auth_statuses; +use codex_mcp::host_owned_codex_apps_enabled; use codex_mcp::with_codex_apps_mcp; use codex_otel::SessionTelemetry; use codex_otel::THREAD_STARTED_METRIC; @@ -383,6 +383,7 @@ pub struct CodexSpawnOk { pub(crate) struct CodexSpawnArgs { pub(crate) config: Config, + pub(crate) installation_id: String, pub(crate) auth_manager: Arc, pub(crate) models_manager: SharedModelsManager, pub(crate) environment_manager: Arc, @@ -446,6 +447,7 @@ impl Codex { async fn spawn_internal(args: CodexSpawnArgs) -> CodexResult { let CodexSpawnArgs { mut config, + installation_id, auth_manager, models_manager, environment_manager, @@ -586,7 +588,7 @@ impl Codex { .auth_cached() .and_then(|auth| auth.account_plan_type()); let service_tier = get_service_tier( - config.service_tier, + config.service_tier.clone(), config.notices.fast_default_opt_out.unwrap_or(false), account_plan_type, config.features.enabled(Feature::FastMode), @@ -629,6 +631,7 @@ impl Codex { let session = Session::new( session_configuration, config.clone(), + installation_id, auth_manager.clone(), models_manager.clone(), exec_policy, @@ -784,18 +787,18 @@ impl Codex { } fn get_service_tier( - configured_service_tier: Option, + configured_service_tier: Option, fast_default_opt_out: bool, account_plan_type: Option, fast_mode_enabled: bool, -) -> Option { +) -> Option { if configured_service_tier.is_some() || fast_default_opt_out || !fast_mode_enabled { return configured_service_tier; } account_plan_type .is_some_and(is_enterprise_default_service_tier_plan) - .then_some(ServiceTier::Fast) + .then_some(ServiceTier::Fast.request_value().to_string()) } fn is_enterprise_default_service_tier_plan(plan_type: AccountPlanType) -> bool { diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index bd50d74bb6..7d4b1b736a 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -102,7 +102,8 @@ pub(super) async fn spawn_review_thread( let per_turn_config = Arc::new(per_turn_config); let review_turn_id = sub_id.to_string(); let turn_metadata_state = Arc::new(TurnMetadataState::new( - sess.conversation_id.to_string(), + sess.session_id().to_string(), + sess.thread_id().to_string(), parent_turn_context.thread_source, review_turn_id.clone(), parent_turn_context.cwd.clone(), diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index d593544b49..6ed0c3fc1a 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1,5 +1,7 @@ use super::*; use crate::goals::GoalRuntimeState; +use codex_protocol::SessionId; +use codex_protocol::config_types::ServiceTier; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::protocol::ThreadSource; @@ -11,6 +13,7 @@ use tokio::sync::Semaphore; /// A session has at most 1 running task at a time, and can be interrupted by user input. pub(crate) struct Session { pub(crate) conversation_id: ThreadId, + pub(crate) installation_id: String, pub(super) tx_event: Sender, pub(super) agent_status: watch::Sender, pub(super) out_of_band_elicitation_paused: watch::Sender, @@ -40,7 +43,7 @@ pub(crate) struct SessionConfiguration { pub(super) collaboration_mode: CollaborationMode, pub(super) model_reasoning_summary: Option, - pub(super) service_tier: Option, + pub(super) service_tier: Option, /// Developer instructions that supplement the base instructions. pub(super) developer_instructions: Option, @@ -134,7 +137,7 @@ impl SessionConfiguration { ThreadConfigSnapshot { model: self.collaboration_mode.model().to_string(), model_provider_id: self.original_config_do_not_use.model_provider_id.clone(), - service_tier: self.service_tier, + service_tier: self.service_tier.clone(), approval_policy: self.approval_policy.value(), approvals_reviewer: self.approvals_reviewer, permission_profile: self.permission_profile(), @@ -180,8 +183,15 @@ impl SessionConfiguration { if let Some(summary) = updates.reasoning_summary { next_configuration.model_reasoning_summary = Some(summary); } - if let Some(service_tier) = updates.service_tier { - next_configuration.service_tier = service_tier; + if let Some(service_tier) = updates.service_tier.clone() { + // TODO(aibrahim): Remove once v2 clients no longer send the legacy + // "fast" service tier value. + next_configuration.service_tier = service_tier.map(|service_tier| { + ServiceTier::from_request_value(&service_tier) + .map_or(service_tier, |service_tier| { + service_tier.request_value().to_string() + }) + }); } if let Some(personality) = updates.personality { next_configuration.personality = Some(personality); @@ -307,7 +317,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) windows_sandbox_level: Option, pub(crate) collaboration_mode: Option, pub(crate) reasoning_summary: Option, - pub(crate) service_tier: Option>, + pub(crate) service_tier: Option>, pub(crate) final_output_json_schema: Option>, /// Turn-local environment override. `None` inherits the sticky thread /// environments stored on `SessionConfiguration`; `Some([])` explicitly @@ -324,6 +334,16 @@ pub(crate) struct AppServerClientMetadata { } impl Session { + /// Returns the concrete identity for this thread. + pub(crate) fn thread_id(&self) -> ThreadId { + self.conversation_id + } + + /// Returns the identity shared by the root thread and all descendant threads. + pub(crate) fn session_id(&self) -> SessionId { + self.services.agent_control.session_id() + } + #[instrument(name = "session_init", level = "info", skip_all)] #[allow(clippy::too_many_arguments)] #[expect( @@ -333,6 +353,7 @@ impl Session { pub(crate) async fn new( mut session_configuration: SessionConfiguration, config: Arc, + installation_id: String, auth_manager: Arc, models_manager: SharedModelsManager, exec_policy: Arc, @@ -363,7 +384,7 @@ impl Session { } else { ThreadEventPersistenceMode::Limited }; - let conversation_id = match &initial_history { + let thread_id = match &initial_history { InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => { ThreadId::default() } @@ -394,7 +415,7 @@ impl Session { LiveThread::create( Arc::clone(&thread_store), CreateThreadParams { - thread_id: conversation_id, + thread_id, forked_from_id, source: session_source, thread_source: session_configuration.thread_source, @@ -449,19 +470,6 @@ impl Session { )); let state_db_ctx = if config.ephemeral { None } else { state_db }; - let is_subagent = session_configuration.session_source.is_non_root_agent(); - let history_meta_fut = async { - if is_subagent { - (0, 0) - } else { - crate::message_history::history_metadata(&config).await - } - } - .instrument(info_span!( - "session_init.history_metadata", - otel.name = "session_init.history_metadata", - session_init.is_subagent = is_subagent, - )); let auth_manager_clone = Arc::clone(&auth_manager); let config_for_mcp = Arc::clone(&config); let mcp_manager_for_mcp = Arc::clone(&mcp_manager); @@ -484,11 +492,8 @@ impl Session { )); // Join all independent futures. - let ( - thread_persistence_result, - (history_log_id, history_entry_count), - (auth, mcp_servers, auth_statuses), - ) = tokio::join!(thread_persistence_fut, history_meta_fut, auth_and_mcp_fut); + let (thread_persistence_result, (auth, mcp_servers, auth_statuses)) = + tokio::join!(thread_persistence_fut, auth_and_mcp_fut); let mut live_thread_init = LiveThreadInitGuard::new(thread_persistence_result.map_err(|e| { @@ -508,7 +513,7 @@ impl Session { let trace_task_name = (!trace_agent_path.is_root()).then(|| trace_agent_path.name().to_string()); let trace_metadata = ThreadStartedTraceMetadata { - thread_id: conversation_id.to_string(), + thread_id: thread_id.to_string(), agent_path: trace_agent_path.to_string(), task_name: trace_task_name, nickname: session_configuration.session_source.get_nickname(), @@ -599,7 +604,7 @@ impl Session { auth_manager.codex_api_key_env_enabled(), ); let mut session_telemetry = SessionTelemetry::new( - conversation_id, + thread_id, session_model.as_str(), session_model.as_str(), account_id.clone(), @@ -615,7 +620,7 @@ impl Session { session_telemetry = session_telemetry.with_metrics_service_name(service_name); } let network_proxy_audit_metadata = NetworkProxyAuditMetadata { - conversation_id: Some(conversation_id.to_string()), + conversation_id: Some(thread_id.to_string()), app_version: Some(env!("CARGO_PKG_VERSION").to_string()), user_account_id: account_id, auth_mode: auth_mode.map(|mode| mode.to_string()), @@ -685,7 +690,7 @@ impl Session { } else { ShellSnapshot::start_snapshotting( config.codex_home.clone(), - conversation_id, + thread_id, session_configuration.cwd.clone(), &mut default_shell, session_telemetry.clone(), @@ -698,7 +703,7 @@ impl Session { tx }; let thread_name = - thread_title_from_state_db(state_db_ctx.as_ref(), &config.codex_home, conversation_id) + thread_title_from_state_db(state_db_ctx.as_ref(), &config.codex_home, thread_id) .instrument(info_span!( "session_init.thread_name_lookup", otel.name = "session_init.thread_name_lookup", @@ -706,7 +711,7 @@ impl Session { .await; session_configuration.thread_name = thread_name.clone(); validate_config_lock_if_configured(&session_configuration).await?; - export_config_lock_if_configured(&session_configuration, conversation_id).await?; + export_config_lock_if_configured(&session_configuration, thread_id).await?; let state = SessionState::new(session_configuration.clone()); let managed_network_requirements_configured = config .config_layer_stack @@ -778,7 +783,6 @@ impl Session { }); } - let installation_id = resolve_installation_id(&config.codex_home).await?; let analytics_events_client = analytics_events_client.unwrap_or_else(|| { AnalyticsEventsClient::new( Arc::clone(&auth_manager), @@ -786,6 +790,12 @@ impl Session { config.analytics_enabled, ) }); + let session_id = if session_configuration.session_source.is_non_root_agent() { + agent_control.session_id() + } else { + SessionId::from(thread_id) + }; + let agent_control = agent_control.with_session_id(session_id); let services = SessionServices { // Initialize the MCP connection manager with an uninitialized // instance. It will be replaced with one created via @@ -830,8 +840,9 @@ impl Session { thread_store: Arc::clone(&thread_store), model_client: ModelClient::new( Some(Arc::clone(&auth_manager)), - conversation_id, - installation_id, + session_id, + thread_id, + installation_id.clone(), session_configuration.provider.clone(), session_configuration.session_source.clone(), config.model_verbosity, @@ -850,7 +861,8 @@ impl Session { let (mailbox, mailbox_rx) = Mailbox::new(); let sess = Arc::new(Session { - conversation_id, + conversation_id: thread_id, + installation_id, tx_event: tx_event.clone(), agent_status, out_of_band_elicitation_paused, @@ -878,21 +890,20 @@ impl Session { let events = std::iter::once(Event { id: INITIAL_SUBMIT_ID.to_owned(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id: conversation_id, + session_id, + thread_id, forked_from_id, thread_source: session_configuration.thread_source, thread_name: session_configuration.thread_name.clone(), model: session_configuration.collaboration_mode.model().to_string(), model_provider_id: config.model_provider_id.clone(), - service_tier: session_configuration.service_tier, + service_tier: session_configuration.service_tier.clone(), approval_policy: session_configuration.approval_policy.value(), approvals_reviewer: session_configuration.approvals_reviewer, permission_profile: session_configuration.permission_profile(), active_permission_profile: session_configuration.active_permission_profile(), cwd: session_configuration.cwd.clone(), reasoning_effort: session_configuration.collaboration_mode.reasoning_effort(), - history_log_id, - history_entry_count, initial_messages, network_proxy: session_network_proxy.filter(|_| { Self::managed_network_proxy_active_for_permission_profile( @@ -918,6 +929,9 @@ impl Session { let enabled_mcp_server_count = mcp_servers.values().filter(|server| server.enabled).count(); let required_mcp_server_count = required_mcp_servers.len(); let tool_plugin_provenance = mcp_manager.tool_plugin_provenance(config.as_ref()).await; + let host_owned_codex_apps_enabled = config + .features + .apps_enabled_for_auth(auth.as_ref().is_some_and(|auth| auth.uses_codex_backend())); { let mut cancel_guard = sess.services.mcp_startup_cancellation_token.lock().await; cancel_guard.cancel(); @@ -959,6 +973,7 @@ impl Session { mcp_runtime_environment, config.codex_home.to_path_buf(), codex_apps_tools_cache_key(auth), + host_owned_codex_apps_enabled, tool_plugin_provenance, auth, ) diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 3875f8fb57..a5c5ec0ca4 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -30,6 +30,7 @@ use codex_models_manager::model_info; use codex_models_manager::test_support::construct_model_info_offline_for_tests; use codex_models_manager::test_support::get_model_offline_for_tests; use codex_protocol::AgentPath; +use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::config_types::ServiceTier; @@ -74,6 +75,7 @@ use crate::tools::registry::ToolHandler; use crate::tools::router::ToolCallSource; use crate::turn_diff_tracker::TurnDiffTracker; use codex_app_server_protocol::AppInfo; +use codex_app_server_protocol::McpElicitationSchema; use codex_config::config_toml::ConfigToml; use codex_config::config_toml::ProjectConfig; use codex_execpolicy::Decision; @@ -124,6 +126,7 @@ use codex_protocol::protocol::UserMessageEvent; use codex_protocol::protocol::W3cTraceContext; use codex_protocol::request_user_input::RequestUserInputAnswer; use codex_protocol::request_user_input::RequestUserInputResponse; +use codex_rmcp_client::ElicitationAction; use core_test_support::PathBufExt; use core_test_support::PathExt; use core_test_support::context_snapshot; @@ -293,6 +296,49 @@ async fn regular_turn_emits_turn_started_without_waiting_for_startup_prewarm() { sess.abort_all_tasks(TurnAbortReason::Interrupted).await; } +#[tokio::test] +async fn request_mcp_server_elicitation_auto_accepts_when_auto_deny_is_enabled() { + let (session, turn_context, rx) = make_session_and_context_with_rx().await; + session + .services + .mcp_connection_manager + .read() + .await + .set_elicitations_auto_deny(/*auto_deny*/ true); + + let requested_schema: McpElicitationSchema = serde_json::from_value(json!({ + "type": "object", + "properties": {}, + })) + .expect("schema should deserialize"); + let response = session + .request_mcp_server_elicitation( + turn_context.as_ref(), + RequestId::String("request-1".into()), + McpServerElicitationRequestParams { + thread_id: session.conversation_id.to_string(), + turn_id: Some(turn_context.sub_id.clone()), + server_name: "codex_apps".to_string(), + request: McpServerElicitationRequest::Form { + meta: None, + message: "Allow this request?".to_string(), + requested_schema, + }, + }, + ) + .await; + + assert_eq!( + response, + Some(ElicitationResponse { + action: ElicitationAction::Accept, + content: Some(json!({})), + meta: None, + }) + ); + assert!(rx.try_recv().is_err()); +} + #[tokio::test] async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted() { let (sess, tc, rx) = make_session_and_context_with_rx().await; @@ -348,10 +394,12 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted } fn test_model_client_session() -> crate::client::ModelClientSession { + let thread_id = ThreadId::try_from("00000000-0000-4000-8000-000000000001") + .expect("test thread id should be valid"); crate::client::ModelClient::new( /*auth_manager*/ None, - ThreadId::try_from("00000000-0000-4000-8000-000000000001") - .expect("test thread id should be valid"), + thread_id.into(), + thread_id, /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), ModelProviderInfo::create_openai_provider(/* base_url */ /*base_url*/ None), codex_protocol::protocol::SessionSource::Exec, @@ -2843,7 +2891,7 @@ fn get_service_tier_defaults_enterprise_accounts_to_fast() { Some(AccountPlanType::Enterprise), /*fast_mode_enabled*/ true, ), - Some(ServiceTier::Fast) + Some(ServiceTier::Fast.request_value().to_string()) ); assert_eq!( get_service_tier( @@ -2852,7 +2900,7 @@ fn get_service_tier_defaults_enterprise_accounts_to_fast() { Some(AccountPlanType::EnterpriseCbpUsageBased), /*fast_mode_enabled*/ true, ), - Some(ServiceTier::Fast) + Some(ServiceTier::Fast.request_value().to_string()) ); assert_eq!( get_service_tier( @@ -2861,7 +2909,7 @@ fn get_service_tier_defaults_enterprise_accounts_to_fast() { Some(AccountPlanType::Business), /*fast_mode_enabled*/ true, ), - Some(ServiceTier::Fast) + Some(ServiceTier::Fast.request_value().to_string()) ); assert_eq!( get_service_tier( @@ -2870,7 +2918,7 @@ fn get_service_tier_defaults_enterprise_accounts_to_fast() { Some(AccountPlanType::Team), /*fast_mode_enabled*/ true, ), - Some(ServiceTier::Fast) + Some(ServiceTier::Fast.request_value().to_string()) ); assert_eq!( get_service_tier( @@ -2879,7 +2927,7 @@ fn get_service_tier_defaults_enterprise_accounts_to_fast() { Some(AccountPlanType::SelfServeBusinessUsageBased), /*fast_mode_enabled*/ true, ), - Some(ServiceTier::Fast) + Some(ServiceTier::Fast.request_value().to_string()) ); } @@ -2932,6 +2980,23 @@ async fn session_settings_null_service_tier_update_clears_service_tier() { assert_eq!(updated.service_tier, None); } +#[tokio::test] +async fn session_settings_legacy_fast_service_tier_update_uses_priority_request_value() { + let session_configuration = make_session_configuration_for_tests().await; + + let updated = session_configuration + .apply(&SessionSettingsUpdate { + service_tier: Some(Some("fast".to_string())), + ..Default::default() + }) + .expect("legacy fast service tier update should apply"); + + assert_eq!( + updated.service_tier, + Some(ServiceTier::Fast.request_value().to_string()) + ); +} + pub(crate) async fn make_session_configuration_for_tests() -> SessionConfiguration { let codex_home = tempfile::tempdir().expect("create temp dir"); let config = build_test_config(codex_home.path()).await; @@ -3518,6 +3583,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { let result = Session::new( session_configuration, Arc::clone(&config), + "11111111-1111-4111-8111-111111111111".to_string(), auth_manager, models_manager, Arc::new(ExecPolicyManager::default()), @@ -3560,7 +3626,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { let codex_home = tempfile::tempdir().expect("create temp dir"); let config = build_test_config(codex_home.path()).await; let config = Arc::new(config); - let conversation_id = ThreadId::default(); + let thread_id = ThreadId::default(); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let models_manager = models_manager_with_provider( config.codex_home.to_path_buf(), @@ -3626,7 +3692,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { &per_turn_config.to_models_manager_config(), ); let session_telemetry = session_telemetry( - conversation_id, + thread_id, config.as_ref(), &model_info, session_configuration.session_source.clone(), @@ -3698,7 +3764,8 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { )), model_client: ModelClient::new( Some(auth_manager.clone()), - conversation_id, + thread_id.into(), + thread_id, /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), session_configuration.provider.clone(), session_configuration.session_source.clone(), @@ -3727,7 +3794,8 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { ); let turn_environments = turn_environments_for_tests(&environment, &session_configuration.cwd); let turn_context = Session::make_turn_context( - conversation_id, + thread_id, + SessionId::from(thread_id), Some(Arc::clone(&auth_manager)), &session_telemetry, session_configuration.provider.clone(), @@ -3748,7 +3816,8 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { let (mailbox, mailbox_rx) = crate::agent::Mailbox::new(); let session = Session { - conversation_id, + conversation_id: thread_id, + installation_id: "11111111-1111-4111-8111-111111111111".to_string(), tx_event, agent_status: agent_status_tx, out_of_band_elicitation_paused: watch::channel(false).0, @@ -3855,6 +3924,7 @@ async fn make_session_with_config_and_rx( let session = Session::new( session_configuration, Arc::clone(&config), + "11111111-1111-4111-8111-111111111111".to_string(), auth_manager, models_manager, Arc::new(ExecPolicyManager::default()), @@ -3886,6 +3956,175 @@ async fn make_session_with_config_and_rx( Ok((session, rx_event, codex_home)) } +async fn make_session_with_history_source_and_agent_control_and_rx( + initial_history: InitialHistory, + session_source: SessionSource, + agent_control: AgentControl, +) -> anyhow::Result<(Arc, async_channel::Receiver)> { + let codex_home = tempfile::tempdir().expect("create temp dir"); + let mut config = build_test_config(codex_home.path()).await; + config.ephemeral = true; + let config = Arc::new(config); + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let models_manager = models_manager_with_provider( + config.codex_home.to_path_buf(), + auth_manager.clone(), + config.model_provider.clone(), + ); + let model = get_model_offline_for_tests(config.model.as_deref()); + let model_info = + construct_model_info_offline_for_tests(model.as_str(), &config.to_models_manager_config()); + let collaboration_mode = CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model, + reasoning_effort: config.model_reasoning_effort, + developer_instructions: None, + }, + }; + let default_environments = vec![TurnEnvironmentSelection { + environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), + cwd: config.cwd.clone(), + }]; + let session_configuration = SessionConfiguration { + provider: config.model_provider.clone(), + collaboration_mode, + model_reasoning_summary: config.model_reasoning_summary, + developer_instructions: config.developer_instructions.clone(), + user_instructions: config.user_instructions.clone(), + service_tier: None, + personality: config.personality, + base_instructions: config + .base_instructions + .clone() + .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), + compact_prompt: config.compact_prompt.clone(), + approval_policy: config.permissions.approval_policy.clone(), + approvals_reviewer: config.approvals_reviewer, + permission_profile: config.permissions.permission_profile.clone(), + active_permission_profile: config.permissions.active_permission_profile(), + windows_sandbox_level: WindowsSandboxLevel::from_config(&config), + cwd: config.cwd.clone(), + codex_home: config.codex_home.clone(), + thread_name: None, + environments: default_environments, + original_config_do_not_use: Arc::clone(&config), + metrics_service_name: None, + app_server_client_name: None, + app_server_client_version: None, + session_source: session_source.clone(), + thread_source: None, + dynamic_tools: Vec::new(), + persist_extended_history: false, + inherited_shell_snapshot: None, + user_shell_override: None, + }; + + let (tx_event, rx_event) = async_channel::unbounded(); + let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit); + let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.to_path_buf())); + let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); + let skills_manager = Arc::new(SkillsManager::new( + config.codex_home.clone(), + /*bundled_skills_enabled*/ true, + )); + + let session = Session::new( + session_configuration, + Arc::clone(&config), + "11111111-1111-4111-8111-111111111111".to_string(), + auth_manager, + models_manager, + Arc::new(ExecPolicyManager::default()), + tx_event, + agent_status_tx, + initial_history, + session_source, + skills_manager, + plugins_manager, + mcp_manager, + Arc::new(SkillsWatcher::noop()), + agent_control, + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + /*analytics_events_client*/ None, + /*state_db*/ None, + Arc::new(codex_thread_store::LocalThreadStore::new( + codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()), + codex_state::StateRuntime::init( + config.sqlite_home.clone(), + config.model_provider_id.clone(), + ) + .await + .expect("state db should initialize"), + )), + codex_rollout_trace::ThreadTraceContext::disabled(), + ) + .await?; + + Ok((session, rx_event)) +} + +#[tokio::test] +async fn resumed_root_session_uses_thread_id_as_session_id() { + let thread_id = ThreadId::new(); + let (session, rx_event) = make_session_with_history_source_and_agent_control_and_rx( + InitialHistory::Resumed(ResumedHistory { + conversation_id: thread_id, + history: Vec::new(), + rollout_path: None, + }), + SessionSource::Exec, + AgentControl::default(), + ) + .await + .expect("resume should succeed"); + + assert_eq!(session.thread_id(), thread_id); + assert_eq!(session.session_id(), SessionId::from(thread_id)); + + let event = rx_event.recv().await.expect("session configured event"); + let EventMsg::SessionConfigured(event) = event.msg else { + panic!("expected session configured event"); + }; + assert_eq!(event.session_id, SessionId::from(thread_id)); + assert_eq!(event.thread_id, thread_id); +} + +#[tokio::test] +async fn resumed_subagent_session_keeps_inherited_session_id() { + let parent_thread_id = ThreadId::new(); + let parent_session_id = SessionId::from(parent_thread_id); + let thread_id = ThreadId::new(); + let session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + }); + let (session, rx_event) = make_session_with_history_source_and_agent_control_and_rx( + InitialHistory::Resumed(ResumedHistory { + conversation_id: thread_id, + history: Vec::new(), + rollout_path: None, + }), + session_source, + AgentControl::default().with_session_id(parent_session_id), + ) + .await + .expect("resume should succeed"); + + assert_eq!(session.thread_id(), thread_id); + assert_eq!(session.session_id(), parent_session_id); + + let event = rx_event.recv().await.expect("session configured event"); + let EventMsg::SessionConfigured(event) = event.msg else { + panic!("expected session configured event"); + }; + assert_eq!(event.session_id, parent_session_id); + assert_eq!(event.thread_id, thread_id); +} + #[tokio::test] async fn notify_request_permissions_response_ignores_unmatched_call_id() { let (session, _turn_context) = make_session_and_context().await; @@ -5072,7 +5311,7 @@ where let mut config = build_test_config(codex_home.as_path()).await; configure_config(&mut config); let config = Arc::new(config); - let conversation_id = ThreadId::default(); + let thread_id = ThreadId::default(); let auth_manager = AuthManager::from_auth_for_testing(auth); let models_manager = models_manager_with_provider( config.codex_home.to_path_buf(), @@ -5138,7 +5377,7 @@ where &per_turn_config.to_models_manager_config(), ); let session_telemetry = session_telemetry( - conversation_id, + thread_id, config.as_ref(), &model_info, session_configuration.session_source.clone(), @@ -5211,7 +5450,8 @@ where )), model_client: ModelClient::new( Some(Arc::clone(&auth_manager)), - conversation_id, + thread_id.into(), + thread_id, /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), session_configuration.provider.clone(), session_configuration.session_source.clone(), @@ -5240,7 +5480,8 @@ where ); let turn_environments = turn_environments_for_tests(&environment, &session_configuration.cwd); let turn_context = Arc::new(Session::make_turn_context( - conversation_id, + thread_id, + SessionId::from(thread_id), Some(Arc::clone(&auth_manager)), &session_telemetry, session_configuration.provider.clone(), @@ -5261,7 +5502,8 @@ where let (mailbox, mailbox_rx) = crate::agent::Mailbox::new(); let session = Arc::new(Session { - conversation_id, + conversation_id: thread_id, + installation_id: "11111111-1111-4111-8111-111111111111".to_string(), tx_event, agent_status: agent_status_tx, out_of_band_elicitation_paused: watch::channel(false).0, @@ -7722,7 +7964,7 @@ async fn completed_goal_accounts_current_turn_tokens_before_tool_response() -> a ) .await?; let persisted_goal = state_db - .get_thread_goal(test.session_configured.session_id) + .get_thread_goal(test.session_configured.thread_id) .await? .expect("goal should be persisted"); assert_eq!( diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 8f8577e7fe..1026468627 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -741,6 +741,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs { config, + installation_id: "11111111-1111-4111-8111-111111111111".to_string(), auth_manager, models_manager, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index d74ddd5fe9..7aa64aae57 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -73,6 +73,7 @@ use codex_hooks::HookEventAfterAgent; use codex_hooks::HookPayload; use codex_hooks::HookResult; use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::ServiceTier; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::items::PlanItem; @@ -693,7 +694,11 @@ async fn track_turn_resolved_config_analytics( permission_profile_cwd: turn_context.cwd.to_path_buf(), reasoning_effort: turn_context.reasoning_effort, reasoning_summary: Some(turn_context.reasoning_summary), - service_tier: turn_context.config.service_tier, + service_tier: turn_context + .config + .service_tier + .as_deref() + .and_then(ServiceTier::from_request_value), approval_policy: turn_context.approval_policy.value(), approvals_reviewer: turn_context.config.approvals_reviewer, sandbox_network_access: turn_context.network_sandbox_policy().is_enabled(), @@ -1504,8 +1509,6 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option { | EventMsg::DeprecationNotice(_) | EventMsg::StreamError(_) | EventMsg::TurnDiff(_) - | EventMsg::GetHistoryEntryResponse(_) - | EventMsg::McpListToolsResponse(_) | EventMsg::RealtimeConversationListVoicesResponse(_) | EventMsg::SkillsUpdateAvailable | EventMsg::PlanUpdate(_) @@ -1858,7 +1861,7 @@ async fn try_run_sampling_request( &turn_context.session_telemetry, turn_context.reasoning_effort, turn_context.reasoning_summary, - turn_context.config.service_tier, + turn_context.config.service_tier.clone(), turn_metadata_header, &inference_trace, ) @@ -1880,6 +1883,7 @@ async fn try_run_sampling_request( let mut assistant_message_stream_parsers = AssistantMessageStreamParsers::new(plan_mode); let mut plan_mode_state = plan_mode.then(|| PlanModeStreamState::new(&turn_context.sub_id)); let receiving_span = trace_span!("receiving_stream"); + let mut completed_response_id: Option = None; let outcome: CodexResult = loop { let handle_responses = trace_span!( parent: &receiving_span, @@ -2107,7 +2111,7 @@ async fn try_run_sampling_request( sess.services.models_manager.refresh_if_new_etag(etag).await; } ResponseEvent::Completed { - response_id: _, + response_id, token_usage, end_turn, } => { @@ -2124,6 +2128,7 @@ async fn try_run_sampling_request( if let Some(false) = end_turn { needs_follow_up = true; } + completed_response_id = Some(response_id); break Ok(SamplingRequestResult { needs_follow_up, last_agent_message, @@ -2235,6 +2240,15 @@ async fn try_run_sampling_request( ) .await; + if sess + .features + .enabled(Feature::ResponsesWebsocketResponseProcessed) + && outcome.is_ok() + && let Some(response_id) = completed_response_id.as_deref() + { + client_session.send_response_processed(response_id).await; + } + drain_in_flight(&mut in_flight, sess.clone(), turn_context.clone()).await?; if cancellation_token.is_cancelled() { diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index e42fc2b102..6d8443e933 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -4,6 +4,7 @@ use crate::config::GhostSnapshotConfig; use crate::environment_selection::ResolvedTurnEnvironments; use codex_model_provider::SharedModelProvider; use codex_model_provider::create_model_provider; +use codex_protocol::SessionId; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TurnEnvironmentSelection; @@ -414,7 +415,7 @@ impl Session { per_turn_config.model_reasoning_effort = session_configuration.collaboration_mode.reasoning_effort(); per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary; - per_turn_config.service_tier = session_configuration.service_tier; + per_turn_config.service_tier = session_configuration.service_tier.clone(); per_turn_config.personality = session_configuration.personality; per_turn_config.approvals_reviewer = session_configuration.approvals_reviewer; per_turn_config.permissions.permission_profile = @@ -440,7 +441,8 @@ impl Session { #[allow(clippy::too_many_arguments)] pub(crate) fn make_turn_context( - conversation_id: ThreadId, + thread_id: ThreadId, + session_id: SessionId, auth_manager: Option>, session_telemetry: &SessionTelemetry, provider: ModelProviderInfo, @@ -522,7 +524,8 @@ impl Session { let per_turn_config = Arc::new(per_turn_config); let turn_metadata_state = Arc::new(TurnMetadataState::new( - conversation_id.to_string(), + session_id.to_string(), + thread_id.to_string(), session_configuration.thread_source, sub_id.clone(), cwd.clone(), @@ -718,7 +721,8 @@ impl Session { ); let goal_tools_supported = !per_turn_config.ephemeral && self.state_db().is_some(); let mut turn_context: TurnContext = Self::make_turn_context( - self.conversation_id, + self.thread_id(), + self.session_id(), Some(Arc::clone(&self.services.auth_manager)), &self.services.session_telemetry, session_configuration.provider.clone(), diff --git a/codex-rs/core/src/session_startup_prewarm.rs b/codex-rs/core/src/session_startup_prewarm.rs index 71afdba6f9..93d14ff7d6 100644 --- a/codex-rs/core/src/session_startup_prewarm.rs +++ b/codex-rs/core/src/session_startup_prewarm.rs @@ -232,7 +232,7 @@ async fn schedule_startup_prewarm_inner( &startup_turn_context.session_telemetry, startup_turn_context.reasoning_effort, startup_turn_context.reasoning_summary, - startup_turn_context.config.service_tier, + startup_turn_context.config.service_tier.clone(), startup_turn_metadata_header.as_deref(), ) .await?; diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 03a3ec92fc..331ed3ca15 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -7,6 +7,7 @@ use crate::environment_selection::default_thread_environment_selections; use crate::environment_selection::resolve_environment_selections; use crate::file_watcher::FileWatcher; use crate::mcp::McpManager; +use crate::resolve_installation_id; use crate::rollout::RolloutRecorder; use crate::rollout::truncation; use crate::session::Codex; @@ -42,7 +43,6 @@ use codex_protocol::openai_models::ModelPreset; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::InitialHistory; -use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionConfiguredEvent; @@ -254,6 +254,7 @@ pub(crate) struct ThreadManagerState { state_db: StateDbHandle, agent_graph_store: Arc, session_source: SessionSource, + installation_id: String, analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, @@ -317,6 +318,7 @@ impl ThreadManager { state_db: StateDbHandle, thread_store: Arc, agent_graph_store: Arc, + installation_id: String, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -347,6 +349,7 @@ impl ThreadManager { agent_graph_store, auth_manager, session_source, + installation_id, analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), @@ -374,12 +377,21 @@ impl ThreadManager { OPENAI_PROVIDER_ID.to_string(), ) .await; + let skills_codex_home = match AbsolutePathBuf::from_absolute_path_checked(&codex_home) { + Ok(codex_home) => codex_home, + Err(err) => panic!("test codex_home should be absolute: {err}"), + }; + let installation_id = resolve_installation_id(&skills_codex_home) + .await + .unwrap_or_else(|err| panic!("resolve test installation id failed: {err}")); let mut manager = Self::with_models_provider_and_home_and_state_db_for_tests( auth, provider, codex_home.clone(), Arc::new(EnvironmentManager::default_for_tests()), state_db, + skills_codex_home, + installation_id, ); manager._test_codex_home_guard = Some(TempCodexHomeGuard { path: codex_home }); manager @@ -399,12 +411,21 @@ impl ThreadManager { OPENAI_PROVIDER_ID.to_string(), ) .await; + let skills_codex_home = match AbsolutePathBuf::from_absolute_path_checked(&codex_home) { + Ok(codex_home) => codex_home, + Err(err) => panic!("test codex_home should be absolute: {err}"), + }; + let installation_id = resolve_installation_id(&skills_codex_home) + .await + .unwrap_or_else(|err| panic!("resolve test installation id failed: {err}")); Self::with_models_provider_and_home_and_state_db_for_tests( auth, provider, codex_home, environment_manager, state_db, + skills_codex_home, + installation_id, ) } @@ -414,13 +435,11 @@ impl ThreadManager { codex_home: PathBuf, environment_manager: Arc, state_db: StateDbHandle, + skills_codex_home: AbsolutePathBuf, + installation_id: String, ) -> Self { set_thread_manager_test_mode_for_tests(/*enabled*/ true); let auth_manager = AuthManager::from_auth_for_testing(auth); - let skills_codex_home = match AbsolutePathBuf::from_absolute_path_checked(&codex_home) { - Ok(codex_home) => codex_home, - Err(err) => panic!("test codex_home should be absolute: {err}"), - }; let (thread_created_tx, _) = broadcast::channel(THREAD_CREATED_CHANNEL_CAPACITY); let restriction_product = SessionSource::Exec.restriction_product(); let plugins_manager = Arc::new(PluginsManager::new_with_restriction_product( @@ -460,6 +479,7 @@ impl ThreadManager { agent_graph_store, auth_manager, session_source: SessionSource::Exec, + installation_id, analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), @@ -526,27 +546,6 @@ impl ThreadManager { self.state.list_thread_ids().await } - pub async fn refresh_mcp_servers(&self, refresh_config: McpServerRefreshConfig) { - let threads = self - .state - .threads - .read() - .await - .values() - .cloned() - .collect::>(); - for thread in threads { - if let Err(err) = thread - .submit(Op::RefreshMcpServers { - config: refresh_config.clone(), - }) - .await - { - warn!("failed to request MCP server refresh: {err}"); - } - } - } - pub fn subscribe_thread_created(&self) -> broadcast::Receiver { self.state.thread_created_tx.subscribe() } @@ -1221,6 +1220,7 @@ impl ThreadManagerState { codex, thread_id, .. } = Codex::spawn(CodexSpawnArgs { config, + installation_id: self.installation_id.clone(), auth_manager, models_manager: Arc::clone(&self.models_manager), environment_manager: Arc::clone(&self.environment_manager), diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index a61a3a3545..0f6afa05a6 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::config::test_config; +use crate::installation_id::INSTALLATION_ID_FILENAME; use crate::rollout::RolloutRecorder; use crate::session::session::SessionSettingsUpdate; use crate::session::tests::make_session_and_context; @@ -26,6 +27,8 @@ use std::time::Duration; use tempfile::tempdir; use wiremock::MockServer; +const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111"; + fn user_msg(text: &str) -> ResponseItem { ResponseItem::Message { id: None, @@ -415,6 +418,7 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { state_db, thread_store, agent_graph_store, + TEST_INSTALLATION_ID.to_string(), ); let selected_cwd = AbsolutePathBuf::try_from(config.cwd.as_path().join("selected")).expect("absolute path"); @@ -509,6 +513,46 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { ); } +#[tokio::test] +async fn explicit_installation_id_skips_codex_home_file() { + let temp_dir = tempdir().expect("tempdir"); + let mut config = test_config().await; + config.codex_home = temp_dir.path().join("codex-home").abs(); + config.cwd = config.codex_home.abs(); + std::fs::create_dir_all(&config.codex_home).expect("create codex home"); + + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let installation_id = uuid::Uuid::new_v4().to_string(); + let (state_db, thread_store, agent_graph_store) = state_backed_stores(&config).await; + let manager = ThreadManager::new( + &config, + auth_manager, + SessionSource::Exec, + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + /*analytics_events_client*/ None, + state_db, + thread_store, + agent_graph_store, + installation_id.clone(), + ); + + let thread = manager + .start_thread(config.clone()) + .await + .expect("start thread with explicit installation id"); + + assert!(!config.codex_home.join(INSTALLATION_ID_FILENAME).exists()); + assert_eq!(thread.thread.codex.session.installation_id, installation_id); + + thread + .thread + .shutdown_and_wait() + .await + .expect("shutdown thread"); + let _ = manager.remove_thread(&thread.thread_id).await; +} + #[tokio::test] async fn resume_active_thread_from_rollout_returns_running_thread() { let temp_dir = tempdir().expect("tempdir"); @@ -529,6 +573,7 @@ async fn resume_active_thread_from_rollout_returns_running_thread() { state_db, thread_store, agent_graph_store, + TEST_INSTALLATION_ID.to_string(), ); let source = manager @@ -585,6 +630,7 @@ async fn resume_stopped_thread_from_rollout_spawns_new_thread() { state_db, thread_store, agent_graph_store, + TEST_INSTALLATION_ID.to_string(), ); let source = manager @@ -646,6 +692,7 @@ async fn resume_stopped_thread_from_rollout_preserves_thread_source() { state_db, thread_store, agent_graph_store, + TEST_INSTALLATION_ID.to_string(), ); let source = manager @@ -731,6 +778,7 @@ async fn new_uses_active_provider_for_model_refresh() { state_db, thread_store, agent_graph_store, + TEST_INSTALLATION_ID.to_string(), ); let _ = manager.list_models(RefreshStrategy::Online).await; @@ -945,6 +993,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor state_db, thread_store, agent_graph_store, + TEST_INSTALLATION_ID.to_string(), ); let source = manager @@ -1051,6 +1100,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { state_db, thread_store, agent_graph_store, + TEST_INSTALLATION_ID.to_string(), ); let source = manager @@ -1146,6 +1196,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ state_db, thread_store, agent_graph_store, + TEST_INSTALLATION_ID.to_string(), ); let source = manager @@ -1287,6 +1338,7 @@ async fn resumed_thread_keeps_paused_goal_paused() -> anyhow::Result<()> { state_db, thread_store, agent_graph_store, + TEST_INSTALLATION_ID.to_string(), ); let source = manager diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 9a23550d41..b678f3ffe8 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -3171,6 +3171,7 @@ async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtr state_db.clone(), thread_store_from_config(&config, state_db.clone()), agent_graph_store_from_state_db(state_db.clone()), + "11111111-1111-4111-8111-111111111111".to_string(), ); let parent = manager diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 45365dfbfd..0bbf06f36b 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -89,6 +89,35 @@ fn shell_command_payload_command(payload: &ToolPayload) -> Option { .map(|params| params.command) } +// Hooks expose legacy function shell tools as joined command strings, while +// their function payload stores argv. Split on the way back in to invert the +// hook-facing representation. +fn rewrite_shell_function_updated_hook_input( + mut invocation: ToolInvocation, + updated_input: JsonValue, + tool_name: &str, +) -> Result { + let ToolPayload::Function { arguments } = invocation.payload else { + return Err(FunctionCallError::RespondToModel(format!( + "hook input rewrite received unsupported {tool_name} payload" + ))); + }; + let command = shlex::split(updated_hook_command(&updated_input)?).ok_or_else(|| { + FunctionCallError::RespondToModel( + "hook returned shell input with an invalid command string".to_string(), + ) + })?; + invocation.payload = ToolPayload::Function { + arguments: rewrite_function_arguments(&arguments, tool_name, |arguments| { + arguments.insert( + "command".to_string(), + JsonValue::Array(command.into_iter().map(JsonValue::String).collect()), + ); + })?, + }; + Ok(invocation) +} + struct RunExecLikeArgs { tool_name: String, exec_params: ExecParams, @@ -222,6 +251,14 @@ impl ToolHandler for ShellHandler { shell_function_pre_tool_use_payload(invocation) } + fn with_updated_hook_input( + &self, + invocation: ToolInvocation, + updated_input: JsonValue, + ) -> Result { + rewrite_shell_function_updated_hook_input(invocation, updated_input, "shell") + } + fn post_tool_use_payload( &self, invocation: &ToolInvocation, @@ -300,6 +337,14 @@ impl ToolHandler for ContainerExecHandler { shell_function_pre_tool_use_payload(invocation) } + fn with_updated_hook_input( + &self, + invocation: ToolInvocation, + updated_input: JsonValue, + ) -> Result { + rewrite_shell_function_updated_hook_input(invocation, updated_input, "container.exec") + } + fn post_tool_use_payload( &self, invocation: &ToolInvocation, diff --git a/codex-rs/core/src/tools/handlers/shell_tests.rs b/codex-rs/core/src/tools/handlers/shell_tests.rs index 8a32e5404b..4f44b953f4 100644 --- a/codex-rs/core/src/tools/handlers/shell_tests.rs +++ b/codex-rs/core/src/tools/handlers/shell_tests.rs @@ -2,6 +2,7 @@ use std::path::PathBuf; use std::sync::Arc; use codex_protocol::models::ShellCommandToolCallParams; +use codex_protocol::models::ShellToolCallParams; use core_test_support::PathBufExt; use core_test_support::test_path_buf; use pretty_assertions::assert_eq; @@ -16,8 +17,10 @@ use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolCallSource; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::handlers::ContainerExecHandler; use crate::tools::handlers::LocalShellHandler; use crate::tools::handlers::ShellCommandHandler; +use crate::tools::handlers::ShellHandler; use crate::tools::hook_names::HookToolName; use crate::tools::registry::ToolHandler; use crate::turn_diff_tracker::TurnDiffTracker; @@ -269,6 +272,106 @@ async fn shell_command_pre_tool_use_payload_uses_raw_command() { ); } +#[tokio::test] +async fn shell_handler_rewrites_hook_command_back_to_argv() { + let payload = ToolPayload::Function { + arguments: json!({ + "command": ["bash", "-lc", "printf old"], + "workdir": "subdir", + }) + .to_string(), + }; + let (session, turn) = make_session_and_context().await; + let handler = ShellHandler; + + let invocation = handler + .with_updated_hook_input( + ToolInvocation { + session: session.into(), + turn: turn.into(), + cancellation_token: tokio_util::sync::CancellationToken::new(), + tracker: Arc::new(Mutex::new(TurnDiffTracker::new())), + call_id: "call-43".to_string(), + tool_name: codex_tools::ToolName::plain("shell"), + source: ToolCallSource::Direct, + payload, + }, + json!({ "command": "bash -lc 'printf new'" }), + ) + .expect("shell rewrite should succeed"); + + let ToolPayload::Function { arguments } = invocation.payload else { + panic!("shell rewrite should preserve a function payload"); + }; + assert_eq!( + serde_json::from_str::(&arguments) + .expect("rewritten shell arguments should parse"), + ShellToolCallParams { + command: vec![ + "bash".to_string(), + "-lc".to_string(), + "printf new".to_string(), + ], + workdir: Some("subdir".to_string()), + timeout_ms: None, + sandbox_permissions: None, + additional_permissions: None, + prefix_rule: None, + justification: None, + } + ); +} + +#[tokio::test] +async fn container_exec_handler_rewrites_hook_command_back_to_argv() { + let payload = ToolPayload::Function { + arguments: json!({ + "command": ["bash", "-lc", "printf old"], + "workdir": "subdir", + }) + .to_string(), + }; + let (session, turn) = make_session_and_context().await; + let handler = ContainerExecHandler; + + let invocation = handler + .with_updated_hook_input( + ToolInvocation { + session: session.into(), + turn: turn.into(), + cancellation_token: tokio_util::sync::CancellationToken::new(), + tracker: Arc::new(Mutex::new(TurnDiffTracker::new())), + call_id: "call-44".to_string(), + tool_name: codex_tools::ToolName::plain("container.exec"), + source: ToolCallSource::Direct, + payload, + }, + json!({ "command": "bash -lc 'printf new'" }), + ) + .expect("container.exec rewrite should succeed"); + + let ToolPayload::Function { arguments } = invocation.payload else { + panic!("container.exec rewrite should preserve a function payload"); + }; + assert_eq!( + serde_json::from_str::(&arguments) + .expect("rewritten container.exec arguments should parse"), + ShellToolCallParams { + command: vec![ + "bash".to_string(), + "-lc".to_string(), + "printf new".to_string(), + ], + workdir: Some("subdir".to_string()), + timeout_ms: None, + sandbox_permissions: None, + additional_permissions: None, + prefix_rule: None, + justification: None, + } + ); +} + #[tokio::test] async fn build_post_tool_use_payload_uses_tool_output_wire_value() { let payload = ToolPayload::Function { diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 1735bf8daa..02e418cedc 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -311,8 +311,6 @@ impl ToolRegistry { let display_name = tool_name.display(); let call_id_owned = invocation.call_id.clone(); let otel = invocation.turn.session_telemetry.clone(); - let payload_for_response = invocation.payload.clone(); - let log_payload = payload_for_response.log_payload(); let metric_tags = [ ( "sandbox", @@ -360,6 +358,7 @@ impl ToolRegistry { Some(handler) => handler, None => { let message = unsupported_tool_call_message(&invocation.payload, &tool_name); + let log_payload = invocation.payload.log_payload(); otel.tool_result_with_tags( &display_name, &call_id_owned, @@ -379,6 +378,7 @@ impl ToolRegistry { if !handler.matches_kind(&invocation.payload) { let message = format!("tool {display_name} invoked with incompatible payload"); + let log_payload = invocation.payload.log_payload(); otel.tool_result_with_tags( &display_name, &call_id_owned, @@ -426,6 +426,7 @@ impl ToolRegistry { let invocation_for_tool = invocation.clone(); let started = Instant::now(); + let log_payload = invocation.payload.log_payload(); let result = otel .log_tool_result_with_tags( &display_name, diff --git a/codex-rs/core/src/turn_metadata.rs b/codex-rs/core/src/turn_metadata.rs index d4a46944eb..02760582f2 100644 --- a/codex-rs/core/src/turn_metadata.rs +++ b/codex-rs/core/src/turn_metadata.rs @@ -69,6 +69,8 @@ pub(crate) struct TurnMetadataBag { #[serde(default, skip_serializing_if = "Option::is_none")] session_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + thread_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] thread_source: Option, #[serde(default, skip_serializing_if = "Option::is_none")] turn_id: Option, @@ -115,6 +117,7 @@ fn merge_turn_metadata( fn build_turn_metadata_bag( session_id: Option, + thread_id: Option, thread_source: Option, turn_id: Option, sandbox: Option, @@ -130,6 +133,7 @@ fn build_turn_metadata_bag( TurnMetadataBag { session_id, + thread_id, thread_source, turn_id, workspaces, @@ -159,6 +163,7 @@ pub async fn build_turn_metadata_header( build_turn_metadata_bag( /*session_id*/ None, + /*thread_id*/ None, /*thread_source*/ None, /*turn_id*/ None, sandbox.map(ToString::to_string), @@ -185,8 +190,10 @@ pub(crate) struct TurnMetadataState { } impl TurnMetadataState { + #[allow(clippy::too_many_arguments)] pub(crate) fn new( session_id: String, + thread_id: String, thread_source: Option, turn_id: String, cwd: AbsolutePathBuf, @@ -205,6 +212,7 @@ impl TurnMetadataState { ); let base_metadata = build_turn_metadata_bag( Some(session_id), + Some(thread_id), thread_source, Some(turn_id), sandbox, @@ -320,6 +328,7 @@ impl TurnMetadataState { let enriched_metadata = build_turn_metadata_bag( state.base_metadata.session_id.clone(), + state.base_metadata.thread_id.clone(), state.base_metadata.thread_source, state.base_metadata.turn_id.clone(), state.base_metadata.sandbox.clone(), diff --git a/codex-rs/core/src/turn_metadata_tests.rs b/codex-rs/core/src/turn_metadata_tests.rs index d840ade91b..2a38447f86 100644 --- a/codex-rs/core/src/turn_metadata_tests.rs +++ b/codex-rs/core/src/turn_metadata_tests.rs @@ -94,6 +94,7 @@ fn turn_metadata_state_uses_platform_sandbox_tag() { let state = TurnMetadataState::new( "session-a".to_string(), + "thread-a".to_string(), Some(ThreadSource::User), "turn-a".to_string(), cwd, @@ -106,11 +107,13 @@ fn turn_metadata_state_uses_platform_sandbox_tag() { let json: Value = serde_json::from_str(&header).expect("json"); let sandbox_name = json.get("sandbox").and_then(Value::as_str); let session_id = json.get("session_id").and_then(Value::as_str); + let thread_id = json.get("thread_id").and_then(Value::as_str); let thread_source = json.get("thread_source").and_then(Value::as_str); let expected_sandbox = sandbox_tag(&sandbox_policy, WindowsSandboxLevel::Disabled); assert_eq!(sandbox_name, Some(expected_sandbox)); assert_eq!(session_id, Some("session-a")); + assert_eq!(thread_id, Some("thread-a")); assert_eq!(thread_source, Some("user")); assert!(json.get("session_source").is_none()); } @@ -122,6 +125,7 @@ fn turn_metadata_state_uses_explicit_subagent_thread_source() { let permission_profile = PermissionProfile::read_only(); let state = TurnMetadataState::new( "session-a".to_string(), + "thread-a".to_string(), Some(ThreadSource::Subagent), "turn-a".to_string(), cwd, @@ -145,6 +149,7 @@ fn turn_metadata_state_includes_turn_started_at_unix_ms_after_start() { let state = TurnMetadataState::new( "session-a".to_string(), + "thread-a".to_string(), Some(ThreadSource::User), "turn-a".to_string(), cwd, @@ -171,6 +176,7 @@ fn turn_metadata_state_includes_model_and_reasoning_effort_only_in_request_meta( let state = TurnMetadataState::new( "session-a".to_string(), + "thread-a".to_string(), /*thread_source*/ None, "turn-a".to_string(), cwd, @@ -215,6 +221,7 @@ fn turn_metadata_state_ignores_client_turn_started_at_unix_ms_before_start() { let state = TurnMetadataState::new( "session-a".to_string(), + "thread-a".to_string(), Some(ThreadSource::User), "turn-a".to_string(), cwd, @@ -241,6 +248,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( let state = TurnMetadataState::new( "session-a".to_string(), + "thread-a".to_string(), Some(ThreadSource::User), "turn-a".to_string(), cwd, @@ -257,6 +265,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( "client-supplied".to_string(), ), ("session_id".to_string(), "client-supplied".to_string()), + ("thread_id".to_string(), "client-supplied".to_string()), ("thread_source".to_string(), "client-supplied".to_string()), ( "turn_started_at_unix_ms".to_string(), @@ -275,6 +284,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( assert_eq!(json["model"].as_str(), Some("client-supplied")); assert_eq!(json["reasoning_effort"].as_str(), Some("client-supplied")); assert_eq!(json["session_id"].as_str(), Some("session-a")); + assert_eq!(json["thread_id"].as_str(), Some("thread-a")); assert_eq!(json["thread_source"].as_str(), Some("user")); assert_eq!(json["turn_id"].as_str(), Some("turn-a")); assert_eq!( diff --git a/codex-rs/core/tests/common/BUILD.bazel b/codex-rs/core/tests/common/BUILD.bazel index aec0c17817..983a2012b0 100644 --- a/codex-rs/core/tests/common/BUILD.bazel +++ b/codex-rs/core/tests/common/BUILD.bazel @@ -7,4 +7,7 @@ codex_rust_crate( lib_data_extra = [ "//codex-rs/core:model_availability_nux_fixtures", ], + deps_extra = [ + "@crates//:similar", + ], ) diff --git a/codex-rs/core/tests/common/Cargo.toml b/codex-rs/core/tests/common/Cargo.toml index c59edf57a6..8c1a79f02d 100644 --- a/codex-rs/core/tests/common/Cargo.toml +++ b/codex-rs/core/tests/common/Cargo.toml @@ -33,6 +33,7 @@ opentelemetry = { workspace = true } opentelemetry_sdk = { workspace = true } regex-lite = { workspace = true } serde_json = { workspace = true } +similar = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["net", "time"] } tokio-tungstenite = { workspace = true } diff --git a/codex-rs/core/tests/common/context_snapshot.rs b/codex-rs/core/tests/common/context_snapshot.rs index cb899969d9..8aaefbbf15 100644 --- a/codex-rs/core/tests/common/context_snapshot.rs +++ b/codex-rs/core/tests/common/context_snapshot.rs @@ -1,5 +1,7 @@ use regex_lite::Regex; use serde_json::Value; +use similar::ChangeTag; +use similar::TextDiff; use std::sync::OnceLock; use crate::responses::ResponsesRequest; @@ -242,6 +244,102 @@ pub fn format_labeled_items_snapshot( format!("Scenario: {scenario}\n\n{sections}") } +/// Render changed JSON lines between two captured `/responses` request bodies. +/// +/// Request-parity tests use this to compare the entire JSON payload while showing only fields that +/// changed, with the same redactions as the other context snapshots. +pub fn format_request_body_diff_snapshot( + scenario: &str, + before_title: &str, + before_request: &ResponsesRequest, + after_title: &str, + after_request: &ResponsesRequest, + options: &ContextSnapshotOptions, +) -> String { + let before = format_request_body_snapshot(before_request, options); + let after = format_request_body_snapshot(after_request, options); + let diff = format_changed_lines_diff(before_title, &before, after_title, &after); + format!("Scenario: {scenario}\n\n{diff}") +} + +fn format_request_body_snapshot( + request: &ResponsesRequest, + options: &ContextSnapshotOptions, +) -> String { + let mut body = request.body_json(); + canonicalize_json_snapshot_value(&mut body, options); + serde_json::to_string_pretty(&body).expect("request body should serialize") +} + +fn canonicalize_json_snapshot_value(value: &mut Value, options: &ContextSnapshotOptions) { + match value { + Value::Array(values) => { + for value in values { + canonicalize_json_snapshot_value(value, options); + } + } + Value::Object(map) => { + // Keep request-body snapshots stable when serde_json preserves insertion order. + let mut entries = std::mem::take(map).into_iter().collect::>(); + entries.sort_by(|(left_key, _), (right_key, _)| left_key.cmp(right_key)); + for (key, mut value) in entries { + canonicalize_json_snapshot_value(&mut value, options); + map.insert(key, value); + } + } + Value::String(text) => { + *text = format_snapshot_json_string(text, options); + } + Value::Null | Value::Bool(_) | Value::Number(_) => {} + } +} + +fn format_snapshot_json_string(text: &str, options: &ContextSnapshotOptions) -> String { + let normalized = match options.render_mode { + ContextSnapshotRenderMode::RedactedText + | ContextSnapshotRenderMode::KindWithTextPrefix { .. } => normalize_snapshot_uuids( + &normalize_snapshot_line_endings(&canonicalize_snapshot_text(text)), + ), + ContextSnapshotRenderMode::FullText => normalize_snapshot_line_endings(text), + ContextSnapshotRenderMode::KindOnly => unreachable!(), + }; + match options.render_mode { + ContextSnapshotRenderMode::KindWithTextPrefix { max_chars } + if normalized.chars().count() > max_chars => + { + let prefix = normalized.chars().take(max_chars).collect::(); + format!("{prefix}...") + } + ContextSnapshotRenderMode::RedactedText + | ContextSnapshotRenderMode::FullText + | ContextSnapshotRenderMode::KindWithTextPrefix { .. } => normalized, + ContextSnapshotRenderMode::KindOnly => unreachable!(), + } +} + +fn format_changed_lines_diff( + before_title: &str, + before: &str, + after_title: &str, + after: &str, +) -> String { + let mut diff = format!("--- {before_title}\n+++ {after_title}\n"); + for change in TextDiff::from_lines(before, after).iter_all_changes() { + match change.tag() { + ChangeTag::Equal => {} + ChangeTag::Delete => { + diff.push('-'); + diff.push_str(change.value()); + } + ChangeTag::Insert => { + diff.push('+'); + diff.push_str(change.value()); + } + } + } + diff +} + fn format_snapshot_text(text: &str, options: &ContextSnapshotOptions) -> String { match options.render_mode { ContextSnapshotRenderMode::RedactedText => { @@ -342,6 +440,17 @@ fn normalize_dynamic_snapshot_paths(text: &str) -> String { .into_owned() } +fn normalize_snapshot_uuids(text: &str) -> String { + static UUID_RE: OnceLock = OnceLock::new(); + let uuid_re = UUID_RE.get_or_init(|| { + Regex::new( + r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b", + ) + .expect("uuid regex should compile") + }); + uuid_re.replace_all(text, "").into_owned() +} + #[cfg(test)] mod tests { use super::ContextSnapshotOptions; diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 13dd026654..1728f5bc5c 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -18,6 +18,7 @@ use codex_core::ThreadManager; use codex_core::agent_graph_store_from_state_db; use codex_core::config::Config; use codex_core::init_state_db_from_config; +use codex_core::resolve_installation_id; use codex_core::shell::Shell; use codex_core::shell::get_shell_by_model_provided_path; use codex_core::thread_store_from_config; @@ -431,6 +432,7 @@ impl TestCodexBuilder { .expect("test codex requires state db"); let thread_store = thread_store_from_config(&config, state_db.clone()); let agent_graph_store = agent_graph_store_from_state_db(state_db.clone()); + let installation_id = resolve_installation_id(&config.codex_home).await?; ThreadManager::new( &config, codex_core::test_support::auth_manager_from_auth(auth.clone()), @@ -440,6 +442,7 @@ impl TestCodexBuilder { state_db, thread_store, agent_graph_store, + installation_id, ) } else { codex_core::test_support::thread_manager_with_models_provider_and_home( @@ -651,7 +654,7 @@ impl TestCodex { prompt, AskForApproval::Never, PermissionProfile::Disabled, - Some(service_tier), + Some(service_tier.map(|service_tier| service_tier.request_value().to_string())), /*environments*/ None, ) .await @@ -713,7 +716,7 @@ impl TestCodex { prompt: &str, approval_policy: AskForApproval, permission_profile: PermissionProfile, - service_tier: Option>, + service_tier: Option>, environments: Option>, ) -> Result<()> { self.submit_turn_with_context( @@ -731,7 +734,7 @@ impl TestCodex { prompt: &str, approval_policy: AskForApproval, permission_profile: PermissionProfile, - service_tier: Option>, + service_tier: Option>, environments: Option>, ) -> Result<()> { let (sandbox_policy, permission_profile) = diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index 893d38ed83..af99790a1f 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -80,13 +80,13 @@ async fn responses_stream_includes_subagent_header_on_review() { config.model = Some(model.clone()); let config = Arc::new(config); - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let auth_mode = TelemetryAuthMode::Chatgpt; let session_source = SessionSource::SubAgent(SubAgentSource::Review); let model_info = codex_core::test_support::construct_model_info_offline(model.as_str(), &config); let session_telemetry = SessionTelemetry::new( - conversation_id, + thread_id, model.as_str(), model_info.slug.as_str(), /*account_id*/ None, @@ -100,7 +100,8 @@ async fn responses_stream_includes_subagent_header_on_review() { let client = ModelClient::new( /*auth_manager*/ None, - conversation_id, + thread_id.into(), + thread_id, /*installation_id*/ TEST_INSTALLATION_ID.to_string(), provider.clone(), session_source, @@ -141,7 +142,7 @@ async fn responses_stream_includes_subagent_header_on_review() { } let request = request_recorder.single_request(); - let expected_window_id = format!("{conversation_id}:0"); + let expected_window_id = format!("{thread_id}:0"); assert_eq!( request.header("x-openai-subagent").as_deref(), Some("review") @@ -205,14 +206,14 @@ async fn responses_stream_includes_subagent_header_on_other() { config.model = Some(model.clone()); let config = Arc::new(config); - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let auth_mode = TelemetryAuthMode::Chatgpt; let session_source = SessionSource::SubAgent(SubAgentSource::Other("my-task".to_string())); let model_info = codex_core::test_support::construct_model_info_offline(model.as_str(), &config); let session_telemetry = SessionTelemetry::new( - conversation_id, + thread_id, model.as_str(), model_info.slug.as_str(), /*account_id*/ None, @@ -226,7 +227,8 @@ async fn responses_stream_includes_subagent_header_on_other() { let client = ModelClient::new( /*auth_manager*/ None, - conversation_id, + thread_id.into(), + thread_id, /*installation_id*/ TEST_INSTALLATION_ID.to_string(), provider.clone(), session_source, @@ -317,7 +319,7 @@ async fn responses_respects_model_info_overrides_from_config() { let model = config.model.clone().expect("model configured"); let config = Arc::new(config); - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let auth_mode = codex_core::test_support::auth_manager_from_auth(CodexAuth::from_api_key("Test API Key")) .auth_mode() @@ -327,7 +329,7 @@ async fn responses_respects_model_info_overrides_from_config() { let model_info = codex_core::test_support::construct_model_info_offline(model.as_str(), &config); let session_telemetry = SessionTelemetry::new( - conversation_id, + thread_id, model.as_str(), model_info.slug.as_str(), /*account_id*/ None, @@ -341,7 +343,8 @@ async fn responses_respects_model_info_overrides_from_config() { let client = ModelClient::new( /*auth_manager*/ None, - conversation_id, + thread_id.into(), + thread_id, /*installation_id*/ TEST_INSTALLATION_ID.to_string(), provider.clone(), session_source, diff --git a/codex-rs/core/tests/suite/agent_websocket.rs b/codex-rs/core/tests/suite/agent_websocket.rs index 305346afac..6e985eebe0 100644 --- a/codex-rs/core/tests/suite/agent_websocket.rs +++ b/codex-rs/core/tests/suite/agent_websocket.rs @@ -313,7 +313,7 @@ async fn websocket_v2_first_turn_drops_fast_tier_after_startup_prewarm() -> Resu .features .enable(Feature::ResponsesWebsocketsV2) .expect("test config should allow feature update"); - config.service_tier = Some(ServiceTier::Fast); + config.service_tier = Some(ServiceTier::Fast.request_value().to_string()); }); let test = builder.build_with_websocket_server(&server).await?; diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 96cc1f3a99..2538c850e3 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -807,7 +807,7 @@ async fn wait_for_spawned_thread(test: &TestCodex) -> Result> { let ids = test.thread_manager.list_thread_ids().await; if let Some(thread_id) = ids .iter() - .find(|id| **id != test.session_configured.session_id) + .find(|id| **id != test.session_configured.thread_id) { return test .thread_manager diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index dafde4ccea..143681e2e1 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -7,6 +7,7 @@ use codex_core::ResponseEvent; use codex_core::ThreadManager; use codex_core::agent_graph_store_from_state_db; use codex_core::init_state_db_from_config; +use codex_core::resolve_installation_id; use codex_core::thread_store_from_config; use codex_features::Feature; use codex_login::AuthManager; @@ -726,7 +727,7 @@ async fn resume_replays_image_tool_outputs_with_detail() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn includes_conversation_id_and_model_headers_in_request() { +async fn includes_session_id_thread_id_and_model_headers_in_request() { skip_if_no_network!(); // Mock server @@ -744,7 +745,8 @@ async fn includes_conversation_id_and_model_headers_in_request() { .await .expect("create new conversation"); let codex = test.codex.clone(); - let session_id = test.session_configured.session_id; + let expected_session_id = test.session_configured.session_id; + let expected_thread_id = test.session_configured.thread_id; codex .submit(Op::UserInput { @@ -764,6 +766,7 @@ async fn includes_conversation_id_and_model_headers_in_request() { let request = resp_mock.single_request(); assert_eq!(request.path(), "/v1/responses"); let request_session_id = request.header("session_id").expect("session_id header"); + let request_thread_id = request.header("thread_id").expect("thread_id header"); let request_authorization = request .header("authorization") .expect("authorization header"); @@ -772,10 +775,16 @@ async fn includes_conversation_id_and_model_headers_in_request() { let installation_id = std::fs::read_to_string(test.codex_home_path().join(INSTALLATION_ID_FILENAME)) .expect("read installation id"); + let thread_id_string = expected_thread_id.to_string(); - assert_eq!(request_session_id, session_id.to_string()); + assert_eq!(request_session_id, expected_session_id.to_string()); + assert_eq!(request_thread_id, thread_id_string.as_str()); assert_eq!(request_originator, originator().value); assert_eq!(request_authorization, "Bearer Test API Key"); + assert_eq!( + request_body["prompt_cache_key"].as_str(), + Some(thread_id_string.as_str()) + ); assert_eq!( request_body["client_metadata"]["x-codex-installation-id"].as_str(), Some(installation_id.as_str()) @@ -867,9 +876,9 @@ async fn send_provider_auth_request(server: &MockServer, auth: ModelProviderAuth let config = Arc::new(config); let model_info = codex_core::test_support::construct_model_info_offline(model.as_str(), &config); - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let session_telemetry = SessionTelemetry::new( - conversation_id, + thread_id, model.as_str(), model_info.slug.as_str(), /*account_id*/ None, @@ -884,7 +893,8 @@ async fn send_provider_auth_request(server: &MockServer, auth: ModelProviderAuth Some(AuthManager::from_auth_for_testing(CodexAuth::from_api_key( "unused-api-key", ))), - conversation_id, + thread_id.into(), + thread_id, /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), provider, SessionSource::Exec, @@ -1000,7 +1010,8 @@ async fn chatgpt_auth_sends_correct_request() { .await .expect("create new conversation"); let codex = test.codex.clone(); - let thread_id = test.session_configured.session_id; + let expected_session_id = test.session_configured.session_id; + let expected_thread_id = test.session_configured.thread_id; codex .submit(Op::UserInput { @@ -1028,11 +1039,13 @@ async fn chatgpt_auth_sends_correct_request() { .expect("chatgpt-account-id header"); let request_body = request.body_json(); - let session_id = request.header("session_id").expect("session_id header"); + let request_session_id = request.header("session_id").expect("session_id header"); + let request_thread_id = request.header("thread_id").expect("thread_id header"); let installation_id = std::fs::read_to_string(test.codex_home_path().join(INSTALLATION_ID_FILENAME)) .expect("read installation id"); - assert_eq!(session_id, thread_id.to_string()); + assert_eq!(request_session_id, expected_session_id.to_string()); + assert_eq!(request_thread_id, expected_thread_id.to_string()); assert_eq!(request_originator, originator().value); assert_eq!(request_authorization, "Bearer Access Token"); @@ -1108,6 +1121,9 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { .expect("client test requires state db"); let thread_store = thread_store_from_config(&config, state_db.clone()); let agent_graph_store = agent_graph_store_from_state_db(state_db.clone()); + let installation_id = resolve_installation_id(&config.codex_home) + .await + .expect("resolve installation id"); let thread_manager = ThreadManager::new( &config, auth_manager, @@ -1117,6 +1133,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { state_db, thread_store, agent_graph_store, + installation_id, ); let NewThread { thread: codex, .. } = thread_manager .start_thread(config.clone()) @@ -2277,11 +2294,11 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { let config = Arc::new(config); let model_info = codex_core::test_support::construct_model_info_offline(model.as_str(), &config); - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let auth_manager = codex_core::test_support::auth_manager_from_auth(CodexAuth::from_api_key("Test API Key")); let session_telemetry = SessionTelemetry::new( - conversation_id, + thread_id, model.as_str(), model_info.slug.as_str(), /*account_id*/ None, @@ -2295,7 +2312,8 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { let client = ModelClient::new( /*auth_manager*/ None, - conversation_id, + thread_id.into(), + thread_id, /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), provider.clone(), SessionSource::Exec, diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index cdbb65aabd..796b85ca5e 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -15,6 +15,7 @@ use codex_otel::MetricsConfig; use codex_otel::SessionTelemetry; use codex_otel::TelemetryAuthMode; use codex_otel::current_span_w3c_trace_context; +use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::config_types::ReasoningSummary; @@ -87,7 +88,8 @@ fn assert_request_trace_matches(body: &serde_json::Value, expected_trace: &W3cTr struct WebsocketTestHarness { _codex_home: TempDir, client: ModelClient, - conversation_id: ThreadId, + session_id: SessionId, + thread_id: ThreadId, model_info: ModelInfo, effort: Option, summary: ReasoningSummary, @@ -125,7 +127,15 @@ async fn responses_websocket_streams_request() { ); assert_eq!( handshake.header(X_CLIENT_REQUEST_ID_HEADER), - Some(harness.conversation_id.to_string()) + Some(harness.thread_id.to_string()) + ); + assert_eq!( + handshake.header("session_id"), + Some(harness.session_id.to_string()) + ); + assert_eq!( + handshake.header("thread_id"), + Some(harness.thread_id.to_string()) ); assert_eq!( handshake.header(USER_AGENT_HEADER), @@ -161,6 +171,93 @@ async fn responses_websocket_streams_without_feature_flag_when_provider_supports server.shutdown().await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_websocket_sends_response_processed_when_feature_enabled() { + skip_if_no_network!(); + + let server = start_websocket_server(vec![vec![ + vec![ + ev_response_created("resp-prewarm"), + ev_completed("resp-prewarm"), + ], + vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "hi"), + ev_completed("resp-1"), + ], + vec![], + ]]) + .await; + + let mut builder = test_codex().with_config(|config| { + config + .features + .enable(Feature::ResponsesWebsocketResponseProcessed) + .expect("test config should allow feature update"); + }); + let test = builder + .build_with_websocket_server(&server) + .await + .expect("build websocket codex"); + + test.submit_turn("hello") + .await + .expect("submission should send response.processed after processing"); + + let processed = server + .wait_for_request(/*connection_index*/ 0, /*request_index*/ 2) + .await; + assert_eq!( + processed.body_json(), + json!({ + "type": "response.processed", + "response_id": "resp-1", + }) + ); + + let connection = server.single_connection(); + assert_eq!(connection.len(), 3); + assert_eq!( + connection[1].body_json()["type"].as_str(), + Some("response.create") + ); + + server.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_websocket_omits_response_processed_without_feature() { + skip_if_no_network!(); + + let server = start_websocket_server(vec![vec![ + vec![ + ev_response_created("resp-prewarm"), + ev_completed("resp-prewarm"), + ], + vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "hi"), + ev_completed("resp-1"), + ], + vec![], + ]]) + .await; + let mut builder = test_codex(); + let test = builder + .build_with_websocket_server(&server) + .await + .expect("build websocket codex"); + + test.submit_turn("hello") + .await + .expect("submission should complete without response.processed"); + + let connection = server.single_connection(); + assert_eq!(connection.len(), 2); + + server.shutdown().await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn responses_websocket_reuses_connection_with_per_turn_trace_payloads() { skip_if_no_network!(); @@ -1827,7 +1924,8 @@ async fn websocket_harness_with_provider_options( } let config = Arc::new(config); let model_info = codex_core::test_support::construct_model_info_offline(MODEL, &config); - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); + let session_id = SessionId::new(); let auth_manager = codex_core::test_support::auth_manager_from_auth(CodexAuth::from_api_key("Test API Key")); let exporter = InMemoryMetricExporter::default(); @@ -1837,7 +1935,7 @@ async fn websocket_harness_with_provider_options( ) .expect("in-memory metrics client"); let session_telemetry = SessionTelemetry::new( - conversation_id, + thread_id, MODEL, model_info.slug.as_str(), /*account_id*/ None, @@ -1853,7 +1951,8 @@ async fn websocket_harness_with_provider_options( let summary = ReasoningSummary::Auto; let client = ModelClient::new( /*auth_manager*/ None, - conversation_id, + session_id, + thread_id, /*installation_id*/ TEST_INSTALLATION_ID.to_string(), provider.clone(), SessionSource::Exec, @@ -1866,7 +1965,8 @@ async fn websocket_harness_with_provider_options( WebsocketTestHarness { _codex_home: codex_home, client, - conversation_id, + session_id, + thread_id, model_info, effort, summary, @@ -1935,7 +2035,7 @@ async fn stream_until_complete_with_request_metadata( &harness.session_telemetry, harness.effort, harness.summary, - service_tier, + service_tier.map(|service_tier| service_tier.request_value().to_string()), turn_metadata_header, &codex_rollout_trace::InferenceTraceContext::disabled(), ) diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 38671bd763..3da841ba79 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -7,6 +7,7 @@ use anyhow::Result; use codex_core::compact::SUMMARY_PREFIX; use codex_features::Feature; use codex_login::CodexAuth; +use codex_protocol::config_types::ServiceTier; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::items::TurnItem; use codex_protocol::models::ContentItem; @@ -105,6 +106,23 @@ fn contains_defer_loading(value: &Value) -> bool { } } +fn canonical_json(value: &Value) -> Value { + match value { + Value::Object(map) => { + let mut entries = map.iter().collect::>(); + entries.sort_by(|(left_key, _), (right_key, _)| left_key.cmp(right_key)); + Value::Object( + entries + .into_iter() + .map(|(key, value)| (key.clone(), canonical_json(value))) + .collect(), + ) + } + Value::Array(values) => Value::Array(values.iter().map(canonical_json).collect()), + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => value.clone(), + } +} + const PRETURN_CONTEXT_DIFF_CWD: &str = "/tmp/PRETURN_CONTEXT_DIFF_CWD"; const DUMMY_FUNCTION_NAME: &str = "test_tool"; const REMOTE_COMPACT_TURN_COMPLETE_TIMEOUT: Duration = Duration::from_secs(30); @@ -273,6 +291,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { .await?; let codex = harness.test().codex.clone(); let session_id = harness.test().session_configured.session_id.to_string(); + let thread_id = harness.test().session_configured.thread_id.to_string(); let responses_mock = responses::mount_sse_sequence( harness.server(), @@ -341,6 +360,10 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { compact_request.header("session_id").as_deref(), Some(session_id.as_str()) ); + assert_eq!( + compact_request.header("thread_id").as_deref(), + Some(thread_id.as_str()) + ); let compact_body = compact_request.body_json(); assert_eq!( compact_body.get("model").and_then(|v| v.as_str()), @@ -412,6 +435,267 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { Ok(()) } +async fn assert_remote_manual_compact_request_parity( + auth: CodexAuth, + configured_service_tier: Option, + expected_service_tier: Option<&str>, + snapshot_name: &str, + scenario: &str, +) -> Result<()> { + let mut builder = test_codex().with_auth(auth); + if let Some(service_tier) = configured_service_tier { + builder = builder.with_config(move |config| { + config.service_tier = Some(service_tier.request_value().to_string()); + }); + } + let harness = TestCodexHarness::with_builder(builder).await?; + let codex = harness.test().codex.clone(); + let image_url = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=" + .to_string(); + + let responses_mock = responses::mount_sse_sequence( + harness.server(), + vec![ + responses::sse(vec![ + responses::ev_assistant_message("turn-one-assistant", "TURN_ONE_ASSISTANT"), + responses::ev_completed("turn-one-response"), + ]), + responses::sse(vec![ + responses::ev_reasoning_item( + "turn-two-reasoning", + &["TURN_TWO_REASONING"], + &["turn two raw content"], + ), + responses::ev_assistant_message("turn-two-assistant", "TURN_TWO_ASSISTANT"), + responses::ev_completed("turn-two-response"), + ]), + responses::sse(vec![ + responses::ev_function_call("turn-three-call", DUMMY_FUNCTION_NAME, "{}"), + responses::ev_completed("turn-three-call-response"), + ]), + responses::sse(vec![ + responses::ev_assistant_message("turn-three-assistant", "TURN_THREE_ASSISTANT"), + responses::ev_completed("turn-three-final-response"), + ]), + responses::sse(vec![ + responses::ev_local_shell_call( + "turn-four-local-shell", + "completed", + vec!["/bin/echo", "TURN_FOUR_LOCAL_SHELL"], + ), + responses::ev_completed("turn-four-local-shell-response"), + ]), + responses::sse(vec![ + responses::ev_assistant_message("turn-four-assistant", "TURN_FOUR_ASSISTANT"), + responses::ev_completed("turn-four-final-response"), + ]), + responses::sse(vec![ + responses::ev_reasoning_item( + "turn-five-reasoning", + &["TURN_FIVE_REASONING"], + &["turn five raw content"], + ), + responses::ev_assistant_message("turn-five-assistant", "TURN_FIVE_ASSISTANT"), + responses::ev_completed("turn-five-response"), + ]), + ], + ) + .await; + let compact_mock = responses::mount_compact_user_history_with_summary_once( + harness.server(), + "REMOTE_CACHE_TIER_SUMMARY", + ) + .await; + + codex + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "TURN_ONE_USER".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + }) + .await?; + wait_for_turn_complete(&codex).await; + + codex + .submit(Op::UserInput { + environments: None, + items: vec![ + UserInput::Text { + text: "TURN_TWO_PREFIX".to_string(), + text_elements: Vec::new(), + }, + UserInput::Text { + text: "TURN_TWO_SUFFIX".to_string(), + text_elements: Vec::new(), + }, + ], + final_output_json_schema: None, + responsesapi_client_metadata: None, + }) + .await?; + wait_for_turn_complete(&codex).await; + + codex + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "TURN_THREE_TOOL_USER".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + }) + .await?; + wait_for_turn_complete(&codex).await; + + codex + .submit(Op::UserInput { + environments: None, + items: vec![ + UserInput::Image { image_url }, + UserInput::Text { + text: "TURN_FOUR_IMAGE_USER".to_string(), + text_elements: Vec::new(), + }, + ], + final_output_json_schema: None, + responsesapi_client_metadata: None, + }) + .await?; + wait_for_turn_complete(&codex).await; + + codex + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "TURN_FIVE_USER".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + }) + .await?; + wait_for_turn_complete(&codex).await; + + codex.submit(Op::Compact).await?; + wait_for_turn_complete(&codex).await; + + let response_requests = responses_mock.requests(); + assert_eq!( + response_requests.len(), + 7, + "expected five turns with one unsupported tool continuation and one local shell continuation" + ); + assert_eq!( + compact_mock.requests().len(), + 1, + "expected exactly one remote compact request" + ); + let normal_request = response_requests + .last() + .cloned() + .expect("last turn request missing"); + let compact_request = compact_mock.single_request(); + let normal_body = normal_request.body_json(); + let compact_body = compact_request.body_json(); + + let mut expected_compact_body_without_input = normal_body.clone(); + let expected_compact_object = expected_compact_body_without_input + .as_object_mut() + .expect("responses request body should be an object"); + for field in [ + "input", + "client_metadata", + "include", + "store", + "stream", + "tool_choice", + ] { + expected_compact_object.remove(field); + } + if expected_service_tier.is_none() { + expected_compact_object.remove("service_tier"); + } + let mut compact_body_without_input = compact_body.clone(); + compact_body_without_input + .as_object_mut() + .expect("compact request body should be an object") + .remove("input"); + let canonical_compact_body_without_input = canonical_json(&compact_body_without_input); + let canonical_expected_compact_body_without_input = + canonical_json(&expected_compact_body_without_input); + + assert_eq!( + json!({ + "compact_body_without_input": canonical_compact_body_without_input, + "expected_compact_body_without_input": canonical_expected_compact_body_without_input, + "prompt_cache_key_matches_responses": compact_body["prompt_cache_key"] == normal_body["prompt_cache_key"], + "prompt_cache_key_present": compact_body["prompt_cache_key"].is_string(), + "service_tier": compact_body.get("service_tier").and_then(Value::as_str), + }), + json!({ + "compact_body_without_input": canonical_expected_compact_body_without_input, + "expected_compact_body_without_input": canonical_expected_compact_body_without_input, + "prompt_cache_key_matches_responses": true, + "prompt_cache_key_present": true, + "service_tier": expected_service_tier, + }), + "compact requests should carry the same shared request fields as /responses" + ); + + insta::assert_snapshot!( + snapshot_name, + context_snapshot::format_request_body_diff_snapshot( + scenario, + "Last Normal /responses Request", + &normal_request, + "Remote /responses/compact Request", + &compact_request, + &ContextSnapshotOptions::default(), + ) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_manual_compact_api_auth_reuses_prompt_cache_key() -> Result<()> { + skip_if_no_network!(Ok(())); + + assert_remote_manual_compact_request_parity( + CodexAuth::from_api_key("dummy"), + Some(ServiceTier::Fast), + Some("priority"), + "remote_manual_compact_api_auth_prompt_cache_key_request_diff", + "After five varied API-key-auth turns, remote manual compaction reuses the normal responses service_tier and prompt_cache_key while omitting responses-only fields.", + ) + .await?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_manual_compact_chatgpt_auth_reuses_service_tier_and_prompt_cache_key() -> Result<()> +{ + skip_if_no_network!(Ok(())); + + assert_remote_manual_compact_request_parity( + CodexAuth::create_dummy_chatgpt_auth_for_testing(), + Some(ServiceTier::Fast), + Some("priority"), + "remote_manual_compact_chatgpt_auth_service_tier_prompt_cache_key_request_diff", + "After five varied ChatGPT-auth turns, remote manual compaction reuses the normal responses service_tier and prompt_cache_key while omitting responses-only fields.", + ) + .await?; + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn remote_compact_v2_reuses_context_compaction_for_followups() -> Result<()> { skip_if_no_network!(Ok(())); @@ -713,6 +997,7 @@ async fn remote_compact_runs_automatically() -> Result<()> { .await?; let codex = harness.test().codex.clone(); let session_id = harness.test().session_configured.session_id.to_string(); + let thread_id = harness.test().session_configured.thread_id.to_string(); mount_sse_once( harness.server(), @@ -764,6 +1049,10 @@ async fn remote_compact_runs_automatically() -> Result<()> { .as_deref(), Some(session_id.as_str()) ); + assert_eq!( + compact_mock.single_request().header("thread_id").as_deref(), + Some(thread_id.as_str()) + ); let follow_up_request = responses_mock.single_request(); let follow_up_body = follow_up_request.body_json().to_string(); assert!(follow_up_body.contains("REMOTE_COMPACTED_SUMMARY")); diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index 91d321b540..37456dce63 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -195,7 +195,7 @@ async fn fork_thread_from_history_does_not_require_source_rollout_path() { ForkSnapshot::Interrupted, test.config.clone(), InitialHistory::Resumed(ResumedHistory { - conversation_id: test.session_configured.session_id, + conversation_id: test.session_configured.thread_id, history: source_items.clone(), rollout_path: None, }), diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index 9530675acd..65087a1fe7 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -358,7 +358,7 @@ async fn image_generation_call_event_is_emitted() -> anyhow::Result<()> { let call_id = "ig_image_saved_to_temp_dir_default"; let expected_saved_path = image_generation_artifact_path( config.codex_home.as_path(), - &session_configured.session_id.to_string(), + &session_configured.thread_id.to_string(), call_id, ); let _ = std::fs::remove_file(&expected_saved_path); @@ -444,7 +444,7 @@ async fn image_generation_call_event_is_emitted_when_image_save_fails() -> anyho } = test_codex().build(&server).await?; let expected_saved_path = image_generation_artifact_path( config.codex_home.as_path(), - &session_configured.session_id.to_string(), + &session_configured.thread_id.to_string(), "ig_invalid", ); let _ = std::fs::remove_file(&expected_saved_path); @@ -547,8 +547,8 @@ async fn agent_message_content_delta_has_item_metadata() -> anyhow::Result<()> { }) .await; - let session_id = session_configured.session_id.to_string(); - assert_eq!(delta_event.thread_id, session_id); + let thread_id = session_configured.thread_id.to_string(); + assert_eq!(delta_event.thread_id, thread_id); assert_eq!(delta_event.turn_id, started_turn_id); assert_eq!(delta_event.item_id, started_item.id); assert_eq!(delta_event.delta, "streamed response"); @@ -614,7 +614,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< assert_eq!( plan_delta.thread_id, - session_configured.session_id.to_string() + session_configured.thread_id.to_string() ); assert_eq!(plan_delta.delta, "- Step 1\n- Step 2\n"); assert_eq!(plan_completed.text, "- Step 1\n- Step 2\n"); diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b0df5050c7..e962b06d41 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -487,7 +487,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { let test = builder.build(&server).await?; let saved_path = image_generation_artifact_path( test.codex_home_path(), - &test.session_configured.session_id.to_string(), + &test.session_configured.thread_id.to_string(), "ig_123", ); let _ = std::fs::remove_file(&saved_path); @@ -601,7 +601,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima let test = builder.build(&server).await?; let saved_path = image_generation_artifact_path( test.codex_home_path(), - &test.session_configured.session_id.to_string(), + &test.session_configured.thread_id.to_string(), "ig_123", ); let _ = std::fs::remove_file(&saved_path); @@ -717,7 +717,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() let test = builder.build(&server).await?; let saved_path = image_generation_artifact_path( test.codex_home_path(), - &test.session_configured.session_id.to_string(), + &test.session_configured.thread_id.to_string(), "ig_rollback", ); let _ = std::fs::remove_file(&saved_path); diff --git a/codex-rs/core/tests/suite/pending_input.rs b/codex-rs/core/tests/suite/pending_input.rs index b582a2abc7..62851c515d 100644 --- a/codex-rs/core/tests/suite/pending_input.rs +++ b/codex-rs/core/tests/suite/pending_input.rs @@ -164,11 +164,11 @@ async fn submit_queue_only_agent_mail(codex: &CodexThread, text: &str) { .await .unwrap_or_else(|err| panic!("submit queue-only agent mail: {err}")); codex - .submit(Op::ListMcpTools) + .submit(Op::RealtimeConversationListVoices) .await - .unwrap_or_else(|err| panic!("submit list-mcp-tools barrier: {err}")); + .unwrap_or_else(|err| panic!("submit list-voices barrier: {err}")); wait_for_event(codex, |event| { - matches!(event, EventMsg::McpListToolsResponse(_)) + matches!(event, EventMsg::RealtimeConversationListVoicesResponse(_)) }) .await; } diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index 5b83d3b136..7b10db0259 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -74,6 +74,7 @@ fn write_plugin_mcp_plugin(home: &TempDir, command: &str) { "mcpServers": {{ "sample": {{ "command": "{command}", + "cwd": ".", "startup_timeout_sec": 60.0 }} }} @@ -98,20 +99,6 @@ fn write_plugin_app_plugin(home: &TempDir) { .expect("write plugin app config"); } -async fn build_plugin_test_codex( - server: &MockServer, - codex_home: Arc, -) -> Result> { - let mut builder = test_codex() - .with_home(codex_home) - .with_auth(CodexAuth::from_api_key("Test API Key")); - Ok(builder - .build(server) - .await - .expect("create new conversation") - .codex) -} - async fn build_analytics_plugin_test_codex( server: &MockServer, codex_home: Arc, @@ -447,34 +434,3 @@ async fn explicit_plugin_mentions_track_plugin_used_analytics() -> Result<()> { Ok(()) } - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn plugin_mcp_tools_are_listed() -> Result<()> { - skip_if_no_network!(Ok(())); - let server = start_mock_server().await; - let codex_home = Arc::new(TempDir::new()?); - let rmcp_test_server_bin = stdio_server_bin()?; - write_plugin_mcp_plugin(codex_home.as_ref(), &rmcp_test_server_bin); - let codex = build_plugin_test_codex(&server, codex_home).await?; - wait_for_sample_mcp_ready(&codex).await?; - - codex.submit(Op::ListMcpTools).await?; - let list_event = wait_for_event_with_timeout( - &codex, - |ev| matches!(ev, EventMsg::McpListToolsResponse(_)), - Duration::from_secs(10), - ) - .await; - let EventMsg::McpListToolsResponse(tool_list) = list_event else { - unreachable!("event guard guarantees McpListToolsResponse"); - }; - let mut available_tools: Vec<&str> = tool_list.tools.keys().map(String::as_str).collect(); - available_tools.sort_unstable(); - assert!( - tool_list.tools.contains_key("mcp__sample__echo") - && tool_list.tools.contains_key("mcp__sample__image"), - "expected plugin MCP tools to be listed; discovered tools: {available_tools:?}" - ); - - Ok(()) -} diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 8b7b4211cf..1a7062bdb1 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -242,31 +242,42 @@ fn copy_binary_to_remote_env( Ok(remote_path) } -async fn wait_for_mcp_tool(fixture: &TestCodex, tool_name: &str) -> anyhow::Result<()> { - let tools_ready_deadline = Instant::now() + Duration::from_secs(30); - loop { - fixture.codex.submit(Op::ListMcpTools).await?; - let list_event = wait_for_event_with_timeout( - &fixture.codex, - |ev| matches!(ev, EventMsg::McpListToolsResponse(_)), - Duration::from_secs(10), - ) - .await; - let EventMsg::McpListToolsResponse(tool_list) = list_event else { - unreachable!("event guard guarantees McpListToolsResponse"); - }; - if tool_list.tools.contains_key(tool_name) { - return Ok(()); - } - - let available_tools: Vec<&str> = tool_list.tools.keys().map(String::as_str).collect(); - if Instant::now() >= tools_ready_deadline { - panic!( - "timed out waiting for MCP tool {tool_name} to become available; discovered tools: {available_tools:?}" - ); - } - sleep(Duration::from_millis(200)).await; +async fn wait_for_mcp_server(fixture: &TestCodex, server_name: &str) -> anyhow::Result<()> { + let startup_event = wait_for_event_with_timeout( + &fixture.codex, + |ev| match ev { + EventMsg::McpStartupComplete(summary) => { + summary.ready.iter().any(|server| server == server_name) + || summary + .failed + .iter() + .any(|failure| failure.server == server_name) + || summary.cancelled.iter().any(|server| server == server_name) + } + _ => false, + }, + Duration::from_secs(70), + ) + .await; + let EventMsg::McpStartupComplete(summary) = startup_event else { + unreachable!("event guard guarantees McpStartupComplete"); + }; + if let Some(failure) = summary + .failed + .iter() + .find(|failure| failure.server == server_name) + { + let error = &failure.error; + anyhow::bail!("MCP server {server_name} failed to start: {error}"); } + if summary.cancelled.iter().any(|server| server == server_name) { + anyhow::bail!("MCP server {server_name} startup was cancelled"); + } + ensure!( + summary.ready.iter().any(|server| server == server_name), + "expected MCP server {server_name} to be ready; startup summary: {summary:?}" + ); + Ok(()) } #[derive(Default)] @@ -731,7 +742,6 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()> let call_id = "sandbox-meta-call"; let server_name = "rmcp"; let namespace = format!("mcp__{server_name}__"); - let tool_name = format!("{namespace}sandbox_meta"); let call_mock = mount_sse_once( &server, @@ -767,30 +777,7 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()> .build_remote_aware(&server) .await?; - let tools_ready_deadline = Instant::now() + Duration::from_secs(30); - loop { - fixture.codex.submit(Op::ListMcpTools).await?; - let list_event = wait_for_event_with_timeout( - &fixture.codex, - |ev| matches!(ev, EventMsg::McpListToolsResponse(_)), - Duration::from_secs(10), - ) - .await; - let EventMsg::McpListToolsResponse(tool_list) = list_event else { - unreachable!("event guard guarantees McpListToolsResponse"); - }; - if tool_list.tools.contains_key(&tool_name) { - break; - } - - let available_tools: Vec<&str> = tool_list.tools.keys().map(String::as_str).collect(); - if Instant::now() >= tools_ready_deadline { - panic!( - "timed out waiting for MCP tool {tool_name} to become available; discovered tools: {available_tools:?}" - ); - } - sleep(Duration::from_millis(200)).await; - } + wait_for_mcp_server(&fixture, server_name).await?; fixture .submit_turn_with_permission_profile( @@ -1039,7 +1026,6 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { let call_id = "img-1"; let server_name = "rmcp"; - let tool_name = format!("mcp__{server_name}__image"); let namespace = format!("mcp__{server_name}__"); // First stream: model decides to call the image tool. @@ -1086,7 +1072,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { }) .build_remote_aware(&server) .await?; - wait_for_mcp_tool(&fixture, &tool_name).await?; + wait_for_mcp_server(&fixture, server_name).await?; fixture .codex @@ -1176,7 +1162,6 @@ async fn stdio_image_responses_preserve_original_detail_metadata() -> anyhow::Re let call_id = "img-original-detail-1"; let server_name = "rmcp"; - let tool_name = format!("mcp__{server_name}__image_scenario"); let namespace = format!("mcp__{server_name}__"); mount_sse_once( @@ -1219,7 +1204,7 @@ async fn stdio_image_responses_preserve_original_detail_metadata() -> anyhow::Re }) .build_remote_aware(&server) .await?; - wait_for_mcp_tool(&fixture, &tool_name).await?; + wait_for_mcp_server(&fixture, server_name).await?; fixture .codex @@ -1955,7 +1940,6 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { let call_id = "call-789"; let server_name = "rmcp_http_oauth"; - let tool_name = format!("mcp__{server_name}__echo"); let namespace = format!("mcp__{server_name}__"); mount_sse_once( @@ -2036,9 +2020,9 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { }) .build_remote_aware(&server) .await?; - // Phase 5: wait for MCP discovery to publish the expected tool before the - // turn is submitted, which keeps failures tied to server startup/discovery. - wait_for_mcp_tool(&fixture, &tool_name).await?; + // Phase 5: wait for MCP startup before the turn is submitted, which keeps + // failures tied to server startup/discovery. + wait_for_mcp_server(&fixture, server_name).await?; // Phase 6: submit the user turn that should invoke the OAuth-backed tool. fixture diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index eda970d0b2..5e2c7db346 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -598,6 +598,10 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - apps_tool_call.pointer("/params/_meta/x-codex-turn-metadata/session_id"), Some(&json!(test.session_configured.session_id.to_string())) ); + assert_eq!( + apps_tool_call.pointer("/params/_meta/x-codex-turn-metadata/thread_id"), + Some(&json!(test.session_configured.thread_id.to_string())) + ); assert!( apps_tool_call .pointer("/params/_meta/x-codex-turn-metadata/turn_id") diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_api_auth_prompt_cache_key_request_diff.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_api_auth_prompt_cache_key_request_diff.snap new file mode 100644 index 0000000000..4527899106 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_api_auth_prompt_cache_key_request_diff.snap @@ -0,0 +1,43 @@ +--- +source: core/tests/suite/compact_remote.rs +expression: "context_snapshot::format_request_body_diff_snapshot(scenario,\n\"Last Normal /responses Request\", &normal_request,\n\"Remote /responses/compact Request\", &compact_request,\n&ContextSnapshotOptions::default(),)" +--- +Scenario: After five varied API-key-auth turns, remote manual compaction reuses the normal responses service_tier and prompt_cache_key while omitting responses-only fields. + +--- Last Normal /responses Request ++++ Remote /responses/compact Request +- "client_metadata": { +- "x-codex-installation-id": "" +- }, +- "include": [ +- "reasoning.encrypted_content" +- ], ++ }, ++ { ++ "content": [ ++ { ++ "text": "turn five raw content", ++ "type": "reasoning_text" ++ } ++ ], ++ "encrypted_content": "YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYnR1cm4gZml2ZSByYXcgY29udGVudA==", ++ "summary": [ ++ { ++ "text": "TURN_FIVE_REASONING", ++ "type": "summary_text" ++ } ++ ], ++ "type": "reasoning" ++ }, ++ { ++ "content": [ ++ { ++ "text": "TURN_FIVE_ASSISTANT", ++ "type": "output_text" ++ } ++ ], ++ "role": "assistant", ++ "type": "message" +- "store": false, +- "stream": true, +- "tool_choice": "auto", diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_chatgpt_auth_service_tier_prompt_cache_key_request_diff.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_chatgpt_auth_service_tier_prompt_cache_key_request_diff.snap new file mode 100644 index 0000000000..40baae2674 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_chatgpt_auth_service_tier_prompt_cache_key_request_diff.snap @@ -0,0 +1,43 @@ +--- +source: core/tests/suite/compact_remote.rs +expression: "context_snapshot::format_request_body_diff_snapshot(scenario,\n\"Last Normal /responses Request\", &normal_request,\n\"Remote /responses/compact Request\", &compact_request,\n&ContextSnapshotOptions::default(),)" +--- +Scenario: After five varied ChatGPT-auth turns, remote manual compaction reuses the normal responses service_tier and prompt_cache_key while omitting responses-only fields. + +--- Last Normal /responses Request ++++ Remote /responses/compact Request +- "client_metadata": { +- "x-codex-installation-id": "" +- }, +- "include": [ +- "reasoning.encrypted_content" +- ], ++ }, ++ { ++ "content": [ ++ { ++ "text": "turn five raw content", ++ "type": "reasoning_text" ++ } ++ ], ++ "encrypted_content": "YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYnR1cm4gZml2ZSByYXcgY29udGVudA==", ++ "summary": [ ++ { ++ "text": "TURN_FIVE_REASONING", ++ "type": "summary_text" ++ } ++ ], ++ "type": "reasoning" ++ }, ++ { ++ "content": [ ++ { ++ "text": "TURN_FIVE_ASSISTANT", ++ "type": "output_text" ++ } ++ ], ++ "role": "assistant", ++ "type": "message" +- "store": false, +- "stream": true, +- "tool_choice": "auto", diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index 20fa8a8f0e..b2a2778047 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -48,7 +48,7 @@ async fn new_thread_is_recorded_in_state_db() -> Result<()> { }); let test = builder.build(&server).await?; - let thread_id = test.session_configured.session_id; + let thread_id = test.session_configured.thread_id; let rollout_path = test.codex.rollout_path().expect("rollout path"); let db_path = codex_state::state_db_path(test.config.sqlite_home.as_path()); @@ -262,7 +262,7 @@ async fn user_messages_persist_in_state_db() -> Result<()> { test.submit_turn("another message").await?; let db = test.codex.state_db().expect("state db enabled"); - let thread_id = test.session_configured.session_id; + let thread_id = test.session_configured.thread_id; let mut metadata = None; for _ in 0..100 { @@ -305,7 +305,7 @@ async fn web_search_marks_thread_memory_mode_polluted_when_configured() -> Resul }); let test = builder.build(&server).await?; let db = test.codex.state_db().expect("state db enabled"); - let thread_id = test.session_configured.session_id; + let thread_id = test.session_configured.thread_id; test.submit_turn("search the web").await?; @@ -397,7 +397,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< }); let test = builder.build(&server).await?; let db = test.codex.state_db().expect("state db enabled"); - let thread_id = test.session_configured.session_id; + let thread_id = test.session_configured.thread_id; let cwd = test.cwd_path().to_path_buf(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::read_only(), cwd.as_path()); @@ -478,7 +478,7 @@ async fn tool_call_logs_include_thread_id() -> Result<()> { }); let test = builder.build(&server).await?; let db = test.codex.state_db().expect("state db enabled"); - let expected_thread_id = test.session_configured.session_id.to_string(); + let expected_thread_id = test.session_configured.thread_id.to_string(); test.submit_turn("run a shell command").await?; diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index 3f457967c1..3a0c37acc7 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -116,7 +116,7 @@ async fn wait_for_spawned_thread_id(test: &TestCodex) -> Result { let ids = test.thread_manager.list_thread_ids().await; if let Some(spawned_id) = ids .iter() - .find(|id| **id != test.session_configured.session_id) + .find(|id| **id != test.session_configured.thread_id) { return Ok(spawned_id.to_string()); } diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output.rs b/codex-rs/exec/src/event_processor_with_jsonl_output.rs index 1641398ae6..045baacc7b 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -392,7 +392,7 @@ impl EventProcessorWithJsonOutput { pub fn thread_started_event(session_configured: &SessionConfiguredEvent) -> ThreadEvent { ThreadEvent::ThreadStarted(ThreadStartedEvent { - thread_id: session_configured.session_id.to_string(), + thread_id: session_configured.thread_id.to_string(), }) } diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 0db63b8034..a26dc525e6 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -78,6 +78,8 @@ use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID; use codex_otel::set_parent_from_context; use codex_otel::traceparent_context_from_env; +use codex_protocol::SessionId; +use codex_protocol::ThreadId; use codex_protocol::config_types::SandboxMode; use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::ActivePermissionProfileModification; @@ -694,7 +696,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let session_configured = session_configured_from_thread_resume_response(&response, &config) .map_err(anyhow::Error::msg)?; - (session_configured.session_id, session_configured) + (session_configured.thread_id, session_configured) } else { let response: ThreadStartResponse = send_request_with_response( &client, @@ -709,7 +711,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let session_configured = session_configured_from_thread_start_response(&response, &config) .map_err(anyhow::Error::msg)?; - (session_configured.session_id, session_configured) + (session_configured.thread_id, session_configured) } } else { let response: ThreadStartResponse = send_request_with_response( @@ -724,7 +726,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { .map_err(anyhow::Error::msg)?; let session_configured = session_configured_from_thread_start_response(&response, &config) .map_err(anyhow::Error::msg)?; - (session_configured.session_id, session_configured) + (session_configured.thread_id, session_configured) }; let primary_thread_id_for_span = primary_thread_id.to_string(); @@ -1058,12 +1060,13 @@ fn session_configured_from_thread_start_response( config: &Config, ) -> Result { session_configured_from_thread_response( + &response.thread.session_id, &response.thread.id, response.thread.name.clone(), response.thread.path.clone(), response.model.clone(), response.model_provider.clone(), - response.service_tier, + response.service_tier.clone(), response.approval_policy.to_core(), response.approvals_reviewer.to_core(), response @@ -1082,12 +1085,13 @@ fn session_configured_from_thread_resume_response( config: &Config, ) -> Result { session_configured_from_thread_response( + &response.thread.session_id, &response.thread.id, response.thread.name.clone(), response.thread.path.clone(), response.model.clone(), response.model_provider.clone(), - response.service_tier, + response.service_tier.clone(), response.approval_policy.to_core(), response.approvals_reviewer.to_core(), response @@ -1115,12 +1119,13 @@ fn review_target_to_api(target: ReviewTarget) -> ApiReviewTarget { reason = "session mapping keeps explicit fields" )] fn session_configured_from_thread_response( + session_id: &str, thread_id: &str, thread_name: Option, rollout_path: Option, model: String, model_provider_id: String, - service_tier: Option, + service_tier: Option, approval_policy: AskForApproval, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, permission_profile: PermissionProfile, @@ -1128,11 +1133,14 @@ fn session_configured_from_thread_response( cwd: AbsolutePathBuf, reasoning_effort: Option, ) -> Result { - let session_id = codex_protocol::ThreadId::from_string(thread_id) + let session_id = SessionId::from_string(session_id) + .map_err(|err| format!("session id `{session_id}` is invalid: {err}"))?; + let thread_id = ThreadId::from_string(thread_id) .map_err(|err| format!("thread id `{thread_id}` is invalid: {err}"))?; Ok(SessionConfiguredEvent { session_id, + thread_id, forked_from_id: None, thread_source: None, thread_name, @@ -1145,8 +1153,6 @@ fn session_configured_from_thread_response( active_permission_profile, cwd, reasoning_effort, - history_log_id: 0, - history_entry_count: 0, initial_messages: None, network_proxy: None, rollout_path, diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index b35f6e9bfa..caa2921d19 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -244,6 +244,7 @@ async fn resume_lookup_model_providers_filters_only_last_lookup() { fn turn_items_for_thread_returns_matching_turn_items() { let thread = AppServerThread { id: "thread-1".to_string(), + session_id: "thread-1".to_string(), forked_from_id: None, preview: String::new(), ephemeral: false, @@ -441,6 +442,14 @@ async fn session_configured_from_thread_response_uses_review_policy_from_respons let event = session_configured_from_thread_start_response(&response, &config) .expect("build bootstrap session configured event"); + assert_eq!( + event.session_id.to_string(), + "67e55044-10b1-426f-9247-bb680e5fe0c7" + ); + assert_eq!( + event.thread_id.to_string(), + "67e55044-10b1-426f-9247-bb680e5fe0c8" + ); assert_eq!(event.approvals_reviewer, ApprovalsReviewer::AutoReview); } @@ -467,6 +476,7 @@ fn sample_thread_start_response() -> ThreadStartResponse { ThreadStartResponse { thread: codex_app_server_protocol::Thread { id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(), + session_id: "67e55044-10b1-426f-9247-bb680e5fe0c7".to_string(), forked_from_id: None, preview: String::new(), ephemeral: false, diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index e066cb6ddc..efda58f412 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -27,6 +27,7 @@ use codex_app_server_protocol::TurnPlanUpdatedNotification; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::WebSearchAction as ApiWebSearchAction; +use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::models::PermissionProfile; use codex_protocol::models::WebSearchAction; @@ -104,9 +105,11 @@ fn map_todo_items_preserves_text_and_completion_state() { #[test] fn session_configured_produces_thread_started_event() { + let thread_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8") + .expect("thread id should parse"); let session_configured = SessionConfiguredEvent { - session_id: ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8") - .expect("thread id should parse"), + session_id: SessionId::from(thread_id), + thread_id, forked_from_id: None, thread_source: None, thread_name: None, @@ -119,8 +122,6 @@ fn session_configured_produces_thread_started_event() { active_permission_profile: None, cwd: test_path_buf("/tmp/project").abs(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, initial_messages: None, network_proxy: None, rollout_path: None, diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index acb6fa195e..6c8ce49894 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -134,6 +134,8 @@ pub enum Feature { Sqlite, /// Enable startup memory extraction and file-backed memory consolidation. MemoryTool, + /// Enable product-owned built-in MCP servers. + BuiltInMcp, /// Enable the Chronicle sidecar for passive screen-context memories. Chronicle, /// Append additional AGENTS.md guidance to user instructions. @@ -204,6 +206,8 @@ pub enum Feature { CollaborationModes, /// Route MCP tool approval prompts through the MCP elicitation request path. ToolCallMcpElicitation, + /// Prompt Codex Apps connector auth failures through MCP URL elicitations. + AuthElicitation, /// Enable personality selection in the TUI. Personality, /// Enable native artifact tools. @@ -227,6 +231,8 @@ pub enum Feature { ResponsesWebsockets, /// Legacy rollout flag for Responses API WebSocket transport v2 experiments. ResponsesWebsocketsV2, + /// Send `response.processed` over Responses API websockets after a turn response is recorded. + ResponsesWebsocketResponseProcessed, /// Enable remote compaction v2 over the normal Responses API. RemoteCompactionV2, /// Enable workspace dependency support. @@ -791,6 +797,12 @@ pub const FEATURES: &[FeatureSpec] = &[ }, default_enabled: false, }, + FeatureSpec { + id: Feature::BuiltInMcp, + key: "builtin_mcp", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::Chronicle, key: "chronicle", @@ -1045,6 +1057,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::AuthElicitation, + key: "auth_elicitation", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::Personality, key: "personality", @@ -1123,6 +1141,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Removed, default_enabled: false, }, + FeatureSpec { + id: Feature::ResponsesWebsocketResponseProcessed, + key: "responses_websocket_response_processed", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::RemoteCompactionV2, key: "remote_compaction_v2", diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index da0e36442c..5464fa7a61 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -129,6 +129,29 @@ fn remote_compaction_v2_is_under_development() { ); } +#[test] +fn responses_websocket_response_processed_is_under_development() { + assert_eq!( + Feature::ResponsesWebsocketResponseProcessed.stage(), + Stage::UnderDevelopment + ); + assert_eq!( + Feature::ResponsesWebsocketResponseProcessed.default_enabled(), + false + ); + assert_eq!( + feature_for_key("responses_websocket_response_processed"), + Some(Feature::ResponsesWebsocketResponseProcessed) + ); +} + +#[test] +fn builtin_mcp_is_under_development() { + assert_eq!(Feature::BuiltInMcp.stage(), Stage::UnderDevelopment); + assert_eq!(Feature::BuiltInMcp.default_enabled(), false); + assert_eq!(feature_for_key("builtin_mcp"), Some(Feature::BuiltInMcp)); +} + #[test] fn terminal_resize_reflow_is_experimental_and_enabled_by_default() { assert_eq!( @@ -247,6 +270,16 @@ fn tool_call_mcp_elicitation_is_stable_and_enabled_by_default() { assert_eq!(Feature::ToolCallMcpElicitation.default_enabled(), true); } +#[test] +fn auth_elicitation_is_under_development() { + assert_eq!(Feature::AuthElicitation.stage(), Stage::UnderDevelopment); + assert_eq!(Feature::AuthElicitation.default_enabled(), false); + assert_eq!( + feature_for_key("auth_elicitation"), + Some(Feature::AuthElicitation) + ); +} + #[test] fn remote_control_is_under_development() { assert_eq!(Feature::RemoteControl.stage(), Stage::UnderDevelopment); diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 772cae63b9..b462022dbc 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -334,7 +334,6 @@ async fn run_codex_tool_session_inner( | EventMsg::AgentReasoningSectionBreak(_) | EventMsg::McpToolCallBegin(_) | EventMsg::McpToolCallEnd(_) - | EventMsg::McpListToolsResponse(_) | EventMsg::RealtimeConversationListVoicesResponse(_) | EventMsg::ExecCommandBegin(_) | EventMsg::TerminalInteraction(_) @@ -347,7 +346,6 @@ async fn run_codex_tool_session_inner( | EventMsg::TurnDiff(_) | EventMsg::WebSearchBegin(_) | EventMsg::WebSearchEnd(_) - | EventMsg::GetHistoryEntryResponse(_) | EventMsg::PlanUpdate(_) | EventMsg::TurnAborted(_) | EventMsg::UserMessage(_) diff --git a/codex-rs/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs index ac764456f5..2fd94e586a 100644 --- a/codex-rs/mcp-server/src/lib.rs +++ b/codex-rs/mcp-server/src/lib.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use codex_arg0::Arg0DispatchPaths; use codex_core::config::Config; +use codex_core::resolve_installation_id; use codex_exec_server::EnvironmentManager; use codex_exec_server::EnvironmentManagerArgs; use codex_exec_server::ExecServerRuntimePaths; @@ -112,6 +113,7 @@ pub async fn run_main( // Set up channels. let (incoming_tx, mut incoming_rx) = mpsc::channel::(CHANNEL_CAPACITY); let (outgoing_tx, mut outgoing_rx) = mpsc::unbounded_channel::(); + let installation_id = resolve_installation_id(&config.codex_home).await?; // Task: read from stdin, push to `incoming_tx`. let stdin_reader_handle = tokio::spawn({ @@ -144,6 +146,7 @@ pub async fn run_main( arg0_paths, Arc::new(config), environment_manager, + installation_id, ) .await; async move { diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 2591891e4d..73cdb6193b 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -55,6 +55,7 @@ impl MessageProcessor { arg0_paths: Arg0DispatchPaths, config: Arc, environment_manager: Arc, + installation_id: String, ) -> Option { let outgoing = Arc::new(outgoing); let auth_manager = AuthManager::shared_from_config( @@ -74,6 +75,7 @@ impl MessageProcessor { state_db, thread_store, agent_graph_store, + installation_id, )); Some(Self { outgoing, diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index 1ab6b1a0af..b2882643ce 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -296,7 +296,8 @@ mod tests { let event = Event { id: "1".to_string(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id: thread_id, + session_id: codex_protocol::SessionId::new(), + thread_id, forked_from_id: None, thread_source: None, thread_name: None, @@ -309,8 +310,6 @@ mod tests { active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffort::default()), - history_log_id: 1, - history_entry_count: 1000, initial_messages: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -339,10 +338,11 @@ mod tests { let (outgoing_tx, mut outgoing_rx) = mpsc::unbounded_channel::(); let outgoing_message_sender = OutgoingMessageSender::new(outgoing_tx); - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new()?; let session_configured_event = SessionConfiguredEvent { - session_id: conversation_id, + session_id: codex_protocol::SessionId::new(), + thread_id, forked_from_id: None, thread_source: None, thread_name: None, @@ -355,8 +355,6 @@ mod tests { active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffort::default()), - history_log_id: 1, - history_entry_count: 1000, initial_messages: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -387,6 +385,7 @@ mod tests { "msg": { "type": "session_configured", "session_id": session_configured_event.session_id, + "thread_id": session_configured_event.thread_id, "model": "gpt-4o", "model_provider_id": "test-provider", "approval_policy": "never", @@ -394,8 +393,6 @@ mod tests { "permission_profile": session_configured_event.permission_profile, "cwd": test_path_buf("/home/user/project"), "reasoning_effort": session_configured_event.reasoning_effort, - "history_log_id": session_configured_event.history_log_id, - "history_entry_count": session_configured_event.history_entry_count, "rollout_path": rollout_file.path().to_path_buf(), } }); @@ -411,7 +408,8 @@ mod tests { let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new()?; let session_configured_event = SessionConfiguredEvent { - session_id: thread_id, + session_id: codex_protocol::SessionId::new(), + thread_id, forked_from_id: None, thread_source: None, thread_name: None, @@ -424,8 +422,6 @@ mod tests { active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffort::default()), - history_log_id: 1, - history_entry_count: 1000, initial_messages: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -457,6 +453,7 @@ mod tests { "msg": { "type": "session_configured", "session_id": session_configured_event.session_id, + "thread_id": session_configured_event.thread_id, "model": "gpt-4o", "model_provider_id": "test-provider", "approval_policy": "never", @@ -464,8 +461,6 @@ mod tests { "permission_profile": session_configured_event.permission_profile, "cwd": test_path_buf("/home/user/project"), "reasoning_effort": session_configured_event.reasoning_effort, - "history_log_id": session_configured_event.history_log_id, - "history_entry_count": session_configured_event.history_entry_count, "rollout_path": rollout_file.path().to_path_buf(), } }); diff --git a/codex-rs/memories/README.md b/codex-rs/memories/README.md index 9195e89ada..73aefde297 100644 --- a/codex-rs/memories/README.md +++ b/codex-rs/memories/README.md @@ -10,6 +10,8 @@ Runtime orchestration for Phase 1 and Phase 2 still lives in `codex-core` under - `codex-rs/memories/read` (`codex-memories-read`) owns the read path: memory developer-instruction injection, memory citation parsing, and read-usage telemetry classification. +- `codex-rs/memories/mcp` (`codex-memories-mcp`) exposes the read-only memory + filesystem through the built-in MCP surface. - `codex-rs/memories/write` (`codex-memories-write`) owns the write path: Phase 1 and Phase 2 prompt rendering, filesystem artifact helpers, workspace diff helpers, and extension resource pruning. diff --git a/codex-rs/memories/write/src/runtime.rs b/codex-rs/memories/write/src/runtime.rs index 5db8761058..53a10934a5 100644 --- a/codex-rs/memories/write/src/runtime.rs +++ b/codex-rs/memories/write/src/runtime.rs @@ -15,9 +15,9 @@ use codex_login::auth_env_telemetry::collect_auth_env_telemetry; use codex_login::default_client::originator; use codex_otel::SessionTelemetry; use codex_otel::TelemetryAuthMode; +use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::config_types::ReasoningSummary; -use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::InitialHistory; @@ -45,7 +45,7 @@ pub(crate) struct StageOneRequestContext { pub(crate) session_telemetry: SessionTelemetry, pub(crate) reasoning_effort: Option, pub(crate) reasoning_summary: ReasoningSummary, - pub(crate) service_tier: Option, + pub(crate) service_tier: Option, pub(crate) turn_metadata_header: Option, } @@ -174,6 +174,7 @@ impl MemoryStartupContext { let session_source = self.thread.config_snapshot().await.session_source; let model_client = ModelClient::new( Some(Arc::clone(&self.auth_manager)), + SessionId::from(self.thread_id), self.thread_id, installation_id, config.model_provider.clone(), @@ -192,7 +193,7 @@ impl MemoryStartupContext { &context.session_telemetry, context.reasoning_effort, context.reasoning_summary, - context.service_tier, + context.service_tier.clone(), context.turn_metadata_header.as_deref(), &InferenceTraceContext::disabled(), ) diff --git a/codex-rs/memories/write/src/startup_tests.rs b/codex-rs/memories/write/src/startup_tests.rs index d89b68825e..4fcb1d409b 100644 --- a/codex-rs/memories/write/src/startup_tests.rs +++ b/codex-rs/memories/write/src/startup_tests.rs @@ -253,19 +253,23 @@ async fn memories_startup_phase1_uses_live_thread_service_tier() -> anyhow::Resu model: None, effort: None, summary: None, - service_tier: Some(Some(ServiceTier::Fast)), + service_tier: Some(Some(ServiceTier::Fast.request_value().to_string())), collaboration_mode: None, personality: None, }) .await?; - let config_snapshot = wait_for_service_tier(&test, Some(ServiceTier::Fast)).await?; - assert_eq!(config_snapshot.service_tier, Some(ServiceTier::Fast)); + let config_snapshot = + wait_for_service_tier(&test, Some(ServiceTier::Fast.request_value().to_string())).await?; + assert_eq!( + config_snapshot.service_tier, + Some(ServiceTier::Fast.request_value().to_string()) + ); let context = crate::runtime::MemoryStartupContext::new( Arc::clone(&test.thread_manager), test.thread_manager.auth_manager(), - test.session_configured.session_id, + test.session_configured.thread_id, Arc::clone(&test.codex), &test.config, config_snapshot.session_source.clone(), @@ -277,7 +281,10 @@ async fn memories_startup_phase1_uses_live_thread_service_tier() -> anyhow::Resu ReasoningEffort::Low, ) .await; - assert_eq!(request_context.service_tier, Some(ServiceTier::Fast)); + assert_eq!( + request_context.service_tier, + Some(ServiceTier::Fast.request_value().to_string()) + ); shutdown_test_codex(&test).await?; Ok(()) @@ -317,7 +324,7 @@ async fn trigger_memories_startup(test: &TestCodex) { start_memories_startup_task( Arc::clone(&test.thread_manager), test.thread_manager.auth_manager(), - test.session_configured.session_id, + test.session_configured.thread_id, Arc::clone(&test.codex), Arc::new(config), &config_snapshot.session_source, @@ -394,7 +401,7 @@ async fn wait_for_request(mock: &ResponseMock, expected_count: usize) -> Vec, + expected_service_tier: Option, ) -> anyhow::Result { let deadline = Instant::now() + Duration::from_secs(10); loop { diff --git a/codex-rs/message-history/BUILD.bazel b/codex-rs/message-history/BUILD.bazel new file mode 100644 index 0000000000..70df76cf77 --- /dev/null +++ b/codex-rs/message-history/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "message-history", + crate_name = "codex_message_history", +) diff --git a/codex-rs/message-history/Cargo.toml b/codex-rs/message-history/Cargo.toml new file mode 100644 index 0000000000..34bffd687a --- /dev/null +++ b/codex-rs/message-history/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "codex-message-history" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_message_history" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +codex-config = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["fs", "io-util", "rt"] } +tracing = { workspace = true, features = ["log"] } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/codex-rs/core/src/message_history.rs b/codex-rs/message-history/src/lib.rs similarity index 90% rename from codex-rs/core/src/message_history.rs rename to codex-rs/message-history/src/lib.rs index 3458ec7306..0d85cb8fe3 100644 --- a/codex-rs/core/src/message_history.rs +++ b/codex-rs/message-history/src/lib.rs @@ -5,16 +5,14 @@ //! JSON-Lines tooling. Each record has the following schema: //! //! ````text -//! {"conversation_id":"","ts":,"text":""} +//! {"session_id":"","ts":,"text":""} //! ```` //! -//! To minimise the chance of interleaved writes when multiple processes are +//! To minimize the chance of interleaved writes when multiple processes are //! appending concurrently, callers should *prepare the full line* (record + //! trailing `\n`) and write it with a **single `write(2)` system call** while //! the file descriptor is opened with the `O_APPEND` flag. POSIX guarantees //! that writes up to `PIPE_BUF` bytes are atomic in that case. -//! Note: `conversation_id` stores the thread id; the field name is preserved for -//! backwards compatibility with existing history files. use std::fs::File; use std::fs::OpenOptions; @@ -26,6 +24,7 @@ use std::io::Seek; use std::io::SeekFrom; use std::io::Write; use std::path::Path; +use std::path::PathBuf; use serde::Deserialize; use serde::Serialize; @@ -34,11 +33,9 @@ use std::time::Duration; use tokio::fs; use tokio::io::AsyncReadExt; -use crate::config::Config; +use codex_config::types::History; use codex_config::types::HistoryPersistence; -use codex_utils_absolute_path::AbsolutePathBuf; -use codex_protocol::ThreadId; #[cfg(unix)] use std::os::unix::fs::OpenOptionsExt; #[cfg(unix)] @@ -60,7 +57,24 @@ pub struct HistoryEntry { pub text: String, } -fn history_filepath(config: &Config) -> AbsolutePathBuf { +#[derive(Debug, Clone, PartialEq)] +pub struct HistoryConfig { + pub codex_home: PathBuf, + pub persistence: HistoryPersistence, + pub max_bytes: Option, +} + +impl HistoryConfig { + pub fn new(codex_home: impl Into, history: &History) -> Self { + Self { + codex_home: codex_home.into(), + persistence: history.persistence, + max_bytes: history.max_bytes, + } + } +} + +fn history_filepath(config: &HistoryConfig) -> PathBuf { config.codex_home.join(HISTORY_FILENAME) } @@ -79,8 +93,12 @@ fn history_filepath(config: &Config) -> AbsolutePathBuf { /// Returns an I/O error if the history file cannot be opened/created, the /// system clock is before the Unix epoch, or the exclusive lock cannot be /// acquired after [`MAX_RETRIES`] attempts. -pub async fn append_entry(text: &str, conversation_id: &ThreadId, config: &Config) -> Result<()> { - match config.history.persistence { +pub async fn append_entry( + text: &str, + conversation_id: impl std::fmt::Display, + config: &HistoryConfig, +) -> Result<()> { + match config.persistence { HistoryPersistence::SaveAll => { // Save everything: proceed. } @@ -128,7 +146,7 @@ pub async fn append_entry(text: &str, conversation_id: &ThreadId, config: &Confi // Ensure permissions. ensure_owner_only_permissions(&history_file).await?; - let history_max_bytes = config.history.max_bytes; + let history_max_bytes = config.max_bytes; // Perform a blocking write under an advisory write lock using std::fs. tokio::task::spawn_blocking(move || -> Result<()> { @@ -256,7 +274,7 @@ fn trim_target_bytes(max_bytes: u64, newest_entry_len: u64) -> u64 { /// `(0, 0)` when the file does not exist or its metadata cannot be read. If /// metadata succeeds but the file cannot be opened or scanned, returns /// `(log_id, 0)` so callers can still detect that a history file exists. -pub async fn history_metadata(config: &Config) -> (u64, usize) { +pub async fn history_metadata(config: &HistoryConfig) -> (u64, usize) { let path = history_filepath(config); history_metadata_for_file(&path).await } @@ -271,7 +289,7 @@ pub async fn history_metadata(config: &Config) -> (u64, usize) { /// This function is synchronous because it acquires a shared advisory file lock /// via `File::try_lock_shared`. Callers on an async runtime should wrap it in /// `spawn_blocking`. -pub fn lookup(log_id: u64, offset: usize, config: &Config) -> Option { +pub fn lookup(log_id: u64, offset: usize, config: &HistoryConfig) -> Option { let path = history_filepath(config); lookup_history_entry(&path, log_id, offset) } @@ -300,7 +318,7 @@ async fn ensure_owner_only_permissions(_file: &File) -> Result<()> { async fn history_metadata_for_file(path: &Path) -> (u64, usize) { let log_id = match fs::metadata(path).await { - Ok(metadata) => history_log_id(&metadata).unwrap_or(0), + Ok(metadata) => log_identity(&metadata).unwrap_or(0), Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (0, 0), Err(_) => return (0, 0), }; @@ -347,7 +365,7 @@ fn lookup_history_entry(path: &Path, log_id: u64, offset: usize) -> Option Option Option { +fn log_identity(metadata: &std::fs::Metadata) -> Option { use std::os::unix::fs::MetadataExt; Some(metadata.ino()) } #[cfg(windows)] -fn history_log_id(metadata: &std::fs::Metadata) -> Option { +fn log_identity(metadata: &std::fs::Metadata) -> Option { use std::os::windows::fs::MetadataExt; Some(metadata.creation_time()) } #[cfg(not(any(unix, windows)))] -fn history_log_id(_metadata: &std::fs::Metadata) -> Option { +fn log_identity(_metadata: &std::fs::Metadata) -> Option { None } #[cfg(test)] -#[path = "message_history_tests.rs"] mod tests; diff --git a/codex-rs/core/src/message_history_tests.rs b/codex-rs/message-history/src/tests.rs similarity index 88% rename from codex-rs/core/src/message_history_tests.rs rename to codex-rs/message-history/src/tests.rs index de89a3eb9c..88f0b7e007 100644 --- a/codex-rs/core/src/message_history_tests.rs +++ b/codex-rs/message-history/src/tests.rs @@ -1,6 +1,5 @@ use super::*; -use crate::config::ConfigBuilder; -use codex_protocol::ThreadId; +use codex_config::types::History; use pretty_assertions::assert_eq; use std::fs::File; use std::io::Write; @@ -88,14 +87,9 @@ async fn lookup_uses_stable_log_id_after_appends() { #[tokio::test] async fn append_entry_trims_history_when_beyond_max_bytes() { let codex_home = TempDir::new().expect("create temp dir"); - - let mut config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .build() - .await - .expect("load config"); - - let conversation_id = ThreadId::new(); + let mut history = History::default(); + let mut config = HistoryConfig::new(codex_home.path(), &history); + let conversation_id = "conversation-id"; let entry_one = "a".repeat(200); let entry_two = "b".repeat(200); @@ -109,8 +103,8 @@ async fn append_entry_trims_history_when_beyond_max_bytes() { let first_len = std::fs::metadata(&history_path).expect("metadata").len(); let limit_bytes = first_len + 10; - config.history.max_bytes = - Some(usize::try_from(limit_bytes).expect("limit should fit into usize")); + history.max_bytes = Some(usize::try_from(limit_bytes).expect("limit should fit into usize")); + config = HistoryConfig::new(codex_home.path(), &history); append_entry(&entry_two, &conversation_id, &config) .await @@ -135,14 +129,9 @@ async fn append_entry_trims_history_when_beyond_max_bytes() { #[tokio::test] async fn append_entry_trims_history_to_soft_cap() { let codex_home = TempDir::new().expect("create temp dir"); - - let mut config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .build() - .await - .expect("load config"); - - let conversation_id = ThreadId::new(); + let mut history = History::default(); + let mut config = HistoryConfig::new(codex_home.path(), &history); + let conversation_id = "conversation-id"; let short_entry = "a".repeat(200); let long_entry = "b".repeat(400); @@ -165,10 +154,11 @@ async fn append_entry_trims_history_to_soft_cap() { .checked_sub(short_entry_len) .expect("second entry length should be larger than first entry length"); - config.history.max_bytes = Some( + history.max_bytes = Some( usize::try_from((2 * long_entry_len) + (short_entry_len / 2)) .expect("max bytes should fit into usize"), ); + config = HistoryConfig::new(codex_home.path(), &history); append_entry(&long_entry, &conversation_id, &config) .await @@ -185,10 +175,7 @@ async fn append_entry_trims_history_to_soft_cap() { assert_eq!(entries[0].text, long_entry); let pruned_len = std::fs::metadata(&history_path).expect("metadata").len(); - let max_bytes = config - .history - .max_bytes - .expect("max bytes should be configured") as u64; + let max_bytes = config.max_bytes.expect("max bytes should be configured") as u64; assert!(pruned_len <= max_bytes); diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index da83ee858a..47dc15f183 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -355,6 +355,23 @@ pub enum ServiceTier { Flex, } +impl ServiceTier { + pub const fn request_value(self) -> &'static str { + match self { + Self::Fast => "priority", + Self::Flex => "flex", + } + } + + pub fn from_request_value(value: &str) -> Option { + match value { + "fast" | "priority" => Some(Self::Fast), + "flex" => Some(Self::Flex), + _ => None, + } + } +} + #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs index 175c92331f..56248fd27e 100644 --- a/codex-rs/protocol/src/lib.rs +++ b/codex-rs/protocol/src/lib.rs @@ -1,9 +1,11 @@ pub mod account; mod agent_path; pub mod auth; +mod session_id; mod thread_id; mod tool_name; pub use agent_path::AgentPath; +pub use session_id::SessionId; pub use thread_id::ThreadId; pub use tool_name::ToolName; pub mod approvals; @@ -14,7 +16,6 @@ pub mod exec_output; pub mod items; pub mod mcp; pub mod memory_citation; -pub mod message_history; pub mod models; pub mod network_policy; pub mod num_format; diff --git a/codex-rs/protocol/src/message_history.rs b/codex-rs/protocol/src/message_history.rs deleted file mode 100644 index 0d8bd8df4e..0000000000 --- a/codex-rs/protocol/src/message_history.rs +++ /dev/null @@ -1,11 +0,0 @@ -use schemars::JsonSchema; -use serde::Deserialize; -use serde::Serialize; -use ts_rs::TS; - -#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] -pub struct HistoryEntry { - pub conversation_id: String, - pub ts: u64, - pub text: String, -} diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 09be839f93..4eb1b8804f 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -14,6 +14,7 @@ use std::time::Duration; use strum_macros::EnumIter; use crate::AgentPath; +use crate::SessionId; use crate::ThreadId; use crate::approvals::ElicitationRequestEvent; use crate::config_types::ApprovalsReviewer; @@ -21,7 +22,6 @@ use crate::config_types::CollaborationMode; use crate::config_types::ModeKind; use crate::config_types::Personality; use crate::config_types::ReasoningSummary as ReasoningSummaryConfig; -use crate::config_types::ServiceTier; use crate::config_types::WindowsSandboxLevel; use crate::dynamic_tools::DynamicToolCallOutputContentItem; use crate::dynamic_tools::DynamicToolCallRequest; @@ -30,11 +30,7 @@ use crate::dynamic_tools::DynamicToolSpec; use crate::items::TurnItem; use crate::mcp::CallToolResult; use crate::mcp::RequestId; -use crate::mcp::Resource as McpResource; -use crate::mcp::ResourceTemplate as McpResourceTemplate; -use crate::mcp::Tool as McpTool; use crate::memory_citation::MemoryCitation; -use crate::message_history::HistoryEntry; use crate::models::ActivePermissionProfile; use crate::models::BaseInstructions; use crate::models::ContentItem; @@ -513,7 +509,7 @@ pub enum Op { /// Use `Some(Some(_))` to set a specific tier, `Some(None)` to clear the /// preference, or `None` to leave the existing value unchanged. #[serde(skip_serializing_if = "Option::is_none")] - service_tier: Option>, + service_tier: Option>, /// EXPERIMENTAL - set a pre-set collaboration mode. /// Takes precedence over model, effort, and developer instructions if set. @@ -574,7 +570,7 @@ pub enum Op { /// explicitly clear the tier for this turn, or `None` to keep the existing /// session preference. #[serde(default, skip_serializing_if = "Option::is_none")] - service_tier: Option>, + service_tier: Option>, // The JSON schema to use for the final assistant message final_output_json_schema: Option, @@ -651,7 +647,7 @@ pub enum Op { /// Use `Some(Some(_))` to set a specific tier, `Some(None)` to clear the /// preference, or `None` to leave the existing value unchanged. #[serde(skip_serializing_if = "Option::is_none")] - service_tier: Option>, + service_tier: Option>, /// EXPERIMENTAL - set a pre-set collaboration mode. /// Takes precedence over model, effort, and developer instructions if set. @@ -723,22 +719,6 @@ pub enum Op { response: DynamicToolResponse, }, - /// Append an entry to the persistent cross-session message history. - /// - /// Note the entry is not guaranteed to be logged if the user has - /// history disabled, it matches the list of "sensitive" patterns, etc. - AddToHistory { - /// The message text to be stored. - text: String, - }, - - /// Request a single history entry identified by `log_id` + `offset`. - GetHistoryEntryRequest { offset: usize, log_id: u64 }, - - /// Request the list of MCP tools available across all configured servers. - /// Reply is delivered via `EventMsg::McpListToolsResponse`. - ListMcpTools, - /// Request MCP servers to reinitialize and refresh cached tool lists. RefreshMcpServers { config: McpServerRefreshConfig }, @@ -875,9 +855,6 @@ impl Op { Self::UserInputAnswer { .. } => "user_input_answer", Self::RequestPermissionsResponse { .. } => "request_permissions_response", Self::DynamicToolResponse { .. } => "dynamic_tool_response", - Self::AddToHistory { .. } => "add_to_history", - Self::GetHistoryEntryRequest { .. } => "get_history_entry_request", - Self::ListMcpTools => "list_mcp_tools", Self::RefreshMcpServers { .. } => "refresh_mcp_servers", Self::ReloadUserConfig => "reload_user_config", Self::Compact => "compact", @@ -1421,12 +1398,6 @@ pub enum EventMsg { TurnDiff(TurnDiffEvent), - /// Response to GetHistoryEntryRequest. - GetHistoryEntryResponse(GetHistoryEntryResponseEvent), - - /// List of MCP tools available to the agent. - McpListToolsResponse(McpListToolsResponseEvent), - /// List of voices supported by realtime conversation streams. RealtimeConversationListVoicesResponse(RealtimeConversationListVoicesResponseEvent), @@ -3265,27 +3236,6 @@ pub struct TurnDiffEvent { pub unified_diff: String, } -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] -pub struct GetHistoryEntryResponseEvent { - pub offset: usize, - pub log_id: u64, - /// The entry at the requested offset, if available and parseable. - #[serde(skip_serializing_if = "Option::is_none")] - pub entry: Option, -} - -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] -pub struct McpListToolsResponseEvent { - /// Fully qualified tool name -> tool definition. - pub tools: std::collections::HashMap, - /// Known resources grouped by server name. - pub resources: std::collections::HashMap>, - /// Known resource templates grouped by server name. - pub resource_templates: std::collections::HashMap>, - /// Authentication status for each configured MCP server. - pub auth_statuses: std::collections::HashMap, -} - #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct McpStartupUpdateEvent { /// Server name being started. @@ -3456,7 +3406,8 @@ pub struct SessionNetworkProxyRuntime { #[derive(Debug, Clone, Serialize, JsonSchema, TS)] pub struct SessionConfiguredEvent { - pub session_id: ThreadId, + pub session_id: SessionId, + pub thread_id: ThreadId, #[serde(skip_serializing_if = "Option::is_none")] pub forked_from_id: Option, /// Optional analytics source classification for this thread. @@ -3474,7 +3425,7 @@ pub struct SessionConfiguredEvent { pub model_provider_id: String, #[serde(skip_serializing_if = "Option::is_none")] - pub service_tier: Option, + pub service_tier: Option, /// When to escalate for approval for execution pub approval_policy: AskForApproval, @@ -3502,12 +3453,6 @@ pub struct SessionConfiguredEvent { #[serde(skip_serializing_if = "Option::is_none")] pub reasoning_effort: Option, - /// Identifier of the history log file (inode on Unix, 0 otherwise). - pub history_log_id: u64, - - /// Current number of entries in the history log. - pub history_entry_count: usize, - /// Optional initial messages (as events) for resumed sessions. /// When present, UIs can use these to seed the history. #[serde(skip_serializing_if = "Option::is_none")] @@ -3530,7 +3475,9 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent { { #[derive(Deserialize)] struct Wire { - session_id: ThreadId, + session_id: SessionId, + #[serde(default)] + thread_id: Option, forked_from_id: Option, #[serde(default)] thread_source: Option, @@ -3538,7 +3485,7 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent { thread_name: Option, model: String, model_provider_id: String, - service_tier: Option, + service_tier: Option, approval_policy: AskForApproval, #[serde(default)] approvals_reviewer: ApprovalsReviewer, @@ -3551,8 +3498,6 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent { active_permission_profile: Option, cwd: AbsolutePathBuf, reasoning_effort: Option, - history_log_id: u64, - history_entry_count: usize, initial_messages: Option>, network_proxy: Option, rollout_path: Option, @@ -3572,6 +3517,7 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent { Ok(Self { session_id: wire.session_id, + thread_id: wire.thread_id.unwrap_or_else(|| wire.session_id.into()), forked_from_id: wire.forked_from_id, thread_source: wire.thread_source, thread_name: wire.thread_name, @@ -3584,8 +3530,6 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent { active_permission_profile: wire.active_permission_profile, cwd: wire.cwd, reasoning_effort: wire.reasoning_effort, - history_log_id: wire.history_log_id, - history_entry_count: wire.history_entry_count, initial_messages: wire.initial_messages, network_proxy: wire.network_proxy, rollout_path: wire.rollout_path, @@ -5294,13 +5238,15 @@ mod tests { /// amount of nesting. #[test] fn serialize_event() -> Result<()> { - let conversation_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?; + let session_id = SessionId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c7")?; + let thread_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?; let rollout_file = NamedTempFile::new()?; let permission_profile = PermissionProfile::read_only(); let event = Event { id: "1234".to_string(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id: conversation_id, + session_id, + thread_id, forked_from_id: None, thread_source: None, thread_name: None, @@ -5313,8 +5259,6 @@ mod tests { active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, initial_messages: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -5325,7 +5269,8 @@ mod tests { "id": "1234", "msg": { "type": "session_configured", - "session_id": "67e55044-10b1-426f-9247-bb680e5fe0c8", + "session_id": "67e55044-10b1-426f-9247-bb680e5fe0c7", + "thread_id": "67e55044-10b1-426f-9247-bb680e5fe0c8", "model": "codex-mini-latest", "model_provider_id": "openai", "approval_policy": "never", @@ -5333,8 +5278,6 @@ mod tests { "permission_profile": permission_profile, "cwd": test_path_buf("/home/user/project"), "reasoning_effort": "medium", - "history_log_id": 0, - "history_entry_count": 0, "rollout_path": format!("{}", rollout_file.path().display()), } }); @@ -5355,8 +5298,6 @@ mod tests { "type": "read-only" }, "cwd": cwd, - "history_log_id": 0, - "history_entry_count": 0, }); let event: SessionConfiguredEvent = serde_json::from_value(value)?; diff --git a/codex-rs/protocol/src/session_id.rs b/codex-rs/protocol/src/session_id.rs new file mode 100644 index 0000000000..ac22103d33 --- /dev/null +++ b/codex-rs/protocol/src/session_id.rs @@ -0,0 +1,126 @@ +use std::fmt::Display; + +use schemars::JsonSchema; +use schemars::r#gen::SchemaGenerator; +use schemars::schema::Schema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; +use uuid::Uuid; + +use crate::ThreadId; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, TS, Hash)] +#[ts(type = "string")] +pub struct SessionId { + pub(crate) uuid: Uuid, +} + +impl SessionId { + pub fn new() -> Self { + Self { + uuid: Uuid::now_v7(), + } + } + + pub fn from_string(s: &str) -> Result { + Ok(Self { + uuid: Uuid::parse_str(s)?, + }) + } +} + +impl TryFrom<&str> for SessionId { + type Error = uuid::Error; + + fn try_from(value: &str) -> Result { + Self::from_string(value) + } +} + +impl TryFrom for SessionId { + type Error = uuid::Error; + + fn try_from(value: String) -> Result { + Self::from_string(value.as_str()) + } +} + +impl From for String { + fn from(value: SessionId) -> Self { + value.to_string() + } +} + +impl From for SessionId { + fn from(value: ThreadId) -> Self { + Self { uuid: value.uuid } + } +} + +impl From for ThreadId { + fn from(value: SessionId) -> Self { + ThreadId { uuid: value.uuid } + } +} + +impl Default for SessionId { + fn default() -> Self { + Self::new() + } +} + +impl Display for SessionId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Display::fmt(&self.uuid, f) + } +} + +impl Serialize for SessionId { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.collect_str(&self.uuid) + } +} + +impl<'de> Deserialize<'de> for SessionId { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + let uuid = Uuid::parse_str(&value).map_err(serde::de::Error::custom)?; + Ok(Self { uuid }) + } +} + +impl JsonSchema for SessionId { + fn schema_name() -> String { + "SessionId".to_string() + } + + fn json_schema(generator: &mut SchemaGenerator) -> Schema { + ::json_schema(generator) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_session_id_default_is_not_zeroes() { + let id = SessionId::default(); + assert_ne!(id.uuid, Uuid::nil()); + } + + #[test] + fn converts_to_and_from_thread_id() { + let thread_id = ThreadId::new(); + let session_id = SessionId::from(thread_id); + + assert_eq!(ThreadId::from(session_id), thread_id); + } +} diff --git a/codex-rs/protocol/src/thread_id.rs b/codex-rs/protocol/src/thread_id.rs index 8d6d96eff8..d6e9a8825e 100644 --- a/codex-rs/protocol/src/thread_id.rs +++ b/codex-rs/protocol/src/thread_id.rs @@ -11,7 +11,7 @@ use uuid::Uuid; #[derive(Debug, Clone, Copy, PartialEq, Eq, TS, Hash)] #[ts(type = "string")] pub struct ThreadId { - uuid: Uuid, + pub(crate) uuid: Uuid, } impl ThreadId { diff --git a/codex-rs/rollout-trace/src/protocol_event.rs b/codex-rs/rollout-trace/src/protocol_event.rs index 4471474154..3d52798b8d 100644 --- a/codex-rs/rollout-trace/src/protocol_event.rs +++ b/codex-rs/rollout-trace/src/protocol_event.rs @@ -259,8 +259,6 @@ pub(crate) fn tool_runtime_trace_event(event: &EventMsg) -> Option Option<&'static s | EventMsg::PatchApplyUpdated(_) | EventMsg::PatchApplyEnd(_) | EventMsg::TurnDiff(_) - | EventMsg::GetHistoryEntryResponse(_) - | EventMsg::McpListToolsResponse(_) | EventMsg::RealtimeConversationListVoicesResponse(_) | EventMsg::SkillsUpdateAvailable | EventMsg::PlanUpdate(_) diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index a965231ef1..558c3fef98 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -154,8 +154,6 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option { | EventMsg::PatchApplyBegin(_) | EventMsg::PatchApplyUpdated(_) | EventMsg::TurnDiff(_) - | EventMsg::GetHistoryEntryResponse(_) - | EventMsg::McpListToolsResponse(_) | EventMsg::RealtimeConversationListVoicesResponse(_) | EventMsg::McpStartupUpdate(_) | EventMsg::McpStartupComplete(_) diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index 7a5e28a5a3..6b549b745c 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -8,7 +8,10 @@ use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; +use std::time::Duration; +use std::time::Instant; +use chrono::DateTime; use chrono::SecondsFormat; use chrono::Utc; use codex_protocol::ThreadId; @@ -1430,9 +1433,28 @@ struct RolloutWriterState { state_builder: Option, default_provider: String, generate_memories: bool, + thread_updated_at_touch: ThreadUpdatedAtTouch, last_logged_error: Option, } +#[cfg(not(test))] +const THREAD_UPDATED_AT_TOUCH_INTERVAL: Duration = Duration::from_secs(5); +#[cfg(test)] +const THREAD_UPDATED_AT_TOUCH_INTERVAL: Duration = Duration::from_millis(50); + +#[derive(Default)] +struct ThreadUpdatedAtTouch { + last_persisted_at: Option, + pending_touch: Option<(ThreadId, DateTime)>, +} + +impl ThreadUpdatedAtTouch { + fn mark_persisted(&mut self, now: Instant) { + self.last_persisted_at = Some(now); + self.pending_touch = None; + } +} + impl RolloutWriterState { #[allow(clippy::too_many_arguments)] fn new( @@ -1460,6 +1482,7 @@ impl RolloutWriterState { state_builder, default_provider, generate_memories, + thread_updated_at_touch: ThreadUpdatedAtTouch::default(), last_logged_error: None, } } @@ -1492,7 +1515,19 @@ impl RolloutWriterState { if self.is_deferred() && self.pending_items.is_empty() { return Ok(()); } - self.write_pending_with_recovery("shutdown").await + self.write_pending_with_recovery("shutdown").await?; + if let Some((thread_id, updated_at)) = self.thread_updated_at_touch.pending_touch.take() + && state_db::touch_thread_updated_at( + self.state_db_ctx.as_deref(), + Some(thread_id), + updated_at, + "rollout_writer_shutdown", + ) + .await + { + self.thread_updated_at_touch.mark_persisted(Instant::now()); + } + Ok(()) } async fn write_pending_with_recovery(&mut self, operation: &str) -> std::io::Result<()> { @@ -1569,6 +1604,7 @@ impl RolloutWriterState { &mut self.state_builder, self.default_provider.as_str(), self.generate_memories, + &mut self.thread_updated_at_touch, ) .await?; self.meta = None; @@ -1612,6 +1648,7 @@ impl RolloutWriterState { written_items.as_slice(), self.default_provider.as_str(), /*new_thread_memory_mode*/ None, + &mut self.thread_updated_at_touch, ) .await; } @@ -1683,6 +1720,7 @@ async fn write_session_meta( state_builder: &mut Option, default_provider: &str, generate_memories: bool, + thread_updated_at_touch: &mut ThreadUpdatedAtTouch, ) -> std::io::Result<()> { let git_info = collect_git_info(cwd).await.map(|info| ProtocolGitInfo { commit_hash: info.commit_hash, @@ -1708,6 +1746,7 @@ async fn write_session_meta( std::slice::from_ref(&rollout_item), default_provider, (!generate_memories).then_some("disabled"), + thread_updated_at_touch, ) .await; Ok(()) @@ -1720,8 +1759,10 @@ async fn sync_thread_state_after_write( items: &[RolloutItem], default_provider: &str, new_thread_memory_mode: Option<&str>, + thread_updated_at_touch: &mut ThreadUpdatedAtTouch, ) { let updated_at = Utc::now(); + let now = Instant::now(); if new_thread_memory_mode.is_some() || items .iter() @@ -1738,15 +1779,27 @@ async fn sync_thread_state_after_write( Some(updated_at), ) .await; + thread_updated_at_touch.mark_persisted(now); return; } let thread_id = state_builder .map(|builder| builder.id) .or_else(|| metadata::builder_from_items(items, rollout_path).map(|builder| builder.id)); + if thread_updated_at_touch + .last_persisted_at + .is_some_and(|last_persisted_at| { + now.duration_since(last_persisted_at) < THREAD_UPDATED_AT_TOUCH_INTERVAL + }) + { + thread_updated_at_touch.pending_touch = thread_id.map(|thread_id| (thread_id, updated_at)); + return; + } + if state_db::touch_thread_updated_at(state_db_ctx, thread_id, updated_at, "rollout_writer") .await { + thread_updated_at_touch.mark_persisted(now); return; } state_db::apply_rollout_items( @@ -1760,6 +1813,7 @@ async fn sync_thread_state_after_write( Some(updated_at), ) .await; + thread_updated_at_touch.mark_persisted(now); } /// Append one already-filtered rollout item to an existing rollout JSONL file. diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index 35018b657d..505cd59929 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -469,7 +469,7 @@ async fn writer_state_retries_write_error_before_reporting_flush_success() -> st } #[tokio::test] -async fn metadata_irrelevant_events_touch_state_db_updated_at() -> std::io::Result<()> { +async fn metadata_irrelevant_events_coalesce_state_db_updated_at() -> std::io::Result<()> { let home = TempDir::new().expect("temp dir"); let config = test_config(home.path()); @@ -519,8 +519,6 @@ async fn metadata_irrelevant_events_touch_state_db_updated_at() -> std::io::Resu let initial_title = initial_thread.title.clone(); let initial_first_user_message = initial_thread.first_user_message.clone(); - tokio::time::sleep(Duration::from_secs(1)).await; - recorder .record_items(&[RolloutItem::EventMsg(EventMsg::AgentMessage( AgentMessageEvent { @@ -538,17 +536,123 @@ async fn metadata_irrelevant_events_touch_state_db_updated_at() -> std::io::Resu .expect("thread should load after agent message") .expect("thread should still exist"); - assert!(updated_thread.updated_at > initial_updated_at); + assert_eq!(updated_thread.updated_at, initial_updated_at); assert_eq!(updated_thread.title, initial_title); assert_eq!( updated_thread.first_user_message, initial_first_user_message ); + tokio::time::sleep(THREAD_UPDATED_AT_TOUCH_INTERVAL + Duration::from_millis(10)).await; + + recorder + .record_items(&[RolloutItem::EventMsg(EventMsg::AgentMessage( + AgentMessageEvent { + message: "more assistant text".to_string(), + phase: None, + memory_citation: None, + }, + ))]) + .await?; + recorder.flush().await?; + + let refreshed_thread = state_db + .get_thread(thread_id) + .await + .expect("thread should load after refresh") + .expect("thread should still exist"); + assert!(refreshed_thread.updated_at > initial_updated_at); + assert_eq!(refreshed_thread.title, initial_title); + assert_eq!( + refreshed_thread.first_user_message, + initial_first_user_message + ); + recorder.shutdown().await?; Ok(()) } +#[tokio::test] +async fn shutdown_flushes_pending_metadata_irrelevant_updated_at() -> std::io::Result<()> { + let home = TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + + let state_db = StateRuntime::init(home.path().to_path_buf(), config.model_provider_id.clone()) + .await + .expect("state db should initialize"); + state_db + .mark_backfill_complete(/*last_watermark*/ None) + .await + .expect("backfill should be complete"); + + let thread_id = ThreadId::new(); + let recorder = RolloutRecorder::new( + &config, + RolloutRecorderParams::new( + thread_id, + /*forked_from_id*/ None, + SessionSource::Cli, + /*thread_source*/ None, + BaseInstructions::default(), + Vec::new(), + EventPersistenceMode::Limited, + ), + Some(state_db.clone()), + /*state_builder*/ None, + ) + .await?; + + recorder + .record_items(&[RolloutItem::EventMsg(EventMsg::UserMessage( + UserMessageEvent { + message: "first-user-message".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + }, + ))]) + .await?; + recorder.persist().await?; + recorder.flush().await?; + let initial_updated_at = state_db + .get_thread(thread_id) + .await + .expect("thread should load") + .expect("thread should exist") + .updated_at; + + recorder + .record_items(&[RolloutItem::EventMsg(EventMsg::AgentMessage( + AgentMessageEvent { + message: "assistant text".to_string(), + phase: None, + memory_citation: None, + }, + ))]) + .await?; + recorder.flush().await?; + assert_eq!( + state_db + .get_thread(thread_id) + .await + .expect("thread should load before shutdown") + .expect("thread should still exist") + .updated_at, + initial_updated_at + ); + + recorder.shutdown().await?; + + let shutdown_updated_at = state_db + .get_thread(thread_id) + .await + .expect("thread should load after shutdown") + .expect("thread should still exist") + .updated_at; + assert!(shutdown_updated_at > initial_updated_at); + Ok(()) +} + #[tokio::test] async fn metadata_irrelevant_events_fall_back_to_upsert_when_thread_missing() -> std::io::Result<()> { @@ -574,6 +678,7 @@ async fn metadata_irrelevant_events_fall_back_to_upsert_when_thread_missing() -> }, ))]; + let mut thread_updated_at_touch = ThreadUpdatedAtTouch::default(); sync_thread_state_after_write( Some(state_db.as_ref()), rollout_path.as_path(), @@ -581,6 +686,7 @@ async fn metadata_irrelevant_events_fall_back_to_upsert_when_thread_missing() -> items.as_slice(), config.model_provider_id.as_str(), /*new_thread_memory_mode*/ None, + &mut thread_updated_at_touch, ) .await; diff --git a/codex-rs/shell-command/src/command_safety/is_dangerous_command.rs b/codex-rs/shell-command/src/command_safety/is_dangerous_command.rs index 19babd82e8..fef98e8362 100644 --- a/codex-rs/shell-command/src/command_safety/is_dangerous_command.rs +++ b/codex-rs/shell-command/src/command_safety/is_dangerous_command.rs @@ -68,32 +68,6 @@ fn is_git_global_option_with_inline_value(arg: &str) -> bool { ) || ((arg.starts_with("-C") || arg.starts_with("-c")) && arg.len() > 2) } -/// Git global options that can redirect config, repository, or helper lookup -/// and therefore must never be auto-approved as "safe". -pub(crate) fn git_global_option_requires_prompt(arg: &str) -> bool { - matches!( - arg, - // `-C` can redirect Git into a repo whose config runs helpers such as - // `core.fsmonitor` during read-only commands like `status`. - "-C" | "-c" - | "--config-env" - | "--exec-path" - | "--git-dir" - | "--namespace" - | "--super-prefix" - | "--work-tree" - ) || matches!( - arg, - s if ((s.starts_with("-C") || s.starts_with("-c")) && s.len() > 2) - || s.starts_with("--config-env=") - || s.starts_with("--exec-path=") - || s.starts_with("--git-dir=") - || s.starts_with("--namespace=") - || s.starts_with("--super-prefix=") - || s.starts_with("--work-tree=") - ) -} - pub(crate) fn executable_name_lookup_key(raw: &str) -> Option { #[cfg(windows)] { @@ -200,12 +174,6 @@ mod tests { assert!(command_might_be_dangerous(&vec_str(&["rm", "-f", "/"]))); } - #[test] - fn git_dash_c_requires_prompt() { - assert!(git_global_option_requires_prompt("-C")); - assert!(git_global_option_requires_prompt("-C/path/to/repo")); - } - #[test] fn direct_powershell_words_reuse_windows_dangerous_detection() { let command = vec_str(&["Remove-Item", "test", "-Force"]); diff --git a/codex-rs/shell-command/src/command_safety/is_safe_command.rs b/codex-rs/shell-command/src/command_safety/is_safe_command.rs index 7200672ec1..b35144a12b 100644 --- a/codex-rs/shell-command/src/command_safety/is_safe_command.rs +++ b/codex-rs/shell-command/src/command_safety/is_safe_command.rs @@ -4,7 +4,6 @@ use crate::command_safety::is_dangerous_command::executable_name_lookup_key; // may appear before it (e.g., `-C`, `-c`, `--git-dir`). // Implemented in `is_dangerous_command` and shared here. use crate::command_safety::is_dangerous_command::find_git_subcommand; -use crate::command_safety::is_dangerous_command::git_global_option_requires_prompt; use crate::command_safety::windows_safe_commands::is_safe_command_windows; #[cfg(windows)] use crate::command_safety::windows_safe_commands::is_safe_powershell_words as is_safe_powershell_words_windows; @@ -170,19 +169,17 @@ fn is_safe_to_call_with_exec(command: &[String]) -> bool { } pub(crate) fn is_safe_git_command(command: &[String]) -> bool { - // Global options that redirect config, repository, or helper lookup can make - // otherwise read-only git commands execute attacker-controlled code, so they - // must never be auto-approved. - if git_has_unsafe_global_option(command) { - return false; - } - let Some((subcommand_idx, subcommand)) = find_git_subcommand(command, &["status", "log", "diff", "show", "branch"]) else { return false; }; + let global_args = &command[1..subcommand_idx]; + if git_has_unsafe_global_option(global_args) { + return false; + } + let subcommand_args = &command[subcommand_idx + 1..]; match subcommand { @@ -226,30 +223,71 @@ fn git_branch_is_read_only(branch_args: &[String]) -> bool { saw_read_only_flag } -fn git_has_unsafe_global_option(command: &[String]) -> bool { - command +#[derive(Clone, Copy)] +enum GitOptionPattern { + Exact(&'static str), + ShortWithInlineValue(&'static str), + Prefix(&'static str), +} + +const UNSAFE_GIT_GLOBAL_OPTIONS: &[GitOptionPattern] = &[ + GitOptionPattern::Exact("-C"), + GitOptionPattern::ShortWithInlineValue("-C"), + GitOptionPattern::Exact("-c"), + GitOptionPattern::ShortWithInlineValue("-c"), + GitOptionPattern::Exact("-p"), + GitOptionPattern::Exact("--config-env"), + GitOptionPattern::Prefix("--config-env="), + GitOptionPattern::Exact("--exec-path"), + GitOptionPattern::Prefix("--exec-path="), + GitOptionPattern::Exact("--git-dir"), + GitOptionPattern::Prefix("--git-dir="), + GitOptionPattern::Exact("--namespace"), + GitOptionPattern::Prefix("--namespace="), + GitOptionPattern::Exact("--paginate"), + GitOptionPattern::Exact("--super-prefix"), + GitOptionPattern::Prefix("--super-prefix="), + GitOptionPattern::Exact("--work-tree"), + GitOptionPattern::Prefix("--work-tree="), +]; + +const UNSAFE_GIT_SUBCOMMAND_OPTIONS: &[GitOptionPattern] = &[ + GitOptionPattern::Exact("--output"), + GitOptionPattern::Prefix("--output="), + GitOptionPattern::Exact("--ext-diff"), + GitOptionPattern::Exact("--textconv"), + GitOptionPattern::Exact("--exec"), + GitOptionPattern::Prefix("--exec="), +]; + +impl GitOptionPattern { + fn matches(self, arg: &str) -> bool { + match self { + GitOptionPattern::Exact(option) => arg == option, + GitOptionPattern::ShortWithInlineValue(option) => { + arg.starts_with(option) && arg.len() > option.len() + } + GitOptionPattern::Prefix(prefix) => arg.starts_with(prefix), + } + } +} + +fn git_matches_option_pattern(arg: &str, patterns: &[GitOptionPattern]) -> bool { + patterns.iter().any(|pattern| pattern.matches(arg)) +} + +fn git_has_unsafe_global_option(global_args: &[String]) -> bool { + global_args .iter() - .skip(1) .map(String::as_str) - .any(git_global_option_requires_prompt) + .any(|arg| git_matches_option_pattern(arg, UNSAFE_GIT_GLOBAL_OPTIONS)) } fn git_subcommand_args_are_read_only(args: &[String]) -> bool { - // Flags that can write to disk or execute external tools should never be - // auto-approved on an unsandboxed machine. - const UNSAFE_GIT_FLAGS: &[&str] = &[ - "--output", - "--ext-diff", - "--textconv", - "--exec", - "--paginate", - ]; - - !args.iter().map(String::as_str).any(|arg| { - UNSAFE_GIT_FLAGS.contains(&arg) - || arg.starts_with("--output=") - || arg.starts_with("--exec=") - }) + !args + .iter() + .map(String::as_str) + .any(|arg| git_matches_option_pattern(arg, UNSAFE_GIT_SUBCOMMAND_OPTIONS)) } // (bash parsing helpers implemented in crate::bash) @@ -395,6 +433,43 @@ mod tests { ]))); } + #[test] + fn git_global_pagination_flags_are_not_safe() { + assert!(!is_known_safe_command(&vec_str(&[ + "git", + "--paginate", + "log", + "-1", + ]))); + assert!(!is_known_safe_command(&vec_str(&[ + "git", "-p", "log", "-1", + ]))); + assert!(!is_known_safe_command(&vec_str(&[ + "bash", + "-lc", + "git --paginate log -1", + ]))); + assert!(!is_known_safe_command(&vec_str(&[ + "bash", + "-lc", + "git -p log -1", + ]))); + } + + #[test] + fn git_subcommand_patch_flags_remain_safe() { + assert!(is_known_safe_command(&vec_str(&["git", "log", "-p", "-1"]))); + assert!(is_known_safe_command(&vec_str(&["git", "diff", "-p"]))); + assert!(is_known_safe_command(&vec_str(&[ + "git", "show", "-p", "HEAD", + ]))); + assert!(is_known_safe_command(&vec_str(&[ + "bash", + "-lc", + "git log -p -1", + ]))); + } + #[test] fn git_global_override_flags_are_not_safe() { assert!(!is_known_safe_command(&vec_str(&[ diff --git a/codex-rs/shell-command/src/command_safety/windows_safe_commands.rs b/codex-rs/shell-command/src/command_safety/windows_safe_commands.rs index ebe0058f75..8ef3f8e8f9 100644 --- a/codex-rs/shell-command/src/command_safety/windows_safe_commands.rs +++ b/codex-rs/shell-command/src/command_safety/windows_safe_commands.rs @@ -373,7 +373,6 @@ mod tests { "git diff --output codex_poc.txt", "git diff --ext-diff HEAD", "git log --textconv -1", - "git log --paginate -1", "git show --output=codex_poc.txt HEAD", "git cat-file --filters HEAD:a.txt", ] @@ -396,7 +395,6 @@ mod tests { ("git diff --output codex_poc.txt", false), ("git diff --ext-diff HEAD", false), ("git log --textconv -1", false), - ("git log --paginate -1", false), ("git show --output=codex_poc.txt HEAD", false), ("git cat-file --filters HEAD:a.txt", false), ], diff --git a/codex-rs/skills/src/assets/samples/openai-docs/SKILL.md b/codex-rs/skills/src/assets/samples/openai-docs/SKILL.md index eb12887b72..4994fd8802 100644 --- a/codex-rs/skills/src/assets/samples/openai-docs/SKILL.md +++ b/codex-rs/skills/src/assets/samples/openai-docs/SKILL.md @@ -8,6 +8,12 @@ description: "Use when the user asks how to build with OpenAI products or APIs a Provide authoritative, current guidance from OpenAI developer docs using the developers.openai.com MCP server. Always prioritize the developer docs MCP tools over web.run for OpenAI-related questions. This skill also owns model selection, API model migration, and prompt-upgrade guidance. Only if the MCP server is installed and returns no meaningful results should you fall back to web search. +## API Key Setup + +For requests to build, run, configure, debug, or implement an API-backed app, script, CLI, generator, or tool, use `openai-platform-api-key` first when available. After that credential gate is resolved, return here for current docs as needed. + +Use this skill directly for docs-only questions, citations, model/API guidance, conceptual explanations, and examples that do not require building or running an API-backed artifact. + ## Quick start - Use `mcp__openaiDeveloperDocs__search_openai_docs` to find the most relevant doc pages. diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index 18f81348a2..2dc24788d8 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -172,28 +172,15 @@ fn base_sqlite_options(path: &Path) -> SqliteConnectOptions { } async fn open_state_sqlite(path: &Path, migrator: &Migrator) -> anyhow::Result { + // New state DBs should use incremental auto-vacuum, but retrofitting an + // existing DB requires a full VACUUM. Do not attempt that during process + // startup: it is maintenance work that can contend with foreground writers. let options = base_sqlite_options(path).auto_vacuum(SqliteAutoVacuum::Incremental); let pool = SqlitePoolOptions::new() .max_connections(5) .connect_with(options) .await?; migrator.run(&pool).await?; - let auto_vacuum = sqlx::query_scalar::<_, i64>("PRAGMA auto_vacuum") - .fetch_one(&pool) - .await?; - if auto_vacuum != SqliteAutoVacuum::Incremental as i64 { - // Existing state DBs need one non-transactional `VACUUM` before - // SQLite persists `auto_vacuum = INCREMENTAL` in the database header. - sqlx::query("PRAGMA auto_vacuum = INCREMENTAL") - .execute(&pool) - .await?; - // We do it on best effort. If the lock can't be acquired, it will be done at next run. - let _ = sqlx::query("VACUUM").execute(&pool).await; - } - // We do it on best effort. If the lock can't be acquired, it will be done at next run. - let _ = sqlx::query("PRAGMA incremental_vacuum") - .execute(&pool) - .await; Ok(pool) } diff --git a/codex-rs/state/src/runtime/logs.rs b/codex-rs/state/src/runtime/logs.rs index 2223310d9f..6c878db624 100644 --- a/codex-rs/state/src/runtime/logs.rs +++ b/codex-rs/state/src/runtime/logs.rs @@ -300,10 +300,10 @@ WHERE id IN ( return Ok(()); }; self.delete_logs_before(cutoff.timestamp()).await?; - sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)") - .execute(self.logs_pool.as_ref()) - .await?; - sqlx::query("PRAGMA incremental_vacuum") + // Startup cleanup should not wait behind or block foreground work. + // PASSIVE checkpoints copy whatever is immediately available and skip + // frames that would require waiting on active readers or writers. + sqlx::query("PRAGMA wal_checkpoint(PASSIVE)") .execute(self.logs_pool.as_ref()) .await?; Ok(()) diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index a27c78d543..4ad937f9af 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -58,6 +58,7 @@ use codex_core_api::built_in_model_providers; use codex_core_api::find_codex_home; use codex_core_api::init_state_db_from_config; use codex_core_api::item_event_to_server_notification; +use codex_core_api::resolve_installation_id; use codex_core_api::set_default_originator; use codex_core_api::thread_store_from_config; @@ -119,6 +120,7 @@ async fn run_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { let agent_graph_store = agent_graph_store_from_state_db(state_db.clone()); let environment_manager = Arc::new(EnvironmentManager::new(EnvironmentManagerArgs::new(local_runtime_paths)).await); + let installation_id = resolve_installation_id(&config.codex_home).await?; let thread_manager = ThreadManager::new( &config, auth_manager, @@ -128,6 +130,7 @@ async fn run_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { state_db, Arc::clone(&thread_store), agent_graph_store, + installation_id, ); let NewThread { diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index c5538c02ed..5ef87bd9b9 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -42,6 +42,7 @@ codex-feedback = { workspace = true } codex-file-search = { workspace = true } codex-git-utils = { workspace = true } codex-login = { workspace = true } +codex-message-history = { workspace = true } codex-model-provider = { workspace = true } codex-model-provider-info = { workspace = true } codex-models-manager = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 0ecbd02d61..d6d65b04a4 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -17,6 +17,7 @@ use crate::app_event_sender::AppEventSender; use crate::app_server_session::AppServerSession; use crate::app_server_session::AppServerStartedThread; use crate::app_server_session::app_server_rate_limit_snapshots; +use crate::bottom_pane::AppLinkViewParams; use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::FeedbackAudience; use crate::bottom_pane::McpServerElicitationFormRequest; @@ -41,13 +42,11 @@ use crate::history_cell::HistoryCell; use crate::history_cell::UpdateAvailableHistoryCell; use crate::key_hint::KeyBindingListExt; use crate::keymap::RuntimeKeymap; -use crate::legacy_core::append_message_history_entry; use crate::legacy_core::config::Config; use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; use crate::legacy_core::config::edit::ConfigEdit; use crate::legacy_core::config::edit::ConfigEditsBuilder; -use crate::legacy_core::lookup_message_history_entry; #[cfg(target_os = "windows")] use crate::legacy_core::windows_sandbox::WindowsSandboxLevelExt; use crate::model_catalog::ModelCatalog; @@ -219,6 +218,7 @@ const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue." const THREAD_EVENT_CHANNEL_CAPACITY: usize = 32768; enum ThreadInteractiveRequest { + AppLink(AppLinkViewParams), Approval(ApprovalRequest), McpServerElicitation(McpServerElicitationFormRequest), } diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 69eba70cf9..e50c2782ca 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -648,8 +648,7 @@ mod tests { cwd: next_cwd.clone().abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), }); diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 37fcdb4251..bfe8dc4b24 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -326,6 +326,17 @@ impl App { AppEvent::CodexOp(op) => { self.submit_active_thread_op(app_server, op).await?; } + AppEvent::AppendMessageHistoryEntry { thread_id, text } => { + self.append_message_history_entry(thread_id, text); + } + AppEvent::LookupMessageHistoryEntry { + thread_id, + offset, + log_id, + } => { + self.lookup_message_history_entry(thread_id, offset, log_id) + .await?; + } AppEvent::ApproveRecentAutoReviewDenial { thread_id, id } => { self.chat_widget .approve_recent_auto_review_denial(thread_id, id); @@ -1263,7 +1274,8 @@ impl App { AppEvent::PersistServiceTierSelection { service_tier } => { self.refresh_status_line(); let profile = self.active_profile.as_deref(); - self.config.service_tier = service_tier; + self.config.service_tier = + service_tier.map(|service_tier| service_tier.request_value().to_string()); let mut edits = ConfigEditsBuilder::new(&self.config.codex_home) .with_profile(profile) .set_service_tier(service_tier); diff --git a/codex-rs/tui/src/app/loaded_threads.rs b/codex-rs/tui/src/app/loaded_threads.rs index 49e5568596..0ab8e14ee3 100644 --- a/codex-rs/tui/src/app/loaded_threads.rs +++ b/codex-rs/tui/src/app/loaded_threads.rs @@ -118,6 +118,7 @@ mod tests { fn test_thread(thread_id: ThreadId, source: SessionSource) -> Thread { Thread { id: thread_id.to_string(), + session_id: thread_id.to_string(), forked_from_id: None, preview: String::new(), ephemeral: false, diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 05aba144c8..b114d08be9 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -617,7 +617,10 @@ impl App { pub(super) fn fresh_session_config(&self) -> Config { let mut config = self.config.clone(); - config.service_tier = self.chat_widget.configured_service_tier(); + config.service_tier = self + .chat_widget + .configured_service_tier() + .map(|service_tier| service_tier.request_value().to_string()); config.notices.fast_default_opt_out = self.chat_widget.fast_default_opt_out(); config } diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index bb00b53533..5ab85887a7 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -37,6 +37,8 @@ use codex_app_server_protocol::FileChangeRequestApprovalParams; use codex_app_server_protocol::FileUpdateChange; use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::McpServerElicitationRequest; +use codex_app_server_protocol::McpServerElicitationRequestParams; use codex_app_server_protocol::McpServerStartupState; use codex_app_server_protocol::McpServerStatusUpdatedNotification; use codex_app_server_protocol::NetworkApprovalContext as AppServerNetworkApprovalContext; @@ -548,18 +550,9 @@ async fn history_lookup_response_is_routed_to_requesting_thread() -> Result<()> let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; let thread_id = ThreadId::new(); - let handled = app - .try_handle_local_history_op( - thread_id, - &Op::GetHistoryEntryRequest { - offset: 0, - log_id: 1, - }, - ) + app.lookup_message_history_entry(thread_id, /*offset*/ 0, /*log_id*/ 1) .await?; - assert!(handled); - let app_event = tokio::time::timeout(Duration::from_secs(1), app_event_rx.recv()) .await .expect("history lookup should emit an app event") @@ -2755,6 +2748,84 @@ async fn inactive_thread_permissions_approval_preserves_file_system_permissions( ); } +#[tokio::test] +async fn inactive_thread_url_elicitation_routes_to_app_link() { + let app = make_test_app().await; + let thread_id = ThreadId::new(); + let request = ServerRequest::McpServerElicitationRequest { + request_id: AppServerRequestId::Integer(9), + params: McpServerElicitationRequestParams { + thread_id: thread_id.to_string(), + turn_id: Some("turn-auth".to_string()), + server_name: "payments".to_string(), + request: McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: "https://payments.example/checkout/123".to_string(), + elicitation_id: "payment-123".to_string(), + }, + }, + }; + + let Some(ThreadInteractiveRequest::AppLink(params)) = app + .interactive_request_for_thread_request(thread_id, &request) + .await + else { + panic!("expected app link request"); + }; + + assert_eq!(params.title, "Action required"); + assert_eq!(params.description, Some("Server: payments".to_string())); + assert_eq!(params.url, "https://payments.example/checkout/123"); + assert_eq!( + params.elicitation_target, + Some(crate::bottom_pane::AppLinkElicitationTarget { + thread_id, + server_name: "payments".to_string(), + request_id: AppServerRequestId::Integer(9), + }) + ); +} + +#[tokio::test] +async fn inactive_thread_invalid_url_elicitation_is_declined() { + let (app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + let request = ServerRequest::McpServerElicitationRequest { + request_id: AppServerRequestId::Integer(10), + params: McpServerElicitationRequestParams { + thread_id: thread_id.to_string(), + turn_id: Some("turn-auth".to_string()), + server_name: "payments".to_string(), + request: McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: "http://payments.example/checkout/123".to_string(), + elicitation_id: "payment-123".to_string(), + }, + }, + }; + + assert!( + app.interactive_request_for_thread_request(thread_id, &request) + .await + .is_none() + ); + assert_matches!( + app_event_rx.try_recv(), + Ok(AppEvent::SubmitThreadOp { + thread_id: op_thread_id, + op: Op::ResolveElicitation { + server_name, + request_id: AppServerRequestId::Integer(10), + decision: codex_app_server_protocol::McpServerElicitationAction::Decline, + content: None, + meta: None, + }, + }) if op_thread_id == thread_id && server_name == "payments" + ); +} + #[tokio::test] async fn inactive_thread_approval_badge_clears_after_turn_completion_notification() -> Result<()> { let mut app = make_test_app().await; @@ -2860,6 +2931,7 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re ServerNotification::ThreadStarted(ThreadStartedNotification { thread: Thread { id: agent_thread_id.to_string(), + session_id: agent_thread_id.to_string(), forked_from_id: None, preview: "agent thread".to_string(), ephemeral: false, @@ -2942,6 +3014,7 @@ async fn inactive_thread_started_notification_preserves_primary_model_when_path_ ServerNotification::ThreadStarted(ThreadStartedNotification { thread: Thread { id: agent_thread_id.to_string(), + session_id: agent_thread_id.to_string(), forked_from_id: None, preview: "agent thread".to_string(), ephemeral: false, @@ -2997,6 +3070,7 @@ async fn thread_read_session_state_does_not_reuse_primary_permission_profile() { let thread = Thread { id: read_thread_id.to_string(), + session_id: read_thread_id.to_string(), forked_from_id: None, preview: "read thread".to_string(), ephemeral: false, @@ -3668,8 +3742,7 @@ async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String { cwd: test_path_buf("/tmp/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::High), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), }; @@ -3914,8 +3987,7 @@ fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState cwd: cwd.abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), } @@ -4413,7 +4485,11 @@ async fn fresh_session_config_uses_current_service_tier() { assert_eq!( config.service_tier, - Some(codex_protocol::config_types::ServiceTier::Fast) + Some( + codex_protocol::config_types::ServiceTier::Fast + .request_value() + .to_string() + ) ); } @@ -4456,8 +4532,7 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), }; @@ -4520,8 +4595,7 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), }); @@ -4613,8 +4687,7 @@ async fn backtrack_resubmit_preserves_data_image_urls_in_user_turn() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), }); @@ -4961,6 +5034,7 @@ async fn thread_rollback_response_discards_queued_active_thread_events() { &ThreadRollbackResponse { thread: Thread { id: thread_id.to_string(), + session_id: thread_id.to_string(), forked_from_id: None, preview: String::new(), ephemeral: false, @@ -5011,8 +5085,7 @@ async fn new_session_requests_shutdown_for_previous_conversation() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), }; @@ -5133,8 +5206,7 @@ async fn clear_only_ui_reset_preserves_chat_session_state() { cwd: test_path_buf("/tmp/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), }); diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index 759adf9228..56477c18cf 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -354,8 +354,7 @@ mod tests { cwd: cwd.abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), } diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index df6f01e8bd..1a7986ad31 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -262,31 +262,47 @@ impl App { }), ), ServerRequest::McpServerElicitationRequest { request_id, params } => { - if let Some(request) = McpServerElicitationFormRequest::from_app_server_request( + if let Some(params) = AppLinkViewParams::from_url_app_server_request( thread_id, + ¶ms.server_name, request_id.clone(), - params.clone(), + ¶ms.request, ) { + Some(ThreadInteractiveRequest::AppLink(params)) + } else if let Some(request) = + McpServerElicitationFormRequest::from_app_server_request( + thread_id, + request_id.clone(), + params.clone(), + ) + { Some(ThreadInteractiveRequest::McpServerElicitation(request)) } else { - Some(ThreadInteractiveRequest::Approval( - ApprovalRequest::McpElicitation { - thread_id, - thread_label, - server_name: params.server_name.clone(), - request_id: request_id.clone(), - message: match ¶ms.request { - codex_app_server_protocol::McpServerElicitationRequest::Form { - message, - .. - } - | codex_app_server_protocol::McpServerElicitationRequest::Url { - message, - .. - } => message.clone(), + match ¶ms.request { + codex_app_server_protocol::McpServerElicitationRequest::Form { + message, + .. + } => Some(ThreadInteractiveRequest::Approval( + ApprovalRequest::McpElicitation { + thread_id, + thread_label, + server_name: params.server_name.clone(), + request_id: request_id.clone(), + message: message.clone(), }, - }, - )) + )), + codex_app_server_protocol::McpServerElicitationRequest::Url { .. } => { + self.app_event_tx.resolve_elicitation( + thread_id, + params.server_name.clone(), + request_id.clone(), + codex_app_server_protocol::McpServerElicitationAction::Decline, + /*content*/ None, + /*meta*/ None, + ); + None + } + } } } ServerRequest::PermissionsRequestApproval { params, .. } => Some( @@ -304,6 +320,9 @@ impl App { pub(super) fn push_thread_interactive_request(&mut self, request: ThreadInteractiveRequest) { match request { + ThreadInteractiveRequest::AppLink(params) => { + self.chat_widget.open_app_link_view(params); + } ThreadInteractiveRequest::Approval(request) => { self.render_inactive_patch_preview(&request); self.chat_widget.push_approval_request(request); @@ -394,10 +413,6 @@ impl App { ) -> Result<()> { crate::session_log::log_outbound_op(&op); - if self.try_handle_local_history_op(thread_id, &op).await? { - return Ok(()); - } - if self .try_resolve_app_server_request(app_server, thread_id, &op) .await? @@ -422,70 +437,57 @@ impl App { Ok(()) } - /// Spawn a background task that fetches MCP server status from the app-server - /// via paginated RPCs, then delivers the result back through - /// `AppEvent::McpInventoryLoaded`. - /// - /// The spawned task is fire-and-forget: no `JoinHandle` is stored, so a stale - /// result may arrive after the user has moved on. We currently accept that - /// tradeoff because the effect is limited to stale inventory output in history, - /// while request-token invalidation would add cross-cutting async state for a - /// low-severity path. - pub(super) async fn try_handle_local_history_op( + /// Persist prompt text in the local cross-session message history. + pub(super) fn append_message_history_entry(&self, thread_id: ThreadId, text: String) { + let history_config = codex_message_history::HistoryConfig::new( + self.chat_widget.config_ref().codex_home.clone(), + &self.chat_widget.config_ref().history, + ); + tokio::spawn(async move { + if let Err(err) = + codex_message_history::append_entry(&text, thread_id, &history_config).await + { + tracing::warn!( + thread_id = %thread_id, + error = %err, + "failed to append to message history" + ); + } + }); + } + + /// Fetch one local cross-session message history entry for the requesting thread. + pub(super) async fn lookup_message_history_entry( &mut self, thread_id: ThreadId, - op: &AppCommand, - ) -> Result { - match op { - AppCommand::AddToHistory { text } => { - let text = text.to_string(); - let config = self.chat_widget.config_ref().clone(); - tokio::spawn(async move { - if let Err(err) = append_message_history_entry(&text, &thread_id, &config).await - { - tracing::warn!( - thread_id = %thread_id, - error = %err, - "failed to append to message history" - ); - } - }); - Ok(true) - } - AppCommand::GetHistoryEntryRequest { offset, log_id } => { - let config = self.chat_widget.config_ref().clone(); - let app_event_tx = self.app_event_tx.clone(); - let offset = *offset; - let log_id = *log_id; - tokio::spawn(async move { - let entry_opt = tokio::task::spawn_blocking(move || { - lookup_message_history_entry(log_id, offset, &config) - }) - .await - .unwrap_or_else(|err| { - tracing::warn!(error = %err, "history lookup task failed"); - None - }); + offset: usize, + log_id: u64, + ) -> Result<()> { + let history_config = codex_message_history::HistoryConfig::new( + self.chat_widget.config_ref().codex_home.clone(), + &self.chat_widget.config_ref().history, + ); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let entry_opt = tokio::task::spawn_blocking(move || { + codex_message_history::lookup(log_id, offset, &history_config) + }) + .await + .unwrap_or_else(|err| { + tracing::warn!(error = %err, "history lookup task failed"); + None + }); - app_event_tx.send(AppEvent::ThreadHistoryEntryResponse { - thread_id, - event: HistoryLookupResponse { - offset, - log_id, - entry: entry_opt.map(|entry| { - codex_protocol::message_history::HistoryEntry { - conversation_id: entry.session_id, - ts: entry.ts, - text: entry.text, - } - }), - }, - }); - }); - Ok(true) - } - _ => Ok(false), - } + app_event_tx.send(AppEvent::ThreadHistoryEntryResponse { + thread_id, + event: HistoryLookupResponse { + offset, + log_id, + entry: entry_opt.map(|entry| entry.text), + }, + }); + }); + Ok(()) } pub(super) async fn try_submit_active_thread_op_via_app_server( @@ -604,7 +606,7 @@ impl App { model.to_string(), *effort, *summary, - *service_tier, + service_tier.clone(), collaboration_mode.clone(), *personality, final_output_json_schema.clone(), @@ -922,8 +924,7 @@ impl App { } else if rollout_path.is_some() { session.model.clear(); } - session.history_log_id = 0; - session.history_entry_count = 0; + session.message_history = None; session.rollout_path = rollout_path; self.upsert_agent_picker_thread( thread_id, diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index ac6f6311ca..524d8f3c06 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -63,7 +63,10 @@ impl App { thread_name: None, model: self.chat_widget.current_model().to_string(), model_provider_id: self.config.model_provider_id.clone(), - service_tier: self.chat_widget.current_service_tier(), + service_tier: self + .chat_widget + .current_service_tier() + .map(|service_tier| service_tier.request_value().to_string()), approval_policy: AskForApproval::from( self.config.permissions.approval_policy.value(), ), @@ -73,8 +76,7 @@ impl App { cwd: thread.cwd.clone(), instruction_source_paths: Vec::new(), reasoning_effort: self.chat_widget.current_reasoning_effort(), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: thread.path.clone(), }); @@ -93,8 +95,7 @@ impl App { } else if thread.path.is_some() { session.model.clear(); } - session.history_log_id = 0; - session.history_entry_count = 0; + session.message_history = None; session } @@ -150,8 +151,7 @@ mod tests { cwd: cwd.abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), } @@ -322,6 +322,7 @@ mod tests { }; let read_thread = Thread { id: read_thread_id.to_string(), + session_id: read_thread_id.to_string(), forked_from_id: None, preview: "read thread".to_string(), ephemeral: false, diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 8633da04bf..89fd2600f8 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -15,7 +15,6 @@ use codex_protocol::approvals::GuardianAssessmentEvent; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; -use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; @@ -46,7 +45,7 @@ pub(crate) enum AppCommand { model: String, effort: Option, summary: Option, - service_tier: Option>, + service_tier: Option>, final_output_json_schema: Option, collaboration_mode: Option, personality: Option, @@ -60,7 +59,7 @@ pub(crate) enum AppCommand { model: Option, effort: Option>, summary: Option, - service_tier: Option>, + service_tier: Option>, collaboration_mode: Option, personality: Option, }, @@ -104,13 +103,6 @@ pub(crate) enum AppCommand { Review { target: ReviewTarget, }, - AddToHistory { - text: String, - }, - GetHistoryEntryRequest { - offset: usize, - log_id: u64, - }, ApproveGuardianDeniedAction { event: GuardianAssessmentEvent, }, @@ -154,7 +146,7 @@ impl AppCommand { model: String, effort: Option, summary: Option, - service_tier: Option>, + service_tier: Option>, final_output_json_schema: Option, collaboration_mode: Option, personality: Option, @@ -185,7 +177,7 @@ impl AppCommand { model: Option, effort: Option>, summary: Option, - service_tier: Option>, + service_tier: Option>, collaboration_mode: Option, personality: Option, ) -> Self { @@ -276,14 +268,6 @@ impl AppCommand { Self::Review { target } } - pub(crate) fn add_to_history(text: String) -> Self { - Self::AddToHistory { text } - } - - pub(crate) fn history_lookup(offset: usize, log_id: u64) -> Self { - Self::GetHistoryEntryRequest { offset, log_id } - } - pub(crate) fn approve_guardian_denied_action(event: GuardianAssessmentEvent) -> Self { Self::ApproveGuardianDeniedAction { event } } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index c88ff17115..4ee405f495 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -28,7 +28,6 @@ use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::ThreadGoalStatus; use codex_file_search::FileMatch; use codex_protocol::ThreadId; -use codex_protocol::message_history::HistoryEntry; use codex_protocol::openai_models::ModelPreset; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_approval_presets::ApprovalPreset; @@ -68,7 +67,7 @@ pub(crate) enum ThreadGoalSetMode { pub(crate) struct HistoryLookupResponse { pub(crate) offset: usize, pub(crate) log_id: u64, - pub(crate) entry: Option, + pub(crate) entry: Option, } impl RealtimeAudioDeviceKind { @@ -150,6 +149,19 @@ pub(crate) enum AppEvent { event: HistoryLookupResponse, }, + /// Persist a submitted prompt in the cross-session message history. + AppendMessageHistoryEntry { + thread_id: ThreadId, + text: String, + }, + + /// Fetch a persistent cross-session message history entry by offset. + LookupMessageHistoryEntry { + thread_id: ThreadId, + offset: usize, + log_id: u64, + }, + /// Start a new session. NewSession, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 9aafe9aed3..6a170beec7 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -4,11 +4,9 @@ //! request/response plumbing out of `App` and `ChatWidget`. use crate::bottom_pane::FeedbackAudience; -#[cfg(test)] -use crate::legacy_core::append_message_history_entry; use crate::legacy_core::config::Config; -use crate::legacy_core::message_history_metadata; use crate::permission_compat::legacy_compatible_permission_profile; +use crate::session_state::MessageHistoryMetadata; use crate::session_state::ThreadSessionState; use crate::status::StatusAccountDisplay; use crate::status::plan_type_display_name; @@ -531,7 +529,7 @@ impl AppServerSession { model: String, effort: Option, summary: Option, - service_tier: Option>, + service_tier: Option>, collaboration_mode: Option, personality: Option, output_schema: Option, @@ -1344,7 +1342,7 @@ async fn thread_session_state_from_thread_start_response( response.thread.path.clone(), response.model.clone(), response.model_provider.clone(), - response.service_tier, + response.service_tier.clone(), response.approval_policy, response.approvals_reviewer.to_core(), permission_profile, @@ -1376,7 +1374,7 @@ async fn thread_session_state_from_thread_resume_response( response.thread.path.clone(), response.model.clone(), response.model_provider.clone(), - response.service_tier, + response.service_tier.clone(), response.approval_policy, response.approvals_reviewer.to_core(), permission_profile, @@ -1408,7 +1406,7 @@ async fn thread_session_state_from_thread_fork_response( response.thread.path.clone(), response.model.clone(), response.model_provider.clone(), - response.service_tier, + response.service_tier.clone(), response.approval_policy, response.approvals_reviewer.to_core(), permission_profile, @@ -1450,7 +1448,7 @@ async fn thread_session_state_from_thread_response( rollout_path: Option, model: String, model_provider_id: String, - service_tier: Option, + service_tier: Option, approval_policy: AskForApproval, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, permission_profile: PermissionProfile, @@ -1467,8 +1465,9 @@ async fn thread_session_state_from_thread_response( .map(ThreadId::from_string) .transpose() .map_err(|err| format!("forked_from_id is invalid: {err}"))?; - let (history_log_id, history_entry_count) = message_history_metadata(config).await; - let history_entry_count = u64::try_from(history_entry_count).unwrap_or(u64::MAX); + let history_config = + codex_message_history::HistoryConfig::new(config.codex_home.clone(), &config.history); + let (log_id, entry_count) = codex_message_history::history_metadata(&history_config).await; Ok(ThreadSessionState { thread_id, forked_from_id, @@ -1484,8 +1483,10 @@ async fn thread_session_state_from_thread_response( cwd, instruction_source_paths, reasoning_effort, - history_log_id, - history_entry_count, + message_history: Some(MessageHistoryMetadata { + log_id, + entry_count, + }), network_proxy: None, rollout_path, }) @@ -1815,6 +1816,7 @@ mod tests { let response = ThreadResumeResponse { thread: codex_app_server_protocol::Thread { id: thread_id.to_string(), + session_id: ThreadId::new().to_string(), forked_from_id: Some(forked_from_id.to_string()), preview: "hello".to_string(), ephemeral: false, @@ -1959,10 +1961,13 @@ mod tests { let config = build_config(&temp_dir).await; let thread_id = ThreadId::new(); - append_message_history_entry("older", &thread_id, &config) + let history_config = + codex_message_history::HistoryConfig::new(config.codex_home.clone(), &config.history); + + codex_message_history::append_entry("older", &thread_id, &history_config) .await .expect("history append should succeed"); - append_message_history_entry("newer", &thread_id, &config) + codex_message_history::append_entry("newer", &thread_id, &history_config) .await .expect("history append should succeed"); @@ -1986,8 +1991,11 @@ mod tests { .await .expect("session should map"); - assert_ne!(session.history_log_id, 0); - assert_eq!(session.history_entry_count, 2); + let metadata = session + .message_history + .expect("session should include message-history metadata"); + assert_ne!(metadata.log_id, 0); + assert_eq!(metadata.entry_count, 2); } #[tokio::test] diff --git a/codex-rs/tui/src/bottom_pane/app_link_view.rs b/codex-rs/tui/src/bottom_pane/app_link_view.rs index 43ff94618d..3702849fce 100644 --- a/codex-rs/tui/src/bottom_pane/app_link_view.rs +++ b/codex-rs/tui/src/bottom_pane/app_link_view.rs @@ -17,6 +17,7 @@ use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; use ratatui::widgets::Wrap; use textwrap::wrap; +use url::Url; use super::CancellationEvent; use super::bottom_pane_view::BottomPaneView; @@ -34,6 +35,13 @@ use crate::style::user_message_style; use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_lines; +const MCP_CODEX_APPS_SERVER_NAME: &str = "codex_apps"; +const MCP_TOOL_CODEX_APPS_META_KEY: &str = "_codex_apps"; +const CONNECTOR_AUTH_FAILURE_META_KEY: &str = "connector_auth_failure"; +const CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: &str = "is_auth_failure"; +const CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: &str = "connector_id"; +const CONNECTOR_AUTH_FAILURE_CONNECTOR_NAME_KEY: &str = "connector_name"; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum AppLinkScreen { Link, @@ -44,6 +52,8 @@ enum AppLinkScreen { pub(crate) enum AppLinkSuggestionType { Install, Enable, + Auth, + ExternalAction, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -53,6 +63,7 @@ pub(crate) struct AppLinkElicitationTarget { pub(crate) request_id: AppServerRequestId, } +#[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct AppLinkViewParams { pub(crate) app_id: String, pub(crate) title: String, @@ -66,6 +77,152 @@ pub(crate) struct AppLinkViewParams { pub(crate) elicitation_target: Option, } +impl AppLinkViewParams { + pub(crate) fn from_url_app_server_request( + thread_id: ThreadId, + server_name: &str, + request_id: AppServerRequestId, + request: &codex_app_server_protocol::McpServerElicitationRequest, + ) -> Option { + let codex_app_server_protocol::McpServerElicitationRequest::Url { + meta, + message, + url, + elicitation_id, + } = request + else { + return None; + }; + if server_name == MCP_CODEX_APPS_SERVER_NAME { + let url = validate_external_url(url, /*require_chatgpt_host*/ true)?; + return Self::from_codex_apps_auth_url_parts( + thread_id, + server_name, + request_id, + meta.as_ref(), + message, + url.as_str(), + elicitation_id, + ); + } + + let url = validate_external_url(url, /*require_chatgpt_host*/ false)?; + Some(Self::from_generic_url_parts( + thread_id, + server_name, + request_id, + message, + url.as_str(), + elicitation_id, + )) + } + + fn from_codex_apps_auth_url_parts( + thread_id: ThreadId, + server_name: &str, + request_id: AppServerRequestId, + meta: Option<&serde_json::Value>, + message: &str, + url: &str, + elicitation_id: &str, + ) -> Option { + let auth_failure = meta? + .as_object()? + .get(MCP_TOOL_CODEX_APPS_META_KEY)? + .as_object()? + .get(CONNECTOR_AUTH_FAILURE_META_KEY)? + .as_object()?; + if auth_failure + .get(CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY) + .and_then(serde_json::Value::as_bool) + != Some(true) + { + return None; + } + + let app_id = auth_failure + .get(CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(elicitation_id) + .to_string(); + let title = auth_failure + .get(CONNECTOR_AUTH_FAILURE_CONNECTOR_NAME_KEY) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(app_id.as_str()) + .to_string(); + + Some(Self { + app_id, + title, + description: None, + instructions: "Sign in to this app in your browser, then return here.".to_string(), + url: url.to_string(), + is_installed: true, + is_enabled: true, + suggest_reason: Some(message.to_string()), + suggestion_type: Some(AppLinkSuggestionType::Auth), + elicitation_target: Some(AppLinkElicitationTarget { + thread_id, + server_name: server_name.to_string(), + request_id, + }), + }) + } + + fn from_generic_url_parts( + thread_id: ThreadId, + server_name: &str, + request_id: AppServerRequestId, + message: &str, + url: &str, + elicitation_id: &str, + ) -> Self { + Self { + app_id: elicitation_id.to_string(), + title: "Action required".to_string(), + description: Some(format!("Server: {server_name}")), + instructions: "Complete the requested action in your browser, then return here." + .to_string(), + url: url.to_string(), + is_installed: true, + is_enabled: true, + suggest_reason: Some(message.to_string()), + suggestion_type: Some(AppLinkSuggestionType::ExternalAction), + elicitation_target: Some(AppLinkElicitationTarget { + thread_id, + server_name: server_name.to_string(), + request_id, + }), + } + } +} + +fn validate_external_url(url: &str, require_chatgpt_host: bool) -> Option { + let parsed = Url::parse(url).ok()?; + if parsed.scheme() != "https" || parsed.host_str().is_none() { + return None; + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return None; + } + if require_chatgpt_host && !is_allowed_chatgpt_auth_host(parsed.host_str()?) { + return None; + } + Some(parsed) +} + +fn is_allowed_chatgpt_auth_host(host: &str) -> bool { + let host = host.to_ascii_lowercase(); + host == "chatgpt.com" + || host == "chatgpt-staging.com" + || host.ends_with(".chatgpt.com") + || host.ends_with(".chatgpt-staging.com") +} + pub(crate) struct AppLinkView { app_id: String, title: String, @@ -116,6 +273,19 @@ impl AppLinkView { } fn action_labels(&self) -> Vec<&'static str> { + if self.is_auth_suggestion() { + return match self.screen { + AppLinkScreen::Link => vec!["Open sign-in URL", "Back"], + AppLinkScreen::InstallConfirmation => vec!["I already signed in", "Back"], + }; + } + if self.is_external_action_suggestion() { + return match self.screen { + AppLinkScreen::Link => vec!["Open link", "Back"], + AppLinkScreen::InstallConfirmation => vec!["I finished", "Back"], + }; + } + match self.screen { AppLinkScreen::Link => { if self.is_installed { @@ -148,6 +318,19 @@ impl AppLinkView { self.elicitation_target.is_some() } + fn is_auth_suggestion(&self) -> bool { + self.is_tool_suggestion() && self.suggestion_type == Some(AppLinkSuggestionType::Auth) + } + + fn is_external_action_suggestion(&self) -> bool { + self.is_tool_suggestion() + && self.suggestion_type == Some(AppLinkSuggestionType::ExternalAction) + } + + fn is_browser_action_suggestion(&self) -> bool { + self.is_auth_suggestion() || self.is_external_action_suggestion() + } + fn resolve_elicitation(&self, decision: McpServerElicitationAction) { let Some(target) = self.elicitation_target.as_ref() else { return; @@ -167,20 +350,26 @@ impl AppLinkView { self.complete = true; } - fn open_chatgpt_link(&mut self) { + fn open_external_url(&mut self) { self.app_event_tx.send(AppEvent::OpenUrlInBrowser { url: self.url.clone(), }); - if !self.is_installed { + if !self.is_installed || self.is_browser_action_suggestion() { self.screen = AppLinkScreen::InstallConfirmation; self.selected_action = 0; } } - fn refresh_connectors_and_close(&mut self) { - self.app_event_tx.send(AppEvent::RefreshConnectors { - force_refetch: true, - }); + fn complete_external_flow_and_close(&mut self) { + let should_refresh_connectors = self + .elicitation_target + .as_ref() + .is_none_or(|target| target.server_name == MCP_CODEX_APPS_SERVER_NAME); + if should_refresh_connectors { + self.app_event_tx.send(AppEvent::RefreshConnectors { + force_refetch: true, + }); + } if self.is_tool_suggestion() { self.resolve_elicitation(McpServerElicitationAction::Accept); } @@ -209,22 +398,42 @@ impl AppLinkView { match self.suggestion_type { Some(AppLinkSuggestionType::Enable) => match self.screen { AppLinkScreen::Link => match self.selected_action { - 0 => self.open_chatgpt_link(), + 0 => self.open_external_url(), 1 if self.is_installed => self.toggle_enabled(), _ => self.decline_tool_suggestion(), }, AppLinkScreen::InstallConfirmation => match self.selected_action { - 0 => self.refresh_connectors_and_close(), + 0 => self.complete_external_flow_and_close(), + _ => self.decline_tool_suggestion(), + }, + }, + Some(AppLinkSuggestionType::Auth) => match self.screen { + AppLinkScreen::Link => match self.selected_action { + 0 => self.open_external_url(), + _ => self.decline_tool_suggestion(), + }, + AppLinkScreen::InstallConfirmation => match self.selected_action { + 0 => self.complete_external_flow_and_close(), + _ => self.decline_tool_suggestion(), + }, + }, + Some(AppLinkSuggestionType::ExternalAction) => match self.screen { + AppLinkScreen::Link => match self.selected_action { + 0 => self.open_external_url(), + _ => self.decline_tool_suggestion(), + }, + AppLinkScreen::InstallConfirmation => match self.selected_action { + 0 => self.complete_external_flow_and_close(), _ => self.decline_tool_suggestion(), }, }, Some(AppLinkSuggestionType::Install) | None => match self.screen { AppLinkScreen::Link => match self.selected_action { - 0 => self.open_chatgpt_link(), + 0 => self.open_external_url(), _ => self.decline_tool_suggestion(), }, AppLinkScreen::InstallConfirmation => match self.selected_action { - 0 => self.refresh_connectors_and_close(), + 0 => self.complete_external_flow_and_close(), _ => self.decline_tool_suggestion(), }, }, @@ -234,12 +443,12 @@ impl AppLinkView { match self.screen { AppLinkScreen::Link => match self.selected_action { - 0 => self.open_chatgpt_link(), + 0 => self.open_external_url(), 1 if self.is_installed => self.toggle_enabled(), _ => self.complete = true, }, AppLinkScreen::InstallConfirmation => match self.selected_action { - 0 => self.refresh_connectors_and_close(), + 0 => self.complete_external_flow_and_close(), _ => self.back_to_link_screen(), }, } @@ -280,31 +489,42 @@ impl AppLinkView { } lines.push(Line::from("")); } - if self.is_installed { + let is_browser_action_suggestion = self.is_browser_action_suggestion(); + if self.is_installed && !is_browser_action_suggestion { for line in wrap("Use $ to insert this app into the prompt.", usable_width) { lines.push(Line::from(line.into_owned())); } lines.push(Line::from("")); } + if is_browser_action_suggestion { + lines.push(Line::from("URL".dim())); + for line in wrap(&self.url, usable_width) { + lines.push(Line::from(line.into_owned())); + } + lines.push(Line::from("")); + } + let instructions = self.instructions.trim(); if !instructions.is_empty() { for line in wrap(instructions, usable_width) { lines.push(Line::from(line.into_owned())); } - for line in wrap( - "Newly installed apps can take a few minutes to appear in /apps.", - usable_width, - ) { - lines.push(Line::from(line.into_owned())); - } - if !self.is_installed { + if !is_browser_action_suggestion { for line in wrap( - "After installed, use $ to insert this app into the prompt.", + "Newly installed apps can take a few minutes to appear in /apps.", usable_width, ) { lines.push(Line::from(line.into_owned())); } + if !self.is_installed { + for line in wrap( + "After installed, use $ to insert this app into the prompt.", + usable_width, + ) { + lines.push(Line::from(line.into_owned())); + } + } } lines.push(Line::from("")); } @@ -316,24 +536,82 @@ impl AppLinkView { let usable_width = width.max(1) as usize; let mut lines: Vec> = Vec::new(); - lines.push(Line::from("Finish App Setup".bold())); + let is_auth_suggestion = self.is_auth_suggestion(); + let is_external_action_suggestion = self.is_external_action_suggestion(); + let is_codex_apps_auth = is_auth_suggestion + && self + .elicitation_target + .as_ref() + .is_some_and(|target| target.server_name == MCP_CODEX_APPS_SERVER_NAME); + lines.push(Line::from( + if is_auth_suggestion { + if is_codex_apps_auth { + "Finish App Sign In" + } else { + "Finish Authentication" + } + } else if is_external_action_suggestion { + "Finish in Browser" + } else { + "Finish App Setup" + } + .bold(), + )); lines.push(Line::from("")); - for line in wrap( - "Complete app setup on ChatGPT in the browser window that just opened.", - usable_width, - ) { - lines.push(Line::from(line.into_owned())); - } - for line in wrap( - "Sign in there if needed, then return here and select \"I already Installed it\".", - usable_width, - ) { - lines.push(Line::from(line.into_owned())); + if is_auth_suggestion { + for line in wrap( + if is_codex_apps_auth { + "Sign in to the app on ChatGPT in the browser window that just opened." + } else { + "Complete authentication in the browser window that just opened." + }, + usable_width, + ) { + lines.push(Line::from(line.into_owned())); + } + for line in wrap( + "Then return here and select \"I already signed in\".", + usable_width, + ) { + lines.push(Line::from(line.into_owned())); + } + } else if is_external_action_suggestion { + for line in wrap( + "Complete the requested action in the browser window that just opened.", + usable_width, + ) { + lines.push(Line::from(line.into_owned())); + } + for line in wrap("Then return here and select \"I finished\".", usable_width) { + lines.push(Line::from(line.into_owned())); + } + } else { + for line in wrap( + "Complete app setup on ChatGPT in the browser window that just opened.", + usable_width, + ) { + lines.push(Line::from(line.into_owned())); + } + for line in wrap( + "Sign in there if needed, then return here and select \"I already Installed it\".", + usable_width, + ) { + lines.push(Line::from(line.into_owned())); + } } lines.push(Line::from("")); - lines.push(Line::from(vec!["Setup URL:".dim()])); + lines.push(Line::from(vec![ + if is_auth_suggestion { + "Sign-in URL:" + } else if is_external_action_suggestion { + "Link:" + } else { + "Setup URL:" + } + .dim(), + ])); let url_line = Line::from(vec![self.url.clone().cyan().underlined()]); lines.extend(adaptive_wrap_lines( vec![url_line], @@ -586,6 +864,135 @@ mod tests { } } + fn generic_url_target() -> AppLinkElicitationTarget { + AppLinkElicitationTarget { + thread_id: ThreadId::try_from("00000000-0000-0000-0000-000000000002") + .expect("valid thread id"), + server_name: "payments".to_string(), + request_id: AppServerRequestId::String("request-2".to_string()), + } + } + + fn auth_url_request(url: &str) -> codex_app_server_protocol::McpServerElicitationRequest { + codex_app_server_protocol::McpServerElicitationRequest::Url { + meta: Some(serde_json::json!({ + "_codex_apps": { + "connector_auth_failure": { + "is_auth_failure": true, + "connector_id": "connector_calendar", + "connector_name": "Google Calendar", + }, + }, + })), + message: "Reconnect Google Calendar on ChatGPT.".to_string(), + url: url.to_string(), + elicitation_id: "codex_apps_auth_call_123".to_string(), + } + } + + #[test] + fn codex_apps_auth_url_elicitation_builds_auth_app_link_params() { + let target = suggestion_target(); + let request = + auth_url_request("https://chatgpt.com/apps/google-calendar/connector_calendar"); + + let params = AppLinkViewParams::from_url_app_server_request( + target.thread_id, + &target.server_name, + target.request_id.clone(), + &request, + ) + .expect("expected auth app link params"); + + assert_eq!(params.app_id, "connector_calendar"); + assert_eq!(params.title, "Google Calendar"); + assert_eq!( + params.url, + "https://chatgpt.com/apps/google-calendar/connector_calendar" + ); + assert_eq!(params.suggestion_type, Some(AppLinkSuggestionType::Auth)); + assert_eq!(params.elicitation_target, Some(target)); + } + + #[test] + fn non_codex_apps_url_elicitation_builds_generic_app_link_params() { + let target = generic_url_target(); + let request = codex_app_server_protocol::McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: "https://payments.example/checkout/123".to_string(), + elicitation_id: "payment-123".to_string(), + }; + + let params = AppLinkViewParams::from_url_app_server_request( + target.thread_id, + &target.server_name, + target.request_id.clone(), + &request, + ) + .expect("expected generic URL app link params"); + + assert_eq!( + params, + AppLinkViewParams { + app_id: "payment-123".to_string(), + title: "Action required".to_string(), + description: Some("Server: payments".to_string()), + instructions: "Complete the requested action in your browser, then return here." + .to_string(), + url: "https://payments.example/checkout/123".to_string(), + is_installed: true, + is_enabled: true, + suggest_reason: Some("Review the payment details to continue.".to_string()), + suggestion_type: Some(AppLinkSuggestionType::ExternalAction), + elicitation_target: Some(target), + } + ); + } + + #[test] + fn codex_apps_auth_url_elicitation_rejects_untrusted_urls() { + let target = suggestion_target(); + for url in [ + "http://chatgpt.com/apps/google-calendar/connector_calendar", + "https://user:pass@chatgpt.com/apps/google-calendar/connector_calendar", + "https://chatgpt.com.evil.example/apps/google-calendar/connector_calendar", + "https://evilchatgpt.com/apps/google-calendar/connector_calendar", + ] { + let request = auth_url_request(url); + let params = AppLinkViewParams::from_url_app_server_request( + target.thread_id, + &target.server_name, + target.request_id.clone(), + &request, + ); + assert!(params.is_none(), "expected {url} to be rejected"); + } + } + + #[test] + fn generic_url_elicitation_rejects_untrusted_urls() { + let target = generic_url_target(); + for url in [ + "http://payments.example/checkout/123", + "https://user:pass@payments.example/checkout/123", + ] { + let request = codex_app_server_protocol::McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: url.to_string(), + elicitation_id: "payment-123".to_string(), + }; + let params = AppLinkViewParams::from_url_app_server_request( + target.thread_id, + &target.server_name, + target.request_id.clone(), + &request, + ); + assert!(params.is_none(), "expected {url} to be rejected"); + } + } + fn render_snapshot(view: &AppLinkView, area: Rect) -> String { let mut buf = Buffer::empty(area); view.render(area, &mut buf); @@ -717,6 +1124,58 @@ mod tests { ); } + #[test] + fn generic_url_elicitation_resolves_without_connector_refresh() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let target = generic_url_target(); + let request = codex_app_server_protocol::McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: "https://payments.example/checkout/123".to_string(), + elicitation_id: "payment-123".to_string(), + }; + let params = AppLinkViewParams::from_url_app_server_request( + target.thread_id, + &target.server_name, + target.request_id.clone(), + &request, + ) + .expect("expected generic URL app link params"); + let mut view = AppLinkView::new(params, tx); + + view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match rx.try_recv() { + Ok(AppEvent::OpenUrlInBrowser { url }) => { + assert_eq!(url, "https://payments.example/checkout/123"); + } + Ok(other) => panic!("unexpected app event: {other:?}"), + Err(err) => panic!("missing app event: {err}"), + } + assert_eq!(view.screen, AppLinkScreen::InstallConfirmation); + + view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match rx.try_recv() { + Ok(AppEvent::SubmitThreadOp { thread_id, op }) => { + assert_eq!(thread_id, target.thread_id); + assert_eq!( + op, + Op::ResolveElicitation { + server_name: "payments".to_string(), + request_id: AppServerRequestId::String("request-2".to_string()), + decision: McpServerElicitationAction::Accept, + content: None, + meta: None, + } + ); + } + Ok(other) => panic!("unexpected app event: {other:?}"), + Err(err) => panic!("missing app event: {err}"), + } + assert!(rx.try_recv().is_err()); + assert!(view.is_complete()); + } + #[test] fn install_confirmation_does_not_split_long_url_like_token_without_scheme() { let (tx_raw, _rx) = unbounded_channel::(); @@ -1076,4 +1535,94 @@ mod tests { ) ); } + + #[test] + fn auth_suggestion_with_reason_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = AppLinkView::new( + AppLinkViewParams { + app_id: "connector_google_calendar".to_string(), + title: "Google Calendar".to_string(), + description: None, + instructions: "Sign in to this app in your browser, then return here.".to_string(), + url: "https://chatgpt.com/apps/google-calendar/connector_google_calendar" + .to_string(), + is_installed: true, + is_enabled: true, + suggest_reason: Some("Reconnect Google Calendar on ChatGPT.".to_string()), + suggestion_type: Some(AppLinkSuggestionType::Auth), + elicitation_target: Some(suggestion_target()), + }, + tx, + ); + + assert_snapshot!( + "app_link_view_auth_suggestion_with_reason", + render_snapshot( + &view, + Rect::new(0, 0, 72, view.desired_height(/*width*/ 72)) + ) + ); + } + + #[test] + fn generic_url_elicitation_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let target = generic_url_target(); + let request = codex_app_server_protocol::McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: "https://payments.example/checkout/123".to_string(), + elicitation_id: "payment-123".to_string(), + }; + let params = AppLinkViewParams::from_url_app_server_request( + target.thread_id, + &target.server_name, + target.request_id.clone(), + &request, + ) + .expect("expected generic URL app link params"); + let view = AppLinkView::new(params, tx); + + assert_snapshot!( + "app_link_view_generic_url_elicitation", + render_snapshot( + &view, + Rect::new(0, 0, 72, view.desired_height(/*width*/ 72)) + ) + ); + } + + #[test] + fn generic_url_elicitation_confirmation_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let target = generic_url_target(); + let request = codex_app_server_protocol::McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: "https://payments.example/checkout/123".to_string(), + elicitation_id: "payment-123".to_string(), + }; + let params = AppLinkViewParams::from_url_app_server_request( + target.thread_id, + &target.server_name, + target.request_id.clone(), + &request, + ) + .expect("expected generic URL app link params"); + let mut view = AppLinkView::new(params, tx); + + view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_snapshot!( + "app_link_view_generic_url_elicitation_confirmation", + render_snapshot( + &view, + Rect::new(0, 0, 72, view.desired_height(/*width*/ 72)) + ) + ); + } } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 32e8c092fa..e7526203bc 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -207,6 +207,7 @@ use crate::render::RectExt; use crate::render::renderable::Renderable; use crate::slash_command::SlashCommand; use crate::style::user_message_style; +use codex_protocol::ThreadId; use codex_protocol::models::local_image_label_text; use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; @@ -849,10 +850,15 @@ impl ChatComposer { && self.remote_image_urls.is_empty() } - /// Record the history metadata advertised by `SessionConfiguredEvent` so - /// that the composer can navigate cross-session history. - pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) { - self.history.set_metadata(log_id, entry_count); + /// Record local persistent-history metadata so the composer can navigate + /// cross-session history. + pub(crate) fn set_history_metadata( + &mut self, + thread_id: ThreadId, + log_id: u64, + entry_count: usize, + ) { + self.history.set_metadata(thread_id, log_id, entry_count); } /// Integrate an asynchronous response to an on-demand history lookup. @@ -2846,11 +2852,11 @@ impl ChatComposer { return None; } if self.reject_slash_command_if_unavailable(cmd) { - self.stage_slash_command_history(); + self.stage_slash_command_history(cmd); self.record_pending_slash_command_history(); return Some(InputResult::None); } - self.stage_slash_command_history(); + self.stage_slash_command_history(cmd); self.textarea.set_text_clearing_elements(""); self.is_bash_mode = false; Some(InputResult::Command(cmd)) @@ -2878,12 +2884,12 @@ impl ChatComposer { return None; } if self.reject_slash_command_if_unavailable(cmd) { - self.stage_slash_command_history(); + self.stage_slash_command_history(cmd); self.record_pending_slash_command_history(); return Some(InputResult::None); } - self.stage_slash_command_history(); + self.stage_slash_command_history(cmd); let mut args_elements = Self::slash_command_args_elements(rest, rest_offset, &self.textarea.text_elements()); @@ -2959,7 +2965,10 @@ impl ChatComposer { /// Staging snapshots the rich composer state before the textarea is cleared. `ChatWidget` /// commits the staged entry after dispatch so command recall follows the submitted text, not /// the command outcome. - fn stage_slash_command_history(&mut self) { + fn stage_slash_command_history(&mut self, cmd: SlashCommand) { + if cmd == SlashCommand::Clear { + return; + } self.stage_slash_command_history_text(self.textarea.text().trim().to_string()); } @@ -2968,6 +2977,9 @@ impl ChatComposer { /// Popup filtering text can be partial, so recording the selected command avoids recalling /// `/di` after the user actually accepted `/diff`. fn stage_selected_slash_command_history(&mut self, cmd: SlashCommand) { + if cmd == SlashCommand::Clear { + return; + } self.stage_slash_command_history_text(format!("/{}", cmd.command())); } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs index 52ae811226..6a490e81ec 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs @@ -15,11 +15,11 @@ use std::collections::HashMap; use std::collections::HashSet; use std::path::PathBuf; -use crate::app_command::AppCommand as Op; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::MentionBinding; use crate::mention_codec::decode_history_mentions; +use codex_protocol::ThreadId; use codex_protocol::user_input::TextElement; /// A composer history entry that can rehydrate draft state. @@ -105,11 +105,13 @@ impl HistoryEntry { /// the chat composer. This struct is intentionally decoupled from the /// rendering widget so the logic remains isolated and easier to test. pub(crate) struct ChatComposerHistory { - /// Identifier of the history log as reported by `SessionConfiguredEvent`. - history_log_id: Option, + /// Thread that owns persistent lookup responses for this metadata snapshot. + thread_id: Option, + /// Identifier of the persistent history log used for stale lookup rejection. + persistent_log_id: Option, /// Number of entries already present in the persistent cross-session /// history file when the session started. - history_entry_count: usize, + persistent_entry_count: usize, /// Messages submitted by the user *during this UI session* (newest at END). /// Local entries retain full draft state (text elements, image paths, pending pastes, remote image URLs). @@ -216,8 +218,9 @@ impl ChatComposerHistory { /// metadata-free lets the composer reset and reuse this helper across session lifecycles. pub fn new() -> Self { Self { - history_log_id: None, - history_entry_count: 0, + thread_id: None, + persistent_log_id: None, + persistent_entry_count: 0, local_history: Vec::new(), fetched_history: HashMap::new(), history_cursor: None, @@ -231,9 +234,10 @@ impl ChatComposerHistory { /// This clears fetched entries, local entries, navigation cursors, and active search state /// because offsets only make sense within one history log snapshot. Reusing old offsets after a /// log-id change would allow a stale async response to hydrate the wrong prompt. - pub fn set_metadata(&mut self, log_id: u64, entry_count: usize) { - self.history_log_id = Some(log_id); - self.history_entry_count = entry_count; + pub fn set_metadata(&mut self, thread_id: ThreadId, log_id: u64, entry_count: usize) { + self.thread_id = Some(thread_id); + self.persistent_log_id = Some(log_id); + self.persistent_entry_count = entry_count; self.fetched_history.clear(); self.local_history.clear(); self.history_cursor = None; @@ -298,7 +302,7 @@ impl ChatComposerHistory { /// history recall. If callers moved the cursor into the middle of a recalled entry and still /// forced navigation, users would lose normal vertical movement within the draft. pub fn should_handle_navigation(&self, text: &str, cursor: usize) -> bool { - if self.history_entry_count == 0 && self.local_history.is_empty() { + if self.persistent_entry_count == 0 && self.local_history.is_empty() { return false; } @@ -320,11 +324,11 @@ impl ChatComposerHistory { /// Handles Up by moving toward older entries in the combined history space. /// /// Local entries can be returned immediately, while missing persistent entries emit a - /// `GetHistoryEntryRequest` and return `None` until the response arrives. Calling this while + /// `LookupMessageHistoryEntry` and return `None` until the response arrives. Calling this while /// Ctrl+R search is active intentionally exits search traversal. pub fn navigate_up(&mut self, app_event_tx: &AppEventSender) -> Option { self.search = None; - let total_entries = self.history_entry_count + self.local_history.len(); + let total_entries = self.persistent_entry_count + self.local_history.len(); if total_entries == 0 { return None; } @@ -346,7 +350,7 @@ impl ChatComposerHistory { /// search state and resumes normal shell-style browsing. pub fn navigate_down(&mut self, app_event_tx: &AppEventSender) -> Option { self.search = None; - let total_entries = self.history_entry_count + self.local_history.len(); + let total_entries = self.persistent_entry_count + self.local_history.len(); if total_entries == 0 { return None; } @@ -385,7 +389,7 @@ impl ChatComposerHistory { entry: Option, app_event_tx: &AppEventSender, ) -> HistoryEntryResponse { - if self.history_log_id != Some(log_id) { + if self.persistent_log_id != Some(log_id) { return HistoryEntryResponse::Ignored; } @@ -517,7 +521,7 @@ impl ChatComposerHistory { // --------------------------------------------------------------------- fn total_entries(&self) -> usize { - self.history_entry_count + self.local_history.len() + self.persistent_entry_count + self.local_history.len() } fn search_start_offset( @@ -588,8 +592,8 @@ impl ChatComposerHistory { if self.search_matches(&entry) && self.search_result_is_unique(&entry) { return self.search_match(offset, entry); } - } else if offset < self.history_entry_count - && let Some(log_id) = self.history_log_id + } else if offset < self.persistent_entry_count + && let (Some(thread_id), Some(log_id)) = (self.thread_id, self.persistent_log_id) { if let Some(search) = self.search.as_mut() { search.awaiting = Some(PendingHistorySearch { @@ -598,7 +602,11 @@ impl ChatComposerHistory { boundary_if_exhausted, }); } - app_event_tx.send(AppEvent::CodexOp(Op::history_lookup(offset, log_id))); + app_event_tx.send(AppEvent::LookupMessageHistoryEntry { + thread_id, + offset, + log_id, + }); return HistorySearchResult::Pending; } @@ -618,9 +626,9 @@ impl ChatComposerHistory { } fn entry_at_cached_offset(&self, offset: usize) -> Option { - if offset >= self.history_entry_count { + if offset >= self.persistent_entry_count { self.local_history - .get(offset - self.history_entry_count) + .get(offset - self.persistent_entry_count) .cloned() } else { self.fetched_history.get(&offset).cloned() @@ -702,11 +710,11 @@ impl ChatComposerHistory { global_idx: usize, app_event_tx: &AppEventSender, ) -> Option { - if global_idx >= self.history_entry_count { + if global_idx >= self.persistent_entry_count { // Local entry. if let Some(entry) = self .local_history - .get(global_idx - self.history_entry_count) + .get(global_idx - self.persistent_entry_count) .cloned() { self.last_history_text = Some(entry.text.clone()); @@ -715,8 +723,12 @@ impl ChatComposerHistory { } else if let Some(entry) = self.fetched_history.get(&global_idx).cloned() { self.last_history_text = Some(entry.text.clone()); return Some(entry); - } else if let Some(log_id) = self.history_log_id { - app_event_tx.send(AppEvent::CodexOp(Op::history_lookup(global_idx, log_id))); + } else if let (Some(thread_id), Some(log_id)) = (self.thread_id, self.persistent_log_id) { + app_event_tx.send(AppEvent::LookupMessageHistoryEntry { + thread_id, + offset: global_idx, + log_id, + }); } None } @@ -794,6 +806,11 @@ mod tests { use pretty_assertions::assert_eq; use tokio::sync::mpsc::unbounded_channel; + fn test_thread_id() -> ThreadId { + ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8") + .expect("thread id should parse") + } + #[test] fn duplicate_submissions_are_not_recorded() { let mut history = ChatComposerHistory::new(); @@ -830,7 +847,8 @@ mod tests { let mut history = ChatComposerHistory::new(); // Pretend there are 3 persistent entries. - history.set_metadata(/*log_id*/ 1, /*entry_count*/ 3); + let thread_id = test_thread_id(); + history.set_metadata(thread_id, /*log_id*/ 1, /*entry_count*/ 3); // First Up should request offset 2 (latest) and await async data. assert!(history.should_handle_navigation("", /*cursor*/ 0)); @@ -838,16 +856,17 @@ mod tests { // Verify that a history lookup request was sent. let event = rx.try_recv().expect("expected AppEvent to be sent"); - let AppEvent::CodexOp(op) = event else { + let AppEvent::LookupMessageHistoryEntry { + thread_id: response_thread_id, + offset, + log_id, + } = event + else { panic!("unexpected event variant"); }; - assert_eq!( - Op::GetHistoryEntryRequest { - log_id: 1, - offset: 2, - }, - op - ); + assert_eq!(response_thread_id, thread_id); + assert_eq!(offset, 2); + assert_eq!(log_id, 1); // Inject the async response. assert_eq!( @@ -865,16 +884,17 @@ mod tests { // Verify second lookup request for offset 1. let event2 = rx.try_recv().expect("expected second event"); - let AppEvent::CodexOp(op) = event2 else { + let AppEvent::LookupMessageHistoryEntry { + thread_id: response_thread_id, + offset, + log_id, + } = event2 + else { panic!("unexpected event variant"); }; - assert_eq!( - Op::GetHistoryEntryRequest { - log_id: 1, - offset: 1, - }, - op - ); + assert_eq!(response_thread_id, thread_id); + assert_eq!(offset, 1); + assert_eq!(log_id, 1); assert_eq!( HistoryEntryResponse::Found(HistoryEntry::new("older".to_string())), @@ -1017,7 +1037,7 @@ mod tests { let tx = AppEventSender::new(tx); let mut history = ChatComposerHistory::new(); - history.set_metadata(/*log_id*/ 1, /*entry_count*/ 3); + history.set_metadata(test_thread_id(), /*log_id*/ 1, /*entry_count*/ 3); assert_eq!( HistorySearchResult::Pending, @@ -1090,7 +1110,8 @@ mod tests { let tx = AppEventSender::new(tx); let mut history = ChatComposerHistory::new(); - history.set_metadata(/*log_id*/ 1, /*entry_count*/ 3); + let thread_id = test_thread_id(); + history.set_metadata(thread_id, /*log_id*/ 1, /*entry_count*/ 3); assert_eq!( HistorySearchResult::Pending, @@ -1101,16 +1122,17 @@ mod tests { &tx ) ); - let AppEvent::CodexOp(op) = rx.try_recv().expect("expected latest lookup") else { + let AppEvent::LookupMessageHistoryEntry { + thread_id: response_thread_id, + offset, + log_id, + } = rx.try_recv().expect("expected latest lookup") + else { panic!("unexpected event variant"); }; - assert_eq!( - Op::GetHistoryEntryRequest { - log_id: 1, - offset: 2, - }, - op - ); + assert_eq!(response_thread_id, thread_id); + assert_eq!(offset, 2); + assert_eq!(log_id, 1); assert_eq!( HistoryEntryResponse::Search(HistorySearchResult::Pending), @@ -1121,16 +1143,17 @@ mod tests { &tx ) ); - let AppEvent::CodexOp(op) = rx.try_recv().expect("expected next lookup") else { + let AppEvent::LookupMessageHistoryEntry { + thread_id: response_thread_id, + offset, + log_id, + } = rx.try_recv().expect("expected next lookup") + else { panic!("unexpected event variant"); }; - assert_eq!( - Op::GetHistoryEntryRequest { - log_id: 1, - offset: 1, - }, - op - ); + assert_eq!(response_thread_id, thread_id); + assert_eq!(offset, 1); + assert_eq!(log_id, 1); assert_eq!( HistoryEntryResponse::Search(HistorySearchResult::Found(HistoryEntry::new( @@ -1151,7 +1174,7 @@ mod tests { let tx = AppEventSender::new(tx); let mut history = ChatComposerHistory::new(); - history.set_metadata(/*log_id*/ 1, /*entry_count*/ 4); + history.set_metadata(test_thread_id(), /*log_id*/ 1, /*entry_count*/ 4); assert_eq!( HistorySearchResult::Pending, @@ -1270,7 +1293,7 @@ mod tests { let tx = AppEventSender::new(tx); let mut history = ChatComposerHistory::new(); - history.set_metadata(/*log_id*/ 1, /*entry_count*/ 3); + history.set_metadata(test_thread_id(), /*log_id*/ 1, /*entry_count*/ 3); history .fetched_history .insert(1, HistoryEntry::new("command2".to_string())); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 7b0694e0b3..71075f3d06 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -17,6 +17,7 @@ use std::collections::VecDeque; use std::path::PathBuf; use crate::app::app_server_requests::ResolvedAppServerRequest; +use crate::app_event::AppEvent; use crate::app_event::ConnectorsSnapshot; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::pending_input_preview::PendingInputPreview; @@ -36,6 +37,7 @@ use codex_core_skills::model::SkillMetadata; use codex_features::Features; use codex_file_search::FileMatch; use codex_plugin::PluginCapabilitySummary; +use codex_protocol::ThreadId; use codex_protocol::user_input::TextElement; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -206,6 +208,7 @@ pub(crate) struct BottomPane { app_event_tx: AppEventSender, frame_requester: FrameRequester, + thread_id: Option, has_input_focus: bool, enhanced_keys_supported: bool, @@ -271,6 +274,7 @@ impl BottomPane { last_composer_activity_at: None, app_event_tx, frame_requester, + thread_id: None, has_input_focus, enhanced_keys_supported, disable_paste_burst, @@ -776,7 +780,16 @@ impl BottomPane { } pub(crate) fn clear_composer_for_ctrl_c(&mut self) { - self.composer.clear_for_ctrl_c(); + if let Some(text) = self.composer.clear_for_ctrl_c() { + if let Some(thread_id) = self.thread_id { + self.app_event_tx + .send(AppEvent::AppendMessageHistoryEntry { thread_id, text }); + } else { + tracing::warn!( + "failed to append Ctrl+C-cleared draft to history: no active thread id" + ); + } + } self.request_redraw(); } @@ -1315,6 +1328,12 @@ impl BottomPane { AppLinkSuggestionType::Enable => { "Enable this app to use it for the current request.".to_string() } + AppLinkSuggestionType::Auth => unreachable!( + "auth uses URL mode elicitation, not tool suggestion forms" + ), + AppLinkSuggestionType::ExternalAction => unreachable!( + "external actions use URL mode elicitation, not tool suggestion forms" + ), }, url: install_url, is_installed, @@ -1420,8 +1439,15 @@ impl BottomPane { // --- History helpers --- - pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) { - self.composer.set_history_metadata(log_id, entry_count); + pub(crate) fn set_history_metadata( + &mut self, + thread_id: ThreadId, + log_id: u64, + entry_count: usize, + ) { + self.thread_id = Some(thread_id); + self.composer + .set_history_metadata(thread_id, log_id, entry_count); } pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool { diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_auth_suggestion_with_reason.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_auth_suggestion_with_reason.snap new file mode 100644 index 0000000000..0cf228c129 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_auth_suggestion_with_reason.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/bottom_pane/app_link_view.rs +expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))" +--- + + Google Calendar + + Reconnect Google Calendar on ChatGPT. + + URL + https://chatgpt.com/apps/google-calendar/connector_google_calendar + + Sign in to this app in your browser, then return here. + + + › 1. Open sign-in URL + 2. Back + Use tab / ↑ ↓ to move, enter to select, esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_generic_url_elicitation.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_generic_url_elicitation.snap new file mode 100644 index 0000000000..ec95ab96fa --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_generic_url_elicitation.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/bottom_pane/app_link_view.rs +expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))" +--- + + Action required + Server: payments + + Review the payment details to continue. + + URL + https://payments.example/checkout/123 + + Complete the requested action in your browser, then return here. + + + › 1. Open link + 2. Back + Use tab / ↑ ↓ to move, enter to select, esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_generic_url_elicitation_confirmation.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_generic_url_elicitation_confirmation.snap new file mode 100644 index 0000000000..14236e2e76 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_generic_url_elicitation_confirmation.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/bottom_pane/app_link_view.rs +expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))" +--- + + Finish in Browser + + Complete the requested action in the browser window that just + opened. + Then return here and select "I finished". + + Link: + https://payments.example/checkout/123 + + › 1. I finished + 2. Back + Use tab / ↑ ↓ to move, enter to select, esc to close diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 0b58a913cb..0dbf0d235a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2043,10 +2043,12 @@ impl ChatWidget { self.visible_user_turn_count = 0; self.copy_history_evicted_by_rollback = false; self.saw_copy_source_this_turn = false; - let history_entry_count = - usize::try_from(session.history_entry_count).unwrap_or(usize::MAX); - self.bottom_pane - .set_history_metadata(session.history_log_id, history_entry_count); + let history_metadata = session.message_history.unwrap_or_default(); + self.bottom_pane.set_history_metadata( + session.thread_id, + history_metadata.log_id, + history_metadata.entry_count, + ); self.set_skills(/*skills*/ None); self.session_network_proxy = session.network_proxy.clone(); let previous_thread_id = self.thread_id; @@ -2066,7 +2068,10 @@ impl ChatWidget { self.current_rollout_path = session.rollout_path.clone(); self.current_cwd = Some(session.cwd.to_path_buf()); self.config.cwd = session.cwd.clone(); - self.effective_service_tier = session.service_tier; + self.effective_service_tier = session + .service_tier + .as_deref() + .and_then(ServiceTier::from_request_value); if let Err(err) = self .config .permissions @@ -2115,7 +2120,7 @@ impl ChatWidget { if display == SessionConfiguredDisplay::Normal { let startup_tooltip_override = self.startup_tooltip_override.take(); let show_fast_status = - self.should_show_fast_status(&model_for_header, session.service_tier); + self.should_show_fast_status(&model_for_header, self.effective_service_tier); let session_info_cell = history_cell::new_session_info( &self.config, &model_for_header, @@ -4034,7 +4039,7 @@ impl ChatWidget { entry, } = event; self.bottom_pane - .on_history_entry_response(log_id, offset, entry.map(|e| e.text)); + .on_history_entry_response(log_id, offset, entry); } fn on_shutdown_complete(&mut self) { @@ -4557,7 +4562,14 @@ impl ChatWidget { }); let thread_id = self.thread_id.unwrap_or_default(); - if let Some(request) = McpServerElicitationFormRequest::from_app_server_request( + if let Some(params) = crate::bottom_pane::AppLinkViewParams::from_url_app_server_request( + thread_id, + ¶ms.server_name, + request_id.clone(), + ¶ms.request, + ) { + self.open_app_link_view(params); + } else if let Some(request) = McpServerElicitationFormRequest::from_app_server_request( thread_id, request_id.clone(), params.clone(), @@ -4565,18 +4577,29 @@ impl ChatWidget { self.bottom_pane .push_mcp_server_elicitation_request(request); } else { - let request = ApprovalRequest::McpElicitation { - thread_id, - thread_label: None, - server_name: params.server_name, - request_id, - message: match params.request { - McpServerElicitationRequest::Form { message, .. } - | McpServerElicitationRequest::Url { message, .. } => message, - }, - }; - self.bottom_pane - .push_approval_request(request, &self.config.features); + match params.request { + McpServerElicitationRequest::Form { message, .. } => { + let request = ApprovalRequest::McpElicitation { + thread_id, + thread_label: None, + server_name: params.server_name, + request_id, + message, + }; + self.bottom_pane + .push_approval_request(request, &self.config.features); + } + McpServerElicitationRequest::Url { .. } => { + self.app_event_tx.resolve_elicitation( + thread_id, + params.server_name, + request_id, + codex_app_server_protocol::McpServerElicitationAction::Decline, + /*content*/ None, + /*meta*/ None, + ); + } + } } self.request_redraw(); } @@ -4862,7 +4885,10 @@ impl ChatWidget { let active_cell = Some(Self::placeholder_session_header_cell(&config)); let current_cwd = Some(config.cwd.to_path_buf()); - let effective_service_tier = config.service_tier; + let effective_service_tier = config + .service_tier + .as_deref() + .and_then(ServiceTier::from_request_value); let current_terminal_info = terminal_info(); let runtime_keymap = RuntimeKeymap::from_config(&config.tui_keymap).ok(); let default_keymap = RuntimeKeymap::defaults(); @@ -5564,7 +5590,7 @@ impl ChatWidget { ) -> QueueDrain { let drain = self.submit_shell_command(command); if drain == QueueDrain::Stop { - self.submit_op(AppCommand::add_to_history(history_text.to_string())); + self.append_message_history_entry(history_text.to_string()); } drain } @@ -5847,7 +5873,7 @@ impl ChatWidget { .personality .filter(|_| self.config.features.enabled(Feature::Personality)) .filter(|_| self.current_model_supports_personality()); - let service_tier = match self.config.service_tier { + let service_tier = match self.config.service_tier.clone() { Some(service_tier) => Some(Some(service_tier)), None if self.config.notices.fast_default_opt_out == Some(true) => Some(None), None => None, @@ -5895,7 +5921,7 @@ impl ChatWidget { } }; if let Some(history_text) = history_text { - self.submit_op(AppCommand::add_to_history(history_text)); + self.append_message_history_entry(history_text); } if let Some(pending_steer) = pending_steer { @@ -9259,7 +9285,8 @@ impl ChatWidget { /// Set Fast mode in the widget's config copy. pub(crate) fn set_service_tier(&mut self, service_tier: Option) { - self.config.service_tier = service_tier; + self.config.service_tier = + service_tier.map(|service_tier| service_tier.request_value().to_string()); self.effective_service_tier = service_tier; } @@ -9268,7 +9295,10 @@ impl ChatWidget { } pub(crate) fn configured_service_tier(&self) -> Option { - self.config.service_tier + self.config + .service_tier + .as_deref() + .and_then(ServiceTier::from_request_value) } pub(crate) fn fast_default_opt_out(&self) -> Option { @@ -9375,7 +9405,7 @@ impl ChatWidget { /*model*/ None, /*effort*/ None, /*summary*/ None, - Some(service_tier), + Some(service_tier.map(|service_tier| service_tier.request_value().to_string())), /*collaboration_mode*/ None, /*personality*/ None, ))); @@ -10522,6 +10552,15 @@ impl ChatWidget { true } + fn append_message_history_entry(&self, text: String) { + let Some(thread_id) = self.thread_id else { + tracing::warn!("failed to append to message history: no active thread id"); + return; + }; + self.app_event_tx + .send(AppEvent::AppendMessageHistoryEntry { thread_id, text }); + } + pub(crate) fn prepare_local_op_submission(&mut self, op: &AppCommand) { if matches!(op, AppCommand::Interrupt) && self.agent_turn_running { if let Some(controller) = self.stream_controller.as_mut() { diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 3e8a0f631e..059366791e 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -1,6 +1,42 @@ use super::*; use pretty_assertions::assert_eq; +#[tokio::test] +async fn invalid_url_elicitation_is_declined() { + let (mut chat, _app_event_tx, mut rx, _op_rx) = make_chatwidget_manual_with_sender().await; + let thread_id = ThreadId::new(); + chat.thread_id = Some(thread_id); + + chat.handle_elicitation_request_now( + codex_app_server_protocol::RequestId::Integer(9), + codex_app_server_protocol::McpServerElicitationRequestParams { + thread_id: thread_id.to_string(), + turn_id: Some("turn-auth".to_string()), + server_name: "payments".to_string(), + request: codex_app_server_protocol::McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: "http://payments.example/checkout/123".to_string(), + elicitation_id: "payment-123".to_string(), + }, + }, + ); + + assert_matches!( + rx.try_recv(), + Ok(AppEvent::SubmitThreadOp { + thread_id: op_thread_id, + op: Op::ResolveElicitation { + server_name, + request_id: codex_app_server_protocol::RequestId::Integer(9), + decision: codex_app_server_protocol::McpServerElicitationAction::Decline, + content: None, + meta: None, + }, + }) if op_thread_id == thread_id && server_name == "payments" + ); +} + #[tokio::test] async fn collab_spawn_end_shows_requested_model_and_effort() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 76b2b29bc0..d16e031a84 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -12,10 +12,10 @@ use pretty_assertions::assert_eq; async fn submission_preserves_text_elements_and_local_images() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -29,8 +29,7 @@ async fn submission_preserves_text_elements_and_local_images() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -95,7 +94,7 @@ async fn submission_preserves_text_elements_and_local_images() { async fn submission_includes_configured_permission_profile() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let expected_permission_profile: PermissionProfile = AppServerPermissionProfile::Managed { network: PermissionProfileNetworkPermissions { enabled: false }, @@ -119,7 +118,7 @@ async fn submission_includes_configured_permission_profile() { } .into(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -133,8 +132,7 @@ async fn submission_includes_configured_permission_profile() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -161,7 +159,7 @@ async fn submission_includes_configured_permission_profile() { async fn submission_keeps_profile_when_legacy_projection_is_external() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let expected_permission_profile: PermissionProfile = AppServerPermissionProfile::Managed { network: PermissionProfileNetworkPermissions { enabled: false }, @@ -169,7 +167,7 @@ async fn submission_keeps_profile_when_legacy_projection_is_external() { } .into(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -183,8 +181,7 @@ async fn submission_keeps_profile_when_legacy_projection_is_external() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -208,10 +205,10 @@ async fn submission_keeps_profile_when_legacy_projection_is_external() { async fn submission_with_remote_and_local_images_keeps_local_placeholder_numbering() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -225,8 +222,7 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -302,10 +298,10 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi async fn enter_with_only_remote_images_submits_user_turn() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -319,8 +315,7 @@ async fn enter_with_only_remote_images_submits_user_turn() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -366,10 +361,10 @@ async fn enter_with_only_remote_images_submits_user_turn() { async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -383,8 +378,7 @@ async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -405,10 +399,10 @@ async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() { async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -422,8 +416,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -444,10 +437,10 @@ async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() { async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -461,8 +454,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -486,10 +478,10 @@ async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() { async fn submission_prefers_selected_duplicate_skill_path() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -503,8 +495,7 @@ async fn submission_prefers_selected_duplicate_skill_path() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index c9789be14d..b1949d7809 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -941,10 +941,10 @@ async fn user_shell_command_renders_output_not_exploring() { #[tokio::test] async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -958,8 +958,7 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -977,8 +976,8 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() { other => panic!("expected RunUserShellCommand op, got {other:?}"), } assert_matches!( - op_rx.try_recv(), - Ok(Op::AddToHistory { text }) if text == "!echo hi" + rx.try_recv(), + Ok(AppEvent::AppendMessageHistoryEntry { text, .. }) if text == "!echo hi" ); assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); } diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 1db968f7e0..0d781a6a38 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -182,7 +182,10 @@ pub(super) async fn make_chatwidget_manual( }; let current_collaboration_mode = base_mode; let active_collaboration_mask = collaboration_modes::default_mask(model_catalog.as_ref()); - let effective_service_tier = cfg.service_tier; + let effective_service_tier = cfg + .service_tier + .as_deref() + .and_then(ServiceTier::from_request_value); let mut widget = ChatWidget { app_event_tx, codex_op_target: super::CodexOpTarget::Direct(op_tx), diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 8c31175998..d3cd753986 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -14,10 +14,10 @@ use pretty_assertions::assert_eq; async fn resumed_initial_messages_render_history() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -31,8 +31,7 @@ async fn resumed_initial_messages_render_history() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -85,10 +84,10 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { )]; let local_images = vec![PathBuf::from("/tmp/replay.png")]; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -102,8 +101,7 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -154,10 +152,10 @@ async fn replayed_user_message_preserves_remote_image_urls() { let message = "replayed with remote image".to_string(); let remote_image_urls = vec!["https://example.com/image.png".to_string()]; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -171,8 +169,7 @@ async fn replayed_user_message_preserves_remote_image_urls() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -271,8 +268,7 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { cwd: expected_cwd.clone(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: None, }; @@ -328,8 +324,7 @@ async fn session_configured_external_sandbox_keeps_external_runtime_policy() { cwd: test_path_buf("/home/user/external").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: None, }; @@ -350,10 +345,10 @@ async fn replayed_user_message_with_only_remote_images_renders_history_cell() { let remote_image_urls = vec!["https://example.com/remote-only.png".to_string()]; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -367,8 +362,7 @@ async fn replayed_user_message_with_only_remote_images_renders_history_cell() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -405,10 +399,10 @@ async fn replayed_user_message_with_only_local_images_renders_history_cell() { let local_images = [PathBuf::from("/tmp/replay-local-only.png")]; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -422,8 +416,7 @@ async fn replayed_user_message_with_only_local_images_renders_history_cell() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -693,8 +686,7 @@ async fn replayed_reasoning_item_hides_raw_reasoning_when_disabled() { cwd: test_project_path().abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: None, }); @@ -739,8 +731,7 @@ async fn replayed_reasoning_item_shows_raw_reasoning_when_enabled() { cwd: test_project_path().abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: None, }); diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index 09595a11ba..df3615c0fd 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -586,8 +586,7 @@ async fn permissions_selection_marks_auto_review_current_after_session_configure cwd: test_project_path().abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), }); @@ -634,8 +633,7 @@ async fn permissions_selection_marks_auto_review_current_with_custom_workspace_w cwd, instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), }); diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 8af07a6aa9..a5dd3d0eb7 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1202,10 +1202,10 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { #[tokio::test(flavor = "multi_thread")] async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -1219,8 +1219,7 @@ async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -1463,8 +1462,7 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: None, }; diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 3b6b0e7ff2..87ad226b51 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -38,14 +38,16 @@ fn recall_latest_after_clearing(chat: &mut ChatWidget) -> String { chat.bottom_pane.composer_text() } -fn next_add_to_history_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> String { +fn next_add_to_history_event(rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> String { loop { - match op_rx.try_recv() { - Ok(Op::AddToHistory { text }) => return text, + match rx.try_recv() { + Ok(AppEvent::AppendMessageHistoryEntry { text, .. }) => return text, Ok(_) => continue, - Err(TryRecvError::Empty) => panic!("expected AddToHistory op but queue was empty"), + Err(TryRecvError::Empty) => { + panic!("expected AppendMessageHistoryEntry event but queue was empty") + } Err(TryRecvError::Disconnected) => { - panic!("expected AddToHistory op but channel closed") + panic!("expected AppendMessageHistoryEntry event but channel closed") } } } @@ -116,15 +118,6 @@ async fn queued_slash_review_with_args_dispatches_after_active_turn() { complete_turn_with_message(&mut chat, "turn-1", Some("done")); match op_rx.try_recv() { - Ok(Op::AddToHistory { .. }) => match op_rx.try_recv() { - Ok(Op::Review { target }) => assert_eq!( - target, - ReviewTarget::Custom { - instructions: "check regressions".to_string(), - } - ), - other => panic!("expected queued /review to submit review op, got {other:?}"), - }, Ok(Op::Review { target }) => assert_eq!( target, ReviewTarget::Custom { @@ -152,7 +145,7 @@ async fn queued_slash_review_with_args_restores_for_edit() { #[tokio::test] async fn queued_bang_shell_dispatches_after_active_turn() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); handle_turn_started(&mut chat, "turn-1"); @@ -171,10 +164,7 @@ async fn queued_bang_shell_dispatches_after_active_turn() { Ok(Op::RunUserShellCommand { command }) => assert_eq!(command, "echo hi"), other => panic!("expected queued shell command op, got {other:?}"), } - assert_matches!( - op_rx.try_recv(), - Ok(Op::AddToHistory { text }) if text == "!echo hi" - ); + assert_eq!(next_add_to_history_event(&mut rx), "!echo hi"); assert!(chat.queued_user_messages.is_empty()); } @@ -217,7 +207,7 @@ async fn queued_empty_bang_shell_reports_help_when_dequeued_and_drains_next_inpu #[tokio::test] async fn queued_bang_shell_waits_for_user_shell_completion_before_next_input() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); handle_turn_started(&mut chat, "turn-1"); @@ -230,10 +220,7 @@ async fn queued_bang_shell_waits_for_user_shell_completion_before_next_input() { Ok(Op::RunUserShellCommand { command }) => assert_eq!(command, "echo hi"), other => panic!("expected queued shell command op, got {other:?}"), } - assert_matches!( - op_rx.try_recv(), - Ok(Op::AddToHistory { text }) if text == "!echo hi" - ); + assert_eq!(next_add_to_history_event(&mut rx), "!echo hi"); assert_eq!(chat.queued_user_messages.len(), 1); let begin = begin_exec_with_source( @@ -412,10 +399,10 @@ async fn queued_inline_rename_does_not_drain_again_before_turn_started() { ), other => panic!("expected first queued message after /rename, got {other:?}"), } - assert_matches!( - op_rx.try_recv(), - Ok(Op::AddToHistory { text }) if text == "first after rename" - ); + assert!(events.iter().any(|event| matches!( + event, + AppEvent::AppendMessageHistoryEntry { text, .. } if text == "first after rename" + ))); assert_eq!( chat.queued_user_message_texts(), vec!["second after rename"] @@ -945,7 +932,7 @@ fn merged_history_record_remaps_override_image_placeholders() { #[tokio::test] async fn interrupted_merged_message_history_encodes_mentions_once() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); chat.on_task_started(); chat.on_agent_message_delta("Final answer line\n".to_string()); @@ -977,7 +964,7 @@ async fn interrupted_merged_message_history_encodes_mentions_once() { other => panic!("expected user turn, got {other:?}"), } let encoded = "use [$figma](app://figma) now"; - assert_eq!(next_add_to_history_op(&mut op_rx), encoded); + assert_eq!(next_add_to_history_event(&mut rx), encoded); chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); next_interrupt_op(&mut op_rx); @@ -997,7 +984,7 @@ async fn interrupted_merged_message_history_encodes_mentions_once() { } other => panic!("expected resubmitted user turn, got {other:?}"), } - assert_eq!(next_add_to_history_op(&mut op_rx), encoded); + assert_eq!(next_add_to_history_event(&mut rx), encoded); } #[tokio::test] @@ -1594,6 +1581,37 @@ async fn slash_clear_requests_ui_clear_when_idle() { assert_matches!(rx.try_recv(), Ok(AppEvent::ClearUi)); } +#[tokio::test] +async fn slash_clear_after_ctrl_c_keeps_stashed_draft_recallable() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let thread_id = ThreadId::new(); + chat.thread_id = Some(thread_id); + chat.bottom_pane + .set_history_metadata(thread_id, /*log_id*/ 1, /*entry_count*/ 0); + + submit_composer_text(&mut chat, "ok"); + assert_eq!(next_add_to_history_event(&mut rx), "ok"); + + let stashed_draft = "explain why history recall lost this draft"; + + chat.bottom_pane + .set_composer_text(stashed_draft.to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + assert_eq!(chat.bottom_pane.composer_text(), ""); + assert_eq!(next_add_to_history_event(&mut rx), stashed_draft); + + chat.bottom_pane + .set_composer_text("/clear".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_matches!(rx.try_recv(), Ok(AppEvent::ClearUi)); + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(chat.bottom_pane.composer_text(), stashed_draft); + + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(chat.bottom_pane.composer_text(), "ok"); +} + #[tokio::test] async fn slash_clear_is_disabled_while_task_running() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -1803,9 +1821,9 @@ async fn fast_slash_command_updates_and_persists_local_service_tier() { events.iter().any(|event| matches!( event, AppEvent::CodexOp(Op::OverrideTurnContext { - service_tier: Some(Some(ServiceTier::Fast)), + service_tier: Some(Some(service_tier)), .. - }) + }) if service_tier == ServiceTier::Fast.request_value() )), "expected fast-mode override app event; events: {events:?}" ); @@ -1834,9 +1852,9 @@ async fn fast_keybinding_toggle_uses_same_events_as_fast_slash_command() { events.iter().any(|event| matches!( event, AppEvent::CodexOp(Op::OverrideTurnContext { - service_tier: Some(Some(ServiceTier::Fast)), + service_tier: Some(Some(service_tier)), .. - }) + }) if service_tier == ServiceTier::Fast.request_value() )), "expected fast-mode override app event; events: {events:?}" ); @@ -1884,9 +1902,9 @@ async fn user_turn_carries_service_tier_after_fast_toggle() { match next_submit_op(&mut op_rx) { Op::UserTurn { - service_tier: Some(Some(ServiceTier::Fast)), + service_tier: Some(Some(service_tier)), .. - } => {} + } if service_tier == ServiceTier::Fast.request_value() => {} other => panic!("expected Op::UserTurn with fast service tier, got {other:?}"), } } @@ -1909,9 +1927,9 @@ async fn queued_fast_slash_applies_before_next_queued_message() { events.iter().any(|event| matches!( event, AppEvent::CodexOp(Op::OverrideTurnContext { - service_tier: Some(Some(ServiceTier::Fast)), + service_tier: Some(Some(service_tier)), .. - }) + }) if service_tier == ServiceTier::Fast.request_value() )), "expected queued /fast to update service tier before next turn; events: {events:?}" ); @@ -1919,9 +1937,9 @@ async fn queued_fast_slash_applies_before_next_queued_message() { match next_submit_op(&mut op_rx) { Op::UserTurn { items, - service_tier: Some(Some(ServiceTier::Fast)), + service_tier: Some(Some(service_tier)), .. - } => assert_eq!( + } if service_tier == ServiceTier::Fast.request_value() => assert_eq!( items, vec![UserInput::Text { text: "hello after fast".to_string(), diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 96b2b681b6..dcb72ed3b2 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1863,8 +1863,7 @@ async fn session_configured_clears_goal_status_footer() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }); diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index a9dd59e794..256345f67a 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -3830,8 +3830,7 @@ mod tests { cwd: test_path_buf("/tmp/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), } diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 171a1c3513..08bd6657bf 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -5684,6 +5684,7 @@ session_picker_view = "dense" let thread_id = ThreadId::new(); let thread = Thread { id: thread_id.to_string(), + session_id: thread_id.to_string(), forked_from_id: None, preview: String::from("remote thread"), ephemeral: false, @@ -5717,6 +5718,7 @@ session_picker_view = "dense" let thread_id = ThreadId::new(); let thread = Thread { id: thread_id.to_string(), + session_id: thread_id.to_string(), forked_from_id: None, preview: String::from("preview"), ephemeral: false, @@ -5783,6 +5785,7 @@ session_picker_view = "dense" let thread_id = ThreadId::new(); let thread = Thread { id: thread_id.to_string(), + session_id: thread_id.to_string(), forked_from_id: None, preview: String::from("preview"), ephemeral: false, @@ -5839,6 +5842,7 @@ session_picker_view = "dense" let thread_id = ThreadId::new(); let thread = Thread { id: thread_id.to_string(), + session_id: thread_id.to_string(), forked_from_id: None, preview: String::from("preview"), ephemeral: false, diff --git a/codex-rs/tui/src/session_state.rs b/codex-rs/tui/src/session_state.rs index ec0f7789d7..e4d2dbab96 100644 --- a/codex-rs/tui/src/session_state.rs +++ b/codex-rs/tui/src/session_state.rs @@ -17,6 +17,12 @@ pub(crate) struct SessionNetworkProxyRuntime { pub(crate) socks_addr: String, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct MessageHistoryMetadata { + pub(crate) log_id: u64, + pub(crate) entry_count: usize, +} + #[derive(Debug, Clone, PartialEq)] pub(crate) struct ThreadSessionState { pub(crate) thread_id: ThreadId, @@ -25,7 +31,7 @@ pub(crate) struct ThreadSessionState { pub(crate) thread_name: Option, pub(crate) model: String, pub(crate) model_provider_id: String, - pub(crate) service_tier: Option, + pub(crate) service_tier: Option, pub(crate) approval_policy: AskForApproval, pub(crate) approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, /// Canonical active permissions for this session. Legacy app-server @@ -38,8 +44,7 @@ pub(crate) struct ThreadSessionState { pub(crate) cwd: AbsolutePathBuf, pub(crate) instruction_source_paths: Vec, pub(crate) reasoning_effort: Option, - pub(crate) history_log_id: u64, - pub(crate) history_entry_count: u64, + pub(crate) message_history: Option, pub(crate) network_proxy: Option, pub(crate) rollout_path: Option, } diff --git a/codex-rs/tui/tests/fixtures/oss-story.jsonl b/codex-rs/tui/tests/fixtures/oss-story.jsonl index 72d0fc40f4..a62182f487 100644 --- a/codex-rs/tui/tests/fixtures/oss-story.jsonl +++ b/codex-rs/tui/tests/fixtures/oss-story.jsonl @@ -2,7 +2,7 @@ {"ts":"2025-08-10T03:12:26.500Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} {"ts":"2025-08-10T03:12:26.502Z","dir":"to_tui","kind":"log_line","line":"[INFO codex_core::codex] resume_path: None"} {"ts":"2025-08-10T03:12:26.502Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} -{"ts":"2025-08-10T03:12:26.519Z","dir":"to_tui","kind":"codex_event","payload":{"id":"0","msg":{"type":"session_configured","session_id":"8f7c4ac2-6141-42da-b4d5-7032a8e8df3b","model":"gpt-oss:20b","history_log_id":2532619,"history_entry_count":355}}} +{"ts":"2025-08-10T03:12:26.519Z","dir":"to_tui","kind":"codex_event","payload":{"id":"0","msg":{"type":"session_configured","session_id":"8f7c4ac2-6141-42da-b4d5-7032a8e8df3b","model":"gpt-oss:20b"}}} {"ts":"2025-08-10T03:12:26.520Z","dir":"to_tui","kind":"insert_history","lines":9} {"ts":"2025-08-10T03:12:26.520Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} {"ts":"2025-08-10T03:12:26.520Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} diff --git a/codex-rs/vendor/bubblewrap/NEWS.md b/codex-rs/vendor/bubblewrap/NEWS.md index da232c4bd7..eb82ed98d9 100644 --- a/codex-rs/vendor/bubblewrap/NEWS.md +++ b/codex-rs/vendor/bubblewrap/NEWS.md @@ -1,3 +1,50 @@ +bubblewrap 0.11.2 +================= + +Released: 2026-04-23 + +Bug fixes: + + * In setuid mode, don't run the low-privileged parts parts of the setup + as dumpable, as that allows it to be ptraced which can lead to problems. + This is CVE-2026-41163, and was reported by François Diakhate. + +Enhancements: + + * New build option `-Dsupport_setuid`, which if set to false (which + is the default) disables the support for setuid. Binaries built + with this will refuse to run if made setuid. We recommend building + normal bubblewrap binaries like this, which allows you to safely + ignore any security issues that only affect setuid mode. + +bubblewrap 0.11.1 +================= + +Released: 2026-03-21 + +Bug fixes: + + * Reset disposition of `SIGCHLD`, restoring normal subprocess management + if bwrap was run from a process that was ignoring that signal, + such as Erlang or volumeicon (#705, Joel Pelaez Jorge) + + * Don't ignore `--userns 0`, `--userns2 0` or `--pidns 0` if used + (#731, Daniel Cazares). + Note that using a fd number ≥ 3 for these purposes is still + preferred, to avoid confusion with the stdin, stdout, stderr + that will be inherited by the command inside the container. + + * Fix grammar in an error message (#694, J. Neuschäfer) + + * Fix a broken link in the documentation (#729, Aaron Brooks) + +Internal changes: + + * Enable user namespaces in Github Actions configuration, fixing a CI + regression with newer Ubuntu (#728, Joel Pelaez Jorge) + + * Clarify comments (#737, Simon McVittie) + bubblewrap 0.11.0 ================= diff --git a/codex-rs/vendor/bubblewrap/README.md b/codex-rs/vendor/bubblewrap/README.md index c16cd7d89a..1f838ce0a1 100644 --- a/codex-rs/vendor/bubblewrap/README.md +++ b/codex-rs/vendor/bubblewrap/README.md @@ -12,23 +12,24 @@ on the host. User namespaces --------------- -There is an effort in the Linux kernel called +There is an feature in the Linux kernel called [user namespaces](https://www.google.com/search?q=user+namespaces+site%3Ahttps%3A%2F%2Flwn.net) -which attempts to allow unprivileged users to use container features. -While significant progress has been made, there are -[still concerns](https://lwn.net/Articles/673597/) about it, and -it is not available to unprivileged users in several production distributions -such as CentOS/Red Hat Enterprise Linux 7, Debian Jessie, etc. +which allows unprivileged users to use container features. Bubblewrap uses these to +build the sandbox, allowing any user to use the tool. -See for example -[CVE-2016-3135](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2016-3135) -which is a local root vulnerability introduced by userns. -[This March 2016 post](https://lkml.org/lkml/2016/3/9/555) has some -more discussion. +Historically, not all Linux distributions supported (at least by +default) unprivileged user namespaces, so bubblewrap supports a second +mode of operation when the binary is setuid root. In that setup +bubblewrap could be viewed as setuid implementation of a *subset* of +user namespaces. However, not all features of bubblewrap work in +this mode. -Bubblewrap could be viewed as setuid implementation of a *subset* of -user namespaces. Emphasis on subset - specifically relevant to the -above CVE, bubblewrap does not allow control over iptables. +However, setuid mode is deprecated, as most recent Linux distributions +support unprivileged user namespaces, and setuid binaries carry +significant risks. By default, bubblewrap binaries refuse to work if +setuid, and you must build explicitly with ` -Dsupport_setuid=true` to +enable it to work. Later versions of bubblewrap aims to completely +remove this support. The original bubblewrap code existed before user namespaces - it inherits code from [xdg-app helper](https://cgit.freedesktop.org/xdg-app/xdg-app/tree/common/xdg-app-helper.c?id=4c3bf179e2e4a2a298cd1db1d045adaf3f564532) @@ -151,7 +152,7 @@ sandbox. You can also change what the value of uid/gid should be in the sandbox. IPC namespaces ([CLONE_NEWIPC](https://linux.die.net/man/2/clone)): The sandbox will get its own copy of all the different forms of IPCs, like SysV shared memory and semaphores. -PID namespaces ([CLONE_NEWPID](https://linux.die.net/man/2/clone)): The sandbox will not see any processes outside the sandbox. Additionally, bubblewrap will run a trivial pid1 inside your container to handle the requirements of reaping children in the sandbox. This avoids what is known now as the [Docker pid 1 problem](https://blog.phusion.nl/2015/01/20/docker-and-the-pid-1-zombie-reaping-problem/). +PID namespaces ([CLONE_NEWPID](https://linux.die.net/man/2/clone)): The sandbox will not see any processes outside the sandbox. Additionally, bubblewrap will run a trivial pid1 inside your container to handle the requirements of reaping children in the sandbox. This avoids what is known now as the [Docker pid 1 problem](https://blog.phusion.nl/docker-and-the-pid-1-zombie-reaping-problem/). Network namespaces ([CLONE_NEWNET](https://linux.die.net/man/2/clone)): The sandbox will not see the network. Instead it will have its own network namespace with only a loopback device. diff --git a/codex-rs/vendor/bubblewrap/SECURITY.md b/codex-rs/vendor/bubblewrap/SECURITY.md index 0ddfc6c873..7e0fb32746 100644 --- a/codex-rs/vendor/bubblewrap/SECURITY.md +++ b/codex-rs/vendor/bubblewrap/SECURITY.md @@ -15,6 +15,13 @@ between the user and the OS, because anything bubblewrap could do, a malicious user could equally well do by writing their own tool equivalent to bubblewrap. +Since 0.11.2, unless compiled with the `-Dsupport_setuid=true` option, +setuid root support is disabled. In this mode bubblewrap will refuse +to operate if the binary has been made setuid. For binaries built like +this it is safe to ignore any bubblewrap CVEs that are described as +affecting setuid mode only. This is the recommended way to package +bubblewrap. + ### Sandbox security bubblewrap is a toolkit for constructing sandbox environments. diff --git a/codex-rs/vendor/bubblewrap/bubblewrap.c b/codex-rs/vendor/bubblewrap/bubblewrap.c index 69d319b7a3..9039ddfa80 100644 --- a/codex-rs/vendor/bubblewrap/bubblewrap.c +++ b/codex-rs/vendor/bubblewrap/bubblewrap.c @@ -55,7 +55,11 @@ static uid_t real_uid; static gid_t real_gid; static uid_t overflow_uid; static gid_t overflow_gid; +#ifdef ENABLE_SUPPORT_SETUID static bool is_privileged; /* See acquire_privs() */ +#else +#define is_privileged 0 +#endif static const char *argv0; static const char *host_tty_dev; static int proc_fd = -1; @@ -840,13 +844,16 @@ set_ambient_capabilities (void) static void acquire_privs (void) { - uid_t euid, new_fsuid; + uid_t euid; euid = geteuid (); /* Are we setuid ? */ if (real_uid != euid) { +#ifdef ENABLE_SUPPORT_SETUID + uid_t new_fsuid; + if (euid != 0) die ("Unexpected setuid user %d, should be 0", euid); @@ -868,13 +875,16 @@ acquire_privs (void) /* setfsuid can't properly report errors, check that it worked (as per manpage) */ new_fsuid = setfsuid (-1); if (new_fsuid != real_uid) - die ("Unable to set fsuid (was %d)", (int)new_fsuid); + die_with_error ("Unable to set fsuid (was %d)", (int)new_fsuid); /* We never need capabilities after execve(), so lets drop everything from the bounding set */ drop_cap_bounding_set (true); /* Keep only the required capabilities for setup */ set_required_caps (); +#else + die ("setuid use of bubblewrap is not supported in this build"); +#endif } else if (real_uid != 0 && has_caps ()) { @@ -937,7 +947,8 @@ switch_to_user_with_privs (void) /* Call setuid() and use capset() to adjust capabilities */ static void drop_privs (bool keep_requested_caps, - bool already_changed_uid) + bool already_changed_uid, + bool set_dumpable) { assert (!keep_requested_caps || !is_privileged); /* Drop root uid */ @@ -947,9 +958,12 @@ drop_privs (bool keep_requested_caps, drop_all_caps (keep_requested_caps); - /* We don't have any privs now, so mark us dumpable which makes /proc/self be owned by the user instead of root */ - if (prctl (PR_SET_DUMPABLE, 1, 0, 0, 0) != 0) - die_with_error ("can't set dumpable"); + if (set_dumpable) + { + /* We don't have any privs now, so mark us dumpable which makes /proc/self be owned by the user instead of root */ + if (prctl (PR_SET_DUMPABLE, 1, 0, 0, 0) != 0) + die_with_error ("can't set dumpable"); + } } static void @@ -1154,7 +1168,9 @@ privileged_op (int privileged_op_socket, break; case PRIV_SEP_OP_OVERLAY_MOUNT: - if (mount ("overlay", arg2, "overlay", MS_MGC_VAL, arg1) != 0) + if (is_privileged) + die ("Overlay mounts are not supported in setuid mode"); + if (mount ("overlay", arg2, "overlay", MS_MGC_VAL | MS_NOSUID | MS_NODEV, arg1) != 0) { /* The standard message for ELOOP, "Too many levels of symbolic * links", is not helpful here. */ @@ -1172,6 +1188,8 @@ privileged_op (int privileged_op_socket, something manages to send hacked priv-sep operation requests. */ if (!opt_unshare_uts) die ("Refusing to set hostname in original namespace"); + if (arg1 == NULL) + die ("Hostname argument is NULL"); if (sethostname (arg1, strlen(arg1)) != 0) die_with_error ("Can't set hostname to %s", arg1); break; @@ -3112,7 +3130,7 @@ main (int argc, } /* Switch to the custom user ns before the clone, gets us privs in that ns (assuming its a child of the current and thus allowed) */ - if (opt_userns_fd > 0 && setns (opt_userns_fd, CLONE_NEWUSER) != 0) + if (opt_userns_fd != -1 && setns (opt_userns_fd, CLONE_NEWUSER) != 0) { if (errno == EINVAL) die ("Joining the specified user namespace failed, it might not be a descendant of the current user namespace."); @@ -3178,11 +3196,11 @@ main (int argc, /* Initial launched process, wait for pid 1 or exec:ed command to exit */ - if (opt_userns2_fd > 0 && setns (opt_userns2_fd, CLONE_NEWUSER) != 0) + if (opt_userns2_fd != -1 && setns (opt_userns2_fd, CLONE_NEWUSER) != 0) die_with_error ("Setting userns2 failed"); /* We don't need any privileges in the launcher, drop them immediately. */ - drop_privs (false, false); + drop_privs (false, false, true); /* Optionally bind our lifecycle to that of the parent */ handle_die_with_parent (); @@ -3219,7 +3237,7 @@ main (int argc, return monitor_child (event_fd, pid, setup_finished_pipe[0]); } - if (opt_pidns_fd > 0) + if (opt_pidns_fd != -1) { if (setns (opt_pidns_fd, CLONE_NEWPID) != 0) die_with_error ("Setting pidns failed"); @@ -3369,8 +3387,10 @@ main (int argc, if (child == 0) { - /* Unprivileged setup process */ - drop_privs (false, true); + /* Unprivileged setup process. + * Note: Don't set dumpable, because we can still perform privileged + * operations via privileged_op(). */ + drop_privs (false, true, false); close (privsep_sockets[0]); setup_newroot (opt_unshare_pid, privsep_sockets[1]); exit (0); @@ -3446,7 +3466,7 @@ main (int argc, die_with_error ("chdir /"); } - if (opt_userns2_fd > 0 && setns (opt_userns2_fd, CLONE_NEWUSER) != 0) + if (opt_userns2_fd != -1 && setns (opt_userns2_fd, CLONE_NEWUSER) != 0) die_with_error ("Setting userns2 failed"); if (opt_unshare_user && opt_userns_block_fd == -1 && @@ -3499,7 +3519,7 @@ main (int argc, } /* All privileged ops are done now, so drop caps we don't need */ - drop_privs (!is_privileged, true); + drop_privs (!is_privileged, true, true); if (opt_block_fd != -1) { diff --git a/codex-rs/vendor/bubblewrap/meson.build b/codex-rs/vendor/bubblewrap/meson.build index 78678d0973..520d0a5f47 100644 --- a/codex-rs/vendor/bubblewrap/meson.build +++ b/codex-rs/vendor/bubblewrap/meson.build @@ -1,7 +1,7 @@ project( 'bubblewrap', 'c', - version : '0.11.0', + version : '0.11.2', meson_version : '>=0.49.0', default_options : [ 'warning_level=2', @@ -91,6 +91,11 @@ if get_option('require_userns') cdata.set('ENABLE_REQUIRE_USERNS', 1) endif +if get_option('support_setuid') + cdata.set('ENABLE_SUPPORT_SETUID', 1) + warning('running bubblewrap setuid is deprecated and risky. Most recent operating systems support unprivileged user namespaces and we recommend using that. Support for this will be removed in the next version.') +endif + configure_file( output : 'config.h', configuration : cdata, diff --git a/codex-rs/vendor/bubblewrap/meson_options.txt b/codex-rs/vendor/bubblewrap/meson_options.txt index 5e25ee86f8..05b1c7873e 100644 --- a/codex-rs/vendor/bubblewrap/meson_options.txt +++ b/codex-rs/vendor/bubblewrap/meson_options.txt @@ -41,6 +41,12 @@ option( type : 'string', description : 'Path to Python 3, or empty to use python3', ) +option( + 'support_setuid', + type : 'boolean', + description : 'Support setuid mode (deprecated)', + value : false, +) option( 'require_userns', type : 'boolean', diff --git a/codex-rs/vendor/bubblewrap/network.c b/codex-rs/vendor/bubblewrap/network.c index 106e6d6e36..373d606a0a 100644 --- a/codex-rs/vendor/bubblewrap/network.c +++ b/codex-rs/vendor/bubblewrap/network.c @@ -50,7 +50,7 @@ static int rtnl_send_request (int rtnl_fd, struct nlmsghdr *header) { - struct sockaddr_nl dst_addr = { .nl_family = AF_NETLINK, .nl_pid = 0, .nl_groups = 0 }; + struct sockaddr_nl dst_addr = { AF_NETLINK, 0 }; ssize_t sent; sent = TEMP_FAILURE_RETRY (sendto (rtnl_fd, (void *) header, header->nlmsg_len, 0, @@ -139,7 +139,7 @@ loopback_setup (void) int r, if_loopback; cleanup_fd int rtnl_fd = -1; char buffer[1024]; - struct sockaddr_nl src_addr = { .nl_family = AF_NETLINK, .nl_pid = 0, .nl_groups = 0 }; + struct sockaddr_nl src_addr = { AF_NETLINK, 0 }; struct nlmsghdr *header; struct ifaddrmsg *addmsg; struct ifinfomsg *infomsg; diff --git a/codex-rs/vendor/bubblewrap/release-checklist.md b/codex-rs/vendor/bubblewrap/release-checklist.md index 5b2119c8af..0c1479e447 100644 --- a/codex-rs/vendor/bubblewrap/release-checklist.md +++ b/codex-rs/vendor/bubblewrap/release-checklist.md @@ -1,13 +1,13 @@ bubblewrap release checklist ============================ -* Collect release notes in `NEWS` -* Update version number in `meson.build` and release date in `NEWS` +* Collect release notes in `NEWS.md` +* Update version number in `meson.build` and release date in `NEWS.md` * Commit the changes * `meson dist -C ${builddir}` * Do any final smoke-testing, e.g. update a package, install and test it * `git evtag sign v$VERSION` - * Include the release notes from `NEWS` in the tag message + * Include the release notes from `NEWS.md` in the tag message * `git push --atomic origin main v$VERSION` * https://github.com/containers/bubblewrap/releases/new * Fill in the new version's tag in the "Tag version" box diff --git a/codex-rs/vendor/bubblewrap/utils.c b/codex-rs/vendor/bubblewrap/utils.c index 51875aea9a..7b7349ab82 100644 --- a/codex-rs/vendor/bubblewrap/utils.c +++ b/codex-rs/vendor/bubblewrap/utils.c @@ -510,14 +510,18 @@ ensure_file (const char *path, the create file will fail in the read-only case with EROFS instead of EEXIST. - We're trying to set up a mount point for a non-directory, so any - non-directory, non-symlink is acceptable - it doesn't necessarily - have to be a regular file. */ + We're trying to set up a mount point for a non-directory, for which + the kernel will accept any non-directory. If it's a symlink, follow + it and look at the target: again, any non-directory is good enough. + We'll only get S_ISLNK if the path is a dangling symlink (target + doesn't exist). */ if (stat (path, &buf) == 0 && !S_ISDIR (buf.st_mode) && !S_ISLNK (buf.st_mode)) return 0; + /* If the file didn't exist, create it. If it was a dangling symlink + * (S_ISLNK above) then this will create the target of the symlink. */ if (create_file (path, mode, NULL) != 0 && errno != EEXIST) return -1; @@ -681,7 +685,8 @@ ensure_dir (const char *path, /* We check this ahead of time, otherwise the mkdir call can fail in the read-only case with EROFS instead of EEXIST on some - filesystems (such as NFS) */ + filesystems (such as NFS). + We follow symlinks: it's OK if path is a symlink to a directory. */ if (stat (path, &buf) == 0) { if (!S_ISDIR (buf.st_mode))