From 42c80385cd6e2c8efc8b85c39bb9bde5edf01fc3 Mon Sep 17 00:00:00 2001 From: Adrian <145513011+adrian-openai@users.noreply.github.com> Date: Thu, 28 May 2026 20:00:35 -0700 Subject: [PATCH 01/40] [codex] Avoid PowerShell safety parsing off Windows (#24946) ## Summary This fixes BUGB-17567 by preventing non-Windows command safety classification from invoking the Windows PowerShell safelist/parser path. Previously, `is_known_safe_command` called the Windows PowerShell classifier on every platform. That classifier recognizes `pwsh`/`powershell` by basename and delegates script parsing to the PowerShell AST parser. The parser starts the supplied executable, so on macOS/Linux a repository-controlled `pwsh` path could execute during safety parsing before the normal sandboxed command execution path. The change gates the Windows PowerShell classifier and module behind `#[cfg(windows)]`. On macOS/Linux, PowerShell-looking commands are no longer auto-approved by the Windows classifier and instead fall through to the normal non-Windows safe-command logic. ## Validation - `/private/tmp/codex-tools/bin/just fmt` - `PATH=/private/tmp/codex-tools/bin:$PATH /private/tmp/codex-tools/bin/just test -p codex-shell-command` The focused test run passed 135 tests with 0 skipped and completed the crate bench-smoke step. ## Notes This PR is scoped to the BUGB-17567 macOS/Linux path. Windows still uses the PowerShell classifier; a separate hardening follow-up should ensure Windows safety parsing only executes a trusted PowerShell parser binary and does not spawn the command's `argv[0]` when that path may be repository-controlled. --- .../src/command_safety/is_safe_command.rs | 55 ++++++++++++++++++- .../shell-command/src/command_safety/mod.rs | 1 + 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/codex-rs/shell-command/src/command_safety/is_safe_command.rs b/codex-rs/shell-command/src/command_safety/is_safe_command.rs index b35144a12b..bca44139cd 100644 --- a/codex-rs/shell-command/src/command_safety/is_safe_command.rs +++ b/codex-rs/shell-command/src/command_safety/is_safe_command.rs @@ -4,6 +4,7 @@ use crate::command_safety::is_dangerous_command::executable_name_lookup_key; // may appear before it (e.g., `-C`, `-c`, `--git-dir`). // Implemented in `is_dangerous_command` and shared here. use crate::command_safety::is_dangerous_command::find_git_subcommand; +#[cfg(windows)] use crate::command_safety::windows_safe_commands::is_safe_command_windows; #[cfg(windows)] use crate::command_safety::windows_safe_commands::is_safe_powershell_words as is_safe_powershell_words_windows; @@ -20,8 +21,11 @@ pub fn is_known_safe_command(command: &[String]) -> bool { }) .collect(); - if is_safe_command_windows(&command) { - return true; + #[cfg(windows)] + { + if is_safe_command_windows(&command) { + return true; + } } if is_safe_to_call_with_exec(&command) { @@ -748,4 +752,51 @@ mod tests { assert!(!is_safe_powershell_words(&command)); } } + + #[cfg(unix)] + #[test] + fn non_windows_safe_classification_does_not_spawn_repo_powershell_path() { + use std::fs; + use std::io::Write; + use std::os::unix::fs::PermissionsExt; + use std::time::SystemTime; + use std::time::UNIX_EPOCH; + + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after unix epoch") + .as_nanos(); + let temp_dir = std::env::temp_dir().join(format!( + "codex-safe-command-pwsh-test-{}-{unique}", + std::process::id() + )); + fs::create_dir(&temp_dir).expect("create temp dir for fake pwsh"); + let fake_pwsh = temp_dir.join("pwsh"); + let marker = temp_dir.join("marker"); + let quoted_marker = marker.to_string_lossy().replace('\'', "'\\''"); + + let mut script = fs::File::create(&fake_pwsh).expect("create fake pwsh"); + writeln!( + script, + "#!/bin/sh\nprintf spawned > '{quoted_marker}'\nexit 0" + ) + .expect("write fake pwsh"); + let mut permissions = fs::metadata(&fake_pwsh) + .expect("stat fake pwsh") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&fake_pwsh, permissions).expect("make fake pwsh executable"); + + assert!(!is_known_safe_command(&[ + fake_pwsh.to_string_lossy().into_owned(), + "-Command".to_string(), + "Get-ChildItem".to_string(), + ])); + assert!( + !marker.exists(), + "non-Windows safety classification must not spawn a PowerShell-looking path" + ); + + fs::remove_dir_all(temp_dir).expect("remove temp dir"); + } } diff --git a/codex-rs/shell-command/src/command_safety/mod.rs b/codex-rs/shell-command/src/command_safety/mod.rs index 2040364e5b..60bed2effc 100644 --- a/codex-rs/shell-command/src/command_safety/mod.rs +++ b/codex-rs/shell-command/src/command_safety/mod.rs @@ -2,5 +2,6 @@ mod powershell_parser; pub mod is_dangerous_command; pub mod is_safe_command; +#[cfg(windows)] pub(crate) mod windows_safe_commands; pub(crate) use powershell_parser::try_parse_powershell_ast_commands; From f0a839ea0c594a53704b83f6bfde1d557b473cf0 Mon Sep 17 00:00:00 2001 From: xl-openai Date: Thu, 28 May 2026 21:14:34 -0700 Subject: [PATCH 02/40] Add runtime extra skill roots API (#24977) ## Summary - Add v2 `skills/extraRoots/set` to replace app-server process-local standalone skill roots. The setting is not persisted, accepts missing roots, and `extraRoots: []` clears the runtime set. - Wire runtime roots into core skill discovery for `skills/list` and turn loads, clear skill caches on set, and register the roots with the skills watcher so later filesystem changes emit `skills/changed`. - Update app-server docs, generated JSON/TypeScript schemas, and coverage for serialization, missing roots, empty clears, and restart behavior. ## Testing - `cargo test -p codex-app-server-protocol` - `cargo test -p codex-core-skills` - `cargo test -p codex-app-server skills_extra_roots_set_updates_process_runtime_roots` - `just fix -p codex-app-server-protocol` - `just fix -p codex-core-skills` - `just fix -p codex-app-server` --- .../schema/json/ClientRequest.json | 38 ++++ .../codex_app_server_protocol.schemas.json | 45 +++++ .../codex_app_server_protocol.v2.schemas.json | 45 +++++ .../json/v2/SkillsExtraRootsSetParams.json | 22 +++ .../json/v2/SkillsExtraRootsSetResponse.json | 5 + .../schema/typescript/ClientRequest.ts | 3 +- .../v2/SkillsExtraRootsSetParams.ts | 6 + .../v2/SkillsExtraRootsSetResponse.ts | 5 + .../schema/typescript/v2/index.ts | 2 + .../src/protocol/common.rs | 16 ++ .../src/protocol/v2/plugin.rs | 12 ++ .../src/protocol/v2/tests.rs | 21 +++ codex-rs/app-server/README.md | 17 +- codex-rs/app-server/src/message_processor.rs | 5 + codex-rs/app-server/src/request_processors.rs | 2 + .../request_processors/catalog_processor.rs | 33 ++++ codex-rs/app-server/src/skills_watcher.rs | 20 +++ .../app-server/tests/common/mcp_process.rs | 10 ++ .../app-server/tests/suite/v2/skills_list.rs | 164 ++++++++++++++++++ codex-rs/core-skills/src/loader.rs | 20 ++- codex-rs/core-skills/src/loader_tests.rs | 1 + codex-rs/core-skills/src/manager.rs | 22 +++ codex-rs/core-skills/src/manager_tests.rs | 122 +++++++++++++ 23 files changed, 632 insertions(+), 4 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/SkillsExtraRootsSetParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/SkillsExtraRootsSetResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/SkillsExtraRootsSetParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/SkillsExtraRootsSetResponse.ts diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index fb51ab25a2..dc5dd6ad60 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2988,6 +2988,20 @@ ], "type": "object" }, + "SkillsExtraRootsSetParams": { + "properties": { + "extraRoots": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + } + }, + "required": [ + "extraRoots" + ], + "type": "object" + }, "SkillsListParams": { "properties": { "cwds": { @@ -4783,6 +4797,30 @@ "title": "Skills/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "skills/extraRoots/set" + ], + "title": "Skills/extraRoots/setRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/SkillsExtraRootsSetParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Skills/extraRoots/setRequest", + "type": "object" + }, { "properties": { "id": { 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 b78690a4f6..92549d74c8 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 @@ -709,6 +709,30 @@ "title": "Skills/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "skills/extraRoots/set" + ], + "title": "Skills/extraRoots/setRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/SkillsExtraRootsSetParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Skills/extraRoots/setRequest", + "type": "object" + }, { "properties": { "id": { @@ -15121,6 +15145,27 @@ "title": "SkillsConfigWriteResponse", "type": "object" }, + "SkillsExtraRootsSetParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "extraRoots": { + "items": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "type": "array" + } + }, + "required": [ + "extraRoots" + ], + "title": "SkillsExtraRootsSetParams", + "type": "object" + }, + "SkillsExtraRootsSetResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "SkillsExtraRootsSetResponse", + "type": "object" + }, "SkillsListEntry": { "properties": { "cwd": { 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 b74ed83f8b..d266a3e977 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 @@ -1457,6 +1457,30 @@ "title": "Skills/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "skills/extraRoots/set" + ], + "title": "Skills/extraRoots/setRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/SkillsExtraRootsSetParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Skills/extraRoots/setRequest", + "type": "object" + }, { "properties": { "id": { @@ -12945,6 +12969,27 @@ "title": "SkillsConfigWriteResponse", "type": "object" }, + "SkillsExtraRootsSetParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "extraRoots": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + } + }, + "required": [ + "extraRoots" + ], + "title": "SkillsExtraRootsSetParams", + "type": "object" + }, + "SkillsExtraRootsSetResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "SkillsExtraRootsSetResponse", + "type": "object" + }, "SkillsListEntry": { "properties": { "cwd": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/SkillsExtraRootsSetParams.json b/codex-rs/app-server-protocol/schema/json/v2/SkillsExtraRootsSetParams.json new file mode 100644 index 0000000000..9992cdf8b4 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/SkillsExtraRootsSetParams.json @@ -0,0 +1,22 @@ +{ + "$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" + } + }, + "properties": { + "extraRoots": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + } + }, + "required": [ + "extraRoots" + ], + "title": "SkillsExtraRootsSetParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/SkillsExtraRootsSetResponse.json b/codex-rs/app-server-protocol/schema/json/v2/SkillsExtraRootsSetResponse.json new file mode 100644 index 0000000000..cf0792c748 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/SkillsExtraRootsSetResponse.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "SkillsExtraRootsSetResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index 86ba2cd171..e19ac2dfde 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -57,6 +57,7 @@ import type { PluginUninstallParams } from "./v2/PluginUninstallParams"; import type { ReviewStartParams } from "./v2/ReviewStartParams"; import type { SendAddCreditsNudgeEmailParams } from "./v2/SendAddCreditsNudgeEmailParams"; import type { SkillsConfigWriteParams } from "./v2/SkillsConfigWriteParams"; +import type { SkillsExtraRootsSetParams } from "./v2/SkillsExtraRootsSetParams"; import type { SkillsListParams } from "./v2/SkillsListParams"; import type { ThreadApproveGuardianDeniedActionParams } from "./v2/ThreadApproveGuardianDeniedActionParams"; import type { ThreadArchiveParams } from "./v2/ThreadArchiveParams"; @@ -85,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": "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/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/v2/SkillsExtraRootsSetParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsExtraRootsSetParams.ts new file mode 100644 index 0000000000..bcddb9f6f5 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsExtraRootsSetParams.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 { AbsolutePathBuf } from "../AbsolutePathBuf"; + +export type SkillsExtraRootsSetParams = { extraRoots: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsExtraRootsSetResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsExtraRootsSetResponse.ts new file mode 100644 index 0000000000..63be08186d --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsExtraRootsSetResponse.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 SkillsExtraRootsSetResponse = Record; 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 0b7316fc4f..66b67fb1b4 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -344,6 +344,8 @@ export type { SkillToolDependency } from "./SkillToolDependency"; export type { SkillsChangedNotification } from "./SkillsChangedNotification"; export type { SkillsConfigWriteParams } from "./SkillsConfigWriteParams"; export type { SkillsConfigWriteResponse } from "./SkillsConfigWriteResponse"; +export type { SkillsExtraRootsSetParams } from "./SkillsExtraRootsSetParams"; +export type { SkillsExtraRootsSetResponse } from "./SkillsExtraRootsSetResponse"; export type { SkillsListEntry } from "./SkillsListEntry"; export type { SkillsListParams } from "./SkillsListParams"; export type { SkillsListResponse } from "./SkillsListResponse"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index a8bec6b402..71c89a185c 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -610,6 +610,11 @@ client_request_definitions! { serialization: global_shared_read("config"), response: v2::SkillsListResponse, }, + SkillsExtraRootsSet => "skills/extraRoots/set" { + params: v2::SkillsExtraRootsSetParams, + serialization: global("config"), + response: v2::SkillsExtraRootsSetResponse, + }, HooksList => "hooks/list" { params: v2::HooksListParams, serialization: global("config"), @@ -1721,6 +1726,17 @@ mod tests { Some(ClientRequestSerializationScope::GlobalSharedRead("config")) ); + let skills_extra_roots_set = ClientRequest::SkillsExtraRootsSet { + request_id: request_id(), + params: v2::SkillsExtraRootsSetParams { + extra_roots: vec![absolute_path("/tmp/skills")], + }, + }; + assert_eq!( + skills_extra_roots_set.serialization_scope(), + Some(ClientRequestSerializationScope::Global("config")) + ); + let plugin_list = ClientRequest::PluginList { request_id: request_id(), params: v2::PluginListParams { 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 70d283994e..1b7e8ba8ff 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -35,6 +35,18 @@ pub struct SkillsListResponse { pub data: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsExtraRootsSetParams { + pub extra_roots: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsExtraRootsSetResponse {} + #[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/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 7d57f6e090..a0be5cb15a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -2626,6 +2626,27 @@ fn skills_list_params_serialization_uses_force_reload() { ); } +#[test] +fn skills_extra_roots_set_params_serialization_uses_extra_roots() { + assert_eq!( + serde_json::to_value(SkillsExtraRootsSetParams { + extra_roots: vec![absolute_path("tmp/skills")], + }) + .unwrap(), + json!({ + "extraRoots": [absolute_path_string("tmp/skills")], + }), + ); +} + +#[test] +fn skills_extra_roots_set_params_rejects_relative_roots() { + let result = serde_json::from_value::(json!({ + "extraRoots": ["relative/path"], + })); + assert!(result.is_err()); +} + #[test] fn plugin_source_serializes_local_git_and_remote_variants() { let local_path = if cfg!(windows) { diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index ad3767bb77..2bf6035b27 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -196,6 +196,7 @@ Example with notification opt-out: - `environment/add` — experimental; add or replace a named remote environment by `environmentId` and `execServerUrl` for later selection by `thread/start` or `turn/start`; returns `{}` and does not change the default environment. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). +- `skills/extraRoots/set` — replace the app-server process runtime extra standalone skill roots. The roots are not persisted; missing directories are accepted and simply load no skills. - `hooks/list` — list discovered hooks for one or more `cwd` values. - `marketplace/add` — add a remote plugin marketplace from an HTTP(S) Git URL, SSH Git URL, or GitHub `owner/repo` shorthand, then persist it into the user marketplace config. Returns the installed root path plus whether the marketplace was already present. - `marketplace/remove` — remove a configured marketplace by name from the user marketplace config, and delete its installed marketplace root when one exists. @@ -1506,6 +1507,7 @@ $skill-creator Add a new skill for triaging flaky CI and include step-by-step us Use `skills/list` to fetch the available skills (optionally scoped by `cwds`, with `forceReload`). `skills/list` might reuse a cached skills result per `cwd`; setting `forceReload` to `true` refreshes the result from disk. The server also emits `skills/changed` notifications when watched local skill files change. Treat this as an invalidation signal and re-run `skills/list` with your current params when needed. +Use `skills/extraRoots/set` to replace additional standalone skill roots for the current app-server process. These roots use the same layout as other standalone skill roots: each root contains skill directories, and each skill directory contains `SKILL.md`. Missing roots are accepted and load no skills until they exist. This setting is lost when app-server exits. ```json { "method": "skills/list", "id": 25, "params": { @@ -1542,12 +1544,23 @@ The server also emits `skills/changed` notifications when watched local skill fi } ``` +```json +{ + "method": "skills/extraRoots/set", + "id": 26, + "params": { + "extraRoots": ["/Users/me/generated-skills"] + } +} +{ "id": 26, "result": {} } +``` + To enable or disable a skill by absolute path: ```json { "method": "skills/config/write", - "id": 26, + "id": 27, "params": { "path": "/Users/alice/.codex/skills/skill-creator/SKILL.md", "name": null, @@ -1561,7 +1574,7 @@ To enable or disable a skill by name: ```json { "method": "skills/config/write", - "id": 27, + "id": 28, "params": { "path": null, "name": "github:yeet", diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 33b9c56d4a..2dbd17a3d7 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -354,6 +354,8 @@ impl MessageProcessor { app_list_shutdown_token, ); let catalog_processor = CatalogRequestProcessor::new( + outgoing.clone(), + Arc::clone(&skills_watcher), auth_manager.clone(), Arc::clone(&thread_manager), Arc::clone(&config), @@ -1108,6 +1110,9 @@ impl MessageProcessor { ClientRequest::SkillsList { params, .. } => { self.catalog_processor.skills_list(params).await } + ClientRequest::SkillsExtraRootsSet { params, .. } => { + self.catalog_processor.skills_extra_roots_set(params).await + } ClientRequest::HooksList { params, .. } => { self.catalog_processor.hooks_list(params).await } diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 8408a3f67f..a110b73191 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -156,6 +156,8 @@ use codex_app_server_protocol::ServerRequestResolvedNotification; use codex_app_server_protocol::SkillSummary; use codex_app_server_protocol::SkillsConfigWriteParams; use codex_app_server_protocol::SkillsConfigWriteResponse; +use codex_app_server_protocol::SkillsExtraRootsSetParams; +use codex_app_server_protocol::SkillsExtraRootsSetResponse; use codex_app_server_protocol::SkillsListParams; use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::SortDirection; 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 ba6c535665..6f17e4614f 100644 --- a/codex-rs/app-server/src/request_processors/catalog_processor.rs +++ b/codex-rs/app-server/src/request_processors/catalog_processor.rs @@ -4,6 +4,8 @@ use futures::StreamExt; #[derive(Clone)] pub(crate) struct CatalogRequestProcessor { + pub(super) outgoing: Arc, + pub(super) skills_watcher: Arc, pub(super) auth_manager: Arc, pub(super) thread_manager: Arc, pub(super) config: Arc, @@ -96,6 +98,8 @@ fn errors_to_info( impl CatalogRequestProcessor { pub(crate) fn new( + outgoing: Arc, + skills_watcher: Arc, auth_manager: Arc, thread_manager: Arc, config: Arc, @@ -103,6 +107,8 @@ impl CatalogRequestProcessor { workspace_settings_cache: Arc, ) -> Self { Self { + outgoing, + skills_watcher, auth_manager, thread_manager, config, @@ -138,6 +144,15 @@ impl CatalogRequestProcessor { .map(|response| Some(response.into())) } + pub(crate) async fn skills_extra_roots_set( + &self, + params: SkillsExtraRootsSetParams, + ) -> Result, JSONRPCErrorError> { + self.skills_extra_roots_set_response(params) + .await + .map(|response| Some(response.into())) + } + pub(crate) async fn model_list( &self, params: ModelListParams, @@ -567,6 +582,24 @@ impl CatalogRequestProcessor { Ok(SkillsListResponse { data }) } + async fn skills_extra_roots_set_response( + &self, + params: SkillsExtraRootsSetParams, + ) -> Result { + let SkillsExtraRootsSetParams { extra_roots } = params; + self.skills_watcher + .register_runtime_extra_roots(&extra_roots); + self.thread_manager + .skills_manager() + .set_extra_roots(extra_roots); + self.outgoing + .send_server_notification(ServerNotification::SkillsChanged( + codex_app_server_protocol::SkillsChangedNotification {}, + )) + .await; + Ok(SkillsExtraRootsSetResponse {}) + } + /// Handle `hooks/list` by resolving hooks for each requested cwd. async fn hooks_list_response( &self, diff --git a/codex-rs/app-server/src/skills_watcher.rs b/codex-rs/app-server/src/skills_watcher.rs index 9e34cdac8e..57edb89c7f 100644 --- a/codex-rs/app-server/src/skills_watcher.rs +++ b/codex-rs/app-server/src/skills_watcher.rs @@ -1,4 +1,5 @@ use std::sync::Arc; +use std::sync::Mutex; use std::time::Duration; use crate::outgoing_message::OutgoingMessageSender; @@ -15,6 +16,7 @@ use codex_file_watcher::ThrottledWatchReceiver; use codex_file_watcher::WatchPath; use codex_file_watcher::WatchRegistration; use codex_protocol::protocol::TurnEnvironmentSelection; +use codex_utils_absolute_path::AbsolutePathBuf; use tokio_util::sync::CancellationToken; use tokio_util::sync::DropGuard; use tracing::warn; @@ -26,6 +28,7 @@ const WATCHER_THROTTLE_INTERVAL: Duration = Duration::from_millis(50); pub(crate) struct SkillsWatcher { subscriber: FileWatcherSubscriber, + runtime_extra_roots_registration: Mutex, shutdown_token: CancellationToken, _shutdown_drop_guard: DropGuard, } @@ -48,6 +51,7 @@ impl SkillsWatcher { Self::spawn_event_loop(rx, skills_manager, outgoing, shutdown_token.child_token()); Arc::new(Self { subscriber, + runtime_extra_roots_registration: Mutex::new(WatchRegistration::default()), shutdown_token, _shutdown_drop_guard: shutdown_drop_guard, }) @@ -57,6 +61,22 @@ impl SkillsWatcher { self.shutdown_token.cancel(); } + pub(crate) fn register_runtime_extra_roots(&self, extra_roots: &[AbsolutePathBuf]) { + let roots = extra_roots + .iter() + .map(|root| WatchPath { + path: root.clone().into_path_buf(), + recursive: true, + }) + .collect(); + let registration = self.subscriber.register_paths(roots); + let mut guard = self + .runtime_extra_roots_registration + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *guard = registration; + } + pub(crate) async fn register_thread_config( &self, config: &Config, diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 2d2c26b5a6..d3bbfbf180 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -71,6 +71,7 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ReviewStartParams; use codex_app_server_protocol::SendAddCreditsNudgeEmailParams; use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::SkillsExtraRootsSetParams; use codex_app_server_protocol::SkillsListParams; use codex_app_server_protocol::ThreadArchiveParams; use codex_app_server_protocol::ThreadCompactStartParams; @@ -675,6 +676,15 @@ impl McpProcess { self.send_request("skills/list", params).await } + /// Send a `skills/extraRoots/set` JSON-RPC request. + pub async fn send_skills_extra_roots_set_request( + &mut self, + params: SkillsExtraRootsSetParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("skills/extraRoots/set", params).await + } + /// Send a `hooks/list` JSON-RPC request. pub async fn send_hooks_list_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/skills_list.rs b/codex-rs/app-server/tests/suite/v2/skills_list.rs index 74100bc19f..2ad0b2006a 100644 --- a/codex-rs/app-server/tests/suite/v2/skills_list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills_list.rs @@ -13,11 +13,14 @@ use codex_app_server_protocol::PluginListParams; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SkillsChangedNotification; +use codex_app_server_protocol::SkillsExtraRootsSetParams; +use codex_app_server_protocol::SkillsExtraRootsSetResponse; use codex_app_server_protocol::SkillsListParams; use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::ThreadStartParams; use codex_config::types::AuthCredentialsStoreMode; use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; +use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::time::timeout; @@ -40,6 +43,23 @@ fn write_skill(root: &TempDir, name: &str) -> Result<()> { Ok(()) } +async fn expect_skills_changed_notification( + mcp: &mut McpProcess, + timeout_duration: Duration, +) -> Result<()> { + let notification = timeout( + timeout_duration, + mcp.read_stream_until_notification_message("skills/changed"), + ) + .await??; + let params = notification + .params + .context("skills/changed params must be present")?; + let notification: SkillsChangedNotification = serde_json::from_value(params)?; + assert_eq!(notification, SkillsChangedNotification {}); + Ok(()) +} + fn write_plugins_enabled_config_with_base_url( codex_home: &std::path::Path, base_url: &str, @@ -573,6 +593,150 @@ async fn skills_list_uses_cached_result_until_force_reload() -> Result<()> { Ok(()) } +#[tokio::test] +async fn skills_extra_roots_set_updates_process_runtime_roots() -> Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + let extra_root = TempDir::new()?; + let extra_skills_root = extra_root.path().join("skills"); + let skill_dir = extra_skills_root.join("runtime-skill"); + std::fs::create_dir_all(&skill_dir)?; + std::fs::write( + skill_dir.join("SKILL.md"), + "---\nname: runtime-skill\ndescription: runtime skill\n---\n\n# Body\n", + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let set_request_id = mcp + .send_skills_extra_roots_set_request(SkillsExtraRootsSetParams { + extra_roots: vec![AbsolutePathBuf::from_absolute_path(&extra_skills_root)?], + }) + .await?; + let set_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(set_request_id)), + ) + .await??; + let _: SkillsExtraRootsSetResponse = to_response(set_response)?; + expect_skills_changed_notification(&mut mcp, DEFAULT_TIMEOUT).await?; + + let skills_request_id = mcp + .send_skills_list_request(SkillsListParams { + cwds: vec![cwd.path().to_path_buf()], + force_reload: false, + }) + .await?; + let skills_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(skills_request_id)), + ) + .await??; + let SkillsListResponse { data } = to_response(skills_response)?; + assert_eq!(data.len(), 1); + assert_eq!(data[0].errors, Vec::new()); + assert!( + data[0] + .skills + .iter() + .any(|skill| skill.name == "runtime-skill") + ); + + let missing_root = extra_root.path().join("missing-skills"); + let reset_request_id = mcp + .send_skills_extra_roots_set_request(SkillsExtraRootsSetParams { + extra_roots: vec![AbsolutePathBuf::from_absolute_path(&missing_root)?], + }) + .await?; + let reset_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(reset_request_id)), + ) + .await??; + let _: SkillsExtraRootsSetResponse = to_response(reset_response)?; + expect_skills_changed_notification(&mut mcp, DEFAULT_TIMEOUT).await?; + + let skills_request_id = mcp + .send_skills_list_request(SkillsListParams { + cwds: vec![cwd.path().to_path_buf()], + force_reload: false, + }) + .await?; + let skills_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(skills_request_id)), + ) + .await??; + let SkillsListResponse { data } = to_response(skills_response)?; + assert_eq!(data.len(), 1); + assert_eq!(data[0].errors, Vec::new()); + assert!( + data[0] + .skills + .iter() + .all(|skill| skill.name != "runtime-skill") + ); + + let clear_request_id = mcp + .send_skills_extra_roots_set_request(SkillsExtraRootsSetParams { + extra_roots: Vec::new(), + }) + .await?; + let clear_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(clear_request_id)), + ) + .await??; + let _: SkillsExtraRootsSetResponse = to_response(clear_response)?; + expect_skills_changed_notification(&mut mcp, DEFAULT_TIMEOUT).await?; + let skills_request_id = mcp + .send_skills_list_request(SkillsListParams { + cwds: vec![cwd.path().to_path_buf()], + force_reload: false, + }) + .await?; + let skills_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(skills_request_id)), + ) + .await??; + let SkillsListResponse { data } = to_response(skills_response)?; + assert_eq!(data.len(), 1); + assert_eq!(data[0].errors, Vec::new()); + assert!( + data[0] + .skills + .iter() + .all(|skill| skill.name != "runtime-skill") + ); + + drop(mcp); + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let skills_request_id = mcp + .send_skills_list_request(SkillsListParams { + cwds: vec![cwd.path().to_path_buf()], + force_reload: false, + }) + .await?; + let skills_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(skills_request_id)), + ) + .await??; + let SkillsListResponse { data } = to_response(skills_response)?; + assert_eq!(data.len(), 1); + assert_eq!(data[0].errors, Vec::new()); + assert!( + data[0] + .skills + .iter() + .all(|skill| skill.name != "runtime-skill") + ); + Ok(()) +} + #[tokio::test] async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; diff --git a/codex-rs/core-skills/src/loader.rs b/codex-rs/core-skills/src/loader.rs index 00d1cbba14..8663737ee5 100644 --- a/codex-rs/core-skills/src/loader.rs +++ b/codex-rs/core-skills/src/loader.rs @@ -235,6 +235,7 @@ pub(crate) async fn skill_roots( config_layer_stack: &ConfigLayerStack, cwd: &AbsolutePathBuf, plugin_skill_roots: Vec, + extra_skill_roots: Vec, ) -> Vec { let home_dir = home_dir().and_then(|path| AbsolutePathBuf::from_absolute_path_checked(path).ok()); @@ -244,6 +245,7 @@ pub(crate) async fn skill_roots( cwd, home_dir.as_ref(), plugin_skill_roots, + extra_skill_roots, ) .await } @@ -254,6 +256,7 @@ async fn skill_roots_with_home_dir( cwd: &AbsolutePathBuf, home_dir: Option<&AbsolutePathBuf>, plugin_skill_roots: Vec, + extra_skill_roots: Vec, ) -> Vec { let mut roots = skill_roots_from_layer_stack_inner(config_layer_stack, home_dir, fs.clone()); roots.extend(plugin_skill_roots.into_iter().map(|root| SkillRoot { @@ -263,6 +266,13 @@ async fn skill_roots_with_home_dir( plugin_id: Some(root.plugin_id), plugin_root: Some(root.plugin_root), })); + roots.extend(extra_skill_roots.into_iter().map(|path| SkillRoot { + path, + scope: SkillScope::User, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, + plugin_root: None, + })); roots.extend(repo_agents_skill_roots(fs, config_layer_stack, cwd).await); dedupe_skill_roots_by_path(&mut roots); roots @@ -1051,7 +1061,15 @@ pub(crate) async fn skill_roots_from_layer_stack( cwd: &AbsolutePathBuf, home_dir: Option<&AbsolutePathBuf>, ) -> Vec { - skill_roots_with_home_dir(Some(fs), config_layer_stack, cwd, home_dir, Vec::new()).await + skill_roots_with_home_dir( + Some(fs), + config_layer_stack, + cwd, + home_dir, + Vec::new(), + Vec::new(), + ) + .await } #[cfg(test)] diff --git a/codex-rs/core-skills/src/loader_tests.rs b/codex-rs/core-skills/src/loader_tests.rs index 84867ca66b..69e357d93d 100644 --- a/codex-rs/core-skills/src/loader_tests.rs +++ b/codex-rs/core-skills/src/loader_tests.rs @@ -1900,6 +1900,7 @@ async fn skill_roots_include_admin_with_lowest_priority() { &cfg.config_layer_stack, &cfg.cwd, Vec::new(), + Vec::new(), ) .await .into_iter() diff --git a/codex-rs/core-skills/src/manager.rs b/codex-rs/core-skills/src/manager.rs index db19322acf..185eaa9320 100644 --- a/codex-rs/core-skills/src/manager.rs +++ b/codex-rs/core-skills/src/manager.rs @@ -51,6 +51,7 @@ impl SkillsLoadInput { pub struct SkillsManager { codex_home: AbsolutePathBuf, restriction_product: Option, + extra_roots: RwLock>, cache_by_cwd: RwLock>, cache_by_config: RwLock>, } @@ -68,6 +69,7 @@ impl SkillsManager { let manager = Self { codex_home, restriction_product, + extra_roots: RwLock::new(Vec::new()), cache_by_cwd: RwLock::new(HashMap::new()), cache_by_config: RwLock::new(HashMap::new()), }; @@ -81,6 +83,17 @@ impl SkillsManager { manager } + pub fn set_extra_roots(&self, extra_roots: Vec) { + { + let mut roots = self + .extra_roots + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *roots = extra_roots; + } + self.clear_cache(); + } + /// Load skills for an already-constructed [`Config`], avoiding any additional config-layer /// loading. /// @@ -118,6 +131,7 @@ impl SkillsManager { &input.config_layer_stack, &input.cwd, input.effective_skill_roots.clone(), + self.extra_roots(), ) .await; if !input.bundled_skills_enabled { @@ -145,6 +159,7 @@ impl SkillsManager { &input.config_layer_stack, &input.cwd, input.effective_skill_roots.clone(), + self.extra_roots(), ) .await; if !bundled_skills_enabled_from_stack(&input.config_layer_stack) { @@ -214,6 +229,13 @@ impl SkillsManager { Err(err) => err.into_inner().get(cache_key).cloned(), } } + + fn extra_roots(&self) -> Vec { + match self.extra_roots.read() { + Ok(roots) => roots.clone(), + Err(err) => err.into_inner().clone(), + } + } } #[derive(Debug, Clone, PartialEq, Eq, Hash)] diff --git a/codex-rs/core-skills/src/manager_tests.rs b/codex-rs/core-skills/src/manager_tests.rs index 4afa83e21e..713b94d7b1 100644 --- a/codex-rs/core-skills/src/manager_tests.rs +++ b/codex-rs/core-skills/src/manager_tests.rs @@ -221,6 +221,128 @@ async fn skills_for_config_reuses_cache_for_same_effective_config() { assert_eq!(outcome2.skills, outcome1.skills); } +#[tokio::test] +async fn set_extra_roots_replaces_runtime_roots_and_clears_cache() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let cwd = tempfile::tempdir().expect("tempdir"); + let extra_root = tempfile::tempdir().expect("tempdir"); + let config_layer_stack = config_stack(&codex_home, ""); + let skills_manager = SkillsManager::new( + codex_home.path().abs(), + /*bundled_skills_enabled*/ true, + ); + + let skills_input = SkillsLoadInput::new( + cwd.path().abs(), + Vec::new(), + config_layer_stack.clone(), + bundled_skills_enabled_from_stack(&config_layer_stack), + ); + let empty_outcome = skills_manager + .skills_for_cwd( + &skills_input, + /*force_reload*/ false, + Some(Arc::clone(&LOCAL_FS)), + ) + .await; + assert!( + empty_outcome + .skills + .iter() + .all(|skill| skill.name != "runtime-skill") + ); + + let extra_skills_root = extra_root.path().join("skills"); + let skill_dir = extra_skills_root.join("runtime-skill"); + fs::create_dir_all(&skill_dir).expect("create skill dir"); + fs::write( + skill_dir.join("SKILL.md"), + "---\nname: runtime-skill\ndescription: runtime skill\n---\n\n# Body\n", + ) + .expect("write skill"); + skills_manager.set_extra_roots(vec![extra_skills_root.abs()]); + + let runtime_outcome = skills_manager + .skills_for_cwd( + &skills_input, + /*force_reload*/ false, + Some(Arc::clone(&LOCAL_FS)), + ) + .await; + assert!( + runtime_outcome + .skills + .iter() + .any(|skill| skill.name == "runtime-skill") + ); + + skills_manager.set_extra_roots(vec![extra_root.path().join("missing-skills").abs()]); + let replaced_outcome = skills_manager + .skills_for_cwd( + &skills_input, + /*force_reload*/ false, + Some(Arc::clone(&LOCAL_FS)), + ) + .await; + assert_eq!(replaced_outcome.errors, Vec::new()); + assert!( + replaced_outcome + .skills + .iter() + .all(|skill| skill.name != "runtime-skill") + ); +} + +#[tokio::test] +async fn set_extra_roots_applies_to_config_loads_and_empty_clears() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let cwd = tempfile::tempdir().expect("tempdir"); + let extra_root = tempfile::tempdir().expect("tempdir"); + let config_layer_stack = config_stack(&codex_home, ""); + let skills_manager = SkillsManager::new( + codex_home.path().abs(), + /*bundled_skills_enabled*/ true, + ); + + let empty_outcome = + skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]).await; + assert!( + empty_outcome + .skills + .iter() + .all(|skill| skill.name != "runtime-skill") + ); + + let extra_skills_root = extra_root.path().join("skills"); + let skill_dir = extra_skills_root.join("runtime-skill"); + fs::create_dir_all(&skill_dir).expect("create skill dir"); + fs::write( + skill_dir.join("SKILL.md"), + "---\nname: runtime-skill\ndescription: runtime skill\n---\n\n# Body\n", + ) + .expect("write skill"); + skills_manager.set_extra_roots(vec![extra_skills_root.abs()]); + + let runtime_outcome = + skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]).await; + assert!( + runtime_outcome + .skills + .iter() + .any(|skill| skill.name == "runtime-skill") + ); + + skills_manager.set_extra_roots(Vec::new()); + let cleared_outcome = + skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]).await; + assert!( + cleared_outcome + .skills + .iter() + .all(|skill| skill.name != "runtime-skill") + ); +} + #[tokio::test] async fn skills_for_config_disables_plugin_skills_by_name() { let codex_home = tempfile::tempdir().expect("tempdir"); From 56958f25125a36777a1cb640297b762e3c4b4a3d Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 28 May 2026 22:08:05 -0700 Subject: [PATCH 03/40] Seed prompt history from resumed messages (#24298) ## Why When the TUI resumes a thread, transcript replay renders prior user messages but did not seed the composer history. That leaves the resumed session with empty in-memory prompt history, so pressing Up can fall through to persisted global history and surface a prompt from another thread. The expected behavior is that prompts from the resumed thread are recalled first, with global history only as a fallback. ## What changed - Record replayed user messages into the composer history during resume replay. - Preserve the existing persisted history format and avoid any startup history scan. - Add focused TUI coverage showing replayed prompts are recalled before persisted global history. ## Validation - Added `replayed_user_messages_seed_composer_history` in `codex-rs/tui/src/chatwidget/tests/history_replay.rs`. - `just test -p codex-tui replayed_user_messages_seed_composer_history` passed. --- codex-rs/connectors/src/metadata.rs | 6 +- codex-rs/tui/src/bottom_pane/chat_composer.rs | 4 + .../src/bottom_pane/chat_composer_history.rs | 125 +++++++++++++++--- codex-rs/tui/src/bottom_pane/mod.rs | 5 + codex-rs/tui/src/chatwidget.rs | 73 +++++++++- .../src/chatwidget/tests/history_replay.rs | 115 ++++++++++++++++ 6 files changed, 303 insertions(+), 25 deletions(-) diff --git a/codex-rs/connectors/src/metadata.rs b/codex-rs/connectors/src/metadata.rs index 64becb766d..0a7eebe4e4 100644 --- a/codex-rs/connectors/src/metadata.rs +++ b/codex-rs/connectors/src/metadata.rs @@ -5,7 +5,11 @@ pub fn connector_display_label(connector: &AppInfo) -> String { } pub fn connector_mention_slug(connector: &AppInfo) -> String { - crate::connector_name_slug(&connector_display_label(connector)) + connector_mention_slug_from_name(&connector_display_label(connector)) +} + +pub fn connector_mention_slug_from_name(name: &str) -> String { + crate::connector_name_slug(name) } pub fn connector_install_url(name: &str, connector_id: &str) -> String { diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 21208de946..721824ed61 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -858,6 +858,10 @@ impl ChatComposer { } } + pub(crate) fn record_replayed_user_message_history(&mut self, entry: HistoryEntry) { + self.history.record_replayed_submission(entry); + } + /// Integrate pasted text into the composer. /// /// Acts as the only place where paste text is integrated, both for: diff --git a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs index 23b553fa08..ff6dd9983c 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs @@ -121,6 +121,8 @@ pub(crate) struct ChatComposerHistory { /// Messages submitted by the user *during this UI session* (newest at END). /// Local entries retain full draft state (text elements, image paths, pending pastes, remote image URLs). local_history: Vec, + /// Local entries seeded from resumed transcript replay. + replay_seeded_history: Vec, /// Cache of persistent history entries fetched on-demand (text-only). fetched_history: HashMap, @@ -128,6 +130,7 @@ pub(crate) struct ChatComposerHistory { /// Current cursor within the combined (persistent + local) history. `None` /// indicates the user is *not* currently browsing history. history_cursor: Option, + pending_navigation_direction: Option, /// The text that was last inserted into the composer as a result of /// history navigation. Used to decide if further Up/Down presses should be @@ -229,8 +232,10 @@ impl ChatComposerHistory { persistent_log_id: None, persistent_entry_count: 0, local_history: Vec::new(), + replay_seeded_history: Vec::new(), fetched_history: HashMap::new(), history_cursor: None, + pending_navigation_direction: None, last_history_text: None, search: None, at_mention_restore_enabled: false, @@ -259,7 +264,9 @@ impl ChatComposerHistory { self.persistent_entry_count = entry_count; self.fetched_history.clear(); self.local_history.clear(); + self.replay_seeded_history.clear(); self.history_cursor = None; + self.pending_navigation_direction = None; self.last_history_text = None; self.search = None; } @@ -269,6 +276,16 @@ impl ChatComposerHistory { /// Empty submissions are ignored, adjacent duplicates are collapsed, and active navigation or /// search state is reset because a new newest entry changes the combined history offset space. pub fn record_local_submission(&mut self, entry: HistoryEntry) { + self.record_local_submission_inner(entry); + } + + pub fn record_replayed_submission(&mut self, entry: HistoryEntry) { + if self.record_local_submission_inner(entry.clone()) { + self.replay_seeded_history.push(entry); + } + } + + fn record_local_submission_inner(&mut self, entry: HistoryEntry) -> bool { if entry.text.is_empty() && entry.text_elements.is_empty() && entry.local_image_paths.is_empty() @@ -276,18 +293,20 @@ impl ChatComposerHistory { && entry.mention_bindings.is_empty() && entry.pending_pastes.is_empty() { - return; + return false; } self.history_cursor = None; + self.pending_navigation_direction = None; self.last_history_text = None; self.search = None; // Avoid inserting a duplicate if identical to the previous entry. if self.local_history.last().is_some_and(|prev| prev == &entry) { - return; + return false; } self.local_history.push(entry); + true } /// Resets normal history navigation so the next Up key resumes from the newest entry. @@ -297,6 +316,7 @@ impl ChatComposerHistory { /// influence later Up/Down recall. pub fn reset_navigation(&mut self) { self.history_cursor = None; + self.pending_navigation_direction = None; self.last_history_text = None; self.search = None; } @@ -359,7 +379,11 @@ impl ChatComposerHistory { }; self.history_cursor = Some(next_idx); - self.populate_history_at_index(next_idx as usize, app_event_tx) + self.populate_history_at_index( + next_idx as usize, + HistorySearchDirection::Older, + app_event_tx, + ) } /// Handles Down by moving toward newer entries or clearing the composer past the newest entry. @@ -383,11 +407,16 @@ impl ChatComposerHistory { match next_idx_opt { Some(idx) => { self.history_cursor = Some(idx); - self.populate_history_at_index(idx as usize, app_event_tx) + self.populate_history_at_index( + idx as usize, + HistorySearchDirection::Newer, + app_event_tx, + ) } None => { // Past newest – clear and exit browsing mode. self.history_cursor = None; + self.pending_navigation_direction = None; self.last_history_text = None; Some(HistoryEntry::new(String::new())) } @@ -449,9 +478,22 @@ impl ChatComposerHistory { } if self.history_cursor == Some(offset as isize) { + let direction = self.pending_navigation_direction.take(); let Some(entry) = entry else { return HistoryEntryResponse::Ignored; }; + if self.persistent_entry_duplicates_local(&entry) + && let Some(direction) = direction + { + let Some(offset) = self.next_history_offset(offset, direction) else { + return HistoryEntryResponse::Ignored; + }; + self.history_cursor = Some(offset as isize); + return self + .populate_history_at_index(offset, direction, app_event_tx) + .map(HistoryEntryResponse::Found) + .unwrap_or(HistoryEntryResponse::Ignored); + } self.last_history_text = Some(entry.text.clone()); return HistoryEntryResponse::Found(entry); } @@ -729,29 +771,61 @@ impl ChatComposerHistory { fn populate_history_at_index( &mut self, global_idx: usize, + direction: HistorySearchDirection, app_event_tx: &AppEventSender, ) -> Option { - if global_idx >= self.persistent_entry_count { - // Local entry. - if let Some(entry) = self - .local_history - .get(global_idx - self.persistent_entry_count) - .cloned() - { + let mut global_idx = global_idx; + loop { + if let Some(entry) = self.entry_at_cached_offset(global_idx) { + if global_idx < self.persistent_entry_count + && self.persistent_entry_duplicates_local(&entry) + { + let Some(next_idx) = self.next_history_offset(global_idx, direction) else { + self.pending_navigation_direction = None; + return None; + }; + self.history_cursor = Some(next_idx as isize); + global_idx = next_idx; + continue; + } + self.pending_navigation_direction = None; self.last_history_text = Some(entry.text.clone()); return Some(entry); } - } else if let Some(entry) = self.fetched_history.get(&global_idx).cloned() { - self.last_history_text = Some(entry.text.clone()); - return Some(entry); - } else if let (Some(thread_id), Some(log_id)) = (self.thread_id, self.persistent_log_id) { - app_event_tx.send(AppEvent::LookupMessageHistoryEntry { - thread_id, - offset: global_idx, - log_id, - }); + + if global_idx >= self.persistent_entry_count { + return None; + } + + if let (Some(thread_id), Some(log_id)) = (self.thread_id, self.persistent_log_id) { + self.pending_navigation_direction = Some(direction); + app_event_tx.send(AppEvent::LookupMessageHistoryEntry { + thread_id, + offset: global_idx, + log_id, + }); + } + return None; } - None + } + + fn next_history_offset( + &self, + offset: usize, + direction: HistorySearchDirection, + ) -> Option { + match direction { + HistorySearchDirection::Older => offset.checked_sub(1), + HistorySearchDirection::Newer => offset + .checked_add(1) + .filter(|next| *next < self.total_entries()), + } + } + + fn persistent_entry_duplicates_local(&self, entry: &HistoryEntry) -> bool { + self.replay_seeded_history.iter().any(|local_entry| { + local_entry.text == entry.text && local_entry.mention_bindings == entry.mention_bindings + }) } } @@ -939,9 +1013,16 @@ mod tests { // Pretend there are 3 persistent entries. let thread_id = test_thread_id(); history.set_metadata(thread_id, /*log_id*/ 1, /*entry_count*/ 3); + history.record_local_submission(HistoryEntry::new("latest".to_string())); - // First Up should request offset 2 (latest) and await async data. + // First Up should recall current-session local history. assert!(history.should_handle_navigation("", /*cursor*/ 0)); + assert_eq!( + Some(HistoryEntry::new("latest".to_string())), + history.navigate_up(&tx) + ); + + // Next Up should request offset 2 and await async data. assert!(history.navigate_up(&tx).is_none()); // don't replace the text yet // Verify that a history lookup request was sent. diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 7ef385570e..fa92914670 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -184,6 +184,7 @@ pub(crate) use chat_composer::ChatComposer; pub(crate) use chat_composer::ChatComposerConfig; pub(crate) use chat_composer::InputResult; pub(crate) use chat_composer::QueuedInputAction; +pub(crate) use chat_composer_history::HistoryEntry; use crate::status_indicator_widget::StatusDetailsCapitalization; use crate::status_indicator_widget::StatusIndicatorWidget; @@ -1543,6 +1544,10 @@ impl BottomPane { } } + pub(crate) fn record_replayed_user_message_history(&mut self, entry: HistoryEntry) { + self.composer.record_replayed_user_message_history(entry); + } + pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec) { self.composer.on_file_search_result(query, matches); self.request_redraw(); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index d3e5cf68ef..7d879a09c5 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -169,6 +169,8 @@ use codex_terminal_detection::TerminalName; use codex_terminal_detection::terminal_info; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cli::resume_hint; +use codex_utils_plugins::mention_syntax::PLUGIN_TEXT_MENTION_SIGIL; +use codex_utils_plugins::mention_syntax::TOOL_MENTION_SIGIL; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -274,6 +276,7 @@ use crate::bottom_pane::DOUBLE_PRESS_QUIT_SHORTCUT_ENABLED; use crate::bottom_pane::ExperimentalFeatureItem; use crate::bottom_pane::ExperimentalFeaturesView; use crate::bottom_pane::GoalStatusIndicator; +use crate::bottom_pane::HistoryEntry; use crate::bottom_pane::InputResult; use crate::bottom_pane::LocalImageAttachment; use crate::bottom_pane::McpServerElicitationFormRequest; @@ -1223,9 +1226,75 @@ impl ChatWidget { fn on_committed_user_message(&mut self, items: &[UserInput], from_replay: bool) { let display = Self::user_message_display_from_inputs(items); if from_replay { - if !self.review.is_review_mode { - self.on_user_message_display(display); + if self.review.is_review_mode { + return; } + let message = display.message.as_str(); + let mention_start = |sigil: char, mention: &str| { + let token = format!("{sigil}{mention}"); + message.match_indices(&token).find_map(|(start, _)| { + let end = start + token.len(); + message + .as_bytes() + .get(end) + .is_none_or(|byte| { + !byte.is_ascii_alphanumeric() && !matches!(byte, b'_' | b'-') + }) + .then_some(start) + }) + }; + let mut mention_bindings: Vec = items + .iter() + .filter_map(|item| match item { + UserInput::Skill { name, path } => Some(MentionBinding { + sigil: TOOL_MENTION_SIGIL, + mention: name.clone(), + path: path.to_string_lossy().into_owned(), + }), + UserInput::Mention { name, path } => { + let plugin_id = path.strip_prefix("plugin://"); + let mention = if let Some(plugin_id) = plugin_id { + plugin_id + .split_once('@') + .map(|(plugin_name, _)| plugin_name) + .unwrap_or(plugin_id) + .to_string() + } else if path.starts_with("app://") { + codex_connectors::metadata::connector_mention_slug_from_name(name) + } else { + name.clone() + }; + let sigil = if plugin_id.is_some() + && mention_start(PLUGIN_TEXT_MENTION_SIGIL, &mention).is_some() + { + PLUGIN_TEXT_MENTION_SIGIL + } else { + TOOL_MENTION_SIGIL + }; + Some(MentionBinding { + sigil, + mention, + path: path.clone(), + }) + } + UserInput::Text { .. } + | UserInput::Image { .. } + | UserInput::LocalImage { .. } => None, + }) + .collect(); + mention_bindings.sort_by_key(|binding| { + mention_start(binding.sigil, &binding.mention).unwrap_or(usize::MAX) + }); + self.bottom_pane + .record_replayed_user_message_history(HistoryEntry { + text: display.message.clone(), + text_elements: display.text_elements.clone(), + local_image_paths: display.local_images.clone(), + remote_image_urls: display.remote_image_urls.clone(), + mention_bindings, + pending_pastes: Vec::new(), + }); + self.on_user_message_display(display); return; } diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 58f99231f3..e6b9d4ede0 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -1,4 +1,5 @@ use super::*; +use crate::app_event::HistoryLookupResponse; use codex_app_server_protocol::NetworkAccess; use codex_app_server_protocol::SandboxPolicy; use codex_protocol::models::ManagedFileSystemPermissions; @@ -74,6 +75,120 @@ async fn resumed_initial_messages_render_history() { ); } +#[tokio::test] +async fn replayed_user_messages_seed_composer_history() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; + chat.bottom_pane.set_history_metadata( + ThreadId::new(), + /*log_id*/ 1, + /*entry_count*/ 3, + ); + + let mut replay_mention = |id: &str, text: &str, name: &str, path: &str| { + replay_user_message_inputs( + &mut chat, + id, + vec![ + AppServerUserInput::Text { + text: text.to_string(), + text_elements: Vec::new(), + }, + AppServerUserInput::Mention { + name: name.to_string(), + path: path.to_string(), + }, + ], + ReplayKind::ResumeInitialMessages, + ); + }; + replay_mention( + "user-1", + "use $sample", + "Sample Plugin", + "plugin://sample@test", + ); + replay_mention( + "user-2", + "use $google-calendar", + "Google Calendar", + "app://google_calendar", + ); + drain_insert_history(&mut rx); + + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(chat.bottom_pane.composer_text(), "use $google-calendar"); + assert_eq!( + chat.bottom_pane.take_mention_bindings(), + vec![MentionBinding { + sigil: '$', + mention: "google-calendar".to_string(), + path: "app://google_calendar".to_string(), + }] + ); + + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(chat.bottom_pane.composer_text(), "use $sample"); + assert_eq!( + chat.bottom_pane.take_mention_bindings(), + vec![MentionBinding { + sigil: '$', + mention: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }] + ); + + let mut next_lookup_offset = || { + let AppEvent::LookupMessageHistoryEntry { offset, .. } = + rx.try_recv().expect("expected lookup") + else { + panic!("unexpected event variant"); + }; + offset + }; + let response = |offset, entry: &str| HistoryLookupResponse { + offset, + log_id: 1, + entry: Some(entry.to_string()), + }; + + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + chat.handle_history_entry_response(response( + next_lookup_offset(), + "use [$google-calendar](app://google_calendar)", + )); + + assert_eq!(next_lookup_offset(), 1); + chat.handle_history_entry_response(response(1, "use [$sample](plugin://sample@test)")); + + assert_eq!(next_lookup_offset(), 0); + chat.handle_history_entry_response(response(0, "/rename smoke-1")); + assert_eq!(chat.bottom_pane.composer_text(), "/rename smoke-1"); +} + +#[tokio::test] +async fn replayed_review_prompt_does_not_seed_composer_history() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.replay_thread_item( + AppServerThreadItem::EnteredReviewMode { + id: "review-start".to_string(), + review: "changes against main".to_string(), + }, + "turn-1".to_string(), + ReplayKind::ResumeInitialMessages, + ); + replay_user_message_text( + &mut chat, + "review-prompt", + "Review the code changes against the base branch 'main'.", + ReplayKind::ResumeInitialMessages, + ); + drain_insert_history(&mut rx); + + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(chat.bottom_pane.composer_text(), ""); +} + #[tokio::test] async fn replayed_user_message_preserves_text_elements_and_local_images() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; From 6e1014219979acdadaadd3a1a3b65a0859a11b7e Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 28 May 2026 22:49:37 -0700 Subject: [PATCH 04/40] fix: preserve deny-read sandboxing for safe commands (#23943) ## Why Permission profiles can mark filesystem entries as unreadable with `deny` rules, including glob patterns. Several shell execution paths treated known-safe commands or execpolicy `allow` rules as sufficient to run outside the filesystem sandbox. That is not valid for read-capable commands: for example, `cat` or `ls` may be reasonable to allow generally, but dropping the sandbox would also drop deny-read constraints such as `**/*.env`. ## What changed - Added a shared check that treats active deny-read restrictions as incompatible with unsandboxed execution. - Kept first-attempt execution sandboxed for explicit escalation and execpolicy allow bypasses when deny-read entries are present. - Prevented no-sandbox retry after a sandbox denial when the active filesystem policy contains deny-read entries. - Updated the zsh-fork execve path so prefix-rule `allow` decisions continue inside the current sandbox when deny-read restrictions are active. ## Verification - `cargo test -p codex-core tools::sandboxing::tests` - `cargo test -p codex-core tools::runtimes::shell::unix_escalation::tests` - `cargo test -p codex-core shell_command_enforces_glob_deny_read_policy` --- codex-rs/core/src/tools/orchestrator.rs | 42 +++- codex-rs/core/src/tools/runtimes/shell.rs | 17 +- .../tools/runtimes/shell/unix_escalation.rs | 30 ++- .../runtimes/shell/unix_escalation_tests.rs | 126 +++++++++++ .../core/src/tools/runtimes/unified_exec.rs | 17 +- codex-rs/core/src/tools/sandboxing.rs | 41 +++- codex-rs/core/src/tools/sandboxing_tests.rs | 28 ++- codex-rs/core/tests/suite/approvals.rs | 212 ++++++++++++++++++ 8 files changed, 485 insertions(+), 28 deletions(-) diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index dc9f3f33b5..235f8c4e96 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -28,6 +28,7 @@ use crate::tools::sandboxing::ToolError; use crate::tools::sandboxing::ToolRuntime; use crate::tools::sandboxing::default_exec_approval_requirement; use crate::tools::sandboxing::sandbox_override_for_first_attempt; +use crate::tools::sandboxing::unsandboxed_execution_allowed; use codex_hooks::PermissionRequestDecision; use codex_otel::ToolDecisionSource; use codex_protocol::error::CodexErr; @@ -294,6 +295,8 @@ impl ToolOrchestrator { network_policy_decision, }))); } + let unsandboxed_allowed = + unsandboxed_execution_allowed(&file_system_sandbox_policy); // Under `Never` or `OnRequest`, do not retry without sandbox; // surface a concise sandbox denial that preserves the // original output. @@ -315,6 +318,12 @@ impl ToolOrchestrator { }))); } } + if !unsandboxed_allowed && network_approval_context.is_none() { + return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { + output, + network_policy_decision, + }))); + } let retry_reason = if let Some(network_approval_context) = network_approval_context.as_ref() { format!( @@ -357,14 +366,30 @@ impl ToolOrchestrator { .await?; } - let escalated_attempt = SandboxAttempt { - sandbox: SandboxType::None, + let retry_sandbox = if unsandboxed_allowed { + SandboxType::None + } else { + self.sandbox.select_initial( + &file_system_sandbox_policy, + network_sandbox_policy, + tool.sandbox_preference(), + turn_ctx.windows_sandbox_level, + managed_network_active, + ) + }; + let retry_codex_linux_sandbox_exe = if unsandboxed_allowed { + None + } else { + turn_ctx.codex_linux_sandbox_exe.as_ref() + }; + let retry_attempt = SandboxAttempt { + sandbox: retry_sandbox, permissions: &turn_ctx.permission_profile, enforce_managed_network: managed_network_active, manager: &self.sandbox, sandbox_cwd, workspace_roots: workspace_roots.as_slice(), - codex_linux_sandbox_exe: None, + codex_linux_sandbox_exe: retry_codex_linux_sandbox_exe, use_legacy_landlock, windows_sandbox_level: turn_ctx.windows_sandbox_level, windows_sandbox_private_desktop: turn_ctx @@ -375,14 +400,9 @@ impl ToolOrchestrator { }; // Second attempt. - let (retry_result, retry_deferred_network_approval) = Self::run_attempt( - tool, - req, - tool_ctx, - &escalated_attempt, - managed_network_active, - ) - .await; + 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, diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 8b8fe0450e..0380f1840d 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -36,6 +36,7 @@ use crate::tools::sandboxing::ToolCtx; use crate::tools::sandboxing::ToolError; use crate::tools::sandboxing::ToolRuntime; use crate::tools::sandboxing::managed_network_for_sandbox_permissions; +use crate::tools::sandboxing::sandbox_permissions_preserving_denied_reads; use crate::tools::sandboxing::with_cached_approval; use codex_network_proxy::NetworkProxy; use codex_protocol::exec_output::ExecToolCallOutput; @@ -207,8 +208,13 @@ impl ToolRuntime for ShellRuntime { req: &ShellRequest, ctx: &ToolCtx, ) -> Option { + let file_system_sandbox_policy = ctx.turn.file_system_sandbox_policy(); + let sandbox_permissions = sandbox_permissions_preserving_denied_reads( + req.sandbox_permissions, + &file_system_sandbox_policy, + ); let network = - managed_network_for_sandbox_permissions(req.network.as_ref(), req.sandbox_permissions)?; + managed_network_for_sandbox_permissions(req.network.as_ref(), sandbox_permissions)?; Some(NetworkApprovalSpec { network: Some(network.clone()), mode: NetworkApprovalMode::Immediate, @@ -233,9 +239,14 @@ impl ToolRuntime for ShellRuntime { ctx: &ToolCtx, ) -> Result { let session_shell = ctx.session.user_shell(); + let (file_system_sandbox_policy, _) = attempt.permissions.to_runtime_permissions(); + let sandbox_permissions = sandbox_permissions_preserving_denied_reads( + req.sandbox_permissions, + &file_system_sandbox_policy, + ); let managed_network = - managed_network_for_sandbox_permissions(req.network.as_ref(), req.sandbox_permissions); - let env = exec_env_for_sandbox_permissions(&req.env, req.sandbox_permissions); + managed_network_for_sandbox_permissions(req.network.as_ref(), sandbox_permissions); + let env = exec_env_for_sandbox_permissions(&req.env, sandbox_permissions); let explicit_env_overrides = req.explicit_env_overrides.clone(); #[cfg(unix)] let (env, explicit_env_overrides) = { 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 5fbf4ea33d..2017718f7d 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -22,6 +22,8 @@ use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::ToolCtx; use crate::tools::sandboxing::ToolError; use crate::tools::sandboxing::managed_network_for_sandbox_permissions; +use crate::tools::sandboxing::sandbox_permissions_preserving_denied_reads; +use crate::tools::sandboxing::unsandboxed_execution_allowed; use codex_execpolicy::Decision; use codex_execpolicy::Evaluation; use codex_execpolicy::MatchOptions; @@ -117,6 +119,15 @@ pub(super) async fn try_run_zsh_fork( return Ok(None); } + let (attempt_file_system_sandbox_policy, _) = attempt.permissions.to_runtime_permissions(); + let sandbox_permissions = sandbox_permissions_preserving_denied_reads( + req.sandbox_permissions, + &attempt_file_system_sandbox_policy, + ); + let req = &ShellRequest { + sandbox_permissions, + ..req.clone() + }; let mut env = exec_env_for_sandbox_permissions(&req.env, req.sandbox_permissions); prepend_zsh_fork_bin_to_path(&mut env, shell_zsh_path); let command = @@ -372,11 +383,18 @@ impl CoreShellActionProvider { fn shell_request_escalation_execution( sandbox_permissions: SandboxPermissions, permission_profile: &PermissionProfile, + file_system_sandbox_policy: &FileSystemSandboxPolicy, additional_permissions: Option<&AdditionalPermissionProfile>, ) -> EscalationExecution { match sandbox_permissions { SandboxPermissions::UseDefault => EscalationExecution::TurnDefault, - SandboxPermissions::RequireEscalated => EscalationExecution::Unsandboxed, + SandboxPermissions::RequireEscalated => { + if unsandboxed_execution_allowed(file_system_sandbox_policy) { + EscalationExecution::Unsandboxed + } else { + EscalationExecution::TurnDefault + } + } SandboxPermissions::WithAdditionalPermissions => additional_permissions .map(|_| { // Shell request additional permissions were already normalized and @@ -616,8 +634,10 @@ impl EscalationPolicy for CoreShellActionProvider { // fallback function. let decision_driven_by_policy = Self::decision_driven_by_policy(&evaluation.matched_rules, evaluation.decision); - let needs_escalation = - self.sandbox_permissions.requires_escalated_permissions() || decision_driven_by_policy; + let unsandboxed_allowed = unsandboxed_execution_allowed(&self.file_system_sandbox_policy); + let needs_escalation = unsandboxed_allowed + && (self.sandbox_permissions.requires_escalated_permissions() + || decision_driven_by_policy); let decision_source = if decision_driven_by_policy { DecisionSource::PrefixRule @@ -625,10 +645,12 @@ impl EscalationPolicy for CoreShellActionProvider { DecisionSource::UnmatchedCommandFallback }; let escalation_execution = match decision_source { - DecisionSource::PrefixRule => EscalationExecution::Unsandboxed, + DecisionSource::PrefixRule if unsandboxed_allowed => EscalationExecution::Unsandboxed, + DecisionSource::PrefixRule => EscalationExecution::TurnDefault, DecisionSource::UnmatchedCommandFallback => Self::shell_request_escalation_execution( self.sandbox_permissions, &self.permission_profile, + &self.file_system_sandbox_policy, self.prompt_permissions.as_ref(), ), }; 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 43d8e46952..6e7a1ced54 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 @@ -66,6 +66,23 @@ fn read_only_file_system_sandbox_policy() -> FileSystemSandboxPolicy { }]) } +fn denied_read_file_system_sandbox_policy() -> FileSystemSandboxPolicy { + FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: "**/*.env".to_string(), + }, + access: FileSystemAccessMode::Deny, + }, + ]) +} + fn test_sandbox_cwd() -> AbsolutePathBuf { AbsolutePathBuf::try_from(host_absolute_path(&["workspace"])).unwrap() } @@ -289,11 +306,13 @@ fn shell_request_escalation_execution_is_explicit() { &file_system_sandbox_policy, network_sandbox_policy, ); + let read_only_file_system_policy = read_only_file_system_sandbox_policy(); assert_eq!( CoreShellActionProvider::shell_request_escalation_execution( crate::sandboxing::SandboxPermissions::UseDefault, &permission_profile, + &file_system_sandbox_policy, /*additional_permissions*/ None, ), EscalationExecution::TurnDefault, @@ -302,14 +321,25 @@ fn shell_request_escalation_execution_is_explicit() { CoreShellActionProvider::shell_request_escalation_execution( crate::sandboxing::SandboxPermissions::RequireEscalated, &permission_profile, + &read_only_file_system_policy, /*additional_permissions*/ None, ), EscalationExecution::Unsandboxed, ); + assert_eq!( + CoreShellActionProvider::shell_request_escalation_execution( + crate::sandboxing::SandboxPermissions::RequireEscalated, + &permission_profile, + &file_system_sandbox_policy, + /*additional_permissions*/ None, + ), + EscalationExecution::TurnDefault, + ); assert_eq!( CoreShellActionProvider::shell_request_escalation_execution( crate::sandboxing::SandboxPermissions::WithAdditionalPermissions, &permission_profile, + &file_system_sandbox_policy, Some(&requested_permissions), ), EscalationExecution::Permissions(EscalationPermissions::ResolvedPermissionProfile( @@ -614,6 +644,102 @@ host_executable(name = "git", paths = ["{git_path_literal}"]) )); } +#[tokio::test(flavor = "current_thread")] +async fn denied_reads_keep_prefix_rule_allow_inside_sandbox() -> anyhow::Result<()> { + let cat_path = host_absolute_path(&["usr", "bin", "cat"]); + let cat_path_literal = starlark_string(&cat_path); + let policy_src = format!( + r#" +prefix_rule(pattern = ["{cat_path_literal}"], decision = "allow") +"# + ); + let mut parser = PolicyParser::new(); + parser.parse("test.rules", &policy_src).unwrap(); + let policy = parser.build(); + + let (session, turn_context) = make_session_and_context().await; + let file_system_sandbox_policy = denied_read_file_system_sandbox_policy(); + let permission_profile = PermissionProfile::from_runtime_permissions( + &file_system_sandbox_policy, + NetworkSandboxPolicy::Restricted, + ); + let workdir = test_sandbox_cwd(); + let provider = CoreShellActionProvider { + policy: Arc::new(RwLock::new(policy)), + session: Arc::new(session), + turn: Arc::new(turn_context), + call_id: "deny-read-prefix-allow".to_string(), + tool_name: GuardianCommandSource::Shell, + 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, + stopwatch: codex_shell_escalation::Stopwatch::new(Duration::from_secs(1)), + }; + + let action = codex_shell_escalation::EscalationPolicy::determine_action( + &provider, + &AbsolutePathBuf::try_from(cat_path).unwrap(), + &["cat".to_string(), "/tmp/visible.txt".to_string()], + &workdir, + ) + .await?; + + assert_eq!(action, codex_shell_escalation::EscalationDecision::Run); + Ok(()) +} + +#[tokio::test(flavor = "current_thread")] +async fn denied_reads_keep_granular_sandbox_rejection_for_escalation() -> anyhow::Result<()> { + let (session, turn_context) = make_session_and_context().await; + let file_system_sandbox_policy = denied_read_file_system_sandbox_policy(); + let permission_profile = PermissionProfile::from_runtime_permissions( + &file_system_sandbox_policy, + NetworkSandboxPolicy::Restricted, + ); + let workdir = test_sandbox_cwd(); + let provider = CoreShellActionProvider { + policy: Arc::new(RwLock::new(PolicyParser::new().build())), + session: Arc::new(session), + turn: Arc::new(turn_context), + call_id: "deny-read-granular-sandbox-reject".to_string(), + tool_name: GuardianCommandSource::Shell, + approval_policy: AskForApproval::Granular(GranularApprovalConfig { + sandbox_approval: false, + rules: true, + skill_approval: true, + request_permissions: true, + mcp_elicitations: true, + }), + permission_profile, + file_system_sandbox_policy, + sandbox_policy_cwd: workdir.clone(), + sandbox_permissions: SandboxPermissions::RequireEscalated, + approval_sandbox_permissions: SandboxPermissions::RequireEscalated, + prompt_permissions: None, + stopwatch: codex_shell_escalation::Stopwatch::new(Duration::from_secs(1)), + }; + + let action = codex_shell_escalation::EscalationPolicy::determine_action( + &provider, + &AbsolutePathBuf::try_from(host_absolute_path(&["usr", "bin", "printf"])).unwrap(), + &["printf".to_string(), "hello".to_string()], + &workdir, + ) + .await?; + + assert_eq!( + action, + codex_shell_escalation::EscalationDecision::Deny { + reason: Some("Execution forbidden by policy".to_string()) + } + ); + Ok(()) +} + #[test] fn intercepted_exec_policy_treats_preapproved_additional_permissions_as_default() { let policy = PolicyParser::new().build(); diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 01054a0369..e49c925728 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -34,6 +34,7 @@ use crate::tools::sandboxing::ToolCtx; use crate::tools::sandboxing::ToolError; use crate::tools::sandboxing::ToolRuntime; use crate::tools::sandboxing::managed_network_for_sandbox_permissions; +use crate::tools::sandboxing::sandbox_permissions_preserving_denied_reads; use crate::tools::sandboxing::with_cached_approval; use crate::unified_exec::NoopSpawnLifecycle; use crate::unified_exec::UnifiedExecError; @@ -230,8 +231,13 @@ impl<'a> ToolRuntime for UnifiedExecRunt req: &UnifiedExecRequest, ctx: &ToolCtx, ) -> Option { + let file_system_sandbox_policy = ctx.turn.file_system_sandbox_policy(); + let sandbox_permissions = sandbox_permissions_preserving_denied_reads( + req.sandbox_permissions, + &file_system_sandbox_policy, + ); let network = - managed_network_for_sandbox_permissions(req.network.as_ref(), req.sandbox_permissions)?; + managed_network_for_sandbox_permissions(req.network.as_ref(), sandbox_permissions)?; Some(NetworkApprovalSpec { network: Some(network.clone()), mode: NetworkApprovalMode::Deferred, @@ -257,6 +263,15 @@ impl<'a> ToolRuntime for UnifiedExecRunt ) -> Result { let base_command = &req.command; let session_shell = ctx.session.user_shell(); + let (file_system_sandbox_policy, _) = attempt.permissions.to_runtime_permissions(); + let sandbox_permissions = sandbox_permissions_preserving_denied_reads( + req.sandbox_permissions, + &file_system_sandbox_policy, + ); + let req = &UnifiedExecRequest { + sandbox_permissions, + ..req.clone() + }; let managed_network = managed_network_for_sandbox_permissions(req.network.as_ref(), req.sandbox_permissions); let mut env = exec_env_for_sandbox_permissions(&req.env, req.sandbox_permissions); diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 90060fed50..598d8855af 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -248,6 +248,13 @@ pub(crate) fn sandbox_override_for_first_attempt( exec_approval_requirement: &ExecApprovalRequirement, file_system_sandbox_policy: &FileSystemSandboxPolicy, ) -> SandboxOverride { + // Deny-read restrictions are part of the active permission policy. Running + // without a filesystem sandbox would discard them, even if the command was + // otherwise approved by rules or explicit escalation. + if !unsandboxed_execution_allowed(file_system_sandbox_policy) { + return SandboxOverride::NoOverride; + } + // ExecPolicy `Allow` can intentionally imply full trust (Skip + bypass_sandbox=true), // which supersedes `with_additional_permissions` sandboxed execution hints. if matches!( @@ -260,12 +267,6 @@ pub(crate) fn sandbox_override_for_first_attempt( return SandboxOverride::BypassSandboxFirstAttempt; } - // Deny-read restrictions suppress explicit escalation because that path - // would otherwise discard the filesystem policy entirely. - if file_system_sandbox_policy.has_denied_read_restrictions() { - return SandboxOverride::NoOverride; - } - if sandbox_permissions.requires_escalated_permissions() { SandboxOverride::BypassSandboxFirstAttempt } else { @@ -273,6 +274,34 @@ pub(crate) fn sandbox_override_for_first_attempt( } } +/// Returns true when the active filesystem policy can be represented by +/// running without a filesystem sandbox. +/// +/// Denied reads only exist inside the sandbox. If a policy contains any +/// denied-read paths, bypassing the sandbox would silently grant those reads, +/// so escalation must keep the command sandboxed with the denied reads intact. +pub(crate) fn unsandboxed_execution_allowed( + file_system_sandbox_policy: &FileSystemSandboxPolicy, +) -> bool { + !file_system_sandbox_policy.has_denied_read_restrictions() +} + +pub(crate) fn sandbox_permissions_preserving_denied_reads( + sandbox_permissions: SandboxPermissions, + file_system_sandbox_policy: &FileSystemSandboxPolicy, +) -> SandboxPermissions { + if sandbox_permissions.requires_escalated_permissions() + && !unsandboxed_execution_allowed(file_system_sandbox_policy) + { + // `RequireEscalated` normally asks the executor to bypass the sandbox. + // When denied reads are active, that would drop the only mechanism that + // enforces them, so fall back to the default sandboxed attempt instead. + SandboxPermissions::UseDefault + } else { + sandbox_permissions + } +} + pub(crate) fn managed_network_for_sandbox_permissions( network: Option<&NetworkProxy>, sandbox_permissions: SandboxPermissions, diff --git a/codex-rs/core/src/tools/sandboxing_tests.rs b/codex-rs/core/src/tools/sandboxing_tests.rs index f2e4c4cd64..21719e281f 100644 --- a/codex-rs/core/src/tools/sandboxing_tests.rs +++ b/codex-rs/core/src/tools/sandboxing_tests.rs @@ -138,7 +138,7 @@ fn guardian_bypasses_sandbox_for_explicit_escalation_on_first_attempt() { } #[test] -fn deny_read_blocks_explicit_escalation_but_preserves_policy_bypass() { +fn deny_read_blocks_explicit_escalation_and_policy_bypass() { let file_system_policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { path: FileSystemPath::GlobPattern { pattern: "**/*.env".to_string(), @@ -158,6 +158,28 @@ fn deny_read_blocks_explicit_escalation_but_preserves_policy_bypass() { SandboxOverride::NoOverride, "explicit escalation would drop deny-read filesystem policy, so keep the first attempt sandboxed", ); + assert!(!unsandboxed_execution_allowed(&file_system_policy)); + assert_eq!( + sandbox_permissions_preserving_denied_reads( + SandboxPermissions::RequireEscalated, + &file_system_policy, + ), + SandboxPermissions::UseDefault, + ); + assert_eq!( + sandbox_permissions_preserving_denied_reads( + SandboxPermissions::WithAdditionalPermissions, + &file_system_policy, + ), + SandboxPermissions::WithAdditionalPermissions, + ); + assert_eq!( + sandbox_permissions_preserving_denied_reads( + SandboxPermissions::RequireEscalated, + &FileSystemSandboxPolicy::default(), + ), + SandboxPermissions::RequireEscalated, + ); assert_eq!( sandbox_override_for_first_attempt( SandboxPermissions::WithAdditionalPermissions, @@ -167,7 +189,7 @@ fn deny_read_blocks_explicit_escalation_but_preserves_policy_bypass() { }, &file_system_policy, ), - SandboxOverride::BypassSandboxFirstAttempt, - "exec-policy allow rules intentionally bypass sandbox even when deny-read entries exist", + SandboxOverride::NoOverride, + "exec-policy allow rules would drop deny-read filesystem policy, so keep the first attempt sandboxed", ); } diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 2a5f8fc43f..a91196a0b9 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -10,6 +10,12 @@ use codex_features::Feature; use codex_protocol::approvals::NetworkApprovalProtocol; use codex_protocol::approvals::NetworkPolicyAmendment; use codex_protocol::approvals::NetworkPolicyRuleAction; +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::NetworkSandboxPolicy; use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; @@ -3244,6 +3250,212 @@ allow_local_binding = true Ok(()) } +#[cfg(not(target_os = "windows"))] +#[tokio::test(flavor = "current_thread")] +async fn network_approval_retry_keeps_deny_read_sandbox_for_escalated_command() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let home = Arc::new(TempDir::new()?); + fs::write( + home.path().join("config.toml"), + r#"default_permissions = "workspace" + +[permissions.workspace.filesystem] +":minimal" = "read" + +[permissions.workspace.network] +enabled = true +mode = "limited" +allow_local_binding = true +"#, + )?; + let approval_policy = AskForApproval::OnRequest; + let sandbox_policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access: true, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + let mut builder = test_codex() + .with_home(home) + .with_cloud_requirements(managed_network_requirements_loader()) + .with_config(move |config| { + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + }); + let test = builder.build(&server).await?; + assert!( + test.config.managed_network_requirements_enabled(), + "expected managed network requirements to be enabled" + ); + assert!( + test.config.permissions.network.is_some(), + "expected managed network proxy config to be present" + ); + test.session_configured + .network_proxy + .as_ref() + .expect("expected runtime managed network proxy addresses"); + + let mut file_system_sandbox_policy = + FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( + &sandbox_policy, + test.config.cwd.as_path(), + ); + file_system_sandbox_policy + .entries + .push(FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: format!("{}/**/*.env", test.config.cwd.as_path().display()), + }, + access: FileSystemAccessMode::Deny, + }); + assert!( + file_system_sandbox_policy.has_denied_read_restrictions(), + "test must exercise a permission profile with denied reads" + ); + let permission_profile = PermissionProfile::from_runtime_permissions( + &file_system_sandbox_policy, + NetworkSandboxPolicy::Restricted, + ); + + let call_id = "deny-read-network-retry"; + let fetch_command = r#"python3 -c "import urllib.request; opener = urllib.request.build_opener(urllib.request.ProxyHandler()); print('OK:' + opener.open('http://codex-network-test.invalid', timeout=30).read().decode(errors='replace'))""# + .to_string(); + let event = shell_event( + call_id, + &fetch_command, + /*timeout_ms*/ 30_000, + SandboxPermissions::RequireEscalated, + )?; + + let _ = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-deny-read-network-1"), + event, + ev_completed("resp-deny-read-network-1"), + ]), + ) + .await; + let results = mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-deny-read-network-1", "done"), + ev_completed("resp-deny-read-network-2"), + ]), + ) + .await; + + let (turn_sandbox_policy, turn_permission_profile) = + turn_permission_fields(permission_profile, test.config.cwd.as_path()); + let session_model = test.session_configured.model.clone(); + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "deny-read network retry".into(), + text_elements: Vec::new(), + }], + environments: None, + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + cwd: Some(test.config.cwd.to_path_buf()), + approval_policy: Some(approval_policy), + approvals_reviewer: Some(ApprovalsReviewer::User), + sandbox_policy: Some(turn_sandbox_policy), + permission_profile: turn_permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: session_model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, + }) + .await?; + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + let mut command_approval_count = 0; + let approval = loop { + let remaining = deadline + .checked_duration_since(std::time::Instant::now()) + .expect("timed out waiting for network approval request"); + let event = wait_for_event_with_timeout( + &test.codex, + |event| { + matches!( + event, + EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) + ) + }, + remaining, + ) + .await; + match event { + EventMsg::ExecApprovalRequest(approval) => { + if approval.command.first().map(std::string::String::as_str) + == Some("network-access") + { + break approval; + } + command_approval_count += 1; + assert_eq!( + command_approval_count, 1, + "expected only the outer explicit escalation approval" + ); + test.codex + .submit(Op::ExecApproval { + id: approval.effective_approval_id(), + turn_id: None, + decision: ReviewDecision::Approved, + }) + .await?; + } + EventMsg::TurnComplete(_) => { + panic!("expected network approval request before completion"); + } + other => panic!("unexpected event: {other:?}"), + } + }; + assert_eq!(command_approval_count, 1); + let network_context = approval + .network_approval_context + .clone() + .expect("expected network approval context"); + assert_eq!(network_context.protocol, NetworkApprovalProtocol::Http); + let allow_network_amendment = approval + .proposed_network_policy_amendments + .clone() + .expect("expected proposed network policy amendments") + .into_iter() + .find(|amendment| amendment.action == NetworkPolicyRuleAction::Allow) + .expect("expected allow network policy amendment"); + + test.codex + .submit(Op::ExecApproval { + id: approval.effective_approval_id(), + turn_id: None, + decision: ReviewDecision::NetworkPolicyAmendment { + network_policy_amendment: allow_network_amendment, + }, + }) + .await?; + wait_for_completion(&test).await; + + let output = parse_result(&results.single_request().function_call_output(call_id)); + assert!( + output.exit_code.is_some(), + "expected the approved retry to report command output" + ); + + Ok(()) +} + #[tokio::test(flavor = "current_thread")] async fn network_approval_flow_survives_danger_full_access_session_start() -> Result<()> { skip_if_no_network!(Ok(())); From 522f549922f6e7b8edbd5e9be3fc720ddb9b256a Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 28 May 2026 23:09:55 -0700 Subject: [PATCH 05/40] Fix fs/watch debounce batching (#24716) ## Summary `fs/watch` was using a local debounce wrapper whose deadline was initialized once and then reused after the first batch. Once that stale deadline was in the past, later file changes could bypass the intended 200ms debounce and send noisier `fs/changed` notifications. This moves the debounce wrapper into `codex-file-watcher` as `DebouncedWatchReceiver`, resets the debounce deadline for each event batch, preserves pending paths across cancelled receives, and updates app-server `fs/watch` to use the shared wrapper. Fixes #24692. --- codex-rs/app-server/src/fs_watch.rs | 48 ++-------------- .../file-watcher/src/file_watcher_tests.rs | 56 +++++++++++++++++++ codex-rs/file-watcher/src/lib.rs | 43 ++++++++++++++ 3 files changed, 103 insertions(+), 44 deletions(-) diff --git a/codex-rs/app-server/src/fs_watch.rs b/codex-rs/app-server/src/fs_watch.rs index 09c6dc5533..405b0560a3 100644 --- a/codex-rs/app-server/src/fs_watch.rs +++ b/codex-rs/app-server/src/fs_watch.rs @@ -8,66 +8,24 @@ use codex_app_server_protocol::FsWatchParams; use codex_app_server_protocol::FsWatchResponse; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::ServerNotification; +use codex_file_watcher::DebouncedWatchReceiver; use codex_file_watcher::FileWatcher; -use codex_file_watcher::FileWatcherEvent; use codex_file_watcher::FileWatcherSubscriber; -use codex_file_watcher::Receiver; use codex_file_watcher::WatchPath; use codex_file_watcher::WatchRegistration; use std::collections::HashMap; -use std::collections::HashSet; use std::collections::hash_map::Entry; use std::hash::Hash; -use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use tokio::sync::Mutex as AsyncMutex; #[cfg(test)] use tokio::sync::mpsc; use tokio::sync::oneshot; -use tokio::time::Instant; use tracing::warn; const FS_CHANGED_NOTIFICATION_DEBOUNCE: Duration = Duration::from_millis(200); -struct DebouncedReceiver { - rx: Receiver, - interval: Duration, - changed_paths: HashSet, - next_allowance: Option, -} - -impl DebouncedReceiver { - fn new(rx: Receiver, interval: Duration) -> Self { - Self { - rx, - interval, - changed_paths: HashSet::new(), - next_allowance: None, - } - } - - async fn recv(&mut self) -> Option { - while self.changed_paths.is_empty() { - self.changed_paths.extend(self.rx.recv().await?.paths); - } - let next_allowance = *self - .next_allowance - .get_or_insert_with(|| Instant::now() + self.interval); - - loop { - tokio::select! { - event = self.rx.recv() => self.changed_paths.extend(event?.paths), - _ = tokio::time::sleep_until(next_allowance) => break, - } - } - - Some(FileWatcherEvent { - paths: self.changed_paths.drain().collect(), - }) - } -} - #[derive(Clone)] pub(crate) struct FsWatchManager { outgoing: Arc, @@ -151,7 +109,7 @@ impl FsWatchManager { let task_watch_id = watch_id.clone(); tokio::spawn(async move { - let mut rx = DebouncedReceiver::new(rx, FS_CHANGED_NOTIFICATION_DEBOUNCE); + let mut rx = DebouncedWatchReceiver::new(rx, FS_CHANGED_NOTIFICATION_DEBOUNCE); tokio::pin!(terminate_rx); loop { let event = tokio::select! { @@ -219,6 +177,8 @@ mod tests { use super::*; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; + use std::collections::HashSet; + use std::path::PathBuf; use tempfile::TempDir; fn absolute_path(path: PathBuf) -> AbsolutePathBuf { diff --git a/codex-rs/file-watcher/src/file_watcher_tests.rs b/codex-rs/file-watcher/src/file_watcher_tests.rs index a989300ffc..8bd7858c21 100644 --- a/codex-rs/file-watcher/src/file_watcher_tests.rs +++ b/codex-rs/file-watcher/src/file_watcher_tests.rs @@ -86,6 +86,62 @@ async fn throttled_receiver_flushes_pending_on_shutdown() { assert_eq!(closed, None); } +#[tokio::test] +async fn debounced_receiver_coalesces_each_event_batch() { + let (tx, rx) = watch_channel(); + let mut debounced = DebouncedWatchReceiver::new(rx, TEST_THROTTLE_INTERVAL); + + tx.add_changed_paths(&[path("a")]).await; + let first = timeout(TEST_THROTTLE_INTERVAL * 2, debounced.recv()) + .await + .expect("first emit timeout"); + assert_eq!( + first, + Some(FileWatcherEvent { + paths: vec![path("a")], + }) + ); + + tx.add_changed_paths(&[path("c")]).await; + let blocked = timeout(TEST_THROTTLE_INTERVAL / 2, debounced.recv()).await; + assert_eq!(blocked.is_err(), true); + + tx.add_changed_paths(&[path("d")]).await; + let second = timeout(TEST_THROTTLE_INTERVAL * 2, debounced.recv()) + .await + .expect("second emit timeout"); + assert_eq!( + second, + Some(FileWatcherEvent { + paths: vec![path("c"), path("d")], + }) + ); +} + +#[tokio::test] +async fn debounced_receiver_flushes_pending_on_shutdown() { + let (tx, rx) = watch_channel(); + let mut debounced = DebouncedWatchReceiver::new(rx, TEST_THROTTLE_INTERVAL); + + tx.add_changed_paths(&[path("a")]).await; + drop(tx); + + let flushed = timeout(Duration::from_secs(1), debounced.recv()) + .await + .expect("shutdown flush timeout"); + assert_eq!( + flushed, + Some(FileWatcherEvent { + paths: vec![path("a")], + }) + ); + + let closed = timeout(Duration::from_secs(1), debounced.recv()) + .await + .expect("closed recv timeout"); + assert_eq!(closed, None); +} + #[test] fn is_mutating_event_filters_non_mutating_event_kinds() { assert_eq!( diff --git a/codex-rs/file-watcher/src/lib.rs b/codex-rs/file-watcher/src/lib.rs index aa182bd19b..a0b1a42401 100644 --- a/codex-rs/file-watcher/src/lib.rs +++ b/codex-rs/file-watcher/src/lib.rs @@ -253,6 +253,49 @@ impl ThrottledWatchReceiver { } } +/// Coalesces file watcher notifications that arrive within a fixed debounce +/// window after the first event in each batch. +pub struct DebouncedWatchReceiver { + rx: Receiver, + interval: Duration, + changed_paths: BTreeSet, +} + +impl DebouncedWatchReceiver { + /// Creates a debouncing wrapper around a raw watcher [`Receiver`]. + pub fn new(rx: Receiver, interval: Duration) -> Self { + Self { + rx, + interval, + changed_paths: BTreeSet::new(), + } + } + + /// Receives the next debounced event batch. + pub async fn recv(&mut self) -> Option { + while self.changed_paths.is_empty() { + self.changed_paths.extend(self.rx.recv().await?.paths); + } + let deadline = Instant::now() + self.interval; + + loop { + tokio::select! { + event = self.rx.recv() => match event { + Some(event) => self.changed_paths.extend(event.paths), + None => break, + }, + _ = sleep_until(deadline) => break, + } + } + + Some(FileWatcherEvent { + paths: std::mem::take(&mut self.changed_paths) + .into_iter() + .collect(), + }) + } +} + /// Handle used to register watched paths for one logical consumer. pub struct FileWatcherSubscriber { id: SubscriberId, From 740d942f901a5a63421298c74dafbeb4255e946d Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 29 May 2026 10:28:25 +0200 Subject: [PATCH 06/40] Use internal model context fragments for goal steering (#24918) ## Why Goal steering is one form of runtime-owned model context, but the old `` wrapper made the contextual-fragment hiding path goal-specific. Using a source-labeled internal context fragment gives core and extensions a shared shape for hidden model steering while keeping those prompts out of visible turn history. The change also keeps legacy `` messages recognized as hidden contextual input so existing stored history does not start rendering old goal-steering prompts as user-visible turn items. ## What Changed - Replaces `GoalContext` with `InternalModelContextFragment` plus a validated `InternalContextSource`. - Renders goal steering as `...`. - Updates core goal steering and `ext/goal` steering to inject the new internal-context fragment. - Updates contextual-fragment, event-mapping, goal, and session tests for the new wrapper. ## Test Coverage - Adds coverage for detecting the new internal model context fragment. - Preserves coverage for hiding legacy `` fragments. - Verifies invalid internal context sources are rejected and arbitrary context tags are not hidden. - Updates goal steering/session assertions to expect the new `source="goal"` wrapper. --- .../src/context/contextual_user_message.rs | 9 +- .../context/contextual_user_message_tests.rs | 43 +++++- codex-rs/core/src/context/goal_context.rs | 36 ----- .../src/context/internal_model_context.rs | 129 ++++++++++++++++++ codex-rs/core/src/context/mod.rs | 6 +- codex-rs/core/src/event_mapping_tests.rs | 11 +- codex-rs/core/src/goals.rs | 10 +- codex-rs/core/src/session/tests.rs | 27 ++-- codex-rs/ext/goal/src/steering.rs | 12 +- 9 files changed, 208 insertions(+), 75 deletions(-) delete mode 100644 codex-rs/core/src/context/goal_context.rs create mode 100644 codex-rs/core/src/context/internal_model_context.rs diff --git a/codex-rs/core/src/context/contextual_user_message.rs b/codex-rs/core/src/context/contextual_user_message.rs index 940a938fce..79768dd3a3 100644 --- a/codex-rs/core/src/context/contextual_user_message.rs +++ b/codex-rs/core/src/context/contextual_user_message.rs @@ -6,7 +6,7 @@ use super::AdditionalContextUserFragment; use super::EnvironmentContext; use super::FragmentRegistration; use super::FragmentRegistrationProxy; -use super::GoalContext; +use super::InternalModelContextFragment; use super::LegacyApplyPatchExecCommandWarning; use super::LegacyModelMismatchWarning; use super::LegacyUnifiedExecProcessLimitWarning; @@ -30,8 +30,9 @@ static TURN_ABORTED_REGISTRATION: FragmentRegistrationProxy = FragmentRegistrationProxy::new(); static SUBAGENT_NOTIFICATION_REGISTRATION: FragmentRegistrationProxy = FragmentRegistrationProxy::new(); -static GOAL_CONTEXT_REGISTRATION: FragmentRegistrationProxy = - FragmentRegistrationProxy::new(); +static INTERNAL_MODEL_CONTEXT_REGISTRATION: FragmentRegistrationProxy< + InternalModelContextFragment, +> = FragmentRegistrationProxy::new(); static LEGACY_UNIFIED_EXEC_PROCESS_LIMIT_WARNING_REGISTRATION: FragmentRegistrationProxy< LegacyUnifiedExecProcessLimitWarning, > = FragmentRegistrationProxy::new(); @@ -50,7 +51,7 @@ static CONTEXTUAL_USER_FRAGMENTS: &[&dyn FragmentRegistration] = &[ &USER_SHELL_COMMAND_REGISTRATION, &TURN_ABORTED_REGISTRATION, &SUBAGENT_NOTIFICATION_REGISTRATION, - &GOAL_CONTEXT_REGISTRATION, + &INTERNAL_MODEL_CONTEXT_REGISTRATION, &LEGACY_UNIFIED_EXEC_PROCESS_LIMIT_WARNING_REGISTRATION, &LEGACY_APPLY_PATCH_EXEC_COMMAND_WARNING_REGISTRATION, &LEGACY_MODEL_MISMATCH_WARNING_REGISTRATION, 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 195b189929..dc7cb0f534 100644 --- a/codex-rs/core/src/context/contextual_user_message_tests.rs +++ b/codex-rs/core/src/context/contextual_user_message_tests.rs @@ -1,6 +1,7 @@ use super::*; use crate::context::ContextualUserFragment; -use crate::context::GoalContext; +use crate::context::InternalContextSource; +use crate::context::InternalModelContextFragment; use crate::context::SubagentNotification; use codex_protocol::items::HookPromptFragment; use codex_protocol::items::build_hook_prompt_message; @@ -30,23 +31,55 @@ fn detects_subagent_notification_fragment_case_insensitively() { } #[test] -fn detects_goal_context_fragment() { - let text = GoalContext::new("Continue working toward the active thread goal.").render(); +fn detects_internal_model_context_fragment() { + let text = InternalModelContextFragment::new( + InternalContextSource::from_static("goal"), + "Continue working toward the active thread goal.", + ) + .render(); + assert_eq!( + text, + "\nContinue working toward the active thread goal.\n" + ); assert!(is_contextual_user_fragment(&ContentItem::InputText { text })); } +#[test] +fn detects_legacy_goal_context_fragment() { + assert!(is_contextual_user_fragment(&ContentItem::InputText { + text: "\nContinue working toward the active thread goal.\n" + .to_string(), + })); +} + +#[test] +fn does_not_hide_arbitrary_context_tags() { + assert!(!is_contextual_user_fragment(&ContentItem::InputText { + text: "\nbody\n".to_string(), + })); +} + +#[test] +fn rejects_invalid_internal_model_context_source() { + assert!(!is_contextual_user_fragment(&ContentItem::InputText { + text: "\nbody\n" + .to_string(), + })); +} + #[test] fn contextual_user_fragment_is_dyn_compatible() { - let fragment: Box = Box::new(GoalContext::new( + let fragment: Box = Box::new(InternalModelContextFragment::new( + InternalContextSource::from_static("goal"), "Continue working toward the active thread goal.", )); assert_eq!( fragment.render(), - "\nContinue working toward the active thread goal.\n" + "\nContinue working toward the active thread goal.\n" ); } diff --git a/codex-rs/core/src/context/goal_context.rs b/codex-rs/core/src/context/goal_context.rs deleted file mode 100644 index ad4c47ef06..0000000000 --- a/codex-rs/core/src/context/goal_context.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! Hidden user-context fragment for runtime-owned goal steering prompts. - -use super::ContextualUserFragment; - -/// Hidden runtime-owned goal steering context injected into model input. -#[derive(Debug, Clone, PartialEq)] -pub struct GoalContext { - prompt: String, -} - -impl GoalContext { - /// Creates goal context around an already-rendered steering prompt. - pub fn new(prompt: impl Into) -> Self { - Self { - prompt: prompt.into(), - } - } -} - -impl ContextualUserFragment for GoalContext { - fn role() -> &'static str { - "user" - } - - fn markers(&self) -> (&'static str, &'static str) { - Self::type_markers() - } - - fn type_markers() -> (&'static str, &'static str) { - ("", "") - } - - fn body(&self) -> String { - format!("\n{}\n", self.prompt) - } -} diff --git a/codex-rs/core/src/context/internal_model_context.rs b/codex-rs/core/src/context/internal_model_context.rs new file mode 100644 index 0000000000..a25e77a82a --- /dev/null +++ b/codex-rs/core/src/context/internal_model_context.rs @@ -0,0 +1,129 @@ +//! Hidden user-context fragment for extension-owned model steering. + +use super::ContextualUserFragment; +use std::error::Error; +use std::fmt; + +const CONTEXT_START_MARKER: &str = ""; + +/// Source label for hidden internal model context. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InternalContextSource(String); + +impl InternalContextSource { + /// Creates a source label for an internal model-context fragment. + /// + /// Sources are intentionally constrained so the value can be embedded in the + /// wrapper without escaping and still remain easy to audit in stored history. + pub fn new(source: impl Into) -> Result { + let source = source.into(); + if is_valid_source(&source) { + Ok(Self(source)) + } else { + Err(InvalidInternalContextSource { source }) + } + } + + /// Creates a source label from a trusted static string. + pub fn from_static(source: &'static str) -> Self { + Self::new(source) + .unwrap_or_else(|_| panic!("invalid static internal context source: {source}")) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Error returned when an internal model-context source is invalid. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InvalidInternalContextSource { + source: String, +} + +impl fmt::Display for InvalidInternalContextSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let source = &self.source; + write!( + f, + "invalid internal model context source {source:?}; expected [a-z][a-z0-9_]*" + ) + } +} + +impl Error for InvalidInternalContextSource {} + +/// Hidden runtime-owned context injected into model input. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InternalModelContextFragment { + source: InternalContextSource, + body: String, +} + +impl InternalModelContextFragment { + /// Creates hidden model context with an extension-owned source label. + pub fn new(source: InternalContextSource, body: impl Into) -> Self { + Self { + source, + body: body.into(), + } + } +} + +impl ContextualUserFragment for InternalModelContextFragment { + fn role() -> &'static str { + "user" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + (CONTEXT_START_MARKER, CONTEXT_END_MARKER) + } + + fn matches_text(text: &str) -> bool { + let trimmed = text.trim(); + if matches_legacy_goal_context(trimmed) { + return true; + } + + let Some(rest) = trimmed.strip_prefix(CONTEXT_START_MARKER) else { + return false; + }; + let Some(rest) = rest.strip_prefix(SOURCE_ATTR_START) else { + return false; + }; + let Some((source, body_and_close)) = rest.split_once(SOURCE_ATTR_END) else { + return false; + }; + + is_valid_source(source) && body_and_close.ends_with(CONTEXT_END_MARKER) + } + + fn body(&self) -> String { + let source = self.source.as_str(); + let body = &self.body; + format!(" source=\"{source}\">\n{body}\n") + } +} + +fn matches_legacy_goal_context(text: &str) -> bool { + text.starts_with(LEGACY_GOAL_CONTEXT_START_MARKER) + && text.ends_with(LEGACY_GOAL_CONTEXT_END_MARKER) +} + +fn is_valid_source(source: &str) -> bool { + let mut chars = source.chars(); + let Some(first) = chars.next() else { + return false; + }; + first.is_ascii_lowercase() + && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_') +} diff --git a/codex-rs/core/src/context/mod.rs b/codex-rs/core/src/context/mod.rs index 6d88df87e2..3e01d5212e 100644 --- a/codex-rs/core/src/context/mod.rs +++ b/codex-rs/core/src/context/mod.rs @@ -9,10 +9,10 @@ mod contextual_user_message; mod environment_context; mod fragment; mod fragments; -mod goal_context; mod guardian_followup_review_reminder; mod hook_additional_context; mod image_generation_instructions; +mod internal_model_context; mod legacy_apply_patch_exec_command_warning; mod legacy_model_mismatch_warning; mod legacy_unified_exec_process_limit_warning; @@ -43,10 +43,12 @@ pub(crate) use fragment::FragmentRegistration; pub(crate) use fragment::FragmentRegistrationProxy; pub(crate) use fragments::AdditionalContextDeveloperFragment; pub(crate) use fragments::AdditionalContextUserFragment; -pub use goal_context::GoalContext; pub(crate) use guardian_followup_review_reminder::GuardianFollowupReviewReminder; pub(crate) use hook_additional_context::HookAdditionalContext; pub(crate) use image_generation_instructions::ImageGenerationInstructions; +pub use internal_model_context::InternalContextSource; +pub use internal_model_context::InternalModelContextFragment; +pub use internal_model_context::InvalidInternalContextSource; pub(crate) use legacy_apply_patch_exec_command_warning::LegacyApplyPatchExecCommandWarning; pub(crate) use legacy_model_mismatch_warning::LegacyModelMismatchWarning; pub(crate) use legacy_unified_exec_process_limit_warning::LegacyUnifiedExecProcessLimitWarning; diff --git a/codex-rs/core/src/event_mapping_tests.rs b/codex-rs/core/src/event_mapping_tests.rs index 57d6362f55..df636d4a6b 100644 --- a/codex-rs/core/src/event_mapping_tests.rs +++ b/codex-rs/core/src/event_mapping_tests.rs @@ -1,6 +1,7 @@ use super::parse_turn_item; use crate::context::ContextualUserFragment; -use crate::context::GoalContext; +use crate::context::InternalContextSource; +use crate::context::InternalModelContextFragment; use codex_protocol::items::AgentMessageContent; use codex_protocol::items::HookPromptFragment; use codex_protocol::items::TurnItem; @@ -317,12 +318,16 @@ fn parses_hook_prompt_and_hides_other_contextual_fragments() { } #[test] -fn goal_context_does_not_parse_as_visible_turn_item() { +fn internal_model_context_does_not_parse_as_visible_turn_item() { let item = ResponseItem::Message { id: Some("msg-1".to_string()), role: "user".to_string(), content: vec![ContentItem::InputText { - text: GoalContext::new("Continue working toward the active thread goal.").render(), + text: InternalModelContextFragment::new( + InternalContextSource::from_static("goal"), + "Continue working toward the active thread goal.", + ) + .render(), }], phase: None, }; diff --git a/codex-rs/core/src/goals.rs b/codex-rs/core/src/goals.rs index ece020385a..00b6854342 100644 --- a/codex-rs/core/src/goals.rs +++ b/codex-rs/core/src/goals.rs @@ -6,7 +6,8 @@ use crate::StateDbHandle; use crate::context::ContextualUserFragment; -use crate::context::GoalContext; +use crate::context::InternalContextSource; +use crate::context::InternalModelContextFragment; use crate::session::TurnInput; use crate::session::session::Session; use crate::session::turn_context::TurnContext; @@ -1597,7 +1598,10 @@ fn budget_limit_steering_item(goal: &ThreadGoal) -> ResponseItem { } fn goal_context_input_item(prompt: String) -> ResponseItem { - ResponseItem::from(GoalContext::new(prompt).into_response_input_item()) + ContextualUserFragment::into(InternalModelContextFragment::new( + InternalContextSource::from_static("goal"), + prompt, + )) } pub(crate) fn protocol_goal_from_state(goal: codex_state::ThreadGoal) -> ThreadGoal { @@ -1799,7 +1803,7 @@ mod tests { id: None, role: "user".to_string(), content: vec![ContentItem::InputText { - text: "\nContinue working.\n".to_string(), + text: "\nContinue working.\n".to_string(), }], phase: None, } diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 62321756db..aa2586cae4 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -8710,24 +8710,13 @@ async fn active_goal_continuation_runs_again_after_no_tool_turn() -> anyhow::Res }) .await??; - let continuation_request = responses + let goal_context_text = responses .requests() .into_iter() - .find(|request| request.body_contains_text("")) - .expect("expected a goal continuation request"); - let body = continuation_request.body_json(); - let goal_context_message = body["input"] - .as_array() - .expect("input should be an array") - .iter() - .find(|item| item.to_string().contains("")) + .flat_map(|request| request.message_input_texts("user")) + .find(|text| text.contains("")) .expect("goal context message should be present"); - assert_eq!(goal_context_message["role"].as_str(), Some("user")); - assert!( - goal_context_message - .to_string() - .contains("Continue working toward the active thread goal.") - ); + assert!(goal_context_text.contains("Continue working toward the active thread goal.")); Ok(()) } @@ -8997,8 +8986,8 @@ async fn budget_limited_accounting_steers_active_turn_without_aborting() -> anyh 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.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()); @@ -9220,8 +9209,8 @@ async fn external_objective_change_steers_active_turn() -> anyhow::Result<()> { && content.iter().any(|content| matches!( content, ContentItem::InputText { text } - if text.starts_with("") - && text.trim_end().ends_with("") + 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") )) diff --git a/codex-rs/ext/goal/src/steering.rs b/codex-rs/ext/goal/src/steering.rs index 83f41ea538..580a4aa971 100644 --- a/codex-rs/ext/goal/src/steering.rs +++ b/codex-rs/ext/goal/src/steering.rs @@ -1,14 +1,20 @@ use codex_core::context::ContextualUserFragment; -use codex_core::context::GoalContext; +use codex_core::context::InternalContextSource; +use codex_core::context::InternalModelContextFragment; use codex_protocol::models::ResponseInputItem; use codex_protocol::protocol::ThreadGoal; pub(crate) fn budget_limit_steering_item(goal: &ThreadGoal) -> ResponseInputItem { - GoalContext::new(budget_limit_prompt(goal)).into_response_input_item() + goal_context_input_item(budget_limit_prompt(goal)) } pub(crate) fn objective_updated_steering_item(goal: &ThreadGoal) -> ResponseInputItem { - GoalContext::new(objective_updated_prompt(goal)).into_response_input_item() + goal_context_input_item(objective_updated_prompt(goal)) +} + +fn goal_context_input_item(prompt: String) -> ResponseInputItem { + InternalModelContextFragment::new(InternalContextSource::from_static("goal"), prompt) + .into_response_input_item() } fn budget_limit_prompt(goal: &ThreadGoal) -> String { From 8f6a945ec93debbf8e7963b84222b4e007b24a84 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 29 May 2026 11:24:39 +0200 Subject: [PATCH 07/40] Use inject_if_running for active goal steering (#24924) ## Why This PR is stacked on #24918, which moves goal steering onto source-labeled internal model context fragments. Active-turn goal steering should use the same running-turn injection path as other runtime steering, so those fragments enter the pending input queue as `ResponseItem`s through the existing [`Session::inject_if_running`](https://github.com/openai/codex/blob/8d6f6cdf69b055c27682e7cdea9caf72a3e2ee7f/codex-rs/core/src/session/inject.rs#L12-L27) behavior instead of through a goal-specific conversion wrapper. ## What Changed - Exposes a narrow `CodexThread::inject_if_running` bridge for callers that only hold a thread handle. - Changes `ext/goal` active-turn steering to pass `ResponseItem`s directly. - Builds goal steering prompts as contextual internal model context `ResponseItem`s before injecting them into the running turn. ## Testing Not run locally; PR metadata update only. --- codex-rs/core/src/codex_thread.rs | 21 ++++++++------------- codex-rs/ext/goal/src/runtime.rs | 10 +++------- codex-rs/ext/goal/src/steering.rs | 14 ++++++++------ 3 files changed, 19 insertions(+), 26 deletions(-) diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 21f9ec4549..dec1ce6895 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -18,7 +18,6 @@ use codex_protocol::mcp::CallToolResult; use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::ContentItem; use codex_protocol::models::PermissionProfile; -use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AdditionalContextEntry; @@ -265,20 +264,16 @@ impl CodexThread { .await } - /// Injects hidden model-visible items into the currently active turn. + /// Injects model-visible items into the currently active turn. /// - /// This is the runtime-owned counterpart to user-facing `steer_input`. + /// This is the thread-level bridge to `Session::inject_if_running` for + /// callers that only hold a `CodexThread`. /// It returns the unchanged items when this thread has no active turn. - pub async fn inject_response_items_into_active_turn( + pub async fn inject_if_running( &self, - items: Vec, - ) -> Result<(), Vec> { - let response_items = items.iter().cloned().map(ResponseItem::from).collect(); - self.codex - .session - .inject_if_running(response_items) - .await - .map_err(|_| items) + items: Vec, + ) -> Result<(), Vec> { + self.codex.session.inject_if_running(items).await } pub async fn set_app_server_client_info( @@ -396,7 +391,7 @@ impl CodexThread { .await; } - /// Append raw Responses API items to the thread's model-visible history. + /// Record raw Responses API items without starting a new turn. pub async fn inject_response_items(&self, items: Vec) -> CodexResult<()> { if items.is_empty() { return Err(CodexErr::InvalidRequest( diff --git a/codex-rs/ext/goal/src/runtime.rs b/codex-rs/ext/goal/src/runtime.rs index d78eda3de5..f077ddc54c 100644 --- a/codex-rs/ext/goal/src/runtime.rs +++ b/codex-rs/ext/goal/src/runtime.rs @@ -5,7 +5,7 @@ use std::sync::atomic::Ordering; use codex_core::ThreadManager; use codex_protocol::ThreadId; -use codex_protocol::models::ResponseInputItem; +use codex_protocol::models::ResponseItem; use codex_protocol::protocol::ThreadGoal; use crate::accounting::BudgetLimitedGoalDisposition; @@ -275,7 +275,7 @@ impl GoalRuntimeHandle { Ok(()) } - pub(crate) async fn inject_active_turn_steering(&self, item: ResponseInputItem) { + pub(crate) async fn inject_active_turn_steering(&self, item: ResponseItem) { let Some(thread_manager) = self.inner.thread_manager.upgrade() else { tracing::debug!("skipping goal steering because thread manager is unavailable"); return; @@ -284,11 +284,7 @@ impl GoalRuntimeHandle { tracing::debug!("skipping goal steering because live thread is unavailable"); return; }; - if thread - .inject_response_items_into_active_turn(vec![item]) - .await - .is_err() - { + if thread.inject_if_running(vec![item]).await.is_err() { tracing::debug!("skipping goal steering because no turn is active"); } } diff --git a/codex-rs/ext/goal/src/steering.rs b/codex-rs/ext/goal/src/steering.rs index 580a4aa971..e540ee171d 100644 --- a/codex-rs/ext/goal/src/steering.rs +++ b/codex-rs/ext/goal/src/steering.rs @@ -1,20 +1,22 @@ use codex_core::context::ContextualUserFragment; use codex_core::context::InternalContextSource; use codex_core::context::InternalModelContextFragment; -use codex_protocol::models::ResponseInputItem; +use codex_protocol::models::ResponseItem; use codex_protocol::protocol::ThreadGoal; -pub(crate) fn budget_limit_steering_item(goal: &ThreadGoal) -> ResponseInputItem { +pub(crate) fn budget_limit_steering_item(goal: &ThreadGoal) -> ResponseItem { goal_context_input_item(budget_limit_prompt(goal)) } -pub(crate) fn objective_updated_steering_item(goal: &ThreadGoal) -> ResponseInputItem { +pub(crate) fn objective_updated_steering_item(goal: &ThreadGoal) -> ResponseItem { goal_context_input_item(objective_updated_prompt(goal)) } -fn goal_context_input_item(prompt: String) -> ResponseInputItem { - InternalModelContextFragment::new(InternalContextSource::from_static("goal"), prompt) - .into_response_input_item() +fn goal_context_input_item(prompt: String) -> ResponseItem { + ContextualUserFragment::into(InternalModelContextFragment::new( + InternalContextSource::from_static("goal"), + prompt, + )) } fn budget_limit_prompt(goal: &ThreadGoal) -> String { From fc8c723553b64017e560c7a72f48fa763acc283a Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 29 May 2026 12:50:58 +0200 Subject: [PATCH 08/40] Drop the stale debug-client manifest (#25063) Deletes `codex-rs/debug-client/Cargo.toml` as one step in removing the stale app-server debug client. This intentionally leaves Cargo workspace and lockfile cleanup for a later follow-up PR. --- codex-rs/debug-client/Cargo.toml | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 codex-rs/debug-client/Cargo.toml diff --git a/codex-rs/debug-client/Cargo.toml b/codex-rs/debug-client/Cargo.toml deleted file mode 100644 index 14dcb5600a..0000000000 --- a/codex-rs/debug-client/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "codex-debug-client" -version.workspace = true -edition.workspace = true -license.workspace = true - -[lints] -workspace = true - -[dependencies] -anyhow.workspace = true -clap = { workspace = true, features = ["derive"] } -codex-app-server-protocol.workspace = true -serde.workspace = true -serde_json.workspace = true - -[dev-dependencies] -pretty_assertions.workspace = true From a9a92cbb0a7afafc64baecdfeb19392a725a01bc Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 29 May 2026 12:51:01 +0200 Subject: [PATCH 09/40] Remove the generated debug-client README (#25064) Deletes `codex-rs/debug-client/README.md` as one step in removing the stale app-server debug client. This intentionally leaves Cargo workspace and lockfile cleanup for a later follow-up PR. --- codex-rs/debug-client/README.md | 60 --------------------------------- 1 file changed, 60 deletions(-) delete mode 100644 codex-rs/debug-client/README.md diff --git a/codex-rs/debug-client/README.md b/codex-rs/debug-client/README.md deleted file mode 100644 index 447ed12360..0000000000 --- a/codex-rs/debug-client/README.md +++ /dev/null @@ -1,60 +0,0 @@ -WARNING: this code is mainly generated by Codex and should not be used in production - -# codex-debug-client - -A tiny interactive client for `codex app-server` (protocol v2 only). It prints -all JSON-RPC lines from the server and lets you send new turns as you type. - -## Usage - -Start the app-server client (it will spawn `codex app-server` itself): - -``` -cargo run -p codex-debug-client -- \ - --codex-bin codex \ - --approval-policy on-request \ - --output-file /tmp/app-server-server-json.jsonl -``` - -You can resume a specific thread: - -``` -cargo run -p codex-debug-client -- --thread-id thr_123 -``` - -### CLI flags - -- `--codex-bin `: path to the `codex` binary (default: `codex`). -- `-c, --config key=value`: pass through `--config` overrides to `codex`. -- `--thread-id `: resume a thread instead of starting a new one. -- `--approval-policy `: `untrusted`, `on-failure` (deprecated), `on-request`, `never`. -- `--auto-approve`: auto-approve command/file-change approvals (default: decline). -- `--final-only`: only show completed assistant messages and tool items. -- `--output-file `: write raw server JSONL to this file instead of stdout. -- `--model `: optional model override for thread start/resume. -- `--model-provider `: optional provider override. -- `--cwd `: optional working directory override. - -## Interactive commands - -Type a line to send it as a new turn. Commands are prefixed with `:`: - -- `:help` show help -- `:new` start a new thread -- `:resume ` resume a thread -- `:use ` switch active thread without resuming -- `:refresh-thread` list available threads -- `:quit` exit - -The prompt shows the active thread id. Client messages (help, errors, approvals) -print to stderr; raw server JSON prints to stdout so you can pipe/record it -unless `--final-only` is set. Pass `--output-file ` to record raw server -JSONL to a file instead of stdout. - -## Notes - -- The client performs the required initialize/initialized handshake. -- It prints every server notification and response line as it arrives. -- Approvals for `item/commandExecution/requestApproval` and - `item/fileChange/requestApproval` are auto-responded to with decline unless - `--auto-approve` is set. From 1197c7d6545054bf69829e24c5eeb3360e3f9ce6 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 29 May 2026 12:51:05 +0200 Subject: [PATCH 10/40] Delete debug-client app-server process plumbing (#25065) Deletes `codex-rs/debug-client/src/client.rs` as one step in removing the stale app-server debug client. This intentionally leaves Cargo workspace and lockfile cleanup for a later follow-up PR. --- codex-rs/debug-client/src/client.rs | 415 ---------------------------- 1 file changed, 415 deletions(-) delete mode 100644 codex-rs/debug-client/src/client.rs diff --git a/codex-rs/debug-client/src/client.rs b/codex-rs/debug-client/src/client.rs deleted file mode 100644 index 6d0390d382..0000000000 --- a/codex-rs/debug-client/src/client.rs +++ /dev/null @@ -1,415 +0,0 @@ -#![allow(clippy::expect_used)] -use std::io::BufRead; -use std::io::BufReader; -use std::io::Write; -use std::process::Child; -use std::process::ChildStdin; -use std::process::ChildStdout; -use std::process::Command; -use std::process::Stdio; -use std::sync::Arc; -use std::sync::Mutex; -use std::sync::atomic::AtomicI64; -use std::sync::atomic::Ordering; -use std::sync::mpsc::Sender; - -use anyhow::Context; -use anyhow::Result; -use codex_app_server_protocol::AskForApproval; -use codex_app_server_protocol::ClientInfo; -use codex_app_server_protocol::ClientNotification; -use codex_app_server_protocol::ClientRequest; -use codex_app_server_protocol::CommandExecutionApprovalDecision; -use codex_app_server_protocol::FileChangeApprovalDecision; -use codex_app_server_protocol::InitializeCapabilities; -use codex_app_server_protocol::JSONRPCMessage; -use codex_app_server_protocol::JSONRPCRequest; -use codex_app_server_protocol::JSONRPCResponse; -use codex_app_server_protocol::RequestId; -use codex_app_server_protocol::ThreadListParams; -use codex_app_server_protocol::ThreadResumeParams; -use codex_app_server_protocol::ThreadResumeResponse; -use codex_app_server_protocol::ThreadStartParams; -use codex_app_server_protocol::ThreadStartResponse; -use codex_app_server_protocol::TurnStartParams; -use codex_app_server_protocol::UserInput; -use serde::Serialize; - -use crate::output::Output; -use crate::reader::start_reader; -use crate::state::PendingRequest; -use crate::state::ReaderEvent; -use crate::state::State; - -pub struct AppServerClient { - child: Child, - stdin: Arc>>, - stdout: Option>, - next_request_id: AtomicI64, - state: Arc>, - output: Output, - filtered_output: bool, -} - -impl AppServerClient { - pub fn spawn( - codex_bin: &str, - config_overrides: &[String], - output: Output, - filtered_output: bool, - ) -> Result { - let mut cmd = Command::new(codex_bin); - for override_kv in config_overrides { - cmd.arg("--config").arg(override_kv); - } - - let mut child = cmd - .arg("app-server") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::inherit()) - .spawn() - .with_context(|| format!("failed to start `{codex_bin}` app-server"))?; - - let stdin = child - .stdin - .take() - .context("codex app-server stdin unavailable")?; - let stdout = child - .stdout - .take() - .context("codex app-server stdout unavailable")?; - - Ok(Self { - child, - stdin: Arc::new(Mutex::new(Some(stdin))), - stdout: Some(BufReader::new(stdout)), - next_request_id: AtomicI64::new(1), - state: Arc::new(Mutex::new(State::default())), - output, - filtered_output, - }) - } - - pub fn initialize(&mut self) -> Result<()> { - let request_id = self.next_request_id(); - let request = ClientRequest::Initialize { - request_id: request_id.clone(), - params: codex_app_server_protocol::InitializeParams { - client_info: ClientInfo { - name: "debug-client".to_string(), - title: Some("Debug Client".to_string()), - version: env!("CARGO_PKG_VERSION").to_string(), - }, - capabilities: Some(InitializeCapabilities { - experimental_api: true, - request_attestation: false, - opt_out_notification_methods: None, - }), - }, - }; - - self.send(&request)?; - let response = self.read_until_response(&request_id)?; - let _parsed: codex_app_server_protocol::InitializeResponse = - serde_json::from_value(response.result).context("decode initialize response")?; - let initialized = ClientNotification::Initialized; - self.send(&initialized)?; - Ok(()) - } - - pub fn start_thread(&mut self, params: ThreadStartParams) -> Result { - let request_id = self.next_request_id(); - let request = ClientRequest::ThreadStart { - request_id: request_id.clone(), - params, - }; - self.send(&request)?; - let response = self.read_until_response(&request_id)?; - let parsed: ThreadStartResponse = - serde_json::from_value(response.result).context("decode thread/start response")?; - let thread_id = parsed.thread.id; - self.set_thread_id(thread_id.clone()); - Ok(thread_id) - } - - pub fn resume_thread(&mut self, params: ThreadResumeParams) -> Result { - let request_id = self.next_request_id(); - let request = ClientRequest::ThreadResume { - request_id: request_id.clone(), - params, - }; - self.send(&request)?; - let response = self.read_until_response(&request_id)?; - let parsed: ThreadResumeResponse = - serde_json::from_value(response.result).context("decode thread/resume response")?; - let thread_id = parsed.thread.id; - self.set_thread_id(thread_id.clone()); - Ok(thread_id) - } - - pub fn request_thread_start(&self, params: ThreadStartParams) -> Result { - let request_id = self.next_request_id(); - self.track_pending(request_id.clone(), PendingRequest::Start); - let request = ClientRequest::ThreadStart { - request_id: request_id.clone(), - params, - }; - self.send(&request)?; - Ok(request_id) - } - - pub fn request_thread_resume(&self, params: ThreadResumeParams) -> Result { - let request_id = self.next_request_id(); - self.track_pending(request_id.clone(), PendingRequest::Resume); - let request = ClientRequest::ThreadResume { - request_id: request_id.clone(), - params, - }; - self.send(&request)?; - Ok(request_id) - } - - pub fn request_thread_list(&self, cursor: Option) -> Result { - let request_id = self.next_request_id(); - self.track_pending(request_id.clone(), PendingRequest::List); - let request = ClientRequest::ThreadList { - request_id: request_id.clone(), - params: ThreadListParams { - cursor, - limit: None, - sort_key: None, - sort_direction: None, - model_providers: None, - source_kinds: None, - archived: None, - cwd: None, - use_state_db_only: false, - search_term: None, - }, - }; - self.send(&request)?; - Ok(request_id) - } - - pub fn send_turn(&self, thread_id: &str, text: String) -> Result { - let request_id = self.next_request_id(); - let request = ClientRequest::TurnStart { - request_id: request_id.clone(), - params: TurnStartParams { - thread_id: thread_id.to_string(), - client_user_message_id: None, - input: vec![UserInput::Text { - text, - // Debug client sends plain text with no UI markup spans. - text_elements: Vec::new(), - }], - ..Default::default() - }, - }; - self.send(&request)?; - Ok(request_id) - } - - pub fn start_reader( - &mut self, - events: Sender, - auto_approve: bool, - filtered_output: bool, - ) -> Result<()> { - let stdout = self.stdout.take().context("reader already started")?; - start_reader( - stdout, - Arc::clone(&self.stdin), - Arc::clone(&self.state), - events, - self.output.clone(), - auto_approve, - filtered_output, - ); - Ok(()) - } - - pub fn thread_id(&self) -> Option { - let state = self.state.lock().expect("state lock poisoned"); - state.thread_id.clone() - } - - pub fn set_thread_id(&self, thread_id: String) { - let mut state = self.state.lock().expect("state lock poisoned"); - state.thread_id = Some(thread_id); - self.remember_thread_locked(&mut state); - } - - pub fn use_thread(&self, thread_id: String) -> bool { - let mut state = self.state.lock().expect("state lock poisoned"); - let known = state.known_threads.iter().any(|id| id == &thread_id); - state.thread_id = Some(thread_id); - self.remember_thread_locked(&mut state); - known - } - - pub fn shutdown(&mut self) { - if let Ok(mut stdin) = self.stdin.lock() { - let _ = stdin.take(); - } - let _ = self.child.wait(); - } - - fn track_pending(&self, request_id: RequestId, kind: PendingRequest) { - let mut state = self.state.lock().expect("state lock poisoned"); - state.pending.insert(request_id, kind); - } - - fn remember_thread_locked(&self, state: &mut State) { - if let Some(thread_id) = state.thread_id.as_ref() - && !state.known_threads.iter().any(|id| id == thread_id) - { - state.known_threads.push(thread_id.clone()); - } - } - - fn next_request_id(&self) -> RequestId { - let id = self.next_request_id.fetch_add(1, Ordering::SeqCst); - RequestId::Integer(id) - } - - fn send(&self, value: &T) -> Result<()> { - let json = serde_json::to_string(value).context("serialize message")?; - let mut line = json; - line.push('\n'); - let mut stdin = self.stdin.lock().expect("stdin lock poisoned"); - let Some(stdin) = stdin.as_mut() else { - anyhow::bail!("stdin already closed"); - }; - stdin.write_all(line.as_bytes()).context("write message")?; - stdin.flush().context("flush message")?; - Ok(()) - } - - fn read_until_response(&mut self, request_id: &RequestId) -> Result { - let stdin = Arc::clone(&self.stdin); - let output = self.output.clone(); - let reader = self.stdout.as_mut().context("stdout missing")?; - let mut buffer = String::new(); - - loop { - buffer.clear(); - let bytes = reader - .read_line(&mut buffer) - .context("read server output")?; - if bytes == 0 { - anyhow::bail!("server closed stdout while awaiting response {request_id:?}"); - } - - let line = buffer.trim_end_matches(['\n', '\r']); - if !line.is_empty() { - let _ = output.server_json_line(line, self.filtered_output); - } - - let message = match serde_json::from_str::(line) { - Ok(message) => message, - Err(_) => continue, - }; - - match message { - JSONRPCMessage::Response(response) if &response.id == request_id => { - return Ok(response); - } - JSONRPCMessage::Request(request) => { - let _ = handle_server_request(request, &stdin); - } - _ => {} - } - } - } -} - -fn handle_server_request( - request: JSONRPCRequest, - stdin: &Arc>>, -) -> Result<()> { - let Ok(server_request) = codex_app_server_protocol::ServerRequest::try_from(request) else { - return Ok(()); - }; - - match server_request { - codex_app_server_protocol::ServerRequest::CommandExecutionRequestApproval { - request_id, - .. - } => { - let response = codex_app_server_protocol::CommandExecutionRequestApprovalResponse { - decision: CommandExecutionApprovalDecision::Decline, - }; - send_jsonrpc_response(stdin, request_id, response) - } - codex_app_server_protocol::ServerRequest::FileChangeRequestApproval { - request_id, .. - } => { - let response = codex_app_server_protocol::FileChangeRequestApprovalResponse { - decision: FileChangeApprovalDecision::Decline, - }; - send_jsonrpc_response(stdin, request_id, response) - } - _ => Ok(()), - } -} - -fn send_jsonrpc_response( - stdin: &Arc>>, - request_id: RequestId, - response: T, -) -> Result<()> { - let result = serde_json::to_value(response).context("serialize response")?; - let message = JSONRPCMessage::Response(JSONRPCResponse { - id: request_id, - result, - }); - send_with_stdin(stdin, &message) -} - -fn send_with_stdin(stdin: &Arc>>, value: &T) -> Result<()> { - let json = serde_json::to_string(value).context("serialize message")?; - let mut line = json; - line.push('\n'); - let mut stdin = stdin.lock().expect("stdin lock poisoned"); - let Some(stdin) = stdin.as_mut() else { - anyhow::bail!("stdin already closed"); - }; - stdin.write_all(line.as_bytes()).context("write message")?; - stdin.flush().context("flush message")?; - Ok(()) -} - -pub fn build_thread_start_params( - approval_policy: AskForApproval, - model: Option, - model_provider: Option, - cwd: Option, -) -> ThreadStartParams { - ThreadStartParams { - model, - model_provider, - cwd, - approval_policy: Some(approval_policy), - experimental_raw_events: false, - ..Default::default() - } -} - -pub fn build_thread_resume_params( - thread_id: String, - approval_policy: AskForApproval, - model: Option, - model_provider: Option, - cwd: Option, -) -> ThreadResumeParams { - ThreadResumeParams { - thread_id, - model, - model_provider, - cwd, - approval_policy: Some(approval_policy), - ..Default::default() - } -} From 3d3cc5a953403e9290928c203e6c688318b71d27 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 29 May 2026 12:51:09 +0200 Subject: [PATCH 11/40] Retire debug-client interactive command parsing (#25066) Deletes `codex-rs/debug-client/src/commands.rs` as one step in removing the stale app-server debug client. This intentionally leaves Cargo workspace and lockfile cleanup for a later follow-up PR. --- codex-rs/debug-client/src/commands.rs | 156 -------------------------- 1 file changed, 156 deletions(-) delete mode 100644 codex-rs/debug-client/src/commands.rs diff --git a/codex-rs/debug-client/src/commands.rs b/codex-rs/debug-client/src/commands.rs deleted file mode 100644 index 447f09a54d..0000000000 --- a/codex-rs/debug-client/src/commands.rs +++ /dev/null @@ -1,156 +0,0 @@ -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum InputAction { - Message(String), - Command(UserCommand), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum UserCommand { - Help, - Quit, - NewThread, - Resume(String), - Use(String), - RefreshThread, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ParseError { - EmptyCommand, - MissingArgument { name: &'static str }, - UnknownCommand { command: String }, -} - -impl ParseError { - pub fn message(&self) -> String { - match self { - Self::EmptyCommand => "empty command after ':'".to_string(), - Self::MissingArgument { name } => { - format!("missing required argument: {name}") - } - Self::UnknownCommand { command } => format!("unknown command: {command}"), - } - } -} - -pub fn parse_input(line: &str) -> Result, ParseError> { - let trimmed = line.trim(); - if trimmed.is_empty() { - return Ok(None); - } - - let Some(command_line) = trimmed.strip_prefix(':') else { - return Ok(Some(InputAction::Message(trimmed.to_string()))); - }; - - let mut parts = command_line.split_whitespace(); - let Some(command) = parts.next() else { - return Err(ParseError::EmptyCommand); - }; - - match command { - "help" | "h" => Ok(Some(InputAction::Command(UserCommand::Help))), - "quit" | "q" | "exit" => Ok(Some(InputAction::Command(UserCommand::Quit))), - "new" => Ok(Some(InputAction::Command(UserCommand::NewThread))), - "resume" => { - let thread_id = parts - .next() - .ok_or(ParseError::MissingArgument { name: "thread-id" })?; - Ok(Some(InputAction::Command(UserCommand::Resume( - thread_id.to_string(), - )))) - } - "use" => { - let thread_id = parts - .next() - .ok_or(ParseError::MissingArgument { name: "thread-id" })?; - Ok(Some(InputAction::Command(UserCommand::Use( - thread_id.to_string(), - )))) - } - "refresh-thread" => Ok(Some(InputAction::Command(UserCommand::RefreshThread))), - _ => Err(ParseError::UnknownCommand { - command: command.to_string(), - }), - } -} - -#[cfg(test)] -mod tests { - use pretty_assertions::assert_eq; - - use super::InputAction; - use super::ParseError; - use super::UserCommand; - use super::parse_input; - - #[test] - fn parses_message() { - let result = parse_input("hello there").unwrap(); - assert_eq!( - result, - Some(InputAction::Message("hello there".to_string())) - ); - } - - #[test] - fn parses_help_command() { - let result = parse_input(":help").unwrap(); - assert_eq!(result, Some(InputAction::Command(UserCommand::Help))); - } - - #[test] - fn parses_new_thread() { - let result = parse_input(":new").unwrap(); - assert_eq!(result, Some(InputAction::Command(UserCommand::NewThread))); - } - - #[test] - fn parses_resume() { - let result = parse_input(":resume thr_123").unwrap(); - assert_eq!( - result, - Some(InputAction::Command(UserCommand::Resume( - "thr_123".to_string() - ))) - ); - } - - #[test] - fn parses_use() { - let result = parse_input(":use thr_456").unwrap(); - assert_eq!( - result, - Some(InputAction::Command(UserCommand::Use( - "thr_456".to_string() - ))) - ); - } - - #[test] - fn parses_refresh_thread() { - let result = parse_input(":refresh-thread").unwrap(); - assert_eq!( - result, - Some(InputAction::Command(UserCommand::RefreshThread)) - ); - } - - #[test] - fn rejects_missing_resume_arg() { - let result = parse_input(":resume"); - assert_eq!( - result, - Err(ParseError::MissingArgument { name: "thread-id" }) - ); - } - - #[test] - fn rejects_missing_use_arg() { - let result = parse_input(":use"); - assert_eq!( - result, - Err(ParseError::MissingArgument { name: "thread-id" }) - ); - } -} From e2b8ec616a1ab7c42e502bd95991751ea02d3717 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 29 May 2026 12:51:12 +0200 Subject: [PATCH 12/40] Remove the debug-client CLI entrypoint (#25067) Deletes `codex-rs/debug-client/src/main.rs` as one step in removing the stale app-server debug client. This intentionally leaves Cargo workspace and lockfile cleanup for a later follow-up PR. --- codex-rs/debug-client/src/main.rs | 310 ------------------------------ 1 file changed, 310 deletions(-) delete mode 100644 codex-rs/debug-client/src/main.rs diff --git a/codex-rs/debug-client/src/main.rs b/codex-rs/debug-client/src/main.rs deleted file mode 100644 index a09d9d04f5..0000000000 --- a/codex-rs/debug-client/src/main.rs +++ /dev/null @@ -1,310 +0,0 @@ -mod client; -mod commands; -mod output; -mod reader; -mod state; - -use std::fs::File; -use std::io; -use std::io::BufRead; -use std::path::PathBuf; -use std::sync::mpsc; - -use anyhow::Context; -use anyhow::Result; -use clap::ArgAction; -use clap::Parser; -use codex_app_server_protocol::AskForApproval; - -use crate::client::AppServerClient; -use crate::client::build_thread_resume_params; -use crate::client::build_thread_start_params; -use crate::commands::InputAction; -use crate::commands::UserCommand; -use crate::commands::parse_input; -use crate::output::Output; -use crate::state::ReaderEvent; - -#[derive(Parser)] -#[command(author = "Codex", version, about = "Minimal app-server client")] -struct Cli { - /// Path to the `codex` CLI binary. - #[arg(long, default_value = "codex")] - codex_bin: String, - - /// Forwarded to the `codex` CLI as `--config key=value`. Repeatable. - #[arg(short = 'c', long = "config", value_name = "key=value", action = ArgAction::Append)] - config_overrides: Vec, - - /// Resume an existing thread instead of starting a new one. - #[arg(long)] - thread_id: Option, - - /// Set the approval policy for the thread. - #[arg(long, default_value = "on-request")] - approval_policy: String, - - /// Auto-approve command/file-change approvals. - #[arg(long, default_value_t = false)] - auto_approve: bool, - - /// Only show final assistant messages and tool calls. - #[arg(long, default_value_t = false)] - final_only: bool, - - /// Write raw server JSONL to this file instead of stdout. - #[arg(long, value_name = "PATH")] - output_file: Option, - - /// Optional model override when starting/resuming a thread. - #[arg(long)] - model: Option, - - /// Optional model provider override when starting/resuming a thread. - #[arg(long)] - model_provider: Option, - - /// Optional working directory override when starting/resuming a thread. - #[arg(long)] - cwd: Option, -} - -fn main() -> Result<()> { - let cli = Cli::parse(); - let jsonl_file = cli - .output_file - .as_ref() - .map(File::create) - .transpose() - .with_context(|| { - let Some(path) = cli.output_file.as_ref() else { - return "open output file".to_string(); - }; - format!("open output file {}", path.display()) - })?; - let output = Output::new(jsonl_file); - let approval_policy = parse_approval_policy(&cli.approval_policy)?; - - let mut client = AppServerClient::spawn( - &cli.codex_bin, - &cli.config_overrides, - output.clone(), - cli.final_only, - )?; - client.initialize()?; - - let thread_id = if let Some(thread_id) = cli.thread_id.as_ref() { - client.resume_thread(build_thread_resume_params( - thread_id.clone(), - approval_policy, - cli.model.clone(), - cli.model_provider.clone(), - cli.cwd.clone(), - ))? - } else { - client.start_thread(build_thread_start_params( - approval_policy, - cli.model.clone(), - cli.model_provider.clone(), - cli.cwd.clone(), - ))? - }; - - output - .client_line(&format!("connected to thread {thread_id}")) - .ok(); - output.set_prompt(&thread_id); - - let (event_tx, event_rx) = mpsc::channel(); - client.start_reader(event_tx, cli.auto_approve, cli.final_only)?; - - print_help(&output); - - let stdin = io::stdin(); - let mut lines = stdin.lock().lines(); - - loop { - drain_events(&event_rx, &output); - let prompt_thread = client - .thread_id() - .unwrap_or_else(|| "no-thread".to_string()); - output.prompt(&prompt_thread).ok(); - - let Some(line) = lines.next() else { - break; - }; - let line = line.context("read stdin")?; - - match parse_input(&line) { - Ok(None) => continue, - Ok(Some(InputAction::Message(message))) => { - let Some(active_thread) = client.thread_id() else { - output - .client_line("no active thread; use :new or :resume ") - .ok(); - continue; - }; - if let Err(err) = client.send_turn(&active_thread, message) { - output - .client_line(&format!("failed to send turn: {err}")) - .ok(); - } - } - Ok(Some(InputAction::Command(command))) => { - if !handle_command(command, &client, &output, approval_policy, &cli) { - break; - } - } - Err(err) => { - output.client_line(&err.message()).ok(); - } - } - } - - client.shutdown(); - Ok(()) -} - -fn handle_command( - command: UserCommand, - client: &AppServerClient, - output: &Output, - approval_policy: AskForApproval, - cli: &Cli, -) -> bool { - match command { - UserCommand::Help => { - print_help(output); - true - } - UserCommand::Quit => false, - UserCommand::NewThread => { - match client.request_thread_start(build_thread_start_params( - approval_policy, - cli.model.clone(), - cli.model_provider.clone(), - cli.cwd.clone(), - )) { - Ok(request_id) => { - output - .client_line(&format!("requested new thread ({request_id:?})")) - .ok(); - } - Err(err) => { - output - .client_line(&format!("failed to start thread: {err}")) - .ok(); - } - } - true - } - UserCommand::Resume(thread_id) => { - match client.request_thread_resume(build_thread_resume_params( - thread_id, - approval_policy, - cli.model.clone(), - cli.model_provider.clone(), - cli.cwd.clone(), - )) { - Ok(request_id) => { - output - .client_line(&format!("requested thread resume ({request_id:?})")) - .ok(); - } - Err(err) => { - output - .client_line(&format!("failed to resume thread: {err}")) - .ok(); - } - } - true - } - UserCommand::Use(thread_id) => { - let known = client.use_thread(thread_id.clone()); - output.set_prompt(&thread_id); - if known { - output - .client_line(&format!("switched active thread to {thread_id}")) - .ok(); - } else { - output - .client_line(&format!( - "switched active thread to {thread_id} (unknown; use :resume to load)" - )) - .ok(); - } - true - } - UserCommand::RefreshThread => { - match client.request_thread_list(/*cursor*/ None) { - Ok(request_id) => { - output - .client_line(&format!("requested thread list ({request_id:?})")) - .ok(); - } - Err(err) => { - output - .client_line(&format!("failed to list threads: {err}")) - .ok(); - } - } - true - } - } -} - -fn parse_approval_policy(value: &str) -> Result { - match value { - "untrusted" | "unless-trusted" | "unlessTrusted" => Ok(AskForApproval::UnlessTrusted), - "on-failure" | "onFailure" => Ok(AskForApproval::OnFailure), - "on-request" | "onRequest" => Ok(AskForApproval::OnRequest), - "never" => Ok(AskForApproval::Never), - _ => anyhow::bail!( - "unknown approval policy: {value}. Expected one of: untrusted, on-failure, on-request, never" - ), - } -} - -fn drain_events(event_rx: &mpsc::Receiver, output: &Output) { - while let Ok(event) = event_rx.try_recv() { - match event { - ReaderEvent::ThreadReady { thread_id } => { - output - .client_line(&format!("active thread is now {thread_id}")) - .ok(); - output.set_prompt(&thread_id); - } - ReaderEvent::ThreadList { - thread_ids, - next_cursor, - } => { - if thread_ids.is_empty() { - output.client_line("threads: (none)").ok(); - } else { - output.client_line("threads:").ok(); - for thread_id in thread_ids { - output.client_line(&format!(" {thread_id}")).ok(); - } - } - if let Some(next_cursor) = next_cursor { - output - .client_line(&format!( - "more threads available, next cursor: {next_cursor}" - )) - .ok(); - } - } - } - } -} - -fn print_help(output: &Output) { - let _ = output.client_line("commands:"); - let _ = output.client_line(" :help show this help"); - let _ = output.client_line(" :new start a new thread"); - let _ = output.client_line(" :resume resume an existing thread"); - let _ = output.client_line(" :use switch the active thread"); - let _ = output.client_line(" :refresh-thread list available threads"); - let _ = output.client_line(" :quit exit"); - let _ = output.client_line("type a message to send it as a new turn"); -} From 5c1387846d600396fec14dc9aafa22099f5407ad Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 29 May 2026 12:51:16 +0200 Subject: [PATCH 13/40] Delete debug-client JSONL output helper (#25068) Deletes `codex-rs/debug-client/src/output.rs` as one step in removing the stale app-server debug client. This intentionally leaves Cargo workspace and lockfile cleanup for a later follow-up PR. --- codex-rs/debug-client/src/output.rs | 175 ---------------------------- 1 file changed, 175 deletions(-) delete mode 100644 codex-rs/debug-client/src/output.rs diff --git a/codex-rs/debug-client/src/output.rs b/codex-rs/debug-client/src/output.rs deleted file mode 100644 index f164a3ade8..0000000000 --- a/codex-rs/debug-client/src/output.rs +++ /dev/null @@ -1,175 +0,0 @@ -#![allow(clippy::expect_used)] -use std::fs::File; -use std::io; -use std::io::IsTerminal; -use std::io::Write; -use std::sync::Arc; -use std::sync::Mutex; - -#[derive(Clone, Copy, Debug)] -pub enum LabelColor { - Assistant, - Tool, - ToolMeta, - Thread, -} - -#[derive(Debug, Default)] -struct PromptState { - thread_id: Option, - visible: bool, -} - -#[derive(Clone, Debug)] -pub struct Output { - lock: Arc>, - prompt: Arc>, - color: bool, - jsonl_file: Option>>, -} - -impl Output { - pub fn new(jsonl_file: Option) -> Self { - let no_color = std::env::var_os("NO_COLOR").is_some(); - let color = !no_color && io::stdout().is_terminal() && io::stderr().is_terminal(); - Self { - lock: Arc::new(Mutex::new(())), - prompt: Arc::new(Mutex::new(PromptState::default())), - color, - jsonl_file: jsonl_file.map(|file| Arc::new(Mutex::new(file))), - } - } - - pub fn server_json_line(&self, line: &str, filtered_output: bool) -> io::Result<()> { - let _guard = self.lock.lock().expect("output lock poisoned"); - - if let Some(file) = self.jsonl_file.as_ref() { - let mut file = file.lock().expect("jsonl file lock poisoned"); - writeln!(file, "{line}")?; - file.flush()?; - } - - if self.jsonl_file.is_none() && !filtered_output { - self.clear_prompt_line_locked()?; - let mut stdout = io::stdout(); - writeln!(stdout, "{line}")?; - stdout.flush()?; - self.redraw_prompt_locked()?; - } - - Ok(()) - } - - pub fn server_line(&self, line: &str) -> io::Result<()> { - let _guard = self.lock.lock().expect("output lock poisoned"); - self.clear_prompt_line_locked()?; - let mut stdout = io::stdout(); - writeln!(stdout, "{line}")?; - stdout.flush()?; - self.redraw_prompt_locked() - } - - pub fn client_line(&self, line: &str) -> io::Result<()> { - let _guard = self.lock.lock().expect("output lock poisoned"); - self.clear_prompt_line_locked()?; - let mut stderr = io::stderr(); - writeln!(stderr, "{line}")?; - stderr.flush() - } - - pub fn prompt(&self, thread_id: &str) -> io::Result<()> { - let _guard = self.lock.lock().expect("output lock poisoned"); - self.set_prompt_locked(thread_id); - self.write_prompt_locked() - } - - pub fn set_prompt(&self, thread_id: &str) { - let _guard = self.lock.lock().expect("output lock poisoned"); - self.set_prompt_locked(thread_id); - } - - pub fn format_label(&self, label: &str, color: LabelColor) -> String { - if !self.color { - return label.to_string(); - } - - let code = match color { - LabelColor::Assistant => "32", - LabelColor::Tool => "36", - LabelColor::ToolMeta => "33", - LabelColor::Thread => "34", - }; - format!("\x1b[{code}m{label}\x1b[0m") - } - - fn clear_prompt_line_locked(&self) -> io::Result<()> { - let mut prompt = self.prompt.lock().expect("prompt lock poisoned"); - if prompt.visible { - let mut stderr = io::stderr(); - writeln!(stderr)?; - stderr.flush()?; - prompt.visible = false; - } - Ok(()) - } - - fn redraw_prompt_locked(&self) -> io::Result<()> { - if self - .prompt - .lock() - .expect("prompt lock poisoned") - .thread_id - .is_some() - { - self.write_prompt_locked()?; - } - Ok(()) - } - - fn set_prompt_locked(&self, thread_id: &str) { - let mut prompt = self.prompt.lock().expect("prompt lock poisoned"); - prompt.thread_id = Some(thread_id.to_string()); - } - - fn write_prompt_locked(&self) -> io::Result<()> { - let mut prompt = self.prompt.lock().expect("prompt lock poisoned"); - let Some(thread_id) = prompt.thread_id.as_ref() else { - return Ok(()); - }; - let mut stderr = io::stderr(); - write!(stderr, "({thread_id})> ")?; - stderr.flush()?; - prompt.visible = true; - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use std::fs; - - use super::*; - - #[test] - fn server_json_line_writes_to_configured_file() { - let path = std::env::temp_dir().join(format!( - "codex-debug-client-output-{}.jsonl", - std::process::id() - )); - let file = File::create(&path).expect("create output file"); - let output = Output::new(Some(file)); - - output - .server_json_line(r#"{"id":1}"#, false) - .expect("write unfiltered line"); - output - .server_json_line(r#"{"id":2}"#, true) - .expect("write filtered line"); - - assert_eq!( - fs::read_to_string(&path).expect("read output file"), - "{\"id\":1}\n{\"id\":2}\n" - ); - let _ = fs::remove_file(path); - } -} From 43fa4e5d259b045e190f1574be86377cfdbd78db Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 29 May 2026 12:51:19 +0200 Subject: [PATCH 14/40] Remove debug-client server event reader (#25069) Deletes `codex-rs/debug-client/src/reader.rs` as one step in removing the stale app-server debug client. This intentionally leaves Cargo workspace and lockfile cleanup for a later follow-up PR. --- codex-rs/debug-client/src/reader.rs | 338 ---------------------------- 1 file changed, 338 deletions(-) delete mode 100644 codex-rs/debug-client/src/reader.rs diff --git a/codex-rs/debug-client/src/reader.rs b/codex-rs/debug-client/src/reader.rs deleted file mode 100644 index cb51641633..0000000000 --- a/codex-rs/debug-client/src/reader.rs +++ /dev/null @@ -1,338 +0,0 @@ -#![allow(clippy::expect_used)] -use std::io::BufRead; -use std::io::BufReader; -use std::process::ChildStdout; -use std::sync::Arc; -use std::sync::Mutex; -use std::sync::mpsc::Sender; -use std::thread; -use std::thread::JoinHandle; - -use anyhow::Context; -use codex_app_server_protocol::CommandExecutionApprovalDecision; -use codex_app_server_protocol::CommandExecutionRequestApprovalResponse; -use codex_app_server_protocol::FileChangeApprovalDecision; -use codex_app_server_protocol::FileChangeRequestApprovalResponse; -use codex_app_server_protocol::JSONRPCMessage; -use codex_app_server_protocol::JSONRPCNotification; -use codex_app_server_protocol::JSONRPCRequest; -use codex_app_server_protocol::JSONRPCResponse; -use codex_app_server_protocol::ServerNotification; -use codex_app_server_protocol::ServerRequest; -use codex_app_server_protocol::ThreadItem; -use codex_app_server_protocol::ThreadListResponse; -use codex_app_server_protocol::ThreadResumeResponse; -use codex_app_server_protocol::ThreadStartResponse; -use serde::Serialize; -use std::io::Write; - -use crate::output::LabelColor; -use crate::output::Output; -use crate::state::PendingRequest; -use crate::state::ReaderEvent; -use crate::state::State; - -pub fn start_reader( - mut stdout: BufReader, - stdin: Arc>>, - state: Arc>, - events: Sender, - output: Output, - auto_approve: bool, - filtered_output: bool, -) -> JoinHandle<()> { - thread::spawn(move || { - let command_decision = if auto_approve { - CommandExecutionApprovalDecision::Accept - } else { - CommandExecutionApprovalDecision::Decline - }; - let file_decision = if auto_approve { - FileChangeApprovalDecision::Accept - } else { - FileChangeApprovalDecision::Decline - }; - - let mut buffer = String::new(); - - loop { - buffer.clear(); - match stdout.read_line(&mut buffer) { - Ok(0) => break, - Ok(_) => {} - Err(err) => { - let _ = output.client_line(&format!("failed to read from server: {err}")); - break; - } - } - - let line = buffer.trim_end_matches(['\n', '\r']); - if !line.is_empty() { - let _ = output.server_json_line(line, filtered_output); - } - - let Ok(message) = serde_json::from_str::(line) else { - continue; - }; - - match message { - JSONRPCMessage::Request(request) => { - if let Err(err) = handle_server_request( - request, - &command_decision, - &file_decision, - &stdin, - &output, - ) { - let _ = - output.client_line(&format!("failed to handle server request: {err}")); - } - } - JSONRPCMessage::Response(response) => { - if let Err(err) = handle_response(response, &state, &events) { - let _ = output.client_line(&format!("failed to handle response: {err}")); - } - } - JSONRPCMessage::Notification(notification) => { - if filtered_output - && let Err(err) = handle_filtered_notification(notification, &output) - { - let _ = - output.client_line(&format!("failed to filter notification: {err}")); - } - } - _ => {} - } - } - }) -} - -fn handle_server_request( - request: JSONRPCRequest, - command_decision: &CommandExecutionApprovalDecision, - file_decision: &FileChangeApprovalDecision, - stdin: &Arc>>, - output: &Output, -) -> anyhow::Result<()> { - let server_request = match ServerRequest::try_from(request) { - Ok(server_request) => server_request, - Err(_) => return Ok(()), - }; - - match server_request { - ServerRequest::CommandExecutionRequestApproval { request_id, params } => { - let response = CommandExecutionRequestApprovalResponse { - decision: command_decision.clone(), - }; - output.client_line(&format!( - "auto-response for command approval {request_id:?}: {command_decision:?} ({params:?})" - ))?; - send_response(stdin, request_id, response) - } - ServerRequest::FileChangeRequestApproval { request_id, params } => { - let response = FileChangeRequestApprovalResponse { - decision: file_decision.clone(), - }; - output.client_line(&format!( - "auto-response for file change approval {request_id:?}: {file_decision:?} ({params:?})" - ))?; - send_response(stdin, request_id, response) - } - _ => Ok(()), - } -} - -fn handle_response( - response: JSONRPCResponse, - state: &Arc>, - events: &Sender, -) -> anyhow::Result<()> { - let pending = { - let mut state = state.lock().expect("state lock poisoned"); - state.pending.remove(&response.id) - }; - - let Some(pending) = pending else { - return Ok(()); - }; - - match pending { - PendingRequest::Start => { - let parsed = serde_json::from_value::(response.result) - .context("decode thread/start response")?; - let thread_id = parsed.thread.id; - { - let mut state = state.lock().expect("state lock poisoned"); - state.thread_id = Some(thread_id.clone()); - if !state.known_threads.iter().any(|id| id == &thread_id) { - state.known_threads.push(thread_id.clone()); - } - } - events.send(ReaderEvent::ThreadReady { thread_id }).ok(); - } - PendingRequest::Resume => { - let parsed = serde_json::from_value::(response.result) - .context("decode thread/resume response")?; - let thread_id = parsed.thread.id; - { - let mut state = state.lock().expect("state lock poisoned"); - state.thread_id = Some(thread_id.clone()); - if !state.known_threads.iter().any(|id| id == &thread_id) { - state.known_threads.push(thread_id.clone()); - } - } - events.send(ReaderEvent::ThreadReady { thread_id }).ok(); - } - PendingRequest::List => { - let parsed = serde_json::from_value::(response.result) - .context("decode thread/list response")?; - let thread_ids: Vec = parsed.data.into_iter().map(|thread| thread.id).collect(); - { - let mut state = state.lock().expect("state lock poisoned"); - for thread_id in &thread_ids { - if !state.known_threads.iter().any(|id| id == thread_id) { - state.known_threads.push(thread_id.clone()); - } - } - } - events - .send(ReaderEvent::ThreadList { - thread_ids, - next_cursor: parsed.next_cursor, - }) - .ok(); - } - } - - Ok(()) -} - -fn handle_filtered_notification( - notification: JSONRPCNotification, - output: &Output, -) -> anyhow::Result<()> { - let Ok(server_notification) = ServerNotification::try_from(notification) else { - return Ok(()); - }; - - match server_notification { - ServerNotification::ItemCompleted(payload) => { - emit_filtered_item(payload.item, &payload.thread_id, output) - } - _ => Ok(()), - } -} - -fn emit_filtered_item(item: ThreadItem, thread_id: &str, output: &Output) -> anyhow::Result<()> { - let thread_label = output.format_label(thread_id, LabelColor::Thread); - match item { - ThreadItem::AgentMessage { text, .. } => { - let label = output.format_label("assistant", LabelColor::Assistant); - output.server_line(&format!("{thread_label} {label}: {text}"))?; - } - ThreadItem::Plan { text, .. } => { - let label = output.format_label("assistant", LabelColor::Assistant); - output.server_line(&format!("{thread_label} {label}: plan"))?; - write_multiline(output, &thread_label, &format!("{label}:"), &text)?; - } - ThreadItem::CommandExecution { - command, - status, - exit_code, - aggregated_output, - .. - } => { - let label = output.format_label("tool", LabelColor::Tool); - output.server_line(&format!( - "{thread_label} {label}: command {command} ({status:?})" - ))?; - if let Some(exit_code) = exit_code { - let label = output.format_label("tool exit", LabelColor::ToolMeta); - output.server_line(&format!("{thread_label} {label}: {exit_code}"))?; - } - if let Some(aggregated_output) = aggregated_output { - let label = output.format_label("tool output", LabelColor::ToolMeta); - write_multiline( - output, - &thread_label, - &format!("{label}:"), - &aggregated_output, - )?; - } - } - ThreadItem::FileChange { - changes, status, .. - } => { - let label = output.format_label("tool", LabelColor::Tool); - output.server_line(&format!( - "{thread_label} {label}: file change ({status:?}, {} files)", - changes.len() - ))?; - } - ThreadItem::McpToolCall { - server, - tool, - status, - arguments, - result, - error, - .. - } => { - let label = output.format_label("tool", LabelColor::Tool); - output.server_line(&format!( - "{thread_label} {label}: {server}.{tool} ({status:?})" - ))?; - if !arguments.is_null() { - let label = output.format_label("tool args", LabelColor::ToolMeta); - output.server_line(&format!("{thread_label} {label}: {arguments}"))?; - } - if let Some(result) = result { - let label = output.format_label("tool result", LabelColor::ToolMeta); - output.server_line(&format!("{thread_label} {label}: {result:?}"))?; - } - if let Some(error) = error { - let label = output.format_label("tool error", LabelColor::ToolMeta); - output.server_line(&format!("{thread_label} {label}: {error:?}"))?; - } - } - _ => {} - } - - Ok(()) -} - -fn write_multiline( - output: &Output, - thread_label: &str, - header: &str, - text: &str, -) -> anyhow::Result<()> { - output.server_line(&format!("{thread_label} {header}"))?; - for line in text.lines() { - output.server_line(&format!("{thread_label} {line}"))?; - } - Ok(()) -} - -fn send_response( - stdin: &Arc>>, - request_id: codex_app_server_protocol::RequestId, - response: T, -) -> anyhow::Result<()> { - let result = serde_json::to_value(response).context("serialize response")?; - let message = JSONRPCResponse { - id: request_id, - result, - }; - let json = serde_json::to_string(&message).context("serialize response message")?; - let mut line = json; - line.push('\n'); - - let mut stdin = stdin.lock().expect("stdin lock poisoned"); - let Some(stdin) = stdin.as_mut() else { - anyhow::bail!("stdin already closed"); - }; - stdin.write_all(line.as_bytes()).context("write response")?; - stdin.flush().context("flush response")?; - Ok(()) -} From 191c39aa75cd040746312c5dcd7800d9553c9c0e Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 29 May 2026 12:51:23 +0200 Subject: [PATCH 15/40] Drop debug-client prompt state tracking (#25070) Deletes `codex-rs/debug-client/src/state.rs` as one step in removing the stale app-server debug client. This intentionally leaves Cargo workspace and lockfile cleanup for a later follow-up PR. --- codex-rs/debug-client/src/state.rs | 28 ---------------------------- 1 file changed, 28 deletions(-) delete mode 100644 codex-rs/debug-client/src/state.rs diff --git a/codex-rs/debug-client/src/state.rs b/codex-rs/debug-client/src/state.rs deleted file mode 100644 index 44e2c5f355..0000000000 --- a/codex-rs/debug-client/src/state.rs +++ /dev/null @@ -1,28 +0,0 @@ -use std::collections::HashMap; - -use codex_app_server_protocol::RequestId; - -#[derive(Debug, Default)] -pub struct State { - pub pending: HashMap, - pub thread_id: Option, - pub known_threads: Vec, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PendingRequest { - Start, - Resume, - List, -} - -#[derive(Debug, Clone)] -pub enum ReaderEvent { - ThreadReady { - thread_id: String, - }, - ThreadList { - thread_ids: Vec, - next_cursor: Option, - }, -} From 3deda3116c12a3a53e0739bda1318d7e709443f8 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 29 May 2026 12:53:31 +0200 Subject: [PATCH 16/40] fix: main (#25075) --- codex-rs/Cargo.lock | 12 ------------ codex-rs/Cargo.toml | 1 - 2 files changed, 13 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 1951db642f..e873e689f4 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2719,18 +2719,6 @@ dependencies = [ "zip 2.4.2", ] -[[package]] -name = "codex-debug-client" -version = "0.0.0" -dependencies = [ - "anyhow", - "clap", - "codex-app-server-protocol", - "pretty_assertions", - "serde", - "serde_json", -] - [[package]] name = "codex-exec" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 808cdef1b9..a56caad775 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -14,7 +14,6 @@ members = [ "app-server-client", "app-server-protocol", "app-server-test-client", - "debug-client", "apply-patch", "arg0", "feedback", From 1c55bb2702fa4fbbccbf21057d344240744b5041 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 29 May 2026 13:32:19 +0200 Subject: [PATCH 17/40] [codex] Improve built-in tool schema docs (#24794) ## Summary - Clarify default, omission, and bounded behavior across built-in tool schemas, including unified exec, classic shell, Code Mode exec/wait, multi-agent, agent job, MCP resource, image, goal, plan, tool_search, and test-sync fields. - Convert update_plan status to an enum and add short field descriptions where the schema previously relied on surrounding context. - Remove the dedicated permission-approval schema test and keep only updates to existing expected-spec tests. ## Validation - Ran `just fmt`. - Ran `git diff --check`. - Did not run clippy or tests, per request. Regression has been eval [here](https://openai.slack.com/archives/C09GDSP1J9X/p1779905065496949) and we proved there are no regressions --- codex-rs/code-mode/src/description.rs | 10 +- .../core/src/tools/code_mode/wait_spec.rs | 14 +-- .../src/tools/handlers/agent_jobs_spec.rs | 38 ++++--- .../tools/handlers/agent_jobs_spec_tests.rs | 35 +++--- codex-rs/core/src/tools/handlers/goal_spec.rs | 3 +- .../src/tools/handlers/mcp_resource_spec.rs | 9 +- .../tools/handlers/mcp_resource_spec_tests.rs | 8 +- .../src/tools/handlers/multi_agents_spec.rs | 22 ++-- .../tools/handlers/multi_agents_spec_tests.rs | 6 +- codex-rs/core/src/tools/handlers/plan_spec.rs | 15 ++- .../core/src/tools/handlers/shell_spec.rs | 102 ++++++++++-------- .../src/tools/handlers/shell_spec_tests.rs | 25 +++-- .../core/src/tools/handlers/test_sync_spec.rs | 6 +- .../tools/handlers/test_sync_spec_tests.rs | 6 +- .../src/tools/handlers/tool_search_spec.rs | 4 +- .../src/tools/handlers/view_image_spec.rs | 6 +- codex-rs/core/tests/suite/search_tool.rs | 2 +- .../tests/suite/spawn_agent_description.rs | 2 +- 18 files changed, 179 insertions(+), 134 deletions(-) diff --git a/codex-rs/code-mode/src/description.rs b/codex-rs/code-mode/src/description.rs index 1362b51a4c..ca606cc100 100644 --- a/codex-rs/code-mode/src/description.rs +++ b/codex-rs/code-mode/src/description.rs @@ -17,8 +17,8 @@ const EXEC_DESCRIPTION_TEMPLATE: &str = r#"Run JavaScript code to orchestrate/co - Runs raw JavaScript -- no Node, no file system, no network access, no console. - Accepts raw JavaScript source text, not JSON, quoted strings, or markdown code fences. - You may optionally start the tool input with a first-line pragma like `// @exec: {"yield_time_ms": 10000, "max_output_tokens": 1000}`. -- `yield_time_ms` asks `exec` to yield early after that many milliseconds if the script is still running. -- `max_output_tokens` sets the token budget for direct `exec` results. By default the result is truncated to 10000 tokens. +- `yield_time_ms` asks `exec` to yield early if the script is still running. Defaults to 10000 ms. +- `max_output_tokens` sets the token budget for direct `exec` results. Defaults to 10000 tokens. - When the JS code is fully evaluated, the isolate's lifetime ends and unawaited promises are silently discarded. - Global helpers: @@ -34,9 +34,9 @@ const EXEC_DESCRIPTION_TEMPLATE: &str = r#"Run JavaScript code to orchestrate/co - `yield_control()`: yields the accumulated output to the model immediately while the script keeps running."#; const WAIT_DESCRIPTION_TEMPLATE: &str = r#"- Use `wait` only after `exec` returns `Script running with cell ID ...`. - `cell_id` identifies the running `exec` cell to resume. -- `yield_time_ms` controls how long to wait for more output before yielding again. If omitted, `wait` uses its default wait timeout. -- `max_tokens` limits how much new output this wait call returns. -- `terminate: true` stops the running cell instead of waiting for more output. +- `yield_time_ms` controls how long to wait for more output before yielding again. Defaults to 10000 ms. +- `max_tokens` limits how much new output this wait call returns. Defaults to 10000 tokens. +- `terminate: true` stops the running cell; false or omitted waits for output. - `wait` returns only the new output since the last yield, or the final completion or termination result for that cell. - If the cell is still running, `wait` may yield again with the same `cell_id`. - If the cell has already finished, `wait` returns the completed result and closes the cell."#; diff --git a/codex-rs/core/src/tools/code_mode/wait_spec.rs b/codex-rs/core/src/tools/code_mode/wait_spec.rs index d700ac53c2..72bb09084c 100644 --- a/codex-rs/core/src/tools/code_mode/wait_spec.rs +++ b/codex-rs/core/src/tools/code_mode/wait_spec.rs @@ -12,20 +12,19 @@ pub(crate) fn create_wait_tool() -> ToolSpec { ( "yield_time_ms".to_string(), JsonSchema::number(Some( - "How long to wait (in milliseconds) for more output before yielding again." - .to_string(), + "Wait before yielding more output. Defaults to 10000 ms.".to_string(), )), ), ( "max_tokens".to_string(), JsonSchema::number(Some( - "Maximum number of output tokens to return for this wait call.".to_string(), + "Output token budget for this wait call. Defaults to 10000 tokens.".to_string(), )), ), ( "terminate".to_string(), JsonSchema::boolean(Some( - "Whether to terminate the running exec cell.".to_string(), + "True stops the running exec cell; false or omitted waits for output.".to_string(), )), ), ]); @@ -77,20 +76,21 @@ mod tests { ( "max_tokens".to_string(), JsonSchema::number(Some( - "Maximum number of output tokens to return for this wait call." + "Output token budget for this wait call. Defaults to 10000 tokens." .to_string(), )), ), ( "terminate".to_string(), JsonSchema::boolean(Some( - "Whether to terminate the running exec cell.".to_string(), + "True stops the running exec cell; false or omitted waits for output." + .to_string(), )), ), ( "yield_time_ms".to_string(), JsonSchema::number(Some( - "How long to wait (in milliseconds) for more output before yielding again." + "Wait before yielding more output. Defaults to 10000 ms." .to_string(), )), ), diff --git a/codex-rs/core/src/tools/handlers/agent_jobs_spec.rs b/codex-rs/core/src/tools/handlers/agent_jobs_spec.rs index 67c756af7d..437549a929 100644 --- a/codex-rs/core/src/tools/handlers/agent_jobs_spec.rs +++ b/codex-rs/core/src/tools/handlers/agent_jobs_spec.rs @@ -4,6 +4,14 @@ use codex_tools::ToolSpec; use std::collections::BTreeMap; pub fn create_spawn_agents_on_csv_tool() -> ToolSpec { + let mut output_schema = JsonSchema::object( + BTreeMap::new(), + /*required*/ None, + /*additional_properties*/ None, + ); + output_schema.description = + Some("JSON Schema for each worker result. Omit to accept any result object.".to_string()); + let properties = BTreeMap::from([ ( "csv_path".to_string(), @@ -19,12 +27,15 @@ pub fn create_spawn_agents_on_csv_tool() -> ToolSpec { ( "id_column".to_string(), JsonSchema::string(Some( - "Optional column name to use as stable item id.".to_string(), + "CSV column to use as stable item id. Omit to use row numbers.".to_string(), )), ), ( "output_csv_path".to_string(), - JsonSchema::string(Some("Optional output CSV path for exported results.".to_string())), + JsonSchema::string(Some( + "Output CSV path for exported results. Omit to create one next to the input CSV." + .to_string(), + )), ), ( "max_concurrency".to_string(), @@ -36,20 +47,17 @@ pub fn create_spawn_agents_on_csv_tool() -> ToolSpec { ( "max_workers".to_string(), JsonSchema::number(Some( - "Alias for max_concurrency. Set to 1 to run sequentially.".to_string(), + "Alias for max_concurrency. Defaults to 16 and is capped by config.".to_string(), )), ), ( "max_runtime_seconds".to_string(), JsonSchema::number(Some( - "Maximum runtime per worker before it is failed. Defaults to 1800 seconds." + "Maximum runtime per worker before failure. Defaults to 1800 seconds; config may set a different default." .to_string(), )), ), - ( - "output_schema".to_string(), - JsonSchema::object(BTreeMap::new(), /*required*/ None, /*additional_properties*/ None), - ), + ("output_schema".to_string(), output_schema), ]); ToolSpec::Function(ResponsesApiTool { @@ -64,6 +72,13 @@ pub fn create_spawn_agents_on_csv_tool() -> ToolSpec { } pub fn create_report_agent_job_result_tool() -> ToolSpec { + let mut result_schema = JsonSchema::object( + BTreeMap::new(), + /*required*/ None, + /*additional_properties*/ None, + ); + result_schema.description = Some("Result object for this job item.".to_string()); + let properties = BTreeMap::from([ ( "job_id".to_string(), @@ -73,14 +88,11 @@ pub fn create_report_agent_job_result_tool() -> ToolSpec { "item_id".to_string(), JsonSchema::string(Some("Identifier of the job item.".to_string())), ), - ( - "result".to_string(), - JsonSchema::object(BTreeMap::new(), /*required*/ None, /*additional_properties*/ None), - ), + ("result".to_string(), result_schema), ( "stop".to_string(), JsonSchema::boolean(Some( - "Optional. When true, cancels the remaining job items after this result is recorded." + "True cancels remaining job items after this result is recorded; false or omitted continues the job." .to_string(), )), ), diff --git a/codex-rs/core/src/tools/handlers/agent_jobs_spec_tests.rs b/codex-rs/core/src/tools/handlers/agent_jobs_spec_tests.rs index 92caec4dbe..b087094095 100644 --- a/codex-rs/core/src/tools/handlers/agent_jobs_spec_tests.rs +++ b/codex-rs/core/src/tools/handlers/agent_jobs_spec_tests.rs @@ -3,6 +3,16 @@ use codex_tools::JsonSchema; use pretty_assertions::assert_eq; use std::collections::BTreeMap; +fn described_object(description: &str) -> JsonSchema { + let mut schema = JsonSchema::object( + BTreeMap::new(), + /*required*/ None, + /*additional_properties*/ None, + ); + schema.description = Some(description.to_string()); + schema +} + #[test] fn spawn_agents_on_csv_tool_requires_csv_and_instruction() { assert_eq!( @@ -30,13 +40,15 @@ fn spawn_agents_on_csv_tool_requires_csv_and_instruction() { ( "id_column".to_string(), JsonSchema::string(Some( - "Optional column name to use as stable item id.".to_string(), + "CSV column to use as stable item id. Omit to use row numbers." + .to_string(), )), ), ( "output_csv_path".to_string(), JsonSchema::string(Some( - "Optional output CSV path for exported results.".to_string(), + "Output CSV path for exported results. Omit to create one next to the input CSV." + .to_string(), )), ), ( @@ -49,22 +61,21 @@ fn spawn_agents_on_csv_tool_requires_csv_and_instruction() { ( "max_workers".to_string(), JsonSchema::number(Some( - "Alias for max_concurrency. Set to 1 to run sequentially.".to_string(), + "Alias for max_concurrency. Defaults to 16 and is capped by config." + .to_string(), )), ), ( "max_runtime_seconds".to_string(), JsonSchema::number(Some( - "Maximum runtime per worker before it is failed. Defaults to 1800 seconds." + "Maximum runtime per worker before failure. Defaults to 1800 seconds; config may set a different default." .to_string(), )), ), ( "output_schema".to_string(), - JsonSchema::object( - BTreeMap::new(), - /*required*/ None, - /*additional_properties*/ None, + described_object( + "JSON Schema for each worker result. Omit to accept any result object.", ), ), ]), Some(vec!["csv_path".to_string(), "instruction".to_string()]), Some(false.into())), @@ -95,16 +106,12 @@ fn report_agent_job_result_tool_requires_result_payload() { ), ( "result".to_string(), - JsonSchema::object( - BTreeMap::new(), - /*required*/ None, - /*additional_properties*/ None, - ), + described_object("Result object for this job item."), ), ( "stop".to_string(), JsonSchema::boolean(Some( - "Optional. When true, cancels the remaining job items after this result is recorded." + "True cancels remaining job items after this result is recorded; false or omitted continues the job." .to_string(), )), ), diff --git a/codex-rs/core/src/tools/handlers/goal_spec.rs b/codex-rs/core/src/tools/handlers/goal_spec.rs index a3ae8703ce..da8e23d305 100644 --- a/codex-rs/core/src/tools/handlers/goal_spec.rs +++ b/codex-rs/core/src/tools/handlers/goal_spec.rs @@ -37,7 +37,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(), )), ), ]); diff --git a/codex-rs/core/src/tools/handlers/mcp_resource_spec.rs b/codex-rs/core/src/tools/handlers/mcp_resource_spec.rs index 28ccd66367..526fb990c3 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource_spec.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource_spec.rs @@ -8,14 +8,13 @@ pub fn create_list_mcp_resources_tool() -> ToolSpec { ( "server".to_string(), JsonSchema::string(Some( - "Optional MCP server name. When omitted, lists resources from every configured server." - .to_string(), + "MCP server name. Omit to list resources from every configured server.".to_string(), )), ), ( "cursor".to_string(), JsonSchema::string(Some( - "Opaque cursor returned by a previous list_mcp_resources call for the same server." + "Opaque cursor from a previous list_mcp_resources call; omit for the first page." .to_string(), )), ), @@ -36,14 +35,14 @@ pub fn create_list_mcp_resource_templates_tool() -> ToolSpec { ( "server".to_string(), JsonSchema::string(Some( - "Optional MCP server name. When omitted, lists resource templates from all configured servers." + "MCP server name. Omit to list resource templates from every configured server." .to_string(), )), ), ( "cursor".to_string(), JsonSchema::string(Some( - "Opaque cursor returned by a previous list_mcp_resource_templates call for the same server." + "Opaque cursor from a previous list_mcp_resource_templates call; omit for the first page." .to_string(), )), ), diff --git a/codex-rs/core/src/tools/handlers/mcp_resource_spec_tests.rs b/codex-rs/core/src/tools/handlers/mcp_resource_spec_tests.rs index 9af7172686..a50879d9df 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource_spec_tests.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource_spec_tests.rs @@ -16,14 +16,14 @@ fn list_mcp_resources_tool_matches_expected_spec() { ( "server".to_string(), JsonSchema::string(Some( - "Optional MCP server name. When omitted, lists resources from every configured server." + "MCP server name. Omit to list resources from every configured server." .to_string(), ),), ), ( "cursor".to_string(), JsonSchema::string(Some( - "Opaque cursor returned by a previous list_mcp_resources call for the same server." + "Opaque cursor from a previous list_mcp_resources call; omit for the first page." .to_string(), ),), ), @@ -46,14 +46,14 @@ fn list_mcp_resource_templates_tool_matches_expected_spec() { ( "server".to_string(), JsonSchema::string(Some( - "Optional MCP server name. When omitted, lists resource templates from all configured servers." + "MCP server name. Omit to list resource templates from every configured server." .to_string(), ),), ), ( "cursor".to_string(), JsonSchema::string(Some( - "Opaque cursor returned by a previous list_mcp_resource_templates call for the same server." + "Opaque cursor from a previous list_mcp_resource_templates call; omit for the first page." .to_string(), ),), ), 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 8aad428411..22bc8d5d1a 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs @@ -11,9 +11,11 @@ use std::collections::BTreeMap; pub const MULTI_AGENT_V1_NAMESPACE: &str = "multi_agent_v1"; const MULTI_AGENT_V1_NAMESPACE_DESCRIPTION: &str = "Tools for spawning and managing sub-agents."; -const SPAWN_AGENT_INHERITED_MODEL_GUIDANCE: &str = "Spawned agents inherit your current model by default. Omit `model` to use that preferred default; set `model` only when an explicit override is needed."; -const SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION: &str = "Optional model override for the new agent. Leave unset to inherit the same model as the parent, which is the preferred default. Only set this when the user explicitly asks for a different model or the task clearly requires one."; -const SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION: &str = "Optional service tier override for the new agent. Leave unset unless the user explicitly asks for one."; +const SPAWN_AGENT_INHERITED_MODEL_GUIDANCE: &str = "Spawned agents inherit your current model by default. If provided, `model` specifies the model to use for the spawned agent."; +const SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION: &str = + "Model override for the new agent. Omit to inherit the parent model."; +const SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION: &str = + "Service tier override for the new agent. Omit unless explicitly requested."; const MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT_DESCRIPTION: usize = 5; #[derive(Debug, Clone, Default)] @@ -125,7 +127,7 @@ pub fn create_send_input_tool_v1() -> ToolSpec { ( "interrupt".to_string(), JsonSchema::boolean(Some( - "When true, stop the agent's current task and handle this immediately. When false (default), queue this message." + "True interrupts the current task and handles this message immediately; false or omitted queues it." .to_string(), )), ), @@ -258,7 +260,7 @@ pub fn create_list_agents_tool() -> ToolSpec { let properties = BTreeMap::from([( "path_prefix".to_string(), JsonSchema::string(Some( - "Optional task-path prefix (not ending with trailing slash). Accepts the same relative or absolute task-path syntax." + "Task-path prefix filter without a trailing slash. Omit to list all live agents." .to_string(), )), )]); @@ -555,7 +557,7 @@ fn spawn_agent_common_properties_v1(agent_type_description: &str) -> BTreeMap BTreeMap BTreeMap JsonSchema ( "timeout_ms".to_string(), JsonSchema::number(Some(format!( - "Optional timeout in milliseconds. Defaults to {}, min {}, max {}. Prefer longer waits (minutes) to avoid busy polling.", + "Timeout in milliseconds. Defaults to {}, min {}, max {}. Prefer longer waits (minutes) to avoid busy polling.", options.default_timeout_ms, options.min_timeout_ms, options.max_timeout_ms, ))), ), @@ -824,7 +826,7 @@ fn wait_agent_tool_parameters_v2(options: WaitAgentTimeoutOptions) -> JsonSchema let properties = BTreeMap::from([( "timeout_ms".to_string(), JsonSchema::number(Some(format!( - "Optional timeout in milliseconds. Defaults to {}, min {}, max {}.", + "Timeout in milliseconds. Defaults to {}, min {}, max {}.", options.default_timeout_ms, options.min_timeout_ms, options.max_timeout_ms, ))), )]); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs index d90cdc95e0..a29056a120 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs @@ -306,7 +306,7 @@ fn wait_agent_tool_v2_uses_timeout_only_summary_output() { properties .get("timeout_ms") .and_then(|schema| schema.description.as_deref()), - Some("Optional timeout in milliseconds. Defaults to 30000, min 10000, max 3600000.") + Some("Timeout in milliseconds. Defaults to 30000, min 10000, max 3600000.") ); assert_eq!(parameters.required.as_ref(), None); assert_eq!( @@ -338,9 +338,7 @@ fn list_agents_tool_includes_path_prefix_and_agent_fields() { properties .get("path_prefix") .and_then(|schema| schema.description.as_deref()), - Some( - "Optional task-path prefix (not ending with trailing slash). Accepts the same relative or absolute task-path syntax." - ) + Some("Task-path prefix filter without a trailing slash. Omit to list all live agents.") ); assert_eq!( output_schema.expect("list_agents output schema")["properties"]["agents"]["items"]["required"], diff --git a/codex-rs/core/src/tools/handlers/plan_spec.rs b/codex-rs/core/src/tools/handlers/plan_spec.rs index 263517b93a..d8ce95767b 100644 --- a/codex-rs/core/src/tools/handlers/plan_spec.rs +++ b/codex-rs/core/src/tools/handlers/plan_spec.rs @@ -1,21 +1,30 @@ use codex_tools::JsonSchema; use codex_tools::ResponsesApiTool; use codex_tools::ToolSpec; +use serde_json::json; use std::collections::BTreeMap; pub fn create_update_plan_tool() -> ToolSpec { let plan_item_properties = BTreeMap::from([ - ("step".to_string(), JsonSchema::string(/*description*/ None)), + ( + "step".to_string(), + JsonSchema::string(Some("Task step text.".to_string())), + ), ( "status".to_string(), - JsonSchema::string(Some("One of: pending, in_progress, completed".to_string())), + JsonSchema::string_enum( + vec![json!("pending"), json!("in_progress"), json!("completed")], + Some("Step status.".to_string()), + ), ), ]); let properties = BTreeMap::from([ ( "explanation".to_string(), - JsonSchema::string(/*description*/ None), + JsonSchema::string(Some( + "Optional explanation for this plan update.".to_string(), + )), ), ( "plan".to_string(), diff --git a/codex-rs/core/src/tools/handlers/shell_spec.rs b/codex-rs/core/src/tools/handlers/shell_spec.rs index 43b78e3afe..361f73bc04 100644 --- a/codex-rs/core/src/tools/handlers/shell_spec.rs +++ b/codex-rs/core/src/tools/handlers/shell_spec.rs @@ -28,7 +28,7 @@ pub(crate) fn create_exec_command_tool_with_environment_id( ( "workdir".to_string(), JsonSchema::string(Some( - "Optional working directory to run the command in; defaults to the turn cwd." + "Working directory for the command. Defaults to the turn cwd." .to_string(), )), ), @@ -41,20 +41,20 @@ pub(crate) fn create_exec_command_tool_with_environment_id( ( "tty".to_string(), JsonSchema::boolean(Some( - "Whether to allocate a TTY for the command. Defaults to false (plain pipes); set to true to open a PTY and access TTY process." + "True allocates a PTY for the command; false or omitted uses plain pipes." .to_string(), )), ), ( "yield_time_ms".to_string(), JsonSchema::number(Some( - "How long to wait (in milliseconds) for output before yielding.".to_string(), + "Wait before yielding output. Defaults to 10000 ms; effective range is 250-30000 ms.".to_string(), )), ), ( "max_output_tokens".to_string(), JsonSchema::number(Some( - "Maximum number of tokens to return. Excess output will be truncated.".to_string(), + "Output token budget. Defaults to 10000 tokens; larger requests may be capped by policy.".to_string(), )), ), ]); @@ -62,7 +62,8 @@ pub(crate) fn create_exec_command_tool_with_environment_id( properties.insert( "login".to_string(), JsonSchema::boolean(Some( - "Whether to run the shell with -l/-i semantics. Defaults to true.".to_string(), + "True runs the shell with -l/-i semantics; false disables them. Defaults to true." + .to_string(), )), ); } @@ -70,7 +71,8 @@ pub(crate) fn create_exec_command_tool_with_environment_id( properties.insert( "environment_id".to_string(), JsonSchema::string(Some( - "Optional environment id from the block. If omitted, uses the primary environment.".to_string(), + "Environment id from . Omit to use the primary environment." + .to_string(), )), ); } @@ -111,19 +113,19 @@ pub fn create_write_stdin_tool() -> ToolSpec { ( "chars".to_string(), JsonSchema::string(Some( - "Bytes to write to stdin (may be empty to poll).".to_string(), + "Bytes to write to stdin. Defaults to empty, which polls without writing.".to_string(), )), ), ( "yield_time_ms".to_string(), JsonSchema::number(Some( - "How long to wait (in milliseconds) for output before yielding.".to_string(), + "Wait before yielding output. Non-empty writes default to 250 ms and cap at 30000 ms; empty polls wait 5000-300000 ms by default.".to_string(), )), ), ( "max_output_tokens".to_string(), JsonSchema::number(Some( - "Maximum number of tokens to return. Excess output will be truncated.".to_string(), + "Output token budget. Defaults to 10000 tokens; larger requests may be capped by policy.".to_string(), )), ), ]); @@ -149,19 +151,19 @@ pub fn create_shell_command_tool(options: CommandToolOptions) -> ToolSpec { ( "command".to_string(), JsonSchema::string(Some( - "The shell script to execute in the user's default shell".to_string(), + "Shell script to run in the user's default shell.".to_string(), )), ), ( "workdir".to_string(), JsonSchema::string(Some( - "The working directory to execute the command in".to_string(), + "Working directory for the command. Defaults to the turn cwd.".to_string(), )), ), ( "timeout_ms".to_string(), JsonSchema::number(Some( - "The timeout for the command in milliseconds".to_string(), + "Maximum command runtime. Defaults to 10000 ms.".to_string(), )), ), ]); @@ -169,7 +171,7 @@ pub fn create_shell_command_tool(options: CommandToolOptions) -> ToolSpec { properties.insert( "login".to_string(), JsonSchema::boolean(Some( - "Whether to run the shell with login shell semantics. Defaults to true." + "True runs with login shell semantics; false disables them. Defaults to true." .to_string(), )), ); @@ -281,92 +283,108 @@ fn unified_exec_output_schema() -> Value { fn create_approval_parameters( exec_permission_approvals_enabled: bool, ) -> BTreeMap { + let mut sandbox_permission_values = vec![json!("use_default")]; + if exec_permission_approvals_enabled { + sandbox_permission_values.push(json!("with_additional_permissions")); + } + sandbox_permission_values.push(json!("require_escalated")); + let sandbox_permissions_description = if exec_permission_approvals_enabled { + "Per-command sandbox override. Defaults to `use_default`; use `with_additional_permissions` with `additional_permissions`, or `require_escalated` for unsandboxed execution." + } else { + "Per-command sandbox override. Defaults to `use_default`; use `require_escalated` for unsandboxed execution." + }; + let mut properties = BTreeMap::from([ ( "sandbox_permissions".to_string(), - JsonSchema::string(Some( - if exec_permission_approvals_enabled { - "Sandbox permissions for the command. Use \"with_additional_permissions\" to request additional sandboxed filesystem or network permissions (preferred), or \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"." - } else { - "Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"." - } - .to_string(), - )), + JsonSchema::string_enum( + sandbox_permission_values, + Some(sandbox_permissions_description.to_string()), + ), ), ( "justification".to_string(), JsonSchema::string(Some( - r#"Only set if sandbox_permissions is \"require_escalated\". - Request approval from the user to run this command outside the sandbox. - Phrased as a simple question that summarizes the purpose of the - command as it relates to the task at hand - e.g. 'Do you want to - fetch and pull the latest version of this git branch?'"# - .to_string(), + "User-facing approval question for `require_escalated`; omit otherwise.".to_string(), )), ), ( "prefix_rule".to_string(), JsonSchema::array(JsonSchema::string(/*description*/ None), Some( - r#"Only specify when sandbox_permissions is `require_escalated`. - Suggest a prefix command pattern that will allow you to fulfill similar requests from the user in the future. - Should be a short but reasonable prefix, e.g. [\"git\", \"pull\"] or [\"uv\", \"run\"] or [\"pytest\"]."#.to_string(), + r#"Reusable approval prefix for `cmd`, only with `sandbox_permissions: "require_escalated"`; for example ["git", "pull"]."#.to_string(), )), ), ]); if exec_permission_approvals_enabled { - properties.insert( - "additional_permissions".to_string(), - permission_profile_schema(), + let mut additional_permissions = permission_profile_schema(); + additional_permissions.description = Some( + "Sandboxed filesystem or network access for this command; only with `sandbox_permissions: \"with_additional_permissions\"`." + .to_string(), ); + properties.insert("additional_permissions".to_string(), additional_permissions); } properties } fn permission_profile_schema() -> JsonSchema { - JsonSchema::object( + let mut schema = JsonSchema::object( BTreeMap::from([ ("network".to_string(), network_permissions_schema()), ("file_system".to_string(), file_system_permissions_schema()), ]), /*required*/ None, Some(false.into()), - ) + ); + schema.description = Some("Filesystem or network access request.".to_string()); + schema } fn network_permissions_schema() -> JsonSchema { - JsonSchema::object( + let mut schema = JsonSchema::object( BTreeMap::from([( "enabled".to_string(), - JsonSchema::boolean(Some("Set to true to request network access.".to_string())), + JsonSchema::boolean(Some( + "True requests network access; false or omitted requests none.".to_string(), + )), )]), /*required*/ None, Some(false.into()), - ) + ); + schema.description = Some("Network access request.".to_string()); + schema } fn file_system_permissions_schema() -> JsonSchema { - JsonSchema::object( + let mut schema = JsonSchema::object( BTreeMap::from([ ( "read".to_string(), JsonSchema::array( JsonSchema::string(/*description*/ None), - Some("Absolute paths to grant read access to.".to_string()), + Some( + "Absolute paths to grant read access; omit when none are needed." + .to_string(), + ), ), ), ( "write".to_string(), JsonSchema::array( JsonSchema::string(/*description*/ None), - Some("Absolute paths to grant write access to.".to_string()), + Some( + "Absolute paths to grant write access; omit when none are needed." + .to_string(), + ), ), ), ]), /*required*/ None, Some(false.into()), - ) + ); + schema.description = Some("Filesystem access request.".to_string()); + schema } fn windows_shell_guidance() -> &'static str { diff --git a/codex-rs/core/src/tools/handlers/shell_spec_tests.rs b/codex-rs/core/src/tools/handlers/shell_spec_tests.rs index 4774815fb2..c11f34e927 100644 --- a/codex-rs/core/src/tools/handlers/shell_spec_tests.rs +++ b/codex-rs/core/src/tools/handlers/shell_spec_tests.rs @@ -31,7 +31,7 @@ fn exec_command_tool_matches_expected_spec() { ( "workdir".to_string(), JsonSchema::string(Some( - "Optional working directory to run the command in; defaults to the turn cwd." + "Working directory for the command. Defaults to the turn cwd." .to_string(), )), ), @@ -44,27 +44,26 @@ fn exec_command_tool_matches_expected_spec() { ( "tty".to_string(), JsonSchema::boolean(Some( - "Whether to allocate a TTY for the command. Defaults to false (plain pipes); set to true to open a PTY and access TTY process." + "True allocates a PTY for the command; false or omitted uses plain pipes." .to_string(), )), ), ( "yield_time_ms".to_string(), JsonSchema::number(Some( - "How long to wait (in milliseconds) for output before yielding.".to_string(), + "Wait before yielding output. Defaults to 10000 ms; effective range is 250-30000 ms.".to_string(), )), ), ( "max_output_tokens".to_string(), JsonSchema::number(Some( - "Maximum number of tokens to return. Excess output will be truncated." - .to_string(), + "Output token budget. Defaults to 10000 tokens; larger requests may be capped by policy.".to_string(), )), ), ( "login".to_string(), JsonSchema::boolean(Some( - "Whether to run the shell with -l/-i semantics. Defaults to true.".to_string(), + "True runs the shell with -l/-i semantics; false disables them. Defaults to true.".to_string(), )), ), ]); @@ -103,19 +102,19 @@ fn write_stdin_tool_matches_expected_spec() { ( "chars".to_string(), JsonSchema::string(Some( - "Bytes to write to stdin (may be empty to poll).".to_string(), + "Bytes to write to stdin. Defaults to empty, which polls without writing.".to_string(), )), ), ( "yield_time_ms".to_string(), JsonSchema::number(Some( - "How long to wait (in milliseconds) for output before yielding.".to_string(), + "Wait before yielding output. Non-empty writes default to 250 ms and cap at 30000 ms; empty polls wait 5000-300000 ms by default.".to_string(), )), ), ( "max_output_tokens".to_string(), JsonSchema::number(Some( - "Maximum number of tokens to return. Excess output will be truncated.".to_string(), + "Output token budget. Defaults to 10000 tokens; larger requests may be capped by policy.".to_string(), )), ), ]); @@ -201,25 +200,25 @@ Examples of valid command strings: ( "command".to_string(), JsonSchema::string(Some( - "The shell script to execute in the user's default shell".to_string(), + "Shell script to run in the user's default shell.".to_string(), )), ), ( "workdir".to_string(), JsonSchema::string(Some( - "The working directory to execute the command in".to_string(), + "Working directory for the command. Defaults to the turn cwd.".to_string(), )), ), ( "timeout_ms".to_string(), JsonSchema::number(Some( - "The timeout for the command in milliseconds".to_string(), + "Maximum command runtime. Defaults to 10000 ms.".to_string(), )), ), ( "login".to_string(), JsonSchema::boolean(Some( - "Whether to run the shell with login shell semantics. Defaults to true." + "True runs with login shell semantics; false disables them. Defaults to true." .to_string(), )), ), diff --git a/codex-rs/core/src/tools/handlers/test_sync_spec.rs b/codex-rs/core/src/tools/handlers/test_sync_spec.rs index 7d2b665713..4de8104034 100644 --- a/codex-rs/core/src/tools/handlers/test_sync_spec.rs +++ b/codex-rs/core/src/tools/handlers/test_sync_spec.rs @@ -20,7 +20,7 @@ pub fn create_test_sync_tool() -> ToolSpec { ( "timeout_ms".to_string(), JsonSchema::number(Some( - "Maximum time in milliseconds to wait at the barrier".to_string(), + "Maximum barrier wait in milliseconds. Defaults to 1000.".to_string(), )), ), ]); @@ -29,13 +29,13 @@ pub fn create_test_sync_tool() -> ToolSpec { ( "sleep_before_ms".to_string(), JsonSchema::number(Some( - "Optional delay in milliseconds before any other action".to_string(), + "Delay before any other action. Defaults to no delay.".to_string(), )), ), ( "sleep_after_ms".to_string(), JsonSchema::number(Some( - "Optional delay in milliseconds after completing the barrier".to_string(), + "Delay after completing the barrier. Defaults to no delay.".to_string(), )), ), ( diff --git a/codex-rs/core/src/tools/handlers/test_sync_spec_tests.rs b/codex-rs/core/src/tools/handlers/test_sync_spec_tests.rs index d6d47cfa9a..55f4a1a0a6 100644 --- a/codex-rs/core/src/tools/handlers/test_sync_spec_tests.rs +++ b/codex-rs/core/src/tools/handlers/test_sync_spec_tests.rs @@ -35,7 +35,7 @@ fn test_sync_tool_matches_expected_spec() { ( "timeout_ms".to_string(), JsonSchema::number(Some( - "Maximum time in milliseconds to wait at the barrier" + "Maximum barrier wait in milliseconds. Defaults to 1000." .to_string(), )), ), @@ -47,14 +47,14 @@ fn test_sync_tool_matches_expected_spec() { ( "sleep_after_ms".to_string(), JsonSchema::number(Some( - "Optional delay in milliseconds after completing the barrier" + "Delay after completing the barrier. Defaults to no delay." .to_string(), )), ), ( "sleep_before_ms".to_string(), JsonSchema::number(Some( - "Optional delay in milliseconds before any other action".to_string(), + "Delay before any other action. Defaults to no delay.".to_string(), )), ), ]), /*required*/ None, Some(false.into())), diff --git a/codex-rs/core/src/tools/handlers/tool_search_spec.rs b/codex-rs/core/src/tools/handlers/tool_search_spec.rs index d5a0a37897..2d17d131c7 100644 --- a/codex-rs/core/src/tools/handlers/tool_search_spec.rs +++ b/codex-rs/core/src/tools/handlers/tool_search_spec.rs @@ -16,7 +16,7 @@ pub(crate) fn create_tool_search_tool( ( "limit".to_string(), JsonSchema::number(Some(format!( - "Maximum number of tools to return (defaults to {default_limit})." + "Maximum number of tools to return. Defaults to {default_limit}." ))), ), ]); @@ -98,7 +98,7 @@ mod tests { ( "limit".to_string(), JsonSchema::number(Some( - "Maximum number of tools to return (defaults to 8)." + "Maximum number of tools to return. Defaults to 8." .to_string(), ),), ), diff --git a/codex-rs/core/src/tools/handlers/view_image_spec.rs b/codex-rs/core/src/tools/handlers/view_image_spec.rs index ed0b1021a5..5d2b7e0b15 100644 --- a/codex-rs/core/src/tools/handlers/view_image_spec.rs +++ b/codex-rs/core/src/tools/handlers/view_image_spec.rs @@ -15,7 +15,7 @@ pub struct ViewImageToolOptions { pub fn create_view_image_tool(options: ViewImageToolOptions) -> ToolSpec { let mut properties = BTreeMap::from([( "path".to_string(), - JsonSchema::string(Some("Local filesystem path to an image file".to_string())), + JsonSchema::string(Some("Local filesystem path to an image file.".to_string())), )]); if options.can_request_original_image_detail { properties.insert( @@ -23,7 +23,7 @@ pub fn create_view_image_tool(options: ViewImageToolOptions) -> ToolSpec { JsonSchema::string_enum( vec![json!("high"), json!("original")], Some( - "Optional detail override. Supported values are `high` and `original`; omit this field for default high resized behavior. Use `original` to preserve the file's original resolution instead of resizing to fit. This is important when high-fidelity image perception or precise localization is needed, especially for CUA agents.".to_string(), + "Image detail level. Defaults to `high`; use `original` to preserve exact resolution.".to_string(), ), ), ); @@ -32,7 +32,7 @@ pub fn create_view_image_tool(options: ViewImageToolOptions) -> ToolSpec { properties.insert( "environment_id".to_string(), JsonSchema::string(Some( - "Optional selected environment id to target. Omit this to use the primary environment." + "Environment id from . Omit to use the primary environment." .to_string(), )), ); diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index c548ded95c..0dc956d951 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -163,7 +163,7 @@ async fn search_tool_enabled_by_default_adds_tool_search() -> Result<()> { "type": "object", "properties": { "query": {"type": "string", "description": "Search query for deferred tools."}, - "limit": {"type": "number", "description": "Maximum number of tools to return (defaults to 8)."}, + "limit": {"type": "number", "description": "Maximum number of tools to return. Defaults to 8."}, }, "required": ["query"], "additionalProperties": false, diff --git a/codex-rs/core/tests/suite/spawn_agent_description.rs b/codex-rs/core/tests/suite/spawn_agent_description.rs index fbeb90fba5..31cb0b7702 100644 --- a/codex-rs/core/tests/suite/spawn_agent_description.rs +++ b/codex-rs/core/tests/suite/spawn_agent_description.rs @@ -183,7 +183,7 @@ async fn spawn_agent_description_lists_visible_models_and_reasoning_efforts() -> ); assert!( description.contains( - "Spawned agents inherit your current model by default. Omit `model` to use that preferred default; set `model` only when an explicit override is needed." + "Spawned agents inherit your current model by default. If provided, `model` specifies the model to use for the spawned agent." ), "expected inherited-model guidance in spawn_agent description: {description:?}" ); From 27e256bc40a7a2f5a54ba04a220d0d5f6cdd4336 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 29 May 2026 15:39:05 +0200 Subject: [PATCH 18/40] Handle goal usage limits from turn errors (#25095) ## Summary - handle goal usage-limit turn errors in the goal extension - exercise the extension path in the goal backend test ## Tests - just fmt - just test -p codex-goal-extension - just fix -p codex-goal-extension --- codex-rs/ext/goal/src/extension.rs | 18 ++++++++++++++++ .../ext/goal/tests/goal_extension_backend.rs | 21 +++++++++++++------ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/codex-rs/ext/goal/src/extension.rs b/codex-rs/ext/goal/src/extension.rs index 5421d17290..e09f041e31 100644 --- a/codex-rs/ext/goal/src/extension.rs +++ b/codex-rs/ext/goal/src/extension.rs @@ -17,11 +17,13 @@ use codex_extension_api::ToolFinishInput; use codex_extension_api::ToolLifecycleContributor; use codex_extension_api::ToolLifecycleFuture; use codex_extension_api::TurnAbortInput; +use codex_extension_api::TurnErrorInput; use codex_extension_api::TurnLifecycleContributor; use codex_extension_api::TurnStartInput; use codex_extension_api::TurnStopInput; use codex_otel::MetricsClient; use codex_protocol::ThreadId; +use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::ThreadGoalStatus; @@ -248,6 +250,22 @@ where } runtime.accounting_state().finish_turn(turn_id); } + + 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; + }; + + if let Err(err) = runtime + .usage_limit_active_goal_for_turn(input.turn_id) + .await + { + tracing::warn!("failed to usage-limit active goal after usage-limit error: {err}"); + } + } } #[async_trait] diff --git a/codex-rs/ext/goal/tests/goal_extension_backend.rs b/codex-rs/ext/goal/tests/goal_extension_backend.rs index 7524d40fe1..92f4313fc7 100644 --- a/codex-rs/ext/goal/tests/goal_extension_backend.rs +++ b/codex-rs/ext/goal/tests/goal_extension_backend.rs @@ -17,6 +17,7 @@ use codex_extension_api::ToolCallSource; use codex_extension_api::ToolExecutor; use codex_extension_api::ToolFinishInput; use codex_extension_api::ToolPayload; +use codex_extension_api::TurnErrorInput; use codex_extension_api::TurnStartInput; use codex_extension_api::TurnStopInput; use codex_goal_extension::GoalRuntimeHandle; @@ -26,6 +27,7 @@ use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; +use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::SessionSource; @@ -398,7 +400,7 @@ async fn budget_limited_goal_keeps_accounting_after_later_tool_finish() -> anyho } #[tokio::test] -async fn usage_limit_active_goal_accounts_progress_and_clears_accounting() -> anyhow::Result<()> { +async fn turn_error_usage_limit_accounts_progress_and_clears_accounting() -> anyhow::Result<()> { let runtime = test_runtime().await?; let thread_id = test_thread_id()?; seed_thread_metadata(runtime.as_ref(), thread_id).await?; @@ -425,11 +427,18 @@ async fn usage_limit_active_goal_accounts_progress_and_clears_accounting() -> an ), ) .await; - harness - .runtime_handle() - .usage_limit_active_goal_for_turn("turn-1") - .await - .map_err(anyhow::Error::msg)?; + 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; + } let goal = runtime .thread_goals() From b40ad0d84dea8a2c2c4b32e73ef73aff85c5af78 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 29 May 2026 17:09:00 +0200 Subject: [PATCH 19/40] Remove stale rollout TODO tests (#25106) ## Summary Remove a stale `TODO(jif)` block of commented-out rollout listing tests that still referenced an older listing API. The current rollout listing behavior is covered by the active state DB and filesystem fallback tests, so keeping the dead commented tests just adds noise. ## Validation - `just fmt` - `just test -p codex-rollout` --- codex-rs/rollout/src/tests.rs | 127 ---------------------------------- 1 file changed, 127 deletions(-) diff --git a/codex-rs/rollout/src/tests.rs b/codex-rs/rollout/src/tests.rs index 4f28f3464e..a8bfc0cd84 100644 --- a/codex-rs/rollout/src/tests.rs +++ b/codex-rs/rollout/src/tests.rs @@ -94,133 +94,6 @@ async fn insert_state_db_thread( runtime } -// TODO(jif) fix -// #[tokio::test] -// async fn list_threads_prefers_state_db_when_available() { -// let temp = TempDir::new().unwrap(); -// let home = temp.path(); -// let fs_uuid = Uuid::from_u128(101); -// write_session_file( -// home, -// "2025-01-03T13-00-00", -// fs_uuid, -// 1, -// Some(SessionSource::Cli), -// ) -// .unwrap(); -// -// let db_uuid = Uuid::from_u128(102); -// let db_thread_id = ThreadId::from_string(&db_uuid.to_string()).expect("valid thread id"); -// let db_rollout_path = home.join(format!( -// "sessions/2025/01/03/rollout-2025-01-03T12-00-00-{db_uuid}.jsonl" -// )); -// insert_state_db_thread(home, db_thread_id, db_rollout_path.as_path(), false).await; -// -// let page = RolloutRecorder::list_threads( -// home, -// 10, -// None, -// ThreadSortKey::CreatedAt, -// NO_SOURCE_FILTER, -// None, -// TEST_PROVIDER, -// ) -// .await -// .expect("thread listing should succeed"); -// -// assert_eq!(page.items.len(), 1); -// assert_eq!(page.items[0].path, db_rollout_path); -// assert_eq!(page.items[0].thread_id, Some(db_thread_id)); -// assert_eq!(page.items[0].cwd, Some(home.to_path_buf())); -// assert_eq!( -// page.items[0].first_user_message.as_deref(), -// Some("Hello from user") -// ); -// } - -// #[tokio::test] -// async fn list_threads_db_excludes_archived_entries() { -// let temp = TempDir::new().unwrap(); -// let home = temp.path(); -// let sessions_root = home.join("sessions/2025/01/03"); -// let archived_root = home.join("archived_sessions"); -// fs::create_dir_all(&sessions_root).unwrap(); -// fs::create_dir_all(&archived_root).unwrap(); -// -// let active_uuid = Uuid::from_u128(211); -// let active_thread_id = -// ThreadId::from_string(&active_uuid.to_string()).expect("valid active thread id"); -// let active_rollout_path = -// sessions_root.join(format!("rollout-2025-01-03T12-00-00-{active_uuid}.jsonl")); -// insert_state_db_thread(home, active_thread_id, active_rollout_path.as_path(), false).await; -// -// let archived_uuid = Uuid::from_u128(212); -// let archived_thread_id = -// ThreadId::from_string(&archived_uuid.to_string()).expect("valid archived thread id"); -// let archived_rollout_path = -// archived_root.join(format!("rollout-2025-01-03T11-00-00-{archived_uuid}.jsonl")); -// insert_state_db_thread( -// home, -// archived_thread_id, -// archived_rollout_path.as_path(), -// true, -// ) -// .await; -// -// let page = RolloutRecorder::list_threads( -// home, -// 10, -// None, -// ThreadSortKey::CreatedAt, -// NO_SOURCE_FILTER, -// None, -// TEST_PROVIDER, -// ) -// .await -// .expect("thread listing should succeed"); -// -// assert_eq!(page.items.len(), 1); -// assert_eq!(page.items[0].path, active_rollout_path); -// } - -// #[tokio::test] -// async fn list_threads_falls_back_to_files_when_state_db_is_unavailable() { -// let temp = TempDir::new().unwrap(); -// let home = temp.path(); -// let fs_uuid = Uuid::from_u128(301); -// write_session_file( -// home, -// "2025-01-03T13-00-00", -// fs_uuid, -// 1, -// Some(SessionSource::Cli), -// ) -// .unwrap(); -// -// let page = RolloutRecorder::list_threads( -// home, -// 10, -// None, -// ThreadSortKey::CreatedAt, -// NO_SOURCE_FILTER, -// None, -// TEST_PROVIDER, -// ) -// .await -// .expect("thread listing should succeed"); -// -// assert_eq!(page.items.len(), 1); -// let file_name = page.items[0] -// .path -// .file_name() -// .and_then(|value| value.to_str()) -// .expect("rollout file name should be utf8"); -// assert!( -// file_name.contains(&fs_uuid.to_string()), -// "expected file path from filesystem listing, got: {file_name}" -// ); -// } - #[tokio::test] async fn find_thread_path_falls_back_when_db_path_is_stale() { let temp = TempDir::new().unwrap(); From 251b2412b2350ba850c92f395a54ba9b666f0c54 Mon Sep 17 00:00:00 2001 From: Abhinav Date: Fri, 29 May 2026 08:12:40 -0700 Subject: [PATCH 20/40] Render multiline hook output in TUI (#24965) # Why Fixes #24529. Completed hook output in the TUI rendered each `HookOutputEntry` as one ratatui line, so explicit newlines inside hook output were not shown as separate transcript rows. That made multiline `SessionStart.additionalContext` hard to inspect even though the model-facing context path preserved the original text. # What - Split completed hook output entries on explicit newlines before rendering them in `codex-rs/tui/src/history_cell/hook_cell.rs`. - Keep the hook output prefix, such as `hook context:` or `warning:`, on the first physical line only. - Preserve explicit blank lines and render continuation lines with the hook body indent. - Add unit coverage for multiline context and warning output, plus a chatwidget snapshot regression for `SessionStart` history output. # Testing - `cargo nextest run -p codex-tui completed_hook_multiline hook_completed_before_reveal_renders_completed_without_running_flash` - `just argument-comment-lint -p codex-tui -- --ignore-rust-version --lib --tests` --- ...pleted_without_running_flash_snapshot.snap | 1 + .../src/chatwidget/tests/status_and_layout.rs | 2 +- codex-rs/tui/src/history_cell/hook_cell.rs | 96 +++++++++++++++++-- 3 files changed, 88 insertions(+), 11 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hook_completed_before_reveal_renders_completed_without_running_flash_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hook_completed_before_reveal_renders_completed_without_running_flash_snapshot.snap index b38d4e260a..0dd1aa064e 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hook_completed_before_reveal_renders_completed_without_running_flash_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hook_completed_before_reveal_renders_completed_without_running_flash_snapshot.snap @@ -7,3 +7,4 @@ started hidden: history: • SessionStart hook (completed) hook context: session context + second line diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index acb1df6c5a..45f9bbca53 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -3381,7 +3381,7 @@ async fn hook_completed_before_reveal_renders_completed_without_running_flash() codex_app_server_protocol::HookRunStatus::Completed, vec![codex_app_server_protocol::HookOutputEntry { kind: codex_app_server_protocol::HookOutputEntryKind::Context, - text: "session context".to_string(), + text: "session context\nsecond line".to_string(), }], ), ); diff --git a/codex-rs/tui/src/history_cell/hook_cell.rs b/codex-rs/tui/src/history_cell/hook_cell.rs index 6a4e8498d9..c3214aeb16 100644 --- a/codex-rs/tui/src/history_cell/hook_cell.rs +++ b/codex-rs/tui/src/history_cell/hook_cell.rs @@ -48,6 +48,9 @@ const HOOK_RUN_REVEAL_DELAY: Duration = Duration::from_millis(300); /// enough to read instead of removing it immediately when the success event arrives. const QUIET_HOOK_MIN_VISIBLE: Duration = Duration::from_millis(600); +const HOOK_OUTPUT_INDENT: &str = " "; +const HOOK_OUTPUT_BODY_INDENT: &str = " "; + #[derive(Debug)] struct HookRunCell { /// Stable protocol id used to match begin/end updates for the same hook invocation. @@ -443,10 +446,18 @@ impl HookRunCell { .into(), ); for entry in entries { - // Output entries are already short hook-authored strings; keep their prefixes - // explicit so warnings/stops/errors remain easy to scan in history. - lines - .push(format!(" {}{}", hook_output_prefix(entry.kind), entry.text).into()); + let prefix = hook_output_prefix(entry.kind); + let mut output_lines = entry.text.split('\n'); + if let Some(first_line) = output_lines.next() { + lines.push(format!("{HOOK_OUTPUT_INDENT}{prefix}{first_line}").into()); + } + for line in output_lines { + if line.is_empty() { + lines.push("".into()); + } else { + lines.push(format!("{HOOK_OUTPUT_BODY_INDENT}{line}").into()); + } + } } } HookRunState::PendingReveal { .. } => {} @@ -748,6 +759,50 @@ mod tests { assert!(bullet.style.add_modifier.contains(Modifier::BOLD)); } + #[test] + fn completed_hook_multiline_context_preserves_display_and_raw_lines() { + let cell = completed_hook_cell( + HookEventName::SessionStart, + HookRunStatus::Completed, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Context, + text: "## Working Memory Recall\n\nSource: Codex compaction\nScope: Durable workspace memory" + .to_string(), + }], + ); + let expected = vec![ + "• SessionStart hook (completed)".to_string(), + " hook context: ## Working Memory Recall".to_string(), + "".to_string(), + " Source: Codex compaction".to_string(), + " Scope: Durable workspace memory".to_string(), + ]; + + assert_eq!(line_texts(&cell.display_lines(/*width*/ 80)), expected); + assert_eq!(line_texts(&cell.raw_lines()), expected); + } + + #[test] + fn completed_hook_multiline_warning_prefixes_first_line_only() { + let cell = completed_hook_cell( + HookEventName::PostToolUse, + HookRunStatus::Completed, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Warning, + text: "Heads up\nReview generated files".to_string(), + }], + ); + + assert_eq!( + line_texts(&cell.display_lines(/*width*/ 80)), + vec![ + "• PostToolUse hook (completed)".to_string(), + " warning: Heads up".to_string(), + " Review generated files".to_string(), + ] + ); + } + #[test] fn pending_hook_does_not_animate_transcript() { let cell = @@ -790,12 +845,7 @@ mod tests { let rendered: Vec = cell .display_lines(/*width*/ 80) .iter() - .map(|line| { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect::() - }) + .map(line_text) .collect(); assert_eq!( @@ -804,6 +854,32 @@ mod tests { ); } + fn completed_hook_cell( + event_name: HookEventName, + status: HookRunStatus, + entries: Vec, + ) -> HookCell { + let mut run = hook_run_summary("hook-1"); + run.event_name = event_name; + run.status = status; + run.status_message = None; + run.completed_at = Some(2); + run.duration_ms = Some(1); + run.entries = entries; + HookCell::new_completed(run, /*animations_enabled*/ false) + } + + fn line_texts(lines: &[Line<'_>]) -> Vec { + lines.iter().map(line_text).collect() + } + + fn line_text(line: &Line<'_>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + } + fn hook_run_summary(id: &str) -> HookRunSummary { HookRunSummary { id: id.to_string(), From 5577a9e1482ed867318dad51a01e5d18a5c5e5fa Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Fri, 29 May 2026 09:05:05 -0700 Subject: [PATCH 21/40] [codex] Add model tool mode selector (#25031) ## Why Some models need to select their code-execution behavior through model catalog metadata. Models without that metadata must continue to follow the existing `CodeMode` and `CodeModeOnly` feature flags, including when a newer server sends an enum value this client does not recognize. ## What changed - add optional `ModelInfo.tool_mode` metadata with `direct`, `code_mode`, and `code_mode_only` - treat omitted and unknown wire values as `None` - resolve `None` from the existing feature flags - carry the resolved `ToolMode` directly on `TurnContext`, outside `Config` - use the resolved value for turn creation, model switches, review turns, tool planning, and code execution ## Coverage - add protocol coverage for omitted, known, and unknown enum values - add focused coverage for flag fallback and explicit metadata overriding feature flags - add core integration coverage that fetches remote model metadata through `/v1/models` and verifies the outbound `/responses` tools for explicit `direct` and `code_mode_only` selectors ## Stack - followed by #25032 --- .../app-server/tests/common/models_cache.rs | 1 + .../codex-api/tests/models_integration.rs | 1 + codex-rs/core/src/session/review.rs | 11 ++ codex-rs/core/src/session/turn_context.rs | 22 +++ codex-rs/core/src/tools/code_mode/mod.rs | 4 +- codex-rs/core/src/tools/spec_plan.rs | 29 +-- codex-rs/core/src/tools/spec_plan_tests.rs | 24 +++ codex-rs/core/tests/suite/mod.rs | 1 + .../tests/suite/model_runtime_selectors.rs | 173 ++++++++++++++++++ codex-rs/core/tests/suite/model_switching.rs | 2 + codex-rs/core/tests/suite/models_cache_ttl.rs | 1 + codex-rs/core/tests/suite/personality.rs | 2 + codex-rs/core/tests/suite/remote_models.rs | 3 + codex-rs/core/tests/suite/rmcp_client.rs | 1 + .../tests/suite/spawn_agent_description.rs | 1 + codex-rs/core/tests/suite/view_image.rs | 1 + codex-rs/models-manager/src/model_info.rs | 1 + codex-rs/protocol/src/openai_models.rs | 66 +++++++ codex-rs/tools/src/tool_config_tests.rs | 1 + 19 files changed, 329 insertions(+), 16 deletions(-) create mode 100644 codex-rs/core/tests/suite/model_runtime_selectors.rs diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index 0a41daa7c6..aaf9f69477 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, + tool_mode: None, } } diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index b1f6f6d4bb..38d8dc9862 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, + tool_mode: None, }], }; diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index 75b7539090..bbb51a1821 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -1,4 +1,5 @@ use super::*; +use codex_protocol::openai_models::ToolMode; use std::sync::atomic::AtomicBool; /// Spawn a review thread using the given prompt. @@ -47,6 +48,15 @@ pub(super) async fn spawn_review_thread( let mut per_turn_config = (*config).clone(); per_turn_config.model = Some(model.clone()); per_turn_config.features = review_features.clone(); + let tool_mode = model_info.tool_mode.unwrap_or_else(|| { + if per_turn_config.features.enabled(Feature::CodeModeOnly) { + ToolMode::CodeModeOnly + } else if per_turn_config.features.enabled(Feature::CodeMode) { + ToolMode::CodeMode + } else { + ToolMode::Direct + } + }); if let Err(err) = per_turn_config.web_search_mode.set(review_web_search_mode) { let fallback_value = per_turn_config.web_search_mode.value(); tracing::warn!( @@ -96,6 +106,7 @@ pub(super) async fn spawn_review_thread( config: per_turn_config, auth_manager: auth_manager_for_context, model_info: model_info.clone(), + tool_mode, session_telemetry: session_telemetry_for_context, provider: provider_for_context, reasoning_effort, diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index dba5d16a75..638bb91198 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -6,6 +6,7 @@ use codex_model_provider::SharedModelProvider; use codex_model_provider::create_model_provider; use codex_protocol::SessionId; use codex_protocol::models::AdditionalPermissionProfile; +use codex_protocol::openai_models::ToolMode; use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_sandboxing::compatibility_sandbox_policy_for_permission_profile; @@ -55,6 +56,7 @@ pub struct TurnContext { pub config: Arc, pub(crate) auth_manager: Option>, pub(crate) model_info: ModelInfo, + pub(crate) tool_mode: ToolMode, pub(crate) session_telemetry: SessionTelemetry, pub(crate) provider: SharedModelProvider, pub(crate) reasoning_effort: Option, @@ -172,6 +174,15 @@ impl TurnContext { let model_info = models_manager .get_model_info(model.as_str(), &config.to_models_manager_config()) .await; + let tool_mode = model_info.tool_mode.unwrap_or_else(|| { + if config.features.enabled(Feature::CodeModeOnly) { + ToolMode::CodeModeOnly + } else if config.features.enabled(Feature::CodeMode) { + ToolMode::CodeMode + } else { + ToolMode::Direct + } + }); let truncation_policy = model_info.truncation_policy.into(); let supported_reasoning_levels = model_info .supported_reasoning_levels @@ -212,6 +223,7 @@ impl TurnContext { config: Arc::new(config), auth_manager: self.auth_manager.clone(), model_info: model_info.clone(), + tool_mode, session_telemetry: self .session_telemetry .clone() @@ -475,6 +487,15 @@ impl Session { ); let mut per_turn_config = per_turn_config; + let tool_mode = model_info.tool_mode.unwrap_or_else(|| { + if per_turn_config.features.enabled(Feature::CodeModeOnly) { + ToolMode::CodeModeOnly + } else if per_turn_config.features.enabled(Feature::CodeMode) { + ToolMode::CodeMode + } else { + ToolMode::Direct + } + }); per_turn_config.service_tier = get_service_tier( per_turn_config.service_tier, per_turn_config.features.enabled(Feature::FastMode), @@ -501,6 +522,7 @@ impl Session { config: per_turn_config.clone(), auth_manager: auth_manager_for_context, model_info: model_info.clone(), + tool_mode, session_telemetry: session_telemetry_for_context, provider: provider_for_context, reasoning_effort, diff --git a/codex-rs/core/src/tools/code_mode/mod.rs b/codex-rs/core/src/tools/code_mode/mod.rs index abf4b9bbab..ff005646c2 100644 --- a/codex-rs/core/src/tools/code_mode/mod.rs +++ b/codex-rs/core/src/tools/code_mode/mod.rs @@ -30,7 +30,7 @@ use crate::tools::parallel::ToolCallRuntime; use crate::tools::router::ToolCall; use crate::tools::router::ToolCallSource; use crate::unified_exec::resolve_max_tokens; -use codex_features::Feature; +use codex_protocol::openai_models::ToolMode; use codex_tools::ToolName; use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::formatted_truncate_text_content_items_with_policy; @@ -91,7 +91,7 @@ impl CodeModeService { router: Arc, tracker: SharedTurnDiffTracker, ) -> Option { - if !turn.features.enabled(Feature::CodeMode) { + if !matches!(turn.tool_mode, ToolMode::CodeMode | ToolMode::CodeModeOnly) { return None; } diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index bb7baf4bd8..9aaccbbd0f 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -60,6 +60,7 @@ use codex_mcp::ToolInfo; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::InputModality; +use codex_protocol::openai_models::ToolMode; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_tools::DiscoverableTool; @@ -230,8 +231,10 @@ fn spec_for_model_request( exposure: ToolExposure, spec: ToolSpec, ) -> ToolSpec { - if code_mode_enabled(turn_context) - && exposure != ToolExposure::DirectModelOnly + if matches!( + turn_context.tool_mode, + ToolMode::CodeMode | ToolMode::CodeModeOnly + ) && exposure != ToolExposure::DirectModelOnly && codex_code_mode::is_code_mode_nested_tool(spec.name()) { codex_tools::augment_tool_spec_for_code_mode(spec) @@ -282,14 +285,6 @@ fn namespace_tools_enabled(turn_context: &TurnContext) -> bool { turn_context.provider.capabilities().namespace_tools } -fn code_mode_enabled(turn_context: &TurnContext) -> bool { - turn_context.features.get().enabled(Feature::CodeMode) -} - -fn code_mode_only_enabled(turn_context: &TurnContext) -> bool { - code_mode_enabled(turn_context) && turn_context.features.get().enabled(Feature::CodeModeOnly) -} - fn multi_agent_v2_enabled(turn_context: &TurnContext) -> bool { turn_context.features.get().enabled(Feature::MultiAgentV2) } @@ -398,7 +393,7 @@ fn is_hidden_by_code_mode_only( tool_name: &ToolName, exposure: ToolExposure, ) -> bool { - code_mode_only_enabled(turn_context) + turn_context.tool_mode == ToolMode::CodeModeOnly && exposure != ToolExposure::DirectModelOnly && codex_code_mode::is_code_mode_nested_tool(&codex_tools::code_mode_name_for_tool_name( tool_name, @@ -410,7 +405,10 @@ fn build_code_mode_executors( executors: &[Arc], deferred_tools_available: bool, ) -> Vec> { - if !code_mode_enabled(turn_context) { + if !matches!( + turn_context.tool_mode, + ToolMode::CodeMode | ToolMode::CodeModeOnly + ) { return vec![]; } @@ -444,7 +442,7 @@ fn build_code_mode_executors( create_code_mode_tool( &enabled_tools, &namespace_descriptions, - code_mode_only_enabled(turn_context), + turn_context.tool_mode == ToolMode::CodeModeOnly, deferred_tools_available, ), code_mode_nested_tool_specs, @@ -847,7 +845,10 @@ fn append_extension_tool_executors( .iter() .map(|executor| executor.tool_name()) .collect::>(); - if code_mode_enabled(turn_context) { + if matches!( + turn_context.tool_mode, + ToolMode::CodeMode | ToolMode::CodeModeOnly + ) { reserved_tool_names.insert(ToolName::plain(codex_code_mode::PUBLIC_TOOL_NAME)); reserved_tool_names.insert(ToolName::plain(codex_code_mode::WAIT_TOOL_NAME)); } diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index a274f934c4..e059b5f4af 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -13,6 +13,7 @@ use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::openai_models::ApplyPatchToolType; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::InputModality; +use codex_protocol::openai_models::ToolMode; use codex_protocol::openai_models::WebSearchToolType; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; @@ -215,6 +216,15 @@ fn set_feature(turn: &mut TurnContext, feature: Feature, enabled: bool) { .expect("test feature should be disableable in config"); } turn.config = Arc::new(config); + turn.tool_mode = turn.model_info.tool_mode.unwrap_or_else(|| { + if turn.config.features.enabled(Feature::CodeModeOnly) { + ToolMode::CodeModeOnly + } else if turn.config.features.enabled(Feature::CodeMode) { + ToolMode::CodeMode + } else { + ToolMode::Direct + } + }); } fn set_features(turn: &mut TurnContext, features: &[Feature]) { @@ -797,6 +807,20 @@ async fn multi_agent_feature_selects_one_agent_tool_family() { ); } +#[tokio::test] +async fn tool_mode_selector_overrides_feature_flags() { + let direct = probe(|turn| { + set_features(turn, &[Feature::CodeMode, Feature::CodeModeOnly]); + turn.model_info.tool_mode = Some(ToolMode::Direct); + turn.tool_mode = ToolMode::Direct; + }) + .await; + direct.assert_visible_lacks(&[ + codex_code_mode::PUBLIC_TOOL_NAME, + codex_code_mode::WAIT_TOOL_NAME, + ]); +} + #[tokio::test] async fn v1_multi_agent_tools_defer_when_tool_search_available() { let plan = probe(|turn| { diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index f4ab6c1d6d..51a5bea57f 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -63,6 +63,7 @@ mod json_result; mod live_cli; mod mcp_turn_metadata; mod model_overrides; +mod model_runtime_selectors; mod model_switching; mod model_visible_layout; mod models_cache_ttl; diff --git a/codex-rs/core/tests/suite/model_runtime_selectors.rs b/codex-rs/core/tests/suite/model_runtime_selectors.rs new file mode 100644 index 0000000000..b6f2ea3c7d --- /dev/null +++ b/codex-rs/core/tests/suite/model_runtime_selectors.rs @@ -0,0 +1,173 @@ +use anyhow::Result; +use codex_core::config::Config; +use codex_features::Feature; +use codex_login::CodexAuth; +use codex_models_manager::manager::RefreshStrategy; +use codex_models_manager::manager::SharedModelsManager; +use codex_models_manager::model_info::model_info_from_slug; +use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ModelVisibility; +use codex_protocol::openai_models::ModelsResponse; +use codex_protocol::openai_models::ToolMode; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; +use codex_protocol::user_input::UserInput; +use core_test_support::responses; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_models_once; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::sse; +use core_test_support::skip_if_no_network; +use core_test_support::submit_thread_settings; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; +use serde_json::Value; +use tokio::time::Duration; +use tokio::time::Instant; +use tokio::time::sleep; + +fn remote_model(slug: &str) -> ModelInfo { + ModelInfo { + visibility: ModelVisibility::List, + used_fallback_model_metadata: false, + ..model_info_from_slug(slug) + } +} + +fn tool_names(body: &Value) -> Vec { + body.get("tools") + .and_then(Value::as_array) + .map(|tools| { + tools + .iter() + .filter_map(|tool| { + tool.get("name") + .or_else(|| tool.get("type")) + .and_then(Value::as_str) + .map(str::to_string) + }) + .collect() + }) + .unwrap_or_default() +} + +async fn wait_for_model_available(manager: &SharedModelsManager, slug: &str) -> ModelPreset { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + if let Some(model) = manager + .list_models(RefreshStrategy::Online) + .await + .iter() + .find(|model| model.model == slug) + .cloned() + { + return model; + } + if Instant::now() >= deadline { + panic!("timed out waiting for the remote model {slug} to appear"); + } + sleep(Duration::from_millis(25)).await; + } +} + +async fn response_body_for_remote_model( + remote_model: ModelInfo, + configure: impl FnOnce(&mut Config) + Send + 'static, +) -> Result { + let server = responses::start_mock_server().await; + let model_slug = remote_model.slug.clone(); + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![remote_model], + }, + ) + .await; + let response_mock = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ]), + ) + .await; + + let mut builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(configure); + let test = builder.build(&server).await?; + let models_manager = test.thread_manager.get_models_manager(); + let available_model = wait_for_model_available(&models_manager, &model_slug).await; + assert_eq!(available_model.model, model_slug); + assert_eq!(models_mock.requests().len(), 1); + + submit_thread_settings( + &test.codex, + ThreadSettingsOverrides { + model: Some(model_slug), + ..Default::default() + }, + ) + .await?; + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "list tools".into(), + text_elements: Vec::new(), + }], + environments: None, + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + Ok(response_mock.single_request().body_json()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_tool_mode_selector_overrides_feature_flags() -> Result<()> { + skip_if_no_network!(Ok(())); + + let mut direct_model = remote_model("test-tool-mode-direct"); + direct_model.tool_mode = Some(ToolMode::Direct); + let direct_body = response_body_for_remote_model(direct_model, |config| { + config + .features + .enable(Feature::CodeModeOnly) + .expect("test config should allow feature update"); + }) + .await?; + let direct_tools = tool_names(&direct_body); + assert!( + direct_tools + .iter() + .all(|name| name != codex_code_mode::PUBLIC_TOOL_NAME + && name != codex_code_mode::WAIT_TOOL_NAME), + "direct mode should override enabled code mode flags: {direct_tools:?}" + ); + + let mut code_mode_only_model = remote_model("test-tool-mode-code-mode-only"); + code_mode_only_model.tool_mode = Some(ToolMode::CodeModeOnly); + let code_mode_only_body = response_body_for_remote_model(code_mode_only_model, |_| {}).await?; + assert_eq!( + tool_names(&code_mode_only_body), + vec![ + codex_code_mode::PUBLIC_TOOL_NAME.to_string(), + codex_code_mode::WAIT_TOOL_NAME.to_string(), + ] + ); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 3c4dd40b32..90bb741f05 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -112,6 +112,7 @@ fn test_model_info( input_modalities, used_fallback_model_metadata: false, supports_search_tool: false, + tool_mode: None, priority: 1, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), @@ -929,6 +930,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, + tool_mode: None, priority: 1, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index fcd90cbaea..76ebb4529c 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -370,5 +370,6 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + tool_mode: None, } } diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 06781dc11d..4cb8b63a09 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -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, + tool_mode: None, }; let _models_mock = mount_models_once( @@ -702,6 +703,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, + tool_mode: None, }; let _models_mock = mount_models_once( diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index b7b16f9c29..82db097f39 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -477,6 +477,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, + tool_mode: None, priority: 1, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), @@ -726,6 +727,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + tool_mode: None, priority: 1, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), @@ -1209,6 +1211,7 @@ fn test_remote_model_with_policy( input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + tool_mode: None, priority, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 2c86cb393c..8aa01a4a11 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -1377,6 +1377,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, + tool_mode: None, }], }, ) diff --git a/codex-rs/core/tests/suite/spawn_agent_description.rs b/codex-rs/core/tests/suite/spawn_agent_description.rs index 31cb0b7702..f9be02349c 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, + tool_mode: None, priority: 1, additional_speed_tiers: Vec::new(), service_tiers, diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index e6320ba1a7..6062228de0 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -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, + tool_mode: None, priority: 1, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), diff --git a/codex-rs/models-manager/src/model_info.rs b/codex-rs/models-manager/src/model_info.rs index 17b843440a..e57b4e186e 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, + tool_mode: None, } } diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index d0a58b5024..b9fa9c933d 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -8,7 +8,9 @@ use std::str::FromStr; use schemars::JsonSchema; use serde::Deserialize; +use serde::Deserializer; use serde::Serialize; +use serde::de::DeserializeOwned; use strum::IntoEnumIterator; use strum_macros::Display; use strum_macros::EnumIter; @@ -227,6 +229,25 @@ pub enum TruncationMode { Tokens, } +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, TS, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ToolMode { + Direct, + CodeMode, + CodeModeOnly, +} + +fn deserialize_optional_model_selector<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: DeserializeOwned, +{ + let Some(value) = Option::::deserialize(deserializer)? else { + return Ok(None); + }; + Ok(serde_json::from_value(serde_json::Value::String(value)).ok()) +} + #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, TS, JsonSchema)] pub struct TruncationPolicyConfig { pub mode: TruncationMode, @@ -318,6 +339,12 @@ pub struct ModelInfo { pub used_fallback_model_metadata: bool, #[serde(default)] pub supports_search_tool: bool, + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_optional_model_selector" + )] + pub tool_mode: Option, } impl ModelInfo { @@ -612,6 +639,7 @@ mod tests { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + tool_mode: None, } } @@ -829,6 +857,44 @@ mod tests { assert!(!model.supports_image_detail_original); assert_eq!(model.web_search_tool_type, WebSearchToolType::Text); assert!(!model.supports_search_tool); + assert_eq!(model.tool_mode, None); + } + + #[test] + fn model_info_deserializes_known_tool_mode() { + let mut value = + serde_json::to_value(test_model(/*spec*/ None)).expect("serialize test model"); + let object = value + .as_object_mut() + .expect("model info should be an object"); + object.insert( + "tool_mode".to_string(), + serde_json::Value::String("code_mode_only".to_string()), + ); + let model = serde_json::from_value::(value).expect("deserialize model info"); + + assert_eq!(model.tool_mode, Some(ToolMode::CodeModeOnly)); + } + + #[test] + fn model_info_treats_unknown_tool_mode_as_omitted() { + let mut value = + serde_json::to_value(test_model(/*spec*/ None)).expect("serialize test model"); + let object = value + .as_object_mut() + .expect("model info should be an object"); + object.insert( + "tool_mode".to_string(), + serde_json::Value::String("future_tool_mode".to_string()), + ); + let model = serde_json::from_value::(value).expect("deserialize model info"); + + assert_eq!(model.tool_mode, None); + let serialized = serde_json::to_value(model).expect("serialize model info"); + let object = serialized + .as_object() + .expect("model info should be an object"); + assert!(!object.contains_key("tool_mode")); } #[test] diff --git a/codex-rs/tools/src/tool_config_tests.rs b/codex-rs/tools/src/tool_config_tests.rs index 033bf07b10..2e49c15584 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, + tool_mode: None, } } From 96f1347fa3504dca512ef11aa97027a5dfcbfc56 Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Fri, 29 May 2026 09:12:58 -0700 Subject: [PATCH 22/40] Show activity for standalone web search calls (#24693) ## Why Standalone `web.run` calls run in the extension, so they need normal web-search progress activity while a request is in flight and durable completed activity after a thread is reloaded. Follow-up to #23823; uses the extension turn-item emission path added in #24813. ## What changed - Emit standalone `web.run` start/completion items through the host turn-item emitter, preserving standard client delivery and rollout persistence. - Include useful completion detail for queries, image queries, and literal-URL `open`/`find` commands. - Render completed searches as `Searched the web` or `Searched the web for `, with snapshot coverage for the detail-free case. - Extend the app-server round-trip test to verify completed search activity is reconstructed by `thread/read` after a fresh-process reload. ## Testing - `just test -p codex-web-search-extension` - `just test -p codex-app-server -E "test(standalone_web_search_round_trips_encrypted_output)"` --- codex-rs/Cargo.lock | 1 + .../app-server/tests/suite/v2/web_search.rs | 89 ++++++++++++- codex-rs/ext/web-search/Cargo.toml | 1 + codex-rs/ext/web-search/src/extension.rs | 7 +- codex-rs/ext/web-search/src/tool.rs | 124 +++++++++++++++++- codex-rs/tui/src/history_cell/search.rs | 8 +- ...sts__web_search_history_cell_snapshot.snap | 4 +- ...arch_history_cell_transcript_snapshot.snap | 4 +- ..._history_cell_without_detail_snapshot.snap | 5 + codex-rs/tui/src/history_cell/tests.rs | 17 ++- 10 files changed, 246 insertions(+), 14 deletions(-) create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__web_search_history_cell_without_detail_snapshot.snap diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index e873e689f4..ab279abe8d 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4174,6 +4174,7 @@ dependencies = [ "pretty_assertions", "schemars 0.8.22", "serde_json", + "url", ] [[package]] diff --git a/codex-rs/app-server/tests/suite/v2/web_search.rs b/codex-rs/app-server/tests/suite/v2/web_search.rs index 5cc02c7f6f..ef8d562703 100644 --- a/codex-rs/app-server/tests/suite/v2/web_search.rs +++ b/codex-rs/app-server/tests/suite/v2/web_search.rs @@ -7,13 +7,19 @@ use app_test_support::ChatGptAuthFixture; use app_test_support::McpProcess; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadReadParams; +use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput as V2UserInput; +use codex_app_server_protocol::WebSearchAction; use codex_config::types::AuthCredentialsStoreMode; use core_test_support::responses; use pretty_assertions::assert_eq; @@ -84,10 +90,11 @@ async fn standalone_web_search_round_trips_encrypted_output() -> Result<()> { ) .await??; let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + let thread_id = thread.id.clone(); let turn_req = mcp .send_turn_start_request(TurnStartParams { - thread_id: thread.id, + thread_id: thread_id.clone(), client_user_message_id: None, input: vec![V2UserInput::Text { text: "Search the web".to_string(), @@ -103,6 +110,13 @@ async fn standalone_web_search_round_trips_encrypted_output() -> Result<()> { .await??; let _turn: TurnStartResponse = to_response::(turn_resp)?; + let started = timeout(DEFAULT_READ_TIMEOUT, wait_for_web_search_started(&mut mcp)).await??; + let completed = timeout( + DEFAULT_READ_TIMEOUT, + wait_for_web_search_completed(&mut mcp), + ) + .await??; + timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_notification_message("turn/completed"), @@ -159,10 +173,83 @@ async fn standalone_web_search_round_trips_encrypted_output() -> Result<()> { }], }) ); + assert_eq!( + started.item, + ThreadItem::WebSearch { + id: call_id.to_string(), + query: String::new(), + action: Some(WebSearchAction::Other), + } + ); + let expected_completed_item = ThreadItem::WebSearch { + id: call_id.to_string(), + query: "standalone web search".to_string(), + action: Some(WebSearchAction::Search { + query: Some("standalone web search".to_string()), + queries: None, + }), + }; + assert_eq!(completed.item, expected_completed_item); + + drop(mcp); + let mut reloaded_mcp = + McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, reloaded_mcp.initialize()).await??; + let read_req = reloaded_mcp + .send_thread_read_request(ThreadReadParams { + thread_id, + include_turns: true, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + reloaded_mcp.read_stream_until_response_message(RequestId::Integer(read_req)), + ) + .await??; + let ThreadReadResponse { thread, .. } = to_response::(read_resp)?; + let persisted_web_searches: Vec<&ThreadItem> = thread + .turns + .iter() + .flat_map(|turn| &turn.items) + .filter(|item| matches!(item, ThreadItem::WebSearch { .. })) + .collect(); + assert_eq!(persisted_web_searches, vec![&expected_completed_item]); Ok(()) } +async fn wait_for_web_search_started(mcp: &mut McpProcess) -> Result { + loop { + let notification = mcp + .read_stream_until_notification_message("item/started") + .await?; + let started: ItemStartedNotification = serde_json::from_value( + notification + .params + .context("item/started notification should include params")?, + )?; + if matches!(&started.item, ThreadItem::WebSearch { .. }) { + return Ok(started); + } + } +} + +async fn wait_for_web_search_completed(mcp: &mut McpProcess) -> Result { + loop { + let notification = mcp + .read_stream_until_notification_message("item/completed") + .await?; + let completed: ItemCompletedNotification = serde_json::from_value( + notification + .params + .context("item/completed notification should include params")?, + )?; + if matches!(&completed.item, ThreadItem::WebSearch { .. }) { + return Ok(completed); + } + } +} + async fn mount_search_response(server: &MockServer) { Mock::given(method("POST")) .and(path("/api/codex/alpha/search")) diff --git a/codex-rs/ext/web-search/Cargo.toml b/codex-rs/ext/web-search/Cargo.toml index 978aa4c45d..0faabf21a8 100644 --- a/codex-rs/ext/web-search/Cargo.toml +++ b/codex-rs/ext/web-search/Cargo.toml @@ -26,6 +26,7 @@ codex-tools = { workspace = true } http = { workspace = true } schemars = { workspace = true } serde_json = { workspace = true } +url = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } diff --git a/codex-rs/ext/web-search/src/extension.rs b/codex-rs/ext/web-search/src/extension.rs index b7b6c83951..2a7f3b882f 100644 --- a/codex-rs/ext/web-search/src/extension.rs +++ b/codex-rs/ext/web-search/src/extension.rs @@ -146,6 +146,8 @@ mod tests { use super::Config; use super::WebSearchExtensionConfig; use super::install; + use crate::tool::RUN_TOOL_NAME; + use crate::tool::WEB_NAMESPACE; #[test] fn installed_extension_contributes_web_run_when_enabled() { @@ -170,6 +172,9 @@ mod tests { .map(|tool| tool.tool_name()) .collect::>(); - assert_eq!(tool_names, vec![ToolName::namespaced("web", "run")]); + assert_eq!( + tool_names, + vec![ToolName::namespaced(WEB_NAMESPACE, RUN_TOOL_NAME)] + ); } } diff --git a/codex-rs/ext/web-search/src/tool.rs b/codex-rs/ext/web-search/src/tool.rs index 875476db9a..2cf25b007c 100644 --- a/codex-rs/ext/web-search/src/tool.rs +++ b/codex-rs/ext/web-search/src/tool.rs @@ -1,8 +1,11 @@ use codex_api::ReqwestTransport; use codex_api::SearchClient; use codex_api::SearchCommands; +use codex_api::SearchQuery; use codex_api::SearchRequest; use codex_api::SearchSettings; +use codex_core::web_search_action_detail; +use codex_extension_api::ExtensionTurnItem; use codex_extension_api::FunctionCallError; use codex_extension_api::ResponsesApiTool; use codex_extension_api::ToolCall; @@ -13,18 +16,21 @@ use codex_extension_api::ToolSpec; use codex_extension_api::parse_tool_input_schema_without_compaction; use codex_login::default_client::build_reqwest_client; use codex_model_provider::SharedModelProvider; +use codex_protocol::items::WebSearchItem; +use codex_protocol::models::WebSearchAction; use codex_tools::ResponsesApiNamespace; use codex_tools::ResponsesApiNamespaceTool; use codex_tools::ToolExposure; use codex_tools::default_namespace_description; use http::HeaderMap; +use url::Url; use crate::history::recent_input; use crate::output::EncryptedSearchOutput; use crate::schema::commands_schema; -const WEB_NAMESPACE: &str = "web"; -const RUN_TOOL_NAME: &str = "run"; +pub(crate) const WEB_NAMESPACE: &str = "web"; +pub(crate) const RUN_TOOL_NAME: &str = "run"; const WEB_RUN_DESCRIPTION: &str = include_str!("../web_run_description.md"); pub(crate) struct WebSearchTool { @@ -66,6 +72,7 @@ impl ToolExecutor for WebSearchTool { async fn handle(&self, call: ToolCall) -> Result, FunctionCallError> { let commands = parse_commands(&call)?; + let command_action = command_action(&commands); let provider = self .provider .api_provider() @@ -92,10 +99,16 @@ impl ToolExecutor for WebSearchTool { u64::try_from(call.truncation_policy.token_budget()).unwrap_or(u64::MAX), ), }; + call.turn_item_emitter + .emit_started(web_search_item(&call.call_id, WebSearchAction::Other)) + .await; let response = client .search(&request, HeaderMap::new()) .await .map_err(|err| FunctionCallError::Fatal(err.to_string()))?; + call.turn_item_emitter + .emit_completed(web_search_item(&call.call_id, command_action)) + .await; Ok(Box::new(EncryptedSearchOutput::new( response.encrypted_output, @@ -112,3 +125,110 @@ fn parse_commands(call: &ToolCall) -> Result serde_json::from_str(arguments) .map_err(|err| FunctionCallError::RespondToModel(err.to_string())) } + +fn command_action(commands: &SearchCommands) -> WebSearchAction { + commands + .search_query + .as_deref() + .and_then(query_action) + .or_else(|| commands.image_query.as_deref().and_then(query_action)) + .or_else(|| { + commands + .open + .as_deref() + .and_then(|operations| operations.first()) + .and_then(|operation| { + literal_url(&operation.ref_id) + .map(|url| WebSearchAction::OpenPage { url: Some(url) }) + }) + }) + .or_else(|| { + commands + .find + .as_deref() + .and_then(|operations| operations.first()) + .map(|operation| WebSearchAction::FindInPage { + url: literal_url(&operation.ref_id), + pattern: Some(operation.pattern.clone()), + }) + }) + .unwrap_or(WebSearchAction::Other) +} + +fn query_action(queries: &[SearchQuery]) -> Option { + match queries { + [] => None, + [query] => Some(WebSearchAction::Search { + query: Some(query.q.clone()), + queries: None, + }), + queries => Some(WebSearchAction::Search { + query: None, + queries: Some(queries.iter().map(|query| query.q.clone()).collect()), + }), + } +} + +fn literal_url(ref_id: &str) -> Option { + Url::parse(ref_id).is_ok().then(|| ref_id.to_string()) +} + +fn web_search_item(call_id: &str, action: WebSearchAction) -> ExtensionTurnItem { + ExtensionTurnItem::WebSearch(WebSearchItem { + id: call_id.to_string(), + query: web_search_action_detail(&action), + action, + }) +} + +#[cfg(test)] +mod tests { + use codex_api::SearchCommands; + use codex_protocol::models::WebSearchAction; + use pretty_assertions::assert_eq; + + use super::command_action; + + #[test] + fn command_action_reports_queries_and_navigation_detail() { + let cases = [ + ( + r#"{"image_query":[{"q":"waterfalls"},{"q":"mountains"}]}"#, + WebSearchAction::Search { + query: None, + queries: Some(vec!["waterfalls".to_string(), "mountains".to_string()]), + }, + ), + ( + r#"{"open":[{"ref_id":"https://example.com/docs"}]}"#, + WebSearchAction::OpenPage { + url: Some("https://example.com/docs".to_string()), + }, + ), + ( + r#"{"find":[{"ref_id":"https://example.com/docs","pattern":"install"}]}"#, + WebSearchAction::FindInPage { + url: Some("https://example.com/docs".to_string()), + pattern: Some("install".to_string()), + }, + ), + ( + r#"{"find":[{"ref_id":"turn0search0","pattern":"install"}]}"#, + WebSearchAction::FindInPage { + url: None, + pattern: Some("install".to_string()), + }, + ), + ( + r#"{"open":[{"ref_id":"turn0search0"}]}"#, + WebSearchAction::Other, + ), + ]; + + for (arguments, expected) in cases { + let commands: SearchCommands = + serde_json::from_str(arguments).expect("valid search command arguments"); + assert_eq!(command_action(&commands), expected); + } + } +} diff --git a/codex-rs/tui/src/history_cell/search.rs b/codex-rs/tui/src/history_cell/search.rs index d75ac50fdc..5373f68ec2 100644 --- a/codex-rs/tui/src/history_cell/search.rs +++ b/codex-rs/tui/src/history_cell/search.rs @@ -4,7 +4,7 @@ use super::*; fn web_search_header(completed: bool) -> &'static str { if completed { - "Searched" + "Searched the web" } else { "Searching the web" } @@ -104,7 +104,8 @@ impl HistoryCell for WebSearchCell { let text: Text<'static> = if detail.is_empty() { Line::from(vec![header.bold()]).into() } else { - Line::from(vec![header.bold(), " ".into(), detail.into()]).into() + let separator = if self.completed { " for " } else { " " }; + Line::from(vec![header.bold(), separator.into(), detail.into()]).into() }; PrefixedWrappedHistoryCell::new(text, vec![bullet, " ".into()], " ").display_lines(width) } @@ -115,7 +116,8 @@ impl HistoryCell for WebSearchCell { if detail.is_empty() { vec![Line::from(header)] } else { - vec![Line::from(format!("{header} {detail}"))] + let separator = if self.completed { " for " } else { " " }; + vec![Line::from(format!("{header}{separator}{detail}"))] } } } diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__web_search_history_cell_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__web_search_history_cell_snapshot.snap index e119420f18..2a4e8b3abb 100644 --- a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__web_search_history_cell_snapshot.snap +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__web_search_history_cell_snapshot.snap @@ -2,5 +2,5 @@ source: tui/src/history_cell.rs expression: rendered --- -• Searched example search query with several generic words to - exercise wrapping +• Searched the web for example search query with several generic + words to exercise wrapping diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__web_search_history_cell_transcript_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__web_search_history_cell_transcript_snapshot.snap index e119420f18..2a4e8b3abb 100644 --- a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__web_search_history_cell_transcript_snapshot.snap +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__web_search_history_cell_transcript_snapshot.snap @@ -2,5 +2,5 @@ source: tui/src/history_cell.rs expression: rendered --- -• Searched example search query with several generic words to - exercise wrapping +• Searched the web for example search query with several generic + words to exercise wrapping diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__web_search_history_cell_without_detail_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__web_search_history_cell_without_detail_snapshot.snap new file mode 100644 index 0000000000..ffda6cb13f --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__web_search_history_cell_without_detail_snapshot.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +• Searched the web diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index 76fe17f027..63a04d19d4 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -1084,6 +1084,14 @@ fn standalone_windows_update_available_history_cell_snapshot() { insta::assert_snapshot!(rendered); } +#[test] +fn web_search_history_cell_without_detail_snapshot() { + let cell = new_web_search_call("call-1".to_string(), String::new(), WebSearchAction::Other); + let rendered = render_lines(&cell.display_lines(/*width*/ 64)).join("\n"); + + insta::assert_snapshot!(rendered); +} + #[test] fn web_search_history_cell_wraps_with_indented_continuation() { let query = "example search query with several generic words to exercise wrapping".to_string(); @@ -1100,8 +1108,8 @@ fn web_search_history_cell_wraps_with_indented_continuation() { assert_eq!( rendered, vec![ - "• Searched example search query with several generic words to".to_string(), - " exercise wrapping".to_string(), + "• Searched the web for example search query with several generic".to_string(), + " words to exercise wrapping".to_string(), ] ); } @@ -1119,7 +1127,10 @@ fn web_search_history_cell_short_query_does_not_wrap() { ); let rendered = render_lines(&cell.display_lines(/*width*/ 64)); - assert_eq!(rendered, vec!["• Searched short query".to_string()]); + assert_eq!( + rendered, + vec!["• Searched the web for short query".to_string()] + ); } #[test] From f4e9d2caac760c6f293f556e38957091f8b85bbf Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 29 May 2026 18:49:21 +0200 Subject: [PATCH 23/40] Move config document helpers into their own module (#25110) ## Why `core/src/config/edit.rs` owns the config edit state machine, but it also carried the TOML document helper code inline as a nested module. Moving those helpers into their own file keeps the edit orchestration easier to scan without changing the config persistence behavior. ## What changed - Moved the existing `document_helpers` module from `core/src/config/edit.rs` into `core/src/config/edit/document_helpers.rs`. - Added `mod document_helpers;` so the existing `pub(super)` helper API remains available to the rest of `config::edit`. ## Testing Not run; this is a refactor-only module extraction with no intended behavior change. --- codex-rs/core/src/config/edit.rs | 315 +----------------- .../core/src/config/edit/document_helpers.rs | 309 +++++++++++++++++ 2 files changed, 311 insertions(+), 313 deletions(-) create mode 100644 codex-rs/core/src/config/edit/document_helpers.rs diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 290edf6fb8..862660ba2e 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -24,6 +24,8 @@ use toml_edit::value; const NOTICE_TABLE_KEY: &str = "notice"; +mod document_helpers; + /// Discrete config mutations supported by the persistence engine. #[derive(Clone, Debug)] pub enum ConfigEdit { @@ -198,319 +200,6 @@ pub fn model_availability_nux_count_edits(shown_count: &HashMap) -> edits } -// TODO(jif) move to a dedicated file -mod document_helpers { - use codex_config::types::AppToolApproval; - use codex_config::types::McpServerConfig; - use codex_config::types::McpServerEnvVar; - use codex_config::types::McpServerToolConfig; - use codex_config::types::McpServerTransportConfig; - use codex_config::types::ToolSuggestDisabledTool; - use codex_config::types::ToolSuggestDiscoverableType; - use toml_edit::Array as TomlArray; - use toml_edit::InlineTable; - use toml_edit::Item as TomlItem; - use toml_edit::Table as TomlTable; - use toml_edit::Value as TomlValue; - use toml_edit::value; - - pub(super) fn ensure_table_for_write(item: &mut TomlItem) -> Option<&mut TomlTable> { - match item { - TomlItem::Table(table) => Some(table), - TomlItem::Value(value) => { - if let Some(inline) = value.as_inline_table() { - *item = TomlItem::Table(table_from_inline(inline)); - item.as_table_mut() - } else { - *item = TomlItem::Table(new_implicit_table()); - item.as_table_mut() - } - } - TomlItem::None => { - *item = TomlItem::Table(new_implicit_table()); - item.as_table_mut() - } - _ => None, - } - } - - pub(super) fn ensure_table_for_read(item: &mut TomlItem) -> Option<&mut TomlTable> { - match item { - TomlItem::Table(table) => Some(table), - TomlItem::Value(value) => { - let inline = value.as_inline_table()?; - *item = TomlItem::Table(table_from_inline(inline)); - item.as_table_mut() - } - _ => None, - } - } - - fn serialize_mcp_server_table(config: &McpServerConfig) -> TomlTable { - let mut entry = TomlTable::new(); - entry.set_implicit(false); - - match &config.transport { - McpServerTransportConfig::Stdio { - command, - args, - env, - env_vars, - cwd, - } => { - entry["command"] = value(command.clone()); - if !args.is_empty() { - entry["args"] = array_from_iter(args.iter().cloned()); - } - if let Some(env) = env - && !env.is_empty() - { - entry["env"] = table_from_pairs(env.iter()); - } - if !env_vars.is_empty() { - entry["env_vars"] = array_from_env_vars(env_vars); - } - if let Some(cwd) = cwd { - entry["cwd"] = value(cwd.to_string_lossy().to_string()); - } - } - McpServerTransportConfig::StreamableHttp { - url, - bearer_token_env_var, - http_headers, - env_http_headers, - } => { - entry["url"] = value(url.clone()); - if let Some(env_var) = bearer_token_env_var { - entry["bearer_token_env_var"] = value(env_var.clone()); - } - if let Some(headers) = http_headers - && !headers.is_empty() - { - entry["http_headers"] = table_from_pairs(headers.iter()); - } - if let Some(headers) = env_http_headers - && !headers.is_empty() - { - entry["env_http_headers"] = table_from_pairs(headers.iter()); - } - } - } - - if !config.enabled { - entry["enabled"] = value(false); - } - if !config.is_local_environment() { - entry["environment_id"] = value(config.environment_id.clone()); - } - if config.required { - entry["required"] = value(true); - } - if config.supports_parallel_tool_calls { - entry["supports_parallel_tool_calls"] = value(true); - } - if let Some(timeout) = config.startup_timeout_sec { - entry["startup_timeout_sec"] = value(timeout.as_secs_f64()); - } - if let Some(timeout) = config.tool_timeout_sec { - entry["tool_timeout_sec"] = value(timeout.as_secs_f64()); - } - if let Some(approval_mode) = config.default_tools_approval_mode { - entry["default_tools_approval_mode"] = value(match approval_mode { - AppToolApproval::Auto => "auto", - AppToolApproval::Prompt => "prompt", - AppToolApproval::Approve => "approve", - }); - } - if let Some(enabled_tools) = &config.enabled_tools - && !enabled_tools.is_empty() - { - entry["enabled_tools"] = array_from_iter(enabled_tools.iter().cloned()); - } - if let Some(disabled_tools) = &config.disabled_tools - && !disabled_tools.is_empty() - { - entry["disabled_tools"] = array_from_iter(disabled_tools.iter().cloned()); - } - if let Some(scopes) = &config.scopes - && !scopes.is_empty() - { - entry["scopes"] = array_from_iter(scopes.iter().cloned()); - } - if let Some(oauth) = &config.oauth - && let Some(client_id) = &oauth.client_id - && !client_id.is_empty() - { - let mut oauth_table = TomlTable::new(); - oauth_table.set_implicit(false); - oauth_table["client_id"] = value(client_id.clone()); - entry["oauth"] = TomlItem::Table(oauth_table); - } - if let Some(resource) = &config.oauth_resource - && !resource.is_empty() - { - entry["oauth_resource"] = value(resource.clone()); - } - if !config.tools.is_empty() { - let mut tools = new_implicit_table(); - let mut tool_entries: Vec<_> = config.tools.iter().collect(); - tool_entries.sort_by_key(|(name, _)| *name); - for (name, tool_config) in tool_entries { - tools.insert(name, serialize_mcp_server_tool(tool_config)); - } - entry.insert("tools", TomlItem::Table(tools)); - } - - entry - } - - fn serialize_mcp_server_tool(config: &McpServerToolConfig) -> TomlItem { - let mut entry = TomlTable::new(); - entry.set_implicit(false); - if let Some(approval_mode) = config.approval_mode { - entry["approval_mode"] = value(match approval_mode { - AppToolApproval::Auto => "auto", - AppToolApproval::Prompt => "prompt", - AppToolApproval::Approve => "approve", - }); - } - TomlItem::Table(entry) - } - - pub(super) fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem { - TomlItem::Table(serialize_mcp_server_table(config)) - } - - pub(super) fn serialize_mcp_server_inline(config: &McpServerConfig) -> InlineTable { - serialize_mcp_server_table(config).into_inline_table() - } - - pub(super) fn merge_inline_table(existing: &mut InlineTable, replacement: InlineTable) { - existing.retain(|key, _| replacement.get(key).is_some()); - - for (key, value) in replacement.iter() { - if let Some(existing_value) = existing.get_mut(key) { - let mut updated_value = value.clone(); - *updated_value.decor_mut() = existing_value.decor().clone(); - *existing_value = updated_value; - } else { - existing.insert(key.to_string(), value.clone()); - } - } - } - - fn table_from_inline(inline: &InlineTable) -> TomlTable { - let mut table = new_implicit_table(); - for (key, value) in inline.iter() { - let mut value = value.clone(); - let decor = value.decor_mut(); - decor.set_suffix(""); - table.insert(key, TomlItem::Value(value)); - } - table - } - - pub(super) fn new_implicit_table() -> TomlTable { - let mut table = TomlTable::new(); - table.set_implicit(true); - table - } - - pub(super) fn parse_tool_suggest_disabled_tool( - value: &TomlValue, - ) -> Option { - let table = value.as_inline_table()?; - let kind = match table.get("type").and_then(TomlValue::as_str) { - Some("connector") => ToolSuggestDiscoverableType::Connector, - Some("plugin") => ToolSuggestDiscoverableType::Plugin, - _ => return None, - }; - let id = table.get("id").and_then(TomlValue::as_str)?; - Some(ToolSuggestDisabledTool { - kind, - id: id.to_string(), - }) - } - - pub(super) fn parse_tool_suggest_disabled_tool_table( - table: &TomlTable, - ) -> Option { - let kind = match table.get("type").and_then(TomlItem::as_str) { - Some("connector") => ToolSuggestDiscoverableType::Connector, - Some("plugin") => ToolSuggestDiscoverableType::Plugin, - _ => return None, - }; - let id = table.get("id").and_then(TomlItem::as_str)?; - Some(ToolSuggestDisabledTool { - kind, - id: id.to_string(), - }) - } - - pub(super) fn tool_suggest_disabled_tools_value( - disabled_tools: &[ToolSuggestDisabledTool], - ) -> TomlItem { - let mut array = TomlArray::new(); - for disabled_tool in disabled_tools { - let mut table = InlineTable::new(); - table.insert( - "type", - match disabled_tool.kind { - ToolSuggestDiscoverableType::Connector => "connector", - ToolSuggestDiscoverableType::Plugin => "plugin", - } - .into(), - ); - table.insert("id", disabled_tool.id.clone().into()); - array.push(table); - } - TomlItem::Value(array.into()) - } - - fn array_from_iter(iter: I) -> TomlItem - where - I: Iterator, - { - let mut array = TomlArray::new(); - for value in iter { - array.push(value); - } - TomlItem::Value(array.into()) - } - - fn array_from_env_vars(env_vars: &[McpServerEnvVar]) -> TomlItem { - let mut array = TomlArray::new(); - for env_var in env_vars { - match env_var { - McpServerEnvVar::Name(name) => array.push(name.clone()), - McpServerEnvVar::Config { name, source } => { - let mut table = InlineTable::new(); - table.insert("name", name.clone().into()); - if let Some(source) = source { - table.insert("source", source.clone().into()); - } - array.push(table); - } - } - } - TomlItem::Value(array.into()) - } - - fn table_from_pairs<'a, I>(pairs: I) -> TomlItem - where - I: IntoIterator, - { - let mut entries: Vec<_> = pairs.into_iter().collect(); - entries.sort_by_key(|(key, _)| *key); - let mut table = TomlTable::new(); - table.set_implicit(false); - for (key, val) in entries { - table.insert(key, value(val.clone())); - } - TomlItem::Table(table) - } -} - struct ConfigDocument { doc: DocumentMut, } diff --git a/codex-rs/core/src/config/edit/document_helpers.rs b/codex-rs/core/src/config/edit/document_helpers.rs new file mode 100644 index 0000000000..5b99306eb9 --- /dev/null +++ b/codex-rs/core/src/config/edit/document_helpers.rs @@ -0,0 +1,309 @@ +use codex_config::types::AppToolApproval; +use codex_config::types::McpServerConfig; +use codex_config::types::McpServerEnvVar; +use codex_config::types::McpServerToolConfig; +use codex_config::types::McpServerTransportConfig; +use codex_config::types::ToolSuggestDisabledTool; +use codex_config::types::ToolSuggestDiscoverableType; +use toml_edit::Array as TomlArray; +use toml_edit::InlineTable; +use toml_edit::Item as TomlItem; +use toml_edit::Table as TomlTable; +use toml_edit::Value as TomlValue; +use toml_edit::value; + +pub(super) fn ensure_table_for_write(item: &mut TomlItem) -> Option<&mut TomlTable> { + match item { + TomlItem::Table(table) => Some(table), + TomlItem::Value(value) => { + if let Some(inline) = value.as_inline_table() { + *item = TomlItem::Table(table_from_inline(inline)); + item.as_table_mut() + } else { + *item = TomlItem::Table(new_implicit_table()); + item.as_table_mut() + } + } + TomlItem::None => { + *item = TomlItem::Table(new_implicit_table()); + item.as_table_mut() + } + _ => None, + } +} + +pub(super) fn ensure_table_for_read(item: &mut TomlItem) -> Option<&mut TomlTable> { + match item { + TomlItem::Table(table) => Some(table), + TomlItem::Value(value) => { + let inline = value.as_inline_table()?; + *item = TomlItem::Table(table_from_inline(inline)); + item.as_table_mut() + } + _ => None, + } +} + +fn serialize_mcp_server_table(config: &McpServerConfig) -> TomlTable { + let mut entry = TomlTable::new(); + entry.set_implicit(false); + + match &config.transport { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { + entry["command"] = value(command.clone()); + if !args.is_empty() { + entry["args"] = array_from_iter(args.iter().cloned()); + } + if let Some(env) = env + && !env.is_empty() + { + entry["env"] = table_from_pairs(env.iter()); + } + if !env_vars.is_empty() { + entry["env_vars"] = array_from_env_vars(env_vars); + } + if let Some(cwd) = cwd { + entry["cwd"] = value(cwd.to_string_lossy().to_string()); + } + } + McpServerTransportConfig::StreamableHttp { + url, + bearer_token_env_var, + http_headers, + env_http_headers, + } => { + entry["url"] = value(url.clone()); + if let Some(env_var) = bearer_token_env_var { + entry["bearer_token_env_var"] = value(env_var.clone()); + } + if let Some(headers) = http_headers + && !headers.is_empty() + { + entry["http_headers"] = table_from_pairs(headers.iter()); + } + if let Some(headers) = env_http_headers + && !headers.is_empty() + { + entry["env_http_headers"] = table_from_pairs(headers.iter()); + } + } + } + + if !config.enabled { + entry["enabled"] = value(false); + } + if !config.is_local_environment() { + entry["environment_id"] = value(config.environment_id.clone()); + } + if config.required { + entry["required"] = value(true); + } + if config.supports_parallel_tool_calls { + entry["supports_parallel_tool_calls"] = value(true); + } + if let Some(timeout) = config.startup_timeout_sec { + entry["startup_timeout_sec"] = value(timeout.as_secs_f64()); + } + if let Some(timeout) = config.tool_timeout_sec { + entry["tool_timeout_sec"] = value(timeout.as_secs_f64()); + } + if let Some(approval_mode) = config.default_tools_approval_mode { + entry["default_tools_approval_mode"] = value(match approval_mode { + AppToolApproval::Auto => "auto", + AppToolApproval::Prompt => "prompt", + AppToolApproval::Approve => "approve", + }); + } + if let Some(enabled_tools) = &config.enabled_tools + && !enabled_tools.is_empty() + { + entry["enabled_tools"] = array_from_iter(enabled_tools.iter().cloned()); + } + if let Some(disabled_tools) = &config.disabled_tools + && !disabled_tools.is_empty() + { + entry["disabled_tools"] = array_from_iter(disabled_tools.iter().cloned()); + } + if let Some(scopes) = &config.scopes + && !scopes.is_empty() + { + entry["scopes"] = array_from_iter(scopes.iter().cloned()); + } + if let Some(oauth) = &config.oauth + && let Some(client_id) = &oauth.client_id + && !client_id.is_empty() + { + let mut oauth_table = TomlTable::new(); + oauth_table.set_implicit(false); + oauth_table["client_id"] = value(client_id.clone()); + entry["oauth"] = TomlItem::Table(oauth_table); + } + if let Some(resource) = &config.oauth_resource + && !resource.is_empty() + { + entry["oauth_resource"] = value(resource.clone()); + } + if !config.tools.is_empty() { + let mut tools = new_implicit_table(); + let mut tool_entries: Vec<_> = config.tools.iter().collect(); + tool_entries.sort_by_key(|(name, _)| *name); + for (name, tool_config) in tool_entries { + tools.insert(name, serialize_mcp_server_tool(tool_config)); + } + entry.insert("tools", TomlItem::Table(tools)); + } + + entry +} + +fn serialize_mcp_server_tool(config: &McpServerToolConfig) -> TomlItem { + let mut entry = TomlTable::new(); + entry.set_implicit(false); + if let Some(approval_mode) = config.approval_mode { + entry["approval_mode"] = value(match approval_mode { + AppToolApproval::Auto => "auto", + AppToolApproval::Prompt => "prompt", + AppToolApproval::Approve => "approve", + }); + } + TomlItem::Table(entry) +} + +pub(super) fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem { + TomlItem::Table(serialize_mcp_server_table(config)) +} + +pub(super) fn serialize_mcp_server_inline(config: &McpServerConfig) -> InlineTable { + serialize_mcp_server_table(config).into_inline_table() +} + +pub(super) fn merge_inline_table(existing: &mut InlineTable, replacement: InlineTable) { + existing.retain(|key, _| replacement.get(key).is_some()); + + for (key, value) in replacement.iter() { + if let Some(existing_value) = existing.get_mut(key) { + let mut updated_value = value.clone(); + *updated_value.decor_mut() = existing_value.decor().clone(); + *existing_value = updated_value; + } else { + existing.insert(key.to_string(), value.clone()); + } + } +} + +fn table_from_inline(inline: &InlineTable) -> TomlTable { + let mut table = new_implicit_table(); + for (key, value) in inline.iter() { + let mut value = value.clone(); + let decor = value.decor_mut(); + decor.set_suffix(""); + table.insert(key, TomlItem::Value(value)); + } + table +} + +pub(super) fn new_implicit_table() -> TomlTable { + let mut table = TomlTable::new(); + table.set_implicit(true); + table +} + +pub(super) fn parse_tool_suggest_disabled_tool( + value: &TomlValue, +) -> Option { + let table = value.as_inline_table()?; + let kind = match table.get("type").and_then(TomlValue::as_str) { + Some("connector") => ToolSuggestDiscoverableType::Connector, + Some("plugin") => ToolSuggestDiscoverableType::Plugin, + _ => return None, + }; + let id = table.get("id").and_then(TomlValue::as_str)?; + Some(ToolSuggestDisabledTool { + kind, + id: id.to_string(), + }) +} + +pub(super) fn parse_tool_suggest_disabled_tool_table( + table: &TomlTable, +) -> Option { + let kind = match table.get("type").and_then(TomlItem::as_str) { + Some("connector") => ToolSuggestDiscoverableType::Connector, + Some("plugin") => ToolSuggestDiscoverableType::Plugin, + _ => return None, + }; + let id = table.get("id").and_then(TomlItem::as_str)?; + Some(ToolSuggestDisabledTool { + kind, + id: id.to_string(), + }) +} + +pub(super) fn tool_suggest_disabled_tools_value( + disabled_tools: &[ToolSuggestDisabledTool], +) -> TomlItem { + let mut array = TomlArray::new(); + for disabled_tool in disabled_tools { + let mut table = InlineTable::new(); + table.insert( + "type", + match disabled_tool.kind { + ToolSuggestDiscoverableType::Connector => "connector", + ToolSuggestDiscoverableType::Plugin => "plugin", + } + .into(), + ); + table.insert("id", disabled_tool.id.clone().into()); + array.push(table); + } + TomlItem::Value(array.into()) +} + +fn array_from_iter(iter: I) -> TomlItem +where + I: Iterator, +{ + let mut array = TomlArray::new(); + for value in iter { + array.push(value); + } + TomlItem::Value(array.into()) +} + +fn array_from_env_vars(env_vars: &[McpServerEnvVar]) -> TomlItem { + let mut array = TomlArray::new(); + for env_var in env_vars { + match env_var { + McpServerEnvVar::Name(name) => array.push(name.clone()), + McpServerEnvVar::Config { name, source } => { + let mut table = InlineTable::new(); + table.insert("name", name.clone().into()); + if let Some(source) = source { + table.insert("source", source.clone().into()); + } + array.push(table); + } + } + } + TomlItem::Value(array.into()) +} + +fn table_from_pairs<'a, I>(pairs: I) -> TomlItem +where + I: IntoIterator, +{ + let mut entries: Vec<_> = pairs.into_iter().collect(); + entries.sort_by_key(|(key, _)| *key); + let mut table = TomlTable::new(); + table.set_implicit(false); + for (key, val) in entries { + table.insert(key, value(val.clone())); + } + TomlItem::Table(table) +} From e29bbb53688ea44f5a59b15eb4fa34bc75fa3fcc Mon Sep 17 00:00:00 2001 From: xl-openai Date: Fri, 29 May 2026 10:09:33 -0700 Subject: [PATCH 24/40] feat: Add focused diagnostics for MCP HTTP send failures (#25013) Adds failure-only logging for MCP streamable HTTP post_message calls and the underlying reqwest send path, capturing the MCP method/request id, endpoint shape, auth-header presence, timeout/connect classification, and sanitized error source chain without logging headers, bodies, tokens, or full URLs. --- .../src/client/reqwest_http_client.rs | 40 +++++++++-- .../rmcp-client/src/http_client_adapter.rs | 69 +++++++++++++++++-- 2 files changed, 100 insertions(+), 9 deletions(-) diff --git a/codex-rs/exec-server/src/client/reqwest_http_client.rs b/codex-rs/exec-server/src/client/reqwest_http_client.rs index 44305e466a..9e22fc01d3 100644 --- a/codex-rs/exec-server/src/client/reqwest_http_client.rs +++ b/codex-rs/exec-server/src/client/reqwest_http_client.rs @@ -5,6 +5,7 @@ //! - in a remote environment, that means the remote runtime after the //! orchestrator has forwarded `http/request` over JSON-RPC +use std::error::Error as StdError; use std::time::Duration; use codex_app_server_protocol::JSONRPCErrorError; @@ -135,15 +136,21 @@ impl ReqwestHttpRequestRunner { } let headers = Self::build_headers(params.headers)?; - let mut request = self.client.request(method, url).headers(headers); + let mut request = self.client.request(method.clone(), url).headers(headers); if let Some(body) = params.body { request = request.body(body.into_inner()); } - let response = request - .send() - .await - .map_err(|error| internal_error(format!("http/request failed: {error}")))?; + let response = match request.send().await { + Ok(response) => response, + Err(error) => { + let error_message = error.to_string(); + log_send_error(&method, error); + return Err(internal_error(format!( + "http/request failed: {error_message}" + ))); + } + }; let status = response.status().as_u16(); let headers = Self::response_headers(response.headers()); @@ -265,3 +272,26 @@ impl ReqwestHttpRequestRunner { .collect() } } + +fn log_send_error(method: &Method, error: reqwest::Error) { + let error = error.without_url(); + let source_chain = error_source_chain(&error); + tracing::warn!( + http_method = method.as_str(), + error_is_timeout = error.is_timeout(), + error_is_connect = error.is_connect(), + error = %error, + error_sources = ?source_chain, + "http/request send failed" + ); +} + +fn error_source_chain(error: &reqwest::Error) -> Option { + let mut sources = Vec::new(); + let mut source = error.source(); + while let Some(error) = source { + sources.push(error.to_string()); + source = error.source(); + } + (!sources.is_empty()).then(|| sources.join(": ")) +} diff --git a/codex-rs/rmcp-client/src/http_client_adapter.rs b/codex-rs/rmcp-client/src/http_client_adapter.rs index 0a5643aabf..6f98f78920 100644 --- a/codex-rs/rmcp-client/src/http_client_adapter.rs +++ b/codex-rs/rmcp-client/src/http_client_adapter.rs @@ -28,6 +28,7 @@ use reqwest::header::CONTENT_TYPE; use reqwest::header::HeaderMap; use reqwest::header::HeaderName; use rmcp::model::ClientJsonRpcMessage; +use rmcp::model::JsonRpcMessage; use rmcp::model::ServerJsonRpcMessage; use rmcp::transport::streamable_http_client::AuthRequiredError; use rmcp::transport::streamable_http_client::InsufficientScopeError; @@ -88,6 +89,8 @@ impl StreamableHttpClient for StreamableHttpClientAdapter { auth_token: Option, custom_headers: HashMap, ) -> std::result::Result> { + let (mcp_method, mcp_request_id) = client_jsonrpc_message_fields(&message); + let has_session_id = session_id.is_some(); let mut headers = self.default_headers.clone(); headers.extend(custom_headers); self.add_auth_headers(&mut headers); @@ -121,7 +124,8 @@ impl StreamableHttpClient for StreamableHttpClientAdapter { } let body = serde_json::to_vec(&message).map_err(StreamableHttpError::Deserialize)?; - let (response, mut body_stream) = self + let has_authorization_header = headers.contains_key(AUTHORIZATION); + let response = self .http_client .http_request_stream(HttpRequestParams { method: "POST".to_string(), @@ -132,9 +136,22 @@ impl StreamableHttpClient for StreamableHttpClientAdapter { request_id: "buffered-request".to_string(), stream_response: true, }) - .await - .map_err(StreamableHttpClientAdapterError::from) - .map_err(StreamableHttpError::Client)?; + .await; + let (response, mut body_stream) = match response { + Ok(response) => response, + Err(error) => { + log_post_message_http_error( + &uri, + mcp_method.as_deref(), + mcp_request_id.as_deref(), + has_session_id, + has_authorization_header, + ); + return Err(StreamableHttpError::Client( + StreamableHttpClientAdapterError::from(error), + )); + } + }; if response.status == StatusCode::NOT_FOUND.as_u16() && session_id.is_some() { return Err(StreamableHttpError::Client( @@ -354,6 +371,50 @@ fn body_preview(body: impl Into) -> String { body_preview } +fn client_jsonrpc_message_fields( + message: &ClientJsonRpcMessage, +) -> (Option, Option) { + match message { + JsonRpcMessage::Request(request) => ( + Some(request.request.method().to_string()), + Some(request.id.to_string()), + ), + JsonRpcMessage::Response(response) => (None, Some(response.id.to_string())), + JsonRpcMessage::Notification(_) => (None, None), + JsonRpcMessage::Error(error) => (None, error.id.as_ref().map(ToString::to_string)), + } +} + +fn log_post_message_http_error( + uri: &str, + mcp_method: Option<&str>, + mcp_request_id: Option<&str>, + has_session_id: bool, + has_authorization_header: bool, +) { + let parsed_url = reqwest::Url::parse(uri).ok(); + tracing::warn!( + endpoint_scheme = parsed_url + .as_ref() + .map(reqwest::Url::scheme) + .unwrap_or(""), + endpoint_host = parsed_url + .as_ref() + .and_then(reqwest::Url::host_str) + .unwrap_or(""), + endpoint_path = parsed_url + .as_ref() + .map(reqwest::Url::path) + .unwrap_or(""), + endpoint_has_query = parsed_url.as_ref().is_some_and(|url| url.query().is_some()), + mcp_method = mcp_method.unwrap_or(""), + mcp_request_id = mcp_request_id.unwrap_or(""), + has_session_id = has_session_id, + has_authorization_header = has_authorization_header, + "streamable HTTP post_message failed" + ); +} + fn insert_header( headers: &mut HeaderMap, name: HeaderName, From 3e666dd32ac7ca3faf28dd2e9252d563e32bfa0a Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Fri, 29 May 2026 10:22:27 -0700 Subject: [PATCH 25/40] [codex] Wait for MCP readiness in core integration tests (#24964) Ensures MCP-backed `codex-core` integration tests exercise initialized servers instead of racing server startup. I've been idly investigating a few flakes and the failure modes are much more confusing when a tool call fails because of a failed server start than when the failed server start causes the test to fail directly. --- codex-rs/core/tests/common/lib.rs | 33 ++++++++++++ codex-rs/core/tests/suite/code_mode.rs | 2 + codex-rs/core/tests/suite/hooks_mcp.rs | 4 ++ codex-rs/core/tests/suite/plugins.rs | 44 +-------------- codex-rs/core/tests/suite/rmcp_client.rs | 65 ++++++++--------------- codex-rs/core/tests/suite/search_tool.rs | 4 ++ codex-rs/core/tests/suite/sqlite_state.rs | 2 + codex-rs/core/tests/suite/truncation.rs | 4 ++ 8 files changed, 73 insertions(+), 85 deletions(-) diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index 194fb2ae0a..3d7fd21228 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -248,6 +248,39 @@ where wait_for_event_with_timeout(codex, predicate, Duration::from_secs(1)).await } +/// Waits for a configured MCP server to finish startup and requires it to be ready. +pub async fn wait_for_mcp_server(codex: &CodexThread, server_name: &str) -> anyhow::Result<()> { + use codex_protocol::protocol::EventMsg; + + // Wait for the startup summary regardless of outcome, then interpret the + // requested server's ready, failed, or cancelled entry below. + let summary = loop { + let event = codex + .next_event() + .await + .expect("stream ended unexpectedly while waiting for MCP startup"); + if let EventMsg::McpStartupComplete(summary) = event.msg { + break summary; + } + }; + if let Some(failure) = summary + .failed + .iter() + .find(|failure| failure.server == server_name) + { + let error = &failure.error; + anyhow::bail!("MCP server {server_name} failed to start: {error}"); + } + if summary.cancelled.iter().any(|server| server == server_name) { + anyhow::bail!("MCP server {server_name} startup was cancelled"); + } + assert!( + summary.ready.iter().any(|server| server == server_name), + "expected MCP server {server_name} to be ready; startup summary: {summary:?}" + ); + Ok(()) +} + pub async fn submit_thread_settings( codex: &CodexThread, thread_settings: codex_protocol::protocol::ThreadSettingsOverrides, diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index cbf6993bc9..9881869313 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -38,6 +38,7 @@ 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_match; +use core_test_support::wait_for_mcp_server; use pretty_assertions::assert_eq; use serde_json::Value; use std::collections::HashMap; @@ -283,6 +284,7 @@ async fn run_code_mode_turn_with_rmcp_config( .expect("test mcp servers should accept any configuration"); }); let test = builder.build(server).await?; + wait_for_mcp_server(&test.codex, "rmcp").await?; responses::mount_sse_once( server, diff --git a/codex-rs/core/tests/suite/hooks_mcp.rs b/codex-rs/core/tests/suite/hooks_mcp.rs index 940db7cf4c..00f1546a7c 100644 --- a/codex-rs/core/tests/suite/hooks_mcp.rs +++ b/codex-rs/core/tests/suite/hooks_mcp.rs @@ -22,6 +22,7 @@ use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::stdio_server_bin; use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_mcp_server; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; @@ -284,6 +285,7 @@ async fn pre_tool_use_blocks_mcp_tool_before_execution( }) .build(&server) .await?; + wait_for_mcp_server(&test.codex, RMCP_SERVER).await?; test.submit_turn("call the rmcp echo tool with the MCP pre hook") .await?; @@ -375,6 +377,7 @@ async fn pre_tool_use_rewrites_mcp_tool_before_execution() -> Result<()> { }) .build(&server) .await?; + wait_for_mcp_server(&test.codex, RMCP_SERVER).await?; test.submit_turn("call the rmcp echo tool with the MCP pre hook rewrite") .await?; @@ -471,6 +474,7 @@ async fn post_tool_use_records_mcp_tool_payload_and_context( }) .build(&server) .await?; + wait_for_mcp_server(&test.codex, RMCP_SERVER).await?; test.submit_turn("call the rmcp echo tool with the MCP post hook") .await?; diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index b89b611a83..d7d7c7e8f5 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -6,7 +6,6 @@ use std::time::Duration; use std::time::Instant; use anyhow::Result; -use anyhow::bail; use codex_features::Feature; use codex_login::CodexAuth; use codex_protocol::protocol::EventMsg; @@ -21,7 +20,7 @@ use core_test_support::skip_if_no_network; use core_test_support::stdio_server_bin; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; -use core_test_support::wait_for_event_with_timeout; +use core_test_support::wait_for_mcp_server; use tempfile::TempDir; use wiremock::MockServer; @@ -140,45 +139,6 @@ async fn build_apps_enabled_plugin_test_codex( .codex) } -async fn wait_for_sample_mcp_ready(codex: &codex_core::CodexThread) -> Result<()> { - let startup_event = wait_for_event_with_timeout( - codex, - |ev| match ev { - EventMsg::McpStartupComplete(summary) => { - summary.ready.iter().any(|server| server == "sample") - || summary - .failed - .iter() - .any(|failure| failure.server == "sample") - || summary.cancelled.iter().any(|server| server == "sample") - } - _ => false, - }, - Duration::from_secs(70), - ) - .await; - let EventMsg::McpStartupComplete(startup) = startup_event else { - unreachable!("event guard guarantees McpStartupComplete"); - }; - if let Some(failure) = startup - .failed - .iter() - .find(|failure| failure.server == "sample") - { - let error = &failure.error; - bail!("plugin MCP server failed to start: {error}"); - } - if startup.cancelled.iter().any(|server| server == "sample") { - bail!("plugin MCP server startup was cancelled"); - } - assert!( - startup.ready.iter().any(|server| server == "sample"), - "expected plugin MCP server to be ready; startup summary: {startup:?}" - ); - - Ok(()) -} - fn tool_names(body: &serde_json::Value) -> Vec { body.get("tools") .and_then(serde_json::Value::as_array) @@ -296,7 +256,7 @@ async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> { let codex = build_apps_enabled_plugin_test_codex(&server, codex_home, apps_server.chatgpt_base_url) .await?; - wait_for_sample_mcp_ready(&codex).await?; + wait_for_mcp_server(&codex, "sample").await?; codex .submit(Op::UserInput { diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 8aa01a4a11..0d669e5dd7 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -55,7 +55,7 @@ use core_test_support::test_codex::TestCodex; 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 core_test_support::wait_for_mcp_server; use reqwest::Client; use reqwest::StatusCode; use serde_json::Value; @@ -268,44 +268,6 @@ fn copy_binary_to_remote_env( Ok(remote_path) } -async fn wait_for_mcp_server(fixture: &TestCodex, server_name: &str) -> anyhow::Result<()> { - let startup_event = wait_for_event_with_timeout( - &fixture.codex, - |ev| match ev { - EventMsg::McpStartupComplete(summary) => { - summary.ready.iter().any(|server| server == server_name) - || summary - .failed - .iter() - .any(|failure| failure.server == server_name) - || summary.cancelled.iter().any(|server| server == server_name) - } - _ => false, - }, - Duration::from_secs(70), - ) - .await; - let EventMsg::McpStartupComplete(summary) = startup_event else { - unreachable!("event guard guarantees McpStartupComplete"); - }; - if let Some(failure) = summary - .failed - .iter() - .find(|failure| failure.server == server_name) - { - let error = &failure.error; - anyhow::bail!("MCP server {server_name} failed to start: {error}"); - } - if summary.cancelled.iter().any(|server| server == server_name) { - anyhow::bail!("MCP server {server_name} startup was cancelled"); - } - ensure!( - summary.ready.iter().any(|server| server == server_name), - "expected MCP server {server_name} to be ready; startup summary: {summary:?}" - ); - Ok(()) -} - struct TestMcpServerOptions { environment_id: String, supports_parallel_tool_calls: bool, @@ -517,6 +479,8 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { }) .build_with_remote_env(&server) .await?; + wait_for_mcp_server(&fixture.codex, server_name).await?; + fixture .codex .submit(read_only_user_turn(&fixture, "call the rmcp echo tool")) @@ -640,6 +604,7 @@ async fn stdio_server_uses_configured_cwd_before_runtime_fallback() -> anyhow::R }) .build_with_remote_env(&server) .await?; + wait_for_mcp_server(&fixture.codex, server_name).await?; let expected_cwd = expected_cwd .lock() @@ -702,6 +667,7 @@ async fn local_stdio_server_uses_runtime_fallback_cwd_when_config_omits_cwd() -> }) .build(&server) .await?; + wait_for_mcp_server(&fixture.codex, server_name).await?; let expected_cwd = expected_cwd .lock() @@ -760,7 +726,7 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()> .build_with_remote_env(&server) .await?; - wait_for_mcp_server(&fixture, server_name).await?; + wait_for_mcp_server(&fixture.codex, server_name).await?; fixture .submit_turn_with_permission_profile( @@ -857,6 +823,8 @@ async fn stdio_mcp_parallel_tool_calls_default_false_runs_serially() -> anyhow:: }) .build_with_remote_env(&server) .await?; + wait_for_mcp_server(&fixture.codex, server_name).await?; + fixture .codex // Keep this baseline on the mutable sync tool so read-only hints do not @@ -991,6 +959,8 @@ async fn stdio_mcp_read_only_tool_calls_run_concurrently_without_server_opt_in() }) .build_with_remote_env(&server) .await?; + wait_for_mcp_server(&fixture.codex, server_name).await?; + fixture .codex .submit(read_only_user_turn( @@ -1073,6 +1043,8 @@ async fn stdio_mcp_parallel_tool_calls_opt_in_runs_concurrently() -> anyhow::Res }) .build_with_remote_env(&server) .await?; + wait_for_mcp_server(&fixture.codex, server_name).await?; + fixture .codex // Exercise the server opt-in with the mutable sync tool rather than the @@ -1157,7 +1129,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { }) .build_with_remote_env(&server) .await?; - wait_for_mcp_server(&fixture, server_name).await?; + wait_for_mcp_server(&fixture.codex, server_name).await?; fixture .codex @@ -1290,7 +1262,7 @@ async fn stdio_image_responses_preserve_original_detail_metadata() -> anyhow::Re }) .build_with_remote_env(&server) .await?; - wait_for_mcp_server(&fixture, server_name).await?; + wait_for_mcp_server(&fixture.codex, server_name).await?; fixture .codex @@ -1427,6 +1399,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re }) .build_with_remote_env(&server) .await?; + wait_for_mcp_server(&fixture.codex, server_name).await?; fixture .thread_manager @@ -1529,6 +1502,8 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { }) .build_with_remote_env(&server) .await?; + wait_for_mcp_server(&fixture.codex, server_name).await?; + fixture .codex .submit(read_only_user_turn(&fixture, "call the rmcp echo tool")) @@ -1647,6 +1622,7 @@ async fn stdio_server_propagates_explicit_local_env_var_source() -> anyhow::Resu }) .build_with_remote_env(&server) .await?; + wait_for_mcp_server(&fixture.codex, server_name).await?; fixture .codex @@ -1739,6 +1715,7 @@ async fn remote_stdio_env_var_source_does_not_copy_local_env() -> anyhow::Result }) .build_with_remote_env(&server) .await?; + wait_for_mcp_server(&fixture.codex, server_name).await?; fixture .codex @@ -1922,6 +1899,8 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { }) .build_with_remote_env(&server) .await?; + wait_for_mcp_server(&fixture.codex, server_name).await?; + // Phase 4: submit the user turn that should trigger the MCP tool call. fixture .codex @@ -2110,7 +2089,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { .await?; // Phase 5: wait for MCP startup before the turn is submitted, which keeps // failures tied to server startup/discovery. - wait_for_mcp_server(&fixture, server_name).await?; + wait_for_mcp_server(&fixture.codex, server_name).await?; // Phase 6: submit the user turn that should invoke the OAuth-backed tool. fixture diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 0dc956d951..42b1b6bb3f 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -47,6 +47,7 @@ use core_test_support::skip_if_no_network; use core_test_support::stdio_server_bin; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; +use core_test_support::wait_for_mcp_server; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; @@ -1114,6 +1115,7 @@ async fn tool_search_indexes_only_enabled_non_app_mcp_tools() -> Result<()> { .expect("test mcp servers should accept any configuration"); }); let test = builder.build(&server).await?; + wait_for_mcp_server(&test.codex, "rmcp").await?; test.submit_turn_with_approval_and_permission_profile( "Find the rmcp echo and image tools.", @@ -1243,6 +1245,7 @@ async fn tool_search_surfaced_mcp_tool_errors_are_returned_to_model() -> Result< .expect("test mcp servers should accept any configuration"); }); let test = builder.build(&server).await?; + wait_for_mcp_server(&test.codex, "rmcp").await?; test.codex .submit(Op::UserInput { @@ -1391,6 +1394,7 @@ async fn tool_search_uses_non_app_mcp_server_instructions_as_namespace_descripti .expect("test mcp servers should accept any configuration"); }); let test = builder.build(&server).await?; + wait_for_mcp_server(&test.codex, "rmcp").await?; test.submit_turn_with_approval_and_permission_profile( "Find the rmcp echo tool.", diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index 85c1e08c84..4d9c89638e 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -29,6 +29,7 @@ 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_match; +use core_test_support::wait_for_mcp_server; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashMap; @@ -460,6 +461,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< .expect("test mcp servers should accept any configuration"); }); let test = builder.build(&server).await?; + 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(); diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index fe078977ff..db90b663e0 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -23,6 +23,7 @@ use core_test_support::skip_if_no_network; use core_test_support::stdio_server_bin; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; +use core_test_support::wait_for_mcp_server; use serde_json::Value; use serde_json::json; use std::collections::HashMap; @@ -410,6 +411,7 @@ async fn mcp_tool_call_output_exceeds_limit_truncated_for_model() -> Result<()> config.tool_output_token_limit = Some(500); }); let fixture = builder.build(&server).await?; + wait_for_mcp_server(&fixture.codex, server_name).await?; fixture .submit_turn_with_permission_profile( @@ -509,6 +511,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { .expect("test mcp servers should accept any configuration"); }); let fixture = builder.build(&server).await?; + wait_for_mcp_server(&fixture.codex, server_name).await?; let session_model = fixture.session_configured.model.clone(); let permission_profile = PermissionProfile::read_only(); let sandbox_policy = permission_profile.to_legacy_sandbox_policy(fixture.cwd.path())?; @@ -798,6 +801,7 @@ async fn mcp_tool_call_output_not_truncated_with_custom_limit() -> Result<()> { .expect("test mcp servers should accept any configuration"); }); let fixture = builder.build(&server).await?; + wait_for_mcp_server(&fixture.codex, server_name).await?; fixture .submit_turn_with_permission_profile( From 10b03990345bd99bbc44f32f4226d065ea310d5f Mon Sep 17 00:00:00 2001 From: Won Park Date: Fri, 29 May 2026 10:33:13 -0700 Subject: [PATCH 26/40] Route extension image generation through the native image completion pipeline (#24972) ## Why The standalone `image_gen.imagegen` extension should behave like native image generation for artifact persistence and UI completion, while returning its save-location guidance as part of the tool result instead of injecting a developer message. ## What Changed - Added an image-generation completion hook for extension tools so core can persist generated images and emit the existing `ImageGeneration` lifecycle events. - Reused core image artifact persistence for extension output and removed extension-local save-path/file-writing logic. - Split shared image persistence from built-in finalization so native image generation keeps its existing developer-message instruction behavior. - Returned the generated image save-location instruction through the extension `FunctionCallOutput`, alongside the generated image input for model follow-up. - Preserved the existing image-generation event shape for current UI and replay compatibility. - Avoided cloning the full generated-image base64 payload when emitting the in-progress image item. - Removed dependencies no longer needed after moving persistence out of the extension crate. ## Fast Follow - Adjust the existing Extension API and add a general `TurnItem` finalization path for re-usability of code ## Validation - Ran `just fmt`. - Ran `just bazel-lock-update`. - Ran `just bazel-lock-check`. - Ran `just test -p codex-tools -p codex-extension-api -p codex-image-generation-extension`. - Ran `just test -p codex-core image_generation_publication_is_finalized_by_core`. - Ran `just test -p codex-core handle_output_item_done_records_image_save_history_message`. - Ran `just fix -p codex-tools -p codex-extension-api -p codex-core -p codex-image-generation-extension`. --- codex-rs/Cargo.lock | 4 - codex-rs/core/src/stream_events_utils.rs | 107 ++++++----- .../src/tools/handlers/extension_tools.rs | 174 ++++++++++++++++++ codex-rs/ext/extension-api/src/lib.rs | 1 + codex-rs/ext/image-generation/Cargo.toml | 5 - .../ext/image-generation/src/extension.rs | 14 +- codex-rs/ext/image-generation/src/tests.rs | 32 +--- codex-rs/ext/image-generation/src/tool.rs | 85 +-------- codex-rs/tools/src/lib.rs | 1 + codex-rs/tools/src/tool_call.rs | 17 ++ 10 files changed, 272 insertions(+), 168 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index ab279abe8d..d5434ad976 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3033,7 +3033,6 @@ name = "codex-image-generation-extension" version = "0.0.0" dependencies = [ "async-trait", - "base64 0.22.1", "codex-api", "codex-core", "codex-extension-api", @@ -3048,9 +3047,6 @@ dependencies = [ "schemars 0.8.22", "serde", "serde_json", - "tempfile", - "tokio", - "tracing", ] [[package]] diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index 1ee4b4b58f..bb1f5fba65 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -5,6 +5,7 @@ use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_extension_api::ExtensionData; use codex_protocol::config_types::ModeKind; +use codex_protocol::items::ImageGenerationItem; use codex_protocol::items::TurnItem; use codex_utils_stream_parser::strip_citations; use tokio_util::sync::CancellationToken; @@ -125,6 +126,68 @@ async fn save_image_generation_result( Ok(path) } +pub(crate) async fn persist_image_generation_item( + sess: &Session, + turn_context: &TurnContext, + image_item: &mut ImageGenerationItem, +) -> Option { + let session_id = sess.conversation_id.to_string(); + match save_image_generation_result( + &turn_context.config.codex_home, + &session_id, + &image_item.id, + &image_item.result, + ) + .await + { + Ok(path) => { + image_item.saved_path = Some(path.clone()); + Some(path) + } + Err(err) => { + let output_path = image_generation_artifact_path( + &turn_context.config.codex_home, + &session_id, + &image_item.id, + ); + let output_dir = output_path + .parent() + .unwrap_or_else(|| turn_context.config.codex_home.clone()); + tracing::warn!( + call_id = %image_item.id, + output_dir = %output_dir.display(), + "failed to save generated image: {err}" + ); + None + } + } +} + +pub(crate) async fn finalize_image_generation_item( + sess: &Session, + turn_context: &TurnContext, + image_item: &mut ImageGenerationItem, +) { + if persist_image_generation_item(sess, turn_context, image_item) + .await + .is_none() + { + return; + } + let session_id = sess.conversation_id.to_string(); + let image_output_path = + image_generation_artifact_path(&turn_context.config.codex_home, &session_id, ""); + let image_output_dir = image_output_path + .parent() + .unwrap_or_else(|| turn_context.config.codex_home.clone()); + let message: ResponseItem = ContextualUserFragment::into(ImageGenerationInstructions::new( + image_output_dir.display(), + image_output_path.display(), + )); + sess.record_conversation_items(turn_context, &[message]) + .await; +} + /// Persist a completed model response item and record any cited memory usage. pub(crate) async fn record_completed_response_item( sess: &Session, @@ -487,49 +550,7 @@ pub(crate) async fn handle_non_tool_response_item( } } if let TurnItem::ImageGeneration(image_item) = &mut turn_item { - let session_id = sess.conversation_id.to_string(); - match save_image_generation_result( - &turn_context.config.codex_home, - &session_id, - &image_item.id, - &image_item.result, - ) - .await - { - Ok(path) => { - image_item.saved_path = Some(path); - let image_output_path = image_generation_artifact_path( - &turn_context.config.codex_home, - &session_id, - "", - ); - let image_output_dir = image_output_path - .parent() - .unwrap_or_else(|| turn_context.config.codex_home.clone()); - let message: ResponseItem = - ContextualUserFragment::into(ImageGenerationInstructions::new( - image_output_dir.display(), - image_output_path.display(), - )); - sess.record_conversation_items(turn_context, &[message]) - .await; - } - Err(err) => { - let output_path = image_generation_artifact_path( - &turn_context.config.codex_home, - &session_id, - &image_item.id, - ); - let output_dir = output_path - .parent() - .unwrap_or_else(|| turn_context.config.codex_home.clone()); - tracing::warn!( - call_id = %image_item.id, - output_dir = %output_dir.display(), - "failed to save generated image: {err}" - ); - } - } + finalize_image_generation_item(sess, turn_context, image_item).await; } Some(turn_item) } diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index d04a6c520e..8a8854c147 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -4,15 +4,19 @@ use std::sync::Weak; use codex_protocol::items::TurnItem; use codex_tools::ConversationHistory; use codex_tools::ExtensionTurnItem; +use codex_tools::ImageGenerationCompletionFuture; use codex_tools::ToolCall as ExtensionToolCall; use codex_tools::ToolName; use codex_tools::ToolSpec; use codex_tools::TurnItemEmissionFuture; use codex_tools::TurnItemEmitter; +use crate::context::ContextualUserFragment; +use crate::context::ImageGenerationInstructions; use crate::function_tool::FunctionCallError; use crate::session::session::Session; use crate::session::turn_context::TurnContext; +use crate::stream_events_utils::persist_image_generation_item; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; @@ -90,6 +94,50 @@ impl TurnItemEmitter for CoreTurnItemEmitter { session.emit_turn_item_completed(turn.as_ref(), item).await; }) } + + fn image_generation_completed<'a>( + &'a self, + call_id: String, + prompt: String, + result: String, + ) -> ImageGenerationCompletionFuture<'a> { + Box::pin(async move { + let (Some(session), Some(turn)) = (self.session.upgrade(), self.turn.upgrade()) else { + return None; + }; + let mut item = codex_protocol::items::ImageGenerationItem { + id: call_id, + status: "completed".to_string(), + revised_prompt: Some(prompt), + result, + saved_path: None, + }; + let output_hint = + persist_image_generation_item(session.as_ref(), turn.as_ref(), &mut item) + .await + .map(|saved_path| { + let output_dir = saved_path + .parent() + .unwrap_or_else(|| turn.config.codex_home.clone()); + ImageGenerationInstructions::new(output_dir.display(), saved_path.display()) + .body() + }); + let started_item = codex_protocol::items::ImageGenerationItem { + id: item.id.clone(), + status: "in_progress".to_string(), + revised_prompt: None, + result: String::new(), + saved_path: None, + }; + session + .emit_turn_item_started(turn.as_ref(), &TurnItem::ImageGeneration(started_item)) + .await; + session + .emit_turn_item_completed(turn.as_ref(), TurnItem::ImageGeneration(item)) + .await; + output_hint + }) + } } async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { @@ -352,4 +400,130 @@ mod tests { assert_eq!(end.query, expected.query); assert_eq!(end.action, expected.action); } + + struct ImageGenerationExtensionExecutor { + output_hint: Arc>>, + } + + #[async_trait::async_trait] + impl codex_extension_api::ToolExecutor for ImageGenerationExtensionExecutor { + fn tool_name(&self) -> codex_tools::ToolName { + codex_tools::ToolName::namespaced("image_gen", "imagegen") + } + + fn spec(&self) -> codex_tools::ToolSpec { + codex_tools::ToolSpec::Function(codex_tools::ResponsesApiTool { + name: "imagegen".to_string(), + description: "Generates an image.".to_string(), + strict: false, + parameters: codex_tools::JsonSchema::default(), + output_schema: None, + defer_loading: None, + }) + } + + async fn handle( + &self, + call: codex_tools::ToolCall, + ) -> Result, codex_tools::FunctionCallError> { + let output_hint = call + .turn_item_emitter + .image_generation_completed( + call.call_id, + "A tiny blue square".to_string(), + "cG5n".to_string(), + ) + .await; + *self.output_hint.lock().await = output_hint; + Ok(Box::new(codex_tools::JsonToolOutput::new( + json!({ "ok": true }), + ))) + } + } + + #[tokio::test] + async fn image_generation_publication_is_finalized_by_core() { + let output_hint = Arc::new(Mutex::new(None)); + let handler = ExtensionToolAdapter::new(Arc::new(ImageGenerationExtensionExecutor { + output_hint: Arc::clone(&output_hint), + })); + let (session, turn, rx) = crate::session::tests::make_session_and_context_with_rx().await; + let expected_path = crate::stream_events_utils::image_generation_artifact_path( + &turn.config.codex_home, + &session.conversation_id.to_string(), + "call-image", + ); + let invocation = ToolInvocation { + session, + turn, + cancellation_token: tokio_util::sync::CancellationToken::new(), + tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())), + call_id: "call-image".to_string(), + tool_name: codex_tools::ToolName::namespaced("image_gen", "imagegen"), + source: ToolCallSource::Direct, + payload: ToolPayload::Function { + arguments: "{}".to_string(), + }, + }; + + crate::tools::registry::ToolExecutor::handle(&handler, invocation) + .await + .expect("extension call should succeed"); + + let started = rx.recv().await.expect("item started event"); + let EventMsg::ItemStarted(started) = started.msg else { + panic!("expected item started event"); + }; + let TurnItem::ImageGeneration(started_item) = started.item else { + panic!("expected image generation item"); + }; + let begin = rx.recv().await.expect("legacy image start event"); + assert!(matches!(begin.msg, EventMsg::ImageGenerationBegin(_))); + let completed = rx.recv().await.expect("item completed event"); + let EventMsg::ItemCompleted(completed) = completed.msg else { + panic!("expected item completed event"); + }; + let TurnItem::ImageGeneration(completed_item) = completed.item else { + panic!("expected image generation item"); + }; + let end = rx.recv().await.expect("legacy image end event"); + assert!(matches!(end.msg, EventMsg::ImageGenerationEnd(_))); + + assert_eq!( + started_item, + codex_protocol::items::ImageGenerationItem { + id: "call-image".to_string(), + status: "in_progress".to_string(), + revised_prompt: None, + result: String::new(), + saved_path: None, + } + ); + assert_eq!( + completed_item, + codex_protocol::items::ImageGenerationItem { + id: "call-image".to_string(), + status: "completed".to_string(), + revised_prompt: Some("A tiny blue square".to_string()), + result: "cG5n".to_string(), + saved_path: Some(expected_path.clone()), + } + ); + assert_eq!( + std::fs::read(&expected_path).expect("generated artifact should be saved"), + b"png" + ); + assert_eq!( + *output_hint.lock().await, + Some(format!( + "Generated images are saved to {} as {} by default.\n\ + If you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it.", + expected_path + .parent() + .expect("generated image path should have a parent") + .display(), + expected_path.display(), + )) + ); + } } diff --git a/codex-rs/ext/extension-api/src/lib.rs b/codex-rs/ext/extension-api/src/lib.rs index 4705581c8a..2f00a8dd98 100644 --- a/codex-rs/ext/extension-api/src/lib.rs +++ b/codex-rs/ext/extension-api/src/lib.rs @@ -13,6 +13,7 @@ pub use capabilities::ResponseItemInjector; pub use codex_tools::ConversationHistory; pub use codex_tools::ExtensionTurnItem; pub use codex_tools::FunctionCallError; +pub use codex_tools::ImageGenerationCompletionFuture; pub use codex_tools::JsonToolOutput; pub use codex_tools::NoopTurnItemEmitter; pub use codex_tools::ResponsesApiTool; diff --git a/codex-rs/ext/image-generation/Cargo.toml b/codex-rs/ext/image-generation/Cargo.toml index d4f2cb50de..995f1786bc 100644 --- a/codex-rs/ext/image-generation/Cargo.toml +++ b/codex-rs/ext/image-generation/Cargo.toml @@ -14,7 +14,6 @@ workspace = true [dependencies] async-trait = { workspace = true } -base64 = { workspace = true } codex-api = { workspace = true } codex-core = { workspace = true } codex-extension-api = { workspace = true } @@ -28,10 +27,6 @@ http = { workspace = true } schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } -tokio = { workspace = true, features = ["fs"] } -tracing = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } -tempfile = { workspace = true } -tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/codex-rs/ext/image-generation/src/extension.rs b/codex-rs/ext/image-generation/src/extension.rs index 8f0b09f8cb..f7a9f2406f 100644 --- a/codex-rs/ext/image-generation/src/extension.rs +++ b/codex-rs/ext/image-generation/src/extension.rs @@ -1,4 +1,3 @@ -use std::path::PathBuf; use std::sync::Arc; use codex_core::config::Config; @@ -17,7 +16,6 @@ use codex_model_provider_info::ModelProviderInfo; use crate::backend::CodexImagesBackend; use crate::tool::ImageGenerationTool; -use crate::tool::generated_image_output_dir; #[derive(Clone)] struct ImageGenerationExtension { @@ -28,7 +26,6 @@ struct ImageGenerationExtension { struct ImageGenerationExtensionConfig { enabled: bool, provider: ModelProviderInfo, - codex_home: PathBuf, } impl From<&Config> for ImageGenerationExtensionConfig { @@ -38,7 +35,6 @@ impl From<&Config> for ImageGenerationExtensionConfig { enabled: config.features.enabled(Feature::ImageGenExt) && config.model_provider.is_openai(), provider: config.model_provider.clone(), - codex_home: config.codex_home.to_path_buf(), } } } @@ -80,13 +76,9 @@ impl ToolContributor for ImageGenerationExtension { return Vec::new(); } - vec![Arc::new(ImageGenerationTool::new( - CodexImagesBackend::new(create_model_provider( - config.provider.clone(), - Some(self.auth_manager.clone()), - )), - generated_image_output_dir(&config.codex_home, thread_store.level_id()), - ))] + vec![Arc::new(ImageGenerationTool::new(CodexImagesBackend::new( + create_model_provider(config.provider.clone(), Some(self.auth_manager.clone())), + )))] } } diff --git a/codex-rs/ext/image-generation/src/tests.rs b/codex-rs/ext/image-generation/src/tests.rs index 1b56270ed3..f866a9018b 100644 --- a/codex-rs/ext/image-generation/src/tests.rs +++ b/codex-rs/ext/image-generation/src/tests.rs @@ -20,14 +20,13 @@ use super::GeneratedImageOutput; use super::ImageRequest; use super::ImagegenAction; use super::ImagegenArgs; -use super::generated_image_output_dir; use super::imagegen_tool_spec; -use super::persist_generated_image; use super::request_for_action; use crate::IMAGE_GEN_NAMESPACE; use crate::IMAGEGEN_TOOL_NAME; const RESULT: &str = "cG5n"; +const OUTPUT_HINT: &str = "Generated images are saved to /tmp as /tmp/call-1.png by default."; #[test] fn uses_reserved_image_gen_namespace() { @@ -55,15 +54,11 @@ fn generate_uses_fixed_request_defaults() { ); } -#[tokio::test] -async fn generated_output_returns_image_input_and_persists_artifact() { - let tempdir = tempfile::tempdir().expect("tempdir"); - let output_hint = persist_generated_image(tempdir.path(), "call-1", RESULT) - .await - .expect("generated image should persist"); +#[test] +fn generated_output_returns_image_input_and_output_hint() { let output = GeneratedImageOutput { result: RESULT.to_string(), - output_hint: Some(output_hint), + output_hint: Some(OUTPUT_HINT.to_string()), }; let ResponseInputItem::FunctionCallOutput { @@ -84,19 +79,10 @@ async fn generated_output_returns_image_input_and_persists_artifact() { detail: Some(DEFAULT_IMAGE_DETAIL), }, FunctionCallOutputContentItem::InputText { - text: format!( - "Generated images are saved to {} as {} by default.\n\ - If you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it.", - tempdir.path().display(), - tempdir.path().join("call-1.png").display(), - ), + text: OUTPUT_HINT.to_string(), }, ] ); - assert_eq!( - std::fs::read(tempdir.path().join("call-1.png")).expect("saved generated image"), - b"png" - ); } #[test] @@ -265,14 +251,6 @@ fn edit_without_image_history_returns_tool_error() { ); } -#[test] -fn generated_image_output_dir_is_scoped_to_sanitized_thread_id() { - assert_eq!( - generated_image_output_dir(std::path::Path::new("/tmp/codex-home"), "thread/1"), - std::path::PathBuf::from("/tmp/codex-home/generated_images/thread_1") - ); -} - fn args(action: ImagegenAction, prompt: &str) -> ImagegenArgs { ImagegenArgs { prompt: prompt.to_string(), diff --git a/codex-rs/ext/image-generation/src/tool.rs b/codex-rs/ext/image-generation/src/tool.rs index fa1614cd47..75a79db2bf 100644 --- a/codex-rs/ext/image-generation/src/tool.rs +++ b/codex-rs/ext/image-generation/src/tool.rs @@ -1,8 +1,3 @@ -use std::path::Path; -use std::path::PathBuf; - -use base64::Engine; -use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_api::ImageBackground; use codex_api::ImageEditRequest; use codex_api::ImageGenerationRequest; @@ -41,21 +36,16 @@ use crate::backend::CodexImagesBackend; const IMAGE_MODEL: &str = "gpt-image-2"; const MAX_EDIT_IMAGES: usize = 5; const IMAGEGEN_DESCRIPTION: &str = include_str!("../imagegen_description.md"); -const GENERATED_IMAGE_ARTIFACTS_DIR: &str = "generated_images"; #[derive(Clone)] pub(crate) struct ImageGenerationTool { backend: CodexImagesBackend, - output_dir: PathBuf, } impl ImageGenerationTool { /// Creates an image-generation tool backed by an image API executor. - pub(crate) fn new(backend: CodexImagesBackend, output_dir: PathBuf) -> Self { - Self { - backend, - output_dir, - } + pub(crate) fn new(backend: CodexImagesBackend) -> Self { + Self { backend } } } @@ -94,7 +84,6 @@ impl ToolExecutor for ImageGenerationTool { async fn handle(&self, call: ToolCall) -> Result, FunctionCallError> { let args = parse_args(&call)?; let request = request_for_action(&args, call.conversation_history.items())?; - let response = match request { ImageRequest::Generate(request) => self.backend.generate(request).await, ImageRequest::Edit(request) => self.backend.edit(request).await, @@ -107,18 +96,10 @@ impl ToolExecutor for ImageGenerationTool { "image generation returned no image data".to_string(), )); }; - let output_hint = - match persist_generated_image(&self.output_dir, &call.call_id, &result).await { - Ok(output_hint) => Some(output_hint), - Err(err) => { - tracing::warn!( - call_id = %call.call_id, - output_dir = %self.output_dir.display(), - "failed to save generated image: {err}" - ); - None - } - }; + let output_hint = call + .turn_item_emitter + .image_generation_completed(call.call_id.clone(), args.prompt, result.clone()) + .await; Ok(Box::new(GeneratedImageOutput { result, output_hint, @@ -268,58 +249,6 @@ fn parse_args(call: &ToolCall) -> Result { .map_err(|err| FunctionCallError::RespondToModel(err.to_string())) } -/// Resolves where generated images for one thread are persisted by the extension. -pub(crate) fn generated_image_output_dir(codex_home: &Path, thread_id: &str) -> PathBuf { - codex_home - .join(GENERATED_IMAGE_ARTIFACTS_DIR) - .join(sanitize_path_component(thread_id)) -} - -fn generated_image_output_path(output_dir: &Path, call_id: &str) -> PathBuf { - output_dir.join(format!("{}.png", sanitize_path_component(call_id))) -} - -fn sanitize_path_component(value: &str) -> String { - let sanitized: String = value - .chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { - ch - } else { - '_' - } - }) - .collect(); - if sanitized.is_empty() { - "generated_image".to_string() - } else { - sanitized - } -} - -async fn persist_generated_image( - output_dir: &Path, - call_id: &str, - result: &str, -) -> Result { - let bytes = BASE64_STANDARD - .decode(result.trim().as_bytes()) - .map_err(|err| format!("invalid image generation payload: {err}"))?; - tokio::fs::create_dir_all(output_dir) - .await - .map_err(|err| err.to_string())?; - tokio::fs::write(generated_image_output_path(output_dir, call_id), bytes) - .await - .map_err(|err| err.to_string())?; - - Ok(format!( - "Generated images are saved to {} as {} by default.\n\ - If you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it.", - output_dir.display(), - generated_image_output_path(output_dir, call_id).display(), - )) -} - /// Builds the namespace function schema exposed to the model. fn imagegen_tool_spec() -> ToolSpec { let mut schema_value = serde_json::to_value( @@ -369,7 +298,7 @@ impl ToolOutput for GeneratedImageOutput { true } - /// Returns generated bytes and persisted-artifact context for the model's follow-up response. + /// Returns generated bytes and persisted-artifact context for model follow-up. fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem { let mut content = vec![FunctionCallOutputContentItem::InputImage { image_url: format!("data:image/png;base64,{}", self.result), diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index 91fa5830a3..e09526048d 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -63,6 +63,7 @@ pub use responses_api::mcp_tool_to_responses_api_tool; pub use responses_api::tool_definition_to_responses_api_tool; pub use tool_call::ConversationHistory; pub use tool_call::ExtensionTurnItem; +pub use tool_call::ImageGenerationCompletionFuture; pub use tool_call::NoopTurnItemEmitter; pub use tool_call::ToolCall; pub use tool_call::TurnItemEmissionFuture; diff --git a/codex-rs/tools/src/tool_call.rs b/codex-rs/tools/src/tool_call.rs index 3c04e216a6..9bbe00a6c4 100644 --- a/codex-rs/tools/src/tool_call.rs +++ b/codex-rs/tools/src/tool_call.rs @@ -29,6 +29,10 @@ impl ConversationHistory { /// Future returned when an extension tool emits a visible turn-item lifecycle event. pub type TurnItemEmissionFuture<'a> = Pin + Send + 'a>>; +/// Future returned when an image-generation extension publishes completed image bytes. +pub type ImageGenerationCompletionFuture<'a> = + Pin> + Send + 'a>>; + /// Visible turn items that an extension fully owns and may emit as-is. /// /// Add only item kinds that require no additional host finalization before @@ -48,6 +52,19 @@ pub trait TurnItemEmitter: Send + Sync { /// Emits the completion of one visible turn item. fn emit_completed<'a>(&'a self, item: ExtensionTurnItem) -> TurnItemEmissionFuture<'a>; + + /// Publishes image bytes for host persistence and visible completion. + /// + /// Returns persisted-artifact context for the extension's model-facing + /// function output when the host saves the generated image successfully. + fn image_generation_completed<'a>( + &'a self, + _call_id: String, + _prompt: String, + _result: String, + ) -> ImageGenerationCompletionFuture<'a> { + Box::pin(std::future::ready(None)) + } } /// Turn-item emitter used when a caller does not expose visible item emission. From cb9178e8b30293d695d0bebea09c62b21116e9de Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Fri, 29 May 2026 11:01:44 -0700 Subject: [PATCH 27/40] Add Windows sandbox provisioning setup command (#24831) ## Why Some Windows users do not have local admin access, so they cannot complete the elevated portion of the Windows sandbox setup when Codex first needs it. This adds an alpha provisioning path that an admin or IT deployment script can run ahead of time for the Codex user. The intended managed-deployment shape is: ```powershell codex sandbox setup --elevated --user "$env:COMPUTERNAME\Alice" --codex-home "C:\Users\Alice\.codex" ``` `--elevated` is treated as the requested sandbox setup level, not as proof that the process is elevated. The Windows sandbox setup orchestration still checks that the caller is actually elevated before launching the helper without a UAC prompt. ## What changed - Added `codex sandbox setup --elevated` with explicit user selection via either `--current-user` or `--user ... --codex-home ...`. - Moved the CLI implementation into `cli/src/sandbox_setup.rs` instead of growing `cli/src/main.rs`. - Added a Windows sandbox `ProvisionOnly` helper mode that runs the elevation-required provisioning work without requiring a workspace cwd or runtime sandbox policy. - Reused the existing elevated helper path for creating/updating sandbox users, configuring firewall/WFP rules, and applying sandbox directory ACLs. - Persisted `windows.sandbox = "elevated"` into the target `CODEX_HOME` so the desktop app does not show the initial sandbox setup banner after pre-provisioning succeeds. ## Validation - `cargo fmt -p codex-windows-sandbox -p codex-core -p codex-cli` - `cargo test -p codex-cli sandbox_setup --target-dir target\sandbox-setup-check` - `cargo test -p codex-windows-sandbox payload_accepts_provision_only_mode --target-dir target\sandbox-setup-check` - `git diff --check` - Manual Windows alpha flow with a standard local user (`Mandi Lavida`): ran the new setup command from an admin shell, verified the target `.codex` contents, sandbox marker/secrets, ACLs, firewall rules, and desktop startup without the sandbox setup banner once experimental network proxy requirements were disabled. ## Notes This intentionally does not solve later elevated update coordination for IT-managed deployments. The setup command can still apply provisioning updates when run again, but a broader coordination/process story is out of scope for this alpha. --- codex-rs/cli/src/main.rs | 12 + codex-rs/cli/src/sandbox_setup.rs | 206 ++++++++++++ codex-rs/core/src/windows_sandbox.rs | 10 + .../src/bin/setup_main/win.rs | 300 +++++++++++------- codex-rs/windows-sandbox-rs/src/lib.rs | 2 + codex-rs/windows-sandbox-rs/src/setup.rs | 49 +++ .../windows-sandbox-rs/src/setup_error.rs | 3 + 7 files changed, 469 insertions(+), 113 deletions(-) create mode 100644 codex-rs/cli/src/sandbox_setup.rs diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 8dfe6dc33f..e5660301b5 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -50,6 +50,8 @@ mod marketplace_cmd; mod mcp_cmd; mod plugin_cmd; mod remote_control_cmd; +#[cfg(target_os = "windows")] +mod sandbox_setup; mod state_db_recovery; #[cfg(not(windows))] mod wsl_paths; @@ -1250,6 +1252,16 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { .await?; } Some(Subcommand::Sandbox(mut sandbox_cli)) => { + #[cfg(target_os = "windows")] + if let Some(setup_cli) = sandbox_setup::parse_setup_command(&sandbox_cli.command)? { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "sandbox setup", + )?; + sandbox_setup::run(setup_cli).await?; + return Ok(()); + } reject_remote_mode_for_subcommand( root_remote.as_deref(), root_remote_auth_token_env.as_deref(), diff --git a/codex-rs/cli/src/sandbox_setup.rs b/codex-rs/cli/src/sandbox_setup.rs new file mode 100644 index 0000000000..958411c428 --- /dev/null +++ b/codex-rs/cli/src/sandbox_setup.rs @@ -0,0 +1,206 @@ +use std::path::PathBuf; + +use clap::ArgAction; +use clap::ArgGroup; +use clap::Parser; +use codex_core::config::edit::ConfigEditsBuilder; +use codex_core::config::find_codex_home; + +#[derive(Debug, Parser)] +#[command(group( + ArgGroup::new("sandbox_user") + .required(true) + .args(["user", "current_user"]) +))] +pub(crate) struct SandboxSetupCommand { + /// Set up the elevated Windows sandbox. + #[arg(long = "elevated", action = ArgAction::SetTrue)] + elevated_sandbox_level: bool, + + /// Windows user that will run Codex after managed deployment. + #[arg( + long = "user", + value_name = "USER", + conflicts_with = "current_user", + requires = "codex_home" + )] + user: Option, + + /// Use the current Windows user as the Codex user. + #[arg( + long = "current-user", + default_value_t = false, + conflicts_with = "user" + )] + current_user: bool, + + /// CODEX_HOME for the Codex user. Required with --user. + #[arg(long = "codex-home", value_name = "DIR")] + codex_home: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SandboxSetupLevel { + Elevated, +} + +impl SandboxSetupCommand { + fn setup_level(&self) -> anyhow::Result { + if self.elevated_sandbox_level { + Ok(SandboxSetupLevel::Elevated) + } else { + anyhow::bail!("`codex sandbox setup` currently requires --elevated"); + } + } +} + +pub(crate) async fn run(cmd: SandboxSetupCommand) -> anyhow::Result<()> { + match cmd.setup_level()? { + SandboxSetupLevel::Elevated => run_elevated(cmd).await, + } +} + +pub(crate) fn parse_setup_command( + sandbox_command: &[String], +) -> anyhow::Result> { + if sandbox_command + .first() + .is_none_or(|command| command != "setup") + { + return Ok(None); + } + + SandboxSetupCommand::try_parse_from(sandbox_command.iter().map(String::as_str)) + .map(Some) + .map_err(anyhow::Error::from) +} + +async fn run_elevated(cmd: SandboxSetupCommand) -> anyhow::Result<()> { + let identity = resolve_sandbox_setup_identity(&cmd)?; + + codex_core::windows_sandbox::run_elevated_provisioning_setup( + identity.codex_home.as_path(), + identity.real_user.as_str(), + )?; + ConfigEditsBuilder::new(identity.codex_home.as_path()) + .set_windows_sandbox_mode("elevated") + .apply() + .await + .map_err(|err| { + anyhow::anyhow!( + "sandbox provisioning succeeded, but failed to persist elevated sandbox config: {err}" + ) + })?; + + println!( + "Windows elevated sandbox setup completed for {} at {}.", + identity.real_user, + identity.codex_home.display() + ); + Ok(()) +} + +struct SandboxSetupIdentity { + real_user: String, + codex_home: PathBuf, +} + +fn resolve_sandbox_setup_identity( + cmd: &SandboxSetupCommand, +) -> anyhow::Result { + if cmd.current_user { + let real_user = std::env::var("USERNAME") + .or_else(|_| std::env::var("USER")) + .map_err(|err| { + anyhow::anyhow!("failed to determine current user from environment: {err}") + })?; + let codex_home = match cmd.codex_home.clone() { + Some(codex_home) => codex_home, + None => find_codex_home()?.to_path_buf(), + }; + return Ok(SandboxSetupIdentity { + real_user, + codex_home, + }); + } + + let real_user = cmd + .user + .clone() + .ok_or_else(|| anyhow::anyhow!("--user or --current-user is required"))?; + let codex_home = cmd + .codex_home + .clone() + .ok_or_else(|| anyhow::anyhow!("--codex-home is required with --user"))?; + Ok(SandboxSetupIdentity { + real_user, + codex_home, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_managed_user_identity() { + let command = SandboxSetupCommand::try_parse_from([ + "setup", + "--elevated", + "--user", + "DOMAIN\\alice", + "--codex-home", + r"C:\Users\alice\.codex", + ]) + .expect("parse"); + + assert!(command.elevated_sandbox_level); + assert_eq!(command.user.as_deref(), Some(r"DOMAIN\alice")); + assert!(!command.current_user); + assert_eq!( + command.codex_home.as_deref(), + Some(std::path::Path::new(r"C:\Users\alice\.codex")) + ); + } + + #[test] + fn requires_explicit_user_identity() { + let err = SandboxSetupCommand::try_parse_from(["setup", "--elevated"]) + .expect_err("parse should fail"); + + assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument); + } + + #[test] + fn requires_codex_home_for_managed_user() { + let err = + SandboxSetupCommand::try_parse_from(["setup", "--elevated", "--user", "DOMAIN\\alice"]) + .expect_err("parse should fail"); + + assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument); + } + + #[test] + fn parses_setup_from_sandbox_command_args() { + let command = parse_setup_command(&[ + "setup".to_string(), + "--elevated".to_string(), + "--user".to_string(), + r"DOMAIN\alice".to_string(), + "--codex-home".to_string(), + r"C:\Users\alice\.codex".to_string(), + ]) + .expect("parse") + .expect("setup command"); + + assert_eq!(command.user.as_deref(), Some(r"DOMAIN\alice")); + } + + #[test] + fn ignores_non_setup_sandbox_command_args() { + let command = + parse_setup_command(&["echo".to_string(), "hello".to_string()]).expect("parse"); + + assert!(command.is_none()); + } +} diff --git a/codex-rs/core/src/windows_sandbox.rs b/codex-rs/core/src/windows_sandbox.rs index 19b404fb71..a1ff5dda09 100644 --- a/codex-rs/core/src/windows_sandbox.rs +++ b/codex-rs/core/src/windows_sandbox.rs @@ -169,6 +169,11 @@ pub fn run_elevated_setup( ) } +#[cfg(target_os = "windows")] +pub fn run_elevated_provisioning_setup(codex_home: &Path, real_user: &str) -> anyhow::Result<()> { + codex_windows_sandbox::run_elevated_provisioning_setup(codex_home, real_user) +} + #[cfg(not(target_os = "windows"))] pub fn run_elevated_setup( _permission_profile: &PermissionProfile, @@ -180,6 +185,11 @@ pub fn run_elevated_setup( anyhow::bail!("elevated Windows sandbox setup is only supported on Windows") } +#[cfg(not(target_os = "windows"))] +pub fn run_elevated_provisioning_setup(_codex_home: &Path, _real_user: &str) -> anyhow::Result<()> { + anyhow::bail!("elevated Windows sandbox setup is only supported on Windows") +} + #[cfg(target_os = "windows")] pub fn run_legacy_setup_preflight( permission_profile: &PermissionProfile, diff --git a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs index 9b178fdca9..41fa2f720e 100644 --- a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs @@ -105,6 +105,7 @@ struct Payload { enum SetupMode { #[default] Full, + ProvisionOnly, ReadAclsOnly, } @@ -476,6 +477,7 @@ fn real_main() -> Result<()> { fn run_setup(payload: &Payload, log: &mut dyn Write, sbx_dir: &Path) -> Result<()> { match payload.mode { SetupMode::ReadAclsOnly => run_read_acl_only(payload, log), + SetupMode::ProvisionOnly => run_provision_only(payload, log, sbx_dir), SetupMode::Full => run_setup_full(payload, log, sbx_dir), } } @@ -543,31 +545,182 @@ fn run_read_acl_only(payload: &Payload, log: &mut dyn Write) -> Result<()> { Ok(()) } +fn provision_and_hide_sandbox_users( + payload: &Payload, + log: &mut dyn Write, + sbx_dir: &Path, +) -> Result<()> { + let provision_result = provision_sandbox_users( + &payload.codex_home, + &payload.offline_username, + &payload.online_username, + &payload.proxy_ports, + payload.allow_local_binding, + log, + ); + if let Err(err) = provision_result { + if extract_setup_failure(&err).is_some() { + return Err(err); + } + return Err(anyhow::Error::new(SetupFailure::new( + SetupErrorCode::HelperUserProvisionFailed, + format!("provision sandbox users failed: {err}"), + ))); + } + let users = vec![ + payload.offline_username.clone(), + payload.online_username.clone(), + ]; + hide_newly_created_users(&users, sbx_dir); + Ok(()) +} + +fn configure_offline_sandbox_network( + payload: &Payload, + offline_sid_str: &str, + log: &mut dyn Write, +) -> Result<()> { + let proxy_allowlist_result = firewall::ensure_offline_proxy_allowlist( + offline_sid_str, + &payload.proxy_ports, + payload.allow_local_binding, + log, + ); + if let Err(err) = proxy_allowlist_result { + if extract_setup_failure(&err).is_some() { + return Err(err); + } + return Err(anyhow::Error::new(SetupFailure::new( + SetupErrorCode::HelperFirewallRuleCreateOrAddFailed, + format!("ensure offline proxy allowlist failed: {err}"), + ))); + } + let firewall_result = firewall::ensure_offline_outbound_block(offline_sid_str, log); + if let Err(err) = firewall_result { + if extract_setup_failure(&err).is_some() { + return Err(err); + } + return Err(anyhow::Error::new(SetupFailure::new( + SetupErrorCode::HelperFirewallRuleCreateOrAddFailed, + format!("ensure offline outbound block failed: {err}"), + ))); + } + install_wfp_filters( + &payload.codex_home, + &payload.offline_username, + payload.otel.as_ref(), + |message| { + let _ = log_line(log, message); + }, + ); + Ok(()) +} + +fn lock_persistent_sandbox_dirs( + payload: &Payload, + sandbox_group_sid: &[u8], + log: &mut dyn Write, +) -> Result<()> { + lock_sandbox_dir( + &sandbox_dir(&payload.codex_home), + &payload.real_user, + sandbox_group_sid, + GRANT_ACCESS, + FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE, + FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE, + log, + ) + .map_err(|err| { + anyhow::Error::new(SetupFailure::new( + SetupErrorCode::HelperSandboxLockFailed, + format!( + "lock sandbox dir {} failed: {err}", + sandbox_dir(&payload.codex_home).display() + ), + )) + })?; + lock_sandbox_dir( + &sandbox_secrets_dir(&payload.codex_home), + &payload.real_user, + sandbox_group_sid, + DENY_ACCESS, + FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE, + FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE, + log, + ) + .map_err(|err| { + anyhow::Error::new(SetupFailure::new( + SetupErrorCode::HelperSandboxLockFailed, + format!( + "lock sandbox secrets dir {} failed: {err}", + sandbox_secrets_dir(&payload.codex_home).display() + ), + )) + })?; + let legacy_users = sandbox_dir(&payload.codex_home).join("sandbox_users.json"); + if legacy_users.exists() { + let _ = std::fs::remove_file(&legacy_users); + } + Ok(()) +} + +fn lock_sandbox_bin_dir( + payload: &Payload, + sandbox_group_sid: &[u8], + log: &mut dyn Write, +) -> Result<()> { + lock_sandbox_dir( + &sandbox_bin_dir(&payload.codex_home), + &payload.real_user, + sandbox_group_sid, + GRANT_ACCESS, + FILE_GENERIC_READ | FILE_GENERIC_EXECUTE, + FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE, + log, + ) + .map_err(|err| { + anyhow::Error::new(SetupFailure::new( + SetupErrorCode::HelperSandboxLockFailed, + format!( + "lock sandbox bin dir {} failed: {err}", + sandbox_bin_dir(&payload.codex_home).display() + ), + )) + }) +} + +fn run_provision_only(payload: &Payload, log: &mut dyn Write, sbx_dir: &Path) -> Result<()> { + provision_and_hide_sandbox_users(payload, log, sbx_dir)?; + let offline_sid = resolve_sid(&payload.offline_username).map_err(|err| { + anyhow::Error::new(SetupFailure::new( + SetupErrorCode::HelperSidResolveFailed, + format!( + "resolve SID for offline user {} failed: {err}", + payload.offline_username + ), + )) + })?; + let offline_sid_str = string_from_sid_bytes(&offline_sid).map_err(anyhow::Error::msg)?; + + let sandbox_group_sid = resolve_sandbox_users_group_sid().map_err(|err| { + anyhow::Error::new(SetupFailure::new( + SetupErrorCode::HelperSidResolveFailed, + format!("resolve sandbox users group SID failed: {err}"), + )) + })?; + + configure_offline_sandbox_network(payload, &offline_sid_str, log)?; + + lock_sandbox_bin_dir(payload, &sandbox_group_sid, log)?; + lock_persistent_sandbox_dirs(payload, &sandbox_group_sid, log)?; + log_note("setup provisioning binary completed", Some(sbx_dir)); + Ok(()) +} + fn run_setup_full(payload: &Payload, log: &mut dyn Write, sbx_dir: &Path) -> Result<()> { let refresh_only = payload.refresh_only; if !refresh_only { - let provision_result = provision_sandbox_users( - &payload.codex_home, - &payload.offline_username, - &payload.online_username, - &payload.proxy_ports, - payload.allow_local_binding, - log, - ); - if let Err(err) = provision_result { - if extract_setup_failure(&err).is_some() { - return Err(err); - } - return Err(anyhow::Error::new(SetupFailure::new( - SetupErrorCode::HelperUserProvisionFailed, - format!("provision sandbox users failed: {err}"), - ))); - } - let users = vec![ - payload.offline_username.clone(), - payload.online_username.clone(), - ]; - hide_newly_created_users(&users, sbx_dir); + provision_and_hide_sandbox_users(payload, log, sbx_dir)?; } let offline_sid = resolve_sid(&payload.offline_username).map_err(|err| { anyhow::Error::new(SetupFailure::new( @@ -597,39 +750,7 @@ fn run_setup_full(payload: &Payload, log: &mut dyn Write, sbx_dir: &Path) -> Res let mut refresh_errors: Vec = Vec::new(); if !refresh_only { - let proxy_allowlist_result = firewall::ensure_offline_proxy_allowlist( - &offline_sid_str, - &payload.proxy_ports, - payload.allow_local_binding, - log, - ); - if let Err(err) = proxy_allowlist_result { - if extract_setup_failure(&err).is_some() { - return Err(err); - } - return Err(anyhow::Error::new(SetupFailure::new( - SetupErrorCode::HelperFirewallRuleCreateOrAddFailed, - format!("ensure offline proxy allowlist failed: {err}"), - ))); - } - let firewall_result = firewall::ensure_offline_outbound_block(&offline_sid_str, log); - if let Err(err) = firewall_result { - if extract_setup_failure(&err).is_some() { - return Err(err); - } - return Err(anyhow::Error::new(SetupFailure::new( - SetupErrorCode::HelperFirewallRuleCreateOrAddFailed, - format!("ensure offline outbound block failed: {err}"), - ))); - } - install_wfp_filters( - &payload.codex_home, - &payload.offline_username, - payload.otel.as_ref(), - |message| { - let _ = log_line(log, message); - }, - ); + configure_offline_sandbox_network(payload, &offline_sid_str, log)?; } // Deny-read ACEs must be present before the sandboxed command starts. Apply @@ -865,24 +986,7 @@ fn run_setup_full(payload: &Payload, log: &mut dyn Write, sbx_dir: &Path) -> Res } } - lock_sandbox_dir( - &sandbox_bin_dir(&payload.codex_home), - &payload.real_user, - &sandbox_group_sid, - GRANT_ACCESS, - FILE_GENERIC_READ | FILE_GENERIC_EXECUTE, - FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE, - log, - ) - .map_err(|err| { - anyhow::Error::new(SetupFailure::new( - SetupErrorCode::HelperSandboxLockFailed, - format!( - "lock sandbox bin dir {} failed: {err}", - sandbox_bin_dir(&payload.codex_home).display() - ), - )) - })?; + lock_sandbox_bin_dir(payload, &sandbox_group_sid, log)?; if refresh_only { log_line( @@ -895,46 +999,7 @@ fn run_setup_full(payload: &Payload, log: &mut dyn Write, sbx_dir: &Path) -> Res )?; } if !refresh_only { - lock_sandbox_dir( - &sandbox_dir(&payload.codex_home), - &payload.real_user, - &sandbox_group_sid, - GRANT_ACCESS, - FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE, - FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE, - log, - ) - .map_err(|err| { - anyhow::Error::new(SetupFailure::new( - SetupErrorCode::HelperSandboxLockFailed, - format!( - "lock sandbox dir {} failed: {err}", - sandbox_dir(&payload.codex_home).display() - ), - )) - })?; - lock_sandbox_dir( - &sandbox_secrets_dir(&payload.codex_home), - &payload.real_user, - &sandbox_group_sid, - DENY_ACCESS, - FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE, - FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE, - log, - ) - .map_err(|err| { - anyhow::Error::new(SetupFailure::new( - SetupErrorCode::HelperSandboxLockFailed, - format!( - "lock sandbox secrets dir {} failed: {err}", - sandbox_secrets_dir(&payload.codex_home).display() - ), - )) - })?; - let legacy_users = sandbox_dir(&payload.codex_home).join("sandbox_users.json"); - if legacy_users.exists() { - let _ = std::fs::remove_file(&legacy_users); - } + lock_persistent_sandbox_dirs(payload, &sandbox_group_sid, log)?; } unsafe { @@ -986,6 +1051,15 @@ mod tests { assert_eq!(payload.otel, None); } + #[test] + fn payload_accepts_provision_only_mode() { + let mut payload = payload_json(); + payload["mode"] = json!("provision-only"); + let payload: Payload = serde_json::from_value(payload).expect("payload"); + + assert_eq!(payload.mode, super::SetupMode::ProvisionOnly); + } + #[test] fn payload_accepts_otel_settings() { let mut payload = payload_json(); diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index d05bb9554c..3bd2639868 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -241,6 +241,8 @@ pub use setup::SandboxSetupRequest; #[cfg(target_os = "windows")] pub use setup::SetupRootOverrides; #[cfg(target_os = "windows")] +pub use setup::run_elevated_provisioning_setup; +#[cfg(target_os = "windows")] pub use setup::run_elevated_setup; #[cfg(target_os = "windows")] pub use setup::run_setup_refresh; diff --git a/codex-rs/windows-sandbox-rs/src/setup.rs b/codex-rs/windows-sandbox-rs/src/setup.rs index bbb3881968..a0f46fe3f8 100644 --- a/codex-rs/windows-sandbox-rs/src/setup.rs +++ b/codex-rs/windows-sandbox-rs/src/setup.rs @@ -203,6 +203,7 @@ fn run_setup_refresh_inner( allow_local_binding: offline_proxy_settings.allow_local_binding, otel: None, real_user: std::env::var("USERNAME").unwrap_or_else(|_| "Administrators".to_string()), + mode: SetupMode::Full, refresh_only: true, }; let json = serde_json::to_vec(&payload)?; @@ -496,10 +497,18 @@ struct ElevationPayload { allow_local_binding: bool, otel: Option, real_user: String, + mode: SetupMode, #[serde(default)] refresh_only: bool, } +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "kebab-case")] +enum SetupMode { + Full, + ProvisionOnly, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct OfflineProxySettings { pub proxy_ports: Vec, @@ -808,6 +817,7 @@ pub fn run_elevated_setup( allow_local_binding: offline_proxy_settings.allow_local_binding, real_user: std::env::var("USERNAME").unwrap_or_else(|_| "Administrators".to_string()), otel: codex_otel::global_statsig_metrics_settings(), + mode: SetupMode::Full, refresh_only: false, }; let needs_elevation = !is_elevated().map_err(|err| { @@ -819,6 +829,45 @@ pub fn run_elevated_setup( run_setup_exe(&payload, needs_elevation, request.codex_home) } +pub fn run_elevated_provisioning_setup(codex_home: &Path, real_user: &str) -> Result<()> { + let sbx_dir = sandbox_dir(codex_home); + std::fs::create_dir_all(&sbx_dir).map_err(|err| { + failure( + SetupErrorCode::OrchestratorSandboxDirCreateFailed, + format!("failed to create sandbox dir {}: {err}", sbx_dir.display()), + ) + })?; + if !is_elevated().map_err(|err| { + failure( + SetupErrorCode::OrchestratorElevationCheckFailed, + format!("failed to determine elevation state: {err}"), + ) + })? { + return Err(failure( + SetupErrorCode::OrchestratorElevationRequired, + "sandbox provisioning setup must be run from an elevated process", + )); + } + let payload = ElevationPayload { + version: SETUP_VERSION, + offline_username: OFFLINE_USERNAME.to_string(), + online_username: ONLINE_USERNAME.to_string(), + codex_home: codex_home.to_path_buf(), + command_cwd: codex_home.to_path_buf(), + read_roots: Vec::new(), + write_roots: Vec::new(), + deny_read_paths: Vec::new(), + deny_write_paths: Vec::new(), + proxy_ports: Vec::new(), + allow_local_binding: false, + otel: codex_otel::global_statsig_metrics_settings(), + real_user: real_user.to_string(), + mode: SetupMode::ProvisionOnly, + refresh_only: false, + }; + run_setup_exe(&payload, /*needs_elevation*/ false, codex_home) +} + fn build_payload_roots( request: &SandboxSetupRequest<'_>, overrides: &SetupRootOverrides, diff --git a/codex-rs/windows-sandbox-rs/src/setup_error.rs b/codex-rs/windows-sandbox-rs/src/setup_error.rs index efaaa531e2..4d2a9d57df 100644 --- a/codex-rs/windows-sandbox-rs/src/setup_error.rs +++ b/codex-rs/windows-sandbox-rs/src/setup_error.rs @@ -19,6 +19,8 @@ pub enum SetupErrorCode { OrchestratorSandboxDirCreateFailed, /// Failed to determine whether the current process is elevated. OrchestratorElevationCheckFailed, + /// The setup command requires an already elevated process. + OrchestratorElevationRequired, /// Failed to serialize the elevation payload before launching the helper. OrchestratorPayloadSerializeFailed, /// Failed to launch the setup helper process (spawn or ShellExecuteExW). @@ -75,6 +77,7 @@ impl SetupErrorCode { match self { Self::OrchestratorSandboxDirCreateFailed => "orchestrator_sandbox_dir_create_failed", Self::OrchestratorElevationCheckFailed => "orchestrator_elevation_check_failed", + Self::OrchestratorElevationRequired => "orchestrator_elevation_required", Self::OrchestratorPayloadSerializeFailed => "orchestrator_payload_serialize_failed", Self::OrchestratorHelperLaunchFailed => "orchestrator_helper_launch_failed", Self::OrchestratorHelperLaunchCanceled => "orchestrator_helper_launch_canceled", From 1333f4a68907e82e6477256c76997c004e363289 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 29 May 2026 11:06:40 -0700 Subject: [PATCH 28/40] Align TUI permissions labels with app (#25017) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The desktop app now presents the on-request permissions mode as `Ask for approval` and the manual-review-backed mode as `Approve for me`. The TUI still exposed older/internal labels like `Default` and `Auto-review`, which made the same underlying settings look different across clients. This updates the TUI UX copy to match the app without changing the underlying default behavior. Fresh threads continue to use the existing on-request approval mode, now displayed as `Ask for approval`. The label changes cover `/permissions`, explicit profile permissions menus, status surfaces, config persistence history/error text, and the corresponding TUI snapshots. ### Before Screenshot 2026-05-28 at 10 19
47 PM Screenshot 2026-05-28 at 10 21 29 PM ### After Screenshot 2026-05-28 at 10 19
32 PM Screenshot 2026-05-28 at 10 20 24 PM --- codex-rs/tui/src/app/config_persistence.rs | 16 ++++----- codex-rs/tui/src/app/tests.rs | 4 +-- codex-rs/tui/src/chatwidget.rs | 4 ++- .../tui/src/chatwidget/permission_popups.rs | 8 +++-- .../tui/src/chatwidget/permissions_menu.rs | 8 ++--- ...get__tests__approvals_selection_popup.snap | 12 +++---- ...ts__approvals_selection_popup@windows.snap | 2 +- ...vals_selection_popup@windows_degraded.snap | 22 ++++++------ ...ection_history_full_access_to_default.snap | 4 +-- ...istory_full_access_to_default@windows.snap | 2 +- ...__profile_permissions_selection_popup.snap | 24 ++++++------- ..._selection_popup_with_custom_profiles.snap | 7 ++-- ...tatus_surface_previews_hardcoded_only.snap | 2 +- .../tui/src/chatwidget/status_surfaces.rs | 13 +++---- .../tui/src/chatwidget/tests/permissions.rs | 32 ++++++++--------- codex-rs/tui/src/status/card.rs | 13 +++---- ...ched_limits_hide_credits_without_flag.snap | 34 +++++++++--------- ..._snapshot_includes_credits_and_limits.snap | 34 +++++++++--------- ..._status_snapshot_includes_forked_from.snap | 34 +++++++++--------- ...tatus_snapshot_includes_monthly_limit.snap | 30 ++++++++-------- ...s_snapshot_includes_reasoning_details.snap | 2 +- ...hot_shows_active_user_defined_profile.snap | 2 +- ...napshot_shows_auto_review_permissions.snap | 2 +- ...snapshot_shows_missing_limits_message.snap | 30 ++++++++-------- ...apshot_shows_refreshing_limits_notice.snap | 32 ++++++++--------- ...s_snapshot_shows_stale_limits_message.snap | 34 +++++++++--------- ...shot_shows_unavailable_limits_message.snap | 30 ++++++++-------- ...efreshing_empty_limits_as_unavailable.snap | 30 ++++++++-------- ...snapshot_truncates_in_narrow_terminal.snap | 2 +- ...s_default_reasoning_when_config_empty.snap | 36 +++++++++---------- ..._limit_labels_for_unsupported_windows.snap | 2 +- codex-rs/tui/src/status/tests.rs | 27 +++++++------- 32 files changed, 271 insertions(+), 263 deletions(-) diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 8c80230cdf..92ed5d470c 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -398,7 +398,7 @@ impl App { serde_json::json!(auto_review_preset.approvals_reviewer.to_string()), )); if previous_approvals_reviewer != auto_review_preset.approvals_reviewer { - permissions_history_label = Some("Auto-review"); + permissions_history_label = Some("Approve for me"); } } else if !effective_enabled { feature_edits.push(crate::config_update::clear_config_value( @@ -406,7 +406,7 @@ impl App { )); feature_config.approvals_reviewer = ApprovalsReviewer::User; if previous_approvals_reviewer != ApprovalsReviewer::User { - permissions_history_label = Some("Default"); + permissions_history_label = Some("Ask for approval"); } } approvals_reviewer_override = Some(feature_config.approvals_reviewer); @@ -419,7 +419,7 @@ impl App { if !self.try_set_approval_policy_on_config( &mut feature_config, auto_review_preset.approval_policy, - "Failed to enable Auto-review", + "Failed to enable Approve for me", "failed to set auto-review approval policy on staged config", ) { continue; @@ -428,7 +428,7 @@ impl App { .try_set_builtin_active_permission_profile_on_config( &mut feature_config, auto_review_preset.active_permission_profile.clone(), - "Failed to enable Auto-review", + "Failed to enable Approve for me", "failed to set auto-review permission profile on staged config", ) else { @@ -546,7 +546,7 @@ impl App { "failed to set auto-review permission profile on chat config" ); self.chat_widget - .add_error_message(format!("Failed to enable Auto-review: {err}")); + .add_error_message(format!("Failed to enable Approve for me: {err}")); } if permission_profile_override.is_some() { self.runtime_permission_profile_override = @@ -818,7 +818,7 @@ impl App { "failed to sync effective approval policy after an overridden write" ); self.chat_widget.add_error_message(format!( - "Failed to refresh overridden Auto-review settings: {err}" + "Failed to refresh overridden Approve for me settings: {err}" )); } else { self.chat_widget.set_approval_policy(policy); @@ -846,7 +846,7 @@ impl App { let Some(permission_profile) = self.try_set_builtin_active_permission_profile_on_config( &mut config, auto_review_preset.active_permission_profile.clone(), - "Failed to refresh overridden Auto-review settings", + "Failed to refresh overridden Approve for me settings", "failed to sync overridden Auto-review permission profile", ) else { return; @@ -864,7 +864,7 @@ impl App { "failed to sync overridden Auto-review permission profile on chat config" ); self.chat_widget.add_error_message(format!( - "Failed to refresh overridden Auto-review settings: {err}" + "Failed to refresh overridden Approve for me settings: {err}" )); return; } diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 3fa71d7b45..993772ab77 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -1820,7 +1820,7 @@ async fn update_feature_flags_enabling_guardian_selects_auto_review() -> Result< .map(|line| line.to_string()) .collect::>() .join("\n"); - assert!(rendered.contains("Permissions updated to Auto-review")); + assert!(rendered.contains("Permissions updated to Approve for me")); let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; assert!(config.contains("guardian_approval = true")); @@ -1915,7 +1915,7 @@ async fn update_feature_flags_disabling_guardian_clears_review_policy_and_restor .map(|line| line.to_string()) .collect::>() .join("\n"); - assert!(rendered.contains("Permissions updated to Default")); + assert!(rendered.contains("Permissions updated to Ask for approval")); let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; assert!(!config.contains("guardian_approval = true")); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 7d879a09c5..32c68529d5 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -467,7 +467,9 @@ use unicode_segmentation::UnicodeSegmentation; const USER_SHELL_COMMAND_HELP_TITLE: &str = "Prefix a command with ! to run it locally"; const USER_SHELL_COMMAND_HELP_HINT: &str = "Example: !ls"; -const AUTO_REVIEW_DESCRIPTION: &str = "Same workspace-write permissions as Default, but eligible `on-request` approvals are routed through the auto-reviewer subagent."; +const ASK_FOR_APPROVAL_LABEL: &str = "Ask for approval"; +const APPROVE_FOR_ME_LABEL: &str = "Approve for me"; +const AUTO_REVIEW_DESCRIPTION: &str = "Only ask for actions detected as potentially unsafe."; const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1"; const DEFAULT_STATUS_LINE_ITEMS: [&str; 2] = ["model-with-reasoning", "current-dir"]; const MAX_AGENT_COPY_HISTORY: usize = 32; diff --git a/codex-rs/tui/src/chatwidget/permission_popups.rs b/codex-rs/tui/src/chatwidget/permission_popups.rs index b271422788..c0d492d7aa 100644 --- a/codex-rs/tui/src/chatwidget/permission_popups.rs +++ b/codex-rs/tui/src/chatwidget/permission_popups.rs @@ -56,7 +56,9 @@ impl ChatWidget { continue; } let base_name = if preset.id == "auto" && windows_degraded_sandbox_enabled { - "Default (non-admin sandbox)".to_string() + format!("{ASK_FOR_APPROVAL_LABEL} (non-admin sandbox)") + } else if preset.id == "auto" { + ASK_FOR_APPROVAL_LABEL.to_string() } else { preset.label.to_string() }; @@ -100,7 +102,7 @@ impl ChatWidget { if guardian_approval_enabled { items.push(SelectionItem { - name: "Auto-review".to_string(), + name: APPROVE_FOR_ME_LABEL.to_string(), description: Some(AUTO_REVIEW_DESCRIPTION.to_string()), is_current: current_review_policy == ApprovalsReviewer::AutoReview && Self::preset_matches_current( @@ -111,7 +113,7 @@ impl ChatWidget { ), actions: self.permission_mode_actions( &preset, - "Auto-review".to_string(), + APPROVE_FOR_ME_LABEL.to_string(), ApprovalsReviewer::AutoReview, /*profile_selection*/ None, /*return_to_permissions*/ !include_read_only, diff --git a/codex-rs/tui/src/chatwidget/permissions_menu.rs b/codex-rs/tui/src/chatwidget/permissions_menu.rs index 7c0477116c..c9cb3b108f 100644 --- a/codex-rs/tui/src/chatwidget/permissions_menu.rs +++ b/codex-rs/tui/src/chatwidget/permissions_menu.rs @@ -94,10 +94,10 @@ impl ChatWidget { approval_policy: AskForApproval, approvals_reviewer: ApprovalsReviewer, ) -> SelectionItem { - let label = if approvals_reviewer == ApprovalsReviewer::AutoReview { - "Auto-review" - } else { - preset.label + let label = match (preset.id, approvals_reviewer) { + ("auto", ApprovalsReviewer::AutoReview) => APPROVE_FOR_ME_LABEL, + ("auto", ApprovalsReviewer::User) => ASK_FOR_APPROVAL_LABEL, + _ => preset.label, }; let active_profile_id = self .config diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap index 52ac576149..bfa5173474 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap @@ -4,11 +4,11 @@ expression: popup --- Update Model Permissions -› 1. Default Codex can read and edit files in the current workspace, and - run commands. Approval is required to access the internet or - edit other files. - 2. Full Access Codex can edit files outside this workspace and access the - internet without asking for approval. Exercise caution when - using. +› 1. Ask for approval Codex can read and edit files in the current workspace, + and run commands. Approval is required to access the + internet or edit other files. + 2. Full Access Codex can edit files outside this workspace and access + the internet without asking for approval. Exercise + caution when using. Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows.snap index 9c512c7781..abd2450bf7 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows.snap @@ -7,7 +7,7 @@ expression: popup › 1. Read Only (current) Codex can read files in the current workspace. Approval is required to edit files or access the internet. - 2. Default Codex can read and edit files in the current + 2. Ask for approval Codex can read and edit files in the current workspace, and run commands. Approval is required to access the internet or edit other files. 3. Full Access Codex can edit files outside this workspace and diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows_degraded.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows_degraded.snap index ecbe5de157..368e732855 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows_degraded.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows_degraded.snap @@ -5,17 +5,17 @@ expression: popup --- Update Model Permissions -› 1. Read Only (current) Codex can read files in the current - workspace. Approval is required to edit - files or access the internet. - 2. Default (non-admin sandbox) Codex can read and edit files in the - current workspace, and run commands. - Approval is required to access the - internet or edit other files. - 3. Full Access Codex can edit files outside this - workspace and access the internet without - asking for approval. Exercise caution - when using. +› 1. Read Only (current) Codex can read files in the current + workspace. Approval is required to + edit files or access the internet. + 2. Ask for approval (non-admin sandbox) Codex can read and edit files in the + current workspace, and run commands. + Approval is required to access the + internet or edit other files. + 3. Full Access Codex can edit files outside this + workspace and access the internet + without asking for approval. Exercise + caution when using. The non-admin sandbox protects your files and prevents network access under most circumstances. However, it carries greater risk if prompt injected. To diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permissions_selection_history_full_access_to_default.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permissions_selection_history_full_access_to_default.snap index 135e5b1bfa..3352c2435a 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permissions_selection_history_full_access_to_default.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permissions_selection_history_full_access_to_default.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/permissions.rs expression: "lines_to_single_string(&cells[0])" --- -• Permissions updated to Default +• Permissions updated to Ask for approval diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permissions_selection_history_full_access_to_default@windows.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permissions_selection_history_full_access_to_default@windows.snap index eb08108564..6e0269a521 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permissions_selection_history_full_access_to_default@windows.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__permissions_selection_history_full_access_to_default@windows.snap @@ -2,4 +2,4 @@ source: tui/src/chatwidget/tests.rs expression: "lines_to_single_string(&cells[0])" --- -• Permissions updated to Default (non-admin sandbox) +• Permissions updated to Ask for approval (non-admin sandbox) diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup.snap index c0a23815a0..5ad5481590 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup.snap @@ -4,17 +4,17 @@ expression: "render_bottom_popup(&chat, 80)" --- Update Model Permissions -› 1. Default (current) Codex can read and edit files in the current - workspace, and run commands. Approval is required to - access the internet or edit other files. - 2. Auto-review Same workspace-write permissions as Default, but - eligible `on-request` approvals are routed through the - auto-reviewer subagent. - 3. Full Access Codex can edit files outside this workspace and access - the internet without asking for approval. Exercise - caution when using. - 4. Read Only Codex can read files in the current workspace. - Approval is required to edit files or access the - internet. +› 1. Ask for approval (current) Codex can read and edit files in the current + workspace, and run commands. Approval is + required to access the internet or edit other + files. + 2. Approve for me Only ask for actions detected as potentially + unsafe. + 3. Full Access Codex can edit files outside this workspace + and access the internet without asking for + approval. Exercise caution when using. + 4. Read Only Codex can read files in the current + workspace. Approval is required to edit files + or access the internet. Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_custom_profiles.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_custom_profiles.snap index 3ca5753795..dac63d4e32 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_custom_profiles.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_custom_profiles.snap @@ -4,12 +4,11 @@ expression: "render_bottom_popup(&chat, 80)" --- Update Model Permissions - 1. Default Codex can read and edit files in the current + 1. Ask for approval Codex can read and edit files in the current workspace, and run commands. Approval is required to access the internet or edit other files. - 2. Auto-review Same workspace-write permissions as Default, but - eligible `on-request` approvals are routed through - the auto-reviewer subagent. + 2. Approve for me Only ask for actions detected as potentially + unsafe. 3. Full Access Codex can edit files outside this workspace and access the internet without asking for approval. Exercise caution when using. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_hardcoded_only.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_hardcoded_only.snap index 250d56ff48..28a7863858 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_hardcoded_only.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_surface_previews_hardcoded_only.snap @@ -2,5 +2,5 @@ source: tui/src/chatwidget/tests/status_surface_previews.rs expression: snapshot --- -status line: my-project · feat/awesome-feature · thread title · Read Only · on-request +status line: my-project · feat/awesome-feature · thread title · Read Only · Ask for approval terminal title: thread title | feat/awesome-feature | Tasks 0/0 diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 2b2b7d52df..6a3bbbc07b 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -1016,13 +1016,14 @@ fn permissions_display(config: &Config) -> String { fn approval_mode_display(config: &Config) -> String { let approval_policy = AskForApproval::from(config.permissions.approval_policy.value()); - if approval_policy == AskForApproval::OnRequest - && config.approvals_reviewer == ApprovalsReviewer::AutoReview - { - "auto-review".to_string() - } else { - config.permissions.approval_policy.value().to_string() + if approval_policy == AskForApproval::OnRequest { + return match config.approvals_reviewer { + ApprovalsReviewer::AutoReview => "Approve for me".to_string(), + ApprovalsReviewer::User => "Ask for approval".to_string(), + }; } + + config.permissions.approval_policy.value().to_string() } fn parse_items_with_invalids(ids: impl IntoIterator) -> (Vec, Vec) diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index f79fe3defc..20d600b303 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -143,7 +143,7 @@ async fn profile_permissions_selection_emits_named_profile_event_only() { approval_policy: Some(AskForApproval::OnRequest), approvals_reviewer: Some(ApprovalsReviewer::User), display_label, - }) if profile_id == ":workspace" && display_label == "Default" + }) if profile_id == ":workspace" && display_label == "Ask for approval" )); } @@ -208,7 +208,7 @@ async fn profile_permissions_selection_emits_auto_review_mode_event() { approval_policy: Some(AskForApproval::OnRequest), approvals_reviewer: Some(ApprovalsReviewer::AutoReview), display_label, - }) if profile_id == ":workspace" && display_label == "Auto-review" + }) if profile_id == ":workspace" && display_label == "Approve for me" )); } @@ -256,7 +256,7 @@ async fn approvals_selection_popup_snapshot_windows_degraded_sandbox() { let popup = render_bottom_popup(&chat, /*width*/ 80); assert!( - popup.contains("Default (non-admin sandbox)"), + popup.contains("Ask for approval (non-admin sandbox)"), "expected degraded sandbox label in approvals popup: {popup}" ); assert!( @@ -285,7 +285,7 @@ async fn preset_matching_accepts_workspace_write_with_extra_roots() { cwd.as_path(), &preset ), - "WorkspaceWrite with extra roots should still match the Default preset" + "WorkspaceWrite with extra roots should still match the Ask for approval preset" ); assert!( !ChatWidget::preset_matches_current( @@ -629,7 +629,7 @@ async fn permissions_selection_history_snapshot_full_access_to_default() { chat.open_permissions_popup(); let popup = render_bottom_popup(&chat, /*width*/ 120); chat.handle_key_event(KeyEvent::from(KeyCode::Up)); - if popup.contains("Auto-review") { + if popup.contains("Approve for me") { chat.handle_key_event(KeyEvent::from(KeyCode::Up)); } chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); @@ -699,8 +699,8 @@ async fn permissions_selection_hides_auto_review_when_feature_disabled() { let popup = render_bottom_popup(&chat, /*width*/ 120); assert!( - !popup.contains("Auto-review"), - "expected Auto-review to stay hidden until the feature is enabled: {popup}" + !popup.contains("Approve for me"), + "expected Approve for me to stay hidden until the feature is enabled: {popup}" ); } @@ -730,8 +730,8 @@ async fn permissions_selection_hides_auto_review_when_feature_disabled_even_if_a let popup = render_bottom_popup(&chat, /*width*/ 120); assert!( - !popup.contains("Auto-review"), - "expected Auto-review to stay hidden when the feature is disabled: {popup}" + !popup.contains("Approve for me"), + "expected Approve for me to stay hidden when the feature is disabled: {popup}" ); } @@ -776,8 +776,8 @@ async fn permissions_selection_marks_auto_review_current_after_session_configure let popup = render_bottom_popup(&chat, /*width*/ 120); assert!( - popup.contains("Auto-review (current)"), - "expected Auto-review to be current after SessionConfigured sync: {popup}" + popup.contains("Approve for me (current)"), + "expected Approve for me to be current after SessionConfigured sync: {popup}" ); } @@ -826,8 +826,8 @@ async fn permissions_selection_marks_auto_review_current_with_custom_workspace_w let popup = render_bottom_popup(&chat, /*width*/ 120); assert!( - popup.contains("Auto-review (current)"), - "expected Auto-review to be current even with custom workspace-write details: {popup}" + popup.contains("Approve for me (current)"), + "expected Approve for me to be current even with custom workspace-write details: {popup}" ); } @@ -861,7 +861,7 @@ async fn permissions_selection_can_disable_auto_review() { event, AppEvent::UpdateApprovalsReviewer(ApprovalsReviewer::User) )), - "expected selecting Default from Auto-review to switch back to manual approval review: {events:?}" + "expected selecting Ask for approval from Approve for me to switch back to manual approval review: {events:?}" ); assert!( !events @@ -906,8 +906,8 @@ async fn permissions_selection_sends_approvals_reviewer_in_override_turn_context assert!( popup .lines() - .any(|line| line.contains("Auto-review") && line.contains('›')), - "expected one Down from Default to select Auto-review: {popup}" + .any(|line| line.contains("Approve for me") && line.contains('›')), + "expected one Down from Ask for approval to select Approve for me: {popup}" ); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 8da9166a3b..0117508ae5 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -682,13 +682,14 @@ fn status_approval_label( approvals_reviewer: ApprovalsReviewer, approval: &str, ) -> String { - if approval_policy == AskForApproval::OnRequest - && approvals_reviewer == ApprovalsReviewer::AutoReview - { - "auto-review".to_string() - } else { - approval.to_string() + if approval_policy == AskForApproval::OnRequest { + return match approvals_reviewer { + ApprovalsReviewer::AutoReview => "Approve for me".to_string(), + ApprovalsReviewer::User => "Ask for approval".to_string(), + }; } + + approval.to_string() } impl HistoryCell for StatusHistoryCell { diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_cached_limits_hide_credits_without_flag.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_cached_limits_hide_credits_without_flag.snap index 6293c0d742..c7d89b17aa 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_cached_limits_hide_credits_without_flag.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_cached_limits_hide_credits_without_flag.snap @@ -4,20 +4,20 @@ expression: sanitized --- /status -╭───────────────────────────────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ -│ information on rate limits and credits │ -│ │ -│ Model: gpt-5.1-codex (reasoning none, summaries auto) │ -│ Directory: [[workspace]] │ -│ Permissions: Custom (workspace with network access, on-request) │ -│ Agents.md: │ -│ │ -│ Token usage: 1.05K total (700 input + 350 output) │ -│ Context window: 100% left (1.45K used / 272K) │ -│ 5h limit: [████████░░░░░░░░░░░░] 40% left (resets 11:32) │ -│ Weekly limit: [█████████████░░░░░░░] 65% left (resets 11:52) │ -│ Warning: limits may be stale - start new turn to refresh. │ -╰───────────────────────────────────────────────────────────────────────╯ +╭─────────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, Ask for approval) │ +│ Agents.md: │ +│ │ +│ Token usage: 1.05K total (700 input + 350 output) │ +│ Context window: 100% left (1.45K used / 272K) │ +│ 5h limit: [████████░░░░░░░░░░░░] 40% left (resets 11:32) │ +│ Weekly limit: [█████████████░░░░░░░] 65% left (resets 11:52) │ +│ Warning: limits may be stale - start new turn to refresh. │ +╰─────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_credits_and_limits.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_credits_and_limits.snap index 0c8dae72cf..48fa575d1f 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_credits_and_limits.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_credits_and_limits.snap @@ -4,20 +4,20 @@ expression: sanitized --- /status -╭───────────────────────────────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ -│ information on rate limits and credits │ -│ │ -│ Model: gpt-5.1-codex (reasoning none, summaries auto) │ -│ Directory: [[workspace]] │ -│ Permissions: Custom (workspace with network access, on-request) │ -│ Agents.md: │ -│ │ -│ Token usage: 2K total (1.4K input + 600 output) │ -│ Context window: 100% left (2.2K used / 272K) │ -│ 5h limit: [███████████░░░░░░░░░] 55% left (resets 09:25) │ -│ Weekly limit: [██████████████░░░░░░] 70% left (resets 09:55) │ -│ Credits: 38 credits │ -╰───────────────────────────────────────────────────────────────────────╯ +╭─────────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, Ask for approval) │ +│ Agents.md: │ +│ │ +│ Token usage: 2K total (1.4K input + 600 output) │ +│ Context window: 100% left (2.2K used / 272K) │ +│ 5h limit: [███████████░░░░░░░░░] 55% left (resets 09:25) │ +│ Weekly limit: [██████████████░░░░░░] 70% left (resets 09:55) │ +│ Credits: 38 credits │ +╰─────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_forked_from.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_forked_from.snap index f884d006ab..152575179e 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_forked_from.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_forked_from.snap @@ -4,20 +4,20 @@ expression: sanitized --- /status -╭───────────────────────────────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ -│ information on rate limits and credits │ -│ │ -│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ -│ Directory: [[workspace]] │ -│ Permissions: Custom (workspace with network access, on-request) │ -│ Agents.md: │ -│ Session: 0f0f3c13-6cf9-4aa4-8b80-7d49c2f1be2e │ -│ Forked from: e9f18a88-8081-4e51-9d4e-8af5cde2d8dd │ -│ │ -│ Token usage: 1.2K total (800 input + 400 output) │ -│ Context window: 100% left (1.2K used / 272K) │ -│ Limits: data not available yet │ -╰───────────────────────────────────────────────────────────────────────╯ +╭─────────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, Ask for approval) │ +│ Agents.md: │ +│ Session: 0f0f3c13-6cf9-4aa4-8b80-7d49c2f1be2e │ +│ Forked from: e9f18a88-8081-4e51-9d4e-8af5cde2d8dd │ +│ │ +│ Token usage: 1.2K total (800 input + 400 output) │ +│ Context window: 100% left (1.2K used / 272K) │ +│ Limits: data not available yet │ +╰─────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap index fc691f6bf7..7a4f58a823 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap @@ -4,18 +4,18 @@ expression: sanitized --- /status -╭────────────────────────────────────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ -│ information on rate limits and credits │ -│ │ -│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ -│ Directory: [[workspace]] │ -│ Permissions: Custom (workspace with network access, on-request) │ -│ Agents.md: │ -│ │ -│ Token usage: 1.2K total (800 input + 400 output) │ -│ Context window: 100% left (1.2K used / 272K) │ -│ Monthly limit: [██████████████████░░] 88% left (resets 07:08 on 7 May) │ -╰────────────────────────────────────────────────────────────────────────────╯ +╭─────────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, Ask for approval) │ +│ Agents.md: │ +│ │ +│ Token usage: 1.2K total (800 input + 400 output) │ +│ Context window: 100% left (1.2K used / 272K) │ +│ Monthly limit: [██████████████████░░] 88% left (resets 07:08 on 7 May) │ +╰─────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap index 8719230212..96b2ed1648 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap @@ -12,7 +12,7 @@ expression: sanitized │ │ │ Model: gpt-5.1-codex-max (reasoning high, summaries detailed) │ │ Directory: [[workspace]] │ -│ Permissions: Workspace (on-request) │ +│ Permissions: Workspace (Ask for approval) │ │ Agents.md: │ │ │ │ Token usage: 1.9K total (1K input + 900 output) │ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_active_user_defined_profile.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_active_user_defined_profile.snap index a57c34bdf1..4604b26f7e 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_active_user_defined_profile.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_active_user_defined_profile.snap @@ -12,7 +12,7 @@ expression: sanitized │ │ │ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ │ Directory: [[workspace]] │ -│ Permissions: Profile locked (read-only, on-request) │ +│ Permissions: Profile locked (read-only, Ask for approval) │ │ Agents.md: │ │ │ │ Token usage: 0 total (0 input + 0 output) │ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_auto_review_permissions.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_auto_review_permissions.snap index f1ceb21ab0..c739d55075 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_auto_review_permissions.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_auto_review_permissions.snap @@ -12,7 +12,7 @@ expression: sanitized │ │ │ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ │ Directory: [[workspace]] │ -│ Permissions: Workspace (auto-review) │ +│ Permissions: Workspace (Approve for me) │ │ Agents.md: │ │ │ │ Token usage: 0 total (0 input + 0 output) │ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap index 79344df582..533be27976 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap @@ -4,18 +4,18 @@ expression: sanitized --- /status -╭───────────────────────────────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ -│ information on rate limits and credits │ -│ │ -│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ -│ Directory: [[workspace]] │ -│ Permissions: Custom (workspace with network access, on-request) │ -│ Agents.md: │ -│ │ -│ Token usage: 750 total (500 input + 250 output) │ -│ Context window: 100% left (750 used / 272K) │ -│ Limits: data not available yet │ -╰───────────────────────────────────────────────────────────────────────╯ +╭─────────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, Ask for approval) │ +│ Agents.md: │ +│ │ +│ Token usage: 750 total (500 input + 250 output) │ +│ Context window: 100% left (750 used / 272K) │ +│ Limits: data not available yet │ +╰─────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_refreshing_limits_notice.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_refreshing_limits_notice.snap index f633cd7f6c..631982bcf9 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_refreshing_limits_notice.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_refreshing_limits_notice.snap @@ -4,19 +4,19 @@ expression: sanitized --- /status -╭───────────────────────────────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ -│ information on rate limits and credits │ -│ │ -│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ -│ Directory: [[workspace]] │ -│ Permissions: Custom (workspace with network access, on-request) │ -│ Agents.md: │ -│ │ -│ Token usage: 750 total (500 input + 250 output) │ -│ Context window: 100% left (750 used / 272K) │ -│ 5h limit: [███████████░░░░░░░░░] 55% left (resets 08:24) │ -│ Weekly limit: [██████████████░░░░░░] 70% left (resets 08:54) │ -╰───────────────────────────────────────────────────────────────────────╯ +╭─────────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, Ask for approval) │ +│ Agents.md: │ +│ │ +│ Token usage: 750 total (500 input + 250 output) │ +│ Context window: 100% left (750 used / 272K) │ +│ 5h limit: [███████████░░░░░░░░░] 55% left (resets 08:24) │ +│ Weekly limit: [██████████████░░░░░░] 70% left (resets 08:54) │ +╰─────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap index dc52e22ec4..8facd56275 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap @@ -4,20 +4,20 @@ expression: sanitized --- /status -╭───────────────────────────────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ -│ information on rate limits and credits │ -│ │ -│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ -│ Directory: [[workspace]] │ -│ Permissions: Custom (workspace with network access, on-request) │ -│ Agents.md: │ -│ │ -│ Token usage: 1.9K total (1K input + 900 output) │ -│ Context window: 100% left (2.25K used / 272K) │ -│ 5h limit: [██████░░░░░░░░░░░░░░] 28% left (resets 03:14) │ -│ Weekly limit: [████████████░░░░░░░░] 60% left (resets 03:34) │ -│ Warning: limits may be stale - start new turn to refresh. │ -╰───────────────────────────────────────────────────────────────────────╯ +╭─────────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, Ask for approval) │ +│ Agents.md: │ +│ │ +│ Token usage: 1.9K total (1K input + 900 output) │ +│ Context window: 100% left (2.25K used / 272K) │ +│ 5h limit: [██████░░░░░░░░░░░░░░] 28% left (resets 03:14) │ +│ Weekly limit: [████████████░░░░░░░░] 60% left (resets 03:34) │ +│ Warning: limits may be stale - start new turn to refresh. │ +╰─────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_unavailable_limits_message.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_unavailable_limits_message.snap index 83d30b5adc..d46a483e4b 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_unavailable_limits_message.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_unavailable_limits_message.snap @@ -4,18 +4,18 @@ expression: sanitized --- /status -╭───────────────────────────────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ -│ information on rate limits and credits │ -│ │ -│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ -│ Directory: [[workspace]] │ -│ Permissions: Custom (workspace with network access, on-request) │ -│ Agents.md: │ -│ │ -│ Token usage: 750 total (500 input + 250 output) │ -│ Context window: 100% left (750 used / 272K) │ -│ Limits: not available for this account │ -╰───────────────────────────────────────────────────────────────────────╯ +╭─────────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, Ask for approval) │ +│ Agents.md: │ +│ │ +│ Token usage: 750 total (500 input + 250 output) │ +│ Context window: 100% left (750 used / 272K) │ +│ Limits: not available for this account │ +╰─────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_treats_refreshing_empty_limits_as_unavailable.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_treats_refreshing_empty_limits_as_unavailable.snap index 83d30b5adc..d46a483e4b 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_treats_refreshing_empty_limits_as_unavailable.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_treats_refreshing_empty_limits_as_unavailable.snap @@ -4,18 +4,18 @@ expression: sanitized --- /status -╭───────────────────────────────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ -│ information on rate limits and credits │ -│ │ -│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ -│ Directory: [[workspace]] │ -│ Permissions: Custom (workspace with network access, on-request) │ -│ Agents.md: │ -│ │ -│ Token usage: 750 total (500 input + 250 output) │ -│ Context window: 100% left (750 used / 272K) │ -│ Limits: not available for this account │ -╰───────────────────────────────────────────────────────────────────────╯ +╭─────────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, Ask for approval) │ +│ Agents.md: │ +│ │ +│ Token usage: 750 total (500 input + 250 output) │ +│ Context window: 100% left (750 used / 272K) │ +│ Limits: not available for this account │ +╰─────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap index a048726bb3..6b28b53b62 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap @@ -12,7 +12,7 @@ expression: sanitized │ │ │ Model: gpt-5.1-codex-max (reasoning high, summaries de │ │ Directory: [[workspace]] │ -│ Permissions: Custom (workspace with network access, on-reque │ +│ Permissions: Custom (workspace with network access, Ask for │ │ Agents.md: │ │ │ │ Token usage: 1.9K total (1K input + 900 output) │ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_uses_default_reasoning_when_config_empty.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_uses_default_reasoning_when_config_empty.snap index 2f6925c830..9683beed97 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_uses_default_reasoning_when_config_empty.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_uses_default_reasoning_when_config_empty.snap @@ -4,21 +4,21 @@ expression: sanitized --- /status -╭──────────────────────────────────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ -│ information on rate limits and credits │ -│ │ -│ Remote: unix:///tmp/codex-home/app-server-control/app-server- │ -│ control.sock (v0.133.0) │ -│ │ -│ Model: gpt-5.1-codex-max (reasoning medium, summaries auto) │ -│ Directory: [[workspace]] │ -│ Permissions: Custom (workspace with network access, on-request) │ -│ Agents.md: │ -│ │ -│ Token usage: 750 total (500 input + 250 output) │ -│ Context window: 100% left (750 used / 272K) │ -│ Limits: data not available yet │ -╰──────────────────────────────────────────────────────────────────────────╯ +╭─────────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Remote: unix:///tmp/codex-home/app-server-control/app-server- │ +│ control.sock (v0.133.0) │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning medium, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, Ask for approval) │ +│ Agents.md: │ +│ │ +│ Token usage: 750 total (500 input + 250 output) │ +│ Context window: 100% left (750 used / 272K) │ +│ Limits: data not available yet │ +╰─────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_uses_generic_limit_labels_for_unsupported_windows.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_uses_generic_limit_labels_for_unsupported_windows.snap index f983cf478d..6b6fad683b 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_uses_generic_limit_labels_for_unsupported_windows.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_uses_generic_limit_labels_for_unsupported_windows.snap @@ -12,7 +12,7 @@ expression: sanitized │ │ │ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ │ Directory: [[workspace]] │ -│ Permissions: Custom (workspace with network access, on-request) │ +│ Permissions: Custom (workspace with network access, Ask for app │ │ Agents.md: │ │ │ │ Token usage: 1.2K total (800 input + 400 output) │ diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 9609703b0d..8b4e41ce76 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -291,7 +291,7 @@ async fn status_permissions_non_default_workspace_write_uses_workspace_label() { assert_eq!( permissions_text_for(&config).as_deref(), - Some("Custom (workspace with network access, on-request)") + Some("Custom (workspace with network access, Ask for approval)") ); } @@ -314,7 +314,7 @@ async fn status_permissions_named_read_only_profile_shows_builtin_label() { assert_eq!( permissions_text_for(&config).as_deref(), - Some("Read Only (on-request)") + Some("Read Only (Ask for approval)") ); } @@ -344,7 +344,7 @@ async fn status_permissions_read_only_profile_shows_additional_writable_roots() assert_eq!( permissions_text_for(&config).as_deref(), - Some("Read Only (on-request)") + Some("Read Only (Ask for approval)") ); } @@ -367,7 +367,7 @@ async fn status_permissions_named_workspace_profile_shows_builtin_label() { assert_eq!( permissions_text_for(&config).as_deref(), - Some("Workspace (on-request)") + Some("Workspace (Ask for approval)") ); } @@ -391,7 +391,7 @@ async fn status_permissions_workspace_auto_review_shows_reviewer_label() { assert_eq!( permissions_text_for(&config).as_deref(), - Some("Workspace (auto-review)") + Some("Workspace (Approve for me)") ); } @@ -420,7 +420,7 @@ async fn status_permissions_named_profile_shows_additional_writable_roots() { assert_eq!( permissions_text_for(&config).as_deref(), - Some("Workspace (on-request)") + Some("Workspace (Ask for approval)") ); } @@ -449,7 +449,10 @@ async fn status_permissions_workspace_roots_show_additional_directories() { assert_eq!( permissions_text_for(&config), - Some(format!("Workspace [{}] (on-request)", extra_root.display())) + Some(format!( + "Workspace [{}] (Ask for approval)", + extra_root.display() + )) ); } @@ -483,7 +486,7 @@ async fn status_permissions_workspace_roots_include_profile_defined_directories( assert_eq!( permissions_text_for(&config), Some(format!( - "Workspace [{}] (on-request)", + "Workspace [{}] (Ask for approval)", profile_root.display() )) ); @@ -513,7 +516,7 @@ async fn status_permissions_broadened_workspace_profile_shows_builtin_label() { assert_eq!( permissions_text_for(&config).as_deref(), - Some("Workspace with network access (on-request)") + Some("Workspace with network access (Ask for approval)") ); } @@ -531,7 +534,7 @@ async fn status_permissions_user_defined_profile_shows_name() { assert_eq!( permissions_text_for(&config).as_deref(), - Some("Profile locked (read-only, on-request)") + Some("Profile locked (read-only, Ask for approval)") ); } @@ -756,7 +759,7 @@ async fn status_permissions_full_disk_managed_with_network_is_danger_full_access assert_eq!( permissions_text_for(&config).as_deref(), - Some("Custom (danger-full-access, on-request)") + Some("Custom (danger-full-access, Ask for approval)") ); } @@ -779,7 +782,7 @@ async fn status_permissions_full_disk_managed_without_network_is_external_sandbo assert_eq!( permissions_text_for(&config).as_deref(), - Some("Custom (external-sandbox, on-request)") + Some("Custom (external-sandbox, Ask for approval)") ); } From 36cd36626db4c16244957d9b4dfa6d041ca440f7 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 29 May 2026 11:07:19 -0700 Subject: [PATCH 29/40] Add `/archive` slash command (#25027) ## Why TUI users can archive saved sessions from other surfaces, but there is no in-session command for archiving the active session. Since archiving the active session also exits the TUI, the command should ask for explicit confirmation instead of firing immediately. I'm also working on [a companion PR](https://github.com/openai/codex/pull/25021) that adds `codex archive` and `codex unarchive` top-level CLI commands. ## What changed - Adds a new `/archive` slash command described as `archive this session and exit`. - Shows a confirmation dialog with `No, don't archive` selected first and `Yes, archive and exit` as the explicit action. - On confirmation, calls the existing `thread/archive` app-server RPC for the active main session and exits after success. - Keeps `/archive` disabled while a task is running and unavailable in side conversations. ## Verification Added focused TUI coverage for the `/archive` confirmation flow, disabled-while-task-running behavior, and the `/ar` slash-command popup snapshot. --- codex-rs/tui/src/app/event_dispatch.rs | 30 ++++++++++++++ codex-rs/tui/src/app_event.rs | 3 ++ codex-rs/tui/src/app_server_session.rs | 17 ++++++++ codex-rs/tui/src/bottom_pane/chat_composer.rs | 26 +++++++++++++ ..._chat_composer__tests__slash_popup_ar.snap | 9 +++++ codex-rs/tui/src/chatwidget/slash_dispatch.rs | 30 ++++++++++++++ ...sts__slash_archive_confirmation_popup.snap | 11 ++++++ .../src/chatwidget/tests/slash_commands.rs | 39 +++++++++++++++++++ codex-rs/tui/src/slash_command.rs | 3 ++ 9 files changed, 168 insertions(+) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_ar.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_archive_confirmation_popup.snap diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 417844acc6..f9683e26ea 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -121,6 +121,9 @@ impl App { } } } + AppEvent::ArchiveCurrentThread => { + return Ok(self.archive_current_thread(app_server).await); + } AppEvent::ForkCurrentSession => { self.session_telemetry.counter( "codex.thread.fork", @@ -2146,4 +2149,31 @@ impl App { } } } + + pub(super) async fn archive_current_thread( + &mut self, + app_server: &mut AppServerSession, + ) -> AppRunControl { + let Some(thread_id) = self.active_thread_id.or(self.chat_widget.thread_id()) else { + self.chat_widget + .add_error_message("A thread must start before it can be archived.".to_string()); + return AppRunControl::Continue; + }; + if self.side_threads.contains_key(&thread_id) { + self.chat_widget.add_error_message( + "'/archive' is unavailable in side conversations. Press Ctrl+C to return to the main thread first." + .to_string(), + ); + return AppRunControl::Continue; + } + + match app_server.thread_archive(thread_id).await { + Ok(()) => AppRunControl::Exit(ExitReason::UserRequested), + Err(err) => { + self.chat_widget + .add_error_message(format!("Failed to archive current thread: {err}")); + AppRunControl::Continue + } + } + } } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index b37aee64e6..f1e612dd58 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -209,6 +209,9 @@ pub(crate) enum AppEvent { /// Resume a thread by UUID or thread name inside the running TUI session. ResumeSessionByIdOrName(String), + /// Archive the current active main thread and exit after it succeeds. + ArchiveCurrentThread, + /// Fork the current session into a new thread. ForkCurrentSession, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 2b7b1cd885..e66b5ee8a2 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -46,6 +46,8 @@ use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadApproveGuardianDeniedActionParams; use codex_app_server_protocol::ThreadApproveGuardianDeniedActionResponse; +use codex_app_server_protocol::ThreadArchiveParams; +use codex_app_server_protocol::ThreadArchiveResponse; use codex_app_server_protocol::ThreadBackgroundTerminalsCleanParams; use codex_app_server_protocol::ThreadBackgroundTerminalsCleanResponse; use codex_app_server_protocol::ThreadCompactStartParams; @@ -564,6 +566,21 @@ impl AppServerSession { Ok(response.thread) } + pub(crate) async fn thread_archive(&mut self, thread_id: ThreadId) -> Result<()> { + let request_id = self.next_request_id(); + let _: ThreadArchiveResponse = self + .client + .request_typed(ClientRequest::ThreadArchive { + request_id, + params: ThreadArchiveParams { + thread_id: thread_id.to_string(), + }, + }) + .await + .wrap_err("thread/archive failed in TUI")?; + Ok(()) + } + pub(crate) async fn thread_metadata_update_branch( &mut self, thread_id: ThreadId, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 721824ed61..ec3284866d 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -7885,6 +7885,32 @@ mod tests { insta::assert_snapshot!("slash_popup_res", terminal.backend()); } + #[test] + fn slash_popup_archive_for_ar_ui() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + type_chars_humanlike(&mut composer, &['/', 'a', 'r']); + + let mut terminal = Terminal::new(TestBackend::new(60, 5)).expect("terminal"); + terminal + .draw(|f| composer.render(f.area(), f.buffer_mut())) + .expect("draw composer"); + + insta::assert_snapshot!("slash_popup_ar", terminal.backend()); + } + #[test] fn slash_popup_resume_for_res_logic() { use super::super::command_popup::CommandItem; diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_ar.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_ar.snap new file mode 100644 index 0000000000..5014c35d28 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_ar.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› /ar " +" " +" " +" /archive archive this session and exit " diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index b479718304..99833da8a8 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -164,6 +164,35 @@ impl ChatWidget { SlashCommand::New => { self.app_event_tx.send(AppEvent::NewSession); } + SlashCommand::Archive => { + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Archive this session?".to_string()), + subtitle: Some( + "Are you sure? This will archive the current session and exit Codex" + .to_string(), + ), + footer_hint: Some(standard_popup_hint_line()), + items: vec![ + SelectionItem { + name: "No, don't archive".to_string(), + description: Some("Return to the current session".to_string()), + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Yes, archive and exit".to_string(), + description: Some("Archive this session now".to_string()), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::ArchiveCurrentThread); + })], + dismiss_on_select: true, + ..Default::default() + }, + ], + ..Default::default() + }); + self.request_redraw(); + } SlashCommand::Clear => { self.app_event_tx.send(AppEvent::ClearUi); } @@ -949,6 +978,7 @@ impl ChatWidget { | SlashCommand::TestApproval => QueueDrain::Continue, SlashCommand::Feedback | SlashCommand::New + | SlashCommand::Archive | SlashCommand::Clear | SlashCommand::Resume | SlashCommand::Fork diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_archive_confirmation_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_archive_confirmation_popup.snap new file mode 100644 index 0000000000..cdc895d7be --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_archive_confirmation_popup.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/slash_commands.rs +expression: popup +--- + Archive this session? + Are you sure? This will archive the current session and exit Codex + +› 1. No, don't archive Return to the current session + 2. Yes, archive and exit Archive this session now + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index ff1feb084c..4f425c25c9 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -1727,6 +1727,27 @@ async fn slash_clear_is_disabled_while_task_running() { assert!(rx.try_recv().is_err(), "expected no follow-up events"); } +#[tokio::test] +async fn slash_archive_is_disabled_while_task_running() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.bottom_pane.set_task_running(/*running*/ true); + + chat.dispatch_command(SlashCommand::Archive); + + let event = rx.try_recv().expect("expected disabled command error"); + match event { + AppEvent::InsertHistoryCell(cell) => { + let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80)); + assert!( + rendered.contains("'/archive' is disabled while a task is in progress."), + "expected /archive task-running error, got {rendered:?}" + ); + } + other => panic!("expected InsertHistoryCell error, got {other:?}"), + } + assert!(rx.try_recv().is_err(), "expected no follow-up events"); +} + #[tokio::test] async fn slash_memory_drop_reports_stubbed_feature() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -1846,6 +1867,24 @@ async fn slash_resume_opens_picker() { assert_matches!(rx.try_recv(), Ok(AppEvent::OpenResumePicker)); } +#[tokio::test] +async fn slash_archive_confirmation_requests_current_thread_archive() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_command(SlashCommand::Archive); + + assert!(chat.bottom_pane.has_active_view()); + assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); + + let popup = render_bottom_popup(&chat, /*width*/ 80); + assert_chatwidget_snapshot!("slash_archive_confirmation_popup", popup); + + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + assert_matches!(rx.try_recv(), Ok(AppEvent::ArchiveCurrentThread)); +} + #[tokio::test] async fn slash_resume_with_arg_requests_named_session() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 4a43409296..3fccdf5967 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -30,6 +30,7 @@ pub enum SlashCommand { Review, Rename, New, + Archive, Resume, Fork, Init, @@ -86,6 +87,7 @@ impl SlashCommand { SlashCommand::Review => "review my current changes and find issues", SlashCommand::Rename => "rename the current thread", SlashCommand::Resume => "resume a saved chat", + SlashCommand::Archive => "archive this session and exit", SlashCommand::Clear => "clear the terminal and start a new chat", SlashCommand::Fork => "fork the current chat", SlashCommand::Quit | SlashCommand::Exit => "exit Codex", @@ -180,6 +182,7 @@ impl SlashCommand { pub fn available_during_task(self) -> bool { match self { SlashCommand::New + | SlashCommand::Archive | SlashCommand::Resume | SlashCommand::Fork | SlashCommand::Init From 62039e8d356a3e983c5a4af82e09458810fc3355 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 29 May 2026 11:09:40 -0700 Subject: [PATCH 30/40] Use session wording in `/rename` confirmation (#25035) ## Why The TUI `/rename` confirmation should use the term "session" for consistency. --- codex-rs/tui/src/chatwidget.rs | 4 ++-- ...ui__chatwidget__tests__thread_name_update_resume_hint.snap | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 32c68529d5..8604229552 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1527,11 +1527,11 @@ impl ChatWidget { fn rename_confirmation_cell(name: &str, thread_id: Option) -> PlainHistoryCell { let mut line = vec![ "• ".into(), - "Thread renamed to ".into(), + "Session renamed to ".into(), name.to_string().cyan(), ]; if let Some(hint) = resume_hint(Some(name), thread_id) { - line.extend([". To resume this thread run ".into(), hint.cyan()]); + line.extend([". To resume this session run ".into(), hint.cyan()]); } PlainHistoryCell::new(vec![line.into()]) } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__thread_name_update_resume_hint.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__thread_name_update_resume_hint.snap index b3cdf89da3..f6f827fe53 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__thread_name_update_resume_hint.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__thread_name_update_resume_hint.snap @@ -2,4 +2,4 @@ source: tui/src/chatwidget/tests/app_server.rs expression: rendered --- -• Thread renamed to review-fix. To resume this thread run codex resume, then select review-fix (123e4567-e89b-12d3-a456-426614174000) +• Session renamed to review-fix. To resume this session run codex resume, then select review-fix (123e4567-e89b-12d3-a456-426614174000) From fc9cf62efb8098fce40b5198d3e7cfc45b4ce441 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Fri, 29 May 2026 11:28:12 -0700 Subject: [PATCH 31/40] Add subagent lineage metadata for responsesapi (#24161) ## Why We recently added `forked_from_thread_id` which lets us trace where a thread's _context_ comes from, but we also want to understand subagent lineage (e.g. which parent thread spawned this subagent? what kind of subagent is it?) which is orthogonal. This PR adds `parent_thread_id` and `subagent_kind` to the `x-codex-turn-metadata` header sent to ResponsesAPI. ## What changed - Adds `parent_thread_id` and `subagent_kind` to core-owned `x-codex-turn-metadata`. - Restores persisted `SessionSource` and `ThreadSource` from resumed session metadata so cold-resumed subagent threads keep their lineage on later Responses API requests. - Centralizes parent-thread extraction on `SessionSource` / `SubAgentSource` and reuses it in the Responses client, analytics, agent control, and state parsing paths. - Extends reserved-key, git-enrichment, thread-spawn, and app-server v2 metadata coverage for the new lineage fields. ## Verification - Not run locally per request. - Added focused coverage in `core/src/turn_metadata_tests.rs` and `app-server/tests/suite/v2/client_metadata.rs`. --- codex-rs/analytics/src/events.rs | 9 +- .../tests/suite/v2/client_metadata.rs | 104 ++++++++ codex-rs/core/src/agent/control.rs | 7 +- codex-rs/core/src/client.rs | 16 +- codex-rs/core/src/session/review.rs | 1 + codex-rs/core/src/session/turn_context.rs | 1 + codex-rs/core/src/thread_manager.rs | 30 ++- codex-rs/core/src/turn_metadata.rs | 85 ++++--- codex-rs/core/src/turn_metadata_tests.rs | 226 +++++++++++++++++- codex-rs/protocol/src/protocol.rs | 49 +++- codex-rs/state/src/runtime/threads.rs | 8 +- 11 files changed, 450 insertions(+), 86 deletions(-) diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 8791e5767a..3d31be12be 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1069,12 +1069,9 @@ pub(crate) fn subagent_source_name(subagent_source: &SubAgentSource) -> String { } pub(crate) fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option { - match subagent_source { - SubAgentSource::ThreadSpawn { - parent_thread_id, .. - } => Some(parent_thread_id.to_string()), - _ => None, - } + subagent_source + .parent_thread_id() + .map(|parent_thread_id| parent_thread_id.to_string()) } fn analytics_hook_status(status: HookRunStatus) -> HookRunStatus { diff --git a/codex-rs/app-server/tests/suite/v2/client_metadata.rs b/codex-rs/app-server/tests/suite/v2/client_metadata.rs index 687efefbdf..ab94fe618e 100644 --- a/codex-rs/app-server/tests/suite/v2/client_metadata.rs +++ b/codex-rs/app-server/tests/suite/v2/client_metadata.rs @@ -1,6 +1,7 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_fake_rollout; +use app_test_support::create_fake_rollout_with_source; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -10,6 +11,8 @@ use codex_app_server_protocol::ReviewStartResponse; use codex_app_server_protocol::ReviewTarget; use codex_app_server_protocol::ThreadForkParams; use codex_app_server_protocol::ThreadForkResponse; +use codex_app_server_protocol::ThreadResumeParams; +use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadSource; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; @@ -18,6 +21,9 @@ use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnSteerParams; use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput as V2UserInput; +use codex_protocol::ThreadId as CoreThreadId; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; use core_test_support::responses; use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; @@ -290,6 +296,104 @@ async fn review_start_sends_fork_lineage_in_turn_metadata_for_thread_fork_v2() - Ok(()) } +#[tokio::test] +async fn turn_start_sends_subagent_lineage_after_cold_thread_resume_v2() -> 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_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + /*supports_websockets*/ false, + )?; + + let parent_thread_id = CoreThreadId::new(); + let parent_thread_id_str = parent_thread_id.to_string(); + let subagent_thread_id = create_fake_rollout_with_source( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved subagent message", + Some("mock_provider"), + /*git_info*/ None, + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + }), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let resume_req = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: subagent_thread_id.clone(), + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_req)), + ) + .await??; + let ThreadResumeResponse { thread, .. } = to_response::(resume_resp)?; + assert_eq!(thread.id, subagent_thread_id); + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Continue".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 TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let request = response_mock.single_request(); + let metadata = request + .header("x-codex-turn-metadata") + .as_deref() + .map(parse_json_header) + .unwrap_or_else(|| panic!("missing x-codex-turn-metadata header")); + assert_eq!( + metadata["parent_thread_id"].as_str(), + Some(parent_thread_id_str.as_str()) + ); + assert_eq!(metadata["subagent_kind"].as_str(), Some("thread_spawn")); + assert_eq!(metadata["thread_id"].as_str(), Some(thread.id.as_str())); + assert_eq!(metadata["turn_id"].as_str(), Some(turn.id.as_str())); + assert!(metadata.get("forked_from_thread_id").is_none()); + + Ok(()) +} + #[tokio::test] async fn turn_steer_updates_client_metadata_on_follow_up_responses_request_v2() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 7e87bf73d6..12fe4f6bea 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -1264,12 +1264,7 @@ impl AgentControl { } fn thread_spawn_parent_thread_id(session_source: &SessionSource) -> Option { - match session_source { - SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id, .. - }) => Some(*parent_thread_id), - _ => None, - } + session_source.parent_thread_id() } fn agent_matches_prefix(agent_path: Option<&AgentPath>, prefix: &AgentPath) -> bool { diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index f47be864fb..620645d8f6 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -1734,19 +1734,9 @@ fn subagent_header_value(session_source: &SessionSource) -> Option { } fn parent_thread_id_header_value(session_source: &SessionSource) -> Option { - match session_source { - SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id, .. - }) => Some(parent_thread_id.to_string()), - SessionSource::Cli - | SessionSource::VSCode - | SessionSource::Exec - | SessionSource::Mcp - | SessionSource::Custom(_) - | SessionSource::Internal(_) - | SessionSource::SubAgent(_) - | SessionSource::Unknown => None, - } + session_source + .parent_thread_id() + .map(|parent_thread_id| parent_thread_id.to_string()) } const RESPONSE_STREAM_CHANNEL_CAPACITY: usize = 1600; diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index bbb51a1821..22f00d7433 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -90,6 +90,7 @@ pub(super) async fn spawn_review_thread( sess.session_id().to_string(), sess.thread_id().to_string(), forked_from_thread_id, + &session_source, parent_turn_context.thread_source, review_turn_id.clone(), #[allow(deprecated)] diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 638bb91198..4d1c27e49c 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -506,6 +506,7 @@ impl Session { session_id.to_string(), thread_id.to_string(), session_configuration.forked_from_thread_id, + &session_configuration.session_source, session_configuration.thread_source, sub_id.clone(), cwd.clone(), diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 3eec8315b1..0a3a089414 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -589,12 +589,12 @@ impl ThreadManager { options: StartThreadOptions, forked_from_thread_id: Option, ) -> CodexResult { - let session_source = options - .session_source - .unwrap_or_else(|| self.state.session_source.clone()); - let thread_source = options - .thread_source - .or_else(|| options.initial_history.get_resumed_thread_source()); + let (resumed_session_source, resumed_thread_source) = options + .initial_history + .get_resumed_session_sources() + .unwrap_or_else(|| (self.state.session_source.clone(), None)); + let session_source = options.session_source.unwrap_or(resumed_session_source); + let thread_source = options.thread_source.or(resumed_thread_source); Box::pin(self.state.spawn_thread_with_source( options.config, options.initial_history, @@ -676,17 +676,22 @@ impl ThreadManager { self.state.environment_manager.as_ref(), &config.cwd, ); - let thread_source = initial_history.get_resumed_thread_source(); - Box::pin(self.state.spawn_thread( + let (session_source, thread_source) = initial_history + .get_resumed_session_sources() + .unwrap_or_else(|| (self.state.session_source.clone(), None)); + Box::pin(self.state.spawn_thread_with_source( config, initial_history, auth_manager, self.agent_control(), + session_source, /*forked_from_thread_id*/ None, thread_source, Vec::new(), persist_extended_history, /*metrics_service_name*/ None, + /*inherited_shell_snapshot*/ None, + /*inherited_exec_policy*/ None, parent_trace, environments, /*user_shell_override*/ None, @@ -732,17 +737,22 @@ impl ThreadManager { self.state.environment_manager.as_ref(), &config.cwd, ); - let thread_source = initial_history.get_resumed_thread_source(); - Box::pin(self.state.spawn_thread( + let (session_source, thread_source) = initial_history + .get_resumed_session_sources() + .unwrap_or_else(|| (self.state.session_source.clone(), None)); + Box::pin(self.state.spawn_thread_with_source( config, initial_history, auth_manager, self.agent_control(), + session_source, /*forked_from_thread_id*/ None, thread_source, Vec::new(), /*persist_extended_history*/ false, /*metrics_service_name*/ None, + /*inherited_shell_snapshot*/ None, + /*inherited_exec_policy*/ None, /*parent_trace*/ None, environments, /*user_shell_override*/ Some(user_shell_override), diff --git a/codex-rs/core/src/turn_metadata.rs b/codex-rs/core/src/turn_metadata.rs index 4f9406d4b0..af4d23a7c1 100644 --- a/codex-rs/core/src/turn_metadata.rs +++ b/codex-rs/core/src/turn_metadata.rs @@ -25,6 +25,7 @@ use codex_protocol::ThreadId; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::ThreadSource; use codex_utils_absolute_path::AbsolutePathBuf; @@ -117,9 +118,10 @@ impl From for TurnMetadataWorkspace { /// Base payload for the outbound model request `x-codex-turn-metadata` header. /// -/// Turn-owned state populates identity fields, including optional fork lineage. A concrete -/// request kind is added at outbound model dispatch so turns, startup prewarm, and compaction -/// remain distinguishable. Detached memory requests are constructed as `memory` directly. +/// Turn-owned state populates identity fields, including optional fork and subagent lineage. A +/// concrete request kind is added at outbound model dispatch so turns, startup prewarm, and +/// compaction remain distinguishable. Detached memory requests are constructed as `memory` +/// directly. #[derive(Clone, Debug, Serialize)] pub(crate) struct TurnMetadataBag { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -131,6 +133,10 @@ pub(crate) struct TurnMetadataBag { #[serde(default, skip_serializing_if = "Option::is_none")] forked_from_thread_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + parent_thread_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + subagent_kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] thread_source: Option, #[serde(default, skip_serializing_if = "Option::is_none")] turn_id: Option, @@ -141,27 +147,6 @@ pub(crate) struct TurnMetadataBag { } impl TurnMetadataBag { - fn new( - request_kind: Option, - session_id: Option, - thread_id: Option, - forked_from_thread_id: Option, - thread_source: Option, - turn_id: Option, - sandbox: Option, - ) -> Self { - Self { - request_kind, - session_id, - thread_id, - forked_from_thread_id, - thread_source, - turn_id, - workspaces: BTreeMap::new(), - sandbox, - } - } - fn with_workspace_git_metadata( mut self, repo_root: Option, @@ -206,6 +191,8 @@ fn merge_turn_metadata( | "turn_id" | TURN_STARTED_AT_UNIX_MS_KEY | "forked_from_thread_id" + | "parent_thread_id" + | "subagent_kind" | REQUEST_KIND_KEY | COMPACTION_KEY | WINDOW_ID_KEY @@ -232,15 +219,18 @@ pub async fn build_turn_metadata_header( get_has_changes(cwd), ); let latest_git_commit_hash = head_commit_hash.map(|sha| sha.0); - TurnMetadataBag::new( - Some(TurnMetadataRequestKind::Memory), - /*session_id*/ None, - /*thread_id*/ None, - /*forked_from_thread_id*/ None, - /*thread_source*/ None, - /*turn_id*/ None, - sandbox.map(ToString::to_string), - ) + TurnMetadataBag { + request_kind: Some(TurnMetadataRequestKind::Memory), + session_id: None, + thread_id: None, + forked_from_thread_id: None, + parent_thread_id: None, + subagent_kind: None, + thread_source: None, + turn_id: None, + workspaces: BTreeMap::new(), + sandbox: sandbox.map(ToString::to_string), + } .with_workspace_git_metadata( repo_root, Some(WorkspaceGitMetadata { @@ -271,6 +261,7 @@ impl TurnMetadataState { session_id: String, thread_id: String, forked_from_thread_id: Option, + session_source: &SessionSource, thread_source: Option, turn_id: String, cwd: AbsolutePathBuf, @@ -287,15 +278,31 @@ impl TurnMetadataState { ) .to_string(), ); - let base_metadata = TurnMetadataBag::new( - /*request_kind*/ None, - Some(session_id), - Some(thread_id), + let (parent_thread_id, subagent_kind) = match session_source { + SessionSource::SubAgent(subagent_source) => ( + subagent_source.parent_thread_id().or(forked_from_thread_id), + Some(subagent_source.kind().to_string()), + ), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Internal(_) + | SessionSource::Unknown => (None, None), + }; + let base_metadata = TurnMetadataBag { + request_kind: None, + session_id: Some(session_id), + thread_id: Some(thread_id), forked_from_thread_id, + parent_thread_id, + subagent_kind, thread_source, - Some(turn_id), + turn_id: Some(turn_id), + workspaces: BTreeMap::new(), sandbox, - ); + }; let base_header = base_metadata.to_header_value(); Self { diff --git a/codex-rs/core/src/turn_metadata_tests.rs b/codex-rs/core/src/turn_metadata_tests.rs index a482392726..f6338f1c63 100644 --- a/codex-rs/core/src/turn_metadata_tests.rs +++ b/codex-rs/core/src/turn_metadata_tests.rs @@ -3,12 +3,16 @@ use super::*; use crate::sandbox_tags::permission_profile_sandbox_tag; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::ThreadSource; +use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::PathBufExt; use core_test_support::PathExt; use pretty_assertions::assert_eq; use serde_json::Value; use std::collections::HashMap; +use std::time::Duration; use tempfile::TempDir; use tokio::process::Command; @@ -19,10 +23,9 @@ fn test_mcp_turn_metadata_context() -> McpTurnMetadataContext<'static> { } } -#[tokio::test] -async fn build_turn_metadata_header_marks_detached_memory_without_turn_identity() { +async fn create_clean_git_repo(repo_name: &str) -> (TempDir, AbsolutePathBuf) { let temp_dir = TempDir::new().expect("temp dir"); - let repo_path = temp_dir.path().join("repo-東京").abs(); + let repo_path = temp_dir.path().join(repo_name).abs(); std::fs::create_dir_all(&repo_path).expect("create repo"); Command::new("git") @@ -43,7 +46,6 @@ async fn build_turn_metadata_header_marks_detached_memory_without_turn_identity( .output() .await .expect("git config user.email"); - std::fs::write(repo_path.join("README.md"), "hello").expect("write file"); Command::new("git") .args(["add", "."]) @@ -58,6 +60,13 @@ async fn build_turn_metadata_header_marks_detached_memory_without_turn_identity( .await .expect("git commit"); + (temp_dir, repo_path) +} + +#[tokio::test] +async fn build_turn_metadata_header_marks_detached_memory_without_turn_identity() { + let (_temp_dir, repo_path) = create_clean_git_repo("repo-東京").await; + let header = build_turn_metadata_header(&repo_path, Some("none")) .await .expect("header"); @@ -113,6 +122,7 @@ fn turn_metadata_state_uses_platform_sandbox_tag() { "session-a".to_string(), "thread-a".to_string(), /*forked_from_thread_id*/ None, + &SessionSource::Exec, Some(ThreadSource::User), "turn-a".to_string(), cwd, @@ -138,6 +148,9 @@ fn turn_metadata_state_uses_platform_sandbox_tag() { assert_eq!(session_id, Some("session-a")); assert_eq!(thread_id, Some("thread-a")); assert_eq!(thread_source, Some("user")); + assert!(json.get("forked_from_thread_id").is_none()); + assert!(json.get("parent_thread_id").is_none()); + assert!(json.get("subagent_kind").is_none()); assert!(json.get("session_source").is_none()); } @@ -150,6 +163,7 @@ fn turn_metadata_state_uses_explicit_subagent_thread_source() { "session-a".to_string(), "thread-a".to_string(), /*forked_from_thread_id*/ None, + &SessionSource::Exec, Some(ThreadSource::Subagent), "turn-a".to_string(), cwd, @@ -177,6 +191,7 @@ fn turn_metadata_state_includes_root_fork_lineage() { "session-a".to_string(), "thread-a".to_string(), Some(source_thread_id), + &SessionSource::Exec, Some(ThreadSource::User), "turn-a".to_string(), cwd, @@ -192,6 +207,122 @@ fn turn_metadata_state_includes_root_fork_lineage() { json["forked_from_thread_id"].as_str(), Some("11111111-1111-4111-8111-111111111111") ); + assert!(json.get("parent_thread_id").is_none()); + assert!(json.get("subagent_kind").is_none()); +} + +#[test] +fn turn_metadata_state_includes_thread_spawn_subagent_parent_without_fork() { + let temp_dir = TempDir::new().expect("temp dir"); + let cwd = temp_dir.path().abs(); + let permission_profile = PermissionProfile::read_only(); + let parent_thread_id = + ThreadId::from_string("22222222-2222-4222-8222-222222222222").expect("thread id"); + + let state = TurnMetadataState::new( + "session-a".to_string(), + "thread-a".to_string(), + /*forked_from_thread_id*/ None, + &SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + }), + Some(ThreadSource::Subagent), + "turn-a".to_string(), + cwd, + &permission_profile, + WindowsSandboxLevel::Disabled, + /*enforce_managed_network*/ false, + ); + + let header = state.current_header_value().expect("header"); + let json: Value = serde_json::from_str(&header).expect("json"); + + assert!(json.get("forked_from_thread_id").is_none()); + assert_eq!( + json["parent_thread_id"].as_str(), + Some("22222222-2222-4222-8222-222222222222") + ); + assert_eq!(json["subagent_kind"].as_str(), Some("thread_spawn")); +} + +#[test] +fn turn_metadata_state_includes_forked_thread_spawn_subagent_lineage() { + let temp_dir = TempDir::new().expect("temp dir"); + let cwd = temp_dir.path().abs(); + let permission_profile = PermissionProfile::read_only(); + let parent_thread_id = + ThreadId::from_string("33333333-3333-4333-8333-333333333333").expect("thread id"); + + let state = TurnMetadataState::new( + "session-a".to_string(), + "thread-a".to_string(), + Some(parent_thread_id), + &SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + }), + Some(ThreadSource::Subagent), + "turn-a".to_string(), + cwd, + &permission_profile, + WindowsSandboxLevel::Disabled, + /*enforce_managed_network*/ false, + ); + + let header = state.current_header_value().expect("header"); + let json: Value = serde_json::from_str(&header).expect("json"); + + assert_eq!( + json["forked_from_thread_id"].as_str(), + Some("33333333-3333-4333-8333-333333333333") + ); + assert_eq!( + json["parent_thread_id"].as_str(), + Some("33333333-3333-4333-8333-333333333333") + ); + assert_eq!(json["subagent_kind"].as_str(), Some("thread_spawn")); +} + +#[test] +fn turn_metadata_state_includes_known_parent_for_other_subagent() { + let temp_dir = TempDir::new().expect("temp dir"); + let cwd = temp_dir.path().abs(); + let permission_profile = PermissionProfile::read_only(); + let parent_thread_id = + ThreadId::from_string("44444444-4444-4444-8444-444444444444").expect("thread id"); + + let state = TurnMetadataState::new( + "session-a".to_string(), + "thread-a".to_string(), + Some(parent_thread_id), + &SessionSource::SubAgent(SubAgentSource::Other("guardian".to_string())), + Some(ThreadSource::Subagent), + "turn-a".to_string(), + cwd, + &permission_profile, + WindowsSandboxLevel::Disabled, + /*enforce_managed_network*/ false, + ); + + let header = state.current_header_value().expect("header"); + let json: Value = serde_json::from_str(&header).expect("json"); + + assert_eq!( + json["forked_from_thread_id"].as_str(), + Some("44444444-4444-4444-8444-444444444444") + ); + assert_eq!( + json["parent_thread_id"].as_str(), + Some("44444444-4444-4444-8444-444444444444") + ); + assert_eq!(json["subagent_kind"].as_str(), Some("guardian")); } #[test] @@ -204,6 +335,7 @@ fn turn_metadata_state_includes_turn_started_at_unix_ms_after_start() { "session-a".to_string(), "thread-a".to_string(), /*forked_from_thread_id*/ None, + &SessionSource::Exec, Some(ThreadSource::User), "turn-a".to_string(), cwd, @@ -232,6 +364,7 @@ fn turn_metadata_state_includes_model_and_reasoning_effort_only_in_request_meta( "session-a".to_string(), "thread-a".to_string(), /*forked_from_thread_id*/ None, + &SessionSource::Exec, /*thread_source*/ None, "turn-a".to_string(), cwd, @@ -279,6 +412,7 @@ fn turn_metadata_state_marks_user_input_requested_during_turn_only_for_mcp_reque "session-a".to_string(), "thread-a".to_string(), /*forked_from_thread_id*/ None, + &SessionSource::Exec, /*thread_source*/ None, "turn-a".to_string(), cwd, @@ -330,6 +464,7 @@ fn turn_metadata_state_ignores_client_reserved_metadata_before_start() { "session-a".to_string(), "thread-a".to_string(), /*forked_from_thread_id*/ None, + &SessionSource::Exec, Some(ThreadSource::User), "turn-a".to_string(), cwd, @@ -346,6 +481,11 @@ fn turn_metadata_state_ignores_client_reserved_metadata_before_start() { "forked_from_thread_id".to_string(), "client-supplied".to_string(), ), + ( + "parent_thread_id".to_string(), + "client-supplied".to_string(), + ), + ("subagent_kind".to_string(), "client-supplied".to_string()), ])); let header = state.current_header_value().expect("header"); @@ -353,6 +493,8 @@ fn turn_metadata_state_ignores_client_reserved_metadata_before_start() { assert!(json.get("turn_started_at_unix_ms").is_none()); assert!(json.get("forked_from_thread_id").is_none()); + assert!(json.get("parent_thread_id").is_none()); + assert!(json.get("subagent_kind").is_none()); } #[test] @@ -362,11 +504,20 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( let permission_profile = PermissionProfile::read_only(); let source_thread_id = ThreadId::from_string("44444444-4444-4444-8444-444444444444").expect("thread id"); + let parent_thread_id = + ThreadId::from_string("55555555-5555-4555-8555-555555555555").expect("thread id"); let state = TurnMetadataState::new( "session-a".to_string(), "thread-a".to_string(), Some(source_thread_id), + &SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + }), Some(ThreadSource::User), "turn-a".to_string(), cwd, @@ -388,6 +539,11 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( "forked_from_thread_id".to_string(), "client-supplied".to_string(), ), + ( + "parent_thread_id".to_string(), + "client-supplied".to_string(), + ), + ("subagent_kind".to_string(), "client-supplied".to_string()), ("turn_id".to_string(), "client-supplied".to_string()), (WINDOW_ID_KEY.to_string(), "client-supplied".to_string()), ("thread_source".to_string(), "client-supplied".to_string()), @@ -414,6 +570,11 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( json["forked_from_thread_id"].as_str(), Some("44444444-4444-4444-8444-444444444444") ); + assert_eq!( + json["parent_thread_id"].as_str(), + Some("55555555-5555-4555-8555-555555555555") + ); + assert_eq!(json["subagent_kind"].as_str(), Some("thread_spawn")); assert_eq!(json["thread_source"].as_str(), Some("user")); assert_eq!(json["turn_id"].as_str(), Some("turn-a")); assert!(json.get("request_kind").is_none()); @@ -451,6 +612,7 @@ fn turn_metadata_state_overlays_compaction_only_on_compaction_requests() { "session-a".to_string(), "thread-a".to_string(), /*forked_from_thread_id*/ None, + &SessionSource::Exec, Some(ThreadSource::User), "turn-a".to_string(), cwd, @@ -497,3 +659,59 @@ fn turn_metadata_state_overlays_compaction_only_on_compaction_requests() { assert_eq!(regular_json[WINDOW_ID_KEY].as_str(), Some("thread-a:3")); assert!(regular_json.get("compaction").is_none()); } + +#[tokio::test] +async fn turn_metadata_state_preserves_lineage_after_git_enrichment() { + let (_temp_dir, repo_path) = create_clean_git_repo("repo").await; + + let permission_profile = PermissionProfile::read_only(); + let parent_thread_id = + ThreadId::from_string("66666666-6666-4666-8666-666666666666").expect("thread id"); + let state = TurnMetadataState::new( + "session-a".to_string(), + "thread-a".to_string(), + Some(parent_thread_id), + &SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + }), + Some(ThreadSource::Subagent), + "turn-a".to_string(), + repo_path, + &permission_profile, + WindowsSandboxLevel::Disabled, + /*enforce_managed_network*/ false, + ); + + state.spawn_git_enrichment_task(); + + let json = tokio::time::timeout(Duration::from_secs(2), async { + loop { + let header = state.current_header_value().expect("header"); + let json: Value = serde_json::from_str(&header).expect("json"); + if json + .get("workspaces") + .and_then(Value::as_object) + .is_some_and(|workspaces| !workspaces.is_empty()) + { + return json; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("git enrichment should complete"); + + assert_eq!( + json["forked_from_thread_id"].as_str(), + Some("66666666-6666-4666-8666-666666666666") + ); + assert_eq!( + json["parent_thread_id"].as_str(), + Some("66666666-6666-4666-8666-666666666666") + ); + assert_eq!(json["subagent_kind"].as_str(), Some("thread_spawn")); +} diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 3c0b5cb462..c83c492e72 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2440,12 +2440,22 @@ impl InitialHistory { } } + pub fn get_resumed_session_sources(&self) -> Option<(SessionSource, Option)> { + let meta = self.get_resumed_session_meta()?; + Some((meta.source.clone(), meta.thread_source)) + } + pub fn get_resumed_thread_source(&self) -> Option { + self.get_resumed_session_meta() + .and_then(|meta| meta.thread_source) + } + + fn get_resumed_session_meta(&self) -> Option<&SessionMeta> { match self { InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => None, InitialHistory::Resumed(resumed) => { resumed.history.iter().find_map(|item| match item { - RolloutItem::SessionMeta(meta_line) => meta_line.meta.thread_source, + RolloutItem::SessionMeta(meta_line) => Some(&meta_line.meta), _ => None, }) } @@ -2630,6 +2640,19 @@ impl SessionSource { .restriction_product() .is_some_and(|product| product.matches_product_restriction(products)) } + + pub fn parent_thread_id(&self) -> Option { + match self { + SessionSource::SubAgent(subagent_source) => subagent_source.parent_thread_id(), + SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Internal(_) + | SessionSource::Unknown => None, + } + } } impl fmt::Display for SubAgentSource { @@ -2650,6 +2673,30 @@ impl fmt::Display for SubAgentSource { } } +impl SubAgentSource { + pub fn kind(&self) -> &str { + match self { + SubAgentSource::Review => "review", + SubAgentSource::Compact => "compact", + SubAgentSource::ThreadSpawn { .. } => "thread_spawn", + SubAgentSource::MemoryConsolidation => "memory_consolidation", + SubAgentSource::Other(other) => other, + } + } + + pub fn parent_thread_id(&self) -> Option { + match self { + SubAgentSource::ThreadSpawn { + parent_thread_id, .. + } => Some(*parent_thread_id), + SubAgentSource::Review + | SubAgentSource::Compact + | SubAgentSource::MemoryConsolidation + | SubAgentSource::Other(_) => None, + } + } +} + impl fmt::Display for InternalSessionSource { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index c67056ab54..e005a0e94d 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -960,13 +960,7 @@ pub(super) fn extract_memory_mode(items: &[RolloutItem]) -> Option { fn thread_spawn_parent_thread_id_from_source_str(source: &str) -> Option { let parsed_source = serde_json::from_str(source) .or_else(|_| serde_json::from_value::(Value::String(source.to_string()))); - match parsed_source.ok() { - Some(SessionSource::SubAgent(codex_protocol::protocol::SubAgentSource::ThreadSpawn { - parent_thread_id, - .. - })) => Some(parent_thread_id), - _ => None, - } + parsed_source.ok()?.parent_thread_id() } #[derive(Clone, Copy)] From 451b38644283345cab51739eef8d683fe1a4219c Mon Sep 17 00:00:00 2001 From: Eric Horacek Date: Fri, 29 May 2026 11:40:44 -0700 Subject: [PATCH 32/40] [exec-server] Kill dropped filesystem helpers (#25116) ## Summary - terminate sandbox filesystem helpers when the Tokio child handle is dropped ## Why A sandbox filesystem helper can stall during process startup before reading stdin. If the owning async operation is cancelled or torn down, the spawned helper should not remain running as an orphaned process. Setting `kill_on_drop(true)` gives the filesystem helper the cleanup behavior that Tokio child processes otherwise do not enable by default. This intentionally does not add a timeout. It does not detect or recover an active hung file edit while the owning future remains alive. A more precise startup-health mechanism can be handled separately. ## Validation - `just test -p codex-exec-server` (186 tests passed; benchmark smoke passed) - `just fmt` - `just fix -p codex-exec-server` - `git diff --check` --- codex-rs/exec-server/src/fs_sandbox.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/exec-server/src/fs_sandbox.rs b/codex-rs/exec-server/src/fs_sandbox.rs index 3356791d44..111d153176 100644 --- a/codex-rs/exec-server/src/fs_sandbox.rs +++ b/codex-rs/exec-server/src/fs_sandbox.rs @@ -298,6 +298,7 @@ fn spawn_command( command.stdin(std::process::Stdio::piped()); command.stdout(std::process::Stdio::piped()); command.stderr(std::process::Stdio::piped()); + command.kill_on_drop(true); command.spawn().map_err(io_error) } From c9dc0f6338450c2bc8b70b96852f7ac422d75bbf Mon Sep 17 00:00:00 2001 From: Channing Conger Date: Fri, 29 May 2026 11:42:52 -0700 Subject: [PATCH 33/40] code-mode: introduce durable session interface (#24180) ## Summary Introduce a `CodeModeSession` interface for executing and managing code-mode cells. This moves cell lifecycle, callback delegation, termination, and shutdown behind a session abstraction, while continuing to use the existing in-process implementation, and the ability to implement an external process one behind this interface. A Codex session owns one `CodeModeSession`, which in turn owns its running cells and stored code-mode state. Each cell is represented to the caller as a `StartedCell`, exposing its cell ID and initial response. It also introduces a `CodeModeSessionDelegate` callback interface. A session uses the delegate to invoke nested host tools and emit notifications while a cell is running, allowing the runtime to communicate with its owning Codex session without depending directly on core turn handling. image --- codex-rs/Cargo.lock | 2 - codex-rs/code-mode/Cargo.toml | 2 - codex-rs/code-mode/src/lib.rs | 13 +- codex-rs/code-mode/src/runtime/mod.rs | 32 +- codex-rs/code-mode/src/service.rs | 952 ++++++++++++------ codex-rs/core/src/session/handlers.rs | 3 + codex-rs/core/src/session/turn.rs | 16 +- codex-rs/core/src/tools/code_mode/delegate.rs | 311 ++++++ .../src/tools/code_mode/execute_handler.rs | 38 +- codex-rs/core/src/tools/code_mode/mod.rs | 107 +- .../core/src/tools/code_mode/wait_handler.rs | 38 +- 11 files changed, 1056 insertions(+), 458 deletions(-) create mode 100644 codex-rs/core/src/tools/code_mode/delegate.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d5434ad976..2eeafe30b9 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2430,8 +2430,6 @@ dependencies = [ name = "codex-code-mode" version = "0.0.0" dependencies = [ - "async-channel", - "async-trait", "codex-protocol", "deno_core_icudata", "pretty_assertions", diff --git a/codex-rs/code-mode/Cargo.toml b/codex-rs/code-mode/Cargo.toml index 19d6c3ab45..879404d8aa 100644 --- a/codex-rs/code-mode/Cargo.toml +++ b/codex-rs/code-mode/Cargo.toml @@ -16,8 +16,6 @@ sandbox = ["v8/v8_enable_sandbox"] workspace = true [dependencies] -async-channel = { workspace = true } -async-trait = { workspace = true } codex-protocol = { workspace = true } deno_core_icudata = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/codex-rs/code-mode/src/lib.rs b/codex-rs/code-mode/src/lib.rs index 3da8c77325..37ba1d2a91 100644 --- a/codex-rs/code-mode/src/lib.rs +++ b/codex-rs/code-mode/src/lib.rs @@ -29,9 +29,18 @@ pub use runtime::WaitOutcome; pub use runtime::WaitRequest; pub use runtime::WaitToPendingOutcome; pub use runtime::WaitToPendingRequest; +pub use service::CellId; pub use service::CodeModeService; -pub use service::CodeModeTurnHost; -pub use service::CodeModeTurnWorker; +pub use service::CodeModeSession; +pub use service::CodeModeSessionDelegate; +pub use service::CodeModeSessionProvider; +pub use service::CodeModeSessionProviderFuture; +pub use service::CodeModeSessionResultFuture; +pub use service::InProcessCodeModeSessionProvider; +pub use service::NoopCodeModeSessionDelegate; +pub use service::NotificationFuture; +pub use service::StartedCell; +pub use service::ToolInvocationFuture; pub const PUBLIC_TOOL_NAME: &str = "exec"; pub const WAIT_TOOL_NAME: &str = "wait"; diff --git a/codex-rs/code-mode/src/runtime/mod.rs b/codex-rs/code-mode/src/runtime/mod.rs index 89893d2b25..21757328ee 100644 --- a/codex-rs/code-mode/src/runtime/mod.rs +++ b/codex-rs/code-mode/src/runtime/mod.rs @@ -19,6 +19,7 @@ use crate::description::EnabledToolMetadata; use crate::description::ToolDefinition; use crate::description::enabled_tool_metadata; use crate::response::FunctionCallOutputContentItem; +use crate::service::CellId; pub const DEFAULT_EXEC_YIELD_TIME_MS: u64 = 10_000; pub const DEFAULT_WAIT_YIELD_TIME_MS: u64 = 10_000; @@ -27,11 +28,6 @@ const EXIT_SENTINEL: &str = "__codex_code_mode_exit__"; #[derive(Clone, Debug)] pub struct ExecuteRequest { - /// Runtime cell id for this execution. - /// - /// Callers allocate this before execution so tracing, waits, and nested tool - /// calls can refer to the cell as soon as JavaScript starts. - pub cell_id: String, pub tool_call_id: String, pub enabled_tools: Vec, pub source: String, @@ -41,14 +37,13 @@ pub struct ExecuteRequest { #[derive(Clone, Debug)] pub struct WaitRequest { - pub cell_id: String, + pub cell_id: CellId, pub yield_time_ms: u64, - pub terminate: bool, } #[derive(Clone, Debug)] pub struct WaitToPendingRequest { - pub cell_id: String, + pub cell_id: CellId, } /// Result of waiting on a code-mode cell. @@ -73,7 +68,7 @@ pub enum ExecuteToPendingOutcome { /// The cell is waiting for more runtime input after draining the runtime /// input queue that was ready at the pending boundary. Pending { - cell_id: String, + cell_id: CellId, content_items: Vec, /// Runtime tool-call ids emitted before this paused execution frontier /// sealed. Hosts can use these ids to drain their tool-call transport @@ -105,15 +100,15 @@ impl From for RuntimeResponse { #[derive(Debug, PartialEq, Serialize)] pub enum RuntimeResponse { Yielded { - cell_id: String, + cell_id: CellId, content_items: Vec, }, Terminated { - cell_id: String, + cell_id: CellId, content_items: Vec, }, Result { - cell_id: String, + cell_id: CellId, content_items: Vec, error_text: Option, }, @@ -126,23 +121,13 @@ pub enum RuntimeResponse { /// if their tool-call graph requires globally unique ids. #[derive(Debug)] pub struct CodeModeNestedToolCall { - pub cell_id: String, + pub cell_id: CellId, pub runtime_tool_call_id: String, pub tool_name: ToolName, pub tool_kind: CodeModeToolKind, pub input: Option, } -#[derive(Debug)] -pub(crate) enum TurnMessage { - ToolCall(CodeModeNestedToolCall), - Notify { - cell_id: String, - call_id: String, - text: String, - }, -} - #[derive(Debug)] pub(crate) enum RuntimeCommand { ToolResponse { id: String, result: JsonValue }, @@ -460,7 +445,6 @@ mod tests { fn execute_request(source: &str) -> ExecuteRequest { ExecuteRequest { - cell_id: "1".to_string(), tool_call_id: "call_1".to_string(), enabled_tools: Vec::new(), source: source.to_string(), diff --git a/codex-rs/code-mode/src/service.rs b/codex-rs/code-mode/src/service.rs index d153f1b416..6589e6ec5a 100644 --- a/codex-rs/code-mode/src/service.rs +++ b/codex-rs/code-mode/src/service.rs @@ -1,14 +1,20 @@ use std::collections::HashMap; +use std::fmt; +use std::future::Future; +use std::pin::Pin; use std::sync::Arc; +use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicU64; use std::sync::atomic::Ordering; use std::time::Duration; -use async_trait::async_trait; +use serde::Deserialize; +use serde::Serialize; use serde_json::Value as JsonValue; use tokio::sync::Mutex; use tokio::sync::mpsc; use tokio::sync::oneshot; +use tokio::task::JoinSet; use tokio_util::sync::CancellationToken; use tracing::warn; @@ -22,35 +28,162 @@ use crate::runtime::RuntimeCommand; use crate::runtime::RuntimeControlCommand; use crate::runtime::RuntimeEvent; use crate::runtime::RuntimeResponse; -use crate::runtime::TurnMessage; use crate::runtime::WaitOutcome; use crate::runtime::WaitRequest; use crate::runtime::WaitToPendingOutcome; use crate::runtime::WaitToPendingRequest; use crate::runtime::spawn_runtime; -#[async_trait] -pub trait CodeModeTurnHost: Send + Sync { - async fn invoke_tool( - &self, +pub type CodeModeSessionResultFuture<'a, T> = + Pin> + Send + 'a>>; +pub type CodeModeSessionProviderFuture<'a> = + CodeModeSessionResultFuture<'a, Arc>; +pub type ToolInvocationFuture<'a> = + Pin> + Send + 'a>>; +pub type NotificationFuture<'a> = Pin> + Send + 'a>>; + +#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +pub struct CellId(String); + +impl CellId { + pub fn new(value: String) -> Self { + Self(value) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl AsRef for CellId { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl fmt::Display for CellId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +pub struct StartedCell { + pub cell_id: CellId, + initial_response_rx: oneshot::Receiver, +} + +impl StartedCell { + pub async fn initial_response(self) -> Result { + self.initial_response_rx + .await + .map_err(|_| "exec runtime ended unexpectedly".to_string()) + } +} + +/// Host callbacks used by a code-mode session while cells are executing. +pub trait CodeModeSessionDelegate: Send + Sync { + fn invoke_tool<'a>( + &'a self, invocation: CodeModeNestedToolCall, cancellation_token: CancellationToken, - ) -> Result; + ) -> ToolInvocationFuture<'a>; - async fn notify(&self, call_id: String, cell_id: String, text: String) -> Result<(), String>; + fn notify<'a>( + &'a self, + call_id: String, + cell_id: CellId, + text: String, + cancellation_token: CancellationToken, + ) -> NotificationFuture<'a>; + + /// Releases delegate state associated with a cell after it reaches a terminal state. + fn cell_closed(&self, cell_id: &CellId); +} + +pub struct NoopCodeModeSessionDelegate; + +impl CodeModeSessionDelegate for NoopCodeModeSessionDelegate { + fn invoke_tool<'a>( + &'a self, + _invocation: CodeModeNestedToolCall, + cancellation_token: CancellationToken, + ) -> ToolInvocationFuture<'a> { + Box::pin(async move { + cancellation_token.cancelled().await; + Err("code mode nested tools are unavailable".to_string()) + }) + } + + fn notify<'a>( + &'a self, + _call_id: String, + _cell_id: CellId, + _text: String, + _cancellation_token: CancellationToken, + ) -> NotificationFuture<'a> { + Box::pin(async { Ok(()) }) + } + + fn cell_closed(&self, _cell_id: &CellId) {} +} + +/// A durable code-mode session owned by one Codex thread. +/// +/// Cells executed in the same session share stored values. Separate sessions +/// must keep those values isolated. Implementations may execute cells +/// in-process or remotely. +pub trait CodeModeSession: Send + Sync { + fn execute<'a>( + &'a self, + request: ExecuteRequest, + ) -> CodeModeSessionResultFuture<'a, StartedCell>; + + fn wait<'a>(&'a self, request: WaitRequest) -> CodeModeSessionResultFuture<'a, WaitOutcome>; + + fn terminate<'a>(&'a self, cell_id: CellId) -> CodeModeSessionResultFuture<'a, WaitOutcome>; + + fn shutdown<'a>(&'a self) -> CodeModeSessionResultFuture<'a, ()>; +} + +/// Creates code-mode sessions for one Codex thread. +/// +/// Providers choose where a session executes and receive the host delegate that +/// the session should use for nested tool calls and notifications. +pub trait CodeModeSessionProvider: Send + Sync { + fn create_session<'a>( + &'a self, + delegate: Arc, + ) -> CodeModeSessionProviderFuture<'a>; +} + +#[derive(Default)] +pub struct InProcessCodeModeSessionProvider; + +impl CodeModeSessionProvider for InProcessCodeModeSessionProvider { + fn create_session<'a>( + &'a self, + delegate: Arc, + ) -> CodeModeSessionProviderFuture<'a> { + Box::pin(async move { + let session: Arc = + Arc::new(CodeModeService::with_delegate(delegate)); + Ok(session) + }) + } } #[derive(Clone)] -struct SessionHandle { - control_tx: mpsc::UnboundedSender, +struct CellHandle { + control_tx: mpsc::UnboundedSender, runtime_tx: std::sync::mpsc::Sender, + cancellation_token: CancellationToken, } struct Inner { stored_values: Mutex>, - sessions: Mutex>, - turn_message_tx: async_channel::Sender, - turn_message_rx: async_channel::Receiver, + cells: Mutex>, + delegate: Arc, + shutting_down: AtomicBool, next_cell_id: AtomicU64, } @@ -60,50 +193,50 @@ pub struct CodeModeService { impl CodeModeService { pub fn new() -> Self { - let (turn_message_tx, turn_message_rx) = async_channel::unbounded(); + Self::with_delegate(Arc::new(NoopCodeModeSessionDelegate)) + } + pub fn with_delegate(delegate: Arc) -> Self { Self { inner: Arc::new(Inner { stored_values: Mutex::new(HashMap::new()), - sessions: Mutex::new(HashMap::new()), - turn_message_tx, - turn_message_rx, + cells: Mutex::new(HashMap::new()), + delegate, + shutting_down: AtomicBool::new(false), next_cell_id: AtomicU64::new(1), }), } } - async fn stored_values(&self) -> HashMap { - self.inner.stored_values.lock().await.clone() + fn allocate_cell_id(&self) -> CellId { + CellId::new( + self.inner + .next_cell_id + .fetch_add(1, Ordering::Relaxed) + .to_string(), + ) } - /// Reserves the runtime cell id for a future `execute` request. - /// - /// The runtime can issue nested tool calls before the first `execute` - /// response is returned. Hosts that need a parent trace object for those - /// nested calls should allocate the cell id up front and pass it back on the - /// `ExecuteRequest`. - pub fn allocate_cell_id(&self) -> String { - self.inner - .next_cell_id - .fetch_add(1, Ordering::Relaxed) - .to_string() - } - - pub async fn execute(&self, request: ExecuteRequest) -> Result { + pub async fn execute(&self, request: ExecuteRequest) -> Result { + if self.inner.shutting_down.load(Ordering::Acquire) { + return Err("code mode session is shutting down".to_string()); + } let initial_yield_time_ms = request.yield_time_ms.unwrap_or(DEFAULT_EXEC_YIELD_TIME_MS); let (response_tx, response_rx) = oneshot::channel(); - self.start_session( + let cell_id = self.allocate_cell_id(); + self.start_cell( + cell_id.clone(), request, - SessionResponseSender::Runtime(response_tx), + CellResponseSender::Runtime(response_tx), Some(initial_yield_time_ms), PendingRuntimeMode::Continue, ) .await?; - response_rx - .await - .map_err(|_| "exec runtime ended unexpectedly".to_string()) + Ok(StartedCell { + cell_id, + initial_response_rx: response_rx, + }) } pub async fn execute_to_pending( @@ -111,9 +244,11 @@ impl CodeModeService { request: ExecuteRequest, ) -> Result { let (response_tx, response_rx) = oneshot::channel(); - self.start_session( + let cell_id = self.allocate_cell_id(); + self.start_cell( + cell_id, request, - SessionResponseSender::ExecuteToPending(response_tx), + CellResponseSender::ExecuteToPending(response_tx), /*initial_yield_time_ms*/ None, PendingRuntimeMode::PauseUntilResumed, ) @@ -124,47 +259,50 @@ impl CodeModeService { .map_err(|_| "exec runtime ended unexpectedly".to_string()) } - async fn start_session( + async fn start_cell( &self, + cell_id: CellId, request: ExecuteRequest, - initial_response_tx: SessionResponseSender, + initial_response_tx: CellResponseSender, initial_yield_time_ms: Option, pending_mode: PendingRuntimeMode, ) -> Result<(), String> { - let cell_id = request.cell_id.clone(); let (event_tx, event_rx) = mpsc::unbounded_channel(); let (control_tx, control_rx) = mpsc::unbounded_channel(); - let stored_values = self.stored_values().await; + let stored_values = self.inner.stored_values.lock().await.clone(); + let cancellation_token = CancellationToken::new(); let (runtime_tx, runtime_control_tx, runtime_terminate_handle) = { - let mut sessions = self.inner.sessions.lock().await; - if sessions.contains_key(&cell_id) { + let mut cells = self.inner.cells.lock().await; + if self.inner.shutting_down.load(Ordering::Acquire) { + return Err("code mode session is shutting down".to_string()); + } + if cells.contains_key(&cell_id) { return Err(format!("exec cell {cell_id} already exists")); } let (runtime_tx, runtime_control_tx, runtime_terminate_handle) = spawn_runtime(stored_values, request, event_tx, pending_mode)?; - // Keep the session registry locked through insertion so a - // caller-owned cell id cannot race with another execute and replace - // a live runtime. - sessions.insert( + cells.insert( cell_id.clone(), - SessionHandle { + CellHandle { control_tx, runtime_tx: runtime_tx.clone(), + cancellation_token: cancellation_token.clone(), }, ); (runtime_tx, runtime_control_tx, runtime_terminate_handle) }; - tokio::spawn(run_session_control( + tokio::spawn(run_cell_control( Arc::clone(&self.inner), - SessionControlContext { - cell_id: cell_id.clone(), + CellControlContext { + cell_id, runtime_tx, runtime_control_tx, pending_mode, runtime_terminate_handle, + cancellation_token, }, event_rx, control_rx, @@ -176,34 +314,44 @@ impl CodeModeService { } pub async fn wait(&self, request: WaitRequest) -> Result { - let cell_id = request.cell_id.clone(); - let handle = self - .inner - .sessions - .lock() - .await - .get(&request.cell_id) - .cloned(); + let WaitRequest { + cell_id, + yield_time_ms, + } = request; + let handle = self.inner.cells.lock().await.get(&cell_id).cloned(); let Some(handle) = handle else { return Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))); }; let (response_tx, response_rx) = oneshot::channel(); - let control_message = if request.terminate { - SessionControlCommand::Terminate { response_tx } - } else { - SessionControlCommand::Poll { - yield_time_ms: request.yield_time_ms, - response_tx, - } + let control_message = CellControlCommand::Poll { + yield_time_ms, + response_tx, }; if handle.control_tx.send(control_message).is_err() { return Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))); } match response_rx.await { Ok(response) => Ok(WaitOutcome::LiveCell(response)), - Err(_) => Ok(WaitOutcome::MissingCell(missing_cell_response( - request.cell_id, - ))), + Err(_) => Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))), + } + } + + pub async fn terminate(&self, cell_id: CellId) -> Result { + let handle = self.inner.cells.lock().await.get(&cell_id).cloned(); + let Some(handle) = handle else { + return Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))); + }; + let (response_tx, response_rx) = oneshot::channel(); + if handle + .control_tx + .send(CellControlCommand::Terminate { response_tx }) + .is_err() + { + return Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))); + } + match response_rx.await { + Ok(response) => Ok(WaitOutcome::LiveCell(response)), + Err(_) => Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))), } } @@ -211,14 +359,8 @@ impl CodeModeService { &self, request: WaitToPendingRequest, ) -> Result { - let cell_id = request.cell_id.clone(); - let handle = self - .inner - .sessions - .lock() - .await - .get(&request.cell_id) - .cloned(); + let cell_id = request.cell_id; + let handle = self.inner.cells.lock().await.get(&cell_id).cloned(); let Some(handle) = handle else { return Ok(WaitToPendingOutcome::MissingCell(missing_cell_response( cell_id, @@ -227,7 +369,7 @@ impl CodeModeService { let (response_tx, response_rx) = oneshot::channel(); if handle .control_tx - .send(SessionControlCommand::PollToPending { response_tx }) + .send(CellControlCommand::PollToPending { response_tx }) .is_err() { return Ok(WaitToPendingOutcome::MissingCell(missing_cell_response( @@ -237,74 +379,33 @@ impl CodeModeService { match response_rx.await { Ok(response) => Ok(WaitToPendingOutcome::LiveCell(response)), Err(_) => Ok(WaitToPendingOutcome::MissingCell(missing_cell_response( - request.cell_id, + cell_id, ))), } } - pub fn start_turn_worker(&self, host: Arc) -> CodeModeTurnWorker { - let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); - let inner = Arc::clone(&self.inner); - let turn_message_rx = self.inner.turn_message_rx.clone(); - - tokio::spawn(async move { - loop { - let next_message = tokio::select! { - _ = &mut shutdown_rx => break, - message = turn_message_rx.recv() => message.ok(), - }; - let Some(next_message) = next_message else { - break; - }; - match next_message { - TurnMessage::Notify { - cell_id, - call_id, - text, - } => { - if let Err(err) = host.notify(call_id, cell_id.clone(), text).await { - warn!( - "failed to deliver code mode notification for cell {cell_id}: {err}" - ); - } - } - TurnMessage::ToolCall(invocation) => { - let host = Arc::clone(&host); - let inner = Arc::clone(&inner); - tokio::spawn(async move { - let cell_id = invocation.cell_id.clone(); - let runtime_tool_call_id = invocation.runtime_tool_call_id.clone(); - let response = - host.invoke_tool(invocation, CancellationToken::new()).await; - let runtime_tx = inner - .sessions - .lock() - .await - .get(&cell_id) - .map(|handle| handle.runtime_tx.clone()); - let Some(runtime_tx) = runtime_tx else { - return; - }; - let command = match response { - Ok(result) => RuntimeCommand::ToolResponse { - id: runtime_tool_call_id, - result, - }, - Err(error_text) => RuntimeCommand::ToolError { - id: runtime_tool_call_id, - error_text, - }, - }; - let _ = runtime_tx.send(command); - }); - } - } - } - }); - - CodeModeTurnWorker { - shutdown_tx: Some(shutdown_tx), + pub async fn shutdown(&self) -> Result<(), String> { + self.inner.shutting_down.store(true, Ordering::Release); + let handles = self + .inner + .cells + .lock() + .await + .values() + .cloned() + .collect::>(); + for handle in handles { + handle.cancellation_token.cancel(); + let (response_tx, _response_rx) = oneshot::channel(); + let _ = handle + .control_tx + .send(CellControlCommand::Terminate { response_tx }); + let _ = handle.runtime_tx.send(RuntimeCommand::Terminate); } + while !self.inner.cells.lock().await.is_empty() { + tokio::task::yield_now().await; + } + Ok(()) } } @@ -314,19 +415,44 @@ impl Default for CodeModeService { } } -pub struct CodeModeTurnWorker { - shutdown_tx: Option>, -} - -impl Drop for CodeModeTurnWorker { +impl Drop for CodeModeService { fn drop(&mut self) { - if let Some(shutdown_tx) = self.shutdown_tx.take() { - let _ = shutdown_tx.send(()); + self.inner.shutting_down.store(true, Ordering::Release); + if let Ok(cells) = self.inner.cells.try_lock() { + for handle in cells.values() { + handle.cancellation_token.cancel(); + let (response_tx, _response_rx) = oneshot::channel(); + let _ = handle + .control_tx + .send(CellControlCommand::Terminate { response_tx }); + let _ = handle.runtime_tx.send(RuntimeCommand::Terminate); + } } } } -enum SessionControlCommand { +impl CodeModeSession for CodeModeService { + fn execute<'a>( + &'a self, + request: ExecuteRequest, + ) -> CodeModeSessionResultFuture<'a, StartedCell> { + Box::pin(CodeModeService::execute(self, request)) + } + + fn wait<'a>(&'a self, request: WaitRequest) -> CodeModeSessionResultFuture<'a, WaitOutcome> { + Box::pin(CodeModeService::wait(self, request)) + } + + fn terminate<'a>(&'a self, cell_id: CellId) -> CodeModeSessionResultFuture<'a, WaitOutcome> { + Box::pin(CodeModeService::terminate(self, cell_id)) + } + + fn shutdown<'a>(&'a self) -> CodeModeSessionResultFuture<'a, ()> { + Box::pin(CodeModeService::shutdown(self)) + } +} + +enum CellControlCommand { Poll { yield_time_ms: u64, response_tx: oneshot::Sender, @@ -339,7 +465,7 @@ enum SessionControlCommand { }, } -enum SessionResponseSender { +enum CellResponseSender { Runtime(oneshot::Sender), ExecuteToPending(oneshot::Sender), } @@ -349,15 +475,16 @@ struct PendingResult { error_text: Option, } -struct SessionControlContext { - cell_id: String, +struct CellControlContext { + cell_id: CellId, runtime_tx: std::sync::mpsc::Sender, runtime_control_tx: std::sync::mpsc::Sender, pending_mode: PendingRuntimeMode, runtime_terminate_handle: v8::IsolateHandle, + cancellation_token: CancellationToken, } -fn missing_cell_response(cell_id: String) -> RuntimeResponse { +fn missing_cell_response(cell_id: CellId) -> RuntimeResponse { RuntimeResponse::Result { error_text: Some(format!("exec cell {cell_id} not found")), cell_id, @@ -365,29 +492,29 @@ fn missing_cell_response(cell_id: String) -> RuntimeResponse { } } -fn pending_result_response(cell_id: &str, result: PendingResult) -> RuntimeResponse { +fn pending_result_response(cell_id: &CellId, result: PendingResult) -> RuntimeResponse { RuntimeResponse::Result { - cell_id: cell_id.to_string(), + cell_id: cell_id.clone(), content_items: result.content_items, error_text: result.error_text, } } -fn send_terminal_response(response_tx: SessionResponseSender, response: RuntimeResponse) { +fn send_terminal_response(response_tx: CellResponseSender, response: RuntimeResponse) { match response_tx { - SessionResponseSender::Runtime(response_tx) => { + CellResponseSender::Runtime(response_tx) => { let _ = response_tx.send(response); } - SessionResponseSender::ExecuteToPending(response_tx) => { + CellResponseSender::ExecuteToPending(response_tx) => { let _ = response_tx.send(ExecuteToPendingOutcome::Completed(response)); } } } fn send_or_buffer_result( - cell_id: &str, + cell_id: &CellId, result: PendingResult, - response_tx: &mut Option, + response_tx: &mut Option, pending_result: &mut Option, ) -> bool { if let Some(response_tx) = response_tx.take() { @@ -401,42 +528,41 @@ fn send_or_buffer_result( } fn send_yield_response( - cell_id: &str, + cell_id: &CellId, content_items: &mut Vec, - response_tx: &mut Option, + response_tx: &mut Option, ) { let Some(current_response_tx) = response_tx.take() else { return; }; match current_response_tx { - SessionResponseSender::Runtime(response_tx) => { + CellResponseSender::Runtime(response_tx) => { let _ = response_tx.send(RuntimeResponse::Yielded { - cell_id: cell_id.to_string(), + cell_id: cell_id.clone(), content_items: std::mem::take(content_items), }); } - SessionResponseSender::ExecuteToPending(execute_to_pending_tx) => { - *response_tx = Some(SessionResponseSender::ExecuteToPending( - execute_to_pending_tx, - )); + CellResponseSender::ExecuteToPending(execute_to_pending_tx) => { + *response_tx = Some(CellResponseSender::ExecuteToPending(execute_to_pending_tx)); } } } -async fn run_session_control( +async fn run_cell_control( inner: Arc, - context: SessionControlContext, + context: CellControlContext, mut event_rx: mpsc::UnboundedReceiver, - mut control_rx: mpsc::UnboundedReceiver, - initial_response_tx: SessionResponseSender, + mut control_rx: mpsc::UnboundedReceiver, + initial_response_tx: CellResponseSender, initial_yield_time_ms: Option, ) { - let SessionControlContext { + let CellControlContext { cell_id, runtime_tx, runtime_control_tx, pending_mode, runtime_terminate_handle, + cancellation_token, } = context; let mut content_items = Vec::new(); let mut pending_tool_call_ids = Vec::new(); @@ -445,6 +571,7 @@ async fn run_session_control( let mut termination_requested = false; let mut runtime_closed = false; let mut yield_timer: Option>> = None; + let mut notification_tasks = JoinSet::new(); loop { tokio::select! { @@ -492,11 +619,11 @@ async fn run_session_control( RuntimeEvent::Pending => { if let Some(current_response_tx) = response_tx.take() { match current_response_tx { - SessionResponseSender::Runtime(runtime_response_tx) => { + CellResponseSender::Runtime(runtime_response_tx) => { response_tx = - Some(SessionResponseSender::Runtime(runtime_response_tx)); + Some(CellResponseSender::Runtime(runtime_response_tx)); } - SessionResponseSender::ExecuteToPending(response_tx) => { + CellResponseSender::ExecuteToPending(response_tx) => { let _ = response_tx.send(ExecuteToPendingOutcome::Pending { cell_id: cell_id.clone(), content_items: std::mem::take(&mut content_items), @@ -516,11 +643,26 @@ async fn run_session_control( send_yield_response(&cell_id, &mut content_items, &mut response_tx); } RuntimeEvent::Notify { call_id, text } => { - let _ = inner.turn_message_tx.send(TurnMessage::Notify { - cell_id: cell_id.clone(), - call_id, - text, - }).await; + let delegate = Arc::clone(&inner.delegate); + let cell_id = cell_id.clone(); + let cancellation_token = cancellation_token.child_token(); + notification_tasks.spawn(async move { + tokio::select! { + result = delegate.notify( + call_id, + cell_id.clone(), + text, + cancellation_token.clone(), + ) => { + if let Err(err) = result { + warn!( + "failed to deliver code mode notification for cell {cell_id}: {err}" + ); + } + } + _ = cancellation_token.cancelled() => {} + } + }); } RuntimeEvent::ToolCall { id, @@ -533,15 +675,25 @@ async fn run_session_control( } let tool_call = CodeModeNestedToolCall { cell_id: cell_id.clone(), - runtime_tool_call_id: id, + runtime_tool_call_id: id.clone(), tool_name: name, tool_kind: kind, input, }; - let _ = inner - .turn_message_tx - .send(TurnMessage::ToolCall(tool_call)) - .await; + let delegate = Arc::clone(&inner.delegate); + let runtime_tx = runtime_tx.clone(); + let cancellation_token = cancellation_token.child_token(); + tokio::spawn(async move { + let response = tokio::select! { + response = delegate.invoke_tool(tool_call, cancellation_token.clone()) => response, + _ = cancellation_token.cancelled() => return, + }; + let command = match response { + Ok(result) => RuntimeCommand::ToolResponse { id, result }, + Err(error_text) => RuntimeCommand::ToolError { id, error_text }, + }; + let _ = runtime_tx.send(command); + }); } RuntimeEvent::Result { stored_value_writes, @@ -558,6 +710,7 @@ async fn run_session_control( } break; } + drain_notification_tasks(&mut notification_tasks).await; inner .stored_values .lock() @@ -578,12 +731,19 @@ async fn run_session_control( } } } + task_result = notification_tasks.join_next(), if !notification_tasks.is_empty() => { + if let Some(Err(err)) = task_result + && !err.is_cancelled() + { + warn!("code mode notification task failed: {err}"); + } + } maybe_command = control_rx.recv() => { let Some(command) = maybe_command else { break; }; match command { - SessionControlCommand::Poll { + CellControlCommand::Poll { yield_time_ms, response_tx: next_response_tx, } => { @@ -591,11 +751,11 @@ async fn run_session_control( let _ = next_response_tx.send(pending_result_response(&cell_id, result)); break; } - response_tx = Some(SessionResponseSender::Runtime(next_response_tx)); + response_tx = Some(CellResponseSender::Runtime(next_response_tx)); yield_timer = Some(Box::pin(tokio::time::sleep(Duration::from_millis(yield_time_ms)))); resume_paused_runtime(&runtime_control_tx, pending_mode); } - SessionControlCommand::PollToPending { + CellControlCommand::PollToPending { response_tx: next_response_tx, } => { if let Some(result) = pending_result.take() { @@ -605,18 +765,19 @@ async fn run_session_control( break; } response_tx = - Some(SessionResponseSender::ExecuteToPending(next_response_tx)); + Some(CellResponseSender::ExecuteToPending(next_response_tx)); yield_timer = None; resume_paused_runtime(&runtime_control_tx, pending_mode); } - SessionControlCommand::Terminate { response_tx: next_response_tx } => { + CellControlCommand::Terminate { response_tx: next_response_tx } => { if let Some(result) = pending_result.take() { let _ = next_response_tx.send(pending_result_response(&cell_id, result)); break; } - response_tx = Some(SessionResponseSender::Runtime(next_response_tx)); + response_tx = Some(CellResponseSender::Runtime(next_response_tx)); termination_requested = true; + cancellation_token.cancel(); yield_timer = None; let _ = runtime_tx.send(RuntimeCommand::Terminate); terminate_paused_runtime(&runtime_control_tx, pending_mode); @@ -650,8 +811,21 @@ async fn run_session_control( } let _ = runtime_tx.send(RuntimeCommand::Terminate); + cancellation_token.cancel(); + drain_notification_tasks(&mut notification_tasks).await; terminate_paused_runtime(&runtime_control_tx, pending_mode); - inner.sessions.lock().await.remove(&cell_id); + inner.cells.lock().await.remove(&cell_id); + inner.delegate.cell_closed(&cell_id); +} + +async fn drain_notification_tasks(notification_tasks: &mut JoinSet<()>) { + while let Some(result) = notification_tasks.join_next().await { + if let Err(err) = result + && !err.is_cancelled() + { + warn!("code mode notification task failed: {err}"); + } + } } fn resume_paused_runtime( @@ -677,6 +851,7 @@ mod tests { use std::collections::HashMap; use std::sync::Arc; use std::sync::atomic::AtomicU64; + use std::sync::atomic::Ordering; use std::time::Duration; use codex_protocol::ToolName; @@ -685,19 +860,21 @@ mod tests { use tokio::sync::mpsc; use tokio::sync::oneshot; + use super::CellControlCommand; + use super::CellControlContext; + use super::CellId; + use super::CellResponseSender; use super::CodeModeService; use super::Inner; + use super::NoopCodeModeSessionDelegate; use super::PendingRuntimeMode; use super::RuntimeCommand; use super::RuntimeResponse; - use super::SessionControlCommand; - use super::SessionControlContext; - use super::SessionResponseSender; use super::WaitOutcome; use super::WaitRequest; use super::WaitToPendingOutcome; use super::WaitToPendingRequest; - use super::run_session_control; + use super::run_cell_control; use crate::CodeModeToolKind; use crate::FunctionCallOutputContentItem; use crate::ToolDefinition; @@ -708,7 +885,6 @@ mod tests { fn execute_request(source: &str) -> ExecuteRequest { ExecuteRequest { - cell_id: "1".to_string(), tool_call_id: "call_1".to_string(), enabled_tools: Vec::new(), source: source.to_string(), @@ -717,13 +893,26 @@ mod tests { } } + fn cell_id(value: &str) -> CellId { + CellId::new(value.to_string()) + } + + async fn execute(service: &CodeModeService, request: ExecuteRequest) -> RuntimeResponse { + service + .execute(request) + .await + .unwrap() + .initial_response() + .await + .unwrap() + } + fn test_inner() -> Arc { - let (turn_message_tx, turn_message_rx) = async_channel::unbounded(); Arc::new(Inner { stored_values: Mutex::new(HashMap::new()), - sessions: Mutex::new(HashMap::new()), - turn_message_tx, - turn_message_rx, + cells: Mutex::new(HashMap::new()), + delegate: Arc::new(NoopCodeModeSessionDelegate), + shutting_down: std::sync::atomic::AtomicBool::new(false), next_cell_id: AtomicU64::new(1), }) } @@ -732,19 +921,20 @@ mod tests { async fn synchronous_exit_returns_successfully() { let service = CodeModeService::new(); - let response = service - .execute(ExecuteRequest { + let response = execute( + &service, + ExecuteRequest { source: r#"text("before"); exit(); text("after");"#.to_string(), yield_time_ms: None, ..execute_request("") - }) - .await - .unwrap(); + }, + ) + .await; assert_eq!( response, RuntimeResponse::Result { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: vec![FunctionCallOutputContentItem::InputText { text: "before".to_string(), }], @@ -753,6 +943,116 @@ mod tests { ); } + #[tokio::test] + async fn stored_values_are_shared_between_cells_but_not_sessions() { + let first_session = CodeModeService::new(); + let second_session = CodeModeService::new(); + + let write_response = execute( + &first_session, + ExecuteRequest { + source: r#"store("key", "visible");"#.to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + let same_session = execute( + &first_session, + ExecuteRequest { + source: r#"text(String(load("key")));"#.to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + let other_session = execute( + &second_session, + ExecuteRequest { + source: r#"text(String(load("key")));"#.to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + assert_eq!( + write_response, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: Vec::new(), + error_text: None, + } + ); + assert_eq!( + same_session, + RuntimeResponse::Result { + cell_id: cell_id("2"), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "visible".to_string(), + }], + error_text: None, + } + ); + assert_eq!( + other_session, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "undefined".to_string(), + }], + error_text: None, + } + ); + } + + #[tokio::test] + async fn shutdown_interrupts_cpu_bound_cells() { + let service = CodeModeService::new(); + + let cell = service + .execute(ExecuteRequest { + source: "while (true) {}".to_string(), + ..execute_request("") + }) + .await + .unwrap(); + assert_eq!( + cell.initial_response().await.unwrap(), + RuntimeResponse::Yielded { + cell_id: cell_id("1"), + content_items: Vec::new(), + } + ); + + tokio::time::timeout(Duration::from_secs(1), service.shutdown()) + .await + .unwrap() + .unwrap(); + } + + #[tokio::test] + async fn start_cell_rejects_new_cell_after_shutdown_begins() { + let service = CodeModeService::new(); + service.inner.shutting_down.store(true, Ordering::Release); + let (response_tx, _response_rx) = oneshot::channel(); + + let error = service + .start_cell( + cell_id("late-cell"), + execute_request(""), + CellResponseSender::Runtime(response_tx), + Some(/*initial_yield_time_ms*/ 1), + PendingRuntimeMode::Continue, + ) + .await + .unwrap_err(); + + assert_eq!(error, "code mode session is shutting down".to_string()); + assert!(service.inner.cells.lock().await.is_empty()); + } + #[tokio::test] async fn execute_to_pending_returns_completed_for_synchronous_results() { let service = CodeModeService::new(); @@ -769,7 +1069,7 @@ mod tests { assert_eq!( response, ExecuteToPendingOutcome::Completed(RuntimeResponse::Result { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: vec![FunctionCallOutputContentItem::InputText { text: "done".to_string(), }], @@ -797,7 +1097,7 @@ mod tests { assert_eq!( response, ExecuteToPendingOutcome::Pending { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: vec![FunctionCallOutputContentItem::InputText { text: "before".to_string(), }], @@ -805,19 +1105,12 @@ mod tests { } ); - let termination = service - .wait(WaitRequest { - cell_id: "1".to_string(), - yield_time_ms: 1, - terminate: true, - }) - .await - .unwrap(); + let termination = service.terminate(cell_id("1")).await.unwrap(); assert_eq!( termination, WaitOutcome::LiveCell(RuntimeResponse::Terminated { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: Vec::new(), }) ); @@ -853,25 +1146,18 @@ await Promise.all([ assert_eq!( response, ExecuteToPendingOutcome::Pending { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: Vec::new(), pending_tool_call_ids: vec!["tool-1".to_string(), "tool-2".to_string()], } ); - let termination = service - .wait(WaitRequest { - cell_id: "1".to_string(), - yield_time_ms: 1, - terminate: true, - }) - .await - .unwrap(); + let termination = service.terminate(cell_id("1")).await.unwrap(); assert_eq!( termination, WaitOutcome::LiveCell(RuntimeResponse::Terminated { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: Vec::new(), }) ); @@ -910,7 +1196,7 @@ await Promise.all([ assert_eq!( initial_response, ExecuteToPendingOutcome::Pending { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: Vec::new(), pending_tool_call_ids: vec!["tool-1".to_string(), "tool-2".to_string()], } @@ -918,10 +1204,10 @@ await Promise.all([ let runtime_tx = service .inner - .sessions + .cells .lock() .await - .get("1") + .get(&cell_id("1")) .unwrap() .runtime_tx .clone(); @@ -932,7 +1218,7 @@ await Promise.all([ let resumed_response = tokio::time::timeout( Duration::from_secs(1), service.wait_to_pending(WaitToPendingRequest { - cell_id: "1".to_string(), + cell_id: cell_id("1"), }), ) .await @@ -942,25 +1228,18 @@ await Promise.all([ assert_eq!( resumed_response, WaitToPendingOutcome::LiveCell(ExecuteToPendingOutcome::Pending { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: Vec::new(), pending_tool_call_ids: vec!["tool-3".to_string()], }) ); - let termination = service - .wait(WaitRequest { - cell_id: "1".to_string(), - yield_time_ms: 1, - terminate: true, - }) - .await - .unwrap(); + let termination = service.terminate(cell_id("1")).await.unwrap(); assert_eq!( termination, WaitOutcome::LiveCell(RuntimeResponse::Terminated { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: Vec::new(), }) ); @@ -987,7 +1266,7 @@ await new Promise(() => {}); assert_eq!( initial_response, ExecuteToPendingOutcome::Pending { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: Vec::new(), pending_tool_call_ids: Vec::new(), } @@ -995,10 +1274,10 @@ await new Promise(() => {}); let runtime_tx = service .inner - .sessions + .cells .lock() .await - .get("1") + .get(&cell_id("1")) .unwrap() .runtime_tx .clone(); @@ -1009,7 +1288,7 @@ await new Promise(() => {}); let resumed_response = tokio::time::timeout( Duration::from_secs(1), service.wait_to_pending(WaitToPendingRequest { - cell_id: "1".to_string(), + cell_id: cell_id("1"), }), ) .await @@ -1019,7 +1298,7 @@ await new Promise(() => {}); assert_eq!( resumed_response, WaitToPendingOutcome::LiveCell(ExecuteToPendingOutcome::Pending { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: vec![FunctionCallOutputContentItem::InputText { text: "after".to_string(), }], @@ -1027,19 +1306,12 @@ await new Promise(() => {}); }) ); - let termination = service - .wait(WaitRequest { - cell_id: "1".to_string(), - yield_time_ms: 1, - terminate: true, - }) - .await - .unwrap(); + let termination = service.terminate(cell_id("1")).await.unwrap(); assert_eq!( termination, WaitOutcome::LiveCell(RuntimeResponse::Terminated { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: Vec::new(), }) ); @@ -1065,7 +1337,7 @@ text("done"); assert_eq!( initial_response, ExecuteToPendingOutcome::Pending { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: Vec::new(), pending_tool_call_ids: Vec::new(), } @@ -1073,10 +1345,10 @@ text("done"); let runtime_tx = service .inner - .sessions + .cells .lock() .await - .get("1") + .get(&cell_id("1")) .unwrap() .runtime_tx .clone(); @@ -1087,7 +1359,7 @@ text("done"); let resumed_response = tokio::time::timeout( Duration::from_secs(1), service.wait_to_pending(WaitToPendingRequest { - cell_id: "1".to_string(), + cell_id: cell_id("1"), }), ) .await @@ -1098,7 +1370,7 @@ text("done"); resumed_response, WaitToPendingOutcome::LiveCell(ExecuteToPendingOutcome::Completed( RuntimeResponse::Result { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: vec![FunctionCallOutputContentItem::InputText { text: "done".to_string(), }], @@ -1112,19 +1384,20 @@ text("done"); async fn v8_console_is_not_exposed_on_global_this() { let service = CodeModeService::new(); - let response = service - .execute(ExecuteRequest { + let response = execute( + &service, + ExecuteRequest { source: r#"text(String(Object.hasOwn(globalThis, "console")));"#.to_string(), yield_time_ms: None, ..execute_request("") - }) - .await - .unwrap(); + }, + ) + .await; assert_eq!( response, RuntimeResponse::Result { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: vec![FunctionCallOutputContentItem::InputText { text: "false".to_string(), }], @@ -1137,8 +1410,9 @@ text("done"); async fn date_locale_string_formats_with_icu_data() { let service = CodeModeService::new(); - let response = service - .execute(ExecuteRequest { + let response = execute( + &service, + ExecuteRequest { source: r#" const value = new Date("2025-01-02T03:04:05Z") .toLocaleString("fr-FR", { @@ -1156,14 +1430,14 @@ text(value); .to_string(), yield_time_ms: None, ..execute_request("") - }) - .await - .unwrap(); + }, + ) + .await; assert_eq!( response, RuntimeResponse::Result { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: vec![FunctionCallOutputContentItem::InputText { text: "jeudi 2 janvier \u{e0} 03:04:05".to_string(), }], @@ -1176,8 +1450,9 @@ text(value); async fn intl_date_time_format_formats_with_icu_data() { let service = CodeModeService::new(); - let response = service - .execute(ExecuteRequest { + let response = execute( + &service, + ExecuteRequest { source: r#" const formatter = new Intl.DateTimeFormat("fr-FR", { weekday: "long", @@ -1194,14 +1469,14 @@ text(formatter.format(new Date("2025-01-02T03:04:05Z"))); .to_string(), yield_time_ms: None, ..execute_request("") - }) - .await - .unwrap(); + }, + ) + .await; assert_eq!( response, RuntimeResponse::Result { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: vec![FunctionCallOutputContentItem::InputText { text: "jeudi 2 janvier \u{e0} 03:04:05".to_string(), }], @@ -1214,8 +1489,9 @@ text(formatter.format(new Date("2025-01-02T03:04:05Z"))); async fn output_helpers_return_undefined() { let service = CodeModeService::new(); - let response = service - .execute(ExecuteRequest { + let response = execute( + &service, + ExecuteRequest { source: r#" const returnsUndefined = [ text("first"), @@ -1227,14 +1503,14 @@ text(JSON.stringify(returnsUndefined)); .to_string(), yield_time_ms: None, ..execute_request("") - }) - .await - .unwrap(); + }, + ) + .await; assert_eq!( response, RuntimeResponse::Result { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: vec![ FunctionCallOutputContentItem::InputText { text: "first".to_string(), @@ -1256,8 +1532,9 @@ text(JSON.stringify(returnsUndefined)); async fn image_helper_accepts_raw_mcp_image_block_with_original_detail() { let service = CodeModeService::new(); - let response = service - .execute(ExecuteRequest { + let response = execute( + &service, + ExecuteRequest { source: r#" image({ type: "image", @@ -1269,14 +1546,14 @@ image({ .to_string(), yield_time_ms: None, ..execute_request("") - }) - .await - .unwrap(); + }, + ) + .await; assert_eq!( response, RuntimeResponse::Result { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: vec![FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==".to_string(), detail: Some(crate::ImageDetail::Original), @@ -1290,8 +1567,9 @@ image({ async fn image_helper_second_arg_overrides_explicit_object_detail() { let service = CodeModeService::new(); - let response = service - .execute(ExecuteRequest { + let response = execute( + &service, + ExecuteRequest { source: r#" image( { @@ -1304,14 +1582,14 @@ image( .to_string(), yield_time_ms: None, ..execute_request("") - }) - .await - .unwrap(); + }, + ) + .await; assert_eq!( response, RuntimeResponse::Result { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: vec![FunctionCallOutputContentItem::InputImage { image_url: "https://example.com/image.jpg".to_string(), detail: Some(crate::ImageDetail::Original), @@ -1325,8 +1603,9 @@ image( async fn image_helper_second_arg_overrides_raw_mcp_image_detail() { let service = CodeModeService::new(); - let response = service - .execute(ExecuteRequest { + let response = execute( + &service, + ExecuteRequest { source: r#" image( { @@ -1341,14 +1620,14 @@ image( .to_string(), yield_time_ms: None, ..execute_request("") - }) - .await - .unwrap(); + }, + ) + .await; assert_eq!( response, RuntimeResponse::Result { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: vec![FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==".to_string(), detail: Some(crate::ImageDetail::High), @@ -1362,8 +1641,9 @@ image( async fn image_helper_accepts_low_detail() { let service = CodeModeService::new(); - let response = service - .execute(ExecuteRequest { + let response = execute( + &service, + ExecuteRequest { source: r#" image({ image_url: "https://example.com/image.jpg", @@ -1373,14 +1653,14 @@ image({ .to_string(), yield_time_ms: None, ..execute_request("") - }) - .await - .unwrap(); + }, + ) + .await; assert_eq!( response, RuntimeResponse::Result { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: vec![FunctionCallOutputContentItem::InputImage { image_url: "https://example.com/image.jpg".to_string(), detail: Some(crate::ImageDetail::Low), @@ -1394,8 +1674,9 @@ image({ async fn image_helper_rejects_unsupported_detail() { let service = CodeModeService::new(); - let response = service - .execute(ExecuteRequest { + let response = execute( + &service, + ExecuteRequest { source: r#" image({ image_url: "https://example.com/image.jpg", @@ -1405,14 +1686,14 @@ image({ .to_string(), yield_time_ms: None, ..execute_request("") - }) - .await - .unwrap(); + }, + ) + .await; assert_eq!( response, RuntimeResponse::Result { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: Vec::new(), error_text: Some( "image detail must be one of: auto, low, high, original".to_string() @@ -1425,8 +1706,9 @@ image({ async fn image_helper_rejects_raw_mcp_result_container() { let service = CodeModeService::new(); - let response = service - .execute(ExecuteRequest { + let response = execute( + &service, + ExecuteRequest { source: r#" image({ content: [ @@ -1443,14 +1725,14 @@ image({ .to_string(), yield_time_ms: None, ..execute_request("") - }) - .await - .unwrap(); + }, + ) + .await; assert_eq!( response, RuntimeResponse::Result { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: Vec::new(), error_text: Some( "image expects a non-empty image URL string, an object with image_url and optional detail, or a raw MCP image block".to_string(), @@ -1465,9 +1747,8 @@ image({ let response = service .wait(WaitRequest { - cell_id: "missing".to_string(), + cell_id: cell_id("missing"), yield_time_ms: 1, - terminate: false, }) .await .unwrap(); @@ -1475,7 +1756,7 @@ image({ assert_eq!( response, WaitOutcome::MissingCell(RuntimeResponse::Result { - cell_id: "missing".to_string(), + cell_id: cell_id("missing"), content_items: Vec::new(), error_text: Some("exec cell missing not found".to_string()), }) @@ -1501,18 +1782,19 @@ image({ ) .unwrap(); - tokio::spawn(run_session_control( + tokio::spawn(run_cell_control( inner, - SessionControlContext { - cell_id: "cell-1".to_string(), + CellControlContext { + cell_id: cell_id("cell-1"), runtime_tx: runtime_tx.clone(), runtime_control_tx, pending_mode: PendingRuntimeMode::Continue, runtime_terminate_handle, + cancellation_token: tokio_util::sync::CancellationToken::new(), }, event_rx, control_rx, - SessionResponseSender::Runtime(initial_response_tx), + CellResponseSender::Runtime(initial_response_tx), Some(/*initial_yield_time_ms*/ 60_000), )); @@ -1521,14 +1803,14 @@ image({ assert_eq!( initial_response_rx.await.unwrap(), RuntimeResponse::Yielded { - cell_id: "cell-1".to_string(), + cell_id: cell_id("cell-1"), content_items: Vec::new(), } ); let (terminate_response_tx, terminate_response_rx) = oneshot::channel(); control_tx - .send(SessionControlCommand::Terminate { + .send(CellControlCommand::Terminate { response_tx: terminate_response_tx, }) .unwrap(); @@ -1545,7 +1827,7 @@ image({ assert_eq!( terminate_response.await, RuntimeResponse::Terminated { - cell_id: "cell-1".to_string(), + cell_id: cell_id("cell-1"), content_items: Vec::new(), } ); diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 135ea61194..7730a30ad7 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -630,6 +630,9 @@ async fn shutdown_session_runtime(sess: &Arc) { .unified_exec_manager .terminate_all_processes() .await; + if let Err(err) = sess.services.code_mode_service.shutdown().await { + warn!("failed to shutdown code mode session: {err}"); + } let mcp_shutdown = { let mut manager = sess.services.mcp_connection_manager.write().await; manager.begin_shutdown() diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 8219c19019..7944be57a5 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -943,16 +943,12 @@ async fn run_sampling_request( Arc::clone(&turn_context), Arc::clone(&turn_diff_tracker), ); - let _code_mode_worker = sess - .services - .code_mode_service - .start_turn_worker( - &sess, - &turn_context, - Arc::clone(&router), - Arc::clone(&turn_diff_tracker), - ) - .await; + let _code_mode_worker = sess.services.code_mode_service.start_turn_worker( + &sess, + &turn_context, + Arc::clone(&router), + Arc::clone(&turn_diff_tracker), + ); let max_retries = turn_context.provider.info().stream_max_retries(); let mut retries = 0; let mut initial_input = Some(input); diff --git a/codex-rs/core/src/tools/code_mode/delegate.rs b/codex-rs/core/src/tools/code_mode/delegate.rs new file mode 100644 index 0000000000..06bb71a280 --- /dev/null +++ b/codex-rs/core/src/tools/code_mode/delegate.rs @@ -0,0 +1,311 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::Mutex; + +use codex_code_mode::CellId; +use codex_code_mode::CodeModeNestedToolCall; +use codex_code_mode::CodeModeSessionDelegate; +use codex_code_mode::NotificationFuture; +use codex_code_mode::ToolInvocationFuture; +use codex_protocol::models::FunctionCallOutputPayload; +use codex_protocol::models::ResponseItem; +use serde_json::Value as JsonValue; +use tokio::sync::oneshot; +use tokio::sync::watch; +use tokio_util::sync::CancellationToken; + +use super::ExecContext; +use super::PUBLIC_TOOL_NAME; +use super::call_nested_tool; +use crate::tools::ToolRouter; +use crate::tools::context::SharedTurnDiffTracker; +use crate::tools::parallel::ToolCallRuntime; + +pub(super) struct CodeModeDispatchBroker { + dispatch_tx: async_channel::Sender, + dispatch_rx: async_channel::Receiver, + dispatch_gates: Arc>>>, +} + +impl CodeModeDispatchBroker { + pub(super) fn new() -> Self { + let (dispatch_tx, dispatch_rx) = async_channel::unbounded(); + Self { + dispatch_tx, + dispatch_rx, + dispatch_gates: Arc::new(Mutex::new(HashMap::new())), + } + } + + pub(super) fn mark_cell_ready_for_dispatch(&self, cell_id: &CellId) { + dispatch_gate(&self.dispatch_gates, cell_id).send_replace(true); + } + + pub(super) fn close_cell(&self, cell_id: &CellId) { + remove_dispatch_gate(&self.dispatch_gates, cell_id); + } + + pub(super) fn start_turn_worker( + &self, + exec: ExecContext, + router: Arc, + tracker: SharedTurnDiffTracker, + ) -> CodeModeDispatchWorker { + let tool_runtime = ToolCallRuntime::new( + router, + Arc::clone(&exec.session), + Arc::clone(&exec.turn), + tracker, + ); + let host = Arc::new(CoreTurnHost { exec, tool_runtime }); + let dispatch_rx = self.dispatch_rx.clone(); + let dispatch_gates = Arc::clone(&self.dispatch_gates); + let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); + tokio::spawn(async move { + loop { + let message = tokio::select! { + _ = &mut shutdown_rx => break, + message = dispatch_rx.recv() => message.ok(), + }; + let Some(message) = message else { + break; + }; + match message { + DispatchMessage::Notify { + call_id, + cell_id, + text, + cancellation_token, + response_tx, + } => { + let response = if wait_until_cell_ready_for_dispatch( + &dispatch_gates, + &cell_id, + &cancellation_token, + ) + .await + { + host.notify(call_id, cell_id, text).await + } else { + remove_dispatch_gate(&dispatch_gates, &cell_id); + Err("code mode notification cancelled".to_string()) + }; + let _ = response_tx.send(response); + } + DispatchMessage::InvokeTool { + invocation, + cancellation_token, + response_tx, + } => { + let cell_id = invocation.cell_id.clone(); + if !wait_until_cell_ready_for_dispatch( + &dispatch_gates, + &cell_id, + &cancellation_token, + ) + .await + { + remove_dispatch_gate(&dispatch_gates, &cell_id); + continue; + } + let host = Arc::clone(&host); + tokio::spawn(async move { + let response = tokio::select! { + response = host.invoke_tool( + invocation, + cancellation_token.clone(), + ) => response, + _ = cancellation_token.cancelled() => return, + }; + let _ = response_tx.send(response); + }); + } + } + } + }); + CodeModeDispatchWorker { + shutdown_tx: Some(shutdown_tx), + } + } +} + +fn dispatch_gate( + dispatch_gates: &Mutex>>, + cell_id: &CellId, +) -> watch::Sender { + let mut dispatch_gates = match dispatch_gates.lock() { + Ok(dispatch_gates) => dispatch_gates, + Err(poisoned) => poisoned.into_inner(), + }; + dispatch_gates + .entry(cell_id.clone()) + .or_insert_with(|| watch::channel(false).0) + .clone() +} + +fn remove_dispatch_gate( + dispatch_gates: &Mutex>>, + cell_id: &CellId, +) { + let mut dispatch_gates = match dispatch_gates.lock() { + Ok(dispatch_gates) => dispatch_gates, + Err(poisoned) => poisoned.into_inner(), + }; + dispatch_gates.remove(cell_id); +} + +async fn wait_until_cell_ready_for_dispatch( + dispatch_gates: &Mutex>>, + cell_id: &CellId, + cancellation_token: &CancellationToken, +) -> bool { + if cancellation_token.is_cancelled() { + return false; + } + let mut ready_rx = dispatch_gate(dispatch_gates, cell_id).subscribe(); + loop { + if *ready_rx.borrow_and_update() { + return true; + } + tokio::select! { + changed = ready_rx.changed() => { + if changed.is_err() { + return false; + } + } + _ = cancellation_token.cancelled() => return false, + } + } +} + +impl CodeModeSessionDelegate for CodeModeDispatchBroker { + fn invoke_tool<'a>( + &'a self, + invocation: CodeModeNestedToolCall, + cancellation_token: CancellationToken, + ) -> ToolInvocationFuture<'a> { + Box::pin(async move { + if cancellation_token.is_cancelled() { + return Err("code mode nested tool call cancelled".to_string()); + } + let (response_tx, response_rx) = oneshot::channel(); + self.dispatch_tx + .send(DispatchMessage::InvokeTool { + invocation, + cancellation_token: cancellation_token.clone(), + response_tx, + }) + .await + .map_err(|_| "code mode nested tool dispatcher is unavailable".to_string())?; + tokio::select! { + response = response_rx => response + .map_err(|_| "code mode nested tool dispatcher stopped".to_string())?, + _ = cancellation_token.cancelled() => { + Err("code mode nested tool call cancelled".to_string()) + } + } + }) + } + + fn notify<'a>( + &'a self, + call_id: String, + cell_id: CellId, + text: String, + cancellation_token: CancellationToken, + ) -> NotificationFuture<'a> { + Box::pin(async move { + if cancellation_token.is_cancelled() { + return Err("code mode notification cancelled".to_string()); + } + let (response_tx, response_rx) = oneshot::channel(); + self.dispatch_tx + .send(DispatchMessage::Notify { + call_id, + cell_id, + text, + cancellation_token: cancellation_token.clone(), + response_tx, + }) + .await + .map_err(|_| "code mode notification dispatcher is unavailable".to_string())?; + tokio::select! { + response = response_rx => response + .map_err(|_| "code mode notification dispatcher stopped".to_string())?, + _ = cancellation_token.cancelled() => { + Err("code mode notification cancelled".to_string()) + } + } + }) + } + + fn cell_closed(&self, cell_id: &CellId) { + self.close_cell(cell_id); + } +} + +enum DispatchMessage { + InvokeTool { + invocation: CodeModeNestedToolCall, + cancellation_token: CancellationToken, + response_tx: oneshot::Sender>, + }, + Notify { + call_id: String, + cell_id: CellId, + text: String, + cancellation_token: CancellationToken, + response_tx: oneshot::Sender>, + }, +} + +pub(crate) struct CodeModeDispatchWorker { + shutdown_tx: Option>, +} + +impl Drop for CodeModeDispatchWorker { + fn drop(&mut self) { + if let Some(shutdown_tx) = self.shutdown_tx.take() { + let _ = shutdown_tx.send(()); + } + } +} + +struct CoreTurnHost { + exec: ExecContext, + tool_runtime: ToolCallRuntime, +} + +impl CoreTurnHost { + async fn invoke_tool( + &self, + invocation: CodeModeNestedToolCall, + cancellation_token: CancellationToken, + ) -> Result { + call_nested_tool( + self.exec.clone(), + self.tool_runtime.clone(), + invocation, + cancellation_token, + ) + .await + .map_err(|error| error.to_string()) + } + + async fn notify(&self, call_id: String, cell_id: CellId, text: String) -> Result<(), String> { + if text.trim().is_empty() { + return Ok(()); + } + self.exec + .session + .inject_if_running(vec![ResponseItem::CustomToolCallOutput { + call_id, + name: Some(PUBLIC_TOOL_NAME.to_string()), + output: FunctionCallOutputPayload::from_text(text), + }]) + .await + .map_err(|_| { + format!("failed to inject exec notify message for cell {cell_id}: no active turn") + }) + } +} diff --git a/codex-rs/core/src/tools/code_mode/execute_handler.rs b/codex-rs/core/src/tools/code_mode/execute_handler.rs index 29f5623849..1d69c8f4cc 100644 --- a/codex-rs/core/src/tools/code_mode/execute_handler.rs +++ b/codex-rs/core/src/tools/code_mode/execute_handler.rs @@ -38,9 +38,22 @@ impl CodeModeExecuteHandler { let exec = ExecContext { session, turn }; let enabled_tools = codex_tools::collect_code_mode_tool_definitions(&self.nested_tool_specs); - // Allocate before starting V8 so the trace can create the parent - // CodeCell before model-authored JavaScript issues nested tool calls. - let runtime_cell_id = exec.session.services.code_mode_service.allocate_cell_id(); + let started_at = std::time::Instant::now(); + let started_cell = exec + .session + .services + .code_mode_service + .execute(codex_code_mode::ExecuteRequest { + tool_call_id: call_id.clone(), + enabled_tools, + source: args.code.clone(), + yield_time_ms: args.yield_time_ms, + max_output_tokens: args.max_output_tokens, + }) + .await + .map_err(FunctionCallError::RespondToModel)?; + let cell_id = started_cell.cell_id.clone(); + let runtime_cell_id = cell_id.to_string(); let code_cell_trace = exec .session .services @@ -51,19 +64,12 @@ impl CodeModeExecuteHandler { call_id.as_str(), args.code.as_str(), ); - let started_at = std::time::Instant::now(); - let response = exec - .session + exec.session .services .code_mode_service - .execute(codex_code_mode::ExecuteRequest { - cell_id: runtime_cell_id, - tool_call_id: call_id, - enabled_tools, - source: args.code, - yield_time_ms: args.yield_time_ms, - max_output_tokens: args.max_output_tokens, - }) + .mark_cell_ready_for_dispatch(&cell_id); + let response = started_cell + .initial_response() .await .map_err(FunctionCallError::RespondToModel)?; // Record the raw runtime boundary. The model-visible custom-tool output @@ -74,6 +80,10 @@ impl CodeModeExecuteHandler { // here when the first response also ended the runtime. if !matches!(response, codex_code_mode::RuntimeResponse::Yielded { .. }) { code_cell_trace.record_ended(&response); + exec.session + .services + .code_mode_service + .finish_cell_dispatch(&cell_id); } handle_runtime_response(&exec, response, args.max_output_tokens, started_at) .await diff --git a/codex-rs/core/src/tools/code_mode/mod.rs b/codex-rs/core/src/tools/code_mode/mod.rs index ff005646c2..ade23cf3b7 100644 --- a/codex-rs/core/src/tools/code_mode/mod.rs +++ b/codex-rs/core/src/tools/code_mode/mod.rs @@ -1,3 +1,4 @@ +mod delegate; mod execute_handler; pub(crate) mod execute_spec; mod response_adapter; @@ -7,13 +8,12 @@ pub(crate) mod wait_spec; use std::sync::Arc; use std::time::Duration; +use codex_code_mode::CellId; use codex_code_mode::CodeModeNestedToolCall; +use codex_code_mode::CodeModeSession; use codex_code_mode::CodeModeToolKind; -use codex_code_mode::CodeModeTurnHost; use codex_code_mode::RuntimeResponse; use codex_protocol::models::FunctionCallOutputContentItem; -use codex_protocol::models::FunctionCallOutputPayload; -use codex_protocol::models::ResponseItem; use serde_json::Value as JsonValue; use tokio_util::sync::CancellationToken; @@ -36,6 +36,8 @@ use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::formatted_truncate_text_content_items_with_policy; use codex_utils_output_truncation::truncate_function_output_items_with_policy; +use delegate::CodeModeDispatchBroker; +use delegate::CodeModeDispatchWorker; pub(crate) use execute_handler::CodeModeExecuteHandler; use response_adapter::into_function_call_output_content_items; pub(crate) use wait_handler::CodeModeWaitHandler; @@ -56,42 +58,67 @@ pub(crate) struct ExecContext { } pub(crate) struct CodeModeService { - inner: codex_code_mode::CodeModeService, + session: Option>, + dispatch_broker: Arc, } impl CodeModeService { pub(crate) fn new() -> Self { + let dispatch_broker = Arc::new(CodeModeDispatchBroker::new()); Self { - inner: codex_code_mode::CodeModeService::new(), + session: Some(Arc::new(codex_code_mode::CodeModeService::with_delegate( + dispatch_broker.clone(), + ))), + dispatch_broker, } } - pub(crate) fn allocate_cell_id(&self) -> String { - self.inner.allocate_cell_id() - } - pub(crate) async fn execute( &self, request: codex_code_mode::ExecuteRequest, - ) -> Result { - self.inner.execute(request).await + ) -> Result { + self.session()?.execute(request).await } pub(crate) async fn wait( &self, request: codex_code_mode::WaitRequest, ) -> Result { - self.inner.wait(request).await + self.session()?.wait(request).await } - pub(crate) async fn start_turn_worker( + pub(crate) async fn terminate( + &self, + cell_id: CellId, + ) -> Result { + self.session()?.terminate(cell_id).await + } + + pub(crate) async fn shutdown(&self) -> Result<(), String> { + match &self.session { + Some(session) => session.shutdown().await, + None => Ok(()), + } + } + + pub(crate) fn mark_cell_ready_for_dispatch(&self, cell_id: &codex_code_mode::CellId) { + self.dispatch_broker.mark_cell_ready_for_dispatch(cell_id); + } + + pub(crate) fn finish_cell_dispatch(&self, cell_id: &CellId) { + self.dispatch_broker.close_cell(cell_id); + } + + pub(crate) fn start_turn_worker( &self, session: &Arc, turn: &Arc, router: Arc, tracker: SharedTurnDiffTracker, - ) -> Option { - if !matches!(turn.tool_mode, ToolMode::CodeMode | ToolMode::CodeModeOnly) { + ) -> Option { + if !matches!(turn.tool_mode, ToolMode::CodeMode | ToolMode::CodeModeOnly) + || self.session.is_none() + { return None; } @@ -99,50 +126,16 @@ impl CodeModeService { session: Arc::clone(session), turn: Arc::clone(turn), }; - let tool_runtime = - ToolCallRuntime::new(router, Arc::clone(session), Arc::clone(turn), tracker); - let host = Arc::new(CoreTurnHost { exec, tool_runtime }); - Some(self.inner.start_turn_worker(host)) - } -} - -struct CoreTurnHost { - exec: ExecContext, - tool_runtime: ToolCallRuntime, -} - -#[async_trait::async_trait] -impl CodeModeTurnHost for CoreTurnHost { - async fn invoke_tool( - &self, - invocation: CodeModeNestedToolCall, - cancellation_token: CancellationToken, - ) -> Result { - call_nested_tool( - self.exec.clone(), - self.tool_runtime.clone(), - invocation, - cancellation_token, + Some( + self.dispatch_broker + .start_turn_worker(exec, router, tracker), ) - .await - .map_err(|error| error.to_string()) } - async fn notify(&self, call_id: String, cell_id: String, text: String) -> Result<(), String> { - if text.trim().is_empty() { - return Ok(()); - } - self.exec - .session - .inject_if_running(vec![ResponseItem::CustomToolCallOutput { - call_id, - name: Some(PUBLIC_TOOL_NAME.to_string()), - output: FunctionCallOutputPayload::from_text(text), - }]) - .await - .map_err(|_| { - format!("failed to inject exec notify message for cell {cell_id}: no active turn") - }) + fn session(&self) -> Result<&Arc, String> { + self.session + .as_ref() + .ok_or_else(|| "code mode is unavailable".to_string()) } } @@ -273,7 +266,7 @@ async fn call_nested_tool( .handle_tool_call_with_source( call, ToolCallSource::CodeMode { - cell_id, + cell_id: cell_id.to_string(), runtime_tool_call_id, }, cancellation_token, diff --git a/codex-rs/core/src/tools/code_mode/wait_handler.rs b/codex-rs/core/src/tools/code_mode/wait_handler.rs index d0c0453df6..74cc314a7b 100644 --- a/codex-rs/core/src/tools/code_mode/wait_handler.rs +++ b/codex-rs/core/src/tools/code_mode/wait_handler.rs @@ -73,17 +73,24 @@ impl ToolExecutor for CodeModeWaitHandler { let args: ExecWaitArgs = parse_arguments(&arguments)?; let exec = ExecContext { session, turn }; let started_at = std::time::Instant::now(); - let wait_response = exec - .session - .services - .code_mode_service - .wait(codex_code_mode::WaitRequest { - cell_id: args.cell_id, - yield_time_ms: args.yield_time_ms, - terminate: args.terminate, - }) - .await - .map_err(FunctionCallError::RespondToModel)?; + let cell_id = codex_code_mode::CellId::new(args.cell_id); + let wait_response = if args.terminate { + exec.session + .services + .code_mode_service + .terminate(cell_id) + .await + } else { + exec.session + .services + .code_mode_service + .wait(codex_code_mode::WaitRequest { + cell_id, + yield_time_ms: args.yield_time_ms, + }) + .await + } + .map_err(FunctionCallError::RespondToModel)?; if let codex_code_mode::WaitOutcome::LiveCell(response) = &wait_response && !matches!(response, codex_code_mode::RuntimeResponse::Yielded { .. }) { @@ -98,8 +105,15 @@ impl ToolExecutor for CodeModeWaitHandler { exec.session .services .rollout_thread_trace - .code_cell_trace_context(exec.turn.sub_id.as_str(), runtime_cell_id) + .code_cell_trace_context( + exec.turn.sub_id.as_str(), + runtime_cell_id.as_str(), + ) .record_ended(response); + exec.session + .services + .code_mode_service + .finish_cell_dispatch(runtime_cell_id); } handle_runtime_response(&exec, wait_response.into(), args.max_tokens, started_at) .await From a1ecf0cf1c25985b2c0e01103b7ddf8cf9fcc051 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 29 May 2026 11:55:31 -0700 Subject: [PATCH 34/40] thread-store: store permission profiles (#23165) ## Why `SandboxPolicy` is the legacy compatibility shape, but `codex-thread-store` still exposed it through `StoredThread`, `ThreadMetadataPatch`, and live metadata sync. That kept thread-store consumers tied to the legacy representation and meant richer permission profile data could not round-trip through thread metadata or cold rollout reconciliation. ## What Changed - Replaced thread-store `sandbox_policy` API fields with canonical `PermissionProfile` fields. - Persist new permission-profile metadata as canonical JSON in the existing SQLite metadata slot while continuing to read older legacy sandbox policy values. - Updated local, in-memory, live metadata sync, and rollout extraction paths to propagate `TurnContextItem::permission_profile()`. - Re-materialize legacy permission metadata against the final rollout cwd when rollout-derived metadata replaces stale SQLite summaries. - Updated affected app-server and core test constructors to build `PermissionProfile` values directly. ## Test Plan - `cargo test -p codex-state` - `cargo test -p codex-thread-store` - `cargo test -p codex-app-server summary_from_stored_thread_preserves_millisecond_precision --lib` - `cargo test -p codex-core realtime_context --lib` --- .../app-server/src/bespoke_event_handling.rs | 4 +- .../thread_processor_tests.rs | 4 +- codex-rs/core/src/realtime_context_tests.rs | 4 +- codex-rs/state/src/extract.rs | 41 ++++++- codex-rs/thread-store/src/in_memory.rs | 8 +- codex-rs/thread-store/src/local/helpers.rs | 39 ++++++- .../thread-store/src/local/read_thread.rs | 110 +++++++++++++++++- .../src/local/update_thread_metadata.rs | 47 +++++++- .../thread-store/src/thread_metadata_sync.rs | 4 +- codex-rs/thread-store/src/types.rs | 16 +-- 10 files changed, 245 insertions(+), 32 deletions(-) diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index fd220ae86d..b870b5b17c 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -2095,6 +2095,7 @@ mod tests { use codex_protocol::items::build_hook_prompt_message; use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; + use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; @@ -2110,7 +2111,6 @@ mod tests { use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow; use codex_protocol::protocol::RolloutItem; - use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -2187,7 +2187,7 @@ mod tests { agent_path: None, git_info: None, approval_mode: AskForApproval::OnRequest, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), token_usage: None, first_user_message: Some("before rollback".to_string()), history: Some(StoredThreadHistory { diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index b5d49cf7fb..2a4b2722ec 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -68,13 +68,13 @@ mod thread_processor_behavior_tests { use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; + use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; - use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_state::ThreadMetadataBuilder; @@ -412,7 +412,7 @@ mod thread_processor_behavior_tests { agent_path: None, git_info: None, approval_mode: AskForApproval::OnRequest, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), token_usage: None, first_user_message: Some("first user message".to_string()), history: None, diff --git a/codex-rs/core/src/realtime_context_tests.rs b/codex-rs/core/src/realtime_context_tests.rs index f36239f2ae..d60484d2e7 100644 --- a/codex-rs/core/src/realtime_context_tests.rs +++ b/codex-rs/core/src/realtime_context_tests.rs @@ -13,10 +13,10 @@ use chrono::Utc; use codex_git_utils::GitSha; use codex_protocol::ThreadId; use codex_protocol::models::ContentItem; +use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::GitInfo; -use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_thread_store::StoredThread; use core_test_support::PathBufExt; @@ -59,7 +59,7 @@ fn stored_thread(cwd: &str, title: &str, first_user_message: &str) -> StoredThre repository_url: None, }), approval_mode: AskForApproval::Never, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), token_usage: None, first_user_message: Some(first_user_message.to_string()), history: None, diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index 5f0734e330..0094e362e5 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -76,7 +76,8 @@ fn apply_turn_context(metadata: &mut ThreadMetadata, turn_ctx: &TurnContextItem) } metadata.model = Some(turn_ctx.model.clone()); metadata.reasoning_effort = turn_ctx.effort; - metadata.sandbox_policy = enum_to_string(&turn_ctx.sandbox_policy); + metadata.sandbox_policy = + serde_json::to_string(&turn_ctx.permission_profile()).unwrap_or_default(); metadata.approval_mode = enum_to_string(&turn_ctx.approval_policy); } @@ -157,6 +158,7 @@ mod tests { use chrono::Utc; use codex_protocol::ThreadId; use codex_protocol::models::ContentItem; + use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; @@ -364,11 +366,46 @@ mod tests { assert_eq!(metadata.cwd, PathBuf::from("/child/worktree")); assert_eq!( metadata.sandbox_policy, - super::enum_to_string(&SandboxPolicy::DangerFullAccess) + serde_json::to_string(&PermissionProfile::Disabled) + .expect("serialize permission profile") ); assert_eq!(metadata.approval_mode, "never"); } + #[test] + fn turn_context_sets_permission_profile_metadata() { + let mut metadata = metadata_for_test(); + let permission_profile = PermissionProfile::workspace_write(); + + apply_rollout_item( + &mut metadata, + &RolloutItem::TurnContext(TurnContextItem { + turn_id: Some("turn-1".to_string()), + cwd: PathBuf::from("/workspace"), + workspace_roots: None, + current_date: None, + timezone: None, + approval_policy: AskForApproval::OnRequest, + sandbox_policy: SandboxPolicy::DangerFullAccess, + permission_profile: Some(permission_profile.clone()), + network: None, + file_system_sandbox_policy: None, + model: "gpt-5".to_string(), + personality: None, + collaboration_mode: None, + realtime_active: None, + effort: None, + summary: codex_protocol::config_types::ReasoningSummary::Auto, + }), + "test-provider", + ); + + assert_eq!( + metadata.sandbox_policy, + serde_json::to_string(&permission_profile).expect("serialize permission profile") + ); + } + #[test] fn turn_context_sets_cwd_when_session_cwd_missing() { let mut metadata = metadata_for_test(); diff --git a/codex-rs/thread-store/src/in_memory.rs b/codex-rs/thread-store/src/in_memory.rs index 5956b3dc41..5dade3a268 100644 --- a/codex-rs/thread-store/src/in_memory.rs +++ b/codex-rs/thread-store/src/in_memory.rs @@ -8,9 +8,9 @@ use std::sync::OnceLock; use async_trait::async_trait; use chrono::Utc; use codex_protocol::ThreadId; +use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::RolloutItem; -use codex_protocol::protocol::SandboxPolicy; use crate::AppendThreadItemsParams; use crate::ArchiveThreadParams; @@ -363,9 +363,9 @@ fn stored_thread_from_state( approval_mode: metadata .and_then(|metadata| metadata.approval_mode) .unwrap_or(AskForApproval::Never), - sandbox_policy: metadata - .and_then(|metadata| metadata.sandbox_policy.clone()) - .unwrap_or_else(SandboxPolicy::new_read_only_policy), + permission_profile: metadata + .and_then(|metadata| metadata.permission_profile.clone()) + .unwrap_or_else(PermissionProfile::read_only), token_usage: metadata.and_then(|metadata| metadata.token_usage.clone()), first_user_message: metadata.and_then(|metadata| metadata.first_user_message.clone()), history, diff --git a/codex-rs/thread-store/src/local/helpers.rs b/codex-rs/thread-store/src/local/helpers.rs index bfdc94fbf0..75120eb56f 100644 --- a/codex-rs/thread-store/src/local/helpers.rs +++ b/codex-rs/thread-store/src/local/helpers.rs @@ -9,8 +9,10 @@ use chrono::DateTime; use chrono::Utc; use codex_git_utils::GitSha; use codex_protocol::ThreadId; +use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::GitInfo; +use codex_protocol::protocol::NetworkAccess; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_rollout::ARCHIVED_SESSIONS_SUBDIR; @@ -140,13 +142,34 @@ pub(super) fn stored_thread_from_rollout_item( agent_path: None, git_info, approval_mode: AskForApproval::OnRequest, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), token_usage: None, first_user_message: item.first_user_message, history: None, }) } +pub(super) fn permission_profile_from_metadata_value(value: &str, cwd: &Path) -> PermissionProfile { + serde_json::from_str::(value) + .or_else(|_| { + parse_legacy_sandbox_policy(value) + .map(|policy| PermissionProfile::from_legacy_sandbox_policy_for_cwd(&policy, cwd)) + }) + .unwrap_or_else(|_| PermissionProfile::read_only()) +} + +pub(super) fn permission_profile_to_metadata_value( + permission_profile: &PermissionProfile, +) -> String { + match serde_json::to_string(permission_profile) { + Ok(value) => value, + Err(err) => { + tracing::warn!("failed to serialize permission profile metadata: {err}"); + String::new() + } + } +} + pub(super) fn distinct_thread_metadata_title(metadata: &ThreadMetadata) -> Option { let title = metadata.title.trim(); if title.is_empty() || metadata.first_user_message.as_deref().map(str::trim) == Some(title) { @@ -169,6 +192,20 @@ fn parse_rfc3339(value: Option<&str>) -> Option> { .map(|dt| dt.with_timezone(&Utc)) } +fn parse_legacy_sandbox_policy(value: &str) -> serde_json::Result { + serde_json::from_str(value) + .or_else(|_| serde_json::from_value(serde_json::Value::String(value.to_string()))) + .or_else(|_| match value { + "danger-full-access" => Ok(SandboxPolicy::DangerFullAccess), + "read-only" => Ok(SandboxPolicy::new_read_only_policy()), + "workspace-write" => Ok(SandboxPolicy::new_workspace_write_policy()), + "external-sandbox" => Ok(SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Restricted, + }), + _ => serde_json::from_value(serde_json::Value::String(value.to_string())), + }) +} + pub(super) fn git_info_from_parts( sha: Option, branch: Option, diff --git a/codex-rs/thread-store/src/local/read_thread.rs b/codex-rs/thread-store/src/local/read_thread.rs index 718527e7ec..767dbda6c0 100644 --- a/codex-rs/thread-store/src/local/read_thread.rs +++ b/codex-rs/thread-store/src/local/read_thread.rs @@ -1,7 +1,7 @@ use chrono::DateTime; use chrono::Utc; +use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource; use codex_rollout::RolloutRecorder; @@ -15,6 +15,7 @@ use codex_state::ThreadMetadata; use super::LocalThreadStore; use super::helpers::distinct_thread_metadata_title; use super::helpers::git_info_from_parts; +use super::helpers::permission_profile_from_metadata_value; use super::helpers::rollout_path_is_archived; use super::helpers::set_thread_name_from_title; use super::helpers::stored_thread_from_rollout_item; @@ -45,6 +46,7 @@ pub(super) async fn read_thread( ) .await) { + let metadata_sandbox_policy = metadata.sandbox_policy.clone(); let mut thread = stored_thread_from_sqlite_metadata(store, metadata).await; if !params.include_history && let Some(rollout_path) = thread.rollout_path.clone() @@ -57,6 +59,10 @@ pub(super) async fn read_thread( rollout_thread.name = thread.name; } rollout_thread.git_info = thread.git_info; + rollout_thread.permission_profile = permission_profile_from_metadata_value( + &metadata_sandbox_policy, + rollout_thread.cwd.as_path(), + ); thread = rollout_thread; } attach_history_if_requested(&mut thread, params.include_history).await?; @@ -286,6 +292,8 @@ async fn stored_thread_from_sqlite_metadata( .clone() .or_else(|| metadata.first_user_message.clone()) .unwrap_or_default(); + let permission_profile = + permission_profile_from_metadata_value(&metadata.sandbox_policy, metadata.cwd.as_path()); StoredThread { thread_id: metadata.id, rollout_path: Some(metadata.rollout_path), @@ -315,10 +323,7 @@ async fn stored_thread_from_sqlite_metadata( metadata.git_origin_url, ), approval_mode: parse_or_default(&metadata.approval_mode, AskForApproval::OnRequest), - sandbox_policy: parse_or_default( - &metadata.sandbox_policy, - SandboxPolicy::new_read_only_policy(), - ), + permission_profile, token_usage: None, first_user_message: metadata.first_user_message, history: None, @@ -377,7 +382,7 @@ fn stored_thread_from_meta_line( agent_path: meta_line.meta.agent_path, git_info: meta_line.git, approval_mode: AskForApproval::OnRequest, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), token_usage: None, first_user_message: None, history: None, @@ -412,6 +417,7 @@ mod tests { use chrono::Utc; use codex_protocol::ThreadId; + use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_state::ThreadMetadataBuilder; use pretty_assertions::assert_eq; @@ -671,6 +677,84 @@ mod tests { assert_eq!(thread.name, Some("Saved title".to_string())); } + #[tokio::test] + async fn read_thread_returns_permission_profile_from_sqlite_metadata() { + let home = TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + let uuid = Uuid::from_u128(225); + let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); + let rollout_path = + write_session_file(home.path(), "2025-01-03T12-00-00", uuid).expect("session file"); + let runtime = codex_state::StateRuntime::init( + config.sqlite_home.clone(), + config.default_model_provider_id.clone(), + ) + .await + .expect("state db should initialize"); + let store = LocalThreadStore::new(config.clone(), Some(runtime.clone())); + let mut builder = + ThreadMetadataBuilder::new(thread_id, rollout_path, Utc::now(), SessionSource::Cli); + builder.model_provider = Some(config.default_model_provider_id.clone()); + builder.cwd = home.path().to_path_buf(); + let mut metadata = builder.build(config.default_model_provider_id.as_str()); + metadata.sandbox_policy = + serde_json::to_string(&PermissionProfile::Disabled).expect("serialize profile"); + runtime + .upsert_thread(&metadata) + .await + .expect("state db upsert should succeed"); + + let thread = store + .read_thread(ReadThreadParams { + thread_id, + include_archived: false, + include_history: false, + }) + .await + .expect("read thread"); + + assert_eq!(thread.preview, "Hello from user"); + assert_eq!(thread.permission_profile, PermissionProfile::Disabled); + } + + #[tokio::test] + async fn read_thread_accepts_legacy_sandbox_policy_metadata() { + let home = TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + let uuid = Uuid::from_u128(226); + let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); + let rollout_path = + write_session_file(home.path(), "2025-01-03T12-00-00", uuid).expect("session file"); + let runtime = codex_state::StateRuntime::init( + config.sqlite_home.clone(), + config.default_model_provider_id.clone(), + ) + .await + .expect("state db should initialize"); + let store = LocalThreadStore::new(config.clone(), Some(runtime.clone())); + let mut builder = + ThreadMetadataBuilder::new(thread_id, rollout_path, Utc::now(), SessionSource::Cli); + builder.model_provider = Some(config.default_model_provider_id.clone()); + builder.cwd = home.path().to_path_buf(); + let mut metadata = builder.build(config.default_model_provider_id.as_str()); + metadata.sandbox_policy = "danger-full-access".to_string(); + runtime + .upsert_thread(&metadata) + .await + .expect("state db upsert should succeed"); + + let thread = store + .read_thread(ReadThreadParams { + thread_id, + include_archived: false, + include_history: true, + }) + .await + .expect("read thread"); + + assert_eq!(thread.permission_profile, PermissionProfile::Disabled); + } + #[tokio::test] async fn read_thread_preserves_rollout_cwd_when_sqlite_metadata_exists() { let home = TempDir::new().expect("temp dir"); @@ -725,6 +809,7 @@ mod tests { let mut metadata = builder.build(config.default_model_provider_id.as_str()); metadata.title = "Saved title".to_string(); metadata.first_user_message = Some("Hello from sqlite".to_string()); + metadata.sandbox_policy = "workspace-write".to_string(); runtime .upsert_thread(&metadata) .await @@ -745,6 +830,19 @@ mod tests { assert_eq!(thread.name, Some("Saved title".to_string())); assert_eq!(thread.model_provider, "rollout-provider"); assert_eq!(thread.cwd, rollout_cwd); + let legacy_policy = SandboxPolicy::WorkspaceWrite { + writable_roots: Vec::new(), + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; + assert_eq!( + thread.permission_profile, + PermissionProfile::from_legacy_sandbox_policy_for_cwd( + &legacy_policy, + rollout_cwd.as_path() + ) + ); } #[tokio::test] diff --git a/codex-rs/thread-store/src/local/update_thread_metadata.rs b/codex-rs/thread-store/src/local/update_thread_metadata.rs index 612328f4c7..3dd0b39a30 100644 --- a/codex-rs/thread-store/src/local/update_thread_metadata.rs +++ b/codex-rs/thread-store/src/local/update_thread_metadata.rs @@ -18,6 +18,7 @@ use tracing::warn; use super::LocalThreadStore; use super::helpers::git_info_from_parts; +use super::helpers::permission_profile_to_metadata_value; use super::live_writer; use crate::GitInfoPatch; use crate::ReadThreadParams; @@ -275,8 +276,8 @@ async fn apply_metadata_update( if let Some(approval_mode) = patch.approval_mode { metadata.approval_mode = enum_to_string(&approval_mode); } - if let Some(sandbox_policy) = patch.sandbox_policy { - metadata.sandbox_policy = enum_to_string(&sandbox_policy); + if let Some(permission_profile) = patch.permission_profile { + metadata.sandbox_policy = permission_profile_to_metadata_value(&permission_profile); } if let Some(token_usage) = patch.token_usage { metadata.tokens_used = token_usage.total_tokens.max(0); @@ -382,7 +383,7 @@ fn has_observed_metadata_facts(patch: &ThreadMetadataPatch) -> bool { || patch.cwd.is_some() || patch.cli_version.is_some() || patch.approval_mode.is_some() - || patch.sandbox_policy.is_some() + || patch.permission_profile.is_some() || patch.token_usage.is_some() || patch.first_user_message.is_some() } @@ -604,6 +605,7 @@ fn rollout_path_is_archived(store: &LocalThreadStore, path: &Path) -> bool { #[cfg(test)] mod tests { + use codex_protocol::models::PermissionProfile; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; @@ -845,6 +847,45 @@ mod tests { ); } + #[tokio::test] + async fn update_thread_metadata_sets_permission_profile() { + let home = TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + let runtime = codex_state::StateRuntime::init( + config.sqlite_home.clone(), + config.default_model_provider_id.clone(), + ) + .await + .expect("state db should initialize"); + let store = LocalThreadStore::new(config, Some(runtime.clone())); + let uuid = Uuid::from_u128(317); + let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); + write_session_file(home.path(), "2025-01-03T20-30-00", uuid).expect("session file"); + + let thread = store + .update_thread_metadata(UpdateThreadMetadataParams { + thread_id, + patch: ThreadMetadataPatch { + permission_profile: Some(PermissionProfile::Disabled), + ..Default::default() + }, + include_archived: false, + }) + .await + .expect("set permission profile"); + + assert_eq!(thread.permission_profile, PermissionProfile::Disabled); + let metadata = runtime + .get_thread(thread_id) + .await + .expect("sqlite metadata read") + .expect("sqlite metadata"); + assert_eq!( + metadata.sandbox_policy, + serde_json::to_string(&PermissionProfile::Disabled).expect("serialize profile") + ); + } + #[tokio::test] async fn update_thread_metadata_partially_updates_git_info() { let home = TempDir::new().expect("temp dir"); diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs index d25a0e65a2..3e83d2f48b 100644 --- a/codex-rs/thread-store/src/thread_metadata_sync.rs +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -234,7 +234,7 @@ impl ThreadMetadataSync { update.model = Some(turn_ctx.model.clone()); update.reasoning_effort = turn_ctx.effort; update.approval_mode = Some(turn_ctx.approval_policy); - update.sandbox_policy = Some(turn_ctx.sandbox_policy.clone()); + update.permission_profile = Some(turn_ctx.permission_profile()); } RolloutItem::EventMsg(EventMsg::UserMessage(user)) => { if let Some(preview) = user_message_preview(user) { @@ -354,7 +354,7 @@ fn update_has_metadata_facts(update: &ThreadMetadataPatch) -> bool { || update.cwd.is_some() || update.cli_version.is_some() || update.approval_mode.is_some() - || update.sandbox_policy.is_some() + || update.permission_profile.is_some() || update.token_usage.is_some() || update.first_user_message.is_some() || update.git_info.is_some() diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index 1e26e5dc77..61763f10f8 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -5,11 +5,11 @@ use chrono::Utc; use codex_protocol::ThreadId; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::BaseInstructions; +use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::GitInfo; use codex_protocol::protocol::RolloutItem; -use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::ThreadMemoryMode as MemoryMode; use codex_protocol::protocol::ThreadSource; @@ -396,8 +396,8 @@ pub struct StoredThread { pub git_info: Option, /// Approval mode captured for the thread. pub approval_mode: AskForApproval, - /// Sandbox policy captured for the thread. - pub sandbox_policy: SandboxPolicy, + /// Canonical runtime permissions captured for the thread. + pub permission_profile: PermissionProfile, /// Last observed token usage. pub token_usage: Option, /// First user message observed for this thread, if any. @@ -519,8 +519,8 @@ pub struct ThreadMetadataPatch { pub cli_version: Option, /// Approval mode. pub approval_mode: Option, - /// Sandbox policy. - pub sandbox_policy: Option, + /// Canonical runtime permissions. + pub permission_profile: Option, /// Last observed token usage. pub token_usage: Option, /// First user message observed for this thread. @@ -589,8 +589,8 @@ impl ThreadMetadataPatch { if next.approval_mode.is_some() { self.approval_mode = next.approval_mode; } - if next.sandbox_policy.is_some() { - self.sandbox_policy = next.sandbox_policy; + if next.permission_profile.is_some() { + self.permission_profile = next.permission_profile; } if next.token_usage.is_some() { self.token_usage = next.token_usage; @@ -626,7 +626,7 @@ impl ThreadMetadataPatch { && self.cwd.is_none() && self.cli_version.is_none() && self.approval_mode.is_none() - && self.sandbox_policy.is_none() + && self.permission_profile.is_none() && self.token_usage.is_none() && self.first_user_message.is_none() && self.git_info.is_none() From 1f93706e994d3f39a9155dff5d41c4141dc67c4c Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Fri, 29 May 2026 12:03:04 -0700 Subject: [PATCH 35/40] [codex] Require model for standalone web search (#25131) ## Why The standalone `/v1/alpha/search` request now requires a `model`, but the `web.run` extension currently omits it. Adds `model` to extension `ToolCall` invocation. Follow-up to #23823. ## What changed - Make `SearchRequest.model` required. - Expose the effective per-turn model on extension tool calls and pass it in standalone web-search requests. - Assert the model is forwarded in the app-server round-trip test. ## Testing - `just test -p codex-api -p codex-tools -p codex-web-search-extension -p codex-memories-extension -p codex-goal-extension` - `just test -p codex-core -E 'test(passes_turn_fields_and_scoped_turn_item_emitter_to_extension_call)'` - `just test -p codex-app-server -E 'test(standalone_web_search_round_trips_encrypted_output)'` --- codex-rs/app-server/tests/suite/v2/web_search.rs | 1 + codex-rs/codex-api/src/endpoint/search.rs | 2 +- codex-rs/codex-api/src/search.rs | 3 +-- codex-rs/core/src/tools/handlers/extension_tools.rs | 3 +++ codex-rs/ext/goal/tests/goal_extension_backend.rs | 1 + codex-rs/ext/memories/src/tests.rs | 6 ++++++ codex-rs/ext/web-search/src/tool.rs | 2 +- codex-rs/tools/src/tool_call.rs | 2 ++ 8 files changed, 16 insertions(+), 4 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/web_search.rs b/codex-rs/app-server/tests/suite/v2/web_search.rs index ef8d562703..0d2d995328 100644 --- a/codex-rs/app-server/tests/suite/v2/web_search.rs +++ b/codex-rs/app-server/tests/suite/v2/web_search.rs @@ -140,6 +140,7 @@ async fn standalone_web_search_round_trips_encrypted_output() -> Result<()> { ); let search_body = search_request_body(&server).await?; + assert_eq!(search_body["model"], json!("mock-model")); assert_eq!( search_body["commands"], json!({ diff --git a/codex-rs/codex-api/src/endpoint/search.rs b/codex-rs/codex-api/src/endpoint/search.rs index 2940f872d8..d01fbfb780 100644 --- a/codex-rs/codex-api/src/endpoint/search.rs +++ b/codex-rs/codex-api/src/endpoint/search.rs @@ -145,7 +145,7 @@ mod tests { .search( &SearchRequest { id: "search-session".to_string(), - model: Some("gpt-test".to_string()), + model: "gpt-test".to_string(), reasoning: None, input: Some(SearchInput::Items(vec![ResponseItem::Message { id: None, diff --git a/codex-rs/codex-api/src/search.rs b/codex-rs/codex-api/src/search.rs index 8328415f83..061b3ac8c6 100644 --- a/codex-rs/codex-api/src/search.rs +++ b/codex-rs/codex-api/src/search.rs @@ -7,8 +7,7 @@ use serde::Serialize; #[derive(Debug, Clone, Serialize, PartialEq)] pub struct SearchRequest { pub id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub model: Option, + pub model: String, #[serde(skip_serializing_if = "Option::is_none")] pub reasoning: Option, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index 8a8854c147..95b272bbf6 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -147,6 +147,7 @@ async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { turn_id: invocation.turn.sub_id.clone(), call_id: invocation.call_id.clone(), tool_name: invocation.tool_name.clone(), + model: invocation.turn.model_info.slug.clone(), truncation_policy: invocation.turn.truncation_policy, conversation_history, turn_item_emitter: Arc::new(CoreTurnItemEmitter { @@ -307,6 +308,7 @@ mod tests { let weak_session = Arc::downgrade(&session); let weak_turn = Arc::downgrade(&turn); let turn_id = turn.sub_id.clone(); + let model = turn.model_info.slug.clone(); let truncation_policy = turn.truncation_policy; let history_item = ResponseItem::Message { id: None, @@ -350,6 +352,7 @@ mod tests { captured_call.tool_name, codex_tools::ToolName::plain("extension_echo") ); + assert_eq!(captured_call.model, model); assert_eq!(captured_call.truncation_policy, truncation_policy); assert_eq!( captured_call.conversation_history.items(), diff --git a/codex-rs/ext/goal/tests/goal_extension_backend.rs b/codex-rs/ext/goal/tests/goal_extension_backend.rs index 92f4313fc7..f325c5f90f 100644 --- a/codex-rs/ext/goal/tests/goal_extension_backend.rs +++ b/codex-rs/ext/goal/tests/goal_extension_backend.rs @@ -1129,6 +1129,7 @@ fn tool_call(tool_name: &str, call_id: &str, arguments: serde_json::Value) -> To turn_id: "turn-1".to_string(), call_id: call_id.to_string(), tool_name: codex_extension_api::ToolName::plain(tool_name), + model: "gpt-test".to_string(), truncation_policy: TruncationPolicy::Bytes(1024), conversation_history: codex_extension_api::ConversationHistory::default(), turn_item_emitter: Arc::new(NoopTurnItemEmitter), diff --git a/codex-rs/ext/memories/src/tests.rs b/codex-rs/ext/memories/src/tests.rs index f2d79c850c..ce03d74965 100644 --- a/codex-rs/ext/memories/src/tests.rs +++ b/codex-rs/ext/memories/src/tests.rs @@ -211,6 +211,7 @@ async fn add_ad_hoc_note_tool_creates_note_file() { turn_id: "turn-1".to_string(), call_id: "call-1".to_string(), tool_name: memory_tool_name(crate::ADD_AD_HOC_NOTE_TOOL_NAME), + model: "gpt-test".to_string(), truncation_policy: TruncationPolicy::Bytes(1024), conversation_history: codex_extension_api::ConversationHistory::default(), turn_item_emitter: Arc::new(NoopTurnItemEmitter), @@ -253,6 +254,7 @@ async fn add_ad_hoc_note_tool_rejects_paths_as_filenames() { turn_id: "turn-1".to_string(), call_id: "call-1".to_string(), tool_name: memory_tool_name(crate::ADD_AD_HOC_NOTE_TOOL_NAME), + model: "gpt-test".to_string(), truncation_policy: TruncationPolicy::Bytes(1024), conversation_history: codex_extension_api::ConversationHistory::default(), turn_item_emitter: Arc::new(NoopTurnItemEmitter), @@ -296,6 +298,7 @@ async fn read_tool_reads_memory_file() { turn_id: "turn-1".to_string(), call_id: "call-1".to_string(), tool_name: memory_tool_name(crate::READ_TOOL_NAME), + model: "gpt-test".to_string(), truncation_policy: TruncationPolicy::Bytes(1024), conversation_history: codex_extension_api::ConversationHistory::default(), turn_item_emitter: Arc::new(NoopTurnItemEmitter), @@ -342,6 +345,7 @@ async fn search_tool_accepts_multiple_queries() { turn_id: "turn-1".to_string(), call_id: "call-1".to_string(), tool_name: memory_tool_name(crate::SEARCH_TOOL_NAME), + model: "gpt-test".to_string(), truncation_policy: TruncationPolicy::Bytes(1024), conversation_history: codex_extension_api::ConversationHistory::default(), turn_item_emitter: Arc::new(NoopTurnItemEmitter), @@ -414,6 +418,7 @@ async fn search_tool_accepts_windowed_all_match_mode() { turn_id: "turn-1".to_string(), call_id: "call-1".to_string(), tool_name: memory_tool_name(crate::SEARCH_TOOL_NAME), + model: "gpt-test".to_string(), truncation_policy: TruncationPolicy::Bytes(1024), conversation_history: codex_extension_api::ConversationHistory::default(), turn_item_emitter: Arc::new(NoopTurnItemEmitter), @@ -466,6 +471,7 @@ async fn search_tool_rejects_legacy_single_query() { turn_id: "turn-1".to_string(), call_id: "call-1".to_string(), tool_name: memory_tool_name(crate::SEARCH_TOOL_NAME), + model: "gpt-test".to_string(), truncation_policy: TruncationPolicy::Bytes(1024), conversation_history: codex_extension_api::ConversationHistory::default(), turn_item_emitter: Arc::new(NoopTurnItemEmitter), diff --git a/codex-rs/ext/web-search/src/tool.rs b/codex-rs/ext/web-search/src/tool.rs index 2cf25b007c..35b855c5c6 100644 --- a/codex-rs/ext/web-search/src/tool.rs +++ b/codex-rs/ext/web-search/src/tool.rs @@ -90,7 +90,7 @@ impl ToolExecutor for WebSearchTool { ); let request = SearchRequest { id: self.session_id.clone(), - model: None, + model: call.model.clone(), reasoning: None, input: recent_input(call.conversation_history.items()), commands: Some(commands), diff --git a/codex-rs/tools/src/tool_call.rs b/codex-rs/tools/src/tool_call.rs index 9bbe00a6c4..e2434ccbe0 100644 --- a/codex-rs/tools/src/tool_call.rs +++ b/codex-rs/tools/src/tool_call.rs @@ -87,6 +87,7 @@ pub struct ToolCall { pub turn_id: String, pub call_id: String, pub tool_name: ToolName, + pub model: String, pub truncation_policy: TruncationPolicy, pub conversation_history: ConversationHistory, pub turn_item_emitter: Arc, @@ -99,6 +100,7 @@ impl std::fmt::Debug for ToolCall { .field("turn_id", &self.turn_id) .field("call_id", &self.call_id) .field("tool_name", &self.tool_name) + .field("model", &self.model) .field("truncation_policy", &self.truncation_policy) .field("conversation_history", &self.conversation_history) .field("turn_item_emitter", &"") From 20da4c37c5a56f168118b4b1573b8bf749d3bece Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 29 May 2026 12:06:55 -0700 Subject: [PATCH 36/40] ci: use issue triage environment for issue workflows (#25134) ## Summary This adds `environment: issue-triage` to the Codex-calling issue workflow jobs so they can read the GitHub Environment Secret while staying on GitHub-hosted runners for public issue-triggered workflows. --- .github/workflows/issue-deduplicator.yml | 2 ++ .github/workflows/issue-labeler.yml | 1 + 2 files changed, 3 insertions(+) diff --git a/.github/workflows/issue-deduplicator.yml b/.github/workflows/issue-deduplicator.yml index f15c190102..fea6348c46 100644 --- a/.github/workflows/issue-deduplicator.yml +++ b/.github/workflows/issue-deduplicator.yml @@ -12,6 +12,7 @@ jobs: # Prevent runs on forks (requires OpenAI API key, wastes Actions minutes) if: github.repository == 'openai/codex' && (github.event.action == 'opened' || (github.event.action == 'labeled' && github.event.label.name == 'codex-deduplicate')) runs-on: ubuntu-latest + environment: issue-triage permissions: contents: read outputs: @@ -157,6 +158,7 @@ jobs: needs: normalize-duplicates-all if: ${{ needs.normalize-duplicates-all.result == 'success' && needs.normalize-duplicates-all.outputs.has_matches != 'true' }} runs-on: ubuntu-latest + environment: issue-triage permissions: contents: read outputs: diff --git a/.github/workflows/issue-labeler.yml b/.github/workflows/issue-labeler.yml index 77fe5d07c8..2c4eb6aa68 100644 --- a/.github/workflows/issue-labeler.yml +++ b/.github/workflows/issue-labeler.yml @@ -12,6 +12,7 @@ jobs: # Prevent runs on forks (requires OpenAI API key, wastes Actions minutes) if: github.repository == 'openai/codex' && (github.event.action == 'opened' || (github.event.action == 'labeled' && github.event.label.name == 'codex-label')) runs-on: ubuntu-latest + environment: issue-triage permissions: contents: read outputs: From a717e4ef31f64feab49b6d857f1b5b1e027ff254 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Fri, 29 May 2026 12:20:17 -0700 Subject: [PATCH 37/40] exec-server: preserve fs helper CoreFoundation env (#25118) ## Summary - preserve macOS `__CF_USER_TEXT_ENCODING` when launching the sandboxed fs helper - keep the fs-helper env narrow; this adds only the CoreFoundation startup var instead of copying the broader MCP stdio baseline - add focused coverage that the helper keeps that var without admitting `HOME` ## Diagnosis The sandboxed fs helper is not launched like a normal child process. Exec-server rebuilds its environment from an allowlist, then calls `env_clear()` before re-execing Codex with `--codex-run-as-fs-helper`. That helper dispatches before the normal Codex startup path and only needs to boot a small Tokio runtime, read one JSON request from stdin, perform the direct filesystem operation, and write one JSON response. The reported macOS hang sampled the helper before Rust main, in CoreFoundation initialization while resolving the default text encoding: `_CFStringGetUserDefaultEncoding -> getpwuid_r -> notify_register_check -> bootstrap_look_up3 -> mach_msg2_trap`. The fs-helper allowlist kept `PATH` and temp vars for runtime needs, but it dropped macOS `__CF_USER_TEXT_ENCODING`. Other Codex subprocess launchers that intentionally build a minimal Unix baseline, such as MCP stdio, already preserve that variable. My read is that stripping `__CF_USER_TEXT_ENCODING` forced this internal helper down CoreFoundation's fallback user-lookup path, and that lookup intermittently wedged on the affected machine before the helper could read stdin or touch the target file. Preserving only this macOS startup variable avoids that fallback without broadening the fs-helper environment to shell-like vars such as `HOME`, `USER`, locale settings, terminal settings, or proxy credentials. Internal Slack thread omitted from the public PR body. ## Validation - `cd codex-rs && just fmt` - `git diff --check` --- codex-rs/exec-server/src/fs_sandbox.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/codex-rs/exec-server/src/fs_sandbox.rs b/codex-rs/exec-server/src/fs_sandbox.rs index 111d153176..8ef20b2d1d 100644 --- a/codex-rs/exec-server/src/fs_sandbox.rs +++ b/codex-rs/exec-server/src/fs_sandbox.rs @@ -230,6 +230,8 @@ fn helper_env_from_vars( fn helper_env_key_is_allowed(key: &str) -> bool { FS_HELPER_ENV_ALLOWLIST.contains(&key) + // CoreFoundation consults this before falling back to user lookup during helper startup. + || (cfg!(target_os = "macos") && key == "__CF_USER_TEXT_ENCODING") || bazel_bwrap_env_key_is_allowed(key) || (cfg!(windows) && key.eq_ignore_ascii_case("PATH")) } @@ -434,6 +436,26 @@ mod tests { ); } + #[cfg(target_os = "macos")] + #[test] + fn helper_env_preserves_corefoundation_text_encoding() { + let env = helper_env_from_vars( + [ + ("__CF_USER_TEXT_ENCODING", "0x1F6:0x0:0x0"), + ("HOME", "/Users/test"), + ] + .map(|(key, value)| (OsString::from(key), OsString::from(value))), + ); + + assert_eq!( + env, + HashMap::from([( + "__CF_USER_TEXT_ENCODING".to_string(), + "0x1F6:0x0:0x0".to_string(), + )]) + ); + } + #[cfg(windows)] #[test] fn helper_env_preserves_windows_path_key_for_system_bwrap_discovery() { From f2e7b462a932c654f8c9810387a73c12bd4e198f Mon Sep 17 00:00:00 2001 From: Jinghan Xu Date: Fri, 29 May 2026 14:01:27 -0700 Subject: [PATCH 38/40] [codex] Fix Vim normal mode editing (#25022) ## Summary - add Vim normal-mode `s` support to substitute the character under the cursor and enter insert mode - fix Vim normal-mode `o` so opening below the final line moves the cursor onto the new blank line - update keymap config/schema and keymap picker snapshots for the new action ## Validation - `just fmt` - `just write-config-schema` - `just test -p codex-config` - focused `just test -p codex-tui` coverage for the Vim `s` and `o` behavior, keymap conflict handling, and keymap picker snapshots - `cargo insta pending-snapshots --manifest-path tui/Cargo.toml` - `git diff --check` ## Notes A full `just test -p codex-tui` run still has two unrelated Guardian feature-flag failures in this checkout: - `app::tests::update_feature_flags_disabling_guardian_clears_review_policy_and_restores_default` - `app::tests::update_feature_flags_disabling_guardian_clears_manual_review_policy_without_history` --- codex-rs/config/src/tui_keymap.rs | 2 + codex-rs/core/config.schema.json | 10 ++++ codex-rs/tui/src/bottom_pane/textarea.rs | 57 ++++++++++++++++++- codex-rs/tui/src/keymap.rs | 43 ++++++++++++++ codex-rs/tui/src/keymap_setup/actions.rs | 3 + ...ap_setup__tests__keymap_picker_custom.snap | 2 +- ...ests__keymap_picker_fast_mode_enabled.snap | 2 +- ...p__tests__keymap_picker_first_actions.snap | 4 +- ...ap_setup__tests__keymap_picker_narrow.snap | 2 +- ...ymap_setup__tests__keymap_picker_wide.snap | 2 +- 10 files changed, 119 insertions(+), 8 deletions(-) diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index e063cac0e8..99cb3e50fc 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -223,6 +223,8 @@ pub struct TuiVimNormalKeymap { pub move_line_end: Option, /// Delete character under cursor (`x`). pub delete_char: Option, + /// Delete character under cursor and enter insert mode (`s`). + pub substitute_char: Option, /// Delete from cursor to end of line (`D`). pub delete_to_line_end: Option, /// Change from cursor to end of line and enter insert mode (`C`). diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index b7bdff813b..2d7fd818c9 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2858,6 +2858,7 @@ "start_change_operator": null, "start_delete_operator": null, "start_yank_operator": null, + "substitute_char": null, "yank_line": null }, "vim_operator": { @@ -3548,6 +3549,7 @@ "start_change_operator": null, "start_delete_operator": null, "start_yank_operator": null, + "substitute_char": null, "yank_line": null } }, @@ -3975,6 +3977,14 @@ ], "description": "Begin yank operator; next key selects motion (`y`)." }, + "substitute_char": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Delete character under cursor and enter insert mode (`s`)." + }, "yank_line": { "allOf": [ { diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 39da5752c2..f67c3f8787 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -677,9 +677,10 @@ impl TextArea { } if self.vim_normal_keymap.open_line_below.is_pressed(event) { let eol = self.end_of_current_line(); - let insert_at = if eol < self.text.len() { eol + 1 } else { eol }; + let old_len = self.text.len(); + let insert_at = if eol < old_len { eol + 1 } else { eol }; self.insert_str_at(insert_at, "\n"); - let cursor = if eol < self.text.len() { + let cursor = if eol < old_len { insert_at } else { insert_at + 1 @@ -735,6 +736,13 @@ impl TextArea { self.delete_forward_kill(/*n*/ 1); return; } + if self.vim_normal_keymap.substitute_char.is_pressed(event) { + if self.cursor_pos < self.end_of_current_line() { + self.delete_forward_kill(/*n*/ 1); + } + self.vim_mode = VimMode::Insert; + return; + } if self.vim_normal_keymap.delete_to_line_end.is_pressed(event) { self.vim_kill_to_end_of_line(); return; @@ -2360,6 +2368,38 @@ mod tests { assert_eq!(t.cursor(), 6); } + #[test] + fn vim_s_substitutes_current_character_and_enters_insert_mode() { + let mut t = ta_with("abc"); + t.set_cursor(/*pos*/ 1); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "ac"); + assert_eq!(t.cursor(), 1); + assert_eq!(t.vim_mode_label(), Some("Insert")); + + t.input(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "aXc"); + assert_eq!(t.cursor(), 2); + assert_eq!(t.vim_mode_label(), Some("Insert")); + } + + #[test] + fn vim_s_on_empty_line_enters_insert_without_deleting_newline() { + let mut t = ta_with("before\n\nnext"); + t.set_cursor(/*pos*/ "before\n".len()); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "before\n\nnext"); + assert_eq!(t.cursor(), "before\n".len()); + assert_eq!(t.vim_mode_label(), Some("Insert")); + } + #[test] fn vim_d_at_line_end_does_not_remove_newline() { let mut t = ta_with("hello\nworld"); @@ -2414,6 +2454,19 @@ mod tests { assert_eq!(t.cursor(), "one\n".len()); } + #[test] + fn vim_o_opens_line_below_final_line_and_moves_to_new_line() { + let mut t = ta_with("one"); + t.set_cursor(/*pos*/ 1); + t.set_vim_enabled(/*enabled*/ true); + + t.input(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "one\n"); + assert_eq!(t.vim_mode_label(), Some("Insert")); + assert_eq!(t.cursor(), "one\n".len()); + } + #[test] fn vim_delete_word() { let mut t = ta_with("hello world"); diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 7bb554a257..9a656d8468 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -158,6 +158,7 @@ pub(crate) struct VimNormalKeymap { pub(crate) move_line_start: Vec, pub(crate) move_line_end: Vec, pub(crate) delete_char: Vec, + pub(crate) substitute_char: Vec, pub(crate) delete_to_line_end: Vec, pub(crate) change_to_line_end: Vec, pub(crate) yank_line: Vec, @@ -494,6 +495,7 @@ impl RuntimeKeymap { move_line_start: resolve_local!(keymap, defaults, vim_normal, move_line_start), move_line_end: resolve_local!(keymap, defaults, vim_normal, move_line_end), delete_char: resolve_local!(keymap, defaults, vim_normal, delete_char), + substitute_char: resolve_local!(keymap, defaults, vim_normal, substitute_char), delete_to_line_end: resolve_local!(keymap, defaults, vim_normal, delete_to_line_end), change_to_line_end: resolve_local!(keymap, defaults, vim_normal, change_to_line_end), yank_line: resolve_local!(keymap, defaults, vim_normal, yank_line), @@ -579,6 +581,10 @@ impl RuntimeKeymap { keymap.vim_normal.delete_char.as_ref(), vim_normal.delete_char.as_slice(), ), + ( + keymap.vim_normal.change_to_line_end.as_ref(), + vim_normal.change_to_line_end.as_slice(), + ), ( keymap.vim_normal.delete_to_line_end.as_ref(), vim_normal.delete_to_line_end.as_slice(), @@ -599,6 +605,10 @@ impl RuntimeKeymap { keymap.vim_normal.start_yank_operator.as_ref(), vim_normal.start_yank_operator.as_slice(), ), + ( + keymap.vim_normal.start_change_operator.as_ref(), + vim_normal.start_change_operator.as_slice(), + ), ( keymap.vim_normal.cancel_operator.as_ref(), vim_normal.cancel_operator.as_slice(), @@ -610,6 +620,11 @@ impl RuntimeKeymap { .start_change_operator .retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding)); } + if keymap.vim_normal.substitute_char.is_none() { + vim_normal + .substitute_char + .retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding)); + } let mut vim_operator = VimOperatorKeymap { delete_line: resolve_local!(keymap, defaults, vim_operator, delete_line), @@ -990,6 +1005,7 @@ impl RuntimeKeymap { shift(KeyCode::Char('$')) ], delete_char: default_bindings![plain(KeyCode::Char('x'))], + substitute_char: default_bindings![plain(KeyCode::Char('s'))], delete_to_line_end: default_bindings![ shift(KeyCode::Char('d')), plain(KeyCode::Char('D')) @@ -1434,6 +1450,10 @@ impl RuntimeKeymap { ), ("move_line_end", self.vim_normal.move_line_end.as_slice()), ("delete_char", self.vim_normal.delete_char.as_slice()), + ( + "substitute_char", + self.vim_normal.substitute_char.as_slice(), + ), ( "delete_to_line_end", self.vim_normal.delete_to_line_end.as_slice(), @@ -2311,6 +2331,29 @@ mod tests { expect_conflict(&keymap, "move_left", "start_change_operator"); } + #[test] + fn configured_legacy_vim_normal_bindings_prune_new_substitute_default() { + let mut keymap = TuiKeymap::default(); + keymap.vim_normal.move_left = Some(one("s")); + + let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); + + assert_eq!( + runtime.vim_normal.move_left, + vec![key_hint::plain(KeyCode::Char('s'))] + ); + assert_eq!(runtime.vim_normal.substitute_char, Vec::new()); + } + + #[test] + fn explicit_new_vim_normal_substitute_binding_still_conflicts_with_legacy_binding() { + let mut keymap = TuiKeymap::default(); + keymap.vim_normal.move_left = Some(one("s")); + keymap.vim_normal.substitute_char = Some(one("s")); + + expect_conflict(&keymap, "move_left", "substitute_char"); + } + #[test] fn configured_legacy_vim_operator_bindings_prune_new_text_object_defaults() { let mut keymap = TuiKeymap::default(); diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index 87d65b225f..b355cda69f 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -135,6 +135,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("vim_normal", "Vim normal", "move_line_start", "Move to the start of the line."), action("vim_normal", "Vim normal", "move_line_end", "Move to the end of the line."), action("vim_normal", "Vim normal", "delete_char", "Delete the character under the cursor."), + action("vim_normal", "Vim normal", "substitute_char", "Delete the character under the cursor and enter insert mode."), action("vim_normal", "Vim normal", "delete_to_line_end", "Delete from cursor to end of line."), action("vim_normal", "Vim normal", "change_to_line_end", "Change from cursor to end of line and enter insert mode."), action("vim_normal", "Vim normal", "yank_line", "Yank the entire line."), @@ -277,6 +278,7 @@ pub(super) fn binding_slot<'a>( ("vim_normal", "move_line_start") => Some(&mut keymap.vim_normal.move_line_start), ("vim_normal", "move_line_end") => Some(&mut keymap.vim_normal.move_line_end), ("vim_normal", "delete_char") => Some(&mut keymap.vim_normal.delete_char), + ("vim_normal", "substitute_char") => Some(&mut keymap.vim_normal.substitute_char), ("vim_normal", "delete_to_line_end") => Some(&mut keymap.vim_normal.delete_to_line_end), ("vim_normal", "change_to_line_end") => Some(&mut keymap.vim_normal.change_to_line_end), ("vim_normal", "yank_line") => Some(&mut keymap.vim_normal.yank_line), @@ -401,6 +403,7 @@ pub(super) fn bindings_for_action<'a>( ("vim_normal", "move_line_start") => Some(runtime_keymap.vim_normal.move_line_start.as_slice()), ("vim_normal", "move_line_end") => Some(runtime_keymap.vim_normal.move_line_end.as_slice()), ("vim_normal", "delete_char") => Some(runtime_keymap.vim_normal.delete_char.as_slice()), + ("vim_normal", "substitute_char") => Some(runtime_keymap.vim_normal.substitute_char.as_slice()), ("vim_normal", "delete_to_line_end") => Some(runtime_keymap.vim_normal.delete_to_line_end.as_slice()), ("vim_normal", "change_to_line_end") => Some(runtime_keymap.vim_normal.change_to_line_end.as_slice()), ("vim_normal", "yank_line") => Some(runtime_keymap.vim_normal.yank_line.as_slice()), diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap index 6c0d008aad..7969d461ef 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 107 actions, 1 customized, 2 unbound. + 108 actions, 1 customized, 2 unbound. [All] Common Customized (1) Unbound (2) App Composer Editor Vim Navigation Approval Debug diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap index b7384d1c4a..3438158332 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 108 actions, 0 customized, 3 unbound. + 109 actions, 0 customized, 3 unbound. [All] Common Customized (0) Unbound (3) App Composer Editor Vim Navigation Approval Debug diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap index 53ccba7e37..4bb6c4dbfc 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap @@ -2,14 +2,14 @@ source: tui/src/keymap_setup.rs expression: snapshot --- -tab: All (107 selectable) +tab: All (108 selectable) tab: Common (20 selectable) tab: Customized (0) (0 selectable) tab: Unbound (2) (2 selectable) tab: App (10 selectable) tab: Composer (5 selectable) tab: Editor (17 selectable) -tab: Vim (47 selectable) +tab: Vim (48 selectable) tab: Navigation (20 selectable) tab: Approval (8 selectable) tab: Debug (1 selectable) diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap index 3b9ebb7a6a..a4a65e3321 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 78)" Keymap All configurable shortcuts. - 107 actions, 0 customized, 2 unbound. + 108 actions, 0 customized, 2 unbound. [All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap index c5efeb30e5..1138ebbf1e 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 107 actions, 0 customized, 2 unbound. + 108 actions, 0 customized, 2 unbound. [All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug From a076b21730c7f0c8445c3f7b09580e7608ce1166 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Fri, 29 May 2026 15:24:41 -0700 Subject: [PATCH 39/40] Recommend Bazel VSCode extension. (#25161) Provides starlark syntax highlighting and editor formatting. --- .vscode/extensions.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.vscode/extensions.json b/.vscode/extensions.json index e92864a68a..5003c6de08 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,5 +1,6 @@ { "recommendations": [ + "BazelBuild.vscode-bazel", "rust-lang.rust-analyzer", "charliermarsh.ruff", "tamasfe.even-better-toml", From 8e5f5616976d98c5e035b7f67fe0632e3a4337bf Mon Sep 17 00:00:00 2001 From: Noah MacCallum <171723556+nm-openai@users.noreply.github.com> Date: Fri, 29 May 2026 15:32:04 -0700 Subject: [PATCH 40/40] Filter plugin install suggestions by installed apps (#24996) ## Summary - Keep the original `TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST` as a fallback seed list, so users with no installed plugins still get initial install suggestions. - Allow additional install suggestions from trusted marketplaces: `openai-curated` and `openai-bundled`. - Require non-fallback, non-configured marketplace candidates to share `.app.json` connector IDs with already installed plugins. - Preserve explicit configured plugin discoverables as an override, while still omitting installed, disabled, and `NOT_AVAILABLE` plugins. ## Context `list_available_plugins_to_install` controls which plugins the model can trigger via `request_plugin_install`. We want a small starter set for empty/new users, but we also want installed workflow plugins to unlock relevant source plugins without maintaining every source plugin ID by hand. This keeps the legacy plugin ID allowlist only as the starter fallback. For everything else, the trusted marketplace is the candidate boundary, and installed app connector overlap is the relevance filter. For example, an installed Sales plugin can make HubSpot and Granola suggestible when those source plugins are in `openai-curated` and share Sales app connector IDs, while an unrelated test-source plugin with an app connector not declared by Sales stays hidden. ## Test Coverage - Empty/no-installed-plugin case: returns the fallback seed plugins from the original allowlist. - Installed-app expansion: returns non-fallback marketplace plugins only when their app connector IDs overlap with an installed plugin. - Sales workflow case: installed Sales declares HubSpot and Granola apps, so `hubspot@openai-curated` and `granola@openai-curated` are returned. - Sales negative case: `test-source@openai-curated` has an app connector not declared by Sales, so it is not returned. - Existing guardrails: installed plugins, disabled suggestions, and `NOT_AVAILABLE` plugins remain omitted; explicit configured discoverables still work as an override. ## Validation - `just fmt` - `just test -p codex-core plugins::discoverable::tests` - `just test -p codex-core` was attempted earlier, but current `main` / local env failed with unrelated existing failures around missing `test_stdio_server`, CLI/code-mode MCP tool setup, and unified_exec/shell snapshot flakes/timeouts. The touched discoverable tests pass. --- codex-rs/core/src/connectors.rs | 10 +- codex-rs/core/src/connectors_tests.rs | 2 +- codex-rs/core/src/plugins/discoverable.rs | 34 +- .../core/src/plugins/discoverable_tests.rs | 333 ++++++++++++++---- codex-rs/core/src/session/turn.rs | 6 + .../tools/handlers/request_plugin_install.rs | 42 +-- codex-rs/core/src/tools/spec_plan.rs | 4 +- 7 files changed, 316 insertions(+), 115 deletions(-) diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 09717bb121..70ccbc83d9 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -113,6 +113,7 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth( config: &Config, auth: Option<&CodexAuth>, accessible_connectors: &[AppInfo], + loaded_plugin_app_connector_ids: &[String], ) -> anyhow::Result> { let connector_ids = tool_suggest_connector_ids(config).await; let directory_connectors = codex_connectors::merge::merge_plugin_connectors( @@ -128,10 +129,11 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth( ) .into_iter() .map(DiscoverableTool::from); - let discoverable_plugins = list_tool_suggest_discoverable_plugins(config) - .await? - .into_iter() - .map(DiscoverableTool::from); + let discoverable_plugins = + list_tool_suggest_discoverable_plugins(config, loaded_plugin_app_connector_ids) + .await? + .into_iter() + .map(DiscoverableTool::from); Ok(discoverable_connectors .chain(discoverable_plugins) .collect()) diff --git a/codex-rs/core/src/connectors_tests.rs b/codex-rs/core/src/connectors_tests.rs index 3733d78c38..972743c6eb 100644 --- a/codex-rs/core/src/connectors_tests.rs +++ b/codex-rs/core/src/connectors_tests.rs @@ -1238,7 +1238,7 @@ discoverables = [ let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let discoverable_tools = - list_tool_suggest_discoverable_tools_with_auth(&config, Some(&auth), &[]) + list_tool_suggest_discoverable_tools_with_auth(&config, Some(&auth), &[], &[]) .await .expect("discoverable tools should load"); diff --git a/codex-rs/core/src/plugins/discoverable.rs b/codex-rs/core/src/plugins/discoverable.rs index 3e328165bc..4f0d88b512 100644 --- a/codex-rs/core/src/plugins/discoverable.rs +++ b/codex-rs/core/src/plugins/discoverable.rs @@ -8,7 +8,8 @@ use codex_config::types::ToolSuggestDiscoverableType; use codex_core_plugins::OPENAI_BUNDLED_MARKETPLACE_NAME; use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME; use codex_core_plugins::PluginsManager; -use codex_core_plugins::TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST; +use codex_core_plugins::TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST as TOOL_SUGGEST_DISCOVERABLE_PLUGIN_FALLBACK_ALLOWLIST; +use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy; use codex_features::Feature; use codex_tools::DiscoverablePluginInfo; @@ -19,6 +20,7 @@ const TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST: &[&str] = &[ pub(crate) async fn list_tool_suggest_discoverable_plugins( config: &Config, + loaded_plugin_app_connector_ids: &[String], ) -> anyhow::Result> { if !config.features.enabled(Feature::Plugins) { return Ok(Vec::new()); @@ -44,18 +46,30 @@ pub(crate) async fn list_tool_suggest_discoverable_plugins( .list_marketplaces_for_config(&plugins_input, &[]) .context("failed to list plugin marketplaces for tool suggestions")? .marketplaces; + let mut installed_app_connector_ids = plugins_manager + .plugins_for_config(&plugins_input) + .await + .capability_summaries() + .iter() + .flat_map(|plugin| plugin.app_connector_ids.iter()) + .map(|connector_id| connector_id.0.clone()) + .collect::>(); + installed_app_connector_ids.extend(loaded_plugin_app_connector_ids.iter().cloned()); + let mut discoverable_plugins = Vec::::new(); for marketplace in marketplaces { let marketplace_name = marketplace.name; - if !TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST.contains(&marketplace_name.as_str()) { - continue; - } + let is_allowlisted_marketplace = + TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST.contains(&marketplace_name.as_str()); for plugin in marketplace.plugins { + let is_configured_plugin = configured_plugin_ids.contains(plugin.id.as_str()); + let is_fallback_plugin = + TOOL_SUGGEST_DISCOVERABLE_PLUGIN_FALLBACK_ALLOWLIST.contains(&plugin.id.as_str()); if plugin.installed + || plugin.policy.installation == MarketplacePluginInstallPolicy::NotAvailable || disabled_plugin_ids.contains(plugin.id.as_str()) - || (!TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST.contains(&plugin.id.as_str()) - && !configured_plugin_ids.contains(plugin.id.as_str())) + || (!is_allowlisted_marketplace && !is_configured_plugin) { continue; } @@ -72,6 +86,14 @@ pub(crate) async fn list_tool_suggest_discoverable_plugins( { Ok(plugin) => { let plugin: PluginCapabilitySummary = plugin.into(); + let matches_installed_app = + plugin.app_connector_ids.iter().any(|connector_id| { + installed_app_connector_ids.contains(connector_id.0.as_str()) + }); + if !is_configured_plugin && !is_fallback_plugin && !matches_installed_app { + continue; + } + discoverable_plugins.push(DiscoverablePluginInfo { id: plugin.config_name, name: plugin.display_name, diff --git a/codex-rs/core/src/plugins/discoverable_tests.rs b/codex-rs/core/src/plugins/discoverable_tests.rs index a481f45822..21203aea23 100644 --- a/codex-rs/core/src/plugins/discoverable_tests.rs +++ b/codex-rs/core/src/plugins/discoverable_tests.rs @@ -5,57 +5,93 @@ use crate::plugins::test_support::write_curated_plugin_sha; use crate::plugins::test_support::write_file; use crate::plugins::test_support::write_openai_curated_marketplace; use crate::plugins::test_support::write_plugins_feature_config; +use codex_core_plugins::OPENAI_BUNDLED_MARKETPLACE_NAME; use codex_core_plugins::PluginInstallRequest; use codex_core_plugins::startup_sync::curated_plugins_repo_path; use codex_tools::DiscoverablePluginInfo; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; +use std::path::Path; use tempfile::tempdir; use tracing::Level; use tracing_subscriber::fmt::format::FmtSpan; use tracing_test::internal::MockWriter; #[tokio::test] -async fn list_tool_suggest_discoverable_plugins_returns_uninstalled_curated_plugins() { +async fn list_tool_suggest_discoverable_plugins_returns_fallback_plugins_without_installed_apps() { let codex_home = tempdir().expect("tempdir should succeed"); let curated_root = curated_plugins_repo_path(codex_home.path()); write_openai_curated_marketplace(&curated_root, &["sample", "slack", "openai-developers"]); write_plugins_feature_config(codex_home.path()); let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) .await .unwrap(); assert_eq!( - discoverable_plugins, + discoverable_plugins + .into_iter() + .map(|plugin| plugin.id) + .collect::>(), vec![ - DiscoverablePluginInfo { - id: "openai-developers@openai-curated".to_string(), - name: "openai-developers".to_string(), - description: Some( - "Plugin that includes skills, MCP servers, and app connectors".to_string(), - ), - has_skills: true, - mcp_server_names: vec!["sample-docs".to_string()], - app_connector_ids: vec!["connector_calendar".to_string()], - }, - DiscoverablePluginInfo { - id: "slack@openai-curated".to_string(), - name: "slack".to_string(), - description: Some( - "Plugin that includes skills, MCP servers, and app connectors".to_string(), - ), - has_skills: true, - mcp_server_names: vec!["sample-docs".to_string()], - app_connector_ids: vec!["connector_calendar".to_string()], - }, + "openai-developers@openai-curated".to_string(), + "slack@openai-curated".to_string(), ] ); } #[tokio::test] -async fn list_tool_suggest_discoverable_plugins_returns_microsoft_curated_plugins() { +async fn list_tool_suggest_discoverable_plugins_filters_non_fallback_by_installed_apps() { + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace(&curated_root, &["sample", "slack", "hubspot"]); + write_plugin_app(&curated_root, "sample", "sample", "connector_sample"); + write_plugins_feature_config(codex_home.path()); + install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "slack").await; + + let config = load_plugins_config(codex_home.path()).await; + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) + .await + .unwrap(); + + assert_eq!( + discoverable_plugins + .into_iter() + .map(|plugin| plugin.id) + .collect::>(), + vec!["hubspot@openai-curated".to_string()] + ); +} + +#[tokio::test] +async fn list_tool_suggest_discoverable_plugins_filters_by_loaded_plugin_apps() { + let hubspot_app_id = "asdk_app_697acb8e53d88191bf7a79e62012ae14"; + let granola_app_id = "asdk_app_697761cab6f48191b5ed345919a3ce8b"; + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace(&curated_root, &["hubspot", "granola"]); + write_plugin_app(&curated_root, "hubspot", "hubspot", hubspot_app_id); + write_plugin_app(&curated_root, "granola", "granola", granola_app_id); + write_plugins_feature_config(codex_home.path()); + + let config = load_plugins_config(codex_home.path()).await; + let discoverable_plugins = + list_tool_suggest_discoverable_plugins(&config, &[hubspot_app_id.to_string()]) + .await + .unwrap(); + + assert_eq!( + discoverable_plugins + .into_iter() + .map(|plugin| plugin.id) + .collect::>(), + vec!["hubspot@openai-curated".to_string()] + ); +} + +#[tokio::test] +async fn list_tool_suggest_discoverable_plugins_filters_microsoft_by_installed_apps() { let codex_home = tempdir().expect("tempdir should succeed"); let curated_root = curated_plugins_repo_path(codex_home.path()); write_openai_curated_marketplace( @@ -63,9 +99,10 @@ async fn list_tool_suggest_discoverable_plugins_returns_microsoft_curated_plugin &["teams", "sharepoint", "outlook-email", "outlook-calendar"], ); write_plugins_feature_config(codex_home.path()); + install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "teams").await; let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) .await .unwrap(); @@ -78,28 +115,92 @@ async fn list_tool_suggest_discoverable_plugins_returns_microsoft_curated_plugin "outlook-calendar@openai-curated".to_string(), "outlook-email@openai-curated".to_string(), "sharepoint@openai-curated".to_string(), - "teams@openai-curated".to_string(), ] ); } #[tokio::test] -async fn list_tool_suggest_discoverable_plugins_deduplicates_allowlisted_configured_plugin() { +async fn list_tool_suggest_discoverable_plugins_filters_sales_apps_by_marketplace() { + let hubspot_app_id = "asdk_app_697acb8e53d88191bf7a79e62012ae14"; + let granola_app_id = "asdk_app_697761cab6f48191b5ed345919a3ce8b"; + let test_app_id = "asdk_app_test_source"; let codex_home = tempdir().expect("tempdir should succeed"); - let plugin_id = TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST - .iter() - .copied() - .find(|plugin_id| { - plugin_id - .rsplit_once('@') - .is_some_and(|(_plugin_name, marketplace_name)| { - marketplace_name == OPENAI_BUNDLED_MARKETPLACE_NAME - }) - }) - .expect("allowlist should include a bundled plugin"); - let (plugin_name, marketplace_name) = plugin_id - .rsplit_once('@') - .expect("plugin id should include a marketplace"); + let curated_root = curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace(&curated_root, &["hubspot", "granola", "test-source"]); + write_plugin_app(&curated_root, "hubspot", "hubspot", hubspot_app_id); + write_plugin_app(&curated_root, "granola", "granola", granola_app_id); + write_plugin_app(&curated_root, "test-source", "test_source", test_app_id); + + let sales_marketplace_name = "oai-maintained-plugins"; + let sales_marketplace_root = codex_home + .path() + .join(format!(".tmp/marketplaces/{sales_marketplace_name}")); + write_file( + &sales_marketplace_root.join(".agents/plugins/marketplace.json"), + &format!( + r#"{{ + "name": "{sales_marketplace_name}", + "plugins": [ + {{"name": "sales", "source": {{"source": "local", "path": "./plugins/sales"}}}} + ] +}} +"# + ), + ); + write_curated_plugin(&sales_marketplace_root, "sales"); + write_file( + &sales_marketplace_root.join("plugins/sales/.app.json"), + &format!( + r#"{{ + "apps": {{ + "hubspot": {{ + "id": "{hubspot_app_id}" + }}, + "granola": {{ + "id": "{granola_app_id}" + }} + }} +}} +"# + ), + ); + write_file( + &codex_home.path().join(crate::config::CONFIG_TOML_FILE), + &format!( + r#"[features] +plugins = true + +[marketplaces.{sales_marketplace_name}] +source_type = "git" +source = "/tmp/{sales_marketplace_name}" +"# + ), + ); + install_marketplace_plugin(codex_home.path(), sales_marketplace_root.as_path(), "sales").await; + + let config = load_plugins_config(codex_home.path()).await; + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) + .await + .unwrap(); + + assert_eq!( + discoverable_plugins + .into_iter() + .map(|plugin| plugin.id) + .collect::>(), + vec![ + "granola@openai-curated".to_string(), + "hubspot@openai-curated".to_string(), + ] + ); +} + +#[tokio::test] +async fn list_tool_suggest_discoverable_plugins_deduplicates_configured_marketplace_plugin() { + let codex_home = tempdir().expect("tempdir should succeed"); + let plugin_name = "sample"; + let marketplace_name = OPENAI_BUNDLED_MARKETPLACE_NAME; + let plugin_id = format!("{plugin_name}@{marketplace_name}"); let marketplace_root = codex_home .path() .join(format!(".tmp/marketplaces/{marketplace_name}")); @@ -133,28 +234,20 @@ discoverables = [{{ type = "plugin", id = "{plugin_id}" }}] ); let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) .await .unwrap(); assert_eq!(discoverable_plugins.len(), 1); - assert_eq!(discoverable_plugins[0].id, plugin_id); + assert_eq!(discoverable_plugins[0].id, plugin_id.as_str()); } #[tokio::test] -async fn list_tool_suggest_discoverable_plugins_ignores_missing_allowlisted_plugin() { +async fn list_tool_suggest_discoverable_plugins_ignores_missing_marketplace_plugin() { let codex_home = tempdir().expect("tempdir should succeed"); let curated_root = curated_plugins_repo_path(codex_home.path()); - write_openai_curated_marketplace(&curated_root, &["slack"]); - let marketplace_name = TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST - .iter() - .copied() - .filter_map(|plugin_id| plugin_id.rsplit_once('@')) - .find(|(_plugin_name, marketplace_name)| { - *marketplace_name == OPENAI_BUNDLED_MARKETPLACE_NAME - }) - .map(|(_plugin_name, marketplace_name)| marketplace_name) - .expect("allowlist should include a bundled plugin"); + write_openai_curated_marketplace(&curated_root, &["installed", "slack"]); + let marketplace_name = OPENAI_BUNDLED_MARKETPLACE_NAME; let marketplace_root = codex_home .path() .join(format!(".tmp/marketplaces/{marketplace_name}")); @@ -182,9 +275,10 @@ source = "/tmp/{marketplace_name}" "# ), ); + install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "installed").await; let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) .await .unwrap(); @@ -205,7 +299,7 @@ plugins = false ); let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) .await .unwrap(); @@ -216,7 +310,7 @@ plugins = false async fn list_tool_suggest_discoverable_plugins_normalizes_description() { let codex_home = tempdir().expect("tempdir should succeed"); let curated_root = curated_plugins_repo_path(codex_home.path()); - write_openai_curated_marketplace(&curated_root, &["slack"]); + write_openai_curated_marketplace(&curated_root, &["installed", "slack"]); write_plugins_feature_config(codex_home.path()); write_file( &curated_root.join("plugins/slack/.codex-plugin/plugin.json"), @@ -225,9 +319,10 @@ async fn list_tool_suggest_discoverable_plugins_normalizes_description() { "description": " Plugin\n with extra spacing " }"#, ); + install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "installed").await; let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) .await .unwrap(); @@ -264,7 +359,7 @@ async fn list_tool_suggest_discoverable_plugins_omits_installed_curated_plugins( .expect("plugin should install"); let refreshed_config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&refreshed_config) + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&refreshed_config, &[]) .await .unwrap(); @@ -289,13 +384,70 @@ disabled_tools = [ ); let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) .await .unwrap(); assert_eq!(discoverable_plugins, Vec::::new()); } +#[tokio::test] +async fn list_tool_suggest_discoverable_plugins_omits_not_available_curated_plugins() { + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = curated_plugins_repo_path(codex_home.path()); + write_file( + &curated_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "openai-curated", + "plugins": [ + { + "name": "installed", + "source": { + "source": "local", + "path": "./plugins/installed" + } + }, + { + "name": "slack", + "source": { + "source": "local", + "path": "./plugins/slack" + } + }, + { + "name": "gmail", + "source": { + "source": "local", + "path": "./plugins/gmail" + }, + "policy": { + "installation": "NOT_AVAILABLE" + } + } + ] +} +"#, + ); + write_curated_plugin(&curated_root, "installed"); + write_curated_plugin(&curated_root, "slack"); + write_curated_plugin(&curated_root, "gmail"); + write_plugins_feature_config(codex_home.path()); + install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "installed").await; + + let config = load_plugins_config(codex_home.path()).await; + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) + .await + .unwrap(); + + assert_eq!( + discoverable_plugins + .into_iter() + .map(|plugin| plugin.id) + .collect::>(), + vec!["slack@openai-curated".to_string()] + ); +} + #[tokio::test] async fn list_tool_suggest_discoverable_plugins_includes_configured_plugin_ids() { let codex_home = tempdir().expect("tempdir should succeed"); @@ -312,7 +464,7 @@ discoverables = [{ type = "plugin", id = "sample@openai-curated" }] ); let config = load_plugins_config(codex_home.path()).await; - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) .await .unwrap(); @@ -335,14 +487,12 @@ discoverables = [{ type = "plugin", id = "sample@openai-curated" }] async fn list_tool_suggest_discoverable_plugins_does_not_reload_marketplace_per_plugin() { let codex_home = tempdir().expect("tempdir should succeed"); let curated_root = curated_plugins_repo_path(codex_home.path()); - write_openai_curated_marketplace( - &curated_root, - &["slack", "build-ios-apps", "life-science-research"], - ); + write_openai_curated_marketplace(&curated_root, &["slack", "gmail", "openai-developers"]); write_plugins_feature_config(codex_home.path()); + install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "slack").await; let too_long_prompt = "x".repeat(129); - for plugin_name in ["build-ios-apps", "life-science-research"] { + for plugin_name in ["gmail", "openai-developers"] { write_file( &curated_root.join(format!("plugins/{plugin_name}/.codex-plugin/plugin.json")), &format!( @@ -369,28 +519,63 @@ async fn list_tool_suggest_discoverable_plugins_does_not_reload_marketplace_per_ .finish(); let _guard = tracing::subscriber::set_default(subscriber); - let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) + let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[]) .await .unwrap(); - assert_eq!(discoverable_plugins.len(), 1); - assert_eq!(discoverable_plugins[0].id, "slack@openai-curated"); + assert_eq!( + discoverable_plugins + .iter() + .map(|plugin| plugin.id.as_str()) + .collect::>(), + vec!["gmail@openai-curated", "openai-developers@openai-curated"] + ); let logs = String::from_utf8(buffer.lock().expect("buffer lock").clone()) .expect("utf8 logs") .replace('\\', "/"); - assert_eq!(logs.matches("ignoring interface.defaultPrompt").count(), 2); + assert_eq!(logs.matches("ignoring interface.defaultPrompt").count(), 8); let normalized_logs = logs.replace('\\', "/"); assert_eq!( normalized_logs - .matches("build-ios-apps/.codex-plugin/plugin.json") + .matches("gmail/.codex-plugin/plugin.json") .count(), - 1 + 4 ); assert_eq!( normalized_logs - .matches("life-science-research/.codex-plugin/plugin.json") + .matches("openai-developers/.codex-plugin/plugin.json") .count(), - 1 + 4 + ); +} + +async fn install_marketplace_plugin(codex_home: &Path, marketplace_root: &Path, plugin_name: &str) { + write_curated_plugin_sha(codex_home); + PluginsManager::new(codex_home.to_path_buf()) + .install_plugin(PluginInstallRequest { + plugin_name: plugin_name.to_string(), + marketplace_path: AbsolutePathBuf::try_from( + marketplace_root.join(".agents/plugins/marketplace.json"), + ) + .expect("marketplace path"), + }) + .await + .expect("plugin should install"); +} + +fn write_plugin_app(root: &Path, plugin_name: &str, app_name: &str, app_id: &str) { + write_file( + &root.join(format!("plugins/{plugin_name}/.app.json")), + &format!( + r#"{{ + "apps": {{ + "{app_name}": {{ + "id": "{app_id}" + }} + }} +}} +"# + ), ); } diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 7944be57a5..f808134f99 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1071,12 +1071,18 @@ pub(crate) async fn built_tools( None }; let auth = sess.services.auth_manager.auth().await; + let loaded_plugin_app_connector_ids = loaded_plugins + .effective_apps() + .into_iter() + .map(|connector_id| connector_id.0) + .collect::>(); let discoverable_tools = if apps_enabled && tool_suggest_enabled(turn_context) { if let Some(accessible_connectors) = accessible_connectors_with_enabled_state.as_ref() { match connectors::list_tool_suggest_discoverable_tools_with_auth( &turn_context.config, auth.as_ref(), accessible_connectors.as_slice(), + &loaded_plugin_app_connector_ids, ) .await .map(|discoverable_tools| { diff --git a/codex-rs/core/src/tools/handlers/request_plugin_install.rs b/codex-rs/core/src/tools/handlers/request_plugin_install.rs index b73a81a940..c134eae079 100644 --- a/codex-rs/core/src/tools/handlers/request_plugin_install.rs +++ b/codex-rs/core/src/tools/handlers/request_plugin_install.rs @@ -37,7 +37,15 @@ use crate::tools::handlers::request_plugin_install_spec::create_request_plugin_i use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; -pub struct RequestPluginInstallHandler; +pub struct RequestPluginInstallHandler { + discoverable_tools: Vec, +} + +impl RequestPluginInstallHandler { + pub(crate) fn new(discoverable_tools: Vec) -> Self { + Self { discoverable_tools } + } +} #[async_trait::async_trait] impl ToolExecutor for RequestPluginInstallHandler { @@ -53,10 +61,6 @@ impl ToolExecutor for RequestPluginInstallHandler { true } - #[expect( - clippy::await_holding_invalid_type, - reason = "plugin install discovery reads through the session-owned manager guard" - )] async fn handle( &self, invocation: ToolInvocation, @@ -99,31 +103,10 @@ impl ToolExecutor for RequestPluginInstallHandler { )); } - let auth = session.services.auth_manager.auth().await; - let manager = session.services.mcp_connection_manager.read().await; - let mcp_tools = manager.list_all_tools().await; - drop(manager); - let accessible_connectors = connectors::with_app_enabled_state( - connectors::accessible_connectors_from_mcp_tools(&mcp_tools), - &turn.config, + let discoverable_tools = filter_request_plugin_install_discoverable_tools_for_client( + self.discoverable_tools.clone(), + turn.app_server_client_name.as_deref(), ); - let discoverable_tools = connectors::list_tool_suggest_discoverable_tools_with_auth( - &turn.config, - auth.as_ref(), - &accessible_connectors, - ) - .await - .map(|discoverable_tools| { - filter_request_plugin_install_discoverable_tools_for_client( - discoverable_tools, - turn.app_server_client_name.as_deref(), - ) - }) - .map_err(|err| { - FunctionCallError::RespondToModel(format!( - "plugin install requests are unavailable right now: {err}" - )) - })?; let tool = discoverable_tools .into_iter() @@ -154,6 +137,7 @@ impl ToolExecutor for RequestPluginInstallHandler { .as_ref() .is_some_and(|response| response.action == ElicitationAction::Accept); + let auth = session.services.auth_manager.auth().await; let completed = if user_confirmed { verify_request_plugin_install_completed(&session, &turn, &tool, auth.as_ref()).await } else { diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 9aaccbbd0f..c6fd80c0ac 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -615,7 +615,9 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut planned_tools.add(ListAvailablePluginsToInstallHandler::new( collect_request_plugin_install_entries(discoverable_tools), )); - planned_tools.add(RequestPluginInstallHandler); + planned_tools.add(RequestPluginInstallHandler::new( + discoverable_tools.to_vec(), + )); } if environment_mode.has_environment() && turn_context.model_info.apply_patch_tool_type.is_some()