diff --git a/.bazelrc b/.bazelrc index e39a3aff2285..c8b8682c4b1c 100644 --- a/.bazelrc +++ b/.bazelrc @@ -105,9 +105,9 @@ common:ci --disk_cache= # Shared config for the main Bazel CI workflow. common:ci-bazel --config=ci common:ci-bazel --build_metadata=TAG_workflow=bazel -# Bazel CI cross-compiles in several legs, and the V8-backed code-mode tests -# are not stable in that setup yet. Keep running the rest of the Rust -# integration suites through the workspace-root launcher. +# Keep code-mode integration cases out of ordinary Bazel legs. The +# Windows-cross config below re-enables them after generating its Windows V8 +# snapshot on the Windows runner. common:ci-bazel --test_env=CODEX_BAZEL_TEST_SKIP_FILTERS=suite::code_mode:: # Shared config for Bazel-backed Rust linting. @@ -186,12 +186,16 @@ common:ci-windows-cross --config=ci-windows common:ci-windows-cross --build_metadata=TAG_windows_cross_compile=true common:ci-windows-cross --host_platform=//:rbe common:ci-windows-cross --strategy=TestRunner=local +# V8 embeds IsolateData offsets in snapshot builtins; Windows snapshots must be +# generated by a Windows mksnapshot binary rather than the Linux RBE host tool. +common:ci-windows-cross --strategy=V8Mksnapshot=local common:ci-windows-cross --local_test_jobs=4 common:ci-windows-cross --test_env=RUST_TEST_THREADS=1 # Native Windows CI still covers the PowerShell tests. The cross-built gnullvm # binaries currently hang in PowerShell AST parser tests when those binaries are -# run on the Windows runner. -common:ci-windows-cross --test_env=CODEX_BAZEL_TEST_SKIP_FILTERS=suite::code_mode::,powershell +# run on the Windows runner. Keep V8-backed code-mode tests enabled except for +# the hidden dynamic-tool callback test, which currently times out on Windows. +common:ci-windows-cross --test_env=CODEX_BAZEL_TEST_SKIP_FILTERS=powershell,suite::code_mode::code_mode_can_call_hidden_dynamic_tools common:ci-windows-cross --platforms=//:windows_x86_64_gnullvm common:ci-windows-cross --extra_execution_platforms=//:rbe,//:windows_x86_64_msvc common:ci-windows-cross --extra_toolchains=//:windows_gnullvm_tests_on_msvc_host_toolchain diff --git a/.codex/skills/pushing-ci-changes/SKILL.md b/.codex/skills/pushing-ci-changes/SKILL.md new file mode 100644 index 000000000000..cb91bdd981dc --- /dev/null +++ b/.codex/skills/pushing-ci-changes/SKILL.md @@ -0,0 +1,17 @@ +--- +name: pushing-ci-changes +description: Pushing GitHub Actions changes, resolving push rejection, requesting upload exceptions. +--- + +The Codex repository prevents anyone from uploading changes to its CI configuration unless they've +been granted a temporary role. + +In order to push changes to `.github/**/*.yml` and related files you'll need the user to read +go/workflow-approvals and request an approval through that flow. It is not possible for you to +make progress by requesting an exemption yourself. + +If you know you're about to push changes that would fail due to these restrictions you should still +try to push anyways to confirm the user's account doesn't already have the necessary approval. + +If you encounter a failed push due to these restrictions, share the go/workflow-approvals link with +the user and ask them to return control to you once the approval has propagated to GitHub. diff --git a/AGENTS.md b/AGENTS.md index 88d3fc4eeac3..46d2626e321d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,8 @@ In the codex-rs folder where the rust code lives: - Implementations may still use `async fn foo(&self, ...) -> T` when they satisfy that contract. - Do not use `#[allow(async_fn_in_trait)]` as a shortcut around spelling the future contract explicitly. - When writing tests, prefer comparing the equality of entire objects over fields one by one. +- Do not add tests for values that are statically defined. +- Do not add negative tests for logic that was removed. - Do not add general product or user-facing documentation to the `docs/` folder. The official Codex documentation lives elsewhere. The exception is app-server API documentation, which is covered by the app-server guidance below. - Prefer private modules and explicitly exported public crate API. - If you change `ConfigToml` or nested config types, run `just write-config-schema` to update `codex-rs/core/config.schema.json`. @@ -190,6 +192,12 @@ If you don’t have the tool: - `cargo install --locked cargo-insta` +### Benchmarks + +cargo benchmarks can be run with `just bench`, use the divan crate to write new ones. + +Use `just bench-smoke` to dry-run the benchmark for a single iteration to ensure it works. + ### Test assertions - Tests should use pretty_assertions::assert_eq for clearer diffs. Import this at the top of the test module if it isn't already. diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 1e79865d4373..d67e418298f4 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1926,6 +1926,7 @@ dependencies = [ "codex-file-search", "codex-file-watcher", "codex-git-utils", + "codex-goal-extension", "codex-guardian", "codex-hooks", "codex-image-generation-extension", @@ -2548,6 +2549,7 @@ dependencies = [ "codex-feedback", "codex-git-utils", "codex-hooks", + "codex-image-generation-extension", "codex-install-context", "codex-login", "codex-mcp", @@ -2583,6 +2585,7 @@ dependencies = [ "codex-utils-pty", "codex-utils-stream-parser", "codex-utils-string", + "codex-web-search-extension", "codex-windows-sandbox", "core_test_support", "csv", @@ -2789,6 +2792,7 @@ dependencies = [ "codex-file-system", "codex-protocol", "codex-sandboxing", + "codex-shell-command", "codex-test-binary-support", "codex-utils-absolute-path", "codex-utils-pty", @@ -3046,7 +3050,6 @@ dependencies = [ "codex-api", "codex-core", "codex-extension-api", - "codex-features", "codex-login", "codex-model-provider", "codex-model-provider-info", @@ -3597,6 +3600,7 @@ dependencies = [ "urlencoding", "webbrowser", "which 8.0.0", + "wiremock", ] [[package]] @@ -3691,6 +3695,7 @@ dependencies = [ "base64 0.22.1", "codex-protocol", "codex-utils-absolute-path", + "libc", "once_cell", "pretty_assertions", "regex", @@ -4204,7 +4209,6 @@ dependencies = [ "codex-api", "codex-core", "codex-extension-api", - "codex-features", "codex-login", "codex-model-provider", "codex-model-provider-info", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index f77a581ac102..e6be3a6e7805 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -121,7 +121,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.138.0-alpha.4" +version = "0.138.0-alpha.6" # Track the edition for all workspace crates in one place. Individual # crates can still override this value, but keeping it here means new # crates created with `cargo new -w ...` automatically inherit the 2024 @@ -485,7 +485,6 @@ unwrap_used = "deny" [workspace.metadata.cargo-shear] ignored = [ "codex-agent-graph-store", - "codex-goal-extension", "icu_provider", "openssl-sys", "codex-v8-poc", diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 7177952c3702..f4cd18da1e91 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -63,6 +63,8 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TrackEventsContext; use crate::facts::TurnCodexErrorFact; +use crate::facts::TurnProfile; +use crate::facts::TurnProfileFact; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; use crate::facts::TurnSteerRequestError; @@ -396,6 +398,18 @@ fn sample_turn_resolved_config(thread_id: &str, turn_id: &str) -> TurnResolvedCo } } +fn sample_turn_profile() -> TurnProfile { + TurnProfile { + before_first_sampling_ms: 100, + sampling_ms: 700, + between_sampling_overhead_ms: 50, + tool_blocking_ms: 250, + after_last_sampling_ms: 134, + sampling_request_count: 2, + sampling_retry_count: 1, + } +} + fn sample_turn_steer_request( thread_id: &str, expected_turn_id: &str, @@ -649,6 +663,18 @@ async fn ingest_turn_prerequisites( ) .await; } + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::TurnProfile(Box::new( + TurnProfileFact { + turn_id: "turn-2".to_string(), + profile: sample_turn_profile(), + }, + ))), + out, + ) + .await; } async fn ingest_review_prerequisites( @@ -3300,6 +3326,13 @@ fn turn_event_serializes_expected_shape() { output_tokens: None, reasoning_output_tokens: None, total_tokens: None, + before_first_sampling_ms: 100, + sampling_ms: 700, + between_sampling_overhead_ms: 50, + tool_blocking_ms: 250, + after_last_sampling_ms: 134, + sampling_request_count: 2, + sampling_retry_count: 1, duration_ms: Some(1234), started_at: Some(455), completed_at: Some(456), @@ -3366,6 +3399,13 @@ fn turn_event_serializes_expected_shape() { "output_tokens": null, "reasoning_output_tokens": null, "total_tokens": null, + "before_first_sampling_ms": 100, + "sampling_ms": 700, + "between_sampling_overhead_ms": 50, + "tool_blocking_ms": 250, + "after_last_sampling_ms": 134, + "sampling_request_count": 2, + "sampling_retry_count": 1, "duration_ms": 1234, "started_at": 455, "completed_at": 456 diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index bd0726b28eed..b99a2ec86fc1 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -19,6 +19,7 @@ use crate::facts::SkillInvokedInput; use crate::facts::SubAgentThreadStartedInput; use crate::facts::TrackEventsContext; use crate::facts::TurnCodexErrorFact; +use crate::facts::TurnProfileFact; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnTokenUsageFact; use crate::reducer::AnalyticsReducer; @@ -257,6 +258,12 @@ impl AnalyticsEventsClient { ))); } + pub fn track_turn_profile(&self, fact: TurnProfileFact) { + self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnProfile( + Box::new(fact), + ))); + } + pub fn track_turn_codex_error(&self, fact: TurnCodexErrorFact) { self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnCodexError( Box::new(fact), diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index bdc2c996dcbc..f0fcddd27fe5 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -817,6 +817,13 @@ pub(crate) struct CodexTurnEventParams { pub(crate) output_tokens: Option, pub(crate) reasoning_output_tokens: Option, pub(crate) total_tokens: Option, + pub(crate) before_first_sampling_ms: u64, + pub(crate) sampling_ms: u64, + pub(crate) between_sampling_overhead_ms: u64, + pub(crate) tool_blocking_ms: u64, + pub(crate) after_last_sampling_ms: u64, + pub(crate) sampling_request_count: u32, + pub(crate) sampling_retry_count: u32, pub(crate) duration_ms: Option, pub(crate) started_at: Option, pub(crate) completed_at: Option, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 1dff5de89dea..38af5ed8b827 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -104,6 +104,23 @@ pub struct TurnTokenUsageFact { pub token_usage: TokenUsage, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct TurnProfile { + pub before_first_sampling_ms: u64, + pub sampling_ms: u64, + pub between_sampling_overhead_ms: u64, + pub tool_blocking_ms: u64, + pub after_last_sampling_ms: u64, + pub sampling_request_count: u32, + pub sampling_retry_count: u32, +} + +#[derive(Clone)] +pub struct TurnProfileFact { + pub turn_id: String, + pub profile: TurnProfile, +} + #[derive(Clone)] pub struct TurnCodexErrorFact { pub(crate) turn_id: String, @@ -476,6 +493,7 @@ pub(crate) enum CustomAnalyticsFact { GuardianReview(Box), TurnResolvedConfig(Box), TurnTokenUsage(Box), + TurnProfile(Box), TurnCodexError(Box), SkillInvoked(SkillInvokedInput), AppMentioned(AppMentionedInput), diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index c227f4daf185..e2e16dfacd84 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -39,6 +39,8 @@ pub use facts::SubAgentThreadStartedInput; pub use facts::ThreadInitializationMode; pub use facts::TrackEventsContext; pub use facts::TurnCodexErrorFact; +pub use facts::TurnProfile; +pub use facts::TurnProfileFact; pub use facts::TurnResolvedConfigFact; pub use facts::TurnStatus; pub use facts::TurnSteerRejectionReason; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 877000928f6a..66caaafe01fa 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -72,6 +72,8 @@ use crate::facts::SubAgentThreadStartedInput; use crate::facts::ThreadInitializationMode; use crate::facts::TurnCodexError; use crate::facts::TurnCodexErrorFact; +use crate::facts::TurnProfile; +use crate::facts::TurnProfileFact; use crate::facts::TurnResolvedConfigFact; use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; @@ -316,6 +318,7 @@ struct CompletedTurnState { duration_ms: Option, } +#[derive(Default)] struct TurnState { connection_id: Option, thread_id: Option, @@ -323,6 +326,7 @@ struct TurnState { resolved_config: Option, started_at: Option, token_usage: Option, + profile: Option, completed: Option, codex_error: Option, latest_diff: Option, @@ -464,6 +468,9 @@ impl AnalyticsReducer { CustomAnalyticsFact::TurnTokenUsage(input) => { self.ingest_turn_token_usage(*input, out).await; } + CustomAnalyticsFact::TurnProfile(input) => { + self.ingest_turn_profile(*input, out).await; + } CustomAnalyticsFact::TurnCodexError(input) => { self.ingest_turn_codex_error(*input); } @@ -604,19 +611,7 @@ impl AnalyticsReducer { let turn_id = input.turn_id.clone(); let thread_id = input.thread_id.clone(); let num_input_images = input.num_input_images; - let turn_state = self.turns.entry(turn_id.clone()).or_insert(TurnState { - connection_id: None, - thread_id: None, - num_input_images: None, - resolved_config: None, - started_at: None, - token_usage: None, - completed: None, - codex_error: None, - latest_diff: None, - steer_count: 0, - tool_counts: TurnToolCounts::default(), - }); + let turn_state = self.turns.entry(turn_id.clone()).or_default(); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); turn_state.resolved_config = Some(input); @@ -629,43 +624,30 @@ impl AnalyticsReducer { out: &mut Vec, ) { let turn_id = input.turn_id.clone(); - let turn_state = self.turns.entry(turn_id.clone()).or_insert(TurnState { - connection_id: None, - thread_id: None, - num_input_images: None, - resolved_config: None, - started_at: None, - token_usage: None, - completed: None, - codex_error: None, - latest_diff: None, - steer_count: 0, - tool_counts: TurnToolCounts::default(), - }); + let turn_state = self.turns.entry(turn_id.clone()).or_default(); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); self.maybe_emit_turn_event(&turn_id, out).await; } + async fn ingest_turn_profile( + &mut self, + input: TurnProfileFact, + out: &mut Vec, + ) { + let TurnProfileFact { turn_id, profile } = input; + let turn_state = self.turns.entry(turn_id.clone()).or_default(); + turn_state.profile = Some(profile); + self.maybe_emit_turn_event(&turn_id, out).await; + } + fn ingest_turn_codex_error(&mut self, input: TurnCodexErrorFact) { let TurnCodexErrorFact { turn_id, thread_id, error, } = input; - let turn_state = self.turns.entry(turn_id).or_insert(TurnState { - connection_id: None, - thread_id: None, - num_input_images: None, - resolved_config: None, - started_at: None, - token_usage: None, - completed: None, - codex_error: None, - latest_diff: None, - steer_count: 0, - tool_counts: TurnToolCounts::default(), - }); + let turn_state = self.turns.entry(turn_id).or_default(); turn_state.thread_id.get_or_insert(thread_id); turn_state.codex_error = Some(error); } @@ -818,19 +800,7 @@ impl AnalyticsReducer { else { return; }; - let turn_state = self.turns.entry(turn_id.clone()).or_insert(TurnState { - connection_id: None, - thread_id: None, - num_input_images: None, - resolved_config: None, - started_at: None, - token_usage: None, - completed: None, - codex_error: None, - latest_diff: None, - steer_count: 0, - tool_counts: TurnToolCounts::default(), - }); + let turn_state = self.turns.entry(turn_id.clone()).or_default(); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); turn_state.num_input_images = Some(pending_request.num_input_images); @@ -1178,61 +1148,19 @@ impl AnalyticsReducer { self.ingest_guardian_review_completed(notification, out); } ServerNotification::TurnStarted(notification) => { - let turn_state = self.turns.entry(notification.turn.id).or_insert(TurnState { - connection_id: None, - thread_id: None, - num_input_images: None, - resolved_config: None, - started_at: None, - token_usage: None, - completed: None, - codex_error: None, - latest_diff: None, - steer_count: 0, - tool_counts: TurnToolCounts::default(), - }); + let turn_state = self.turns.entry(notification.turn.id).or_default(); turn_state.started_at = notification .turn .started_at .and_then(|started_at| u64::try_from(started_at).ok()); } ServerNotification::TurnDiffUpdated(notification) => { - let turn_state = - self.turns - .entry(notification.turn_id.clone()) - .or_insert(TurnState { - connection_id: None, - thread_id: None, - num_input_images: None, - resolved_config: None, - started_at: None, - token_usage: None, - completed: None, - codex_error: None, - latest_diff: None, - steer_count: 0, - tool_counts: TurnToolCounts::default(), - }); + let turn_state = self.turns.entry(notification.turn_id.clone()).or_default(); turn_state.thread_id = Some(notification.thread_id); turn_state.latest_diff = Some(notification.diff); } ServerNotification::TurnCompleted(notification) => { - let turn_state = - self.turns - .entry(notification.turn.id.clone()) - .or_insert(TurnState { - connection_id: None, - thread_id: None, - num_input_images: None, - resolved_config: None, - started_at: None, - token_usage: None, - completed: None, - codex_error: None, - latest_diff: None, - steer_count: 0, - tool_counts: TurnToolCounts::default(), - }); + let turn_state = self.turns.entry(notification.turn.id.clone()).or_default(); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), turn_error: notification @@ -1511,6 +1439,7 @@ impl AnalyticsReducer { if turn_state.thread_id.is_none() || turn_state.num_input_images.is_none() || turn_state.resolved_config.is_none() + || turn_state.profile.is_none() || turn_state.completed.is_none() { return; @@ -2457,12 +2386,20 @@ fn codex_turn_event_params( turn_state: &TurnState, thread_metadata: &ThreadMetadataState, ) -> CodexTurnEventParams { - let (Some(thread_id), Some(num_input_images), Some(resolved_config), Some(completed)) = ( + let ( + Some(thread_id), + Some(num_input_images), + Some(resolved_config), + Some(profile), + Some(completed), + ) = ( turn_state.thread_id.clone(), turn_state.num_input_images, turn_state.resolved_config.clone(), + turn_state.profile.clone(), turn_state.completed.clone(), - ) else { + ) + else { unreachable!("turn event params require a fully populated turn state"); }; let started_at = turn_state.started_at; @@ -2488,6 +2425,15 @@ fn codex_turn_event_params( workspace_kind, is_first_turn, } = resolved_config; + let TurnProfile { + before_first_sampling_ms, + sampling_ms, + between_sampling_overhead_ms, + tool_blocking_ms, + after_last_sampling_ms, + sampling_request_count, + sampling_retry_count, + } = profile; let token_usage = turn_state.token_usage.clone(); let codex_error = turn_state.codex_error.as_ref(); CodexTurnEventParams { @@ -2550,6 +2496,13 @@ fn codex_turn_event_params( total_tokens: token_usage .as_ref() .map(|token_usage| token_usage.total_tokens), + before_first_sampling_ms, + sampling_ms, + between_sampling_overhead_ms, + tool_blocking_ms, + after_last_sampling_ms, + sampling_request_count, + sampling_retry_count, duration_ms: completed.duration_ms, started_at, completed_at: Some(completed.completed_at), diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 861ae1dcbdf9..6d9be4ec026c 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -34,6 +34,30 @@ ], "type": "string" }, + "AgentMessageInputContent": { + "oneOf": [ + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": [ + "encrypted_content" + ], + "title": "EncryptedContentAgentMessageInputContentType", + "type": "string" + } + }, + "required": [ + "encrypted_content", + "type" + ], + "title": "EncryptedContentAgentMessageInputContent", + "type": "object" + } + ] + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ @@ -2120,6 +2144,37 @@ "title": "MessageResponseItem", "type": "object" }, + { + "properties": { + "author": { + "type": "string" + }, + "content": { + "items": { + "$ref": "#/definitions/AgentMessageInputContent" + }, + "type": "array" + }, + "recipient": { + "type": "string" + }, + "type": { + "enum": [ + "agent_message" + ], + "title": "AgentMessageResponseItemType", + "type": "string" + } + }, + "required": [ + "author", + "content", + "recipient", + "type" + ], + "title": "AgentMessageResponseItem", + "type": "object" + }, { "properties": { "content": { @@ -5914,6 +5969,29 @@ "title": "Account/rateLimits/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "account/usage/read" + ], + "title": "Account/usage/readRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "Account/usage/readRequest", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json index 961e24c2c28a..f2ab78334200 100644 --- a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json @@ -288,8 +288,8 @@ "environmentId": { "default": null, "type": [ - "null", - "string" + "string", + "null" ] }, "itemId": { @@ -326,4 +326,4 @@ ], "title": "PermissionsRequestApprovalParams", "type": "object" -} +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 16656e3a5544..96b24f9324b9 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -525,6 +525,13 @@ "agentIdentity" ], "type": "string" + }, + { + "description": "Programmatic Codex auth backed by a personal access token.", + "enum": [ + "personalAccessToken" + ], + "type": "string" } ] }, @@ -4909,6 +4916,23 @@ } ] }, + "TurnModerationMetadataNotification": { + "properties": { + "metadata": true, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "metadata", + "threadId", + "turnId" + ], + "type": "object" + }, "TurnPlanStep": { "properties": { "status": { @@ -6248,6 +6272,26 @@ "title": "Model/verificationNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "turn/moderationMetadata" + ], + "title": "Turn/moderationMetadataNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TurnModerationMetadataNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Turn/moderationMetadataNotification", + "type": "object" + }, { "properties": { "method": { diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index 13bac2e118a8..dbfca64f4cf8 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -1593,8 +1593,8 @@ "environmentId": { "default": null, "type": [ - "null", - "string" + "string", + "null" ] }, "itemId": { @@ -2005,4 +2005,4 @@ } ], "title": "ServerRequest" -} +} \ No newline at end of file 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 81617aa93c4c..0c29f4a097d0 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 @@ -1833,6 +1833,29 @@ "title": "Account/rateLimits/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "account/usage/read" + ], + "title": "Account/usage/readRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "Account/usage/readRequest", + "type": "object" + }, { "properties": { "id": { @@ -3786,8 +3809,8 @@ "environmentId": { "default": null, "type": [ - "null", - "string" + "string", + "null" ] }, "itemId": { @@ -4904,6 +4927,26 @@ "title": "Model/verificationNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "turn/moderationMetadata" + ], + "title": "Turn/moderationMetadataNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/TurnModerationMetadataNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Turn/moderationMetadataNotification", + "type": "object" + }, { "properties": { "method": { @@ -5739,6 +5782,62 @@ "title": "AccountRateLimitsUpdatedNotification", "type": "object" }, + "AccountTokenUsageDailyBucket": { + "properties": { + "startDate": { + "type": "string" + }, + "tokens": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "startDate", + "tokens" + ], + "type": "object" + }, + "AccountTokenUsageSummary": { + "properties": { + "currentStreakDays": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "lifetimeTokens": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "longestRunningTurnSec": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "longestStreakDays": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "peakDailyTokens": { + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, "AccountUpdatedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -5900,6 +5999,30 @@ "title": "AgentMessageDeltaNotification", "type": "object" }, + "AgentMessageInputContent": { + "oneOf": [ + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": [ + "encrypted_content" + ], + "title": "EncryptedContentAgentMessageInputContentType", + "type": "string" + } + }, + "required": [ + "encrypted_content", + "type" + ], + "title": "EncryptedContentAgentMessageInputContent", + "type": "object" + } + ] + }, "AgentPath": { "type": "string" }, @@ -6278,6 +6401,69 @@ ], "type": "object" }, + "AppTemplateSummary": { + "properties": { + "canonicalConnectorId": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "logoUrl": { + "type": [ + "string", + "null" + ] + }, + "logoUrlDark": { + "type": [ + "string", + "null" + ] + }, + "materializedAppIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "name": { + "type": "string" + }, + "reason": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AppTemplateUnavailableReason" + }, + { + "type": "null" + } + ] + }, + "templateId": { + "type": "string" + } + }, + "required": [ + "materializedAppIds", + "name", + "templateId" + ], + "type": "object" + }, + "AppTemplateUnavailableReason": { + "enum": [ + "NOT_CONFIGURED_FOR_WORKSPACE", + "NO_ACTIVE_WORKSPACE" + ], + "type": "string" + }, "AppToolApproval": { "enum": [ "auto", @@ -6491,6 +6677,13 @@ "agentIdentity" ], "type": "string" + }, + { + "description": "Programmatic Codex auth backed by a personal access token.", + "enum": [ + "personalAccessToken" + ], + "type": "string" } ] }, @@ -7827,12 +8020,12 @@ "null" ] }, - "allowedPermissions": { - "items": { - "type": "string" + "allowedPermissionProfiles": { + "additionalProperties": { + "type": "boolean" }, "type": [ - "array", + "object", "null" ] }, @@ -7873,6 +8066,12 @@ } ] }, + "defaultPermissions": { + "type": [ + "string", + "null" + ] + }, "enforceResidency": { "anyOf": [ { @@ -9519,6 +9718,28 @@ "title": "GetAccountResponse", "type": "object" }, + "GetAccountTokenUsageResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "dailyUsageBuckets": { + "items": { + "$ref": "#/definitions/v2/AccountTokenUsageDailyBucket" + }, + "type": [ + "array", + "null" + ] + }, + "summary": { + "$ref": "#/definitions/v2/AccountTokenUsageSummary" + } + }, + "required": [ + "summary" + ], + "title": "GetAccountTokenUsageResponse", + "type": "object" + }, "GitInfo": { "properties": { "branch": { @@ -12168,6 +12389,12 @@ }, "PluginDetail": { "properties": { + "appTemplates": { + "items": { + "$ref": "#/definitions/v2/AppTemplateSummary" + }, + "type": "array" + }, "apps": { "items": { "$ref": "#/definitions/v2/AppSummary" @@ -12216,6 +12443,7 @@ } }, "required": [ + "appTemplates", "apps", "hooks", "marketplaceName", @@ -13980,6 +14208,37 @@ "title": "MessageResponseItem", "type": "object" }, + { + "properties": { + "author": { + "type": "string" + }, + "content": { + "items": { + "$ref": "#/definitions/v2/AgentMessageInputContent" + }, + "type": "array" + }, + "recipient": { + "type": "string" + }, + "type": { + "enum": [ + "agent_message" + ], + "title": "AgentMessageResponseItemType", + "type": "string" + } + }, + "required": [ + "author", + "content", + "recipient", + "type" + ], + "title": "AgentMessageResponseItem", + "type": "object" + }, { "properties": { "content": { @@ -18476,6 +18735,25 @@ } ] }, + "TurnModerationMetadataNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "metadata": true, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "metadata", + "threadId", + "turnId" + ], + "title": "TurnModerationMetadataNotification", + "type": "object" + }, "TurnPlanStep": { "properties": { "status": { @@ -19232,4 +19510,4 @@ }, "title": "CodexAppServerProtocol", "type": "object" -} +} \ No newline at end of file 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 336350bcec5f..b378a54d16b8 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 @@ -104,6 +104,62 @@ "title": "AccountRateLimitsUpdatedNotification", "type": "object" }, + "AccountTokenUsageDailyBucket": { + "properties": { + "startDate": { + "type": "string" + }, + "tokens": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "startDate", + "tokens" + ], + "type": "object" + }, + "AccountTokenUsageSummary": { + "properties": { + "currentStreakDays": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "lifetimeTokens": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "longestRunningTurnSec": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "longestStreakDays": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "peakDailyTokens": { + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, "AccountUpdatedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -265,6 +321,30 @@ "title": "AgentMessageDeltaNotification", "type": "object" }, + "AgentMessageInputContent": { + "oneOf": [ + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": [ + "encrypted_content" + ], + "title": "EncryptedContentAgentMessageInputContentType", + "type": "string" + } + }, + "required": [ + "encrypted_content", + "type" + ], + "title": "EncryptedContentAgentMessageInputContent", + "type": "object" + } + ] + }, "AgentPath": { "type": "string" }, @@ -643,6 +723,69 @@ ], "type": "object" }, + "AppTemplateSummary": { + "properties": { + "canonicalConnectorId": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "logoUrl": { + "type": [ + "string", + "null" + ] + }, + "logoUrlDark": { + "type": [ + "string", + "null" + ] + }, + "materializedAppIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "name": { + "type": "string" + }, + "reason": { + "anyOf": [ + { + "$ref": "#/definitions/AppTemplateUnavailableReason" + }, + { + "type": "null" + } + ] + }, + "templateId": { + "type": "string" + } + }, + "required": [ + "materializedAppIds", + "name", + "templateId" + ], + "type": "object" + }, + "AppTemplateUnavailableReason": { + "enum": [ + "NOT_CONFIGURED_FOR_WORKSPACE", + "NO_ACTIVE_WORKSPACE" + ], + "type": "string" + }, "AppToolApproval": { "enum": [ "auto", @@ -856,6 +999,13 @@ "agentIdentity" ], "type": "string" + }, + { + "description": "Programmatic Codex auth backed by a personal access token.", + "enum": [ + "personalAccessToken" + ], + "type": "string" } ] }, @@ -2592,6 +2742,29 @@ "title": "Account/rateLimits/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "account/usage/read" + ], + "title": "Account/usage/readRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "Account/usage/readRequest", + "type": "object" + }, { "properties": { "id": { @@ -4189,12 +4362,12 @@ "null" ] }, - "allowedPermissions": { - "items": { - "type": "string" + "allowedPermissionProfiles": { + "additionalProperties": { + "type": "boolean" }, "type": [ - "array", + "object", "null" ] }, @@ -4235,6 +4408,12 @@ } ] }, + "defaultPermissions": { + "type": [ + "string", + "null" + ] + }, "enforceResidency": { "anyOf": [ { @@ -5992,6 +6171,28 @@ "title": "GetAccountResponse", "type": "object" }, + "GetAccountTokenUsageResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "dailyUsageBuckets": { + "items": { + "$ref": "#/definitions/AccountTokenUsageDailyBucket" + }, + "type": [ + "array", + "null" + ] + }, + "summary": { + "$ref": "#/definitions/AccountTokenUsageSummary" + } + }, + "required": [ + "summary" + ], + "title": "GetAccountTokenUsageResponse", + "type": "object" + }, "GitInfo": { "properties": { "branch": { @@ -8690,6 +8891,12 @@ }, "PluginDetail": { "properties": { + "appTemplates": { + "items": { + "$ref": "#/definitions/AppTemplateSummary" + }, + "type": "array" + }, "apps": { "items": { "$ref": "#/definitions/AppSummary" @@ -8738,6 +8945,7 @@ } }, "required": [ + "appTemplates", "apps", "hooks", "marketplaceName", @@ -10502,6 +10710,37 @@ "title": "MessageResponseItem", "type": "object" }, + { + "properties": { + "author": { + "type": "string" + }, + "content": { + "items": { + "$ref": "#/definitions/AgentMessageInputContent" + }, + "type": "array" + }, + "recipient": { + "type": "string" + }, + "type": { + "enum": [ + "agent_message" + ], + "title": "AgentMessageResponseItemType", + "type": "string" + } + }, + "required": [ + "author", + "content", + "recipient", + "type" + ], + "title": "AgentMessageResponseItem", + "type": "object" + }, { "properties": { "content": { @@ -12295,6 +12534,26 @@ "title": "Model/verificationNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "turn/moderationMetadata" + ], + "title": "Turn/moderationMetadataNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TurnModerationMetadataNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Turn/moderationMetadataNotification", + "type": "object" + }, { "properties": { "method": { @@ -16293,6 +16552,25 @@ } ] }, + "TurnModerationMetadataNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "metadata": true, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "metadata", + "threadId", + "turnId" + ], + "title": "TurnModerationMetadataNotification", + "type": "object" + }, "TurnPlanStep": { "properties": { "status": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json index e7546f5570e9..013592c9ed67 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json @@ -31,6 +31,13 @@ "agentIdentity" ], "type": "string" + }, + { + "description": "Programmatic Codex auth backed by a personal access token.", + "enum": [ + "personalAccessToken" + ], + "type": "string" } ] }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index 0b8170d659c3..def89d64e36a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -94,12 +94,12 @@ "null" ] }, - "allowedPermissions": { - "items": { - "type": "string" + "allowedPermissionProfiles": { + "additionalProperties": { + "type": "boolean" }, "type": [ - "array", + "object", "null" ] }, @@ -140,6 +140,12 @@ } ] }, + "defaultPermissions": { + "type": [ + "string", + "null" + ] + }, "enforceResidency": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountTokenUsageResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountTokenUsageResponse.json new file mode 100644 index 000000000000..557a297ce119 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountTokenUsageResponse.json @@ -0,0 +1,80 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AccountTokenUsageDailyBucket": { + "properties": { + "startDate": { + "type": "string" + }, + "tokens": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "startDate", + "tokens" + ], + "type": "object" + }, + "AccountTokenUsageSummary": { + "properties": { + "currentStreakDays": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "lifetimeTokens": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "longestRunningTurnSec": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "longestStreakDays": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "peakDailyTokens": { + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "properties": { + "dailyUsageBuckets": { + "items": { + "$ref": "#/definitions/AccountTokenUsageDailyBucket" + }, + "type": [ + "array", + "null" + ] + }, + "summary": { + "$ref": "#/definitions/AccountTokenUsageSummary" + } + }, + "required": [ + "summary" + ], + "title": "GetAccountTokenUsageResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json index c9fe43c34f9f..5a4c2e5b0972 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -37,6 +37,69 @@ ], "type": "object" }, + "AppTemplateSummary": { + "properties": { + "canonicalConnectorId": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "logoUrl": { + "type": [ + "string", + "null" + ] + }, + "logoUrlDark": { + "type": [ + "string", + "null" + ] + }, + "materializedAppIds": { + "items": { + "type": "string" + }, + "type": "array" + }, + "name": { + "type": "string" + }, + "reason": { + "anyOf": [ + { + "$ref": "#/definitions/AppTemplateUnavailableReason" + }, + { + "type": "null" + } + ] + }, + "templateId": { + "type": "string" + } + }, + "required": [ + "materializedAppIds", + "name", + "templateId" + ], + "type": "object" + }, + "AppTemplateUnavailableReason": { + "enum": [ + "NOT_CONFIGURED_FOR_WORKSPACE", + "NO_ACTIVE_WORKSPACE" + ], + "type": "string" + }, "HookEventName": { "enum": [ "preToolUse", @@ -78,6 +141,12 @@ }, "PluginDetail": { "properties": { + "appTemplates": { + "items": { + "$ref": "#/definitions/AppTemplateSummary" + }, + "type": "array" + }, "apps": { "items": { "$ref": "#/definitions/AppSummary" @@ -126,6 +195,7 @@ } }, "required": [ + "appTemplates", "apps", "hooks", "marketplaceName", diff --git a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json index f82acbae64cb..c18f9aeb1597 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -1,6 +1,30 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AgentMessageInputContent": { + "oneOf": [ + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": [ + "encrypted_content" + ], + "title": "EncryptedContentAgentMessageInputContentType", + "type": "string" + } + }, + "required": [ + "encrypted_content", + "type" + ], + "title": "EncryptedContentAgentMessageInputContent", + "type": "object" + } + ] + }, "ContentItem": { "oneOf": [ { @@ -369,6 +393,37 @@ "title": "MessageResponseItem", "type": "object" }, + { + "properties": { + "author": { + "type": "string" + }, + "content": { + "items": { + "$ref": "#/definitions/AgentMessageInputContent" + }, + "type": "array" + }, + "recipient": { + "type": "string" + }, + "type": { + "enum": [ + "agent_message" + ], + "title": "AgentMessageResponseItemType", + "type": "string" + } + }, + "required": [ + "author", + "content", + "recipient", + "type" + ], + "title": "AgentMessageResponseItem", + "type": "object" + }, { "properties": { "content": { 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 9d2f834dd8c6..d9a543e9394a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json @@ -1,6 +1,10 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ 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 364cb36617d4..dc5ef5cdaa1d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -1,6 +1,34 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "AgentMessageInputContent": { + "oneOf": [ + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": [ + "encrypted_content" + ], + "title": "EncryptedContentAgentMessageInputContentType", + "type": "string" + } + }, + "required": [ + "encrypted_content", + "type" + ], + "title": "EncryptedContentAgentMessageInputContent", + "type": "object" + } + ] + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ @@ -436,6 +464,37 @@ "title": "MessageResponseItem", "type": "object" }, + { + "properties": { + "author": { + "type": "string" + }, + "content": { + "items": { + "$ref": "#/definitions/AgentMessageInputContent" + }, + "type": "array" + }, + "recipient": { + "type": "string" + }, + "type": { + "enum": [ + "agent_message" + ], + "title": "AgentMessageResponseItemType", + "type": "string" + } + }, + "required": [ + "author", + "content", + "recipient", + "type" + ], + "title": "AgentMessageResponseItem", + "type": "object" + }, { "properties": { "content": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnModerationMetadataNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnModerationMetadataNotification.json new file mode 100644 index 000000000000..273bd4106196 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnModerationMetadataNotification.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "metadata": true, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "metadata", + "threadId", + "turnId" + ], + "title": "TurnModerationMetadataNotification", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/AgentMessageInputContent.ts b/codex-rs/app-server-protocol/schema/typescript/AgentMessageInputContent.ts new file mode 100644 index 000000000000..a3bb645597fc --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/AgentMessageInputContent.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 AgentMessageInputContent = { "type": "encrypted_content", encrypted_content: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/AuthMode.ts b/codex-rs/app-server-protocol/schema/typescript/AuthMode.ts index 210e54c4a5fe..1cb6ccb6b3b7 100644 --- a/codex-rs/app-server-protocol/schema/typescript/AuthMode.ts +++ b/codex-rs/app-server-protocol/schema/typescript/AuthMode.ts @@ -5,4 +5,4 @@ /** * Authentication mode for OpenAI-backed providers. */ -export type AuthMode = "apikey" | "chatgpt" | "chatgptAuthTokens" | "agentIdentity"; +export type AuthMode = "apikey" | "chatgpt" | "chatgptAuthTokens" | "agentIdentity" | "personalAccessToken"; diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index e19ac2dfde3d..c91792c72656 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -86,4 +86,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/goal/set", id: RequestId, params: ThreadGoalSetParams, } | { "method": "thread/goal/get", id: RequestId, params: ThreadGoalGetParams, } | { "method": "thread/goal/clear", id: RequestId, params: ThreadGoalClearParams, } | { "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": "skills/extraRoots/set", id: RequestId, params: SkillsExtraRootsSetParams, } | { "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/installed", id: RequestId, params: PluginInstalledParams, } | { "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/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "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": "permissionProfile/list", id: RequestId, params: PermissionProfileListParams, } | { "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/goal/set", id: RequestId, params: ThreadGoalSetParams, } | { "method": "thread/goal/get", id: RequestId, params: ThreadGoalGetParams, } | { "method": "thread/goal/clear", id: RequestId, params: ThreadGoalClearParams, } | { "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": "skills/extraRoots/set", id: RequestId, params: SkillsExtraRootsSetParams, } | { "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/installed", id: RequestId, params: PluginInstalledParams, } | { "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/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "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": "permissionProfile/list", id: RequestId, params: PermissionProfileListParams, } | { "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/usage/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/ResponseItem.ts b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts index e5e960ff81a0..b90963db8020 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AgentMessageInputContent } from "./AgentMessageInputContent"; import type { ContentItem } from "./ContentItem"; import type { FunctionCallOutputBody } from "./FunctionCallOutputBody"; import type { LocalShellAction } from "./LocalShellAction"; @@ -10,7 +11,7 @@ import type { ReasoningItemContent } from "./ReasoningItemContent"; import type { ReasoningItemReasoningSummary } from "./ReasoningItemReasoningSummary"; import type { WebSearchAction } from "./WebSearchAction"; -export type ResponseItem = { "type": "message", role: string, content: Array, phase?: MessagePhase, } | { "type": "reasoning", summary: Array, content?: Array, encrypted_content: string | null, } | { "type": "local_shell_call", +export type ResponseItem = { "type": "message", role: string, content: Array, phase?: MessagePhase, } | { "type": "agent_message", author: string, recipient: string, content: Array, } | { "type": "reasoning", summary: Array, content?: Array, encrypted_content: string | null, } | { "type": "local_shell_call", /** * Set when using the Responses API. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts index 3ed710efc0a3..2520c71c3ca6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts @@ -61,6 +61,7 @@ import type { ThreadTokenUsageUpdatedNotification } from "./v2/ThreadTokenUsageU import type { ThreadUnarchivedNotification } from "./v2/ThreadUnarchivedNotification"; import type { TurnCompletedNotification } from "./v2/TurnCompletedNotification"; import type { TurnDiffUpdatedNotification } from "./v2/TurnDiffUpdatedNotification"; +import type { TurnModerationMetadataNotification } from "./v2/TurnModerationMetadataNotification"; import type { TurnPlanUpdatedNotification } from "./v2/TurnPlanUpdatedNotification"; import type { TurnStartedNotification } from "./v2/TurnStartedNotification"; import type { WarningNotification } from "./v2/WarningNotification"; @@ -70,4 +71,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW /** * Notification sent from the server to the client. */ -export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/goal/updated", "params": ThreadGoalUpdatedNotification } | { "method": "thread/goal/cleared", "params": ThreadGoalClearedNotification } | { "method": "thread/settings/updated", "params": ThreadSettingsUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "process/outputDelta", "params": ProcessOutputDeltaNotification } | { "method": "process/exited", "params": ProcessExitedNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "remoteControl/status/changed", "params": RemoteControlStatusChangedNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "model/verification", "params": ModelVerificationNotification } | { "method": "warning", "params": WarningNotification } | { "method": "guardianWarning", "params": GuardianWarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; +export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/goal/updated", "params": ThreadGoalUpdatedNotification } | { "method": "thread/goal/cleared", "params": ThreadGoalClearedNotification } | { "method": "thread/settings/updated", "params": ThreadSettingsUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "process/outputDelta", "params": ProcessOutputDeltaNotification } | { "method": "process/exited", "params": ProcessExitedNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "remoteControl/status/changed", "params": RemoteControlStatusChangedNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "model/verification", "params": ModelVerificationNotification } | { "method": "turn/moderationMetadata", "params": TurnModerationMetadataNotification } | { "method": "warning", "params": WarningNotification } | { "method": "guardianWarning", "params": GuardianWarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 458d2e43b9a7..149b3aec0d62 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! export type { AbsolutePathBuf } from "./AbsolutePathBuf"; +export type { AgentMessageInputContent } from "./AgentMessageInputContent"; export type { AgentPath } from "./AgentPath"; export type { ApplyPatchApprovalParams } from "./ApplyPatchApprovalParams"; export type { ApplyPatchApprovalResponse } from "./ApplyPatchApprovalResponse"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AccountTokenUsageDailyBucket.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AccountTokenUsageDailyBucket.ts new file mode 100644 index 000000000000..a92c6c00c479 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AccountTokenUsageDailyBucket.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 AccountTokenUsageDailyBucket = { startDate: string, tokens: bigint, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AccountTokenUsageSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AccountTokenUsageSummary.ts new file mode 100644 index 000000000000..6f87acdaffaa --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AccountTokenUsageSummary.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 AccountTokenUsageSummary = { lifetimeTokens: bigint | null, peakDailyTokens: bigint | null, longestRunningTurnSec: bigint | null, currentStreakDays: bigint | null, longestStreakDays: bigint | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppTemplateSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppTemplateSummary.ts new file mode 100644 index 000000000000..dd5f76229f05 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppTemplateSummary.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 { AppTemplateUnavailableReason } from "./AppTemplateUnavailableReason"; + +export type AppTemplateSummary = { templateId: string, name: string, description: string | null, canonicalConnectorId: string | null, logoUrl: string | null, logoUrlDark: string | null, materializedAppIds: Array, reason: AppTemplateUnavailableReason | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppTemplateUnavailableReason.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppTemplateUnavailableReason.ts new file mode 100644 index 000000000000..563056798ec5 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppTemplateUnavailableReason.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 AppTemplateUnavailableReason = "NOT_CONFIGURED_FOR_WORKSPACE" | "NO_ACTIVE_WORKSPACE"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts index 3e60e78da53f..29704982ff53 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts @@ -8,4 +8,4 @@ import type { ResidencyRequirement } from "./ResidencyRequirement"; import type { SandboxMode } from "./SandboxMode"; import type { WindowsSandboxSetupMode } from "./WindowsSandboxSetupMode"; -export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedWindowsSandboxImplementations: Array | null, allowedPermissions: Array | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, allowAppshots: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null}; +export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedWindowsSandboxImplementations: Array | null, allowedPermissionProfiles: { [key in string]?: boolean } | null, defaultPermissions: string | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, allowAppshots: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountTokenUsageResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountTokenUsageResponse.ts new file mode 100644 index 000000000000..175c9152722e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountTokenUsageResponse.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AccountTokenUsageDailyBucket } from "./AccountTokenUsageDailyBucket"; +import type { AccountTokenUsageSummary } from "./AccountTokenUsageSummary"; + +export type GetAccountTokenUsageResponse = { summary: AccountTokenUsageSummary, dailyUsageBuckets: Array | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts index 64836c87f7cc..cc2042dd5dd5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts @@ -3,8 +3,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { AppSummary } from "./AppSummary"; +import type { AppTemplateSummary } from "./AppTemplateSummary"; import type { PluginHookSummary } from "./PluginHookSummary"; import type { PluginSummary } from "./PluginSummary"; import type { SkillSummary } from "./SkillSummary"; -export type PluginDetail = { marketplaceName: string, marketplacePath: AbsolutePathBuf | null, summary: PluginSummary, description: string | null, skills: Array, hooks: Array, apps: Array, mcpServers: Array, }; +export type PluginDetail = { marketplaceName: string, marketplacePath: AbsolutePathBuf | null, summary: PluginSummary, description: string | null, skills: Array, hooks: Array, apps: Array, appTemplates: Array, mcpServers: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnModerationMetadataNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnModerationMetadataNotification.ts new file mode 100644 index 000000000000..1d46d1b4b833 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnModerationMetadataNotification.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 { JsonValue } from "../serde_json/JsonValue"; + +export type TurnModerationMetadataNotification = { threadId: string, turnId: string, metadata: JsonValue, }; 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 48e1f9580b7f..727c049d10be 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -3,6 +3,8 @@ export type { Account } from "./Account"; export type { AccountLoginCompletedNotification } from "./AccountLoginCompletedNotification"; export type { AccountRateLimitsUpdatedNotification } from "./AccountRateLimitsUpdatedNotification"; +export type { AccountTokenUsageDailyBucket } from "./AccountTokenUsageDailyBucket"; +export type { AccountTokenUsageSummary } from "./AccountTokenUsageSummary"; export type { AccountUpdatedNotification } from "./AccountUpdatedNotification"; export type { ActivePermissionProfile } from "./ActivePermissionProfile"; export type { AddCreditsNudgeCreditType } from "./AddCreditsNudgeCreditType"; @@ -21,6 +23,8 @@ export type { AppMetadata } from "./AppMetadata"; export type { AppReview } from "./AppReview"; export type { AppScreenshot } from "./AppScreenshot"; export type { AppSummary } from "./AppSummary"; +export type { AppTemplateSummary } from "./AppTemplateSummary"; +export type { AppTemplateUnavailableReason } from "./AppTemplateUnavailableReason"; export type { AppToolApproval } from "./AppToolApproval"; export type { AppToolsConfig } from "./AppToolsConfig"; export type { ApprovalsReviewer } from "./ApprovalsReviewer"; @@ -139,6 +143,7 @@ export type { FsWriteFileResponse } from "./FsWriteFileResponse"; export type { GetAccountParams } from "./GetAccountParams"; export type { GetAccountRateLimitsResponse } from "./GetAccountRateLimitsResponse"; export type { GetAccountResponse } from "./GetAccountResponse"; +export type { GetAccountTokenUsageResponse } from "./GetAccountTokenUsageResponse"; export type { GitInfo } from "./GitInfo"; export type { GrantedPermissionProfile } from "./GrantedPermissionProfile"; export type { GuardianApprovalReview } from "./GuardianApprovalReview"; @@ -445,6 +450,7 @@ export type { TurnError } from "./TurnError"; export type { TurnInterruptParams } from "./TurnInterruptParams"; export type { TurnInterruptResponse } from "./TurnInterruptResponse"; export type { TurnItemsView } from "./TurnItemsView"; +export type { TurnModerationMetadataNotification } from "./TurnModerationMetadataNotification"; export type { TurnPlanStep } from "./TurnPlanStep"; export type { TurnPlanStepStatus } from "./TurnPlanStepStatus"; export type { TurnPlanUpdatedNotification } from "./TurnPlanUpdatedNotification"; diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index 20f923f327cf..cd6c7e47b6a9 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -2963,11 +2963,14 @@ permissionProfile?: string | null}; let client_request_json = fs::read_to_string(output_dir.join("ClientRequest.json"))?; assert!(client_request_json.contains("remoteControl/pairing/start")); + assert!(client_request_json.contains("remoteControl/pairing/status")); assert!(client_request_json.contains("remoteControl/client/list")); assert!(client_request_json.contains("remoteControl/client/revoke")); for schema in [ "RemoteControlPairingStartParams.json", "RemoteControlPairingStartResponse.json", + "RemoteControlPairingStatusParams.json", + "RemoteControlPairingStatusResponse.json", "RemoteControlClientsListParams.json", "RemoteControlClientsListResponse.json", "RemoteControlClientsRevokeParams.json", diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 5a60d8d002fe..d3c8eae22b35 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -36,6 +36,21 @@ pub enum AuthMode { #[ts(rename = "agentIdentity")] #[strum(serialize = "agentIdentity")] AgentIdentity, + /// Programmatic Codex auth backed by a personal access token. + #[serde(rename = "personalAccessToken")] + #[ts(rename = "personalAccessToken")] + #[strum(serialize = "personalAccessToken")] + PersonalAccessToken, +} + +impl AuthMode { + /// Returns whether this mode represents an authenticated human ChatGPT account. + pub fn has_chatgpt_account(self) -> bool { + match self { + Self::Chatgpt | Self::ChatgptAuthTokens | Self::PersonalAccessToken => true, + Self::ApiKey | Self::AgentIdentity => false, + } + } } macro_rules! experimental_reason_expr { @@ -849,6 +864,12 @@ client_request_definitions! { serialization: global("remote-control-pairing"), response: v2::RemoteControlPairingStartResponse, }, + #[experimental("remoteControl/pairing/status")] + RemoteControlPairingStatus => "remoteControl/pairing/status" { + params: v2::RemoteControlPairingStatusParams, + serialization: global_shared_read("remote-control-pairing"), + response: v2::RemoteControlPairingStatusResponse, + }, #[experimental("remoteControl/client/list")] RemoteControlClientsList => "remoteControl/client/list" { params: v2::RemoteControlClientsListParams, @@ -949,6 +970,12 @@ client_request_definitions! { response: v2::GetAccountRateLimitsResponse, }, + GetAccountTokenUsage => "account/usage/read" { + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + serialization: None, + response: v2::GetAccountTokenUsageResponse, + }, + SendAddCreditsNudgeEmail => "account/sendAddCreditsNudgeEmail" { params: v2::SendAddCreditsNudgeEmailParams, serialization: global("account-auth"), @@ -1549,6 +1576,8 @@ server_notification_definitions! { ContextCompacted => "thread/compacted" (v2::ContextCompactedNotification), ModelRerouted => "model/rerouted" (v2::ModelReroutedNotification), ModelVerification => "model/verification" (v2::ModelVerificationNotification), + #[experimental("turn/moderationMetadata")] + TurnModerationMetadata => "turn/moderationMetadata" (v2::TurnModerationMetadataNotification), Warning => "warning" (v2::WarningNotification), GuardianWarning => "guardianWarning" (v2::GuardianWarningNotification), DeprecationNotice => "deprecationNotice" (v2::DeprecationNoticeNotification), @@ -2006,6 +2035,19 @@ mod tests { "remote-control-pairing" )) ); + let remote_control_pairing_status = ClientRequest::RemoteControlPairingStatus { + request_id: request_id(), + params: v2::RemoteControlPairingStatusParams { + pairing_code: Some("pairing-code".to_string()), + manual_pairing_code: None, + }, + }; + assert_eq!( + remote_control_pairing_status.serialization_scope(), + Some(ClientRequestSerializationScope::GlobalSharedRead( + "remote-control-pairing" + )) + ); let remote_control_clients_list = ClientRequest::RemoteControlClientsList { request_id: request_id(), params: v2::RemoteControlClientsListParams::default(), @@ -2370,6 +2412,24 @@ mod tests { Ok(()) } + #[test] + fn serialize_get_account_token_usage() -> Result<()> { + let request = ClientRequest::GetAccountTokenUsage { + request_id: RequestId::Integer(1), + params: None, + }; + assert_eq!(request.id(), &RequestId::Integer(1)); + assert_eq!(request.method(), "account/usage/read"); + assert_eq!( + json!({ + "method": "account/usage/read", + "id": 1, + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + #[test] fn serialize_client_response() -> Result<()> { let cwd = absolute_path("/tmp"); @@ -3222,6 +3282,21 @@ mod tests { ); } + #[test] + fn turn_moderation_metadata_notification_is_marked_experimental() { + let notification = + ServerNotification::TurnModerationMetadata(v2::TurnModerationMetadataNotification { + thread_id: "thr_123".to_string(), + turn_id: "turn_123".to_string(), + metadata: json!({"presentation": "inline"}), + }); + + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(¬ification), + Some("turn/moderationMetadata") + ); + } + #[test] fn thread_realtime_started_notification_is_marked_experimental() { let notification = diff --git a/codex-rs/app-server-protocol/src/protocol/v2/account.rs b/codex-rs/app-server-protocol/src/protocol/v2/account.rs index 83f1bc02b875..58fc93cc76d2 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/account.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/account.rs @@ -258,6 +258,33 @@ pub struct GetAccountRateLimitsResponse { pub rate_limits_by_limit_id: Option>, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GetAccountTokenUsageResponse { + pub summary: AccountTokenUsageSummary, + pub daily_usage_buckets: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AccountTokenUsageSummary { + pub lifetime_tokens: Option, + pub peak_daily_tokens: Option, + pub longest_running_turn_sec: Option, + pub current_streak_days: Option, + pub longest_streak_days: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AccountTokenUsageDailyBucket { + pub start_date: String, + pub tokens: i64, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] 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 f40192ddc662..227adffc30b2 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -375,7 +375,8 @@ pub struct ConfigRequirements { pub allowed_approvals_reviewers: Option>, pub allowed_sandbox_modes: Option>, pub allowed_windows_sandbox_implementations: Option>, - pub allowed_permissions: Option>, + pub allowed_permission_profiles: Option>, + pub default_permissions: Option, pub allowed_web_search_modes: Option>, pub allow_managed_hooks_only: Option, pub allow_appshots: Option, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/model.rs b/codex-rs/app-server-protocol/src/protocol/v2/model.rs index 7f97d791a603..d8fa24958252 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/model.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/model.rs @@ -8,6 +8,7 @@ use codex_protocol::protocol::ModelVerification as CoreModelVerification; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; +use serde_json::Value as JsonValue; use ts_rs::TS; v2_enum_from_core!( @@ -152,3 +153,12 @@ pub struct ModelVerificationNotification { pub turn_id: String, pub verifications: Vec, } + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnModerationMetadataNotification { + pub thread_id: String, + pub turn_id: String, + pub metadata: JsonValue, +} 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 1b7e8ba8ffb5..6222eb6e73bb 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -642,9 +642,32 @@ pub struct PluginDetail { pub skills: Vec, pub hooks: Vec, pub apps: Vec, + pub app_templates: Vec, pub mcp_servers: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +#[ts(export_to = "v2/")] +pub enum AppTemplateUnavailableReason { + NotConfiguredForWorkspace, + NoActiveWorkspace, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AppTemplateSummary { + pub template_id: String, + pub name: String, + pub description: Option, + pub canonical_connector_id: Option, + pub logo_url: Option, + pub logo_url_dark: Option, + pub materialized_app_ids: Vec, + pub reason: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs b/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs index f5f004f78422..0e1809805dc3 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs @@ -62,6 +62,23 @@ pub struct RemoteControlPairingStartResponse { pub expires_at: i64, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlPairingStatusParams { + #[ts(optional = nullable)] + pub pairing_code: Option, + #[ts(optional = nullable)] + pub manual_pairing_code: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlPairingStatusResponse { + pub claimed: bool, +} + #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] 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 1c1e92b36f34..4a683be9904d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -1674,7 +1674,8 @@ fn config_requirements_granular_allowed_approval_policy_is_marked_experimental() allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_windows_sandbox_implementations: None, - allowed_permissions: None, + allowed_permission_profiles: None, + default_permissions: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, allow_appshots: None, 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 9dab60a0467b..486e121cf708 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -107,11 +107,10 @@ pub struct ThreadStartParams { pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, - /// Replace the thread's runtime workspace roots. Relative paths are - /// resolved against the effective cwd for the thread. + /// Replace the thread's runtime workspace roots. Paths must be absolute. #[experimental("thread/start.runtimeWorkspaceRoots")] #[ts(optional = nullable)] - pub runtime_workspace_roots: Option>, + pub runtime_workspace_roots: Option>, #[experimental(nested)] #[ts(optional = nullable)] pub approval_policy: Option, @@ -358,11 +357,10 @@ pub struct ThreadResumeParams { pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, - /// Replace the thread's runtime workspace roots. Relative paths are - /// resolved against the effective cwd for the thread. + /// Replace the thread's runtime workspace roots. Paths must be absolute. #[experimental("thread/resume.runtimeWorkspaceRoots")] #[ts(optional = nullable)] - pub runtime_workspace_roots: Option>, + pub runtime_workspace_roots: Option>, #[experimental(nested)] #[ts(optional = nullable)] pub approval_policy: Option, @@ -509,11 +507,10 @@ pub struct ThreadForkParams { pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, - /// Replace the thread's runtime workspace roots. Relative paths are - /// resolved against the effective cwd for the thread. + /// Replace the thread's runtime workspace roots. Paths must be absolute. #[experimental("thread/fork.runtimeWorkspaceRoots")] #[ts(optional = nullable)] - pub runtime_workspace_roots: Option>, + pub runtime_workspace_roots: Option>, #[experimental(nested)] #[ts(optional = nullable)] pub approval_policy: Option, 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 f9bfe21479d1..3bac7bf7ec05 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -88,11 +88,10 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub cwd: Option, /// Replace the thread's runtime workspace roots for this turn and - /// subsequent turns. Relative paths are resolved against the effective - /// cwd for the turn. + /// subsequent turns. Paths must be absolute. #[experimental("turn/start.runtimeWorkspaceRoots")] #[ts(optional = nullable)] - pub runtime_workspace_roots: Option>, + pub runtime_workspace_roots: Option>, /// Override the approval policy for this turn and subsequent turns. #[experimental(nested)] #[ts(optional = nullable)] diff --git a/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs b/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs index c491596e6597..b4506a3d4c5e 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs @@ -3,11 +3,14 @@ use super::pairing_unavailable_error; use super::protocol::EnrollRemoteServerRequest; use super::protocol::EnrollRemoteServerResponse; use super::protocol::RefreshRemoteServerRequest; +use super::protocol::RemoteControlPairingStatusRequest; +use super::protocol::RemoteControlPairingStatusResponse as BackendRemoteControlPairingStatusResponse; use super::protocol::RemoteControlTarget; use super::protocol::StartRemoteControlPairingRequest; use super::protocol::StartRemoteControlPairingResponse; use axum::http::HeaderMap; use codex_app_server_protocol::RemoteControlPairingStartResponse; +use codex_app_server_protocol::RemoteControlPairingStatusResponse; use codex_login::default_client::build_reqwest_client; use codex_state::RemoteControlEnrollmentRecord; use codex_state::StateRuntime; @@ -136,6 +139,69 @@ impl RemoteControlEnrollment { }) } + pub(super) async fn pairing_status( + &self, + request: RemoteControlPairingStatusRequest, + ) -> io::Result { + if self.should_refresh_server_token() { + return Err(pairing_unavailable_error()); + } + let remote_control_token = self + .remote_control_token + .as_deref() + .ok_or_else(pairing_unavailable_error)?; + + let response = build_reqwest_client() + .post(&self.remote_control_target.pair_status_url) + .timeout(REMOTE_CONTROL_PAIRING_TIMEOUT) + .bearer_auth(remote_control_token) + .json(&request) + .send() + .await + .map_err(|err| { + io::Error::other(format!( + "failed to check remote control pairing status at `{}`: {err}", + self.remote_control_target.pair_status_url + )) + })?; + let headers = response.headers().clone(); + let status = response.status(); + let body = response.bytes().await.map_err(|err| { + io::Error::other(format!( + "failed to read remote control pairing status response from `{}`: {err}", + self.remote_control_target.pair_status_url + )) + })?; + let body_preview = preview_remote_control_response_body(&body); + if !status.is_success() { + let error_kind = match status.as_u16() { + 401 | 403 => ErrorKind::PermissionDenied, + 404 | 410 => ErrorKind::InvalidInput, + _ => ErrorKind::Other, + }; + return Err(io::Error::new( + error_kind, + format!( + "remote control pairing status failed at `{}`: HTTP {status}, {}, body: {body_preview}", + self.remote_control_target.pair_status_url, + format_headers(&headers) + ), + )); + } + + let response = serde_json::from_slice::(&body) + .map_err(|err| { + io::Error::other(format!( + "failed to parse remote control pairing status response from `{}`: HTTP {status}, {}, body: {body_preview}, decode error: {err}", + self.remote_control_target.pair_status_url, + format_headers(&headers) + )) + })?; + Ok(RemoteControlPairingStatusResponse { + claimed: response.claimed, + }) + } + pub(super) fn should_refresh_server_token(&self) -> bool { self.remote_control_token.is_none() || self.expires_at.is_none_or(|expires_at| { diff --git a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs index 38e34e6d435b..6e8f31d45d3c 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs @@ -9,12 +9,16 @@ mod websocket; use self::auth::load_remote_control_auth; use self::auth::recover_remote_control_auth; use self::enroll::RemoteControlEnrollment; +use self::enroll::enroll_remote_control_server; +use self::enroll::load_persisted_remote_control_enrollment; use self::enroll::refresh_remote_control_server; +use self::enroll::update_persisted_remote_control_enrollment; use crate::transport::remote_control::websocket::RemoteControlChannels; use crate::transport::remote_control::websocket::RemoteControlStatusPublisher; use crate::transport::remote_control::websocket::RemoteControlWebsocket; pub use self::protocol::ClientId; +use self::protocol::RemoteControlPairingStatusCode; use self::protocol::ServerEvent; use self::protocol::StreamId; use self::protocol::normalize_remote_control_url; @@ -28,6 +32,8 @@ use codex_app_server_protocol::RemoteControlClientsRevokeResponse; use codex_app_server_protocol::RemoteControlConnectionStatus; use codex_app_server_protocol::RemoteControlPairingStartParams; use codex_app_server_protocol::RemoteControlPairingStartResponse; +use codex_app_server_protocol::RemoteControlPairingStatusParams; +use codex_app_server_protocol::RemoteControlPairingStatusResponse; use codex_app_server_protocol::RemoteControlStatusChangedNotification; use codex_login::AuthManager; use codex_state::StateRuntime; @@ -36,9 +42,13 @@ use gethostname::gethostname; use std::error::Error; use std::fmt; use std::io; +use std::ops::Deref; +use std::ops::DerefMut; use std::panic::AssertUnwindSafe; use std::sync::Arc; use std::sync::Mutex as StdMutex; +use tokio::sync::Semaphore; +use tokio::sync::SemaphorePermit; use tokio::sync::mpsc; use tokio::sync::oneshot; use tokio::sync::watch; @@ -65,12 +75,81 @@ pub struct RemoteControlHandle { enabled_tx: Arc>, status_tx: Arc>, state_db_available: bool, + state_db: Option>, remote_control_url: String, current_enrollment: CurrentRemoteControlEnrollment, + pairing_persistence_key: RemoteControlPairingPersistenceKey, + pairing_persistence_key_required: bool, auth_manager: Arc, } -type CurrentRemoteControlEnrollment = Arc>>; +// Pairing and websocket connect share one selected server so they cannot enroll or clear +// different persisted rows while either path is awaiting backend I/O. +type CurrentRemoteControlEnrollment = Arc; +type RemoteControlPairingPersistenceKey = watch::Sender>; + +struct RemoteControlEnrollmentState { + enrollment: StdMutex>, + lock: Semaphore, +} + +impl RemoteControlEnrollmentState { + fn new(enrollment: Option) -> Self { + Self { + enrollment: StdMutex::new(enrollment), + lock: Semaphore::new(1), + } + } + + async fn lock(&self) -> RemoteControlEnrollmentLease<'_> { + let permit = match self.lock.acquire().await { + Ok(permit) => permit, + Err(_) => unreachable!("remote control enrollment lock should stay open"), + }; + RemoteControlEnrollmentLease { + state: self, + enrollment: self.snapshot(), + _permit: permit, + } + } + + fn snapshot(&self) -> Option { + self.enrollment + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } +} + +struct RemoteControlEnrollmentLease<'a> { + state: &'a RemoteControlEnrollmentState, + enrollment: Option, + _permit: SemaphorePermit<'a>, +} + +impl Deref for RemoteControlEnrollmentLease<'_> { + type Target = Option; + + fn deref(&self) -> &Self::Target { + &self.enrollment + } +} + +impl DerefMut for RemoteControlEnrollmentLease<'_> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.enrollment + } +} + +impl Drop for RemoteControlEnrollmentLease<'_> { + fn drop(&mut self) { + *self + .state + .enrollment + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = self.enrollment.take(); + } +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RemoteControlUnavailable; @@ -126,8 +205,6 @@ impl RemoteControlHandle { *state = false; changed }); - clear_current_enrollment(&self.current_enrollment); - let status = self.status(); info!( enabled_changed, @@ -151,28 +228,30 @@ impl RemoteControlHandle { pub async fn start_pairing( &self, params: RemoteControlPairingStartParams, + app_server_client_name: Option<&str>, ) -> io::Result { - if !*self.enabled_tx.borrow() { - return Err(Self::pairing_disabled_error()); - } let mut auth = load_remote_control_auth(&self.auth_manager) .await .map_err(|_| pairing_unavailable_error())?; - let mut enrollment = { - let current_enrollment = self - .current_enrollment - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - current_enrollment - .as_ref() - .filter(|enrollment| enrollment.account_id == auth.account_id) - .cloned() - } - .ok_or_else(pairing_unavailable_error)?; - let installation_id = self.status().installation_id; + let status = self.status(); + let installation_id = status.installation_id; + let app_server_client_name = self.pairing_persistence_key(app_server_client_name)?; + let app_server_client_name = app_server_client_name.as_deref(); + let mut current_enrollment = self.current_enrollment.lock().await; + let mut enrollment = self + .load_or_enroll_pairing_server( + &mut current_enrollment, + &mut auth, + &installation_id, + &status.server_name, + app_server_client_name, + ) + .await?; if enrollment.should_refresh_server_token() { refresh_pairing_enrollment( - &self.current_enrollment, + &mut current_enrollment, + self.state_db.as_deref(), + app_server_client_name, &self.auth_manager, &mut auth, &installation_id, @@ -185,9 +264,11 @@ impl RemoteControlHandle { }; let pairing_response = match enrollment.start_pairing(pairing_request()).await { Err(err) if err.kind() == io::ErrorKind::PermissionDenied => { - clear_pairing_server_token(&self.current_enrollment, &mut enrollment)?; + clear_pairing_server_token(&mut current_enrollment, &mut enrollment)?; refresh_pairing_enrollment( - &self.current_enrollment, + &mut current_enrollment, + self.state_db.as_deref(), + app_server_client_name, &self.auth_manager, &mut auth, &installation_id, @@ -196,16 +277,189 @@ impl RemoteControlHandle { .await?; enrollment.start_pairing(pairing_request()).await } + Err(err) if err.kind() == io::ErrorKind::NotFound => { + clear_pairing_enrollment( + &mut current_enrollment, + self.state_db.as_deref(), + app_server_client_name, + &enrollment, + ) + .await; + enrollment = self + .load_or_enroll_pairing_server( + &mut current_enrollment, + &mut auth, + &installation_id, + &status.server_name, + app_server_client_name, + ) + .await?; + enrollment.start_pairing(pairing_request()).await + } pairing_response => pairing_response, }; if let Err(err) = &pairing_response { match err.kind() { io::ErrorKind::NotFound => { - clear_current_enrollment_if_matches(&self.current_enrollment, &enrollment); + clear_pairing_enrollment( + &mut current_enrollment, + self.state_db.as_deref(), + app_server_client_name, + &enrollment, + ) + .await; return Err(pairing_unavailable_error()); } io::ErrorKind::PermissionDenied => { - clear_pairing_server_token(&self.current_enrollment, &mut enrollment)?; + clear_pairing_server_token(&mut current_enrollment, &mut enrollment)?; + return Err(pairing_unavailable_error()); + } + _ => {} + } + } + let current_auth = load_remote_control_auth(&self.auth_manager) + .await + .map_err(|_| pairing_unavailable_error())?; + if current_auth.account_id != auth.account_id { + return Err(pairing_unavailable_error()); + } + pairing_response + } + + async fn load_or_enroll_pairing_server( + &self, + current_enrollment: &mut Option, + auth: &mut auth::RemoteControlConnectionAuth, + installation_id: &str, + server_name: &str, + app_server_client_name: Option<&str>, + ) -> io::Result { + if let Some(enrollment) = current_enrollment + .as_ref() + .filter(|enrollment| enrollment.account_id == auth.account_id) + .cloned() + { + return Ok(enrollment); + } + + let remote_control_target = normalize_remote_control_url(&self.remote_control_url)?; + let state_db = self + .state_db + .as_deref() + .ok_or_else(pairing_unavailable_error)?; + if let Some(mut enrollment) = load_persisted_remote_control_enrollment( + Some(state_db), + &remote_control_target, + &auth.account_id, + app_server_client_name, + ) + .await? + { + enrollment.server_name = server_name.to_string(); + publish_current_enrollment(current_enrollment, &enrollment); + return Ok(enrollment); + } + + let enrollment = enroll_pairing_server( + &self.auth_manager, + auth, + &remote_control_target, + installation_id, + server_name, + ) + .await?; + update_persisted_remote_control_enrollment( + Some(state_db), + &remote_control_target, + &auth.account_id, + app_server_client_name, + Some(&enrollment), + ) + .await?; + publish_current_enrollment(current_enrollment, &enrollment); + Ok(enrollment) + } + + fn pairing_persistence_key( + &self, + app_server_client_name: Option<&str>, + ) -> io::Result> { + if self.pairing_persistence_key_required && self.pairing_persistence_key.borrow().is_none() + { + let app_server_client_name = + app_server_client_name.ok_or_else(pairing_unavailable_error)?; + self.pairing_persistence_key + .send_replace(Some(app_server_client_name.to_string())); + } + Ok(self.pairing_persistence_key.borrow().clone()) + } + + pub async fn pairing_status( + &self, + params: RemoteControlPairingStatusParams, + ) -> io::Result { + if !*self.enabled_tx.borrow() { + return Err(Self::pairing_disabled_error()); + } + let mut auth = load_remote_control_auth(&self.auth_manager) + .await + .map_err(|_| pairing_unavailable_error())?; + let app_server_client_name = self.pairing_persistence_key.borrow().clone(); + let app_server_client_name = app_server_client_name.as_deref(); + let mut current_enrollment = self.current_enrollment.lock().await; + let mut enrollment = current_enrollment + .as_ref() + .filter(|enrollment| enrollment.account_id == auth.account_id) + .cloned() + .ok_or_else(pairing_unavailable_error)?; + let installation_id = self.status().installation_id; + if enrollment.should_refresh_server_token() { + refresh_pairing_enrollment( + &mut current_enrollment, + self.state_db.as_deref(), + app_server_client_name, + &self.auth_manager, + &mut auth, + &installation_id, + &mut enrollment, + ) + .await?; + } + let status_code = remote_control_pairing_status_code(¶ms)?; + let pairing_status_request = + || protocol::RemoteControlPairingStatusRequest::from(status_code.clone()); + let pairing_status_response = + match enrollment.pairing_status(pairing_status_request()).await { + Err(err) if err.kind() == io::ErrorKind::PermissionDenied => { + clear_pairing_server_token(&mut current_enrollment, &mut enrollment)?; + refresh_pairing_enrollment( + &mut current_enrollment, + self.state_db.as_deref(), + app_server_client_name, + &self.auth_manager, + &mut auth, + &installation_id, + &mut enrollment, + ) + .await?; + enrollment.pairing_status(pairing_status_request()).await + } + pairing_status_response => pairing_status_response, + }; + if let Err(err) = &pairing_status_response { + match err.kind() { + io::ErrorKind::NotFound => { + clear_pairing_enrollment( + &mut current_enrollment, + self.state_db.as_deref(), + app_server_client_name, + &enrollment, + ) + .await; + return Err(pairing_unavailable_error()); + } + io::ErrorKind::PermissionDenied => { + clear_pairing_server_token(&mut current_enrollment, &mut enrollment)?; return Err(pairing_unavailable_error()); } _ => {} @@ -220,7 +474,7 @@ impl RemoteControlHandle { if current_auth.account_id != auth.account_id { return Err(pairing_unavailable_error()); } - pairing_response + pairing_status_response } pub async fn list_clients( @@ -277,8 +531,57 @@ impl RemoteControlHandle { } } +async fn enroll_pairing_server( + auth_manager: &Arc, + auth: &mut auth::RemoteControlConnectionAuth, + remote_control_target: &protocol::RemoteControlTarget, + installation_id: &str, + server_name: &str, +) -> io::Result { + match enroll_remote_control_server(remote_control_target, auth, installation_id, server_name) + .await + { + Ok(enrollment) => return Ok(enrollment), + Err(err) if err.kind() == io::ErrorKind::PermissionDenied => { + let mut auth_recovery = auth_manager.unauthorized_recovery(); + let mut auth_change_rx = auth_manager.auth_change_receiver(); + if !recover_remote_control_auth(&mut auth_recovery, &mut auth_change_rx).await { + return Err(err); + } + *auth = load_remote_control_auth(auth_manager) + .await + .map_err(|_| pairing_unavailable_error())?; + } + Err(err) => return Err(err), + } + enroll_remote_control_server(remote_control_target, auth, installation_id, server_name).await +} + +fn remote_control_pairing_status_code( + params: &RemoteControlPairingStatusParams, +) -> io::Result { + match (¶ms.pairing_code, ¶ms.manual_pairing_code) { + (Some(pairing_code), None) => Ok(RemoteControlPairingStatusCode::PairingCode( + pairing_code.clone(), + )), + (None, Some(manual_pairing_code)) => Ok(RemoteControlPairingStatusCode::ManualPairingCode( + manual_pairing_code.clone(), + )), + (Some(_), Some(_)) => Err(io::Error::new( + io::ErrorKind::InvalidInput, + "remote control pairing status accepts either pairingCode or manualPairingCode, not both", + )), + (None, None) => Err(io::Error::new( + io::ErrorKind::InvalidInput, + "remote control pairing status requires pairingCode or manualPairingCode", + )), + } +} + async fn refresh_pairing_enrollment( - current_enrollment: &CurrentRemoteControlEnrollment, + current_enrollment: &mut Option, + state_db: Option<&StateRuntime>, + app_server_client_name: Option<&str>, auth_manager: &Arc, auth: &mut auth::RemoteControlConnectionAuth, installation_id: &str, @@ -286,7 +589,14 @@ async fn refresh_pairing_enrollment( ) -> io::Result<()> { if let Err(err) = refresh_remote_control_server(auth, installation_id, enrollment).await { if err.kind() != io::ErrorKind::PermissionDenied { - return handle_pairing_refresh_error(current_enrollment, enrollment, err); + return handle_pairing_refresh_error( + current_enrollment, + state_db, + app_server_client_name, + enrollment, + err, + ) + .await; } let mut auth_recovery = auth_manager.unauthorized_recovery(); let mut auth_change_rx = auth_manager.auth_change_receiver(); @@ -300,7 +610,14 @@ async fn refresh_pairing_enrollment( return Err(pairing_unavailable_error()); } if let Err(err) = refresh_remote_control_server(auth, installation_id, enrollment).await { - return handle_pairing_refresh_error(current_enrollment, enrollment, err); + return handle_pairing_refresh_error( + current_enrollment, + state_db, + app_server_client_name, + enrollment, + err, + ) + .await; } } if replace_current_enrollment(current_enrollment, enrollment) { @@ -310,21 +627,54 @@ async fn refresh_pairing_enrollment( } } -fn handle_pairing_refresh_error( - current_enrollment: &CurrentRemoteControlEnrollment, +async fn handle_pairing_refresh_error( + current_enrollment: &mut Option, + state_db: Option<&StateRuntime>, + app_server_client_name: Option<&str>, enrollment: &RemoteControlEnrollment, err: io::Error, ) -> io::Result<()> { if err.kind() == io::ErrorKind::NotFound { - clear_current_enrollment_if_matches(current_enrollment, enrollment); + clear_pairing_enrollment( + current_enrollment, + state_db, + app_server_client_name, + enrollment, + ) + .await; Err(pairing_unavailable_error()) } else { Err(err) } } +async fn clear_pairing_enrollment( + current_enrollment: &mut Option, + state_db: Option<&StateRuntime>, + app_server_client_name: Option<&str>, + enrollment: &RemoteControlEnrollment, +) { + if !clear_current_enrollment_if_matches(current_enrollment, enrollment) { + return; + } + let Some(state_db) = state_db else { + return; + }; + if let Err(err) = update_persisted_remote_control_enrollment( + Some(state_db), + &enrollment.remote_control_target, + &enrollment.account_id, + app_server_client_name, + /*enrollment*/ None, + ) + .await + { + warn!("failed to clear stale pairing enrollment: {err}"); + } +} + fn clear_pairing_server_token( - current_enrollment: &CurrentRemoteControlEnrollment, + current_enrollment: &mut Option, enrollment: &mut RemoteControlEnrollment, ) -> io::Result<()> { enrollment.clear_server_token(); @@ -359,27 +709,16 @@ fn remote_control_status_with_connection_status( } fn publish_current_enrollment( - current_enrollment: &CurrentRemoteControlEnrollment, + current_enrollment: &mut Option, enrollment: &RemoteControlEnrollment, ) { - *current_enrollment - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(enrollment.clone()); -} - -fn clear_current_enrollment(current_enrollment: &CurrentRemoteControlEnrollment) { - *current_enrollment - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + *current_enrollment = Some(enrollment.clone()); } fn replace_current_enrollment( - current_enrollment: &CurrentRemoteControlEnrollment, + current_enrollment: &mut Option, enrollment: &RemoteControlEnrollment, ) -> bool { - let mut current_enrollment = current_enrollment - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); if !current_enrollment .as_ref() .is_some_and(|current| same_remote_control_enrollment(current, enrollment)) @@ -391,17 +730,17 @@ fn replace_current_enrollment( } fn clear_current_enrollment_if_matches( - current_enrollment: &CurrentRemoteControlEnrollment, + current_enrollment: &mut Option, enrollment: &RemoteControlEnrollment, -) { - let mut current_enrollment = current_enrollment - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); +) -> bool { if current_enrollment .as_ref() .is_some_and(|current| same_remote_control_enrollment(current, enrollment)) { *current_enrollment = None; + true + } else { + false } } @@ -438,9 +777,13 @@ pub async fn start_remote_control( }; let (enabled_tx, enabled_rx) = watch::channel(initial_enabled); - let current_enrollment = Arc::new(StdMutex::new(None)); + let current_enrollment = Arc::new(RemoteControlEnrollmentState::new(/*enrollment*/ None)); let websocket_current_enrollment = current_enrollment.clone(); + let pairing_persistence_key_required = app_server_client_name_rx.is_some(); + let (pairing_persistence_key, _pairing_persistence_key_rx) = watch::channel(None); + let websocket_pairing_persistence_key = pairing_persistence_key.clone(); let handle_auth_manager = auth_manager.clone(); + let handle_state_db = state_db.clone(); let server_name = gethostname().to_string_lossy().trim().to_string(); let remote_control_url = config.remote_control_url; let installation_id = config.installation_id; @@ -490,6 +833,7 @@ pub async fn start_remote_control( transport_event_tx, status_publisher, current_enrollment: websocket_current_enrollment, + pairing_persistence_key: websocket_pairing_persistence_key, }, shutdown_token, enabled_rx, @@ -534,8 +878,11 @@ pub async fn start_remote_control( enabled_tx: Arc::new(enabled_tx), status_tx: Arc::new(status_tx), state_db_available, + state_db: handle_state_db, remote_control_url: handle_remote_control_url, current_enrollment, + pairing_persistence_key, + pairing_persistence_key_required, auth_manager: handle_auth_manager, }, )) diff --git a/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs b/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs index 630fd862c8b8..e3c122c1f7c2 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs @@ -13,6 +13,7 @@ pub(super) struct RemoteControlTarget { pub(super) enroll_url: String, pub(super) refresh_url: String, pub(super) pair_url: String, + pub(super) pair_status_url: String, } #[derive(Debug, Serialize)] @@ -52,6 +53,40 @@ pub(super) struct StartRemoteControlPairingResponse { pub(super) expires_at: String, } +#[derive(Debug, Serialize)] +pub(super) struct RemoteControlPairingStatusRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) pairing_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) manual_pairing_code: Option, +} + +#[derive(Clone)] +pub(super) enum RemoteControlPairingStatusCode { + PairingCode(String), + ManualPairingCode(String), +} + +impl From for RemoteControlPairingStatusRequest { + fn from(code: RemoteControlPairingStatusCode) -> Self { + match code { + RemoteControlPairingStatusCode::PairingCode(pairing_code) => Self { + pairing_code: Some(pairing_code), + manual_pairing_code: None, + }, + RemoteControlPairingStatusCode::ManualPairingCode(manual_pairing_code) => Self { + pairing_code: None, + manual_pairing_code: Some(manual_pairing_code), + }, + } + } +} + +#[derive(Debug, Deserialize)] +pub(super) struct RemoteControlPairingStatusResponse { + pub(super) claimed: bool, +} + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct ClientId(pub String); @@ -194,6 +229,9 @@ pub(super) fn normalize_remote_control_url( let pair_url = remote_control_url .join("wham/remote/control/server/pair") .map_err(map_url_parse_error)?; + let pair_status_url = remote_control_url + .join("wham/remote/control/server/pair/status") + .map_err(map_url_parse_error)?; let mut websocket_url = remote_control_url .join("wham/remote/control/server") .map_err(map_url_parse_error)?; @@ -215,6 +253,7 @@ pub(super) fn normalize_remote_control_url( enroll_url: enroll_url.to_string(), refresh_url: refresh_url.to_string(), pair_url: pair_url.to_string(), + pair_status_url: pair_status_url.to_string(), }) } @@ -269,6 +308,9 @@ mod tests { .to_string(), pair_url: "https://chatgpt.com/backend-api/wham/remote/control/server/pair" .to_string(), + pair_status_url: + "https://chatgpt.com/backend-api/wham/remote/control/server/pair/status" + .to_string(), } ); assert_eq!( @@ -287,6 +329,9 @@ mod tests { pair_url: "https://api.chatgpt-staging.com/backend-api/wham/remote/control/server/pair" .to_string(), + pair_status_url: + "https://api.chatgpt-staging.com/backend-api/wham/remote/control/server/pair/status" + .to_string(), } ); } @@ -305,6 +350,9 @@ mod tests { .to_string(), pair_url: "http://localhost:8080/backend-api/wham/remote/control/server/pair" .to_string(), + pair_status_url: + "http://localhost:8080/backend-api/wham/remote/control/server/pair/status" + .to_string(), } ); assert_eq!( @@ -320,6 +368,9 @@ mod tests { .to_string(), pair_url: "https://localhost:8443/backend-api/wham/remote/control/server/pair" .to_string(), + pair_status_url: + "https://localhost:8443/backend-api/wham/remote/control/server/pair/status" + .to_string(), } ); } diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs index a1a284da9b78..69659e84a1e5 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs @@ -21,6 +21,7 @@ use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::RemoteControlConnectionStatus; use codex_app_server_protocol::RemoteControlPairingStartParams; +use codex_app_server_protocol::RemoteControlPairingStatusParams; use codex_app_server_protocol::RemoteControlStatusChangedNotification; use codex_app_server_protocol::ServerNotification; use codex_config::types::AuthCredentialsStoreMode; @@ -40,7 +41,6 @@ use pretty_assertions::assert_eq; use serde_json::json; use std::collections::BTreeMap; use std::sync::Arc; -use std::sync::Mutex as StdMutex; use tempfile::TempDir; use time::OffsetDateTime; use tokio::io::AsyncBufReadExt; @@ -115,6 +115,7 @@ fn remote_control_auth_dot_json(account_id: Option<&str>) -> AuthDotJson { }), last_refresh: Some(chrono::Utc::now()), agent_identity: None, + personal_access_token: None, } } @@ -148,24 +149,29 @@ fn remote_control_handle_with_current_enrollment( }); let remote_control_target = normalize_remote_control_url(remote_control_url) .expect("remote control target should normalize"); - let current_enrollment = Arc::new(StdMutex::new(Some(RemoteControlEnrollment { - remote_control_target, - account_id: "account_id".to_string(), - environment_id: "env_test".to_string(), - server_id: "srv_e_test".to_string(), - server_name: test_server_name(), - remote_control_token: Some(TEST_REMOTE_CONTROL_SERVER_TOKEN.to_string()), - expires_at: Some( - OffsetDateTime::from_unix_timestamp(33_336_362_096) - .expect("future timestamp should parse"), - ), - }))); + let current_enrollment = Arc::new(RemoteControlEnrollmentState::new(Some( + RemoteControlEnrollment { + remote_control_target, + account_id: "account_id".to_string(), + environment_id: "env_test".to_string(), + server_id: "srv_e_test".to_string(), + server_name: test_server_name(), + remote_control_token: Some(TEST_REMOTE_CONTROL_SERVER_TOKEN.to_string()), + expires_at: Some( + OffsetDateTime::from_unix_timestamp(33_336_362_096) + .expect("future timestamp should parse"), + ), + }, + ))); RemoteControlHandle { enabled_tx: Arc::new(enabled_tx), status_tx: Arc::new(status_tx), state_db_available: true, + state_db: None, remote_control_url: remote_control_url.to_string(), current_enrollment, + pairing_persistence_key: watch::channel(None).0, + pairing_persistence_key_required: false, auth_manager, } } diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs index 22aa290762b1..060063909cfa 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs @@ -24,8 +24,11 @@ fn client_management_handle( enabled_tx: Arc::new(enabled_tx), status_tx: Arc::new(status_tx), state_db_available: false, + state_db: None, remote_control_url, - current_enrollment: Arc::new(StdMutex::new(None)), + current_enrollment: Arc::new(RemoteControlEnrollmentState::new(/*enrollment*/ None)), + pairing_persistence_key: watch::channel(None).0, + pairing_persistence_key_required: false, auth_manager, } } diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs index 176aeb4ed5c4..fcdd5e30f2be 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs @@ -1,6 +1,8 @@ +use super::super::protocol::RemoteControlPairingStatusRequest; use super::super::protocol::StartRemoteControlPairingRequest; use super::*; use pretty_assertions::assert_eq; +use std::io; fn remote_control_enrollment( remote_control_url: &str, @@ -66,6 +68,36 @@ async fn pairing_response_error(body: serde_json::Value) -> String { err.to_string() } +async fn pairing_status_error(status: &'static str, body: &'static str) -> (io::Error, String) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let expected_status_url = normalize_remote_control_url(&remote_control_url) + .expect("target should normalize") + .pair_status_url; + let server_task = tokio::spawn(async move { + let status_request = accept_http_request(&listener).await; + respond_with_status_and_headers( + status_request.stream, + status, + &[("x-request-id", "request-123"), ("cf-ray", "ray-123")], + body, + ) + .await; + }); + + let err = remote_control_enrollment(&remote_control_url, "remote-control-token") + .pairing_status(RemoteControlPairingStatusRequest { + pairing_code: Some("pairing-code".to_string()), + manual_pairing_code: None, + }) + .await + .expect_err("pairing status should fail"); + server_task.await.expect("server task should finish"); + (err, expected_status_url) +} + #[tokio::test] async fn remote_control_handle_starts_pairing_before_websocket_connects() { let listener = TcpListener::bind("127.0.0.1:0") @@ -131,13 +163,16 @@ async fn remote_control_handle_starts_pairing_before_websocket_connects() { remote_handle .current_enrollment .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) + .await .as_mut() .expect("current enrollment should exist") .expires_at = Some(OffsetDateTime::now_utc() + time::Duration::seconds(29)); let response = remote_handle - .start_pairing(RemoteControlPairingStartParams { manual_code: true }) + .start_pairing( + RemoteControlPairingStartParams { manual_code: true }, + /*app_server_client_name*/ None, + ) .await .expect("pairing should use the current server before websocket connect"); server_task.await.expect("server task should finish"); @@ -153,6 +188,190 @@ async fn remote_control_handle_starts_pairing_before_websocket_connects() { ); } +#[tokio::test] +async fn remote_control_pairing_status_returns_pending() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let server_task = tokio::spawn(async move { + let status_request = accept_http_request(&listener).await; + assert_eq!( + status_request.request_line, + "POST /backend-api/wham/remote/control/server/pair/status HTTP/1.1" + ); + assert_eq!( + status_request.headers.get("authorization"), + Some(&"Bearer remote-control-token".to_string()) + ); + assert_eq!( + serde_json::from_str::(&status_request.body) + .expect("status request body should deserialize"), + json!({ "pairing_code": "pairing-code" }) + ); + respond_with_json(status_request.stream, json!({ "claimed": false })).await; + }); + + let response = remote_control_enrollment(&remote_control_url, "remote-control-token") + .pairing_status(RemoteControlPairingStatusRequest { + pairing_code: Some("pairing-code".to_string()), + manual_pairing_code: None, + }) + .await + .expect("pairing status should succeed"); + server_task.await.expect("server task should finish"); + + assert!(!response.claimed); +} + +#[tokio::test] +async fn remote_control_pairing_status_accepts_manual_pairing_code() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let server_task = tokio::spawn(async move { + let status_request = accept_http_request(&listener).await; + assert_eq!( + status_request.request_line, + "POST /backend-api/wham/remote/control/server/pair/status HTTP/1.1" + ); + assert_eq!( + serde_json::from_str::(&status_request.body) + .expect("status request body should deserialize"), + json!({ "manual_pairing_code": "ABCD-EFGH" }) + ); + respond_with_json(status_request.stream, json!({ "claimed": false })).await; + }); + + let response = remote_control_enrollment(&remote_control_url, "remote-control-token") + .pairing_status(RemoteControlPairingStatusRequest { + pairing_code: None, + manual_pairing_code: Some("ABCD-EFGH".to_string()), + }) + .await + .expect("pairing status should succeed"); + server_task.await.expect("server task should finish"); + + assert!(!response.claimed); +} + +#[tokio::test] +async fn remote_control_pairing_status_returns_claimed() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let server_task = tokio::spawn(async move { + let status_request = accept_http_request(&listener).await; + assert_eq!( + status_request.request_line, + "POST /backend-api/wham/remote/control/server/pair/status HTTP/1.1" + ); + respond_with_json(status_request.stream, json!({ "claimed": true })).await; + }); + + let response = remote_control_enrollment(&remote_control_url, "remote-control-token") + .pairing_status(RemoteControlPairingStatusRequest { + pairing_code: Some("pairing-code".to_string()), + manual_pairing_code: None, + }) + .await + .expect("pairing status should succeed"); + server_task.await.expect("server task should finish"); + + assert!(response.claimed); +} + +#[tokio::test] +async fn remote_control_handle_refreshes_after_pairing_status_auth_failure() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let server_task = tokio::spawn(async move { + let stale_status_request = accept_http_request(&listener).await; + assert_eq!( + stale_status_request.request_line, + "POST /backend-api/wham/remote/control/server/pair/status HTTP/1.1" + ); + assert_eq!( + stale_status_request.headers.get("authorization"), + Some(&format!("Bearer {TEST_REMOTE_CONTROL_SERVER_TOKEN}")) + ); + respond_with_status(stale_status_request.stream, "401 Unauthorized", "").await; + + let refresh_request = accept_http_request(&listener).await; + assert_eq!( + refresh_request.request_line, + "POST /backend-api/wham/remote/control/server/refresh HTTP/1.1" + ); + respond_with_json( + refresh_request.stream, + remote_control_server_token_response( + "srv_e_test", + "env_test", + TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN, + ), + ) + .await; + + let refreshed_status_request = accept_http_request(&listener).await; + assert_eq!( + refreshed_status_request.request_line, + "POST /backend-api/wham/remote/control/server/pair/status HTTP/1.1" + ); + assert_eq!( + refreshed_status_request.headers.get("authorization"), + Some(&format!( + "Bearer {TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN}" + )) + ); + respond_with_json(refreshed_status_request.stream, json!({ "claimed": true })).await; + }); + let remote_handle = remote_control_handle_with_current_enrollment( + &remote_control_url, + remote_control_auth_manager(), + ); + + let response = remote_handle + .pairing_status(RemoteControlPairingStatusParams { + pairing_code: Some("pairing-code".to_string()), + manual_pairing_code: None, + }) + .await + .expect("pairing status should refresh after server token auth failure"); + server_task.await.expect("server task should finish"); + + assert!(response.claimed); +} + +#[tokio::test] +async fn remote_control_pairing_status_maps_user_actionable_backend_errors() { + for (status, expected_kind) in [ + ("403 Forbidden", io::ErrorKind::PermissionDenied), + ("404 Not Found", io::ErrorKind::InvalidInput), + ("410 Gone", io::ErrorKind::InvalidInput), + ] { + let (err, _expected_status_url) = pairing_status_error(status, "not available").await; + assert_eq!(err.kind(), expected_kind); + } +} + +#[tokio::test] +async fn remote_control_pairing_status_preserves_decode_error_context() { + let (err, expected_status_url) = pairing_status_error("200 OK", "{").await; + let err = err.to_string(); + + assert!(err.contains(&format!( + "failed to parse remote control pairing status response from `{expected_status_url}`: HTTP 200 OK" + ))); + assert!(err.contains("request-id: request-123")); + assert!(err.contains("cf-ray: ray-123")); + assert!(err.contains("body: {")); + assert!(err.contains("decode error:")); +} + #[tokio::test] async fn remote_control_handle_refreshes_after_pairing_auth_failure() { let listener = TcpListener::bind("127.0.0.1:0") @@ -219,7 +438,10 @@ async fn remote_control_handle_refreshes_after_pairing_auth_failure() { ); let response = remote_handle - .start_pairing(RemoteControlPairingStartParams::default()) + .start_pairing( + RemoteControlPairingStartParams::default(), + /*app_server_client_name*/ None, + ) .await .expect("pairing should refresh after server token auth failure"); server_task.await.expect("server task should finish"); @@ -332,13 +554,16 @@ async fn remote_control_handle_recovers_auth_before_refreshing_pairing() { remote_handle .current_enrollment .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) + .await .as_mut() .expect("current enrollment should exist") .expires_at = Some(OffsetDateTime::now_utc() + time::Duration::seconds(29)); let response = remote_handle - .start_pairing(RemoteControlPairingStartParams::default()) + .start_pairing( + RemoteControlPairingStartParams::default(), + /*app_server_client_name*/ None, + ) .await .expect("pairing should refresh after auth recovery"); server_task.await.expect("server task should finish"); @@ -414,21 +639,137 @@ async fn start_remote_control_pairing_preserves_expiry_parse_error_context() { } #[tokio::test] -async fn remote_control_handle_disable_clears_current_enrollment() { +async fn remote_control_handle_disable_keeps_current_enrollment() { let remote_handle = remote_control_handle_with_current_enrollment( TEST_REMOTE_CONTROL_URL, remote_control_auth_manager(), ); remote_handle.disable(); - remote_handle.enable().expect("enable should succeed"); + assert!( + remote_handle.current_enrollment.lock().await.is_some(), + "disabled remote control should keep the selected pairing server" + ); +} + +#[tokio::test] +async fn remote_control_handle_reenrolls_after_stale_pairing_enrollment() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let codex_home = TempDir::new().expect("temp dir should create"); + let state_db = remote_control_state_runtime(&codex_home).await; + let mut remote_handle = remote_control_handle_with_current_enrollment( + &remote_control_url, + remote_control_auth_manager_with_home(&codex_home), + ); + remote_handle.state_db = Some(state_db.clone()); + remote_handle.disable(); + let stale_enrollment = remote_handle + .current_enrollment + .lock() + .await + .clone() + .expect("current enrollment should exist"); + let remote_control_target = stale_enrollment.remote_control_target.clone(); + let refreshed_enrollment = RemoteControlEnrollment { + remote_control_target: remote_control_target.clone(), + account_id: "account_id".to_string(), + environment_id: "env_refreshed".to_string(), + server_id: "srv_e_refreshed".to_string(), + server_name: test_server_name(), + remote_control_token: None, + expires_at: None, + }; + update_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &remote_control_target, + "account_id", + /*app_server_client_name*/ None, + Some(&stale_enrollment), + ) + .await + .expect("stale enrollment should save"); + let server_refreshed_enrollment = refreshed_enrollment.clone(); + let server_task = tokio::spawn(async move { + let stale_pairing_request = accept_http_request(&listener).await; + assert_eq!( + stale_pairing_request.request_line, + "POST /backend-api/wham/remote/control/server/pair HTTP/1.1" + ); + assert_eq!( + stale_pairing_request.headers.get("authorization"), + Some(&format!("Bearer {TEST_REMOTE_CONTROL_SERVER_TOKEN}")) + ); + respond_with_status(stale_pairing_request.stream, "404 Not Found", "").await; + + let enroll_request = accept_http_request(&listener).await; + assert_eq!( + enroll_request.request_line, + "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" + ); + respond_with_json( + enroll_request.stream, + remote_control_server_token_response( + &server_refreshed_enrollment.server_id, + &server_refreshed_enrollment.environment_id, + TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN, + ), + ) + .await; + + let refreshed_pairing_request = accept_http_request(&listener).await; + assert_eq!( + refreshed_pairing_request.request_line, + "POST /backend-api/wham/remote/control/server/pair HTTP/1.1" + ); + assert_eq!( + refreshed_pairing_request.headers.get("authorization"), + Some(&format!( + "Bearer {TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN}" + )) + ); + respond_with_json( + refreshed_pairing_request.stream, + json!({ + "pairing_code": "pairing-code", + "manual_pairing_code": "ABCD-EFGH", + "server_id": server_refreshed_enrollment.server_id, + "environment_id": server_refreshed_enrollment.environment_id, + "expires_at": "3026-05-22T12:34:56Z", + }), + ) + .await; + }); + let response = remote_handle + .start_pairing( + RemoteControlPairingStartParams::default(), + /*app_server_client_name*/ None, + ) + .await + .expect("pairing should re-enroll after stale enrollment"); + server_task.await.expect("server task should finish"); + assert_eq!( - remote_handle - .start_pairing(RemoteControlPairingStartParams::default()) - .await - .expect_err("re-enabled remote control should wait for a current server") - .to_string(), - "remote control pairing is unavailable until enrollment completes" + response, + RemoteControlPairingStartResponse { + pairing_code: "pairing-code".to_string(), + manual_pairing_code: Some("ABCD-EFGH".to_string()), + environment_id: "env_refreshed".to_string(), + expires_at: 33_336_362_096, + } + ); + assert_eq!( + load_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &remote_control_target, + "account_id", + /*app_server_client_name*/ None, + ) + .await + .expect("refreshed enrollment should load"), + Some(refreshed_enrollment) ); } @@ -458,7 +799,10 @@ async fn remote_control_handle_discards_pairing_response_after_auth_change() { let remote_handle = remote_handle.clone(); async move { remote_handle - .start_pairing(RemoteControlPairingStartParams::default()) + .start_pairing( + RemoteControlPairingStartParams::default(), + /*app_server_client_name*/ None, + ) .await } }); diff --git a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs index 60792df54c19..86b72849d3ca 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs @@ -1,13 +1,13 @@ use super::CurrentRemoteControlEnrollment; -use super::clear_current_enrollment; +use super::RemoteControlPairingPersistenceKey; use super::protocol::ClientEnvelope; use super::protocol::ClientEvent; use super::protocol::ClientId; use super::protocol::RemoteControlTarget; use super::protocol::ServerEnvelope; use super::protocol::StreamId; -use super::publish_current_enrollment; use super::remote_control_status_with_connection_status; +use super::same_remote_control_enrollment; use super::segment::ClientSegmentObservation; use super::segment::ClientSegmentReassembler; use super::segment::REMOTE_CONTROL_SEGMENT_MAX_BYTES; @@ -55,6 +55,9 @@ use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite; use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_util::sync::CancellationToken; + +#[cfg(test)] +use super::RemoteControlEnrollmentState; use tracing::error; use tracing::info; use tracing::warn; @@ -252,10 +255,10 @@ pub(crate) struct RemoteControlWebsocket { status_publisher: RemoteControlStatusPublisher, shutdown_token: CancellationToken, reconnect_attempt: u64, - enrollment: Option, auth_recovery: UnauthorizedRecovery, auth_change_rx: watch::Receiver, current_enrollment: CurrentRemoteControlEnrollment, + pairing_persistence_key: RemoteControlPairingPersistenceKey, client_tracker: Arc>, state: Arc>, server_event_rx: Arc>>, @@ -294,6 +297,7 @@ pub(super) struct RemoteControlChannels { pub(super) transport_event_tx: mpsc::Sender, pub(super) status_publisher: RemoteControlStatusPublisher, pub(super) current_enrollment: CurrentRemoteControlEnrollment, + pub(super) pairing_persistence_key: RemoteControlPairingPersistenceKey, } #[derive(Clone)] @@ -407,10 +411,10 @@ impl RemoteControlWebsocket { status_publisher: channels.status_publisher, shutdown_token, reconnect_attempt: 0, - enrollment: None, auth_recovery, auth_change_rx, current_enrollment: channels.current_enrollment, + pairing_persistence_key: channels.pairing_persistence_key, client_tracker: Arc::new(Mutex::new(client_tracker)), state: Arc::new(Mutex::new(WebsocketState { outbound_buffer, @@ -457,6 +461,8 @@ impl RemoteControlWebsocket { return; } }; + self.pairing_persistence_key + .send_replace(app_server_client_name.clone()); loop { if !self.wait_until_enabled().await { @@ -579,15 +585,15 @@ impl RemoteControlWebsocket { loop { let subscribe_cursor = self.state.lock().await.subscribe_cursor.clone(); - let enrollment = self.enrollment.as_ref(); + let enrollment = self.current_enrollment.snapshot(); info!( websocket_url = %remote_control_target.websocket_url, installation_id = %self.installation_id, server_name = %self.server_name, reconnect_attempt = self.reconnect_attempt.saturating_add(1), has_enrollment = enrollment.is_some(), - server_id = ?enrollment.map(|enrollment| enrollment.server_id.as_str()), - environment_id = ?enrollment.map(|enrollment| enrollment.environment_id.as_str()), + server_id = ?enrollment.as_ref().map(|enrollment| enrollment.server_id.as_str()), + environment_id = ?enrollment.as_ref().map(|enrollment| enrollment.environment_id.as_str()), subscribe_cursor_present = subscribe_cursor.is_some(), app_server_client_name = ?app_server_client_name, "connecting to app-server remote control websocket" @@ -611,15 +617,17 @@ impl RemoteControlWebsocket { } return ConnectOutcome::Disabled; } - connect_result = connect_remote_control_websocket( - &remote_control_target, - self.state_db.as_deref(), - auth_context, - &mut self.enrollment, - connect_options, - &self.status_publisher, - &self.current_enrollment, - ) => connect_result, + connect_result = async { + connect_remote_control_websocket( + &remote_control_target, + self.state_db.as_deref(), + auth_context, + &self.current_enrollment, + connect_options, + &self.status_publisher, + ) + .await + } => connect_result, }; match connect_result { @@ -631,13 +639,13 @@ impl RemoteControlWebsocket { self.auth_recovery = self.auth_manager.unauthorized_recovery(); self.status_publisher .publish_status(RemoteControlConnectionStatus::Connected); - let enrollment = self.enrollment.as_ref(); + let enrollment = self.current_enrollment.snapshot(); info!( websocket_url = %remote_control_target.websocket_url, installation_id = %self.installation_id, server_name = %self.server_name, - server_id = ?enrollment.map(|enrollment| enrollment.server_id.as_str()), - environment_id = ?enrollment.map(|enrollment| enrollment.environment_id.as_str()), + server_id = ?enrollment.as_ref().map(|enrollment| enrollment.server_id.as_str()), + environment_id = ?enrollment.as_ref().map(|enrollment| enrollment.environment_id.as_str()), subscribe_cursor_present = subscribe_cursor.is_some(), response_headers = %format_headers(response.headers()), "connected to app-server remote control websocket" @@ -656,7 +664,7 @@ impl RemoteControlWebsocket { let reconnect_attempt = self.reconnect_attempt.saturating_add(1); let (reconnect_delay, reconnect_backoff_reset) = next_reconnect_delay(&mut self.reconnect_attempt); - let enrollment = self.enrollment.as_ref(); + let enrollment = self.current_enrollment.snapshot(); warn!( websocket_url = %remote_control_target.websocket_url, installation_id = %self.installation_id, @@ -667,8 +675,8 @@ impl RemoteControlWebsocket { reconnect_delay = ?reconnect_delay, reconnect_backoff_reset, has_enrollment = enrollment.is_some(), - server_id = ?enrollment.map(|enrollment| enrollment.server_id.as_str()), - environment_id = ?enrollment.map(|enrollment| enrollment.environment_id.as_str()), + server_id = ?enrollment.as_ref().map(|enrollment| enrollment.server_id.as_str()), + environment_id = ?enrollment.as_ref().map(|enrollment| enrollment.environment_id.as_str()), subscribe_cursor_present = subscribe_cursor.is_some(), "failed to connect to app-server remote control websocket" ); @@ -1189,19 +1197,108 @@ pub(super) async fn connect_remote_control_websocket( remote_control_target: &RemoteControlTarget, state_db: Option<&StateRuntime>, mut auth_context: RemoteControlAuthContext<'_>, - enrollment: &mut Option, + current_enrollment: &CurrentRemoteControlEnrollment, connect_options: RemoteControlConnectOptions<'_>, status_publisher: &RemoteControlStatusPublisher, - current_enrollment: &CurrentRemoteControlEnrollment, ) -> io::Result<( WebSocketStream>, tungstenite::http::Response<()>, )> { ensure_rustls_crypto_provider(); + let (auth, enrollment) = { + let mut current_enrollment = current_enrollment.lock().await; + let auth = prepare_remote_control_enrollment( + remote_control_target, + state_db, + &mut auth_context, + &mut current_enrollment, + connect_options, + status_publisher, + ) + .await?; + let enrollment = current_enrollment.as_ref().cloned().ok_or_else(|| { + io::Error::other("missing remote control enrollment after enrollment step") + })?; + (auth, enrollment) + }; + let request = build_remote_control_websocket_request( + &remote_control_target.websocket_url, + &enrollment, + connect_options.installation_id, + connect_options.subscribe_cursor, + )?; + + let websocket_connect_result = tokio::time::timeout( + REMOTE_CONTROL_WEBSOCKET_CONNECT_TIMEOUT, + connect_async(request), + ) + .await + .map_err(|_| { + io::Error::new( + ErrorKind::TimedOut, + format!( + "timed out connecting to remote control websocket at `{}` after {:?}", + remote_control_target.websocket_url, REMOTE_CONTROL_WEBSOCKET_CONNECT_TIMEOUT + ), + ) + })?; + + match websocket_connect_result { + Ok((websocket_stream, response)) => Ok((websocket_stream, response.map(|_| ()))), + Err(err) => { + match &err { + tungstenite::Error::Http(response) if response.status().as_u16() == 404 => { + info!( + "remote control websocket returned HTTP 404; clearing stale enrollment before re-enrolling: websocket_url={}, account_id={}, server_id={}, environment_id={}", + remote_control_target.websocket_url, + auth.account_id, + enrollment.server_id, + enrollment.environment_id + ); + clear_remote_control_enrollment_if_matches( + state_db, + remote_control_target, + &auth.account_id, + connect_options.app_server_client_name, + current_enrollment, + &enrollment, + status_publisher, + ) + .await; + } + tungstenite::Error::Http(response) + if matches!(response.status().as_u16(), 401 | 403) => + { + clear_remote_control_server_token_if_matches(current_enrollment, &enrollment) + .await?; + return Err(io::Error::other(format!( + "remote control websocket auth failed with HTTP {}; refreshing server token before reconnect", + response.status() + ))); + } + _ => {} + } + Err(io::Error::other( + format_remote_control_websocket_connect_error( + &remote_control_target.websocket_url, + &err, + ), + )) + } + } +} + +async fn prepare_remote_control_enrollment( + remote_control_target: &RemoteControlTarget, + state_db: Option<&StateRuntime>, + auth_context: &mut RemoteControlAuthContext<'_>, + enrollment: &mut Option, + connect_options: RemoteControlConnectOptions<'_>, + status_publisher: &RemoteControlStatusPublisher, +) -> io::Result { let Some(state_db) = state_db else { *enrollment = None; - clear_current_enrollment(current_enrollment); return Err(io::Error::new( ErrorKind::NotFound, "remote control requires sqlite state db", @@ -1214,7 +1311,6 @@ pub(super) async fn connect_remote_control_websocket( if err.kind() == ErrorKind::PermissionDenied { *enrollment = None; status_publisher.publish_environment_id(/*environment_id*/ None); - clear_current_enrollment(current_enrollment); } return Err(err); } @@ -1231,7 +1327,6 @@ pub(super) async fn connect_remote_control_websocket( ); *enrollment = None; status_publisher.publish_environment_id(/*environment_id*/ None); - clear_current_enrollment(current_enrollment); } if let Some(enrollment) = enrollment.as_mut() { enrollment.remote_control_target = remote_control_target.clone(); @@ -1262,7 +1357,7 @@ pub(super) async fn connect_remote_control_websocket( remote_control_target, state_db, &auth, - &mut auth_context, + auth_context, enrollment, connect_options, status_publisher, @@ -1307,14 +1402,13 @@ pub(super) async fn connect_remote_control_websocket( connect_options.app_server_client_name, enrollment, status_publisher, - current_enrollment, ) .await; enroll_remote_control_server_if_missing( remote_control_target, state_db, &auth, - &mut auth_context, + auth_context, enrollment, connect_options, status_publisher, @@ -1337,82 +1431,7 @@ pub(super) async fn connect_remote_control_websocket( } } - let enrollment_ref = enrollment.as_ref().ok_or_else(|| { - io::Error::other("missing remote control enrollment after enrollment step") - })?; - publish_current_enrollment(current_enrollment, enrollment_ref); - let request = build_remote_control_websocket_request( - &remote_control_target.websocket_url, - enrollment_ref, - connect_options.installation_id, - connect_options.subscribe_cursor, - )?; - - let websocket_connect_result = tokio::time::timeout( - REMOTE_CONTROL_WEBSOCKET_CONNECT_TIMEOUT, - connect_async(request), - ) - .await - .map_err(|_| { - io::Error::new( - ErrorKind::TimedOut, - format!( - "timed out connecting to remote control websocket at `{}` after {:?}", - remote_control_target.websocket_url, REMOTE_CONTROL_WEBSOCKET_CONNECT_TIMEOUT - ), - ) - })?; - - match websocket_connect_result { - Ok((websocket_stream, response)) => Ok((websocket_stream, response.map(|_| ()))), - Err(err) => { - match &err { - tungstenite::Error::Http(response) if response.status().as_u16() == 404 => { - info!( - "remote control websocket returned HTTP 404; clearing stale enrollment before re-enrolling: websocket_url={}, account_id={}, server_id={}, environment_id={}", - remote_control_target.websocket_url, - auth.account_id, - enrollment_ref.server_id, - enrollment_ref.environment_id - ); - clear_remote_control_enrollment( - state_db, - remote_control_target, - &auth.account_id, - connect_options.app_server_client_name, - enrollment, - status_publisher, - current_enrollment, - ) - .await; - } - tungstenite::Error::Http(response) - if matches!(response.status().as_u16(), 401 | 403) => - { - enrollment - .as_mut() - .ok_or_else(|| { - io::Error::other( - "missing remote control enrollment after websocket auth failure", - ) - })? - .clear_server_token(); - clear_current_enrollment(current_enrollment); - return Err(io::Error::other(format!( - "remote control websocket auth failed with HTTP {}; refreshing server token before reconnect", - response.status() - ))); - } - _ => {} - } - Err(io::Error::other( - format_remote_control_websocket_connect_error( - &remote_control_target.websocket_url, - &err, - ), - )) - } - } + Ok(auth) } async fn clear_remote_control_enrollment( @@ -1422,7 +1441,6 @@ async fn clear_remote_control_enrollment( app_server_client_name: Option<&str>, enrollment: &mut Option, status_publisher: &RemoteControlStatusPublisher, - current_enrollment: &CurrentRemoteControlEnrollment, ) { if let Err(clear_err) = update_persisted_remote_control_enrollment( Some(state_db), @@ -1437,7 +1455,51 @@ async fn clear_remote_control_enrollment( } *enrollment = None; status_publisher.publish_environment_id(/*environment_id*/ None); - clear_current_enrollment(current_enrollment); +} + +async fn clear_remote_control_enrollment_if_matches( + state_db: Option<&StateRuntime>, + remote_control_target: &RemoteControlTarget, + account_id: &str, + app_server_client_name: Option<&str>, + current_enrollment: &CurrentRemoteControlEnrollment, + enrollment: &RemoteControlEnrollment, + status_publisher: &RemoteControlStatusPublisher, +) { + let Some(state_db) = state_db else { + return; + }; + let mut current_enrollment = current_enrollment.lock().await; + if !current_enrollment + .as_ref() + .is_some_and(|current| same_remote_control_enrollment(current, enrollment)) + { + return; + } + clear_remote_control_enrollment( + state_db, + remote_control_target, + account_id, + app_server_client_name, + &mut current_enrollment, + status_publisher, + ) + .await; +} + +async fn clear_remote_control_server_token_if_matches( + current_enrollment: &CurrentRemoteControlEnrollment, + enrollment: &RemoteControlEnrollment, +) -> io::Result<()> { + let mut current_enrollment = current_enrollment.lock().await; + current_enrollment + .as_mut() + .filter(|current| same_remote_control_enrollment(current, enrollment)) + .ok_or_else(|| { + io::Error::other("missing remote control enrollment after websocket auth failure") + })? + .clear_server_token(); + Ok(()) } async fn enroll_remote_control_server_if_missing( @@ -1585,8 +1647,10 @@ mod tests { } } - fn test_current_enrollment() -> CurrentRemoteControlEnrollment { - Arc::new(std::sync::Mutex::new(None)) + fn test_current_enrollment( + enrollment: Option, + ) -> CurrentRemoteControlEnrollment { + Arc::new(RemoteControlEnrollmentState::new(enrollment)) } #[test] @@ -1705,6 +1769,7 @@ mod tests { }), last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, } } @@ -1739,10 +1804,9 @@ mod tests { let auth_manager = remote_control_auth_manager(); let mut auth_recovery = auth_manager.unauthorized_recovery(); let mut auth_change_rx = auth_manager.auth_change_receiver(); - let mut enrollment = Some(remote_control_enrollment(Some( + let current_enrollment = test_current_enrollment(Some(remote_control_enrollment(Some( TEST_REMOTE_CONTROL_SERVER_TOKEN, - ))); - let current_enrollment = test_current_enrollment(); + )))); let (status_publisher, status_rx) = remote_control_status_channel(); let err = match connect_remote_control_websocket( @@ -1753,7 +1817,7 @@ mod tests { auth_recovery: &mut auth_recovery, auth_change_rx: &mut auth_change_rx, }, - &mut enrollment, + ¤t_enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, server_name: "test-server", @@ -1761,7 +1825,6 @@ mod tests { app_server_client_name: None, }, &status_publisher, - ¤t_enrollment, ) .await { @@ -1771,12 +1834,7 @@ mod tests { server_task.await.expect("server task should succeed"); assert_eq!(err.to_string(), expected_error); - assert!( - current_enrollment - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .is_some() - ); + assert!(current_enrollment.lock().await.is_some()); assert_eq!( status_rx.borrow().clone(), RemoteControlStatusChangedNotification { @@ -1801,10 +1859,9 @@ mod tests { let auth_manager = remote_control_auth_manager(); let mut auth_recovery = auth_manager.unauthorized_recovery(); let mut auth_change_rx = auth_manager.auth_change_receiver(); - let mut enrollment = Some(remote_control_enrollment(Some( + let current_enrollment = test_current_enrollment(Some(remote_control_enrollment(Some( TEST_REMOTE_CONTROL_SERVER_TOKEN, - ))); - let current_enrollment = test_current_enrollment(); + )))); let (status_publisher, status_rx) = remote_control_status_channel(); let server_task = tokio::spawn(async move { @@ -1824,7 +1881,7 @@ mod tests { auth_recovery: &mut auth_recovery, auth_change_rx: &mut auth_change_rx, }, - &mut enrollment, + ¤t_enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, server_name: "test-server", @@ -1832,7 +1889,6 @@ mod tests { app_server_client_name: None, }, &status_publisher, - ¤t_enrollment, ) .await .expect_err("unauthorized response should fail the websocket connect"); @@ -1853,13 +1909,7 @@ mod tests { ); let mut expected_enrollment = remote_control_enrollment(/*remote_control_token*/ None); expected_enrollment.remote_control_target = remote_control_target; - assert_eq!(enrollment, Some(expected_enrollment)); - assert!( - current_enrollment - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .is_none() - ); + assert_eq!(*current_enrollment.lock().await, Some(expected_enrollment)); } #[tokio::test] @@ -1896,7 +1946,7 @@ mod tests { .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); let mut auth_change_rx = auth_manager.auth_change_receiver(); - let mut enrollment = None; + let current_enrollment = test_current_enrollment(/*enrollment*/ None); let (status_publisher, status_rx) = remote_control_status_channel(); save_auth( codex_home.path(), @@ -1913,7 +1963,7 @@ mod tests { auth_recovery: &mut auth_recovery, auth_change_rx: &mut auth_change_rx, }, - &mut enrollment, + ¤t_enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, server_name: "test-server", @@ -1921,7 +1971,6 @@ mod tests { app_server_client_name: None, }, &status_publisher, - &test_current_enrollment(), ) .await .expect_err("unauthorized enrollment should fail the websocket connect"); @@ -1989,9 +2038,9 @@ mod tests { .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); let mut auth_change_rx = auth_manager.auth_change_receiver(); - let mut enrollment = Some(remote_control_enrollment( + let current_enrollment = test_current_enrollment(Some(remote_control_enrollment( /*remote_control_token*/ None, - )); + ))); let (status_publisher, status_rx) = remote_control_status_channel(); save_auth( codex_home.path(), @@ -2008,7 +2057,7 @@ mod tests { auth_recovery: &mut auth_recovery, auth_change_rx: &mut auth_change_rx, }, - &mut enrollment, + ¤t_enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, server_name: "test-server", @@ -2016,7 +2065,6 @@ mod tests { app_server_client_name: None, }, &status_publisher, - &test_current_enrollment(), ) .await .expect_err("unauthorized refresh should fail the websocket connect"); @@ -2061,9 +2109,9 @@ mod tests { let auth_manager = remote_control_auth_manager(); let mut auth_recovery = auth_manager.unauthorized_recovery(); let mut auth_change_rx = auth_manager.auth_change_receiver(); - let mut enrollment = Some(remote_control_enrollment(Some( + let current_enrollment = test_current_enrollment(Some(remote_control_enrollment(Some( TEST_REMOTE_CONTROL_SERVER_TOKEN, - ))); + )))); let (status_publisher, _status_rx) = remote_control_status_channel(); let err = connect_remote_control_websocket( @@ -2074,7 +2122,7 @@ mod tests { auth_recovery: &mut auth_recovery, auth_change_rx: &mut auth_change_rx, }, - &mut enrollment, + ¤t_enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, server_name: "test-server", @@ -2082,14 +2130,13 @@ mod tests { app_server_client_name: None, }, &status_publisher, - &test_current_enrollment(), ) .await .expect_err("missing sqlite state db should fail remote control"); assert_eq!(err.kind(), ErrorKind::NotFound); assert_eq!(err.to_string(), "remote control requires sqlite state db"); - assert_eq!(enrollment, None); + assert_eq!(*current_enrollment.lock().await, None); } #[tokio::test] @@ -2107,9 +2154,9 @@ mod tests { .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); let mut auth_change_rx = auth_manager.auth_change_receiver(); - let mut enrollment = Some(remote_control_enrollment(Some( + let current_enrollment = test_current_enrollment(Some(remote_control_enrollment(Some( TEST_REMOTE_CONTROL_SERVER_TOKEN, - ))); + )))); let (status_publisher, mut status_rx) = remote_control_status_channel(); status_publisher.publish_environment_id(Some("env_test".to_string())); status_rx @@ -2125,7 +2172,7 @@ mod tests { auth_recovery: &mut auth_recovery, auth_change_rx: &mut auth_change_rx, }, - &mut enrollment, + ¤t_enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, server_name: "test-server", @@ -2133,7 +2180,6 @@ mod tests { app_server_client_name: None, }, &status_publisher, - &test_current_enrollment(), ) .await .expect_err("missing auth should fail remote control"); @@ -2143,7 +2189,7 @@ mod tests { err.to_string(), "remote control requires ChatGPT authentication" ); - assert_eq!(enrollment, None); + assert_eq!(*current_enrollment.lock().await, None); assert_eq!( status_rx.borrow().clone(), RemoteControlStatusChangedNotification { @@ -2185,7 +2231,8 @@ mod tests { RemoteControlChannels { transport_event_tx, status_publisher, - current_enrollment: test_current_enrollment(), + current_enrollment: test_current_enrollment(/*enrollment*/ None), + pairing_persistence_key: watch::channel(None).0, }, shutdown_token, enabled_rx, diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 37cdf219bfb2..4bdc430a1a90 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -41,6 +41,7 @@ codex-extension-api = { workspace = true } codex-external-agent-migration = { workspace = true } codex-external-agent-sessions = { workspace = true } codex-features = { workspace = true } +codex-goal-extension = { workspace = true } codex-guardian = { workspace = true } codex-git-utils = { workspace = true } codex-file-watcher = { workspace = true } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 6d705afa985f..d918f70b7343 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -130,7 +130,7 @@ Example with notification opt-out: ## API Overview -- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; relative paths resolve against the effective thread cwd. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. +- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. - `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`. - `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`. - `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. @@ -158,7 +158,7 @@ Example with notification opt-out: - `thread/shellCommand` — run a user-initiated `!` shell command against a thread; this runs unsandboxed with full access rather than inheriting the thread sandbox policy. Returns `{}` immediately while progress streams through standard turn/item notifications and any active turn receives the formatted output in its message stream. - `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted. - `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success. -- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; relative paths resolve against the effective turn cwd. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". +- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". - `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success. - `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Review and manual compaction turns reject `turn/steer`. - `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. @@ -211,6 +211,7 @@ Example with notification opt-out: - `remoteControl/disable` — experimental; disable remote control for the current app-server process and return the current remote-control status snapshot. This does not revoke already enrolled controller devices. - `remoteControl/status/read` — experimental; read the current remote-control status snapshot. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `serverName` is the local machine name used by this app-server process; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. - `remoteControl/pairing/start` — experimental; start a short-lived remote-control pairing artifact for the current app-server process. Pass `manualCode: true` to also request a manual pairing code. Returns `pairingCode`, `manualPairingCode`, `environmentId`, and Unix-seconds `expiresAt`; app-server intentionally does not expose the backend `serverId`. +- `remoteControl/pairing/status` — experimental; poll whether a remote-control `pairingCode` or `manualPairingCode` has been claimed. Pass exactly one of the two fields. Returns `claimed`. - `remoteControl/client/list` — experimental; list controller devices granted access to an environment. Pass `environmentId` and optional `cursor`, `limit`, and `order`; returns picker-oriented client metadata plus `nextCursor`. This signed-in account-management operation works while the local relay is disabled or unenrolled. - `remoteControl/client/revoke` — experimental; revoke one controller device's grant for an environment. Pass `environmentId` and `clientId`; returns an empty object. This signed-in account-management operation works while the local relay is disabled or unenrolled. - `remoteControl/status/changed` — notification emitted when the remote-control status or client-visible environment id changes. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `serverName` is the local machine name used by this app-server process; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. Newly initialized app-server clients always receive the current status snapshot. @@ -230,7 +231,7 @@ Example with notification opt-out: - `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin/session `details` returned by detect. When a request includes migration items, the server emits `externalAgentConfig/import/completed` once after the full import finishes (immediately after the response when everything completed synchronously, or after background imports finish). - `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface. - `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads, including multiple `desktop.*` edits. -- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`, `allowedPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. +- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), the layered permission-profile allow map (`allowedPermissionProfiles`), the managed permission-profile default (`defaultPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. ### Example: Start or resume a thread @@ -1244,6 +1245,7 @@ The app-server streams JSON-RPC notifications while a turn is running. Each turn - `turn/plan/updated` — `{ turnId, explanation?, plan }` whenever the agent shares or changes its plan; each `plan` entry is `{ step, status }` with `status` in `pending`, `inProgress`, or `completed`. - `model/rerouted` — `{ threadId, turnId, fromModel, toModel, reason }` when the backend reroutes a request to a different model (for example, due to high-risk cyber safety checks). - `model/verification` — `{ threadId, turnId, verifications }` when the backend flags additional account verification, such as `trustedAccessForCyber`. +- `turn/moderationMetadata` — experimental; `{ threadId, turnId, metadata }` when a first-party backend supplies turn-scoped moderation metadata for client-side presentation. Today both notifications carry an empty `items` array even when item events were streamed; rely on `item/*` notifications for the canonical item list until this is fixed. @@ -1768,6 +1770,7 @@ Codex supports these authentication modes. The current mode is surfaced in `acco - **API key (`apiKey`)**: Caller supplies an OpenAI API key via `account/login/start` with `type: "apiKey"`. The API key is saved and used for API requests. - **ChatGPT managed (`chatgpt`)** (recommended): Codex owns the ChatGPT OAuth flow and refresh tokens. Start via `account/login/start` with `type: "chatgpt"` for the browser flow or `type: "chatgptDeviceCode"` for device code; Codex persists tokens to disk and refreshes them automatically. +- **Personal access token (`personalAccessToken`)**: Codex uses a ChatGPT-backed personal access token loaded outside the app-server login RPCs, such as with `codex login --with-access-token` or `CODEX_ACCESS_TOKEN`. ### API Overview @@ -1776,8 +1779,9 @@ Codex supports these authentication modes. The current mode is surfaced in `acco - `account/login/completed` (notify) — emitted when a login attempt finishes (success or error). - `account/login/cancel` — cancel a pending managed ChatGPT login by `loginId`. - `account/logout` — sign out; triggers `account/updated`. -- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, or `null`) and includes the current ChatGPT `planType` when available. +- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, `personalAccessToken`, or `null`) and includes the current ChatGPT `planType` when available. - `account/rateLimits/read` — fetch ChatGPT rate limits and an optional effective monthly credit limit; updates arrive via `account/rateLimits/updated` (notify). +- `account/usage/read` — fetch ChatGPT account token-activity summary and daily buckets. - `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. This is a sparse rolling update; merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot. - `account/sendAddCreditsNudgeEmail` — ask ChatGPT to email the workspace owner about depleted credits or a reached usage limit. - `mcpServer/oauthLogin/completed` (notify) — emitted after a `mcpServer/oauth/login` flow finishes for a server; payload includes `{ name, success, error? }`. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index f4a5f3b45f01..0cec31b6f253 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -75,6 +75,7 @@ use codex_app_server_protocol::TurnDiffUpdatedNotification; use codex_app_server_protocol::TurnError; use codex_app_server_protocol::TurnInterruptResponse; use codex_app_server_protocol::TurnItemsView; +use codex_app_server_protocol::TurnModerationMetadataNotification; use codex_app_server_protocol::TurnPlanStep; use codex_app_server_protocol::TurnPlanUpdatedNotification; use codex_app_server_protocol::TurnStartedNotification; @@ -337,6 +338,16 @@ pub(crate) async fn apply_bespoke_event_handling( .send_server_notification(ServerNotification::ModelVerification(notification)) .await; } + EventMsg::TurnModerationMetadata(event) => { + let notification = TurnModerationMetadataNotification { + thread_id: conversation_id.to_string(), + turn_id: event_turn_id.clone(), + metadata: event.metadata, + }; + outgoing + .send_server_notification(ServerNotification::TurnModerationMetadata(notification)) + .await; + } EventMsg::RealtimeConversationStarted(event) => { let notification = ThreadRealtimeStartedNotification { thread_id: conversation_id.to_string(), diff --git a/codex-rs/app-server/src/extensions.rs b/codex-rs/app-server/src/extensions.rs index 7b2673ca068c..53f19997cd1f 100644 --- a/codex-rs/app-server/src/extensions.rs +++ b/codex-rs/app-server/src/extensions.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use std::sync::Weak; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ThreadGoal; use codex_app_server_protocol::ThreadGoalUpdatedNotification; use codex_core::NewThread; use codex_core::StartThreadOptions; @@ -12,23 +13,40 @@ use codex_extension_api::AgentSpawner; use codex_extension_api::ExtensionEventSink; use codex_extension_api::ExtensionRegistry; use codex_extension_api::ExtensionRegistryBuilder; +use codex_goal_extension::GoalService; use codex_login::AuthManager; use codex_protocol::ThreadId; use codex_protocol::error::CodexErr; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; +use codex_rollout::state_db::StateDbHandle; use crate::outgoing_message::OutgoingMessageSender; +use crate::thread_state::ThreadListenerCommand; +use crate::thread_state::ThreadStateManager; pub(crate) fn thread_extensions( guardian_agent_spawner: S, event_sink: Arc, auth_manager: Arc, + state_db: Option, + thread_manager: Weak, + goal_service: Arc, ) -> Arc> where S: AgentSpawner + 'static, { let mut builder = ExtensionRegistryBuilder::::with_event_sink(event_sink); + if let Some(state_db) = state_db { + codex_goal_extension::install_with_backend( + &mut builder, + state_db, + codex_otel::global(), + thread_manager, + goal_service, + |config: &Config| config.features.enabled(codex_features::Feature::Goals), + ); + } codex_guardian::install(&mut builder, guardian_agent_spawner); codex_memories_extension::install(&mut builder, codex_otel::global()); codex_web_search_extension::install(&mut builder, auth_manager.clone()); @@ -38,26 +56,53 @@ where pub(crate) fn app_server_extension_event_sink( outgoing: Arc, + thread_state_manager: ThreadStateManager, ) -> Arc { - Arc::new(AppServerExtensionEventSink { outgoing }) + Arc::new(AppServerExtensionEventSink { + outgoing, + thread_state_manager, + }) } struct AppServerExtensionEventSink { outgoing: Arc, + thread_state_manager: ThreadStateManager, } impl ExtensionEventSink for AppServerExtensionEventSink { fn emit(&self, event: Event) { match event.msg { EventMsg::ThreadGoalUpdated(thread_goal_event) => { - self.outgoing - .try_send_server_notification(ServerNotification::ThreadGoalUpdated( - ThreadGoalUpdatedNotification { - thread_id: thread_goal_event.thread_id.to_string(), - turn_id: thread_goal_event.turn_id, - goal: thread_goal_event.goal.into(), - }, - )); + let thread_id = thread_goal_event.thread_id; + let turn_id = thread_goal_event.turn_id; + let goal: ThreadGoal = thread_goal_event.goal.into(); + if let Some(listener_command_tx) = self + .thread_state_manager + .current_listener_command_tx(thread_id) + { + let command = ThreadListenerCommand::EmitThreadGoalUpdated { + turn_id: turn_id.clone(), + goal: goal.clone(), + }; + if listener_command_tx.send(command).is_ok() { + return; + } + tracing::warn!( + "failed to enqueue extension goal update for {thread_id}: listener command channel is closed" + ); + } + let outgoing = Arc::clone(&self.outgoing); + tokio::spawn(async move { + outgoing + .send_server_notification(ServerNotification::ThreadGoalUpdated( + ThreadGoalUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id, + goal, + }, + )) + .await; + }); } msg => { tracing::debug!(event_id = %event.id, ?msg, "dropping unsupported extension event"); @@ -89,10 +134,7 @@ mod tests { use std::time::Duration; use codex_analytics::AnalyticsEventsClient; - use codex_app_server_protocol::ServerNotification; - use codex_app_server_protocol::ThreadGoal as AppServerThreadGoal; - use codex_app_server_protocol::ThreadGoalStatus as AppServerThreadGoalStatus; - use codex_protocol::protocol::ThreadGoal; + use codex_protocol::protocol::ThreadGoal as CoreThreadGoal; use codex_protocol::protocol::ThreadGoalStatus; use codex_protocol::protocol::ThreadGoalUpdatedEvent; use pretty_assertions::assert_eq; @@ -100,25 +142,61 @@ mod tests { use tokio::time::timeout; use super::*; - use crate::outgoing_message::OutgoingEnvelope; - use crate::outgoing_message::OutgoingMessage; #[tokio::test] - async fn app_server_event_sink_forwards_thread_goal_updates() { - let (outgoing_tx, mut outgoing_rx) = mpsc::channel(4); + async fn app_server_event_sink_uses_listener_fifo_for_goal_updates_and_clears() { + let (outgoing_tx, _outgoing_rx) = mpsc::channel(4); let outgoing = Arc::new(OutgoingMessageSender::new( outgoing_tx, AnalyticsEventsClient::disabled(), )); - let sink = app_server_extension_event_sink(outgoing); + let thread_state_manager = ThreadStateManager::new(); let thread_id = ThreadId::default(); + let (listener_command_tx, mut listener_command_rx) = mpsc::unbounded_channel(); + thread_state_manager.register_listener_command_tx(thread_id, listener_command_tx.clone()); + let sink = app_server_extension_event_sink(outgoing, thread_state_manager); + + for turn_id in ["turn-1", "turn-2"] { + sink.emit(thread_goal_updated_event(thread_id, turn_id)); + } + listener_command_tx + .send(ThreadListenerCommand::EmitThreadGoalCleared) + .expect("listener command channel should be open"); + + let mut observed = Vec::new(); + for _ in 0..3 { + let command = timeout(Duration::from_secs(1), listener_command_rx.recv()) + .await + .expect("timed out waiting for listener command") + .expect("listener command channel closed unexpectedly"); + match command { + ThreadListenerCommand::EmitThreadGoalUpdated { turn_id, .. } => { + observed.push(turn_id.expect("extension goal updates should include turn ids")); + } + ThreadListenerCommand::EmitThreadGoalCleared => { + observed.push("cleared".to_string()) + } + _ => panic!("unexpected listener command"), + } + } + + assert_eq!( + vec![ + "turn-1".to_string(), + "turn-2".to_string(), + "cleared".to_string() + ], + observed + ); + } - sink.emit(Event { - id: "call-1".to_string(), + fn thread_goal_updated_event(thread_id: ThreadId, turn_id: &str) -> Event { + Event { + id: turn_id.to_string(), msg: EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent { thread_id, - turn_id: Some("turn-1".to_string()), - goal: ThreadGoal { + turn_id: Some(turn_id.to_string()), + goal: CoreThreadGoal { thread_id, objective: "wire extension events".to_string(), status: ThreadGoalStatus::Active, @@ -129,38 +207,6 @@ mod tests { updated_at: 8, }, }), - }); - - let envelope = timeout(Duration::from_secs(1), outgoing_rx.recv()) - .await - .expect("timed out waiting for forwarded extension event") - .expect("outgoing channel closed unexpectedly"); - let OutgoingEnvelope::Broadcast { message } = envelope else { - panic!("expected broadcast notification"); - }; - let OutgoingMessage::AppServerNotification(ServerNotification::ThreadGoalUpdated( - notification, - )) = message - else { - panic!("expected thread goal updated notification"); - }; - - assert_eq!( - ThreadGoalUpdatedNotification { - thread_id: thread_id.to_string(), - turn_id: Some("turn-1".to_string()), - goal: AppServerThreadGoal { - thread_id: thread_id.to_string(), - objective: "wire extension events".to_string(), - status: AppServerThreadGoalStatus::Active, - token_budget: Some(123), - tokens_used: 45, - time_used_seconds: 6, - created_at: 7, - updated_at: 8, - }, - }, - notification - ); + } } } diff --git a/codex-rs/app-server/src/mcp_refresh.rs b/codex-rs/app-server/src/mcp_refresh.rs index 0996c409bc26..8c72533ba52a 100644 --- a/codex-rs/app-server/src/mcp_refresh.rs +++ b/codex-rs/app-server/src/mcp_refresh.rs @@ -191,6 +191,9 @@ mod tests { guardian_agent_spawner(thread_manager.clone()), Arc::new(NoopExtensionEventSink), auth_manager.clone(), + Some(state_db.clone()), + thread_manager.clone(), + Arc::new(codex_goal_extension::GoalService::new()), ), /*analytics_events_client*/ None, Arc::clone(&thread_store), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index d89f0b064536..b7e0ff258d37 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -70,6 +70,7 @@ use codex_core::ThreadManager; use codex_core::config::Config; use codex_exec_server::EnvironmentManager; use codex_feedback::CodexFeedback; +use codex_goal_extension::GoalService; use codex_login::AuthManager; use codex_login::auth::ExternalAuth; use codex_login::auth::ExternalAuthRefreshContext; @@ -305,6 +306,7 @@ impl MessageProcessor { // resumed, or forked threads to a different persistence backend/root. let thread_store = codex_core::thread_store_from_config(config.as_ref(), state_db.clone()); let environment_manager_for_requests = Arc::clone(&environment_manager); + let goal_service = Arc::new(GoalService::new()); let thread_manager = Arc::new_cyclic(|thread_manager| { ThreadManager::new( config.as_ref(), @@ -313,8 +315,11 @@ impl MessageProcessor { environment_manager, thread_extensions( guardian_agent_spawner(thread_manager.clone()), - app_server_extension_event_sink(outgoing.clone()), + app_server_extension_event_sink(outgoing.clone(), thread_state_manager.clone()), auth_manager.clone(), + state_db.clone(), + thread_manager.clone(), + Arc::clone(&goal_service), ), Some(analytics_events_client.clone()), Arc::clone(&thread_store), @@ -416,6 +421,7 @@ impl MessageProcessor { Arc::clone(&config), thread_state_manager.clone(), state_db.clone(), + Arc::clone(&goal_service), ); let thread_processor = ThreadRequestProcessor::new( auth_manager.clone(), @@ -918,7 +924,12 @@ impl MessageProcessor { .map(|response| Some(response.into())), ClientRequest::RemoteControlPairingStart { params, .. } => self .remote_control_processor - .pairing_start(params) + .pairing_start(params, app_server_client_name.as_deref()) + .await + .map(|response| Some(response.into())), + ClientRequest::RemoteControlPairingStatus { params, .. } => self + .remote_control_processor + .pairing_status(params) .await .map(|response| Some(response.into())), ClientRequest::RemoteControlClientsList { params, .. } => self @@ -1294,6 +1305,9 @@ impl MessageProcessor { ClientRequest::GetAccountRateLimits { .. } => { self.account_processor.get_account_rate_limits().await } + ClientRequest::GetAccountTokenUsage { .. } => { + self.account_processor.get_account_token_usage().await + } ClientRequest::SendAddCreditsNudgeEmail { params, .. } => { self.account_processor .send_add_credits_nudge_email(params) diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index ac7e87741a5d..ba75d0afd386 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -555,16 +555,6 @@ impl OutgoingMessageSender { .await; } - pub(crate) fn try_send_server_notification(&self, notification: ServerNotification) { - tracing::trace!("app-server event: {notification}"); - let outgoing_message = OutgoingMessage::AppServerNotification(notification); - if let Err(err) = self.sender.try_send(OutgoingEnvelope::Broadcast { - message: outgoing_message, - }) { - warn!("failed to send server notification to client without waiting: {err:?}"); - } - } - pub(crate) async fn send_server_notification_to_connections( &self, connection_ids: &[ConnectionId], @@ -725,6 +715,7 @@ mod tests { use codex_app_server_protocol::RateLimitWindow; use codex_app_server_protocol::ServerResponse; use codex_app_server_protocol::ToolRequestUserInputParams; + use codex_app_server_protocol::TurnModerationMetadataNotification; use codex_protocol::ThreadId; use pretty_assertions::assert_eq; use serde_json::json; @@ -951,6 +942,31 @@ mod tests { ); } + #[test] + fn verify_turn_moderation_metadata_notification_serialization() { + let notification = + ServerNotification::TurnModerationMetadata(TurnModerationMetadataNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + metadata: json!({"presentation": "inline"}), + }); + + let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification); + assert_eq!( + json!({ + "method": "turn/moderationMetadata", + "params": { + "threadId": "thread-1", + "turnId": "turn-1", + "metadata": {"presentation": "inline"}, + }, + }), + serde_json::to_value(jsonrpc_notification) + .expect("ensure the notification serializes correctly"), + "ensure the notification serializes correctly" + ); + } + #[test] fn server_request_response_from_result_decodes_typed_response() { let request = ServerRequest::CommandExecutionRequestApproval { diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 2f3c87d8687a..57205798b23c 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -22,6 +22,8 @@ use codex_analytics::InputError; use codex_analytics::TurnSteerRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; +use codex_app_server_protocol::AccountTokenUsageDailyBucket; +use codex_app_server_protocol::AccountTokenUsageSummary; use codex_app_server_protocol::AccountUpdatedNotification; use codex_app_server_protocol::AddCreditsNudgeCreditType; use codex_app_server_protocol::AddCreditsNudgeEmailStatus; @@ -30,6 +32,8 @@ use codex_app_server_protocol::AdditionalContextKind; use codex_app_server_protocol::AppInfo; use codex_app_server_protocol::AppListUpdatedNotification; use codex_app_server_protocol::AppSummary; +use codex_app_server_protocol::AppTemplateSummary; +use codex_app_server_protocol::AppTemplateUnavailableReason; use codex_app_server_protocol::AppsListParams; use codex_app_server_protocol::AppsListResponse; use codex_app_server_protocol::AskForApproval; @@ -62,6 +66,7 @@ use codex_app_server_protocol::FeedbackUploadResponse; use codex_app_server_protocol::GetAccountParams; use codex_app_server_protocol::GetAccountRateLimitsResponse; use codex_app_server_protocol::GetAccountResponse; +use codex_app_server_protocol::GetAccountTokenUsageResponse; use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::GetAuthStatusResponse; use codex_app_server_protocol::GetConversationSummaryParams; @@ -264,6 +269,7 @@ use codex_app_server_protocol::WindowsSandboxSetupStartResponse; use codex_arg0::Arg0DispatchPaths; use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCreditType; use codex_backend_client::Client as BackendClient; +use codex_backend_client::TokenUsageProfile; use codex_chatgpt::connectors; use codex_chatgpt::workspace_settings; use codex_config::CloudConfigBundleLoadError; @@ -273,8 +279,6 @@ use codex_config::loader::project_trust_key; use codex_config::types::McpServerTransportConfig; use codex_core::CodexThread; use codex_core::CodexThreadSettingsOverrides; -use codex_core::ExternalGoalPreviousStatus; -use codex_core::ExternalGoalSet; use codex_core::ForkSnapshot; use codex_core::NewThread; #[cfg(test)] @@ -353,7 +357,6 @@ use codex_mcp::discover_supported_scopes; use codex_mcp::read_mcp_resource as read_mcp_resource_without_thread; use codex_mcp::resolve_oauth_scopes; use codex_memories_write::clear_memory_roots_contents; -use codex_model_provider::ProviderAccountError; use codex_model_provider::create_model_provider; use codex_models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets; use codex_protocol::ThreadId; @@ -507,6 +510,24 @@ use crate::thread_state::ThreadStateManager; use token_usage_replay::latest_token_usage_turn_id_from_rollout_items; use token_usage_replay::send_thread_token_usage_update_to_connection; +fn resolve_request_cwd(cwd: Option) -> Result, JSONRPCErrorError> { + cwd.map(|cwd| { + AbsolutePathBuf::relative_to_current_dir(path_utils::normalize_for_native_workdir(cwd)) + .map_err(|err| invalid_request(format!("invalid cwd: {err}"))) + }) + .transpose() +} + +fn resolve_runtime_workspace_roots(workspace_roots: Vec) -> Vec { + let mut resolved_roots = Vec::new(); + for root in workspace_roots { + if !resolved_roots.iter().any(|existing| existing == &root) { + resolved_roots.push(root); + } + } + resolved_roots +} + mod config_errors; mod request_errors; mod thread_goal_processor; 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 8ebfc037e1a1..a4bbe42d6139 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -2,6 +2,7 @@ use super::*; // Duration before a browser ChatGPT login attempt is abandoned. const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60); +const ACCOUNT_TOKEN_USAGE_FETCH_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 10); // The override is intentionally available only in debug builds, matching the login path below. #[cfg(debug_assertions)] const LOGIN_ISSUER_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER"; @@ -131,6 +132,14 @@ impl AccountRequestProcessor { .map(|response| Some(response.into())) } + pub(crate) async fn get_account_token_usage( + &self, + ) -> Result, JSONRPCErrorError> { + self.get_account_token_usage_response() + .await + .map(|response| Some(response.into())) + } + pub(crate) async fn send_add_credits_nudge_email( &self, params: SendAddCreditsNudgeEmailParams, @@ -767,24 +776,28 @@ impl AccountRequestProcessor { let permanent_refresh_failure = self.auth_manager.refresh_failure_for_auth(&auth).is_some(); let auth_mode = auth.api_auth_mode(); - let (reported_auth_method, token_opt) = - if matches!(auth, CodexAuth::AgentIdentity(_)) - || include_token && permanent_refresh_failure - { - (Some(auth_mode), None) - } else { - match auth.get_token() { - Ok(token) if !token.is_empty() => { - let tok = if include_token { Some(token) } else { None }; - (Some(auth_mode), tok) - } - Ok(_) => (None, None), - Err(err) => { - tracing::warn!("failed to get token for auth status: {err}"); - (None, None) - } + let (reported_auth_method, token_opt) = if matches!( + auth, + CodexAuth::AgentIdentity(_) | CodexAuth::PersonalAccessToken(_) + ) || include_token + && permanent_refresh_failure + { + // This response cannot represent the metadata needed to reuse these + // credentials. + (Some(auth_mode), None) + } else { + match auth.get_token() { + Ok(token) if !token.is_empty() => { + let tok = if include_token { Some(token) } else { None }; + (Some(auth_mode), tok) } - }; + Ok(_) => (None, None), + Err(err) => { + tracing::warn!("failed to get token for auth status: {err}"); + (None, None) + } + } + }; GetAuthStatusResponse { auth_method: reported_auth_method, auth_token: token_opt, @@ -816,11 +829,7 @@ impl AccountRequestProcessor { ); let account_state = match provider.account_state() { Ok(account_state) => account_state, - Err(ProviderAccountError::MissingChatgptAccountDetails) => { - return Err(invalid_request( - "email and plan type are required for chatgpt authentication", - )); - } + Err(err) => return Err(invalid_request(err.to_string())), }; let account = account_state.account.map(Account::from); @@ -848,6 +857,55 @@ impl AccountRequestProcessor { ) } + async fn get_account_token_usage_response( + &self, + ) -> Result { + let Some(auth) = self.auth_manager.auth().await else { + return Err(invalid_request( + "codex account authentication required to read token usage", + )); + }; + + if !auth.uses_codex_backend() { + return Err(invalid_request( + "chatgpt authentication required to read token usage", + )); + } + + let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth) + .map_err(|err| internal_error(format!("failed to construct backend client: {err}")))?; + let profile = tokio::time::timeout( + ACCOUNT_TOKEN_USAGE_FETCH_TIMEOUT, + client.get_token_usage_profile(), + ) + .await + .map_err(|_| internal_error("token usage profile fetch timed out"))? + .map_err(|err| internal_error(format!("failed to fetch token usage profile: {err}")))?; + Ok(Self::account_token_usage_response(profile)) + } + + fn account_token_usage_response(profile: TokenUsageProfile) -> GetAccountTokenUsageResponse { + let stats = profile.stats; + GetAccountTokenUsageResponse { + summary: AccountTokenUsageSummary { + lifetime_tokens: stats.lifetime_tokens, + peak_daily_tokens: stats.peak_daily_tokens, + longest_running_turn_sec: stats.longest_running_turn_sec, + current_streak_days: stats.current_streak_days, + longest_streak_days: stats.longest_streak_days, + }, + daily_usage_buckets: stats.daily_usage_buckets.map(|buckets| { + buckets + .into_iter() + .map(|bucket| AccountTokenUsageDailyBucket { + start_date: bucket.start_date, + tokens: bucket.tokens, + }) + .collect() + }), + } + } + async fn send_add_credits_nudge_email_response( &self, params: SendAddCreditsNudgeEmailParams, @@ -952,3 +1010,45 @@ impl AccountRequestProcessor { Ok((primary, rate_limits_by_limit_id)) } } + +#[cfg(test)] +mod tests { + use super::*; + use codex_backend_client::TokenUsageProfileDailyBucket; + use codex_backend_client::TokenUsageProfileStats; + use pretty_assertions::assert_eq; + + #[test] + fn account_token_usage_response_maps_profile_stats_and_daily_buckets() { + let response = AccountRequestProcessor::account_token_usage_response(TokenUsageProfile { + stats: TokenUsageProfileStats { + lifetime_tokens: Some(123), + peak_daily_tokens: Some(45), + longest_running_turn_sec: Some(67), + current_streak_days: Some(8), + longest_streak_days: Some(9), + daily_usage_buckets: Some(vec![TokenUsageProfileDailyBucket { + start_date: "2026-05-29".to_string(), + tokens: 10, + }]), + }, + }); + + assert_eq!( + response, + GetAccountTokenUsageResponse { + summary: AccountTokenUsageSummary { + lifetime_tokens: Some(123), + peak_daily_tokens: Some(45), + longest_running_turn_sec: Some(67), + current_streak_days: Some(8), + longest_streak_days: Some(9), + }, + daily_usage_buckets: Some(vec![AccountTokenUsageDailyBucket { + start_date: "2026-05-29".to_string(), + tokens: 10, + }]), + } + ); + } +} diff --git a/codex-rs/app-server/src/request_processors/catalog_processor.rs b/codex-rs/app-server/src/request_processors/catalog_processor.rs index bbba0bae6210..9c0cc1c8b517 100644 --- a/codex-rs/app-server/src/request_processors/catalog_processor.rs +++ b/codex-rs/app-server/src/request_processors/catalog_processor.rs @@ -647,9 +647,11 @@ impl CatalogRequestProcessor { config.features.enabled(Feature::Plugins) && workspace_codex_plugins_enabled; let plugin_hooks = if plugins_enabled { let plugins_input = config.plugins_config_input(); - plugins_manager - .plugin_hooks_for_layer_stack(&config.config_layer_stack, &plugins_input) - .await + let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await; + codex_core_plugins::PluginHookLoadOutcome { + hook_sources: plugin_outcome.effective_plugin_hook_sources(), + hook_load_warnings: plugin_outcome.effective_plugin_hook_warnings(), + } } else { codex_core_plugins::PluginHookLoadOutcome::default() }; diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index 37535509252a..bdc801395097 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -350,7 +350,8 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR .collect() }) }), - allowed_permissions: requirements.allowed_permissions, + allowed_permission_profiles: requirements.allowed_permission_profiles, + default_permissions: requirements.default_permissions, allowed_web_search_modes: requirements.allowed_web_search_modes.map(|modes| { let mut normalized = modes .into_iter() @@ -569,29 +570,43 @@ mod tests { use codex_config::ConfigRequirementsToml; use codex_config::WindowsRequirementsToml; use pretty_assertions::assert_eq; + use std::collections::BTreeMap; #[test] fn requirements_api_includes_allow_managed_hooks_only() { let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { - allowed_permissions: Some(vec![ - "managed-standard".to_string(), - "managed-build".to_string(), - ]), allow_managed_hooks_only: Some(true), ..ConfigRequirementsToml::default() }); - assert_eq!( - mapped.allowed_permissions, - Some(vec![ - "managed-standard".to_string(), - "managed-build".to_string(), - ]) - ); assert_eq!(mapped.allow_managed_hooks_only, Some(true)); assert_eq!(mapped.hooks, None); } + #[test] + fn requirements_api_includes_permission_default_and_allowlist() { + let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { + allowed_permission_profiles: Some(BTreeMap::from([ + ("managed-build".to_string(), false), + ("managed-standard".to_string(), true), + ])), + default_permissions: Some("managed-standard".to_string()), + ..ConfigRequirementsToml::default() + }); + + assert_eq!( + mapped.allowed_permission_profiles, + Some(BTreeMap::from([ + ("managed-build".to_string(), false), + ("managed-standard".to_string(), true), + ])) + ); + assert_eq!( + mapped.default_permissions, + Some("managed-standard".to_string()) + ); + } + #[test] fn requirements_api_includes_allow_appshots() { let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 23356948567b..79791c142a8e 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -8,6 +8,7 @@ use codex_app_server_protocol::PluginShareTargetRole; use codex_config::types::McpServerConfig; use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME; use codex_core_plugins::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; +use codex_core_plugins::remote::RemoteAppTemplateUnavailableReason; use codex_core_plugins::remote::RemotePluginScope; use codex_core_plugins::remote::is_valid_remote_plugin_id; use codex_core_plugins::remote::validate_remote_plugin_id; @@ -1062,6 +1063,7 @@ impl PluginRequestProcessor { }) .collect(), apps: app_summaries, + app_templates: Vec::new(), mcp_servers: outcome.plugin.mcp_server_names, } } @@ -2007,6 +2009,28 @@ fn remote_plugin_detail_to_info( detail: RemoteCatalogPluginDetail, apps: Vec, ) -> PluginDetail { + let app_templates = detail + .app_templates + .into_iter() + .map(|template| AppTemplateSummary { + template_id: template.template_id, + name: template.name, + description: template.description, + canonical_connector_id: template.canonical_connector_id, + logo_url: template.logo_url, + logo_url_dark: template.logo_url_dark, + materialized_app_ids: template.materialized_app_ids, + reason: template.reason.map(|reason| match reason { + RemoteAppTemplateUnavailableReason::NotConfiguredForWorkspace => { + AppTemplateUnavailableReason::NotConfiguredForWorkspace + } + RemoteAppTemplateUnavailableReason::NoActiveWorkspace => { + AppTemplateUnavailableReason::NoActiveWorkspace + } + }), + }) + .collect(); + PluginDetail { marketplace_name: detail.marketplace_name, marketplace_path: None, @@ -2026,7 +2050,8 @@ fn remote_plugin_detail_to_info( .collect(), hooks: Vec::new(), apps, - mcp_servers: Vec::new(), + app_templates, + mcp_servers: detail.mcp_servers, } } diff --git a/codex-rs/app-server/src/request_processors/remote_control_processor.rs b/codex-rs/app-server/src/request_processors/remote_control_processor.rs index 804339c17a5e..31b5fd6b749e 100644 --- a/codex-rs/app-server/src/request_processors/remote_control_processor.rs +++ b/codex-rs/app-server/src/request_processors/remote_control_processor.rs @@ -11,6 +11,8 @@ use codex_app_server_protocol::RemoteControlDisableResponse; use codex_app_server_protocol::RemoteControlEnableResponse; use codex_app_server_protocol::RemoteControlPairingStartParams; use codex_app_server_protocol::RemoteControlPairingStartResponse; +use codex_app_server_protocol::RemoteControlPairingStatusParams; +use codex_app_server_protocol::RemoteControlPairingStatusResponse; use codex_app_server_protocol::RemoteControlStatusReadResponse; use std::io; @@ -52,9 +54,21 @@ impl RemoteControlRequestProcessor { pub(crate) async fn pairing_start( &self, params: RemoteControlPairingStartParams, + app_server_client_name: Option<&str>, ) -> Result { self.handle()? - .start_pairing(params) + .start_pairing(params, app_server_client_name) + .await + .map_err(map_pairing_start_error) + } + + pub(crate) async fn pairing_status( + &self, + params: RemoteControlPairingStatusParams, + ) -> Result { + validate_pairing_status_params(¶ms)?; + self.handle()? + .pairing_status(params) .await .map_err(map_pairing_start_error) } @@ -98,6 +112,20 @@ fn map_pairing_start_error(err: io::Error) -> JSONRPCErrorError { } } +fn validate_pairing_status_params( + params: &RemoteControlPairingStatusParams, +) -> Result<(), JSONRPCErrorError> { + match (¶ms.pairing_code, ¶ms.manual_pairing_code) { + (Some(_), None) | (None, Some(_)) => Ok(()), + (Some(_), Some(_)) => Err(invalid_request( + "remoteControl/pairing/status accepts either pairingCode or manualPairingCode, not both", + )), + (None, None) => Err(invalid_request( + "remoteControl/pairing/status requires pairingCode or manualPairingCode", + )), + } +} + fn map_client_management_error(err: io::Error) -> JSONRPCErrorError { match err.kind() { io::ErrorKind::InvalidInput diff --git a/codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs b/codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs index 4381e48959da..36c60b913e3f 100644 --- a/codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/remote_control_processor/remote_control_processor_tests.rs @@ -6,7 +6,10 @@ use pretty_assertions::assert_eq; #[tokio::test] async fn pairing_start_returns_internal_error_when_remote_control_is_unavailable() { let err = RemoteControlRequestProcessor::new(/*remote_control_handle*/ None) - .pairing_start(RemoteControlPairingStartParams::default()) + .pairing_start( + RemoteControlPairingStartParams::default(), + /*app_server_client_name*/ None, + ) .await .expect_err("missing remote control should fail pairing"); @@ -20,6 +23,59 @@ async fn pairing_start_returns_internal_error_when_remote_control_is_unavailable ); } +#[tokio::test] +async fn pairing_status_returns_internal_error_when_remote_control_is_unavailable() { + let err = RemoteControlRequestProcessor::new(/*remote_control_handle*/ None) + .pairing_status(RemoteControlPairingStatusParams { + pairing_code: Some("pairing-code".to_string()), + manual_pairing_code: None, + }) + .await + .expect_err("missing remote control should fail pairing status"); + + assert_eq!( + err, + JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + data: None, + message: "remote control is unavailable for this app-server".to_string(), + } + ); +} + +#[test] +fn pairing_status_rejects_missing_pairing_codes() { + assert_eq!( + validate_pairing_status_params(&RemoteControlPairingStatusParams { + pairing_code: None, + manual_pairing_code: None, + }), + Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + data: None, + message: "remoteControl/pairing/status requires pairingCode or manualPairingCode" + .to_string(), + }) + ); +} + +#[test] +fn pairing_status_rejects_conflicting_pairing_codes() { + assert_eq!( + validate_pairing_status_params(&RemoteControlPairingStatusParams { + pairing_code: Some("pairing-code".to_string()), + manual_pairing_code: Some("ABCD-EFGH".to_string()), + }), + Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + data: None, + message: + "remoteControl/pairing/status accepts either pairingCode or manualPairingCode, not both" + .to_string(), + }) + ); +} + #[test] fn pairing_start_maps_invalid_input_to_invalid_request() { assert_eq!( diff --git a/codex-rs/app-server/src/request_processors/thread_goal_processor.rs b/codex-rs/app-server/src/request_processors/thread_goal_processor.rs index c18806ea0385..2f40ce6602f3 100644 --- a/codex-rs/app-server/src/request_processors/thread_goal_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_goal_processor.rs @@ -1,5 +1,9 @@ use super::*; -use codex_protocol::protocol::validate_thread_goal_objective; +use codex_goal_extension::GoalObjectiveUpdate; +use codex_goal_extension::GoalService; +use codex_goal_extension::GoalServiceError; +use codex_goal_extension::GoalSetRequest; +use codex_goal_extension::GoalTokenBudgetUpdate; #[derive(Clone)] pub(crate) struct ThreadGoalRequestProcessor { @@ -8,6 +12,7 @@ pub(crate) struct ThreadGoalRequestProcessor { config: Arc, thread_state_manager: ThreadStateManager, state_db: Option, + goal_service: Arc, } impl ThreadGoalRequestProcessor { @@ -17,6 +22,7 @@ impl ThreadGoalRequestProcessor { config: Arc, thread_state_manager: ThreadStateManager, state_db: Option, + goal_service: Arc, ) -> Self { Self { thread_manager, @@ -24,6 +30,7 @@ impl ThreadGoalRequestProcessor { config, thread_state_manager, state_db, + goal_service, } } @@ -66,10 +73,8 @@ impl ThreadGoalRequestProcessor { } self.emit_thread_goal_snapshot(thread_id).await; // App-server owns resume response and snapshot ordering, so wait until - // those are sent before letting core start goal continuation. - if let Err(err) = thread.continue_active_goal_if_idle().await { - tracing::warn!("failed to continue active goal after resume: {err}"); - } + // those are sent before letting extensions react to the idle thread. + thread.emit_thread_idle_lifecycle_if_idle().await; } pub(crate) async fn pending_resume_goal_state( @@ -100,140 +105,36 @@ impl ThreadGoalRequestProcessor { let thread_id = parse_thread_id_for_request(params.thread_id.as_str())?; let state_db = self.state_db_for_materialized_thread(thread_id).await?; - let running_thread = self.thread_manager.get_thread(thread_id).await.ok(); - let rollout_path = match running_thread.as_ref() { - Some(thread) => thread.rollout_path().ok_or_else(|| { - invalid_request(format!( - "ephemeral thread does not support goals: {thread_id}" - )) - })?, - None => codex_rollout::find_thread_path_by_id_str( - &self.config.codex_home, - &thread_id.to_string(), - self.state_db.as_deref(), - ) - .await - .map_err(|err| { - internal_error(format!("failed to locate thread id {thread_id}: {err}")) - })? - .ok_or_else(|| invalid_request(format!("thread not found: {thread_id}")))?, - }; - reconcile_rollout( - Some(&state_db), - rollout_path.as_path(), - self.config.model_provider_id.as_str(), - /*builder*/ None, - &[], - /*archived_only*/ None, - /*new_thread_memory_mode*/ None, - ) - .await; + self.reconcile_thread_goal_rollout(thread_id, &state_db) + .await?; let listener_command_tx = { let thread_state = self.thread_state_manager.thread_state(thread_id).await; let thread_state = thread_state.lock().await; thread_state.listener_command_tx() }; - let status = params.status.map(thread_goal_status_to_state); - let objective = params.objective.as_deref().map(str::trim); - - if let Some(objective) = objective { - validate_thread_goal_objective(objective).map_err(invalid_request)?; - } - if objective.is_some() || params.token_budget.is_some() { - validate_goal_budget(params.token_budget.flatten()).map_err(invalid_request)?; - } - - if let Some(thread) = running_thread.as_ref() { - thread.prepare_external_goal_mutation().await; - } + let status = params.status.map(ThreadGoalStatus::to_core); + let objective = params.objective.as_deref(); - let should_set_thread_preview = objective.is_some(); - let (goal, previous_status) = (if let Some(objective) = objective { - let existing_goal = state_db - .thread_goals() - .get_thread_goal(thread_id) - .await - .map_err(|err| invalid_request(err.to_string()))?; - if let Some(goal) = existing_goal.as_ref() { - let previous_status = ExternalGoalPreviousStatus::from(goal); - state_db - .thread_goals() - .update_thread_goal( - thread_id, - codex_state::GoalUpdate { - objective: Some(objective.to_string()), - status, - token_budget: params.token_budget, - expected_goal_id: Some(goal.goal_id.clone()), - }, - ) - .await - .and_then(|goal| { - goal.ok_or_else(|| { - anyhow::anyhow!( - "cannot update goal for thread {thread_id}: no goal exists" - ) - }) - }) - .map(|goal| (goal, previous_status)) - } else { - let previous_status = ExternalGoalPreviousStatus::NewGoal; - state_db - .thread_goals() - .replace_thread_goal( - thread_id, - objective, - status.unwrap_or(codex_state::ThreadGoalStatus::Active), - params.token_budget.flatten(), - ) - .await - .map(|goal| (goal, previous_status)) - } - } else { - let existing_goal = state_db - .thread_goals() - .get_thread_goal(thread_id) - .await - .map_err(|err| invalid_request(err.to_string()))?; - let Some(existing_goal) = existing_goal else { - return Err(invalid_request(format!( - "cannot update goal for thread {thread_id}: no goal exists" - ))); - }; - let previous_status = ExternalGoalPreviousStatus::from(&existing_goal); - state_db - .thread_goals() - .update_thread_goal( + let outcome = self + .goal_service + .set_thread_goal( + &state_db, + GoalSetRequest { thread_id, - codex_state::GoalUpdate { - objective: None, - status, - token_budget: params.token_budget, - expected_goal_id: None, + objective: objective + .map(GoalObjectiveUpdate::Set) + .unwrap_or(GoalObjectiveUpdate::Keep), + status, + token_budget: match params.token_budget { + Some(token_budget) => GoalTokenBudgetUpdate::Set(token_budget), + None => GoalTokenBudgetUpdate::Keep, }, - ) - .await - .and_then(|goal| { - goal.ok_or_else(|| { - anyhow::anyhow!("cannot update goal for thread {thread_id}: no goal exists") - }) - }) - .map(|goal| (goal, previous_status)) - }) - .map_err(|err| invalid_request(err.to_string()))?; - if should_set_thread_preview - && let Err(err) = state_db - .set_thread_preview_if_empty(thread_id, goal.objective.as_str()) - .await - { - warn!("failed to set empty thread preview from goal objective for {thread_id}: {err}"); - } - let external_goal_set = ExternalGoalSet { - goal: goal.clone(), - previous_status, - }; - let goal = api_thread_goal_from_state(goal); + }, + ) + .await + .map_err(goal_service_error)?; + let goal = ThreadGoal::from(outcome.goal.clone()); self.outgoing .send_response( request_id.clone(), @@ -242,9 +143,7 @@ impl ThreadGoalRequestProcessor { .await; self.emit_thread_goal_updated_ordered(thread_id, goal, listener_command_tx) .await; - if let Some(thread) = running_thread.as_ref() { - thread.apply_external_goal_set(external_goal_set).await; - } + outcome.apply_runtime_effects(&self.goal_service).await; Ok(()) } @@ -258,12 +157,12 @@ impl ThreadGoalRequestProcessor { let thread_id = parse_thread_id_for_request(params.thread_id.as_str())?; let state_db = self.state_db_for_materialized_thread(thread_id).await?; - let goal = state_db - .thread_goals() - .get_thread_goal(thread_id) + let goal = self + .goal_service + .get_thread_goal(&state_db, thread_id) .await - .map_err(|err| internal_error(format!("failed to read thread goal: {err}")))? - .map(api_thread_goal_from_state); + .map_err(goal_service_error)? + .map(ThreadGoal::from); Ok(ThreadGoalGetResponse { goal }) } @@ -278,53 +177,19 @@ impl ThreadGoalRequestProcessor { let thread_id = parse_thread_id_for_request(params.thread_id.as_str())?; let state_db = self.state_db_for_materialized_thread(thread_id).await?; - let running_thread = self.thread_manager.get_thread(thread_id).await.ok(); - let rollout_path = match running_thread.as_ref() { - Some(thread) => thread.rollout_path().ok_or_else(|| { - invalid_request(format!( - "ephemeral thread does not support goals: {thread_id}" - )) - })?, - None => codex_rollout::find_thread_path_by_id_str( - &self.config.codex_home, - &thread_id.to_string(), - self.state_db.as_deref(), - ) - .await - .map_err(|err| { - internal_error(format!("failed to locate thread id {thread_id}: {err}")) - })? - .ok_or_else(|| invalid_request(format!("thread not found: {thread_id}")))?, - }; - reconcile_rollout( - Some(&state_db), - rollout_path.as_path(), - self.config.model_provider_id.as_str(), - /*builder*/ None, - &[], - /*archived_only*/ None, - /*new_thread_memory_mode*/ None, - ) - .await; - - if let Some(thread) = running_thread.as_ref() { - thread.prepare_external_goal_mutation().await; - } + self.reconcile_thread_goal_rollout(thread_id, &state_db) + .await?; let listener_command_tx = { let thread_state = self.thread_state_manager.thread_state(thread_id).await; let thread_state = thread_state.lock().await; thread_state.listener_command_tx() }; - let cleared = state_db - .thread_goals() - .delete_thread_goal(thread_id) + let cleared = self + .goal_service + .clear_thread_goal(&state_db, thread_id) .await - .map_err(|err| internal_error(format!("failed to clear thread goal: {err}")))?; - - if cleared && let Some(thread) = running_thread.as_ref() { - thread.apply_external_goal_clear().await; - } + .map_err(goal_service_error)?; self.outgoing .send_response(request_id, ThreadGoalClearResponse { cleared }) @@ -367,6 +232,42 @@ impl ThreadGoalRequestProcessor { .ok_or_else(|| internal_error("sqlite state db unavailable for thread goals")) } + async fn reconcile_thread_goal_rollout( + &self, + thread_id: ThreadId, + state_db: &StateDbHandle, + ) -> Result<(), JSONRPCErrorError> { + let running_thread = self.thread_manager.get_thread(thread_id).await.ok(); + let rollout_path = match running_thread.as_ref() { + Some(thread) => thread.rollout_path().ok_or_else(|| { + invalid_request(format!( + "ephemeral thread does not support goals: {thread_id}" + )) + })?, + None => codex_rollout::find_thread_path_by_id_str( + &self.config.codex_home, + &thread_id.to_string(), + self.state_db.as_deref(), + ) + .await + .map_err(|err| { + internal_error(format!("failed to locate thread id {thread_id}: {err}")) + })? + .ok_or_else(|| invalid_request(format!("thread not found: {thread_id}")))?, + }; + reconcile_rollout( + Some(state_db), + rollout_path.as_path(), + self.config.model_provider_id.as_str(), + /*builder*/ None, + &[], + /*archived_only*/ None, + /*new_thread_memory_mode*/ None, + ) + .await; + Ok(()) + } + async fn emit_thread_goal_snapshot(&self, thread_id: ThreadId) { let state_db = match self.state_db_for_materialized_thread(thread_id).await { Ok(state_db) => state_db, @@ -405,6 +306,7 @@ impl ThreadGoalRequestProcessor { ) { if let Some(listener_command_tx) = listener_command_tx { let command = crate::thread_state::ThreadListenerCommand::EmitThreadGoalUpdated { + turn_id: None, goal: goal.clone(), }; if listener_command_tx.send(command).is_ok() { @@ -449,27 +351,20 @@ impl ThreadGoalRequestProcessor { } } -fn validate_goal_budget(value: Option) -> Result<(), String> { - if let Some(value) = value - && value <= 0 - { - return Err("goal budgets must be positive when provided".to_string()); - } - Ok(()) -} - -fn thread_goal_status_to_state(status: ThreadGoalStatus) -> codex_state::ThreadGoalStatus { - match status { - ThreadGoalStatus::Active => codex_state::ThreadGoalStatus::Active, - ThreadGoalStatus::Paused => codex_state::ThreadGoalStatus::Paused, - ThreadGoalStatus::Blocked => codex_state::ThreadGoalStatus::Blocked, - ThreadGoalStatus::UsageLimited => codex_state::ThreadGoalStatus::UsageLimited, - ThreadGoalStatus::BudgetLimited => codex_state::ThreadGoalStatus::BudgetLimited, - ThreadGoalStatus::Complete => codex_state::ThreadGoalStatus::Complete, +pub(super) fn api_thread_goal_from_state(goal: codex_state::ThreadGoal) -> ThreadGoal { + ThreadGoal { + thread_id: goal.thread_id.to_string(), + objective: goal.objective, + status: api_thread_goal_status_from_state(goal.status), + token_budget: goal.token_budget, + tokens_used: goal.tokens_used, + time_used_seconds: goal.time_used_seconds, + created_at: goal.created_at.timestamp(), + updated_at: goal.updated_at.timestamp(), } } -fn thread_goal_status_from_state(status: codex_state::ThreadGoalStatus) -> ThreadGoalStatus { +fn api_thread_goal_status_from_state(status: codex_state::ThreadGoalStatus) -> ThreadGoalStatus { match status { codex_state::ThreadGoalStatus::Active => ThreadGoalStatus::Active, codex_state::ThreadGoalStatus::Paused => ThreadGoalStatus::Paused, @@ -480,16 +375,10 @@ fn thread_goal_status_from_state(status: codex_state::ThreadGoalStatus) -> Threa } } -pub(super) fn api_thread_goal_from_state(goal: codex_state::ThreadGoal) -> ThreadGoal { - ThreadGoal { - thread_id: goal.thread_id.to_string(), - objective: goal.objective, - status: thread_goal_status_from_state(goal.status), - token_budget: goal.token_budget, - tokens_used: goal.tokens_used, - time_used_seconds: goal.time_used_seconds, - created_at: goal.created_at.timestamp(), - updated_at: goal.updated_at.timestamp(), +fn goal_service_error(err: GoalServiceError) -> JSONRPCErrorError { + match err { + GoalServiceError::InvalidRequest(message) => invalid_request(message), + GoalServiceError::Internal(message) => internal_error(message), } } 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 f72cfb5a626e..8bfb282ace4d 100644 --- a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs +++ b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs @@ -244,12 +244,22 @@ pub(super) async fn ensure_listener_task_running( if thread_state.listener_matches(&conversation) { return Ok(()); } - thread_state.set_listener( + let (listener_command_rx, listener_generation) = thread_state.set_listener( cancel_tx, &conversation, watch_registration, thread_settings_baseline, - ) + ); + let Some(listener_command_tx) = thread_state.listener_command_tx() else { + tracing::warn!( + "thread listener command sender missing immediately after listener registration" + ); + return Ok(()); + }; + listener_task_context + .thread_state_manager + .register_listener_command_tx(conversation_id, listener_command_tx); + (listener_command_rx, listener_generation) }; let ListenerTaskContext { outgoing, @@ -378,6 +388,7 @@ pub(super) async fn ensure_listener_task_running( let mut thread_state = thread_state.lock().await; if thread_state.listener_generation == listener_generation { + thread_state_manager.unregister_listener_command_tx(conversation_id); thread_state.clear_listener(); } }); @@ -471,12 +482,12 @@ pub(super) async fn handle_thread_listener_command( ) .await; } - ThreadListenerCommand::EmitThreadGoalUpdated { goal } => { + ThreadListenerCommand::EmitThreadGoalUpdated { turn_id, goal } => { outgoing .send_server_notification(ServerNotification::ThreadGoalUpdated( ThreadGoalUpdatedNotification { thread_id: conversation_id.to_string(), - turn_id: None, + turn_id, goal, }, )) @@ -616,12 +627,6 @@ pub(super) async fn handle_pending_thread_resume_request( } } - if pending.emit_thread_goal_update - && let Err(err) = conversation.apply_goal_resume_runtime_effects().await - { - tracing::warn!("failed to apply goal resume runtime effects: {err}"); - } - let ThreadConfigSnapshot { model, model_provider_id, @@ -691,11 +696,9 @@ pub(super) async fn handle_pending_thread_resume_request( .replay_requests_to_connection_for_thread(connection_id, conversation_id) .await; // App-server owns resume response and snapshot ordering, so wait until - // replay completes before letting core start goal continuation. - if pending.emit_thread_goal_update - && let Err(err) = conversation.continue_active_goal_if_idle().await - { - tracing::warn!("failed to continue active goal after running-thread resume: {err}"); + // replay completes before letting extensions react to the idle thread. + if pending.emit_thread_goal_update { + conversation.emit_thread_idle_lifecycle_if_idle().await; } } 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 efff8e7cd851..eab606bf6258 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -56,17 +56,7 @@ fn collect_resume_override_mismatches( } } if let Some(requested_runtime_workspace_roots) = request.runtime_workspace_roots.as_ref() { - let base_cwd = request - .cwd - .as_deref() - .map(|cwd| { - AbsolutePathBuf::resolve_path_against_base(cwd, config_snapshot.cwd.as_path()) - }) - .unwrap_or_else(|| config_snapshot.cwd.clone()); - let requested_runtime_workspace_roots = requested_runtime_workspace_roots - .iter() - .map(|path| AbsolutePathBuf::resolve_path_against_base(path, base_cwd.as_path())) - .collect::>(); + let requested_runtime_workspace_roots = requested_runtime_workspace_roots.to_vec(); if requested_runtime_workspace_roots != config_snapshot.workspace_roots { mismatch_details.push(format!( "runtime_workspace_roots requested={requested_runtime_workspace_roots:?} active={:?}", @@ -846,6 +836,7 @@ impl ThreadRequestProcessor { )); } let environment_selections = self.parse_environment_selections(environments)?; + let runtime_workspace_roots = runtime_workspace_roots.map(resolve_runtime_workspace_roots); let mut typesafe_overrides = self.build_thread_config_overrides( model, model_provider, @@ -1215,7 +1206,7 @@ impl ThreadRequestProcessor { model_provider: Option, service_tier: Option>, cwd: Option, - runtime_workspace_roots: Option>, + runtime_workspace_roots: Option>, approval_policy: Option, approvals_reviewer: Option, sandbox: Option, @@ -2483,6 +2474,7 @@ impl ThreadRequestProcessor { }; let history_cwd = thread_history.session_cwd(); + let runtime_workspace_roots = runtime_workspace_roots.map(resolve_runtime_workspace_roots); let mut typesafe_overrides = self.build_thread_config_overrides( model, model_provider, @@ -3201,6 +3193,7 @@ impl ThreadRequestProcessor { } else { Some(cli_overrides) }; + let runtime_workspace_roots = runtime_workspace_roots.map(resolve_runtime_workspace_roots); let mut typesafe_overrides = self.build_thread_config_overrides( model, model_provider, 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 8ab6cf7731a0..ce98a2e8e020 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -18,20 +18,6 @@ pub(crate) struct TurnRequestProcessor { skills_watcher: Arc, } -fn resolve_runtime_workspace_roots( - workspace_roots: Vec, - base_cwd: &AbsolutePathBuf, -) -> Vec { - let mut resolved_roots = Vec::new(); - for path in workspace_roots { - let root = AbsolutePathBuf::resolve_path_against_base(path, base_cwd.as_path()); - if !resolved_roots.iter().any(|existing| existing == &root) { - resolved_roots.push(root); - } - } - resolved_roots -} - fn map_additional_context( additional_context: Option>, ) -> BTreeMap { @@ -57,8 +43,8 @@ fn map_additional_context( struct ThreadSettingsBuildParams { method: &'static str, - cwd: Option, - runtime_workspace_roots: Option>, + cwd: Option, + runtime_workspace_roots: Option>, approval_policy: Option, approvals_reviewer: Option, sandbox_policy: Option, @@ -419,12 +405,13 @@ impl TurnRequestProcessor { let client_user_message_id = params.client_user_message_id; let additional_context = map_additional_context(params.additional_context); let turn_has_input = !mapped_items.is_empty(); + let cwd = resolve_request_cwd(params.cwd)?; let thread_settings = self .build_thread_settings_overrides( thread.as_ref(), ThreadSettingsBuildParams { method: "turn/start", - cwd: params.cwd, + cwd, runtime_workspace_roots: params.runtime_workspace_roots, approval_policy: params.approval_policy, approvals_reviewer: params.approvals_reviewer, @@ -524,7 +511,7 @@ impl TurnRequestProcessor { // `thread/settings/update` only acknowledges that the update was queued. // Clients that send dependent partial updates should wait for // `thread/settings/updated` or combine the fields in one request. - let snapshot = if permissions.is_some() || runtime_workspace_roots_request.is_some() { + let snapshot = if permissions.is_some() { Some(thread.config_snapshot().await) } else { None @@ -543,22 +530,8 @@ impl TurnRequestProcessor { || collaboration_mode.is_some() || personality.is_some(); - let runtime_workspace_roots = if let Some(workspace_roots) = - runtime_workspace_roots_request.clone() - { - let Some(snapshot) = snapshot.as_ref() else { - return Err(internal_error(format!( - "{method} runtime workspace roots missing thread snapshot" - ))); - }; - let base_cwd = cwd - .as_ref() - .map(|cwd| AbsolutePathBuf::resolve_path_against_base(cwd, snapshot.cwd.as_path())) - .unwrap_or_else(|| snapshot.cwd.clone()); - Some(resolve_runtime_workspace_roots(workspace_roots, &base_cwd)) - } else { - None - }; + let runtime_workspace_roots = + runtime_workspace_roots_request.map(resolve_runtime_workspace_roots); let approval_policy = approval_policy.map(codex_app_server_protocol::AskForApproval::to_core); let approvals_reviewer = @@ -572,16 +545,12 @@ impl TurnRequestProcessor { ))); }; let overrides = ConfigOverrides { - cwd: cwd.clone(), - workspace_roots: Some(runtime_workspace_roots_request.clone().unwrap_or_else( - || { - snapshot - .workspace_roots - .iter() - .map(AbsolutePathBuf::to_path_buf) - .collect() - }, - )), + cwd: cwd.as_ref().map(AbsolutePathBuf::to_path_buf), + workspace_roots: Some( + runtime_workspace_roots + .clone() + .unwrap_or_else(|| snapshot.workspace_roots.clone()), + ), default_permissions: Some(permissions), codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), @@ -666,12 +635,13 @@ impl TurnRequestProcessor { params: ThreadSettingsUpdateParams, ) -> Result { let (_, thread) = self.load_thread(¶ms.thread_id).await?; + let cwd = resolve_request_cwd(params.cwd)?; let thread_settings = self .build_thread_settings_overrides( thread.as_ref(), ThreadSettingsBuildParams { method: "thread/settings/update", - cwd: params.cwd, + cwd, runtime_workspace_roots: None, approval_policy: params.approval_policy, approvals_reviewer: params.approvals_reviewer, diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index 2d932f03266c..6d2b48a4c88b 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -17,6 +17,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; use std::collections::HashSet; use std::sync::Arc; +use std::sync::Mutex as StdMutex; use std::sync::Weak; use tokio::sync::Mutex; use tokio::sync::mpsc; @@ -44,8 +45,9 @@ pub(crate) struct PendingThreadResumeRequest { pub(crate) enum ThreadListenerCommand { // SendThreadResumeResponse is used to resume an already running thread by sending the thread's history to the client and atomically subscribing for new updates. SendThreadResumeResponse(Box), - // EmitThreadGoalUpdated is used to order app-server goal updates with running-thread resume responses. + // EmitThreadGoalUpdated is used to order goal updates with running-thread resume responses and goal clears. EmitThreadGoalUpdated { + turn_id: Option, goal: ThreadGoal, }, // EmitThreadGoalCleared is used to order app-server goal clears with running-thread resume responses. @@ -284,6 +286,10 @@ pub(crate) struct ConnectionCapabilities { #[derive(Clone, Default)] pub(crate) struct ThreadStateManager { state: Arc>, + // Extension event sinks are synchronous, so they need an await-free way to + // enqueue work on the active per-thread listener. + listener_commands: + Arc>>>, } impl ThreadStateManager { @@ -337,6 +343,35 @@ impl ThreadStateManager { state.threads.entry(thread_id).or_default().state.clone() } + pub(crate) fn current_listener_command_tx( + &self, + thread_id: ThreadId, + ) -> Option> { + self.listener_commands + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(&thread_id) + .cloned() + } + + pub(crate) fn register_listener_command_tx( + &self, + thread_id: ThreadId, + tx: mpsc::UnboundedSender, + ) { + self.listener_commands + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(thread_id, tx); + } + + pub(crate) fn unregister_listener_command_tx(&self, thread_id: ThreadId) { + self.listener_commands + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&thread_id); + } + pub(crate) async fn remove_thread_state(&self, thread_id: ThreadId) { let thread_state = { let mut state = self.state.lock().await; @@ -350,6 +385,7 @@ impl ThreadStateManager { }); thread_state }; + self.unregister_listener_command_tx(thread_id); if let Some(thread_state) = thread_state { let mut thread_state = thread_state.lock().await; @@ -375,6 +411,7 @@ impl ThreadStateManager { }; for (thread_id, thread_state) in thread_states { + self.unregister_listener_command_tx(thread_id); let mut thread_state = thread_state.lock().await; tracing::debug!( thread_id = %thread_id, diff --git a/codex-rs/app-server/tests/common/auth_fixtures.rs b/codex-rs/app-server/tests/common/auth_fixtures.rs index 86f0fb456ddb..cf78e788de74 100644 --- a/codex-rs/app-server/tests/common/auth_fixtures.rs +++ b/codex-rs/app-server/tests/common/auth_fixtures.rs @@ -164,6 +164,7 @@ pub fn write_chatgpt_auth( tokens: Some(tokens), last_refresh, agent_identity: None, + personal_access_token: None, }; save_auth(codex_home, &auth, cli_auth_credentials_store_mode).context("write auth.json") diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index 127c14bbb435..8233b1b2966e 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -52,6 +52,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/app-server/tests/common/test_app_server.rs b/codex-rs/app-server/tests/common/test_app_server.rs index ec4b30d92f9f..646de6ad2159 100644 --- a/codex-rs/app-server/tests/common/test_app_server.rs +++ b/codex-rs/app-server/tests/common/test_app_server.rs @@ -70,6 +70,7 @@ use codex_app_server_protocol::ProcessWriteStdinParams; use codex_app_server_protocol::RemoteControlClientsListParams; use codex_app_server_protocol::RemoteControlClientsRevokeParams; use codex_app_server_protocol::RemoteControlPairingStartParams; +use codex_app_server_protocol::RemoteControlPairingStatusParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ReviewStartParams; use codex_app_server_protocol::SendAddCreditsNudgeEmailParams; @@ -656,6 +657,16 @@ impl TestAppServer { .await } + /// Send a `remoteControl/pairing/status` JSON-RPC request. + pub async fn send_remote_control_pairing_status_request( + &mut self, + params: RemoteControlPairingStatusParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("remoteControl/pairing/status", params) + .await + } + /// Send a `remoteControl/client/list` JSON-RPC request. pub async fn send_remote_control_clients_list_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/auth.rs b/codex-rs/app-server/tests/suite/auth.rs index b1ef99cb71fd..6e88866c7d4a 100644 --- a/codex-rs/app-server/tests/suite/auth.rs +++ b/codex-rs/app-server/tests/suite/auth.rs @@ -21,6 +21,7 @@ use tokio::time::timeout; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; +use wiremock::matchers::header; use wiremock::matchers::method; use wiremock::matchers::path; @@ -160,6 +161,64 @@ async fn get_auth_status_with_api_key() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_auth_status_with_personal_access_token_omits_token() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path())?; + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/user-auth-credential/whoami")) + .and(header("Authorization", "Bearer at-test-token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "email": "user@example.com", + "chatgpt_user_id": "user-123", + "chatgpt_account_id": "account-123", + "chatgpt_plan_type": "pro", + "chatgpt_account_is_fedramp": false, + }))) + .expect(1..) + .mount(&server) + .await; + + let authapi_base_url = server.uri(); + let mut mcp = TestAppServer::new_with_env( + codex_home.path(), + &[ + ("OPENAI_API_KEY", None), + ("CODEX_ACCESS_TOKEN", Some("at-test-token")), + ("CODEX_AUTHAPI_BASE_URL", Some(authapi_base_url.as_str())), + ], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_get_auth_status_request(GetAuthStatusParams { + include_token: Some(true), + refresh_token: Some(false), + }) + .await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let status: GetAuthStatusResponse = to_response(resp)?; + assert_eq!( + status, + GetAuthStatusResponse { + auth_method: Some(AuthMode::PersonalAccessToken), + auth_token: None, + requires_openai_auth: Some(true), + } + ); + + server.verify().await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_auth_status_with_api_key_when_auth_not_required() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/app-server/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index 2177106f8f77..c9615d3a2761 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -118,6 +118,7 @@ async fn list_apps_returns_empty_with_api_key_auth() -> Result<()> { tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }, AuthCredentialsStoreMode::File, )?; diff --git a/codex-rs/app-server/tests/suite/v2/hooks_list.rs b/codex-rs/app-server/tests/suite/v2/hooks_list.rs index b7270f317edb..1fd1340a6c47 100644 --- a/codex-rs/app-server/tests/suite/v2/hooks_list.rs +++ b/codex-rs/app-server/tests/suite/v2/hooks_list.rs @@ -264,6 +264,84 @@ async fn hooks_list_shows_discovered_plugin_hook() -> Result<()> { Ok(()) } +#[tokio::test] +async fn hooks_list_warms_plugin_capabilities_for_thread_start() -> Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + write_plugin_hook_config( + codex_home.path(), + r#"{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "echo plugin hook" + } + ] + } + ] + } +}"#, + )?; + let plugin_mcp_path = codex_home + .path() + .join("plugins/cache/test/demo/local/.mcp.json"); + std::fs::write( + &plugin_mcp_path, + r#"{ + "mcpServers": { + "plugin-server": { + "url": "http://127.0.0.1:1/mcp" + } + } +}"#, + )?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let hooks_list_id = mcp + .send_hooks_list_request(HooksListParams { + cwds: vec![cwd.path().to_path_buf()], + }) + .await?; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(hooks_list_id)), + ) + .await??; + + std::fs::remove_file(plugin_mcp_path)?; + + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + let _: ThreadStartResponse = to_response( + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + ) + .await??, + )?; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_matching_notification("plugin MCP server starting", |notification| { + notification.method == "mcpServer/startupStatus/updated" + && notification + .params + .as_ref() + .and_then(|params| params.get("name")) + .and_then(serde_json::Value::as_str) + == Some("plugin-server") + }), + ) + .await??; + + Ok(()) +} + #[tokio::test] async fn hooks_list_shows_plugin_hook_load_warnings() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index ae622db8a87d..49ad3c418e90 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -38,7 +38,6 @@ use wiremock::matchers::query_param; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567"; -const STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE: &str = ".tmp/app-server-remote-plugin-sync-v1"; const TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS: &str = "CODEX_TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS"; const ALTERNATE_MARKETPLACE_RELATIVE_PATH: &str = ".claude-plugin/marketplace.json"; @@ -1460,94 +1459,6 @@ enabled = true Ok(()) } -#[tokio::test] -async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> { - let codex_home = TempDir::new()?; - let server = MockServer::start().await; - write_plugin_sync_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, - )?; - write_openai_curated_marketplace(codex_home.path(), &["linear"])?; - - Mock::given(method("GET")) - .and(path("/backend-api/plugins/list")) - .and(header("authorization", "Bearer chatgpt-token")) - .and(header("chatgpt-account-id", "account-123")) - .respond_with(ResponseTemplate::new(200).set_body_string( - r#"[ - {"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} -]"#, - )) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/backend-api/plugins/featured")) - .and(query_param("platform", "codex")) - .and(header("authorization", "Bearer chatgpt-token")) - .and(header("chatgpt-account-id", "account-123")) - .respond_with(ResponseTemplate::new(200).set_body_string(r#"["linear@openai-curated"]"#)) - .mount(&server) - .await; - - let marker_path = codex_home - .path() - .join(STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE); - - { - let mut mcp = TestAppServer::new_with_plugin_startup_tasks(codex_home.path()).await?; - timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - - wait_for_path_exists(&marker_path).await?; - wait_for_remote_plugin_request_count(&server, "/plugins/list", /*expected_count*/ 1) - .await?; - let request_id = mcp - .send_plugin_list_request(PluginListParams { - cwds: None, - marketplace_kinds: None, - }) - .await?; - let response: JSONRPCResponse = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(request_id)), - ) - .await??; - let response: PluginListResponse = to_response(response)?; - let curated_marketplace = response - .marketplaces - .into_iter() - .find(|marketplace| marketplace.name == "openai-curated") - .expect("expected openai-curated marketplace entry"); - assert_eq!( - curated_marketplace - .plugins - .into_iter() - .map(|plugin| (plugin.id, plugin.installed, plugin.enabled)) - .collect::>(), - vec![("linear@openai-curated".to_string(), true, true)] - ); - wait_for_remote_plugin_request_count(&server, "/plugins/list", /*expected_count*/ 1) - .await?; - } - - let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; - assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); - - { - let mut mcp = TestAppServer::new_with_plugin_startup_tasks(codex_home.path()).await?; - timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - } - - tokio::time::sleep(Duration::from_millis(250)).await; - wait_for_remote_plugin_request_count(&server, "/plugins/list", /*expected_count*/ 1).await?; - Ok(()) -} - #[tokio::test] async fn app_server_startup_sync_downloads_remote_installed_plugin_bundles() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index f78cd2a0881e..e675a6172ae1 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -17,6 +17,8 @@ use axum::http::Uri; use axum::http::header::AUTHORIZATION; use axum::routing::get; use codex_app_server_protocol::AppInfo; +use codex_app_server_protocol::AppTemplateSummary; +use codex_app_server_protocol::AppTemplateUnavailableReason; use codex_app_server_protocol::HookEventName; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; @@ -122,7 +124,7 @@ async fn plugin_read_rejects_multiple_read_sources() -> Result<()> { } #[tokio::test] -async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_is_disabled() -> Result<()> { +async fn plugin_read_returns_remote_mcp_servers_when_uninstalled() -> Result<()> { let codex_home = TempDir::new()?; let server = MockServer::start().await; std::fs::write( @@ -148,22 +150,31 @@ plugins = true let detail_body = r#"{ "id": "plugins~Plugin_00000000000000000000000000000000", - "name": "linear", + "name": "example-plugin", "scope": "GLOBAL", "installation_policy": "AVAILABLE", "authentication_policy": "ON_USE", "release": { - "display_name": "Linear", - "description": "Track work in Linear", + "version": "1.2.1", + "display_name": "Example Plugin", + "description": "Example plugin", "app_ids": [], "keywords": [], "interface": { - "short_description": "Plan and track work", + "short_description": "Example plugin", "capabilities": [], - "default_prompt": "Use the legacy Linear prompt", + "default_prompt": "Use the legacy example prompt", "default_prompts": [] }, - "skills": [] + "skills": [], + "mcp_servers": [ + { + "key": "example-server", + "metadata": { + "command": "example-mcp" + } + } + ] } }"#; let installed_body = r#"{ @@ -211,12 +222,15 @@ plugins = true let response: PluginReadResponse = to_response(response)?; assert_eq!(response.plugin.marketplace_name, "openai-curated-remote"); - assert_eq!(response.plugin.summary.id, "linear@openai-curated-remote"); + assert_eq!( + response.plugin.summary.id, + "example-plugin@openai-curated-remote" + ); assert_eq!( response.plugin.summary.remote_plugin_id.as_deref(), Some("plugins~Plugin_00000000000000000000000000000000") ); - assert_eq!(response.plugin.summary.name, "linear"); + assert_eq!(response.plugin.summary.name, "example-plugin"); assert_eq!(response.plugin.summary.source, PluginSource::Remote); assert_eq!(response.plugin.summary.share_context, None); assert_eq!( @@ -226,7 +240,11 @@ plugins = true .interface .as_ref() .and_then(|interface| interface.default_prompt.clone()), - Some(vec!["Use the legacy Linear prompt".to_string()]) + Some(vec!["Use the legacy example prompt".to_string()]) + ); + assert_eq!( + response.plugin.mcp_servers, + vec!["example-server".to_string()] ); Ok(()) } @@ -411,6 +429,28 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> "display_name": "Linear", "description": "Track work in Linear", "app_ids": [], + "app_templates": [ + { + "template_id": "templated_apps_GitHubEnterprise", + "name": "GitHub Enterprise", + "description": "Connect GitHub Enterprise", + "canonical_connector_id": "github_enterprise", + "logo_url": "https://example.com/ghe-light.png", + "logo_url_dark": "https://example.com/ghe-dark.png", + "materialized_app_ids": ["asdk_app_ghe"], + "reason": null + }, + { + "template_id": "templated_apps_Databricks", + "name": "Databricks", + "description": null, + "canonical_connector_id": null, + "logo_url": null, + "logo_url_dark": null, + "materialized_app_ids": [], + "reason": "NOT_CONFIGURED_FOR_WORKSPACE" + } + ], "keywords": ["issue-tracking", "project management"], "interface": { "short_description": "Plan and track work", @@ -548,6 +588,31 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> assert_eq!(response.plugin.skills[0].path, None); assert_eq!(response.plugin.skills[0].enabled, false); assert_eq!(response.plugin.apps.len(), 0); + assert_eq!( + response.plugin.app_templates, + vec![ + AppTemplateSummary { + template_id: "templated_apps_GitHubEnterprise".to_string(), + name: "GitHub Enterprise".to_string(), + description: Some("Connect GitHub Enterprise".to_string()), + canonical_connector_id: Some("github_enterprise".to_string()), + logo_url: Some("https://example.com/ghe-light.png".to_string()), + logo_url_dark: Some("https://example.com/ghe-dark.png".to_string()), + materialized_app_ids: vec!["asdk_app_ghe".to_string()], + reason: None, + }, + AppTemplateSummary { + template_id: "templated_apps_Databricks".to_string(), + name: "Databricks".to_string(), + description: None, + canonical_connector_id: None, + logo_url: None, + logo_url_dark: None, + materialized_app_ids: Vec::new(), + reason: Some(AppTemplateUnavailableReason::NotConfiguredForWorkspace), + }, + ] + ); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/remote_control.rs b/codex-rs/app-server/tests/suite/v2/remote_control.rs index b5384d3bad9b..0229b0e5af24 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_control.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_control.rs @@ -19,12 +19,15 @@ use codex_app_server_protocol::RemoteControlDisableResponse; use codex_app_server_protocol::RemoteControlEnableResponse; use codex_app_server_protocol::RemoteControlPairingStartParams; use codex_app_server_protocol::RemoteControlPairingStartResponse; +use codex_app_server_protocol::RemoteControlPairingStatusParams; +use codex_app_server_protocol::RemoteControlPairingStatusResponse; use codex_app_server_protocol::RemoteControlStatusReadResponse; use codex_app_server_protocol::RequestId; use codex_config::types::AuthCredentialsStoreMode; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::io::AsyncBufReadExt; +use tokio::io::AsyncReadExt; use tokio::io::AsyncWriteExt; use tokio::io::BufReader; use tokio::net::TcpListener; @@ -181,6 +184,80 @@ async fn remote_control_pairing_start_returns_pairing_artifacts() -> Result<()> assert_eq!(response.result.get("serverId"), None); let received: RemoteControlPairingStartResponse = to_response(response)?; + assert_eq!( + received, + RemoteControlPairingStartResponse { + pairing_code: "pairing-code".to_string(), + manual_pairing_code: Some("ABCD-EFGH".to_string()), + environment_id: "environment-id".to_string(), + expires_at: 33_336_362_096, + } + ); + + let request_id = mcp + .send_remote_control_pairing_status_request(RemoteControlPairingStatusParams { + pairing_code: Some("pairing-code".to_string()), + manual_pairing_code: None, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!(response.result.get("serverId"), None); + let received: RemoteControlPairingStatusResponse = to_response(response)?; + + assert_eq!( + received, + RemoteControlPairingStatusResponse { claimed: true } + ); + + let request_id = mcp + .send_remote_control_pairing_status_request(RemoteControlPairingStatusParams { + pairing_code: None, + manual_pairing_code: Some("ABCD-EFGH".to_string()), + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!(response.result.get("serverId"), None); + let received: RemoteControlPairingStatusResponse = to_response(response)?; + + assert_eq!( + received, + RemoteControlPairingStatusResponse { claimed: true } + ); + Ok(()) +} + +#[tokio::test] +async fn remote_control_pairing_start_returns_pairing_artifacts_while_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let mut backend = PairingRemoteControlBackend::start(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_remote_control_pairing_start_request(RemoteControlPairingStartParams { + manual_code: true, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!( + timeout(DEFAULT_TIMEOUT, backend.wait_for_enroll_request()).await??, + "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" + ); + assert_eq!(response.result.get("serverId"), None); + let received: RemoteControlPairingStartResponse = to_response(response)?; + assert_eq!( received, RemoteControlPairingStartResponse { @@ -376,8 +453,12 @@ impl PairingRemoteControlBackend { ) .await?; - let _websocket_request = read_http_request(&listener).await?; - let pair_http_request = read_http_request(&listener).await?; + let request_after_enroll = read_http_request(&listener).await?; + let pair_http_request = if request_after_enroll.request_line.starts_with("GET ") { + read_http_request(&listener).await? + } else { + request_after_enroll + }; respond_with_json( pair_http_request.reader.into_inner(), serde_json::json!({ @@ -389,6 +470,25 @@ impl PairingRemoteControlBackend { }), ) .await?; + for expected_body in [ + serde_json::json!({ "pairing_code": "pairing-code" }), + serde_json::json!({ "manual_pairing_code": "ABCD-EFGH" }), + ] { + let status_http_request = read_http_request(&listener).await?; + assert_eq!( + status_http_request.request_line, + "POST /backend-api/wham/remote/control/server/pair/status HTTP/1.1" + ); + assert_eq!( + serde_json::from_str::(&status_http_request.body)?, + expected_body + ); + respond_with_json( + status_http_request.reader.into_inner(), + serde_json::json!({ "claimed": true }), + ) + .await?; + } std::future::pending::<()>().await; Ok::<(), anyhow::Error>(()) } @@ -436,6 +536,7 @@ impl Drop for ClientManagementRemoteControlBackend { struct HttpRequest { request_line: String, + body: String, reader: BufReader, } @@ -468,16 +569,29 @@ async fn read_http_request(listener: &TcpListener) -> Result { let mut request_line = String::new(); reader.read_line(&mut request_line).await?; + let mut content_length = 0; loop { let mut line = String::new(); reader.read_line(&mut line).await?; if line == "\r\n" { break; } + if let Some(value) = line + .trim_end() + .strip_prefix("content-length:") + .or_else(|| line.trim_end().strip_prefix("Content-Length:")) + { + content_length = value.trim().parse::()?; + } + } + let mut body = vec![0; content_length]; + if content_length > 0 { + reader.read_exact(&mut body).await?; } Ok(HttpRequest { request_line: request_line.trim_end().to_string(), + body: String::from_utf8(body)?, reader, }) } diff --git a/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs b/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs index c5b70b2334bc..ea3cd8dada08 100644 --- a/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs +++ b/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs @@ -15,6 +15,7 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnModerationMetadataNotification; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput; @@ -309,6 +310,87 @@ async fn model_verification_emits_typed_notification_and_warning_v2() -> Result< Ok(()) } +#[tokio::test] +async fn turn_moderation_metadata_emits_typed_notification_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + serde_json::json!({ + "type": "response.metadata", + "sequence_number": 1, + "response_id": "resp-1", + "metadata": { + "openai_chatgpt_moderation_metadata": { + "presentation": "inline" + } + } + }), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let response = responses::sse_response(body); + let _response_mock = responses::mount_response_once(&server, response).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some(REQUESTED_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 turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + client_user_message_id: None, + input: vec![UserInput::Text { + text: "trigger moderation metadata".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let turn_start: TurnStartResponse = to_response(turn_resp)?; + + let notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/moderationMetadata"), + ) + .await??; + let metadata: TurnModerationMetadataNotification = + serde_json::from_value(notification.params.ok_or_else(|| { + anyhow::anyhow!("turn/moderationMetadata notifications must include params") + })?)?; + assert_eq!( + metadata, + TurnModerationMetadataNotification { + thread_id: thread.id, + turn_id: turn_start.turn.id, + metadata: serde_json::json!({"presentation": "inline"}), + } + ); + + Ok(()) +} + async fn collect_turn_notifications_and_validate_no_warning_item( mcp: &mut TestAppServer, ) -> Result { 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 4e052041ad34..582177ee7da7 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -284,7 +284,7 @@ async fn thread_resume_running_thread_uses_cached_instruction_sources() -> Resul instruction_sources, .. } = to_response::(start_resp)?; - let project_agents = AbsolutePathBuf::try_from(std::fs::canonicalize(project_agents)?)?; + let project_agents = AbsolutePathBuf::try_from(project_agents)?; assert_eq!(instruction_sources, vec![project_agents.clone()]); let turn_id = mcp @@ -366,7 +366,10 @@ async fn turn_start_updates_runtime_workspace_roots_for_loaded_thread() -> Resul text: "Hello".to_string(), text_elements: Vec::new(), }], - runtime_workspace_roots: Some(vec![extra_root.clone(), extra_root.join(".")]), + runtime_workspace_roots: Some(vec![ + AbsolutePathBuf::from_absolute_path(&extra_root)?, + AbsolutePathBuf::from_absolute_path(extra_root.join("."))?, + ]), ..Default::default() }) .await?; 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 208af14299cf..5954342b9cef 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -197,15 +197,15 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { } #[tokio::test] -async fn thread_start_resolves_runtime_workspace_roots_against_cwd() -> Result<()> { +async fn thread_start_accepts_absolute_runtime_workspace_roots() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; let cwd_tmp = TempDir::new()?; let cwd = cwd_tmp.path().to_path_buf(); - let relative_root = PathBuf::from("extra-root"); - std::fs::create_dir_all(cwd.join(&relative_root))?; + let extra_root = cwd.join("extra-root"); + std::fs::create_dir_all(&extra_root)?; let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -213,7 +213,7 @@ async fn thread_start_resolves_runtime_workspace_roots_against_cwd() -> Result<( let req_id = mcp .send_thread_start_request(ThreadStartParams { cwd: Some(cwd.to_string_lossy().to_string()), - runtime_workspace_roots: Some(vec![relative_root.clone()]), + runtime_workspace_roots: Some(vec![extra_root.abs()]), ..Default::default() }) .await?; @@ -230,10 +230,7 @@ async fn thread_start_resolves_runtime_workspace_roots_against_cwd() -> Result<( } = to_response::(resp)?; assert_eq!(response_cwd, cwd.abs()); - assert_eq!( - runtime_workspace_roots, - vec![cwd_tmp.path().join(relative_root).abs()] - ); + assert_eq!(runtime_workspace_roots, vec![extra_root.abs()]); Ok(()) } @@ -348,7 +345,7 @@ async fn thread_start_response_includes_loaded_instruction_sources() -> Result<( .collect::>(); let expected_instruction_sources = vec![ std::fs::canonicalize(global_agents_path)?, - std::fs::canonicalize(project_agents_path)?, + project_agents_path, ] .into_iter() .map(normalize_path_for_comparison) 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 9f6a027a75eb..aed3c58697de 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -9,6 +9,7 @@ use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; +use app_test_support::create_request_user_input_sse_response; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; @@ -78,6 +79,7 @@ use std::collections::HashMap; use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; +use wiremock::ResponseTemplate; use super::analytics::mount_analytics_capture; use super::analytics::wait_for_analytics_event; @@ -819,8 +821,20 @@ async fn thread_start_omits_empty_instruction_overrides_from_model_request() -> #[tokio::test] async fn turn_start_tracks_turn_event_analytics() -> Result<()> { - let responses = vec![create_final_assistant_message_sse_response("Done")?]; - let server = create_mock_responses_server_sequence_unchecked(responses).await; + let server = responses::start_mock_server().await; + let response_mock = responses::mount_response_sequence( + &server, + vec![ + ResponseTemplate::new(500).set_body_json(json!({ + "error": { + "type": "server_error", + "message": "synthetic retryable error" + } + })), + responses::sse_response(create_final_assistant_message_sse_response("Done")?), + ], + ) + .await; let codex_home = TempDir::new()?; write_mock_responses_config_toml_with_chatgpt_base_url( @@ -828,6 +842,10 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { &server.uri(), &server.uri(), )?; + let config_path = codex_home.path().join("config.toml"); + let config = std::fs::read_to_string(&config_path)? + .replace("stream_max_retries = 0", "stream_max_retries = 1"); + std::fs::write(config_path, config)?; mount_analytics_capture(&server, codex_home.path()).await?; let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; @@ -908,6 +926,136 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { assert_eq!(event["event_params"]["output_tokens"], 0); assert_eq!(event["event_params"]["reasoning_output_tokens"], 0); assert_eq!(event["event_params"]["total_tokens"], 0); + let params = &event["event_params"]; + let timings_are_numbers = [ + "before_first_sampling_ms", + "sampling_ms", + "between_sampling_overhead_ms", + "tool_blocking_ms", + "after_last_sampling_ms", + ] + .into_iter() + .all(|field| params[field].as_u64().is_some()); + assert_eq!( + json!({ + "timingsAreNumbers": timings_are_numbers, + "toolBlockingMs": params["tool_blocking_ms"], + "samplingRequestCount": params["sampling_request_count"], + "samplingRetryCount": params["sampling_retry_count"], + "responseRequestCount": response_mock.requests().len(), + }), + json!({ + "timingsAreNumbers": true, + "toolBlockingMs": 0, + "samplingRequestCount": 2, + "samplingRetryCount": 1, + "responseRequestCount": 2, + }) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn turn_profile_tracks_blocking_tool_and_follow_up_sampling() -> Result<()> { + let responses = vec![ + create_request_user_input_sse_response("call1")?, + create_final_assistant_message_sse_response("Done")?, + ]; + let server = create_mock_responses_server_sequence(responses).await; + + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; + mount_analytics_capture(&server, codex_home.path()).await?; + + let mut mcp = TestAppServer::new_without_managed_config(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 turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + client_user_message_id: None, + input: vec![V2UserInput::Text { + text: "ask something".to_string(), + text_elements: Vec::new(), + }], + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Plan, + settings: Settings { + model: "mock-model".to_string(), + reasoning_effort: Some(ReasoningEffort::Medium), + developer_instructions: None, + }, + }), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + + let server_req = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::ToolRequestUserInput { request_id, .. } = server_req else { + panic!("expected ToolRequestUserInput request, got: {server_req:?}"); + }; + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + mcp.send_response( + request_id, + json!({ + "answers": { + "confirm_path": { "answers": ["yes"] } + } + }), + ) + .await?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; + let params = &event["event_params"]; + assert_eq!( + json!({ + "toolBlockingIsPositive": params["tool_blocking_ms"] + .as_u64() + .is_some_and(|duration| duration > 0), + "samplingRequestCount": params["sampling_request_count"], + "samplingRetryCount": params["sampling_retry_count"], + "status": params["status"], + }), + json!({ + "toolBlockingIsPositive": true, + "samplingRequestCount": 2, + "samplingRetryCount": 0, + "status": "completed", + }) + ); Ok(()) } @@ -2301,6 +2449,8 @@ async fn turn_start_permission_profile_rebinds_runtime_workspace_roots_between_t std::fs::create_dir(&new_root)?; let old_root_text = old_root.to_string_lossy().into_owned(); let new_root_text = new_root.to_string_lossy().into_owned(); + let old_root = codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(old_root)?; + let new_root = codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(new_root)?; let server = responses::start_mock_server().await; let response_mock = responses::mount_sse_sequence( diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 24d277659096..52275eb4be52 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -4,6 +4,7 @@ use crate::types::ConfigBundleResponse; use crate::types::PaginatedListTaskListItem; use crate::types::RateLimitReachedKind as BackendRateLimitReachedKind; use crate::types::RateLimitStatusPayload; +use crate::types::TokenUsageProfile; use crate::types::TurnAttemptsSiblingTurnsResponse; use anyhow::Result; use codex_api::SharedAuthProvider; @@ -313,6 +314,20 @@ impl Client { self.decode_json(&url, &ct, &body) } + pub async fn get_token_usage_profile(&self) -> Result { + let url = self.token_usage_profile_url(); + let req = self.http.get(&url).headers(self.headers()); + let (body, ct) = self.exec_request(req, "GET", &url).await?; + self.decode_json(&url, &ct, &body) + } + + fn token_usage_profile_url(&self) -> String { + match self.path_style { + PathStyle::CodexApi => format!("{}/api/codex/profiles/me", self.base_url), + PathStyle::ChatGptApi => format!("{}/wham/profiles/me", self.base_url), + } + } + pub async fn send_add_credits_nudge_email( &self, credit_type: AddCreditsNudgeCreditType, @@ -883,29 +898,13 @@ mod tests { #[test] fn add_credits_nudge_email_uses_expected_paths_and_bodies() { - let codex_client = Client { - base_url: "https://example.test".to_string(), - http: reqwest::Client::new(), - auth_provider: codex_model_provider::unauthenticated_auth_provider(), - user_agent: None, - chatgpt_account_id: None, - chatgpt_account_is_fedramp: false, - path_style: PathStyle::CodexApi, - }; + let codex_client = test_client("https://example.test", PathStyle::CodexApi); assert_eq!( codex_client.send_add_credits_nudge_email_url(), "https://example.test/api/codex/accounts/send_add_credits_nudge_email" ); - let chatgpt_client = Client { - base_url: "https://chatgpt.com/backend-api".to_string(), - http: reqwest::Client::new(), - auth_provider: codex_model_provider::unauthenticated_auth_provider(), - user_agent: None, - chatgpt_account_id: None, - chatgpt_account_is_fedramp: false, - path_style: PathStyle::ChatGptApi, - }; + let chatgpt_client = test_client("https://chatgpt.com/backend-api", PathStyle::ChatGptApi); assert_eq!( chatgpt_client.send_add_credits_nudge_email_url(), "https://chatgpt.com/backend-api/wham/accounts/send_add_credits_nudge_email" @@ -926,4 +925,31 @@ mod tests { serde_json::json!({ "credit_type": "usage_limit" }) ); } + + #[test] + fn token_usage_profile_uses_expected_paths() { + let codex_client = test_client("https://example.test", PathStyle::CodexApi); + assert_eq!( + codex_client.token_usage_profile_url(), + "https://example.test/api/codex/profiles/me" + ); + + let chatgpt_client = test_client("https://chatgpt.com/backend-api", PathStyle::ChatGptApi); + assert_eq!( + chatgpt_client.token_usage_profile_url(), + "https://chatgpt.com/backend-api/wham/profiles/me" + ); + } + + fn test_client(base_url: &str, path_style: PathStyle) -> Client { + Client { + base_url: base_url.to_string(), + http: reqwest::Client::new(), + auth_provider: codex_model_provider::unauthenticated_auth_provider(), + user_agent: None, + chatgpt_account_id: None, + chatgpt_account_is_fedramp: false, + path_style, + } + } } diff --git a/codex-rs/backend-client/src/lib.rs b/codex-rs/backend-client/src/lib.rs index dfd7e816f662..e50fd8db40cb 100644 --- a/codex-rs/backend-client/src/lib.rs +++ b/codex-rs/backend-client/src/lib.rs @@ -14,4 +14,7 @@ pub use types::DeliveredRequirementsToml; pub use types::DeliveredTomlFragment; pub use types::PaginatedListTaskListItem; pub use types::TaskListItem; +pub use types::TokenUsageProfile; +pub use types::TokenUsageProfileDailyBucket; +pub use types::TokenUsageProfileStats; pub use types::TurnAttemptsSiblingTurnsResponse; diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs index 06989241fa44..3ccacbc8c948 100644 --- a/codex-rs/backend-client/src/types.rs +++ b/codex-rs/backend-client/src/types.rs @@ -409,6 +409,27 @@ pub struct TurnAttemptsSiblingTurnsResponse { pub sibling_turns: Vec>, } +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +pub struct TokenUsageProfile { + pub stats: TokenUsageProfileStats, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +pub struct TokenUsageProfileStats { + pub lifetime_tokens: Option, + pub peak_daily_tokens: Option, + pub longest_running_turn_sec: Option, + pub current_streak_days: Option, + pub longest_streak_days: Option, + pub daily_usage_buckets: Option>, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +pub struct TokenUsageProfileDailyBucket { + pub start_date: String, + pub tokens: i64, +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/chatgpt/src/workspace_settings.rs b/codex-rs/chatgpt/src/workspace_settings.rs index a17721551820..d5875adcf827 100644 --- a/codex-rs/chatgpt/src/workspace_settings.rs +++ b/codex-rs/chatgpt/src/workspace_settings.rs @@ -3,7 +3,6 @@ use std::sync::RwLock; use std::time::Duration; use std::time::Instant; -use anyhow::Context; use codex_core::config::Config; use codex_login::CodexAuth; use serde::Deserialize; @@ -93,20 +92,17 @@ pub async fn codex_plugins_enabled_for_workspace( return Ok(true); } - let token_data = auth - .get_token_data() - .context("ChatGPT token data is not available")?; - if !token_data.id_token.is_workspace_account() { + if !auth.is_workspace_account() { return Ok(true); } - let Some(account_id) = token_data.account_id.as_deref().filter(|id| !id.is_empty()) else { + let Some(account_id) = auth.get_account_id().filter(|id| !id.is_empty()) else { return Ok(true); }; let cache_key = WorkspaceSettingsCacheKey { chatgpt_base_url: config.chatgpt_base_url.clone(), - account_id: account_id.to_string(), + account_id: account_id.clone(), }; if let Some(cache) = cache && let Some(enabled) = cache.get_codex_plugins_enabled(&cache_key) @@ -114,7 +110,7 @@ pub async fn codex_plugins_enabled_for_workspace( return Ok(enabled); } - let encoded_account_id = encode_path_segment(account_id); + let encoded_account_id = encode_path_segment(&account_id); let settings: WorkspaceSettingsResponse = chatgpt_get_request_with_timeout( config, format!("/accounts/{encoded_account_id}/settings"), diff --git a/codex-rs/cli/src/desktop_app/windows.rs b/codex-rs/cli/src/desktop_app/windows.rs index 932ca00cf2b7..717c54dda48a 100644 --- a/codex-rs/cli/src/desktop_app/windows.rs +++ b/codex-rs/cli/src/desktop_app/windows.rs @@ -11,13 +11,11 @@ pub async fn run_windows_app_open_or_install( workspace: PathBuf, download_url_override: Option, ) -> anyhow::Result<()> { - if let Some(app_id) = find_codex_app_id().await? { - eprintln!("Opening Codex Desktop..."); - open_installed_codex_app(&app_id).await?; - eprintln!( - "In Codex Desktop, open workspace {workspace}.", - workspace = display_workspace_path(&workspace) - ); + let workspace_path = workspace.display().to_string(); + let display_workspace = display_workspace_path(&workspace); + if codex_app_is_installed().await? { + eprintln!("Opening Codex Desktop workspace {display_workspace}..."); + open_url(&codex_new_thread_url(&workspace_path)).await?; return Ok(()); } @@ -28,14 +26,11 @@ pub async fn run_windows_app_open_or_install( if open_url(download_url).await.is_err() && download_url_override.is_none() { open_url(CODEX_MICROSOFT_STORE_WEB_URL).await?; } - eprintln!( - "After installing Codex Desktop, open workspace {workspace}.", - workspace = display_workspace_path(&workspace) - ); + eprintln!("After installing Codex Desktop, open workspace {display_workspace}."); Ok(()) } -async fn find_codex_app_id() -> anyhow::Result> { +async fn codex_app_is_installed() -> anyhow::Result { let output = Command::new("powershell.exe") .arg("-NoProfile") .arg("-Command") @@ -45,20 +40,10 @@ async fn find_codex_app_id() -> anyhow::Result> { .context("failed to invoke `powershell.exe`")?; if !output.status.success() { - return Ok(None); + return Ok(false); } - let app_id = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if app_id.is_empty() { - Ok(None) - } else { - Ok(Some(app_id)) - } -} - -async fn open_installed_codex_app(app_id: &str) -> anyhow::Result<()> { - let target = format!("shell:AppsFolder\\{app_id}"); - open_shell_target(&target).await + Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty()) } async fn open_url(url: &str) -> anyhow::Result<()> { @@ -78,15 +63,11 @@ async fn open_url(url: &str) -> anyhow::Result<()> { } } -async fn open_shell_target(target: &str) -> anyhow::Result<()> { - // Explorer can successfully hand off shell targets and still return exit code 1. - let _status = Command::new("explorer.exe") - .arg(target) - .status() - .await - .with_context(|| format!("failed to open {target}"))?; - - Ok(()) +fn codex_new_thread_url(workspace: &str) -> String { + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + serializer.append_pair("path", workspace); + let query = serializer.finish(); + format!("codex://threads/new?{query}") } fn display_workspace_path(workspace: &Path) -> String { @@ -102,6 +83,7 @@ fn display_workspace_path(workspace: &Path) -> String { #[cfg(test)] mod tests { + use super::codex_new_thread_url; use super::display_workspace_path; use pretty_assertions::assert_eq; use std::path::Path; @@ -129,4 +111,20 @@ mod tests { r"C:\Users\fcoury\code\codex" ); } + + #[test] + fn codex_new_thread_url_encodes_windows_workspace_path() { + assert_eq!( + codex_new_thread_url(r"C:\Users\akuma\repos\koba"), + r"codex://threads/new?path=C%3A%5CUsers%5Cakuma%5Crepos%5Ckoba" + ); + } + + #[test] + fn codex_new_thread_url_preserves_verbatim_workspace_path() { + assert_eq!( + codex_new_thread_url(r"\\?\C:\Users\akuma\repos\koba"), + r"codex://threads/new?path=%5C%5C%3F%5CC%3A%5CUsers%5Cakuma%5Crepos%5Ckoba" + ); + } } diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index 2e53d5d51f15..dde6fad4702e 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -1308,6 +1308,7 @@ fn stored_auth_mode(auth: &codex_login::AuthDotJson) -> &'static str { codex_app_server_protocol::AuthMode::Chatgpt => "chatgpt", codex_app_server_protocol::AuthMode::ChatgptAuthTokens => "chatgpt_auth_tokens", codex_app_server_protocol::AuthMode::AgentIdentity => "agent_identity", + codex_app_server_protocol::AuthMode::PersonalAccessToken => "personal_access_token", } } @@ -1317,6 +1318,8 @@ fn stored_auth_mode_value(auth: &AuthDotJson) -> codex_app_server_protocol::Auth } if auth.openai_api_key.is_some() { codex_app_server_protocol::AuthMode::ApiKey + } else if auth.personal_access_token.is_some() { + codex_app_server_protocol::AuthMode::PersonalAccessToken } else { codex_app_server_protocol::AuthMode::Chatgpt } @@ -1380,6 +1383,15 @@ fn stored_auth_issues( issues.push("agent identity auth is missing an agent identity token"); } } + codex_app_server_protocol::AuthMode::PersonalAccessToken => { + if auth + .personal_access_token + .as_deref() + .is_none_or(|token| token.trim().is_empty()) + { + issues.push("personal access token auth is missing a personal access token"); + } + } } issues } @@ -2408,6 +2420,7 @@ fn auth_mode_name(auth: &CodexAuth) -> &'static str { codex_app_server_protocol::AuthMode::Chatgpt => "chatgpt", codex_app_server_protocol::AuthMode::ChatgptAuthTokens => "chatgpt_auth_tokens", codex_app_server_protocol::AuthMode::AgentIdentity => "agent_identity", + codex_app_server_protocol::AuthMode::PersonalAccessToken => "personal_access_token", } } @@ -2541,7 +2554,8 @@ fn provider_auth_reachability_mode_from_auth( Some( codex_app_server_protocol::AuthMode::Chatgpt | codex_app_server_protocol::AuthMode::ChatgptAuthTokens - | codex_app_server_protocol::AuthMode::AgentIdentity, + | codex_app_server_protocol::AuthMode::AgentIdentity + | codex_app_server_protocol::AuthMode::PersonalAccessToken, ) | None => ProviderAuthReachabilityMode::Chatgpt, } @@ -3401,6 +3415,7 @@ mod tests { tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }; assert_eq!( @@ -3418,6 +3433,7 @@ mod tests { tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }; assert_eq!( @@ -3429,6 +3445,28 @@ mod tests { ); } + #[test] + fn stored_auth_validation_handles_personal_access_token() { + let mut auth = AuthDotJson { + auth_mode: None, + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: None, + personal_access_token: Some("at-test".to_string()), + }; + + assert_eq!(stored_auth_mode(&auth), "personal_access_token"); + assert!(stored_auth_issues(&auth, |_| false).is_empty()); + + auth.auth_mode = Some(codex_app_server_protocol::AuthMode::PersonalAccessToken); + auth.personal_access_token = None; + assert_eq!( + stored_auth_issues(&auth, |_| false), + vec!["personal access token auth is missing a personal access token"] + ); + } + #[test] fn provider_reachability_mode_uses_api_key_auth() { let api_key_auth = AuthDotJson { @@ -3437,6 +3475,7 @@ mod tests { tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }; assert_eq!( diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index 6fdc62fdb12c..b3fc1bd544e4 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -391,6 +391,10 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { eprintln!("Logged in using access token"); std::process::exit(0); } + AuthMode::PersonalAccessToken => { + eprintln!("Logged in using personal access token"); + std::process::exit(0); + } }, Ok(None) => { eprintln!("Not logged in"); diff --git a/codex-rs/cli/src/marketplace_cmd.rs b/codex-rs/cli/src/marketplace_cmd.rs index 95dba06f17be..fc4d9eef6206 100644 --- a/codex-rs/cli/src/marketplace_cmd.rs +++ b/codex-rs/cli/src/marketplace_cmd.rs @@ -7,11 +7,14 @@ use codex_core::config::find_codex_home; use codex_core_plugins::PluginMarketplaceUpgradeOutcome; use codex_core_plugins::PluginsManager; use codex_core_plugins::marketplace::marketplace_root_dir; +use codex_core_plugins::marketplace_add::MarketplaceAddOutcome; use codex_core_plugins::marketplace_add::MarketplaceAddRequest; use codex_core_plugins::marketplace_add::add_marketplace; +use codex_core_plugins::marketplace_remove::MarketplaceRemoveOutcome; use codex_core_plugins::marketplace_remove::MarketplaceRemoveRequest; use codex_core_plugins::marketplace_remove::remove_marketplace; use codex_utils_cli::CliConfigOverrides; +use serde::Serialize; use std::collections::HashSet; use crate::plugin_cmd::configured_marketplace_snapshot_issues; @@ -32,7 +35,7 @@ enum MarketplaceSubcommand { Add(AddMarketplaceArgs), /// List plugin marketplaces Codex is currently considering and their roots. - List, + List(ListMarketplaceArgs), /// Refresh configured Git marketplace snapshots. /// @@ -64,6 +67,18 @@ struct AddMarketplaceArgs { action = clap::ArgAction::Append )] sparse_paths: Vec, + + /// Output add result as JSON. + #[arg(long = "json")] + json: bool, +} + +#[derive(Debug, Parser)] +#[command(bin_name = "codex plugin marketplace list")] +struct ListMarketplaceArgs { + /// Output marketplace list as JSON. + #[arg(long = "json")] + json: bool, } #[derive(Debug, Parser)] @@ -75,6 +90,10 @@ struct UpgradeMarketplaceArgs { /// Optional configured marketplace name to upgrade. Omit to upgrade all Git marketplaces. #[arg(value_name = "MARKETPLACE_NAME")] marketplace_name: Option, + + /// Output upgrade result as JSON. + #[arg(long = "json")] + json: bool, } #[derive(Debug, Parser)] @@ -86,6 +105,10 @@ struct RemoveMarketplaceArgs { /// Configured marketplace name to remove. #[arg(value_name = "MARKETPLACE_NAME")] marketplace_name: String, + + /// Output remove result as JSON. + #[arg(long = "json")] + json: bool, } impl MarketplaceCli { @@ -101,7 +124,7 @@ impl MarketplaceCli { match subcommand { MarketplaceSubcommand::Add(args) => run_add(args).await?, - MarketplaceSubcommand::List => run_list(overrides).await?, + MarketplaceSubcommand::List(args) => run_list(overrides, args).await?, MarketplaceSubcommand::Upgrade(args) => run_upgrade(overrides, args).await?, MarketplaceSubcommand::Remove(args) => run_remove(args).await?, } @@ -115,6 +138,7 @@ async fn run_add(args: AddMarketplaceArgs) -> Result<()> { source, ref_name, sparse_paths, + json, } = args; let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?; @@ -128,6 +152,12 @@ async fn run_add(args: AddMarketplaceArgs) -> Result<()> { ) .await?; + if json { + let output = JsonMarketplaceAddOutput::from_outcome(outcome); + println!("{}", serde_json::to_string_pretty(&output)?); + return Ok(()); + } + if outcome.already_added { println!( "Marketplace `{}` is already added from {}.", @@ -147,7 +177,25 @@ async fn run_add(args: AddMarketplaceArgs) -> Result<()> { Ok(()) } -async fn run_list(overrides: Vec<(String, toml::Value)>) -> Result<()> { +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonMarketplaceAddOutput { + marketplace_name: String, + installed_root: String, + already_added: bool, +} + +impl JsonMarketplaceAddOutput { + fn from_outcome(outcome: MarketplaceAddOutcome) -> Self { + Self { + marketplace_name: outcome.marketplace_name, + installed_root: outcome.installed_root.as_path().display().to_string(), + already_added: outcome.already_added, + } + } +} + +async fn run_list(overrides: Vec<(String, toml::Value)>, args: ListMarketplaceArgs) -> Result<()> { let config = Config::load_with_cli_overrides(overrides) .await .context("failed to load configuration")?; @@ -191,6 +239,12 @@ async fn run_list(overrides: Vec<(String, toml::Value)>) -> Result<()> { bail!("failed to load marketplace(s):\n{issue_lines}"); } let marketplaces = marketplace_listing.marketplaces; + if args.json { + let output = JsonMarketplaceListOutput::from_marketplaces(marketplaces); + println!("{}", serde_json::to_string_pretty(&output)?); + return Ok(()); + } + if marketplaces.is_empty() { println!("No plugin marketplaces in scope."); return Ok(()); @@ -227,11 +281,48 @@ async fn run_list(overrides: Vec<(String, toml::Value)>) -> Result<()> { Ok(()) } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonMarketplaceListOutput { + marketplaces: Vec, +} + +impl JsonMarketplaceListOutput { + fn from_marketplaces(marketplaces: Vec) -> Self { + let mut seen_roots = HashSet::new(); + let marketplaces = marketplaces + .into_iter() + .filter_map(|marketplace| { + let root = marketplace_root_dir(&marketplace.path).ok()?; + if !seen_roots.insert(root.clone()) { + return None; + } + Some(JsonMarketplaceListEntry { + name: marketplace.name, + root: root.display().to_string(), + }) + }) + .collect(); + + Self { marketplaces } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonMarketplaceListEntry { + name: String, + root: String, +} + async fn run_upgrade( overrides: Vec<(String, toml::Value)>, args: UpgradeMarketplaceArgs, ) -> Result<()> { - let UpgradeMarketplaceArgs { marketplace_name } = args; + let UpgradeMarketplaceArgs { + marketplace_name, + json, + } = args; let config = Config::load_with_cli_overrides(overrides) .await .context("failed to load configuration")?; @@ -241,11 +332,18 @@ async fn run_upgrade( let outcome = manager .upgrade_configured_marketplaces_for_config(&plugins_input, marketplace_name.as_deref()) .map_err(anyhow::Error::msg)?; - print_upgrade_outcome(&outcome, marketplace_name.as_deref()) + if json { + print_upgrade_outcome_json(&outcome) + } else { + print_upgrade_outcome(&outcome, marketplace_name.as_deref()) + } } async fn run_remove(args: RemoveMarketplaceArgs) -> Result<()> { - let RemoveMarketplaceArgs { marketplace_name } = args; + let RemoveMarketplaceArgs { + marketplace_name, + json, + } = args; let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?; let outcome = remove_marketplace( codex_home.to_path_buf(), @@ -253,6 +351,12 @@ async fn run_remove(args: RemoveMarketplaceArgs) -> Result<()> { ) .await?; + if json { + let output = JsonMarketplaceRemoveOutput::from_outcome(outcome); + println!("{}", serde_json::to_string_pretty(&output)?); + return Ok(()); + } + println!("Removed marketplace `{}`.", outcome.marketplace_name); if let Some(installed_root) = outcome.removed_installed_root { println!( @@ -264,6 +368,76 @@ async fn run_remove(args: RemoveMarketplaceArgs) -> Result<()> { Ok(()) } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonMarketplaceRemoveOutput { + marketplace_name: String, + installed_root: Option, +} + +impl JsonMarketplaceRemoveOutput { + fn from_outcome(outcome: MarketplaceRemoveOutcome) -> Self { + Self { + marketplace_name: outcome.marketplace_name, + installed_root: outcome + .removed_installed_root + .map(|root| root.as_path().display().to_string()), + } + } +} + +fn print_upgrade_outcome_json(outcome: &PluginMarketplaceUpgradeOutcome) -> Result<()> { + for error in &outcome.errors { + eprintln!( + "Failed to upgrade marketplace `{}`: {}", + error.marketplace_name, error.message + ); + } + if !outcome.all_succeeded() { + bail!("{} upgrade failure(s) occurred.", outcome.errors.len()); + } + + let output = JsonMarketplaceUpgradeOutput::from_outcome(outcome); + println!("{}", serde_json::to_string_pretty(&output)?); + Ok(()) +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonMarketplaceUpgradeOutput { + selected_marketplaces: Vec, + upgraded_roots: Vec, + errors: Vec, +} + +impl JsonMarketplaceUpgradeOutput { + fn from_outcome(outcome: &PluginMarketplaceUpgradeOutcome) -> Self { + Self { + selected_marketplaces: outcome.selected_marketplaces.clone(), + upgraded_roots: outcome + .upgraded_roots + .iter() + .map(|root| root.display().to_string()) + .collect(), + errors: outcome + .errors + .iter() + .map(|error| JsonMarketplaceUpgradeError { + marketplace_name: error.marketplace_name.clone(), + message: error.message.clone(), + }) + .collect(), + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonMarketplaceUpgradeError { + marketplace_name: String, + message: String, +} + fn print_upgrade_outcome( outcome: &PluginMarketplaceUpgradeOutcome, marketplace_name: Option<&str>, diff --git a/codex-rs/cli/src/plugin_cmd.rs b/codex-rs/cli/src/plugin_cmd.rs index c850556e4d47..7def7283c0fd 100644 --- a/codex-rs/cli/src/plugin_cmd.rs +++ b/codex-rs/cli/src/plugin_cmd.rs @@ -6,6 +6,7 @@ use codex_core::config::Config; use codex_core::config::find_codex_home; use codex_core_plugins::ConfiguredMarketplace; use codex_core_plugins::OPENAI_BUNDLED_MARKETPLACE_NAME; +use codex_core_plugins::PluginInstallOutcome; use codex_core_plugins::PluginInstallRequest; use codex_core_plugins::PluginsConfigInput; use codex_core_plugins::PluginsManager; @@ -73,6 +74,10 @@ pub struct AddPluginArgs { /// Configured marketplace name to use when PLUGIN does not include @MARKETPLACE. #[arg(long = "marketplace", short = 'm', value_name = "MARKETPLACE")] marketplace_name: Option, + + /// Output install result as JSON. + #[arg(long = "json")] + json: bool, } #[derive(Debug, Parser)] @@ -107,6 +112,10 @@ pub struct RemovePluginArgs { /// Marketplace name to use when PLUGIN does not include @MARKETPLACE. #[arg(long = "marketplace", short = 'm', value_name = "MARKETPLACE")] marketplace_name: Option, + + /// Output remove result as JSON. + #[arg(long = "json")] + json: bool, } pub async fn run_plugin_add( @@ -118,11 +127,16 @@ pub async fn run_plugin_add( plugins_input, manager, } = load_plugin_command_context(overrides).await?; + let AddPluginArgs { + plugin, + marketplace_name, + json, + } = args; let PluginSelection { plugin_name, marketplace_name, .. - } = parse_plugin_selection(args.plugin, args.marketplace_name)?; + } = parse_plugin_selection(plugin, marketplace_name)?; let marketplace = find_marketplace_for_plugin( &manager, codex_home.as_path(), @@ -137,6 +151,12 @@ pub async fn run_plugin_add( }) .await?; + if json { + let output = JsonPluginAddOutput::from_outcome(outcome); + println!("{}", serde_json::to_string_pretty(&output)?); + return Ok(()); + } + println!( "Added plugin `{}` from marketplace `{}`.", outcome.plugin_id.plugin_name, outcome.plugin_id.marketplace_name @@ -149,6 +169,30 @@ pub async fn run_plugin_add( Ok(()) } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonPluginAddOutput { + plugin_id: String, + name: String, + marketplace_name: String, + version: String, + installed_path: String, + auth_policy: &'static str, +} + +impl JsonPluginAddOutput { + fn from_outcome(outcome: PluginInstallOutcome) -> Self { + Self { + plugin_id: outcome.plugin_id.as_key(), + name: outcome.plugin_id.plugin_name, + marketplace_name: outcome.plugin_id.marketplace_name, + version: outcome.plugin_version, + installed_path: outcome.installed_path.as_path().display().to_string(), + auth_policy: auth_policy_label(outcome.auth_policy), + } + } +} + pub async fn run_plugin_list( overrides: Vec<(String, toml::Value)>, args: ListPluginsArgs, @@ -449,9 +493,22 @@ pub async fn run_plugin_remove( args: RemovePluginArgs, ) -> Result<()> { let PluginCommandContext { manager, .. } = load_plugin_command_context(overrides).await?; - let selection = parse_plugin_selection(args.plugin, args.marketplace_name)?; + let RemovePluginArgs { + plugin, + marketplace_name, + json, + } = args; + let selection = parse_plugin_selection(plugin, marketplace_name)?; + + manager + .uninstall_plugin(selection.plugin_key.clone()) + .await?; + if json { + let output = JsonPluginRemoveOutput::from_selection(selection); + println!("{}", serde_json::to_string_pretty(&output)?); + return Ok(()); + } - manager.uninstall_plugin(selection.plugin_key).await?; println!( "Removed plugin `{}` from marketplace `{}`.", selection.plugin_name, selection.marketplace_name @@ -460,6 +517,24 @@ pub async fn run_plugin_remove( Ok(()) } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonPluginRemoveOutput { + plugin_id: String, + name: String, + marketplace_name: String, +} + +impl JsonPluginRemoveOutput { + fn from_selection(selection: PluginSelection) -> Self { + Self { + plugin_id: selection.plugin_key, + name: selection.plugin_name, + marketplace_name: selection.marketplace_name, + } + } +} + struct PluginCommandContext { codex_home: PathBuf, plugins_input: PluginsConfigInput, diff --git a/codex-rs/cli/tests/marketplace_add.rs b/codex-rs/cli/tests/marketplace_add.rs index 5ab18e24c48f..0439846a30fc 100644 --- a/codex-rs/cli/tests/marketplace_add.rs +++ b/codex-rs/cli/tests/marketplace_add.rs @@ -1,8 +1,10 @@ use anyhow::Result; use codex_config::CONFIG_TOML_FILE; use codex_core_plugins::installed_marketplaces::marketplace_install_root; +use codex_utils_absolute_path::AbsolutePathBuf; use predicates::str::contains; use pretty_assertions::assert_eq; +use serde_json::json; use std::path::Path; use tempfile::TempDir; @@ -70,6 +72,41 @@ async fn marketplace_add_local_directory_source() -> Result<()> { Ok(()) } +#[tokio::test] +async fn marketplace_add_json_prints_add_outcome() -> Result<()> { + let codex_home = TempDir::new()?; + let source = TempDir::new()?; + write_marketplace_source(source.path(), "local ref")?; + let source_parent = source.path().parent().unwrap(); + let source_arg = format!("./{}", source.path().file_name().unwrap().to_string_lossy()); + + let assert = codex_command(codex_home.path())? + .current_dir(source_parent) + .args([ + "plugin", + "marketplace", + "add", + source_arg.as_str(), + "--json", + ]) + .assert() + .success(); + let stdout = assert.get_output().stdout.as_slice(); + let actual: serde_json::Value = serde_json::from_slice(stdout)?; + let expected_installed_root = AbsolutePathBuf::try_from(source.path().canonicalize()?)?; + + assert_eq!( + actual, + json!({ + "marketplaceName": "debug", + "installedRoot": expected_installed_root.as_path().display().to_string(), + "alreadyAdded": false, + }) + ); + + Ok(()) +} + #[tokio::test] async fn marketplace_add_rejects_local_manifest_file_source() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/cli/tests/marketplace_remove.rs b/codex-rs/cli/tests/marketplace_remove.rs index 5c8c7a1f9164..b124574106f6 100644 --- a/codex-rs/cli/tests/marketplace_remove.rs +++ b/codex-rs/cli/tests/marketplace_remove.rs @@ -2,7 +2,10 @@ use anyhow::Result; use codex_config::MarketplaceConfigUpdate; use codex_config::record_user_marketplace; use codex_core_plugins::installed_marketplaces::marketplace_install_root; +use codex_utils_absolute_path::canonicalize_existing_preserving_symlinks; use predicates::str::contains; +use pretty_assertions::assert_eq; +use serde_json::json; use std::path::Path; use tempfile::TempDir; @@ -54,6 +57,32 @@ async fn marketplace_remove_deletes_config_and_installed_root() -> Result<()> { Ok(()) } +#[tokio::test] +async fn marketplace_remove_json_prints_remove_outcome() -> Result<()> { + let codex_home = TempDir::new()?; + record_user_marketplace(codex_home.path(), "debug", &configured_marketplace_update())?; + write_installed_marketplace(codex_home.path(), "debug")?; + let installed_root = marketplace_install_root(codex_home.path()).join("debug"); + let normalized_installed_root = canonicalize_existing_preserving_symlinks(&installed_root)?; + + let assert = codex_command(codex_home.path())? + .args(["plugin", "marketplace", "remove", "debug", "--json"]) + .assert() + .success(); + let stdout = assert.get_output().stdout.as_slice(); + let actual: serde_json::Value = serde_json::from_slice(stdout)?; + + assert_eq!( + actual, + json!({ + "marketplaceName": "debug", + "installedRoot": normalized_installed_root.display().to_string(), + }) + ); + + Ok(()) +} + #[tokio::test] async fn marketplace_remove_rejects_unknown_marketplace() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/cli/tests/marketplace_upgrade.rs b/codex-rs/cli/tests/marketplace_upgrade.rs index 268d75358e92..a2cf11f4af39 100644 --- a/codex-rs/cli/tests/marketplace_upgrade.rs +++ b/codex-rs/cli/tests/marketplace_upgrade.rs @@ -1,5 +1,7 @@ use anyhow::Result; use predicates::str::contains; +use pretty_assertions::assert_eq; +use serde_json::json; use std::path::Path; use tempfile::TempDir; @@ -22,6 +24,29 @@ async fn marketplace_upgrade_runs_under_plugin() -> Result<()> { Ok(()) } +#[tokio::test] +async fn marketplace_upgrade_json_prints_upgrade_outcome() -> Result<()> { + let codex_home = TempDir::new()?; + + let assert = codex_command(codex_home.path())? + .args(["plugin", "marketplace", "upgrade", "--json"]) + .assert() + .success(); + let stdout = assert.get_output().stdout.as_slice(); + let actual: serde_json::Value = serde_json::from_slice(stdout)?; + + assert_eq!( + actual, + json!({ + "selectedMarketplaces": [], + "upgradedRoots": [], + "errors": [], + }) + ); + + Ok(()) +} + #[tokio::test] async fn marketplace_upgrade_no_longer_runs_at_top_level() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/cli/tests/plugin_cli.rs b/codex-rs/cli/tests/plugin_cli.rs index 2625e0b68b28..cda79cf9a180 100644 --- a/codex-rs/cli/tests/plugin_cli.rs +++ b/codex-rs/cli/tests/plugin_cli.rs @@ -338,6 +338,32 @@ async fn marketplace_list_shows_configured_marketplace_names() -> Result<()> { Ok(()) } +#[tokio::test] +async fn marketplace_list_json_prints_configured_marketplaces() -> Result<()> { + let (codex_home, source) = setup_local_marketplace()?; + + let assert = codex_command(codex_home.path())? + .args(["plugin", "marketplace", "list", "--json"]) + .assert() + .success(); + let stdout = assert.get_output().stdout.as_slice(); + let actual: serde_json::Value = serde_json::from_slice(stdout)?; + + assert_eq!( + actual, + json!({ + "marketplaces": [ + { + "name": "debug", + "root": source.path().display().to_string(), + }, + ], + }) + ); + + Ok(()) +} + #[tokio::test] async fn marketplace_list_includes_home_marketplace_when_present() -> Result<()> { let codex_home = TempDir::new()?; @@ -802,6 +828,69 @@ async fn plugin_add_and_remove_updates_installed_plugin_config() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_add_json_prints_install_outcome() -> Result<()> { + let (codex_home, _source) = setup_local_marketplace()?; + + let assert = codex_command(codex_home.path())? + .args(["plugin", "add", "sample@debug", "--json"]) + .assert() + .success(); + let stdout = assert.get_output().stdout.as_slice(); + let actual: serde_json::Value = serde_json::from_slice(stdout)?; + let installed_path = codex_home.path().join("plugins/cache/debug/sample/1.2.3"); + let normalized_installed_path = canonicalize_existing_preserving_symlinks(&installed_path)?; + + assert_eq!( + actual, + json!({ + "pluginId": "sample@debug", + "name": "sample", + "marketplaceName": "debug", + "version": "1.2.3", + "installedPath": normalized_installed_path.display().to_string(), + "authPolicy": "ON_INSTALL", + }) + ); + + Ok(()) +} + +#[tokio::test] +async fn plugin_remove_json_prints_remove_outcome() -> Result<()> { + let (codex_home, _source) = setup_local_marketplace()?; + + codex_command(codex_home.path())? + .args(["plugin", "add", "sample@debug"]) + .assert() + .success(); + + let assert = codex_command(codex_home.path())? + .args([ + "plugin", + "remove", + "sample", + "--marketplace", + "debug", + "--json", + ]) + .assert() + .success(); + let stdout = assert.get_output().stdout.as_slice(); + let actual: serde_json::Value = serde_json::from_slice(stdout)?; + + assert_eq!( + actual, + json!({ + "pluginId": "sample@debug", + "name": "sample", + "marketplaceName": "debug", + }) + ); + + Ok(()) +} + #[tokio::test] async fn plugin_add_rejects_unconfigured_repo_local_marketplaces() -> Result<()> { let (codex_home, source) = setup_unconfigured_local_marketplace()?; diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index 50ac2685b447..17753e799ad8 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -6,6 +6,7 @@ use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::ModelVerification; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::TokenUsage; +use codex_protocol::protocol::TurnModerationMetadataEvent; use codex_protocol::protocol::W3cTraceContext; use futures::Stream; use serde::Deserialize; @@ -78,6 +79,8 @@ pub enum ResponseEvent { ServerModel(String), /// Emitted when the server recommends additional account verification. ModelVerifications(Vec), + /// Emitted when the server includes moderation metadata for first-party turn presentation. + TurnModerationMetadata(TurnModerationMetadataEvent), /// Emitted when `X-Reasoning-Included: true` is present on the response, /// meaning the server already accounted for past reasoning tokens and the /// client should not re-estimate them. @@ -110,12 +113,22 @@ pub enum ResponseEvent { ModelsEtag(String), } +#[derive(Debug, Serialize, Clone, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum ReasoningContext { + Auto, + CurrentTurn, + AllTurns, +} + #[derive(Debug, Serialize, Clone, PartialEq)] pub struct Reasoning { #[serde(skip_serializing_if = "Option::is_none")] pub effort: Option, #[serde(skip_serializing_if = "Option::is_none")] pub summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub context: Option, } #[derive(Debug, Serialize, Default, Clone, PartialEq)] diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index a7366a98717d..44cb6a544beb 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -683,6 +683,7 @@ async fn run_websocket_response_stream( } }; let model_verifications = event.model_verifications(); + let turn_moderation_metadata = event.turn_moderation_metadata(); if event.kind() == "codex.rate_limits" { if let Some(snapshot) = parse_rate_limit_event(&text) { let _ = tx_event.send(Ok(ResponseEvent::RateLimits(snapshot))).await; @@ -707,6 +708,16 @@ async fn run_websocket_response_stream( "response event consumer dropped".to_string(), )); } + if let Some(metadata) = turn_moderation_metadata + && tx_event + .send(Ok(ResponseEvent::TurnModerationMetadata(metadata))) + .await + .is_err() + { + return Err(ApiError::Stream( + "response event consumer dropped".to_string(), + )); + } match process_responses_event(event) { Ok(Some(event)) => { let is_completed = matches!(event, ResponseEvent::Completed { .. }); diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index 8efebf076c73..d2d767940647 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -30,6 +30,7 @@ pub use crate::common::OpenAiVerbosity; pub use crate::common::RawMemory; pub use crate::common::RawMemoryMetadata; pub use crate::common::Reasoning; +pub use crate::common::ReasoningContext; pub use crate::common::ResponseCreateWsRequest; pub use crate::common::ResponseEvent; pub use crate::common::ResponseStream; diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index c9f35e5a4ec8..ab1be640df9b 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -8,6 +8,7 @@ use codex_client::StreamResponse; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::ModelVerification; use codex_protocol::protocol::TokenUsage; +use codex_protocol::protocol::TurnModerationMetadataEvent; use eventsource_stream::Eventsource; use futures::StreamExt; use serde::Deserialize; @@ -193,6 +194,18 @@ impl ResponsesStreamEvent { .and_then(|metadata| metadata.get("openai_verification_recommendation")) .and_then(model_verifications_from_json_value) } + + pub(crate) fn turn_moderation_metadata(&self) -> Option { + if self.kind() != "response.metadata" { + return None; + } + + self.metadata + .as_ref() + .and_then(|metadata| metadata.get("openai_chatgpt_moderation_metadata")) + .cloned() + .map(|metadata| TurnModerationMetadataEvent { metadata }) + } } fn header_openai_model_value_from_json(value: &Value) -> Option { @@ -444,6 +457,7 @@ pub async fn process_sse( } }; let model_verifications = event.model_verifications(); + let turn_moderation_metadata = event.turn_moderation_metadata(); if let Some(model) = event.response_model() && last_server_model.as_deref() != Some(model.as_str()) @@ -465,6 +479,14 @@ pub async fn process_sse( { return; } + if let Some(metadata) = turn_moderation_metadata + && tx_event + .send(Ok(ResponseEvent::TurnModerationMetadata(metadata))) + .await + .is_err() + { + return; + } match process_responses_event(event) { Ok(Some(event)) => { @@ -1215,6 +1237,41 @@ mod tests { ); } + #[tokio::test] + async fn process_sse_emits_turn_moderation_metadata_field() { + let events = run_sse(vec![ + json!({ + "type": "response.metadata", + "metadata": { + "openai_chatgpt_moderation_metadata": { + "presentation": "inline" + } + } + }), + json!({ + "type": "response.completed", + "response": { + "id": "resp-1" + } + }), + ]) + .await; + + assert_matches!( + &events[0], + ResponseEvent::TurnModerationMetadata(result) + if result.metadata == json!({"presentation": "inline"}) + ); + assert_matches!( + &events[1], + ResponseEvent::Completed { + response_id, + token_usage: None, + end_turn: None, + } if response_id == "resp-1" + ); + } + #[test] fn responses_stream_event_response_model_reads_top_level_headers() { let ev: ResponsesStreamEvent = serde_json::from_value(json!({ diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index 9ff273211ff7..4ee6069898b7 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -98,6 +98,7 @@ async fn models_client_hits_models_endpoint() { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index dcaefb8dbead..1fbdebbd0a34 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -822,7 +822,8 @@ pub struct ConfigRequirementsToml { pub allowed_approval_policies: Option>, pub allowed_approvals_reviewers: Option>, pub allowed_sandbox_modes: Option>, - pub allowed_permissions: Option>, + pub allowed_permission_profiles: Option>, + pub default_permissions: Option, pub remote_sandbox_config: Option>, pub allowed_web_search_modes: Option>, pub allow_managed_hooks_only: Option, @@ -876,7 +877,8 @@ pub struct ConfigRequirementsWithSources { pub allowed_approval_policies: Option>>, pub allowed_approvals_reviewers: Option>>, pub allowed_sandbox_modes: Option>>, - pub allowed_permissions: Option>>, + pub allowed_permission_profiles: Option>>, + pub default_permissions: Option>, pub allowed_web_search_modes: Option>>, pub allow_managed_hooks_only: Option>, pub allow_appshots: Option>, @@ -916,7 +918,8 @@ impl ConfigRequirementsWithSources { allowed_approval_policies: _, allowed_approvals_reviewers: _, allowed_sandbox_modes: _, - allowed_permissions: _, + allowed_permission_profiles: _, + default_permissions: _, remote_sandbox_config: _, allowed_web_search_modes: _, allow_managed_hooks_only: _, @@ -951,7 +954,8 @@ impl ConfigRequirementsWithSources { allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, - allowed_permissions, + allowed_permission_profiles, + default_permissions, allowed_web_search_modes, allow_managed_hooks_only, allow_appshots, @@ -983,7 +987,8 @@ impl ConfigRequirementsWithSources { allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, - allowed_permissions, + allowed_permission_profiles, + default_permissions, allowed_web_search_modes, allow_managed_hooks_only, allow_appshots, @@ -1004,7 +1009,8 @@ impl ConfigRequirementsWithSources { allowed_approval_policies: allowed_approval_policies.map(|sourced| sourced.value), allowed_approvals_reviewers: allowed_approvals_reviewers.map(|sourced| sourced.value), allowed_sandbox_modes: allowed_sandbox_modes.map(|sourced| sourced.value), - allowed_permissions: allowed_permissions.map(|sourced| sourced.value), + allowed_permission_profiles: allowed_permission_profiles.map(|sourced| sourced.value), + default_permissions: default_permissions.map(|sourced| sourced.value), remote_sandbox_config: None, allowed_web_search_modes: allowed_web_search_modes.map(|sourced| sourced.value), allow_managed_hooks_only: allow_managed_hooks_only.map(|sourced| sourced.value), @@ -1092,7 +1098,8 @@ impl ConfigRequirementsToml { self.allowed_approval_policies.is_none() && self.allowed_approvals_reviewers.is_none() && self.allowed_sandbox_modes.is_none() - && self.allowed_permissions.is_none() + && self.allowed_permission_profiles.is_none() + && self.default_permissions.is_none() && self.remote_sandbox_config.is_none() && self.allowed_web_search_modes.is_none() && self.allow_managed_hooks_only.is_none() @@ -1144,7 +1151,8 @@ impl TryFrom for ConfigRequirements { allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, - allowed_permissions: _, + allowed_permission_profiles: _, + default_permissions: _, allowed_web_search_modes, allow_managed_hooks_only, allow_appshots, @@ -1511,7 +1519,8 @@ mod tests { allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, - allowed_permissions, + allowed_permission_profiles, + default_permissions, remote_sandbox_config: _, allowed_web_search_modes, allow_managed_hooks_only, @@ -1536,7 +1545,9 @@ mod tests { .map(|value| Sourced::new(value, RequirementSource::Unknown)), allowed_sandbox_modes: allowed_sandbox_modes .map(|value| Sourced::new(value, RequirementSource::Unknown)), - allowed_permissions: allowed_permissions + allowed_permission_profiles: allowed_permission_profiles + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + default_permissions: default_permissions .map(|value| Sourced::new(value, RequirementSource::Unknown)), allowed_web_search_modes: allowed_web_search_modes .map(|value| Sourced::new(value, RequirementSource::Unknown)), @@ -1592,7 +1603,11 @@ mod tests { fn deserialize_managed_permission_profiles() -> Result<()> { let requirements: ConfigRequirementsToml = from_str( r#" - allowed_permissions = ["managed-standard", "managed-build"] + default_permissions = "managed-standard" + + [allowed_permission_profiles] + managed-standard = true + managed-build = true [permissions.managed-standard] extends = ":workspace" @@ -1603,11 +1618,15 @@ mod tests { )?; assert_eq!( - requirements.allowed_permissions, - Some(vec![ - "managed-standard".to_string(), - "managed-build".to_string(), - ]) + requirements.allowed_permission_profiles, + Some(BTreeMap::from([ + ("managed-build".to_string(), true), + ("managed-standard".to_string(), true), + ])) + ); + assert_eq!( + requirements.default_permissions, + Some("managed-standard".to_string()) ); let permissions = requirements .permissions @@ -1720,7 +1739,8 @@ mod tests { allowed_approval_policies: Some(allowed_approval_policies.clone()), allowed_approvals_reviewers: Some(allowed_approvals_reviewers.clone()), allowed_sandbox_modes: Some(allowed_sandbox_modes.clone()), - allowed_permissions: Some(vec!["managed".to_string()]), + allowed_permission_profiles: Some(BTreeMap::from([("managed".to_string(), true)])), + default_permissions: Some("managed".to_string()), remote_sandbox_config: None, allowed_web_search_modes: Some(allowed_web_search_modes.clone()), allow_managed_hooks_only: Some(true), @@ -1753,10 +1773,11 @@ mod tests { source.clone(), )), allowed_sandbox_modes: Some(Sourced::new(allowed_sandbox_modes, source.clone(),)), - allowed_permissions: Some(Sourced::new( - vec!["managed".to_string()], + allowed_permission_profiles: Some(Sourced::new( + BTreeMap::from([("managed".to_string(), true)]), source.clone(), )), + default_permissions: Some(Sourced::new("managed".to_string(), source.clone(),)), allowed_web_search_modes: Some(Sourced::new( allowed_web_search_modes, enforce_source.clone(), @@ -1809,7 +1830,8 @@ mod tests { )), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, - allowed_permissions: None, + allowed_permission_profiles: None, + default_permissions: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, allow_appshots: None, @@ -1861,7 +1883,8 @@ mod tests { )), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, - allowed_permissions: None, + allowed_permission_profiles: None, + default_permissions: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, allow_appshots: None, diff --git a/codex-rs/config/src/requirements_layers/stack.rs b/codex-rs/config/src/requirements_layers/stack.rs index e93dd1c1da4c..0396cda237a5 100644 --- a/codex-rs/config/src/requirements_layers/stack.rs +++ b/codex-rs/config/src/requirements_layers/stack.rs @@ -176,7 +176,8 @@ fn populate_merged_regular_fields_with_sources( allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, - allowed_permissions, + allowed_permission_profiles, + default_permissions, remote_sandbox_config: _, allowed_web_search_modes, allow_managed_hooks_only, @@ -201,7 +202,11 @@ fn populate_merged_regular_fields_with_sources( &["allowed_approvals_reviewers"] ); set_sourced!(allowed_sandbox_modes, &["allowed_sandbox_modes"]); - set_sourced!(allowed_permissions, &["allowed_permissions"]); + set_sourced!( + allowed_permission_profiles, + &["allowed_permission_profiles"] + ); + set_sourced!(default_permissions, &["default_permissions"]); set_sourced!(allowed_web_search_modes, &["allowed_web_search_modes"]); set_sourced!(allow_managed_hooks_only, &["allow_managed_hooks_only"]); set_sourced!(allow_appshots, &["allow_appshots"]); diff --git a/codex-rs/config/src/requirements_layers/stack_tests.rs b/codex-rs/config/src/requirements_layers/stack_tests.rs index 8acfd5f0d5bd..82a99a7f0ac9 100644 --- a/codex-rs/config/src/requirements_layers/stack_tests.rs +++ b/codex-rs/config/src/requirements_layers/stack_tests.rs @@ -62,6 +62,11 @@ fn top_level_values_use_toml_priority() { r#" allowed_approval_policies = ["on-request"] allowed_sandbox_modes = ["workspace-write"] +default_permissions = ":workspace" + +[allowed_permission_profiles] +":read-only" = true +":workspace" = true "#, ), layer( @@ -70,6 +75,11 @@ allowed_sandbox_modes = ["workspace-write"] r#" allowed_approval_policies = ["never"] allowed_sandbox_modes = ["read-only"] +default_permissions = ":read-only" + +[allowed_permission_profiles] +":danger-full-access" = false +":workspace" = false "#, ), ]) @@ -82,6 +92,12 @@ allowed_sandbox_modes = ["read-only"] r#" allowed_approval_policies = ["never"] allowed_sandbox_modes = ["read-only"] +default_permissions = ":read-only" + +[allowed_permission_profiles] +":danger-full-access" = false +":read-only" = true +":workspace" = false "# ) ); diff --git a/codex-rs/core-plugins/src/discoverable.rs b/codex-rs/core-plugins/src/discoverable.rs index ccf093d8166f..f376d099ce22 100644 --- a/codex-rs/core-plugins/src/discoverable.rs +++ b/codex-rs/core-plugins/src/discoverable.rs @@ -4,6 +4,8 @@ use codex_app_server_protocol::PluginInstallPolicy; use codex_login::CodexAuth; use codex_plugin::PluginCapabilitySummary; use std::collections::HashSet; +use std::path::Component; +use std::path::Path; use tracing::warn; use crate::OPENAI_BUNDLED_MARKETPLACE_NAME; @@ -53,6 +55,9 @@ const TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST: &[&str] = &[ REMOTE_GLOBAL_MARKETPLACE_NAME, ]; +const OPENAI_CURATED_MARKETPLACE_PATH_SUFFIX: &str = + ".tmp/plugins/.agents/plugins/marketplace.json"; + #[derive(Debug, Clone)] pub struct ToolSuggestPluginDiscoveryInput { pub plugins: PluginsConfigInput, @@ -108,6 +113,10 @@ impl PluginsManager { { continue; } + let use_legacy_local_curated_filter = should_use_legacy_local_curated_discovery_filter( + &marketplace_name, + marketplace.path.as_path(), + ); let is_allowlisted_marketplace = TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST .contains(&marketplace_name.as_str()); @@ -123,6 +132,13 @@ impl PluginsManager { continue; } + // On Windows-backed WSL mounts, keep local curated discovery bounded to the + // legacy fallback/configured set instead of reading every plugin detail for app + // ids. Remote curated has cached app ids and still expands by installed apps. + if use_legacy_local_curated_filter && !is_configured_plugin && !is_fallback_plugin { + continue; + } + let plugin_id = plugin.id.clone(); match self @@ -216,3 +232,30 @@ impl PluginsManager { Ok(discoverable_plugins) } } + +fn should_use_legacy_local_curated_discovery_filter( + marketplace_name: &str, + marketplace_path: &Path, +) -> bool { + marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME + && is_wsl_windows_drive_path(marketplace_path) + && marketplace_path.ends_with(Path::new(OPENAI_CURATED_MARKETPLACE_PATH_SUFFIX)) +} + +fn is_wsl_windows_drive_path(path: &Path) -> bool { + let mut components = path.components(); + if components.next() != Some(Component::RootDir) { + return false; + } + if components.next().and_then(|part| part.as_os_str().to_str()) != Some("mnt") { + return false; + } + let Some(drive) = components.next().and_then(|part| part.as_os_str().to_str()) else { + return false; + }; + drive.len() == 1 && drive.as_bytes()[0].is_ascii_alphabetic() +} + +#[cfg(test)] +#[path = "discoverable_tests.rs"] +mod tests; diff --git a/codex-rs/core-plugins/src/discoverable_tests.rs b/codex-rs/core-plugins/src/discoverable_tests.rs new file mode 100644 index 000000000000..240fd86f1749 --- /dev/null +++ b/codex-rs/core-plugins/src/discoverable_tests.rs @@ -0,0 +1,61 @@ +use std::path::Path; + +use super::is_wsl_windows_drive_path; +use super::should_use_legacy_local_curated_discovery_filter; +use crate::OPENAI_BUNDLED_MARKETPLACE_NAME; +use crate::OPENAI_CURATED_MARKETPLACE_NAME; + +#[test] +fn legacy_local_curated_filter_matches_wsl_windows_backed_curated_checkout() { + let marketplace_path = + Path::new("/mnt/c/Users/user/.codex/.tmp/plugins/.agents/plugins/marketplace.json"); + + assert!(should_use_legacy_local_curated_discovery_filter( + OPENAI_CURATED_MARKETPLACE_NAME, + marketplace_path, + )); +} + +#[test] +fn legacy_local_curated_filter_does_not_match_native_wsl_curated_checkout() { + let marketplace_path = + Path::new("/home/user/.codex/.tmp/plugins/.agents/plugins/marketplace.json"); + + assert!(!should_use_legacy_local_curated_discovery_filter( + OPENAI_CURATED_MARKETPLACE_NAME, + marketplace_path, + )); +} + +#[test] +fn legacy_local_curated_filter_does_not_match_other_wsl_marketplaces() { + let other_marketplace_path = Path::new( + "/mnt/c/Users/user/.codex/.tmp/marketplaces/other/.agents/plugins/marketplace.json", + ); + let local_curated_marketplace_path = + Path::new("/mnt/c/Users/user/.codex/.tmp/plugins/.agents/plugins/marketplace.json"); + + assert!(!should_use_legacy_local_curated_discovery_filter( + OPENAI_CURATED_MARKETPLACE_NAME, + other_marketplace_path, + )); + assert!(!should_use_legacy_local_curated_discovery_filter( + OPENAI_BUNDLED_MARKETPLACE_NAME, + local_curated_marketplace_path, + )); +} + +#[test] +fn wsl_windows_drive_path_matches_only_mnt_drive_paths() { + assert!(is_wsl_windows_drive_path(Path::new( + "/mnt/c/Users/user/.codex/.tmp/plugins", + ))); + assert!(is_wsl_windows_drive_path(Path::new("/mnt/Z/tmp"))); + assert!(!is_wsl_windows_drive_path(Path::new("/home/user/.codex"))); + assert!(!is_wsl_windows_drive_path(Path::new( + "/mnt/codex/Users/user/.codex", + ))); + assert!(!is_wsl_windows_drive_path(Path::new( + "/media/c/Users/user/.codex", + ))); +} diff --git a/codex-rs/core-plugins/src/lib.rs b/codex-rs/core-plugins/src/lib.rs index 98cb73e1276b..8cfb9092f62d 100644 --- a/codex-rs/core-plugins/src/lib.rs +++ b/codex-rs/core-plugins/src/lib.rs @@ -11,7 +11,6 @@ mod plugin_bundle_archive; pub mod remote; pub mod remote_bundle; pub mod remote_legacy; -pub(crate) mod startup_remote_sync; pub mod startup_sync; pub mod store; #[cfg(test)] @@ -37,10 +36,8 @@ pub use manager::PluginInstallOutcome; pub use manager::PluginInstallRequest; pub use manager::PluginReadOutcome; pub use manager::PluginReadRequest; -pub use manager::PluginRemoteSyncError; pub use manager::PluginUninstallError; pub use manager::PluginsConfigInput; pub use manager::PluginsManager; -pub use manager::RemotePluginSyncResult; pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeError as PluginMarketplaceUpgradeError; pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome as PluginMarketplaceUpgradeOutcome; diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 08f668d13074..1839b06e0235 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -1,5 +1,4 @@ use super::PluginLoadOutcome; -use super::startup_remote_sync::start_startup_remote_plugin_sync_once; use crate::OPENAI_CURATED_MARKETPLACE_NAME; use crate::installed_marketplaces::installed_marketplace_roots_from_layer_stack; use crate::loader::PluginHookLoadOutcome; @@ -32,7 +31,6 @@ use crate::marketplace::ResolvedMarketplacePlugin; use crate::marketplace::find_installable_marketplace_plugin; use crate::marketplace::find_marketplace_plugin; use crate::marketplace::list_marketplaces; -use crate::marketplace::load_marketplace; use crate::marketplace::plugin_interface_with_marketplace_category; use crate::marketplace_upgrade::ConfiguredMarketplaceUpgradeError; use crate::marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome; @@ -52,13 +50,12 @@ use crate::store::PluginStore; use crate::store::PluginStoreError; use codex_analytics::AnalyticsEventsClient; use codex_config::ConfigLayerStack; -use codex_config::PluginConfigEdit; -use codex_config::apply_user_plugin_config_edits; use codex_config::clear_user_plugin; use codex_config::set_user_plugin_enabled; use codex_config::types::PluginConfig; -use codex_config::version_for_toml; use codex_core_skills::SkillMetadata; +use codex_core_skills::config_rules::SkillConfigRules; +use codex_core_skills::config_rules::skill_config_rules_from_stack; use codex_hooks::plugin_hook_declarations; use codex_login::AuthManager; use codex_login::CodexAuth; @@ -80,7 +77,6 @@ use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::time::Instant; use tokio::sync::Semaphore; -use tracing::info; use tracing::warn; static CURATED_REPO_SYNC_STARTED: AtomicBool = AtomicBool::new(false); @@ -296,127 +292,39 @@ impl From for PluginCapabilitySummary { } } -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct RemotePluginSyncResult { - /// Plugin ids newly installed into the local plugin cache. - pub installed_plugin_ids: Vec, - /// Plugin ids whose local config was changed to enabled. - pub enabled_plugin_ids: Vec, - /// Plugin ids whose local config was changed to disabled. - /// This is not populated by `sync_plugins_from_remote`. - pub disabled_plugin_ids: Vec, - /// Plugin ids removed from local cache or plugin config. - pub uninstalled_plugin_ids: Vec, -} - -#[derive(Debug, thiserror::Error)] -pub enum PluginRemoteSyncError { - #[error("chatgpt authentication required to sync remote plugins")] - AuthRequired, - - #[error( - "chatgpt authentication required to sync remote plugins; api key auth is not supported" - )] - UnsupportedAuthMode, - - #[error("failed to read auth token for remote plugin sync: {0}")] - AuthToken(#[source] std::io::Error), - - #[error("failed to send remote plugin sync request to {url}: {source}")] - Request { - url: String, - #[source] - source: reqwest::Error, - }, - - #[error("remote plugin sync request to {url} failed with status {status}: {body}")] - UnexpectedStatus { - url: String, - status: reqwest::StatusCode, - body: String, - }, - - #[error("failed to parse remote plugin sync response from {url}: {source}")] - Decode { - url: String, - #[source] - source: serde_json::Error, - }, - - #[error("local curated marketplace is not available")] - LocalMarketplaceNotFound, - - #[error("remote marketplace `{marketplace_name}` is not available locally")] - UnknownRemoteMarketplace { marketplace_name: String }, - - #[error("duplicate remote plugin `{plugin_name}` in sync response")] - DuplicateRemotePlugin { plugin_name: String }, - - #[error( - "remote plugin `{plugin_name}` was not found in local marketplace `{marketplace_name}`" - )] - UnknownRemotePlugin { - plugin_name: String, - marketplace_name: String, - }, - - #[error("{0}")] - InvalidPluginId(#[from] PluginIdError), - - #[error("{0}")] - Marketplace(#[from] MarketplaceError), - - #[error("{0}")] - Store(#[from] PluginStoreError), - - #[error("{0}")] - Config(#[from] anyhow::Error), - - #[error("failed to join remote plugin sync task: {0}")] - Join(#[from] tokio::task::JoinError), -} - -impl PluginRemoteSyncError { - fn join(source: tokio::task::JoinError) -> Self { - Self::Join(source) - } -} - -impl From for PluginRemoteSyncError { - fn from(value: RemotePluginFetchError) -> Self { - match value { - RemotePluginFetchError::AuthRequired => Self::AuthRequired, - RemotePluginFetchError::UnsupportedAuthMode => Self::UnsupportedAuthMode, - RemotePluginFetchError::AuthToken(source) => Self::AuthToken(source), - RemotePluginFetchError::Request { url, source } => Self::Request { url, source }, - RemotePluginFetchError::UnexpectedStatus { url, status, body } => { - Self::UnexpectedStatus { url, status, body } - } - RemotePluginFetchError::Decode { url, source } => Self::Decode { url, source }, - } - } -} - pub struct PluginsManager { codex_home: PathBuf, store: PluginStore, featured_plugin_ids_cache: RwLock>, configured_marketplace_upgrade_state: RwLock, non_curated_cache_refresh_state: RwLock, - cached_enabled_outcome: RwLock>, + enabled_outcome_cache: RwLock, + enabled_outcome_load_semaphore: Semaphore, remote_installed_plugins_cache: RwLock>>, remote_installed_plugins_cache_refresh_state: RwLock, - remote_sync_lock: Semaphore, restriction_product: Option, analytics_events_client: RwLock>, } #[derive(Clone)] struct CachedPluginLoadOutcome { - config_version: String, + key: PluginLoadCacheKey, outcome: PluginLoadOutcome, } +#[derive(Default)] +struct EnabledOutcomeCache { + generation: u64, + outcome: Option, +} + +#[derive(Clone, PartialEq, Eq)] +struct PluginLoadCacheKey { + configured_plugins: HashMap, + skill_config_rules: SkillConfigRules, + remote_plugin_enabled: bool, +} + impl PluginsManager { pub fn new(codex_home: PathBuf) -> Self { Self::new_with_restriction_product(codex_home, Some(Product::Codex)) @@ -441,12 +349,12 @@ impl PluginsManager { ConfiguredMarketplaceUpgradeState::default(), ), non_curated_cache_refresh_state: RwLock::new(NonCuratedCacheRefreshState::default()), - cached_enabled_outcome: RwLock::new(None), + enabled_outcome_cache: RwLock::new(EnabledOutcomeCache::default()), + enabled_outcome_load_semaphore: Semaphore::new(/*permits*/ 1), remote_installed_plugins_cache: RwLock::new(None), remote_installed_plugins_cache_refresh_state: RwLock::new( RemoteInstalledPluginsCacheRefreshState::default(), ), - remote_sync_lock: Semaphore::new(/*permits*/ 1), restriction_product, analytics_events_client: RwLock::new(None), } @@ -484,11 +392,23 @@ impl PluginsManager { return PluginLoadOutcome::default(); } - let config_version = version_for_toml(&config.config_layer_stack.effective_config()); - if !force_reload && let Some(outcome) = self.cached_enabled_outcome(&config_version) { + let cache_key = PluginLoadCacheKey { + configured_plugins: configured_plugins_from_stack(&config.config_layer_stack), + skill_config_rules: skill_config_rules_from_stack(&config.config_layer_stack), + remote_plugin_enabled: config.remote_plugin_enabled, + }; + if !force_reload && let Some(outcome) = self.cached_enabled_outcome(&cache_key) { return outcome; } + let Ok(_load_permit) = self.enabled_outcome_load_semaphore.acquire().await else { + warn!("plugin load semaphore closed"); + return PluginLoadOutcome::default(); + }; + if !force_reload && let Some(outcome) = self.cached_enabled_outcome(&cache_key) { + return outcome; + } + let cache_generation = self.enabled_outcome_cache_generation(); let outcome = load_plugins_from_layer_stack( &config.config_layer_stack, self.remote_installed_plugin_configs(), @@ -498,14 +418,7 @@ impl PluginsManager { ) .await; log_plugin_load_errors(&outcome); - let mut cache = match self.cached_enabled_outcome.write() { - Ok(cache) => cache, - Err(err) => err.into_inner(), - }; - *cache = Some(CachedPluginLoadOutcome { - config_version, - outcome: outcome.clone(), - }); + self.cache_enabled_outcome_if_current(cache_generation, cache_key, outcome.clone()); outcome } @@ -519,11 +432,12 @@ impl PluginsManager { } fn clear_enabled_outcome_cache(&self) { - let mut cached_enabled_outcome = match self.cached_enabled_outcome.write() { + let mut cache = match self.enabled_outcome_cache.write() { Ok(cache) => cache, Err(err) => err.into_inner(), }; - *cached_enabled_outcome = None; + cache.generation = cache.generation.wrapping_add(1); + cache.outcome = None; } /// Load plugins for a config layer stack without touching the plugins cache. @@ -574,20 +488,44 @@ impl PluginsManager { .effective_plugin_skill_roots() } - fn cached_enabled_outcome(&self, config_version: &str) -> Option { - match self.cached_enabled_outcome.read() { + fn cached_enabled_outcome(&self, key: &PluginLoadCacheKey) -> Option { + match self.enabled_outcome_cache.read() { Ok(cache) => cache + .outcome .as_ref() - .filter(|cached| cached.config_version == config_version) + .filter(|cached| cached.key == *key) .map(|cached| cached.outcome.clone()), Err(err) => err .into_inner() + .outcome .as_ref() - .filter(|cached| cached.config_version == config_version) + .filter(|cached| cached.key == *key) .map(|cached| cached.outcome.clone()), } } + fn enabled_outcome_cache_generation(&self) -> u64 { + match self.enabled_outcome_cache.read() { + Ok(cache) => cache.generation, + Err(err) => err.into_inner().generation, + } + } + + fn cache_enabled_outcome_if_current( + &self, + generation: u64, + key: PluginLoadCacheKey, + outcome: PluginLoadOutcome, + ) { + let mut cache = match self.enabled_outcome_cache.write() { + Ok(cache) => cache, + Err(err) => err.into_inner(), + }; + if cache.generation == generation { + cache.outcome = Some(CachedPluginLoadOutcome { key, outcome }); + } + } + fn remote_installed_plugin_configs(&self) -> HashMap { let cache = match self.remote_installed_plugins_cache.read() { Ok(cache) => cache, @@ -1005,224 +943,6 @@ impl PluginsManager { Ok(()) } - pub async fn sync_plugins_from_remote( - &self, - config: &PluginsConfigInput, - auth: Option<&CodexAuth>, - additive_only: bool, - ) -> Result { - let _remote_sync_guard = self.remote_sync_lock.acquire().await.map_err(|_| { - PluginRemoteSyncError::Config(anyhow::anyhow!("remote plugin sync semaphore closed")) - })?; - - if !config.plugins_enabled { - return Ok(RemotePluginSyncResult::default()); - } - - info!("starting remote plugin sync"); - let remote_plugins = crate::remote_legacy::fetch_remote_plugin_status( - &remote_plugin_service_config(config), - auth, - ) - .await - .map_err(PluginRemoteSyncError::from)?; - let configured_plugins = configured_plugins_from_stack(&config.config_layer_stack); - let curated_marketplace_root = curated_plugins_repo_path(self.codex_home.as_path()); - let curated_marketplace_path = AbsolutePathBuf::try_from( - curated_marketplace_root.join(".agents/plugins/marketplace.json"), - ) - .map_err(|_| PluginRemoteSyncError::LocalMarketplaceNotFound)?; - let curated_marketplace = match load_marketplace(&curated_marketplace_path) { - Ok(marketplace) => marketplace, - Err(MarketplaceError::MarketplaceNotFound { .. }) => { - return Err(PluginRemoteSyncError::LocalMarketplaceNotFound); - } - Err(err) => return Err(err.into()), - }; - - let marketplace_name = curated_marketplace.name.clone(); - let curated_plugin_version = read_curated_plugins_sha(self.codex_home.as_path()) - .ok_or_else(|| { - PluginStoreError::Invalid( - "local curated marketplace sha is not available".to_string(), - ) - })?; - let cache_plugin_version = curated_plugin_cache_version(&curated_plugin_version); - let mut local_plugins = Vec::<( - String, - PluginId, - AbsolutePathBuf, - Option, - Option, - bool, - )>::new(); - let mut local_plugin_names = HashSet::new(); - for plugin in curated_marketplace.plugins { - let plugin_name = plugin.name; - if !local_plugin_names.insert(plugin_name.clone()) { - warn!( - plugin = plugin_name, - marketplace = %marketplace_name, - "ignoring duplicate local plugin entry during remote sync" - ); - continue; - } - - let plugin_id = PluginId::new(plugin_name.clone(), marketplace_name.clone())?; - let plugin_key = plugin_id.as_key(); - let source_path = match plugin.source { - MarketplacePluginSource::Local { path } => path, - MarketplacePluginSource::Git { .. } => { - warn!( - plugin = plugin_name, - marketplace = %marketplace_name, - "skipping remote plugin source during remote sync" - ); - continue; - } - }; - let current_enabled = configured_plugins - .get(&plugin_key) - .map(|plugin| plugin.enabled); - let installed_version = self.store.active_plugin_version(&plugin_id); - let product_allowed = - self.restriction_product_matches(plugin.policy.products.as_deref()); - local_plugins.push(( - plugin_name, - plugin_id, - source_path, - current_enabled, - installed_version, - product_allowed, - )); - } - - let mut missing_remote_plugins = Vec::::new(); - let mut remote_installed_plugin_names = HashSet::::new(); - for plugin in remote_plugins { - if plugin.marketplace_name != marketplace_name { - return Err(PluginRemoteSyncError::UnknownRemoteMarketplace { - marketplace_name: plugin.marketplace_name, - }); - } - if !local_plugin_names.contains(&plugin.name) { - missing_remote_plugins.push(plugin.name); - continue; - } - // For now, sync treats remote `enabled = false` as uninstall rather than a distinct - // disabled state. - // TODO: Switch sync to `plugins/installed` so install and enable states stay distinct. - if !plugin.enabled { - continue; - } - if !remote_installed_plugin_names.insert(plugin.name.clone()) { - return Err(PluginRemoteSyncError::DuplicateRemotePlugin { - plugin_name: plugin.name, - }); - } - } - - let mut config_edits = Vec::new(); - let mut installs = Vec::new(); - let mut uninstalls = Vec::new(); - let mut result = RemotePluginSyncResult::default(); - let remote_plugin_count = remote_installed_plugin_names.len(); - let local_plugin_count = local_plugins.len(); - if !missing_remote_plugins.is_empty() { - let sample_missing_plugins = missing_remote_plugins - .iter() - .take(10) - .cloned() - .collect::>(); - warn!( - marketplace = %marketplace_name, - missing_remote_plugin_count = missing_remote_plugins.len(), - missing_remote_plugin_examples = ?sample_missing_plugins, - "ignoring remote plugins missing from local marketplace during sync" - ); - } - - for ( - plugin_name, - plugin_id, - source_path, - current_enabled, - installed_version, - product_allowed, - ) in local_plugins - { - let plugin_key = plugin_id.as_key(); - let is_installed = installed_version.is_some(); - if !product_allowed { - continue; - } - if remote_installed_plugin_names.contains(&plugin_name) { - if !is_installed { - installs.push((source_path, plugin_id.clone(), cache_plugin_version.clone())); - } - if !is_installed { - result.installed_plugin_ids.push(plugin_key.clone()); - } - - if current_enabled != Some(true) { - result.enabled_plugin_ids.push(plugin_key.clone()); - config_edits.push(PluginConfigEdit::SetEnabled { - plugin_key, - enabled: true, - }); - } - } else if !additive_only { - if is_installed { - uninstalls.push(plugin_id); - } - if is_installed || current_enabled.is_some() { - result.uninstalled_plugin_ids.push(plugin_key.clone()); - } - if current_enabled.is_some() { - config_edits.push(PluginConfigEdit::Clear { plugin_key }); - } - } - } - - let store = self.store.clone(); - let store_result = tokio::task::spawn_blocking(move || { - for (source_path, plugin_id, plugin_version) in installs { - store.install_with_version(source_path, plugin_id, plugin_version)?; - } - for plugin_id in uninstalls { - store.uninstall(&plugin_id)?; - } - Ok::<(), PluginStoreError>(()) - }) - .await - .map_err(PluginRemoteSyncError::join)?; - if let Err(err) = store_result { - self.clear_cache(); - return Err(err.into()); - } - - let config_result = if config_edits.is_empty() { - Ok(()) - } else { - apply_user_plugin_config_edits(&self.codex_home, config_edits).await - }; - self.clear_cache(); - config_result.map_err(anyhow::Error::from)?; - - info!( - marketplace = %marketplace_name, - remote_plugin_count, - local_plugin_count, - installed_plugin_ids = ?result.installed_plugin_ids, - enabled_plugin_ids = ?result.enabled_plugin_ids, - disabled_plugin_ids = ?result.disabled_plugin_ids, - uninstalled_plugin_ids = ?result.uninstalled_plugin_ids, - "completed remote plugin sync" - ); - - Ok(result) - } - pub fn list_marketplaces_for_config( &self, config: &PluginsConfigInput, @@ -1573,13 +1293,6 @@ impl PluginsManager { warn!("failed to start configured marketplace auto-upgrade task: {err}"); } } - start_startup_remote_plugin_sync_once( - Arc::clone(self), - self.codex_home.clone(), - config.clone(), - auth_manager.clone(), - ); - let config_for_remote_sync = config.clone(); let manager = Arc::clone(self); let auth_manager_for_remote_sync = auth_manager.clone(); diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index d6c13b1fe0b2..b1be37655bf2 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -1300,6 +1300,97 @@ async fn load_plugins_returns_empty_when_feature_disabled() { assert_eq!(outcome, PluginLoadOutcome::default()); } +#[tokio::test] +async fn plugin_cache_ignores_unrelated_session_overrides() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + write_plugin( + codex_home.path().join("plugins/cache/test").as_path(), + "sample/local", + "sample", + ); + write_file( + &plugin_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "sample": { + "url": "https://sample.example/mcp" + } + } +}"#, + ); + + let user_file = codex_home.path().join(CONFIG_TOML_FILE).abs(); + let user_config: toml::Value = toml::from_str(&plugin_config_toml( + /*enabled*/ true, /*plugins_feature_enabled*/ true, + )) + .expect("user config should parse"); + let stack = |session_config: &str| { + ConfigLayerStack::new( + vec![ + ConfigLayerEntry::new( + ConfigLayerSource::User { + file: user_file.clone(), + profile: None, + }, + user_config.clone(), + ), + ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + toml::from_str(session_config).expect("session config should parse"), + ), + ], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("config layer stack should build") + }; + let config = |session_config| { + PluginsConfigInput::new( + stack(session_config), + /*plugins_enabled*/ true, + /*remote_plugin_enabled*/ false, + "https://chatgpt.com".to_string(), + ) + }; + let manager = PluginsManager::new(codex_home.path().to_path_buf()); + + let first = manager + .plugins_for_config(&config(r#"model = "first""#)) + .await; + std::fs::remove_file(plugin_root.join(".mcp.json")).unwrap(); + let second = manager + .plugins_for_config(&config(r#"model = "second""#)) + .await; + + assert_eq!(second, first); + assert_eq!(second.plugins()[0].mcp_servers.len(), 1); +} + +#[test] +fn plugin_cache_invalidation_rejects_stale_load_completion() { + let codex_home = TempDir::new().unwrap(); + let manager = PluginsManager::new(codex_home.path().to_path_buf()); + let cache_key = PluginLoadCacheKey { + configured_plugins: HashMap::new(), + skill_config_rules: SkillConfigRules::default(), + remote_plugin_enabled: false, + }; + let stale_generation = manager.enabled_outcome_cache_generation(); + + manager.clear_enabled_outcome_cache(); + manager.cache_enabled_outcome_if_current( + stale_generation, + cache_key.clone(), + PluginLoadOutcome::default(), + ); + + assert_eq!(manager.cached_enabled_outcome(&cache_key), None); +} + #[tokio::test] async fn load_plugins_rejects_invalid_plugin_keys() { let codex_home = TempDir::new().unwrap(); @@ -2325,25 +2416,6 @@ enabled = true ); } -#[tokio::test] -async fn sync_plugins_from_remote_returns_default_when_feature_disabled() { - let tmp = tempfile::tempdir().unwrap(); - write_file( - &tmp.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = false -"#, - ); - - let config = load_config(tmp.path(), tmp.path()).await; - let outcome = PluginsManager::new(tmp.path().to_path_buf()) - .sync_plugins_from_remote(&config, /*auth*/ None, /*additive_only*/ false) - .await - .unwrap(); - - assert_eq!(outcome, RemotePluginSyncResult::default()); -} - #[tokio::test] async fn list_marketplaces_includes_curated_repo_marketplace() { let tmp = tempfile::tempdir().unwrap(); @@ -2834,431 +2906,6 @@ enabled = true ); } -#[tokio::test] -async fn sync_plugins_from_remote_reconciles_cache_and_config() { - let tmp = tempfile::tempdir().unwrap(); - let curated_root = curated_plugins_repo_path(tmp.path()); - write_openai_curated_marketplace(&curated_root, &["linear", "gmail", "calendar"]); - write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); - write_plugin( - &tmp.path().join("plugins/cache/openai-curated"), - "linear/local", - "linear", - ); - write_plugin( - &tmp.path().join("plugins/cache/openai-curated"), - "gmail/local", - "gmail", - ); - write_plugin( - &tmp.path().join("plugins/cache/openai-curated"), - "calendar/local", - "calendar", - ); - write_file( - &tmp.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = true - -[plugins."linear@openai-curated"] -enabled = false - -[plugins."gmail@openai-curated"] -enabled = false - -[plugins."calendar@openai-curated"] -enabled = true -"#, - ); - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/backend-api/plugins/list")) - .and(header("authorization", "Bearer Access Token")) - .and(header("chatgpt-account-id", "account_id")) - .respond_with(ResponseTemplate::new(200).set_body_string( - r#"[ - {"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true}, - {"id":"2","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":false} -]"#, - )) - .mount(&server) - .await; - - let mut config = load_config(tmp.path(), tmp.path()).await; - config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); - let manager = PluginsManager::new(tmp.path().to_path_buf()); - let result = manager - .sync_plugins_from_remote( - &config, - Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), - /*additive_only*/ false, - ) - .await - .unwrap(); - - assert_eq!( - result, - RemotePluginSyncResult { - installed_plugin_ids: Vec::new(), - enabled_plugin_ids: vec!["linear@openai-curated".to_string()], - disabled_plugin_ids: Vec::new(), - uninstalled_plugin_ids: vec![ - "gmail@openai-curated".to_string(), - "calendar@openai-curated".to_string(), - ], - } - ); - - assert!( - tmp.path() - .join("plugins/cache/openai-curated/linear/local") - .is_dir() - ); - assert!( - !tmp.path() - .join("plugins/cache/openai-curated/gmail") - .exists() - ); - assert!( - !tmp.path() - .join("plugins/cache/openai-curated/calendar") - .exists() - ); - - let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); - assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); - assert!(config.contains("enabled = true")); - assert!(!config.contains(r#"[plugins."gmail@openai-curated"]"#)); - assert!(!config.contains(r#"[plugins."calendar@openai-curated"]"#)); - - let synced_config = load_config(tmp.path(), tmp.path()).await; - let curated_marketplace = manager - .list_marketplaces_for_config(&synced_config, &[]) - .unwrap() - .marketplaces - .into_iter() - .find(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME) - .unwrap(); - assert_eq!( - curated_marketplace - .plugins - .into_iter() - .map(|plugin| (plugin.id, plugin.installed, plugin.enabled)) - .collect::>(), - vec![ - ("linear@openai-curated".to_string(), true, true), - ("gmail@openai-curated".to_string(), false, false), - ("calendar@openai-curated".to_string(), false, false), - ] - ); -} - -#[tokio::test] -async fn sync_plugins_from_remote_additive_only_keeps_existing_plugins() { - let tmp = tempfile::tempdir().unwrap(); - let curated_root = curated_plugins_repo_path(tmp.path()); - write_openai_curated_marketplace(&curated_root, &["linear", "gmail", "calendar"]); - write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); - write_plugin( - &tmp.path().join("plugins/cache/openai-curated"), - "linear/local", - "linear", - ); - write_plugin( - &tmp.path().join("plugins/cache/openai-curated"), - "gmail/local", - "gmail", - ); - write_plugin( - &tmp.path().join("plugins/cache/openai-curated"), - "calendar/local", - "calendar", - ); - write_file( - &tmp.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = true - -[plugins."linear@openai-curated"] -enabled = false - -[plugins."gmail@openai-curated"] -enabled = false - -[plugins."calendar@openai-curated"] -enabled = true -"#, - ); - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/backend-api/plugins/list")) - .and(header("authorization", "Bearer Access Token")) - .and(header("chatgpt-account-id", "account_id")) - .respond_with(ResponseTemplate::new(200).set_body_string( - r#"[ - {"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true}, - {"id":"2","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":false} -]"#, - )) - .mount(&server) - .await; - - let mut config = load_config(tmp.path(), tmp.path()).await; - config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); - let manager = PluginsManager::new(tmp.path().to_path_buf()); - let result = manager - .sync_plugins_from_remote( - &config, - Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), - /*additive_only*/ true, - ) - .await - .unwrap(); - - assert_eq!( - result, - RemotePluginSyncResult { - installed_plugin_ids: Vec::new(), - enabled_plugin_ids: vec!["linear@openai-curated".to_string()], - disabled_plugin_ids: Vec::new(), - uninstalled_plugin_ids: Vec::new(), - } - ); - - assert!( - tmp.path() - .join("plugins/cache/openai-curated/linear/local") - .is_dir() - ); - assert!( - tmp.path() - .join("plugins/cache/openai-curated/gmail/local") - .is_dir() - ); - assert!( - tmp.path() - .join("plugins/cache/openai-curated/calendar/local") - .is_dir() - ); - - let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); - assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); - assert!(config.contains(r#"[plugins."gmail@openai-curated"]"#)); - assert!(config.contains(r#"[plugins."calendar@openai-curated"]"#)); - assert!(config.contains("enabled = true")); -} - -#[tokio::test] -async fn sync_plugins_from_remote_ignores_unknown_remote_plugins() { - let tmp = tempfile::tempdir().unwrap(); - let curated_root = curated_plugins_repo_path(tmp.path()); - write_openai_curated_marketplace(&curated_root, &["linear"]); - write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); - write_file( - &tmp.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = true - -[plugins."linear@openai-curated"] -enabled = false -"#, - ); - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/backend-api/plugins/list")) - .respond_with(ResponseTemplate::new(200).set_body_string( - r#"[ - {"id":"1","name":"plugin-one","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} -]"#, - )) - .mount(&server) - .await; - - let mut config = load_config(tmp.path(), tmp.path()).await; - config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); - let manager = PluginsManager::new(tmp.path().to_path_buf()); - let result = manager - .sync_plugins_from_remote( - &config, - Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), - /*additive_only*/ false, - ) - .await - .unwrap(); - - assert_eq!( - result, - RemotePluginSyncResult { - installed_plugin_ids: Vec::new(), - enabled_plugin_ids: Vec::new(), - disabled_plugin_ids: Vec::new(), - uninstalled_plugin_ids: vec!["linear@openai-curated".to_string()], - } - ); - let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); - assert!(!config.contains(r#"[plugins."linear@openai-curated"]"#)); - assert!( - !tmp.path() - .join("plugins/cache/openai-curated/linear") - .exists() - ); -} - -#[tokio::test] -async fn sync_plugins_from_remote_keeps_existing_plugins_when_install_fails() { - let tmp = tempfile::tempdir().unwrap(); - let curated_root = curated_plugins_repo_path(tmp.path()); - write_openai_curated_marketplace(&curated_root, &["linear", "gmail"]); - write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); - fs::remove_dir_all(curated_root.join("plugins/gmail")).unwrap(); - write_plugin( - &tmp.path().join("plugins/cache/openai-curated"), - "linear/local", - "linear", - ); - write_file( - &tmp.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = true - -[plugins."linear@openai-curated"] -enabled = false -"#, - ); - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/backend-api/plugins/list")) - .respond_with(ResponseTemplate::new(200).set_body_string( - r#"[ - {"id":"1","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} -]"#, - )) - .mount(&server) - .await; - - let mut config = load_config(tmp.path(), tmp.path()).await; - config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); - let manager = PluginsManager::new(tmp.path().to_path_buf()); - let err = manager - .sync_plugins_from_remote( - &config, - Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), - /*additive_only*/ false, - ) - .await - .unwrap_err(); - - assert!(matches!( - err, - PluginRemoteSyncError::Store(PluginStoreError::Invalid(ref message)) - if message.contains("plugin source path is not a directory") - )); - assert!( - tmp.path() - .join("plugins/cache/openai-curated/linear/local") - .is_dir() - ); - assert!( - !tmp.path() - .join("plugins/cache/openai-curated/gmail") - .exists() - ); - - let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); - assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); - assert!(!config.contains(r#"[plugins."gmail@openai-curated"]"#)); - assert!(config.contains("enabled = false")); -} - -#[tokio::test] -async fn sync_plugins_from_remote_uses_first_duplicate_local_plugin_entry() { - let tmp = tempfile::tempdir().unwrap(); - let curated_root = curated_plugins_repo_path(tmp.path()); - write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); - fs::create_dir_all(curated_root.join(".agents/plugins")).unwrap(); - fs::write( - curated_root.join(".agents/plugins/marketplace.json"), - r#"{ - "name": "openai-curated", - "plugins": [ - { - "name": "gmail", - "source": { - "source": "local", - "path": "./plugins/gmail-first" - } - }, - { - "name": "gmail", - "source": { - "source": "local", - "path": "./plugins/gmail-second" - } - } - ] -}"#, - ) - .unwrap(); - write_plugin(&curated_root, "plugins/gmail-first", "gmail"); - write_plugin(&curated_root, "plugins/gmail-second", "gmail"); - fs::write(curated_root.join("plugins/gmail-first/marker.txt"), "first").unwrap(); - fs::write( - curated_root.join("plugins/gmail-second/marker.txt"), - "second", - ) - .unwrap(); - write_file( - &tmp.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = true -"#, - ); - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/backend-api/plugins/list")) - .respond_with(ResponseTemplate::new(200).set_body_string( - r#"[ - {"id":"1","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} -]"#, - )) - .mount(&server) - .await; - - let mut config = load_config(tmp.path(), tmp.path()).await; - config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); - let manager = PluginsManager::new(tmp.path().to_path_buf()); - let result = manager - .sync_plugins_from_remote( - &config, - Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), - /*additive_only*/ false, - ) - .await - .unwrap(); - - assert_eq!( - result, - RemotePluginSyncResult { - installed_plugin_ids: vec!["gmail@openai-curated".to_string()], - enabled_plugin_ids: vec!["gmail@openai-curated".to_string()], - disabled_plugin_ids: Vec::new(), - uninstalled_plugin_ids: Vec::new(), - } - ); - assert_eq!( - fs::read_to_string(tmp.path().join(format!( - "plugins/cache/openai-curated/gmail/{TEST_CURATED_PLUGIN_CACHE_VERSION}/marker.txt" - ))) - .unwrap(), - "first" - ); -} - #[tokio::test] async fn featured_plugin_ids_for_config_uses_restriction_product_query_param() { let tmp = tempfile::tempdir().unwrap(); diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 045ad8e18423..7daf1da31fa4 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -167,6 +167,27 @@ pub struct RemotePluginDetail { pub app_manifest: Option, pub skills: Vec, pub app_ids: Vec, + pub app_templates: Vec, + pub mcp_servers: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteAppTemplate { + pub template_id: String, + pub name: String, + pub description: Option, + pub canonical_connector_id: Option, + pub logo_url: Option, + pub logo_url_dark: Option, + pub materialized_app_ids: Vec, + pub reason: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum RemoteAppTemplateUnavailableReason { + NotConfiguredForWorkspace, + NoActiveWorkspace, } #[derive(Debug, Clone, PartialEq)] @@ -412,11 +433,38 @@ struct RemotePluginReleaseResponse { app_ids: Vec, #[serde(default)] app_manifest: Option, + #[serde(default, alias = "unavailable_app_templates")] + app_templates: Vec, #[serde(default)] keywords: Vec, interface: RemotePluginReleaseInterfaceResponse, #[serde(default)] skills: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + mcp_servers: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +struct RemotePluginMcpServerResponse { + key: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +struct RemoteAppTemplateResponse { + template_id: String, + name: String, + #[serde(default)] + description: Option, + #[serde(default)] + canonical_connector_id: Option, + #[serde(default)] + logo_url: Option, + #[serde(default)] + logo_url_dark: Option, + #[serde(default)] + materialized_app_ids: Vec, + #[serde(default)] + reason: Option, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] @@ -948,6 +996,14 @@ async fn build_remote_plugin_detail( enabled: !disabled_skill_names.contains(&skill.name), }) .collect(); + let mut mcp_servers = plugin + .release + .mcp_servers + .iter() + .map(|server| server.key.clone()) + .collect::>(); + mcp_servers.sort_unstable(); + mcp_servers.dedup(); Ok(RemotePluginDetail { marketplace_name, @@ -959,6 +1015,22 @@ async fn build_remote_plugin_detail( app_manifest: plugin.release.app_manifest, skills, app_ids: plugin.release.app_ids, + app_templates: plugin + .release + .app_templates + .into_iter() + .map(|template| RemoteAppTemplate { + template_id: template.template_id, + name: template.name, + description: template.description, + canonical_connector_id: template.canonical_connector_id, + logo_url: template.logo_url, + logo_url_dark: template.logo_url_dark, + materialized_app_ids: template.materialized_app_ids, + reason: template.reason, + }) + .collect(), + mcp_servers, }) } diff --git a/codex-rs/core-plugins/src/remote_legacy.rs b/codex-rs/core-plugins/src/remote_legacy.rs index dcf9f79eb856..137c33753b72 100644 --- a/codex-rs/core-plugins/src/remote_legacy.rs +++ b/codex-rs/core-plugins/src/remote_legacy.rs @@ -6,19 +6,9 @@ use serde::Deserialize; use std::time::Duration; use url::Url; -const DEFAULT_REMOTE_MARKETPLACE_NAME: &str = "openai-curated"; -const REMOTE_PLUGIN_FETCH_TIMEOUT: Duration = Duration::from_secs(30); const REMOTE_FEATURED_PLUGIN_FETCH_TIMEOUT: Duration = Duration::from_secs(10); const REMOTE_PLUGIN_MUTATION_TIMEOUT: Duration = Duration::from_secs(30); -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -pub struct RemotePluginStatusSummary { - pub name: String, - #[serde(default = "default_remote_marketplace_name")] - pub marketplace_name: String, - pub enabled: bool, -} - #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(rename_all = "camelCase")] struct RemotePluginMutationResponse { @@ -83,32 +73,21 @@ pub enum RemotePluginMutationError { #[derive(Debug, thiserror::Error)] pub enum RemotePluginFetchError { - #[error("chatgpt authentication required to sync remote plugins")] - AuthRequired, - - #[error( - "chatgpt authentication required to sync remote plugins; api key auth is not supported" - )] - UnsupportedAuthMode, - - #[error("failed to read auth token for remote plugin sync: {0}")] - AuthToken(#[source] std::io::Error), - - #[error("failed to send remote plugin sync request to {url}: {source}")] + #[error("failed to send remote featured plugin request to {url}: {source}")] Request { url: String, #[source] source: reqwest::Error, }, - #[error("remote plugin sync request to {url} failed with status {status}: {body}")] + #[error("remote featured plugin request to {url} failed with status {status}: {body}")] UnexpectedStatus { url: String, status: reqwest::StatusCode, body: String, }, - #[error("failed to parse remote plugin sync response from {url}: {source}")] + #[error("failed to parse remote featured plugin response from {url}: {source}")] Decode { url: String, #[source] @@ -116,44 +95,6 @@ pub enum RemotePluginFetchError { }, } -pub async fn fetch_remote_plugin_status( - config: &RemotePluginServiceConfig, - auth: Option<&CodexAuth>, -) -> Result, RemotePluginFetchError> { - let Some(auth) = auth else { - return Err(RemotePluginFetchError::AuthRequired); - }; - if !auth.uses_codex_backend() { - return Err(RemotePluginFetchError::UnsupportedAuthMode); - } - - let base_url = config.chatgpt_base_url.trim_end_matches('/'); - let url = format!("{base_url}/plugins/list"); - let client = build_reqwest_client(); - let request = client - .get(&url) - .timeout(REMOTE_PLUGIN_FETCH_TIMEOUT) - .headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers()); - - let response = request - .send() - .await - .map_err(|source| RemotePluginFetchError::Request { - url: url.clone(), - source, - })?; - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - if !status.is_success() { - return Err(RemotePluginFetchError::UnexpectedStatus { url, status, body }); - } - - serde_json::from_str(&body).map_err(|source| RemotePluginFetchError::Decode { - url: url.clone(), - source, - }) -} - pub async fn fetch_remote_featured_plugin_ids( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, @@ -224,10 +165,6 @@ fn ensure_codex_backend_auth( Ok(auth) } -fn default_remote_marketplace_name() -> String { - DEFAULT_REMOTE_MARKETPLACE_NAME.to_string() -} - async fn post_remote_plugin_mutation( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, diff --git a/codex-rs/core-plugins/src/startup_remote_sync.rs b/codex-rs/core-plugins/src/startup_remote_sync.rs deleted file mode 100644 index 90c0e119c0cb..000000000000 --- a/codex-rs/core-plugins/src/startup_remote_sync.rs +++ /dev/null @@ -1,100 +0,0 @@ -use std::path::Path; -use std::path::PathBuf; -use std::sync::Arc; -use std::time::Duration; - -use crate::manager::PluginsConfigInput; -use crate::manager::PluginsManager; -use crate::startup_sync::has_local_curated_plugins_snapshot; -use codex_login::AuthManager; -use tracing::info; -use tracing::warn; - -const STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE: &str = ".tmp/app-server-remote-plugin-sync-v1"; -const STARTUP_REMOTE_PLUGIN_SYNC_PREREQUISITE_TIMEOUT: Duration = Duration::from_secs(10); - -pub(crate) fn start_startup_remote_plugin_sync_once( - manager: Arc, - codex_home: PathBuf, - config: PluginsConfigInput, - auth_manager: Arc, -) { - let marker_path = startup_remote_plugin_sync_marker_path(codex_home.as_path()); - if marker_path.is_file() { - return; - } - - tokio::spawn(async move { - if marker_path.is_file() { - return; - } - - if !wait_for_startup_remote_plugin_sync_prerequisites(codex_home.as_path()).await { - warn!( - codex_home = %codex_home.display(), - "skipping startup remote plugin sync because curated marketplace is not ready" - ); - return; - } - - let auth = auth_manager.auth().await; - match manager - .sync_plugins_from_remote(&config, auth.as_ref(), /*additive_only*/ true) - .await - { - Ok(sync_result) => { - info!( - installed_plugin_ids = ?sync_result.installed_plugin_ids, - enabled_plugin_ids = ?sync_result.enabled_plugin_ids, - disabled_plugin_ids = ?sync_result.disabled_plugin_ids, - uninstalled_plugin_ids = ?sync_result.uninstalled_plugin_ids, - "completed startup remote plugin sync" - ); - if let Err(err) = - write_startup_remote_plugin_sync_marker(codex_home.as_path()).await - { - warn!( - error = %err, - path = %marker_path.display(), - "failed to persist startup remote plugin sync marker" - ); - } - } - Err(err) => { - warn!( - error = %err, - "startup remote plugin sync failed; will retry on next app-server start" - ); - } - } - }); -} - -fn startup_remote_plugin_sync_marker_path(codex_home: &Path) -> PathBuf { - codex_home.join(STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE) -} - -async fn wait_for_startup_remote_plugin_sync_prerequisites(codex_home: &Path) -> bool { - let deadline = tokio::time::Instant::now() + STARTUP_REMOTE_PLUGIN_SYNC_PREREQUISITE_TIMEOUT; - loop { - if has_local_curated_plugins_snapshot(codex_home) { - return true; - } - if tokio::time::Instant::now() >= deadline { - return false; - } - tokio::time::sleep(Duration::from_millis(50)).await; - } -} - -async fn write_startup_remote_plugin_sync_marker(codex_home: &Path) -> std::io::Result<()> { - let marker_path = startup_remote_plugin_sync_marker_path(codex_home); - if let Some(parent) = marker_path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - tokio::fs::write(marker_path, b"ok\n").await -} - -#[cfg(test)] -#[path = "startup_remote_sync_tests.rs"] -mod tests; diff --git a/codex-rs/core-plugins/src/startup_remote_sync_tests.rs b/codex-rs/core-plugins/src/startup_remote_sync_tests.rs deleted file mode 100644 index bdbc5e1a7a13..000000000000 --- a/codex-rs/core-plugins/src/startup_remote_sync_tests.rs +++ /dev/null @@ -1,91 +0,0 @@ -use super::*; -use crate::PluginsManager; -use crate::startup_sync::curated_plugins_repo_path; -use crate::test_support::TEST_CURATED_PLUGIN_CACHE_VERSION; -use crate::test_support::load_plugins_config; -use crate::test_support::write_curated_plugin_sha; -use crate::test_support::write_file; -use crate::test_support::write_openai_curated_marketplace; -use codex_config::CONFIG_TOML_FILE; -use codex_login::AuthManager; -use codex_login::CodexAuth; -use pretty_assertions::assert_eq; -use std::sync::Arc; -use std::time::Duration; -use tempfile::tempdir; -use wiremock::Mock; -use wiremock::MockServer; -use wiremock::ResponseTemplate; -use wiremock::matchers::header; -use wiremock::matchers::method; -use wiremock::matchers::path; - -#[tokio::test] -async fn startup_remote_plugin_sync_writes_marker_and_reconciles_state() { - let tmp = tempdir().expect("tempdir"); - let curated_root = curated_plugins_repo_path(tmp.path()); - write_openai_curated_marketplace(&curated_root, &["linear"]); - write_curated_plugin_sha(tmp.path()); - write_file( - &tmp.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = true - -[plugins."linear@openai-curated"] -enabled = false -"#, - ); - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/backend-api/plugins/list")) - .and(header("authorization", "Bearer Access Token")) - .and(header("chatgpt-account-id", "account_id")) - .respond_with(ResponseTemplate::new(200).set_body_string( - r#"[ - {"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} -]"#, - )) - .mount(&server) - .await; - - let mut config = load_plugins_config(tmp.path(), tmp.path()).await; - config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); - let manager = Arc::new(PluginsManager::new(tmp.path().to_path_buf())); - let auth_manager = - AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - - start_startup_remote_plugin_sync_once( - Arc::clone(&manager), - tmp.path().to_path_buf(), - config, - auth_manager, - ); - - let marker_path = tmp.path().join(STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE); - tokio::time::timeout(Duration::from_secs(5), async { - loop { - if marker_path.is_file() { - break; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("marker should be written"); - - assert!( - tmp.path() - .join(format!( - "plugins/cache/openai-curated/linear/{TEST_CURATED_PLUGIN_CACHE_VERSION}" - )) - .is_dir() - ); - let config = - std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("config should exist"); - assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); - assert!(config.contains("enabled = true")); - - let marker_contents = std::fs::read_to_string(marker_path).expect("marker should be readable"); - assert_eq!(marker_contents, "ok\n"); -} diff --git a/codex-rs/core-plugins/src/startup_sync.rs b/codex-rs/core-plugins/src/startup_sync.rs index f03aff93f632..c5965f2212eb 100644 --- a/codex-rs/core-plugins/src/startup_sync.rs +++ b/codex-rs/core-plugins/src/startup_sync.rs @@ -1,3 +1,4 @@ +use std::fs::File; use std::path::Path; use std::path::PathBuf; use std::process::Command; @@ -22,8 +23,11 @@ const CURATED_PLUGINS_BACKUP_ARCHIVE_API_URL: &str = "https://chatgpt.com/backend-api/plugins/export/curated"; const OPENAI_PLUGINS_OWNER: &str = "openai"; const OPENAI_PLUGINS_REPO: &str = "plugins"; +const OPENAI_PLUGINS_GIT_URL: &str = "https://github.com/openai/plugins.git"; +const CURATED_PLUGINS_FETCH_REF: &str = "refs/codex/curated-sync"; const CURATED_PLUGINS_RELATIVE_DIR: &str = ".tmp/plugins"; const CURATED_PLUGINS_SHA_FILE: &str = ".tmp/plugins.sha"; +const CURATED_PLUGINS_SYNC_LOCK_FILE: &str = ".tmp/plugins.sync.lock"; const CURATED_PLUGINS_BACKUP_ARCHIVE_FALLBACK_VERSION: &str = "export-backup"; const CURATED_PLUGINS_GIT_TIMEOUT: Duration = Duration::from_secs(30); const CURATED_PLUGINS_HTTP_TIMEOUT: Duration = Duration::from_secs(30); @@ -78,6 +82,8 @@ fn sync_openai_plugins_repo_with_transport_overrides( api_base_url: &str, backup_archive_api_url: &str, ) -> Result { + let _file_guard = lock_curated_plugins_startup_sync(codex_home)?; + match sync_openai_plugins_repo_via_git(codex_home, git_binary) { Ok(remote_sha) => { emit_curated_plugins_startup_sync_metric("git", "success"); @@ -135,6 +141,22 @@ fn sync_openai_plugins_repo_with_transport_overrides( } } +fn lock_curated_plugins_startup_sync(codex_home: &Path) -> Result { + let lock_path = codex_home.join(CURATED_PLUGINS_SYNC_LOCK_FILE); + std::fs::create_dir_all(codex_home.join(".tmp")) + .map_err(|err| format!("failed to create curated plugins sync directory: {err}"))?; + let lock_file = File::options() + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + .map_err(|err| format!("failed to open curated plugins sync lock: {err}"))?; + lock_file + .lock() + .map_err(|err| format!("failed to lock curated plugins sync: {err}"))?; + Ok(lock_file) +} + fn sync_openai_plugins_repo_via_git(codex_home: &Path, git_binary: &str) -> Result { let repo_path = curated_plugins_repo_path(codex_home); let sha_path = codex_home.join(CURATED_PLUGINS_SHA_FILE); @@ -146,23 +168,30 @@ fn sync_openai_plugins_repo_via_git(codex_home: &Path, git_binary: &str) -> Resu } let staged_repo_dir = prepare_curated_repo_parent_and_temp_dir(&repo_path)?; - let clone_output = run_git_command_with_timeout( - Command::new(git_binary) - .env("GIT_OPTIONAL_LOCKS", "0") - .arg("clone") - .arg("--depth") - .arg("1") - .arg("https://github.com/openai/plugins.git") - .arg(staged_repo_dir.path()), - "git clone curated plugins repo", - CURATED_PLUGINS_GIT_TIMEOUT, + run_git_in_repo( + staged_repo_dir.path(), + git_binary, + &["init"], + "git init curated plugins repo", )?; - ensure_git_success(&clone_output, "git clone curated plugins repo")?; - let cloned_sha = git_head_sha(staged_repo_dir.path(), git_binary)?; - if cloned_sha != remote_sha { + if repo_path.join(".git").is_dir() { + fetch_curated_plugins_commit(&repo_path, &remote_sha, git_binary)?; + fetch_curated_plugins_commit_from_source( + staged_repo_dir.path(), + &repo_path, + CURATED_PLUGINS_FETCH_REF, + git_binary, + )?; + } else { + fetch_curated_plugins_commit(staged_repo_dir.path(), &remote_sha, git_binary)?; + } + + reset_curated_plugins_checkout(staged_repo_dir.path(), git_binary)?; + let fetched_sha = git_head_sha(staged_repo_dir.path(), git_binary)?; + if fetched_sha != remote_sha { return Err(format!( - "curated plugins clone HEAD mismatch: expected {remote_sha}, got {cloned_sha}" + "curated plugins fetch HEAD mismatch: expected {remote_sha}, got {fetched_sha}" )); } @@ -172,6 +201,90 @@ fn sync_openai_plugins_repo_via_git(codex_home: &Path, git_binary: &str) -> Resu Ok(remote_sha) } +fn fetch_curated_plugins_commit( + repo_path: &Path, + remote_sha: &str, + git_binary: &str, +) -> Result<(), String> { + fetch_curated_plugins_commit_from( + repo_path, + OPENAI_PLUGINS_GIT_URL.as_ref(), + remote_sha, + git_binary, + "git fetch curated plugins repo", + ) +} + +fn fetch_curated_plugins_commit_from_source( + repo_path: &Path, + source_repo_path: &Path, + remote_sha: &str, + git_binary: &str, +) -> Result<(), String> { + fetch_curated_plugins_commit_from( + repo_path, + source_repo_path, + remote_sha, + git_binary, + "git copy fetched curated plugins commit", + ) +} + +fn fetch_curated_plugins_commit_from( + repo_path: &Path, + source: &Path, + source_revision: &str, + git_binary: &str, + context: &str, +) -> Result<(), String> { + let fetch_refspec = format!("+{source_revision}:{CURATED_PLUGINS_FETCH_REF}"); + let output = run_git_command_with_timeout( + Command::new(git_binary) + .env("GIT_OPTIONAL_LOCKS", "0") + .arg("-C") + .arg(repo_path) + .args(["fetch", "--depth", "1", "--no-tags"]) + .arg(source) + .arg(fetch_refspec), + context, + CURATED_PLUGINS_GIT_TIMEOUT, + )?; + ensure_git_success(&output, context) +} + +fn reset_curated_plugins_checkout(repo_path: &Path, git_binary: &str) -> Result<(), String> { + run_git_in_repo( + repo_path, + git_binary, + &["reset", "--hard", CURATED_PLUGINS_FETCH_REF], + "git reset curated plugins repo", + )?; + run_git_in_repo( + repo_path, + git_binary, + &["clean", "-fdx"], + "git clean curated plugins repo", + ) +} + +fn run_git_in_repo( + repo_path: &Path, + git_binary: &str, + args: &[&str], + context: &str, +) -> Result<(), String> { + let output = run_git_command_with_timeout( + Command::new(git_binary) + .env("GIT_OPTIONAL_LOCKS", "0") + .arg("-C") + .arg(repo_path) + .args(args), + context, + CURATED_PLUGINS_GIT_TIMEOUT, + )?; + ensure_git_success(&output, context) +} + fn sync_openai_plugins_repo_via_http( codex_home: &Path, api_base_url: &str, diff --git a/codex-rs/core-plugins/src/startup_sync_tests.rs b/codex-rs/core-plugins/src/startup_sync_tests.rs index a9388e3fce82..f73e4ad9d129 100644 --- a/codex-rs/core-plugins/src/startup_sync_tests.rs +++ b/codex-rs/core-plugins/src/startup_sync_tests.rs @@ -3,6 +3,8 @@ use pretty_assertions::assert_eq; use std::io::Write; use std::path::Path; use std::path::PathBuf; +#[cfg(unix)] +use std::sync::Barrier; use tempfile::tempdir; use wiremock::Mock; use wiremock::MockServer; @@ -95,6 +97,22 @@ fn write_executable_script(path: &Path, contents: &str) { } } +#[cfg(unix)] +fn run_git(repo: &Path, args: &[&str]) -> std::process::Output { + let output = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .output() + .expect("run git"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + output +} + async fn mount_github_repo_and_ref(server: &MockServer, sha: &str) { Mock::given(method("GET")) .and(path("/repos/openai/plugins")) @@ -253,30 +271,42 @@ fn remove_stale_curated_repo_temp_dirs_removes_only_matching_directories() { #[cfg(unix)] #[test] -fn sync_openai_plugins_repo_prefers_git_when_available() { +fn concurrent_syncs_serialize_fetches_without_skipping_remote_checks() { let tmp = tempdir().expect("tempdir"); let bin_dir = tempfile::Builder::new() .prefix("fake-git-") .tempdir() .expect("tempdir"); let git_path = bin_dir.path().join("git"); + let invocation_log = bin_dir.path().join("invocations.log"); let sha = "0123456789abcdef0123456789abcdef01234567"; write_executable_script( &git_path, &format!( r#"#!/bin/sh +printf '%s\n' "$*" >> '{}' if [ "$1" = "ls-remote" ]; then + sleep 1 printf '%s\tHEAD\n' "{sha}" exit 0 fi -if [ "$1" = "clone" ]; then - dest="$5" - mkdir -p "$dest/.git" "$dest/.agents/plugins" "$dest/plugins/gmail/.codex-plugin" - cat > "$dest/.agents/plugins/marketplace.json" <<'EOF' +if [ "$1" = "-C" ] && [ "$3" = "init" ]; then + mkdir -p "$2/.git" + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "fetch" ]; then + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "reset" ]; then + mkdir -p "$2/.agents/plugins" "$2/plugins/gmail/.codex-plugin" + cat > "$2/.agents/plugins/marketplace.json" <<'EOF' {{"name":"openai-curated","plugins":[{{"name":"gmail","source":{{"source":"local","path":"./plugins/gmail"}}}}]}} EOF - printf '%s\n' '{{"name":"gmail"}}' > "$dest/plugins/gmail/.codex-plugin/plugin.json" + printf '%s\n' '{{"name":"gmail"}}' > "$2/plugins/gmail/.codex-plugin/plugin.json" + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "clean" ]; then exit 0 fi if [ "$1" = "-C" ] && [ "$3" = "rev-parse" ] && [ "$4" = "HEAD" ]; then @@ -285,23 +315,55 @@ if [ "$1" = "-C" ] && [ "$3" = "rev-parse" ] && [ "$4" = "HEAD" ]; then fi echo "unexpected git invocation: $@" >&2 exit 1 -"# +"#, + invocation_log.display() ), ); - let synced_sha = sync_openai_plugins_repo_with_transport_overrides( - tmp.path(), - git_path.to_str().expect("utf8 path"), - "http://127.0.0.1:9", - "http://127.0.0.1:9/backend-api/plugins/export/curated", - ) - .expect("git sync should succeed"); - - assert_eq!(synced_sha, sha); + let barrier = Barrier::new(2); + let results = std::thread::scope(|scope| { + let run_sync = || { + barrier.wait(); + sync_openai_plugins_repo_with_transport_overrides( + tmp.path(), + git_path.to_str().expect("utf8 path"), + "http://127.0.0.1:9", + "http://127.0.0.1:9/backend-api/plugins/export/curated", + ) + }; + let first = scope.spawn(run_sync); + let second = scope.spawn(run_sync); + [ + first.join().expect("first sync thread"), + second.join().expect("second sync thread"), + ] + }); + + assert_eq!(results, [Ok(sha.to_string()), Ok(sha.to_string())]); let repo_path = curated_plugins_repo_path(tmp.path()); assert!(repo_path.join(".git").is_dir()); assert_curated_gmail_repo(&repo_path); assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha)); + let invocations = std::fs::read_to_string(invocation_log).expect("read invocation log"); + assert_eq!( + invocations + .lines() + .filter(|invocation| invocation.starts_with("ls-remote ")) + .count(), + 2 + ); + assert_eq!( + invocations + .lines() + .filter(|invocation| invocation.contains(" fetch --depth 1 --no-tags ")) + .count(), + 1 + ); + assert!( + !invocations + .lines() + .any(|invocation| invocation.split_whitespace().any(|arg| arg == "clone")) + ); } #[cfg(unix)] @@ -328,36 +390,20 @@ fn sync_openai_plugins_repo_via_git_succeeds_with_local_rewritten_remote() { ) .expect("write plugin manifest"); - let init_status = Command::new("git") - .arg("-C") - .arg(&work_repo) - .arg("init") - .status() - .expect("run git init"); - assert!(init_status.success()); - - let add_status = Command::new("git") - .arg("-C") - .arg(&work_repo) - .arg("add") - .arg(".") - .status() - .expect("run git add"); - assert!(add_status.success()); - - let commit_status = Command::new("git") - .arg("-C") - .arg(&work_repo) - .arg("-c") - .arg("user.name=Codex Test") - .arg("-c") - .arg("user.email=codex@example.com") - .arg("commit") - .arg("-m") - .arg("init") - .status() - .expect("run git commit"); - assert!(commit_status.success()); + run_git(&work_repo, &["init"]); + run_git(&work_repo, &["add", "."]); + run_git( + &work_repo, + &[ + "-c", + "user.name=Codex Test", + "-c", + "user.email=codex@example.com", + "commit", + "-m", + "init", + ], + ); std::fs::create_dir_all(remote_repo.parent().expect("remote parent")) .expect("create remote parent"); @@ -370,14 +416,7 @@ fn sync_openai_plugins_repo_via_git_succeeds_with_local_rewritten_remote() { .expect("run git clone --bare"); assert!(clone_status.success()); - let sha_output = Command::new("git") - .arg("-C") - .arg(&work_repo) - .arg("rev-parse") - .arg("HEAD") - .output() - .expect("run git rev-parse"); - assert!(sha_output.status.success()); + let sha_output = run_git(&work_repo, &["rev-parse", "HEAD"]); let sha = String::from_utf8_lossy(&sha_output.stdout) .trim() .to_string(); @@ -397,10 +436,12 @@ fn sync_openai_plugins_repo_via_git_succeeds_with_local_rewritten_remote() { .tempdir() .expect("tempdir"); let git_wrapper = bin_dir.path().join("git"); + let invocation_log = bin_dir.path().join("invocations.log"); write_executable_script( &git_wrapper, &format!( - "#!/bin/sh\nGIT_CONFIG_GLOBAL='{}' exec git \"$@\"\n", + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nGIT_CONFIG_GLOBAL='{}' exec git \"$@\"\n", + invocation_log.display(), git_config_path.display() ), ); @@ -416,6 +457,125 @@ fn sync_openai_plugins_repo_via_git_succeeds_with_local_rewritten_remote() { Some(sha.as_str()) ); assert!(!has_plugins_clone_dirs(tmp.path())); + + let first_sync_invocation_count = std::fs::read_to_string(&invocation_log) + .expect("read first sync invocations") + .lines() + .count(); + let first_sync_invocations = + std::fs::read_to_string(&invocation_log).expect("read first sync invocations"); + assert!( + first_sync_invocations + .lines() + .any(|invocation| invocation.contains(" fetch --depth 1 --no-tags ")) + ); + assert!( + !first_sync_invocations + .lines() + .any(|invocation| invocation.split_whitespace().any(|arg| arg == "clone")) + ); + write_openai_curated_marketplace(&work_repo, &["gmail", "linear"]); + run_git(&work_repo, &["add", "."]); + run_git( + &work_repo, + &[ + "-c", + "user.name=Codex Test", + "-c", + "user.email=codex@example.com", + "commit", + "-m", + "update", + ], + ); + let branch_output = run_git(&work_repo, &["symbolic-ref", "--short", "HEAD"]); + let branch = String::from_utf8_lossy(&branch_output.stdout) + .trim() + .to_string(); + let remote_repo = remote_repo.to_str().expect("utf8 remote repo"); + let push_ref = format!("HEAD:refs/heads/{branch}"); + run_git(&work_repo, &["push", remote_repo, &push_ref]); + let updated_sha_output = run_git(&work_repo, &["rev-parse", "HEAD"]); + let updated_sha = String::from_utf8_lossy(&updated_sha_output.stdout) + .trim() + .to_string(); + + let synced_sha = + sync_openai_plugins_repo_via_git(tmp.path(), git_wrapper.to_str().expect("utf8 path")) + .expect("incremental git sync should succeed"); + + assert_eq!(synced_sha, updated_sha); + assert!( + curated_plugins_repo_path(tmp.path()) + .join("plugins/linear/.codex-plugin/plugin.json") + .is_file() + ); + assert_eq!( + read_curated_plugins_sha(tmp.path()).as_deref(), + Some(updated_sha.as_str()) + ); + assert!( + !curated_plugins_repo_path(tmp.path()) + .join(".git/objects/info/alternates") + .exists() + ); + let invocation_log_contents = + std::fs::read_to_string(&invocation_log).expect("read sync invocations"); + let incremental_sync_invocations = invocation_log_contents + .lines() + .skip(first_sync_invocation_count) + .collect::>(); + let curated_repo_path = curated_plugins_repo_path(tmp.path()); + assert!(incremental_sync_invocations.iter().any(|invocation| { + invocation.starts_with(&format!("-C {} fetch ", curated_repo_path.display())) + && invocation.contains(" https://github.com/openai/plugins.git ") + && invocation.contains(updated_sha.as_str()) + && invocation.ends_with(CURATED_PLUGINS_FETCH_REF) + })); + assert!(incremental_sync_invocations.iter().any(|invocation| { + invocation.contains(" fetch --depth 1 --no-tags ") + && invocation.contains(&format!(" {} ", curated_repo_path.display())) + && invocation.ends_with(&format!( + "{CURATED_PLUGINS_FETCH_REF}:{CURATED_PLUGINS_FETCH_REF}" + )) + })); + assert!( + incremental_sync_invocations + .iter() + .any(|invocation| invocation.ends_with(" init")) + ); + assert!( + !incremental_sync_invocations + .iter() + .any(|invocation| invocation.split_whitespace().any(|arg| arg == "clone")) + ); + assert!(!incremental_sync_invocations.iter().any(|invocation| { + invocation.starts_with(&format!("-C {} reset ", curated_repo_path.display())) + || invocation.starts_with(&format!("-C {} clean ", curated_repo_path.display())) + })); + assert!(!has_plugins_clone_dirs(tmp.path())); + + let unchanged_sync_invocation_count = invocation_log_contents.lines().count(); + let synced_sha = + sync_openai_plugins_repo_via_git(tmp.path(), git_wrapper.to_str().expect("utf8 path")) + .expect("unchanged git sync should succeed"); + + assert_eq!(synced_sha, updated_sha); + let invocation_log = std::fs::read_to_string(&invocation_log).expect("read sync invocations"); + let unchanged_sync_invocations = invocation_log + .lines() + .skip(unchanged_sync_invocation_count) + .collect::>(); + assert!( + unchanged_sync_invocations + .iter() + .any(|invocation| invocation.starts_with("ls-remote ")) + ); + assert!( + !unchanged_sync_invocations + .iter() + .any(|invocation| invocation.contains(" fetch ")) + ); } #[tokio::test] @@ -482,7 +642,7 @@ exit 1 #[cfg(unix)] #[test] -fn sync_openai_plugins_repo_via_git_cleans_up_staged_dir_on_clone_failure() { +fn sync_openai_plugins_repo_via_git_cleans_up_staged_dir_on_fetch_failure() { let tmp = tempdir().expect("tempdir"); let bin_dir = tempfile::Builder::new() .prefix("fake-git-partial-fail-") @@ -499,9 +659,11 @@ if [ "$1" = "ls-remote" ]; then printf '%s\tHEAD\n' "{sha}" exit 0 fi -if [ "$1" = "clone" ]; then - dest="$5" - mkdir -p "$dest/.git" +if [ "$1" = "-C" ] && [ "$3" = "init" ]; then + mkdir -p "$2/.git" + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "fetch" ]; then echo "fatal: early EOF" >&2 exit 128 fi @@ -518,6 +680,77 @@ exit 1 assert!(!has_plugins_clone_dirs(tmp.path())); } +#[cfg(unix)] +#[test] +fn sync_openai_plugins_repo_via_git_preserves_existing_snapshot_on_validation_failure() { + let tmp = tempdir().expect("tempdir"); + let repo_path = curated_plugins_repo_path(tmp.path()); + write_openai_curated_marketplace(&repo_path, &["gmail"]); + std::fs::create_dir_all(repo_path.join(".git")).expect("create git dir"); + write_curated_plugin_sha(tmp.path()); + + let bin_dir = tempfile::Builder::new() + .prefix("fake-git-invalid-update-") + .tempdir() + .expect("tempdir"); + let git_path = bin_dir.path().join("git"); + let remote_sha = "fedcba9876543210fedcba9876543210fedcba98"; + + write_executable_script( + &git_path, + &format!( + r#"#!/bin/sh +if [ "$1" = "ls-remote" ]; then + printf '%s\tHEAD\n' "{remote_sha}" + exit 0 +fi +if [ "$1" = "-C" ] && [ "$2" = "{}" ] && [ "$3" = "rev-parse" ]; then + printf '%s\n' "{TEST_CURATED_PLUGIN_SHA}" + exit 0 +fi +if [ "$1" = "-C" ] && [ "$2" = "{}" ] && [ "$3" = "fetch" ]; then + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "init" ]; then + mkdir -p "$2/.git" + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "fetch" ]; then + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "reset" ]; then + mkdir -p "$2/plugins/linear/.codex-plugin" + printf '%s\n' '{{"name":"linear"}}' > "$2/plugins/linear/.codex-plugin/plugin.json" + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "clean" ]; then + exit 0 +fi +if [ "$1" = "-C" ] && [ "$3" = "rev-parse" ]; then + printf '%s\n' "{remote_sha}" + exit 0 +fi +echo "unexpected git invocation: $@" >&2 +exit 1 +"#, + repo_path.display(), + repo_path.display(), + ), + ); + + let err = sync_openai_plugins_repo_via_git(tmp.path(), git_path.to_str().expect("utf8 path")) + .expect_err("invalid staged checkout should fail"); + + assert!(err.contains("curated plugins archive missing marketplace manifest")); + assert_curated_gmail_repo(&repo_path); + assert!(!repo_path.join("plugins/linear").exists()); + assert_eq!( + read_curated_plugins_sha(tmp.path()).as_deref(), + Some(TEST_CURATED_PLUGIN_SHA) + ); + assert!(!has_plugins_clone_dirs(tmp.path())); +} + #[tokio::test] async fn sync_openai_plugins_repo_via_http_cleans_up_staged_dir_on_extract_failure() { let tmp = tempdir().expect("tempdir"); diff --git a/codex-rs/core-plugins/src/test_support.rs b/codex-rs/core-plugins/src/test_support.rs index 07f1fd4f26f7..2da64f967480 100644 --- a/codex-rs/core-plugins/src/test_support.rs +++ b/codex-rs/core-plugins/src/test_support.rs @@ -88,10 +88,6 @@ pub(crate) fn write_openai_curated_marketplace(root: &Path, plugin_names: &[&str } } -pub(crate) fn write_curated_plugin_sha(codex_home: &Path) { - write_curated_plugin_sha_with(codex_home, TEST_CURATED_PLUGIN_SHA); -} - pub(crate) fn write_curated_plugin_sha_with(codex_home: &Path, sha: &str) { write_file(&codex_home.join(".tmp/plugins.sha"), &format!("{sha}\n")); } diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index d95c4f70f709..e16eaa387c4a 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -138,9 +138,11 @@ codex-shell-escalation = { workspace = true } [dev-dependencies] assert_cmd = { workspace = true } assert_matches = { workspace = true } +codex-image-generation-extension = { workspace = true } codex-otel = { workspace = true } codex-test-binary-support = { workspace = true } codex-utils-cargo-bin = { workspace = true } +codex-web-search-extension = { workspace = true } core_test_support = { workspace = true } ctor = { workspace = true } insta = { workspace = true } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index f5a97b142a15..b30b868bc7b9 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -614,6 +614,9 @@ "terminal_resize_reflow": { "type": "boolean" }, + "terminal_visualization_instructions": { + "type": "boolean" + }, "tool_call_mcp_elicitation": { "type": "boolean" }, @@ -4737,6 +4740,9 @@ "terminal_resize_reflow": { "type": "boolean" }, + "terminal_visualization_instructions": { + "type": "boolean" + }, "tool_call_mcp_elicitation": { "type": "boolean" }, diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 549852e3602a..6c6650a55933 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -42,9 +42,11 @@ use std::sync::Weak; use tokio::sync::watch; use tracing::warn; -const AGENT_NAMES: &str = include_str!("agent_names.txt"); const ROOT_LAST_TASK_MESSAGE: &str = "Main thread"; +mod legacy; +mod spawn; + #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum SpawnAgentForkMode { FullHistory, @@ -59,11 +61,6 @@ pub(crate) struct SpawnAgentOptions { pub(crate) environments: Option>, } -struct SpawnAgentThreadInheritance { - shell_snapshot: Option>, - exec_policy: Option>, -} - #[derive(Clone, Debug)] pub(crate) struct LiveAgent { pub(crate) thread_id: ThreadId, @@ -78,76 +75,6 @@ pub(crate) struct ListedAgent { pub(crate) last_task_message: Option, } -fn default_agent_nickname_list() -> Vec<&'static str> { - AGENT_NAMES - .lines() - .map(str::trim) - .filter(|name| !name.is_empty()) - .collect() -} - -fn agent_nickname_candidates(config: &Config, role_name: Option<&str>) -> Vec { - let role_name = role_name.unwrap_or(DEFAULT_ROLE_NAME); - if let Some(candidates) = - resolve_role_config(config, role_name).and_then(|role| role.nickname_candidates.clone()) - { - return candidates; - } - - default_agent_nickname_list() - .into_iter() - .map(ToOwned::to_owned) - .collect() -} - -fn keep_forked_rollout_item(item: &RolloutItem, preserve_reference_context_item: bool) -> bool { - match item { - RolloutItem::ResponseItem(ResponseItem::Message { role, phase, .. }) => match role.as_str() - { - "system" | "developer" | "user" => true, - "assistant" => *phase == Some(MessagePhase::FinalAnswer), - _ => false, - }, - RolloutItem::ResponseItem( - ResponseItem::Reasoning { .. } - | ResponseItem::LocalShellCall { .. } - | ResponseItem::FunctionCall { .. } - | ResponseItem::ToolSearchCall { .. } - | ResponseItem::FunctionCallOutput { .. } - | ResponseItem::CustomToolCall { .. } - | ResponseItem::CustomToolCallOutput { .. } - | ResponseItem::ToolSearchOutput { .. } - | ResponseItem::WebSearchCall { .. } - | ResponseItem::ImageGenerationCall { .. } - | ResponseItem::Compaction { .. } - | ResponseItem::CompactionTrigger - | ResponseItem::ContextCompaction { .. } - | ResponseItem::Other, - ) => false, - // Full-history forks preserve the cached prompt prefix and can keep diffing - // from the parent's durable baseline. Truncated forks drop part of that prompt, - // so they must rebuild context on their first child turn. - RolloutItem::TurnContext(_) => preserve_reference_context_item, - RolloutItem::Compacted(_) | RolloutItem::EventMsg(_) | RolloutItem::SessionMeta(_) => true, - } -} - -fn is_multi_agent_v2_usage_hint_message(item: &ResponseItem, usage_hint_texts: &[String]) -> bool { - let ResponseItem::Message { role, content, .. } = item else { - return false; - }; - if role != "developer" { - return false; - } - let [ContentItem::InputText { text }] = content.as_slice() else { - return false; - }; - - usage_hint_texts - .iter() - .any(|usage_hint_text| usage_hint_text == text) -} - /// Control-plane handle for multi-agent operations. /// `AgentControl` is held by each session (via `SessionServices`). It provides capability to /// spawn new agents and the inter-agent communication layer. @@ -184,538 +111,18 @@ impl AgentControl { self.session_id } - /// Spawn a new agent thread and submit the initial prompt. - #[cfg(test)] - pub(crate) async fn spawn_agent( - &self, - config: Config, - initial_operation: Op, - session_source: Option, - ) -> CodexResult { - let spawned_agent = Box::pin(self.spawn_agent_internal( - config, - initial_operation, - session_source, - SpawnAgentOptions::default(), - )) - .await?; - Ok(spawned_agent.thread_id) - } - - /// Spawn an agent thread with some metadata. - pub(crate) async fn spawn_agent_with_metadata( - &self, - config: Config, - initial_operation: Op, - session_source: Option, - options: SpawnAgentOptions, // TODO(jif) drop with new fork. - ) -> CodexResult { - Box::pin(self.spawn_agent_internal(config, initial_operation, session_source, options)) - .await - } - - async fn spawn_agent_internal( - &self, - config: Config, - initial_operation: Op, - session_source: Option, - options: SpawnAgentOptions, - ) -> CodexResult { - let state = self.upgrade()?; - let multi_agent_version = state - .effective_multi_agent_version_for_spawn( - &InitialHistory::New, - session_source.as_ref(), - options.parent_thread_id, - /*forked_from_thread_id*/ None, - &config, - ) - .await; - let agent_max_threads = config.effective_agent_max_threads(multi_agent_version); - let mut reservation = self.state.reserve_spawn_slot(agent_max_threads)?; - let inheritance = SpawnAgentThreadInheritance { - shell_snapshot: self - .inherited_shell_snapshot_for_source(&state, session_source.as_ref()) - .await, - exec_policy: self - .inherited_exec_policy_for_source(&state, session_source.as_ref(), &config) - .await, - }; - let (session_source, mut agent_metadata) = match session_source { - Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id, - depth, - agent_path, - agent_role, - .. - })) => { - let (session_source, agent_metadata) = self.prepare_thread_spawn( - &mut reservation, - &config, - parent_thread_id, - depth, - agent_path, - agent_role, - /*preferred_agent_nickname*/ None, - )?; - (Some(session_source), agent_metadata) - } - other => (other, AgentMetadata::default()), - }; - let notification_source = session_source.clone(); - - // The same `AgentControl` is sent to spawn the thread. - let new_thread = match (session_source, options.fork_mode.as_ref(), inheritance) { - (Some(session_source), Some(_), inheritance) => { - Box::pin(self.spawn_forked_thread( - &state, - config, - session_source, - &options, - inheritance, - multi_agent_version, - )) - .await? - } - (Some(session_source), None, inheritance) => { - Box::pin(state.spawn_new_thread_with_source( - config.clone(), - self.clone(), - session_source, - options.parent_thread_id, - /*forked_from_thread_id*/ None, - /*thread_source*/ Some(ThreadSource::Subagent), - /*metrics_service_name*/ None, - inheritance.shell_snapshot, - inheritance.exec_policy, - options.environments.clone(), - )) - .await? - } - (None, _, _) => Box::pin(state.spawn_new_thread(config.clone(), self.clone())).await?, - }; - agent_metadata.agent_id = Some(new_thread.thread_id); - reservation.commit(agent_metadata.clone()); - - if let Some(SessionSource::SubAgent( - subagent_source @ SubAgentSource::ThreadSpawn { - parent_thread_id, .. - }, - )) = notification_source.as_ref() - { - let client_metadata = match state.get_thread(*parent_thread_id).await { - Ok(parent_thread) => { - parent_thread - .codex - .session - .app_server_client_metadata() - .await - } - Err(error) => { - tracing::warn!( - error = %error, - parent_thread_id = %parent_thread_id, - "skipping subagent thread analytics: failed to load parent thread metadata" - ); - crate::session::session::AppServerClientMetadata { - client_name: None, - client_version: None, - } - } - }; - let thread_config = new_thread.thread.codex.thread_config_snapshot().await; - let parent_thread_id = thread_config.parent_thread_id; - emit_subagent_session_started( - &new_thread - .thread - .codex - .session - .services - .analytics_events_client, - client_metadata, - new_thread.thread.codex.session.session_id(), - new_thread.thread_id, - parent_thread_id, - thread_config, - subagent_source.clone(), - ); - } - - // Notify a new thread has been created. This notification will be processed by clients - // to subscribe or drain this newly created thread. - // TODO(jif) add helper for drain - state.notify_thread_created(new_thread.thread_id); - - self.persist_thread_spawn_edge_for_source( - new_thread.thread.as_ref(), - new_thread.thread_id, - notification_source.as_ref(), - ) - .await; - - self.send_input(new_thread.thread_id, initial_operation) - .await?; - if multi_agent_version != MultiAgentVersion::V2 { - let child_reference = agent_metadata - .agent_path - .as_ref() - .map(ToString::to_string) - .unwrap_or_else(|| new_thread.thread_id.to_string()); - self.maybe_start_completion_watcher( - new_thread.thread_id, - notification_source, - child_reference, - agent_metadata.agent_path.clone(), - ); - } - - Ok(LiveAgent { - thread_id: new_thread.thread_id, - metadata: agent_metadata, - status: self.get_status(new_thread.thread_id).await, - }) - } - - async fn spawn_forked_thread( - &self, - state: &Arc, - config: Config, - session_source: SessionSource, - options: &SpawnAgentOptions, - inheritance: SpawnAgentThreadInheritance, - multi_agent_version: MultiAgentVersion, - ) -> CodexResult { - let SpawnAgentThreadInheritance { - shell_snapshot: inherited_shell_snapshot, - exec_policy: inherited_exec_policy, - } = inheritance; - if options.fork_parent_spawn_call_id.is_none() { - return Err(CodexErr::Fatal( - "spawn_agent fork requires a parent spawn call id".to_string(), - )); - } - let Some(fork_mode) = options.fork_mode.as_ref() else { - return Err(CodexErr::Fatal( - "spawn_agent fork requires a fork mode".to_string(), - )); - }; - let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id, .. - }) = &session_source - else { - return Err(CodexErr::Fatal( - "spawn_agent fork requires a thread-spawn session source".to_string(), - )); - }; - - let parent_thread_id = *parent_thread_id; - let parent_thread = state.get_thread(parent_thread_id).await.ok(); - if let Some(parent_thread) = parent_thread.as_ref() { - // `record_conversation_items` only queues persistence writes asynchronously. - // Flush before snapshotting store history for a fork. - parent_thread.ensure_rollout_materialized().await; - parent_thread.flush_rollout().await?; - } - - let parent_history = state - .read_stored_thread(ReadThreadParams { - thread_id: parent_thread_id, - include_archived: true, - include_history: true, - }) - .await? - .history - .ok_or_else(|| { - CodexErr::Fatal(format!( - "parent thread history unavailable for fork: {parent_thread_id}" - )) - })?; - - let mut forked_rollout_items = parent_history.items; - if let SpawnAgentForkMode::LastNTurns(last_n_turns) = fork_mode { - forked_rollout_items = - truncate_rollout_to_last_n_fork_turns(&forked_rollout_items, *last_n_turns); - } - let multi_agent_v2_usage_hint_texts_to_filter: Vec = - if let Some(parent_thread) = parent_thread.as_ref() { - if multi_agent_version == MultiAgentVersion::V2 { - let parent_config = parent_thread.codex.session.get_config().await; - [ - parent_config - .multi_agent_v2 - .root_agent_usage_hint_text - .clone(), - parent_config - .multi_agent_v2 - .subagent_usage_hint_text - .clone(), - ] - .into_iter() - .flatten() - .collect() - } else { - Vec::new() - } - } else if multi_agent_version == MultiAgentVersion::V2 { - [ - config.multi_agent_v2.root_agent_usage_hint_text.clone(), - config.multi_agent_v2.subagent_usage_hint_text.clone(), - ] - .into_iter() - .flatten() - .collect() - } else { - Vec::new() - }; - let preserve_reference_context_item = matches!(fork_mode, SpawnAgentForkMode::FullHistory); - forked_rollout_items.retain(|item| { - keep_forked_rollout_item(item, preserve_reference_context_item) - && !matches!( - item, - RolloutItem::ResponseItem(response_item) - if is_multi_agent_v2_usage_hint_message( - response_item, - &multi_agent_v2_usage_hint_texts_to_filter, - ) - ) - }); - for item in &mut forked_rollout_items { - if let RolloutItem::Compacted(compacted) = item - && let Some(replacement_history) = compacted.replacement_history.as_mut() - { - replacement_history.retain(|response_item| { - !is_multi_agent_v2_usage_hint_message( - response_item, - &multi_agent_v2_usage_hint_texts_to_filter, - ) - }); - } - } - if preserve_reference_context_item - && multi_agent_version == MultiAgentVersion::V2 - && config.multi_agent_v2.usage_hint_enabled - && let Some(subagent_usage_hint_text) = - config.multi_agent_v2.subagent_usage_hint_text.clone() - && let Some(subagent_usage_hint_message) = - crate::context_manager::updates::build_developer_update_item(vec![ - subagent_usage_hint_text, - ]) - { - forked_rollout_items.push(RolloutItem::ResponseItem(subagent_usage_hint_message)); - } - - state - .fork_thread_with_source( - config.clone(), - InitialHistory::Forked(forked_rollout_items), - self.clone(), - session_source, - /*thread_source*/ Some(ThreadSource::Subagent), - /*parent_thread_id*/ Some(parent_thread_id), - /*forked_from_thread_id*/ Some(parent_thread_id), - inherited_shell_snapshot, - inherited_exec_policy, - options.environments.clone(), - ) - .await - } - - /// Resume an existing agent thread from a recorded rollout file. - pub(crate) async fn resume_agent_from_rollout( - &self, - config: Config, - thread_id: ThreadId, - session_source: SessionSource, - ) -> CodexResult { - let root_depth = thread_spawn_depth(&session_source).unwrap_or(0); - let resumed_thread_id = Box::pin(self.resume_single_agent_from_rollout( - config.clone(), - thread_id, - session_source, - )) - .await?; - let state = self.upgrade()?; - let Ok(resumed_thread) = state.get_thread(resumed_thread_id).await else { - return Ok(resumed_thread_id); - }; - let Some(state_db_ctx) = resumed_thread.state_db() else { - return Ok(resumed_thread_id); - }; - - let mut resume_queue = VecDeque::from([(thread_id, root_depth)]); - while let Some((parent_thread_id, parent_depth)) = resume_queue.pop_front() { - let child_ids = match state_db_ctx - .list_thread_spawn_children_with_status( - parent_thread_id, - DirectionalThreadSpawnEdgeStatus::Open, - ) - .await - { - Ok(child_ids) => child_ids, - Err(err) => { - warn!( - "failed to load persisted thread-spawn children for {parent_thread_id}: {err}" - ); - continue; - } - }; - - for child_thread_id in child_ids { - let child_depth = parent_depth + 1; - let child_resumed = if state.get_thread(child_thread_id).await.is_ok() { - true - } else { - let child_session_source = - SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id, - depth: child_depth, - agent_path: None, - agent_nickname: None, - agent_role: None, - }); - match Box::pin(self.resume_single_agent_from_rollout( - config.clone(), - child_thread_id, - child_session_source, - )) - .await - { - Ok(_) => true, - Err(err) => { - warn!("failed to resume descendant thread {child_thread_id}: {err}"); - false - } - } - }; - if child_resumed { - resume_queue.push_back((child_thread_id, child_depth)); - } - } - } - - Ok(resumed_thread_id) - } - - async fn resume_single_agent_from_rollout( - &self, - config: Config, - thread_id: ThreadId, - session_source: SessionSource, - ) -> CodexResult { - let state = self.upgrade()?; - let state_db_ctx = state.state_db(); - let stored_thread = state - .read_stored_thread(ReadThreadParams { - thread_id, - include_archived: true, - include_history: true, - }) - .await?; - let history = stored_thread - .history - .ok_or_else(|| CodexErr::ThreadNotFound(thread_id))? - .items; - let initial_history = InitialHistory::Resumed(ResumedHistory { - conversation_id: thread_id, - history, - rollout_path: stored_thread.rollout_path, - }); - let parent_thread_id = stored_thread.parent_thread_id; - let multi_agent_version = state - .effective_multi_agent_version_for_spawn( - &initial_history, - Some(&session_source), - parent_thread_id, - /*forked_from_thread_id*/ None, - &config, - ) - .await; - let agent_max_threads = config.effective_agent_max_threads(multi_agent_version); - let mut reservation = self.state.reserve_spawn_slot(agent_max_threads)?; - let (session_source, agent_metadata) = match session_source { - SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id, - depth, - agent_path, - agent_role: _, - agent_nickname: _, - }) => { - let (resumed_agent_nickname, resumed_agent_role) = - if let Some(state_db_ctx) = state_db_ctx.as_ref() { - match state_db_ctx.get_thread(thread_id).await { - Ok(Some(metadata)) => (metadata.agent_nickname, metadata.agent_role), - Ok(None) | Err(_) => (None, None), - } - } else { - (None, None) - }; - self.prepare_thread_spawn( - &mut reservation, - &config, - parent_thread_id, - depth, - agent_path, - resumed_agent_role, - resumed_agent_nickname, - )? - } - other => (other, AgentMetadata::default()), - }; - let notification_source = session_source.clone(); - let inherited_shell_snapshot = self - .inherited_shell_snapshot_for_source(&state, Some(&session_source)) - .await; - let inherited_exec_policy = self - .inherited_exec_policy_for_source(&state, Some(&session_source), &config) - .await; - - let resumed_thread = state - .resume_thread_with_history_with_source(ResumeThreadWithHistoryOptions { - config: config.clone(), - initial_history, - agent_control: self.clone(), - session_source, - parent_thread_id, - inherited_shell_snapshot, - inherited_exec_policy, - }) - .await?; - let mut agent_metadata = agent_metadata; - agent_metadata.agent_id = Some(resumed_thread.thread_id); - reservation.commit(agent_metadata.clone()); - // Resumed threads are re-registered in-memory and need the same listener - // attachment path as freshly spawned threads. - state.notify_thread_created(resumed_thread.thread_id); - if multi_agent_version != MultiAgentVersion::V2 { - let child_reference = agent_metadata - .agent_path - .as_ref() - .map(ToString::to_string) - .unwrap_or_else(|| resumed_thread.thread_id.to_string()); - self.maybe_start_completion_watcher( - resumed_thread.thread_id, - Some(notification_source.clone()), - child_reference, - agent_metadata.agent_path.clone(), - ); - } - self.persist_thread_spawn_edge_for_source( - resumed_thread.thread.as_ref(), - resumed_thread.thread_id, - Some(¬ification_source), - ) - .await; - - Ok(resumed_thread.thread_id) - } - /// Send rich user input items to an existing agent thread. pub(crate) async fn send_input( &self, agent_id: ThreadId, initial_operation: Op, ) -> CodexResult { - let last_task_message = render_input_preview(&initial_operation); + let last_task_message = match &initial_operation { + Op::InterAgentCommunication { communication } => { + last_task_message_from_communication(communication) + } + _ => non_empty_task_message(render_input_preview(&initial_operation)), + }; let state = self.upgrade()?; let result = self .handle_thread_request_result( @@ -725,8 +132,12 @@ impl AgentControl { ) .await; if result.is_ok() { - self.state - .update_last_task_message(agent_id, last_task_message); + match last_task_message { + Some(last_task_message) => self + .state + .update_last_task_message(agent_id, last_task_message), + None => self.state.clear_last_task_message(agent_id), + } } result } @@ -736,7 +147,7 @@ impl AgentControl { agent_id: ThreadId, communication: InterAgentCommunication, ) -> CodexResult { - let last_task_message = communication.content.clone(); + let last_task_message = last_task_message_from_communication(&communication); let state = self.upgrade()?; let result = self .handle_thread_request_result( @@ -748,8 +159,12 @@ impl AgentControl { ) .await; if result.is_ok() { - self.state - .update_last_task_message(agent_id, last_task_message); + match last_task_message { + Some(last_task_message) => self + .state + .update_last_task_message(agent_id, last_task_message), + None => self.state.clear_last_task_message(agent_id), + } } result } @@ -773,86 +188,6 @@ impl AgentControl { result } - /// Submit a shutdown request for a live agent without marking it explicitly closed in - /// persisted spawn-edge state. - pub(crate) async fn shutdown_live_agent(&self, agent_id: ThreadId) -> CodexResult { - let state = self.upgrade()?; - let result = if let Ok(thread) = state.get_thread(agent_id).await { - thread.codex.session.ensure_rollout_materialized().await; - thread.codex.session.flush_rollout().await?; - let result = if matches!(thread.agent_status().await, AgentStatus::Shutdown) { - Ok(String::new()) - } else { - state.send_op(agent_id, Op::Shutdown {}).await - }; - thread.wait_until_terminated().await; - result - } else { - state.send_op(agent_id, Op::Shutdown {}).await - }; - let _ = state.remove_thread(&agent_id).await; - self.state.release_spawned_thread(agent_id); - result - } - - /// Mark `agent_id` as explicitly closed in persisted spawn-edge state, then shut down the - /// agent and any live descendants reached from the in-memory tree. - pub(crate) async fn close_agent(&self, agent_id: ThreadId) -> CodexResult { - let state = self.upgrade()?; - let known_agent = self.state.agent_metadata_for_thread(agent_id).is_some(); - match state.get_thread(agent_id).await { - Ok(thread) => { - if let Some(state_db_ctx) = thread.state_db() - && let Err(err) = state_db_ctx - .set_thread_spawn_edge_status( - agent_id, - DirectionalThreadSpawnEdgeStatus::Closed, - ) - .await - { - warn!("failed to persist thread-spawn edge status for {agent_id}: {err}"); - } - } - Err(CodexErr::ThreadNotFound(_)) if known_agent => { - if let Some(state_db_ctx) = state.state_db() - && let Err(err) = state_db_ctx - .set_thread_spawn_edge_status( - agent_id, - DirectionalThreadSpawnEdgeStatus::Closed, - ) - .await - { - return Err(CodexErr::Fatal(format!( - "failed to persist stale thread-spawn edge status for {agent_id}: {err}" - ))); - } - } - Err(CodexErr::ThreadNotFound(_)) => {} - Err(err) => { - warn!("failed to inspect agent before close {agent_id}: {err}"); - } - } - match Box::pin(self.shutdown_agent_tree(agent_id)).await { - Err(CodexErr::ThreadNotFound(_)) | Err(CodexErr::InternalAgentDied) if known_agent => { - Ok(String::new()) - } - result => result, - } - } - - /// Shut down `agent_id` and any live descendants reachable from the in-memory spawn tree. - async fn shutdown_agent_tree(&self, agent_id: ThreadId) -> CodexResult { - let descendant_ids = self.live_thread_spawn_descendants(agent_id).await?; - let result = self.shutdown_live_agent(agent_id).await; - for descendant_id in descendant_ids { - match self.shutdown_live_agent(descendant_id).await { - Ok(_) | Err(CodexErr::ThreadNotFound(_)) | Err(CodexErr::InternalAgentDied) => {} - Err(err) => return Err(err), - } - } - result - } - /// Fetch the last known status for `agent_id`, returning `NotFound` when unavailable. pub(crate) async fn get_status(&self, agent_id: ThreadId) -> AgentStatus { let Ok(state) = self.upgrade() else { @@ -1126,7 +461,7 @@ impl AgentControl { if let Some(agent_path) = agent_path.as_ref() { reservation.reserve_agent_path(agent_path)?; } - let candidate_names = agent_nickname_candidates(config, agent_role.as_deref()); + let candidate_names = spawn::agent_nickname_candidates(config, agent_role.as_deref()); let candidate_name_refs: Vec<&str> = candidate_names.iter().map(String::as_str).collect(); let agent_nickname = Some(reservation.reserve_agent_nickname_with_preference( &candidate_name_refs, @@ -1329,6 +664,17 @@ pub(crate) fn render_input_preview(initial_operation: &Op) -> String { } } +fn last_task_message_from_communication(communication: &InterAgentCommunication) -> Option { + if communication.encrypted_content.is_some() { + return None; + } + non_empty_task_message(communication.content.clone()) +} + +fn non_empty_task_message(message: String) -> Option { + (!message.is_empty()).then_some(message) +} + fn thread_spawn_depth(session_source: &SessionSource) -> Option { match session_source { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) => Some(*depth), diff --git a/codex-rs/core/src/agent/control/legacy.rs b/codex-rs/core/src/agent/control/legacy.rs new file mode 100644 index 000000000000..e85090b90555 --- /dev/null +++ b/codex-rs/core/src/agent/control/legacy.rs @@ -0,0 +1,83 @@ +use super::*; + +impl AgentControl { + /// Submit a shutdown request for a live agent without marking it explicitly closed in + /// persisted spawn-edge state. + pub(crate) async fn shutdown_live_agent(&self, agent_id: ThreadId) -> CodexResult { + let state = self.upgrade()?; + let result = if let Ok(thread) = state.get_thread(agent_id).await { + thread.codex.session.ensure_rollout_materialized().await; + thread.codex.session.flush_rollout().await?; + let result = if matches!(thread.agent_status().await, AgentStatus::Shutdown) { + Ok(String::new()) + } else { + state.send_op(agent_id, Op::Shutdown {}).await + }; + thread.wait_until_terminated().await; + result + } else { + state.send_op(agent_id, Op::Shutdown {}).await + }; + let _ = state.remove_thread(&agent_id).await; + self.state.release_spawned_thread(agent_id); + result + } + + /// Mark `agent_id` as explicitly closed in persisted spawn-edge state, then shut down the + /// agent and any live descendants reached from the in-memory tree. + pub(crate) async fn close_agent(&self, agent_id: ThreadId) -> CodexResult { + let state = self.upgrade()?; + let known_agent = self.state.agent_metadata_for_thread(agent_id).is_some(); + match state.get_thread(agent_id).await { + Ok(thread) => { + if let Some(state_db_ctx) = thread.state_db() + && let Err(err) = state_db_ctx + .set_thread_spawn_edge_status( + agent_id, + DirectionalThreadSpawnEdgeStatus::Closed, + ) + .await + { + warn!("failed to persist thread-spawn edge status for {agent_id}: {err}"); + } + } + Err(CodexErr::ThreadNotFound(_)) if known_agent => { + if let Some(state_db_ctx) = state.state_db() + && let Err(err) = state_db_ctx + .set_thread_spawn_edge_status( + agent_id, + DirectionalThreadSpawnEdgeStatus::Closed, + ) + .await + { + return Err(CodexErr::Fatal(format!( + "failed to persist stale thread-spawn edge status for {agent_id}: {err}" + ))); + } + } + Err(CodexErr::ThreadNotFound(_)) => {} + Err(err) => { + warn!("failed to inspect agent before close {agent_id}: {err}"); + } + } + match Box::pin(self.shutdown_agent_tree(agent_id)).await { + Err(CodexErr::ThreadNotFound(_)) | Err(CodexErr::InternalAgentDied) if known_agent => { + Ok(String::new()) + } + result => result, + } + } + + /// Shut down `agent_id` and any live descendants reachable from the in-memory spawn tree. + pub(crate) async fn shutdown_agent_tree(&self, agent_id: ThreadId) -> CodexResult { + let descendant_ids = self.live_thread_spawn_descendants(agent_id).await?; + let result = self.shutdown_live_agent(agent_id).await; + for descendant_id in descendant_ids { + match self.shutdown_live_agent(descendant_id).await { + Ok(_) | Err(CodexErr::ThreadNotFound(_)) | Err(CodexErr::InternalAgentDied) => {} + Err(err) => return Err(err), + } + } + result + } +} diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs new file mode 100644 index 000000000000..8df3b0a07f82 --- /dev/null +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -0,0 +1,679 @@ +use super::*; + +const AGENT_NAMES: &str = include_str!("../agent_names.txt"); + +struct SpawnAgentThreadInheritance { + shell_snapshot: Option>, + exec_policy: Option>, +} + +fn default_agent_nickname_list() -> Vec<&'static str> { + AGENT_NAMES + .lines() + .map(str::trim) + .filter(|name| !name.is_empty()) + .collect() +} + +pub(super) fn agent_nickname_candidates(config: &Config, role_name: Option<&str>) -> Vec { + let role_name = role_name.unwrap_or(DEFAULT_ROLE_NAME); + if let Some(candidates) = + resolve_role_config(config, role_name).and_then(|role| role.nickname_candidates.clone()) + { + return candidates; + } + + default_agent_nickname_list() + .into_iter() + .map(ToOwned::to_owned) + .collect() +} + +fn keep_forked_rollout_item(item: &RolloutItem, preserve_reference_context_item: bool) -> bool { + match item { + RolloutItem::ResponseItem(ResponseItem::Message { role, phase, .. }) => match role.as_str() + { + "system" | "developer" | "user" => true, + "assistant" => *phase == Some(MessagePhase::FinalAnswer), + _ => false, + }, + RolloutItem::ResponseItem( + ResponseItem::AgentMessage { .. } + | ResponseItem::Reasoning { .. } + | ResponseItem::LocalShellCall { .. } + | ResponseItem::FunctionCall { .. } + | ResponseItem::ToolSearchCall { .. } + | ResponseItem::FunctionCallOutput { .. } + | ResponseItem::CustomToolCall { .. } + | ResponseItem::CustomToolCallOutput { .. } + | ResponseItem::ToolSearchOutput { .. } + | ResponseItem::WebSearchCall { .. } + | ResponseItem::ImageGenerationCall { .. } + | ResponseItem::Compaction { .. } + | ResponseItem::CompactionTrigger + | ResponseItem::ContextCompaction { .. } + | ResponseItem::Other, + ) => false, + // Full-history forks preserve the cached prompt prefix and can keep diffing + // from the parent's durable baseline. Truncated forks drop part of that prompt, + // so they must rebuild context on their first child turn. + RolloutItem::TurnContext(_) => preserve_reference_context_item, + RolloutItem::Compacted(_) | RolloutItem::EventMsg(_) | RolloutItem::SessionMeta(_) => true, + } +} + +fn is_multi_agent_v2_usage_hint_message(item: &ResponseItem, usage_hint_texts: &[String]) -> bool { + let ResponseItem::Message { role, content, .. } = item else { + return false; + }; + if role != "developer" { + return false; + } + let [ContentItem::InputText { text }] = content.as_slice() else { + return false; + }; + + usage_hint_texts + .iter() + .any(|usage_hint_text| usage_hint_text == text) +} + +impl AgentControl { + /// Spawn a new agent thread and submit the initial prompt. + #[cfg(test)] + pub(crate) async fn spawn_agent( + &self, + config: Config, + initial_operation: Op, + session_source: Option, + ) -> CodexResult { + let spawned_agent = Box::pin(self.spawn_agent_internal( + config, + initial_operation, + session_source, + SpawnAgentOptions::default(), + )) + .await?; + Ok(spawned_agent.thread_id) + } + + /// Spawn an agent thread with some metadata. + pub(crate) async fn spawn_agent_with_metadata( + &self, + config: Config, + initial_operation: Op, + session_source: Option, + options: SpawnAgentOptions, // TODO(jif) drop with new fork. + ) -> CodexResult { + Box::pin(self.spawn_agent_internal(config, initial_operation, session_source, options)) + .await + } + + pub(crate) async fn ensure_v2_agent_loaded( + &self, + config: Config, + thread_id: ThreadId, + ) -> CodexResult<()> { + let state = self.upgrade()?; + if state.get_thread(thread_id).await.is_ok() { + return Ok(()); + } + if self.state.agent_metadata_for_thread(thread_id).is_none() { + return Err(CodexErr::ThreadNotFound(thread_id)); + } + + let stored_thread = state + .read_stored_thread(ReadThreadParams { + thread_id, + include_archived: true, + include_history: true, + }) + .await?; + let stored_source = stored_thread.source.clone(); + let stored_parent_thread_id = stored_thread.parent_thread_id; + let history = stored_thread + .history + .ok_or(CodexErr::ThreadNotFound(thread_id))? + .items; + let initial_history = InitialHistory::Resumed(ResumedHistory { + conversation_id: thread_id, + history, + rollout_path: stored_thread.rollout_path, + }); + if initial_history.get_multi_agent_version() != Some(MultiAgentVersion::V2) { + return Err(CodexErr::ThreadNotFound(thread_id)); + } + + let (session_source, _) = initial_history + .get_resumed_session_sources() + .unwrap_or((stored_source, None)); + let parent_thread_id = initial_history + .get_resumed_parent_thread_id() + .or(stored_parent_thread_id); + let inherited_shell_snapshot = self + .inherited_shell_snapshot_for_source(&state, Some(&session_source)) + .await; + let inherited_exec_policy = self + .inherited_exec_policy_for_source(&state, Some(&session_source), &config) + .await; + + match state + .resume_thread_with_history_with_source(ResumeThreadWithHistoryOptions { + config, + initial_history, + agent_control: self.clone(), + session_source, + parent_thread_id, + inherited_shell_snapshot, + inherited_exec_policy, + }) + .await + { + Ok(reloaded_thread) => { + state.notify_thread_created(reloaded_thread.thread_id); + Ok(()) + } + Err(err) => { + if state.get_thread(thread_id).await.is_ok() { + return Ok(()); + } + Err(err) + } + } + } + + async fn spawn_agent_internal( + &self, + config: Config, + initial_operation: Op, + session_source: Option, + options: SpawnAgentOptions, + ) -> CodexResult { + let state = self.upgrade()?; + let multi_agent_version = state + .effective_multi_agent_version_for_spawn( + &InitialHistory::New, + session_source.as_ref(), + options.parent_thread_id, + /*forked_from_thread_id*/ None, + &config, + ) + .await; + let agent_max_threads = config.effective_agent_max_threads(multi_agent_version); + let mut reservation = self.state.reserve_spawn_slot(agent_max_threads)?; + let inheritance = SpawnAgentThreadInheritance { + shell_snapshot: self + .inherited_shell_snapshot_for_source(&state, session_source.as_ref()) + .await, + exec_policy: self + .inherited_exec_policy_for_source(&state, session_source.as_ref(), &config) + .await, + }; + let (session_source, mut agent_metadata) = match session_source { + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth, + agent_path, + agent_role, + .. + })) => { + let (session_source, agent_metadata) = self.prepare_thread_spawn( + &mut reservation, + &config, + parent_thread_id, + depth, + agent_path, + agent_role, + /*preferred_agent_nickname*/ None, + )?; + (Some(session_source), agent_metadata) + } + other => (other, AgentMetadata::default()), + }; + let notification_source = session_source.clone(); + + // The same `AgentControl` is sent to spawn the thread. + let new_thread = match (session_source, options.fork_mode.as_ref(), inheritance) { + (Some(session_source), Some(_), inheritance) => { + Box::pin(self.spawn_forked_thread( + &state, + config, + session_source, + &options, + inheritance, + multi_agent_version, + )) + .await? + } + (Some(session_source), None, inheritance) => { + Box::pin(state.spawn_new_thread_with_source( + config.clone(), + self.clone(), + session_source, + options.parent_thread_id, + /*forked_from_thread_id*/ None, + /*thread_source*/ Some(ThreadSource::Subagent), + /*metrics_service_name*/ None, + inheritance.shell_snapshot, + inheritance.exec_policy, + options.environments.clone(), + )) + .await? + } + (None, _, _) => Box::pin(state.spawn_new_thread(config.clone(), self.clone())).await?, + }; + agent_metadata.agent_id = Some(new_thread.thread_id); + reservation.commit(agent_metadata.clone()); + + if let Some(SessionSource::SubAgent( + subagent_source @ SubAgentSource::ThreadSpawn { + parent_thread_id, .. + }, + )) = notification_source.as_ref() + { + let client_metadata = match state.get_thread(*parent_thread_id).await { + Ok(parent_thread) => { + parent_thread + .codex + .session + .app_server_client_metadata() + .await + } + Err(error) => { + tracing::warn!( + error = %error, + parent_thread_id = %parent_thread_id, + "skipping subagent thread analytics: failed to load parent thread metadata" + ); + crate::session::session::AppServerClientMetadata { + client_name: None, + client_version: None, + } + } + }; + let thread_config = new_thread.thread.codex.thread_config_snapshot().await; + let parent_thread_id = thread_config.parent_thread_id; + emit_subagent_session_started( + &new_thread + .thread + .codex + .session + .services + .analytics_events_client, + client_metadata, + new_thread.thread.codex.session.session_id(), + new_thread.thread_id, + parent_thread_id, + thread_config, + subagent_source.clone(), + ); + } + + // Notify a new thread has been created. This notification will be processed by clients + // to subscribe or drain this newly created thread. + // TODO(jif) add helper for drain + state.notify_thread_created(new_thread.thread_id); + + self.persist_thread_spawn_edge_for_source( + new_thread.thread.as_ref(), + new_thread.thread_id, + notification_source.as_ref(), + ) + .await; + + self.send_input(new_thread.thread_id, initial_operation) + .await?; + if multi_agent_version != MultiAgentVersion::V2 { + let child_reference = agent_metadata + .agent_path + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| new_thread.thread_id.to_string()); + self.maybe_start_completion_watcher( + new_thread.thread_id, + notification_source, + child_reference, + agent_metadata.agent_path.clone(), + ); + } + + Ok(LiveAgent { + thread_id: new_thread.thread_id, + metadata: agent_metadata, + status: self.get_status(new_thread.thread_id).await, + }) + } + + async fn spawn_forked_thread( + &self, + state: &Arc, + config: Config, + session_source: SessionSource, + options: &SpawnAgentOptions, + inheritance: SpawnAgentThreadInheritance, + multi_agent_version: MultiAgentVersion, + ) -> CodexResult { + let SpawnAgentThreadInheritance { + shell_snapshot: inherited_shell_snapshot, + exec_policy: inherited_exec_policy, + } = inheritance; + if options.fork_parent_spawn_call_id.is_none() { + return Err(CodexErr::Fatal( + "spawn_agent fork requires a parent spawn call id".to_string(), + )); + } + let Some(fork_mode) = options.fork_mode.as_ref() else { + return Err(CodexErr::Fatal( + "spawn_agent fork requires a fork mode".to_string(), + )); + }; + let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, .. + }) = &session_source + else { + return Err(CodexErr::Fatal( + "spawn_agent fork requires a thread-spawn session source".to_string(), + )); + }; + + let parent_thread_id = *parent_thread_id; + let parent_thread = state.get_thread(parent_thread_id).await.ok(); + if let Some(parent_thread) = parent_thread.as_ref() { + // `record_conversation_items` only queues persistence writes asynchronously. + // Flush before snapshotting store history for a fork. + parent_thread.ensure_rollout_materialized().await; + parent_thread.flush_rollout().await?; + } + + let parent_history = state + .read_stored_thread(ReadThreadParams { + thread_id: parent_thread_id, + include_archived: true, + include_history: true, + }) + .await? + .history + .ok_or_else(|| { + CodexErr::Fatal(format!( + "parent thread history unavailable for fork: {parent_thread_id}" + )) + })?; + + let mut forked_rollout_items = parent_history.items; + if let SpawnAgentForkMode::LastNTurns(last_n_turns) = fork_mode { + forked_rollout_items = + truncate_rollout_to_last_n_fork_turns(&forked_rollout_items, *last_n_turns); + } + let multi_agent_v2_usage_hint_texts_to_filter: Vec = + if let Some(parent_thread) = parent_thread.as_ref() { + if multi_agent_version == MultiAgentVersion::V2 { + let parent_config = parent_thread.codex.session.get_config().await; + [ + parent_config + .multi_agent_v2 + .root_agent_usage_hint_text + .clone(), + parent_config + .multi_agent_v2 + .subagent_usage_hint_text + .clone(), + ] + .into_iter() + .flatten() + .collect() + } else { + Vec::new() + } + } else if multi_agent_version == MultiAgentVersion::V2 { + [ + config.multi_agent_v2.root_agent_usage_hint_text.clone(), + config.multi_agent_v2.subagent_usage_hint_text.clone(), + ] + .into_iter() + .flatten() + .collect() + } else { + Vec::new() + }; + let preserve_reference_context_item = matches!(fork_mode, SpawnAgentForkMode::FullHistory); + forked_rollout_items.retain(|item| { + keep_forked_rollout_item(item, preserve_reference_context_item) + && !matches!( + item, + RolloutItem::ResponseItem(response_item) + if is_multi_agent_v2_usage_hint_message( + response_item, + &multi_agent_v2_usage_hint_texts_to_filter, + ) + ) + }); + for item in &mut forked_rollout_items { + if let RolloutItem::Compacted(compacted) = item + && let Some(replacement_history) = compacted.replacement_history.as_mut() + { + replacement_history.retain(|response_item| { + !is_multi_agent_v2_usage_hint_message( + response_item, + &multi_agent_v2_usage_hint_texts_to_filter, + ) + }); + } + } + if preserve_reference_context_item + && multi_agent_version == MultiAgentVersion::V2 + && config.multi_agent_v2.usage_hint_enabled + && let Some(subagent_usage_hint_text) = + config.multi_agent_v2.subagent_usage_hint_text.clone() + && let Some(subagent_usage_hint_message) = + crate::context_manager::updates::build_developer_update_item(vec![ + subagent_usage_hint_text, + ]) + { + forked_rollout_items.push(RolloutItem::ResponseItem(subagent_usage_hint_message)); + } + + state + .fork_thread_with_source( + config.clone(), + InitialHistory::Forked(forked_rollout_items), + self.clone(), + session_source, + /*thread_source*/ Some(ThreadSource::Subagent), + /*parent_thread_id*/ Some(parent_thread_id), + /*forked_from_thread_id*/ Some(parent_thread_id), + inherited_shell_snapshot, + inherited_exec_policy, + options.environments.clone(), + ) + .await + } + + /// Resume an existing agent thread from a recorded rollout file. + pub(crate) async fn resume_agent_from_rollout( + &self, + config: Config, + thread_id: ThreadId, + session_source: SessionSource, + ) -> CodexResult { + let root_depth = thread_spawn_depth(&session_source).unwrap_or(0); + let resumed_thread_id = Box::pin(self.resume_single_agent_from_rollout( + config.clone(), + thread_id, + session_source, + )) + .await?; + let state = self.upgrade()?; + let Ok(resumed_thread) = state.get_thread(resumed_thread_id).await else { + return Ok(resumed_thread_id); + }; + let Some(state_db_ctx) = resumed_thread.state_db() else { + return Ok(resumed_thread_id); + }; + + let mut resume_queue = VecDeque::from([(thread_id, root_depth)]); + while let Some((parent_thread_id, parent_depth)) = resume_queue.pop_front() { + let child_ids = match state_db_ctx + .list_thread_spawn_children_with_status( + parent_thread_id, + DirectionalThreadSpawnEdgeStatus::Open, + ) + .await + { + Ok(child_ids) => child_ids, + Err(err) => { + warn!( + "failed to load persisted thread-spawn children for {parent_thread_id}: {err}" + ); + continue; + } + }; + + for child_thread_id in child_ids { + let child_depth = parent_depth + 1; + let child_resumed = if state.get_thread(child_thread_id).await.is_ok() { + true + } else { + let child_session_source = + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: child_depth, + agent_path: None, + agent_nickname: None, + agent_role: None, + }); + match Box::pin(self.resume_single_agent_from_rollout( + config.clone(), + child_thread_id, + child_session_source, + )) + .await + { + Ok(_) => true, + Err(err) => { + warn!("failed to resume descendant thread {child_thread_id}: {err}"); + false + } + } + }; + if child_resumed { + resume_queue.push_back((child_thread_id, child_depth)); + } + } + } + + Ok(resumed_thread_id) + } + + async fn resume_single_agent_from_rollout( + &self, + config: Config, + thread_id: ThreadId, + session_source: SessionSource, + ) -> CodexResult { + let state = self.upgrade()?; + let state_db_ctx = state.state_db(); + let stored_thread = state + .read_stored_thread(ReadThreadParams { + thread_id, + include_archived: true, + include_history: true, + }) + .await?; + let history = stored_thread + .history + .ok_or_else(|| CodexErr::ThreadNotFound(thread_id))? + .items; + let initial_history = InitialHistory::Resumed(ResumedHistory { + conversation_id: thread_id, + history, + rollout_path: stored_thread.rollout_path, + }); + let parent_thread_id = stored_thread.parent_thread_id; + let multi_agent_version = state + .effective_multi_agent_version_for_spawn( + &initial_history, + Some(&session_source), + parent_thread_id, + /*forked_from_thread_id*/ None, + &config, + ) + .await; + let agent_max_threads = config.effective_agent_max_threads(multi_agent_version); + let mut reservation = self.state.reserve_spawn_slot(agent_max_threads)?; + let (session_source, agent_metadata) = match session_source { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth, + agent_path, + agent_role: _, + agent_nickname: _, + }) => { + let (resumed_agent_nickname, resumed_agent_role) = + if let Some(state_db_ctx) = state_db_ctx.as_ref() { + match state_db_ctx.get_thread(thread_id).await { + Ok(Some(metadata)) => (metadata.agent_nickname, metadata.agent_role), + Ok(None) | Err(_) => (None, None), + } + } else { + (None, None) + }; + self.prepare_thread_spawn( + &mut reservation, + &config, + parent_thread_id, + depth, + agent_path, + resumed_agent_role, + resumed_agent_nickname, + )? + } + other => (other, AgentMetadata::default()), + }; + let notification_source = session_source.clone(); + let inherited_shell_snapshot = self + .inherited_shell_snapshot_for_source(&state, Some(&session_source)) + .await; + let inherited_exec_policy = self + .inherited_exec_policy_for_source(&state, Some(&session_source), &config) + .await; + + let resumed_thread = state + .resume_thread_with_history_with_source(ResumeThreadWithHistoryOptions { + config: config.clone(), + initial_history, + agent_control: self.clone(), + session_source, + parent_thread_id, + inherited_shell_snapshot, + inherited_exec_policy, + }) + .await?; + let mut agent_metadata = agent_metadata; + agent_metadata.agent_id = Some(resumed_thread.thread_id); + reservation.commit(agent_metadata.clone()); + // Resumed threads are re-registered in-memory and need the same listener + // attachment path as freshly spawned threads. + state.notify_thread_created(resumed_thread.thread_id); + if multi_agent_version != MultiAgentVersion::V2 { + let child_reference = agent_metadata + .agent_path + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| resumed_thread.thread_id.to_string()); + self.maybe_start_completion_watcher( + resumed_thread.thread_id, + Some(notification_source.clone()), + child_reference, + agent_metadata.agent_path.clone(), + ); + } + self.persist_thread_spawn_edge_for_source( + resumed_thread.thread.as_ref(), + resumed_thread.thread_id, + Some(¬ification_source), + ) + .await; + + Ok(resumed_thread.thread_id) + } +} diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index f9462e94f653..c9cd44492549 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -527,6 +527,169 @@ async fn send_inter_agent_communication_without_turn_queues_message_without_trig )); } +#[tokio::test] +async fn ensure_v2_agent_loaded_reloads_registered_unloaded_agent() { + let (home, mut config) = test_config().await; + let _ = config.features.enable(Feature::MultiAgentV2); + let _ = config.features.enable(Feature::Sqlite); + let state_db = init_state_db(&config).await; + let manager = ThreadManager::with_models_provider_home_and_state_for_tests( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.to_path_buf(), + std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + state_db.clone(), + ); + let control = manager.agent_control(); + let harness = AgentControlHarness { + _home: home, + config, + state_db, + manager, + control, + }; + let (parent_thread_id, _parent_thread) = harness.start_thread().await; + let agent_path = AgentPath::try_from("/root/worker").expect("agent path"); + let spawned_agent = harness + .control + .spawn_agent_with_metadata( + harness.config.clone(), + text_input("hello child"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: Some(agent_path.clone()), + agent_nickname: None, + agent_role: None, + })), + SpawnAgentOptions { + parent_thread_id: Some(parent_thread_id), + ..Default::default() + }, + ) + .await + .expect("spawn_agent should succeed"); + let child_thread = harness + .manager + .get_thread(spawned_agent.thread_id) + .await + .expect("child thread should exist"); + child_thread + .inject_response_items(vec![assistant_message( + "child persisted", + Some(MessagePhase::FinalAnswer), + )]) + .await + .expect("child rollout should persist with v2 metadata"); + child_thread + .shutdown_and_wait() + .await + .expect("child thread should shut down"); + + assert!( + harness + .manager + .remove_thread(&spawned_agent.thread_id) + .await + .is_some() + ); + match harness.manager.get_thread(spawned_agent.thread_id).await { + Err(CodexErr::ThreadNotFound(id)) => assert_eq!(id, spawned_agent.thread_id), + Err(err) => panic!("expected ThreadNotFound, got {err:?}"), + Ok(_) => panic!("expected thread to be removed"), + } + + harness + .control + .ensure_v2_agent_loaded(harness.config.clone(), spawned_agent.thread_id) + .await + .expect("known v2 agent should reload"); + let _ = harness + .manager + .get_thread(spawned_agent.thread_id) + .await + .expect("reloaded child thread should exist"); + + let communication = InterAgentCommunication::new( + AgentPath::root(), + agent_path, + Vec::new(), + "hello after reload".to_string(), + /*trigger_turn*/ false, + ); + harness + .control + .send_inter_agent_communication(spawned_agent.thread_id, communication.clone()) + .await + .expect("send_inter_agent_communication should succeed after reload"); + let expected = ( + spawned_agent.thread_id, + Op::InterAgentCommunication { communication }, + ); + let captured = harness + .manager + .captured_ops() + .into_iter() + .find(|entry| *entry == expected); + assert_eq!(captured, Some(expected)); +} + +#[tokio::test] +async fn encrypted_inter_agent_communication_clears_existing_last_task_message() { + let harness = AgentControlHarness::new().await; + let (parent_thread_id, _) = harness.start_thread().await; + let agent_path = AgentPath::try_from("/root/worker").expect("agent path"); + let spawned_agent = harness + .control + .spawn_agent_with_metadata( + harness.config.clone(), + text_input("old plaintext task"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: Some(agent_path.clone()), + agent_nickname: None, + agent_role: None, + })), + SpawnAgentOptions { + parent_thread_id: Some(parent_thread_id), + ..Default::default() + }, + ) + .await + .expect("spawn_agent should succeed"); + assert_eq!( + harness + .control + .state + .agent_metadata_for_thread(spawned_agent.thread_id) + .and_then(|metadata| metadata.last_task_message), + Some("old plaintext task".to_string()) + ); + + let communication = InterAgentCommunication::new_encrypted( + AgentPath::root(), + agent_path, + Vec::new(), + "encrypted-task".to_string(), + /*trigger_turn*/ true, + ); + harness + .control + .send_inter_agent_communication(spawned_agent.thread_id, communication) + .await + .expect("send_inter_agent_communication should succeed"); + + assert_eq!( + harness + .control + .state + .agent_metadata_for_thread(spawned_agent.thread_id) + .and_then(|metadata| metadata.last_task_message), + None + ); +} + #[tokio::test] async fn spawn_agent_creates_thread_and_sends_prompt() { let harness = AgentControlHarness::new().await; diff --git a/codex-rs/core/src/agent/registry.rs b/codex-rs/core/src/agent/registry.rs index 1acd73085f44..43aca201bfa2 100644 --- a/codex-rs/core/src/agent/registry.rs +++ b/codex-rs/core/src/agent/registry.rs @@ -180,6 +180,20 @@ impl AgentRegistry { } } + pub(crate) fn clear_last_task_message(&self, thread_id: ThreadId) { + let mut active_agents = self + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(metadata) = active_agents + .agent_tree + .values_mut() + .find(|metadata| metadata.agent_id == Some(thread_id)) + { + metadata.last_task_message = None; + } + } + fn register_spawned_thread(&self, agent_metadata: AgentMetadata) { let Some(thread_id) = agent_metadata.agent_id else { return; diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index bc3740a46fd5..5739e42f309f 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -206,10 +206,7 @@ impl<'a> AgentsMdManager<'a> { return Ok(Vec::new()); } - let mut dir = self.config.cwd.clone(); - if let Ok(canonical_dir) = fs.canonicalize(&dir, /*sandbox*/ None).await { - dir = canonical_dir; - } + let dir = self.config.cwd.clone(); let mut merged = TomlValue::Table(toml::map::Map::new()); for layer in self.config.config_layer_stack.get_layers( diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index 95659511b01d..d8298dfd7dcb 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -5,6 +5,7 @@ use codex_features::Feature; use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::PathBufExt; use core_test_support::TempDirExt; +use core_test_support::create_directory_symlink; use pretty_assertions::assert_eq; use std::fs; use std::path::Path; @@ -223,8 +224,7 @@ async fn project_doc_invalid_utf8_warns_and_uses_lossy_text() { .text(); assert_eq!(res, "project\u{FFFD} doc"); - let canonical_path = dunce::canonicalize(&path).expect("canonical doc path"); - assert_invalid_utf8_warning(&warnings, "Project", &canonical_path); + assert_invalid_utf8_warning(&warnings, "Project", config.cwd.join("AGENTS.md").as_path()); } /// Oversize file is truncated to `project_doc_max_bytes`. @@ -330,10 +330,7 @@ async fn sourceless_user_instructions_preserve_separator_without_reporting_a_sou .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) .await .expect("instructions expected"); - let project_agents = AbsolutePathBuf::try_from( - dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical project doc path"), - ) - .expect("absolute project doc path"); + let project_agents = cfg.cwd.join("AGENTS.md"); assert_eq!( loaded.text(), @@ -385,14 +382,8 @@ async fn concatenates_root_and_cwd_docs() { .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) .await .expect("doc expected"); - let root_agents = AbsolutePathBuf::try_from( - dunce::canonicalize(repo.path().join("AGENTS.md")).expect("canonical root doc path"), - ) - .expect("absolute root doc path"); - let crate_agents = AbsolutePathBuf::try_from( - dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical crate doc path"), - ) - .expect("absolute crate doc path"); + let root_agents = repo.path().join("AGENTS.md").abs(); + let crate_agents = cfg.cwd.join("AGENTS.md"); let expected = LoadedAgentsMd { entries: vec![ InstructionEntry { @@ -434,14 +425,8 @@ async fn project_root_markers_are_honored_for_agents_discovery() { cfg.cwd = nested.abs(); let discovery = agents_md_paths(&cfg).await.expect("discover paths"); - let expected_parent = AbsolutePathBuf::try_from( - dunce::canonicalize(root.path().join("AGENTS.md")).expect("canonical parent doc path"), - ) - .expect("absolute parent doc path"); - let expected_child = AbsolutePathBuf::try_from( - dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical child doc path"), - ) - .expect("absolute child doc path"); + let expected_parent = root.path().join("AGENTS.md").abs(); + let expected_child = cfg.cwd.join("AGENTS.md"); assert_eq!(discovery.len(), 2); assert_eq!(discovery[0], expected_parent); assert_eq!(discovery[1], expected_child); @@ -450,6 +435,26 @@ async fn project_root_markers_are_honored_for_agents_discovery() { assert_eq!(res, "parent doc\n\nchild doc"); } +#[tokio::test] +async fn agents_md_paths_preserve_symlinked_cwd() { + let tmp = tempfile::tempdir().expect("tempdir"); + let target = tmp.path().join("target"); + fs::create_dir(&target).unwrap(); + fs::write(target.join("AGENTS.md"), "project doc").unwrap(); + + let linked_cwd = tmp.path().join("linked"); + create_directory_symlink(&target, &linked_cwd); + + let mut cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await; + cfg.cwd = linked_cwd.abs(); + + let discovery = agents_md_paths(&cfg).await.expect("discover paths"); + assert_eq!(discovery, vec![cfg.cwd.join("AGENTS.md")]); + + let res = get_user_instructions(&cfg).await.expect("doc expected"); + assert_eq!(res, "project doc"); +} + #[tokio::test] async fn child_agents_message_after_global_instructions_uses_plain_separator() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -497,10 +502,7 @@ async fn instruction_sources_include_global_before_agents_md_docs() { .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) .await .expect("instructions expected"); - let project_agents = AbsolutePathBuf::try_from( - dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical project doc path"), - ) - .expect("absolute project doc path"); + let project_agents = cfg.cwd.join("AGENTS.md"); let expected = LoadedAgentsMd { entries: vec![ @@ -541,10 +543,7 @@ async fn child_agents_message_after_project_docs_is_not_an_instruction_source() .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) .await .expect("instructions expected"); - let project_agents = AbsolutePathBuf::try_from( - dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical project doc path"), - ) - .expect("absolute project doc path"); + let project_agents = cfg.cwd.join("AGENTS.md"); let expected = LoadedAgentsMd { entries: vec![ diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 9c133d85e401..dd7b9f400c28 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -44,6 +44,7 @@ use codex_api::RawMemory as ApiRawMemory; use codex_api::RealtimeCallClient as ApiRealtimeCallClient; use codex_api::RealtimeSessionConfig as ApiRealtimeSessionConfig; use codex_api::Reasoning; +use codex_api::ReasoningContext; use codex_api::RequestTelemetry; use codex_api::ReqwestTransport; use codex_api::ResponseCreateWsRequest; @@ -142,7 +143,11 @@ pub const X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER: &str = "x-responsesapi-include-timing-metrics"; const X_CODEX_WS_STREAM_REQUEST_START_MS_CLIENT_METADATA_KEY: &str = "x-codex-ws-stream-request-start-ms"; +const WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY: &str = + "ws_request_header_x_openai_internal_codex_responses_lite"; const RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06"; +const X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER: &str = + "x-openai-internal-codex-responses-lite"; const RESPONSES_ENDPOINT: &str = "/responses"; const RESPONSES_COMPACT_ENDPOINT: &str = "/responses/compact"; // `/responses/compact` is unary, so the timeout covers the full response rather than one idle @@ -525,6 +530,7 @@ impl ModelClient { if let Some(header_value) = self.generate_attestation_header_for().await { extra_headers.insert(X_OAI_ATTESTATION_HEADER, header_value); } + add_responses_lite_header(&mut extra_headers, model_info.use_responses_lite); let compact_request_timeout = client_setup .api_provider .stream_idle_timeout @@ -609,6 +615,7 @@ impl ModelClient { reasoning: effort.map(|effort| Reasoning { effort: Some(effort), summary: None, + context: None, }), }; @@ -653,6 +660,7 @@ impl ModelClient { fn build_ws_client_metadata( &self, turn_metadata_header: Option<&str>, + use_responses_lite: bool, ) -> HashMap { let mut client_metadata = HashMap::new(); client_metadata.insert( @@ -680,6 +688,12 @@ impl ModelClient { turn_metadata.to_string(), ); } + if use_responses_lite { + client_metadata.insert( + WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY.to_string(), + "true".to_string(), + ); + } client_metadata } @@ -727,6 +741,11 @@ impl ModelClient { } else { Some(summary) }, + // When Responses Lite is disabled, omit context so Responses uses the default, + // which is currently `current_turn`. + context: model_info + .use_responses_lite + .then_some(ReasoningContext::AllTurns), }) } else { None @@ -775,7 +794,7 @@ impl ModelClient { input, tools, tool_choice: "auto".to_string(), - parallel_tool_calls: prompt.parallel_tool_calls, + parallel_tool_calls: prompt.parallel_tool_calls && !model_info.use_responses_lite, reasoning, store: provider.is_azure_responses_endpoint(), stream: true, @@ -975,6 +994,7 @@ impl ModelClientSession { &self, turn_metadata_header: Option<&str>, compression: Compression, + use_responses_lite: bool, ) -> ApiResponsesOptions { let turn_metadata_header = parse_turn_metadata_header(turn_metadata_header); let session_id = self.client.state.session_id.to_string(); @@ -993,6 +1013,7 @@ impl ModelClientSession { if let Some(header_value) = self.client.generate_attestation_header_for().await { headers.insert(X_OAI_ATTESTATION_HEADER, header_value); } + add_responses_lite_header(&mut headers, use_responses_lite); headers }, compression, @@ -1260,7 +1281,11 @@ impl ModelClientSession { ); let compression = self.responses_request_compression(client_setup.auth.as_ref()); let mut options = self - .build_responses_options(turn_metadata_header, compression) + .build_responses_options( + turn_metadata_header, + compression, + model_info.use_responses_lite, + ) .await; let request = self.client.build_responses_request( @@ -1370,7 +1395,11 @@ impl ModelClientSession { let compression = self.responses_request_compression(client_setup.auth.as_ref()); let options = self - .build_responses_options(turn_metadata_header, compression) + .build_responses_options( + turn_metadata_header, + compression, + model_info.use_responses_lite, + ) .await; let request = self.client.build_responses_request( &client_setup.api_provider, @@ -1382,7 +1411,10 @@ impl ModelClientSession { )?; let mut ws_payload = ResponseCreateWsRequest { client_metadata: response_create_client_metadata( - Some(self.client.build_ws_client_metadata(turn_metadata_header)), + Some(self.client.build_ws_client_metadata( + turn_metadata_header, + model_info.use_responses_lite, + )), request_trace.as_ref(), ), ..ResponseCreateWsRequest::from(&request) @@ -1700,6 +1732,15 @@ fn build_responses_headers( headers } +fn add_responses_lite_header(headers: &mut ApiHeaderMap, use_responses_lite: bool) { + if use_responses_lite { + headers.insert( + X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER, + HeaderValue::from_static("true"), + ); + } +} + fn subagent_header_value(session_source: &SessionSource) -> Option { match session_source { SessionSource::SubAgent(subagent_source) => match subagent_source { @@ -1943,9 +1984,10 @@ impl AuthRequestTelemetryContext { Self { auth_mode: auth_mode.map(|mode| match mode { AuthMode::ApiKey => "ApiKey", - AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens | AuthMode::AgentIdentity => { - "Chatgpt" - } + AuthMode::Chatgpt + | AuthMode::ChatgptAuthTokens + | AuthMode::AgentIdentity + | AuthMode::PersonalAccessToken => "Chatgpt", }), auth_header_attached: auth_telemetry.attached, auth_header_name: auth_telemetry.name, diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index d775f9c01cfd..48d0523df42b 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -3,6 +3,7 @@ use codex_config::types::Personality; use codex_protocol::error::Result; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::InterAgentCommunication; use codex_tools::ToolSpec; use futures::Stream; use serde_json::Value; @@ -53,7 +54,22 @@ impl Default for Prompt { impl Prompt { pub(crate) fn get_formatted_input(&self) -> Vec { - self.input.clone() + self.input + .iter() + .cloned() + .map(|item| { + let ResponseItem::Message { role, content, .. } = &item else { + return item; + }; + if role != "assistant" { + return item; + } + InterAgentCommunication::from_message_content(content) + .filter(|communication| communication.encrypted_content.is_some()) + .map(|communication| communication.to_model_input_item()) + .unwrap_or(item) + }) + .collect() } } diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index f8d904036afe..2ebffe392284 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -293,7 +293,10 @@ 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 client_metadata = client.build_ws_client_metadata( + Some(r#"{"turn_id":"turn-123"}"#), + /*use_responses_lite*/ false, + ); let thread_id = client.state.thread_id; assert_eq!( client_metadata, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index f9f10881b56b..b3f9cb0e2e5b 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -1,7 +1,5 @@ use crate::agent::AgentStatus; use crate::config::ConstraintResult; -use crate::goals::ExternalGoalSet; -use crate::goals::GoalRuntimeEvent; use crate::session::Codex; use crate::session::SessionSettingsUpdate; use crate::session::SteerInputError; @@ -127,7 +125,7 @@ impl ThreadConfigSnapshot { /// Thread settings overrides that app-server validates before starting a turn. #[derive(Clone, Default)] pub struct CodexThreadSettingsOverrides { - pub cwd: Option, + pub cwd: Option, pub workspace_roots: Option>, pub profile_workspace_roots: Option>, pub approval_policy: Option, @@ -205,51 +203,11 @@ impl CodexThread { } } - pub async fn apply_goal_resume_runtime_effects(&self) -> anyhow::Result<()> { + pub async fn emit_thread_idle_lifecycle_if_idle(&self) { self.codex .session - .goal_runtime_apply(GoalRuntimeEvent::ThreadResumed) - .await - } - - pub async fn continue_active_goal_if_idle(&self) -> anyhow::Result<()> { - self.codex - .session - .goal_runtime_apply(GoalRuntimeEvent::MaybeContinueIfIdle) - .await - } - - pub async fn prepare_external_goal_mutation(&self) { - if let Err(err) = self - .codex - .session - .goal_runtime_apply(GoalRuntimeEvent::ExternalMutationStarting) - .await - { - tracing::warn!("failed to prepare external goal mutation: {err}"); - } - } - - pub async fn apply_external_goal_set(&self, external_set: ExternalGoalSet) { - if let Err(err) = self - .codex - .session - .goal_runtime_apply(GoalRuntimeEvent::ExternalSet { external_set }) - .await - { - tracing::warn!("failed to apply external goal status runtime effects: {err}"); - } - } - - pub async fn apply_external_goal_clear(&self) { - if let Err(err) = self - .codex - .session - .goal_runtime_apply(GoalRuntimeEvent::ExternalClear) - .await - { - tracing::warn!("failed to apply external goal clear runtime effects: {err}"); - } + .emit_thread_idle_lifecycle_if_idle() + .await; } #[doc(hidden)] diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index a480c1eba815..c7730ddfb616 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -348,6 +348,7 @@ pub(crate) fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { } ResponseItem::Message { role, .. } if role == "assistant" => true, ResponseItem::Message { .. } => false, + ResponseItem::AgentMessage { .. } => true, ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true, ResponseItem::CompactionTrigger => false, ResponseItem::Reasoning { .. } diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index 5edecebec03f..87a46fd14a52 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -31,7 +31,7 @@ use codex_config::test_support::CloudConfigBundleFixture; use codex_exec_server::LOCAL_FS; use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::WebSearchMode; -use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; @@ -1375,7 +1375,10 @@ default_permissions = "managed-standard" tokio::fs::write( &requirements_path, r#" -allowed_permissions = ["managed-standard"] +default_permissions = "managed-standard" + +[allowed_permission_profiles] +managed-standard = true [permissions.managed-standard] extends = ":workspace" @@ -1397,8 +1400,8 @@ extends = ":workspace" config .config_layer_stack .requirements_toml() - .allowed_permissions, - Some(vec!["managed-standard".to_string()]) + .allowed_permission_profiles, + Some(BTreeMap::from([("managed-standard".to_string(), true)])) ); assert_eq!( config @@ -1411,26 +1414,9 @@ extends = ":workspace" } #[tokio::test] -async fn system_allowed_permissions_keep_builtin_permission_fallbacks() -> anyhow::Result<()> { - for (trust_level, expected_profile) in [ - ( - Some(TrustLevel::Trusted), - if cfg!(target_os = "windows") { - BUILT_IN_PERMISSION_PROFILE_READ_ONLY - } else { - BUILT_IN_PERMISSION_PROFILE_WORKSPACE - }, - ), - ( - Some(TrustLevel::Untrusted), - if cfg!(target_os = "windows") { - BUILT_IN_PERMISSION_PROFILE_READ_ONLY - } else { - BUILT_IN_PERMISSION_PROFILE_WORKSPACE - }, - ), - (None, BUILT_IN_PERMISSION_PROFILE_READ_ONLY), - ] { +async fn system_allowed_permission_profiles_select_managed_default_without_local_default() +-> anyhow::Result<()> { + for trust_level in [Some(TrustLevel::Trusted), Some(TrustLevel::Untrusted), None] { let tmp = tempdir()?; let codex_home = tmp.path().join("home"); tokio::fs::create_dir_all(&codex_home).await?; @@ -1447,10 +1433,17 @@ async fn system_allowed_permissions_keep_builtin_permission_fallbacks() -> anyho tokio::fs::write( &requirements_path, r#" -allowed_permissions = ["managed-standard"] +default_permissions = "managed-standard" + +[allowed_permission_profiles] +managed-build = true +managed-standard = true [permissions.managed-standard.filesystem] ":workspace_roots" = "read" + +[permissions.managed-build] +extends = ":workspace" "#, ) .await?; @@ -1470,15 +1463,225 @@ allowed_permissions = ["managed-standard"] .permissions .active_permission_profile() .map(|profile| profile.id), - Some(expected_profile.to_string()), + Some("managed-standard".to_string()), "trust level {trust_level:?}", ); + assert!( + !config.startup_warnings.iter().any(|warning| warning + .contains("Configured value for `permission_profile` is disallowed")), + "{:?}", + config.startup_warnings + ); } Ok(()) } #[tokio::test] -async fn system_allowed_permissions_keep_explicit_builtin_defaults() -> anyhow::Result<()> { +async fn system_allowed_permission_profiles_require_managed_default() -> anyhow::Result<()> { + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + let requirements_path = tmp.path().join("requirements.toml"); + tokio::fs::write( + &requirements_path, + r#" +[permissions.managed-standard] +extends = ":read-only" + +[allowed_permission_profiles] +managed-standard = true +"#, + ) + .await?; + + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.system_requirements_path = Some(requirements_path); + let err = ConfigBuilder::default() + .codex_home(codex_home) + .fallback_cwd(Some(tmp.path().to_path_buf())) + .loader_overrides(overrides) + .build() + .await + .expect_err("allowed_permission_profiles without default_permissions should fail"); + + assert!( + err.to_string().contains( + "default_permissions must be set unless allowed_permission_profiles allows both" + ), + "{err}" + ); + Ok(()) +} + +#[tokio::test] +async fn system_allowed_permission_profiles_standard_pair_defaults_to_workspace() +-> anyhow::Result<()> { + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + let requirements_path = tmp.path().join("requirements.toml"); + tokio::fs::write( + &requirements_path, + r#" +[allowed_permission_profiles] +":read-only" = true +":workspace" = true +"#, + ) + .await?; + + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.system_requirements_path = Some(requirements_path); + let config = ConfigBuilder::default() + .codex_home(codex_home) + .fallback_cwd(Some(tmp.path().to_path_buf())) + .loader_overrides(overrides) + .build() + .await?; + + assert_eq!( + config + .permissions + .active_permission_profile() + .map(|profile| profile.id), + Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()) + ); + Ok(()) +} + +#[tokio::test] +async fn system_managed_default_must_be_allowed() -> anyhow::Result<()> { + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + let requirements_path = tmp.path().join("requirements.toml"); + tokio::fs::write( + &requirements_path, + r#" +default_permissions = "managed-build" + +[allowed_permission_profiles] +managed-standard = true + +[permissions.managed-standard] +extends = ":read-only" + +[permissions.managed-build] +extends = ":workspace" +"#, + ) + .await?; + + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.system_requirements_path = Some(requirements_path); + let err = ConfigBuilder::default() + .codex_home(codex_home) + .fallback_cwd(Some(tmp.path().to_path_buf())) + .loader_overrides(overrides) + .build() + .await + .expect_err("managed default outside allowed_permission_profiles should fail"); + + assert!( + err.to_string().contains( + "default_permissions `managed-build` must be allowed by allowed_permission_profiles" + ), + "{err}" + ); + Ok(()) +} + +#[tokio::test] +async fn system_managed_default_requires_allowed_permission_profiles() -> anyhow::Result<()> { + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + let requirements_path = tmp.path().join("requirements.toml"); + tokio::fs::write( + &requirements_path, + r#" +default_permissions = ":read-only" +"#, + ) + .await?; + + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.system_requirements_path = Some(requirements_path); + let err = ConfigBuilder::default() + .codex_home(codex_home) + .fallback_cwd(Some(tmp.path().to_path_buf())) + .loader_overrides(overrides) + .build() + .await + .expect_err("managed default without allowed_permission_profiles should fail"); + + assert!( + err.to_string() + .contains("default_permissions requires allowed_permission_profiles"), + "{err}" + ); + Ok(()) +} + +#[tokio::test] +async fn system_allowed_permission_profiles_fall_back_from_disallowed_danger_full_access() +-> anyhow::Result<()> { + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + tokio::fs::write( + codex_home.join(CONFIG_TOML_FILE), + format!( + r#" +default_permissions = "{BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS}" +"# + ), + ) + .await?; + let requirements_path = tmp.path().join("requirements.toml"); + tokio::fs::write( + &requirements_path, + r#" +default_permissions = "managed-standard" + +[allowed_permission_profiles] +managed-standard = true + +[permissions.managed-standard.filesystem] +":workspace_roots" = "read" +"#, + ) + .await?; + + let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.system_requirements_path = Some(requirements_path); + let config = ConfigBuilder::default() + .codex_home(codex_home) + .fallback_cwd(Some(cwd.to_path_buf())) + .loader_overrides(overrides) + .build() + .await?; + + assert_eq!( + config + .permissions + .active_permission_profile() + .map(|profile| profile.id), + Some("managed-standard".to_string()) + ); + assert!( + config.startup_warnings.iter().any(|warning| warning + .contains("Configured value for `permission_profile` is disallowed by requirements")), + "{:?}", + config.startup_warnings + ); + Ok(()) +} + +#[tokio::test] +async fn system_allowed_permission_profiles_fall_back_from_disallowed_workspace() +-> anyhow::Result<()> { let tmp = tempdir()?; let codex_home = tmp.path().join("home"); tokio::fs::create_dir_all(&codex_home).await?; @@ -1493,7 +1696,10 @@ default_permissions = ":workspace" tokio::fs::write( &requirements_path, r#" -allowed_permissions = ["managed-standard"] +default_permissions = "managed-standard" + +[allowed_permission_profiles] +managed-standard = true [permissions.managed-standard.filesystem] ":workspace_roots" = "read" @@ -1516,7 +1722,13 @@ allowed_permissions = ["managed-standard"] .permissions .active_permission_profile() .map(|profile| profile.id), - Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()) + Some("managed-standard".to_string()) + ); + assert!( + config.startup_warnings.iter().any(|warning| warning + .contains("Configured value for `permission_profile` is disallowed by requirements")), + "{:?}", + config.startup_warnings ); Ok(()) } @@ -1538,7 +1750,11 @@ default_permissions = "managed-build" tokio::fs::write( &requirements_path, r#" -allowed_permissions = ["managed-standard", "managed-build"] +default_permissions = "managed-standard" + +[allowed_permission_profiles] +managed-build = true +managed-standard = true [permissions.managed-standard] extends = ":read-only" @@ -1579,7 +1795,10 @@ async fn system_requirements_warn_for_disallowed_explicit_permission_override() tokio::fs::write( &requirements_path, r#" -allowed_permissions = ["managed-standard"] +default_permissions = "managed-standard" + +[allowed_permission_profiles] +managed-standard = true [permissions.managed-standard] extends = ":workspace" diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 588eb7ac1b4b..3a3b490d697c 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -8351,7 +8351,8 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() allowed_approval_policies: None, allowed_approvals_reviewers: None, allowed_sandbox_modes: None, - allowed_permissions: None, + allowed_permission_profiles: None, + default_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: Some(vec![codex_config::WebSearchModeRequirement::Cached]), allow_managed_hooks_only: None, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index b74f2feefee2..fd74fcf2d251 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -119,6 +119,7 @@ use std::path::Path; use std::path::PathBuf; use std::sync::Arc; +use crate::config::permissions::BUILT_IN_READ_ONLY_PROFILE; use crate::config::permissions::BUILT_IN_WORKSPACE_PROFILE; use crate::config::permissions::apply_network_proxy_feature_config; use crate::config::permissions::builtin_permission_profile; @@ -2245,9 +2246,9 @@ pub struct ConfigOverrides { pub bypass_hook_trust: Option, /// Additional directories that should be treated as writable roots for this session. pub additional_writable_roots: Vec, - /// Explicit runtime workspace roots for this session. When set, this is - /// the full runtime root list rather than an additive override. - pub workspace_roots: Option>, + /// Explicit absolute runtime workspace roots for this session. When set, + /// this is the full runtime root list rather than an additive override. + pub workspace_roots: Option>, } fn dedupe_absolute_paths(paths: &mut Vec) { @@ -2821,12 +2822,7 @@ impl Config { || !requested_additional_writable_roots.is_empty() || legacy_workspace_roots_explicit; let mut workspace_roots = match workspace_roots_override { - Some(workspace_roots) => workspace_roots - .into_iter() - .map(|path| { - AbsolutePathBuf::resolve_path_against_base(path, resolved_cwd.as_path()) - }) - .collect(), + Some(workspace_roots) => workspace_roots, None => { let mut workspace_roots = vec![resolved_cwd.clone()]; workspace_roots.extend(requested_additional_writable_roots.clone()); @@ -3840,7 +3836,9 @@ fn resolve_effective_permission_selection<'a>( Ok(EffectivePermissionSelection { profiles, selected_profile_id, - requirements_force_profile_selection: requirements_toml.allowed_permissions.is_some(), + requirements_force_profile_selection: requirements_toml + .allowed_permission_profiles + .is_some(), }) } @@ -3850,28 +3848,36 @@ fn resolve_default_permissions<'a>( requirements_toml: &'a ConfigRequirementsToml, startup_warnings: &mut Vec, ) -> std::io::Result> { - let allowed_permissions = requirements_toml.allowed_permissions.as_ref(); - let mut default_permissions = default_permissions_override.or(configured_default_permissions); - if let (Some(selected_permissions), Some(allowed_permissions)) = - (default_permissions, allowed_permissions) - && !is_builtin_permission_profile_name(selected_permissions) - && !allowed_permissions - .iter() - .any(|allowed_permission| allowed_permission == selected_permissions) - { - let Some(fallback_permissions) = allowed_permissions.first().map(String::as_str) else { - return Err(std::io::Error::new( - ErrorKind::InvalidInput, - "requirements.toml allowed_permissions must include at least one profile", - )); - }; - startup_warnings.push(format!( - "Configured value for `permission_profile` is disallowed by requirements; falling back from `{selected_permissions}` to required value `{fallback_permissions}`." + let selected_permissions = default_permissions_override.or(configured_default_permissions); + let Some(allowed_permission_profiles) = requirements_toml.allowed_permission_profiles.as_ref() + else { + return Ok(selected_permissions); + }; + let Some(fallback_permissions) = requirements_toml + .default_permissions + .as_deref() + .or_else(|| implicit_default_permissions(allowed_permission_profiles)) + else { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + "requirements.toml default_permissions must be set unless allowed_permission_profiles allows both `:workspace` and `:read-only`", )); - default_permissions = Some(fallback_permissions); - } + }; - Ok(default_permissions) + match selected_permissions { + None => Ok(Some(fallback_permissions)), + Some(selected_permissions) + if is_permission_allowed(allowed_permission_profiles, selected_permissions) => + { + Ok(Some(selected_permissions)) + } + Some(selected_permissions) => { + startup_warnings.push(format!( + "Configured value for `permission_profile` is disallowed by requirements; falling back from `{selected_permissions}` to required value `{fallback_permissions}`." + )); + Ok(Some(fallback_permissions)) + } + } } fn validate_required_permission_profile_catalog( @@ -3885,30 +3891,67 @@ fn validate_required_permission_profile_catalog( .is_some_and(|permissions| permissions.entries.contains_key(profile_id)) }; - let Some(allowed_permissions) = requirements_toml.allowed_permissions.as_ref() else { + let Some(allowed_permission_profiles) = requirements_toml.allowed_permission_profiles.as_ref() + else { + if requirements_toml.default_permissions.is_some() { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + "requirements.toml default_permissions requires allowed_permission_profiles", + )); + } return Ok(()); }; - if allowed_permissions.is_empty() { - return Err(std::io::Error::new( - ErrorKind::InvalidInput, - "requirements.toml allowed_permissions must include at least one profile", - )); - } - - for profile_id in allowed_permissions { + for profile_id in allowed_permission_profiles.keys() { if !is_known_profile(profile_id) { return Err(std::io::Error::new( ErrorKind::InvalidInput, format!( - "requirements.toml allowed_permissions refers to undefined profile `{profile_id}`" + "requirements.toml allowed_permission_profiles refers to undefined profile `{profile_id}`" ), )); } } + let Some(default_permissions) = requirements_toml + .default_permissions + .as_deref() + .or_else(|| implicit_default_permissions(allowed_permission_profiles)) + else { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + "requirements.toml default_permissions must be set unless allowed_permission_profiles allows both `:workspace` and `:read-only`", + )); + }; + if !is_permission_allowed(allowed_permission_profiles, default_permissions) { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + format!( + "requirements.toml default_permissions `{default_permissions}` must be allowed by allowed_permission_profiles" + ), + )); + } + Ok(()) } +fn implicit_default_permissions( + allowed_permission_profiles: &BTreeMap, +) -> Option<&'static str> { + (is_permission_allowed(allowed_permission_profiles, BUILT_IN_WORKSPACE_PROFILE) + && is_permission_allowed(allowed_permission_profiles, BUILT_IN_READ_ONLY_PROFILE)) + .then_some(BUILT_IN_WORKSPACE_PROFILE) +} + +fn is_permission_allowed( + allowed_permission_profiles: &BTreeMap, + profile_id: &str, +) -> bool { + allowed_permission_profiles + .get(profile_id) + .copied() + .unwrap_or(false) +} + fn normalize_guardian_policy_config(value: Option<&str>) -> Option { value.and_then(|value| { let trimmed = value.trim(); diff --git a/codex-rs/core/src/context/contextual_user_message_tests.rs b/codex-rs/core/src/context/contextual_user_message_tests.rs index dc7cb0f5347f..c3f360772ab6 100644 --- a/codex-rs/core/src/context/contextual_user_message_tests.rs +++ b/codex-rs/core/src/context/contextual_user_message_tests.rs @@ -33,14 +33,14 @@ fn detects_subagent_notification_fragment_case_insensitively() { #[test] fn detects_internal_model_context_fragment() { let text = InternalModelContextFragment::new( - InternalContextSource::from_static("goal"), - "Continue working toward the active thread goal.", + InternalContextSource::from_static("extension"), + "Internal steering.", ) .render(); assert_eq!( text, - "\nContinue working toward the active thread goal.\n" + "\nInternal steering.\n" ); assert!(is_contextual_user_fragment(&ContentItem::InputText { text @@ -65,7 +65,7 @@ fn does_not_hide_arbitrary_context_tags() { #[test] fn rejects_invalid_internal_model_context_source() { assert!(!is_contextual_user_fragment(&ContentItem::InputText { - text: "\nbody\n" + text: "\nbody\n" .to_string(), })); } @@ -73,13 +73,13 @@ fn rejects_invalid_internal_model_context_source() { #[test] fn contextual_user_fragment_is_dyn_compatible() { let fragment: Box = Box::new(InternalModelContextFragment::new( - InternalContextSource::from_static("goal"), - "Continue working toward the active thread goal.", + InternalContextSource::from_static("extension"), + "Internal steering.", )); assert_eq!( fragment.render(), - "\nContinue working toward the active thread goal.\n" + "\nInternal steering.\n" ); } diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 0be9bbc18e18..b8bcf9c8467d 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -386,6 +386,7 @@ impl ContextManager { output: truncate_function_output_payload(output, policy_with_serialization_budget), }, ResponseItem::Message { .. } + | ResponseItem::AgentMessage { .. } | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } @@ -474,7 +475,8 @@ pub(crate) fn truncate_function_output_payload( fn is_api_message(message: &ResponseItem) -> bool { match message { ResponseItem::Message { role, .. } => role.as_str() != "system", - ResponseItem::FunctionCallOutput { .. } + ResponseItem::AgentMessage { .. } + | ResponseItem::FunctionCallOutput { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::ToolSearchCall { .. } | ResponseItem::ToolSearchOutput { .. } @@ -720,11 +722,15 @@ fn is_model_generated_item(item: &ResponseItem) -> bool { ResponseItem::FunctionCallOutput { .. } | ResponseItem::ToolSearchOutput { .. } | ResponseItem::CustomToolCallOutput { .. } + | ResponseItem::AgentMessage { .. } | ResponseItem::Other => false, } } pub(crate) fn is_user_turn_boundary(item: &ResponseItem) -> bool { + if matches!(item, ResponseItem::AgentMessage { .. }) { + return true; + } let ResponseItem::Message { role, content, .. } = item else { return false; }; diff --git a/codex-rs/core/src/event_mapping_tests.rs b/codex-rs/core/src/event_mapping_tests.rs index 4cf1b72d5d65..5f2ed6683041 100644 --- a/codex-rs/core/src/event_mapping_tests.rs +++ b/codex-rs/core/src/event_mapping_tests.rs @@ -324,8 +324,8 @@ fn internal_model_context_does_not_parse_as_visible_turn_item() { role: "user".to_string(), content: vec![ContentItem::InputText { text: InternalModelContextFragment::new( - InternalContextSource::from_static("goal"), - "Continue working toward the active thread goal.", + InternalContextSource::from_static("extension"), + "Internal steering.", ) .render(), }], diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index a44ffbe3ab66..7b33a196b086 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -20,9 +20,9 @@ use codex_execpolicy::RuleMatch; use codex_execpolicy::blocking_append_allow_prefix_rule; use codex_execpolicy::blocking_append_network_rule; use codex_protocol::approvals::ExecPolicyAmendment; +use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemSandboxKind; -use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_shell_command::is_dangerous_command::command_might_be_dangerous; use codex_shell_command::is_safe_command::is_known_safe_command; @@ -121,8 +121,7 @@ pub(crate) enum ExecPolicyCommandOrigin { pub(crate) struct UnmatchedCommandContext<'a> { pub(crate) approval_policy: AskForApproval, pub(crate) permission_profile: &'a PermissionProfile, - pub(crate) file_system_sandbox_policy: &'a FileSystemSandboxPolicy, - pub(crate) sandbox_cwd: &'a Path, + pub(crate) windows_sandbox_level: WindowsSandboxLevel, pub(crate) sandbox_permissions: SandboxPermissions, pub(crate) used_complex_parsing: bool, pub(crate) command_origin: ExecPolicyCommandOrigin, @@ -242,8 +241,7 @@ pub(crate) struct ExecApprovalRequest<'a> { pub(crate) command: &'a [String], pub(crate) approval_policy: AskForApproval, pub(crate) permission_profile: PermissionProfile, - pub(crate) file_system_sandbox_policy: &'a FileSystemSandboxPolicy, - pub(crate) sandbox_cwd: &'a Path, + pub(crate) windows_sandbox_level: WindowsSandboxLevel, pub(crate) sandbox_permissions: SandboxPermissions, pub(crate) prefix_rule: Option>, } @@ -277,8 +275,7 @@ impl ExecPolicyManager { command, approval_policy, permission_profile, - file_system_sandbox_policy, - sandbox_cwd, + windows_sandbox_level, sandbox_permissions, prefix_rule, } = req; @@ -298,8 +295,7 @@ impl ExecPolicyManager { UnmatchedCommandContext { approval_policy, permission_profile: &permission_profile, - file_system_sandbox_policy, - sandbox_cwd, + windows_sandbox_level, sandbox_permissions, used_complex_parsing, command_origin, @@ -636,12 +632,12 @@ pub(crate) fn render_decision_for_unmatched_command( let UnmatchedCommandContext { approval_policy, permission_profile, - file_system_sandbox_policy, - sandbox_cwd, + windows_sandbox_level, sandbox_permissions, used_complex_parsing, command_origin, } = context; + let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); let is_known_safe = match command_origin { ExecPolicyCommandOrigin::Generic => is_known_safe_command(command), #[cfg(windows)] @@ -650,19 +646,18 @@ pub(crate) fn render_decision_for_unmatched_command( } }; - // On Windows, ReadOnly sandbox is not a real sandbox, so special-case it - // here. - let environment_lacks_sandbox_protections = cfg!(windows) - && profile_is_managed_read_only( - permission_profile, - file_system_sandbox_policy, - sandbox_cwd, - ); + // When the Windows sandbox backend is disabled, managed filesystem + // restrictions are only a policy shape; there is no platform sandbox to + // enforce the boundary. Keep that legacy case conservative while still + // relying on the real Windows sandbox when it is enabled. + let windows_managed_fs_restrictions_without_sandbox_backend = cfg!(windows) + && windows_sandbox_level == WindowsSandboxLevel::Disabled + && profile_has_managed_filesystem_restrictions(permission_profile); if is_known_safe && !used_complex_parsing && (approval_policy == AskForApproval::UnlessTrusted - || environment_lacks_sandbox_protections) + || windows_managed_fs_restrictions_without_sandbox_backend) { return Decision::Allow; } @@ -680,7 +675,7 @@ pub(crate) fn render_decision_for_unmatched_command( codex_shell_command::is_dangerous_command::is_dangerous_powershell_words(command) } }; - if command_is_dangerous || environment_lacks_sandbox_protections { + if command_is_dangerous || windows_managed_fs_restrictions_without_sandbox_backend { return match approval_policy { AskForApproval::Never => { let sandbox_is_explicitly_disabled = matches!( @@ -749,20 +744,14 @@ pub(crate) fn render_decision_for_unmatched_command( } } -fn profile_is_managed_read_only( - permission_profile: &PermissionProfile, - file_system_sandbox_policy: &FileSystemSandboxPolicy, - sandbox_cwd: &Path, -) -> bool { +fn profile_has_managed_filesystem_restrictions(permission_profile: &PermissionProfile) -> bool { + let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); matches!(permission_profile, PermissionProfile::Managed { .. }) && matches!( file_system_sandbox_policy.kind, FileSystemSandboxKind::Restricted ) && !file_system_sandbox_policy.has_full_disk_write_access() - && file_system_sandbox_policy - .get_writable_roots_with_cwd(sandbox_cwd) - .is_empty() } fn default_policy_path(codex_home: &Path) -> PathBuf { diff --git a/codex-rs/core/src/exec_policy_tests.rs b/codex-rs/core/src/exec_policy_tests.rs index 7b0883db2a90..a74cd5d2dd32 100644 --- a/codex-rs/core/src/exec_policy_tests.rs +++ b/codex-rs/core/src/exec_policy_tests.rs @@ -15,10 +15,12 @@ use codex_config::Sourced; use codex_config::config_toml::ConfigToml; use codex_config::config_toml::ProjectConfig; use codex_protocol::config_types::TrustLevel; +use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; @@ -104,31 +106,6 @@ async fn write_project_trust_config( .await } -fn read_only_file_system_sandbox_policy() -> FileSystemSandboxPolicy { - FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Read, - }]) -} - -fn workspace_write_file_system_sandbox_policy() -> FileSystemSandboxPolicy { - FileSystemSandboxPolicy::workspace_write( - &[], - /*exclude_tmpdir_env_var*/ false, - /*exclude_slash_tmp*/ false, - ) -} - -fn unrestricted_file_system_sandbox_policy() -> FileSystemSandboxPolicy { - FileSystemSandboxPolicy::unrestricted() -} - -fn external_file_system_sandbox_policy() -> FileSystemSandboxPolicy { - FileSystemSandboxPolicy::external_sandbox() -} - async fn test_config() -> (TempDir, Config) { let home = TempDir::new().expect("create temp dir"); let config = ConfigBuilder::without_managed_config_for_tests() @@ -665,7 +642,6 @@ async fn evaluates_bash_lc_inner_commands() { ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -761,7 +737,6 @@ async fn evaluates_heredoc_script_against_prefix_rules() { command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -785,7 +760,6 @@ async fn omits_auto_amendment_for_heredoc_fallback_prompts() { ], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -809,7 +783,6 @@ async fn drops_requested_amendment_for_heredoc_fallback_prompts_when_it_wont_mat ], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: Some(vec![ "python3".to_string(), @@ -837,7 +810,6 @@ async fn drops_requested_amendment_for_heredoc_fallback_prompts_when_it_matches( ], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: Some(vec!["python3".to_string()]), }, @@ -862,7 +834,6 @@ async fn heredoc_with_variable_assignment_is_not_reduced_to_allowed_prefix() { ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -893,7 +864,6 @@ EOF"# ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::workspace_write(), - file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -927,7 +897,6 @@ EOF"# ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::workspace_write(), - file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, }, @@ -967,7 +936,6 @@ prefix_rule( ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -986,7 +954,6 @@ async fn exec_approval_requirement_prefers_execpolicy_match() { command: vec!["rm".to_string()], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1014,7 +981,6 @@ prefix_rule(pattern=["git"], decision="allow") command: vec![git_path, "status".to_string()], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1048,7 +1014,6 @@ prefix_rule(pattern=["git"], decision="prompt") command: vec![disallowed_git_path.clone(), "status".to_string()], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1075,7 +1040,6 @@ async fn requested_prefix_rule_can_approve_absolute_path_commands() { ], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]), }, @@ -1098,7 +1062,6 @@ async fn exec_approval_requirement_respects_approval_policy() { command: vec!["rm".to_string()], approval_policy: AskForApproval::Never, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1126,8 +1089,7 @@ fn unmatched_granular_policy_still_prompts_for_restricted_sandbox_escalation() { mcp_elicitations: true, }), permission_profile: &PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::RequireEscalated, used_complex_parsing: false, command_origin: ExecPolicyCommandOrigin::Generic, @@ -1137,9 +1099,8 @@ fn unmatched_granular_policy_still_prompts_for_restricted_sandbox_escalation() { } #[test] -fn unmatched_on_request_uses_split_filesystem_policy_for_escalation_prompts() { +fn unmatched_on_request_uses_permission_profile_file_system_policy_for_escalation_prompts() { let command = vec!["madeup-cmd".to_string()]; - let restricted_file_system_policy = FileSystemSandboxPolicy::restricted(vec![]); assert_eq!( Decision::Prompt, @@ -1147,9 +1108,8 @@ fn unmatched_on_request_uses_split_filesystem_policy_for_escalation_prompts() { &command, UnmatchedCommandContext { approval_policy: AskForApproval::OnRequest, - permission_profile: &PermissionProfile::Disabled, - file_system_sandbox_policy: &restricted_file_system_policy, - sandbox_cwd: Path::new("/tmp"), + permission_profile: &PermissionProfile::read_only(), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::RequireEscalated, used_complex_parsing: false, command_origin: ExecPolicyCommandOrigin::Generic, @@ -1169,8 +1129,7 @@ fn known_safe_on_request_still_prompts_for_restricted_sandbox_escalation() { UnmatchedCommandContext { approval_policy: AskForApproval::OnRequest, permission_profile: &PermissionProfile::workspace_write(), - file_system_sandbox_policy: &workspace_write_file_system_sandbox_policy(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::RestrictedToken, sandbox_permissions: SandboxPermissions::RequireEscalated, used_complex_parsing: false, command_origin: ExecPolicyCommandOrigin::Generic, @@ -1180,7 +1139,7 @@ fn known_safe_on_request_still_prompts_for_restricted_sandbox_escalation() { } #[test] -fn managed_cwd_write_profile_is_not_read_only() { +fn managed_cwd_write_profile_has_filesystem_restrictions() { let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -1200,15 +1159,13 @@ fn managed_cwd_write_profile_is_not_read_only() { NetworkSandboxPolicy::Restricted, ); - assert!(!profile_is_managed_read_only( - &permission_profile, - &file_system_sandbox_policy, - Path::new("/tmp/project") + assert!(profile_has_managed_filesystem_restrictions( + &permission_profile )); } #[test] -fn managed_unresolvable_write_profile_is_still_read_only() { +fn managed_unresolvable_write_profile_has_filesystem_restrictions() { let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -1231,10 +1188,27 @@ fn managed_unresolvable_write_profile_is_still_read_only() { NetworkSandboxPolicy::Restricted, ); - assert!(profile_is_managed_read_only( - &permission_profile, + assert!(profile_has_managed_filesystem_restrictions( + &permission_profile + )); +} + +#[test] +fn managed_full_disk_write_profile_has_no_filesystem_restrictions() { + let file_system_sandbox_policy = + FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Write, + }]); + let permission_profile = PermissionProfile::from_runtime_permissions( &file_system_sandbox_policy, - Path::new("/tmp/project") + NetworkSandboxPolicy::Restricted, + ); + + assert!(!profile_has_managed_filesystem_restrictions( + &permission_profile )); } @@ -1250,7 +1224,6 @@ async fn exec_approval_requirement_prompts_for_inline_additional_permissions_und ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::WithAdditionalPermissions, prefix_rule: None, }, @@ -1273,7 +1246,6 @@ async fn exec_approval_requirement_prompts_for_known_safe_escalation_under_on_re command: vec!["echo".to_string(), "hello".to_string()], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::workspace_write(), - file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, }, @@ -1303,7 +1275,6 @@ async fn exec_approval_requirement_rejects_known_safe_escalation_when_granular_s mcp_elicitations: true, }), permission_profile: PermissionProfile::workspace_write(), - file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, }, @@ -1329,7 +1300,6 @@ async fn exec_approval_requirement_rejects_unmatched_sandbox_escalation_when_gra mcp_elicitations: true, }), permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, }, @@ -1365,8 +1335,7 @@ async fn mixed_rule_and_sandbox_prompt_prioritizes_rule_for_rejection_decision() mcp_elicitations: true, }), permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, }) @@ -1403,8 +1372,7 @@ async fn mixed_rule_and_sandbox_prompt_rejects_when_granular_rules_are_disabled( mcp_elicitations: true, }), permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: None, }) @@ -1428,8 +1396,7 @@ async fn exec_approval_requirement_falls_back_to_heuristics() { command: &command, approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }) @@ -1454,8 +1421,7 @@ async fn empty_bash_lc_script_falls_back_to_original_command() { command: &command, approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }) @@ -1484,8 +1450,7 @@ async fn whitespace_bash_lc_script_falls_back_to_original_command() { command: &command, approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }) @@ -1514,8 +1479,7 @@ async fn request_rule_uses_prefix_rule() { command: &command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]), }) @@ -1547,8 +1511,7 @@ async fn request_rule_falls_back_when_prefix_rule_does_not_approve_all_commands( command: &command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]), }) @@ -1587,8 +1550,7 @@ async fn heuristics_apply_when_other_commands_match_policy() { command: &command, approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: &unrestricted_file_system_sandbox_policy(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }) @@ -1663,7 +1625,6 @@ async fn proposed_execpolicy_amendment_is_present_for_single_command_without_pol command: command.clone(), approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1683,7 +1644,6 @@ async fn proposed_execpolicy_amendment_is_omitted_when_policy_prompts() { command: vec!["rm".to_string()], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1707,7 +1667,6 @@ async fn proposed_execpolicy_amendment_is_present_for_multi_command_scripts() { ], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1737,7 +1696,6 @@ async fn proposed_execpolicy_amendment_uses_first_no_match_in_multi_command_scri command, approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1761,7 +1719,6 @@ async fn proposed_execpolicy_amendment_is_present_when_heuristics_allow() { command: command.clone(), approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1785,7 +1742,6 @@ async fn proposed_execpolicy_amendment_is_suppressed_when_policy_matches_allow() ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1816,7 +1772,6 @@ prefix_rule(pattern=["cat"], decision="allow") command: command.clone(), approval_policy, permission_profile: PermissionProfile::workspace_write(), - file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -1848,7 +1803,6 @@ prefix_rule(pattern=["bash"], decision="allow") ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -2014,7 +1968,6 @@ async fn dangerous_rm_rf_requires_approval_in_danger_full_access() { command: command.clone(), approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -2078,8 +2031,7 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() { command: &sneaky_command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: permissions, prefix_rule: None, }) @@ -2103,8 +2055,7 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() { command: &dangerous_command, approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: permissions, prefix_rule: None, }) @@ -2124,8 +2075,7 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() { command: &dangerous_command, approval_policy: AskForApproval::Never, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: permissions, prefix_rule: None, }) @@ -2146,7 +2096,6 @@ async fn dangerous_command_allowed_when_sandbox_is_explicitly_disabled() { permission_profile: PermissionProfile::External { network: NetworkSandboxPolicy::Restricted, }, - file_system_sandbox_policy: external_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -2171,7 +2120,6 @@ async fn dangerous_command_forbidden_in_external_sandbox_when_policy_matches() { permission_profile: PermissionProfile::External { network: NetworkSandboxPolicy::Restricted, }, - file_system_sandbox_policy: external_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -2188,7 +2136,6 @@ struct ExecApprovalRequirementScenario { command: Vec, approval_policy: AskForApproval, permission_profile: PermissionProfile, - file_system_sandbox_policy: FileSystemSandboxPolicy, sandbox_permissions: SandboxPermissions, prefix_rule: Option>, } @@ -2212,7 +2159,6 @@ async fn exec_approval_requirement_for_command( command, approval_policy, permission_profile, - file_system_sandbox_policy, sandbox_permissions, prefix_rule, } = test; @@ -2224,8 +2170,7 @@ async fn exec_approval_requirement_for_command( command: &command, approval_policy, permission_profile, - file_system_sandbox_policy: &file_system_sandbox_policy, - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::RestrictedToken, sandbox_permissions, prefix_rule, }) diff --git a/codex-rs/core/src/exec_policy_windows_tests.rs b/codex-rs/core/src/exec_policy_windows_tests.rs index 1d14d938137a..735cdd4ddcea 100644 --- a/codex-rs/core/src/exec_policy_windows_tests.rs +++ b/codex-rs/core/src/exec_policy_windows_tests.rs @@ -1,6 +1,5 @@ use super::*; use pretty_assertions::assert_eq; -use std::path::Path; #[tokio::test] async fn evaluates_powershell_inner_commands_against_prompt_rules() { @@ -15,7 +14,6 @@ async fn evaluates_powershell_inner_commands_against_prompt_rules() { ], approval_policy: AskForApproval::Never, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -39,7 +37,6 @@ async fn evaluates_powershell_inner_commands_against_allow_rules() { ], approval_policy: AskForApproval::UnlessTrusted, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: read_only_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, @@ -81,8 +78,7 @@ fn unmatched_safe_powershell_words_are_allowed() { UnmatchedCommandContext { approval_policy: AskForApproval::UnlessTrusted, permission_profile: &PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), - sandbox_cwd: Path::new("/tmp"), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, used_complex_parsing: false, command_origin: ExecPolicyCommandOrigin::PowerShell, @@ -91,6 +87,90 @@ fn unmatched_safe_powershell_words_are_allowed() { ); } +#[test] +fn read_only_windows_sandbox_runs_unmatched_commands_under_sandbox() { + let command = vec!["cmd.exe".to_string(), "/c".to_string(), "dir".to_string()]; + + for windows_sandbox_level in [ + WindowsSandboxLevel::RestrictedToken, + WindowsSandboxLevel::Elevated, + ] { + assert_eq!( + Decision::Allow, + render_decision_for_unmatched_command( + &command, + UnmatchedCommandContext { + approval_policy: AskForApproval::Never, + permission_profile: &PermissionProfile::read_only(), + windows_sandbox_level, + sandbox_permissions: SandboxPermissions::UseDefault, + used_complex_parsing: false, + command_origin: ExecPolicyCommandOrigin::Generic, + }, + ) + ); + } +} + +#[test] +fn read_only_windows_policy_without_sandbox_backend_still_requires_approval() { + let command = vec!["cmd.exe".to_string(), "/c".to_string(), "dir".to_string()]; + + assert_eq!( + Decision::Forbidden, + render_decision_for_unmatched_command( + &command, + UnmatchedCommandContext { + approval_policy: AskForApproval::Never, + permission_profile: &PermissionProfile::read_only(), + windows_sandbox_level: WindowsSandboxLevel::Disabled, + sandbox_permissions: SandboxPermissions::UseDefault, + used_complex_parsing: false, + command_origin: ExecPolicyCommandOrigin::Generic, + }, + ), + "command is forbidden because approval policy is never and there is no Windows sandbox to rely on" + ); +} + +#[test] +fn writable_windows_policy_without_sandbox_backend_still_requires_approval() { + let command = vec!["cmd.exe".to_string(), "/c".to_string(), "dir".to_string()]; + let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + }, + access: FileSystemAccessMode::Write, + }, + ]); + let permission_profile = PermissionProfile::from_runtime_permissions( + &file_system_sandbox_policy, + NetworkSandboxPolicy::Restricted, + ); + + assert_eq!( + Decision::Forbidden, + render_decision_for_unmatched_command( + &command, + UnmatchedCommandContext { + approval_policy: AskForApproval::Never, + permission_profile: &permission_profile, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + sandbox_permissions: SandboxPermissions::UseDefault, + used_complex_parsing: false, + command_origin: ExecPolicyCommandOrigin::Generic, + }, + ) + ); +} + #[tokio::test] async fn unmatched_dangerous_powershell_inner_commands_require_approval() { let inner_command = vec![ @@ -110,7 +190,6 @@ async fn unmatched_dangerous_powershell_inner_commands_require_approval() { ], approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, - file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }, diff --git a/codex-rs/core/src/goals.rs b/codex-rs/core/src/goals.rs deleted file mode 100644 index 6e0663655c92..000000000000 --- a/codex-rs/core/src/goals.rs +++ /dev/null @@ -1,1622 +0,0 @@ -//! Core support for persisted thread goals. -//! -//! This module bridges core sessions and the state-db goal table. It validates -//! goal mutations, converts between state and protocol shapes, emits goal-update -//! events, and owns helper hooks used by goal lifecycle behavior. - -use crate::StateDbHandle; -use crate::context::ContextualUserFragment; -use crate::context::InternalContextSource; -use crate::context::InternalModelContextFragment; -use crate::session::TurnInput; -use crate::session::session::Session; -use crate::session::turn_context::TurnContext; -use crate::state::ActiveTurn; -use crate::state::TurnState; -use crate::tasks::RegularTask; -use crate::tools::handlers::goal_spec::UPDATE_GOAL_TOOL_NAME; -use anyhow::Context; -use codex_features::Feature; -use codex_otel::GOAL_BLOCKED_METRIC; -use codex_otel::GOAL_BUDGET_LIMITED_METRIC; -use codex_otel::GOAL_COMPLETED_METRIC; -use codex_otel::GOAL_CREATED_METRIC; -use codex_otel::GOAL_DURATION_SECONDS_METRIC; -use codex_otel::GOAL_RESUMED_METRIC; -use codex_otel::GOAL_TOKEN_COUNT_METRIC; -use codex_otel::GOAL_USAGE_LIMITED_METRIC; -use codex_prompts::budget_limit_prompt; -use codex_prompts::continuation_prompt; -use codex_prompts::objective_updated_prompt; -use codex_protocol::ThreadId; -use codex_protocol::config_types::ModeKind; -use codex_protocol::models::ResponseItem; -use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::ThreadGoal; -use codex_protocol::protocol::ThreadGoalStatus; -use codex_protocol::protocol::ThreadGoalUpdatedEvent; -use codex_protocol::protocol::TokenUsage; -use codex_protocol::protocol::validate_thread_goal_objective; -use codex_rollout::state_db::reconcile_rollout; -use codex_thread_store::LocalThreadStore; -use futures::future::BoxFuture; -use std::sync::Arc; -use std::time::Duration; -use std::time::Instant; -use tokio::sync::Mutex; -use tokio::sync::Semaphore; -use tokio::sync::SemaphorePermit; - -pub(crate) struct SetGoalRequest { - pub(crate) objective: Option, - pub(crate) status: Option, - pub(crate) token_budget: Option>, -} - -pub(crate) struct CreateGoalRequest { - pub(crate) objective: String, - pub(crate) token_budget: Option, -} - -#[derive(Clone, Copy)] -enum BudgetLimitSteering { - Allowed, - Suppressed, -} - -#[derive(Clone, Copy)] -enum TerminalMetricEmission { - Emit, - Suppress, -} - -/// Describes whether an external goal mutation created a new logical goal or -/// updated an existing one. -#[derive(Clone)] -pub enum ExternalGoalPreviousStatus { - NewGoal, - Existing(ExternalGoalPreviousGoal), -} - -#[derive(Clone)] -pub struct ExternalGoalPreviousGoal { - goal_id: String, - status: codex_state::ThreadGoalStatus, - objective: String, -} - -impl From<&codex_state::ThreadGoal> for ExternalGoalPreviousStatus { - fn from(goal: &codex_state::ThreadGoal) -> Self { - Self::Existing(ExternalGoalPreviousGoal::from(goal)) - } -} - -impl From<&codex_state::ThreadGoal> for ExternalGoalPreviousGoal { - fn from(goal: &codex_state::ThreadGoal) -> Self { - Self { - goal_id: goal.goal_id.clone(), - status: goal.status, - objective: goal.objective.clone(), - } - } -} - -/// Runtime effects for an externally persisted goal mutation. -#[derive(Clone)] -pub struct ExternalGoalSet { - pub goal: codex_state::ThreadGoal, - pub previous_status: ExternalGoalPreviousStatus, -} - -/// Runtime lifecycle events that can affect goal accounting, scheduling, or -/// model-visible steering. -/// -/// Callers report the session event they observed; this module owns the policy -/// for how that event changes goal runtime state. -pub(crate) enum GoalRuntimeEvent<'a> { - TurnStarted { - turn_context: &'a TurnContext, - token_usage: TokenUsage, - }, - ToolCompleted { - turn_context: &'a TurnContext, - tool_name: &'a str, - }, - ToolCompletedGoal { - turn_context: &'a TurnContext, - }, - TurnFinished { - turn_context: &'a TurnContext, - turn_completed: bool, - }, - MaybeContinueIfIdle, - TaskAborted { - turn_context: Option<&'a TurnContext>, - }, - UsageLimitReached { - turn_context: &'a TurnContext, - }, - ExternalMutationStarting, - ExternalSet { - external_set: ExternalGoalSet, - }, - ExternalClear, - ThreadResumed, -} - -pub(crate) struct GoalRuntimeState { - pub(crate) state_db: Mutex>, - pub(crate) budget_limit_reported_goal_id: Mutex>, - accounting_lock: Semaphore, - accounting: Mutex, - pub(crate) continuation_lock: Semaphore, -} - -struct GoalContinuationCandidate { - goal_id: String, - items: Vec, -} - -impl GoalRuntimeState { - pub(crate) fn new() -> Self { - Self { - state_db: Mutex::new(None), - budget_limit_reported_goal_id: Mutex::new(None), - accounting_lock: Semaphore::new(/*permits*/ 1), - accounting: Mutex::new(GoalAccountingSnapshot::new()), - continuation_lock: Semaphore::new(/*permits*/ 1), - } - } -} - -#[derive(Debug)] -struct GoalAccountingSnapshot { - turn: Option, - wall_clock: GoalWallClockAccountingSnapshot, -} - -#[derive(Debug)] -struct GoalTurnAccountingSnapshot { - turn_id: String, - last_accounted_token_usage: TokenUsage, - active_goal_id: Option, -} - -impl GoalRuntimeState { - async fn accounting_permit(&self) -> anyhow::Result> { - self.accounting_lock - .acquire() - .await - .context("goal accounting semaphore closed") - } -} - -impl GoalAccountingSnapshot { - fn new() -> Self { - Self { - turn: None, - wall_clock: GoalWallClockAccountingSnapshot::new(), - } - } -} - -impl GoalTurnAccountingSnapshot { - fn new(turn_id: impl Into, token_usage: TokenUsage) -> Self { - Self { - turn_id: turn_id.into(), - last_accounted_token_usage: token_usage, - active_goal_id: None, - } - } - - fn mark_active_goal(&mut self, goal_id: impl Into) { - self.active_goal_id = Some(goal_id.into()); - } - - fn active_this_turn(&self) -> bool { - self.active_goal_id.is_some() - } - - fn active_goal_id(&self) -> Option { - self.active_goal_id.clone() - } - - fn clear_active_goal(&mut self) { - self.active_goal_id = None; - } - - fn reset_baseline(&mut self, token_usage: TokenUsage) { - self.last_accounted_token_usage = token_usage; - } - - fn token_delta_since_last_accounting(&self, current: &TokenUsage) -> i64 { - let last = &self.last_accounted_token_usage; - let delta = TokenUsage { - input_tokens: current.input_tokens.saturating_sub(last.input_tokens), - cached_input_tokens: current - .cached_input_tokens - .saturating_sub(last.cached_input_tokens), - output_tokens: current.output_tokens.saturating_sub(last.output_tokens), - reasoning_output_tokens: current - .reasoning_output_tokens - .saturating_sub(last.reasoning_output_tokens), - total_tokens: current.total_tokens.saturating_sub(last.total_tokens), - }; - goal_token_delta_for_usage(&delta) - } - - fn mark_accounted(&mut self, current: TokenUsage) { - self.last_accounted_token_usage = current; - } -} - -#[derive(Debug)] -struct GoalWallClockAccountingSnapshot { - last_accounted_at: Instant, - active_goal_id: Option, -} - -impl GoalWallClockAccountingSnapshot { - fn new() -> Self { - Self { - last_accounted_at: Instant::now(), - active_goal_id: None, - } - } - - fn time_delta_since_last_accounting(&self) -> i64 { - let last = self.last_accounted_at; - i64::try_from(last.elapsed().as_secs()).unwrap_or(i64::MAX) - } - - fn mark_accounted(&mut self, accounted_seconds: i64) { - if accounted_seconds <= 0 { - return; - } - let advance = Duration::from_secs(u64::try_from(accounted_seconds).unwrap_or(u64::MAX)); - self.last_accounted_at = self - .last_accounted_at - .checked_add(advance) - .unwrap_or_else(Instant::now); - } - - fn reset_baseline(&mut self) { - self.last_accounted_at = Instant::now(); - } - - fn mark_active_goal(&mut self, goal_id: impl Into) { - let goal_id = goal_id.into(); - if self.active_goal_id.as_deref() != Some(goal_id.as_str()) { - self.reset_baseline(); - self.active_goal_id = Some(goal_id); - } - } - - fn clear_active_goal(&mut self) { - self.active_goal_id = None; - self.reset_baseline(); - } - - fn active_goal_id(&self) -> Option { - self.active_goal_id.clone() - } -} - -impl Session { - /// Applies runtime policy for a goal lifecycle event. - /// - /// Goal data methods validate and persist state; this dispatcher owns the - /// cross-cutting runtime behavior: plan mode ignores continuations, turn - /// starts capture the active goal and token baseline, tool completions - /// account usage and may inject budget steering, completion accounting - /// suppresses that steering, external mutations account best-effort before - /// changing state, thread resumes restore runtime state for already-active - /// goals, explicit maybe-continue events - /// start idle goal continuation turns, and continuation turns with no counted - /// autonomous activity suppress the next automatic continuation until - /// user/tool/external activity resets it. - pub(crate) fn goal_runtime_apply<'a>( - self: &'a Arc, - event: GoalRuntimeEvent<'a>, - ) -> BoxFuture<'a, anyhow::Result<()>> { - match event { - GoalRuntimeEvent::TurnStarted { - turn_context, - token_usage, - } => Box::pin(async move { - self.mark_thread_goal_turn_started(turn_context, token_usage) - .await; - Ok(()) - }), - GoalRuntimeEvent::ToolCompleted { - turn_context, - tool_name, - } => Box::pin(async move { - if tool_name != UPDATE_GOAL_TOOL_NAME { - self.account_thread_goal_progress( - turn_context, - BudgetLimitSteering::Allowed, - TerminalMetricEmission::Emit, - ) - .await?; - } - Ok(()) - }), - GoalRuntimeEvent::ToolCompletedGoal { turn_context } => Box::pin(async move { - self.account_thread_goal_progress( - turn_context, - BudgetLimitSteering::Suppressed, - TerminalMetricEmission::Suppress, - ) - .await?; - Ok(()) - }), - GoalRuntimeEvent::TurnFinished { - turn_context, - turn_completed, - } => Box::pin(async move { - self.finish_thread_goal_turn(turn_context, turn_completed) - .await; - Ok(()) - }), - GoalRuntimeEvent::MaybeContinueIfIdle => Box::pin(async move { - self.maybe_continue_goal_if_idle_runtime().await; - Ok(()) - }), - GoalRuntimeEvent::TaskAborted { turn_context } => Box::pin(async move { - self.handle_thread_goal_task_abort(turn_context).await; - Ok(()) - }), - GoalRuntimeEvent::UsageLimitReached { turn_context } => Box::pin(async move { - self.usage_limit_active_thread_goal_for_turn(turn_context) - .await?; - Ok(()) - }), - GoalRuntimeEvent::ExternalMutationStarting => Box::pin(async move { - if let Err(err) = self.account_thread_goal_before_external_mutation().await { - tracing::warn!( - "failed to account thread goal progress before external mutation: {err}" - ); - } - Ok(()) - }), - GoalRuntimeEvent::ExternalSet { external_set } => Box::pin(async move { - self.apply_external_thread_goal_status(external_set).await; - Ok(()) - }), - GoalRuntimeEvent::ExternalClear => Box::pin(async move { - self.clear_stopped_thread_goal_runtime_state().await; - Ok(()) - }), - GoalRuntimeEvent::ThreadResumed => Box::pin(async move { - self.restore_thread_goal_runtime_after_resume().await?; - Ok(()) - }), - } - } - - pub(crate) async fn get_thread_goal(&self) -> anyhow::Result> { - if !self.enabled(Feature::Goals) { - anyhow::bail!("goals feature is disabled"); - } - - let state_db = self.require_state_db_for_thread_goals().await?; - state_db - .thread_goals() - .get_thread_goal(self.thread_id) - .await - .map(|goal| goal.map(protocol_goal_from_state)) - } - - pub(crate) async fn set_thread_goal( - &self, - turn_context: &TurnContext, - request: SetGoalRequest, - ) -> anyhow::Result { - if !self.enabled(Feature::Goals) { - anyhow::bail!("goals feature is disabled"); - } - - let SetGoalRequest { - objective, - status, - token_budget, - } = request; - validate_goal_budget(token_budget.flatten())?; - let state_db = self.require_state_db_for_thread_goals().await?; - let objective = objective.map(|objective| objective.trim().to_string()); - if let Some(objective) = objective.as_deref() - && let Err(err) = validate_thread_goal_objective(objective) - { - anyhow::bail!("{err}"); - } - - self.account_thread_goal_wall_clock_usage( - &state_db, - codex_state::GoalAccountingMode::ActiveOnly, - TerminalMetricEmission::Emit, - ) - .await?; - let mut replacing_goal = false; - let previous_status; - let goal = if let Some(objective) = objective.as_deref() { - let existing_goal = state_db - .thread_goals() - .get_thread_goal(self.thread_id) - .await?; - previous_status = existing_goal.as_ref().map(|goal| goal.status); - if let Some(existing_goal) = existing_goal.as_ref() { - state_db - .thread_goals() - .update_thread_goal( - self.thread_id, - codex_state::GoalUpdate { - objective: Some(objective.to_string()), - status: status.map(state_goal_status_from_protocol), - token_budget, - expected_goal_id: Some(existing_goal.goal_id.clone()), - }, - ) - .await? - .ok_or_else(|| { - anyhow::anyhow!( - "cannot update goal for thread {}: no goal exists", - self.thread_id - ) - })? - } else { - replacing_goal = true; - state_db - .thread_goals() - .replace_thread_goal( - self.thread_id, - objective, - status - .map(state_goal_status_from_protocol) - .unwrap_or(codex_state::ThreadGoalStatus::Active), - token_budget.flatten(), - ) - .await? - } - } else { - let existing_goal = state_db - .thread_goals() - .get_thread_goal(self.thread_id) - .await?; - previous_status = existing_goal.as_ref().map(|goal| goal.status); - let expected_goal_id = existing_goal.map(|goal| goal.goal_id); - let status = status.map(state_goal_status_from_protocol); - state_db - .thread_goals() - .update_thread_goal( - self.thread_id, - codex_state::GoalUpdate { - objective: None, - status, - token_budget, - expected_goal_id, - }, - ) - .await? - .ok_or_else(|| { - anyhow::anyhow!( - "cannot update goal for thread {}: no goal exists", - self.thread_id - ) - })? - }; - - if objective.is_some() { - set_thread_preview_from_goal_objective( - &state_db, - self.thread_id, - goal.objective.as_str(), - ) - .await; - } - let goal_status = goal.status; - let goal_id = goal.goal_id.clone(); - let previous_status_for_goal = if replacing_goal { - None - } else { - previous_status - }; - if replacing_goal { - self.emit_goal_created_metric(); - } - self.emit_goal_resumed_metric_if_status_changed(previous_status_for_goal, goal_status); - self.emit_goal_terminal_metrics_if_status_changed(previous_status_for_goal, &goal); - let goal = protocol_goal_from_state(goal); - *self.goal_runtime.budget_limit_reported_goal_id.lock().await = None; - let newly_active_goal = goal_status == codex_state::ThreadGoalStatus::Active - && (replacing_goal - || previous_status - .is_some_and(|status| status != codex_state::ThreadGoalStatus::Active)); - if newly_active_goal { - let current_token_usage = self.total_token_usage().await.unwrap_or_default(); - self.mark_active_goal_accounting( - goal_id, - Some(turn_context.sub_id.clone()), - current_token_usage, - ) - .await; - } else if goal_status != codex_state::ThreadGoalStatus::Active { - self.clear_active_goal_accounting(turn_context).await; - } - self.send_event( - turn_context, - EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent { - thread_id: self.thread_id, - turn_id: Some(turn_context.sub_id.clone()), - goal: goal.clone(), - }), - ) - .await; - Ok(goal) - } - - pub(crate) async fn create_thread_goal( - &self, - turn_context: &TurnContext, - request: CreateGoalRequest, - ) -> anyhow::Result { - if !self.enabled(Feature::Goals) { - anyhow::bail!("goals feature is disabled"); - } - - let CreateGoalRequest { - objective, - token_budget, - } = request; - validate_goal_budget(token_budget)?; - let objective = objective.trim(); - validate_thread_goal_objective(objective).map_err(anyhow::Error::msg)?; - - let state_db = self.require_state_db_for_thread_goals().await?; - self.account_thread_goal_wall_clock_usage( - &state_db, - codex_state::GoalAccountingMode::ActiveOnly, - TerminalMetricEmission::Emit, - ) - .await?; - let goal = state_db - .thread_goals() - .insert_thread_goal( - self.thread_id, - objective, - codex_state::ThreadGoalStatus::Active, - token_budget, - ) - .await? - .ok_or_else(|| { - anyhow::anyhow!( - "cannot create a new goal because thread {} already has a goal", - self.thread_id - ) - })?; - - set_thread_preview_from_goal_objective(&state_db, self.thread_id, goal.objective.as_str()) - .await; - let goal_id = goal.goal_id.clone(); - self.emit_goal_created_metric(); - let goal = protocol_goal_from_state(goal); - *self.goal_runtime.budget_limit_reported_goal_id.lock().await = None; - - let current_token_usage = self.total_token_usage().await.unwrap_or_default(); - self.mark_active_goal_accounting( - goal_id, - Some(turn_context.sub_id.clone()), - current_token_usage, - ) - .await; - - self.send_event( - turn_context, - EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent { - thread_id: self.thread_id, - turn_id: Some(turn_context.sub_id.clone()), - goal: goal.clone(), - }), - ) - .await; - Ok(goal) - } - - async fn apply_external_thread_goal_status(self: &Arc, external_set: ExternalGoalSet) { - let ExternalGoalSet { - goal, - previous_status, - } = external_set; - let previous_goal = match previous_status { - ExternalGoalPreviousStatus::NewGoal => None, - ExternalGoalPreviousStatus::Existing(goal) => Some(goal), - }; - let replaced_existing_goal = previous_goal - .as_ref() - .is_some_and(|previous_goal| previous_goal.goal_id != goal.goal_id); - if previous_goal.is_none() || replaced_existing_goal { - self.emit_goal_created_metric(); - } - let objective_changed = previous_goal - .as_ref() - .is_some_and(|previous_goal| previous_goal.objective != goal.objective); - let previous_status = previous_goal - .as_ref() - .and_then(|previous_goal| (!replaced_existing_goal).then_some(previous_goal.status)); - self.emit_goal_resumed_metric_if_status_changed(previous_status, goal.status); - self.emit_goal_terminal_metrics_if_status_changed(previous_status, &goal); - let goal_for_steering = objective_changed.then(|| protocol_goal_from_state(goal.clone())); - let goal_id = goal.goal_id; - let status = goal.status; - match status { - codex_state::ThreadGoalStatus::Active => { - let turn_id = self - .active_turn_context() - .await - .map(|turn_context| turn_context.sub_id.clone()); - let current_token_usage = self.total_token_usage().await.unwrap_or_default(); - self.mark_active_goal_accounting(goal_id, turn_id, current_token_usage) - .await; - if let Some(goal) = goal_for_steering { - let item = goal_context_input_item(objective_updated_prompt(&goal)); - if self.inject_if_running(vec![item]).await.is_err() { - tracing::debug!( - "skipping objective-updated goal steering because no turn is active" - ); - } - } - self.maybe_continue_goal_if_idle_runtime().await; - } - codex_state::ThreadGoalStatus::BudgetLimited => { - if self.active_turn_context().await.is_none() { - self.clear_stopped_thread_goal_runtime_state().await; - } - } - codex_state::ThreadGoalStatus::Paused - | codex_state::ThreadGoalStatus::Blocked - | codex_state::ThreadGoalStatus::UsageLimited - | codex_state::ThreadGoalStatus::Complete => { - self.clear_stopped_thread_goal_runtime_state().await; - } - } - } - - async fn clear_stopped_thread_goal_runtime_state(&self) { - *self.goal_runtime.budget_limit_reported_goal_id.lock().await = None; - let mut accounting = self.goal_runtime.accounting.lock().await; - if let Some(turn) = accounting.turn.as_mut() { - turn.clear_active_goal(); - } - accounting.wall_clock.clear_active_goal(); - } - - async fn clear_active_goal_accounting(&self, turn_context: &TurnContext) { - let mut accounting = self.goal_runtime.accounting.lock().await; - if let Some(turn) = accounting.turn.as_mut() - && turn.turn_id == turn_context.sub_id - { - turn.clear_active_goal(); - } - accounting.wall_clock.clear_active_goal(); - } - - async fn mark_active_goal_accounting( - &self, - goal_id: String, - turn_id: Option, - token_usage: TokenUsage, - ) { - let mut accounting = self.goal_runtime.accounting.lock().await; - if let Some(turn_id) = turn_id { - match accounting.turn.as_mut() { - Some(turn) if turn.turn_id == turn_id => { - turn.reset_baseline(token_usage); - turn.mark_active_goal(goal_id.clone()); - } - _ => { - let mut turn = GoalTurnAccountingSnapshot::new(turn_id, token_usage); - turn.mark_active_goal(goal_id.clone()); - accounting.turn = Some(turn); - } - } - } - accounting.wall_clock.mark_active_goal(goal_id); - } - - fn emit_goal_created_metric(&self) { - self.services - .session_telemetry - .counter(GOAL_CREATED_METRIC, /*inc*/ 1, &[]); - } - - fn emit_goal_resumed_metric(&self) { - self.services - .session_telemetry - .counter(GOAL_RESUMED_METRIC, /*inc*/ 1, &[]); - } - - fn emit_goal_resumed_metric_if_status_changed( - &self, - previous_status: Option, - goal_status: codex_state::ThreadGoalStatus, - ) { - if goal_status == codex_state::ThreadGoalStatus::Active - && matches!( - previous_status, - Some( - codex_state::ThreadGoalStatus::Paused - | codex_state::ThreadGoalStatus::Blocked - | codex_state::ThreadGoalStatus::UsageLimited - ) - ) - { - self.emit_goal_resumed_metric(); - } - } - - fn emit_goal_terminal_metrics_if_status_changed( - &self, - previous_status: Option, - goal: &codex_state::ThreadGoal, - ) { - if previous_status == Some(goal.status) { - return; - } - - let counter = match goal.status { - codex_state::ThreadGoalStatus::Blocked => GOAL_BLOCKED_METRIC, - codex_state::ThreadGoalStatus::UsageLimited => GOAL_USAGE_LIMITED_METRIC, - codex_state::ThreadGoalStatus::BudgetLimited => GOAL_BUDGET_LIMITED_METRIC, - codex_state::ThreadGoalStatus::Complete => GOAL_COMPLETED_METRIC, - codex_state::ThreadGoalStatus::Active | codex_state::ThreadGoalStatus::Paused => { - return; - } - }; - let status_tag = [("status", goal.status.as_str())]; - self.services - .session_telemetry - .counter(counter, /*inc*/ 1, &[]); - self.services.session_telemetry.histogram( - GOAL_TOKEN_COUNT_METRIC, - goal.tokens_used, - &status_tag, - ); - self.services.session_telemetry.histogram( - GOAL_DURATION_SECONDS_METRIC, - goal.time_used_seconds, - &status_tag, - ); - } - - async fn current_goal_status_for_metrics( - &self, - state_db: &StateDbHandle, - expected_goal_id: Option<&str>, - ) -> anyhow::Result> { - let goal = state_db - .thread_goals() - .get_thread_goal(self.thread_id) - .await?; - Ok(goal.and_then(|goal| { - expected_goal_id - .is_none_or(|expected_goal_id| goal.goal_id == expected_goal_id) - .then_some(goal.status) - })) - } - - async fn active_turn_context(&self) -> Option> { - let active = self.active_turn.lock().await; - active - .as_ref() - .and_then(|active_turn| active_turn.task.as_ref()) - .map(|task| Arc::clone(&task.turn_context)) - } - - async fn mark_thread_goal_turn_started( - &self, - turn_context: &TurnContext, - token_usage: TokenUsage, - ) { - self.goal_runtime.accounting.lock().await.turn = Some(GoalTurnAccountingSnapshot::new( - turn_context.sub_id.clone(), - token_usage, - )); - - if !self.enabled(Feature::Goals) { - return; - } - if should_ignore_goal_for_mode(turn_context.collaboration_mode.mode) { - self.clear_active_goal_accounting(turn_context).await; - return; - } - let state_db = match self.state_db_for_thread_goals().await { - Ok(Some(state_db)) => state_db, - Ok(None) => return, - Err(err) => { - tracing::warn!("failed to open state db at turn start: {err}"); - return; - } - }; - match state_db - .thread_goals() - .get_thread_goal(self.thread_id) - .await - { - Ok(Some(goal)) - if matches!( - goal.status, - codex_state::ThreadGoalStatus::Active - | codex_state::ThreadGoalStatus::BudgetLimited - ) => - { - let mut accounting = self.goal_runtime.accounting.lock().await; - if let Some(turn) = accounting.turn.as_mut() - && turn.turn_id == turn_context.sub_id - { - turn.mark_active_goal(goal.goal_id.clone()); - } - accounting.wall_clock.mark_active_goal(goal.goal_id); - } - Ok(Some(_)) | Ok(None) => { - self.goal_runtime - .accounting - .lock() - .await - .wall_clock - .clear_active_goal(); - } - Err(err) => { - tracing::warn!("failed to read thread goal at turn start: {err}"); - } - } - } - - async fn clear_reserved_goal_continuation_turn(&self, turn_state: &Arc>) { - let mut active_turn_guard = self.active_turn.lock().await; - if let Some(active_turn) = active_turn_guard.as_ref() - && active_turn.task.is_none() - && Arc::ptr_eq(&active_turn.turn_state, turn_state) - { - *active_turn_guard = None; - } - } - - async fn finish_thread_goal_turn( - self: &Arc, - turn_context: &TurnContext, - turn_completed: bool, - ) { - if turn_completed - && let Err(err) = self - .account_thread_goal_progress( - turn_context, - BudgetLimitSteering::Suppressed, - TerminalMetricEmission::Emit, - ) - .await - { - tracing::warn!("failed to account thread goal progress at turn end: {err}"); - } - - if turn_completed { - let mut accounting = self.goal_runtime.accounting.lock().await; - if accounting - .turn - .as_ref() - .is_some_and(|turn| turn.turn_id == turn_context.sub_id) - { - accounting.turn = None; - } - } - } - - async fn handle_thread_goal_task_abort(&self, turn_context: Option<&TurnContext>) { - if let Some(turn_context) = turn_context { - if let Err(err) = self - .account_thread_goal_progress( - turn_context, - BudgetLimitSteering::Suppressed, - TerminalMetricEmission::Emit, - ) - .await - { - tracing::warn!("failed to account thread goal progress after abort: {err}"); - } - let mut accounting = self.goal_runtime.accounting.lock().await; - if accounting - .turn - .as_ref() - .is_some_and(|turn| turn.turn_id == turn_context.sub_id) - { - accounting.turn = None; - } - } - } - - async fn account_thread_goal_progress( - &self, - turn_context: &TurnContext, - budget_limit_steering: BudgetLimitSteering, - terminal_metric_emission: TerminalMetricEmission, - ) -> anyhow::Result<()> { - if !self.enabled(Feature::Goals) { - return Ok(()); - } - if should_ignore_goal_for_mode(turn_context.collaboration_mode.mode) { - return Ok(()); - } - let Some(state_db) = self.state_db_for_thread_goals().await? else { - return Ok(()); - }; - let _accounting_permit = self.goal_runtime.accounting_permit().await?; - let current_token_usage = self.total_token_usage().await.unwrap_or_default(); - let (token_delta, expected_goal_id, time_delta_seconds) = { - let accounting = self.goal_runtime.accounting.lock().await; - let Some(turn) = accounting - .turn - .as_ref() - .filter(|turn| turn.turn_id == turn_context.sub_id) - else { - return Ok(()); - }; - if !turn.active_this_turn() { - return Ok(()); - } - ( - turn.token_delta_since_last_accounting(¤t_token_usage), - turn.active_goal_id(), - accounting.wall_clock.time_delta_since_last_accounting(), - ) - }; - if time_delta_seconds == 0 && token_delta <= 0 { - return Ok(()); - } - let previous_status = self - .current_goal_status_for_metrics(&state_db, expected_goal_id.as_deref()) - .await?; - let outcome = state_db - .thread_goals() - .account_thread_goal_usage( - self.thread_id, - time_delta_seconds, - token_delta, - codex_state::GoalAccountingMode::ActiveOnly, - expected_goal_id.as_deref(), - ) - .await?; - let budget_limit_was_already_reported = { - let reported_goal_id = self.goal_runtime.budget_limit_reported_goal_id.lock().await; - expected_goal_id - .as_deref() - .is_some_and(|goal_id| reported_goal_id.as_deref() == Some(goal_id)) - }; - let goal = match outcome { - codex_state::GoalAccountingOutcome::Updated(goal) => { - let clear_active_goal = match goal.status { - codex_state::ThreadGoalStatus::Active => false, - codex_state::ThreadGoalStatus::BudgetLimited => { - matches!(budget_limit_steering, BudgetLimitSteering::Suppressed) - } - codex_state::ThreadGoalStatus::Paused - | codex_state::ThreadGoalStatus::Blocked - | codex_state::ThreadGoalStatus::UsageLimited - | codex_state::ThreadGoalStatus::Complete => true, - }; - { - let mut accounting = self.goal_runtime.accounting.lock().await; - if let Some(turn) = accounting - .turn - .as_mut() - .filter(|turn| turn.turn_id == turn_context.sub_id) - { - turn.mark_accounted(current_token_usage); - if clear_active_goal { - turn.clear_active_goal(); - } - } - accounting.wall_clock.mark_accounted(time_delta_seconds); - if clear_active_goal { - accounting.wall_clock.clear_active_goal(); - } - } - if matches!(terminal_metric_emission, TerminalMetricEmission::Emit) { - self.emit_goal_terminal_metrics_if_status_changed(previous_status, &goal); - } - goal - } - codex_state::GoalAccountingOutcome::Unchanged(_) => return Ok(()), - }; - let should_steer_budget_limit = - matches!(budget_limit_steering, BudgetLimitSteering::Allowed) - && goal.status == codex_state::ThreadGoalStatus::BudgetLimited - && !budget_limit_was_already_reported; - let goal_status = goal.status; - let goal_id = goal.goal_id.clone(); - if goal_status != codex_state::ThreadGoalStatus::BudgetLimited { - *self.goal_runtime.budget_limit_reported_goal_id.lock().await = None; - } - let goal = protocol_goal_from_state(goal); - self.send_event( - turn_context, - EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent { - thread_id: self.thread_id, - turn_id: Some(turn_context.sub_id.clone()), - goal: goal.clone(), - }), - ) - .await; - if should_steer_budget_limit { - let item = budget_limit_steering_item(&goal); - if self.inject_if_running(vec![item]).await.is_err() { - tracing::debug!("skipping budget-limit goal steering because no turn is active"); - } - *self.goal_runtime.budget_limit_reported_goal_id.lock().await = Some(goal_id); - } - Ok(()) - } - - async fn account_thread_goal_before_external_mutation(&self) -> anyhow::Result<()> { - if let Some(turn_context) = self.active_turn_context().await { - return self - .account_thread_goal_progress( - turn_context.as_ref(), - BudgetLimitSteering::Suppressed, - TerminalMetricEmission::Emit, - ) - .await; - } - - let Some(state_db) = self.state_db_for_thread_goals().await? else { - return Ok(()); - }; - self.account_thread_goal_wall_clock_usage( - &state_db, - codex_state::GoalAccountingMode::ActiveOnly, - TerminalMetricEmission::Suppress, - ) - .await?; - Ok(()) - } - - async fn account_thread_goal_wall_clock_usage( - &self, - state_db: &StateDbHandle, - mode: codex_state::GoalAccountingMode, - terminal_metric_emission: TerminalMetricEmission, - ) -> anyhow::Result> { - let _accounting_permit = self.goal_runtime.accounting_permit().await?; - let (time_delta_seconds, expected_goal_id) = { - let accounting = self.goal_runtime.accounting.lock().await; - ( - accounting.wall_clock.time_delta_since_last_accounting(), - accounting.wall_clock.active_goal_id(), - ) - }; - if time_delta_seconds == 0 { - return Ok(None); - } - let previous_status = self - .current_goal_status_for_metrics(state_db, expected_goal_id.as_deref()) - .await?; - - match state_db - .thread_goals() - .account_thread_goal_usage( - self.thread_id, - time_delta_seconds, - /*token_delta*/ 0, - mode, - expected_goal_id.as_deref(), - ) - .await? - { - codex_state::GoalAccountingOutcome::Updated(goal) => { - if matches!(terminal_metric_emission, TerminalMetricEmission::Emit) { - self.emit_goal_terminal_metrics_if_status_changed(previous_status, &goal); - } - self.goal_runtime - .accounting - .lock() - .await - .wall_clock - .mark_accounted(time_delta_seconds); - let goal = protocol_goal_from_state(goal); - Ok(Some(goal)) - } - codex_state::GoalAccountingOutcome::Unchanged(goal) => { - { - let mut accounting = self.goal_runtime.accounting.lock().await; - accounting.wall_clock.reset_baseline(); - accounting.wall_clock.clear_active_goal(); - } - if let Some(goal) = goal { - let goal = protocol_goal_from_state(goal); - return Ok(Some(goal)); - } - Ok(None) - } - } - } - - async fn usage_limit_active_thread_goal_for_turn( - &self, - turn_context: &TurnContext, - ) -> anyhow::Result<()> { - if should_ignore_goal_for_mode(turn_context.collaboration_mode.mode) { - return Ok(()); - } - - if !self.enabled(Feature::Goals) { - return Ok(()); - } - - let _continuation_guard = self - .goal_runtime - .continuation_lock - .acquire() - .await - .context("goal continuation semaphore closed")?; - let Some(state_db) = self.state_db_for_thread_goals().await? else { - return Ok(()); - }; - self.account_thread_goal_progress( - turn_context, - BudgetLimitSteering::Suppressed, - TerminalMetricEmission::Emit, - ) - .await?; - let previous_status = self - .current_goal_status_for_metrics(&state_db, /*expected_goal_id*/ None) - .await?; - let Some(goal) = state_db - .thread_goals() - .usage_limit_active_thread_goal(self.thread_id) - .await? - else { - return Ok(()); - }; - self.emit_goal_terminal_metrics_if_status_changed(previous_status, &goal); - let goal = protocol_goal_from_state(goal); - *self.goal_runtime.budget_limit_reported_goal_id.lock().await = None; - self.clear_active_goal_accounting(turn_context).await; - self.send_event( - turn_context, - EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent { - thread_id: self.thread_id, - turn_id: Some(turn_context.sub_id.clone()), - goal, - }), - ) - .await; - Ok(()) - } - - async fn restore_thread_goal_runtime_after_resume(&self) -> anyhow::Result<()> { - if !self.enabled(Feature::Goals) { - return Ok(()); - } - if should_ignore_goal_for_mode(self.collaboration_mode().await.mode) { - tracing::debug!( - "skipping goal runtime restore while current collaboration mode ignores goals" - ); - return Ok(()); - } - - let _continuation_guard = self - .goal_runtime - .continuation_lock - .acquire() - .await - .context("goal continuation semaphore closed")?; - let Some(state_db) = self.state_db_for_thread_goals().await? else { - return Ok(()); - }; - let Some(goal) = state_db - .thread_goals() - .get_thread_goal(self.thread_id) - .await? - else { - self.clear_stopped_thread_goal_runtime_state().await; - return Ok(()); - }; - match goal.status { - codex_state::ThreadGoalStatus::Active => { - self.goal_runtime - .accounting - .lock() - .await - .wall_clock - .mark_active_goal(goal.goal_id); - self.emit_goal_resumed_metric(); - } - codex_state::ThreadGoalStatus::Paused - | codex_state::ThreadGoalStatus::Blocked - | codex_state::ThreadGoalStatus::UsageLimited - | codex_state::ThreadGoalStatus::BudgetLimited - | codex_state::ThreadGoalStatus::Complete => { - self.clear_stopped_thread_goal_runtime_state().await; - } - } - Ok(()) - } - - async fn maybe_continue_goal_if_idle_runtime(self: &Arc) { - self.maybe_start_turn_for_pending_work().await; - self.maybe_start_goal_continuation_turn().await; - } - - async fn maybe_start_goal_continuation_turn(self: &Arc) { - let Ok(_continuation_guard) = self.goal_runtime.continuation_lock.acquire().await else { - tracing::warn!("goal continuation semaphore closed"); - return; - }; - let Some(candidate) = self.goal_continuation_candidate_if_active().await else { - return; - }; - - let turn_state = { - let mut active_turn = self.active_turn.lock().await; - if active_turn.is_some() { - return; - } - let active_turn = active_turn.get_or_insert_with(ActiveTurn::default); - Arc::clone(&active_turn.turn_state) - }; - let goal_is_current = match self.state_db_for_thread_goals().await { - Ok(Some(state_db)) => match state_db - .thread_goals() - .get_thread_goal(self.thread_id) - .await - { - Ok(Some(goal)) - if goal.goal_id == candidate.goal_id - && goal.status == codex_state::ThreadGoalStatus::Active => - { - true - } - Ok(Some(_)) | Ok(None) => { - tracing::debug!( - "skipping active goal continuation because the goal changed before launch" - ); - false - } - Err(err) => { - tracing::warn!("failed to re-read thread goal before continuation: {err}"); - false - } - }, - Ok(None) => { - tracing::debug!("skipping active goal continuation for ephemeral thread"); - false - } - Err(err) => { - tracing::warn!("failed to open state db before goal continuation: {err}"); - false - } - }; - if !goal_is_current { - self.clear_reserved_goal_continuation_turn(&turn_state) - .await; - return; - } - self.input_queue - .extend_pending_input_for_turn_state( - turn_state.as_ref(), - candidate - .items - .into_iter() - .map(TurnInput::ResponseItem) - .collect(), - ) - .await; - - let turn_context = self - .new_default_turn_with_sub_id(uuid::Uuid::new_v4().to_string()) - .await; - self.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref()) - .await; - let still_reserved = { - let active_turn = self.active_turn.lock().await; - active_turn.as_ref().is_some_and(|active_turn| { - active_turn.task.is_none() && Arc::ptr_eq(&active_turn.turn_state, &turn_state) - }) - }; - if !still_reserved { - self.clear_reserved_goal_continuation_turn(&turn_state) - .await; - return; - } - self.start_task(turn_context, Vec::new(), RegularTask::new()) - .await; - } - - async fn goal_continuation_candidate_if_active( - self: &Arc, - ) -> Option { - if !self.enabled(Feature::Goals) { - return None; - } - if should_ignore_goal_for_mode(self.collaboration_mode().await.mode) { - tracing::debug!("skipping active goal continuation while plan mode is active"); - return None; - } - if self.active_turn.lock().await.is_some() { - tracing::debug!("skipping active goal continuation because a turn is already active"); - return None; - } - if self.input_queue.has_trigger_turn_mailbox_items().await { - tracing::debug!( - "skipping active goal continuation because trigger-turn mailbox input is pending" - ); - return None; - } - let state_db = match self.state_db_for_thread_goals().await { - Ok(Some(state_db)) => state_db, - Ok(None) => { - tracing::debug!("skipping active goal continuation for ephemeral thread"); - return None; - } - Err(err) => { - tracing::warn!("failed to open state db for goal continuation: {err}"); - return None; - } - }; - let goal = match state_db - .thread_goals() - .get_thread_goal(self.thread_id) - .await - { - Ok(Some(goal)) => goal, - Ok(None) => { - tracing::debug!("skipping active goal continuation because no goal is set"); - return None; - } - Err(err) => { - tracing::warn!("failed to read thread goal for continuation: {err}"); - return None; - } - }; - if goal.status != codex_state::ThreadGoalStatus::Active { - tracing::debug!(status = ?goal.status, "skipping inactive thread goal"); - return None; - } - if self.active_turn.lock().await.is_some() - || self.input_queue.has_trigger_turn_mailbox_items().await - { - tracing::debug!("skipping active goal continuation because pending work appeared"); - return None; - } - let goal_id = goal.goal_id.clone(); - let goal = protocol_goal_from_state(goal); - Some(GoalContinuationCandidate { - goal_id, - items: vec![goal_context_input_item(continuation_prompt(&goal))], - }) - } -} - -impl Session { - async fn state_db_for_thread_goals(&self) -> anyhow::Result> { - let config = self.get_config().await; - if config.ephemeral { - return Ok(None); - } - - self.try_ensure_rollout_materialized() - .await - .context("failed to materialize rollout before opening state db for thread goals")?; - - let state_db = if let Some(state_db) = self.state_db() { - state_db - } else if let Some(state_db) = self.goal_runtime.state_db.lock().await.clone() { - state_db - } else if let Some(local_store) = self - .services - .thread_store - .as_any() - .downcast_ref::() - { - local_store.state_db().await.ok_or_else(|| { - anyhow::anyhow!( - "thread goals require a local persisted thread with a state database" - ) - })? - } else { - anyhow::bail!("thread goals require a local persisted thread with a state database"); - }; - - let thread_metadata_present = state_db - .get_thread(self.thread_id) - .await - .context("failed to read thread metadata before reconciling thread goals")? - .is_some(); - if !thread_metadata_present { - let rollout_path = self - .current_rollout_path() - .await - .context("failed to locate rollout before reconciling thread goals")? - .ok_or_else(|| { - anyhow::anyhow!("thread goals require materialized thread metadata") - })?; - reconcile_rollout( - Some(&state_db), - rollout_path.as_path(), - config.model_provider_id.as_str(), - /*builder*/ None, - &[], - /*archived_only*/ None, - /*new_thread_memory_mode*/ None, - ) - .await; - let thread_metadata_present = state_db - .get_thread(self.thread_id) - .await - .context("failed to read thread metadata after reconciling thread goals")? - .is_some(); - if !thread_metadata_present { - anyhow::bail!("thread metadata is unavailable after reconciling thread goals"); - } - } - - *self.goal_runtime.state_db.lock().await = Some(state_db.clone()); - Ok(Some(state_db)) - } - - async fn require_state_db_for_thread_goals(&self) -> anyhow::Result { - self.state_db_for_thread_goals().await?.ok_or_else(|| { - anyhow::anyhow!("thread goals require a persisted thread; this thread is ephemeral") - }) - } -} - -async fn set_thread_preview_from_goal_objective( - state_db: &StateDbHandle, - thread_id: ThreadId, - objective: &str, -) { - if let Err(err) = state_db - .set_thread_preview_if_empty(thread_id, objective) - .await - { - tracing::warn!( - "failed to set empty thread preview from goal objective for {thread_id}: {err}" - ); - } -} - -fn should_ignore_goal_for_mode(mode: ModeKind) -> bool { - mode == ModeKind::Plan -} - -fn budget_limit_steering_item(goal: &ThreadGoal) -> ResponseItem { - goal_context_input_item(budget_limit_prompt(goal)) -} - -fn goal_context_input_item(prompt: String) -> ResponseItem { - ContextualUserFragment::into(InternalModelContextFragment::new( - InternalContextSource::from_static("goal"), - prompt, - )) -} - -pub(crate) fn protocol_goal_from_state(goal: codex_state::ThreadGoal) -> ThreadGoal { - ThreadGoal { - thread_id: goal.thread_id, - objective: goal.objective, - status: protocol_goal_status_from_state(goal.status), - token_budget: goal.token_budget, - tokens_used: goal.tokens_used, - time_used_seconds: goal.time_used_seconds, - created_at: goal.created_at.timestamp(), - updated_at: goal.updated_at.timestamp(), - } -} - -pub(crate) fn protocol_goal_status_from_state( - status: codex_state::ThreadGoalStatus, -) -> ThreadGoalStatus { - match status { - codex_state::ThreadGoalStatus::Active => ThreadGoalStatus::Active, - codex_state::ThreadGoalStatus::Paused => ThreadGoalStatus::Paused, - codex_state::ThreadGoalStatus::Blocked => ThreadGoalStatus::Blocked, - codex_state::ThreadGoalStatus::UsageLimited => ThreadGoalStatus::UsageLimited, - codex_state::ThreadGoalStatus::BudgetLimited => ThreadGoalStatus::BudgetLimited, - codex_state::ThreadGoalStatus::Complete => ThreadGoalStatus::Complete, - } -} - -pub(crate) fn state_goal_status_from_protocol( - status: ThreadGoalStatus, -) -> codex_state::ThreadGoalStatus { - match status { - ThreadGoalStatus::Active => codex_state::ThreadGoalStatus::Active, - ThreadGoalStatus::Paused => codex_state::ThreadGoalStatus::Paused, - ThreadGoalStatus::Blocked => codex_state::ThreadGoalStatus::Blocked, - ThreadGoalStatus::UsageLimited => codex_state::ThreadGoalStatus::UsageLimited, - ThreadGoalStatus::BudgetLimited => codex_state::ThreadGoalStatus::BudgetLimited, - ThreadGoalStatus::Complete => codex_state::ThreadGoalStatus::Complete, - } -} - -pub(crate) fn validate_goal_budget(value: Option) -> anyhow::Result<()> { - if let Some(value) = value - && value <= 0 - { - anyhow::bail!("goal budgets must be positive when provided"); - } - Ok(()) -} - -pub(crate) fn goal_token_delta_for_usage(usage: &TokenUsage) -> i64 { - usage - .non_cached_input() - .saturating_add(usage.output_tokens.max(0)) -} - -#[cfg(test)] -mod tests { - use super::goal_context_input_item; - use super::goal_token_delta_for_usage; - use super::should_ignore_goal_for_mode; - use codex_protocol::config_types::ModeKind; - use codex_protocol::models::ContentItem; - use codex_protocol::models::ResponseItem; - use codex_protocol::protocol::TokenUsage; - use std::time::Duration; - use std::time::Instant; - - #[test] - fn goal_continuation_is_ignored_only_in_plan_mode() { - assert!(should_ignore_goal_for_mode(ModeKind::Plan)); - assert!(!should_ignore_goal_for_mode(ModeKind::Default)); - assert!(!should_ignore_goal_for_mode(ModeKind::PairProgramming)); - assert!(!should_ignore_goal_for_mode(ModeKind::Execute)); - } - - #[test] - fn goal_token_delta_excludes_cached_input_and_does_not_double_count_reasoning() { - let usage = TokenUsage { - input_tokens: 900, - cached_input_tokens: 400, - output_tokens: 80, - reasoning_output_tokens: 20, - total_tokens: 1_000, - }; - - assert_eq!(580, goal_token_delta_for_usage(&usage)); - } - - #[test] - fn wall_clock_accounting_advances_by_persisted_seconds() { - let mut snapshot = super::GoalWallClockAccountingSnapshot::new(); - let original = Instant::now() - Duration::from_millis(1500); - snapshot.last_accounted_at = original; - - snapshot.mark_accounted(/*accounted_seconds*/ 1); - assert_eq!( - original + Duration::from_secs(1), - snapshot.last_accounted_at - ); - - let token_only_original = snapshot.last_accounted_at; - snapshot.mark_accounted(/*accounted_seconds*/ 0); - assert_eq!(token_only_original, snapshot.last_accounted_at); - } - - #[test] - fn goal_context_input_item_is_hidden_user_context() { - let item = goal_context_input_item("Continue working.".to_string()); - - assert_eq!( - item, - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "\nContinue working.\n".to_string(), - }], - phase: None, - } - ); - } -} diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index d81f84590b21..8504690b299a 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -733,7 +733,7 @@ async fn run_review_on_session( additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { #[allow(deprecated)] - cwd: Some(params.parent_turn.cwd.to_path_buf()), + cwd: Some(params.parent_turn.cwd.clone()), approval_policy: Some(AskForApproval::Never), sandbox_policy: None, permission_profile: Some(guardian_permission_profile), diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index b77f96cdb1a6..400c80368084 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -39,9 +39,6 @@ pub mod exec_env; mod exec_policy; #[cfg(test)] mod git_info_tests; -mod goals; -pub use goals::ExternalGoalPreviousStatus; -pub use goals::ExternalGoalSet; mod guardian; mod hook_runtime; mod installation_id; @@ -83,7 +80,6 @@ mod sandbox_tags; pub mod sandboxing; mod session_prefix; mod session_startup_prewarm; -mod shell_detect; pub mod skills; pub(crate) use skills::SkillInjections; pub(crate) use skills::SkillLoadOutcome; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 9aeca1991b81..37d55256f5e6 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -32,7 +32,6 @@ use crate::default_skill_metadata_budget; use crate::environment_selection::ResolvedTurnEnvironments; use crate::exec_policy::ExecPolicyManager; use crate::parse_turn_item; -use crate::path_utils::normalize_for_native_workdir; use crate::realtime_conversation::RealtimeConversationManager; use crate::session_prefix::format_subagent_notification_message; use crate::skills::SkillRenderSideEffects; @@ -359,6 +358,7 @@ use codex_protocol::protocol::ThreadMemoryMode; use codex_protocol::protocol::TokenCountEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; +use codex_protocol::protocol::TurnModerationMetadataEvent; use codex_protocol::protocol::WarningEvent; use codex_protocol::user_input::UserInput; use codex_tools::ToolEnvironmentMode; @@ -2637,6 +2637,15 @@ impl Session { .await; } + pub(crate) async fn emit_turn_moderation_metadata( + self: &Arc, + turn_context: &Arc, + metadata: TurnModerationMetadataEvent, + ) { + self.send_event(turn_context, EventMsg::TurnModerationMetadata(metadata)) + .await; + } + pub(crate) async fn replace_history( &self, items: Vec, diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index ff4c77aab9f2..a3b23c6a78b5 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -26,7 +26,6 @@ pub(super) async fn spawn_review_thread( let _ = review_features.disable(Feature::WebSearchCached); let _ = review_features.disable(Feature::Goals); let review_web_search_mode = WebSearchMode::Disabled; - let goal_tools_supported = !config.ephemeral && parent_turn_context.goal_tools_enabled(); let available_models = sess .services .models_manager @@ -126,7 +125,6 @@ pub(super) async fn spawn_review_thread( environments: parent_turn_context.environments.clone(), available_models, unified_exec_shell_mode, - goal_tools_supported, features: review_features, ghost_snapshot: parent_turn_context.ghost_snapshot.clone(), current_date: parent_turn_context.current_date.clone(), diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 9314a743a59c..122ff6aeb9d1 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -2,7 +2,6 @@ use super::input_queue::InputQueue; use super::*; use crate::agents_md::LoadedAgentsMd; use crate::config::ConstraintError; -use crate::goals::GoalRuntimeState; use crate::skills::SkillError; use crate::state::ActiveTurn; use codex_protocol::SessionId; @@ -37,7 +36,6 @@ pub(crate) struct Session { pub(crate) conversation: Arc, pub(crate) active_turn: Mutex>, pub(crate) input_queue: InputQueue, - pub(crate) goal_runtime: GoalRuntimeState, pub(crate) guardian_review_session: GuardianReviewSessionManager, pub(crate) services: SessionServices, pub(super) next_internal_sub_id: AtomicU64, @@ -251,19 +249,7 @@ impl SessionConfiguration { next_configuration.windows_sandbox_level = windows_sandbox_level; } - let absolute_cwd = updates - .cwd - .as_ref() - .map(|cwd| { - AbsolutePathBuf::relative_to_current_dir(normalize_for_native_workdir( - cwd.as_path(), - )) - .unwrap_or_else(|e| { - warn!("failed to normalize update cwd: {cwd:?}: {e}"); - self.cwd.clone() - }) - }) - .unwrap_or_else(|| self.cwd.clone()); + let absolute_cwd = updates.cwd.clone().unwrap_or_else(|| self.cwd.clone()); let cwd_changed = absolute_cwd.as_path() != self.cwd.as_path(); next_configuration.cwd = absolute_cwd; @@ -415,7 +401,7 @@ impl SessionConfiguration { #[derive(Default, Clone)] pub(crate) struct SessionSettingsUpdate { - pub(crate) cwd: Option, + pub(crate) cwd: Option, pub(crate) workspace_roots: Option>, pub(crate) profile_workspace_roots: Option>, pub(crate) approval_policy: Option, @@ -1071,7 +1057,6 @@ impl Session { conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), input_queue: InputQueue::new(), - goal_runtime: GoalRuntimeState::new(), guardian_review_session: GuardianReviewSessionManager::default(), services, next_internal_sub_id: AtomicU64::new(0), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index c5e37fca0e6a..366cf6f22ac1 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -57,11 +57,6 @@ use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; use tracing::Span; -use crate::goals::CreateGoalRequest; -use crate::goals::ExternalGoalPreviousStatus; -use crate::goals::ExternalGoalSet; -use crate::goals::GoalRuntimeEvent; -use crate::goals::SetGoalRequest; use crate::rollout::recorder::RolloutRecorder; use crate::state::ActiveTurn; use crate::state::TaskKind; @@ -72,11 +67,9 @@ use crate::tasks::execute_user_shell_command; use crate::tools::ToolRouter; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; -use crate::tools::handlers::CreateGoalHandler; use crate::tools::handlers::ExecCommandHandler; use crate::tools::handlers::RequestPermissionsHandler; use crate::tools::handlers::ShellCommandHandler; -use crate::tools::handlers::UpdateGoalHandler; use crate::tools::registry::ToolExecutor; use crate::tools::router::ToolCallSource; use crate::turn_diff_tracker::TurnDiffTracker; @@ -128,7 +121,6 @@ use codex_protocol::protocol::SessionMeta; use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::Submission; -use codex_protocol::protocol::ThreadGoalStatus; use codex_protocol::protocol::ThreadRolledBackEvent; use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::protocol::TokenCountEvent; @@ -139,28 +131,21 @@ use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnStartedEvent; 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; use core_test_support::context_snapshot::ContextSnapshotOptions; use core_test_support::context_snapshot::ContextSnapshotRenderMode; -use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; -use core_test_support::responses::ev_completed_with_tokens; -use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; -use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::test_codex::test_codex; use core_test_support::test_path_buf; use core_test_support::tracing::install_test_tracing; use core_test_support::wait_for_event; -use core_test_support::wait_for_event_match; use opentelemetry::trace::TraceContextExt; use opentelemetry::trace::TraceId; use opentelemetry_sdk::metrics::InMemoryMetricExporter; @@ -3932,6 +3917,7 @@ async fn session_configuration_apply_preserves_profile_file_system_policy_on_cwd let original_cwd = project_root.join("subdir"); let docs_dir = original_cwd.join("docs"); std::fs::create_dir_all(&docs_dir).expect("create docs dir"); + let project_root = project_root.abs(); let docs_dir = docs_dir.abs(); session_configuration.cwd = original_cwd.abs(); @@ -4152,7 +4138,7 @@ async fn session_configuration_apply_retargets_implicit_workspace_root_on_cwd_up let updated = session_configuration .apply(&SessionSettingsUpdate { - cwd: Some(new_root.to_path_buf()), + cwd: Some(new_root.clone()), ..Default::default() }) .expect("cwd-only update should succeed"); @@ -4391,7 +4377,7 @@ async fn session_configuration_apply_retargets_legacy_workspace_root_on_cwd_upda let updated = session_configuration .apply(&SessionSettingsUpdate { - cwd: Some(project_root.to_path_buf()), + cwd: Some(project_root.clone()), ..Default::default() }) .expect("cwd-only update should succeed"); @@ -4420,6 +4406,7 @@ async fn session_configuration_apply_preserves_absolute_cwd_write_root_on_cwd_up std::fs::create_dir_all(&original_cwd).expect("create original cwd"); std::fs::create_dir_all(&next_cwd).expect("create next cwd"); let original_cwd = original_cwd.abs(); + let next_cwd = next_cwd.abs(); session_configuration.cwd = original_cwd.clone(); let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ @@ -4480,7 +4467,7 @@ async fn session_update_settings_does_not_rewrite_sticky_environment_cwds() { session .update_settings(SessionSettingsUpdate { - cwd: Some(PathBuf::from("project")), + cwd: Some(updated_cwd.clone()), ..Default::default() }) .await @@ -4516,7 +4503,7 @@ async fn relative_cwd_update_without_environments_resolves_under_session_cwd() { session .update_settings(SessionSettingsUpdate { - cwd: Some(PathBuf::from("project")), + cwd: Some(updated_cwd.clone()), ..Default::default() }) .await @@ -4545,7 +4532,7 @@ async fn cwd_update_does_not_rewrite_sticky_environment_cwd() { session .update_settings(SessionSettingsUpdate { - cwd: Some(PathBuf::from("project")), + cwd: Some(updated_cwd.clone()), ..Default::default() }) .await @@ -4572,7 +4559,7 @@ async fn absolute_cwd_update_with_turn_environment_is_allowed() { .new_turn_with_sub_id( "sub-1".to_string(), SessionSettingsUpdate { - cwd: Some(absolute_cwd.to_path_buf()), + cwd: Some(absolute_cwd.clone()), environments: Some(vec![TurnEnvironmentSelection { environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), cwd: absolute_cwd.clone(), @@ -4896,7 +4883,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { session_configuration.cwd.clone(), "turn_id".to_string(), skills_outcome, - /*goal_tools_supported*/ true, ); let session = Session { @@ -4913,7 +4899,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), input_queue: super::input_queue::InputQueue::new(), - goal_runtime: crate::goals::GoalRuntimeState::new(), guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(), services, next_internal_sub_id: AtomicU64::new(0), @@ -5989,7 +5974,7 @@ async fn user_turn_updates_approvals_reviewer() { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(config.cwd.to_path_buf()), + cwd: Some(config.cwd.clone()), approval_policy: Some(config.permissions.approval_policy.value()), approvals_reviewer: Some(codex_config::types::ApprovalsReviewer::AutoReview), sandbox_policy: Some(config.legacy_sandbox_policy()), @@ -6780,18 +6765,7 @@ where let (tx_event, rx_event) = async_channel::unbounded(); let mut config = build_test_config(codex_home).await; configure_config(&mut config); - let state_db = if config.features.enabled(Feature::Goals) { - Some( - codex_state::StateRuntime::init( - config.sqlite_home.clone(), - config.model_provider_id.clone(), - ) - .await - .expect("goal tests should initialize sqlite state db"), - ) - } else { - None - }; + let state_db = None; let config = Arc::new(config); let thread_id = ThreadId::default(); let auth_manager = AuthManager::from_auth_for_testing(auth); @@ -6986,7 +6960,6 @@ where session_configuration.cwd.clone(), "turn_id".to_string(), skills_outcome, - /*goal_tools_supported*/ true, )); let session = Arc::new(Session { @@ -7003,7 +6976,6 @@ where conversation: Arc::new(RealtimeConversationManager::new()), active_turn: Mutex::new(None), input_queue: super::input_queue::InputQueue::new(), - goal_runtime: crate::goals::GoalRuntimeState::new(), guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(), services, next_internal_sub_id: AtomicU64::new(0), @@ -7027,52 +6999,6 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( .await } -async fn make_goal_session_and_context_with_rx() -> ( - Arc, - Arc, - async_channel::Receiver, - tempfile::TempDir, -) { - let codex_home = tempfile::tempdir().expect("create temp dir"); - let (session, turn_context, rx) = make_session_and_context_with_auth_config_home_and_rx( - CodexAuth::from_api_key("Test API Key"), - Vec::new(), - codex_home.path(), - |config| { - config - .features - .enable(Feature::Goals) - .expect("goal mode should be enableable in tests"); - }, - ) - .await; - upsert_goal_test_thread(session.as_ref()).await; - (session, turn_context, rx, codex_home) -} - -async fn upsert_goal_test_thread(session: &Session) { - let config = session.get_config().await; - let state_db = session - .state_db() - .expect("goal test session should have a state db"); - let mut builder = codex_state::ThreadMetadataBuilder::new( - session.thread_id, - config - .codex_home - .join("goal-test-rollout.jsonl") - .to_path_buf(), - chrono::Utc::now(), - SessionSource::Cli, - ); - builder.cwd = config.cwd.to_path_buf(); - builder.model_provider = Some(config.model_provider_id.clone()); - let metadata = builder.build(config.model_provider_id.as_str()); - state_db - .upsert_thread(&metadata) - .await - .expect("goal test thread should be upserted"); -} - // Like make_session_and_context, but returns Arc and the event receiver // so tests can assert on emitted events. pub(crate) async fn make_session_and_context_with_rx() -> ( @@ -9129,926 +9055,113 @@ async fn abort_empty_active_turn_preserves_pending_input() { ); } -#[tokio::test] -async fn interrupt_accounts_active_goal_without_pausing() -> anyhow::Result<()> { - let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; - sess.set_thread_goal( - tc.as_ref(), - SetGoalRequest { - objective: Some("Keep improving the benchmark".to_string()), - status: None, - token_budget: None, - }, - ) - .await?; +async fn set_total_token_usage(sess: &Session, total_token_usage: TokenUsage) { + let mut state = sess.state.lock().await; + state.set_token_info(Some(TokenUsageInfo { + total_token_usage, + last_token_usage: TokenUsage::default(), + model_context_window: None, + })); +} +#[tokio::test] +async fn queue_only_mailbox_mail_waits_for_next_turn_after_answer_boundary() { + let (sess, tc, _rx) = make_session_and_context_with_rx().await; + let communication = InterAgentCommunication::new( + AgentPath::try_from("/root/worker").expect("worker path should parse"), + AgentPath::root(), + Vec::new(), + "late queue-only update".to_string(), + /*trigger_turn*/ false, + ); sess.spawn_task( Arc::clone(&tc), Vec::new(), NeverEndingTask { kind: TaskKind::Regular, - listen_to_cancellation_token: false, + listen_to_cancellation_token: true, }, ) .await; - set_total_token_usage(&sess, post_goal_token_usage()).await; - sess.abort_all_tasks(TurnAbortReason::Interrupted).await; + sess.input_queue + .defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id) + .await; + sess.input_queue + .enqueue_mailbox_communication(communication.clone()) + .await; - let goal = sess - .get_thread_goal() - .await? - .expect("goal should remain persisted after interrupt"); + assert!( + !sess.input_queue.has_pending_input(&sess.active_turn).await, + "queue-only mailbox mail should stay buffered once the current turn emitted its answer" + ); assert_eq!( - codex_protocol::protocol::ThreadGoalStatus::Active, - goal.status + sess.input_queue.get_pending_input(&sess.active_turn).await, + Vec::new() ); - assert_eq!(70, goal.tokens_used); - - assert!(sess.active_turn.lock().await.is_none()); - - Ok(()) -} -#[tokio::test] -async fn shutdown_without_active_turn_keeps_active_goal_active() -> anyhow::Result<()> { - let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; - sess.set_thread_goal( - tc.as_ref(), - SetGoalRequest { - objective: Some("Keep improving the benchmark".to_string()), - status: None, - token_budget: None, - }, - ) - .await?; - - assert!(sess.active_turn.lock().await.is_none()); - assert!(handlers::shutdown(&sess, "shutdown".to_string()).await); + sess.abort_all_tasks(TurnAbortReason::Replaced).await; - let goal = sess - .get_thread_goal() - .await? - .expect("goal should remain persisted after shutdown"); assert_eq!( - codex_protocol::protocol::ThreadGoalStatus::Active, - goal.status + sess.input_queue.get_pending_input(&sess.active_turn).await, + vec![TurnInput::ResponseItem(ResponseItem::from( + communication.to_response_input_item() + ))], ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn active_goal_continuation_runs_again_after_no_tool_turn() -> anyhow::Result<()> { - let server = start_mock_server().await; - let mut builder = test_codex().with_config(|config| { - config - .features - .enable(Feature::Goals) - .expect("goal mode should be enableable in tests"); - }); - let test = builder.build(&server).await?; - let responses = mount_sse_sequence( - &server, - vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_function_call( - "call-create-goal", - "create_goal", - r#"{"objective":"write a benchmark note"}"#, - ), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_assistant_message("msg-1", "Draft ready."), - ev_completed("resp-2"), - ]), - sse(vec![ - ev_assistant_message("msg-2", "I am still working on the benchmark note."), - ev_completed("resp-3"), - ]), - sse(vec![ - ev_response_created("resp-4"), - ev_function_call( - "call-complete-goal", - "update_goal", - r#"{"status":"complete"}"#, - ), - ev_completed("resp-4"), - ]), - sse(vec![ - ev_assistant_message("msg-3", "Goal complete."), - ev_completed("resp-5"), - ]), - ], - ) - .await; - - test.codex - .submit(Op::UserInput { - environments: None, - items: vec![UserInput::Text { - text: "write a benchmark note".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) - .await?; - - let mut completed_turns = 0; - tokio::time::timeout(std::time::Duration::from_secs(8), async { - loop { - let event = test.codex.next_event().await?; - if matches!(event.msg, EventMsg::TurnComplete(_)) { - completed_turns += 1; - if completed_turns == 3 { - return anyhow::Ok(()); - } - } - } - }) - .await??; - - let goal_context_text = responses - .requests() - .into_iter() - .flat_map(|request| request.message_input_texts("user")) - .find(|text| text.contains("")) - .expect("goal context message should be present"); - assert!(goal_context_text.contains("Continue working toward the active thread goal.")); - - Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn pending_request_user_input_does_not_spawn_extra_goal_continuation() -> anyhow::Result<()> { - let server = start_mock_server().await; - let mut builder = test_codex().with_config(|config| { - config - .features - .enable(Feature::Goals) - .expect("goal mode should be enableable in tests"); - config - .features - .enable(Feature::DefaultModeRequestUserInput) - .expect("default-mode request_user_input should be enableable in tests"); - }); - let test = builder.build(&server).await?; - let responses = mount_sse_sequence( - &server, - vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_function_call( - "call-create-goal", - "create_goal", - r#"{"objective":"write a benchmark note"}"#, - ), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_assistant_message("msg-1", "Draft ready."), - ev_completed("resp-2"), - ]), - sse(vec![ - ev_response_created("resp-3"), - ev_function_call( - "call-ask-user", - "request_user_input", - r#"{"questions":[{"header":"Choice","id":"next_step","question":"Pick one","options":[{"label":"Outline","description":"Start with an outline."},{"label":"Draft","description":"Write a full draft."}]}]}"#, - ), - ev_completed("resp-3"), - ]), - sse(vec![ - ev_response_created("resp-4"), - ev_function_call( - "call-complete-goal", - "update_goal", - r#"{"status":"complete"}"#, - ), - ev_completed("resp-4"), - ]), - sse(vec![ - ev_assistant_message("msg-2", "Goal complete."), - ev_completed("resp-5"), - ]), - ], +#[tokio::test] +async fn trigger_turn_mailbox_mail_waits_for_next_turn_after_answer_boundary() { + let (sess, tc, _rx) = make_session_and_context_with_rx().await; + sess.spawn_task( + Arc::clone(&tc), + Vec::new(), + NeverEndingTask { + kind: TaskKind::Regular, + listen_to_cancellation_token: true, + }, ) .await; - test.codex - .submit(Op::UserInput { - environments: None, - items: vec![UserInput::Text { - text: "write a benchmark note".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) - .await?; + sess.input_queue + .defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id) + .await; + sess.input_queue + .enqueue_mailbox_communication(InterAgentCommunication::new( + AgentPath::try_from("/root/worker").expect("worker path should parse"), + AgentPath::root(), + Vec::new(), + "late trigger update".to_string(), + /*trigger_turn*/ true, + )) + .await; - let request_user_input_event = wait_for_event_match(&test.codex, |event| match event { - EventMsg::RequestUserInput(event) => Some(event.clone()), - _ => None, - }) - .await; - assert_eq!(3, responses.requests().len()); assert!( - timeout(Duration::from_millis(200), test.codex.next_event()) - .await - .is_err(), - "waiting for request_user_input should keep the turn open without emitting more events" + !sess.input_queue.has_pending_input(&sess.active_turn).await, + "trigger-turn mailbox mail should not extend the current turn after its answer boundary" ); - assert_eq!( - 3, - responses.requests().len(), - "waiting for request_user_input should not start another continuation request" - ); - - test.codex - .submit(Op::UserInputAnswer { - id: request_user_input_event.turn_id, - response: RequestUserInputResponse { - answers: std::collections::HashMap::from([( - "next_step".to_string(), - RequestUserInputAnswer { - answers: vec!["Outline".to_string()], - }, - )]), - }, - }) - .await?; - - let mut completed_turns = 0; - timeout(Duration::from_secs(8), async { - loop { - let event = test.codex.next_event().await?; - if matches!(event.msg, EventMsg::TurnComplete(_)) { - completed_turns += 1; - if completed_turns == 1 { - return anyhow::Ok(()); - } - } - } - }) - .await??; - - assert_eq!(5, responses.requests().len()); - - Ok(()) -} - -async fn set_total_token_usage(sess: &Session, total_token_usage: TokenUsage) { - let mut state = sess.state.lock().await; - state.set_token_info(Some(TokenUsageInfo { - total_token_usage, - last_token_usage: TokenUsage::default(), - model_context_window: None, - })); -} -fn post_goal_token_usage() -> TokenUsage { - TokenUsage { - input_tokens: 50, - cached_input_tokens: 10, - output_tokens: 30, - reasoning_output_tokens: 5, - total_tokens: 75, - } -} + sess.abort_all_tasks(TurnAbortReason::Replaced).await; -async fn goal_test_state_db(sess: &Session) -> anyhow::Result { - if let Some(state_db) = sess.state_db() { - return Ok(state_db); - } - let config = sess.get_config().await; - codex_state::StateRuntime::init(config.sqlite_home.clone(), config.model_provider_id.clone()) - .await + assert!(sess.input_queue.has_trigger_turn_mailbox_items().await); } #[tokio::test] -async fn create_thread_goal_fills_empty_thread_preview() -> anyhow::Result<()> { - let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; - let state_db = goal_test_state_db(sess.as_ref()).await?; - - let page = state_db - .list_threads( - /*page_size*/ 10, - codex_state::ThreadFilterOptions { - archived_only: false, - allowed_sources: &[], - model_providers: None, - cwd_filters: None, - anchor: None, - sort_key: codex_state::SortKey::UpdatedAt, - sort_direction: codex_state::SortDirection::Desc, - search_term: None, - }, - ) - .await?; - assert!(page.items.is_empty()); - - sess.create_thread_goal( - tc.as_ref(), - CreateGoalRequest { - objective: "Keep improving the benchmark".to_string(), - token_budget: None, - }, - ) - .await?; - - let page = state_db - .list_threads( - /*page_size*/ 10, - codex_state::ThreadFilterOptions { - archived_only: false, - allowed_sources: &[], - model_providers: None, - cwd_filters: None, - anchor: None, - sort_key: codex_state::SortKey::UpdatedAt, - sort_direction: codex_state::SortDirection::Desc, - search_term: None, - }, - ) - .await?; - let ids = page - .items - .iter() - .map(|thread| thread.id) - .collect::>(); - assert_eq!(vec![sess.thread_id], ids); - assert_eq!( - Some("Keep improving the benchmark"), - page.items[0].preview.as_deref() +async fn steered_input_reopens_mailbox_delivery_for_current_turn() { + let (sess, tc, _rx) = make_session_and_context_with_rx().await; + let communication = InterAgentCommunication::new( + AgentPath::try_from("/root/worker").expect("worker path should parse"), + AgentPath::root(), + Vec::new(), + "queued child update".to_string(), + /*trigger_turn*/ false, ); - - Ok(()) -} - -#[tokio::test] -async fn budget_limited_accounting_steers_active_turn_without_aborting() -> anyhow::Result<()> { - let (sess, tc, rx, _codex_home) = make_goal_session_and_context_with_rx().await; - sess.set_thread_goal( - tc.as_ref(), - SetGoalRequest { - objective: Some("Keep improving the benchmark".to_string()), - status: None, - token_budget: Some(Some(10)), - }, - ) - .await?; - sess.goal_runtime_apply(GoalRuntimeEvent::TurnStarted { - turn_context: tc.as_ref(), - token_usage: TokenUsage::default(), - }) - .await?; sess.spawn_task( Arc::clone(&tc), Vec::new(), NeverEndingTask { kind: TaskKind::Regular, - listen_to_cancellation_token: false, - }, - ) - .await; - while rx.try_recv().is_ok() {} - - set_total_token_usage( - &sess, - TokenUsage { - input_tokens: 20, - cached_input_tokens: 0, - output_tokens: 5, - reasoning_output_tokens: 0, - total_tokens: 25, - }, - ) - .await; - - sess.goal_runtime_apply(GoalRuntimeEvent::ToolCompleted { - turn_context: tc.as_ref(), - tool_name: "shell_command", - }) - .await?; - - let pending_input = sess.input_queue.get_pending_input(&sess.active_turn).await; - let [TurnInput::ResponseItem(ResponseItem::Message { role, content, .. })] = - pending_input.as_slice() - else { - panic!("expected one budget-limit steering message, got {pending_input:#?}"); - }; - assert_eq!("user", role); - let [ContentItem::InputText { text }] = content.as_slice() else { - panic!("expected one text span in budget-limit steering message, got {content:#?}"); - }; - assert!(text.starts_with("")); - assert!(text.trim_end().ends_with("")); - assert!(text.contains("budget_limited")); - assert!(text.to_lowercase().contains("wrap up this turn soon")); - assert!(sess.active_turn.lock().await.is_some()); - while let Ok(event) = rx.try_recv() { - assert!( - !matches!(event.msg, EventMsg::TurnAborted(_)), - "budget limit should steer the active turn instead of aborting it" - ); - } - - let state_db = goal_test_state_db(sess.as_ref()).await?; - let goal = state_db - .thread_goals() - .get_thread_goal(sess.thread_id) - .await? - .expect("goal should remain persisted after accounting"); - assert_eq!(codex_state::ThreadGoalStatus::BudgetLimited, goal.status); - assert_eq!(25, goal.tokens_used); - - set_total_token_usage( - &sess, - TokenUsage { - input_tokens: 30, - cached_input_tokens: 0, - output_tokens: 10, - reasoning_output_tokens: 0, - total_tokens: 40, - }, - ) - .await; - sess.goal_runtime_apply(GoalRuntimeEvent::ToolCompletedGoal { - turn_context: tc.as_ref(), - }) - .await?; - - let goal = state_db - .thread_goals() - .get_thread_goal(sess.thread_id) - .await? - .expect("goal should remain persisted after follow-up accounting"); - assert_eq!(codex_state::ThreadGoalStatus::BudgetLimited, goal.status); - assert_eq!(40, goal.tokens_used); - - sess.abort_all_tasks(TurnAbortReason::Interrupted).await; - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn usage_limit_runtime_stops_active_goal_and_prevents_idle_continuation() -> anyhow::Result<()> -{ - let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; - sess.set_thread_goal( - tc.as_ref(), - SetGoalRequest { - objective: Some("Keep improving the benchmark".to_string()), - status: None, - token_budget: Some(Some(50)), - }, - ) - .await?; - sess.goal_runtime_apply(GoalRuntimeEvent::TurnStarted { - turn_context: tc.as_ref(), - token_usage: TokenUsage::default(), - }) - .await?; - sess.spawn_task( - Arc::clone(&tc), - Vec::new(), - NeverEndingTask { - kind: TaskKind::Regular, - listen_to_cancellation_token: false, - }, - ) - .await; - set_total_token_usage(&sess, post_goal_token_usage()).await; - - sess.goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { - turn_context: tc.as_ref(), - }) - .await?; - - let state_db = goal_test_state_db(sess.as_ref()).await?; - let goal = state_db - .thread_goals() - .get_thread_goal(sess.thread_id) - .await? - .expect("goal should remain persisted after usage limiting"); - assert_eq!(codex_state::ThreadGoalStatus::UsageLimited, goal.status); - assert_eq!(70, goal.tokens_used); - - sess.abort_all_tasks(TurnAbortReason::Replaced).await; - sess.goal_runtime_apply(GoalRuntimeEvent::MaybeContinueIfIdle) - .await?; - assert!(sess.active_turn.lock().await.is_none()); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn external_goal_mutation_accounts_active_turn_before_status_change() -> anyhow::Result<()> { - let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; - sess.set_thread_goal( - tc.as_ref(), - SetGoalRequest { - objective: Some("Keep improving the benchmark".to_string()), - status: None, - token_budget: None, - }, - ) - .await?; - sess.spawn_task( - Arc::clone(&tc), - Vec::new(), - NeverEndingTask { - kind: TaskKind::Regular, - listen_to_cancellation_token: false, - }, - ) - .await; - set_total_token_usage(&sess, post_goal_token_usage()).await; - - sess.goal_runtime_apply(GoalRuntimeEvent::ExternalMutationStarting) - .await?; - - let state_db = goal_test_state_db(sess.as_ref()).await?; - let goal = state_db - .thread_goals() - .get_thread_goal(sess.thread_id) - .await? - .expect("goal should remain persisted"); - assert_eq!(70, goal.tokens_used); - - let previous_goal = goal.clone(); - let goal_id = goal.goal_id.clone(); - let updated_goal = state_db - .thread_goals() - .update_thread_goal( - sess.thread_id, - codex_state::GoalUpdate { - objective: None, - status: Some(codex_state::ThreadGoalStatus::Complete), - token_budget: None, - expected_goal_id: Some(goal_id), - }, - ) - .await? - .expect("goal status update should succeed"); - sess.goal_runtime_apply(GoalRuntimeEvent::ExternalSet { - external_set: ExternalGoalSet { - goal: updated_goal, - previous_status: ExternalGoalPreviousStatus::from(&previous_goal), - }, - }) - .await?; - - assert!(sess.active_turn.lock().await.is_some()); - let goal = state_db - .thread_goals() - .get_thread_goal(sess.thread_id) - .await? - .expect("goal should remain persisted"); - assert_eq!(codex_state::ThreadGoalStatus::Complete, goal.status); - assert_eq!(70, goal.tokens_used); - - sess.abort_all_tasks(TurnAbortReason::Replaced).await; - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn external_objective_change_steers_active_turn() -> anyhow::Result<()> { - let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; - sess.spawn_task( - Arc::clone(&tc), - Vec::new(), - NeverEndingTask { - kind: TaskKind::Regular, - listen_to_cancellation_token: false, - }, - ) - .await; - - let state_db = goal_test_state_db(sess.as_ref()).await?; - let old_goal = state_db - .thread_goals() - .replace_thread_goal( - sess.thread_id, - "Keep improving the benchmark", - codex_state::ThreadGoalStatus::Active, - /*token_budget*/ Some(10_000), - ) - .await?; - let new_goal = state_db - .thread_goals() - .replace_thread_goal( - sess.thread_id, - "Write a concise benchmark summary", - codex_state::ThreadGoalStatus::Active, - /*token_budget*/ Some(10_000), - ) - .await?; - - sess.goal_runtime_apply(GoalRuntimeEvent::ExternalSet { - external_set: ExternalGoalSet { - goal: new_goal, - previous_status: ExternalGoalPreviousStatus::from(&old_goal), - }, - }) - .await?; - - let pending_input = sess.input_queue.get_pending_input(&sess.active_turn).await; - assert!( - pending_input.iter().any(|item| { - matches!( - item, - TurnInput::ResponseItem(ResponseItem::Message { role, content, .. }) - if role == "user" - && content.iter().any(|content| matches!( - content, - ContentItem::InputText { text } - if text.starts_with("") - && text.trim_end().ends_with("") - && text.contains("The active thread goal objective was edited") - && text.contains("Write a concise benchmark summary") - )) - ) - }), - "expected objective-updated steering prompt in pending input: {pending_input:?}" - ); - - sess.abort_all_tasks(TurnAbortReason::Replaced).await; - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn external_active_goal_set_marks_current_turn_for_accounting() -> anyhow::Result<()> { - let (sess, tc, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; - sess.spawn_task( - Arc::clone(&tc), - Vec::new(), - NeverEndingTask { - kind: TaskKind::Regular, - listen_to_cancellation_token: false, - }, - ) - .await; - set_total_token_usage(&sess, post_goal_token_usage()).await; - - let state_db = goal_test_state_db(sess.as_ref()).await?; - let goal = state_db - .thread_goals() - .replace_thread_goal( - sess.thread_id, - "Keep improving the benchmark", - codex_state::ThreadGoalStatus::Active, - /*token_budget*/ None, - ) - .await?; - sess.goal_runtime_apply(GoalRuntimeEvent::ExternalSet { - external_set: ExternalGoalSet { - goal, - previous_status: ExternalGoalPreviousStatus::NewGoal, - }, - }) - .await?; - - set_total_token_usage( - &sess, - TokenUsage { - input_tokens: 65, - cached_input_tokens: 10, - output_tokens: 40, - reasoning_output_tokens: 5, - total_tokens: 110, - }, - ) - .await; - sess.goal_runtime_apply(GoalRuntimeEvent::ToolCompleted { - turn_context: tc.as_ref(), - tool_name: "shell_command", - }) - .await?; - - let goal = state_db - .thread_goals() - .get_thread_goal(sess.thread_id) - .await? - .expect("goal should remain persisted"); - assert_eq!(codex_state::ThreadGoalStatus::Active, goal.status); - assert_eq!(25, goal.tokens_used); - - sess.abort_all_tasks(TurnAbortReason::Replaced).await; - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn completed_goal_accounts_current_turn_tokens_before_tool_response() -> anyhow::Result<()> { - let server = start_mock_server().await; - let mut builder = test_codex().with_config(|config| { - config - .features - .enable(Feature::Goals) - .expect("goal mode should be enableable in tests"); - }); - let test = builder.build(&server).await?; - let responses = mount_sse_sequence( - &server, - vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_function_call( - "call-create-goal", - "create_goal", - r#"{"objective":"write a report","token_budget":500}"#, - ), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_response_created("resp-2"), - ev_function_call( - "call-complete-goal", - "update_goal", - r#"{"status":"complete"}"#, - ), - ev_completed_with_tokens("resp-2", /*total_tokens*/ 580), - ]), - sse(vec![ - ev_assistant_message("msg-1", "Goal complete."), - ev_completed("resp-3"), - ]), - ], - ) - .await; - - test.codex - .submit(Op::UserInput { - environments: None, - items: vec![UserInput::Text { - text: "write a report".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }) - .await?; - - tokio::time::timeout(std::time::Duration::from_secs(8), async { - loop { - let event = test.codex.next_event().await?; - if matches!(event.msg, EventMsg::TurnComplete(_)) { - return anyhow::Ok(()); - } - } - }) - .await??; - - let complete_output = responses - .function_call_output_text("call-complete-goal") - .expect("complete tool output should be sent to the model"); - let complete_output: serde_json::Value = serde_json::from_str(&complete_output)?; - assert_eq!(complete_output["goal"]["tokensUsed"], 580); - assert_eq!(complete_output["goal"]["status"], "complete"); - assert_eq!(complete_output["remainingTokens"], 0); - assert_eq!( - complete_output["completionBudgetReport"], - "Goal achieved. Report final usage from this tool result's structured goal fields. If `goal.tokenBudget` is present, include token usage from `goal.tokensUsed` and `goal.tokenBudget`. If `goal.timeUsedSeconds` is greater than 0, summarize elapsed time in a concise, human-friendly form appropriate to the response language." - ); - let requests = responses.requests(); - let completion_followup_request = requests - .last() - .expect("completion tool output should be sent in a follow-up request"); - assert!( - !completion_followup_request.body_contains_text("budget_limited"), - "completion follow-up should not include budget-limit steering" - ); - - let state_db = codex_state::StateRuntime::init( - test.config.sqlite_home.clone(), - test.config.model_provider_id.clone(), - ) - .await?; - let persisted_goal = state_db - .thread_goals() - .get_thread_goal(test.session_configured.thread_id) - .await? - .expect("goal should be persisted"); - assert_eq!( - codex_state::ThreadGoalStatus::Complete, - persisted_goal.status - ); - assert_eq!(580, persisted_goal.tokens_used); - - Ok(()) -} - -#[tokio::test] -async fn queue_only_mailbox_mail_waits_for_next_turn_after_answer_boundary() { - let (sess, tc, _rx) = make_session_and_context_with_rx().await; - let communication = InterAgentCommunication::new( - AgentPath::try_from("/root/worker").expect("worker path should parse"), - AgentPath::root(), - Vec::new(), - "late queue-only update".to_string(), - /*trigger_turn*/ false, - ); - sess.spawn_task( - Arc::clone(&tc), - Vec::new(), - NeverEndingTask { - kind: TaskKind::Regular, - listen_to_cancellation_token: true, - }, - ) - .await; - - sess.input_queue - .defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id) - .await; - sess.input_queue - .enqueue_mailbox_communication(communication.clone()) - .await; - - assert!( - !sess.input_queue.has_pending_input(&sess.active_turn).await, - "queue-only mailbox mail should stay buffered once the current turn emitted its answer" - ); - assert_eq!( - sess.input_queue.get_pending_input(&sess.active_turn).await, - Vec::new() - ); - - sess.abort_all_tasks(TurnAbortReason::Replaced).await; - - assert_eq!( - sess.input_queue.get_pending_input(&sess.active_turn).await, - vec![TurnInput::ResponseItem(ResponseItem::from( - communication.to_response_input_item() - ))], - ); -} - -#[tokio::test] -async fn trigger_turn_mailbox_mail_waits_for_next_turn_after_answer_boundary() { - let (sess, tc, _rx) = make_session_and_context_with_rx().await; - sess.spawn_task( - Arc::clone(&tc), - Vec::new(), - NeverEndingTask { - kind: TaskKind::Regular, - listen_to_cancellation_token: true, - }, - ) - .await; - - sess.input_queue - .defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id) - .await; - sess.input_queue - .enqueue_mailbox_communication(InterAgentCommunication::new( - AgentPath::try_from("/root/worker").expect("worker path should parse"), - AgentPath::root(), - Vec::new(), - "late trigger update".to_string(), - /*trigger_turn*/ true, - )) - .await; - - assert!( - !sess.input_queue.has_pending_input(&sess.active_turn).await, - "trigger-turn mailbox mail should not extend the current turn after its answer boundary" - ); - - sess.abort_all_tasks(TurnAbortReason::Replaced).await; - - assert!(sess.input_queue.has_trigger_turn_mailbox_items().await); -} - -#[tokio::test] -async fn steered_input_reopens_mailbox_delivery_for_current_turn() { - let (sess, tc, _rx) = make_session_and_context_with_rx().await; - let communication = InterAgentCommunication::new( - AgentPath::try_from("/root/worker").expect("worker path should parse"), - AgentPath::root(), - Vec::new(), - "queued child update".to_string(), - /*trigger_turn*/ false, - ); - sess.spawn_task( - Arc::clone(&tc), - Vec::new(), - NeverEndingTask { - kind: TaskKind::Regular, - listen_to_cancellation_token: true, + listen_to_cancellation_token: true, }, ) .await; @@ -10502,297 +9615,6 @@ async fn sample_rollout( ) } -#[tokio::test] -async fn create_goal_tool_rejects_existing_goal() { - let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); - let handler = CreateGoalHandler; - - handler - .handle(ToolInvocation { - session: Arc::clone(&session), - turn: Arc::clone(&turn_context), - cancellation_token: CancellationToken::new(), - tracker: Arc::clone(&tracker), - call_id: "create-goal-1".to_string(), - tool_name: codex_tools::ToolName::plain("create_goal"), - source: ToolCallSource::Direct, - payload: ToolPayload::Function { - arguments: serde_json::json!({ - "objective": "Keep the watcher alive", - "token_budget": 123, - }) - .to_string(), - }, - }) - .await - .expect("initial create_goal should succeed"); - - let response = handler - .handle(ToolInvocation { - session: Arc::clone(&session), - turn: Arc::clone(&turn_context), - cancellation_token: CancellationToken::new(), - tracker, - call_id: "create-goal-2".to_string(), - tool_name: codex_tools::ToolName::plain("create_goal"), - source: ToolCallSource::Direct, - payload: ToolPayload::Function { - arguments: serde_json::json!({ - "objective": "Replace the watcher", - "token_budget": 456, - }) - .to_string(), - }, - }) - .await; - - let Err(FunctionCallError::RespondToModel(output)) = response else { - panic!("expected create_goal to reject an existing goal"); - }; - assert_eq!( - output, - "cannot create a new goal because this thread already has a goal; use update_goal only when the existing goal is complete" - ); - - let goal = session - .get_thread_goal() - .await - .expect("read thread goal") - .expect("goal should still exist"); - assert_eq!(goal.objective, "Keep the watcher alive"); - assert_eq!(goal.token_budget, Some(123)); -} - -#[tokio::test] -async fn update_goal_tool_rejects_pausing_goal() { - let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); - let create_handler = CreateGoalHandler; - let update_handler = UpdateGoalHandler; - - create_handler - .handle(ToolInvocation { - session: Arc::clone(&session), - turn: Arc::clone(&turn_context), - cancellation_token: CancellationToken::new(), - tracker: Arc::clone(&tracker), - call_id: "create-goal".to_string(), - tool_name: codex_tools::ToolName::plain("create_goal"), - source: ToolCallSource::Direct, - payload: ToolPayload::Function { - arguments: serde_json::json!({ - "objective": "Keep the watcher alive", - "token_budget": 123, - }) - .to_string(), - }, - }) - .await - .expect("initial create_goal should succeed"); - - let response = update_handler - .handle(ToolInvocation { - session: Arc::clone(&session), - turn: Arc::clone(&turn_context), - cancellation_token: CancellationToken::new(), - tracker, - call_id: "pause-goal".to_string(), - tool_name: codex_tools::ToolName::plain("update_goal"), - source: ToolCallSource::Direct, - payload: ToolPayload::Function { - arguments: serde_json::json!({ - "status": "paused", - }) - .to_string(), - }, - }) - .await; - - let Err(FunctionCallError::RespondToModel(output)) = response else { - panic!("expected update_goal to reject pausing a goal"); - }; - assert_eq!( - output, - "update_goal can only mark the existing goal complete or blocked; pause, resume, budget-limited, and usage-limited status changes are controlled by the user or system" - ); - - let goal = session - .get_thread_goal() - .await - .expect("read thread goal") - .expect("goal should still exist"); - assert_eq!(goal.status, ThreadGoalStatus::Active); -} - -#[tokio::test] -async fn update_goal_tool_marks_goal_blocked() { - let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); - let create_handler = CreateGoalHandler; - let update_handler = UpdateGoalHandler; - - create_handler - .handle(ToolInvocation { - session: Arc::clone(&session), - turn: Arc::clone(&turn_context), - cancellation_token: CancellationToken::new(), - tracker: Arc::clone(&tracker), - call_id: "create-goal".to_string(), - tool_name: codex_tools::ToolName::plain("create_goal"), - source: ToolCallSource::Direct, - payload: ToolPayload::Function { - arguments: serde_json::json!({ - "objective": "Keep the watcher alive", - "token_budget": 123, - }) - .to_string(), - }, - }) - .await - .expect("initial create_goal should succeed"); - - update_handler - .handle(ToolInvocation { - session: Arc::clone(&session), - turn: Arc::clone(&turn_context), - cancellation_token: CancellationToken::new(), - tracker, - call_id: "block-goal".to_string(), - tool_name: codex_tools::ToolName::plain("update_goal"), - source: ToolCallSource::Direct, - payload: ToolPayload::Function { - arguments: serde_json::json!({ - "status": "blocked", - }) - .to_string(), - }, - }) - .await - .expect("update_goal should mark the goal blocked"); - - let goal = session - .get_thread_goal() - .await - .expect("read thread goal") - .expect("goal should still exist"); - assert_eq!(goal.status, ThreadGoalStatus::Blocked); -} - -#[tokio::test] -async fn update_goal_tool_rejects_usage_limited_goal() { - let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); - let create_handler = CreateGoalHandler; - let update_handler = UpdateGoalHandler; - - create_handler - .handle(ToolInvocation { - session: Arc::clone(&session), - turn: Arc::clone(&turn_context), - cancellation_token: CancellationToken::new(), - tracker: Arc::clone(&tracker), - call_id: "create-goal".to_string(), - tool_name: codex_tools::ToolName::plain("create_goal"), - source: ToolCallSource::Direct, - payload: ToolPayload::Function { - arguments: serde_json::json!({ - "objective": "Keep the watcher alive", - }) - .to_string(), - }, - }) - .await - .expect("initial create_goal should succeed"); - - let response = update_handler - .handle(ToolInvocation { - session: Arc::clone(&session), - turn: Arc::clone(&turn_context), - cancellation_token: CancellationToken::new(), - tracker, - call_id: "usage-limit-goal".to_string(), - tool_name: codex_tools::ToolName::plain("update_goal"), - source: ToolCallSource::Direct, - payload: ToolPayload::Function { - arguments: serde_json::json!({ - "status": "usageLimited", - }) - .to_string(), - }, - }) - .await; - - let Err(FunctionCallError::RespondToModel(output)) = response else { - panic!("expected update_goal to reject usage-limiting a goal"); - }; - assert_eq!( - output, - "update_goal can only mark the existing goal complete or blocked; pause, resume, budget-limited, and usage-limited status changes are controlled by the user or system" - ); - - let goal = session - .get_thread_goal() - .await - .expect("read thread goal") - .expect("goal should still exist"); - assert_eq!(goal.status, ThreadGoalStatus::Active); -} - -#[tokio::test] -async fn update_goal_tool_marks_goal_complete() { - let (session, turn_context, _rx, _codex_home) = make_goal_session_and_context_with_rx().await; - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); - let create_handler = CreateGoalHandler; - let update_handler = UpdateGoalHandler; - - create_handler - .handle(ToolInvocation { - session: Arc::clone(&session), - turn: Arc::clone(&turn_context), - cancellation_token: CancellationToken::new(), - tracker: Arc::clone(&tracker), - call_id: "create-goal".to_string(), - tool_name: codex_tools::ToolName::plain("create_goal"), - source: ToolCallSource::Direct, - payload: ToolPayload::Function { - arguments: serde_json::json!({ - "objective": "Keep the watcher alive", - "token_budget": 123, - }) - .to_string(), - }, - }) - .await - .expect("initial create_goal should succeed"); - - update_handler - .handle(ToolInvocation { - session: Arc::clone(&session), - turn: Arc::clone(&turn_context), - cancellation_token: CancellationToken::new(), - tracker, - call_id: "complete-goal".to_string(), - tool_name: codex_tools::ToolName::plain("update_goal"), - source: ToolCallSource::Direct, - payload: ToolPayload::Function { - arguments: serde_json::json!({ - "status": "complete", - }) - .to_string(), - }, - }) - .await - .expect("update_goal should mark the goal complete"); - - let goal = session - .get_thread_goal() - .await - .expect("read thread goal") - .expect("goal should still exist"); - assert_eq!(goal.status, ThreadGoalStatus::Complete); -} - #[tokio::test] async fn rejects_escalated_permissions_when_policy_not_on_request() { use crate::exec_policy::ExecApprovalRequest; @@ -10868,7 +9690,6 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { let turn_context_mut = Arc::get_mut(&mut turn_context).expect("unique thread settings Arc"); turn_context_mut.permission_profile = PermissionProfile::Disabled; - let file_system_sandbox_policy = turn_context.file_system_sandbox_policy(); let command = session.user_shell().derive_exec_args( command_script, turn_context.config.permissions.allow_login_shell, @@ -10880,9 +9701,7 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { command: &command, approval_policy: turn_context.approval_policy.value(), permission_profile: turn_context.permission_profile(), - file_system_sandbox_policy: &file_system_sandbox_policy, - #[allow(deprecated)] - sandbox_cwd: turn_context.cwd.as_path(), + windows_sandbox_level: turn_context.windows_sandbox_level, sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, }) diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 4a9316496cfd..9d4e7d1e8288 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; use std::collections::HashSet; +use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::Ordering; @@ -17,7 +18,6 @@ use crate::compact_remote_v2::run_inline_remote_auto_compact_task as run_inline_ use crate::connectors; use crate::context::ContextualUserFragment; use crate::feedback_tags; -use crate::goals::GoalRuntimeEvent; use crate::hook_runtime::inspect_pending_input; use crate::hook_runtime::record_additional_contexts; use crate::hook_runtime::record_pending_input; @@ -73,7 +73,6 @@ use codex_core_skills::injection::InjectedHostSkillPrompts; use codex_extension_api::TurnInputContext; use codex_extension_api::TurnInputEnvironment; use codex_features::Feature; -use codex_git_utils::get_git_repo_root; use codex_git_utils::get_git_repo_root_with_fs; use codex_protocol::config_types::AutoCompactTokenLimitScope; use codex_protocol::config_types::ModeKind; @@ -151,15 +150,6 @@ pub(crate) async fn run_turn( let error = err.to_codex_protocol_error(); sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone()) .await; - if error == CodexErrorInfo::UsageLimitExceeded - && let Err(err) = sess - .goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { - turn_context: turn_context.as_ref(), - }) - .await - { - warn!("failed to usage-limit active goal after usage-limit error: {err}"); - } error!("Failed to run pre-sampling compact"); return None; } @@ -196,21 +186,11 @@ pub(crate) async fn run_turn( let mut stop_hook_active = false; // Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Task which contains // many turns, from the perspective of the user, it is a single turn. - #[allow(deprecated)] - let display_root = match turn_context.environments.primary() { - Some(turn_environment) => get_git_repo_root_with_fs( - turn_environment.environment.get_filesystem().as_ref(), - &turn_environment.cwd, - ) - .await - .unwrap_or_else(|| turn_environment.cwd.clone()) - .into_path_buf(), - None => get_git_repo_root(turn_context.cwd.as_path()) - .unwrap_or_else(|| turn_context.cwd.clone().into_path_buf()), - }; - let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::with_display_root( - display_root, - ))); + let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new( + TurnDiffTracker::with_environment_display_roots( + turn_diff_display_roots(turn_context.as_ref()).await, + ), + )); // `ModelClientSession` is turn-scoped and caches WebSocket + sticky routing state, so we reuse // one instance across retries within this turn. @@ -304,17 +284,6 @@ pub(crate) async fn run_turn( let error = err.to_codex_protocol_error(); sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone()) .await; - if error == CodexErrorInfo::UsageLimitExceeded - && let Err(err) = sess - .goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { - turn_context: turn_context.as_ref(), - }) - .await - { - warn!( - "failed to usage-limit active goal after usage-limit error: {err}" - ); - } return None; } can_drain_pending_input = !model_needs_follow_up; @@ -400,15 +369,6 @@ pub(crate) async fn run_turn( let error = e.to_codex_protocol_error(); sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone()) .await; - if error == CodexErrorInfo::UsageLimitExceeded - && let Err(err) = sess - .goal_runtime_apply(GoalRuntimeEvent::UsageLimitReached { - turn_context: turn_context.as_ref(), - }) - .await - { - warn!("failed to usage-limit active goal after usage-limit error: {err}"); - } sess.track_turn_codex_error(turn_context.as_ref(), &e); let event = EventMsg::Error(e.to_error_event(/*message_prefix*/ None)); sess.send_event(&turn_context, event).await; @@ -421,6 +381,21 @@ pub(crate) async fn run_turn( last_agent_message } +async fn turn_diff_display_roots(turn_context: &TurnContext) -> Vec<(String, PathBuf)> { + let mut display_roots = Vec::new(); + for turn_environment in &turn_context.environments.turn_environments { + let root = get_git_repo_root_with_fs( + turn_environment.environment.get_filesystem().as_ref(), + &turn_environment.cwd, + ) + .await + .unwrap_or_else(|| turn_environment.cwd.clone()) + .into_path_buf(); + display_roots.push((turn_environment.environment_id.clone(), root)); + } + display_roots +} + async fn run_hooks_and_record_inputs( sess: &Arc, turn_context: &Arc, @@ -1083,6 +1058,7 @@ async fn run_sampling_request( ResponsesStreamRequest::Sampling, ) .await?; + turn_context.turn_timing_state.record_sampling_retry(); } } @@ -1392,6 +1368,7 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option { | EventMsg::RealtimeConversationClosed(_) | EventMsg::ModelReroute(_) | EventMsg::ModelVerification(_) + | EventMsg::TurnModerationMetadata(_) | EventMsg::ContextCompacted(_) | EventMsg::ThreadRolledBack(_) | EventMsg::TurnStarted(_) @@ -1794,6 +1771,7 @@ async fn try_run_sampling_request( turn_context.model_info.slug.as_str(), turn_context.provider.info().name.as_str(), ); + let sampling_timing_guard = turn_context.turn_timing_state.begin_sampling(); let mut stream = client_session .stream( prompt, @@ -1924,6 +1902,7 @@ async fn try_run_sampling_request( role == "assistant" && matches!(phase, Some(MessagePhase::Commentary)) } ResponseItem::Reasoning { .. } => true, + ResponseItem::AgentMessage { .. } => false, ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::ToolSearchCall { .. } @@ -2056,6 +2035,10 @@ async fn try_run_sampling_request( .await; } } + ResponseEvent::TurnModerationMetadata(metadata) => { + sess.emit_turn_moderation_metadata(&turn_context, metadata) + .await; + } ResponseEvent::ServerReasoningIncluded(included) => { sess.set_server_reasoning_included(included).await; } @@ -2202,6 +2185,7 @@ async fn try_run_sampling_request( } } }; + drop(sampling_timing_guard); flush_assistant_text_segments_all( &sess, @@ -2211,7 +2195,13 @@ async fn try_run_sampling_request( ) .await; + let tool_blocking_timing_guard = if in_flight.is_empty() { + None + } else { + Some(turn_context.turn_timing_state.begin_tool_blocking()) + }; drain_in_flight(&mut in_flight, sess.clone(), turn_context.clone()).await?; + drop(tool_blocking_timing_guard); if should_emit_token_count { // A tool call such as request_user_input can intentionally pause the turn. Emit token diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 66a32f2aa6bd..5f52912d59a8 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -91,7 +91,6 @@ pub struct TurnContext { pub(crate) shell_environment_policy: ShellEnvironmentPolicy, pub(crate) available_models: Vec, pub(crate) unified_exec_shell_mode: UnifiedExecShellMode, - pub(crate) goal_tools_supported: bool, pub features: ManagedFeatures, pub(crate) ghost_snapshot: GhostSnapshotConfig, pub(crate) final_output_json_schema: Option, @@ -170,10 +169,6 @@ impl TurnContext { ToolEnvironmentMode::from_count(self.environments.turn_environments.len()) } - pub(crate) fn goal_tools_enabled(&self) -> bool { - self.goal_tools_supported && self.features.get().enabled(Feature::Goals) - } - pub(crate) async fn with_model( &self, model: String, @@ -264,7 +259,6 @@ impl TurnContext { shell_environment_policy: self.shell_environment_policy.clone(), available_models, unified_exec_shell_mode: self.unified_exec_shell_mode.clone(), - goal_tools_supported: self.goal_tools_supported, features, ghost_snapshot: self.ghost_snapshot.clone(), final_output_json_schema: self.final_output_json_schema.clone(), @@ -477,7 +471,6 @@ impl Session { cwd: AbsolutePathBuf, sub_id: String, skills_outcome: Arc, - goal_tools_supported: bool, ) -> TurnContext { let reasoning_effort = session_configuration.collaboration_mode.reasoning_effort(); let reasoning_summary = session_configuration @@ -568,7 +561,6 @@ impl Session { shell_environment_policy: per_turn_config.permissions.shell_environment_policy.clone(), available_models, unified_exec_shell_mode, - goal_tools_supported, features: per_turn_config.features.clone(), ghost_snapshot: per_turn_config.ghost_snapshot.clone(), final_output_json_schema: None, @@ -781,7 +773,6 @@ impl Session { .skills_for_config(&skills_input, fs) .await, ); - let goal_tools_supported = !per_turn_config.ephemeral && self.state_db().is_some(); let mut turn_context: TurnContext = Self::make_turn_context( self.thread_id(), self.session_id(), @@ -810,7 +801,6 @@ impl Session { cwd, sub_id, skills_outcome, - goal_tools_supported, ); turn_context.realtime_active = self.conversation.running_state().await.is_some(); diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index 48c760d6677a..9843d4cea587 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -1,19 +1,12 @@ -use crate::shell_detect::detect_shell_type; use crate::shell_snapshot::ShellSnapshot; +use codex_shell_command::shell_detect::DetectedShell; use serde::Deserialize; use serde::Serialize; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::watch; -#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] -pub enum ShellType { - Zsh, - Bash, - PowerShell, - Sh, - Cmd, -} +pub use codex_shell_command::shell_detect::ShellType; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Shell { @@ -29,13 +22,7 @@ pub struct Shell { impl Shell { pub fn name(&self) -> &'static str { - match self.shell_type { - ShellType::Zsh => "zsh", - ShellType::Bash => "bash", - ShellType::PowerShell => "powershell", - ShellType::Sh => "sh", - ShellType::Cmd => "cmd", - } + self.shell_type.name() } /// Takes a string of shell and returns the full list of command args to @@ -88,319 +75,36 @@ impl PartialEq for Shell { impl Eq for Shell {} -#[cfg(unix)] -fn get_user_shell_path() -> Option { - let uid = unsafe { libc::getuid() }; - use std::ffi::CStr; - use std::mem::MaybeUninit; - use std::ptr; - - let mut passwd = MaybeUninit::::uninit(); - - // We cannot use getpwuid here: it returns pointers into libc-managed - // storage, which is not safe to read concurrently on all targets (the musl - // static build used by the CLI can segfault when parallel callers race on - // that buffer). getpwuid_r keeps the passwd data in caller-owned memory. - let suggested_buffer_len = unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) }; - let buffer_len = usize::try_from(suggested_buffer_len) - .ok() - .filter(|len| *len > 0) - .unwrap_or(1024); - let mut buffer = vec![0; buffer_len]; - - loop { - let mut result = ptr::null_mut(); - let status = unsafe { - libc::getpwuid_r( - uid, - passwd.as_mut_ptr(), - buffer.as_mut_ptr().cast(), - buffer.len(), - &mut result, - ) - }; - - if status == 0 { - if result.is_null() { - return None; - } - - let passwd = unsafe { passwd.assume_init_ref() }; - if passwd.pw_shell.is_null() { - return None; - } - - let shell_path = unsafe { CStr::from_ptr(passwd.pw_shell) } - .to_string_lossy() - .into_owned(); - return Some(PathBuf::from(shell_path)); - } - - if status != libc::ERANGE { - return None; - } - - // Retry with a larger buffer until libc can materialize the passwd entry. - let new_len = buffer.len().checked_mul(2)?; - if new_len > 1024 * 1024 { - return None; - } - buffer.resize(new_len, 0); - } -} - -#[cfg(not(unix))] -fn get_user_shell_path() -> Option { - None -} - -fn file_exists(path: &PathBuf) -> Option { - if std::fs::metadata(path).is_ok_and(|metadata| metadata.is_file()) { - Some(PathBuf::from(path)) - } else { - None - } -} - -fn get_shell_path( - shell_type: ShellType, - provided_path: Option<&PathBuf>, - binary_name: &str, - fallback_paths: &[&str], -) -> Option { - // If exact provided path exists, use it - if provided_path.and_then(file_exists).is_some() { - return provided_path.cloned(); - } - - // Check if the shell we are trying to load is user's default shell - // if just use it - let default_shell_path = get_user_shell_path(); - if let Some(default_shell_path) = default_shell_path - && detect_shell_type(&default_shell_path) == Some(shell_type) - && file_exists(&default_shell_path).is_some() - { - return Some(default_shell_path); - } - - if let Ok(path) = which::which(binary_name) { - return Some(path); - } - - for path in fallback_paths { - //check exists - if let Some(path) = file_exists(&PathBuf::from(path)) { - return Some(path); +impl From for Shell { + fn from(detected: DetectedShell) -> Self { + Self { + shell_type: detected.shell_type, + shell_path: detected.shell_path, + shell_snapshot: empty_shell_snapshot_receiver(), } } - - None -} - -const ZSH_FALLBACK_PATHS: &[&str] = &["/bin/zsh"]; - -fn get_zsh_shell(path: Option<&PathBuf>) -> Option { - let shell_path = get_shell_path(ShellType::Zsh, path, "zsh", ZSH_FALLBACK_PATHS); - - shell_path.map(|shell_path| Shell { - shell_type: ShellType::Zsh, - shell_path, - shell_snapshot: empty_shell_snapshot_receiver(), - }) -} - -const BASH_FALLBACK_PATHS: &[&str] = &["/bin/bash"]; - -fn get_bash_shell(path: Option<&PathBuf>) -> Option { - let shell_path = get_shell_path(ShellType::Bash, path, "bash", BASH_FALLBACK_PATHS); - - shell_path.map(|shell_path| Shell { - shell_type: ShellType::Bash, - shell_path, - shell_snapshot: empty_shell_snapshot_receiver(), - }) -} - -const SH_FALLBACK_PATHS: &[&str] = &["/bin/sh"]; - -fn get_sh_shell(path: Option<&PathBuf>) -> Option { - let shell_path = get_shell_path(ShellType::Sh, path, "sh", SH_FALLBACK_PATHS); - - shell_path.map(|shell_path| Shell { - shell_type: ShellType::Sh, - shell_path, - shell_snapshot: empty_shell_snapshot_receiver(), - }) -} - -// Note the `pwsh` and `powershell` fallback paths are where the respective -// shells are commonly installed on GitHub Actions Windows runners, but may not -// be present on all Windows machines: -// https://docs.github.com/en/actions/tutorials/build-and-test-code/powershell - -#[cfg(windows)] -const PWSH_FALLBACK_PATHS: &[&str] = &[r#"C:\Program Files\PowerShell\7\pwsh.exe"#]; -#[cfg(not(windows))] -const PWSH_FALLBACK_PATHS: &[&str] = &["/usr/local/bin/pwsh"]; - -#[cfg(windows)] -const POWERSHELL_FALLBACK_PATHS: &[&str] = - &[r#"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"#]; -#[cfg(not(windows))] -const POWERSHELL_FALLBACK_PATHS: &[&str] = &[]; - -fn get_powershell_shell(path: Option<&PathBuf>) -> Option { - let shell_path = get_shell_path(ShellType::PowerShell, path, "pwsh", PWSH_FALLBACK_PATHS) - .or_else(|| { - get_shell_path( - ShellType::PowerShell, - path, - "powershell", - POWERSHELL_FALLBACK_PATHS, - ) - }); - - shell_path.map(|shell_path| Shell { - shell_type: ShellType::PowerShell, - shell_path, - shell_snapshot: empty_shell_snapshot_receiver(), - }) -} - -fn get_cmd_shell(path: Option<&PathBuf>) -> Option { - let shell_path = get_shell_path(ShellType::Cmd, path, "cmd", &[]); - - shell_path.map(|shell_path| Shell { - shell_type: ShellType::Cmd, - shell_path, - shell_snapshot: empty_shell_snapshot_receiver(), - }) } +#[cfg(all(test, unix))] fn ultimate_fallback_shell() -> Shell { - if cfg!(windows) { - Shell { - shell_type: ShellType::Cmd, - shell_path: PathBuf::from("cmd.exe"), - shell_snapshot: empty_shell_snapshot_receiver(), - } - } else { - Shell { - shell_type: ShellType::Sh, - shell_path: PathBuf::from("/bin/sh"), - shell_snapshot: empty_shell_snapshot_receiver(), - } - } + codex_shell_command::shell_detect::ultimate_fallback_shell().into() } pub fn get_shell_by_model_provided_path(shell_path: &PathBuf) -> Shell { - detect_shell_type(shell_path) - .and_then(|shell_type| get_shell(shell_type, Some(shell_path))) - .unwrap_or(ultimate_fallback_shell()) + codex_shell_command::shell_detect::get_shell_by_model_provided_path(shell_path).into() } pub fn get_shell(shell_type: ShellType, path: Option<&PathBuf>) -> Option { - match shell_type { - ShellType::Zsh => get_zsh_shell(path), - ShellType::Bash => get_bash_shell(path), - ShellType::PowerShell => get_powershell_shell(path), - ShellType::Sh => get_sh_shell(path), - ShellType::Cmd => get_cmd_shell(path), - } + codex_shell_command::shell_detect::get_shell(shell_type, path).map(Into::into) } pub fn default_user_shell() -> Shell { - default_user_shell_from_path(get_user_shell_path()) + codex_shell_command::shell_detect::default_user_shell().into() } +#[cfg(all(test, target_os = "macos"))] fn default_user_shell_from_path(user_shell_path: Option) -> Shell { - if cfg!(windows) { - get_shell(ShellType::PowerShell, /*path*/ None).unwrap_or(ultimate_fallback_shell()) - } else { - let user_default_shell = user_shell_path - .and_then(|shell| detect_shell_type(&shell)) - .and_then(|shell_type| get_shell(shell_type, /*path*/ None)); - - let shell_with_fallback = if cfg!(target_os = "macos") { - user_default_shell - .or_else(|| get_shell(ShellType::Zsh, /*path*/ None)) - .or_else(|| get_shell(ShellType::Bash, /*path*/ None)) - } else { - user_default_shell - .or_else(|| get_shell(ShellType::Bash, /*path*/ None)) - .or_else(|| get_shell(ShellType::Zsh, /*path*/ None)) - }; - - shell_with_fallback.unwrap_or(ultimate_fallback_shell()) - } -} - -#[cfg(test)] -mod detect_shell_type_tests { - use super::*; - - #[test] - fn test_detect_shell_type() { - assert_eq!( - detect_shell_type(&PathBuf::from("zsh")), - Some(ShellType::Zsh) - ); - assert_eq!( - detect_shell_type(&PathBuf::from("bash")), - Some(ShellType::Bash) - ); - assert_eq!( - detect_shell_type(&PathBuf::from("pwsh")), - Some(ShellType::PowerShell) - ); - assert_eq!( - detect_shell_type(&PathBuf::from("powershell")), - Some(ShellType::PowerShell) - ); - assert_eq!(detect_shell_type(&PathBuf::from("fish")), None); - assert_eq!(detect_shell_type(&PathBuf::from("other")), None); - assert_eq!( - detect_shell_type(&PathBuf::from("/bin/zsh")), - Some(ShellType::Zsh) - ); - assert_eq!( - detect_shell_type(&PathBuf::from("/bin/bash")), - Some(ShellType::Bash) - ); - assert_eq!( - detect_shell_type(&PathBuf::from("powershell.exe")), - Some(ShellType::PowerShell) - ); - assert_eq!( - detect_shell_type(&PathBuf::from(if cfg!(windows) { - "C:\\windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" - } else { - "/usr/local/bin/pwsh" - })), - Some(ShellType::PowerShell) - ); - assert_eq!( - detect_shell_type(&PathBuf::from("pwsh.exe")), - Some(ShellType::PowerShell) - ); - assert_eq!( - detect_shell_type(&PathBuf::from("/usr/local/bin/pwsh")), - Some(ShellType::PowerShell) - ); - assert_eq!( - detect_shell_type(&PathBuf::from("/bin/sh")), - Some(ShellType::Sh) - ); - assert_eq!(detect_shell_type(&PathBuf::from("sh")), Some(ShellType::Sh)); - assert_eq!( - detect_shell_type(&PathBuf::from("cmd")), - Some(ShellType::Cmd) - ); - assert_eq!( - detect_shell_type(&PathBuf::from("cmd.exe")), - Some(ShellType::Cmd) - ); - } + codex_shell_command::shell_detect::default_user_shell_from_path(user_shell_path).into() } #[cfg(test)] diff --git a/codex-rs/core/src/shell_detect.rs b/codex-rs/core/src/shell_detect.rs deleted file mode 100644 index 3595ab3469bb..000000000000 --- a/codex-rs/core/src/shell_detect.rs +++ /dev/null @@ -1,24 +0,0 @@ -use crate::shell::ShellType; -use std::path::Path; -use std::path::PathBuf; - -pub(crate) fn detect_shell_type(shell_path: &PathBuf) -> Option { - match shell_path.as_os_str().to_str() { - Some("zsh") => Some(ShellType::Zsh), - Some("sh") => Some(ShellType::Sh), - Some("cmd") => Some(ShellType::Cmd), - Some("bash") => Some(ShellType::Bash), - Some("pwsh") => Some(ShellType::PowerShell), - Some("powershell") => Some(ShellType::PowerShell), - _ => { - let shell_name = shell_path.file_stem(); - if let Some(shell_name) = shell_name { - let shell_name_path = Path::new(shell_name); - if shell_name_path != Path::new(shell_path) { - return detect_shell_type(&shell_name_path.to_path_buf()); - } - } - None - } - } -} diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index b328a977d7e3..1cbd53866769 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -151,9 +151,7 @@ impl ShellSnapshot { }); // Make the new snapshot. - if let Err(err) = - write_shell_snapshot(shell.shell_type.clone(), &temp_path, session_cwd).await - { + if let Err(err) = write_shell_snapshot(shell.shell_type, &temp_path, session_cwd).await { tracing::warn!( "Failed to create shell snapshot for {}: {err:?}", shell.name() @@ -203,7 +201,7 @@ async fn write_shell_snapshot( if shell_type == ShellType::PowerShell || shell_type == ShellType::Cmd { bail!("Shell snapshot not supported yet for {shell_type:?}"); } - let shell = get_shell(shell_type.clone(), /*path*/ None) + let shell = get_shell(shell_type, /*path*/ None) .with_context(|| format!("No available shell for {shell_type:?}"))?; let raw_snapshot = capture_snapshot(&shell, cwd).await?; @@ -225,7 +223,7 @@ async fn write_shell_snapshot( } async fn capture_snapshot(shell: &Shell, cwd: &AbsolutePathBuf) -> Result { - let shell_type = shell.shell_type.clone(); + let shell_type = shell.shell_type; match shell_type { ShellType::Zsh => run_shell_script(shell, &zsh_snapshot_script(), cwd).await, ShellType::Bash => run_shell_script(shell, &bash_snapshot_script(), cwd).await, diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index ef2ba0f16d1a..a9a664a50510 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -23,7 +23,6 @@ use tracing::warn; use crate::config::Config; use crate::context::ContextualUserFragment; -use crate::goals::GoalRuntimeEvent; use crate::hook_runtime::inspect_pending_input; use crate::hook_runtime::record_additional_contexts; use crate::hook_runtime::record_pending_input; @@ -33,6 +32,7 @@ use crate::session::turn_context::TurnContext; use crate::state::ActiveTurn; use crate::state::RunningTask; use crate::state::TaskKind; +use codex_analytics::TurnProfileFact; use codex_analytics::TurnTokenUsageFact; use codex_login::AuthManager; use codex_models_manager::manager::SharedModelsManager; @@ -341,15 +341,6 @@ impl Session { .await .clear_turn(&turn_context.sub_id); - if let Err(err) = self - .goal_runtime_apply(GoalRuntimeEvent::TurnStarted { - turn_context: turn_context.as_ref(), - token_usage: token_usage_at_turn_start.clone(), - }) - .await - { - warn!("failed to apply goal runtime turn-start event: {err}"); - } let pending_items = self.input_queue.get_pending_input(&self.active_turn).await; let turn_state = { let mut active = self.active_turn.lock().await; @@ -503,15 +494,6 @@ impl Session { self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref()) .await; } - if (aborted_turn || reason == TurnAbortReason::Interrupted) - && let Err(err) = self - .goal_runtime_apply(GoalRuntimeEvent::TaskAborted { - turn_context: turn_context.as_deref(), - }) - .await - { - warn!("failed to apply goal runtime abort event: {err}"); - } if let Some(active_turn) = active_turn_to_clear { // Let interrupted tasks observe cancellation before dropping pending approvals, or an // in-flight approval wait can surface as a model-visible rejection before TurnAborted. @@ -552,14 +534,6 @@ impl Session { self.emit_turn_abort_lifecycle(reason.clone(), turn_context.extension_data.as_ref()) .await; } - if let Err(err) = self - .goal_runtime_apply(GoalRuntimeEvent::TaskAborted { - turn_context: turn_context.as_deref(), - }) - .await - { - warn!("failed to apply goal runtime abort event: {err}"); - } // Let interrupted tasks observe cancellation before dropping pending approvals, or an // in-flight approval wait can surface as a model-visible rejection before TurnAborted. self.input_queue.clear_pending(&active_turn).await; @@ -751,17 +725,14 @@ impl Session { .turn_timing_state .time_to_first_token_ms() .await; + self.services + .analytics_events_client + .track_turn_profile(TurnProfileFact { + turn_id: turn_context.sub_id.clone(), + profile: turn_context.turn_timing_state.complete_profile(), + }); self.emit_turn_stop_lifecycle(turn_context.extension_data.as_ref()) .await; - if let Err(err) = self - .goal_runtime_apply(GoalRuntimeEvent::TurnFinished { - turn_context: turn_context.as_ref(), - turn_completed: true, - }) - .await - { - warn!("failed to apply goal runtime turn-finished event: {err}"); - } let event = EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_context.sub_id.clone(), last_agent_message, @@ -791,12 +762,6 @@ impl Session { if !cleared_active_turn { return; } - if let Err(err) = self - .goal_runtime_apply(GoalRuntimeEvent::MaybeContinueIfIdle) - .await - { - warn!("failed to apply goal runtime maybe-continue event: {err}"); - } self.emit_thread_idle_lifecycle_if_idle().await; } @@ -868,6 +833,12 @@ impl Session { .turn_timing_state .completed_at_and_duration_ms() .await; + self.services + .analytics_events_client + .track_turn_profile(TurnProfileFact { + turn_id: task.turn_context.sub_id.clone(), + profile: task.turn_context.turn_timing_state.complete_profile(), + }); let event = EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some(task.turn_context.sub_id.clone()), reason, diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index d130a358158d..1cde8c932058 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1343,9 +1343,6 @@ impl ThreadManagerState { .await?; if is_resumed_thread { new_thread.thread.emit_thread_resume_lifecycle().await; - if let Err(err) = new_thread.thread.apply_goal_resume_runtime_effects().await { - warn!("failed to apply goal resume runtime effects: {err}"); - } } Ok(new_thread) } diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 52a0f8eb3bdc..34ec68211059 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -8,7 +8,6 @@ use crate::session::tests::make_session_and_context; use crate::tasks::InterruptedTurnHistoryMarker; use crate::tasks::interrupted_turn_history_marker; use codex_extension_api::empty_extension_registry; -use codex_features::Feature; use codex_models_manager::manager::RefreshStrategy; use codex_protocol::models::ContentItem; use codex_protocol::models::ReasoningItemReasoningSummary; @@ -1501,103 +1500,3 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ 1, ); } - -#[tokio::test] -async fn resumed_thread_keeps_paused_goal_paused() -> anyhow::Result<()> { - 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(); - config - .features - .enable(Feature::Goals) - .expect("goals should be enableable in tests"); - 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 state_db = init_state_db(&config).await; - let manager = ThreadManager::new( - &config, - auth_manager.clone(), - SessionSource::Exec, - Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), - empty_extension_registry(), - /*analytics_events_client*/ None, - thread_store_from_config(&config, state_db.clone()), - state_db.clone(), - TEST_INSTALLATION_ID.to_string(), - /*attestation_provider*/ None, - ); - - let source = manager - .resume_thread_with_history( - config.clone(), - InitialHistory::Forked(vec![RolloutItem::ResponseItem(user_msg("keep working"))]), - auth_manager.clone(), - /*parent_trace*/ None, - ) - .await - .expect("create source thread"); - let source_path = source - .thread - .rollout_path() - .expect("source rollout path should exist"); - source.thread.flush_rollout().await?; - let state_db = source - .thread - .state_db() - .expect("source thread should have a state db"); - state_db - .thread_goals() - .replace_thread_goal( - source.thread_id, - "Keep working until the task is done", - codex_state::ThreadGoalStatus::Paused, - /*token_budget*/ None, - ) - .await?; - source.thread.shutdown_and_wait().await?; - manager.remove_thread(&source.thread_id).await; - - let resumed = manager - .resume_thread_from_rollout( - config.clone(), - source_path, - auth_manager, - /*parent_trace*/ None, - ) - .await - .expect("resume source thread"); - let goal = state_db - .thread_goals() - .get_thread_goal(resumed.thread_id) - .await? - .expect("goal should still exist after resume"); - assert_eq!(codex_state::ThreadGoalStatus::Paused, goal.status); - assert!( - resumed - .thread - .codex - .session - .active_turn - .lock() - .await - .is_none() - ); - - resumed.thread.continue_active_goal_if_idle().await?; - assert!( - resumed - .thread - .codex - .session - .active_turn - .lock() - .await - .is_none() - ); - - resumed.thread.shutdown_and_wait().await?; - Ok(()) -} diff --git a/codex-rs/core/src/tools/events.rs b/codex-rs/core/src/tools/events.rs index 747143ff69ac..e286af53d4ee 100644 --- a/codex-rs/core/src/tools/events.rs +++ b/codex-rs/core/src/tools/events.rs @@ -70,16 +70,25 @@ pub(crate) enum ToolEventFailure<'a> { } enum TurnDiffTrackerUpdate<'a> { - Track(&'a AppliedPatchDelta), + Track { + environment_id: Option, + delta: &'a AppliedPatchDelta, + }, Invalidate, None, } -fn tracker_update_for_known_delta(delta: &AppliedPatchDelta) -> TurnDiffTrackerUpdate<'_> { +fn tracker_update_for_known_delta<'a>( + environment_id: Option<&str>, + delta: &'a AppliedPatchDelta, +) -> TurnDiffTrackerUpdate<'a> { if delta.is_exact() && delta.is_empty() { TurnDiffTrackerUpdate::None } else { - TurnDiffTrackerUpdate::Track(delta) + TurnDiffTrackerUpdate::Track { + environment_id: environment_id.map(str::to_string), + delta, + } } } @@ -120,6 +129,7 @@ pub(crate) enum ToolEmitter { ApplyPatch { changes: HashMap, auto_approved: bool, + environment_id: Option, }, UnifiedExec { command: Vec, @@ -141,10 +151,15 @@ impl ToolEmitter { } } - pub fn apply_patch(changes: HashMap, auto_approved: bool) -> Self { + pub fn apply_patch_for_environment( + changes: HashMap, + auto_approved: bool, + environment_id: String, + ) -> Self { Self::ApplyPatch { changes, auto_approved, + environment_id: Some(environment_id), } } @@ -210,7 +225,11 @@ impl ToolEmitter { .await; } ( - Self::ApplyPatch { changes, .. }, + Self::ApplyPatch { + changes, + environment_id, + .. + }, ToolEventStage::Success { output, applied_patch_delta, @@ -222,7 +241,7 @@ impl ToolEmitter { PatchApplyStatus::Failed }; let tracker_update = applied_patch_delta - .map(tracker_update_for_known_delta) + .map(|delta| tracker_update_for_known_delta(environment_id.as_deref(), delta)) .unwrap_or(TurnDiffTrackerUpdate::Invalidate); emit_patch_end( ctx, @@ -267,7 +286,11 @@ impl ToolEmitter { .await; } ( - Self::ApplyPatch { changes, .. }, + Self::ApplyPatch { + changes, + environment_id, + .. + }, ToolEventStage::Failure(ToolEventFailure::Rejected { message, applied_patch_delta, @@ -280,7 +303,9 @@ impl ToolEmitter { (*message).to_string(), PatchApplyStatus::Declined, applied_patch_delta - .map(tracker_update_for_known_delta) + .map(|delta| { + tracker_update_for_known_delta(environment_id.as_deref(), delta) + }) .unwrap_or(TurnDiffTrackerUpdate::None), ) .await; @@ -565,8 +590,11 @@ async fn emit_patch_end( let mut guard = tracker.lock().await; let previous_diff = guard.get_unified_diff(); let tracker_changed = match tracker_update { - TurnDiffTrackerUpdate::Track(delta) => { - guard.track_delta(delta); + TurnDiffTrackerUpdate::Track { + environment_id, + delta, + } => { + guard.track_delta(environment_id.as_deref().unwrap_or_default(), delta); true } TurnDiffTrackerUpdate::Invalidate => { @@ -627,14 +655,18 @@ mod tests { .await .expect("apply patch"); - ToolEmitter::apply_patch(HashMap::new(), /*auto_approved*/ false) - .finish( - ToolEventCtx::new(session.as_ref(), turn.as_ref(), "call-id", Some(&tracker)), - out, - Some(&delta), - ) - .await - .expect_err("failed patch"); + ToolEmitter::ApplyPatch { + changes: HashMap::new(), + auto_approved: false, + environment_id: None, + } + .finish( + ToolEventCtx::new(session.as_ref(), turn.as_ref(), "call-id", Some(&tracker)), + out, + Some(&delta), + ) + .await + .expect_err("failed patch"); let completed = rx_event.recv().await.expect("item completed event"); assert!(matches!( diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index c12ba0517ed3..66fb1d308092 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -378,8 +378,11 @@ impl ToolExecutor for ApplyPatchHandler { } InternalApplyPatchInvocation::DelegateToRuntime(apply) => { let changes = convert_apply_patch_to_protocol(&apply.action); - let emitter = - ToolEmitter::apply_patch(changes.clone(), apply.auto_approved); + let emitter = ToolEmitter::apply_patch_for_environment( + changes.clone(), + apply.auto_approved, + turn_environment.environment_id.clone(), + ); let event_ctx = ToolEventCtx::new( session.as_ref(), turn.as_ref(), @@ -537,7 +540,11 @@ pub(crate) async fn intercept_apply_patch( } InternalApplyPatchInvocation::DelegateToRuntime(apply) => { let changes = convert_apply_patch_to_protocol(&apply.action); - let emitter = ToolEmitter::apply_patch(changes.clone(), apply.auto_approved); + let emitter = ToolEmitter::apply_patch_for_environment( + changes.clone(), + apply.auto_approved, + turn_environment.environment_id.clone(), + ); let event_ctx = ToolEventCtx::new( session.as_ref(), turn.as_ref(), diff --git a/codex-rs/core/src/tools/handlers/goal.rs b/codex-rs/core/src/tools/handlers/goal.rs deleted file mode 100644 index 694eafca6585..000000000000 --- a/codex-rs/core/src/tools/handlers/goal.rs +++ /dev/null @@ -1,158 +0,0 @@ -//! Built-in model tool handlers for persisted thread goals. -//! -//! The public tool contract intentionally splits goal creation from stopped -//! status updates: `create_goal` starts an active objective, while -//! `update_goal` can only mark the existing goal complete or blocked. - -use crate::function_tool::FunctionCallError; -use crate::tools::context::FunctionToolOutput; -use codex_protocol::protocol::ThreadGoal; -use codex_protocol::protocol::ThreadGoalStatus; -use serde::Deserialize; -use serde::Serialize; -use std::fmt::Write as _; - -mod create_goal; -mod get_goal; -mod update_goal; - -pub use create_goal::CreateGoalHandler; -pub use get_goal::GetGoalHandler; -pub use update_goal::UpdateGoalHandler; - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "snake_case")] -struct CreateGoalArgs { - objective: String, - token_budget: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "snake_case")] -struct UpdateGoalArgs { - status: ThreadGoalStatus, -} - -#[derive(Debug, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -struct GoalToolResponse { - goal: Option, - remaining_tokens: Option, - completion_budget_report: Option, -} - -#[derive(Clone, Copy)] -enum CompletionBudgetReport { - Include, - Omit, -} - -impl GoalToolResponse { - fn new(goal: Option, report_mode: CompletionBudgetReport) -> Self { - let remaining_tokens = goal.as_ref().and_then(|goal| { - goal.token_budget - .map(|budget| (budget - goal.tokens_used).max(0)) - }); - let completion_budget_report = match report_mode { - CompletionBudgetReport::Include => goal - .as_ref() - .filter(|goal| goal.status == ThreadGoalStatus::Complete) - .and_then(completion_budget_report), - CompletionBudgetReport::Omit => None, - }; - Self { - goal, - remaining_tokens, - completion_budget_report, - } - } -} - -fn format_goal_error(err: anyhow::Error) -> String { - let mut message = err.to_string(); - for cause in err.chain().skip(1) { - let _ = write!(message, ": {cause}"); - } - message -} - -fn goal_response( - goal: Option, - completion_budget_report: CompletionBudgetReport, -) -> Result { - let response = - serde_json::to_string_pretty(&GoalToolResponse::new(goal, completion_budget_report)) - .map_err(|err| FunctionCallError::Fatal(err.to_string()))?; - Ok(FunctionToolOutput::from_text(response, Some(true))) -} - -fn completion_budget_report(goal: &ThreadGoal) -> Option { - if goal.token_budget.is_none() && goal.time_used_seconds <= 0 { - None - } else { - Some( - "Goal achieved. Report final usage from this tool result's structured goal fields. If `goal.tokenBudget` is present, include token usage from `goal.tokensUsed` and `goal.tokenBudget`. If `goal.timeUsedSeconds` is greater than 0, summarize elapsed time in a concise, human-friendly form appropriate to the response language." - .to_string(), - ) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use codex_protocol::ThreadId; - use pretty_assertions::assert_eq; - - #[test] - fn completed_budgeted_goal_response_reports_final_usage() { - let goal = ThreadGoal { - thread_id: ThreadId::new(), - objective: "Keep optimizing".to_string(), - status: ThreadGoalStatus::Complete, - token_budget: Some(10_000), - tokens_used: 3_250, - time_used_seconds: 75, - created_at: 1, - updated_at: 2, - }; - - let response = GoalToolResponse::new(Some(goal.clone()), CompletionBudgetReport::Include); - - assert_eq!( - response, - GoalToolResponse { - goal: Some(goal), - remaining_tokens: Some(6_750), - completion_budget_report: Some( - "Goal achieved. Report final usage from this tool result's structured goal fields. If `goal.tokenBudget` is present, include token usage from `goal.tokensUsed` and `goal.tokenBudget`. If `goal.timeUsedSeconds` is greater than 0, summarize elapsed time in a concise, human-friendly form appropriate to the response language." - .to_string() - ), - } - ); - } - - #[test] - fn completed_unbudgeted_goal_response_omits_budget_report() { - let goal = ThreadGoal { - thread_id: ThreadId::new(), - objective: "Write a poem".to_string(), - status: ThreadGoalStatus::Complete, - token_budget: None, - tokens_used: 120, - time_used_seconds: 0, - created_at: 1, - updated_at: 2, - }; - - let response = GoalToolResponse::new(Some(goal.clone()), CompletionBudgetReport::Include); - - assert_eq!( - response, - GoalToolResponse { - goal: Some(goal), - remaining_tokens: None, - completion_budget_report: None, - } - ); - } -} diff --git a/codex-rs/core/src/tools/handlers/goal/create_goal.rs b/codex-rs/core/src/tools/handlers/goal/create_goal.rs deleted file mode 100644 index 1791a5e85f9b..000000000000 --- a/codex-rs/core/src/tools/handlers/goal/create_goal.rs +++ /dev/null @@ -1,78 +0,0 @@ -use crate::function_tool::FunctionCallError; -use crate::goals::CreateGoalRequest; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolPayload; -use crate::tools::context::boxed_tool_output; -use crate::tools::handlers::goal_spec::CREATE_GOAL_TOOL_NAME; -use crate::tools::handlers::goal_spec::create_create_goal_tool; -use crate::tools::handlers::parse_arguments; -use crate::tools::registry::CoreToolRuntime; -use crate::tools::registry::ToolExecutor; -use codex_tools::ToolName; -use codex_tools::ToolSpec; - -use super::CompletionBudgetReport; -use super::CreateGoalArgs; -use super::format_goal_error; -use super::goal_response; - -pub struct CreateGoalHandler; - -#[async_trait::async_trait] -impl ToolExecutor for CreateGoalHandler { - fn tool_name(&self) -> ToolName { - ToolName::plain(CREATE_GOAL_TOOL_NAME) - } - - fn spec(&self) -> ToolSpec { - create_create_goal_tool() - } - - async fn handle( - &self, - invocation: ToolInvocation, - ) -> Result, FunctionCallError> { - let ToolInvocation { - session, - turn, - payload, - .. - } = invocation; - - let arguments = match payload { - ToolPayload::Function { arguments } => arguments, - _ => { - return Err(FunctionCallError::RespondToModel( - "goal handler received unsupported payload".to_string(), - )); - } - }; - - let args: CreateGoalArgs = parse_arguments(&arguments)?; - let goal = session - .create_thread_goal( - turn.as_ref(), - CreateGoalRequest { - objective: args.objective, - token_budget: args.token_budget, - }, - ) - .await - .map_err(|err| { - if err - .chain() - .any(|cause| cause.to_string().contains("already has a goal")) - { - FunctionCallError::RespondToModel( - "cannot create a new goal because this thread already has a goal; use update_goal only when the existing goal is complete" - .to_string(), - ) - } else { - FunctionCallError::RespondToModel(format_goal_error(err)) - } - })?; - goal_response(Some(goal), CompletionBudgetReport::Omit).map(boxed_tool_output) - } -} - -impl CoreToolRuntime for CreateGoalHandler {} diff --git a/codex-rs/core/src/tools/handlers/goal/get_goal.rs b/codex-rs/core/src/tools/handlers/goal/get_goal.rs deleted file mode 100644 index ff460706759d..000000000000 --- a/codex-rs/core/src/tools/handlers/goal/get_goal.rs +++ /dev/null @@ -1,51 +0,0 @@ -use crate::function_tool::FunctionCallError; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolPayload; -use crate::tools::context::boxed_tool_output; -use crate::tools::handlers::goal_spec::GET_GOAL_TOOL_NAME; -use crate::tools::handlers::goal_spec::create_get_goal_tool; -use crate::tools::registry::CoreToolRuntime; -use crate::tools::registry::ToolExecutor; -use codex_tools::ToolName; -use codex_tools::ToolSpec; - -use super::CompletionBudgetReport; -use super::format_goal_error; -use super::goal_response; - -pub struct GetGoalHandler; - -#[async_trait::async_trait] -impl ToolExecutor for GetGoalHandler { - fn tool_name(&self) -> ToolName { - ToolName::plain(GET_GOAL_TOOL_NAME) - } - - fn spec(&self) -> ToolSpec { - create_get_goal_tool() - } - - async fn handle( - &self, - invocation: ToolInvocation, - ) -> Result, FunctionCallError> { - let ToolInvocation { - session, payload, .. - } = invocation; - - match payload { - ToolPayload::Function { .. } => { - let goal = session - .get_thread_goal() - .await - .map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?; - goal_response(goal, CompletionBudgetReport::Omit).map(boxed_tool_output) - } - _ => Err(FunctionCallError::RespondToModel( - "get_goal handler received unsupported payload".to_string(), - )), - } - } -} - -impl CoreToolRuntime for GetGoalHandler {} diff --git a/codex-rs/core/src/tools/handlers/goal/update_goal.rs b/codex-rs/core/src/tools/handlers/goal/update_goal.rs deleted file mode 100644 index 373c849fc2a8..000000000000 --- a/codex-rs/core/src/tools/handlers/goal/update_goal.rs +++ /dev/null @@ -1,89 +0,0 @@ -use crate::function_tool::FunctionCallError; -use crate::goals::GoalRuntimeEvent; -use crate::goals::SetGoalRequest; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolPayload; -use crate::tools::context::boxed_tool_output; -use crate::tools::handlers::goal_spec::UPDATE_GOAL_TOOL_NAME; -use crate::tools::handlers::goal_spec::create_update_goal_tool; -use crate::tools::handlers::parse_arguments; -use crate::tools::registry::CoreToolRuntime; -use crate::tools::registry::ToolExecutor; -use codex_protocol::protocol::ThreadGoalStatus; -use codex_tools::ToolName; -use codex_tools::ToolSpec; - -use super::CompletionBudgetReport; -use super::UpdateGoalArgs; -use super::format_goal_error; -use super::goal_response; - -pub struct UpdateGoalHandler; - -#[async_trait::async_trait] -impl ToolExecutor for UpdateGoalHandler { - fn tool_name(&self) -> ToolName { - ToolName::plain(UPDATE_GOAL_TOOL_NAME) - } - - fn spec(&self) -> ToolSpec { - create_update_goal_tool() - } - - async fn handle( - &self, - invocation: ToolInvocation, - ) -> Result, FunctionCallError> { - let ToolInvocation { - session, - turn, - payload, - .. - } = invocation; - - let arguments = match payload { - ToolPayload::Function { arguments } => arguments, - _ => { - return Err(FunctionCallError::RespondToModel( - "update_goal handler received unsupported payload".to_string(), - )); - } - }; - - let args: UpdateGoalArgs = parse_arguments(&arguments)?; - if !matches!( - args.status, - ThreadGoalStatus::Complete | ThreadGoalStatus::Blocked - ) { - return Err(FunctionCallError::RespondToModel( - "update_goal can only mark the existing goal complete or blocked; pause, resume, budget-limited, and usage-limited status changes are controlled by the user or system" - .to_string(), - )); - } - session - .goal_runtime_apply(GoalRuntimeEvent::ToolCompletedGoal { - turn_context: turn.as_ref(), - }) - .await - .map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?; - let goal = session - .set_thread_goal( - turn.as_ref(), - SetGoalRequest { - objective: None, - status: Some(args.status), - token_budget: None, - }, - ) - .await - .map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?; - let completion_budget_report = if args.status == ThreadGoalStatus::Complete { - CompletionBudgetReport::Include - } else { - CompletionBudgetReport::Omit - }; - goal_response(Some(goal), completion_budget_report).map(boxed_tool_output) - } -} - -impl CoreToolRuntime for UpdateGoalHandler {} diff --git a/codex-rs/core/src/tools/handlers/goal_spec.rs b/codex-rs/core/src/tools/handlers/goal_spec.rs deleted file mode 100644 index da8e23d3053d..000000000000 --- a/codex-rs/core/src/tools/handlers/goal_spec.rs +++ /dev/null @@ -1,120 +0,0 @@ -//! Responses API tool definitions for persisted thread goals. -//! -//! These specs expose goal read/update primitives to the model while keeping -//! usage accounting system-managed. - -use codex_tools::JsonSchema; -use codex_tools::ResponsesApiTool; -use codex_tools::ToolSpec; -use serde_json::json; -use std::collections::BTreeMap; - -pub const GET_GOAL_TOOL_NAME: &str = "get_goal"; -pub const CREATE_GOAL_TOOL_NAME: &str = "create_goal"; -pub const UPDATE_GOAL_TOOL_NAME: &str = "update_goal"; - -pub fn create_get_goal_tool() -> ToolSpec { - ToolSpec::Function(ResponsesApiTool { - name: GET_GOAL_TOOL_NAME.to_string(), - description: "Get the current goal for this thread, including status, budgets, token and elapsed-time usage, and remaining token budget." - .to_string(), - strict: false, - defer_loading: None, - parameters: JsonSchema::object(BTreeMap::new(), Some(Vec::new()), Some(false.into())), - output_schema: None, - }) -} - -pub fn create_create_goal_tool() -> ToolSpec { - let properties = BTreeMap::from([ - ( - "objective".to_string(), - JsonSchema::string(Some( - "Required. The concrete objective to start pursuing. This starts a new active goal only when no goal is currently defined; if a goal already exists, this tool fails." - .to_string(), - )), - ), - ( - "token_budget".to_string(), - JsonSchema::integer(Some( - "Positive token budget for the new goal. Omit unless explicitly requested." - .to_string(), - )), - ), - ]); - - ToolSpec::Function(ResponsesApiTool { - name: CREATE_GOAL_TOOL_NAME.to_string(), - description: format!( - r#"Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks. -Set token_budget only when an explicit token budget is requested. Fails if a goal exists; use {UPDATE_GOAL_TOOL_NAME} only for status."# - ), - strict: false, - defer_loading: None, - parameters: JsonSchema::object( - properties, - /*required*/ Some(vec!["objective".to_string()]), - Some(false.into()), - ), - output_schema: None, - }) -} - -pub fn create_update_goal_tool() -> ToolSpec { - let properties = BTreeMap::from([( - "status".to_string(), - JsonSchema::string_enum( - vec![json!("complete"), json!("blocked")], - Some( - "Required. Set to `complete` only when the objective is achieved and no required work remains. Set to `blocked` only after the same blocking condition has recurred for at least three consecutive goal turns and the agent is at an impasse. After a previously blocked goal is resumed, the resumed run starts a fresh blocked audit." - .to_string(), - ), - ), - )]); - - ToolSpec::Function(ResponsesApiTool { - name: UPDATE_GOAL_TOOL_NAME.to_string(), - description: r#"Update the existing goal. -Use this tool only to mark the goal achieved or genuinely blocked. -Set status to `complete` only when the objective has actually been achieved and no required work remains. -Set status to `blocked` only when the same blocking condition has repeated for at least three consecutive goal turns, counting the original/user-triggered turn and any automatic continuations, and the agent cannot make meaningful progress without user input or an external-state change. -If the user resumes a goal that was previously marked `blocked`, treat the resumed run as a fresh blocked audit. If the same blocking condition then repeats for at least three consecutive resumed goal turns, set status to `blocked` again. -Once the blocked threshold is satisfied, do not keep reporting that you are still blocked while leaving the goal active; set status to `blocked`. -Do not use `blocked` merely because the work is hard, slow, uncertain, incomplete, or would benefit from clarification. -Do not mark a goal complete merely because its budget is nearly exhausted or because you are stopping work. -You cannot use this tool to pause, resume, budget-limit, or usage-limit a goal; those status changes are controlled by the user or system. -When marking a budgeted goal achieved with status `complete`, report the final token usage from the tool result to the user."# - .to_string(), - strict: false, - defer_loading: None, - parameters: JsonSchema::object( - properties, - /*required*/ Some(vec!["status".to_string()]), - Some(false.into()), - ), - output_schema: None, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn update_goal_tool_exposes_complete_and_blocked_statuses() { - let ToolSpec::Function(tool) = create_update_goal_tool() else { - panic!("update_goal should be a function tool"); - }; - let status = tool - .parameters - .properties - .as_ref() - .and_then(|properties| properties.get("status")) - .expect("status property should exist"); - - assert_eq!( - status.enum_values, - Some(vec![json!("complete"), json!("blocked")]) - ); - } -} diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index d6963f07617b..fdfb80996557 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -4,8 +4,6 @@ pub(crate) mod apply_patch; pub(crate) mod apply_patch_spec; mod dynamic; pub(crate) mod extension_tools; -mod goal; -pub(crate) mod goal_spec; mod list_available_plugins_to_install; pub(crate) mod list_available_plugins_to_install_spec; mod mcp; @@ -53,9 +51,6 @@ pub use apply_patch::ApplyPatchHandler; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::protocol::AskForApproval; pub use dynamic::DynamicToolHandler; -pub use goal::CreateGoalHandler; -pub use goal::GetGoalHandler; -pub use goal::UpdateGoalHandler; pub use list_available_plugins_to_install::ListAvailablePluginsToInstallHandler; pub use mcp::McpHandler; pub use mcp_resource::ListMcpResourceTemplatesHandler; diff --git a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs index 1b7018939e35..6e28d0a1d97e 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs @@ -42,8 +42,17 @@ impl ToolExecutor for Handler { let receiver_agent = session .services .agent_control - .get_agent_metadata(receiver_thread_id) - .unwrap_or_default(); + .get_agent_metadata(receiver_thread_id); + if receiver_agent.is_some() { + let resume_config = build_agent_resume_config(turn.as_ref())?; + session + .services + .agent_control + .ensure_v2_agent_loaded(resume_config, receiver_thread_id) + .await + .map_err(|err| collab_agent_error(receiver_thread_id, err))?; + } + let receiver_agent = receiver_agent.unwrap_or_default(); if args.interrupt { session .services diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs index a15464d9df1f..1dcb55f436fd 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs @@ -167,7 +167,8 @@ pub fn create_send_message_tool() -> ToolSpec { "message".to_string(), JsonSchema::string(Some( "Message text to queue on the target agent.".to_string(), - )), + )) + .with_encrypted(), ), ]); @@ -199,13 +200,14 @@ pub fn create_followup_task_tool() -> ToolSpec { "message".to_string(), JsonSchema::string(Some( "Message text to send to the target agent.".to_string(), - )), + )) + .with_encrypted(), ), ]); ToolSpec::Function(ResponsesApiTool { name: "followup_task".to_string(), - description: "Send a follow-up task to an existing non-root target agent and trigger a turn in that target. If the target is currently mid-turn, the message is queued and will be used to start the target's next turn, after the current turn completes." + description: "Send a follow-up task to an existing non-root target agent and trigger a turn if it is idle. If the target is already running, deliver the task promptly at message boundaries while sampling, or after the pending tool call completes." .to_string(), strict: false, defer_loading: None, @@ -595,7 +597,10 @@ fn spawn_agent_common_properties_v2(agent_type_description: &str) -> BTreeMap InterAgentCommunication { + InterAgentCommunication::new_encrypted( + author, + recipient, + Vec::new(), + message, + /*trigger_turn*/ true, + ) +} diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs index 258d80c85b2d..528fa55264c0 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs @@ -1,4 +1,4 @@ -//! Shared argument parsing and dispatch for the v2 text-only agent messaging tools. +//! Shared argument parsing and dispatch for the v2 agent messaging tools. //! //! `send_message` and `followup_task` share the same submission path and differ only in whether the //! resulting `InterAgentCommunication` should wake the target immediately. @@ -55,26 +55,31 @@ fn message_content(message: String) -> Result { Ok(message) } -/// Handles the shared MultiAgentV2 plain-text message flow for both `send_message` and `followup_task`. +/// Handles the shared MultiAgentV2 message flow for both `send_message` and `followup_task`. pub(crate) async fn handle_message_string_tool( invocation: ToolInvocation, mode: MessageDeliveryMode, target: String, message: String, ) -> Result { - let prompt = message_content(message)?; + let message = message_content(message)?; let ToolInvocation { session, turn, call_id, .. } = invocation; + let prompt = String::new(); let receiver_thread_id = resolve_agent_target(&session, &turn, &target).await?; let receiver_agent = session .services .agent_control .get_agent_metadata(receiver_thread_id) - .unwrap_or_default(); + .ok_or_else(|| { + FunctionCallError::RespondToModel(format!( + "agent with id {receiver_thread_id} not found" + )) + })?; if mode == MessageDeliveryMode::TriggerTurn && receiver_agent .agent_path @@ -85,6 +90,16 @@ pub(crate) async fn handle_message_string_tool( "Follow-up tasks can't target the root agent".to_string(), )); } + let receiver_agent_path = receiver_agent.agent_path.clone().ok_or_else(|| { + FunctionCallError::RespondToModel("target agent is missing an agent_path".to_string()) + })?; + let resume_config = build_agent_resume_config(turn.as_ref())?; + session + .services + .agent_control + .ensure_v2_agent_loaded(resume_config, receiver_thread_id) + .await + .map_err(|err| collab_agent_error(receiver_thread_id, err))?; session .send_event( &turn, @@ -98,18 +113,11 @@ pub(crate) async fn handle_message_string_tool( .into(), ) .await; - let receiver_agent_path = receiver_agent.agent_path.clone().ok_or_else(|| { - FunctionCallError::RespondToModel("target agent is missing an agent_path".to_string()) - })?; - let communication = InterAgentCommunication::new( - turn.session_source - .get_agent_path() - .unwrap_or_else(AgentPath::root), - receiver_agent_path, - Vec::new(), - prompt.clone(), - /*trigger_turn*/ true, - ); + let author = turn + .session_source + .get_agent_path() + .unwrap_or_else(AgentPath::root); + let communication = communication_from_tool_message(author, receiver_agent_path, message); let result = session .services .agent_control diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index 7d5b469a4506..b5b7d210f590 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -1,7 +1,6 @@ use super::*; use crate::agent::control::SpawnAgentForkMode; use crate::agent::control::SpawnAgentOptions; -use crate::agent::control::render_input_preview; use crate::agent::next_thread_spawn_depth; use crate::agent::role::DEFAULT_ROLE_NAME; use crate::agent::role::apply_role_to_config; @@ -9,7 +8,6 @@ use crate::tools::handlers::multi_agents_spec::SpawnAgentToolOptions; use crate::tools::handlers::multi_agents_spec::create_spawn_agent_tool_v2; use crate::turn_timing::now_unix_timestamp_ms; use codex_protocol::AgentPath; -use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::Op; use codex_tools::ToolSpec; @@ -61,8 +59,9 @@ async fn handle_spawn_agent( .map(str::trim) .filter(|role| !role.is_empty()); + let message = args.message.clone(); let initial_operation = parse_collab_input(Some(args.message), /*items*/ None)?; - let prompt = render_input_preview(&initial_operation); + let prompt = String::new(); let session_source = turn.session_source.clone(); let child_depth = next_thread_spawn_depth(&session_source); @@ -129,17 +128,12 @@ async fn handle_spawn_agent( .iter() .all(|item| matches!(item, UserInput::Text { .. })) => { - Op::InterAgentCommunication { - communication: InterAgentCommunication::new( - turn.session_source - .get_agent_path() - .unwrap_or_else(AgentPath::root), - recipient, - Vec::new(), - prompt.clone(), - /*trigger_turn*/ true, - ), - } + let author = turn + .session_source + .get_agent_path() + .unwrap_or_else(AgentPath::root); + let communication = communication_from_tool_message(author, recipient, message); + Op::InterAgentCommunication { communication } } (_, initial_operation) => initial_operation, }, diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 504653cf7672..b978a2f577a8 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -160,7 +160,6 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result Result for ShellCommandHandler { session.thread_id, turn.config.permissions.allow_login_shell, )?; - let shell_type = Some(session.user_shell().shell_type.clone()); + let shell_type = Some(session.user_shell().shell_type); run_exec_like(RunExecLikeArgs { tool_name, exec_params, diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 6fa2e40685b7..ca6b2a4b3e44 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -122,7 +122,7 @@ pub(crate) fn get_command( let shell = model_shell.as_ref().unwrap_or(session_shell.as_ref()); Ok(ResolvedCommand { command: shell.derive_exec_args(&args.cmd, use_login_shell), - shell_type: shell.shell_type.clone(), + shell_type: shell.shell_type, }) } UnifiedExecShellMode::ZshFork(zsh_fork_config) => { diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 235f8c4e96a5..77cbffde328c 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -39,6 +39,7 @@ use codex_protocol::protocol::NetworkPolicyRuleAction; use codex_protocol::protocol::ReviewDecision; use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxType; +use std::time::Instant; pub(crate) struct ToolOrchestrator { sandbox: SandboxManager, @@ -256,6 +257,7 @@ impl ToolOrchestrator { network_denial_cancellation_token: None, }; + let initial_attempt_start = Instant::now(); let (first_result, first_deferred_network_approval) = Self::run_attempt( tool, req, @@ -264,6 +266,7 @@ impl ToolOrchestrator { managed_network_active, ) .await; + let initial_duration = initial_attempt_start.elapsed(); match first_result { Ok(out) => { // We have a successful initial result @@ -284,12 +287,26 @@ impl ToolOrchestrator { None }; if network_policy_decision.is_some() && network_approval_context.is_none() { + otel.sandbox_outcome( + &otel_tn, + otel_ci, + "denied", + initial_duration, + /*escalated_duration*/ None, + ); return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output, network_policy_decision, }))); } if !tool.escalate_on_failure() { + otel.sandbox_outcome( + &otel_tn, + otel_ci, + "denied", + initial_duration, + /*escalated_duration*/ None, + ); return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output, network_policy_decision, @@ -312,6 +329,13 @@ impl ToolOrchestrator { ExecApprovalRequirement::NeedsApproval { .. } ); if !allow_on_request_network_prompt { + otel.sandbox_outcome( + &otel_tn, + otel_ci, + "denied", + initial_duration, + /*escalated_duration*/ None, + ); return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output, network_policy_decision, @@ -319,6 +343,13 @@ impl ToolOrchestrator { } } if !unsandboxed_allowed && network_approval_context.is_none() { + otel.sandbox_outcome( + &otel_tn, + otel_ci, + "denied", + initial_duration, + /*escalated_duration*/ None, + ); return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output, network_policy_decision, @@ -400,15 +431,51 @@ impl ToolOrchestrator { }; // Second attempt. + let escalated_attempt_start = Instant::now(); let (retry_result, retry_deferred_network_approval) = Self::run_attempt(tool, req, tool_ctx, &retry_attempt, managed_network_active) .await; - retry_result.map(|output| OrchestratorRunResult { - output, - deferred_network_approval: retry_deferred_network_approval, - }) + let escalated_duration = escalated_attempt_start.elapsed(); + match retry_result { + Ok(output) => { + otel.sandbox_outcome( + &otel_tn, + otel_ci, + "escalated", + initial_duration, + Some(escalated_duration), + ); + Ok(OrchestratorRunResult { + output, + deferred_network_approval: retry_deferred_network_approval, + }) + } + Err(err) => { + if let Some(outcome) = sandbox_outcome_from_tool_error(&err) { + otel.sandbox_outcome( + &otel_tn, + otel_ci, + outcome, + initial_duration, + Some(escalated_duration), + ); + } + Err(err) + } + } + } + Err(err) => { + if let Some(outcome) = sandbox_outcome_from_tool_error(&err) { + otel.sandbox_outcome( + &otel_tn, + otel_ci, + outcome, + initial_duration, + /*escalated_duration*/ None, + ); + } + Err(err) } - Err(err) => Err(err), } } @@ -509,6 +576,15 @@ impl ToolOrchestrator { } } +fn sandbox_outcome_from_tool_error(err: &ToolError) -> Option<&'static str> { + match err { + ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { .. })) => Some("denied"), + ToolError::Codex(CodexErr::Sandbox(SandboxErr::Timeout { .. })) => Some("timed_out"), + ToolError::Codex(CodexErr::Sandbox(SandboxErr::Signal(_))) => Some("signal"), + ToolError::Rejected(_) | ToolError::Codex(_) => None, + } +} + fn build_denial_reason_from_output(_output: &ExecToolCallOutput) -> String { // Keep approval reason terse and stable for UX/tests, but accept the // output so we can evolve heuristics later without touching call sites. diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index b2a0b7704ad7..1cc5e67eb56f 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -5,7 +5,6 @@ use std::sync::atomic::Ordering; use std::time::Duration; use crate::function_tool::FunctionCallError; -use crate::goals::GoalRuntimeEvent; use crate::hook_runtime::PreToolUseHookResult; use crate::hook_runtime::record_additional_contexts; use crate::hook_runtime::run_post_tool_use_hooks; @@ -34,7 +33,6 @@ use codex_tools::ToolSearchInfo; use codex_tools::ToolSpec; use futures::future::BoxFuture; use serde_json::Value; -use tracing::warn; pub(crate) type ToolTelemetryTags = Vec<(&'static str, String)>; @@ -649,25 +647,13 @@ impl ToolRegistry { handler_executed: true, }, }; - let finished = notify_tool_finish_if_unclaimed( + notify_tool_finish_if_unclaimed( &invocation, terminal_outcome_reached.as_deref(), lifecycle_outcome, ) .await; - if finished - && let Err(err) = invocation - .session - .goal_runtime_apply(GoalRuntimeEvent::ToolCompleted { - turn_context: invocation.turn.as_ref(), - tool_name: tool_name.name.as_str(), - }) - .await - { - warn!("failed to account thread goal progress after tool call: {err}"); - } - match result { Ok(_) => { let mut guard = response_cell.lock().await; diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 2017718f7da6..6be105021049 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -65,7 +65,6 @@ use codex_shell_escalation::ShellCommandExecutor; use codex_shell_escalation::Stopwatch; use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; -use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -224,7 +223,6 @@ pub(super) async fn try_run_zsh_fork( approval_policy: ctx.turn.approval_policy.value(), permission_profile: command_executor.permission_profile.clone(), file_system_sandbox_policy: command_executor.file_system_sandbox_policy.clone(), - sandbox_policy_cwd: command_executor.sandbox_policy_cwd.clone(), sandbox_permissions: req.sandbox_permissions, approval_sandbox_permissions, prompt_permissions: req.additional_permissions.clone(), @@ -297,7 +295,6 @@ pub(crate) async fn prepare_unified_exec_zsh_fork( approval_policy: ctx.turn.approval_policy.value(), permission_profile: exec_request.permission_profile.clone(), file_system_sandbox_policy: exec_request.file_system_sandbox_policy.clone(), - sandbox_policy_cwd: exec_request.windows_sandbox_policy_cwd.clone(), sandbox_permissions: req.sandbox_permissions, approval_sandbox_permissions: approval_sandbox_permissions( req.sandbox_permissions, @@ -332,7 +329,6 @@ struct CoreShellActionProvider { approval_policy: AskForApproval, permission_profile: PermissionProfile, file_system_sandbox_policy: FileSystemSandboxPolicy, - sandbox_policy_cwd: AbsolutePathBuf, sandbox_permissions: SandboxPermissions, approval_sandbox_permissions: SandboxPermissions, prompt_permissions: Option, @@ -622,8 +618,7 @@ impl EscalationPolicy for CoreShellActionProvider { InterceptedExecPolicyContext { approval_policy: self.approval_policy, permission_profile: self.permission_profile.clone(), - file_system_sandbox_policy: &self.file_system_sandbox_policy, - sandbox_cwd: self.sandbox_policy_cwd.as_path(), + windows_sandbox_level: self.turn.windows_sandbox_level, sandbox_permissions: self.approval_sandbox_permissions, enable_shell_wrapper_parsing: ENABLE_INTERCEPTED_EXEC_POLICY_SHELL_WRAPPER_PARSING, @@ -672,13 +667,12 @@ fn evaluate_intercepted_exec_policy( policy: &Policy, program: &AbsolutePathBuf, argv: &[String], - context: InterceptedExecPolicyContext<'_>, + context: InterceptedExecPolicyContext, ) -> Evaluation { let InterceptedExecPolicyContext { approval_policy, permission_profile, - file_system_sandbox_policy, - sandbox_cwd, + windows_sandbox_level, sandbox_permissions, enable_shell_wrapper_parsing, } = context; @@ -705,8 +699,7 @@ fn evaluate_intercepted_exec_policy( crate::exec_policy::UnmatchedCommandContext { approval_policy, permission_profile: &permission_profile, - file_system_sandbox_policy, - sandbox_cwd, + windows_sandbox_level, sandbox_permissions, used_complex_parsing, command_origin: crate::exec_policy::ExecPolicyCommandOrigin::Generic, @@ -724,11 +717,10 @@ fn evaluate_intercepted_exec_policy( } #[derive(Clone)] -struct InterceptedExecPolicyContext<'a> { +struct InterceptedExecPolicyContext { approval_policy: AskForApproval, permission_profile: PermissionProfile, - file_system_sandbox_policy: &'a FileSystemSandboxPolicy, - sandbox_cwd: &'a Path, + windows_sandbox_level: WindowsSandboxLevel, sandbox_permissions: SandboxPermissions, enable_shell_wrapper_parsing: bool, } diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index 6e7a1ced546f..8cfbd0736ddb 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -16,6 +16,7 @@ use codex_execpolicy::PolicyParser; use codex_execpolicy::RuleMatch; use codex_hooks::Hooks; use codex_hooks::HooksConfig; +use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::PermissionProfile; @@ -456,7 +457,6 @@ async fn execve_permission_request_hook_short_circuits_prompt() -> anyhow::Resul approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), - sandbox_policy_cwd: workdir.clone(), sandbox_permissions: SandboxPermissions::RequireEscalated, approval_sandbox_permissions: SandboxPermissions::RequireEscalated, prompt_permissions: None, @@ -508,7 +508,6 @@ fn evaluate_intercepted_exec_policy_uses_wrapper_command_when_shell_wrapper_pars parser.parse("test.rules", policy_src).unwrap(); let policy = parser.build(); let program = AbsolutePathBuf::try_from(host_absolute_path(&["bin", "zsh"])).unwrap(); - let sandbox_cwd = test_sandbox_cwd(); let enable_intercepted_exec_policy_shell_wrapper_parsing = false; let evaluation = evaluate_intercepted_exec_policy( @@ -522,8 +521,7 @@ fn evaluate_intercepted_exec_policy_uses_wrapper_command_when_shell_wrapper_pars InterceptedExecPolicyContext { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), - sandbox_cwd: sandbox_cwd.as_path(), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, enable_shell_wrapper_parsing: enable_intercepted_exec_policy_shell_wrapper_parsing, }, @@ -561,7 +559,6 @@ fn evaluate_intercepted_exec_policy_matches_inner_shell_commands_when_enabled() parser.parse("test.rules", policy_src).unwrap(); let policy = parser.build(); let program = AbsolutePathBuf::try_from(host_absolute_path(&["bin", "bash"])).unwrap(); - let sandbox_cwd = test_sandbox_cwd(); let enable_intercepted_exec_policy_shell_wrapper_parsing = true; let evaluation = evaluate_intercepted_exec_policy( @@ -575,8 +572,7 @@ fn evaluate_intercepted_exec_policy_matches_inner_shell_commands_when_enabled() InterceptedExecPolicyContext { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), - sandbox_cwd: sandbox_cwd.as_path(), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, enable_shell_wrapper_parsing: enable_intercepted_exec_policy_shell_wrapper_parsing, }, @@ -610,7 +606,6 @@ host_executable(name = "git", paths = ["{git_path_literal}"]) parser.parse("test.rules", &policy_src).unwrap(); let policy = parser.build(); let program = AbsolutePathBuf::try_from(git_path).unwrap(); - let sandbox_cwd = test_sandbox_cwd(); let evaluation = evaluate_intercepted_exec_policy( &policy, @@ -619,8 +614,7 @@ host_executable(name = "git", paths = ["{git_path_literal}"]) InterceptedExecPolicyContext { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), - sandbox_cwd: sandbox_cwd.as_path(), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, enable_shell_wrapper_parsing: false, }, @@ -673,7 +667,6 @@ prefix_rule(pattern = ["{cat_path_literal}"], decision = "allow") approval_policy: AskForApproval::OnRequest, permission_profile, file_system_sandbox_policy, - sandbox_policy_cwd: workdir.clone(), sandbox_permissions: SandboxPermissions::UseDefault, approval_sandbox_permissions: SandboxPermissions::UseDefault, prompt_permissions: None, @@ -716,7 +709,6 @@ async fn denied_reads_keep_granular_sandbox_rejection_for_escalation() -> anyhow }), permission_profile, file_system_sandbox_policy, - sandbox_policy_cwd: workdir.clone(), sandbox_permissions: SandboxPermissions::RequireEscalated, approval_sandbox_permissions: SandboxPermissions::RequireEscalated, prompt_permissions: None, @@ -747,8 +739,6 @@ fn intercepted_exec_policy_treats_preapproved_additional_permissions_as_default( let argv = ["printf".to_string(), "hello".to_string()]; let approval_policy = AskForApproval::OnRequest; let permission_profile = PermissionProfile::workspace_write(); - let file_system_sandbox_policy = read_only_file_system_sandbox_policy(); - let sandbox_cwd = test_sandbox_cwd(); let preapproved = evaluate_intercepted_exec_policy( &policy, @@ -757,8 +747,7 @@ fn intercepted_exec_policy_treats_preapproved_additional_permissions_as_default( InterceptedExecPolicyContext { approval_policy, permission_profile: permission_profile.clone(), - file_system_sandbox_policy: &file_system_sandbox_policy, - sandbox_cwd: sandbox_cwd.as_path(), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: super::approval_sandbox_permissions( SandboxPermissions::WithAdditionalPermissions, /*additional_permissions_preapproved*/ true, @@ -773,8 +762,7 @@ fn intercepted_exec_policy_treats_preapproved_additional_permissions_as_default( InterceptedExecPolicyContext { approval_policy, permission_profile, - file_system_sandbox_policy: &file_system_sandbox_policy, - sandbox_cwd: sandbox_cwd.as_path(), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::WithAdditionalPermissions, enable_shell_wrapper_parsing: false, }, @@ -799,7 +787,6 @@ host_executable(name = "git", paths = ["{allowed_git_literal}"]) parser.parse("test.rules", &policy_src).unwrap(); let policy = parser.build(); let program = AbsolutePathBuf::try_from(other_git.clone()).unwrap(); - let sandbox_cwd = test_sandbox_cwd(); let evaluation = evaluate_intercepted_exec_policy( &policy, @@ -808,8 +795,7 @@ host_executable(name = "git", paths = ["{allowed_git_literal}"]) InterceptedExecPolicyContext { approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::read_only(), - file_system_sandbox_policy: &read_only_file_system_sandbox_policy(), - sandbox_cwd: sandbox_cwd.as_path(), + windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, enable_shell_wrapper_parsing: false, }, diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index eae0644b83ff..b1a01cd7aad4 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -6,11 +6,9 @@ use crate::tools::context::ToolInvocation; use crate::tools::handlers::ApplyPatchHandler; use crate::tools::handlers::CodeModeExecuteHandler; use crate::tools::handlers::CodeModeWaitHandler; -use crate::tools::handlers::CreateGoalHandler; use crate::tools::handlers::DynamicToolHandler; use crate::tools::handlers::ExecCommandHandler; use crate::tools::handlers::ExecCommandHandlerOptions; -use crate::tools::handlers::GetGoalHandler; use crate::tools::handlers::ListAvailablePluginsToInstallHandler; use crate::tools::handlers::ListMcpResourceTemplatesHandler; use crate::tools::handlers::ListMcpResourcesHandler; @@ -24,7 +22,6 @@ use crate::tools::handlers::ShellCommandHandler; use crate::tools::handlers::ShellCommandHandlerOptions; use crate::tools::handlers::TestSyncHandler; use crate::tools::handlers::ToolSearchHandler; -use crate::tools::handlers::UpdateGoalHandler; use crate::tools::handlers::ViewImageHandler; use crate::tools::handlers::WriteStdinHandler; use crate::tools::handlers::agent_jobs::ReportAgentJobResultHandler; @@ -59,6 +56,7 @@ use crate::tools::router::ToolRouterParams; use codex_features::Feature; use codex_login::AuthManager; use codex_mcp::ToolInfo; +use codex_protocol::config_types::WebSearchMode; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::InputModality; @@ -249,22 +247,31 @@ fn spec_for_model_request( fn hosted_model_tool_specs(context: &CoreToolPlanContext<'_>) -> Vec { let turn_context = context.turn_context; + // Responses Lite accepts schemas for client-executed tools, not hosted Responses tools. + if turn_context.model_info.use_responses_lite { + return Vec::new(); + } + let mut specs = Vec::new(); - let provider_capabilities = turn_context.provider.capabilities(); - let web_search_mode = (!standalone_web_run_available(context.extension_tool_executors) - && provider_capabilities.web_search) + let standalone_web_search_available = standalone_web_search_enabled(turn_context) + && context + .extension_tool_executors + .iter() + .any(|executor| executor.tool_name() == ToolName::namespaced("web", "run")); + // `Some(Cached/Live/Disabled)` are the options for mode when standalone search is unavailable + // and the provider supports hosted search. `None` prevents emitting a hosted search tool. + let web_search_mode = (!standalone_web_search_available + && turn_context.provider.capabilities().web_search) .then_some(turn_context.config.web_search_mode.value()); - let web_search_config = if provider_capabilities.web_search { - turn_context.config.web_search_config.as_ref() - } else { - None - }; - if let Some(web_search_tool) = create_web_search_tool(WebSearchToolOptions { + let web_search_config = web_search_mode + .as_ref() + .and(turn_context.config.web_search_config.as_ref()); + if let Some(hosted_web_search_tool) = create_web_search_tool(WebSearchToolOptions { web_search_mode, web_search_config, web_search_tool_type: turn_context.model_info.web_search_tool_type, }) { - specs.push(web_search_tool); + specs.push(hosted_web_search_tool); } // TODO: Remove hosted image generation once the standalone extension is ready. if image_generation_tool_enabled(turn_context) @@ -305,14 +312,6 @@ fn collab_tools_enabled(turn_context: &TurnContext) -> bool { } } -fn goal_tools_enabled(turn_context: &TurnContext) -> bool { - turn_context.goal_tools_enabled() - && !matches!( - turn_context.session_source, - SessionSource::SubAgent(SubAgentSource::Review) - ) -} - fn agent_jobs_tools_enabled(turn_context: &TurnContext) -> bool { turn_context.features.get().enabled(Feature::SpawnCsv) && collab_tools_enabled(turn_context) } @@ -347,9 +346,15 @@ fn image_generation_runtime_enabled(turn_context: &TurnContext) -> bool { } fn standalone_image_generation_model_visible(turn_context: &TurnContext) -> bool { - image_generation_runtime_enabled(turn_context) - && turn_context.features.get().enabled(Feature::ImageGenExt) - && namespace_tools_enabled(turn_context) + if !image_generation_runtime_enabled(turn_context) || !namespace_tools_enabled(turn_context) { + return false; + } + + if turn_context.model_info.use_responses_lite { + return true; + } + + turn_context.features.get().enabled(Feature::ImageGenExt) } fn standalone_image_generation_available( @@ -558,20 +563,20 @@ fn add_tool_sources(context: &CoreToolPlanContext<'_>, planned_tools: &mut Plann add_core_utility_tools(context, planned_tools); add_collaboration_tools(context, planned_tools); add_mcp_runtime_tools(context, planned_tools); - add_dynamic_tools(context, planned_tools); add_extension_tools(context, planned_tools); + add_dynamic_tools(context, planned_tools); for spec in hosted_model_tool_specs(context) { planned_tools.add_hosted_spec(spec); } } -fn standalone_web_run_available( - extension_tools: &[Arc>], -) -> bool { - let web_run = ToolName::namespaced("web", "run"); - extension_tools - .iter() - .any(|executor| executor.tool_name() == web_run) +fn standalone_web_search_enabled(turn_context: &TurnContext) -> bool { + namespace_tools_enabled(turn_context) + && (turn_context.model_info.use_responses_lite + || turn_context + .features + .get() + .enabled(Feature::StandaloneWebSearch)) } fn add_shell_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) { @@ -639,11 +644,6 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut let environment_mode = turn_context.tool_environment_mode(); planned_tools.add(PlanHandler); - if goal_tools_enabled(turn_context) { - planned_tools.add(GetGoalHandler); - planned_tools.add(CreateGoalHandler); - planned_tools.add(UpdateGoalHandler); - } if turn_context.config.experimental_request_user_input_enabled { planned_tools.add(RequestUserInputHandler { @@ -762,10 +762,7 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu SpawnAgentHandler::new(SpawnAgentToolOptions { available_models: turn_context.available_models.clone(), agent_type_description, - hide_agent_type_model_reasoning: turn_context - .config - .multi_agent_v2 - .hide_spawn_agent_metadata, + hide_agent_type_model_reasoning: false, include_usage_hint: turn_context.config.multi_agent_v2.usage_hint_enabled, usage_hint_text: turn_context.config.multi_agent_v2.usage_hint_text.clone(), max_concurrent_threads_per_session: max_concurrent_threads_per_session( @@ -902,8 +899,16 @@ fn append_extension_tool_executors( reserved_tool_names.insert(ToolName::plain(TOOL_SEARCH_TOOL_NAME)); } + let standalone_web_search_enabled = standalone_web_search_enabled(turn_context); + let web_search_mode_on = turn_context.config.web_search_mode.value() != WebSearchMode::Disabled; + for executor in executors.iter().cloned() { let tool_name = executor.tool_name(); + if tool_name == ToolName::namespaced("web", "run") + && (!standalone_web_search_enabled || !web_search_mode_on) + { + continue; + } if tool_name == ToolName::namespaced(IMAGE_GEN_NAMESPACE, IMAGEGEN_TOOL_NAME) && !standalone_image_generation_model_visible(turn_context) { diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index bf5b1c01bbc9..5fe811b19e82 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -619,36 +619,7 @@ async fn environment_count_controls_environment_backed_tools() { } #[tokio::test] -async fn host_context_gates_goal_and_agent_job_tools() { - let feature_disabled = probe(|turn| { - set_feature(turn, Feature::Goals, /*enabled*/ false); - turn.goal_tools_supported = true; - }) - .await; - feature_disabled.assert_visible_lacks(&["get_goal", "create_goal", "update_goal"]); - - let host_disabled = probe(|turn| { - set_feature(turn, Feature::Goals, /*enabled*/ true); - turn.goal_tools_supported = false; - }) - .await; - host_disabled.assert_visible_lacks(&["get_goal", "create_goal", "update_goal"]); - - let enabled = probe(|turn| { - set_feature(turn, Feature::Goals, /*enabled*/ true); - turn.goal_tools_supported = true; - }) - .await; - enabled.assert_visible_contains(&["get_goal", "create_goal", "update_goal"]); - - let review_thread = probe(|turn| { - set_feature(turn, Feature::Goals, /*enabled*/ true); - turn.goal_tools_supported = true; - turn.session_source = SessionSource::SubAgent(SubAgentSource::Review); - }) - .await; - review_thread.assert_visible_lacks(&["get_goal", "create_goal", "update_goal"]); - +async fn host_context_gates_agent_job_tools() { let normal_agent_job = probe(|turn| { set_feature(turn, Feature::SpawnCsv, /*enabled*/ true); }) @@ -1003,6 +974,30 @@ async fn multi_agent_feature_selects_one_agent_tool_family() { "wait_agent".to_string(), ] ); + let ToolSpec::Namespace(namespace) = v1.visible_spec(MULTI_AGENT_V1_NAMESPACE) else { + panic!("expected v1 multi-agent namespace"); + }; + let Some(ResponsesApiNamespaceTool::Function(spawn_agent)) = + namespace.tools.iter().find(|tool| { + matches!( + tool, + ResponsesApiNamespaceTool::Function(tool) if tool.name == "spawn_agent" + ) + }) + else { + panic!("expected v1 spawn_agent function"); + }; + let properties = spawn_agent + .parameters + .properties + .as_ref() + .expect("spawn_agent should use object params"); + for property in ["agent_type", "model", "reasoning_effort", "service_tier"] { + assert!( + properties.contains_key(property), + "expected v1 spawn_agent to expose `{property}`" + ); + } let v2 = probe(|turn| { set_feature(turn, Feature::MultiAgentV2, /*enabled*/ true); @@ -1047,6 +1042,30 @@ async fn multi_agent_feature_selects_one_agent_tool_family() { ); } +#[tokio::test] +async fn multi_agent_v2_message_schemas_are_encrypted() { + let plan = probe(|turn| { + set_feature(turn, Feature::MultiAgentV2, /*enabled*/ true); + }) + .await; + for tool_name in ["spawn_agent", "send_message", "followup_task"] { + let ToolSpec::Function(tool) = plan.visible_spec(tool_name) else { + panic!("expected {tool_name} function spec"); + }; + let properties = tool + .parameters + .properties + .as_ref() + .expect("tool should use object params"); + assert_eq!( + properties + .get("message") + .and_then(|schema| schema.encrypted), + Some(true) + ); + } +} + #[tokio::test] async fn tool_mode_selector_overrides_feature_flags() { let direct = probe(|turn| { diff --git a/codex-rs/core/src/turn_diff_tracker.rs b/codex-rs/core/src/turn_diff_tracker.rs index 3835ae234593..388c2210bd2e 100644 --- a/codex-rs/core/src/turn_diff_tracker.rs +++ b/codex-rs/core/src/turn_diff_tracker.rs @@ -13,21 +13,36 @@ const ZERO_OID: &str = "0000000000000000000000000000000000000000"; const DEV_NULL: &str = "/dev/null"; const REGULAR_FILE_MODE: &str = "100644"; +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct TrackedPath { + environment_id: String, + path: PathBuf, +} + +impl TrackedPath { + fn new(environment_id: &str, path: &Path) -> Self { + Self { + environment_id: environment_id.to_string(), + path: path.to_path_buf(), + } + } +} + /// Tracks the net text diff for the current turn from committed apply_patch /// mutations, without rereading the workspace filesystem. pub struct TurnDiffTracker { valid: bool, - display_root: Option, - baseline_by_path: HashMap, - current_by_path: HashMap, - origin_by_current_path: HashMap, + display_roots_by_environment: HashMap, + baseline_by_path: HashMap, + current_by_path: HashMap, + origin_by_current_path: HashMap, } impl Default for TurnDiffTracker { fn default() -> Self { Self { valid: true, - display_root: None, + display_roots_by_environment: HashMap::new(), baseline_by_path: HashMap::new(), current_by_path: HashMap::new(), origin_by_current_path: HashMap::new(), @@ -40,20 +55,22 @@ impl TurnDiffTracker { Self::default() } - pub fn with_display_root(display_root: PathBuf) -> Self { + pub fn with_environment_display_roots( + display_roots: impl IntoIterator, + ) -> Self { let mut tracker = Self::new(); - tracker.display_root = Some(display_root); + tracker.display_roots_by_environment = display_roots.into_iter().collect(); tracker } - pub fn track_delta(&mut self, delta: &AppliedPatchDelta) { + pub fn track_delta(&mut self, environment_id: &str, delta: &AppliedPatchDelta) { if !delta.is_exact() { self.invalidate(); return; } for change in delta.changes() { - self.apply_change(change); + self.apply_change(environment_id, change); } } @@ -106,8 +123,8 @@ impl TurnDiffTracker { (!aggregated.is_empty()).then_some(aggregated) } - fn apply_change(&mut self, change: &AppliedPatchChange) { - let source_path = change.path.as_path(); + fn apply_change(&mut self, environment_id: &str, change: &AppliedPatchChange) { + let source_path = TrackedPath::new(environment_id, change.path.as_path()); match &change.change { AppliedPatchFileChange::Add { content, @@ -119,85 +136,87 @@ impl TurnDiffTracker { old_content, overwritten_move_content, new_content, - } => self.apply_update( - source_path, - move_path.as_deref(), - old_content, - overwritten_move_content.as_deref(), - new_content, - ), + } => { + let move_path = move_path + .as_deref() + .map(|path| TrackedPath::new(environment_id, path)); + self.apply_update( + source_path, + move_path, + old_content, + overwritten_move_content.as_deref(), + new_content, + ) + } } } - fn apply_add(&mut self, path: &Path, content: &str, overwritten_content: Option<&str>) { - self.origin_by_current_path.remove(path); - if !self.current_by_path.contains_key(path) - && !self.baseline_by_path.contains_key(path) + fn apply_add(&mut self, path: TrackedPath, content: &str, overwritten_content: Option<&str>) { + self.origin_by_current_path.remove(&path); + if !self.current_by_path.contains_key(&path) + && !self.baseline_by_path.contains_key(&path) && let Some(overwritten_content) = overwritten_content { self.baseline_by_path - .insert(path.to_path_buf(), overwritten_content.to_string()); + .insert(path.clone(), overwritten_content.to_string()); } - self.current_by_path - .insert(path.to_path_buf(), content.to_string()); + self.current_by_path.insert(path, content.to_string()); } - fn apply_delete(&mut self, path: &Path, content: &str) { - if self.current_by_path.remove(path).is_none() && !self.baseline_by_path.contains_key(path) + fn apply_delete(&mut self, path: TrackedPath, content: &str) { + if self.current_by_path.remove(&path).is_none() + && !self.baseline_by_path.contains_key(&path) { self.baseline_by_path - .insert(path.to_path_buf(), content.to_string()); + .insert(path.clone(), content.to_string()); } - self.origin_by_current_path.remove(path); + self.origin_by_current_path.remove(&path); } fn apply_update( &mut self, - source_path: &Path, - move_path: Option<&Path>, + source_path: TrackedPath, + move_path: Option, old_content: &str, overwritten_move_content: Option<&str>, new_content: &str, ) { - if !self.current_by_path.contains_key(source_path) - && !self.baseline_by_path.contains_key(source_path) + if !self.current_by_path.contains_key(&source_path) + && !self.baseline_by_path.contains_key(&source_path) { self.baseline_by_path - .insert(source_path.to_path_buf(), old_content.to_string()); + .insert(source_path.clone(), old_content.to_string()); } match move_path { Some(dest_path) => { - if !self.current_by_path.contains_key(dest_path) - && !self.baseline_by_path.contains_key(dest_path) + if !self.current_by_path.contains_key(&dest_path) + && !self.baseline_by_path.contains_key(&dest_path) && let Some(overwritten_move_content) = overwritten_move_content { - self.baseline_by_path.insert( - dest_path.to_path_buf(), - overwritten_move_content.to_string(), - ); + self.baseline_by_path + .insert(dest_path.clone(), overwritten_move_content.to_string()); } let origin = self .origin_by_current_path - .remove(source_path) - .unwrap_or_else(|| source_path.to_path_buf()); - self.current_by_path.remove(source_path); + .remove(&source_path) + .unwrap_or_else(|| source_path.clone()); + self.current_by_path.remove(&source_path); self.current_by_path - .insert(dest_path.to_path_buf(), new_content.to_string()); - self.origin_by_current_path.remove(dest_path); - if dest_path != origin.as_path() { - self.origin_by_current_path - .insert(dest_path.to_path_buf(), origin); + .insert(dest_path.clone(), new_content.to_string()); + self.origin_by_current_path.remove(&dest_path); + if dest_path != origin { + self.origin_by_current_path.insert(dest_path, origin); } } None => { self.current_by_path - .insert(source_path.to_path_buf(), new_content.to_string()); + .insert(source_path, new_content.to_string()); } } } - fn rename_pairs(&self) -> HashMap { + fn rename_pairs(&self) -> HashMap { self.origin_by_current_path .iter() .filter_map(|(dest_path, origin_path)| { @@ -215,7 +234,7 @@ impl TurnDiffTracker { .collect() } - fn render_path_diff(&self, path: &Path) -> Option { + fn render_path_diff(&self, path: &TrackedPath) -> Option { self.render_diff( path, self.baseline_by_path.get(path).map(String::as_str), @@ -224,7 +243,11 @@ impl TurnDiffTracker { ) } - fn render_rename_diff(&self, source_path: &Path, dest_path: &Path) -> Option { + fn render_rename_diff( + &self, + source_path: &TrackedPath, + dest_path: &TrackedPath, + ) -> Option { self.render_diff( source_path, self.baseline_by_path.get(source_path).map(String::as_str), @@ -235,9 +258,9 @@ impl TurnDiffTracker { fn render_diff( &self, - left_path: &Path, + left_path: &TrackedPath, left_content: Option<&str>, - right_path: &Path, + right_path: &TrackedPath, right_content: Option<&str>, ) -> Option { if left_content == right_content { @@ -286,13 +309,18 @@ impl TurnDiffTracker { Some(diff) } - fn display_path(&self, path: &Path) -> String { + fn display_path(&self, path: &TrackedPath) -> String { let display = self - .display_root - .as_deref() - .and_then(|root| path.strip_prefix(root).ok()) - .unwrap_or(path); - display.display().to_string().replace('\\', "/") + .display_roots_by_environment + .get(&path.environment_id) + .and_then(|root| path.path.strip_prefix(root).ok()) + .unwrap_or(path.path.as_path()); + let display = display.display().to_string().replace('\\', "/"); + if self.display_roots_by_environment.len() > 1 && !path.environment_id.is_empty() { + format!("{}/{display}", path.environment_id) + } else { + display + } } } diff --git a/codex-rs/core/src/turn_diff_tracker_tests.rs b/codex-rs/core/src/turn_diff_tracker_tests.rs index d25fec2aadd7..645443040436 100644 --- a/codex-rs/core/src/turn_diff_tracker_tests.rs +++ b/codex-rs/core/src/turn_diff_tracker_tests.rs @@ -41,24 +41,28 @@ async fn apply_verified_patch(root: &Path, patch: &str) -> AppliedPatchDelta { .expect("patch should apply") } +fn tracker_with_root(root: &Path) -> TurnDiffTracker { + TurnDiffTracker::with_environment_display_roots([("".to_string(), root.to_path_buf())]) +} + #[tokio::test] async fn accumulates_add_then_update_as_single_add() { let dir = tempdir().expect("tempdir"); - let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf()); + let mut tracker = tracker_with_root(dir.path()); let add = apply_verified_patch( dir.path(), "*** Begin Patch\n*** Add File: a.txt\n+foo\n*** End Patch", ) .await; - tracker.track_delta(&add); + tracker.track_delta("", &add); let update = apply_verified_patch( dir.path(), "*** Begin Patch\n*** Update File: a.txt\n@@\n foo\n+bar\n*** End Patch", ) .await; - tracker.track_delta(&update); + tracker.track_delta("", &update); let right_oid = git_blob_sha1_hex("foo\nbar\n"); let expected = format!( @@ -78,32 +82,69 @@ index {ZERO_OID}..{right_oid} #[tokio::test] async fn invalidated_tracker_suppresses_existing_diff() { let dir = tempdir().expect("tempdir"); - let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf()); + let mut tracker = tracker_with_root(dir.path()); let add = apply_verified_patch( dir.path(), "*** Begin Patch\n*** Add File: a.txt\n+foo\n*** End Patch", ) .await; - tracker.track_delta(&add); + tracker.track_delta("", &add); tracker.invalidate(); assert_eq!(tracker.get_unified_diff(), None); } +#[tokio::test] +async fn tracks_same_absolute_path_across_multiple_environments() { + let dir = tempdir().expect("tempdir"); + let add = apply_verified_patch( + dir.path(), + "*** Begin Patch\n*** Add File: shared.txt\n+content\n*** End Patch", + ) + .await; + + let mut tracker = TurnDiffTracker::with_environment_display_roots([ + ("local".to_string(), dir.path().to_path_buf()), + ("remote".to_string(), dir.path().to_path_buf()), + ]); + tracker.track_delta("remote", &add); + tracker.track_delta("local", &add); + + let right_oid = git_blob_sha1_hex("content\n"); + let expected = format!( + r#"diff --git a/local/shared.txt b/local/shared.txt +new file mode {REGULAR_FILE_MODE} +index {ZERO_OID}..{right_oid} +--- {DEV_NULL} ++++ b/local/shared.txt +@@ -0,0 +1 @@ ++content +diff --git a/remote/shared.txt b/remote/shared.txt +new file mode {REGULAR_FILE_MODE} +index {ZERO_OID}..{right_oid} +--- {DEV_NULL} ++++ b/remote/shared.txt +@@ -0,0 +1 @@ ++content +"#, + ); + assert_eq!(tracker.get_unified_diff(), Some(expected)); +} + #[tokio::test] async fn accumulates_delete() { let dir = tempdir().expect("tempdir"); fs::write(dir.path().join("b.txt"), "x\n").expect("seed file"); - let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf()); + let mut tracker = tracker_with_root(dir.path()); let delete = apply_verified_patch( dir.path(), "*** Begin Patch\n*** Delete File: b.txt\n*** End Patch", ) .await; - tracker.track_delta(&delete); + tracker.track_delta("", &delete); let left_oid = git_blob_sha1_hex("x\n"); let expected = format!( @@ -124,13 +165,13 @@ async fn accumulates_move_and_update() { let dir = tempdir().expect("tempdir"); fs::write(dir.path().join("src.txt"), "line\n").expect("seed file"); - let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf()); + let mut tracker = tracker_with_root(dir.path()); let update = apply_verified_patch( dir.path(), "*** Begin Patch\n*** Update File: src.txt\n*** Move to: dst.txt\n@@\n-line\n+line2\n*** End Patch", ) .await; - tracker.track_delta(&update); + tracker.track_delta("", &update); let left_oid = git_blob_sha1_hex("line\n"); let right_oid = git_blob_sha1_hex("line2\n"); @@ -152,13 +193,13 @@ async fn pure_rename_yields_no_diff() { let dir = tempdir().expect("tempdir"); fs::write(dir.path().join("old.txt"), "same\n").expect("seed file"); - let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf()); + let mut tracker = tracker_with_root(dir.path()); let rename = apply_verified_patch( dir.path(), "*** Begin Patch\n*** Update File: old.txt\n*** Move to: new.txt\n@@\n same\n*** End Patch", ) .await; - tracker.track_delta(&rename); + tracker.track_delta("", &rename); assert_eq!(tracker.get_unified_diff(), None); } @@ -168,13 +209,13 @@ async fn add_over_existing_file_becomes_update() { let dir = tempdir().expect("tempdir"); fs::write(dir.path().join("dup.txt"), "before\n").expect("seed file"); - let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf()); + let mut tracker = tracker_with_root(dir.path()); let add = apply_verified_patch( dir.path(), "*** Begin Patch\n*** Add File: dup.txt\n+after\n*** End Patch", ) .await; - tracker.track_delta(&add); + tracker.track_delta("", &add); let left_oid = git_blob_sha1_hex("before\n"); let right_oid = git_blob_sha1_hex("after\n"); @@ -196,20 +237,20 @@ async fn delete_then_readd_same_path_becomes_update() { let dir = tempdir().expect("tempdir"); fs::write(dir.path().join("cycle.txt"), "before\n").expect("seed file"); - let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf()); + let mut tracker = tracker_with_root(dir.path()); let delete = apply_verified_patch( dir.path(), "*** Begin Patch\n*** Delete File: cycle.txt\n*** End Patch", ) .await; - tracker.track_delta(&delete); + tracker.track_delta("", &delete); let add = apply_verified_patch( dir.path(), "*** Begin Patch\n*** Add File: cycle.txt\n+after\n*** End Patch", ) .await; - tracker.track_delta(&add); + tracker.track_delta("", &add); let left_oid = git_blob_sha1_hex("before\n"); let right_oid = git_blob_sha1_hex("after\n"); @@ -232,13 +273,13 @@ async fn move_over_existing_destination_without_content_change_deletes_source_on fs::write(dir.path().join("a.txt"), "same\n").expect("seed source"); fs::write(dir.path().join("b.txt"), "same\n").expect("seed destination"); - let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf()); + let mut tracker = tracker_with_root(dir.path()); let move_overwrite = apply_verified_patch( dir.path(), "*** Begin Patch\n*** Update File: a.txt\n*** Move to: b.txt\n@@\n same\n*** End Patch", ) .await; - tracker.track_delta(&move_overwrite); + tracker.track_delta("", &move_overwrite); let left_oid = git_blob_sha1_hex("same\n"); let expected = format!( @@ -261,13 +302,13 @@ async fn move_over_existing_destination_with_content_change_deletes_source_and_u fs::write(dir.path().join("a.txt"), "from\n").expect("seed source"); fs::write(dir.path().join("b.txt"), "existing\n").expect("seed destination"); - let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf()); + let mut tracker = tracker_with_root(dir.path()); let move_overwrite = apply_verified_patch( dir.path(), "*** Begin Patch\n*** Update File: a.txt\n*** Move to: b.txt\n@@\n-from\n+new\n*** End Patch", ) .await; - tracker.track_delta(&move_overwrite); + tracker.track_delta("", &move_overwrite); let left_oid_a = git_blob_sha1_hex("from\n"); let left_oid_b = git_blob_sha1_hex("existing\n"); @@ -298,13 +339,13 @@ async fn preserves_committed_change_order_with_delete_then_move_overwrite() { fs::write(dir.path().join("a.txt"), "from\n").expect("seed source"); fs::write(dir.path().join("b.txt"), "existing\n").expect("seed destination"); - let mut tracker = TurnDiffTracker::with_display_root(dir.path().to_path_buf()); + let mut tracker = tracker_with_root(dir.path()); let ordered_patch = apply_verified_patch( dir.path(), "*** Begin Patch\n*** Delete File: b.txt\n*** Update File: a.txt\n*** Move to: b.txt\n@@\n-from\n+new\n*** End Patch", ) .await; - tracker.track_delta(&ordered_patch); + tracker.track_delta("", &ordered_patch); let left_oid_a = git_blob_sha1_hex("from\n"); let left_oid_b = git_blob_sha1_hex("existing\n"); diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index 570e4a6e3222..380e246fae0b 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -1,8 +1,11 @@ +use std::sync::Arc; +use std::sync::Mutex as StdMutex; use std::time::Duration; use std::time::Instant; use std::time::SystemTime; use std::time::UNIX_EPOCH; +use codex_analytics::TurnProfile; use codex_otel::TURN_TTFM_DURATION_METRIC; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; @@ -39,6 +42,7 @@ pub(crate) async fn record_turn_ttfm_metric(turn_context: &TurnContext, item: &T #[derive(Debug, Default)] pub(crate) struct TurnTimingState { state: Mutex, + profile: StdMutex, } #[derive(Debug, Default)] @@ -49,6 +53,35 @@ struct TurnTimingStateInner { first_message_at: Option, } +#[derive(Debug, Default)] +struct TurnProfileState { + started_at: Option, + last_transition_at: Option, + active_phase: Option, + seen_sampling: bool, + before_first_sampling: Duration, + sampling: Duration, + between_sampling_overhead: Duration, + tool_blocking: Duration, + pending_idle_after_sampling: Duration, + sampling_request_count: u32, + sampling_retry_count: u32, + completed_profile: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TurnProfilePhase { + Sampling, + ToolBlocking, +} + +#[must_use] +pub(crate) struct TurnProfileTimingGuard { + timing: Arc, + phase: TurnProfilePhase, + active: bool, +} + impl TurnTimingState { pub(crate) async fn mark_turn_started(&self, started_at: Instant) -> i64 { let started_at_unix_ms = now_unix_timestamp_ms(); @@ -57,6 +90,7 @@ impl TurnTimingState { state.started_at_unix_secs = Some(started_at_unix_ms / 1000); state.first_token_at = None; state.first_message_at = None; + self.profile_state().start(started_at); started_at_unix_ms } @@ -80,6 +114,32 @@ impl TurnTimingState { .map(|duration| i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)) } + pub(crate) fn complete_profile(&self) -> TurnProfile { + self.profile_state().complete(Instant::now()) + } + + pub(crate) fn begin_sampling(self: &Arc) -> TurnProfileTimingGuard { + let active = self.profile_state().begin_sampling(Instant::now()); + TurnProfileTimingGuard { + timing: Arc::clone(self), + phase: TurnProfilePhase::Sampling, + active, + } + } + + pub(crate) fn record_sampling_retry(&self) { + self.profile_state().record_sampling_retry(); + } + + pub(crate) fn begin_tool_blocking(self: &Arc) -> TurnProfileTimingGuard { + let active = self.profile_state().begin_tool_blocking(Instant::now()); + TurnProfileTimingGuard { + timing: Arc::clone(self), + phase: TurnProfilePhase::ToolBlocking, + active, + } + } + pub(crate) async fn record_ttft_for_response_event( &self, event: &ResponseEvent, @@ -98,6 +158,22 @@ impl TurnTimingState { let mut state = self.state.lock().await; state.record_turn_ttfm() } + + fn profile_state(&self) -> std::sync::MutexGuard<'_, TurnProfileState> { + self.profile + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +impl Drop for TurnProfileTimingGuard { + fn drop(&mut self) { + if self.active { + self.timing + .profile_state() + .end_phase(Instant::now(), self.phase); + } + } } fn now_unix_timestamp_secs() -> i64 { @@ -111,6 +187,121 @@ pub(crate) fn now_unix_timestamp_ms() -> i64 { i64::try_from(duration.as_millis()).unwrap_or(i64::MAX) } +fn duration_to_u64_ms(duration: Duration) -> u64 { + u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) +} + +impl TurnProfileState { + fn start(&mut self, started_at: Instant) { + *self = Self { + started_at: Some(started_at), + last_transition_at: Some(started_at), + ..Self::default() + }; + } + + fn begin_sampling(&mut self, now: Instant) -> bool { + if self.completed_profile.is_some() + || self.started_at.is_none() + || self.active_phase.is_some() + { + return false; + } + self.advance(now); + if self.seen_sampling { + self.between_sampling_overhead += std::mem::take(&mut self.pending_idle_after_sampling); + } + self.seen_sampling = true; + self.active_phase = Some(TurnProfilePhase::Sampling); + self.sampling_request_count = self.sampling_request_count.saturating_add(1); + true + } + + fn record_sampling_retry(&mut self) { + if self.completed_profile.is_none() && self.started_at.is_some() { + self.sampling_retry_count = self.sampling_retry_count.saturating_add(1); + } + } + + fn begin_tool_blocking(&mut self, now: Instant) -> bool { + if self.completed_profile.is_some() + || self.started_at.is_none() + || self.active_phase.is_some() + { + return false; + } + self.advance(now); + self.active_phase = Some(TurnProfilePhase::ToolBlocking); + true + } + + fn end_phase(&mut self, now: Instant, phase: TurnProfilePhase) { + if self.completed_profile.is_some() || self.active_phase != Some(phase) { + return; + } + self.advance(now); + self.active_phase = None; + } + + fn advance(&mut self, now: Instant) { + let Some(previous) = self.last_transition_at.replace(now) else { + return; + }; + let elapsed = now.saturating_duration_since(previous); + match self.active_phase { + Some(TurnProfilePhase::Sampling) => self.sampling += elapsed, + Some(TurnProfilePhase::ToolBlocking) => self.tool_blocking += elapsed, + None if self.seen_sampling => self.pending_idle_after_sampling += elapsed, + None => self.before_first_sampling += elapsed, + } + } + + fn complete(&mut self, now: Instant) -> TurnProfile { + if let Some(profile) = self.completed_profile.as_ref() { + return profile.clone(); + } + + let final_phase = self.active_phase; + self.advance(now); + let after_last_sampling = if self.seen_sampling { + std::mem::take(&mut self.pending_idle_after_sampling) + } else { + Duration::ZERO + }; + + let mut profile = TurnProfile { + before_first_sampling_ms: duration_to_u64_ms(self.before_first_sampling), + sampling_ms: duration_to_u64_ms(self.sampling), + between_sampling_overhead_ms: duration_to_u64_ms(self.between_sampling_overhead), + tool_blocking_ms: duration_to_u64_ms(self.tool_blocking), + after_last_sampling_ms: duration_to_u64_ms(after_last_sampling), + sampling_request_count: self.sampling_request_count, + sampling_retry_count: self.sampling_retry_count, + }; + let total_ms = self + .started_at + .map(|started_at| duration_to_u64_ms(now.saturating_duration_since(started_at))) + .unwrap_or_default(); + let classified_ms = profile + .before_first_sampling_ms + .saturating_add(profile.sampling_ms) + .saturating_add(profile.between_sampling_overhead_ms) + .saturating_add(profile.tool_blocking_ms) + .saturating_add(profile.after_last_sampling_ms); + let rounding_ms = total_ms.saturating_sub(classified_ms); + match final_phase { + Some(TurnProfilePhase::Sampling) => profile.sampling_ms += rounding_ms, + Some(TurnProfilePhase::ToolBlocking) => profile.tool_blocking_ms += rounding_ms, + None if self.seen_sampling => profile.after_last_sampling_ms += rounding_ms, + None => profile.before_first_sampling_ms += rounding_ms, + } + + self.active_phase = None; + self.completed_profile = Some(profile.clone()); + profile + } +} + impl TurnTimingStateInner { fn time_to_first_token(&self) -> Option { Some(self.first_token_at?.duration_since(self.started_at?)) @@ -147,6 +338,7 @@ fn response_event_records_turn_ttft(event: &ResponseEvent) -> bool { ResponseEvent::Created | ResponseEvent::ServerModel(_) | ResponseEvent::ModelVerifications(_) + | ResponseEvent::TurnModerationMetadata(_) | ResponseEvent::ServerReasoningIncluded(_) | ResponseEvent::ToolCallInputDelta { .. } | ResponseEvent::Completed { .. } @@ -177,6 +369,7 @@ fn response_item_records_turn_ttft(item: &ResponseItem) -> bool { }) }) } + ResponseItem::AgentMessage { .. } => false, ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::CustomToolCall { .. } diff --git a/codex-rs/core/src/turn_timing_tests.rs b/codex-rs/core/src/turn_timing_tests.rs index a6675aea8eb6..7146f537be6f 100644 --- a/codex-rs/core/src/turn_timing_tests.rs +++ b/codex-rs/core/src/turn_timing_tests.rs @@ -1,13 +1,17 @@ +use codex_analytics::TurnProfile; use codex_protocol::items::AgentMessageItem; use codex_protocol::items::TurnItem; use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseItem; use pretty_assertions::assert_eq; +use std::time::Duration; use std::time::Instant; use std::time::SystemTime; use std::time::UNIX_EPOCH; +use super::TurnProfilePhase; +use super::TurnProfileState; use super::TurnTimingState; use super::response_item_records_turn_ttft; use crate::ResponseEvent; @@ -146,3 +150,40 @@ fn response_item_records_turn_ttft_ignores_empty_non_output_items() { } )); } + +#[test] +fn turn_profile_breaks_down_sampling_blocking_and_retry_overhead() { + let started_at = Instant::now(); + let mut state = TurnProfileState::default(); + state.start(started_at); + + let _ = state.begin_sampling(started_at + Duration::from_millis(100)); + state.end_phase( + started_at + Duration::from_millis(600), + TurnProfilePhase::Sampling, + ); + let _ = state.begin_tool_blocking(started_at + Duration::from_millis(600)); + state.end_phase( + started_at + Duration::from_millis(900), + TurnProfilePhase::ToolBlocking, + ); + state.record_sampling_retry(); + let _ = state.begin_sampling(started_at + Duration::from_millis(1_000)); + state.end_phase( + started_at + Duration::from_millis(1_200), + TurnProfilePhase::Sampling, + ); + + assert_eq!( + state.complete(started_at + Duration::from_millis(1_300)), + TurnProfile { + before_first_sampling_ms: 100, + sampling_ms: 700, + between_sampling_overhead_ms: 100, + tool_blocking_ms: 300, + after_last_sampling_ms: 100, + sampling_request_count: 2, + sampling_retry_count: 1, + } + ); +} diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 66b8b49a8098..cb455b113b6d 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -1011,7 +1011,6 @@ impl UnifiedExecProcessManager { }; let mut orchestrator = ToolOrchestrator::new(); let mut runtime = UnifiedExecRuntime::new(self, request.shell_mode.clone()); - let file_system_sandbox_policy = context.turn.file_system_sandbox_policy(); let exec_approval_requirement = context .session .services @@ -1020,10 +1019,7 @@ impl UnifiedExecProcessManager { command: &request.command, approval_policy: context.turn.approval_policy.value(), permission_profile: context.turn.permission_profile(), - file_system_sandbox_policy: &file_system_sandbox_policy, - // The process cwd may be model-controlled. Policy resolution - // stays anchored to the selected turn environment cwd instead. - sandbox_cwd: request.sandbox_cwd.as_path(), + windows_sandbox_level: context.turn.windows_sandbox_level, sandbox_permissions: if request.additional_permissions_preapproved { crate::sandboxing::SandboxPermissions::UseDefault } else { @@ -1034,7 +1030,7 @@ impl UnifiedExecProcessManager { .await; let req = UnifiedExecToolRequest { command: request.command.clone(), - shell_type: request.shell_type.clone(), + shell_type: request.shell_type, hook_command: request.hook_command.clone(), process_id: request.process_id, cwd, diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index be07b803f853..94d72d619b0a 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -19,6 +19,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; pub use codex_utils_absolute_path::test_support::PathBufExt; pub use codex_utils_absolute_path::test_support::PathExt; use regex_lite::Regex; +use std::path::Path; use std::path::PathBuf; pub mod apps_test_server; @@ -110,6 +111,20 @@ pub fn test_absolute_path(unix_path: &str) -> AbsolutePathBuf { test_absolute_path_with_windows(unix_path, /*windows_path*/ None) } +#[cfg(unix)] +#[allow(clippy::expect_used)] +pub fn create_directory_symlink(source: &Path, link: &Path) { + std::os::unix::fs::symlink(source, link).expect("create directory symlink"); +} + +#[cfg(windows)] +#[allow(clippy::expect_used)] +pub fn create_directory_symlink(source: &Path, link: &Path) { + // Running this test locally may require Windows Developer Mode or an elevated process. + std::os::windows::fs::symlink_dir(source, link) + .expect("create directory symlink; enable Developer Mode or run the test elevated"); +} + pub trait TempDirExt { fn abs(&self) -> AbsolutePathBuf; } diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 4fe0609a7154..1e4313590023 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -23,12 +23,14 @@ use codex_core::thread_store_from_config; use codex_exec_server::CreateDirectoryOptions; use codex_exec_server::ExecutorFileSystem; use codex_exec_server::RemoveOptions; +use codex_extension_api::ExtensionRegistry; use codex_extension_api::empty_extension_registry; use codex_login::CodexAuth; use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::built_in_model_providers; use codex_models_manager::bundled_models_response; use codex_protocol::models::PermissionProfile; +use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; @@ -216,6 +218,7 @@ pub struct TestCodexBuilder { cloud_config_bundle: Option, user_shell_override: Option, exec_server_url: Option, + extensions: Arc>, } impl TestCodexBuilder { @@ -239,6 +242,26 @@ impl TestCodexBuilder { }) } + pub fn with_model_info_override(self, model: &str, override_model_info: T) -> Self + where + T: FnOnce(&mut ModelInfo) + Send + 'static, + { + let model = model.to_string(); + self.with_config(move |config| { + let model_catalog = config.model_catalog.get_or_insert_with(|| { + bundled_models_response() + .unwrap_or_else(|err| panic!("bundled models.json should parse: {err}")) + }); + let model_info = model_catalog + .models + .iter_mut() + .find(|model_info| model_info.slug == model) + .unwrap_or_else(|| panic!("{model} should exist in the configured model catalog")); + override_model_info(model_info); + config.model = Some(model); + }) + } + pub fn with_pre_build_hook(mut self, hook: F) -> Self where F: FnOnce(&Path) + Send + 'static, @@ -280,6 +303,11 @@ impl TestCodexBuilder { self } + pub fn with_extensions(mut self, extensions: Arc>) -> Self { + self.extensions = extensions; + self + } + pub fn with_windows_cmd_shell(self) -> Self { if cfg!(windows) { self.with_user_shell(get_shell_by_model_provided_path(&PathBuf::from("cmd.exe"))) @@ -473,7 +501,7 @@ impl TestCodexBuilder { codex_core::test_support::auth_manager_from_auth(auth.clone()), SessionSource::Exec, Arc::clone(&environment_manager), - empty_extension_registry(), + Arc::clone(&self.extensions), /*analytics_events_client*/ None, thread_store, state_db.clone(), @@ -772,7 +800,7 @@ impl TestCodex { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(self.config.cwd.to_path_buf()), + cwd: Some(self.config.cwd.clone()), approval_policy: Some(approval_policy), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -846,6 +874,10 @@ impl TestCodexHarness { self.test.config.cwd.as_path() } + pub fn cwd_abs(&self) -> AbsolutePathBuf { + self.test.config.cwd.clone() + } + pub fn path(&self, rel: impl AsRef) -> PathBuf { self.path_abs(rel).into_path_buf() } @@ -1037,6 +1069,7 @@ pub fn test_codex() -> TestCodexBuilder { cloud_config_bundle: None, user_shell_override: None, exec_server_url: None, + extensions: empty_extension_registry(), } } diff --git a/codex-rs/core/tests/suite/agents_md.rs b/codex-rs/core/tests/suite/agents_md.rs index 318c288dc6f6..3fa2136ecbd9 100644 --- a/codex-rs/core/tests/suite/agents_md.rs +++ b/codex-rs/core/tests/suite/agents_md.rs @@ -1,6 +1,7 @@ use anyhow::Result; use codex_exec_server::CreateDirectoryOptions; use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::create_directory_symlink; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; @@ -8,6 +9,7 @@ use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::test_codex; +use pretty_assertions::assert_eq; use std::sync::Arc; use tempfile::TempDir; @@ -143,6 +145,86 @@ async fn agents_docs_are_concatenated_from_project_root_to_cwd() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn symlinked_cwd_uses_logical_parent_for_agents_discovery() -> Result<()> { + let server = start_mock_server().await; + let resp_mock = mount_sse_once( + &server, + sse(vec![ev_response_created("resp1"), ev_completed("resp1")]), + ) + .await; + + let mut builder = test_codex() + .with_config(|config| { + config.cwd = config.cwd.join("logical-repo/workspace"); + }) + .with_workspace_setup(|cwd, _fs| async move { + // Construct two sibling repositories with the configured cwd as a + // directory symlink from the logical repository into the physical + // repository: + // + // test-root/ + // |-- logical-repo/ + // | |-- .git + // | |-- AGENTS.md ("logical parent doc") + // | `-- workspace ------------> physical-repo/workspace/ + // `-- physical-repo/ + // |-- .git + // |-- AGENTS.md ("physical parent doc") + // `-- workspace/ + // `-- AGENTS.md ("workspace doc") + // + // Discovery should walk the lexical path through logical-repo, + // while opening logical-repo/workspace/AGENTS.md still follows the + // symlink into physical-repo/workspace. + let logical_root = cwd.parent().expect("symlink should have a parent"); + let test_root = logical_root + .parent() + .expect("logical repository should have a parent"); + let physical_root = test_root.join("physical-repo"); + let physical_workspace = physical_root.join("workspace"); + + std::fs::create_dir_all(logical_root.as_path())?; + std::fs::write(logical_root.join(".git"), "")?; + std::fs::write(logical_root.join("AGENTS.md"), "logical parent doc")?; + + std::fs::create_dir_all(physical_workspace.as_path())?; + std::fs::write(physical_root.join(".git"), "")?; + std::fs::write(physical_root.join("AGENTS.md"), "physical parent doc")?; + std::fs::write(physical_workspace.join("AGENTS.md"), "workspace doc")?; + + create_directory_symlink(physical_workspace.as_path(), cwd.as_path()); + Ok(()) + }); + let test = builder.build(&server).await?; + let logical_root = test + .config + .cwd + .parent() + .expect("symlink should have a parent"); + + assert_eq!( + test.codex.instruction_sources().await, + vec![ + logical_root.join("AGENTS.md"), + test.config.cwd.join("AGENTS.md") + ] + ); + + test.submit_turn("hello").await?; + let instructions = resp_mock + .single_request() + .message_input_texts("user") + .into_iter() + .find(|text| text.starts_with("# AGENTS.md instructions for ")) + .expect("instructions message"); + assert!(instructions.contains("logical parent doc")); + assert!(instructions.contains("workspace doc")); + assert!(!instructions.contains("physical parent doc")); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn selected_environment_sources_match_model_visible_instructions() -> Result<()> { let server = start_mock_server().await; @@ -167,13 +249,7 @@ async fn selected_environment_sources_match_model_visible_instructions() -> Resu Ok::<(), anyhow::Error>(()) }); let test = builder.build_with_remote_env(&server).await?; - let project_agents = test - .fs() - .canonicalize( - &test.executor_environment().cwd().join("AGENTS.md"), - /*sandbox*/ None, - ) - .await?; + let project_agents = test.config.cwd.join("AGENTS.md"); let global_agents = AbsolutePathBuf::try_from(global_agents).expect("absolute path"); assert_eq!( diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index da138851f55c..db301e39b40e 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -8,11 +8,18 @@ use core_test_support::responses::ev_apply_patch_shell_command_call_via_heredoc; use core_test_support::responses::ev_shell_command_call; use core_test_support::test_codex::ApplyPatchModelOutput; use pretty_assertions::assert_eq; +use std::fs; +use std::path::PathBuf; use std::sync::atomic::AtomicI32; use std::sync::atomic::Ordering; use std::time::Duration; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use codex_exec_server::CreateDirectoryOptions; +use codex_exec_server::LOCAL_ENVIRONMENT_ID; +use codex_exec_server::REMOTE_ENVIRONMENT_ID; +use codex_exec_server::RemoveOptions; use codex_features::Feature; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; @@ -25,11 +32,14 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::user_input::UserInput; #[cfg(target_os = "linux")] use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0; use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::PathBufExt; use core_test_support::assert_regex_match; +use core_test_support::get_remote_test_env; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; @@ -37,11 +47,13 @@ use core_test_support::responses::ev_response_created; use core_test_support::responses::ev_shell_command_call_with_args; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::skip_if_remote; use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::TestCodexHarness; use core_test_support::test_codex::test_codex; +use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; use core_test_support::wait_for_event_with_timeout; use serde_json::json; @@ -93,7 +105,7 @@ async fn submit_without_wait_with_turn_permissions( responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(harness.cwd().to_path_buf()), + cwd: Some(harness.cwd_abs()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -1553,6 +1565,162 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apply_patch_turn_diff_tracks_local_and_remote_environment_paths() -> Result<()> { + skip_if_no_network!(Ok(())); + let Some(_remote_env) = get_remote_test_env() else { + return Ok(()); + }; + + let server = start_mock_server().await; + let mut builder = test_codex(); + let test = builder.build_with_remote_and_local_env(&server).await?; + let file_name = "shared-turn-diff.txt"; + let shared_cwd = PathBuf::from(format!( + "/tmp/codex-remote-turn-diff-{}", + SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() + )) + .abs(); + let _ = fs::remove_dir_all(shared_cwd.as_path()); + test.fs() + .remove( + &shared_cwd, + RemoveOptions { + recursive: true, + force: true, + }, + /*sandbox*/ None, + ) + .await?; + fs::create_dir_all(shared_cwd.as_path())?; + test.fs() + .create_directory( + &shared_cwd, + CreateDirectoryOptions { recursive: true }, + /*sandbox*/ None, + ) + .await?; + + let local_patch = format!( + "*** Begin Patch\n*** Environment ID: {LOCAL_ENVIRONMENT_ID}\n*** Add File: {file_name}\n+local\n*** End Patch" + ); + let remote_patch = format!( + "*** Begin Patch\n*** Environment ID: {REMOTE_ENVIRONMENT_ID}\n*** Add File: {file_name}\n+remote\n*** End Patch" + ); + mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-local"), + ev_apply_patch_custom_tool_call("call-local", &local_patch), + ev_completed("resp-local"), + ]), + sse(vec![ + ev_response_created("resp-remote"), + ev_apply_patch_custom_tool_call("call-remote", &remote_patch), + ev_completed("resp-remote"), + ]), + sse(vec![ + ev_response_created("resp-done"), + ev_assistant_message("msg-done", "done"), + ev_completed("resp-done"), + ]), + ], + ) + .await; + + let (sandbox_policy, permission_profile) = + turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path()); + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "apply matching patches to local and remote environments".into(), + text_elements: Vec::new(), + }], + environments: Some(vec![ + TurnEnvironmentSelection { + environment_id: LOCAL_ENVIRONMENT_ID.to_string(), + cwd: shared_cwd.clone(), + }, + TurnEnvironmentSelection { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd: shared_cwd.clone(), + }, + ]), + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(test.config.cwd.clone()), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: test.session_configured.model.clone(), + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, + }) + .await?; + + let mut last_diff = None; + wait_for_event(&test.codex, |event| match event { + EventMsg::TurnDiff(ev) => { + last_diff = Some(ev.unified_diff.clone()); + false + } + EventMsg::TurnComplete(_) => true, + _ => false, + }) + .await; + + assert_eq!(fs::read_to_string(shared_cwd.join(file_name))?, "local\n"); + assert_eq!( + test.fs() + .read_file_text(&shared_cwd.join(file_name), /*sandbox*/ None) + .await?, + "remote\n" + ); + let diff = last_diff.expect("expected TurnDiff event"); + assert_eq!( + diff, + r#"diff --git a/local/shared-turn-diff.txt b/local/shared-turn-diff.txt +new file mode 100644 +index 0000000000000000000000000000000000000000..40830374235df1c19661a2901b7ca73cc9499f3d +--- /dev/null ++++ b/local/shared-turn-diff.txt +@@ -0,0 +1 @@ ++local +diff --git a/remote/shared-turn-diff.txt b/remote/shared-turn-diff.txt +new file mode 100644 +index 0000000000000000000000000000000000000000..9c998f7b995a7327177b38a90d1385170df2b94b +--- /dev/null ++++ b/remote/shared-turn-diff.txt +@@ -0,0 +1 @@ ++remote +"# + ); + + let _ = fs::remove_dir_all(shared_cwd.as_path()); + test.fs() + .remove( + &shared_cwd, + RemoveOptions { + recursive: true, + force: true, + }, + /*sandbox*/ None, + ) + .await?; + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index aceac7320e43..865aabb46c01 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -663,7 +663,7 @@ async fn submit_turn( responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd.path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(sandbox_policy), @@ -2624,7 +2624,7 @@ async fn env_zsh_script_spawned_by_python_can_request_escalation_under_zsh_fork( responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd.path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(sandbox_policy), @@ -2769,7 +2769,7 @@ async fn matched_prefix_rule_runs_unsandboxed_under_zsh_fork() -> Result<()> { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd.path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(sandbox_policy), @@ -3361,7 +3361,7 @@ allow_local_binding = true responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(turn_sandbox_policy), diff --git a/codex-rs/core/tests/suite/auto_review.rs b/codex-rs/core/tests/suite/auto_review.rs index 570f97008f9b..5de8f421b983 100644 --- a/codex-rs/core/tests/suite/auto_review.rs +++ b/codex-rs/core/tests/suite/auto_review.rs @@ -22,6 +22,7 @@ use codex_protocol::protocol::Op; use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::user_input::UserInput; +use core_test_support::TempDirExt; use core_test_support::responses::ev_apply_patch_custom_tool_call; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -150,7 +151,7 @@ async fn remote_model_override_uses_catalog_model_for_strict_auto_review() -> Re ) .await?; - let cwd_path = cwd.path().to_path_buf(); + let cwd_path = cwd.abs(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::read_only(), cwd_path.as_path()); codex @@ -231,6 +232,7 @@ fn remote_model_with_auto_review_override(slug: &str, review_model: &str) -> Mod input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: Some(review_model.to_string()), tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/tests/suite/cli_stream.rs b/codex-rs/core/tests/suite/cli_stream.rs index 361096943e1f..fc9b7d2569ad 100644 --- a/codex-rs/core/tests/suite/cli_stream.rs +++ b/codex-rs/core/tests/suite/cli_stream.rs @@ -1,14 +1,27 @@ use assert_cmd::Command as AssertCommand; use codex_git_utils::collect_git_info; +use codex_login::CODEX_ACCESS_TOKEN_ENV_VAR; use codex_login::CODEX_API_KEY_ENV_VAR; use codex_protocol::protocol::GitInfo; use core_test_support::fs_wait; use core_test_support::responses; use core_test_support::skip_if_no_network; +use pretty_assertions::assert_eq; use std::time::Duration; use tempfile::TempDir; use uuid::Uuid; +use wiremock::Mock; use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; + +const PERSONAL_ACCESS_TOKEN: &str = "at-cli-test"; +const PERSONAL_ACCESS_TOKEN_AUTHORIZATION: &str = "Bearer at-cli-test"; +const PERSONAL_ACCESS_TOKEN_ACCOUNT_ID: &str = "account-pat"; +const WHOAMI_PATH: &str = "/v1/user-auth-credential/whoami"; +const CLOUD_CONFIG_BUNDLE_PATH: &str = "/backend-api/wham/config/bundle"; fn repo_root() -> std::path::PathBuf { #[expect(clippy::expect_used)] @@ -23,6 +36,118 @@ fn cli_sse_response() -> String { ]) } +async fn mount_personal_access_token_startup(server: &MockServer) { + Mock::given(method("GET")) + .and(path(WHOAMI_PATH)) + .and(header("authorization", PERSONAL_ACCESS_TOKEN_AUTHORIZATION)) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "email": "user@example.com", + "chatgpt_user_id": "user-pat", + "chatgpt_account_id": PERSONAL_ACCESS_TOKEN_ACCOUNT_ID, + "chatgpt_plan_type": "enterprise", + "chatgpt_account_is_fedramp": true, + }))) + .expect(1..) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path(CLOUD_CONFIG_BUNDLE_PATH)) + .and(header("authorization", PERSONAL_ACCESS_TOKEN_AUTHORIZATION)) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({}))) + .expect(1) + .mount(server) + .await; +} + +#[expect(clippy::unwrap_used)] +fn personal_access_token_exec_command(server: &MockServer, home: &TempDir) -> AssertCommand { + let bin = codex_utils_cargo_bin::cargo_bin("codex").unwrap(); + let mut cmd = AssertCommand::new(bin); + cmd.timeout(Duration::from_secs(30)); + cmd.arg("exec") + .arg("--skip-git-repo-check") + .arg("-c") + .arg(format!("openai_base_url=\"{}/api/codex\"", server.uri())) + .arg("-c") + .arg(format!("chatgpt_base_url=\"{}/backend-api\"", server.uri())) + .arg("-C") + .arg(repo_root()) + .arg("hello?"); + cmd.env("CODEX_HOME", home.path()) + .env(CODEX_ACCESS_TOKEN_ENV_VAR, PERSONAL_ACCESS_TOKEN) + .env("CODEX_AUTHAPI_BASE_URL", server.uri()) + .env_remove(CODEX_API_KEY_ENV_VAR) + .env_remove("OPENAI_API_KEY"); + cmd +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_mode_stream_cli_supports_personal_access_tokens() { + skip_if_no_network!(); + + let server = MockServer::start().await; + mount_personal_access_token_startup(&server).await; + let resp_mock = responses::mount_sse_once(&server, cli_sse_response()).await; + let home = TempDir::new().unwrap(); + + let output = personal_access_token_exec_command(&server, &home) + .output() + .unwrap(); + + assert!( + output.status.success(), + "codex-cli exec failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let request = resp_mock.single_request(); + assert_eq!(request.path(), "/api/codex/responses"); + assert_eq!( + request.header("authorization").as_deref(), + Some("Bearer at-cli-test") + ); + assert_eq!( + request.header("chatgpt-account-id").as_deref(), + Some(PERSONAL_ACCESS_TOKEN_ACCOUNT_ID) + ); + assert_eq!(request.header("x-openai-fedramp").as_deref(), Some("true")); + server.verify().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_mode_stream_cli_does_not_attempt_oauth_refresh_for_personal_access_tokens_after_401() + { + skip_if_no_network!(); + + let server = MockServer::start().await; + mount_personal_access_token_startup(&server).await; + Mock::given(method("POST")) + .and(path("/api/codex/responses")) + .and(header("authorization", PERSONAL_ACCESS_TOKEN_AUTHORIZATION)) + .and(header( + "chatgpt-account-id", + PERSONAL_ACCESS_TOKEN_ACCOUNT_ID, + )) + .and(header("x-openai-fedramp", "true")) + .respond_with(ResponseTemplate::new(401).set_body_string("unauthorized")) + .expect(1..) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(500)) + .expect(0) + .mount(&server) + .await; + let home = TempDir::new().unwrap(); + + let output = personal_access_token_exec_command(&server, &home) + .output() + .unwrap(); + + assert!(!output.status.success()); + server.verify().await; +} + /// Tests streaming the Responses API through the CLI using a mock server. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn responses_mode_stream_cli() { diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index ad8ff17cdc02..2fcb73e25a20 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1799,7 +1799,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(config.cwd.to_path_buf()), + cwd: Some(config.cwd.clone()), approval_policy: Some(config.permissions.approval_policy.value()), sandbox_policy: Some(config.legacy_sandbox_policy()), summary: Some( @@ -1872,6 +1872,61 @@ async fn configured_reasoning_summary_is_sent() -> anyhow::Result<()> { .and_then(|value| value.as_str()), Some("concise") ); + pretty_assertions::assert_eq!( + request_body + .get("reasoning") + .and_then(|reasoning| reasoning.get("context")), + None + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_lite_sets_all_turns_context_and_disables_parallel_tool_calls() +-> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + let server = MockServer::start().await; + + let resp_mock = mount_sse_once( + &server, + sse(vec![ev_response_created("resp1"), ev_completed("resp1")]), + ) + .await; + + let TestCodex { codex, .. } = test_codex() + .with_model_info_override("gpt-5.4", |model_info| { + model_info.use_responses_lite = true; + model_info.supports_parallel_tool_calls = true; + }) + .build(&server) + .await?; + + codex + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let request_body = resp_mock.single_request().body_json(); + pretty_assertions::assert_eq!( + request_body + .get("reasoning") + .and_then(|reasoning| reasoning.get("context")) + .and_then(|value| value.as_str()), + Some("all_turns") + ); + pretty_assertions::assert_eq!(request_body.get("parallel_tool_calls"), Some(&json!(false))); Ok(()) } @@ -1922,7 +1977,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(config.cwd.to_path_buf()), + cwd: Some(config.cwd.clone()), approval_policy: Some(config.permissions.approval_policy.value()), sandbox_policy: Some(config.legacy_sandbox_policy()), summary: Some(ReasoningSummary::Concise), diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index b28f820569ae..3e1ed2326149 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -62,6 +62,8 @@ const OPENAI_BETA_HEADER: &str = "OpenAI-Beta"; const USER_AGENT_HEADER: &str = "user-agent"; const WS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06"; const X_CLIENT_REQUEST_ID_HEADER: &str = "x-client-request-id"; +const WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY: &str = + "ws_request_header_x_openai_internal_codex_responses_lite"; const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111"; const X_CODEX_WS_STREAM_REQUEST_START_MS_CLIENT_METADATA_KEY: &str = "x-codex-ws-stream-request-start-ms"; @@ -512,6 +514,85 @@ async fn responses_websocket_reuses_connection_after_session_drop() { server.shutdown().await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_websocket_sends_responses_lite_metadata_per_request() { + skip_if_no_network!(); + + let server = start_websocket_server(vec![vec![ + vec![ev_response_created("normal-1"), ev_completed("normal-1")], + vec![ev_response_created("lite-1"), ev_completed("lite-1")], + vec![ev_response_created("normal-2"), ev_completed("normal-2")], + ]]) + .await; + + let harness = websocket_harness(&server).await; + let mut normal_model_info = harness.model_info.clone(); + normal_model_info.supports_reasoning_summaries = true; + let mut lite_model_info = normal_model_info.clone(); + lite_model_info.use_responses_lite = true; + let mut session = harness.client.new_session(); + + stream_until_complete_with_model_info( + &mut session, + &harness, + &prompt_with_input(vec![message_item("normal one")]), + &normal_model_info, + "normal-1", + ) + .await; + stream_until_complete_with_model_info( + &mut session, + &harness, + &prompt_with_input(vec![message_item("lite")]), + &lite_model_info, + "lite-1", + ) + .await; + stream_until_complete_with_model_info( + &mut session, + &harness, + &prompt_with_input(vec![message_item("normal two")]), + &normal_model_info, + "normal-2", + ) + .await; + + let connection = server.single_connection(); + assert_eq!( + connection + .iter() + .map(|request| { + let body = request.body_json(); + json!({ + "responses_lite": body["client_metadata"] + .get(WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY), + "reasoning_context": body["reasoning"].get("context"), + "parallel_tool_calls": body["parallel_tool_calls"], + }) + }) + .collect::>(), + vec![ + json!({ + "responses_lite": null, + "reasoning_context": null, + "parallel_tool_calls": false, + }), + json!({ + "responses_lite": "true", + "reasoning_context": "all_turns", + "parallel_tool_calls": false, + }), + json!({ + "responses_lite": null, + "reasoning_context": null, + "parallel_tool_calls": false, + }), + ] + ); + + server.shutdown().await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn responses_websocket_preconnect_is_reused_even_with_header_changes() { skip_if_no_network!(); @@ -2028,6 +2109,40 @@ async fn stream_until_complete( .await; } +async fn stream_until_complete_with_model_info( + client_session: &mut ModelClientSession, + harness: &WebsocketTestHarness, + prompt: &Prompt, + model_info: &ModelInfo, + expected_response_id: &str, +) { + let mut stream = client_session + .stream( + prompt, + model_info, + &harness.session_telemetry, + harness.effort.clone(), + harness.summary, + /*service_tier*/ None, + /*turn_metadata_header*/ None, + &codex_rollout_trace::InferenceTraceContext::disabled(), + ) + .await + .expect("websocket stream failed"); + + while let Some(event) = stream.next().await { + match event { + Ok(ResponseEvent::Completed { response_id, .. }) => { + assert_eq!(response_id, expected_response_id); + return; + } + Ok(_) => {} + Err(err) => panic!("websocket stream failed: {err}"), + } + } + panic!("websocket stream ended before completion"); +} + async fn stream_until_complete_with_service_tier( client_session: &mut ModelClientSession, harness: &WebsocketTestHarness, diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index ffe23bff5678..fca4556972d9 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -375,7 +375,11 @@ async fn code_mode_only_restricts_prompt_tools() -> Result<()> { let first_body = resp_mock.single_request().body_json(); assert_eq!( tool_names(&first_body), - vec!["exec".to_string(), "wait".to_string()] + vec![ + "exec".to_string(), + "wait".to_string(), + "web_search".to_string() + ] ); Ok(()) @@ -457,7 +461,12 @@ if (!tool) { let first_body = resp_mock.single_request().body_json(); assert_eq!( tool_names(&first_body), - vec!["exec".to_string(), "wait".to_string()] + vec![ + "exec".to_string(), + "wait".to_string(), + "web_search".to_string(), + "image_generation".to_string() + ] ); let exec_description = first_body @@ -2898,6 +2907,7 @@ text(JSON.stringify(Object.getOwnPropertyNames(globalThis).sort())); "escape", "exit", "eval", + "generatedImage", "globalThis", "image", "isFinite", @@ -2953,7 +2963,7 @@ text(JSON.stringify(tool)); parsed, serde_json::json!({ "name": "view_image", - "description": "View a local image file from the filesystem when visual inspection is needed. Use this for images already available on disk.\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: {\n // Local filesystem path to an image file\n path: string;\n}): Promise<{\n // Image detail hint returned by view_image. Returns `high` for default resized behavior or `original` when original resolution is preserved.\n detail: \"high\" | \"original\";\n // Data URL for the loaded image.\n image_url: string;\n}>; };\n```", + "description": "View a local image file from the filesystem when visual inspection is needed. Use this for images already available on disk.\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: {\n // Local filesystem path to an image file.\n path: string;\n}): Promise<{\n // Image detail hint returned by view_image. Returns `high` for default resized behavior or `original` when original resolution is preserved.\n detail: \"high\" | \"original\";\n // Data URL for the loaded image.\n image_url: string;\n}>; };\n```", }) ); @@ -3069,7 +3079,7 @@ text( ) .await; - let cwd = test.cwd.path().to_path_buf(); + let cwd = test.config.cwd.clone(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index bbbb0f1aa23e..0d4f4cf6f808 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -174,7 +174,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(test.config.permissions.approval_policy.value()), sandbox_policy: Some(test.config.legacy_sandbox_policy()), summary: Some( @@ -225,7 +225,7 @@ async fn collaboration_instructions_omitted_when_disabled() -> Result<()> { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(test.config.permissions.approval_policy.value()), sandbox_policy: Some(test.config.legacy_sandbox_policy()), summary: Some( @@ -334,7 +334,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(test.config.permissions.approval_policy.value()), sandbox_policy: Some(test.config.legacy_sandbox_policy()), summary: Some( diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 0e2c02de9170..5fe90bf7a778 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -23,6 +23,7 @@ use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; use codex_protocol::protocol::WarningEvent; use codex_protocol::user_input::UserInput; +use core_test_support::PathBufExt; use core_test_support::context_snapshot; use core_test_support::context_snapshot::ContextSnapshotOptions; use core_test_support::context_snapshot::ContextSnapshotRenderMode; @@ -32,6 +33,7 @@ use core_test_support::responses::mount_models_once; use core_test_support::skip_if_no_network; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; +use core_test_support::test_path_buf; use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; use std::path::PathBuf; @@ -99,7 +101,7 @@ fn disabled_permission_user_turn(text: impl Into, cwd: PathBuf, model: S responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd), + cwd: Some(cwd.abs()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -3697,7 +3699,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess core_test_support::submit_thread_settings( &codex, codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(PathBuf::from(PRETURN_CONTEXT_DIFF_CWD)), + cwd: Some(test_path_buf(PRETURN_CONTEXT_DIFF_CWD).abs()), ..Default::default() }, ) diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index ebb78a017608..2200c7b77823 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -1,7 +1,6 @@ #![allow(clippy::expect_used)] use std::fs; -use std::path::PathBuf; use anyhow::Result; use codex_core::compact::SUMMARY_PREFIX; @@ -24,6 +23,7 @@ use codex_protocol::protocol::RealtimeOutputModality; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; use codex_protocol::user_input::UserInput; +use core_test_support::PathBufExt; use core_test_support::apps_test_server::configure_search_capable_model; use core_test_support::context_snapshot; use core_test_support::context_snapshot::ContextSnapshotOptions; @@ -36,6 +36,7 @@ use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodexBuilder; use core_test_support::test_codex::TestCodexHarness; use core_test_support::test_codex::test_codex; +use core_test_support::test_path_buf; use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; use core_test_support::wait_for_event_with_timeout; @@ -3313,7 +3314,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_us core_test_support::submit_thread_settings( &codex, codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(PathBuf::from(PRETURN_CONTEXT_DIFF_CWD)), + cwd: Some(test_path_buf(PRETURN_CONTEXT_DIFF_CWD).abs()), ..Default::default() }, ) diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 54056b5b9dbf..47d1d1bb5827 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -551,7 +551,7 @@ async fn snapshot_rollback_followup_turn_trims_context_updates() -> Result<()> { core_test_support::submit_thread_settings( &conversation, codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(override_cwd.to_path_buf()), + cwd: Some(override_cwd.clone()), collaboration_mode: Some(CollaborationMode { mode: ModeKind::Default, settings: Settings { diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index 9e350d27730a..25292149a0a6 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -56,7 +56,7 @@ async fn submit_user_turn( responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd_path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(approval_policy), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -87,6 +87,77 @@ fn assert_no_matched_rules_invariant(output_item: &Value) { ); } +#[cfg(windows)] +#[tokio::test] +async fn unified_exec_disabled_windows_sandbox_rejects_managed_read_only_command() -> Result<()> { + let server = start_mock_server().await; + let mut builder = test_codex().with_config(|config| { + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); + config + .features + .disable(Feature::WindowsSandbox) + .expect("test config should allow feature update"); + config + .features + .disable(Feature::WindowsSandboxElevated) + .expect("test config should allow feature update"); + config.set_windows_sandbox_enabled(false); + config.set_windows_elevated_sandbox_enabled(false); + }); + let test = builder.build(&server).await?; + let call_id = "unified-exec-disabled-windows-sandbox-read-only"; + let args = json!({ + "cmd": "cmd.exe /c dir", + "yield_time_ms": 1_000, + }); + + mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-disabled-windows-sandbox-1"), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), + ev_completed("resp-disabled-windows-sandbox-1"), + ]), + ) + .await; + let results_mock = mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-disabled-windows-sandbox-1", "done"), + ev_completed("resp-disabled-windows-sandbox-2"), + ]), + ) + .await; + + submit_user_turn( + &test, + "run unified exec with disabled Windows sandbox", + AskForApproval::Never, + PermissionProfile::read_only(), + None, + ) + .await?; + + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let output_item = results_mock.single_request().function_call_output(call_id); + let Some(output) = output_item.get("output").and_then(Value::as_str) else { + panic!("function_call_output should include string output payload: {output_item:?}"); + }; + assert!( + output.contains("cmd.exe /c dir") && output.contains("rejected: blocked by policy"), + "unexpected output: {output}", + ); + + Ok(()) +} + #[tokio::test] async fn execpolicy_blocks_shell_invocation() -> Result<()> { let mut builder = test_codex().with_config(|config| { @@ -144,7 +215,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd_path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/guardian_review.rs b/codex-rs/core/tests/suite/guardian_review.rs index df38fb43539c..32808dc434e8 100644 --- a/codex-rs/core/tests/suite/guardian_review.rs +++ b/codex-rs/core/tests/suite/guardian_review.rs @@ -120,7 +120,7 @@ printf '%s\n' "${@: -1}" >> "${payload_path}""#, responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd.path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::AutoReview), sandbox_policy: Some(sandbox_policy), diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 7018274510e0..bcfa0b739227 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -9,6 +9,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; use codex_protocol::user_input::UserInput; +use core_test_support::TempDirExt; use core_test_support::responses; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -129,7 +130,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd.path().to_path_buf()), + cwd: Some(cwd.abs()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -228,7 +229,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd.path().to_path_buf()), + cwd: Some(cwd.abs()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index a56b2ab95f5d..a0010b6ffe53 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -17,6 +17,7 @@ use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::PathBufExt; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_image_generation_call; @@ -47,7 +48,7 @@ fn disabled_plan_turn( _model: String, collaboration_mode: CollaborationMode, ) -> anyhow::Result { - let cwd = std::env::current_dir()?; + let cwd = std::env::current_dir()?.abs(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); Ok(Op::UserInput { diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 67275d314687..e65d290b8786 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -69,9 +69,10 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { }; responses::mount_sse_once_match(&server, match_json_text_param, sse1).await; - let TestCodex { codex, cwd, .. } = test_codex().build(&server).await?; + let TestCodex { codex, config, .. } = test_codex().build(&server).await?; + let cwd = config.cwd.clone(); let (sandbox_policy, permission_profile) = - turn_permission_fields(PermissionProfile::Disabled, cwd.path()); + turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); // 1) Normal user input – should hit server once. codex @@ -85,7 +86,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd.path().to_path_buf()), + cwd: Some(cwd), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/mcp_turn_metadata.rs b/codex-rs/core/tests/suite/mcp_turn_metadata.rs index 897d8621628a..b0c1910d6091 100644 --- a/codex-rs/core/tests/suite/mcp_turn_metadata.rs +++ b/codex-rs/core/tests/suite/mcp_turn_metadata.rs @@ -78,7 +78,7 @@ async fn submit_user_turn( responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd.path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(approval_policy), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 1e4a5f9501e1..ce853e810f58 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -91,6 +91,7 @@ mod request_permissions_tool; mod request_plugin_install; mod request_user_input; mod responses_api_proxy_headers; +mod responses_lite; mod resume; mod resume_warning; mod review; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 111ce5043688..b0d4bb558d9f 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -50,7 +50,7 @@ fn read_only_user_turn(test: &TestCodex, items: Vec, model: String) - responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd_path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -112,6 +112,7 @@ fn test_model_info( input_modalities, used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, @@ -932,6 +933,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 27d92d27749e..25dd52174d7f 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -11,6 +11,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; +use core_test_support::PathBufExt; use core_test_support::context_snapshot; use core_test_support::context_snapshot::ContextSnapshotOptions; use core_test_support::context_snapshot::ContextSnapshotRenderMode; @@ -112,7 +113,8 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { let test = builder.build(&server).await?; let preturn_context_diff_cwd = test.cwd_path().join(PRETURN_CONTEXT_DIFF_CWD); fs::create_dir_all(&preturn_context_diff_cwd)?; - let first_turn_cwd = test.cwd_path().to_path_buf(); + let preturn_context_diff_cwd = preturn_context_diff_cwd.abs(); + let first_turn_cwd = test.config.cwd.clone(); let (first_sandbox_policy, first_permission_profile) = turn_permission_fields(PermissionProfile::read_only(), first_turn_cwd.as_path()); @@ -239,6 +241,8 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R cwd_two.join("AGENTS.md"), "# AGENTS two\n\n\nTurn two agents instructions.\n\n", )?; + let cwd_one = cwd_one.abs(); + let cwd_two = cwd_two.abs(); let (first_sandbox_policy, first_permission_profile) = turn_permission_fields(PermissionProfile::read_only(), cwd_one.as_path()); @@ -397,6 +401,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul let resumed = resume_builder.resume(&server, home, rollout_path).await?; let resume_override_cwd = resumed.cwd_path().join(PRETURN_CONTEXT_DIFF_CWD); fs::create_dir_all(&resume_override_cwd)?; + let resume_override_cwd = resume_override_cwd.abs(); let (sandbox_policy, permission_profile) = turn_permission_fields( PermissionProfile::read_only(), resume_override_cwd.as_path(), @@ -508,6 +513,7 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - let resumed = resume_builder.resume(&server, home, rollout_path).await?; let resume_override_cwd = resumed.cwd_path().join(PRETURN_CONTEXT_DIFF_CWD); fs::create_dir_all(&resume_override_cwd)?; + let resume_override_cwd = resume_override_cwd.abs(); core_test_support::submit_thread_settings( &resumed.codex, codex_protocol::protocol::ThreadSettingsOverrides { diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 34fc98b59f95..485a51346d78 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -102,7 +102,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd_path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(codex_protocol::protocol::AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -370,6 +370,7 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/tests/suite/models_etag_responses.rs b/codex-rs/core/tests/suite/models_etag_responses.rs index cbd73fffe9cf..11935ca27fbb 100644 --- a/codex-rs/core/tests/suite/models_etag_responses.rs +++ b/codex-rs/core/tests/suite/models_etag_responses.rs @@ -12,6 +12,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; +use core_test_support::TempDirExt; use core_test_support::responses; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -62,7 +63,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch let codex = Arc::clone(&test.codex); let cwd = Arc::clone(&test.cwd); let session_model = test.session_configured.model.clone(); - let cwd_path = cwd.path().to_path_buf(); + let cwd_path = cwd.abs(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index 7f5fa260cd62..d6c7577a7f60 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -1,11 +1,15 @@ use codex_core::config::Constrained; use codex_features::Feature; +use codex_otel::SessionTelemetry; +use codex_otel::TelemetryAuthMode; +use codex_protocol::ThreadId; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; +use codex_protocol::protocol::SessionSource; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -27,6 +31,7 @@ use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use std::sync::Mutex; +use std::time::Duration; use tracing::Level; use tracing_test::traced_test; @@ -1145,6 +1150,70 @@ fn tool_decision_assertion<'a>( } } +fn sandbox_outcome_assertion<'a>( + call_id: &'a str, + expected_outcome: &'a str, +) -> impl Fn(&[&str]) -> Result<(), String> + 'a { + let call_id = call_id.to_string(); + let expected_outcome = expected_outcome.to_string(); + + move |lines: &[&str]| { + let line = lines + .iter() + .find(|line| { + line.contains("codex.sandbox_outcome") + && line.contains(&format!("call_id={call_id}")) + }) + .ok_or_else(|| format!("missing codex.sandbox_outcome event for {call_id}"))?; + + let lower = line.to_lowercase(); + if !lower.contains("tool_name=shell_command") { + return Err("missing tool_name for shell_command".to_string()); + } + if !lower.contains(&format!("outcome={expected_outcome}")) { + return Err(format!("unexpected sandbox outcome for {call_id}")); + } + if !lower.contains("initial_duration_ms=12") { + return Err("missing initial_duration_ms field".to_string()); + } + if !lower.contains("escalated_duration_ms=34") { + return Err("missing escalated_duration_ms field".to_string()); + } + + Ok(()) + } +} + +#[test] +#[traced_test] +fn sandbox_outcome_event_records_outcome() { + let telemetry = SessionTelemetry::new( + ThreadId::new(), + "gpt-5.5", + "gpt-5.5", + /*account_id*/ None, + /*account_email*/ None, + Some(TelemetryAuthMode::ApiKey), + "Codex_Desktop".to_string(), + /*log_user_prompts*/ false, + "tty".to_string(), + SessionSource::Cli, + ); + + telemetry.sandbox_outcome( + "shell_command", + "sandbox-outcome-call", + "escalated", + Duration::from_millis(/*millis*/ 12), + Some(Duration::from_millis(/*millis*/ 34)), + ); + + logs_assert(sandbox_outcome_assertion( + "sandbox-outcome-call", + "escalated", + )); +} + #[tokio::test] #[traced_test] async fn handle_shell_command_autoapprove_from_config_records_tool_decision() { diff --git a/codex-rs/core/tests/suite/override_updates.rs b/codex-rs/core/tests/suite/override_updates.rs index ce7c87a93b90..e42703dff872 100644 --- a/codex-rs/core/tests/suite/override_updates.rs +++ b/codex-rs/core/tests/suite/override_updates.rs @@ -6,6 +6,7 @@ use codex_protocol::config_types::Settings; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; +use core_test_support::TempDirExt; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::test_codex::test_codex; @@ -67,7 +68,7 @@ async fn thread_settings_update_without_user_turn_does_not_record_environment_up core_test_support::submit_thread_settings( &test.codex, codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(new_cwd.path().to_path_buf()), + cwd: Some(new_cwd.abs()), ..Default::default() }, ) diff --git a/codex-rs/core/tests/suite/pending_input.rs b/codex-rs/core/tests/suite/pending_input.rs index 96b89caec84a..8a6443b6ce7f 100644 --- a/codex-rs/core/tests/suite/pending_input.rs +++ b/codex-rs/core/tests/suite/pending_input.rs @@ -124,7 +124,7 @@ async fn submit_danger_full_access_user_turn(test: &TestCodex, text: &str) { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index cd264c0734f3..4bd335bb354c 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -70,7 +70,7 @@ fn read_only_text_turn_with_personality( responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd_path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(approval_policy), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -592,6 +592,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, @@ -705,6 +706,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index ea646a4dbc4c..640c1c1db1aa 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -188,9 +188,6 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { }; expected_tools_names.extend([ "update_plan", - "get_goal", - "create_goal", - "update_goal", "request_user_input", "apply_patch", "view_image", @@ -767,7 +764,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(new_cwd.path().to_path_buf()), + cwd: Some(new_cwd.abs()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -884,7 +881,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(default_cwd.to_path_buf()), + cwd: Some(default_cwd.clone()), approval_policy: Some(default_approval_policy), sandbox_policy: Some(default_sandbox_policy.clone()), summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), @@ -913,7 +910,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(default_cwd.to_path_buf()), + cwd: Some(default_cwd.clone()), approval_policy: Some(default_approval_policy), sandbox_policy: Some(default_sandbox_policy.clone()), summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), @@ -1027,7 +1024,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(default_cwd.to_path_buf()), + cwd: Some(default_cwd.clone()), approval_policy: Some(default_approval_policy), sandbox_policy: Some(default_sandbox_policy.clone()), summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)), @@ -1058,7 +1055,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(default_cwd.to_path_buf()), + cwd: Some(default_cwd.clone()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index 0862482f9dc1..9363bdd3bd88 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -81,7 +81,7 @@ async fn submit_turn_with_approval_and_environments( responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd.path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(AskForApproval::OnRequest), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(SandboxPolicy::new_read_only_policy()), diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 608558d36545..76e7a1c29e91 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -23,6 +23,7 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecCommandSource; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; +use core_test_support::TempDirExt; use core_test_support::load_default_config_for_test; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -478,6 +479,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, @@ -572,7 +574,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { ]; mount_sse_sequence(&server, responses).await; - let cwd_path = cwd.path().to_path_buf(); + let cwd_path = cwd.abs(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); codex @@ -730,6 +732,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, @@ -799,7 +802,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { ) .await?; - let cwd_path = cwd.path().to_path_buf(); + let cwd_path = cwd.abs(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); codex @@ -1216,6 +1219,7 @@ fn test_remote_model_with_policy( input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index a78e4a016eb8..3beecfc33ec5 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -200,7 +200,7 @@ async fn submit_turn( responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd.path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(sandbox_policy), diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index a122cc399b3d..9b8761c85cfb 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -152,7 +152,7 @@ async fn submit_turn( responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd.path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(approval_policy), approvals_reviewer, sandbox_policy: Some(sandbox_policy), diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index d09071d72d99..50cabe416e54 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -13,6 +13,7 @@ use codex_protocol::protocol::Op; use codex_protocol::request_user_input::RequestUserInputAnswer; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; +use core_test_support::TempDirExt; use core_test_support::responses; use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; @@ -146,7 +147,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd.path().to_path_buf()), + cwd: Some(cwd.abs()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -290,7 +291,7 @@ async fn request_user_input_interrupt_emits_deferred_token_count() -> anyhow::Re responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd.path().to_path_buf()), + cwd: Some(cwd.abs()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -395,7 +396,7 @@ where responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd.path().to_path_buf()), + cwd: Some(cwd.abs()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/responses_api_proxy_headers.rs b/codex-rs/core/tests/suite/responses_api_proxy_headers.rs index 144feb016fd4..8492f08fddfe 100644 --- a/codex-rs/core/tests/suite/responses_api_proxy_headers.rs +++ b/codex-rs/core/tests/suite/responses_api_proxy_headers.rs @@ -141,7 +141,7 @@ async fn responses_api_parent_and_subagent_requests_include_identity_headers() - async fn submit_turn_with_timeout(test: &TestCodex, prompt: &str) -> Result<()> { let session_model = test.session_configured.model.clone(); - let cwd = test.config.cwd.to_path_buf(); + let cwd = test.config.cwd.clone(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::workspace_write(), cwd.as_path()); test.codex diff --git a/codex-rs/core/tests/suite/responses_lite.rs b/codex-rs/core/tests/suite/responses_lite.rs new file mode 100644 index 000000000000..60db4c064dad --- /dev/null +++ b/codex-rs/core/tests/suite/responses_lite.rs @@ -0,0 +1,229 @@ +use std::sync::Arc; + +use anyhow::Context; +use anyhow::Result; +use codex_core::config::Config; +use codex_extension_api::ExtensionRegistry; +use codex_extension_api::ExtensionRegistryBuilder; +use codex_features::Feature; +use codex_image_generation_extension::install as install_image_generation_extension; +use codex_login::CodexAuth; +use codex_protocol::config_types::WebSearchMode; +use codex_protocol::openai_models::InputModality; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_web_search_extension::install as install_web_search_extension; +use core_test_support::responses; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; +use serde_json::Value; + +const RESPONSES_LITE_HEADER: &str = "x-openai-internal-codex-responses-lite"; + +fn responses_extensions(auth: &CodexAuth) -> Arc> { + let auth_manager = codex_core::test_support::auth_manager_from_auth(auth.clone()); + let mut extension_builder = ExtensionRegistryBuilder::::new(); + install_web_search_extension(&mut extension_builder, Arc::clone(&auth_manager)); + install_image_generation_extension(&mut extension_builder, auth_manager); + Arc::new(extension_builder.build()) +} + +fn configure_responses_tools(config: &mut Config) { + assert!(config.web_search_mode.set(WebSearchMode::Live).is_ok()); + assert!( + config + .features + .disable(Feature::StandaloneWebSearch) + .is_ok() + ); + assert!(config.features.enable(Feature::ImageGeneration).is_ok()); + assert!(config.features.disable(Feature::ImageGenExt).is_ok()); +} + +fn configure_image_capable_model(model_info: &mut codex_protocol::openai_models::ModelInfo) { + model_info.input_modalities = vec![InputModality::Text, InputModality::Image]; +} + +fn has_hosted_tool(tools: &[Value], tool_type: &str) -> bool { + tools + .iter() + .any(|tool| tool.get("type").and_then(Value::as_str) == Some(tool_type)) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_lite_uses_standalone_web_search_and_image_generation() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let extensions = responses_extensions(&auth); + + let mut builder = test_codex() + .with_auth(auth) + .with_extensions(extensions) + .with_model_info_override("gpt-5.4", |model_info| { + model_info.use_responses_lite = true; + configure_image_capable_model(model_info); + }) + .with_config(configure_responses_tools); + let test = builder.build(&server).await?; + + test.submit_turn("Use standalone tools").await?; + + let request = response_mock.single_request(); + assert_eq!( + request.header(RESPONSES_LITE_HEADER).as_deref(), + Some("true") + ); + request + .tool_by_name("web", "run") + .context("Responses Lite should expose standalone web search")?; + request + .tool_by_name("image_gen", "imagegen") + .context("Responses Lite should expose standalone image generation")?; + + let body = request.body_json(); + let tools = body["tools"] + .as_array() + .context("Responses request tools should be an array")?; + assert!(!has_hosted_tool(tools, "web_search")); + assert!(!has_hosted_tool(tools, "image_generation")); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_lite_compact_request_uses_lite_transport_contract() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + let compact_mock = + responses::mount_compact_json_once(&server, serde_json::json!({ "output": [] })).await; + + let mut builder = test_codex().with_model_info_override("gpt-5.4", |model_info| { + model_info.use_responses_lite = true; + model_info.supports_parallel_tool_calls = true; + }); + let test = builder.build(&server).await?; + + test.submit_turn("Compact this conversation").await?; + test.codex.submit(Op::Compact).await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + response_mock.single_request(); + let compact_request = compact_mock.single_request(); + assert_eq!( + compact_request.header(RESPONSES_LITE_HEADER).as_deref(), + Some("true") + ); + let compact_body = compact_request.body_json(); + assert_eq!( + compact_body + .get("reasoning") + .and_then(|reasoning| reasoning.get("context")) + .and_then(Value::as_str), + Some("all_turns") + ); + assert_eq!( + compact_body.get("parallel_tool_calls"), + Some(&Value::Bool(false)) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_lite_omits_hosted_tools_without_standalone_extensions() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + + let mut builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_model_info_override("gpt-5.4", |model_info| { + model_info.use_responses_lite = true; + configure_image_capable_model(model_info); + }) + .with_config(configure_responses_tools); + let test = builder.build(&server).await?; + + test.submit_turn("Do not use hosted tools").await?; + + let body = response_mock.single_request().body_json(); + let tools = body["tools"] + .as_array() + .context("Responses request tools should be an array")?; + assert!(!has_hosted_tool(tools, "web_search")); + assert!(!has_hosted_tool(tools, "image_generation")); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn non_lite_uses_hosted_tools_when_standalone_features_are_disabled() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let extensions = responses_extensions(&auth); + let mut builder = test_codex() + .with_auth(auth) + .with_extensions(extensions) + .with_model_info_override("gpt-5.4", configure_image_capable_model) + .with_config(configure_responses_tools); + let test = builder.build(&server).await?; + + test.submit_turn("Use hosted tools").await?; + + let request = response_mock.single_request(); + assert_eq!(request.header(RESPONSES_LITE_HEADER), None); + assert!(request.tool_by_name("web", "run").is_none()); + assert!(request.tool_by_name("image_gen", "imagegen").is_none()); + let body = request.body_json(); + let tools = body["tools"] + .as_array() + .context("Responses request tools should be an array")?; + assert!(has_hosted_tool(tools, "web_search")); + assert!(has_hosted_tool(tools, "image_generation")); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 0d3f56489317..5f07d0b39ca0 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -821,7 +821,7 @@ async fn review_uses_overridden_cwd_for_base_branch_merge_base() { core_test_support::submit_thread_settings( &codex, codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(repo_path.to_path_buf()), + cwd: Some(repo_path.to_path_buf().abs()), ..Default::default() }, ) diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 36ea7d4b2c18..97452542726c 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -120,7 +120,7 @@ fn user_turn_with_permission_profile( model: String, permission_profile: PermissionProfile, ) -> Op { - let cwd = fixture.cwd.path().to_path_buf(); + let cwd = fixture.config.cwd.clone(); let (sandbox_policy, permission_profile) = turn_permission_fields(permission_profile, cwd.as_path()); Op::UserInput { @@ -1349,6 +1349,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re input_modalities: vec![InputModality::Text], used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index 532f4821d956..d21299b1eaed 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -47,7 +47,7 @@ fn disabled_text_turn(test: &TestCodex, text: &str) -> Op { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd_path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/shell_snapshot.rs b/codex-rs/core/tests/suite/shell_snapshot.rs index 69db30b21b44..61ca2860052d 100644 --- a/codex-rs/core/tests/suite/shell_snapshot.rs +++ b/codex-rs/core/tests/suite/shell_snapshot.rs @@ -154,7 +154,7 @@ async fn run_snapshot_command_with_options( let codex = test.codex.clone(); let codex_home = test.home.path().to_path_buf(); let session_model = test.session_configured.model.clone(); - let cwd = test.cwd_path().to_path_buf(); + let cwd = test.config.cwd.clone(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); @@ -255,7 +255,7 @@ async fn run_shell_command_snapshot_with_options( let codex = test.codex.clone(); let codex_home = test.home.path().to_path_buf(); let session_model = test.session_configured.model.clone(); - let cwd = test.cwd_path().to_path_buf(); + let cwd = test.config.cwd.clone(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); @@ -337,7 +337,7 @@ async fn run_tool_turn_on_harness( let test = harness.test(); let codex = test.codex.clone(); let session_model = test.session_configured.model.clone(); - let cwd = test.cwd_path().to_path_buf(); + let cwd = test.config.cwd.clone(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); codex @@ -554,7 +554,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> { let test = harness.test(); let codex = test.codex.clone(); - let cwd = test.cwd_path().to_path_buf(); + let cwd = test.config.cwd.clone(); let codex_home = test.home.path().to_path_buf(); let target = cwd.join("snapshot-apply.txt"); diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index 2335ba641556..d60d719cc880 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -56,7 +56,7 @@ async fn submit_turn_with_policies( responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd_path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(approval_policy), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index e21668fa8428..c559615d4e29 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -90,7 +90,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/spawn_agent_description.rs b/codex-rs/core/tests/suite/spawn_agent_description.rs index 3b581c905122..ba29b4f00c14 100644 --- a/codex-rs/core/tests/suite/spawn_agent_description.rs +++ b/codex-rs/core/tests/suite/spawn_agent_description.rs @@ -60,6 +60,7 @@ fn test_model_info( input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index 94deeb48a0db..9f8c940a0f60 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -462,7 +462,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< wait_for_mcp_server(&test.codex, server_name).await?; let db = test.codex.state_db().expect("state db enabled"); let thread_id = test.session_configured.thread_id; - let cwd = test.cwd_path().to_path_buf(); + let cwd = test.config.cwd.clone(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::read_only(), cwd.as_path()); diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index c39a6bb39db7..391b6e52365e 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -17,6 +17,7 @@ use core_test_support::hooks::trust_discovered_hooks; use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_function_call_with_namespace; use core_test_support::responses::ev_response_created; use core_test_support::responses::ev_tool_search_call; @@ -772,7 +773,7 @@ async fn subagent_stop_replaces_stop_and_skips_internal_subagents() -> Result<() responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -1026,6 +1027,80 @@ async fn spawned_multi_agent_v2_child_inherits_parent_developer_context() -> Res Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result<()> { + let server = start_mock_server().await; + let encrypted_message = "opaque-encrypted-message"; + let spawn_args = serde_json::to_string(&json!({ + "message": encrypted_message, + "task_name": "worker", + }))?; + mount_sse_once_match( + &server, + |req: &wiremock::Request| body_contains(req, TURN_1_PROMPT), + sse(vec![ + ev_response_created("resp-parent-1"), + ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args), + ev_completed("resp-parent-1"), + ]), + ) + .await; + let child_request_log = mount_sse_once_match( + &server, + |req: &wiremock::Request| body_contains(req, "\"type\":\"agent_message\""), + sse(vec![ + ev_response_created("resp-child-1"), + ev_completed("resp-child-1"), + ]), + ) + .await; + mount_sse_once_match( + &server, + |req: &wiremock::Request| { + body_contains(req, SPAWN_CALL_ID) && !body_contains(req, "\"type\":\"agent_message\"") + }, + sse(vec![ + ev_response_created("resp-parent-2"), + ev_assistant_message("msg-parent-2", "done"), + ev_completed("resp-parent-2"), + ]), + ) + .await; + + let mut builder = test_codex().with_model("koffing").with_config(|config| { + config + .features + .enable(Feature::Collab) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + test.submit_turn(TURN_1_PROMPT).await?; + + let child_request = wait_for_requests(&child_request_log) + .await? + .pop() + .expect("child request"); + assert_eq!( + child_request.inputs_of_type("agent_message"), + vec![json!({ + "type": "agent_message", + "author": "/root", + "recipient": "/root/worker", + "content": [{ + "type": "encrypted_content", + "encrypted_content": encrypted_message, + }], + })] + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn skills_toggle_skips_instructions_for_parent_and_spawned_child() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index a6f808b9d791..d628c8c49d82 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -10,6 +10,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; +use core_test_support::TempDirExt; use core_test_support::assert_regex_match; use core_test_support::responses; use core_test_support::responses::ResponsesRequest; @@ -97,7 +98,7 @@ async fn shell_command_tool_executes_command_and_streams_output() -> anyhow::Res let second_mock = responses::mount_sse_once(&server, second_response).await; let session_model = session_configured.model.clone(); - let cwd_path = cwd.path().to_path_buf(); + let cwd_path = cwd.abs(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); @@ -179,7 +180,7 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> { let second_mock = responses::mount_sse_once(&server, second_response).await; let session_model = session_configured.model.clone(); - let cwd_path = cwd.path().to_path_buf(); + let cwd_path = cwd.abs(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); @@ -271,7 +272,7 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> { let second_mock = responses::mount_sse_once(&server, second_response).await; let session_model = session_configured.model.clone(); - let cwd_path = cwd.path().to_path_buf(); + let cwd_path = cwd.abs(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); @@ -373,7 +374,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() let second_mock = responses::mount_sse_once(&server, second_response).await; let session_model = session_configured.model.clone(); - let cwd_path = cwd.path().to_path_buf(); + let cwd_path = cwd.abs(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); @@ -512,7 +513,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { let second_mock = responses::mount_sse_once(&server, second_response).await; let session_model = session_configured.model.clone(); - let cwd_path = cwd.path().to_path_buf(); + let cwd_path = cwd.abs(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path()); diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index 76bb27c4230e..f7f046fef77c 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -47,7 +47,7 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd.path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -374,7 +374,7 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.cwd.path().to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index db90b663e092..35a6b0df6d67 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -10,6 +10,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; +use core_test_support::TempDirExt; use core_test_support::assert_regex_match; use core_test_support::responses; use core_test_support::responses::ev_assistant_message; @@ -528,7 +529,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(fixture.cwd.path().to_path_buf()), + cwd: Some(fixture.cwd.abs()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile: Some(permission_profile), diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index d86a0f620e18..3eb2c6a6d3bf 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -16,6 +16,7 @@ use codex_protocol::protocol::ExecCommandSource; use codex_protocol::protocol::ExecCommandStatus; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; +use core_test_support::TempDirExt; use core_test_support::assert_regex_match; use core_test_support::managed_network_requirements_loader; use core_test_support::process::process_is_alive; @@ -202,7 +203,7 @@ async fn submit_unified_exec_turn( responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -278,7 +279,7 @@ async fn unified_exec_intercepts_apply_patch_exec_command() -> Result<()> { let test = harness.test(); let codex = test.codex.clone(); - let cwd = test.cwd_path().to_path_buf(); + let cwd = test.config.cwd.clone(); let session_model = test.session_configured.model.clone(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, &cwd); @@ -2135,9 +2136,9 @@ async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> mount_sse_sequence(&server, responses).await; let session_model = session_configured.model.clone(); - let turn_cwd = cwd.path().to_path_buf(); + let turn_cwd = cwd.abs(); let (sandbox_policy, permission_profile) = - turn_permission_fields(PermissionProfile::Disabled, &turn_cwd); + turn_permission_fields(PermissionProfile::Disabled, turn_cwd.as_path()); codex .submit(Op::UserInput { @@ -2239,9 +2240,9 @@ async fn unified_exec_interrupt_preserves_long_running_session() -> Result<()> { mount_sse_sequence(&server, responses).await; let session_model = session_configured.model.clone(); - let turn_cwd = cwd.path().to_path_buf(); + let turn_cwd = cwd.abs(); let (sandbox_policy, permission_profile) = - turn_permission_fields(PermissionProfile::Disabled, &turn_cwd); + turn_permission_fields(PermissionProfile::Disabled, turn_cwd.as_path()); codex .submit(Op::UserInput { @@ -2712,9 +2713,9 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> { let request_log = mount_sse_sequence(&server, responses).await; let session_model = session_configured.model.clone(); - let turn_cwd = cwd.path().to_path_buf(); + let turn_cwd = cwd.abs(); let (sandbox_policy, permission_profile) = - turn_permission_fields(PermissionProfile::read_only(), &turn_cwd); + turn_permission_fields(PermissionProfile::read_only(), turn_cwd.as_path()); codex .submit(Op::UserInput { @@ -2836,9 +2837,9 @@ async fn unified_exec_enforces_glob_deny_read_policy() -> Result<()> { let request_log = mount_sse_sequence(&server, responses).await; let session_model = session_configured.model.clone(); - let turn_cwd = cwd.path().to_path_buf(); + let turn_cwd = cwd.abs(); let (sandbox_policy, permission_profile) = - turn_permission_fields(PermissionProfile::read_only(), &turn_cwd); + turn_permission_fields(PermissionProfile::read_only(), turn_cwd.as_path()); codex .submit(Op::UserInput { items: vec![UserInput::Text { @@ -2974,9 +2975,9 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> { let request_log = mount_sse_sequence(&server, responses).await; let session_model = session_configured.model.clone(); - let turn_cwd = cwd.path().to_path_buf(); + let turn_cwd = cwd.abs(); let (sandbox_policy, permission_profile) = - turn_permission_fields(PermissionProfile::read_only(), &turn_cwd); + turn_permission_fields(PermissionProfile::read_only(), turn_cwd.as_path()); codex .submit(Op::UserInput { diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index 3e2263705ab3..fe9715f61e96 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -168,7 +168,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> ]); let mock = responses::mount_sse_sequence(&server, vec![first, second]).await; - let cwd = fixture.cwd.path().to_path_buf(); + let cwd = fixture.config.cwd.clone(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::Disabled, cwd.as_path()); diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 2e34475387c2..5d7263d54b2d 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -79,7 +79,7 @@ fn disabled_user_turn(test: &TestCodex, items: Vec, model: String) -> responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(test.config.cwd.to_path_buf()), + cwd: Some(test.config.cwd.clone()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, @@ -1355,6 +1355,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an input_modalities: vec![InputModality::Text], used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index be33467d6a5a..b41f8be9630d 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -5,6 +5,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; +use core_test_support::TempDirExt; use core_test_support::responses; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; @@ -162,7 +163,7 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result responsesapi_client_metadata: None, additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - cwd: Some(cwd.path().to_path_buf()), + cwd: Some(cwd.abs()), approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(sandbox_policy), permission_profile, diff --git a/codex-rs/docs/bazel.md b/codex-rs/docs/bazel.md index 085c15992f28..f12eeefd7092 100644 --- a/codex-rs/docs/bazel.md +++ b/codex-rs/docs/bazel.md @@ -81,7 +81,10 @@ GitHub Actions routes Bazel build and output-resolution commands through `.github/scripts/run-bazel-ci.sh` and `.github/scripts/rusty_v8_bazel.py` delegate remote configuration selection to that wrapper. The wrapper reads the GitHub Actions repository and event payload rather than relying on workflow -files to duplicate tenant-selection logic. +files to duplicate tenant-selection logic. It also normalizes GitHub Actions +startup options so all Bazel launches in a job reuse the same server and +in-memory analysis cache. Target-discovery and lockfile helpers delegate to the +same wrapper so their callers do not need to select CI-specific startup options. Loading-phase target-discovery `bazel query` commands run locally because they only enumerate labels and do not need remote caches or execution. diff --git a/codex-rs/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index 464e8187b47a..f16da209f60a 100644 --- a/codex-rs/docs/protocol_v1.md +++ b/codex-rs/docs/protocol_v1.md @@ -54,6 +54,7 @@ Since only 1 `Task` can be run at a time, for parallel tasks it is recommended t - These are messages sent on the `SQ` (UI -> `Codex`) - Has an string ID provided by the UI, referred to as `sub_id` - `Op` refers to the enum of all possible `Submission` payloads + - In the current codebase these are primarily in-process Rust types rather than a stable serde wire contract - This enum is `non_exhaustive`; variants can be added at future dates - `Event` - These are messages sent on the `EQ` (`Codex` -> UI) @@ -103,7 +104,7 @@ The `response_id` returned from each turn matches the OpenAI `response_id` store Can operate over any transport that supports bi-directional streaming. - cross-thread channels - IPC channels - stdin/stdout - TCP - HTTP2 - gRPC -Non-framed transports, such as stdin/stdout and TCP, should use newline-delimited JSON in sending messages. +Events still serialize cleanly to newline-delimited JSON for non-framed transports, such as stdin/stdout and TCP. Submission payloads should be treated as implementation details unless a specific transport owns an explicit adapter. ## Example Flows diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index d842094a1621..eadab83fe703 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -22,6 +22,7 @@ codex-client = { workspace = true } codex-file-system = { workspace = true } codex-protocol = { workspace = true } codex-sandboxing = { workspace = true } +codex-shell-command = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-pty = { workspace = true } codex-utils-rustls-provider = { workspace = true } diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index b6c56e9f5e27..8cf18e7d1f43 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -29,6 +29,7 @@ use crate::connection::JsonRpcConnection; use crate::process::ExecProcessEvent; use crate::process::ExecProcessEventLog; use crate::process::ExecProcessEventReceiver; +use crate::protocol::ENVIRONMENT_INFO_METHOD; use crate::protocol::EXEC_CLOSED_METHOD; use crate::protocol::EXEC_EXITED_METHOD; use crate::protocol::EXEC_METHOD; @@ -36,6 +37,7 @@ use crate::protocol::EXEC_OUTPUT_DELTA_METHOD; use crate::protocol::EXEC_READ_METHOD; use crate::protocol::EXEC_TERMINATE_METHOD; use crate::protocol::EXEC_WRITE_METHOD; +use crate::protocol::EnvironmentInfo; use crate::protocol::ExecClosedNotification; use crate::protocol::ExecExitedNotification; use crate::protocol::ExecOutputDeltaNotification; @@ -279,6 +281,12 @@ impl HttpClient for LazyRemoteExecServerClient { } } +impl LazyRemoteExecServerClient { + pub(crate) async fn environment_info(&self) -> Result { + self.get().await?.environment_info().await + } +} + #[derive(Debug, thiserror::Error)] pub enum ExecServerError { #[error("failed to spawn exec-server: {0}")] @@ -363,6 +371,10 @@ impl ExecServerClient { self.call(EXEC_METHOD, ¶ms).await } + pub async fn environment_info(&self) -> Result { + self.call(ENVIRONMENT_INFO_METHOD, &()).await + } + pub async fn read(&self, params: ReadParams) -> Result { self.call(EXEC_READ_METHOD, ¶ms).await } diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 55dc031273e2..1dfd49d3fdbd 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -2,6 +2,9 @@ use std::collections::HashMap; use std::sync::Arc; use std::sync::RwLock; +use futures::FutureExt; +use futures::future::BoxFuture; + use crate::ExecServerError; use crate::ExecServerRuntimePaths; use crate::ExecutorFileSystem; @@ -18,8 +21,11 @@ use crate::environment_toml::environment_provider_from_codex_home; use crate::local_file_system::LocalFileSystem; use crate::local_process::LocalProcess; use crate::process::ExecBackend; +use crate::protocol::EnvironmentInfo; +use crate::protocol::ShellInfo; use crate::remote_file_system::RemoteFileSystem; use crate::remote_process::RemoteProcess; +use codex_shell_command::shell_detect::DetectedShell; pub const CODEX_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_EXEC_SERVER_URL"; @@ -286,18 +292,49 @@ impl EnvironmentManager { pub struct Environment { exec_server_url: Option, remote_transport: Option, + info_provider: Arc, exec_backend: Arc, filesystem: Arc, http_client: Arc, local_runtime_paths: Option, } +/// Provides environment metadata from either a local environment or a remote exec-server. +trait EnvironmentInfoProvider: Send + Sync { + fn info(&self) -> BoxFuture<'_, Result>; +} + +struct LocalEnvironmentInfoProvider; + +impl EnvironmentInfoProvider for LocalEnvironmentInfoProvider { + fn info(&self) -> BoxFuture<'_, Result> { + std::future::ready(Ok(EnvironmentInfo::local())).boxed() + } +} + +struct RemoteEnvironmentInfoProvider { + client: LazyRemoteExecServerClient, +} + +impl RemoteEnvironmentInfoProvider { + fn new(client: LazyRemoteExecServerClient) -> Self { + Self { client } + } +} + +impl EnvironmentInfoProvider for RemoteEnvironmentInfoProvider { + fn info(&self) -> BoxFuture<'_, Result> { + async move { self.client.environment_info().await }.boxed() + } +} + impl Environment { /// Builds a test-only local environment without configured sandbox helper paths. pub fn default_for_tests() -> Self { Self { exec_server_url: None, remote_transport: None, + info_provider: Arc::new(LocalEnvironmentInfoProvider), exec_backend: Arc::new(LocalProcess::default()), filesystem: Arc::new(LocalFileSystem::unsandboxed()), http_client: Arc::new(ReqwestHttpClient), @@ -354,6 +391,7 @@ impl Environment { Self { exec_server_url: None, remote_transport: None, + info_provider: Arc::new(LocalEnvironmentInfoProvider), exec_backend: Arc::new(LocalProcess::default()), filesystem: Arc::new(LocalFileSystem::with_runtime_paths( local_runtime_paths.clone(), @@ -392,6 +430,7 @@ impl Environment { Self { exec_server_url, remote_transport: Some(remote_transport), + info_provider: Arc::new(RemoteEnvironmentInfoProvider::new(client.clone())), exec_backend, filesystem, http_client: Arc::new(client), @@ -412,6 +451,11 @@ impl Environment { self.local_runtime_paths.as_ref() } + /// Returns environment information from the selected execution/filesystem environment. + pub async fn info(&self) -> Result { + self.info_provider.info().await + } + pub fn get_exec_backend(&self) -> Arc { Arc::clone(&self.exec_backend) } @@ -425,6 +469,23 @@ impl Environment { } } +impl EnvironmentInfo { + pub(crate) fn local() -> Self { + Self { + shell: codex_shell_command::shell_detect::default_user_shell().into(), + } + } +} + +impl From for ShellInfo { + fn from(shell: DetectedShell) -> Self { + Self { + name: shell.name().to_string(), + path: shell.shell_path.to_string_lossy().into_owned(), + } + } +} + #[cfg(test)] mod tests { use std::sync::Arc; @@ -458,6 +519,7 @@ mod tests { assert_eq!(environment.exec_server_url(), None); assert!(!environment.is_remote()); + assert!(environment.info().await.is_ok()); } #[tokio::test] diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index dea039c28836..e306ec395e7b 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -57,6 +57,7 @@ pub use process::ExecProcessEvent; pub use process::ExecProcessEventReceiver; pub use process::StartedExecProcess; pub use process_id::ProcessId; +pub use protocol::EnvironmentInfo; pub use protocol::ExecClosedNotification; pub use protocol::ExecEnvPolicy; pub use protocol::ExecExitedNotification; @@ -94,6 +95,7 @@ pub use protocol::InitializeResponse; pub use protocol::ProcessOutputChunk; pub use protocol::ReadParams; pub use protocol::ReadResponse; +pub use protocol::ShellInfo; pub use protocol::TerminateParams; pub use protocol::TerminateResponse; pub use protocol::WriteParams; diff --git a/codex-rs/exec-server/src/protocol.rs b/codex-rs/exec-server/src/protocol.rs index 9ac80c188ed3..6eeeeefdd73f 100644 --- a/codex-rs/exec-server/src/protocol.rs +++ b/codex-rs/exec-server/src/protocol.rs @@ -19,6 +19,7 @@ pub const EXEC_TERMINATE_METHOD: &str = "process/terminate"; pub const EXEC_OUTPUT_DELTA_METHOD: &str = "process/output"; pub const EXEC_EXITED_METHOD: &str = "process/exited"; pub const EXEC_CLOSED_METHOD: &str = "process/closed"; +pub const ENVIRONMENT_INFO_METHOD: &str = "environment/info"; pub const FS_READ_FILE_METHOD: &str = "fs/readFile"; pub const FS_WRITE_FILE_METHOD: &str = "fs/writeFile"; pub const FS_CREATE_DIRECTORY_METHOD: &str = "fs/createDirectory"; @@ -64,6 +65,23 @@ pub struct InitializeResponse { pub session_id: String, } +/// Information about an execution/filesystem environment. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EnvironmentInfo { + pub shell: ShellInfo, +} + +/// Shell detected for an execution/filesystem environment. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ShellInfo { + /// Stable shell name, for example `zsh`, `bash`, `powershell`, `sh`, or `cmd`. + pub name: String, + /// Path the exec server would use for that shell. + pub path: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExecParams { diff --git a/codex-rs/exec-server/src/server/handler.rs b/codex-rs/exec-server/src/server/handler.rs index 5456ce41c0b2..b8934705d2b8 100644 --- a/codex-rs/exec-server/src/server/handler.rs +++ b/codex-rs/exec-server/src/server/handler.rs @@ -14,6 +14,7 @@ use tokio_util::task::TaskTracker; use crate::ExecServerRuntimePaths; use crate::client::http_client::PendingReqwestHttpBodyStream; use crate::client::http_client::ReqwestHttpRequestRunner; +use crate::protocol::EnvironmentInfo; use crate::protocol::ExecParams; use crate::protocol::ExecResponse; use crate::protocol::FsCanonicalizeParams; @@ -147,6 +148,11 @@ impl ExecServerHandler { session.process().exec(params).await } + pub(crate) fn environment_info(&self) -> Result { + self.require_initialized_for("environment info")?; + Ok(EnvironmentInfo::local()) + } + pub(crate) async fn exec_read( &self, params: ReadParams, diff --git a/codex-rs/exec-server/src/server/registry.rs b/codex-rs/exec-server/src/server/registry.rs index 74adf905805d..26d2876c6383 100644 --- a/codex-rs/exec-server/src/server/registry.rs +++ b/codex-rs/exec-server/src/server/registry.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use crate::protocol::ENVIRONMENT_INFO_METHOD; use crate::protocol::EXEC_METHOD; use crate::protocol::EXEC_READ_METHOD; use crate::protocol::EXEC_TERMINATE_METHOD; @@ -60,6 +61,10 @@ pub(crate) fn build_router() -> RpcRouter { EXEC_METHOD, |handler: Arc, params: ExecParams| async move { handler.exec(params).await }, ); + router.request( + ENVIRONMENT_INFO_METHOD, + |handler: Arc, _params: ()| async move { handler.environment_info() }, + ); router.request( EXEC_READ_METHOD, |handler: Arc, params: ReadParams| async move { diff --git a/codex-rs/exec-server/tests/health.rs b/codex-rs/exec-server/tests/health.rs index 91b3806a22b2..1fcfb049b5ed 100644 --- a/codex-rs/exec-server/tests/health.rs +++ b/codex-rs/exec-server/tests/health.rs @@ -2,6 +2,7 @@ mod common; +use codex_exec_server::Environment; use common::exec_server::exec_server; use pretty_assertions::assert_eq; @@ -19,3 +20,17 @@ async fn exec_server_serves_readyz_alongside_websocket_endpoint() -> anyhow::Res server.shutdown().await?; Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_environment_fetches_info_from_exec_server() -> anyhow::Result<()> { + let mut server = exec_server().await?; + let environment = Environment::create_for_tests(Some(server.websocket_url().to_string()))?; + assert!(environment.is_remote()); + + let remote_info = environment.info().await?; + let local_info = Environment::default_for_tests().info().await?; + assert_eq!(remote_info, local_info); + + server.shutdown().await?; + Ok(()) +} diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 797e168dc5ea..b8b87a85eca6 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -1044,13 +1044,7 @@ fn thread_start_params_from_config(config: &Config) -> ThreadStartParams { model: config.model.clone(), model_provider: Some(config.model_provider_id.clone()), cwd: Some(config.cwd.to_string_lossy().to_string()), - runtime_workspace_roots: Some( - config - .workspace_roots - .iter() - .map(AbsolutePathBuf::to_path_buf) - .collect(), - ), + runtime_workspace_roots: Some(config.workspace_roots.clone()), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(config), sandbox: sandbox.flatten(), @@ -1075,13 +1069,7 @@ fn thread_resume_params_from_config(config: &Config, thread_id: String) -> Threa model: config.model.clone(), model_provider: Some(config.model_provider_id.clone()), cwd: Some(config.cwd.to_string_lossy().to_string()), - runtime_workspace_roots: Some( - config - .workspace_roots - .iter() - .map(AbsolutePathBuf::to_path_buf) - .collect(), - ), + runtime_workspace_roots: Some(config.workspace_roots.clone()), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(config), sandbox: sandbox.flatten(), diff --git a/codex-rs/ext/goal/src/api.rs b/codex-rs/ext/goal/src/api.rs index 0e23c295a219..5123e6a5ceb7 100644 --- a/codex-rs/ext/goal/src/api.rs +++ b/codex-rs/ext/goal/src/api.rs @@ -124,7 +124,19 @@ impl GoalService { .map_err(GoalServiceError::InvalidRequest)?; } - if let Some(runtime) = self.runtime_for_thread(thread_id) + let runtime = self.runtime_for_thread(thread_id); + // Hold this through the prepare/write window so idle continuation cannot + // launch from goal state that this external mutation is about to change. + let _goal_state_permit = match runtime.as_ref() { + Some(runtime) => Some( + runtime + .goal_state_permit() + .await + .map_err(GoalServiceError::Internal)?, + ), + None => None, + }; + if let Some(runtime) = runtime.as_ref() && let Err(err) = runtime.prepare_external_goal_mutation().await { tracing::warn!("failed to prepare external goal mutation: {err}"); @@ -229,7 +241,19 @@ impl GoalService { state_db: &codex_state::StateRuntime, thread_id: ThreadId, ) -> Result { - if let Some(runtime) = self.runtime_for_thread(thread_id) + let runtime = self.runtime_for_thread(thread_id); + // Hold this through the prepare/write window so idle continuation cannot + // launch from goal state that this external mutation is about to change. + let goal_state_permit = match runtime.as_ref() { + Some(runtime) => Some( + runtime + .goal_state_permit() + .await + .map_err(GoalServiceError::Internal)?, + ), + None => None, + }; + if let Some(runtime) = runtime.as_ref() && let Err(err) = runtime.prepare_external_goal_mutation().await { tracing::warn!("failed to prepare external goal mutation: {err}"); @@ -242,6 +266,8 @@ impl GoalService { .map_err(|err| { GoalServiceError::Internal(format!("failed to clear thread goal: {err}")) })?; + drop(goal_state_permit); + drop(runtime); if cleared && let Some(runtime) = self.runtime_for_thread(thread_id) diff --git a/codex-rs/ext/goal/src/extension.rs b/codex-rs/ext/goal/src/extension.rs index 563c40b50337..78ae0b5b443e 100644 --- a/codex-rs/ext/goal/src/extension.rs +++ b/codex-rs/ext/goal/src/extension.rs @@ -36,6 +36,7 @@ use crate::accounting::GoalAccountingState; use crate::api::GoalService; use crate::events::GoalEventEmitter; use crate::metrics::GoalMetrics; +use crate::runtime::ActiveGoalStopReason; use crate::runtime::GoalRuntimeConfig; use crate::runtime::GoalRuntimeHandle; use crate::spec::UPDATE_GOAL_TOOL_NAME; @@ -278,18 +279,26 @@ where } async fn on_turn_error(&self, input: TurnErrorInput<'_>) { - if input.error != CodexErrorInfo::UsageLimitExceeded { - return; - } let Some(runtime) = goal_runtime_handle(input.thread_store) else { return; }; + let reason = match input.error { + CodexErrorInfo::UsageLimitExceeded => ActiveGoalStopReason::UsageLimit, + // The turn has ended because the error was non-retryable or its + // retries were exhausted. Block the goal to prevent automatic + // continuation from looping and consuming tokens, as can happen + // with compaction errors. + _ => ActiveGoalStopReason::TurnError, + }; if let Err(err) = runtime - .usage_limit_active_goal_for_turn(input.turn_id) + .stop_active_goal_for_turn(input.turn_id, reason) .await { - tracing::warn!("failed to usage-limit active goal after usage-limit error: {err}"); + tracing::warn!( + error = ?input.error, + "failed to stop active goal after turn error: {err}" + ); } } } diff --git a/codex-rs/ext/goal/src/lib.rs b/codex-rs/ext/goal/src/lib.rs index 8433e1f7df76..bb640c6ce899 100644 --- a/codex-rs/ext/goal/src/lib.rs +++ b/codex-rs/ext/goal/src/lib.rs @@ -1,8 +1,4 @@ -//! Extension crate sketch for the `/goal` feature. -//! -//! This crate is intentionally not wired into the host yet. It contains the -//! goal tool specs, extension registration shape, and the parts of runtime -//! accounting that can be represented with today's extension API. +//! Extension crate for the `/goal` feature. mod accounting; mod api; diff --git a/codex-rs/ext/goal/src/runtime.rs b/codex-rs/ext/goal/src/runtime.rs index b58d1244e6ba..2641dfb949a6 100644 --- a/codex-rs/ext/goal/src/runtime.rs +++ b/codex-rs/ext/goal/src/runtime.rs @@ -15,6 +15,8 @@ use crate::metrics::GoalMetrics; use crate::steering::continuation_steering_item; use crate::steering::objective_updated_steering_item; use crate::tool::protocol_goal_from_state; +use tokio::sync::Semaphore; +use tokio::sync::SemaphorePermit; #[derive(Clone)] pub struct GoalRuntimeHandle { @@ -26,6 +28,11 @@ pub(crate) struct GoalRuntimeConfig { pub(crate) tools_available_for_thread: bool, } +pub(crate) enum ActiveGoalStopReason { + TurnError, + UsageLimit, +} + struct GoalRuntimeInner { thread_id: ThreadId, state_dbs: Arc, @@ -35,6 +42,7 @@ struct GoalRuntimeInner { accounting_state: Arc, enabled: AtomicBool, tools_available_for_thread: bool, + goal_state_lock: Semaphore, } pub(crate) struct AccountedGoalProgress { @@ -85,6 +93,7 @@ impl GoalRuntimeHandle { accounting_state, enabled: AtomicBool::new(config.enabled), tools_available_for_thread: config.tools_available_for_thread, + goal_state_lock: Semaphore::new(/*permits*/ 1), }), } } @@ -109,6 +118,14 @@ impl GoalRuntimeHandle { Arc::clone(&self.inner.accounting_state) } + pub(crate) async fn goal_state_permit(&self) -> Result, String> { + self.inner + .goal_state_lock + .acquire() + .await + .map_err(|err| err.to_string()) + } + pub async fn prepare_external_goal_mutation(&self) -> Result<(), String> { if !self.is_enabled() { return Ok(()); @@ -204,10 +221,23 @@ impl GoalRuntimeHandle { } pub async fn usage_limit_active_goal_for_turn(&self, turn_id: &str) -> Result<(), String> { + self.stop_active_goal_for_turn(turn_id, ActiveGoalStopReason::UsageLimit) + .await + } + + /// Accounts the ending turn and stops its active goal after a terminal error. + pub(crate) async fn stop_active_goal_for_turn( + &self, + turn_id: &str, + reason: ActiveGoalStopReason, + ) -> Result<(), String> { if !self.is_enabled() { return Ok(()); } + // Hold this through accounting and the status update so external goal + // mutations and idle continuation cannot interleave between them. + let _goal_state_permit = self.goal_state_permit().await?; if !self .inner .accounting_state @@ -216,23 +246,54 @@ impl GoalRuntimeHandle { return Ok(()); } - let progress_event_id = format!("{turn_id}:usage-limit-progress"); + let (event_name, status) = match reason { + ActiveGoalStopReason::TurnError => { + ("turn-error", codex_state::ThreadGoalStatus::Blocked) + } + ActiveGoalStopReason::UsageLimit => { + ("usage-limit", codex_state::ThreadGoalStatus::UsageLimited) + } + }; self.account_active_goal_progress( turn_id, - progress_event_id.as_str(), + &format!("{turn_id}:{event_name}-progress"), codex_state::GoalAccountingMode::ActiveOnly, BudgetLimitedGoalDisposition::ClearActive, ) .await?; - let previous_status = self - .current_goal_status_for_metrics(/*expected_goal_id*/ None) - .await?; + let Some(active_goal) = self + .inner + .state_dbs + .thread_goals() + .get_thread_goal(self.thread_id()) + .await + .map_err(|err| err.to_string())? + else { + self.inner.accounting_state.clear_active_goal(); + return Ok(()); + }; + let can_stop = active_goal.status == codex_state::ThreadGoalStatus::Active + || (active_goal.status == codex_state::ThreadGoalStatus::BudgetLimited + && status == codex_state::ThreadGoalStatus::UsageLimited); + if !can_stop { + self.inner.accounting_state.clear_active_goal(); + return Ok(()); + } + let previous_status = Some(active_goal.status); let Some(goal) = self .inner .state_dbs .thread_goals() - .usage_limit_active_thread_goal(self.thread_id()) + .update_thread_goal( + self.thread_id(), + codex_state::GoalUpdate { + objective: None, + status: Some(status), + token_budget: None, + expected_goal_id: Some(active_goal.goal_id), + }, + ) .await .map_err(|err| err.to_string())? else { @@ -244,7 +305,7 @@ impl GoalRuntimeHandle { self.inner.accounting_state.clear_active_goal(); let goal = protocol_goal_from_state(goal); self.inner.event_emitter.thread_goal_updated( - format!("{turn_id}:usage-limit"), + format!("{turn_id}:{event_name}"), Some(turn_id.to_string()), goal, ); @@ -280,6 +341,18 @@ impl GoalRuntimeHandle { self.inner.accounting_state.clear_active_goal(); return Ok(()); } + // Hold this through the read/start window so external set/clear cannot + // change the goal after we read it but before the continuation launches. + let _goal_state_permit = self.goal_state_permit().await?; + + let Some(thread_manager) = self.inner.thread_manager.upgrade() else { + tracing::debug!("skipping goal continuation because thread manager is unavailable"); + return Ok(()); + }; + let Ok(thread) = thread_manager.get_thread(self.inner.thread_id).await else { + tracing::debug!("skipping goal continuation because live thread is unavailable"); + return Ok(()); + }; let Some(goal) = self .inner @@ -296,16 +369,7 @@ impl GoalRuntimeHandle { self.inner.accounting_state.clear_active_goal(); return Ok(()); } - let item = continuation_steering_item(&protocol_goal_from_state(goal)); - let Some(thread_manager) = self.inner.thread_manager.upgrade() else { - tracing::debug!("skipping goal continuation because thread manager is unavailable"); - return Ok(()); - }; - let Ok(thread) = thread_manager.get_thread(self.inner.thread_id).await else { - tracing::debug!("skipping goal continuation because live thread is unavailable"); - return Ok(()); - }; if let Err(err) = thread.try_start_turn_if_idle(vec![item]).await { let reason = err.reason(); diff --git a/codex-rs/ext/goal/src/spec.rs b/codex-rs/ext/goal/src/spec.rs index 70e89e4fbb6c..2c92c0384814 100644 --- a/codex-rs/ext/goal/src/spec.rs +++ b/codex-rs/ext/goal/src/spec.rs @@ -34,7 +34,8 @@ pub fn create_create_goal_tool() -> ToolSpec { ( "token_budget".to_string(), JsonSchema::integer(Some( - "Optional positive token budget for the new active goal.".to_string(), + "Positive token budget for the new goal. Omit unless explicitly requested." + .to_string(), )), ), ]); @@ -62,7 +63,7 @@ pub fn create_update_goal_tool() -> ToolSpec { JsonSchema::string_enum( vec![json!("complete"), json!("blocked")], Some( - "Required. Set to complete only when the objective is achieved and no required work remains. Set to blocked only when the goal cannot currently proceed without a user decision, missing dependency, or external unblock." + "Required. Set to `complete` only when the objective is achieved and no required work remains. Set to `blocked` only after the same blocking condition has recurred for at least three consecutive goal turns and the agent is at an impasse. After a previously blocked goal is resumed, the resumed run starts a fresh blocked audit." .to_string(), ), ), @@ -71,11 +72,14 @@ pub fn create_update_goal_tool() -> ToolSpec { ToolSpec::Function(ResponsesApiTool { name: UPDATE_GOAL_TOOL_NAME.to_string(), description: r#"Update the existing goal. -Use this tool only to mark the goal achieved or blocked. +Use this tool only to mark the goal achieved or genuinely blocked. Set status to `complete` only when the objective has actually been achieved and no required work remains. -Set status to `blocked` only when the goal cannot currently proceed until something external changes. +Set status to `blocked` only when the same blocking condition has repeated for at least three consecutive goal turns, counting the original/user-triggered turn and any automatic continuations, and the agent cannot make meaningful progress without user input or an external-state change. +If the user resumes a goal that was previously marked `blocked`, treat the resumed run as a fresh blocked audit. If the same blocking condition then repeats for at least three consecutive resumed goal turns, set status to `blocked` again. +Once the blocked threshold is satisfied, do not keep reporting that you are still blocked while leaving the goal active; set status to `blocked`. +Do not use `blocked` merely because the work is hard, slow, uncertain, incomplete, or would benefit from clarification. Do not mark a goal complete merely because its budget is nearly exhausted or because you are stopping work. -You cannot use this tool to pause, resume, or budget-limit a goal; those status changes are controlled by the user or system. +You cannot use this tool to pause, resume, budget-limit, or usage-limit a goal; those status changes are controlled by the user or system. When marking a budgeted goal achieved with status `complete`, report the final token usage from the tool result to the user."# .to_string(), strict: false, diff --git a/codex-rs/ext/goal/tests/goal_extension_backend.rs b/codex-rs/ext/goal/tests/goal_extension_backend.rs index 3c437a088965..f89a08ebbb20 100644 --- a/codex-rs/ext/goal/tests/goal_extension_backend.rs +++ b/codex-rs/ext/goal/tests/goal_extension_backend.rs @@ -494,18 +494,9 @@ async fn turn_error_usage_limit_accounts_progress_and_clears_accounting() -> any ), ) .await; - let turn_store = ExtensionData::new("turn-1"); - for contributor in harness.registry.turn_lifecycle_contributors() { - contributor - .on_turn_error(TurnErrorInput { - turn_id: "turn-1", - error: CodexErrorInfo::UsageLimitExceeded, - session_store: &harness.session_store, - thread_store: &harness.thread_store, - turn_store: &turn_store, - }) - .await; - } + harness + .notify_turn_error("turn-1", CodexErrorInfo::UsageLimitExceeded) + .await; let goal = runtime .thread_goals() @@ -557,6 +548,36 @@ async fn turn_error_usage_limit_accounts_progress_and_clears_accounting() -> any Ok(()) } +#[tokio::test] +async fn turn_error_blocks_goal() -> anyhow::Result<()> { + let runtime = test_runtime().await?; + let thread_id = test_thread_id()?; + seed_thread_metadata(runtime.as_ref(), thread_id).await?; + let harness = GoalExtensionHarness::new(runtime.clone(), thread_id).await?; + harness.start_turn("turn-1", &TokenUsage::default()).await; + + let tools = harness.tools(); + tool_by_name(&tools, "create_goal") + .handle(tool_call( + "create_goal", + "call-create-goal", + json!({ "objective": "ship goal extension backend" }), + )) + .await?; + + harness + .notify_turn_error("turn-1", CodexErrorInfo::Other) + .await; + + let goal = runtime + .thread_goals() + .get_thread_goal(thread_id) + .await? + .ok_or_else(|| anyhow::anyhow!("goal should exist"))?; + assert_eq!(codex_state::ThreadGoalStatus::Blocked, goal.status); + Ok(()) +} + #[tokio::test] async fn usage_limit_budget_limited_goal_accounts_remaining_progress() -> anyhow::Result<()> { let runtime = test_runtime().await?; @@ -1255,6 +1276,21 @@ impl GoalExtensionHarness { } } + async fn notify_turn_error(&self, turn_id: &str, error: CodexErrorInfo) { + let turn_store = ExtensionData::new(turn_id); + for contributor in self.registry.turn_lifecycle_contributors() { + contributor + .on_turn_error(TurnErrorInput { + turn_id, + error: error.clone(), + session_store: &self.session_store, + thread_store: &self.thread_store, + turn_store: &turn_store, + }) + .await; + } + } + fn runtime_handle(&self) -> Arc { self.thread_store .get::() diff --git a/codex-rs/ext/image-generation/Cargo.toml b/codex-rs/ext/image-generation/Cargo.toml index 3f22bd3fffa9..c6b87e1d4fd0 100644 --- a/codex-rs/ext/image-generation/Cargo.toml +++ b/codex-rs/ext/image-generation/Cargo.toml @@ -17,7 +17,6 @@ async-trait = { workspace = true } codex-api = { workspace = true } codex-core = { workspace = true } codex-extension-api = { workspace = true } -codex-features = { workspace = true } codex-login = { workspace = true } codex-model-provider = { workspace = true } codex-model-provider-info = { workspace = true } diff --git a/codex-rs/ext/image-generation/src/extension.rs b/codex-rs/ext/image-generation/src/extension.rs index 6f6a7c7b1bc6..2c68f614f5a2 100644 --- a/codex-rs/ext/image-generation/src/extension.rs +++ b/codex-rs/ext/image-generation/src/extension.rs @@ -9,7 +9,6 @@ use codex_extension_api::ThreadStartInput; use codex_extension_api::ToolCall; use codex_extension_api::ToolContributor; use codex_extension_api::ToolExecutor; -use codex_features::Feature; use codex_login::AuthManager; use codex_model_provider::create_model_provider; use codex_model_provider_info::ModelProviderInfo; @@ -25,7 +24,7 @@ struct ImageGenerationExtension { #[derive(Clone)] struct ImageGenerationExtensionConfig { - enabled: bool, + available: bool, provider: ModelProviderInfo, codex_home: AbsolutePathBuf, } @@ -34,8 +33,8 @@ impl From<&Config> for ImageGenerationExtensionConfig { /// Resolves whether standalone image generation should be available for a thread. fn from(config: &Config) -> Self { Self { - enabled: config.features.enabled(Feature::ImageGenExt) - && config.model_provider.is_openai(), + // Core selects this executor per turn using the feature flag or model metadata. + available: config.model_provider.is_openai(), provider: config.model_provider.clone(), codex_home: config.codex_home.clone(), } @@ -75,7 +74,7 @@ impl ToolContributor for ImageGenerationExtension { let Some(config) = thread_store.get::() else { return Vec::new(); }; - if !config.enabled || !self.auth_manager.current_auth_uses_codex_backend() { + if !config.available || !self.auth_manager.current_auth_uses_codex_backend() { return Vec::new(); } @@ -90,7 +89,7 @@ impl ToolContributor for ImageGenerationExtension { } } -/// Installs the feature-gated standalone image-generation extension contributors. +/// Installs the standalone image-generation extension contributors. pub fn install(registry: &mut ExtensionRegistryBuilder, auth_manager: Arc) { let extension = Arc::new(ImageGenerationExtension { auth_manager }); registry.thread_lifecycle_contributor(extension.clone()); diff --git a/codex-rs/ext/image-generation/src/tool.rs b/codex-rs/ext/image-generation/src/tool.rs index 9c73e96b23cf..41c06c8007c2 100644 --- a/codex-rs/ext/image-generation/src/tool.rs +++ b/codex-rs/ext/image-generation/src/tool.rs @@ -242,6 +242,7 @@ fn edit_images(history: &[ResponseItem]) -> Vec { )); } ResponseItem::Message { .. } + | ResponseItem::AgentMessage { .. } | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } diff --git a/codex-rs/ext/web-search/Cargo.toml b/codex-rs/ext/web-search/Cargo.toml index 0faabf21a86b..954ac3dac9e6 100644 --- a/codex-rs/ext/web-search/Cargo.toml +++ b/codex-rs/ext/web-search/Cargo.toml @@ -17,7 +17,6 @@ async-trait = { workspace = true } codex-api = { workspace = true } codex-core = { workspace = true } codex-extension-api = { workspace = true } -codex-features = { workspace = true } codex-login = { workspace = true } codex-model-provider = { workspace = true } codex-model-provider-info = { workspace = true } diff --git a/codex-rs/ext/web-search/src/extension.rs b/codex-rs/ext/web-search/src/extension.rs index 30fc48961e41..d081d4fb29b9 100644 --- a/codex-rs/ext/web-search/src/extension.rs +++ b/codex-rs/ext/web-search/src/extension.rs @@ -13,7 +13,6 @@ use codex_extension_api::ExtensionRegistryBuilder; use codex_extension_api::ThreadLifecycleContributor; use codex_extension_api::ThreadStartInput; use codex_extension_api::ToolContributor; -use codex_features::Feature; use codex_login::AuthManager; use codex_model_provider::create_model_provider; use codex_model_provider_info::ModelProviderInfo; @@ -29,7 +28,7 @@ struct WebSearchExtension { #[derive(Clone)] struct WebSearchExtensionConfig { - enabled: bool, + available: bool, provider: ModelProviderInfo, settings: SearchSettings, } @@ -38,8 +37,8 @@ impl From<&Config> for WebSearchExtensionConfig { fn from(config: &Config) -> Self { let web_search_mode = config.web_search_mode.value(); Self { - enabled: config.features.enabled(Feature::StandaloneWebSearch) - && config.model_provider.is_openai() + // Core selects this executor per turn using the feature flag or model metadata. + available: config.model_provider.is_openai() && web_search_mode != WebSearchMode::Disabled, provider: config.model_provider.clone(), settings: search_settings(config, web_search_mode), @@ -111,7 +110,7 @@ impl ToolContributor for WebSearchExtension { let Some(config) = thread_store.get::() else { return Vec::new(); }; - if !config.enabled { + if !config.available { return Vec::new(); } @@ -160,7 +159,7 @@ mod tests { let session_store = ExtensionData::new("session"); let thread_store = ExtensionData::new("11111111-1111-4111-8111-111111111111"); thread_store.insert(WebSearchExtensionConfig { - enabled: true, + available: true, provider: ModelProviderInfo::create_openai_provider(/*base_url*/ None), settings: Default::default(), }); diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 3ba973b80427..8ee05aed21d8 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -99,6 +99,8 @@ pub enum Feature { UnifiedExecZshFork, /// Reflow transcript scrollback when the terminal is resized. TerminalResizeReflow, + /// Add terminal-specific visualization guidance to TUI developer instructions. + TerminalVisualizationInstructions, /// Stream structured progress while apply_patch input is being generated. ApplyPatchStreamingEvents, /// Allow exec tools to request additional permissions while staying sandboxed. @@ -1118,6 +1120,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::TerminalVisualizationInstructions, + key: "terminal_visualization_instructions", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::GuardianApproval, key: "guardian_approval", diff --git a/codex-rs/linux-sandbox/src/landlock.rs b/codex-rs/linux-sandbox/src/landlock.rs index 3f95224ab619..65ba3ebf1750 100644 --- a/codex-rs/linux-sandbox/src/landlock.rs +++ b/codex-rs/linux-sandbox/src/landlock.rs @@ -217,10 +217,11 @@ fn install_network_seccomp_filter_on_current_thread( } NetworkSeccompMode::ProxyRouted => { // In proxy-routed mode we allow IP sockets in the isolated - // namespace (used to reach the local TCP bridge) but deny all - // other socket families, including AF_UNIX. This prevents - // bypassing the routed bridge via new Unix sockets and narrows the - // socket surface in proxy-only mode. + // namespace (used to reach the local TCP bridge) but deny socket() + // for all other families, including AF_UNIX. Only AF_UNIX + // socketpair() remains available for process-local IPC because it + // cannot connect to a socket outside the sandbox or bypass the + // bridge. let deny_non_ip_socket = SeccompRule::new(vec![ SeccompCondition::new( 0, @@ -235,14 +236,14 @@ fn install_network_seccomp_filter_on_current_thread( libc::AF_INET6 as u64, )?, ])?; - let deny_unix_socketpair = SeccompRule::new(vec![SeccompCondition::new( + let deny_non_unix_socketpair = SeccompRule::new(vec![SeccompCondition::new( 0, SeccompCmpArgLen::Dword, - SeccompCmpOp::Eq, + SeccompCmpOp::Ne, libc::AF_UNIX as u64, )?])?; rules.insert(libc::SYS_socket, vec![deny_non_ip_socket]); - rules.insert(libc::SYS_socketpair, vec![deny_unix_socketpair]); + rules.insert(libc::SYS_socketpair, vec![deny_non_unix_socketpair]); } } diff --git a/codex-rs/linux-sandbox/src/proxy_routing.rs b/codex-rs/linux-sandbox/src/proxy_routing.rs index d28b8646660c..f171d91745d8 100644 --- a/codex-rs/linux-sandbox/src/proxy_routing.rs +++ b/codex-rs/linux-sandbox/src/proxy_routing.rs @@ -14,6 +14,7 @@ use std::net::SocketAddr; use std::net::TcpListener; use std::net::TcpStream; use std::os::fd::FromRawFd; +use std::os::unix::ffi::OsStrExt; use std::os::unix::fs::DirBuilderExt; use std::os::unix::fs::PermissionsExt; use std::os::unix::net::UnixListener; @@ -43,6 +44,8 @@ const PROXY_ENV_KEYS: &[&str] = &[ const PROXY_SOCKET_DIR_PREFIX: &str = "codex-linux-sandbox-proxy-"; const HOST_BRIDGE_READY: u8 = 1; const LOOPBACK_INTERFACE_NAME: &[u8] = b"lo"; +// Linux sockaddr_un.sun_path allows 108 bytes, including the trailing NUL. +const UNIX_SOCKET_PATH_MAX_BYTES: usize = 107; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub(crate) struct ProxyRouteSpec { @@ -278,8 +281,9 @@ fn rewrite_proxy_env_value(proxy_url: &str, local_port: u16) -> Option { fn create_proxy_socket_dir() -> io::Result { let temp_dir = proxy_socket_parent_dir(); let pid = std::process::id(); + let uid = unsafe { libc::geteuid() }; for attempt in 0..128 { - let candidate = temp_dir.join(format!("{PROXY_SOCKET_DIR_PREFIX}{pid}-{attempt}")); + let candidate = temp_dir.join(format!("{PROXY_SOCKET_DIR_PREFIX}{pid}-{uid}-{attempt}")); // The bridge UDS paths live under a shared temp root, so the per-run // directory should not be traversable by other processes. let mut dir_builder = DirBuilder::new(); @@ -302,11 +306,29 @@ fn create_proxy_socket_dir() -> io::Result { fn proxy_socket_parent_dir() -> PathBuf { if let Some(codex_home) = std::env::var_os("CODEX_HOME") { let candidate = PathBuf::from(codex_home).join("tmp"); - if ensure_private_proxy_socket_parent_dir(candidate.as_path()).is_ok() { + if proxy_socket_paths_fit(candidate.as_path()) + && ensure_private_proxy_socket_parent_dir(candidate.as_path()).is_ok() + { return candidate; } } - std::env::temp_dir() + let temp_dir = std::env::temp_dir(); + if proxy_socket_paths_fit(temp_dir.as_path()) { + temp_dir + } else { + PathBuf::from("/tmp") + } +} + +fn proxy_socket_paths_fit(parent: &Path) -> bool { + let socket_path = parent + .join(format!( + "{PROXY_SOCKET_DIR_PREFIX}{}-{}-127", + u32::MAX, + libc::uid_t::MAX + )) + .join(format!("proxy-route-{}.sock", usize::MAX)); + socket_path.as_os_str().as_bytes().len() <= UNIX_SOCKET_PATH_MAX_BYTES } fn ensure_private_proxy_socket_parent_dir(path: &Path) -> io::Result<()> { @@ -661,6 +683,7 @@ mod tests { use super::parse_loopback_proxy_endpoint; use super::parse_proxy_socket_dir_owner_pid; use super::plan_proxy_routes; + use super::proxy_socket_paths_fit; use super::rewrite_proxy_env_value; use pretty_assertions::assert_eq; use std::collections::HashMap; @@ -735,6 +758,18 @@ mod tests { assert_eq!(default_proxy_port("socks5h"), 1080); } + #[test] + fn proxy_socket_paths_enforce_linux_path_limit() { + assert_eq!( + proxy_socket_paths_fit(PathBuf::from("/tmp").as_path()), + true + ); + assert_eq!( + proxy_socket_paths_fit(PathBuf::from(format!("/tmp/{}", "a".repeat(96))).as_path()), + false + ); + } + #[test] fn cleanup_proxy_socket_dir_removes_bridge_artifacts() { let root = tempfile::tempdir().expect("tempdir should create"); @@ -770,6 +805,10 @@ mod tests { parse_proxy_socket_dir_owner_pid("codex-linux-sandbox-proxy-1234-0"), Some(1234) ); + assert_eq!( + parse_proxy_socket_dir_owner_pid("codex-linux-sandbox-proxy-1234-1000-0"), + Some(1234) + ); assert_eq!( parse_proxy_socket_dir_owner_pid("codex-linux-sandbox-proxy-x"), None diff --git a/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs b/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs index d1aa6856c41a..71ed97150625 100644 --- a/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs +++ b/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs @@ -266,7 +266,7 @@ async fn managed_proxy_mode_routes_through_bridge_and_blocks_direct_egress() { } #[tokio::test] -async fn managed_proxy_mode_denies_af_unix_creation_for_user_command() { +async fn managed_proxy_mode_denies_af_unix_socket_but_allows_socketpair() { if let Some(skip_reason) = managed_proxy_skip_reason().await { eprintln!("skipping managed proxy test: {skip_reason}"); return; @@ -292,7 +292,7 @@ async fn managed_proxy_mode_denies_af_unix_creation_for_user_command() { &[ "python3", "-c", - "import socket,sys\ntry:\n socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)\nexcept PermissionError:\n sys.exit(0)\nexcept OSError:\n sys.exit(2)\nsys.exit(1)\n", + "import socket,sys\ntry:\n socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)\nexcept PermissionError:\n pass\nexcept OSError:\n sys.exit(2)\nelse:\n sys.exit(1)\nleft,right = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM)\nleft.sendall(b'ok')\nif right.recv(2) != b'ok':\n sys.exit(3)\n", ], &PermissionProfile::Disabled, /*allow_network_for_proxy*/ true, @@ -304,7 +304,7 @@ async fn managed_proxy_mode_denies_af_unix_creation_for_user_command() { assert_eq!( output.status.code(), Some(0), - "expected AF_UNIX creation to be denied cleanly for user command; status={:?}; stdout={}; stderr={}", + "expected AF_UNIX socket creation to be denied and socketpair to work; status={:?}; stdout={}; stderr={}", output.status.code(), String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) diff --git a/codex-rs/login/src/auth/access_token.rs b/codex-rs/login/src/auth/access_token.rs new file mode 100644 index 000000000000..5859ab1c252a --- /dev/null +++ b/codex-rs/login/src/auth/access_token.rs @@ -0,0 +1,18 @@ +const PERSONAL_ACCESS_TOKEN_PREFIX: &str = "at-"; + +pub(super) enum CodexAccessToken<'a> { + PersonalAccessToken(&'a str), + AgentIdentityJwt(&'a str), +} + +pub(super) fn classify_codex_access_token(access_token: &str) -> CodexAccessToken<'_> { + if access_token.starts_with(PERSONAL_ACCESS_TOKEN_PREFIX) { + CodexAccessToken::PersonalAccessToken(access_token) + } else { + CodexAccessToken::AgentIdentityJwt(access_token) + } +} + +#[cfg(test)] +#[path = "access_token_tests.rs"] +mod tests; diff --git a/codex-rs/login/src/auth/access_token_tests.rs b/codex-rs/login/src/auth/access_token_tests.rs new file mode 100644 index 000000000000..d734d149d5e8 --- /dev/null +++ b/codex-rs/login/src/auth/access_token_tests.rs @@ -0,0 +1,13 @@ +use super::*; + +#[test] +fn classifies_personal_access_tokens_by_prefix() { + assert!(matches!( + classify_codex_access_token("at-example"), + CodexAccessToken::PersonalAccessToken("at-example") + )); + assert!(matches!( + classify_codex_access_token("header.payload.signature"), + CodexAccessToken::AgentIdentityJwt("header.payload.signature") + )); +} diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index 63ab3e0c424a..e7b5502ebcd6 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -19,6 +19,7 @@ use tempfile::tempdir; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; +use wiremock::matchers::header; use wiremock::matchers::method; use wiremock::matchers::path; @@ -126,6 +127,84 @@ async fn login_with_access_token_writes_only_token() { server.verify().await; } +#[tokio::test] +#[serial(codex_auth_env)] +async fn login_with_access_token_writes_only_personal_access_token() { + let dir = tempdir().unwrap(); + let auth_path = dir.path().join("auth.json"); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/user-auth-credential/whoami")) + .and(header("authorization", "Bearer at-login-test")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(personal_access_token_whoami(WORKSPACE_ID_ALLOWED)), + ) + .expect(1) + .mount(&server) + .await; + let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri()); + super::login_with_access_token( + dir.path(), + "at-login-test", + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await + .expect("personal access token login should succeed"); + + let storage = FileAuthStorage::new(dir.path().to_path_buf()); + let auth = storage + .try_read_auth_json(&auth_path) + .expect("auth.json should parse"); + assert_eq!( + auth, + AuthDotJson { + auth_mode: None, + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: None, + personal_access_token: Some("at-login-test".to_string()), + } + ); + assert_eq!(auth.resolved_mode(), AuthMode::PersonalAccessToken); + let persisted: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(auth_path).unwrap()).unwrap(); + assert!(persisted.get("auth_mode").is_none()); + server.verify().await; +} + +#[tokio::test] +#[serial(codex_auth_env)] +async fn login_with_access_token_rejects_invalid_personal_access_token() { + let dir = tempdir().unwrap(); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/user-auth-credential/whoami")) + .respond_with(ResponseTemplate::new(403)) + .expect(1) + .mount(&server) + .await; + let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri()); + + let err = super::login_with_access_token( + dir.path(), + "at-invalid-login", + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await + .expect_err("invalid personal access token should fail"); + + assert_eq!(err.kind(), std::io::ErrorKind::Other); + assert!( + !get_auth_file(dir.path()).exists(), + "invalid personal access token should not write auth.json" + ); + server.verify().await; +} + #[tokio::test] async fn login_with_access_token_rejects_invalid_jwt() { let dir = tempdir().unwrap(); @@ -245,6 +324,7 @@ async fn pro_account_with_no_api_key_uses_chatgpt_auth() { }), last_refresh: Some(last_refresh), agent_identity: None, + personal_access_token: None, }, auth_dot_json ); @@ -286,6 +366,7 @@ fn logout_removes_auth_file() -> Result<(), std::io::Error> { tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }; super::save_auth(dir.path(), &auth_dot_json, AuthCredentialsStoreMode::File)?; let auth_file = get_auth_file(dir.path()); @@ -762,6 +843,98 @@ async fn load_auth_reads_access_token_from_env() { server.verify().await; } +#[tokio::test] +#[serial(codex_auth_env)] +async fn load_auth_reads_personal_access_token_from_env() { + let codex_home = tempdir().unwrap(); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/user-auth-credential/whoami")) + .and(header("authorization", "Bearer at-env-test")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(personal_access_token_whoami(WORKSPACE_ID_ALLOWED)), + ) + .expect(2) + .mount(&server) + .await; + let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri()); + let _access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, "at-env-test"); + + for auth_credentials_store_mode in [ + AuthCredentialsStoreMode::File, + AuthCredentialsStoreMode::Ephemeral, + ] { + let auth = super::load_auth( + codex_home.path(), + /*enable_codex_api_key_env*/ false, + auth_credentials_store_mode, + /*chatgpt_base_url*/ None, + ) + .await + .expect("env auth should load") + .expect("env auth should be present"); + + assert_eq!(auth.api_auth_mode(), AuthMode::PersonalAccessToken); + assert_eq!( + auth.get_token() + .expect("personal access token should be exposed"), + "at-env-test" + ); + assert_eq!(auth.get_account_id().as_deref(), Some(WORKSPACE_ID_ALLOWED)); + assert_eq!(auth.get_chatgpt_user_id().as_deref(), Some("user-123")); + assert_eq!( + auth.get_account_email().as_deref(), + Some("user@example.com") + ); + assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Business)); + assert!(auth.is_fedramp_account()); + } + assert!( + !get_auth_file(codex_home.path()).exists(), + "env auth should not write auth.json" + ); + server.verify().await; +} + +#[tokio::test] +#[serial(codex_auth_env)] +async fn personal_access_token_does_not_offer_unauthorized_recovery() { + let codex_home = tempdir().unwrap(); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/user-auth-credential/whoami")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(personal_access_token_whoami(WORKSPACE_ID_ALLOWED)), + ) + .expect(1) + .mount(&server) + .await; + let _authapi_guard = EnvVarGuard::set("CODEX_AUTHAPI_BASE_URL", &server.uri()); + let _access_token_guard = + EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, "at-no-unauthorized-recovery"); + let manager = Arc::new( + AuthManager::new( + codex_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await, + ); + + let recovery = manager.unauthorized_recovery(); + + assert!(!recovery.has_next()); + assert_eq!(recovery.unavailable_reason(), "not_refreshable_auth"); + manager + .refresh_token_from_authority() + .await + .expect("personal access tokens do not use OAuth refresh"); + server.verify().await; +} + #[tokio::test] #[serial(codex_auth_env)] async fn load_auth_keeps_codex_api_key_env_precedence() { @@ -938,6 +1111,7 @@ async fn enforce_login_restrictions_logs_out_for_agent_identity_workspace_mismat tokens: None, last_refresh: None, agent_identity: Some(agent_identity), + personal_access_token: None, }, AuthCredentialsStoreMode::File, ) @@ -1091,6 +1265,16 @@ fn test_jwks_body() -> serde_json::Value { }) } +fn personal_access_token_whoami(account_id: &str) -> serde_json::Value { + json!({ + "email": "user@example.com", + "chatgpt_user_id": "user-123", + "chatgpt_account_id": account_id, + "chatgpt_plan_type": "business", + "chatgpt_account_is_fedramp": true, + }) +} + const TEST_AGENT_IDENTITY_RSA_PRIVATE_KEY_PEM: &[u8] = br#"-----BEGIN PRIVATE KEY----- MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDWpAXYypOsYAwO bvBduMk/mxaoYDze0AZSzaSzLuIlcsl2EKDgC3AabhIWXh/qTGEJLOU3VB1e5mO9 diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 22e2c9f3b9e2..9e255a99a3eb 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -24,9 +24,12 @@ use codex_app_server_protocol::AuthMode as ApiAuthMode; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ModelProviderAuthInfo; +use super::access_token::CodexAccessToken; +use super::access_token::classify_codex_access_token; use super::external_bearer::BearerTokenRefresher; use super::revoke::revoke_auth_tokens; pub use crate::auth::agent_identity::AgentIdentityAuth; +pub use crate::auth::personal_access_token::PersonalAccessTokenAuth; pub use crate::auth::storage::AgentIdentityAuthRecord; pub use crate::auth::storage::AuthDotJson; use crate::auth::storage::AuthStorageBackend; @@ -53,11 +56,15 @@ pub enum CodexAuth { Chatgpt(ChatgptAuth), ChatgptAuthTokens(ChatgptAuthTokens), AgentIdentity(AgentIdentityAuth), + PersonalAccessToken(PersonalAccessTokenAuth), } impl PartialEq for CodexAuth { fn eq(&self, other: &Self) -> bool { - self.api_auth_mode() == other.api_auth_mode() + match (self, other) { + (Self::PersonalAccessToken(a), Self::PersonalAccessToken(b)) => a == b, + _ => self.api_auth_mode() == other.api_auth_mode(), + } } } @@ -220,6 +227,14 @@ impl CodexAuth { }; return Self::from_agent_identity_jwt(&agent_identity, chatgpt_base_url).await; } + if auth_mode == ApiAuthMode::PersonalAccessToken { + let Some(personal_access_token) = auth_dot_json.personal_access_token.as_deref() else { + return Err(std::io::Error::other( + "personal access token auth is missing a personal access token.", + )); + }; + return Self::from_personal_access_token(personal_access_token).await; + } let storage_mode = auth_dot_json.storage_mode(auth_credentials_store_mode); let state = ChatgptAuthState { @@ -237,6 +252,9 @@ impl CodexAuth { } ApiAuthMode::ApiKey => unreachable!("api key mode is handled above"), ApiAuthMode::AgentIdentity => unreachable!("agent identity mode is handled above"), + ApiAuthMode::PersonalAccessToken => { + unreachable!("personal access token mode is handled above") + } } } @@ -266,11 +284,18 @@ impl CodexAuth { Ok(Self::AgentIdentity(AgentIdentityAuth::load(record).await?)) } + pub async fn from_personal_access_token(access_token: &str) -> std::io::Result { + Ok(Self::PersonalAccessToken( + PersonalAccessTokenAuth::load(access_token).await?, + )) + } + pub fn auth_mode(&self) -> AuthMode { match self { Self::ApiKey(_) => AuthMode::ApiKey, Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) => AuthMode::Chatgpt, Self::AgentIdentity(_) => AuthMode::AgentIdentity, + Self::PersonalAccessToken(_) => AuthMode::PersonalAccessToken, } } @@ -280,6 +305,7 @@ impl CodexAuth { Self::Chatgpt(_) => ApiAuthMode::Chatgpt, Self::ChatgptAuthTokens(_) => ApiAuthMode::ChatgptAuthTokens, Self::AgentIdentity(_) => ApiAuthMode::AgentIdentity, + Self::PersonalAccessToken(_) => ApiAuthMode::PersonalAccessToken, } } @@ -287,14 +313,21 @@ impl CodexAuth { self.auth_mode() == AuthMode::ApiKey } + pub fn is_personal_access_token_auth(&self) -> bool { + self.auth_mode() == AuthMode::PersonalAccessToken + } + pub fn is_chatgpt_auth(&self) -> bool { - matches!(self, Self::Chatgpt(_) | Self::ChatgptAuthTokens(_)) + self.api_auth_mode().has_chatgpt_account() } pub fn uses_codex_backend(&self) -> bool { matches!( self, - Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) | Self::AgentIdentity(_) + Self::Chatgpt(_) + | Self::ChatgptAuthTokens(_) + | Self::AgentIdentity(_) + | Self::PersonalAccessToken(_) ) } @@ -302,11 +335,18 @@ impl CodexAuth { matches!(self, Self::ChatgptAuthTokens(_)) } + fn supports_unauthorized_recovery(&self) -> bool { + matches!(self, Self::Chatgpt(_) | Self::ChatgptAuthTokens(_)) + } + /// Returns `None` if `auth_mode() != AuthMode::ApiKey`. pub fn api_key(&self) -> Option<&str> { match self { Self::ApiKey(auth) => Some(auth.api_key.as_str()), - Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) | Self::AgentIdentity(_) => None, + Self::Chatgpt(_) + | Self::ChatgptAuthTokens(_) + | Self::AgentIdentity(_) + | Self::PersonalAccessToken(_) => None, } } @@ -334,6 +374,7 @@ impl CodexAuth { Self::AgentIdentity(_) => Err(std::io::Error::other( "agent identity auth does not expose a bearer token", )), + Self::PersonalAccessToken(auth) => Ok(auth.access_token().to_string()), } } @@ -341,6 +382,7 @@ impl CodexAuth { pub fn get_account_id(&self) -> Option { match self { Self::AgentIdentity(auth) => Some(auth.account_id().to_string()), + Self::PersonalAccessToken(auth) => Some(auth.account_id().to_string()), _ => self.get_current_token_data().and_then(|t| t.account_id), } } @@ -349,6 +391,7 @@ impl CodexAuth { pub fn is_fedramp_account(&self) -> bool { match self { Self::AgentIdentity(auth) => auth.is_fedramp_account(), + Self::PersonalAccessToken(auth) => auth.is_fedramp_account(), _ => self .get_current_token_data() .is_some_and(|t| t.id_token.is_fedramp_account()), @@ -359,6 +402,7 @@ impl CodexAuth { pub fn get_account_email(&self) -> Option { match self { Self::AgentIdentity(auth) => Some(auth.email().to_string()), + Self::PersonalAccessToken(auth) => Some(auth.email().to_string()), _ => self.get_current_token_data().and_then(|t| t.id_token.email), } } @@ -367,6 +411,7 @@ impl CodexAuth { pub fn get_chatgpt_user_id(&self) -> Option { match self { Self::AgentIdentity(auth) => Some(auth.chatgpt_user_id().to_string()), + Self::PersonalAccessToken(auth) => Some(auth.chatgpt_user_id().to_string()), _ => self .get_current_token_data() .and_then(|t| t.id_token.chatgpt_user_id), @@ -380,6 +425,9 @@ impl CodexAuth { if let Self::AgentIdentity(auth) = self { return Some(auth.plan_type()); } + if let Self::PersonalAccessToken(auth) = self { + return Some(auth.plan_type()); + } self.get_current_token_data().map(|t| { t.id_token @@ -399,7 +447,7 @@ impl CodexAuth { let state = match self { Self::Chatgpt(auth) => &auth.state, Self::ChatgptAuthTokens(auth) => &auth.state, - Self::ApiKey(_) | Self::AgentIdentity(_) => return None, + Self::ApiKey(_) | Self::AgentIdentity(_) | Self::PersonalAccessToken(_) => return None, }; #[expect(clippy::unwrap_used)] state.auth_dot_json.lock().unwrap().clone() @@ -423,6 +471,7 @@ impl CodexAuth { }), last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, }; let client = create_client(); @@ -539,6 +588,7 @@ pub fn login_with_api_key( tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }; save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode) } @@ -550,17 +600,35 @@ pub async fn login_with_access_token( auth_credentials_store_mode: AuthCredentialsStoreMode, chatgpt_base_url: Option<&str>, ) -> std::io::Result<()> { - let base_url = chatgpt_base_url - .unwrap_or(DEFAULT_CHATGPT_BACKEND_BASE_URL) - .trim_end_matches('/') - .to_string(); - verified_agent_identity_record(access_token, &base_url).await?; - let auth_dot_json = AuthDotJson { - auth_mode: Some(ApiAuthMode::AgentIdentity), - openai_api_key: None, - tokens: None, - last_refresh: None, - agent_identity: Some(access_token.to_string()), + let auth_dot_json = match classify_codex_access_token(access_token) { + CodexAccessToken::PersonalAccessToken(access_token) => { + PersonalAccessTokenAuth::load(access_token).await?; + AuthDotJson { + // Infer PAT auth from the credential field so older Codex builds can still + // deserialize auth.json after a rollback. + auth_mode: None, + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: None, + personal_access_token: Some(access_token.to_string()), + } + } + CodexAccessToken::AgentIdentityJwt(jwt) => { + let base_url = chatgpt_base_url + .unwrap_or(DEFAULT_CHATGPT_BACKEND_BASE_URL) + .trim_end_matches('/') + .to_string(); + verified_agent_identity_record(jwt, &base_url).await?; + AuthDotJson { + auth_mode: Some(ApiAuthMode::AgentIdentity), + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: Some(jwt.to_string()), + personal_access_token: None, + } + } }; save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode) } @@ -633,10 +701,12 @@ pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result< (ForcedLoginMethod::Api, AuthMode::ApiKey) => None, (ForcedLoginMethod::Chatgpt, AuthMode::Chatgpt) | (ForcedLoginMethod::Chatgpt, AuthMode::ChatgptAuthTokens) - | (ForcedLoginMethod::Chatgpt, AuthMode::AgentIdentity) => None, + | (ForcedLoginMethod::Chatgpt, AuthMode::AgentIdentity) + | (ForcedLoginMethod::Chatgpt, AuthMode::PersonalAccessToken) => None, (ForcedLoginMethod::Api, AuthMode::Chatgpt) | (ForcedLoginMethod::Api, AuthMode::ChatgptAuthTokens) - | (ForcedLoginMethod::Api, AuthMode::AgentIdentity) => Some( + | (ForcedLoginMethod::Api, AuthMode::AgentIdentity) + | (ForcedLoginMethod::Api, AuthMode::PersonalAccessToken) => Some( "API key login is required, but ChatGPT is currently being used. Logging out." .to_string(), ), @@ -657,7 +727,7 @@ pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result< if let Some(expected_account_ids) = config.forced_chatgpt_workspace_id.as_deref() { let chatgpt_account_id = match &auth { - CodexAuth::ApiKey(_) => return Ok(()), + CodexAuth::ApiKey(_) | CodexAuth::PersonalAccessToken(_) => return Ok(()), CodexAuth::AgentIdentity(_) => auth.get_account_id(), CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) => { let token_data = match auth.get_token_data() { @@ -758,17 +828,26 @@ async fn load_auth( return Ok(Some(auth)); } + if let Some(access_token) = read_codex_access_token_from_env() { + return match classify_codex_access_token(&access_token) { + CodexAccessToken::PersonalAccessToken(access_token) => { + CodexAuth::from_personal_access_token(access_token) + .await + .map(Some) + } + CodexAccessToken::AgentIdentityJwt(jwt) => { + CodexAuth::from_agent_identity_jwt(jwt, chatgpt_base_url) + .await + .map(Some) + } + }; + } + // If the caller explicitly requested ephemeral auth, there is no persisted fallback. if auth_credentials_store_mode == AuthCredentialsStoreMode::Ephemeral { return Ok(None); } - if let Some(agent_identity) = read_codex_access_token_from_env() { - return CodexAuth::from_agent_identity_jwt(&agent_identity, chatgpt_base_url) - .await - .map(Some); - } - // Fall back to the configured persistent store (file/keyring/auto) for managed auth. let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode); let auth_dot_json = match storage.load()? { @@ -963,6 +1042,7 @@ impl AuthDotJson { tokens: Some(tokens), last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, }) } @@ -983,6 +1063,9 @@ impl AuthDotJson { if let Some(mode) = self.auth_mode { return mode; } + if self.personal_access_token.is_some() { + return ApiAuthMode::PersonalAccessToken; + } if self.openai_api_key.is_some() { return ApiAuthMode::ApiKey; } @@ -1126,7 +1209,7 @@ impl UnauthorizedRecovery { .manager .auth_cached() .as_ref() - .is_some_and(CodexAuth::is_chatgpt_auth) + .is_some_and(CodexAuth::supports_unauthorized_recovery) { return false; } @@ -1147,11 +1230,20 @@ impl UnauthorizedRecovery { }; } + if self + .manager + .auth_cached() + .as_ref() + .is_some_and(CodexAuth::is_personal_access_token_auth) + { + return "not_refreshable_auth"; + } + if !self .manager .auth_cached() .as_ref() - .is_some_and(CodexAuth::is_chatgpt_auth) + .is_some_and(CodexAuth::supports_unauthorized_recovery) { return "not_chatgpt_auth"; } @@ -1497,6 +1589,7 @@ impl AuthManager { } _ => false, }, + (ApiAuthMode::PersonalAccessToken, ApiAuthMode::PersonalAccessToken) => a == b, _ => false, }, _ => false, @@ -1690,7 +1783,7 @@ impl AuthManager { let auth_before_reload = self.auth_cached(); if auth_before_reload .as_ref() - .is_some_and(CodexAuth::is_api_key_auth) + .is_some_and(|auth| auth.is_api_key_auth() || auth.is_personal_access_token_auth()) { return Ok(()); } @@ -1756,7 +1849,9 @@ impl AuthManager { self.refresh_and_persist_chatgpt_token(&chatgpt_auth, token_data.refresh_token) .await } - CodexAuth::ApiKey(_) | CodexAuth::AgentIdentity(_) => Ok(()), + CodexAuth::ApiKey(_) + | CodexAuth::AgentIdentity(_) + | CodexAuth::PersonalAccessToken(_) => Ok(()), }; if let Err(RefreshTokenError::Permanent(error)) = &result { self.record_permanent_refresh_failure_if_unchanged(&attempted_auth, error); @@ -1805,7 +1900,12 @@ impl AuthManager { pub fn current_auth_uses_codex_backend(&self) -> bool { matches!( self.auth_mode(), - Some(AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens | AuthMode::AgentIdentity) + Some( + AuthMode::Chatgpt + | AuthMode::ChatgptAuthTokens + | AuthMode::AgentIdentity + | AuthMode::PersonalAccessToken + ) ) } diff --git a/codex-rs/login/src/auth/mod.rs b/codex-rs/login/src/auth/mod.rs index 167ccd3f75bd..9bf6c350f7fc 100644 --- a/codex-rs/login/src/auth/mod.rs +++ b/codex-rs/login/src/auth/mod.rs @@ -1,6 +1,8 @@ +mod access_token; mod agent_identity; pub mod default_client; pub mod error; +mod personal_access_token; mod storage; mod util; diff --git a/codex-rs/login/src/auth/personal_access_token.rs b/codex-rs/login/src/auth/personal_access_token.rs new file mode 100644 index 000000000000..b99092f51f19 --- /dev/null +++ b/codex-rs/login/src/auth/personal_access_token.rs @@ -0,0 +1,112 @@ +use codex_client::CodexHttpClient; +use codex_protocol::account::PlanType as AccountPlanType; +use codex_protocol::auth::PlanType as InternalPlanType; +use serde::Deserialize; +use std::env; +use std::fmt; + +use crate::default_client::create_client; + +const PROD_AUTHAPI_BASE_URL: &str = "https://auth.openai.com/api/accounts"; +const CODEX_AUTHAPI_BASE_URL_ENV_VAR: &str = "CODEX_AUTHAPI_BASE_URL"; +const WHOAMI_PATH: &str = "/v1/user-auth-credential/whoami"; + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +struct PersonalAccessTokenMetadata { + email: String, + chatgpt_user_id: String, + chatgpt_account_id: String, + chatgpt_plan_type: String, + chatgpt_account_is_fedramp: bool, +} + +#[derive(Clone, PartialEq, Eq)] +pub struct PersonalAccessTokenAuth { + access_token: String, + metadata: PersonalAccessTokenMetadata, +} + +impl fmt::Debug for PersonalAccessTokenAuth { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PersonalAccessTokenAuth") + .field("access_token", &"") + .field("metadata", &self.metadata) + .finish() + } +} + +impl PersonalAccessTokenAuth { + pub(super) async fn load(access_token: &str) -> std::io::Result { + let authapi_base_url = env::var(CODEX_AUTHAPI_BASE_URL_ENV_VAR) + .ok() + .map(|base_url| base_url.trim().trim_end_matches('/').to_string()) + .filter(|base_url| !base_url.is_empty()) + .unwrap_or_else(|| PROD_AUTHAPI_BASE_URL.to_string()); + hydrate_personal_access_token(&create_client(), &authapi_base_url, access_token).await + } + + pub fn access_token(&self) -> &str { + &self.access_token + } + + pub fn account_id(&self) -> &str { + &self.metadata.chatgpt_account_id + } + + pub fn chatgpt_user_id(&self) -> &str { + &self.metadata.chatgpt_user_id + } + + pub fn email(&self) -> &str { + &self.metadata.email + } + + pub fn plan_type(&self) -> AccountPlanType { + InternalPlanType::from_raw_value(&self.metadata.chatgpt_plan_type).into() + } + + pub fn is_fedramp_account(&self) -> bool { + self.metadata.chatgpt_account_is_fedramp + } +} + +async fn hydrate_personal_access_token( + client: &CodexHttpClient, + authapi_base_url: &str, + access_token: &str, +) -> std::io::Result { + let endpoint = format!("{}{WHOAMI_PATH}", authapi_base_url.trim_end_matches('/')); + let response = client + .get(&endpoint) + .bearer_auth(access_token) + .send() + .await + .map_err(|err| { + std::io::Error::other(format!( + "failed to request personal access token metadata: {err}" + )) + })?; + if !response.status().is_success() { + return Err(std::io::Error::other(format!( + "personal access token metadata request failed with status {}", + response.status() + ))); + } + + let metadata = response + .json::() + .await + .map_err(|err| { + std::io::Error::other(format!( + "failed to decode personal access token metadata: {err}" + )) + })?; + Ok(PersonalAccessTokenAuth { + access_token: access_token.to_string(), + metadata, + }) +} + +#[cfg(test)] +#[path = "personal_access_token_tests.rs"] +mod tests; diff --git a/codex-rs/login/src/auth/personal_access_token_tests.rs b/codex-rs/login/src/auth/personal_access_token_tests.rs new file mode 100644 index 000000000000..ac6ee12265eb --- /dev/null +++ b/codex-rs/login/src/auth/personal_access_token_tests.rs @@ -0,0 +1,71 @@ +use super::*; +use pretty_assertions::assert_eq; +use serde_json::json; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; + +fn response(email: Option<&str>) -> serde_json::Value { + json!({ + "email": email, + "chatgpt_user_id": "user-123", + "chatgpt_account_id": "account-123", + "chatgpt_plan_type": "enterprise", + "chatgpt_account_is_fedramp": true, + }) +} + +#[tokio::test] +async fn hydrate_sends_bearer_token_and_preserves_metadata() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(WHOAMI_PATH)) + .and(header("authorization", "Bearer at-example")) + .respond_with(ResponseTemplate::new(200).set_body_json(response(Some("user@example.com")))) + .expect(1) + .mount(&server) + .await; + + let auth = hydrate_personal_access_token(&create_client(), &server.uri(), "at-example") + .await + .expect("personal access token hydration should succeed"); + + assert_eq!( + auth, + PersonalAccessTokenAuth { + access_token: "at-example".to_string(), + metadata: PersonalAccessTokenMetadata { + email: "user@example.com".to_string(), + chatgpt_user_id: "user-123".to_string(), + chatgpt_account_id: "account-123".to_string(), + chatgpt_plan_type: "enterprise".to_string(), + chatgpt_account_is_fedramp: true, + }, + } + ); + server.verify().await; +} + +#[tokio::test] +async fn hydrate_rejects_missing_email() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(WHOAMI_PATH)) + .respond_with(ResponseTemplate::new(200).set_body_json(response(/*email*/ None))) + .expect(1) + .mount(&server) + .await; + + let err = hydrate_personal_access_token(&create_client(), &server.uri(), "at-example") + .await + .expect_err("personal access token hydration should reject missing email"); + + assert!( + err.to_string() + .contains("failed to decode personal access token metadata") + ); + server.verify().await; +} diff --git a/codex-rs/login/src/auth/storage.rs b/codex-rs/login/src/auth/storage.rs index 3a1c8ae6aa53..d0e606a8cc12 100644 --- a/codex-rs/login/src/auth/storage.rs +++ b/codex-rs/login/src/auth/storage.rs @@ -45,6 +45,9 @@ pub struct AuthDotJson { #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_identity: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub personal_access_token: Option, } #[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)] diff --git a/codex-rs/login/src/auth/storage_tests.rs b/codex-rs/login/src/auth/storage_tests.rs index b5646ef53e83..debe0e18d37a 100644 --- a/codex-rs/login/src/auth/storage_tests.rs +++ b/codex-rs/login/src/auth/storage_tests.rs @@ -19,6 +19,7 @@ async fn file_storage_load_returns_auth_dot_json() -> anyhow::Result<()> { tokens: None, last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, }; storage @@ -40,6 +41,7 @@ async fn file_storage_save_persists_auth_dot_json() -> anyhow::Result<()> { tokens: None, last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, }; let file = get_auth_file(codex_home.path()); @@ -73,6 +75,27 @@ async fn file_storage_round_trips_agent_identity_auth() -> anyhow::Result<()> { tokens: None, last_refresh: None, agent_identity: Some(agent_identity), + personal_access_token: None, + }; + + storage.save(&auth_dot_json)?; + + let loaded = storage.load()?; + assert_eq!(Some(auth_dot_json), loaded); + Ok(()) +} + +#[tokio::test] +async fn file_storage_round_trips_personal_access_token_auth() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); + let auth_dot_json = AuthDotJson { + auth_mode: Some(AuthMode::PersonalAccessToken), + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: None, + personal_access_token: Some("at-example".to_string()), }; storage.save(&auth_dot_json)?; @@ -122,6 +145,7 @@ fn file_storage_delete_removes_auth_file() -> anyhow::Result<()> { tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }; let storage = create_auth_storage(dir.path().to_path_buf(), AuthCredentialsStoreMode::File); storage.save(&auth_dot_json)?; @@ -146,6 +170,7 @@ fn ephemeral_storage_save_load_delete_is_in_memory_only() -> anyhow::Result<()> tokens: None, last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, }; storage.save(&auth_dot_json)?; @@ -245,6 +270,7 @@ fn auth_with_prefix(prefix: &str) -> AuthDotJson { }), last_refresh: None, agent_identity: None, + personal_access_token: None, } } @@ -270,6 +296,7 @@ fn keyring_auth_storage_load_returns_deserialized_auth() -> anyhow::Result<()> { tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }; seed_keyring_with_auth( &mock_keyring, @@ -313,6 +340,7 @@ fn keyring_auth_storage_save_persists_and_removes_fallback_file() -> anyhow::Res }), last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, }; storage.save(&auth)?; diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index b72bc946f279..5a1833b58c57 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -822,6 +822,7 @@ pub(crate) async fn persist_tokens_async( tokens: Some(tokens), last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, }; save_auth(&codex_home, &auth, auth_credentials_store_mode)?; Ok::<_, io::Error>((previous_auth, auth)) @@ -940,6 +941,20 @@ pub(crate) fn ensure_workspace_allowed( return Err("Login is restricted to a specific workspace, but the token did not include an chatgpt_account_id claim.".to_string()); }; + ensure_workspace_account_allowed(Some(expected), actual) +} + +/// Validates an already known ChatGPT account ID against an optional workspace restriction. +/// +/// PAT login calls this directly because `/whoami` supplies the account ID without an ID token. +pub(crate) fn ensure_workspace_account_allowed( + expected: Option<&[String]>, + actual: &str, +) -> Result<(), String> { + let Some(expected) = expected else { + return Ok(()); + }; + if expected.iter().any(|workspace_id| workspace_id == actual) { Ok(()) } else { @@ -1311,6 +1326,7 @@ mod tests { }), last_refresh: None, agent_identity: None, + personal_access_token: None, } } diff --git a/codex-rs/login/tests/suite/auth_refresh.rs b/codex-rs/login/tests/suite/auth_refresh.rs index 83897a0d38bd..b30d738cd4f4 100644 --- a/codex-rs/login/tests/suite/auth_refresh.rs +++ b/codex-rs/login/tests/suite/auth_refresh.rs @@ -55,6 +55,7 @@ async fn refresh_token_succeeds_updates_storage() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -119,6 +120,7 @@ async fn refresh_token_refreshes_when_auth_is_unchanged() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -184,6 +186,7 @@ async fn auth_refreshes_when_access_token_is_near_expiry() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -234,6 +237,7 @@ async fn auth_skips_access_token_outside_refresh_window() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -270,6 +274,7 @@ async fn refresh_token_skips_refresh_when_auth_changed() -> Result<()> { tokens: Some(initial_tokens), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -280,6 +285,7 @@ async fn refresh_token_skips_refresh_when_auth_changed() -> Result<()> { tokens: Some(disk_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; save_auth( ctx.codex_home.path(), @@ -335,6 +341,7 @@ async fn refresh_token_errors_on_account_mismatch() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -346,6 +353,7 @@ async fn refresh_token_errors_on_account_mismatch() -> Result<()> { tokens: Some(disk_tokens), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; save_auth( ctx.codex_home.path(), @@ -405,6 +413,7 @@ async fn returns_fresh_tokens_as_is() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(stale_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -453,6 +462,7 @@ async fn refreshes_token_when_access_token_is_expired() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(fresh_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -503,6 +513,7 @@ async fn auth_reloads_disk_auth_when_cached_auth_is_stale() -> Result<()> { tokens: Some(initial_tokens), last_refresh: Some(stale_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -514,6 +525,7 @@ async fn auth_reloads_disk_auth_when_cached_auth_is_stale() -> Result<()> { tokens: Some(disk_tokens.clone()), last_refresh: Some(fresh_refresh), agent_identity: None, + personal_access_token: None, }; save_auth( ctx.codex_home.path(), @@ -566,6 +578,7 @@ async fn auth_reloads_disk_auth_without_calling_expired_refresh_token() -> Resul tokens: Some(initial_tokens), last_refresh: Some(stale_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -577,6 +590,7 @@ async fn auth_reloads_disk_auth_without_calling_expired_refresh_token() -> Resul tokens: Some(disk_tokens.clone()), last_refresh: Some(fresh_refresh), agent_identity: None, + personal_access_token: None, }; save_auth( ctx.codex_home.path(), @@ -627,6 +641,7 @@ async fn refresh_token_returns_permanent_error_for_expired_refresh_token() -> Re tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -680,6 +695,7 @@ async fn refresh_token_does_not_retry_after_permanent_failure() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -747,6 +763,7 @@ async fn refresh_token_does_not_retry_after_bad_request_reused_failure() -> Resu tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -814,6 +831,7 @@ async fn refresh_token_reloads_changed_auth_after_permanent_failure() -> Result< tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -836,6 +854,7 @@ async fn refresh_token_reloads_changed_auth_after_permanent_failure() -> Result< tokens: Some(disk_tokens.clone()), last_refresh: Some(fresh_refresh), agent_identity: None, + personal_access_token: None, }; save_auth( ctx.codex_home.path(), @@ -895,6 +914,7 @@ async fn refresh_token_returns_transient_error_on_server_failure() -> Result<()> tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -948,6 +968,7 @@ async fn unauthorized_recovery_reloads_then_refreshes_tokens() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -958,6 +979,7 @@ async fn unauthorized_recovery_reloads_then_refreshes_tokens() -> Result<()> { tokens: Some(disk_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; save_auth( ctx.codex_home.path(), @@ -1042,6 +1064,7 @@ async fn unauthorized_recovery_errors_on_account_mismatch() -> Result<()> { tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&initial_auth).await?; @@ -1053,6 +1076,7 @@ async fn unauthorized_recovery_errors_on_account_mismatch() -> Result<()> { tokens: Some(disk_tokens), last_refresh: Some(initial_last_refresh), agent_identity: None, + personal_access_token: None, }; save_auth( ctx.codex_home.path(), @@ -1111,6 +1135,7 @@ async fn unauthorized_recovery_requires_chatgpt_auth() -> Result<()> { tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }; ctx.write_auth(&auth).await?; diff --git a/codex-rs/login/tests/suite/logout.rs b/codex-rs/login/tests/suite/logout.rs index 2364ee7f54a5..2dd1bef83fdb 100644 --- a/codex-rs/login/tests/suite/logout.rs +++ b/codex-rs/login/tests/suite/logout.rs @@ -195,6 +195,7 @@ fn chatgpt_auth_with_refresh_token(refresh_token: &str) -> AuthDotJson { }), last_refresh: None, agent_identity: None, + personal_access_token: None, } } diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 05b995e72157..fd56805d4ee0 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -266,7 +266,8 @@ async fn run_codex_tool_session_inner( } EventMsg::Warning(_) | EventMsg::GuardianWarning(_) - | EventMsg::ModelVerification(_) => { + | EventMsg::ModelVerification(_) + | EventMsg::TurnModerationMetadata(_) => { continue; } EventMsg::GuardianAssessment(_) => { diff --git a/codex-rs/model-provider-info/src/lib.rs b/codex-rs/model-provider-info/src/lib.rs index c20a51a3aa6a..5f0f840508f5 100644 --- a/codex-rs/model-provider-info/src/lib.rs +++ b/codex-rs/model-provider-info/src/lib.rs @@ -237,7 +237,12 @@ impl ModelProviderInfo { pub fn to_api_provider(&self, auth_mode: Option) -> CodexResult { let default_base_url = if matches!( auth_mode, - Some(AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens | AuthMode::AgentIdentity) + Some( + AuthMode::Chatgpt + | AuthMode::ChatgptAuthTokens + | AuthMode::AgentIdentity + | AuthMode::PersonalAccessToken + ) ) { CHATGPT_CODEX_BASE_URL } else { diff --git a/codex-rs/model-provider-info/src/model_provider_info_tests.rs b/codex-rs/model-provider-info/src/model_provider_info_tests.rs index abfa40a36a49..a303050dc137 100644 --- a/codex-rs/model-provider-info/src/model_provider_info_tests.rs +++ b/codex-rs/model-provider-info/src/model_provider_info_tests.rs @@ -139,6 +139,15 @@ fn test_supports_remote_compaction_for_openai() { assert!(provider.supports_remote_compaction()); } +#[test] +fn test_personal_access_token_uses_chatgpt_codex_base_url() { + let api_provider = ModelProviderInfo::create_openai_provider(/*base_url*/ None) + .to_api_provider(Some(AuthMode::PersonalAccessToken)) + .expect("OpenAI provider should build API provider"); + + assert_eq!(api_provider.base_url, CHATGPT_CODEX_BASE_URL); +} + #[test] fn test_supports_remote_compaction_for_azure_name() { let provider = ModelProviderInfo { diff --git a/codex-rs/model-provider/src/auth.rs b/codex-rs/model-provider/src/auth.rs index 3c7f4dbd0fc2..4f87d46baec9 100644 --- a/codex-rs/model-provider/src/auth.rs +++ b/codex-rs/model-provider/src/auth.rs @@ -109,13 +109,14 @@ pub fn auth_provider_from_auth(auth: &CodexAuth) -> SharedAuthProvider { CodexAuth::AgentIdentity(auth) => { Arc::new(AgentIdentityAuthProvider { auth: auth.clone() }) } - CodexAuth::ApiKey(_) | CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) => { - Arc::new(BearerAuthProvider { - token: auth.get_token().ok(), - account_id: auth.get_account_id(), - is_fedramp_account: auth.is_fedramp_account(), - }) - } + CodexAuth::ApiKey(_) + | CodexAuth::Chatgpt(_) + | CodexAuth::ChatgptAuthTokens(_) + | CodexAuth::PersonalAccessToken(_) => Arc::new(BearerAuthProvider { + token: auth.get_token().ok(), + account_id: auth.get_account_id(), + is_fedramp_account: auth.is_fedramp_account(), + }), } } diff --git a/codex-rs/model-provider/src/provider.rs b/codex-rs/model-provider/src/provider.rs index e2ef3f3650e2..afd07065d4ec 100644 --- a/codex-rs/model-provider/src/provider.rs +++ b/codex-rs/model-provider/src/provider.rs @@ -212,7 +212,8 @@ impl ModelProvider for ConfiguredModelProvider { CodexAuth::ApiKey(_) => Ok(ProviderAccount::ApiKey), CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) - | CodexAuth::AgentIdentity(_) => { + | CodexAuth::AgentIdentity(_) + | CodexAuth::PersonalAccessToken(_) => { let email = auth.get_account_email(); let plan_type = auth.account_plan_type(); @@ -453,6 +454,21 @@ mod tests { ); } + #[test] + fn openai_provider_rejects_chatgpt_account_state_without_email() { + let provider = create_model_provider( + ModelProviderInfo::create_openai_provider(/*base_url*/ None), + Some(AuthManager::from_auth_for_testing( + CodexAuth::create_dummy_chatgpt_auth_for_testing(), + )), + ); + + assert_eq!( + provider.account_state(), + Err(ProviderAccountError::MissingChatgptAccountDetails) + ); + } + #[test] fn custom_non_openai_provider_returns_no_account_state() { let provider = create_model_provider( diff --git a/codex-rs/models-manager/src/manager.rs b/codex-rs/models-manager/src/manager.rs index ac4044ac202a..b21911a4d68d 100644 --- a/codex-rs/models-manager/src/manager.rs +++ b/codex-rs/models-manager/src/manager.rs @@ -328,10 +328,9 @@ impl OpenAiModelsManager { .iter() .any(|model| model.visibility == ModelVisibility::List) && self.auth_manager.as_ref().is_some_and(|auth_manager| { - matches!( - auth_manager.auth_mode(), - Some(AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens) - ) + auth_manager + .auth_mode() + .is_some_and(AuthMode::has_chatgpt_account) }); if should_use_remote_models_only { *self.remote_models.write().await = models; diff --git a/codex-rs/models-manager/src/manager_tests.rs b/codex-rs/models-manager/src/manager_tests.rs index ede7cfe79580..2e39ef3160d0 100644 --- a/codex-rs/models-manager/src/manager_tests.rs +++ b/codex-rs/models-manager/src/manager_tests.rs @@ -211,6 +211,7 @@ c2ln", }), last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, }; std::fs::create_dir_all(codex_home).expect("codex home should be created"); std::fs::write( diff --git a/codex-rs/models-manager/src/model_info.rs b/codex-rs/models-manager/src/model_info.rs index 58137a3f5002..81a5c6e5edcd 100644 --- a/codex-rs/models-manager/src/model_info.rs +++ b/codex-rs/models-manager/src/model_info.rs @@ -99,6 +99,7 @@ pub fn model_info_from_slug(slug: &str) -> ModelInfo { input_modalities: default_input_modalities(), used_fallback_model_metadata: true, // this is the fallback model metadata supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index f4534a0791af..00c13046dd81 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -998,6 +998,37 @@ impl SessionTelemetry { ); } + pub fn sandbox_outcome( + &self, + tool_name: &str, + call_id: &str, + outcome: &str, + initial_duration: Duration, + escalated_duration: Option, + ) { + let initial_duration_ms = initial_duration.as_millis().min(i64::MAX as u128) as i64; + let escalated_duration_ms = + escalated_duration.map(|duration| duration.as_millis().min(i64::MAX as u128) as i64); + log_event!( + self, + event.name = "codex.sandbox_outcome", + tool_name = %tool_name, + call_id = %call_id, + outcome = %outcome, + initial_duration_ms = initial_duration_ms, + escalated_duration_ms = escalated_duration_ms, + ); + trace_event!( + self, + event.name = "codex.sandbox_outcome", + tool_name = %tool_name, + call_id = %call_id, + outcome = %outcome, + initial_duration_ms = initial_duration_ms, + escalated_duration_ms = escalated_duration_ms, + ); + } + #[allow(clippy::too_many_arguments)] pub async fn log_tool_result_with_tags( &self, @@ -1177,6 +1208,7 @@ impl SessionTelemetry { } ResponseEvent::ServerModel(_) => "server_model".into(), ResponseEvent::ModelVerifications(_) => "model_verifications".into(), + ResponseEvent::TurnModerationMetadata(_) => "turn_moderation_metadata".into(), ResponseEvent::ServerReasoningIncluded(_) => "server_reasoning_included".into(), ResponseEvent::RateLimits(_) => "rate_limits".into(), ResponseEvent::ModelsEtag(_) => "models_etag".into(), @@ -1186,6 +1218,7 @@ impl SessionTelemetry { fn responses_item_type(item: &ResponseItem) -> String { match item { ResponseItem::Message { role, .. } => format!("message_from_{role}"), + ResponseItem::AgentMessage { .. } => "agent_message".into(), ResponseItem::Reasoning { .. } => "reasoning".into(), ResponseItem::LocalShellCall { .. } => "local_shell_call".into(), ResponseItem::FunctionCall { .. } => "function_call".into(), diff --git a/codex-rs/otel/src/lib.rs b/codex-rs/otel/src/lib.rs index 1a689d3684f4..586dfa15a7de 100644 --- a/codex-rs/otel/src/lib.rs +++ b/codex-rs/otel/src/lib.rs @@ -57,7 +57,8 @@ impl From for TelemetryAuthMode { codex_app_server_protocol::AuthMode::ApiKey => Self::ApiKey, codex_app_server_protocol::AuthMode::Chatgpt | codex_app_server_protocol::AuthMode::ChatgptAuthTokens - | codex_app_server_protocol::AuthMode::AgentIdentity => Self::Chatgpt, + | codex_app_server_protocol::AuthMode::AgentIdentity + | codex_app_server_protocol::AuthMode::PersonalAccessToken => Self::Chatgpt, } } } diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index d95370c0614b..517c33cac982 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -715,6 +715,12 @@ pub enum ContentItem { }, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum AgentMessageInputContent { + EncryptedContent { encrypted_content: String }, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "lowercase")] pub enum ImageDetail { @@ -758,6 +764,11 @@ pub enum ResponseItem { #[ts(optional)] phase: Option, }, + AgentMessage { + author: String, + recipient: String, + content: Vec, + }, Reasoning { #[serde(default, skip_serializing)] #[ts(skip)] diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 4c04edbb5662..1553ef3435ee 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -404,6 +404,8 @@ pub struct ModelInfo { pub used_fallback_model_metadata: bool, #[serde(default)] pub supports_search_tool: bool, + #[serde(default)] + pub use_responses_lite: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub auto_review_model_override: Option, #[serde( @@ -674,6 +676,7 @@ mod tests { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, @@ -936,6 +939,7 @@ mod tests { assert!(!model.supports_image_detail_original); assert_eq!(model.web_search_tool_type, WebSearchToolType::Text); assert!(!model.supports_search_tool); + assert!(!model.use_responses_lite); assert_eq!(model.auto_review_model_override, None); assert_eq!(model.tool_mode, None); } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 91ef624fa7ab..c9e2e8bb286a 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -33,6 +33,7 @@ use crate::mcp::CallToolResult; use crate::mcp::RequestId; use crate::memory_citation::MemoryCitation; use crate::models::ActivePermissionProfile; +use crate::models::AgentMessageInputContent; use crate::models::BaseInstructions; use crate::models::ContentItem; use crate::models::ImageDetail; @@ -105,7 +106,7 @@ pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)] +#[derive(Debug, Clone, PartialEq)] pub struct TurnEnvironmentSelection { pub environment_id: String, pub cwd: AbsolutePathBuf, @@ -123,17 +124,15 @@ impl GitSha { } /// Submission Queue Entry - requests from user -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +#[derive(Debug, Clone)] pub struct Submission { /// Unique id for this Submission to correlate with Events pub id: String, /// Payload pub op: Op, /// Client-provided id for the user message represented by `Op::UserInput`. - #[serde(default, skip_serializing_if = "Option::is_none")] pub client_user_message_id: Option, /// Optional W3C trace carrier propagated across async submission handoffs. - #[serde(default, skip_serializing_if = "Option::is_none")] pub trace: Option, } @@ -148,34 +147,23 @@ pub struct W3cTraceContext { } /// Config payload for refreshing MCP servers. -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)] +#[derive(Debug, Clone, PartialEq)] pub struct McpServerRefreshConfig { pub mcp_servers: Value, pub mcp_oauth_credentials_store_mode: Value, } -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] +#[derive(Debug, Clone, PartialEq)] pub struct ConversationStartParams { /// Selects whether the realtime session should produce text or audio output. pub output_modality: RealtimeOutputModality, - #[serde( - default, - deserialize_with = "conversation_start_prompt_serde::deserialize", - serialize_with = "conversation_start_prompt_serde::serialize", - skip_serializing_if = "Option::is_none" - )] pub prompt: Option>, - #[serde(skip_serializing_if = "Option::is_none")] pub realtime_session_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub transport: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub voice: Option, } -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "snake_case")] -#[ts(tag = "type")] +#[derive(Debug, Clone, PartialEq)] pub enum ConversationStartTransport { Websocket, Webrtc { sdp: String }, @@ -188,28 +176,6 @@ pub enum RealtimeOutputModality { Audio, } -mod conversation_start_prompt_serde { - use serde::Deserializer; - use serde::Serializer; - - pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result>, D::Error> - where - D: Deserializer<'de>, - { - serde_with::rust::double_option::deserialize(deserializer) - } - - pub(crate) fn serialize( - value: &Option>, - serializer: S, - ) -> Result - where - S: Serializer, - { - serde_with::rust::double_option::serialize(value, serializer) - } -} - #[derive( Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, JsonSchema, TS, Ord, PartialOrd, )] @@ -390,109 +356,92 @@ pub enum RealtimeEvent { Error(String), } -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] +#[derive(Debug, Clone, PartialEq)] pub struct ConversationAudioParams { pub frame: RealtimeAudioFrame, } -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] +#[derive(Debug, Clone, PartialEq)] pub struct ConversationTextParams { pub text: String, } /// Persistent thread-settings overrides that can be applied before user input or /// on their own. -#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, JsonSchema)] +#[derive(Debug, Clone, Default, PartialEq)] pub struct ThreadSettingsOverrides { /// Updated `cwd` for sandbox/tool calls. - #[serde(skip_serializing_if = "Option::is_none")] - pub cwd: Option, + pub cwd: Option, /// Updated runtime workspace roots used to materialize symbolic /// `:workspace_roots` filesystem permissions. - #[serde(skip_serializing_if = "Option::is_none")] pub workspace_roots: Option>, /// Updated profile-defined workspace roots for status summaries and /// per-turn config reconstruction. - #[serde(skip_serializing_if = "Option::is_none")] pub profile_workspace_roots: Option>, /// Updated command approval policy. - #[serde(skip_serializing_if = "Option::is_none")] pub approval_policy: Option, /// Updated approval reviewer for future approval prompts. - #[serde(skip_serializing_if = "Option::is_none")] pub approvals_reviewer: Option, /// Updated sandbox policy for tool calls. - #[serde(skip_serializing_if = "Option::is_none")] pub sandbox_policy: Option, /// Updated permissions profile for tool calls. - #[serde(skip_serializing_if = "Option::is_none")] pub permission_profile: Option, /// Named or built-in profile that produced `permission_profile`, if the /// update selected a profile rather than supplying raw permissions. - #[serde(skip_serializing_if = "Option::is_none")] pub active_permission_profile: Option, /// Updated Windows sandbox mode for tool execution. - #[serde(skip_serializing_if = "Option::is_none")] pub windows_sandbox_level: Option, /// Updated model slug. When set, the model info is derived automatically. - #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, /// Updated reasoning effort (honored only for reasoning-capable models). /// /// Use `Some(Some(_))` to set a specific effort, `Some(None)` to clear the /// effort, or `None` to leave the existing value unchanged. - #[serde(skip_serializing_if = "Option::is_none")] pub effort: Option>, /// Updated reasoning summary preference (honored only for reasoning-capable models). - #[serde(skip_serializing_if = "Option::is_none")] pub summary: Option, /// Updated service tier preference for future turns. /// /// 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")] pub service_tier: Option>, /// EXPERIMENTAL - set a pre-set collaboration mode. /// Takes precedence over model, effort, and developer instructions if set. - #[serde(skip_serializing_if = "Option::is_none")] pub collaboration_mode: Option, /// Updated personality preference. - #[serde(skip_serializing_if = "Option::is_none")] pub personality: Option, } /// Source classification for client-supplied context. -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema)] -#[serde(rename_all = "snake_case")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AdditionalContextKind { Untrusted, Application, } /// Client-supplied context keyed by an opaque source identifier. -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct AdditionalContextEntry { pub value: String, pub kind: AdditionalContextKind, } /// Submission operation -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)] -#[serde(tag = "type", rename_all = "snake_case")] +#[derive(Debug, Clone, PartialEq)] #[allow(clippy::large_enum_variant)] #[non_exhaustive] pub enum Op { @@ -524,20 +473,15 @@ pub enum Op { /// User input items, see `InputItem` items: Vec, /// Optional turn-scoped environments. - #[serde(default, skip_serializing_if = "Option::is_none")] environments: Option>, /// Optional JSON Schema used to constrain the final assistant message for this turn. - #[serde(skip_serializing_if = "Option::is_none")] final_output_json_schema: Option, /// Optional turn-scoped Responses API `client_metadata`. - #[serde(default, skip_serializing_if = "Option::is_none")] responsesapi_client_metadata: Option>, /// Client-supplied context fragments keyed by an opaque source identifier. - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] additional_context: BTreeMap, /// Persistent thread-settings overrides to apply before the input. - #[serde(default, flatten)] thread_settings: ThreadSettingsOverrides, }, @@ -547,7 +491,6 @@ pub enum Op { /// preserve caller order between both kinds of mutation. ThreadSettings { /// Persistent thread-settings overrides to apply. - #[serde(flatten)] thread_settings: ThreadSettingsOverrides, }, @@ -562,7 +505,6 @@ pub enum Op { /// The id of the submission we are approving id: String, /// Turn id associated with the approval event, when available. - #[serde(default, skip_serializing_if = "Option::is_none")] turn_id: Option, /// The user's decision in response to the request. decision: ReviewDecision, @@ -585,15 +527,12 @@ pub enum Op { /// User's decision for the request. decision: ElicitationAction, /// Structured user input supplied for accepted elicitations. - #[serde(default, skip_serializing_if = "Option::is_none")] content: Option, /// Optional client metadata associated with the elicitation response. - #[serde(default, skip_serializing_if = "Option::is_none")] meta: Option, }, /// Resolve a request_user_input tool call. - #[serde(rename = "user_input_answer", alias = "request_user_input_response")] UserInputAnswer { /// Turn id for the in-flight request. id: String, @@ -690,6 +629,9 @@ pub struct InterAgentCommunication { #[serde(default)] pub other_recipients: Vec, pub content: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub encrypted_content: Option, pub trigger_turn: bool, } @@ -706,6 +648,24 @@ impl InterAgentCommunication { recipient, other_recipients, content, + encrypted_content: None, + trigger_turn, + } + } + + pub fn new_encrypted( + author: AgentPath, + recipient: AgentPath, + other_recipients: Vec, + encrypted_content: String, + trigger_turn: bool, + ) -> Self { + Self { + author, + recipient, + other_recipients, + content: String::new(), + encrypted_content: Some(encrypted_content), trigger_turn, } } @@ -720,6 +680,19 @@ impl InterAgentCommunication { } } + pub fn to_model_input_item(&self) -> ResponseItem { + match &self.encrypted_content { + Some(encrypted_content) => ResponseItem::AgentMessage { + author: self.author.to_string(), + recipient: self.recipient.to_string(), + content: vec![AgentMessageInputContent::EncryptedContent { + encrypted_content: encrypted_content.clone(), + }], + }, + None => self.to_response_input_item().into(), + } + } + pub fn is_message_content(content: &[ContentItem]) -> bool { Self::from_message_content(content).is_some() } @@ -1186,6 +1159,9 @@ pub enum EventMsg { /// Backend recommends additional account verification for this turn. ModelVerification(ModelVerificationEvent), + /// Backend moderation metadata intended for first-party turn presentation. + TurnModerationMetadata(TurnModerationMetadataEvent), + /// Conversation history was compacted (either automatically or manually). ContextCompacted(ContextCompactedEvent), @@ -1850,6 +1826,11 @@ pub struct ModelVerificationEvent { pub verifications: Vec, } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] +pub struct TurnModerationMetadataEvent { + pub metadata: Value, +} + #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ContextCompactedEvent; @@ -4063,6 +4044,7 @@ mod tests { recipient: AgentPath::root().join("reviewer").expect("recipient path"), other_recipients: vec![AgentPath::root().join("worker").expect("recipient path")], content: "review the diff".to_string(), + encrypted_content: None, trigger_turn: true, }; @@ -4906,146 +4888,6 @@ mod tests { assert!(event.affects_turn_status()); } - #[test] - fn conversation_op_serializes_as_unnested_variants() { - let audio = Op::RealtimeConversationAudio(ConversationAudioParams { - frame: RealtimeAudioFrame { - data: "AQID".to_string(), - sample_rate: 24_000, - num_channels: 1, - samples_per_channel: Some(480), - item_id: None, - }, - }); - let start = Op::RealtimeConversationStart(ConversationStartParams { - output_modality: RealtimeOutputModality::Audio, - prompt: Some(Some("be helpful".to_string())), - realtime_session_id: Some("conv_1".to_string()), - transport: None, - voice: None, - }); - let webrtc_start = Op::RealtimeConversationStart(ConversationStartParams { - output_modality: RealtimeOutputModality::Audio, - prompt: Some(Some("be helpful".to_string())), - realtime_session_id: Some("conv_1".to_string()), - transport: Some(ConversationStartTransport::Webrtc { - sdp: "v=offer\r\n".to_string(), - }), - voice: Some(RealtimeVoice::Cove), - }); - let text = Op::RealtimeConversationText(ConversationTextParams { - text: "hello".to_string(), - }); - let close = Op::RealtimeConversationClose; - let default_prompt_start = Op::RealtimeConversationStart(ConversationStartParams { - output_modality: RealtimeOutputModality::Audio, - prompt: None, - realtime_session_id: None, - transport: None, - voice: None, - }); - let null_prompt_start = Op::RealtimeConversationStart(ConversationStartParams { - output_modality: RealtimeOutputModality::Audio, - prompt: Some(None), - realtime_session_id: None, - transport: None, - voice: None, - }); - let list_voices = Op::RealtimeConversationListVoices; - - assert_eq!( - serde_json::to_value(&start).unwrap(), - json!({ - "type": "realtime_conversation_start", - "output_modality": "audio", - "prompt": "be helpful", - "realtime_session_id": "conv_1" - }) - ); - assert_eq!( - serde_json::to_value(&default_prompt_start).unwrap(), - json!({ - "type": "realtime_conversation_start", - "output_modality": "audio" - }) - ); - assert_eq!( - serde_json::to_value(&null_prompt_start).unwrap(), - json!({ - "type": "realtime_conversation_start", - "output_modality": "audio", - "prompt": null - }) - ); - assert_eq!( - serde_json::from_value::(json!({ - "type": "realtime_conversation_start", - "output_modality": "audio" - })) - .unwrap(), - default_prompt_start - ); - assert_eq!( - serde_json::from_value::(json!({ - "type": "realtime_conversation_start", - "output_modality": "audio", - "prompt": null - })) - .unwrap(), - null_prompt_start - ); - assert_eq!( - serde_json::to_value(&audio).unwrap(), - json!({ - "type": "realtime_conversation_audio", - "frame": { - "data": "AQID", - "sample_rate": 24000, - "num_channels": 1, - "samples_per_channel": 480 - } - }) - ); - assert_eq!( - serde_json::from_value::(serde_json::to_value(&text).unwrap()).unwrap(), - text - ); - assert_eq!( - serde_json::to_value(&close).unwrap(), - json!({ - "type": "realtime_conversation_close" - }) - ); - assert_eq!( - serde_json::from_value::(serde_json::to_value(&close).unwrap()).unwrap(), - close - ); - assert_eq!( - serde_json::to_value(&list_voices).unwrap(), - json!({ - "type": "realtime_conversation_list_voices" - }) - ); - assert_eq!( - serde_json::from_value::(serde_json::to_value(&list_voices).unwrap()).unwrap(), - list_voices - ); - assert_eq!( - serde_json::to_value(&webrtc_start).unwrap(), - json!({ - "type": "realtime_conversation_start", - "output_modality": "audio", - "prompt": "be helpful", - "realtime_session_id": "conv_1", - "transport": { - "type": "webrtc", - "sdp": "v=offer\r\n" - }, - "voice": "cove" - }) - ); - } - #[test] fn realtime_conversation_started_event_uses_realtime_session_id() { let event = RealtimeConversationStartedEvent { @@ -5096,104 +4938,6 @@ mod tests { ); } - #[test] - fn user_input_serialization_omits_final_output_json_schema_when_none() -> Result<()> { - let op = Op::UserInput { - environments: None, - items: Vec::new(), - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }; - - let json_op = serde_json::to_value(op)?; - assert_eq!(json_op, json!({ "type": "user_input", "items": [] })); - - Ok(()) - } - - #[test] - fn user_input_deserializes_without_final_output_json_schema_field() -> Result<()> { - let op: Op = serde_json::from_value(json!({ "type": "user_input", "items": [] }))?; - - assert_eq!( - op, - Op::UserInput { - environments: None, - items: Vec::new(), - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - } - ); - - Ok(()) - } - - #[test] - fn user_input_serialization_includes_final_output_json_schema_when_some() -> Result<()> { - let schema = json!({ - "type": "object", - "properties": { - "answer": { "type": "string" } - }, - "required": ["answer"], - "additionalProperties": false - }); - let op = Op::UserInput { - environments: None, - items: Vec::new(), - final_output_json_schema: Some(schema.clone()), - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: Default::default(), - }; - - let json_op = serde_json::to_value(op)?; - assert_eq!( - json_op, - json!({ - "type": "user_input", - "items": [], - "final_output_json_schema": schema, - }) - ); - - Ok(()) - } - - #[test] - fn user_input_with_responsesapi_client_metadata_round_trips() -> Result<()> { - let op = Op::UserInput { - environments: None, - items: Vec::new(), - final_output_json_schema: None, - responsesapi_client_metadata: Some(HashMap::from([( - "fiber_run_id".to_string(), - "fiber-123".to_string(), - )])), - additional_context: Default::default(), - thread_settings: Default::default(), - }; - - let json_op = serde_json::to_value(&op)?; - assert_eq!( - json_op, - json!({ - "type": "user_input", - "items": [], - "responsesapi_client_metadata": { - "fiber_run_id": "fiber-123", - } - }) - ); - assert_eq!(serde_json::from_value::(json_op)?, op); - - Ok(()) - } - #[test] fn user_input_text_serializes_empty_text_elements() -> Result<()> { let input = UserInput::Text { diff --git a/codex-rs/rmcp-client/Cargo.toml b/codex-rs/rmcp-client/Cargo.toml index e3417de70ecb..1006ad74e80a 100644 --- a/codex-rs/rmcp-client/Cargo.toml +++ b/codex-rs/rmcp-client/Cargo.toml @@ -70,6 +70,7 @@ codex-utils-cargo-bin = { workspace = true } pretty_assertions = { workspace = true } serial_test = { workspace = true } tempfile = { workspace = true } +wiremock = { workspace = true } [target.'cfg(target_os = "linux")'.dependencies] keyring = { workspace = true, features = ["linux-native-async-persistent"] } diff --git a/codex-rs/rmcp-client/src/oauth.rs b/codex-rs/rmcp-client/src/oauth.rs index e23eee84bee9..c348460795de 100644 --- a/codex-rs/rmcp-client/src/oauth.rs +++ b/codex-rs/rmcp-client/src/oauth.rs @@ -113,7 +113,13 @@ fn refresh_expires_in_from_timestamp(tokens: &mut StoredOAuthTokens) { tokens.token_response.0.set_expires_in(Some(&duration)); } None => { - tokens.token_response.0.set_expires_in(None); + // RMCP treats a missing expiry as unknown and uses the access token + // as-is. Treat a known-expired timestamp as an explicit zero so + // startup refreshes the token before the first request. + tokens + .token_response + .0 + .set_expires_in(Some(&Duration::ZERO)); } } } @@ -830,7 +836,7 @@ mod tests { } #[test] - fn refresh_expires_in_from_timestamp_clears_expired_tokens() { + fn refresh_expires_in_from_timestamp_marks_expired_tokens() { let mut tokens = sample_tokens(); let now = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -843,7 +849,7 @@ mod tests { super::refresh_expires_in_from_timestamp(&mut tokens); - assert!(tokens.token_response.0.expires_in().is_none()); + assert_eq!(tokens.token_response.0.expires_in(), Some(Duration::ZERO)); } fn assert_tokens_match_without_expiry( diff --git a/codex-rs/rmcp-client/tests/streamable_http_oauth_startup.rs b/codex-rs/rmcp-client/tests/streamable_http_oauth_startup.rs new file mode 100644 index 000000000000..1c18f2c98bda --- /dev/null +++ b/codex-rs/rmcp-client/tests/streamable_http_oauth_startup.rs @@ -0,0 +1,155 @@ +mod streamable_http_test_support; + +use std::time::Duration; + +use codex_config::types::OAuthCredentialsStoreMode; +use codex_exec_server::Environment; +use codex_rmcp_client::RmcpClient; +use codex_rmcp_client::StoredOAuthTokens; +use codex_rmcp_client::WrappedOAuthTokenResponse; +use codex_rmcp_client::save_oauth_tokens; +use oauth2::AccessToken; +use oauth2::RefreshToken; +use oauth2::basic::BasicTokenType; +use rmcp::transport::auth::OAuthTokenResponse; +use rmcp::transport::auth::VendorExtraTokenFields; +use serde_json::Value; +use serde_json::json; +use tempfile::TempDir; +use tokio::process::Command; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::Request; +use wiremock::ResponseTemplate; +use wiremock::matchers::body_string_contains; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; + +use streamable_http_test_support::initialize_client; + +const SERVER_NAME: &str = "test-streamable-http-oauth-startup"; +const EXPIRED_ACCESS_TOKEN: &str = "expired-access-token"; +const REFRESH_TOKEN: &str = "valid-refresh-token"; +const REFRESHED_ACCESS_TOKEN: &str = "refreshed-access-token"; +const CHILD_SERVER_URL_ENV: &str = "MCP_TEST_OAUTH_STARTUP_SERVER_URL"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn refreshes_expired_persisted_token_before_initialize() -> anyhow::Result<()> { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/.well-known/oauth-authorization-server/mcp")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "authorization_endpoint": format!("{}/oauth/authorize", server.uri()), + "token_endpoint": format!("{}/oauth/token", server.uri()), + "scopes_supported": [""], + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .and(body_string_contains("grant_type=refresh_token")) + .and(body_string_contains(format!( + "refresh_token={REFRESH_TOKEN}" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": REFRESHED_ACCESS_TOKEN, + "token_type": "Bearer", + "expires_in": 7200, + "refresh_token": REFRESH_TOKEN, + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/mcp")) + .and(header( + "authorization", + format!("Bearer {REFRESHED_ACCESS_TOKEN}"), + )) + .respond_with(|request: &Request| { + let body: Value = request.body_json().expect("valid JSON-RPC request"); + match body.get("method").and_then(Value::as_str) { + Some("initialize") => ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", + "id": body.get("id").cloned().unwrap_or(Value::Null), + "result": { + "protocolVersion": body + .pointer("/params/protocolVersion") + .cloned() + .unwrap_or_else(|| json!("2025-06-18")), + "capabilities": {}, + "serverInfo": { + "name": "oauth-startup-test", + "version": "0.0.0-test", + }, + }, + })), + Some("notifications/initialized") => ResponseTemplate::new(202), + method => ResponseTemplate::new(400) + .set_body_string(format!("unexpected JSON-RPC method: {method:?}")), + } + }) + .expect(2) + .mount(&server) + .await; + + let codex_home = TempDir::new()?; + let server_url = format!("{}/mcp", server.uri()); + + // Credential storage resolves CODEX_HOME from the process environment. + // Run the client half of the test in an ignored helper test so it can use + // an isolated home without mutating the parent test runner's environment. + let status = Command::new(std::env::current_exe()?) + .args(["oauth_startup_child", "--exact", "--ignored", "--nocapture"]) + .env("CODEX_HOME", codex_home.path()) + .env(CHILD_SERVER_URL_ENV, server_url) + .status() + .await?; + assert!(status.success(), "OAuth startup child failed: {status}"); + server.verify().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[ignore = "spawned by refreshes_expired_persisted_token_before_initialize"] +async fn oauth_startup_child() -> anyhow::Result<()> { + let server_url = std::env::var(CHILD_SERVER_URL_ENV)?; + + // Save an expired access token with a valid refresh token so startup must + // refresh before sending the initialize request. + let mut response = OAuthTokenResponse::new( + AccessToken::new(EXPIRED_ACCESS_TOKEN.to_string()), + BasicTokenType::Bearer, + VendorExtraTokenFields::default(), + ); + response.set_refresh_token(Some(RefreshToken::new(REFRESH_TOKEN.to_string()))); + response.set_expires_in(Some(&Duration::from_secs(7200))); + let tokens = StoredOAuthTokens { + server_name: SERVER_NAME.to_string(), + url: server_url.clone(), + client_id: "test-client-id".to_string(), + token_response: WrappedOAuthTokenResponse(response), + expires_at: Some(0), + }; + save_oauth_tokens(SERVER_NAME, &tokens, OAuthCredentialsStoreMode::File)?; + + // This mirrors create_client's transport and initialization setup, except + // it omits the direct bearer token. Supplying that token would bypass the + // persisted OAuth credentials and the startup refresh under test. + let client = RmcpClient::new_streamable_http_client( + SERVER_NAME, + &server_url, + /*bearer_token*/ None, + /*http_headers*/ None, + /*env_http_headers*/ None, + OAuthCredentialsStoreMode::File, + Environment::default_for_tests().get_http_client(), + /*auth_provider*/ None, + ) + .await?; + + initialize_client(&client).await?; + Ok(()) +} diff --git a/codex-rs/rmcp-client/tests/streamable_http_test_support.rs b/codex-rs/rmcp-client/tests/streamable_http_test_support.rs index 822acef1a26b..03ee79392cd9 100644 --- a/codex-rs/rmcp-client/tests/streamable_http_test_support.rs +++ b/codex-rs/rmcp-client/tests/streamable_http_test_support.rs @@ -86,6 +86,12 @@ pub(crate) async fn create_client(base_url: &str) -> anyhow::Result ) .await?; + initialize_client(&client).await?; + + Ok(client) +} + +pub(crate) async fn initialize_client(client: &RmcpClient) -> anyhow::Result<()> { client .initialize( init_params(), @@ -102,8 +108,7 @@ pub(crate) async fn create_client(base_url: &str) -> anyhow::Result }), ) .await?; - - Ok(client) + Ok(()) } /// Creates a Streamable HTTP RMCP client that sends traffic through the remote diff --git a/codex-rs/rollout-trace/src/protocol_event.rs b/codex-rs/rollout-trace/src/protocol_event.rs index a862af116c1b..a3e1f184ecdf 100644 --- a/codex-rs/rollout-trace/src/protocol_event.rs +++ b/codex-rs/rollout-trace/src/protocol_event.rs @@ -224,6 +224,7 @@ pub(crate) fn tool_runtime_trace_event(event: &EventMsg) -> Option Option<&'static s | EventMsg::RealtimeConversationSdp(_) | EventMsg::ModelReroute(_) | EventMsg::ModelVerification(_) + | EventMsg::TurnModerationMetadata(_) | EventMsg::ContextCompacted(_) | EventMsg::ThreadSettingsApplied(_) | EventMsg::TokenCount(_) diff --git a/codex-rs/rollout-trace/src/reducer/tool/agents.rs b/codex-rs/rollout-trace/src/reducer/tool/agents.rs index a49b794d1470..37f3220c2c11 100644 --- a/codex-rs/rollout-trace/src/reducer/tool/agents.rs +++ b/codex-rs/rollout-trace/src/reducer/tool/agents.rs @@ -613,7 +613,12 @@ fn inter_agent_message_fields(item: &ConversationItem) -> Option<(String, String return None; }; let communication = serde_json::from_str::(text).ok()?; - Some((communication.recipient.to_string(), communication.content)) + Some(( + communication.recipient.to_string(), + communication + .encrypted_content + .unwrap_or(communication.content), + )) } #[cfg(test)] diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index 92e5f5690b14..17da9e62038e 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -30,6 +30,7 @@ pub fn persisted_rollout_items(items: &[RolloutItem]) -> Vec { pub fn should_persist_response_item(item: &ResponseItem) -> bool { match item { ResponseItem::Message { .. } + | ResponseItem::AgentMessage { .. } | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } @@ -60,7 +61,8 @@ pub fn should_persist_response_item_for_memories(item: &ResponseItem) -> bool { | ResponseItem::CustomToolCall { .. } | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::WebSearchCall { .. } => true, - ResponseItem::Reasoning { .. } + ResponseItem::AgentMessage { .. } + | ResponseItem::Reasoning { .. } | ResponseItem::ImageGenerationCall { .. } | ResponseItem::Compaction { .. } | ResponseItem::CompactionTrigger @@ -115,6 +117,7 @@ pub fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::RealtimeConversationClosed(_) | EventMsg::ModelReroute(_) | EventMsg::ModelVerification(_) + | EventMsg::TurnModerationMetadata(_) | EventMsg::AgentReasoningSectionBreak(_) | EventMsg::RawResponseItem(_) | EventMsg::SessionConfigured(_) diff --git a/codex-rs/shell-command/Cargo.toml b/codex-rs/shell-command/Cargo.toml index cc33d3621c1e..e918488f826a 100644 --- a/codex-rs/shell-command/Cargo.toml +++ b/codex-rs/shell-command/Cargo.toml @@ -11,6 +11,7 @@ workspace = true base64 = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } +libc = { workspace = true } once_cell = { workspace = true } regex = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/codex-rs/shell-command/src/bash.rs b/codex-rs/shell-command/src/bash.rs index 60ee5c420c51..b25d3fd37c12 100644 --- a/codex-rs/shell-command/src/bash.rs +++ b/codex-rs/shell-command/src/bash.rs @@ -100,7 +100,7 @@ pub fn extract_bash_command(command: &[String]) -> Option<(&str, &str)> { }; if !matches!(flag.as_str(), "-lc" | "-c") || !matches!( - detect_shell_type(&PathBuf::from(shell)), + detect_shell_type(PathBuf::from(shell)), Some(ShellType::Zsh) | Some(ShellType::Bash) | Some(ShellType::Sh) ) { diff --git a/codex-rs/shell-command/src/lib.rs b/codex-rs/shell-command/src/lib.rs index 1d9e302a4e70..947de5e645a7 100644 --- a/codex-rs/shell-command/src/lib.rs +++ b/codex-rs/shell-command/src/lib.rs @@ -1,6 +1,6 @@ //! Command parsing and safety utilities shared across Codex crates. -mod shell_detect; +pub mod shell_detect; pub mod bash; pub(crate) mod command_safety; diff --git a/codex-rs/shell-command/src/powershell.rs b/codex-rs/shell-command/src/powershell.rs index e8c9a500c407..9730439bea31 100644 --- a/codex-rs/shell-command/src/powershell.rs +++ b/codex-rs/shell-command/src/powershell.rs @@ -47,7 +47,7 @@ pub fn extract_powershell_command(command: &[String]) -> Option<(&str, &str)> { let shell = &command[0]; if !matches!( - detect_shell_type(&PathBuf::from(shell)), + detect_shell_type(PathBuf::from(shell)), Some(ShellType::PowerShell) ) { return None; diff --git a/codex-rs/shell-command/src/shell_detect.rs b/codex-rs/shell-command/src/shell_detect.rs index 34322a06d003..69a66f05635e 100644 --- a/codex-rs/shell-command/src/shell_detect.rs +++ b/codex-rs/shell-command/src/shell_detect.rs @@ -1,8 +1,10 @@ -use std::path::Path; use std::path::PathBuf; -#[derive(Debug, PartialEq, Eq, Clone, Copy)] -pub(crate) enum ShellType { +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)] +pub enum ShellType { Zsh, Bash, PowerShell, @@ -10,7 +12,32 @@ pub(crate) enum ShellType { Cmd, } -pub(crate) fn detect_shell_type(shell_path: &PathBuf) -> Option { +impl ShellType { + pub fn name(self) -> &'static str { + match self { + Self::Zsh => "zsh", + Self::Bash => "bash", + Self::PowerShell => "powershell", + Self::Sh => "sh", + Self::Cmd => "cmd", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DetectedShell { + pub shell_type: ShellType, + pub shell_path: PathBuf, +} + +impl DetectedShell { + pub fn name(&self) -> &'static str { + self.shell_type.name() + } +} + +pub fn detect_shell_type(shell_path: impl AsRef) -> Option { + let shell_path = shell_path.as_ref(); match shell_path.as_os_str().to_str() { Some("zsh") => Some(ShellType::Zsh), Some("sh") => Some(ShellType::Sh), @@ -21,12 +48,321 @@ pub(crate) fn detect_shell_type(shell_path: &PathBuf) -> Option { _ => { let shell_name = shell_path.file_stem(); if let Some(shell_name) = shell_name { - let shell_name_path = Path::new(shell_name); - if shell_name_path != Path::new(shell_path) { - return detect_shell_type(&shell_name_path.to_path_buf()); + let shell_name_path = std::path::Path::new(shell_name); + if shell_name_path != shell_path { + return detect_shell_type(shell_name_path); } } None } } } + +#[cfg(unix)] +fn get_user_shell_path() -> Option { + let uid = unsafe { libc::getuid() }; + use std::ffi::CStr; + use std::mem::MaybeUninit; + use std::ptr; + + let mut passwd = MaybeUninit::::uninit(); + + // We cannot use getpwuid here: it returns pointers into libc-managed + // storage, which is not safe to read concurrently on all targets (the musl + // static build used by the CLI can segfault when parallel callers race on + // that buffer). getpwuid_r keeps the passwd data in caller-owned memory. + let suggested_buffer_len = unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) }; + let buffer_len = usize::try_from(suggested_buffer_len) + .ok() + .filter(|len| *len > 0) + .unwrap_or(1024); + let mut buffer = vec![0; buffer_len]; + + loop { + let mut result = ptr::null_mut(); + let status = unsafe { + libc::getpwuid_r( + uid, + passwd.as_mut_ptr(), + buffer.as_mut_ptr().cast(), + buffer.len(), + &mut result, + ) + }; + + if status == 0 { + if result.is_null() { + return None; + } + + let passwd = unsafe { passwd.assume_init_ref() }; + if passwd.pw_shell.is_null() { + return None; + } + + let shell_path = unsafe { CStr::from_ptr(passwd.pw_shell) } + .to_string_lossy() + .into_owned(); + return Some(PathBuf::from(shell_path)); + } + + if status != libc::ERANGE { + return None; + } + + // Retry with a larger buffer until libc can materialize the passwd entry. + let new_len = buffer.len().checked_mul(2)?; + if new_len > 1024 * 1024 { + return None; + } + buffer.resize(new_len, 0); + } +} + +#[cfg(not(unix))] +fn get_user_shell_path() -> Option { + None +} + +fn file_exists(path: &std::path::Path) -> Option { + if std::fs::metadata(path).is_ok_and(|metadata| metadata.is_file()) { + Some(PathBuf::from(path)) + } else { + None + } +} + +fn get_shell_path( + shell_type: ShellType, + provided_path: Option<&PathBuf>, + binary_name: &str, + fallback_paths: &[&str], +) -> Option { + if let Some(path) = provided_path.and_then(|path| file_exists(path)) { + return Some(path); + } + + let default_shell_path = get_user_shell_path(); + if let Some(default_shell_path) = default_shell_path + && detect_shell_type(&default_shell_path) == Some(shell_type) + && file_exists(&default_shell_path).is_some() + { + return Some(default_shell_path); + } + + if let Ok(path) = which::which(binary_name) { + return Some(path); + } + + for path in fallback_paths { + if let Some(path) = file_exists(std::path::Path::new(path)) { + return Some(path); + } + } + + None +} + +const ZSH_FALLBACK_PATHS: &[&str] = &["/bin/zsh"]; + +fn get_zsh_shell(path: Option<&PathBuf>) -> Option { + let shell_path = get_shell_path(ShellType::Zsh, path, "zsh", ZSH_FALLBACK_PATHS); + + shell_path.map(|shell_path| DetectedShell { + shell_type: ShellType::Zsh, + shell_path, + }) +} + +const BASH_FALLBACK_PATHS: &[&str] = &["/bin/bash", "/usr/bin/bash"]; + +fn get_bash_shell(path: Option<&PathBuf>) -> Option { + let shell_path = get_shell_path(ShellType::Bash, path, "bash", BASH_FALLBACK_PATHS); + + shell_path.map(|shell_path| DetectedShell { + shell_type: ShellType::Bash, + shell_path, + }) +} + +const SH_FALLBACK_PATHS: &[&str] = &["/bin/sh"]; + +fn get_sh_shell(path: Option<&PathBuf>) -> Option { + let shell_path = get_shell_path(ShellType::Sh, path, "sh", SH_FALLBACK_PATHS); + + shell_path.map(|shell_path| DetectedShell { + shell_type: ShellType::Sh, + shell_path, + }) +} + +// Note the `pwsh` and `powershell` fallback paths are where the respective +// shells are commonly installed on GitHub Actions Windows runners, but may not +// be present on all Windows machines: +// https://docs.github.com/en/actions/tutorials/build-and-test-code/powershell + +#[cfg(windows)] +const PWSH_FALLBACK_PATHS: &[&str] = &[r#"C:\Program Files\PowerShell\7\pwsh.exe"#]; +#[cfg(not(windows))] +const PWSH_FALLBACK_PATHS: &[&str] = &["/usr/local/bin/pwsh"]; + +#[cfg(windows)] +const POWERSHELL_FALLBACK_PATHS: &[&str] = + &[r#"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"#]; +#[cfg(not(windows))] +const POWERSHELL_FALLBACK_PATHS: &[&str] = &[]; + +fn get_powershell_shell(path: Option<&PathBuf>) -> Option { + let shell_path = get_shell_path(ShellType::PowerShell, path, "pwsh", PWSH_FALLBACK_PATHS) + .or_else(|| { + get_shell_path( + ShellType::PowerShell, + path, + "powershell", + POWERSHELL_FALLBACK_PATHS, + ) + }); + + shell_path.map(|shell_path| DetectedShell { + shell_type: ShellType::PowerShell, + shell_path, + }) +} + +fn get_cmd_shell(path: Option<&PathBuf>) -> Option { + let shell_path = get_shell_path(ShellType::Cmd, path, "cmd", &[]); + + shell_path.map(|shell_path| DetectedShell { + shell_type: ShellType::Cmd, + shell_path, + }) +} + +pub fn ultimate_fallback_shell() -> DetectedShell { + if cfg!(windows) { + DetectedShell { + shell_type: ShellType::Cmd, + shell_path: PathBuf::from("cmd.exe"), + } + } else { + DetectedShell { + shell_type: ShellType::Sh, + shell_path: PathBuf::from("/bin/sh"), + } + } +} + +pub fn get_shell_by_model_provided_path(shell_path: &PathBuf) -> DetectedShell { + detect_shell_type(shell_path) + .and_then(|shell_type| get_shell(shell_type, Some(shell_path))) + .unwrap_or_else(ultimate_fallback_shell) +} + +pub fn get_shell(shell_type: ShellType, path: Option<&PathBuf>) -> Option { + match shell_type { + ShellType::Zsh => get_zsh_shell(path), + ShellType::Bash => get_bash_shell(path), + ShellType::PowerShell => get_powershell_shell(path), + ShellType::Sh => get_sh_shell(path), + ShellType::Cmd => get_cmd_shell(path), + } +} + +pub fn default_user_shell() -> DetectedShell { + default_user_shell_from_path(get_user_shell_path()) +} + +pub fn default_user_shell_from_path(user_shell_path: Option) -> DetectedShell { + if cfg!(windows) { + get_shell(ShellType::PowerShell, /*path*/ None).unwrap_or_else(ultimate_fallback_shell) + } else { + let user_default_shell = user_shell_path + .and_then(|shell| detect_shell_type(&shell)) + .and_then(|shell_type| get_shell(shell_type, /*path*/ None)); + + let shell_with_fallback = if cfg!(target_os = "macos") { + user_default_shell + .or_else(|| get_shell(ShellType::Zsh, /*path*/ None)) + .or_else(|| get_shell(ShellType::Bash, /*path*/ None)) + } else { + user_default_shell + .or_else(|| get_shell(ShellType::Bash, /*path*/ None)) + .or_else(|| get_shell(ShellType::Zsh, /*path*/ None)) + }; + + shell_with_fallback.unwrap_or_else(ultimate_fallback_shell) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_detect_shell_type() { + assert_eq!( + detect_shell_type(PathBuf::from("zsh")), + Some(ShellType::Zsh) + ); + assert_eq!( + detect_shell_type(PathBuf::from("bash")), + Some(ShellType::Bash) + ); + assert_eq!( + detect_shell_type(PathBuf::from("pwsh")), + Some(ShellType::PowerShell) + ); + assert_eq!( + detect_shell_type(PathBuf::from("powershell")), + Some(ShellType::PowerShell) + ); + assert_eq!(detect_shell_type(PathBuf::from("fish")), None); + assert_eq!(detect_shell_type(PathBuf::from("other")), None); + assert_eq!( + detect_shell_type(PathBuf::from("/bin/zsh")), + Some(ShellType::Zsh) + ); + assert_eq!( + detect_shell_type(PathBuf::from("/bin/bash")), + Some(ShellType::Bash) + ); + assert_eq!( + detect_shell_type(PathBuf::from("/usr/bin/bash")), + Some(ShellType::Bash) + ); + assert_eq!( + detect_shell_type(PathBuf::from("powershell.exe")), + Some(ShellType::PowerShell) + ); + assert_eq!( + detect_shell_type(PathBuf::from(if cfg!(windows) { + "C:\\windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" + } else { + "/usr/local/bin/pwsh" + })), + Some(ShellType::PowerShell) + ); + assert_eq!( + detect_shell_type(PathBuf::from("pwsh.exe")), + Some(ShellType::PowerShell) + ); + assert_eq!( + detect_shell_type(PathBuf::from("/usr/local/bin/pwsh")), + Some(ShellType::PowerShell) + ); + assert_eq!( + detect_shell_type(PathBuf::from("/bin/sh")), + Some(ShellType::Sh) + ); + assert_eq!(detect_shell_type(PathBuf::from("sh")), Some(ShellType::Sh)); + assert_eq!( + detect_shell_type(PathBuf::from("cmd")), + Some(ShellType::Cmd) + ); + assert_eq!( + detect_shell_type(PathBuf::from("cmd.exe")), + Some(ShellType::Cmd) + ); + } +} diff --git a/codex-rs/tools/src/json_schema.rs b/codex-rs/tools/src/json_schema.rs index 1b53ad980340..5352eaa91d2b 100644 --- a/codex-rs/tools/src/json_schema.rs +++ b/codex-rs/tools/src/json_schema.rs @@ -43,6 +43,9 @@ pub struct JsonSchema { pub schema_type: Option, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, + /// Responses-only marker for reviewed encrypted tool parameters. + #[serde(skip_serializing_if = "Option::is_none")] + pub encrypted: Option, #[serde(rename = "enum", skip_serializing_if = "Option::is_none")] pub enum_values: Option>, #[serde(skip_serializing_if = "Option::is_none")] @@ -90,6 +93,11 @@ impl JsonSchema { Self::typed(JsonSchemaPrimitiveType::String, description) } + pub fn with_encrypted(mut self) -> Self { + self.encrypted = Some(true); + self + } + pub fn number(description: Option) -> Self { Self::typed(JsonSchemaPrimitiveType::Number, description) } diff --git a/codex-rs/tools/src/json_schema_tests.rs b/codex-rs/tools/src/json_schema_tests.rs index 9315d43c004b..6973d06c0052 100644 --- a/codex-rs/tools/src/json_schema_tests.rs +++ b/codex-rs/tools/src/json_schema_tests.rs @@ -24,6 +24,20 @@ fn parse_tool_input_schema_coerces_boolean_schemas() { assert_eq!(schema, JsonSchema::string(/*description*/ None)); } +#[test] +fn json_schema_serializes_encrypted_marker() { + let schema = JsonSchema::string(Some("Secret value".to_string())).with_encrypted(); + + assert_eq!( + serde_json::to_value(schema).expect("serialize schema"), + serde_json::json!({ + "type": "string", + "description": "Secret value", + "encrypted": true, + }) + ); +} + #[test] fn parse_tool_input_schema_infers_object_shape_and_defaults_properties() { // Example schema shape: diff --git a/codex-rs/tools/src/tool_config_tests.rs b/codex-rs/tools/src/tool_config_tests.rs index 1b4d789394a6..0e3917d69773 100644 --- a/codex-rs/tools/src/tool_config_tests.rs +++ b/codex-rs/tools/src/tool_config_tests.rs @@ -44,6 +44,7 @@ fn model_with_shell_type(shell_type: ConfigShellToolType) -> ModelInfo { input_modalities: codex_protocol::openai_models::default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + use_responses_lite: false, auto_review_model_override: None, tool_mode: None, multi_agent_version: None, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index b1245db3ec35..4b86eda89852 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -16,6 +16,7 @@ use crate::app_event::RealtimeAudioDeviceKind; #[cfg(target_os = "windows")] use crate::app_event::WindowsSandboxEnableMode; use crate::app_event_sender::AppEventSender; +use crate::app_server_session::AppServerBootstrap; use crate::app_server_session::AppServerSession; use crate::app_server_session::AppServerStartedThread; use crate::app_server_session::TurnPermissionsOverride; @@ -512,6 +513,8 @@ pub(crate) struct App { status_line_invalid_items_warned: Arc, // Shared across ChatWidget instances so invalid terminal-title config warnings only emit once. terminal_title_invalid_items_warned: Arc, + // Tracks active skill-load warnings so refreshes do not duplicate history cells. + skill_load_warnings: SkillLoadWarningState, // Esc-backtracking state grouped pub(crate) backtrack: crate::app_backtrack::BacktrackState, @@ -727,6 +730,8 @@ impl App { app_server_target: AppServerTarget, state_db: Option, environment_manager: Arc, + startup_elapsed_before_app: Duration, + startup_bootstrap: Option, startup_hooks_browser: Option, ) -> Result { use tokio_stream::StreamExt; @@ -774,9 +779,11 @@ impl App { }); } }; - let bootstrap_started_at = Instant::now(); - let bootstrap = app_server.bootstrap(&config).await?; - let bootstrap_ms = bootstrap_started_at.elapsed().as_millis(); + let bootstrap = match startup_bootstrap { + Some(bootstrap) => bootstrap, + None => app_server.bootstrap(&config).await?, + }; + let bootstrap_ms = bootstrap.duration.as_millis(); let mut model = bootstrap.default_model; let available_models = bootstrap.available_models; let remote_connection = crate::status::remote_connection::remote_connection_status_value( @@ -1010,6 +1017,7 @@ See the Codex keymap documentation for supported actions and examples." commit_anim_running: Arc::new(AtomicBool::new(false)), status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), terminal_title_invalid_items_warned: terminal_title_invalid_items_warned.clone(), + skill_load_warnings: SkillLoadWarningState::default(), backtrack: BacktrackState::default(), backtrack_render_pending: false, feedback: feedback.clone(), @@ -1085,7 +1093,7 @@ See the Codex keymap documentation for supported actions and examples." tui.frame_requester().schedule_frame(); tracing::info!( - duration_ms = %startup_started_at.elapsed().as_millis(), + duration_ms = %(startup_elapsed_before_app + startup_started_at.elapsed()).as_millis(), bootstrap_ms = %bootstrap_ms, runtime_model_provider_ms = %runtime_model_provider_ms, thread_and_widget_ms = %thread_and_widget_ms, diff --git a/codex-rs/tui/src/app/app_server_event_targets.rs b/codex-rs/tui/src/app/app_server_event_targets.rs index 0b2143b429b9..ea9fce90fb25 100644 --- a/codex-rs/tui/src/app/app_server_event_targets.rs +++ b/codex-rs/tui/src/app/app_server_event_targets.rs @@ -118,6 +118,9 @@ pub(super) fn server_notification_thread_target( ServerNotification::ModelVerification(notification) => { Some(notification.thread_id.as_str()) } + ServerNotification::TurnModerationMetadata(notification) => { + Some(notification.thread_id.as_str()) + } ServerNotification::ThreadRealtimeStarted(notification) => { Some(notification.thread_id.as_str()) } diff --git a/codex-rs/tui/src/app/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs index 4aeba59aab2d..dea630876059 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -87,10 +87,9 @@ impl App { notification.plan_type, ), notification.plan_type, - matches!( - notification.auth_mode, - Some(AuthMode::Chatgpt) | Some(AuthMode::ChatgptAuthTokens) - ), + notification + .auth_mode + .is_some_and(AuthMode::has_chatgpt_account), ); return; } diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index d90f511d6f9e..456676f7963e 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -7,6 +7,7 @@ use super::plugin_mentions::fetch_plugin_mentions; use super::*; use crate::app_event::ConnectorsSnapshot; +use crate::config_update::format_config_error; use codex_app_server_protocol::AppsListParams; use codex_app_server_protocol::AppsListResponse; use codex_app_server_protocol::MarketplaceAddParams; @@ -357,7 +358,12 @@ impl App { let result = write_hook_enabled(request_handle, key, enabled) .await .map(|_| ()) - .map_err(|err| format!("Failed to update hook config: {err}")); + .map_err(|err| { + format!( + "Failed to update hook config: {}", + format_config_error(&err) + ) + }); app_event_tx.send(AppEvent::HookEnabledSet { key: key_for_event, enabled, @@ -378,7 +384,7 @@ impl App { let result = write_hook_trust(request_handle, key, current_hash) .await .map(|_| ()) - .map_err(|err| format!("Failed to trust hook: {err}")); + .map_err(|err| format!("Failed to trust hook: {}", format_config_error(&err))); app_event_tx.send(AppEvent::HookTrusted { result }); }); } @@ -394,7 +400,7 @@ impl App { let result = write_hook_trusts(request_handle, updates) .await .map(|_| ()) - .map_err(|err| format!("Failed to trust hooks: {err}")); + .map_err(|err| format!("Failed to trust hooks: {}", format_config_error(&err))); app_event_tx.send(AppEvent::HookTrusted { result }); }); } diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 946223ec3e69..319830b82a03 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -482,9 +482,10 @@ impl App { { Ok(response) => response, Err(err) => { - tracing::error!(error = %err, "failed to persist feature flags"); + let error = crate::config_update::format_config_error(&err); + tracing::error!(error = %error, "failed to persist feature flags"); self.chat_widget - .add_error_message(format!("Failed to update experimental features: {err}")); + .add_error_message(format!("Failed to update experimental features: {error}")); return; } }; diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 58f090e4c79b..548aa5442781 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -5,6 +5,7 @@ use super::resize_reflow::trailing_run_start; use super::*; +use crate::config_update::format_config_error; #[cfg(target_os = "windows")] use codex_config::types::WindowsSandboxModeToml; @@ -1321,12 +1322,13 @@ impl App { self.chat_widget.add_info_message(message, /*hint*/ None); } Err(err) => { + let error = format_config_error(&err); tracing::error!( - error = %err, + error = %error, "failed to persist model selection" ); self.chat_widget - .add_error_message(format!("Failed to save default model: {err}")); + .add_error_message(format!("Failed to save default model: {error}")); } } } @@ -1945,9 +1947,10 @@ impl App { self.chat_widget.setup_status_line(items, use_theme_colors); } Err(err) => { - tracing::error!(error = %err, "failed to persist status line settings; keeping previous selection"); + let error = format_config_error(&err); + tracing::error!(error = %error, "failed to persist status line settings; keeping previous selection"); self.chat_widget.add_error_message(format!( - "Failed to save status line settings: {err}" + "Failed to save status line settings: {error}" )); } } diff --git a/codex-rs/tui/src/app/history_ui.rs b/codex-rs/tui/src/app/history_ui.rs index 4cb5072daf00..0aff920e6ddd 100644 --- a/codex-rs/tui/src/app/history_ui.rs +++ b/codex-rs/tui/src/app/history_ui.rs @@ -113,6 +113,7 @@ impl App { self.initial_history_replay_buffer = None; self.backtrack = BacktrackState::default(); self.backtrack_render_pending = false; + self.skill_load_warnings.clear(); } } diff --git a/codex-rs/tui/src/app/startup_prompts.rs b/codex-rs/tui/src/app/startup_prompts.rs index a027dcd5056b..4834656617e9 100644 --- a/codex-rs/tui/src/app/startup_prompts.rs +++ b/codex-rs/tui/src/app/startup_prompts.rs @@ -4,6 +4,45 @@ //! catalog state into one-time TUI prompts or warning cells without owning the main event loop. use super::*; +use std::collections::HashSet; +use std::path::PathBuf; + +#[derive(Debug, PartialEq, Eq, Hash)] +struct SkillLoadWarningKey { + path: PathBuf, + message: String, +} + +#[derive(Debug, Default)] +pub(super) struct SkillLoadWarningState { + active: HashSet, +} + +impl SkillLoadWarningState { + pub(super) fn clear(&mut self) { + self.active.clear(); + } + + pub(super) fn newly_active_errors(&mut self, errors: &[SkillErrorInfo]) -> Vec { + let previous = std::mem::take(&mut self.active); + let mut current = HashSet::new(); + let mut newly_active = Vec::new(); + + for error in errors { + let key = SkillLoadWarningKey { + path: error.path.clone(), + message: error.message.clone(), + }; + let was_active = previous.contains(&key); + if current.insert(key) && !was_active { + newly_active.push(error.clone()); + } + } + + self.active = current; + newly_active + } +} pub(super) fn emit_skill_load_warnings(app_event_tx: &AppEventSender, errors: &[SkillErrorInfo]) { if errors.is_empty() { @@ -336,8 +375,10 @@ mod tests { use super::*; use crate::test_support::PathBufExt; use pretty_assertions::assert_eq; + use ratatui::text::Line; use std::path::PathBuf; use tempfile::tempdir; + use tokio::sync::mpsc::unbounded_channel; #[test] fn normalize_harness_overrides_resolves_relative_add_dirs() -> Result<()> { @@ -357,4 +398,126 @@ mod tests { ); Ok(()) } + + fn skill_error(path: &str, message: &str) -> SkillErrorInfo { + SkillErrorInfo { + path: PathBuf::from(path), + message: message.to_string(), + } + } + + fn render_line_text(line: &Line<'static>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect() + } + + fn render_skill_load_warning_cells(errors: &[SkillErrorInfo]) -> String { + let (tx, mut rx) = unbounded_channel(); + let app_event_tx = AppEventSender::new(tx); + + emit_skill_load_warnings(&app_event_tx, errors); + + let mut rendered = Vec::new(); + while let Ok(AppEvent::InsertHistoryCell(cell)) = rx.try_recv() { + rendered.extend( + cell.display_lines(/*width*/ 120) + .iter() + .map(render_line_text), + ); + } + rendered.join("\n") + } + + #[test] + fn skill_load_warning_state_suppresses_repeated_active_errors() { + let mut state = SkillLoadWarningState::default(); + let error = skill_error("/repo/.codex/skills/abc/SKILL.md", "invalid description"); + + assert_eq!( + state.newly_active_errors(std::slice::from_ref(&error)), + vec![error.clone()] + ); + assert_eq!( + state.newly_active_errors(std::slice::from_ref(&error)), + Vec::::new() + ); + } + + #[test] + fn skill_load_warning_state_reemits_after_error_clears() { + let mut state = SkillLoadWarningState::default(); + let error = skill_error("/repo/.codex/skills/abc/SKILL.md", "invalid description"); + + assert_eq!( + state.newly_active_errors(std::slice::from_ref(&error)), + vec![error.clone()] + ); + assert_eq!(state.newly_active_errors(&[]), Vec::::new()); + assert_eq!( + state.newly_active_errors(std::slice::from_ref(&error)), + vec![error] + ); + } + + #[test] + fn skill_load_warning_state_displays_new_message_for_active_path() { + let mut state = SkillLoadWarningState::default(); + let initial = skill_error("/repo/.codex/skills/abc/SKILL.md", "invalid description"); + let changed = skill_error("/repo/.codex/skills/abc/SKILL.md", "invalid frontmatter"); + + assert_eq!( + state.newly_active_errors(std::slice::from_ref(&initial)), + vec![initial] + ); + assert_eq!( + state.newly_active_errors(std::slice::from_ref(&changed)), + vec![changed] + ); + } + + #[test] + fn skill_load_warning_state_clear_allows_active_error_again() { + let mut state = SkillLoadWarningState::default(); + let error = skill_error("/repo/.codex/skills/abc/SKILL.md", "invalid description"); + + assert_eq!( + state.newly_active_errors(std::slice::from_ref(&error)), + vec![error.clone()] + ); + assert_eq!( + state.newly_active_errors(std::slice::from_ref(&error)), + Vec::::new() + ); + + state.clear(); + + assert_eq!( + state.newly_active_errors(std::slice::from_ref(&error)), + vec![error] + ); + } + + #[test] + fn repeated_active_skill_load_warning_renders_once() { + let mut state = SkillLoadWarningState::default(); + let error = skill_error("/repo/.codex/skills/abc/SKILL.md", "invalid description"); + + let first_errors = state.newly_active_errors(std::slice::from_ref(&error)); + let repeated_errors = state.newly_active_errors(std::slice::from_ref(&error)); + let rendered = [ + render_skill_load_warning_cells(&first_errors), + render_skill_load_warning_cells(&repeated_errors), + ] + .into_iter() + .filter(|output| !output.is_empty()) + .collect::>() + .join("\n"); + + insta::assert_snapshot!(rendered, @r" +⚠ Skipped loading 1 skill(s) due to invalid SKILL.md files. +⚠ /repo/.codex/skills/abc/SKILL.md: invalid description +"); + } } diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index 9bbe0c60b478..c59102fa6d8e 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -39,6 +39,7 @@ pub(super) async fn make_test_app() -> App { commit_anim_running: Arc::new(AtomicBool::new(false)), status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), + skill_load_warnings: SkillLoadWarningState::default(), backtrack: BacktrackState::default(), backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index b18726004e01..2e3e1b8c906f 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3788,6 +3788,7 @@ async fn make_test_app() -> App { commit_anim_running: Arc::new(AtomicBool::new(false)), status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), + skill_load_warnings: SkillLoadWarningState::default(), backtrack: BacktrackState::default(), backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), @@ -3851,6 +3852,7 @@ async fn make_test_app_with_channels() -> ( commit_anim_running: Arc::new(AtomicBool::new(false)), status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), + skill_load_warnings: SkillLoadWarningState::default(), backtrack: BacktrackState::default(), backtrack_render_pending: false, feedback: codex_feedback::CodexFeedback::new(), @@ -5576,6 +5578,34 @@ async fn clear_only_ui_reset_preserves_chat_session_state() { assert_eq!(app.chat_widget.composer_text_with_pending(), "draft prompt"); } +#[tokio::test] +async fn clear_only_ui_reset_allows_active_skill_warning_to_render_again() { + let mut app = make_test_app().await; + let error = SkillErrorInfo { + path: test_path_buf("/tmp/project/.codex/skills/abc/SKILL.md"), + message: "invalid description".to_string(), + }; + + assert_eq!( + app.skill_load_warnings + .newly_active_errors(std::slice::from_ref(&error)), + vec![error.clone()] + ); + assert_eq!( + app.skill_load_warnings + .newly_active_errors(std::slice::from_ref(&error)), + Vec::::new() + ); + + app.reset_app_ui_state_after_clear(); + + assert_eq!( + app.skill_load_warnings + .newly_active_errors(std::slice::from_ref(&error)), + vec![error] + ); +} + #[tokio::test] async fn backtrack_esc_does_not_steal_empty_vim_insert_escape() { let mut app = make_test_app().await; diff --git a/codex-rs/tui/src/app/thread_goal_actions.rs b/codex-rs/tui/src/app/thread_goal_actions.rs index 591180175502..b1a1bd367866 100644 --- a/codex-rs/tui/src/app/thread_goal_actions.rs +++ b/codex-rs/tui/src/app/thread_goal_actions.rs @@ -6,6 +6,7 @@ use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::popup_consts::standard_popup_hint_line; +use crate::goal_display::GOAL_USAGE; use crate::goal_display::goal_status_label; use crate::goal_display::goal_usage_summary; use codex_app_server_protocol::ThreadGoal; @@ -39,7 +40,7 @@ impl App { let Some(goal) = response.goal else { self.chat_widget.add_info_message( - "Usage: /goal ".to_string(), + GOAL_USAGE.to_string(), Some("No goal is currently set.".to_string()), ); return; @@ -280,7 +281,7 @@ impl App { self.chat_widget .add_error_message("No goal is currently set.".to_string()); self.chat_widget.add_info_message( - "Usage: /goal ".to_string(), + GOAL_USAGE.to_string(), Some("Create a goal before editing it.".to_string()), ); } diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index c53cfc05e3e2..7e9f91befdaa 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -1342,6 +1342,7 @@ impl App { pub(super) fn handle_skills_list_response(&mut self, response: SkillsListResponse) { let cwd = self.chat_widget.config_ref().cwd.clone(); let errors = errors_for_cwd(&cwd, &response); + let errors = self.skill_load_warnings.newly_active_errors(&errors); emit_skill_load_warnings(&self.app_event_tx, &errors); self.chat_widget.handle_skills_list_response(response); } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index ccd6304d76b5..32db3d2cf796 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -11,6 +11,7 @@ use crate::session_state::MessageHistoryMetadata; use crate::session_state::ThreadSessionState; use crate::status::StatusAccountDisplay; use crate::status::plan_type_display_name; +use crate::terminal_visualization_instructions::with_terminal_visualization_instructions; use codex_app_server_client::AppServerClient; use codex_app_server_client::AppServerEvent; use codex_app_server_client::AppServerRequestHandle; @@ -127,6 +128,8 @@ use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; use std::collections::HashMap; use std::path::PathBuf; +use std::time::Duration; +use std::time::Instant; use uuid::Uuid; const JSONRPC_INVALID_REQUEST: i64 = -32600; @@ -150,6 +153,7 @@ fn is_thread_settings_update_unsupported(source: &JSONRPCErrorError) -> bool { /// fetched asynchronously after bootstrap returns so that the TUI can render /// its first frame without waiting for the rate-limit round-trip. pub(crate) struct AppServerBootstrap { + pub(crate) duration: Duration, pub(crate) account_email: Option, pub(crate) auth_mode: Option, pub(crate) status_account_display: Option, @@ -239,6 +243,7 @@ impl AppServerSession { } pub(crate) async fn bootstrap(&mut self, config: &Config) -> Result { + let started_at = Instant::now(); let account = self.read_account().await?; let model_request_id = self.next_request_id(); let models: ModelListResponse = self @@ -314,6 +319,7 @@ impl AppServerSession { None => (None, None, None, None, FeedbackAudience::External, false), }; Ok(AppServerBootstrap { + duration: started_at.elapsed(), account_email, auth_mode, status_account_display, @@ -708,12 +714,7 @@ impl AppServerSession { additional_context: None, environments: None, cwd: Some(cwd), - runtime_workspace_roots: Some( - workspace_roots - .iter() - .map(AbsolutePathBuf::to_path_buf) - .collect(), - ), + runtime_workspace_roots: Some(workspace_roots.to_vec()), approval_policy: Some(approval_policy), approvals_reviewer: Some(approvals_reviewer.into()), sandbox_policy, @@ -1191,7 +1192,8 @@ pub(crate) fn status_account_display_from_auth_mode( Some(AuthMode::ApiKey) => Some(StatusAccountDisplay::ApiKey), Some(AuthMode::Chatgpt) | Some(AuthMode::ChatgptAuthTokens) - | Some(AuthMode::AgentIdentity) => Some(StatusAccountDisplay::ChatGpt { + | Some(AuthMode::AgentIdentity) + | Some(AuthMode::PersonalAccessToken) => Some(StatusAccountDisplay::ChatGpt { email: None, plan: plan_type.map(plan_type_display_name), }), @@ -1403,13 +1405,7 @@ fn thread_start_params_from_config( model_provider: thread_params_mode.model_provider_from_config(config), service_tier: service_tier_override_from_config(config), cwd: thread_cwd_from_config(config, thread_params_mode, remote_cwd_override), - runtime_workspace_roots: Some( - config - .workspace_roots - .iter() - .map(AbsolutePathBuf::to_path_buf) - .collect(), - ), + runtime_workspace_roots: Some(config.workspace_roots.clone()), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(config), sandbox, @@ -1418,6 +1414,9 @@ fn thread_start_params_from_config( ephemeral: Some(config.ephemeral), session_start_source, thread_source: Some(ThreadSource::User), + developer_instructions: with_terminal_visualization_instructions( + config, /*control_instructions*/ None, + ), ..ThreadStartParams::default() } } @@ -1444,18 +1443,15 @@ fn thread_resume_params_from_config( model_provider: thread_params_mode.model_provider_from_config(&config), service_tier: service_tier_override_from_config(&config), cwd: thread_cwd_from_config(&config, thread_params_mode, remote_cwd_override), - runtime_workspace_roots: Some( - config - .workspace_roots - .iter() - .map(AbsolutePathBuf::to_path_buf) - .collect(), - ), + runtime_workspace_roots: Some(config.workspace_roots.clone()), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(&config), sandbox, permissions, config: config_request_overrides_from_config(&config), + developer_instructions: with_terminal_visualization_instructions( + &config, /*control_instructions*/ None, + ), ..ThreadResumeParams::default() } } @@ -1482,20 +1478,17 @@ fn thread_fork_params_from_config( model_provider: thread_params_mode.model_provider_from_config(&config), service_tier: service_tier_override_from_config(&config), cwd: thread_cwd_from_config(&config, thread_params_mode, remote_cwd_override), - runtime_workspace_roots: Some( - config - .workspace_roots - .iter() - .map(AbsolutePathBuf::to_path_buf) - .collect(), - ), + runtime_workspace_roots: Some(config.workspace_roots.clone()), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(&config), sandbox, permissions, config: config_request_overrides_from_config(&config), base_instructions: config.base_instructions.clone(), - developer_instructions: config.developer_instructions.clone(), + developer_instructions: with_terminal_visualization_instructions( + &config, + config.developer_instructions.clone(), + ), ephemeral: config.ephemeral, thread_source: Some(ThreadSource::User), ..ThreadForkParams::default() @@ -1766,6 +1759,7 @@ mod tests { use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnStatus; + use codex_features::Feature; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::ServiceTier; @@ -1885,13 +1879,7 @@ mod tests { assert_eq!(params.cwd, Some(config.cwd.to_string_lossy().to_string())); assert_eq!( params.runtime_workspace_roots, - Some( - config - .workspace_roots - .iter() - .map(AbsolutePathBuf::to_path_buf) - .collect() - ) + Some(config.workspace_roots.clone()) ); assert_eq!(params.sandbox, None); assert_eq!( @@ -2009,13 +1997,7 @@ mod tests { &config.permissions.effective_permission_profile(), config.cwd.as_path(), ); - let expected_runtime_workspace_roots = Some( - config - .workspace_roots - .iter() - .map(AbsolutePathBuf::to_path_buf) - .collect::>(), - ); + let expected_runtime_workspace_roots = Some(config.workspace_roots.clone()); let start = thread_start_params_from_config( &config, @@ -2265,6 +2247,79 @@ mod tests { ); } + #[tokio::test] + async fn terminal_visualization_instructions_are_gated_for_all_tui_thread_flows() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let mut config = build_config(&temp_dir).await; + config.developer_instructions = Some("Developer override.".to_string()); + let thread_id = ThreadId::new(); + + let control_start = thread_start_params_from_config( + &config, + ThreadParamsMode::Embedded, + /*remote_cwd_override*/ None, + /*session_start_source*/ None, + ); + let control_resume = thread_resume_params_from_config( + config.clone(), + thread_id, + ThreadParamsMode::Embedded, + /*remote_cwd_override*/ None, + ); + let control_fork = thread_fork_params_from_config( + config.clone(), + thread_id, + ThreadParamsMode::Embedded, + /*remote_cwd_override*/ None, + ); + + assert_eq!(control_start.developer_instructions, None); + assert_eq!(control_resume.developer_instructions, None); + assert_eq!( + control_fork.developer_instructions.as_deref(), + Some("Developer override.") + ); + + let _ = config + .features + .enable(Feature::TerminalVisualizationInstructions); + let treatment_start = thread_start_params_from_config( + &config, + ThreadParamsMode::Embedded, + /*remote_cwd_override*/ None, + /*session_start_source*/ None, + ); + let treatment_resume = thread_resume_params_from_config( + config.clone(), + thread_id, + ThreadParamsMode::Embedded, + /*remote_cwd_override*/ None, + ); + let treatment_fork = thread_fork_params_from_config( + config, + thread_id, + ThreadParamsMode::Embedded, + /*remote_cwd_override*/ None, + ); + let expected = format!( + "Developer override.\n\n{}", + crate::terminal_visualization_instructions::TERMINAL_VISUALIZATION_INSTRUCTIONS + ); + + assert_eq!( + treatment_start.developer_instructions.as_deref(), + Some(expected.as_str()) + ); + assert_eq!( + treatment_resume.developer_instructions.as_deref(), + Some(expected.as_str()) + ); + assert_eq!( + treatment_fork.developer_instructions.as_deref(), + Some(expected.as_str()) + ); + } + #[tokio::test] async fn resume_response_restores_turns_from_thread_items() { let temp_dir = tempfile::tempdir().expect("tempdir"); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 5a39b53aa94e..6ebfac43357f 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1258,6 +1258,11 @@ impl ChatComposer { self.draft.textarea.cursor() + if self.draft.is_bash_mode { 1 } else { 0 } } + #[cfg(test)] + pub(crate) fn cursor(&self) -> usize { + self.current_cursor() + } + fn history_navigation_cursor(&self) -> usize { if self.draft.is_bash_mode && self.draft.textarea.cursor() == 0 { 0 diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 9bada615f614..cdd4c752d3f7 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -786,6 +786,7 @@ impl BottomPane { local_image_paths, mention_bindings, ); + self.composer.move_cursor_to_end(); self.request_redraw(); } @@ -824,6 +825,11 @@ impl BottomPane { self.composer.current_text() } + #[cfg(test)] + pub(crate) fn composer_cursor(&self) -> usize { + self.composer.cursor() + } + pub(crate) fn composer_draft_snapshot(&self) -> chat_composer::ComposerDraftSnapshot { self.composer.draft_snapshot() } diff --git a/codex-rs/tui/src/chatwidget/protocol.rs b/codex-rs/tui/src/chatwidget/protocol.rs index 6d56330367f8..1ddbe40d54b2 100644 --- a/codex-rs/tui/src/chatwidget/protocol.rs +++ b/codex-rs/tui/src/chatwidget/protocol.rs @@ -233,6 +233,7 @@ impl ChatWidget { | ServerNotification::RemoteControlStatusChanged(_) | ServerNotification::ExternalAgentConfigImportCompleted(_) | ServerNotification::FsChanged(_) + | ServerNotification::TurnModerationMetadata(_) | ServerNotification::FuzzyFileSearchSessionUpdated(_) | ServerNotification::FuzzyFileSearchSessionCompleted(_) | ServerNotification::ThreadRealtimeTranscriptDelta(_) diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 08c6db56dc47..48caf8f54d21 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -13,6 +13,7 @@ use crate::bottom_pane::slash_commands::BuiltinCommandFlags; use crate::bottom_pane::slash_commands::ServiceTierCommand; use crate::bottom_pane::slash_commands::SlashCommandItem; use crate::bottom_pane::slash_commands::find_slash_command; +use crate::goal_display::GOAL_USAGE; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum SlashCommandDispatchSource { @@ -32,7 +33,6 @@ struct PreparedSlashCommandArgs { const SIDE_STARTING_CONTEXT_LABEL: &str = "Side starting..."; const SIDE_SLASH_COMMAND_UNAVAILABLE_HINT: &str = "Press Ctrl+C to return to the main thread first."; -const GOAL_USAGE: &str = "Usage: /goal "; const GOAL_USAGE_HINT: &str = "Example: /goal improve benchmark coverage"; const RAW_USAGE: &str = "Usage: /raw [on|off]"; diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chained_config_error_wraps_in_history_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chained_config_error_wraps_in_history_snapshot.snap new file mode 100644 index 000000000000..a3a7e21365e8 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chained_config_error_wraps_in_history_snapshot.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests/config_errors_tests.rs +expression: normalize_snapshot_paths(term.backend().vt100().screen().contents()) +--- +■ Failed to save default model: config/batchWrite failed +in TUI: Invalid configuration: features.fast_mode=true +is not supported; allowed set [fast_mode=false] diff --git a/codex-rs/tui/src/chatwidget/streaming.rs b/codex-rs/tui/src/chatwidget/streaming.rs index 3dd9f26ee998..848b0c2ef332 100644 --- a/codex-rs/tui/src/chatwidget/streaming.rs +++ b/codex-rs/tui/src/chatwidget/streaming.rs @@ -38,7 +38,8 @@ impl ChatWidget { // Consolidate the run of streaming AgentMessageCells into a single AgentMarkdownCell // that can re-render from source on resize. if let Some(source) = source { - let source = parse_assistant_markdown(&source).visible_markdown; + let source = + parse_assistant_markdown(&source, self.config.cwd.as_path()).visible_markdown; self.app_event_tx.send(AppEvent::ConsolidateAgentMessage { source, cwd: self.config.cwd.to_path_buf(), @@ -261,7 +262,7 @@ impl ChatWidget { AgentMessageContent::Text { text } => message.push_str(text), } } - let parsed = parse_assistant_markdown(&message); + let parsed = parse_assistant_markdown(&message, self.config.cwd.as_path()); self.finalize_completed_assistant_message( (!parsed.visible_markdown.is_empty()).then_some(parsed.visible_markdown.as_str()), ); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 2da524910aab..ba89379e734d 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -219,6 +219,8 @@ macro_rules! assert_chatwidget_snapshot { mod app_server; mod approval_requests; mod composer_submission; +#[path = "tests/config_errors_tests.rs"] +mod config_errors; mod exec_flow; mod goal_menu; mod goal_validation; diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index f3e32b2eb42a..154add1d6709 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -730,6 +730,7 @@ async fn queued_restore_with_remote_images_keeps_local_placeholder_mapping() { }); assert_eq!(chat.bottom_pane.composer_text(), text); + assert_eq!(chat.bottom_pane.composer_cursor(), text.len()); assert_eq!(chat.bottom_pane.composer_text_elements(), text_elements); assert_eq!(chat.bottom_pane.composer_local_images(), local_images); assert_eq!(chat.remote_image_urls(), remote_image_urls); diff --git a/codex-rs/tui/src/chatwidget/tests/config_errors_tests.rs b/codex-rs/tui/src/chatwidget/tests/config_errors_tests.rs new file mode 100644 index 000000000000..645c01f0f412 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/config_errors_tests.rs @@ -0,0 +1,25 @@ +use super::*; + +#[tokio::test] +async fn chained_config_error_wraps_in_history_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.add_error_message( + "Failed to save default model: config/batchWrite failed in TUI: Invalid configuration: features.fast_mode=true is not supported; allowed set [fast_mode=false]" + .to_string(), + ); + + let width = 56; + let height = 8; + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + term.set_viewport_area(ratatui::layout::Rect::new(0, 0, width, height)); + for lines in drain_insert_history(&mut rx) { + crate::insert_history::insert_history_lines(&mut term, lines) + .expect("insert history lines"); + } + + assert_chatwidget_snapshot!( + "chained_config_error_wraps_in_history_snapshot", + normalize_snapshot_paths(term.backend().vt100().screen().contents()) + ); +} diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index cfc9278ded35..b058b47cca39 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1429,6 +1429,7 @@ pub(super) fn plugins_test_detail( needs_auth: *needs_auth, }) .collect(), + app_templates: Vec::new(), mcp_servers: mcp_servers.iter().map(|name| (*name).to_string()).collect(), } } diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 099ae1d76d82..9c2802fb6041 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -784,6 +784,21 @@ async fn goal_control_slash_commands_emit_goal_events() { } } +#[tokio::test] +async fn goal_control_slash_command_without_thread_shows_full_usage() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); + + submit_composer_text(&mut chat, "/goal pause"); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected goal usage message"); + insta::assert_snapshot!( + lines_to_single_string(&cells[0]), + @"• Usage: /goal [|clear|edit|pause|resume] The session must start before you can change a goal." + ); +} + #[tokio::test] async fn goal_edit_slash_command_opens_goal_editor() { for thread_id in [Some(ThreadId::new()), None] { diff --git a/codex-rs/tui/src/chatwidget/turn_runtime.rs b/codex-rs/tui/src/chatwidget/turn_runtime.rs index bbea783cf205..24674d710e53 100644 --- a/codex-rs/tui/src/chatwidget/turn_runtime.rs +++ b/codex-rs/tui/src/chatwidget/turn_runtime.rs @@ -89,9 +89,9 @@ impl ChatWidget { // source only when no earlier item-level event (AgentMessageItem, plan // commit, review output) already recorded markdown for this turn. This // prevents the final summary from overwriting a more specific source. - let sanitized_last_agent_message = last_agent_message - .as_deref() - .map(|message| parse_assistant_markdown(message).visible_markdown); + let sanitized_last_agent_message = last_agent_message.as_deref().map(|message| { + parse_assistant_markdown(message, self.config.cwd.as_path()).visible_markdown + }); if let Some(message) = sanitized_last_agent_message .as_ref() .filter(|message| !message.is_empty()) diff --git a/codex-rs/tui/src/config_update.rs b/codex-rs/tui/src/config_update.rs index a882b21231c1..a71f34b52bc4 100644 --- a/codex-rs/tui/src/config_update.rs +++ b/codex-rs/tui/src/config_update.rs @@ -23,6 +23,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; use serde_json::Value as JsonValue; +use std::fmt::Display; use std::path::Path; use uuid::Uuid; @@ -43,6 +44,10 @@ pub(crate) fn app_scoped_key_path(app_id: &str, key_path: &str) -> String { format!("apps.{app_id}.{key_path}") } +pub(crate) fn format_config_error(err: &impl Display) -> String { + format!("{err:#}") +} + fn trusted_project_edit(project_path: &Path) -> ConfigEdit { let project_key = project_trust_key(project_path) .replace('\\', "\\\\") @@ -203,27 +208,5 @@ pub(crate) async fn write_skill_enabled( } #[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn app_scoped_key_path_quotes_dotted_app_ids() { - assert_eq!( - app_scoped_key_path("plugin.linear", "enabled"), - "apps.\"plugin.linear\".enabled" - ); - } - - #[test] - fn trusted_project_edit_targets_project_trust_level() { - assert_eq!( - trusted_project_edit(Path::new("/workspace/team.project")), - ConfigEdit { - key_path: "projects.\"/workspace/team.project\".trust_level".to_string(), - value: serde_json::json!("trusted"), - merge_strategy: MergeStrategy::Replace, - } - ); - } -} +#[path = "config_update_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/config_update_tests.rs b/codex-rs/tui/src/config_update_tests.rs new file mode 100644 index 000000000000..ee7508862c1b --- /dev/null +++ b/codex-rs/tui/src/config_update_tests.rs @@ -0,0 +1,40 @@ +use super::*; +use color_eyre::eyre::WrapErr; +use pretty_assertions::assert_eq; +use std::path::Path; + +#[test] +fn app_scoped_key_path_quotes_dotted_app_ids() { + assert_eq!( + app_scoped_key_path("plugin.linear", "enabled"), + "apps.\"plugin.linear\".enabled" + ); +} + +#[test] +fn trusted_project_edit_targets_project_trust_level() { + assert_eq!( + trusted_project_edit(Path::new("/workspace/team.project")), + ConfigEdit { + key_path: "projects.\"/workspace/team.project\".trust_level".to_string(), + value: serde_json::json!("trusted"), + merge_strategy: MergeStrategy::Replace, + } + ); +} + +#[test] +fn format_config_error_preserves_server_validation_message() { + let err = Err::<(), _>(color_eyre::eyre::eyre!( + "config/batchWrite failed: Invalid configuration: features.fast_mode=true violates \ + managed requirements; allowed set [fast_mode=false]" + )) + .wrap_err("config/batchWrite failed in TUI") + .unwrap_err(); + + assert_eq!( + format_config_error(&err), + "config/batchWrite failed in TUI: config/batchWrite failed: Invalid configuration: \ + features.fast_mode=true violates managed requirements; allowed set [fast_mode=false]" + ); +} diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index 562df95989aa..de5f79a6767d 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -702,7 +702,8 @@ mod tests { allowed_approval_policies: Some(vec![AskForApproval::OnRequest.to_core()]), allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]), allowed_sandbox_modes: Some(vec![SandboxModeRequirement::ReadOnly]), - allowed_permissions: None, + allowed_permission_profiles: None, + default_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: Some(vec![WebSearchModeRequirement::Cached]), allow_managed_hooks_only: Some(true), @@ -973,7 +974,8 @@ approval_policy = "never" allowed_approval_policies: None, allowed_approvals_reviewers: None, allowed_sandbox_modes: None, - allowed_permissions: None, + allowed_permission_profiles: None, + default_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: Some(Vec::new()), allow_managed_hooks_only: None, diff --git a/codex-rs/tui/src/external_agent_config_migration_startup.rs b/codex-rs/tui/src/external_agent_config_migration_startup.rs index 3ec2ca390337..ce184303c273 100644 --- a/codex-rs/tui/src/external_agent_config_migration_startup.rs +++ b/codex-rs/tui/src/external_agent_config_migration_startup.rs @@ -25,7 +25,7 @@ pub(crate) enum ExternalAgentConfigMigrationStartupOutcome { ExitRequested, } -fn should_show_external_agent_config_migration_prompt( +pub(crate) fn should_show_external_agent_config_migration_prompt( config: &Config, entered_trust_nux: bool, ) -> bool { diff --git a/codex-rs/tui/src/git_action_directives.rs b/codex-rs/tui/src/git_action_directives.rs index 4dda4d451c3b..c54744c84502 100644 --- a/codex-rs/tui/src/git_action_directives.rs +++ b/codex-rs/tui/src/git_action_directives.rs @@ -1,6 +1,8 @@ -//! Codex App git action directives embedded in assistant markdown. +//! Codex App directives embedded in assistant markdown. +use std::collections::HashMap; use std::collections::HashSet; +use std::path::Path; #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub(crate) enum GitActionDirective { @@ -50,12 +52,16 @@ impl ParsedAssistantMarkdown { } } -pub(crate) fn parse_assistant_markdown(markdown: &str) -> ParsedAssistantMarkdown { +pub(crate) fn parse_assistant_markdown(markdown: &str, cwd: &Path) -> ParsedAssistantMarkdown { let mut git_actions = Vec::new(); let mut seen = HashSet::new(); let mut visible_lines = Vec::new(); for line in markdown.lines() { + if let Some(rewritten) = rewrite_code_comment_line(line, cwd) { + visible_lines.push(rewritten.trim_end().to_string()); + continue; + } let (visible_line, line_actions) = strip_line_directives(line); for action in line_actions { if seen.insert(action.clone()) { @@ -78,6 +84,53 @@ pub(crate) fn parse_assistant_markdown(markdown: &str) -> ParsedAssistantMarkdow } } +fn rewrite_code_comment_line(line: &str, cwd: &Path) -> Option { + let content = line.trim_start_matches([' ', '\t']); + let indent = &line[..line.len() - content.len()]; + let marker_length = content.bytes().take_while(|byte| *byte == b':').count(); + if !(1..=3).contains(&marker_length) { + return None; + } + + let directive = content[marker_length..].strip_prefix("code-comment{")?; + let (attributes, suffix) = directive.rsplit_once('}')?; + let attributes = parse_code_comment_attributes(attributes)?; + let title = attributes.get("title")?; + let body = attributes.get("body")?; + let file = attributes.get("file")?; + let title = title.trim(); + let body = body.trim(); + let file = file.trim(); + (!title.is_empty() && !body.is_empty() && !file.is_empty()).then_some(())?; + + let start = directive_integer(&attributes, "start").unwrap_or(1).max(1); + let end = directive_integer(&attributes, "end") + .unwrap_or(start) + .max(start); + let title = if title_has_priority(title) { + title.to_string() + } else if let Some(priority @ 0..=3) = directive_integer(&attributes, "priority") { + format!("[P{priority}] {title}") + } else { + title.to_string() + }; + let file_path = Path::new(file); + let file = file_path + .strip_prefix(cwd) + .unwrap_or(file_path) + .to_string_lossy() + .replace('\\', "/"); + let location = if start == end { + format!("{file}:{start}") + } else { + format!("{file}:{start}-{end}") + }; + + Some(format!( + "{indent}- {title} — {location}\n{indent} {body}{suffix}" + )) +} + fn strip_line_directives(line: &str) -> (String, Vec) { let mut visible = String::new(); let mut actions = Vec::new(); @@ -106,6 +159,46 @@ fn strip_line_directives(line: &str) -> (String, Vec) { (visible, actions) } +fn directive_integer(attributes: &HashMap, name: &str) -> Option { + attributes + .get(name)? + .trim() + .trim_start_matches(['P', 'p']) + .parse() + .ok() +} + +fn title_has_priority(title: &str) -> bool { + let bytes = title.trim_start().as_bytes(); + bytes.len() >= 4 + && bytes[0] == b'[' + && matches!(bytes[1], b'P' | b'p') + && bytes[2].is_ascii_digit() + && bytes[3] == b']' +} + +fn parse_code_comment_attributes(input: &str) -> Option> { + let mut attributes = HashMap::new(); + let mut rest = input.trim(); + while !rest.is_empty() { + let equals = rest.find('=')?; + let name = rest[..equals].trim(); + if name.is_empty() { + return None; + } + rest = rest[equals + 1..].trim_start(); + let (value, next) = if let Some(quoted) = rest.strip_prefix('"') { + parse_quoted_value(quoted)? + } else { + let end = rest.find(char::is_whitespace).unwrap_or(rest.len()); + (rest[..end].to_string(), &rest[end..]) + }; + attributes.insert(name.to_string(), value); + rest = next.trim_start(); + } + Some(attributes) +} + fn parse_git_action(name: &str, attributes: &str) -> Option { let attrs = parse_attributes(attributes)?; let cwd = attrs.get("cwd")?.clone(); @@ -153,14 +246,36 @@ fn parse_attributes(input: &str) -> Option Option<(String, &str)> { + let mut value = String::new(); + let mut characters = input.char_indices().peekable(); + + while let Some((index, character)) = characters.next() { + if character == '"' { + return Some((value, &input[index + 1..])); + } + match character { + '\\' if characters.peek().is_some_and(|(_, next)| *next == '"') => { + value.push('"'); + characters.next(); + } + _ => value.push(character), + } + } + + None +} + #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; #[test] fn strips_and_parses_git_action_directives() { let parsed = parse_assistant_markdown( - "Done\n\n::git-stage{cwd=\"/repo\"} ::git-push{cwd=\"/repo\" branch=\"feat/x\"}", + "Done\n\n::git-stage{cwd=\"/repo\"} ::git-push{cwd=\"/repo\" branch=\"feat/x\"} ::git-stage{cwd=\"C:\\repo\\\"}", + Path::new("/repo"), ); assert_eq!(parsed.visible_markdown, "Done"); @@ -174,22 +289,50 @@ mod tests { cwd: "/repo".to_string(), branch: "feat/x".to_string(), }, + GitActionDirective::Stage { + cwd: "C:\\repo\\".to_string(), + }, ] ); } #[test] fn hides_malformed_directives_without_materializing_rows() { - let parsed = parse_assistant_markdown("Done ::git-push{cwd=\"/repo\"}"); + let parsed = parse_assistant_markdown("Done ::git-push{cwd=\"/repo\"}", Path::new("/repo")); assert_eq!(parsed.visible_markdown, "Done"); assert!(parsed.git_actions.is_empty()); } + #[test] + fn renders_code_comment_directives_as_markdown() { + let parsed = parse_assistant_markdown( + concat!( + "Found two issues.\n\n", + r#"::code-comment{title="Fix body= parsing" body="Keep role=\"tab\", ::git-stage{cwd=/tmp}, file=, and \n literal." file="/repo/src/app.ts" start=10 end=12 priority="P2"}"#, + "\n\n", + r#":::code-comment{title="[P1] Clamp the range" body="The line range should match the App." file="codex/src/range.ts" start=8 end=2 priority=3}"#, + ), + Path::new("/repo"), + ); + + insta::assert_snapshot!("code_comment_directive_fallback", parsed.visible_markdown); + assert!(parsed.git_actions.is_empty()); + } + + #[test] + fn preserves_non_directive_and_malformed_code_comment_text() { + let markdown = "Mention `::code-comment{title=\"Example\"}` inline.\n::code-comment{title=\"Missing body\" file=\"/repo/src/app.ts\"}"; + let parsed = parse_assistant_markdown(markdown, Path::new("/repo")); + + assert_eq!(parsed.visible_markdown, markdown); + } + #[test] fn last_created_branch_cwd_uses_the_last_matching_directive() { let parsed = parse_assistant_markdown( "::git-create-branch{cwd=\"/first\" branch=\"first\"}\n::git-push{cwd=\"/repo\" branch=\"first\"}\n::git-create-branch{cwd=\"/second\" branch=\"second\"}", + Path::new("/repo"), ); assert_eq!(parsed.last_created_branch_cwd(), Some("/second")); diff --git a/codex-rs/tui/src/goal_display.rs b/codex-rs/tui/src/goal_display.rs index 961b87f16eb1..210bac107678 100644 --- a/codex-rs/tui/src/goal_display.rs +++ b/codex-rs/tui/src/goal_display.rs @@ -2,6 +2,8 @@ use crate::status::format_tokens_compact; use codex_app_server_protocol::ThreadGoal; use codex_app_server_protocol::ThreadGoalStatus; +pub(crate) const GOAL_USAGE: &str = "Usage: /goal [|clear|edit|pause|resume]"; + pub(crate) fn format_goal_elapsed_seconds(seconds: i64) -> String { let seconds = seconds.max(0) as u64; if seconds < 60 { diff --git a/codex-rs/tui/src/history_cell/messages.rs b/codex-rs/tui/src/history_cell/messages.rs index ed899f3b2e8a..787f999cf1ee 100644 --- a/codex-rs/tui/src/history_cell/messages.rs +++ b/codex-rs/tui/src/history_cell/messages.rs @@ -423,7 +423,7 @@ impl HistoryCell for StreamingAgentTailCell { fn display_hyperlink_lines(&self, _width: u16) -> Vec { // Tail lines are already rendered at the controller's current stream width. // Re-wrapping them here can split table borders and produce malformed in-flight rows. - prefix_hyperlink_lines( + let mut lines = prefix_hyperlink_lines( self.lines.clone(), if self.is_first_line { "• ".dim() @@ -431,7 +431,19 @@ impl HistoryCell for StreamingAgentTailCell { " ".into() }, " ".into(), - ) + ); + for line in &mut lines { + if line + .line + .spans + .iter() + .all(|span| span.content.chars().all(char::is_whitespace)) + { + line.line = Line::default().style(line.line.style); + line.hyperlinks.clear(); + } + } + lines } fn transcript_hyperlink_lines(&self, width: u16) -> Vec { diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index 63a04d19d4f1..ec3ae49f2159 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -45,6 +45,24 @@ fn test_cwd() -> PathBuf { std::env::temp_dir() } +#[test] +fn streaming_agent_tail_blank_line_uses_one_viewport_row() { + let cell = StreamingAgentTailCell::new( + vec![ + HyperlinkLine::from("first"), + HyperlinkLine::from(""), + HyperlinkLine::from("second"), + ], + /*is_first_line*/ false, + ); + + let lines = cell.display_lines(/*width*/ 80); + insta::assert_snapshot!(render_lines(&lines).join("\n"), @" first + + second"); + assert_eq!(cell.desired_height(/*width*/ 80), 3); +} + fn stdio_server_config( command: &str, args: Vec<&str>, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 7d57c26d3833..6f716a8123e6 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -71,6 +71,7 @@ use std::fs::OpenOptions; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; +use std::time::Instant; pub use token_usage::TokenUsage; use tracing::Level; use tracing::error; @@ -187,6 +188,7 @@ mod terminal_hyperlinks; mod terminal_palette; mod terminal_probe; mod terminal_title; +mod terminal_visualization_instructions; mod text_formatting; mod theme_picker; mod token_usage; @@ -276,6 +278,7 @@ pub(crate) mod test_support; use crate::onboarding::onboarding_screen::OnboardingScreenArgs; use crate::onboarding::onboarding_screen::run_onboarding_app; use crate::startup_hooks_review::StartupHooksReviewOutcome; +use crate::startup_hooks_review::load_startup_hooks_review_entry; use crate::startup_hooks_review::maybe_run_startup_hooks_review; use crate::tui::Tui; pub use cli::Cli; @@ -744,18 +747,37 @@ async fn lookup_latest_session_target_with_app_server( cwd_filter: Option<&Path>, include_non_interactive: bool, ) -> color_eyre::Result> { - let response = app_server - .thread_list(latest_session_lookup_params( - app_server.uses_remote_workspace(), - config, - cwd_filter, - include_non_interactive, - )) - .await?; - Ok(response - .data - .into_iter() - .find_map(session_target_from_app_server_thread)) + let uses_remote_workspace = app_server.uses_remote_workspace(); + for lookup_mode in [ + LatestSessionLookupMode::StateDbOnly, + LatestSessionLookupMode::ScanAndRepair, + ] { + let response = app_server + .thread_list(latest_session_lookup_params( + uses_remote_workspace, + config, + cwd_filter, + include_non_interactive, + lookup_mode, + )) + .await?; + let target = response + .data + .into_iter() + .find_map(session_target_from_app_server_thread); + if target.as_ref().is_some_and(|target| { + uses_remote_workspace || target.path.as_deref().is_some_and(std::path::Path::exists) + }) { + return Ok(target); + } + } + Ok(None) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum LatestSessionLookupMode { + StateDbOnly, + ScanAndRepair, } fn latest_session_lookup_params( @@ -763,6 +785,7 @@ fn latest_session_lookup_params( config: &Config, cwd_filter: Option<&Path>, include_non_interactive: bool, + lookup_mode: LatestSessionLookupMode, ) -> ThreadListParams { ThreadListParams { cursor: None, @@ -777,7 +800,10 @@ fn latest_session_lookup_params( source_kinds: Some(resume_source_kinds(include_non_interactive)), archived: Some(false), cwd: cwd_filter.map(|cwd| ThreadListCwdFilter::One(cwd.to_string_lossy().to_string())), - use_state_db_only: false, + use_state_db_only: match lookup_mode { + LatestSessionLookupMode::StateDbOnly => true, + LatestSessionLookupMode::ScanAndRepair => false, + }, search_term: None, } } @@ -1781,11 +1807,33 @@ async fn run_ratatui_app( resume_picker::SessionSelection::Resume(_) ); let bypass_hook_trust_for_startup_review = config.bypass_hook_trust && !is_persistent_resume; + let hooks_request_handle = app_server.request_handle(); + let hooks_cwd = config.cwd.to_path_buf(); + let startup_prefetch_started_at = Instant::now(); + let should_defer_bootstrap = + external_agent_config_migration_startup::should_show_external_agent_config_migration_prompt( + &config, + should_show_trust_screen_flag, + ); + let (startup_bootstrap, startup_hooks_entry) = if should_defer_bootstrap { + ( + None, + load_startup_hooks_review_entry(hooks_request_handle, hooks_cwd).await, + ) + } else { + let (bootstrap, entry) = tokio::join!( + app_server.bootstrap(&config), + load_startup_hooks_review_entry(hooks_request_handle, hooks_cwd), + ); + (Some(bootstrap?), entry) + }; + let startup_elapsed_before_app = startup_prefetch_started_at.elapsed(); let startup_hooks_browser = match maybe_run_startup_hooks_review( &mut app_server, &mut tui, &config, bypass_hook_trust_for_startup_review, + startup_hooks_entry, ) .await? { @@ -1810,6 +1858,8 @@ async fn run_ratatui_app( app_server_target, state_db, environment_manager, + startup_elapsed_before_app, + startup_bootstrap, startup_hooks_browser, ) .await; @@ -2040,6 +2090,85 @@ mod tests { .await } + fn write_session_rollout( + codex_home: &Path, + filename_ts: &str, + meta_rfc3339: &str, + preview: &str, + model_provider: &str, + cwd: &Path, + ) -> color_eyre::Result { + let uuid = Uuid::new_v4(); + let uuid_str = uuid.to_string(); + let thread_id = ThreadId::from_string(&uuid_str)?; + let year = &filename_ts[0..4]; + let month = &filename_ts[5..7]; + let day = &filename_ts[8..10]; + let rollout_path = codex_home + .join("sessions") + .join(year) + .join(month) + .join(day) + .join(format!("rollout-{filename_ts}-{uuid_str}.jsonl")); + let parent = rollout_path + .parent() + .ok_or_else(|| color_eyre::eyre::eyre!("rollout path is missing a parent directory"))?; + std::fs::create_dir_all(parent)?; + + let session_meta = codex_protocol::protocol::SessionMeta { + id: thread_id, + timestamp: meta_rfc3339.to_string(), + cwd: cwd.to_path_buf(), + originator: "codex".to_string(), + cli_version: "0.0.0".to_string(), + source: codex_protocol::protocol::SessionSource::Cli, + model_provider: Some(model_provider.to_string()), + ..Default::default() + }; + let session_meta = serde_json::to_value(codex_protocol::protocol::SessionMetaLine { + meta: session_meta, + git: None, + })?; + let lines = [ + serde_json::json!({ + "timestamp": meta_rfc3339, + "type": "session_meta", + "payload": session_meta, + }) + .to_string(), + serde_json::json!({ + "timestamp": meta_rfc3339, + "type": "response_item", + "payload": { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": preview}], + }, + }) + .to_string(), + serde_json::json!({ + "timestamp": meta_rfc3339, + "type": "event_msg", + "payload": { + "type": "user_message", + "message": preview, + "kind": "plain", + }, + }) + .to_string(), + ]; + std::fs::write(&rollout_path, lines.join("\n") + "\n")?; + let updated_at = + chrono::DateTime::parse_from_rfc3339(meta_rfc3339)?.with_timezone(&chrono::Utc); + let times = std::fs::FileTimes::new().set_modified(updated_at.into()); + std::fs::OpenOptions::new() + .append(true) + .open(rollout_path)? + .set_times(times)?; + + Ok(thread_id) + } + #[test] fn startup_removes_legacy_tui_log_file() -> std::io::Result<()> { let temp_dir = TempDir::new()?; @@ -2328,13 +2457,27 @@ mod tests { &config, Some(cwd.as_path()), /*include_non_interactive*/ false, + LatestSessionLookupMode::StateDbOnly, ); - assert_eq!(params.model_providers, Some(vec![config.model_provider_id])); + assert_eq!( + params.model_providers, + Some(vec![config.model_provider_id.clone()]) + ); assert_eq!( params.cwd, Some(ThreadListCwdFilter::One(cwd.to_string_lossy().to_string())) ); + assert!(params.use_state_db_only); + + let scan_params = latest_session_lookup_params( + /*uses_remote_workspace*/ false, + &config, + Some(cwd.as_path()), + /*include_non_interactive*/ false, + LatestSessionLookupMode::ScanAndRepair, + ); + assert!(!scan_params.use_state_db_only); Ok(()) } @@ -2355,6 +2498,7 @@ mod tests { &config, Some(cwd.as_path()), /*include_non_interactive*/ false, + LatestSessionLookupMode::StateDbOnly, ); assert_eq!(params.model_providers, Some(vec![config.model_provider_id])); @@ -2372,8 +2516,11 @@ mod tests { let config = build_config(&temp_dir).await?; let params = latest_session_lookup_params( - /*uses_remote_workspace*/ true, &config, /*cwd_filter*/ None, + /*uses_remote_workspace*/ true, + &config, + /*cwd_filter*/ None, /*include_non_interactive*/ false, + LatestSessionLookupMode::StateDbOnly, ); assert_eq!(params.model_providers, None); @@ -2388,8 +2535,11 @@ mod tests { let config = build_config(&temp_dir).await?; let params = latest_session_lookup_params( - /*uses_remote_workspace*/ true, &config, /*cwd_filter*/ None, + /*uses_remote_workspace*/ true, + &config, + /*cwd_filter*/ None, /*include_non_interactive*/ true, + LatestSessionLookupMode::StateDbOnly, ); assert_eq!( @@ -2416,6 +2566,7 @@ mod tests { &config, Some(cwd), /*include_non_interactive*/ false, + LatestSessionLookupMode::StateDbOnly, ); assert_eq!(params.model_providers, None); @@ -2455,85 +2606,6 @@ mod tests { #[tokio::test] async fn fork_last_filters_latest_session_by_cwd_unless_show_all() -> color_eyre::Result<()> { - fn write_session_rollout( - codex_home: &Path, - filename_ts: &str, - meta_rfc3339: &str, - preview: &str, - model_provider: &str, - cwd: &Path, - ) -> color_eyre::Result { - let uuid = Uuid::new_v4(); - let uuid_str = uuid.to_string(); - let thread_id = ThreadId::from_string(&uuid_str)?; - let year = &filename_ts[0..4]; - let month = &filename_ts[5..7]; - let day = &filename_ts[8..10]; - let rollout_path = codex_home - .join("sessions") - .join(year) - .join(month) - .join(day) - .join(format!("rollout-{filename_ts}-{uuid_str}.jsonl")); - let parent = rollout_path.parent().ok_or_else(|| { - color_eyre::eyre::eyre!("rollout path is missing a parent directory") - })?; - std::fs::create_dir_all(parent)?; - - let session_meta = codex_protocol::protocol::SessionMeta { - id: thread_id, - timestamp: meta_rfc3339.to_string(), - cwd: cwd.to_path_buf(), - originator: "codex".to_string(), - cli_version: "0.0.0".to_string(), - source: codex_protocol::protocol::SessionSource::Cli, - model_provider: Some(model_provider.to_string()), - ..Default::default() - }; - let session_meta = serde_json::to_value(codex_protocol::protocol::SessionMetaLine { - meta: session_meta, - git: None, - })?; - let lines = [ - serde_json::json!({ - "timestamp": meta_rfc3339, - "type": "session_meta", - "payload": session_meta, - }) - .to_string(), - serde_json::json!({ - "timestamp": meta_rfc3339, - "type": "response_item", - "payload": { - "type": "message", - "role": "user", - "content": [{"type": "input_text", "text": preview}], - }, - }) - .to_string(), - serde_json::json!({ - "timestamp": meta_rfc3339, - "type": "event_msg", - "payload": { - "type": "user_message", - "message": preview, - "kind": "plain", - }, - }) - .to_string(), - ]; - std::fs::write(&rollout_path, lines.join("\n") + "\n")?; - let updated_at = - chrono::DateTime::parse_from_rfc3339(meta_rfc3339)?.with_timezone(&chrono::Utc); - let times = std::fs::FileTimes::new().set_modified(updated_at.into()); - std::fs::OpenOptions::new() - .append(true) - .open(rollout_path)? - .set_times(times)?; - - Ok(thread_id) - } - let temp_dir = TempDir::new()?; let project_cwd = temp_dir.path().join("project"); let other_cwd = temp_dir.path().join("other-project"); @@ -2603,6 +2675,51 @@ mod tests { Ok(()) } + #[tokio::test] + async fn latest_session_lookup_falls_back_for_rollout_missing_from_state_db() + -> color_eyre::Result<()> { + let temp_dir = TempDir::new()?; + let project_cwd = temp_dir.path().join("project"); + std::fs::create_dir_all(&project_cwd)?; + let config = ConfigBuilder::default() + .codex_home(temp_dir.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + cwd: Some(project_cwd.clone()), + ..Default::default() + }) + .build() + .await?; + let mut app_server = AppServerSession::new( + codex_app_server_client::AppServerClient::InProcess( + start_test_embedded_app_server(config.clone()).await?, + ), + ThreadParamsMode::Embedded, + ); + + // Simulate a legacy writer creating a rollout after the state DB backfill completed. + let thread_id = write_session_rollout( + temp_dir.path(), + "2025-01-02T10-00-00", + "2025-01-02T10:00:00Z", + "legacy writer session", + config.model_provider_id.as_str(), + &project_cwd, + )?; + + let target = lookup_latest_session_target_with_app_server( + &mut app_server, + &config, + Some(project_cwd.as_path()), + /*include_non_interactive*/ false, + ) + .await? + .expect("expected scan-and-repair fallback to find the rollout"); + app_server.shutdown().await?; + + assert_eq!(target.thread_id, thread_id); + Ok(()) + } + #[tokio::test] async fn config_cwd_for_app_server_target_omits_cwd_for_remote_sessions() -> std::io::Result<()> { diff --git a/codex-rs/tui/src/local_chatgpt_auth.rs b/codex-rs/tui/src/local_chatgpt_auth.rs index 270d64f5f7fb..d7ca01f618ba 100644 --- a/codex-rs/tui/src/local_chatgpt_auth.rs +++ b/codex-rs/tui/src/local_chatgpt_auth.rs @@ -109,6 +109,7 @@ mod tests { }), last_refresh: Some(Utc::now()), agent_identity: None, + personal_access_token: None, }; save_auth(codex_home, &auth, AuthCredentialsStoreMode::File) .expect("chatgpt auth should save"); @@ -156,6 +157,7 @@ mod tests { tokens: None, last_refresh: None, agent_identity: None, + personal_access_token: None, }, AuthCredentialsStoreMode::File, ) diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 0d8a1fa77371..e10700ed823b 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -10,6 +10,7 @@ use codex_app_server_client::AppServerRequestHandle; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; +#[cfg(test)] use codex_app_server_protocol::AuthMode as AppServerAuthMode; use codex_app_server_protocol::CancelLoginAccountParams; use codex_app_server_protocol::ClientRequest; @@ -845,8 +846,7 @@ impl AuthModeWidget { fn handle_existing_chatgpt_login(&mut self) -> bool { if matches!( self.login_status, - LoginStatus::AuthMode(AppServerAuthMode::Chatgpt) - | LoginStatus::AuthMode(AppServerAuthMode::ChatgptAuthTokens) + LoginStatus::AuthMode(auth_mode) if auth_mode.has_chatgpt_account() ) { *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; self.request_frame.schedule_frame(); @@ -1106,17 +1106,22 @@ mod tests { } #[tokio::test] - async fn existing_chatgpt_auth_tokens_login_counts_as_signed_in() { - let (mut widget, _tmp) = widget_forced_chatgpt().await; - widget.login_status = LoginStatus::AuthMode(AppServerAuthMode::ChatgptAuthTokens); - - let handled = widget.handle_existing_chatgpt_login(); - - assert_eq!(handled, true); - assert!(matches!( - &*widget.sign_in_state.read().unwrap(), - SignInState::ChatGptSuccess - )); + async fn existing_non_oauth_chatgpt_login_counts_as_signed_in() { + for auth_mode in [ + AppServerAuthMode::ChatgptAuthTokens, + AppServerAuthMode::PersonalAccessToken, + ] { + let (mut widget, _tmp) = widget_forced_chatgpt().await; + widget.login_status = LoginStatus::AuthMode(auth_mode); + + let handled = widget.handle_existing_chatgpt_login(); + + assert_eq!(handled, true); + assert!(matches!( + &*widget.sign_in_state.read().unwrap(), + SignInState::ChatGptSuccess + )); + } } #[tokio::test] diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 5291934a31f1..e4c4b346d50d 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -32,6 +32,7 @@ use codex_protocol::config_types::ForcedLoginMethod; use crate::LoginStatus; use crate::app_server_session::AppServerSession; +use crate::config_update::format_config_error; use crate::config_update::write_trusted_project; use crate::key_hint::KeyBindingListExt; use crate::legacy_core::config::Config; @@ -605,8 +606,9 @@ async fn persist_selected_trust( match result { Ok(()) => true, Err(error) => { + let error = format_config_error(&error); tracing::error!( - "failed to persist trusted project state for {}: {error:?}", + "failed to persist trusted project state for {}: {error}", trust_target.display() ); if let Step::TrustDirectory(widget) = &mut onboarding_screen.steps[trust_step_index] { diff --git a/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__renders_snapshot_for_trust_error.snap b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__renders_snapshot_for_trust_error.snap new file mode 100644 index 000000000000..6f7a495d1a5c --- /dev/null +++ b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__renders_snapshot_for_trust_error.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/onboarding/trust_directory.rs +expression: terminal.backend() +--- +> You are in /workspace/project + + Do you trust the contents of this directory? Working with untrusted + contents comes with higher risk of prompt injection. Trusting the + directory allows project-local config, hooks, and exec policies to + load. + +› 1. Yes, continue + 2. No, quit + + Failed to set trust for /workspace/project: config/batchWrite failed + in TUI: Invalid configuration: features.fast_mode=true is not + supported; allowed set [fast_mode=false] + + Press enter to continue diff --git a/codex-rs/tui/src/onboarding/trust_directory.rs b/codex-rs/tui/src/onboarding/trust_directory.rs index 36d13e5a5a10..8a98cbca6bb9 100644 --- a/codex-rs/tui/src/onboarding/trust_directory.rs +++ b/codex-rs/tui/src/onboarding/trust_directory.rs @@ -191,6 +191,18 @@ mod tests { use ratatui::Terminal; use std::path::PathBuf; + fn widget(error: Option) -> TrustDirectoryWidget { + TrustDirectoryWidget { + cwd: PathBuf::from("/workspace/project"), + trust_target: PathBuf::from("/workspace/project"), + show_windows_create_sandbox_hint: false, + should_quit: false, + selection: None, + highlighted: TrustDirectorySelection::Trust, + error, + } + } + #[test] fn release_event_does_not_change_selection() { let mut widget = TrustDirectoryWidget { @@ -217,15 +229,7 @@ mod tests { #[test] fn renders_snapshot_for_git_repo() { - let widget = TrustDirectoryWidget { - cwd: PathBuf::from("/workspace/project"), - trust_target: PathBuf::from("/workspace/project"), - show_windows_create_sandbox_hint: false, - should_quit: false, - selection: None, - highlighted: TrustDirectorySelection::Trust, - error: None, - }; + let widget = widget(/*error*/ None); let mut terminal = Terminal::new(VT100Backend::new(/*width*/ 70, /*height*/ 14)).expect("terminal"); @@ -235,4 +239,20 @@ mod tests { insta::assert_snapshot!(terminal.backend()); } + + #[test] + fn renders_snapshot_for_trust_error() { + let widget = widget(Some( + "Failed to set trust for /workspace/project: config/batchWrite failed in TUI: Invalid configuration: features.fast_mode=true is not supported; allowed set [fast_mode=false]" + .to_string(), + )); + + let mut terminal = + Terminal::new(VT100Backend::new(/*width*/ 70, /*height*/ 18)).expect("terminal"); + terminal + .draw(|f| (&widget).render_ref(f.area(), f.buffer_mut())) + .expect("draw"); + + insta::assert_snapshot!(terminal.backend()); + } } diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index c261ade7d2e9..de9a46e8ffac 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -774,6 +774,7 @@ async fn load_transcript_preview( .thread_read(thread_id, /*include_turns*/ true) .await .map_err(std::io::Error::other)?; + let cwd = thread.cwd.as_path(); let mut lines = thread .turns .iter() @@ -794,7 +795,7 @@ async fn load_transcript_preview( }), ThreadItem::AgentMessage { text, .. } => Some(TranscriptPreviewLine { speaker: TranscriptPreviewSpeaker::Assistant, - text: parse_assistant_markdown(text).visible_markdown, + text: parse_assistant_markdown(text, cwd).visible_markdown, }), _ => None, }) diff --git a/codex-rs/tui/src/resume_picker/transcript.rs b/codex-rs/tui/src/resume_picker/transcript.rs index abf13bd144c0..e211a7126591 100644 --- a/codex-rs/tui/src/resume_picker/transcript.rs +++ b/codex-rs/tui/src/resume_picker/transcript.rs @@ -67,7 +67,7 @@ pub(crate) fn thread_to_transcript_cells( })); } ThreadItem::AgentMessage { text, .. } => { - let parsed = parse_assistant_markdown(text); + let parsed = parse_assistant_markdown(text, cwd); if !parsed.visible_markdown.trim().is_empty() { cells.push(Arc::new(AgentMarkdownCell::new( parsed.visible_markdown, diff --git a/codex-rs/tui/src/snapshots/codex_tui__git_action_directives__tests__code_comment_directive_fallback.snap b/codex-rs/tui/src/snapshots/codex_tui__git_action_directives__tests__code_comment_directive_fallback.snap new file mode 100644 index 000000000000..630a3a542b87 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__git_action_directives__tests__code_comment_directive_fallback.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/git_action_directives.rs +expression: parsed.visible_markdown +--- +Found two issues. + +- [P2] Fix body= parsing — src/app.ts:10-12 + Keep role="tab", ::git-stage{cwd=/tmp}, file=, and \n literal. + +- [P1] Clamp the range — codex/src/range.ts:8 + The line range should match the App. diff --git a/codex-rs/tui/src/snapshots/codex_tui__startup_hooks_review__tests__startup_hooks_review_prompt.snap b/codex-rs/tui/src/snapshots/codex_tui__startup_hooks_review__tests__startup_hooks_review_prompt.snap index 038534e98a49..49106a3f5b00 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__startup_hooks_review__tests__startup_hooks_review_prompt.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__startup_hooks_review__tests__startup_hooks_review_prompt.snap @@ -2,13 +2,13 @@ source: tui/src/startup_hooks_review.rs expression: "render_lines(&view, 80)" --- - - Hooks need review - 2 hooks are new or changed. - Hooks can run outside the sandbox after you trust them. - -› 1. Review hooks - 2. Trust all and continue - 3. Continue without trusting (hooks won't run) - + + Hooks need review + 2 hooks are new or changed. + Hooks can run outside the sandbox after you trust them. + +› 1. Review hooks + 2. Trust all and continue + 3. Continue without trusting (hooks won't run) + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/snapshots/codex_tui__startup_hooks_review__tests__startup_hooks_review_prompt_with_trust_error.snap b/codex-rs/tui/src/snapshots/codex_tui__startup_hooks_review__tests__startup_hooks_review_prompt_with_trust_error.snap index 340c0d233c52..574c31b497d8 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__startup_hooks_review__tests__startup_hooks_review_prompt_with_trust_error.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__startup_hooks_review__tests__startup_hooks_review_prompt_with_trust_error.snap @@ -1,15 +1,17 @@ --- source: tui/src/startup_hooks_review.rs -expression: "render_lines(&view, 80)" +expression: "render_lines(&view, 62)" --- - - Hooks need review - 2 hooks are new or changed. - Hooks can run outside the sandbox after you trust them. - Failed to trust hooks: disk full - -› 1. Review hooks - 2. Trust all and continue - 3. Continue without trusting (hooks won't run) - + + Hooks need review + 2 hooks are new or changed. + Hooks can run outside the sandbox after you trust them. + Failed to trust hooks: config/batchWrite failed in TUI: + Invalid configuration: features.fast_mode=true is not + supported; allowed set [fast_mode=false] + +› 1. Review hooks + 2. Trust all and continue + 3. Continue without trusting (hooks won't run) + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/startup_hooks_review.rs b/codex-rs/tui/src/startup_hooks_review.rs index 559c796aa0d7..97c53f714446 100644 --- a/codex-rs/tui/src/startup_hooks_review.rs +++ b/codex-rs/tui/src/startup_hooks_review.rs @@ -5,7 +5,9 @@ use ratatui::layout::Rect; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::Clear; +use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; +use ratatui::widgets::Wrap; use tokio::sync::mpsc::unbounded_channel; use tokio_stream::StreamExt; @@ -17,6 +19,7 @@ use crate::bottom_pane::ListSelectionView; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::popup_consts::standard_popup_hint_line_for_keymap; +use crate::config_update::format_config_error; use crate::hooks_rpc::HookTrustUpdate; use crate::hooks_rpc::fetch_hooks_list; use crate::hooks_rpc::hook_needs_review; @@ -28,7 +31,9 @@ use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use crate::tui::Tui; use crate::tui::TuiEvent; +use codex_app_server_client::AppServerRequestHandle; use codex_app_server_protocol::HooksListEntry; +use std::path::PathBuf; pub(crate) enum StartupHooksReviewOutcome { Continue, @@ -42,21 +47,32 @@ enum StartupHooksReviewSelection { ContinueWithoutTrusting, } +pub(crate) async fn load_startup_hooks_review_entry( + request_handle: AppServerRequestHandle, + cwd: PathBuf, +) -> HooksListEntry { + let response = match fetch_hooks_list(request_handle, cwd.clone()).await { + Ok(response) => response, + Err(err) => { + tracing::warn!("failed to load startup hook review state: {err:#}"); + return HooksListEntry { + cwd, + hooks: Vec::new(), + warnings: Vec::new(), + errors: Vec::new(), + }; + } + }; + hooks_list_entry_for_cwd(response, &cwd) +} + pub(crate) async fn maybe_run_startup_hooks_review( app_server: &mut AppServerSession, tui: &mut Tui, config: &Config, bypass_hook_trust: bool, + entry: HooksListEntry, ) -> Result { - let cwd = config.cwd.to_path_buf(); - let response = match fetch_hooks_list(app_server.request_handle(), cwd.clone()).await { - Ok(response) => response, - Err(err) => { - tracing::warn!("failed to load startup hook review state: {err:#}"); - return Ok(StartupHooksReviewOutcome::Continue); - } - }; - let entry = hooks_list_entry_for_cwd(response, &cwd); if !review_is_needed(bypass_hook_trust, &entry) { return Ok(StartupHooksReviewOutcome::Continue); } @@ -130,7 +146,9 @@ async fn run_startup_hooks_review_app( ) .await .map(|_| ()) - .map_err(|err| format!("Failed to trust hooks: {err}")); + .map_err(|err| { + format!("Failed to trust hooks: {}", format_config_error(&err)) + }); match result { Ok(()) => return Ok(StartupHooksReviewOutcome::Continue), Err(err) => { @@ -199,7 +217,7 @@ fn selection_view_params( "Hooks can run outside the sandbox after you trust them.".dim(), )); if let Some(error) = trust_all_error { - header.push(Line::from(error.to_string()).red()); + header.push(Paragraph::new(Line::from(error.to_string()).red()).wrap(Wrap { trim: false })); } else if trusting_all { header.push(Line::from("Trusting hooks...".dim())); } @@ -333,7 +351,7 @@ mod tests { } }) .collect::(); - format!("{rendered:width$}", width = area.width as usize) + rendered.trim_end().to_string() }) .collect::>() .join("\n") @@ -373,7 +391,9 @@ mod tests { let keymap = RuntimeKeymap::defaults(); let view = selection_view( &entry(), - Some("Failed to trust hooks: disk full"), + Some( + "Failed to trust hooks: config/batchWrite failed in TUI: Invalid configuration: features.fast_mode=true is not supported; allowed set [fast_mode=false]", + ), /*trusting_all*/ false, AppEventSender::new(tx_raw), &keymap, @@ -381,7 +401,7 @@ mod tests { assert_snapshot!( "startup_hooks_review_prompt_with_trust_error", - render_lines(&view, /*width*/ 80) + render_lines(&view, /*width*/ 62) ); } } diff --git a/codex-rs/tui/src/terminal_palette.rs b/codex-rs/tui/src/terminal_palette.rs index 53c68d96e776..1b539f94cb78 100644 --- a/codex-rs/tui/src/terminal_palette.rs +++ b/codex-rs/tui/src/terminal_palette.rs @@ -1,4 +1,6 @@ use crate::color::perceptual_distance; +use codex_terminal_detection::TerminalName; +use codex_terminal_detection::terminal_info; use ratatui::style::Color; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -30,19 +32,49 @@ pub fn indexed_color(index: u8) -> Color { /// Returns the closest color to the target color that the terminal can display. pub fn best_color(target: (u8, u8, u8)) -> Color { - let color_level = stdout_color_level(); - if color_level == StdoutColorLevel::TrueColor { - rgb_color(target) - } else if color_level == StdoutColorLevel::Ansi256 - && let Some((i, _)) = xterm_fixed_colors().min_by(|(_, a), (_, b)| { - perceptual_distance(*a, target) - .partial_cmp(&perceptual_distance(*b, target)) - .unwrap_or(std::cmp::Ordering::Equal) - }) + best_color_for_color_level(target, effective_stdout_color_level()) +} + +fn effective_stdout_color_level() -> StdoutColorLevel { + stdout_color_level_for_terminal( + stdout_color_level(), + terminal_info().name, + std::env::var_os("WT_SESSION").is_some(), + std::env::var_os("FORCE_COLOR").is_some(), + ) +} + +fn stdout_color_level_for_terminal( + stdout_level: StdoutColorLevel, + terminal_name: TerminalName, + has_wt_session: bool, + has_force_color_override: bool, +) -> StdoutColorLevel { + if has_wt_session && !has_force_color_override { + return StdoutColorLevel::TrueColor; + } + + if stdout_level == StdoutColorLevel::Ansi16 + && terminal_name == TerminalName::WindowsTerminal + && !has_force_color_override { - indexed_color(i as u8) + StdoutColorLevel::TrueColor } else { - Color::default() + stdout_level + } +} + +fn best_color_for_color_level(target: (u8, u8, u8), color_level: StdoutColorLevel) -> Color { + match color_level { + StdoutColorLevel::TrueColor => rgb_color(target), + StdoutColorLevel::Ansi256 => xterm_fixed_colors() + .min_by(|(_, a), (_, b)| { + perceptual_distance(*a, target) + .partial_cmp(&perceptual_distance(*b, target)) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .map_or_else(Color::default, |(i, _)| indexed_color(i as u8)), + StdoutColorLevel::Ansi16 | StdoutColorLevel::Unknown => Color::default(), } } @@ -68,7 +100,7 @@ pub fn default_bg() -> Option<(u8, u8, u8)> { default_colors().map(|c| c.bg) } -#[cfg(unix)] +#[cfg(any(unix, windows))] pub(crate) fn set_default_colors_from_startup_probe( colors: Option, ) { @@ -177,7 +209,73 @@ mod imp { } } -#[cfg(not(all(unix, not(test))))] +#[cfg(windows)] +mod imp { + use super::DefaultColors; + use std::sync::Mutex; + use std::sync::OnceLock; + + struct Cache { + attempted: bool, + value: Option, + } + + impl Default for Cache { + fn default() -> Self { + Self { + attempted: false, + value: None, + } + } + } + + impl Cache { + fn get_or_init_with(&mut self, mut init: impl FnMut() -> Option) -> Option { + if !self.attempted { + self.value = init(); + self.attempted = true; + } + self.value + } + } + + fn default_colors_cache() -> &'static Mutex> { + static CACHE: OnceLock>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(Cache::default())) + } + + pub(super) fn default_colors() -> Option { + let cache = default_colors_cache(); + let mut cache = cache.lock().ok()?; + cache.get_or_init_with(query_default_colors) + } + + pub(super) fn set_default_colors_from_startup_probe( + colors: Option, + ) { + if let Ok(mut cache) = default_colors_cache().lock() { + cache.value = colors.map(|colors| DefaultColors { + fg: colors.fg, + bg: colors.bg, + }); + cache.attempted = true; + } + } + + pub(super) fn requery_default_colors() {} + + fn query_default_colors() -> Option { + crate::terminal_probe::default_colors(crate::terminal_probe::DEFAULT_TIMEOUT) + .ok() + .flatten() + .map(|colors| DefaultColors { + fg: colors.fg, + bg: colors.bg, + }) + } +} + +#[cfg(not(any(all(unix, not(test)), windows)))] mod imp { use super::DefaultColors; @@ -185,7 +283,7 @@ mod imp { None } - #[cfg(unix)] + #[cfg(any(unix, windows))] pub(super) fn set_default_colors_from_startup_probe( _colors: Option, ) { @@ -461,3 +559,64 @@ pub const XTERM_COLORS: [(u8, u8, u8); 256] = [ (228, 228, 228), // 254 Grey89 (238, 238, 238), // 255 Grey93 ]; + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn best_color_uses_truecolor_without_quantization() { + assert_eq!( + best_color_for_color_level((12, 34, 56), StdoutColorLevel::TrueColor), + rgb_color((12, 34, 56)) + ); + } + + #[test] + fn best_color_resets_for_ansi16() { + assert_eq!( + best_color_for_color_level((12, 34, 56), StdoutColorLevel::Ansi16), + Color::Reset + ); + } + + #[test] + fn windows_terminal_wt_session_promotes_to_truecolor() { + assert_eq!( + stdout_color_level_for_terminal( + StdoutColorLevel::Ansi16, + TerminalName::Unknown, + /*has_wt_session*/ true, + /*has_force_color_override*/ false, + ), + StdoutColorLevel::TrueColor + ); + } + + #[test] + fn windows_terminal_name_promotes_ansi16_to_truecolor() { + assert_eq!( + stdout_color_level_for_terminal( + StdoutColorLevel::Ansi16, + TerminalName::WindowsTerminal, + /*has_wt_session*/ false, + /*has_force_color_override*/ false, + ), + StdoutColorLevel::TrueColor + ); + } + + #[test] + fn force_color_keeps_reported_stdout_level() { + assert_eq!( + stdout_color_level_for_terminal( + StdoutColorLevel::Ansi16, + TerminalName::WindowsTerminal, + /*has_wt_session*/ true, + /*has_force_color_override*/ true, + ), + StdoutColorLevel::Ansi16 + ); + } +} diff --git a/codex-rs/tui/src/terminal_probe.rs b/codex-rs/tui/src/terminal_probe.rs index d9927ffa2085..bea195083b30 100644 --- a/codex-rs/tui/src/terminal_probe.rs +++ b/codex-rs/tui/src/terminal_probe.rs @@ -12,9 +12,25 @@ //! startup. A future input-preservation layer would need to replay unrelated bytes through the same //! parser that normal TUI input uses. +use std::time::Duration; + +/// Default wall-clock budget for each startup probe group. +pub(crate) const DEFAULT_TIMEOUT: Duration = Duration::from_millis(100); + +/// Default terminal foreground and background colors reported by OSC 10 and OSC 11. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub(crate) struct DefaultColors { + /// Default foreground color as an 8-bit RGB tuple. + pub(crate) fg: (u8, u8, u8), + /// Default background color as an 8-bit RGB tuple. + pub(crate) bg: (u8, u8, u8), +} + #[cfg(unix)] #[cfg_attr(test, allow(dead_code))] mod imp { + use super::DefaultColors; + use super::parse_default_colors; use std::fs::File; use std::fs::OpenOptions; use std::io; @@ -27,18 +43,6 @@ mod imp { use crossterm::event::KeyboardEnhancementFlags; use ratatui::layout::Position; - /// Default wall-clock budget for each startup probe group. - pub(crate) const DEFAULT_TIMEOUT: Duration = Duration::from_millis(100); - - /// Default terminal foreground and background colors reported by OSC 10 and OSC 11. - #[derive(Debug, Clone, Copy, Eq, PartialEq)] - pub(crate) struct DefaultColors { - /// Default foreground color as an 8-bit RGB tuple. - pub(crate) fg: (u8, u8, u8), - /// Default background color as an 8-bit RGB tuple. - pub(crate) bg: (u8, u8, u8), - } - /// Results from the TUI's one-shot startup terminal probe. #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub(crate) struct StartupProbe { @@ -389,60 +393,6 @@ mod imp { None } - fn parse_osc_color(buffer: &[u8], slot: u8) -> Option<(u8, u8, u8)> { - let prefix = format!("\x1B]{slot};"); - let start = find_subslice(buffer, prefix.as_bytes())?; - let payload_start = start + prefix.len(); - let rest = &buffer[payload_start..]; - let (payload_end, _terminator_len) = osc_payload_end(rest)?; - let payload = std::str::from_utf8(&rest[..payload_end]).ok()?; - parse_osc_rgb(payload) - } - - fn parse_default_colors(buffer: &[u8]) -> Option { - let fg = parse_osc_color(buffer, /*slot*/ 10)?; - let bg = parse_osc_color(buffer, /*slot*/ 11)?; - Some(DefaultColors { fg, bg }) - } - - fn osc_payload_end(buffer: &[u8]) -> Option<(usize, usize)> { - let mut idx = 0; - while idx < buffer.len() { - match buffer[idx] { - 0x07 => return Some((idx, 1)), - 0x1B if buffer.get(idx + 1) == Some(&b'\\') => return Some((idx, 2)), - _ => idx += 1, - } - } - None - } - - fn parse_osc_rgb(payload: &str) -> Option<(u8, u8, u8)> { - let (prefix, values) = payload.trim().split_once(':')?; - if !prefix.eq_ignore_ascii_case("rgb") && !prefix.eq_ignore_ascii_case("rgba") { - return None; - } - - let mut parts = values.split('/'); - let r = parse_osc_component(parts.next()?)?; - let g = parse_osc_component(parts.next()?)?; - let b = parse_osc_component(parts.next()?)?; - if prefix.eq_ignore_ascii_case("rgba") { - parse_osc_component(parts.next()?)?; - } - parts.next().is_none().then_some((r, g, b)) - } - - fn parse_osc_component(component: &str) -> Option { - match component.len() { - 2 => u8::from_str_radix(component, 16).ok(), - 4 => u16::from_str_radix(component, 16) - .ok() - .map(|value| (value / 257) as u8), - _ => None, - } - } - /// Parser state for the keyboard enhancement probe. /// /// `UnsupportedFallback` records that a primary-device-attributes response arrived without @@ -517,12 +467,6 @@ mod imp { None } - fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { - haystack - .windows(needle.len()) - .position(|window| window == needle) - } - fn find_all_subslices<'a>( haystack: &'a [u8], needle: &'a [u8], @@ -550,53 +494,6 @@ mod imp { ); } - #[test] - fn parses_osc_colors_with_bel_and_st() { - assert_eq!( - parse_osc_color(b"\x1B]10;rgb:ffff/8000/0000\x07", /*slot*/ 10), - Some((255, 127, 0)) - ); - assert_eq!( - parse_osc_color(b"\x1B]11;rgba:00/80/ff/ff\x1B\\", /*slot*/ 11), - Some((0, 128, 255)) - ); - } - - #[test] - fn parses_two_and_four_digit_color_components() { - assert_eq!(parse_osc_rgb("rgb:00/80/ff"), Some((0, 128, 255))); - assert_eq!( - parse_osc_rgb("rgba:ffff/8000/0000/ffff"), - Some((255, 127, 0)) - ); - } - - #[test] - fn parses_default_colors_from_one_buffer() { - assert_eq!( - parse_default_colors( - b"\x1B]10;rgb:eeee/eeee/eeee\x1B\\\x1B]11;rgb:1111/1111/1111\x07" - ), - Some(DefaultColors { - fg: (238, 238, 238), - bg: (17, 17, 17) - }) - ); - assert_eq!( - parse_default_colors( - b"\x1B]11;rgb:1111/1111/1111\x07\x1B]10;rgb:eeee/eeee/eeee\x1B\\" - ), - Some(DefaultColors { - fg: (238, 238, 238), - bg: (17, 17, 17) - }) - ); - assert_eq!( - parse_default_colors(b"\x1B]10;rgb:eeee/eeee/eeee\x1B\\"), - None - ); - } - #[test] fn parses_keyboard_enhancement_flags_and_pda_fallback() { assert_eq!( @@ -655,5 +552,404 @@ mod imp { } } -#[cfg(unix)] +#[cfg(windows)] +mod imp { + use super::DefaultColors; + use super::parse_default_colors; + use std::io; + use std::io::ErrorKind; + use std::time::Duration; + use std::time::Instant; + use windows_sys::Win32::Foundation::HANDLE; + use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; + use windows_sys::Win32::Foundation::WAIT_OBJECT_0; + use windows_sys::Win32::Foundation::WAIT_TIMEOUT; + use windows_sys::Win32::Storage::FileSystem::ReadFile; + use windows_sys::Win32::Storage::FileSystem::WriteFile; + use windows_sys::Win32::System::Console::CONSOLE_SCREEN_BUFFER_INFOEX; + use windows_sys::Win32::System::Console::ENABLE_VIRTUAL_TERMINAL_INPUT; + use windows_sys::Win32::System::Console::GetConsoleMode; + use windows_sys::Win32::System::Console::GetConsoleScreenBufferInfoEx; + use windows_sys::Win32::System::Console::GetStdHandle; + use windows_sys::Win32::System::Console::STD_INPUT_HANDLE; + use windows_sys::Win32::System::Console::STD_OUTPUT_HANDLE; + use windows_sys::Win32::System::Console::SetConsoleMode; + use windows_sys::Win32::System::Threading::WaitForSingleObject; + + /// Queries OSC 10 and OSC 11 default colors under one shared deadline. + /// + /// The Windows path uses raw console handles because crossterm's public color query helper is + /// currently Unix-only. Failures and missing responses are reported as `Ok(None)` by callers so + /// terminals without OSC 10/11 support keep the existing conservative palette fallback. + pub(crate) fn default_colors(timeout: Duration) -> io::Result> { + let Ok(output) = std_handle(STD_OUTPUT_HANDLE) else { + return Ok(None); + }; + + if let Ok(input) = std_handle(STD_INPUT_HANDLE) + && let Ok(Some(colors)) = query_osc_default_colors(input, output, timeout) + { + return Ok(Some(colors)); + } + + Ok(query_console_default_colors(output).ok().flatten()) + } + + fn query_osc_default_colors( + input: HANDLE, + output: HANDLE, + timeout: Duration, + ) -> io::Result> { + let _vt_input = VirtualTerminalInputMode::enable(input)?; + write_all(output, b"\x1B]10;?\x1B\\\x1B]11;?\x1B\\")?; + read_until(input, timeout, parse_default_colors) + } + + fn query_console_default_colors(output: HANDLE) -> io::Result> { + let mut info = unsafe { std::mem::zeroed::() }; + info.cbSize = std::mem::size_of::() as u32; + if unsafe { GetConsoleScreenBufferInfoEx(output, &mut info) } == 0 { + return Err(io::Error::last_os_error()); + } + Ok(Some(decode_console_default_colors( + info.wAttributes, + &info.ColorTable, + ))) + } + + fn decode_console_default_colors(attributes: u16, color_table: &[u32; 16]) -> DefaultColors { + let fg_index = (attributes & 0x0f) as usize; + let bg_index = ((attributes >> 4) & 0x0f) as usize; + // COMMON_LVB_REVERSE_VIDEO changes how cells render, but this probe is discovering the + // configured default colors for palette blending. Keep the attribute fg/bg indices as-is. + DefaultColors { + fg: decode_color_ref(color_table[fg_index]), + bg: decode_color_ref(color_table[bg_index]), + } + } + + fn decode_color_ref(color_ref: u32) -> (u8, u8, u8) { + ( + (color_ref & 0xff) as u8, + ((color_ref >> 8) & 0xff) as u8, + ((color_ref >> 16) & 0xff) as u8, + ) + } + + fn std_handle(kind: u32) -> io::Result { + let handle = unsafe { GetStdHandle(kind) }; + if handle == 0 || handle == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + Ok(handle) + } + + struct VirtualTerminalInputMode { + handle: HANDLE, + original_mode: u32, + } + + impl VirtualTerminalInputMode { + fn enable(handle: HANDLE) -> io::Result { + let mut original_mode = 0; + if unsafe { GetConsoleMode(handle, &mut original_mode) } == 0 { + return Err(io::Error::last_os_error()); + } + + let requested_mode = original_mode | ENABLE_VIRTUAL_TERMINAL_INPUT; + if unsafe { SetConsoleMode(handle, requested_mode) } == 0 { + return Err(io::Error::last_os_error()); + } + + Ok(Self { + handle, + original_mode, + }) + } + } + + impl Drop for VirtualTerminalInputMode { + fn drop(&mut self) { + unsafe { + SetConsoleMode(self.handle, self.original_mode); + } + } + } + + fn write_all(handle: HANDLE, mut bytes: &[u8]) -> io::Result<()> { + while !bytes.is_empty() { + let mut written = 0; + let ok = unsafe { + WriteFile( + handle, + bytes.as_ptr().cast(), + bytes.len().min(u32::MAX as usize) as u32, + &mut written, + std::ptr::null_mut(), + ) + }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + if written == 0 { + return Err(io::Error::from(ErrorKind::WriteZero)); + } + bytes = &bytes[written as usize..]; + } + Ok(()) + } + + fn read_until( + handle: HANDLE, + timeout: Duration, + mut parse: impl FnMut(&[u8]) -> Option, + ) -> io::Result> { + let deadline = Instant::now() + timeout; + let mut buffer = Vec::new(); + loop { + if let Some(value) = parse(&buffer) { + return Ok(Some(value)); + } + + let now = Instant::now(); + if now >= deadline { + return Ok(None); + } + let timeout_ms = deadline + .saturating_duration_since(now) + .as_millis() + .min(u32::MAX as u128) as u32; + match unsafe { WaitForSingleObject(handle, timeout_ms) } { + WAIT_OBJECT_0 => read_once(handle, &mut buffer)?, + WAIT_TIMEOUT => return Ok(None), + _ => return Err(io::Error::last_os_error()), + } + } + } + + fn read_once(handle: HANDLE, buffer: &mut Vec) -> io::Result<()> { + let mut chunk = [0_u8; 256]; + let mut read = 0; + let ok = unsafe { + ReadFile( + handle, + chunk.as_mut_ptr().cast(), + chunk.len() as u32, + &mut read, + std::ptr::null_mut(), + ) + }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + buffer.extend_from_slice(&chunk[..read as usize]); + Ok(()) + } + + #[cfg(test)] + mod tests { + use super::*; + use pretty_assertions::assert_eq; + use windows_sys::Win32::System::Console::COMMON_LVB_REVERSE_VIDEO; + + fn color_table() -> [u32; 16] { + [ + 0x00000000, 0x00000080, 0x00008000, 0x00008080, 0x00800000, 0x00800080, 0x00808000, + 0x00c0c0c0, 0x00808080, 0x000000ff, 0x0000ff00, 0x0000ffff, 0x00ff0000, 0x00ff00ff, + 0x00ffff00, 0x00ffffff, + ] + } + + #[test] + fn decodes_console_color_attribute_indices() { + assert_eq!( + decode_console_default_colors(/*attributes*/ 0x21, &color_table()), + DefaultColors { + fg: (128, 0, 0), + bg: (0, 128, 0), + } + ); + } + + #[test] + fn decodes_console_color_intensity_indices() { + assert_eq!( + decode_console_default_colors(/*attributes*/ 0xe9, &color_table()), + DefaultColors { + fg: (255, 0, 0), + bg: (0, 255, 255), + } + ); + } + + #[test] + fn decodes_console_color_ref_byte_order() { + let mut colors = color_table(); + colors[3] = 0x00112233; + colors[4] = 0x00aabbcc; + + assert_eq!( + decode_console_default_colors(/*attributes*/ 0x43, &colors), + DefaultColors { + fg: (0x33, 0x22, 0x11), + bg: (0xcc, 0xbb, 0xaa), + } + ); + } + + #[test] + fn ignores_reverse_video_when_decoding_default_colors() { + assert_eq!( + decode_console_default_colors( + /*attributes*/ COMMON_LVB_REVERSE_VIDEO | 0x21, + &color_table(), + ), + DefaultColors { + fg: (128, 0, 0), + bg: (0, 128, 0), + } + ); + } + } +} + +fn parse_osc_color(buffer: &[u8], slot: u8) -> Option<(u8, u8, u8)> { + let prefix = format!("\x1B]{slot};"); + let start = find_subslice(buffer, prefix.as_bytes())?; + let payload_start = start + prefix.len(); + let rest = &buffer[payload_start..]; + let (payload_end, _terminator_len) = osc_payload_end(rest)?; + let payload = std::str::from_utf8(&rest[..payload_end]).ok()?; + parse_osc_rgb(payload) +} + +fn parse_default_colors(buffer: &[u8]) -> Option { + let fg = parse_osc_color(buffer, /*slot*/ 10)?; + let bg = parse_osc_color(buffer, /*slot*/ 11)?; + Some(DefaultColors { fg, bg }) +} + +fn osc_payload_end(buffer: &[u8]) -> Option<(usize, usize)> { + let mut idx = 0; + while idx < buffer.len() { + match buffer[idx] { + 0x07 => return Some((idx, 1)), + 0x1B if buffer.get(idx + 1) == Some(&b'\\') => return Some((idx, 2)), + _ => idx += 1, + } + } + None +} + +fn parse_osc_rgb(payload: &str) -> Option<(u8, u8, u8)> { + let (prefix, values) = payload.trim().split_once(':')?; + if !prefix.eq_ignore_ascii_case("rgb") && !prefix.eq_ignore_ascii_case("rgba") { + return None; + } + + let mut parts = values.split('/'); + let r = parse_osc_component(parts.next()?)?; + let g = parse_osc_component(parts.next()?)?; + let b = parse_osc_component(parts.next()?)?; + if prefix.eq_ignore_ascii_case("rgba") { + parse_osc_component(parts.next()?)?; + } + parts.next().is_none().then_some((r, g, b)) +} + +fn parse_osc_component(component: &str) -> Option { + match component.len() { + 2 => u8::from_str_radix(component, 16).ok(), + 4 => u16::from_str_radix(component, 16) + .ok() + .map(|value| (value / 257) as u8), + _ => None, + } +} + +fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + +#[cfg(any(unix, windows))] pub(crate) use imp::*; + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn parses_osc_colors_with_bel_and_st() { + assert_eq!( + parse_osc_color(b"\x1B]10;rgb:ffff/8000/0000\x07", /*slot*/ 10), + Some((255, 127, 0)) + ); + assert_eq!( + parse_osc_color(b"\x1B]11;rgba:00/80/ff/ff\x1B\\", /*slot*/ 11), + Some((0, 128, 255)) + ); + } + + #[test] + fn parses_two_and_four_digit_color_components() { + assert_eq!(parse_osc_rgb("rgb:00/80/ff"), Some((0, 128, 255))); + assert_eq!( + parse_osc_rgb("rgba:ffff/8000/0000/ffff"), + Some((255, 127, 0)) + ); + } + + #[test] + fn parses_default_colors_from_one_buffer() { + assert_eq!( + parse_default_colors(b"\x1B]10;rgb:eeee/eeee/eeee\x1B\\\x1B]11;rgb:1111/1111/1111\x07"), + Some(DefaultColors { + fg: (238, 238, 238), + bg: (17, 17, 17) + }) + ); + assert_eq!( + parse_default_colors(b"\x1B]11;rgb:1111/1111/1111\x07\x1B]10;rgb:eeee/eeee/eeee\x1B\\"), + Some(DefaultColors { + fg: (238, 238, 238), + bg: (17, 17, 17) + }) + ); + assert_eq!( + parse_default_colors(b"\x1B]10;rgb:eeee/eeee/eeee\x1B\\"), + None + ); + } + + #[test] + fn ignores_malformed_or_partial_default_color_responses() { + assert_eq!( + parse_default_colors(b"\x1B]10;rgb:eeee/eeee/eeee\x1B\\\x1B]11;rgb:nope\x07"), + None + ); + assert_eq!( + parse_default_colors(b"\x1B]10;rgb:eeee/eeee/eeee\x1B\\\x1B]11;rgb:11/11/11/11\x07"), + None + ); + assert_eq!( + parse_default_colors(b"\x1B]10;rgb:eeee/eeee/eeee\x1B\\\x1B]11;rgb:1111/1111/1111"), + None + ); + } + + #[test] + fn parses_default_colors_with_unrelated_bytes() { + assert_eq!( + parse_default_colors( + b"typed\x1B]10;rgb:eeee/eeee/eeee\x1B\\noise\x1B]11;rgb:1111/1111/1111\x07" + ), + Some(DefaultColors { + fg: (238, 238, 238), + bg: (17, 17, 17), + }) + ); + } +} diff --git a/codex-rs/tui/src/terminal_visualization_instructions.rs b/codex-rs/tui/src/terminal_visualization_instructions.rs new file mode 100644 index 000000000000..eb59f2d15719 --- /dev/null +++ b/codex-rs/tui/src/terminal_visualization_instructions.rs @@ -0,0 +1,29 @@ +use crate::legacy_core::config::Config; +use codex_features::Feature; + +pub(crate) const TERMINAL_VISUALIZATION_INSTRUCTIONS: &str = "\ +- This surface is a terminal. When the formatting rules require a visual, include one in the final answer using compact ASCII diagrams, trees, timelines, or tables. +- Use tables for exact mappings or comparisons rather than collapsing known mappings into prose. +- Use trees for hierarchy or one-to-many relationships, and diagrams or timelines for sequence, change, or state transferred between records across event order. +- Use only ASCII characters in visuals."; + +pub(crate) fn with_terminal_visualization_instructions( + config: &Config, + control_instructions: Option, +) -> Option { + if !config + .features + .enabled(Feature::TerminalVisualizationInstructions) + { + return control_instructions; + } + + let existing_instructions = + control_instructions.or_else(|| config.developer_instructions.clone()); + Some(match existing_instructions.as_deref() { + Some(existing) if !existing.trim().is_empty() => { + format!("{existing}\n\n{TERMINAL_VISUALIZATION_INSTRUCTIONS}") + } + _ => TERMINAL_VISUALIZATION_INSTRUCTIONS.to_string(), + }) +} diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index b9055f5d6b51..681e0cb3977f 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -438,6 +438,9 @@ pub(crate) fn init() -> Result { let enhanced_keys_supported = !keyboard_modes::keyboard_enhancement_disabled() && detect_keyboard_enhancement_supported(); + #[cfg(windows)] + probe_windows_default_colors(); + let tui = CustomTerminal::with_options_and_cursor_position(backend, cursor_pos)?; let stderr_guard = terminal_stderr::TerminalStderrGuard::install()?; Ok(InitializedTerminal { @@ -457,11 +460,33 @@ fn cursor_position_with_crossterm(backend: &mut CrosstermBackend) -> Pos #[cfg(not(unix))] fn detect_keyboard_enhancement_supported() -> bool { - // Non-Unix startup keeps the existing crossterm path because the bounded probe implementation - // relies on Unix file descriptors and `/dev/tty` semantics. + // Non-Unix startup keeps the existing crossterm keyboard probe path because it already knows + // how to interpret platform-specific event sources. supports_keyboard_enhancement().unwrap_or(/*default*/ false) } +#[cfg(windows)] +fn probe_windows_default_colors() { + let started_at = std::time::Instant::now(); + match crate::terminal_probe::default_colors(crate::terminal_probe::DEFAULT_TIMEOUT) { + Ok(colors) => { + tracing::info!( + duration_ms = %started_at.elapsed().as_millis(), + default_colors = colors.is_some(), + "terminal default color probe completed" + ); + crate::terminal_palette::set_default_colors_from_startup_probe(colors); + } + Err(err) => { + tracing::warn!( + duration_ms = %started_at.elapsed().as_millis(), + "terminal default color probe failed: {err}" + ); + crate::terminal_palette::set_default_colors_from_startup_probe(/*colors*/ None); + } + } +} + fn set_panic_hook() { let hook = panic::take_hook(); panic::set_hook(Box::new(move |panic_info| { diff --git a/codex-rs/windows-sandbox-rs/src/setup.rs b/codex-rs/windows-sandbox-rs/src/setup.rs index c1881b55c86f..428b5ff6a131 100644 --- a/codex-rs/windows-sandbox-rs/src/setup.rs +++ b/codex-rs/windows-sandbox-rs/src/setup.rs @@ -15,6 +15,7 @@ use crate::allow::compute_allow_paths_for_permissions; use crate::helper_materialization::bundled_executable_path_for_exe; use crate::helper_materialization::helper_bin_dir; use crate::identity::sandbox_setup_is_complete; +use crate::logging::current_log_file_path; use crate::logging::log_note; use crate::path_normalization::canonical_path_key; use crate::path_normalization::canonicalize_path; @@ -25,7 +26,6 @@ use crate::setup_error::clear_setup_error_report; use crate::setup_error::failure; use crate::setup_error::read_setup_error_report; use crate::ssh_config_dependencies::ssh_config_dependency_paths; -use anyhow::Context; use anyhow::Result; use anyhow::anyhow; use base64::Engine; @@ -210,6 +210,18 @@ fn run_setup_refresh_inner( let json = serde_json::to_vec(&payload)?; let b64 = BASE64_STANDARD.encode(json); let exe = find_setup_exe(); + let sbx_dir = sandbox_dir(request.codex_home); + let log_path = current_log_file_path(&sbx_dir); + let cleared_report = match clear_setup_error_report(request.codex_home) { + Ok(()) => true, + Err(err) => { + log_note( + &format!("setup refresh: failed to clear setup_error.json before launch: {err}"), + Some(&sbx_dir), + ); + false + } + }; // Refresh should never request elevation; ensure verb isn't set and we don't trigger UAC. let mut cmd = Command::new(&exe); cmd.arg(&b64).stdout(Stdio::null()).stderr(Stdio::null()); @@ -221,24 +233,34 @@ fn run_setup_refresh_inner( cwd.display(), b64.len() ), - Some(&sandbox_dir(request.codex_home)), + Some(&sbx_dir), ); - let status = cmd - .status() - .map_err(|e| { - log_note( - &format!("setup refresh: failed to spawn {}: {e}", exe.display()), - Some(&sandbox_dir(request.codex_home)), - ); - e - }) - .context("spawn setup refresh")?; + let status = cmd.status().map_err(|err| { + let message = format!( + "setup refresh failed to launch helper: helper={}, cwd={}, log={}, error={err}", + exe.display(), + cwd.display(), + log_path.display() + ); + log_note(&format!("setup refresh: {message}"), Some(&sbx_dir)); + failure(SetupErrorCode::OrchestratorHelperLaunchFailed, message) + })?; if !status.success() { log_note( &format!("setup refresh: exited with status {status:?}"), - Some(&sandbox_dir(request.codex_home)), + Some(&sbx_dir), + ); + return Err(report_helper_failure( + request.codex_home, + cleared_report, + status.code(), + )); + } + if let Err(err) = clear_setup_error_report(request.codex_home) { + log_note( + &format!("setup refresh: failed to clear setup_error.json after success: {err}"), + Some(&sbx_dir), ); - return Err(anyhow!("setup refresh failed with status {status}")); } Ok(()) } @@ -1087,7 +1109,9 @@ mod tests { use crate::helper_materialization::helper_bin_dir; use crate::resolved_permissions::ResolvedWindowsSandboxPermissions; use crate::setup_error::SetupErrorCode; + use crate::setup_error::SetupErrorReport; use crate::setup_error::extract_failure; + use crate::setup_error::write_setup_error_report; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; @@ -1146,6 +1170,64 @@ mod tests { ) } + #[test] + fn report_helper_failure_uses_setup_error_report_when_clear_succeeded() { + let tmp = TempDir::new().expect("tempdir"); + let codex_home = tmp.path().join("codex-home"); + write_setup_error_report( + codex_home.as_path(), + &SetupErrorReport { + code: super::SetupErrorCode::HelperFirewallPolicyAccessFailed, + message: "firewall policy unavailable".to_string(), + }, + ) + .expect("write setup error report"); + + let err = super::report_helper_failure( + codex_home.as_path(), + /*cleared_report*/ true, + /*exit_code*/ Some(1), + ); + + let failure = extract_failure(&err).expect("structured setup failure"); + assert_eq!( + &super::SetupFailure::new( + super::SetupErrorCode::HelperFirewallPolicyAccessFailed, + "firewall policy unavailable", + ), + failure + ); + } + + #[test] + fn report_helper_failure_ignores_setup_error_report_when_clear_failed() { + let tmp = TempDir::new().expect("tempdir"); + let codex_home = tmp.path().join("codex-home"); + write_setup_error_report( + codex_home.as_path(), + &SetupErrorReport { + code: super::SetupErrorCode::HelperFirewallPolicyAccessFailed, + message: "stale report".to_string(), + }, + ) + .expect("write setup error report"); + + let err = super::report_helper_failure( + codex_home.as_path(), + /*cleared_report*/ false, + /*exit_code*/ Some(1), + ); + + let failure = extract_failure(&err).expect("structured setup failure"); + assert_eq!( + &super::SetupFailure::new( + super::SetupErrorCode::OrchestratorHelperExitNonzero, + "setup helper exited with status Some(1)", + ), + failure + ); + } + #[test] fn setup_refresh_skips_profiles_without_managed_filesystem_permissions() { let tmp = TempDir::new().expect("tempdir"); diff --git a/codex-rs/windows-sandbox-rs/src/setup_error.rs b/codex-rs/windows-sandbox-rs/src/setup_error.rs index 0f759ef879b4..d9104602bc19 100644 --- a/codex-rs/windows-sandbox-rs/src/setup_error.rs +++ b/codex-rs/windows-sandbox-rs/src/setup_error.rs @@ -117,7 +117,7 @@ pub struct SetupErrorReport { pub message: String, } -#[derive(Debug)] +#[derive(Debug, PartialEq, Eq)] pub struct SetupFailure { pub code: SetupErrorCode, pub message: String, diff --git a/justfile b/justfile index 1ab4a97a1d15..417e428d1683 100644 --- a/justfile +++ b/justfile @@ -76,12 +76,10 @@ install: [unix] test *args: RUST_MIN_STACK={{ rust_min_stack }} cargo nextest run --no-fail-fast "$@" - just bench-smoke [windows] test *args: $env:RUST_MIN_STACK = "{{ rust_min_stack }}"; cargo nextest run --no-fail-fast @($args | Select-Object -Skip 1) - just bench-smoke # Run from the repository root so scripts that resolve paths from `cwd` see # the same layout they use in GitHub Actions. diff --git a/patches/v8_bazel_rules.patch b/patches/v8_bazel_rules.patch index 1c49d06b093c..97e07d9d4713 100644 --- a/patches/v8_bazel_rules.patch +++ b/patches/v8_bazel_rules.patch @@ -106,6 +106,55 @@ index 9648e4a..88efd41 100644 }) + select({ ":should_add_rdynamic": ["-rdynamic"], "//conditions:default": [], +@@ -459,6 +488,11 @@ + def _mksnapshot(ctx): + prefix = ctx.attr.prefix + suffix = ctx.attr.suffix ++ # Windows cross-builds use Linux for the exec configuration, but the ++ # snapshot generator must match the target ABI and run on the Windows ++ # runner. Action strategies only choose where an action runs; they cannot ++ # change this executable from the exec to the target configuration. ++ tool = ctx.executable.target_tool if ctx.attr.target_os == "win" else ctx.executable.tool + outs = [ + ctx.actions.declare_file(prefix + "/snapshot" + suffix + ".cc"), + ctx.actions.declare_file(prefix + "/embedded" + suffix + ".S"), +@@ -476,7 +510,7 @@ + outs[1].path, + ] + ctx.attr.args, +- executable = ctx.executable.tool, ++ executable = tool, + progress_message = "Running mksnapshot", + ) + return [DefaultInfo(files = depset(outs))] +@@ -491,6 +525,12 @@ + executable = True, + cfg = "exec", + ), ++ "target_tool": attr.label( ++ mandatory = True, ++ allow_files = True, ++ executable = True, ++ cfg = "target", ++ ), + "target_os": attr.string(mandatory = True), + "prefix": attr.string(mandatory = True), + "suffix": attr.string(mandatory = True), +@@ -504,6 +544,7 @@ + args = args, + prefix = "noicu", + tool = ":noicu/mksnapshot" + suffix, ++ target_tool = ":noicu/mksnapshot" + suffix, + suffix = suffix, + target_os = select({ + "@v8//bazel/config:is_macos": "mac", +@@ -516,6 +557,7 @@ + args = args, + prefix = "icu", + tool = ":icu/mksnapshot" + suffix, ++ target_tool = ":icu/mksnapshot" + suffix, + suffix = suffix, + target_os = select({ + "@v8//bazel/config:is_macos": "mac", diff --git a/orig/v8-14.6.202.11/BUILD.bazel b/mod/v8-14.6.202.11/BUILD.bazel index 421ebcd..52283ea 100644 --- a/orig/v8-14.6.202.11/BUILD.bazel @@ -309,7 +358,7 @@ index 421ebcd..52283ea 100644 ], ) -@@ -4772,9 +4784,15 @@ v8_binary( +@@ -4772,9 +4784,20 @@ v8_binary( ":icu/generated_torque_initializers", ":icu/v8_initializers_files", ], @@ -317,11 +366,16 @@ index 421ebcd..52283ea 100644 + # external-reference helpers together while producing the snapshot, the + # final embedder binary may not fold the same pair and startup + # deserialization will reject the snapshot. ++ # GN also increases the x64 Windows stack reserve because constructing the ++ # snapshot overflows the linker's 1 MiB default. linkopts = select({ "@v8//bazel/config:is_android": ["-llog"], - "//conditions:default": [], + "@v8//bazel/config:is_macos": ["-Wl,-no_deduplicate"], -+ "@v8//bazel/config:is_windows": ["/OPT:NOICF"], ++ "@v8//bazel/config:is_windows": [ ++ "-Wl,/OPT:NOICF", ++ "-Wl,/STACK:2097152", ++ ], + "//conditions:default": ["-Wl,--icf=none"], }), noicu_deps = [":v8_libshared_noicu"], diff --git a/scripts/check-module-bazel-lock.sh b/scripts/check-module-bazel-lock.sh index 1a148896437a..49ddaaef2bfc 100755 --- a/scripts/check-module-bazel-lock.sh +++ b/scripts/check-module-bazel-lock.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash set -euo pipefail -if ! bazel mod deps --lockfile_mode=error; then +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +if ! "${repo_root}/.github/scripts/run_bazel_with_buildbuddy.py" mod deps --lockfile_mode=error; then echo "MODULE.bazel.lock is out of date." echo "Run 'just bazel-lock-update' and commit the updated lockfile." exit 1