From 0b08d893042ba0c0d5c2f020b1c78b46af2ebe59 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Tue, 24 Mar 2026 19:27:00 -0700 Subject: [PATCH 01/11] [app-server] Add a method to override feature flags. (#15601) - [x] Add a method to override feature flags globally and not just thread level. --- .../schema/json/ClientRequest.json | 39 +++ .../codex_app_server_protocol.schemas.json | 58 ++++ .../codex_app_server_protocol.v2.schemas.json | 58 ++++ ...xperimentalFeatureEnablementSetParams.json | 17 ++ ...erimentalFeatureEnablementSetResponse.json | 17 ++ .../schema/typescript/ClientRequest.ts | 3 +- .../ExperimentalFeatureEnablementSetParams.ts | 12 + ...xperimentalFeatureEnablementSetResponse.ts | 9 + .../schema/typescript/v2/index.ts | 2 + .../src/protocol/common.rs | 4 + .../app-server-protocol/src/protocol/v2.rs | 19 ++ codex-rs/app-server/README.md | 1 + .../app-server/src/codex_message_processor.rs | 74 +++-- codex-rs/app-server/src/config_api.rs | 264 +++++++++++++++++- codex-rs/app-server/src/message_processor.rs | 41 ++- .../app-server/tests/common/mcp_process.rs | 10 + .../suite/v2/experimental_feature_list.rs | 216 +++++++++++++- 17 files changed, 806 insertions(+), 38 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureEnablementSetParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureEnablementSetResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureEnablementSetParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureEnablementSetResponse.ts diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index e245c17954..9227a97d55 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -524,6 +524,21 @@ ], "type": "object" }, + "ExperimentalFeatureEnablementSetParams": { + "properties": { + "enablement": { + "additionalProperties": { + "type": "boolean" + }, + "description": "Process-wide runtime feature enablement keyed by canonical feature name.\n\nOnly named features are updated. Omitted features are left unchanged. Send an empty map for a no-op.", + "type": "object" + } + }, + "required": [ + "enablement" + ], + "type": "object" + }, "ExperimentalFeatureListParams": { "properties": { "cursor": { @@ -4294,6 +4309,30 @@ "title": "ExperimentalFeature/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "experimentalFeature/enablement/set" + ], + "title": "ExperimentalFeature/enablement/setRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ExperimentalFeatureEnablementSetParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ExperimentalFeature/enablement/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 ad79d0987e..d4269f5239 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 @@ -1151,6 +1151,30 @@ "title": "ExperimentalFeature/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "experimentalFeature/enablement/set" + ], + "title": "ExperimentalFeature/enablement/setRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ExperimentalFeatureEnablementSetParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ExperimentalFeature/enablement/setRequest", + "type": "object" + }, { "properties": { "id": { @@ -7249,6 +7273,40 @@ ], "type": "object" }, + "ExperimentalFeatureEnablementSetParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enablement": { + "additionalProperties": { + "type": "boolean" + }, + "description": "Process-wide runtime feature enablement keyed by canonical feature name.\n\nOnly named features are updated. Omitted features are left unchanged. Send an empty map for a no-op.", + "type": "object" + } + }, + "required": [ + "enablement" + ], + "title": "ExperimentalFeatureEnablementSetParams", + "type": "object" + }, + "ExperimentalFeatureEnablementSetResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enablement": { + "additionalProperties": { + "type": "boolean" + }, + "description": "Feature enablement entries updated by this request.", + "type": "object" + } + }, + "required": [ + "enablement" + ], + "title": "ExperimentalFeatureEnablementSetResponse", + "type": "object" + }, "ExperimentalFeatureListParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { 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 30bb2efb22..acf36d6c3c 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 @@ -1681,6 +1681,30 @@ "title": "ExperimentalFeature/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "experimentalFeature/enablement/set" + ], + "title": "ExperimentalFeature/enablement/setRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ExperimentalFeatureEnablementSetParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ExperimentalFeature/enablement/setRequest", + "type": "object" + }, { "properties": { "id": { @@ -3809,6 +3833,40 @@ ], "type": "object" }, + "ExperimentalFeatureEnablementSetParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enablement": { + "additionalProperties": { + "type": "boolean" + }, + "description": "Process-wide runtime feature enablement keyed by canonical feature name.\n\nOnly named features are updated. Omitted features are left unchanged. Send an empty map for a no-op.", + "type": "object" + } + }, + "required": [ + "enablement" + ], + "title": "ExperimentalFeatureEnablementSetParams", + "type": "object" + }, + "ExperimentalFeatureEnablementSetResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enablement": { + "additionalProperties": { + "type": "boolean" + }, + "description": "Feature enablement entries updated by this request.", + "type": "object" + } + }, + "required": [ + "enablement" + ], + "title": "ExperimentalFeatureEnablementSetResponse", + "type": "object" + }, "ExperimentalFeatureListParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureEnablementSetParams.json b/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureEnablementSetParams.json new file mode 100644 index 0000000000..9d6bcec932 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureEnablementSetParams.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enablement": { + "additionalProperties": { + "type": "boolean" + }, + "description": "Process-wide runtime feature enablement keyed by canonical feature name.\n\nOnly named features are updated. Omitted features are left unchanged. Send an empty map for a no-op.", + "type": "object" + } + }, + "required": [ + "enablement" + ], + "title": "ExperimentalFeatureEnablementSetParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureEnablementSetResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureEnablementSetResponse.json new file mode 100644 index 0000000000..9cdbf0691f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureEnablementSetResponse.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enablement": { + "additionalProperties": { + "type": "boolean" + }, + "description": "Feature enablement entries updated by this request.", + "type": "object" + } + }, + "required": [ + "enablement" + ], + "title": "ExperimentalFeatureEnablementSetResponse", + "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 3c77652cec..e33a986359 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -16,6 +16,7 @@ import type { CommandExecWriteParams } from "./v2/CommandExecWriteParams"; import type { ConfigBatchWriteParams } from "./v2/ConfigBatchWriteParams"; import type { ConfigReadParams } from "./v2/ConfigReadParams"; import type { ConfigValueWriteParams } from "./v2/ConfigValueWriteParams"; +import type { ExperimentalFeatureEnablementSetParams } from "./v2/ExperimentalFeatureEnablementSetParams"; import type { ExperimentalFeatureListParams } from "./v2/ExperimentalFeatureListParams"; import type { ExternalAgentConfigDetectParams } from "./v2/ExternalAgentConfigDetectParams"; import type { ExternalAgentConfigImportParams } from "./v2/ExternalAgentConfigImportParams"; @@ -63,4 +64,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/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": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "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": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "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": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/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": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "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": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "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": "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/ExperimentalFeatureEnablementSetParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureEnablementSetParams.ts new file mode 100644 index 0000000000..cd9ced0b3a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureEnablementSetParams.ts @@ -0,0 +1,12 @@ +// 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 ExperimentalFeatureEnablementSetParams = { +/** + * Process-wide runtime feature enablement keyed by canonical feature name. + * + * Only named features are updated. Omitted features are left unchanged. + * Send an empty map for a no-op. + */ +enablement: { [key in string]?: boolean }, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureEnablementSetResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureEnablementSetResponse.ts new file mode 100644 index 0000000000..903576d9bc --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureEnablementSetResponse.ts @@ -0,0 +1,9 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ExperimentalFeatureEnablementSetResponse = { +/** + * Feature enablement entries updated by this request. + */ +enablement: { [key in string]?: boolean }, }; 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 2ee447bba2..d0b1b88198 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -81,6 +81,8 @@ export type { DynamicToolSpec } from "./DynamicToolSpec"; export type { ErrorNotification } from "./ErrorNotification"; export type { ExecPolicyAmendment } from "./ExecPolicyAmendment"; export type { ExperimentalFeature } from "./ExperimentalFeature"; +export type { ExperimentalFeatureEnablementSetParams } from "./ExperimentalFeatureEnablementSetParams"; +export type { ExperimentalFeatureEnablementSetResponse } from "./ExperimentalFeatureEnablementSetResponse"; export type { ExperimentalFeatureListParams } from "./ExperimentalFeatureListParams"; export type { ExperimentalFeatureListResponse } from "./ExperimentalFeatureListResponse"; export type { ExperimentalFeatureStage } from "./ExperimentalFeatureStage"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 80cb24f0ff..5a9215f2ee 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -392,6 +392,10 @@ client_request_definitions! { params: v2::ExperimentalFeatureListParams, response: v2::ExperimentalFeatureListResponse, }, + ExperimentalFeatureEnablementSet => "experimentalFeature/enablement/set" { + params: v2::ExperimentalFeatureEnablementSetParams, + response: v2::ExperimentalFeatureEnablementSetResponse, + }, #[experimental("collaborationMode/list")] /// Lists collaboration mode presets. CollaborationModeList => "collaborationMode/list" { diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index fab3fe3190..67c4e95856 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1926,6 +1926,25 @@ pub struct ExperimentalFeatureListResponse { pub next_cursor: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExperimentalFeatureEnablementSetParams { + /// Process-wide runtime feature enablement keyed by canonical feature name. + /// + /// Only named features are updated. Omitted features are left unchanged. + /// Send an empty map for a no-op. + pub enablement: std::collections::BTreeMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExperimentalFeatureEnablementSetResponse { + /// Feature enablement entries updated by this request. + pub enablement: std::collections::BTreeMap, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index b877c4a035..1e186c0d5e 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -164,6 +164,7 @@ Example with notification opt-out: - `fs/changed` — notification emitted when watched paths change, including the `watchId` and `changedPaths`. - `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`. +- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `plugins`). For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). 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`). - `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category. Pass `forceRemoteSync: true` to refresh curated plugin state before listing (**under development; do not call from production clients yet**). diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 1d58e7317d..402dd51ddd 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1,6 +1,7 @@ use crate::bespoke_event_handling::apply_bespoke_event_handling; use crate::command_exec::CommandExecManager; use crate::command_exec::StartCommandExecParams; +use crate::config_api::apply_runtime_feature_enablement; use crate::error_code::INPUT_TOO_LARGE_ERROR_CODE; use crate::error_code::INTERNAL_ERROR_CODE; use crate::error_code::INVALID_PARAMS_ERROR_CODE; @@ -283,6 +284,7 @@ use codex_state::ThreadMetadataBuilder; use codex_state::log_db::LogDbLayer; use codex_utils_json_to_toml::json_to_toml; use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; +use std::collections::BTreeMap; use std::collections::HashMap; use std::collections::HashSet; use std::ffi::OsStr; @@ -371,7 +373,8 @@ pub(crate) struct CodexMessageProcessor { outgoing: Arc, arg0_paths: Arg0DispatchPaths, config: Arc, - cli_overrides: Vec<(String, TomlValue)>, + cli_overrides: Arc>>, + runtime_feature_enablement: Arc>>, cloud_requirements: Arc>, active_login: Arc>>, pending_thread_unloads: Arc>>, @@ -422,7 +425,8 @@ pub(crate) struct CodexMessageProcessorArgs { pub(crate) outgoing: Arc, pub(crate) arg0_paths: Arg0DispatchPaths, pub(crate) config: Arc, - pub(crate) cli_overrides: Vec<(String, TomlValue)>, + pub(crate) cli_overrides: Arc>>, + pub(crate) runtime_feature_enablement: Arc>>, pub(crate) cloud_requirements: Arc>, pub(crate) feedback: CodexFeedback, pub(crate) log_db: Option, @@ -486,6 +490,7 @@ impl CodexMessageProcessor { arg0_paths, config, cli_overrides, + runtime_feature_enablement, cloud_requirements, feedback, log_db, @@ -497,6 +502,7 @@ impl CodexMessageProcessor { arg0_paths, config, cli_overrides, + runtime_feature_enablement, cloud_requirements, active_login: Arc::new(Mutex::new(None)), pending_thread_unloads: Arc::new(Mutex::new(HashSet::new())), @@ -517,7 +523,7 @@ impl CodexMessageProcessor { ) -> Result { let cloud_requirements = self.current_cloud_requirements(); let mut config = codex_core::config::ConfigBuilder::default() - .cli_overrides(self.cli_overrides.clone()) + .cli_overrides(self.current_cli_overrides()) .fallback_cwd(fallback_cwd) .cloud_requirements(cloud_requirements) .build() @@ -527,6 +533,7 @@ impl CodexMessageProcessor { message: format!("failed to reload config: {err}"), data: None, })?; + apply_runtime_feature_enablement(&mut config, &self.current_runtime_feature_enablement()); config.codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone(); config.main_execve_wrapper_exe = self.arg0_paths.main_execve_wrapper_exe.clone(); Ok(config) @@ -539,6 +546,20 @@ impl CodexMessageProcessor { .unwrap_or_default() } + fn current_cli_overrides(&self) -> Vec<(String, TomlValue)> { + self.cli_overrides + .read() + .map(|guard| guard.clone()) + .unwrap_or_default() + } + + fn current_runtime_feature_enablement(&self) -> BTreeMap { + self.runtime_feature_enablement + .read() + .map(|guard| guard.clone()) + .unwrap_or_default() + } + /// If a client sends `developer_instructions: null` during a mode switch, /// use the built-in instructions for that mode. fn normalize_turn_start_collaboration_mode( @@ -884,7 +905,8 @@ impl CodexMessageProcessor { } ClientRequest::ConfigRead { .. } | ClientRequest::ConfigValueWrite { .. } - | ClientRequest::ConfigBatchWrite { .. } => { + | ClientRequest::ConfigBatchWrite { .. } + | ClientRequest::ExperimentalFeatureEnablementSet { .. } => { warn!("Config request reached CodexMessageProcessor unexpectedly"); } ClientRequest::FsReadFile { .. } @@ -1085,7 +1107,7 @@ impl CodexMessageProcessor { let cloud_requirements = self.cloud_requirements.clone(); let chatgpt_base_url = self.config.chatgpt_base_url.clone(); let codex_home = self.config.codex_home.clone(); - let cli_overrides = self.cli_overrides.clone(); + let cli_overrides = self.current_cli_overrides(); let auth_url = server.auth_url.clone(); tokio::spawn(async move { let (success, error_msg) = match tokio::time::timeout( @@ -1273,11 +1295,9 @@ impl CodexMessageProcessor { self.config.chatgpt_base_url.clone(), self.config.codex_home.clone(), ); - sync_default_client_residency_requirement( - &self.cli_overrides, - self.cloud_requirements.as_ref(), - ) - .await; + let cli_overrides = self.current_cli_overrides(); + sync_default_client_residency_requirement(&cli_overrides, self.cloud_requirements.as_ref()) + .await; self.outgoing .send_response(request_id, LoginAccountResponse::ChatgptAuthTokens {}) @@ -1892,8 +1912,8 @@ impl CodexMessageProcessor { personality, ); typesafe_overrides.ephemeral = ephemeral; - let cli_overrides = self.cli_overrides.clone(); let cloud_requirements = self.current_cloud_requirements(); + let cli_overrides = self.current_cli_overrides(); let listener_task_context = ListenerTaskContext { thread_manager: Arc::clone(&self.thread_manager), thread_state_manager: self.thread_state_manager.clone(), @@ -1903,10 +1923,12 @@ impl CodexMessageProcessor { codex_home: self.config.codex_home.clone(), }; let request_trace = request_context.request_trace(); + let runtime_feature_enablement = self.current_runtime_feature_enablement(); let thread_start_task = async move { Self::thread_start_task( listener_task_context, cli_overrides, + runtime_feature_enablement, cloud_requirements, request_id, config, @@ -1979,6 +2001,7 @@ impl CodexMessageProcessor { async fn thread_start_task( listener_task_context: ListenerTaskContext, cli_overrides: Vec<(String, TomlValue)>, + runtime_feature_enablement: BTreeMap, cloud_requirements: CloudRequirementsLoader, request_id: ConnectionRequestId, config_overrides: Option>, @@ -1995,6 +2018,7 @@ impl CodexMessageProcessor { typesafe_overrides, &cloud_requirements, &listener_task_context.codex_home, + &runtime_feature_enablement, ) .await { @@ -3497,13 +3521,16 @@ impl CodexMessageProcessor { // Derive a Config using the same logic as new conversation, honoring overrides if provided. let cloud_requirements = self.current_cloud_requirements(); + let cli_overrides = self.current_cli_overrides(); + let runtime_feature_enablement = self.current_runtime_feature_enablement(); let config = match derive_config_for_cwd( - &self.cli_overrides, + &cli_overrides, request_overrides, typesafe_overrides, history_cwd, &cloud_requirements, &self.config.codex_home, + &runtime_feature_enablement, ) .await { @@ -4039,13 +4066,16 @@ impl CodexMessageProcessor { typesafe_overrides.ephemeral = ephemeral.then_some(true); // Derive a Config using the same logic as new conversation, honoring overrides if provided. let cloud_requirements = self.current_cloud_requirements(); + let cli_overrides = self.current_cli_overrides(); + let runtime_feature_enablement = self.current_runtime_feature_enablement(); let config = match derive_config_for_cwd( - &self.cli_overrides, + &cli_overrides, request_overrides, typesafe_overrides, history_cwd, &cloud_requirements, &self.config.codex_home, + &runtime_feature_enablement, ) .await { @@ -7224,12 +7254,13 @@ impl CodexMessageProcessor { WindowsSandboxSetupMode::Unelevated => CoreWindowsSandboxSetupMode::Unelevated, }; let config = Arc::clone(&self.config); - let cli_overrides = self.cli_overrides.clone(); let cloud_requirements = self.current_cloud_requirements(); let command_cwd = params .cwd .map(PathBuf::from) .unwrap_or_else(|| config.cwd.clone()); + let cli_overrides = self.current_cli_overrides(); + let runtime_feature_enablement = self.current_runtime_feature_enablement(); let outgoing = Arc::clone(&self.outgoing); let connection_id = request_id.connection_id; @@ -7244,6 +7275,7 @@ impl CodexMessageProcessor { Some(command_cwd.clone()), &cloud_requirements, &config.codex_home, + &runtime_feature_enablement, ) .await; let setup_result = match derived_config { @@ -7873,6 +7905,7 @@ async fn derive_config_from_params( typesafe_overrides: ConfigOverrides, cloud_requirements: &CloudRequirementsLoader, codex_home: &Path, + runtime_feature_enablement: &BTreeMap, ) -> std::io::Result { let merged_cli_overrides = cli_overrides .iter() @@ -7885,13 +7918,15 @@ async fn derive_config_from_params( ) .collect::>(); - codex_core::config::ConfigBuilder::default() + let mut config = codex_core::config::ConfigBuilder::default() .codex_home(codex_home.to_path_buf()) .cli_overrides(merged_cli_overrides) .harness_overrides(typesafe_overrides) .cloud_requirements(cloud_requirements.clone()) .build() - .await + .await?; + apply_runtime_feature_enablement(&mut config, runtime_feature_enablement); + Ok(config) } async fn derive_config_for_cwd( @@ -7901,6 +7936,7 @@ async fn derive_config_for_cwd( cwd: Option, cloud_requirements: &CloudRequirementsLoader, codex_home: &Path, + runtime_feature_enablement: &BTreeMap, ) -> std::io::Result { let merged_cli_overrides = cli_overrides .iter() @@ -7913,14 +7949,16 @@ async fn derive_config_for_cwd( ) .collect::>(); - codex_core::config::ConfigBuilder::default() + let mut config = codex_core::config::ConfigBuilder::default() .codex_home(codex_home.to_path_buf()) .cli_overrides(merged_cli_overrides) .harness_overrides(typesafe_overrides) .fallback_cwd(cwd) .cloud_requirements(cloud_requirements.clone()) .build() - .await + .await?; + apply_runtime_feature_enablement(&mut config, runtime_feature_enablement); + Ok(config) } async fn read_history_cwd_from_state_db( diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index bc82e9152e..9ade74914d 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -9,11 +9,14 @@ use codex_app_server_protocol::ConfigRequirementsReadResponse; use codex_app_server_protocol::ConfigValueWriteParams; use codex_app_server_protocol::ConfigWriteErrorCode; use codex_app_server_protocol::ConfigWriteResponse; +use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams; +use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::NetworkRequirements; use codex_app_server_protocol::SandboxMode; use codex_core::AnalyticsEventsClient; use codex_core::ThreadManager; +use codex_core::config::Config; use codex_core::config::ConfigService; use codex_core::config::ConfigServiceError; use codex_core::config_loader::CloudRequirementsLoader; @@ -24,15 +27,21 @@ use codex_core::config_loader::SandboxModeRequirement as CoreSandboxModeRequirem use codex_core::plugins::PluginId; use codex_core::plugins::collect_plugin_enabled_candidates; use codex_core::plugins::installed_plugin_telemetry_metadata; +use codex_features::canonical_feature_for_key; +use codex_features::feature_for_key; use codex_protocol::config_types::WebSearchMode; use codex_protocol::protocol::Op; use serde_json::json; +use std::collections::BTreeMap; +use std::collections::BTreeSet; use std::path::PathBuf; use std::sync::Arc; use std::sync::RwLock; use toml::Value as TomlValue; use tracing::warn; +const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &["apps", "plugins"]; + #[async_trait] pub(crate) trait UserConfigReloader: Send + Sync { async fn reload_user_config(&self); @@ -56,7 +65,8 @@ impl UserConfigReloader for ThreadManager { #[derive(Clone)] pub(crate) struct ConfigApi { codex_home: PathBuf, - cli_overrides: Vec<(String, TomlValue)>, + cli_overrides: Arc>>, + runtime_feature_enablement: Arc>>, loader_overrides: LoaderOverrides, cloud_requirements: Arc>, user_config_reloader: Arc, @@ -66,7 +76,8 @@ pub(crate) struct ConfigApi { impl ConfigApi { pub(crate) fn new( codex_home: PathBuf, - cli_overrides: Vec<(String, TomlValue)>, + cli_overrides: Arc>>, + runtime_feature_enablement: Arc>>, loader_overrides: LoaderOverrides, cloud_requirements: Arc>, user_config_reloader: Arc, @@ -75,6 +86,7 @@ impl ConfigApi { Self { codex_home, cli_overrides, + runtime_feature_enablement, loader_overrides, cloud_requirements, user_config_reloader, @@ -83,24 +95,87 @@ impl ConfigApi { } fn config_service(&self) -> ConfigService { - let cloud_requirements = self - .cloud_requirements - .read() - .map(|guard| guard.clone()) - .unwrap_or_default(); ConfigService::new( self.codex_home.clone(), - self.cli_overrides.clone(), + self.current_cli_overrides(), self.loader_overrides.clone(), - cloud_requirements, + self.current_cloud_requirements(), ) } + fn current_cli_overrides(&self) -> Vec<(String, TomlValue)> { + self.cli_overrides + .read() + .map(|guard| guard.clone()) + .unwrap_or_default() + } + + fn current_runtime_feature_enablement(&self) -> BTreeMap { + self.runtime_feature_enablement + .read() + .map(|guard| guard.clone()) + .unwrap_or_default() + } + + fn current_cloud_requirements(&self) -> CloudRequirementsLoader { + self.cloud_requirements + .read() + .map(|guard| guard.clone()) + .unwrap_or_default() + } + + async fn load_latest_config( + &self, + fallback_cwd: Option, + ) -> Result { + let mut config = codex_core::config::ConfigBuilder::default() + .codex_home(self.codex_home.clone()) + .cli_overrides(self.current_cli_overrides()) + .loader_overrides(self.loader_overrides.clone()) + .fallback_cwd(fallback_cwd) + .cloud_requirements(self.current_cloud_requirements()) + .build() + .await + .map_err(|err| JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to resolve feature override precedence: {err}"), + data: None, + })?; + apply_runtime_feature_enablement(&mut config, &self.current_runtime_feature_enablement()); + Ok(config) + } + pub(crate) async fn read( &self, params: ConfigReadParams, ) -> Result { - self.config_service().read(params).await.map_err(map_error) + let fallback_cwd = params.cwd.as_ref().map(PathBuf::from); + let mut response = self + .config_service() + .read(params) + .await + .map_err(map_error)?; + let config = self.load_latest_config(fallback_cwd).await?; + for feature_key in SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT { + let Some(feature) = feature_for_key(feature_key) else { + continue; + }; + let features = response + .config + .additional + .entry("features".to_string()) + .or_insert_with(|| json!({})); + if !features.is_object() { + *features = json!({}); + } + if let Some(features) = features.as_object_mut() { + features.insert( + (*feature_key).to_string(), + json!(config.features.enabled(feature)), + ); + } + } + Ok(response) } pub(crate) async fn config_requirements_read( @@ -154,6 +229,68 @@ impl ConfigApi { Ok(response) } + pub(crate) async fn set_experimental_feature_enablement( + &self, + params: ExperimentalFeatureEnablementSetParams, + ) -> Result { + let ExperimentalFeatureEnablementSetParams { enablement } = params; + for key in enablement.keys() { + if canonical_feature_for_key(key).is_some() { + if SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.contains(&key.as_str()) { + continue; + } + + return Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!( + "unsupported feature enablement `{key}`: currently supported features are {}", + SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.join(", ") + ), + data: None, + }); + } + + let message = if let Some(feature) = feature_for_key(key) { + format!( + "invalid feature enablement `{key}`: use canonical feature key `{}`", + feature.key() + ) + } else { + format!("invalid feature enablement `{key}`") + }; + return Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message, + data: None, + }); + } + + if enablement.is_empty() { + return Ok(ExperimentalFeatureEnablementSetResponse { enablement }); + } + + { + let mut runtime_feature_enablement = + self.runtime_feature_enablement + .write() + .map_err(|_| JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: "failed to update feature enablement".to_string(), + data: None, + })?; + runtime_feature_enablement.extend( + enablement + .iter() + .map(|(name, enabled)| (name.clone(), *enabled)), + ); + } + + self.load_latest_config(/*fallback_cwd*/ None).await?; + self.user_config_reloader.reload_user_config().await; + + Ok(ExperimentalFeatureEnablementSetResponse { enablement }) + } + fn emit_plugin_toggle_events(&self, pending_changes: std::collections::BTreeMap) { for (plugin_id, enabled) in pending_changes { let Ok(plugin_id) = PluginId::parse(&plugin_id) else { @@ -170,6 +307,49 @@ impl ConfigApi { } } +pub(crate) fn protected_feature_keys( + config_layer_stack: &codex_core::config_loader::ConfigLayerStack, +) -> BTreeSet { + let mut protected_features = config_layer_stack + .effective_config() + .get("features") + .and_then(toml::Value::as_table) + .map(|features| features.keys().cloned().collect::>()) + .unwrap_or_default(); + + if let Some(feature_requirements) = config_layer_stack + .requirements_toml() + .feature_requirements + .as_ref() + { + protected_features.extend(feature_requirements.entries.keys().cloned()); + } + + protected_features +} + +pub(crate) fn apply_runtime_feature_enablement( + config: &mut Config, + runtime_feature_enablement: &BTreeMap, +) { + let protected_features = protected_feature_keys(&config.config_layer_stack); + for (name, enabled) in runtime_feature_enablement { + if protected_features.contains(name) { + continue; + } + let Some(feature) = feature_for_key(name) else { + continue; + }; + if let Err(err) = config.features.set_enabled(feature, *enabled) { + warn!( + feature = name, + error = %err, + "failed to apply runtime feature enablement" + ); + } + } +} + fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigRequirements { ConfigRequirements { allowed_approval_policies: requirements.allowed_approval_policies.map(|policies| { @@ -265,6 +445,7 @@ mod tests { use super::*; use codex_core::AnalyticsEventsClient; use codex_core::config_loader::NetworkRequirementsToml as CoreNetworkRequirementsToml; + use codex_features::Feature; use codex_protocol::protocol::AskForApproval as CoreAskForApproval; use pretty_assertions::assert_eq; use serde_json::json; @@ -392,6 +573,66 @@ mod tests { ); } + #[tokio::test] + async fn apply_runtime_feature_enablement_keeps_cli_overrides_above_config_and_runtime() { + let codex_home = TempDir::new().expect("create temp dir"); + std::fs::write( + codex_home.path().join("config.toml"), + "[features]\napps = false\n", + ) + .expect("write config"); + + let mut config = codex_core::config::ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .cli_overrides(vec![( + "features.apps".to_string(), + TomlValue::Boolean(true), + )]) + .build() + .await + .expect("load config"); + + apply_runtime_feature_enablement( + &mut config, + &BTreeMap::from([("apps".to_string(), false)]), + ); + + assert!(config.features.enabled(Feature::Apps)); + } + + #[tokio::test] + async fn apply_runtime_feature_enablement_keeps_cloud_pins_above_cli_and_runtime() { + let codex_home = TempDir::new().expect("create temp dir"); + + let mut config = codex_core::config::ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .cli_overrides(vec![( + "features.apps".to_string(), + TomlValue::Boolean(true), + )]) + .cloud_requirements(CloudRequirementsLoader::new(async { + Ok(Some(ConfigRequirementsToml { + feature_requirements: Some( + codex_core::config_loader::FeatureRequirementsToml { + entries: BTreeMap::from([("apps".to_string(), false)]), + }, + ), + ..Default::default() + })) + })) + .build() + .await + .expect("load config"); + + apply_runtime_feature_enablement( + &mut config, + &BTreeMap::from([("apps".to_string(), true)]), + ); + + assert!(!config.features.enabled(Feature::Apps)); + } + #[tokio::test] async fn batch_write_reloads_user_config_when_requested() { let codex_home = TempDir::new().expect("create temp dir"); @@ -406,7 +647,8 @@ mod tests { ); let config_api = ConfigApi::new( codex_home.path().to_path_buf(), - Vec::new(), + Arc::new(RwLock::new(Vec::new())), + Arc::new(RwLock::new(BTreeMap::new())), LoaderOverrides::default(), Arc::new(RwLock::new(CloudRequirementsLoader::default())), reloader.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index a67c810bd0..d070738fba 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::collections::HashSet; use std::future::Future; use std::sync::Arc; @@ -30,6 +31,7 @@ use codex_app_server_protocol::ConfigReadParams; use codex_app_server_protocol::ConfigValueWriteParams; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::ExperimentalApi; +use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams; use codex_app_server_protocol::ExternalAgentConfigDetectParams; use codex_app_server_protocol::ExternalAgentConfigImportParams; use codex_app_server_protocol::FsCopyParams; @@ -226,6 +228,8 @@ impl MessageProcessor { .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); + let cli_overrides = Arc::new(RwLock::new(cli_overrides)); + let runtime_feature_enablement = Arc::new(RwLock::new(BTreeMap::new())); let cloud_requirements = Arc::new(RwLock::new(cloud_requirements)); let codex_message_processor = CodexMessageProcessor::new(CodexMessageProcessorArgs { auth_manager: auth_manager.clone(), @@ -234,6 +238,7 @@ impl MessageProcessor { arg0_paths, config: Arc::clone(&config), cli_overrides: cli_overrides.clone(), + runtime_feature_enablement: runtime_feature_enablement.clone(), cloud_requirements: cloud_requirements.clone(), feedback, log_db, @@ -246,6 +251,7 @@ impl MessageProcessor { let config_api = ConfigApi::new( config.codex_home.clone(), cli_overrides, + runtime_feature_enablement, loader_overrides, cloud_requirements, thread_manager, @@ -700,6 +706,16 @@ impl MessageProcessor { ) .await; } + ClientRequest::ExperimentalFeatureEnablementSet { request_id, params } => { + self.handle_experimental_feature_enablement_set( + ConnectionRequestId { + connection_id, + request_id, + }, + params, + ) + .await; + } ClientRequest::ConfigRequirementsRead { request_id, params: _, @@ -848,7 +864,30 @@ impl MessageProcessor { request_id: ConnectionRequestId, params: ConfigBatchWriteParams, ) { - match self.config_api.batch_write(params).await { + self.handle_config_mutation_result(request_id, self.config_api.batch_write(params).await) + .await; + } + + async fn handle_experimental_feature_enablement_set( + &self, + request_id: ConnectionRequestId, + params: ExperimentalFeatureEnablementSetParams, + ) { + self.handle_config_mutation_result( + request_id, + self.config_api + .set_experimental_feature_enablement(params) + .await, + ) + .await; + } + + async fn handle_config_mutation_result( + &self, + request_id: ConnectionRequestId, + result: std::result::Result, + ) { + match result { Ok(response) => { self.codex_message_processor.clear_plugin_related_caches(); self.codex_message_processor diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 067e06ec43..fe21cdafff 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -465,6 +465,16 @@ impl McpProcess { self.send_request("experimentalFeature/list", params).await } + /// Send an `experimentalFeature/enablement/set` JSON-RPC request. + pub async fn send_experimental_feature_enablement_set_request( + &mut self, + params: codex_app_server_protocol::ExperimentalFeatureEnablementSetParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("experimentalFeature/enablement/set", params) + .await + } + /// Send an `app/list` JSON-RPC request. pub async fn send_apps_list_request(&mut self, params: AppsListParams) -> anyhow::Result { let params = Some(serde_json::to_value(params)?); diff --git a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs index 7ff5f6fe39..7bbcc36ee2 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs @@ -3,16 +3,24 @@ use std::time::Duration; use anyhow::Result; use app_test_support::McpProcess; use app_test_support::to_response; +use codex_app_server_protocol::ConfigReadParams; +use codex_app_server_protocol::ConfigReadResponse; use codex_app_server_protocol::ExperimentalFeature; +use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams; +use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse; use codex_app_server_protocol::ExperimentalFeatureListParams; use codex_app_server_protocol::ExperimentalFeatureListResponse; use codex_app_server_protocol::ExperimentalFeatureStage; +use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_core::config::ConfigBuilder; use codex_features::FEATURES; use codex_features::Stage; use pretty_assertions::assert_eq; +use serde::de::DeserializeOwned; +use serde_json::json; +use std::collections::BTreeMap; use tempfile::TempDir; use tokio::time::timeout; @@ -34,13 +42,7 @@ async fn experimental_feature_list_returns_feature_metadata_with_stage() -> Resu .send_experimental_feature_list_request(ExperimentalFeatureListParams::default()) .await?; - let response: JSONRPCResponse = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(request_id)), - ) - .await??; - - let actual = to_response::(response)?; + let actual = read_response::(&mut mcp, request_id).await?; let expected_data = FEATURES .iter() .map(|spec| { @@ -82,3 +84,203 @@ async fn experimental_feature_list_returns_feature_metadata_with_stage() -> Resu assert_eq!(actual, expected); Ok(()) } + +#[tokio::test] +async fn experimental_feature_enablement_set_applies_to_global_and_thread_config_reads() +-> Result<()> { + let codex_home = TempDir::new()?; + let project_cwd = codex_home.path().join("project"); + std::fs::create_dir_all(&project_cwd)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let actual = + set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)])) + .await?; + assert_eq!( + actual, + ExperimentalFeatureEnablementSetResponse { + enablement: BTreeMap::from([("apps".to_string(), true)]), + } + ); + + for cwd in [None, Some(project_cwd.display().to_string())] { + let ConfigReadResponse { config, .. } = read_config(&mut mcp, cwd).await?; + + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("apps")), + Some(&json!(true)) + ); + } + + Ok(()) +} + +#[tokio::test] +async fn experimental_feature_enablement_set_does_not_override_user_config() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + "[features]\napps = false\n", + )?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let actual = + set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)])) + .await?; + assert_eq!( + actual, + ExperimentalFeatureEnablementSetResponse { + enablement: BTreeMap::from([("apps".to_string(), true)]), + } + ); + + let ConfigReadResponse { config, .. } = read_config(&mut mcp, /*cwd*/ None).await?; + + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("apps")), + Some(&json!(false)) + ); + + Ok(()) +} + +#[tokio::test] +async fn experimental_feature_enablement_set_only_updates_named_features() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)])) + .await?; + let actual = set_experimental_feature_enablement( + &mut mcp, + BTreeMap::from([("plugins".to_string(), true)]), + ) + .await?; + + assert_eq!( + actual, + ExperimentalFeatureEnablementSetResponse { + enablement: BTreeMap::from([("plugins".to_string(), true)]), + } + ); + + let ConfigReadResponse { config, .. } = read_config(&mut mcp, /*cwd*/ None).await?; + + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("apps")), + Some(&json!(true)) + ); + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("plugins")), + Some(&json!(true)) + ); + + Ok(()) +} + +#[tokio::test] +async fn experimental_feature_enablement_set_empty_map_is_no_op() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)])) + .await?; + let actual = set_experimental_feature_enablement(&mut mcp, BTreeMap::new()).await?; + + assert_eq!( + actual, + ExperimentalFeatureEnablementSetResponse { + enablement: BTreeMap::new(), + } + ); + + let ConfigReadResponse { config, .. } = read_config(&mut mcp, /*cwd*/ None).await?; + + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("apps")), + Some(&json!(true)) + ); + + Ok(()) +} + +#[tokio::test] +async fn experimental_feature_enablement_set_rejects_non_allowlisted_feature() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams { + enablement: BTreeMap::from([("personality".to_string(), true)]), + }) + .await?; + let JSONRPCError { error, .. } = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.code, -32600); + assert!( + error + .message + .contains("unsupported feature enablement `personality`"), + "{}", + error.message + ); + assert!(error.message.contains("apps, plugins"), "{}", error.message); + + Ok(()) +} + +async fn set_experimental_feature_enablement( + mcp: &mut McpProcess, + enablement: BTreeMap, +) -> Result { + let request_id = mcp + .send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams { + enablement, + }) + .await?; + read_response(mcp, request_id).await +} + +async fn read_config(mcp: &mut McpProcess, cwd: Option) -> Result { + let request_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: false, + cwd, + }) + .await?; + read_response(mcp, request_id).await +} + +async fn read_response(mcp: &mut McpProcess, request_id: i64) -> Result { + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + to_response(response) +} From 2250508c2e900c577c48de2620d4f3cd0575e1c9 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Tue, 24 Mar 2026 20:03:10 -0700 Subject: [PATCH 02/11] TUI plugin menu cleanup - hide app ID (#15708) - Hide App ID from plugin details page. --- codex-rs/tui/src/chatwidget/plugins.rs | 14 +------------- codex-rs/tui_app_server/src/chatwidget/plugins.rs | 14 +------------- 2 files changed, 2 insertions(+), 26 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index ce08cc8791..120b7237ee 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -420,13 +420,6 @@ impl ChatWidget { } else { "Not installed yet." }; - let description = app - .description - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - let mut header = ColumnRenderable::new(); header.push(Line::from("Plugins".bold())); header.push(Line::from( @@ -437,12 +430,7 @@ impl ChatWidget { )); header.push(Line::from(status_label.dim())); - let mut items = vec![SelectionItem { - name: app.name.clone(), - description, - is_disabled: true, - ..Default::default() - }]; + let mut items = Vec::new(); if let Some(install_url) = app.install_url.clone() { let install_label = if is_installed { diff --git a/codex-rs/tui_app_server/src/chatwidget/plugins.rs b/codex-rs/tui_app_server/src/chatwidget/plugins.rs index ce08cc8791..120b7237ee 100644 --- a/codex-rs/tui_app_server/src/chatwidget/plugins.rs +++ b/codex-rs/tui_app_server/src/chatwidget/plugins.rs @@ -420,13 +420,6 @@ impl ChatWidget { } else { "Not installed yet." }; - let description = app - .description - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - let mut header = ColumnRenderable::new(); header.push(Line::from("Plugins".bold())); header.push(Line::from( @@ -437,12 +430,7 @@ impl ChatWidget { )); header.push(Line::from(status_label.dim())); - let mut items = vec![SelectionItem { - name: app.name.clone(), - description, - is_disabled: true, - ..Default::default() - }]; + let mut items = Vec::new(); if let Some(install_url) = app.install_url.clone() { let install_label = if is_installed { From fece9ce7455261bf8a158c350970984bfff581d1 Mon Sep 17 00:00:00 2001 From: Shaqayeq Date: Tue, 24 Mar 2026 20:12:52 -0700 Subject: [PATCH 03/11] Fix stale quickstart integration assertion (#15677) TL;DR: update the quickstart integration assertion to match the current example output. - replace the stale `Status:` expectation for `01_quickstart_constructor` with `Server:`, `Items:`, and `Text:` - keep the existing guard against `Server: unknown` --- sdk/python/tests/test_real_app_server_integration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/python/tests/test_real_app_server_integration.py b/sdk/python/tests/test_real_app_server_integration.py index b5e37c444c..3c114f1202 100644 --- a/sdk/python/tests/test_real_app_server_integration.py +++ b/sdk/python/tests/test_real_app_server_integration.py @@ -505,7 +505,7 @@ def test_real_examples_run_and_assert( out = result.stdout if folder == "01_quickstart_constructor": - assert "Status:" in out and "Text:" in out + assert "Server:" in out and "Items:" in out and "Text:" in out assert "Server: unknown" not in out elif folder == "02_turn_run": assert "thread_id:" in out and "turn_id:" in out and "status:" in out From 0bff38c54a89f4658cfe79466e8a17a469f9e201 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Tue, 24 Mar 2026 20:31:21 -0700 Subject: [PATCH 04/11] [plugins] Flip the flags. (#15713) - [x] Flip the `plugins` and `apps` flags. --- codex-rs/core/src/plugins/discoverable_tests.rs | 6 ++++++ codex-rs/core/src/tools/spec_tests.rs | 8 +++----- codex-rs/features/src/lib.rs | 12 ++++-------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/codex-rs/core/src/plugins/discoverable_tests.rs b/codex-rs/core/src/plugins/discoverable_tests.rs index cb2ac15493..fd9df9ddf6 100644 --- a/codex-rs/core/src/plugins/discoverable_tests.rs +++ b/codex-rs/core/src/plugins/discoverable_tests.rs @@ -44,6 +44,12 @@ async fn list_tool_suggest_discoverable_plugins_returns_empty_when_plugins_featu let codex_home = tempdir().expect("tempdir should succeed"); let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path()); write_openai_curated_marketplace(&curated_root, &["slack"]); + write_file( + &codex_home.path().join(crate::config::CONFIG_TOML_FILE), + r#"[features] +plugins = false +"#, + ); let config = load_plugins_config(codex_home.path()).await; let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index dd806f887b..b5f50ae6ff 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -2075,11 +2075,9 @@ fn tool_suggest_requires_apps_and_plugins_features() { for disabled_feature in [Feature::Apps, Feature::Plugins] { let mut features = Features::with_defaults(); features.enable(Feature::ToolSuggest); - for feature in [Feature::Apps, Feature::Plugins] { - if feature != disabled_feature { - features.enable(feature); - } - } + features.enable(Feature::Apps); + features.enable(Feature::Plugins); + features.disable(disabled_feature); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index f49428cec5..6128fe43fa 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -714,12 +714,8 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::Apps, key: "apps", - stage: Stage::Experimental { - name: "Apps", - menu_description: "Use a connected ChatGPT App using \"$\". Install Apps via /apps command. Restart Codex after enabling.", - announcement: "NEW: Use ChatGPT Apps (Connectors) in Codex via $ mentions. Enable in /experimental and restart Codex!", - }, - default_enabled: false, + stage: Stage::Stable, + default_enabled: true, }, FeatureSpec { id: Feature::ToolSuggest, @@ -730,8 +726,8 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::Plugins, key: "plugins", - stage: Stage::UnderDevelopment, - default_enabled: false, + stage: Stage::Stable, + default_enabled: true, }, FeatureSpec { id: Feature::ImageGeneration, From 8c62829a2b0e46fe0a9b62145eddad696fbac700 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Tue, 24 Mar 2026 21:52:11 -0700 Subject: [PATCH 05/11] [plugins] Flip on additional flags. (#15719) - [x] Flip on additional flags. --- codex-rs/core/src/tools/spec_tests.rs | 3 +-- codex-rs/features/src/lib.rs | 8 ++++---- codex-rs/features/src/tests.rs | 12 +++++++++--- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index b5f50ae6ff..c114761761 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -2030,8 +2030,7 @@ fn search_tool_requires_model_capability_only() { fn tool_suggest_is_not_registered_without_feature_flag() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); - features.enable(Feature::Apps); - features.enable(Feature::Plugins); + features.disable(Feature::ToolSuggest); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 6128fe43fa..be98fac69a 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -720,8 +720,8 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::ToolSuggest, key: "tool_suggest", - stage: Stage::UnderDevelopment, - default_enabled: false, + stage: Stage::Stable, + default_enabled: true, }, FeatureSpec { id: Feature::Plugins, @@ -778,8 +778,8 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::ToolCallMcpElicitation, key: "tool_call_mcp_elicitation", - stage: Stage::UnderDevelopment, - default_enabled: false, + stage: Stage::Stable, + default_enabled: true, }, FeatureSpec { id: Feature::Personality, diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index faf02b083e..7c37ef6175 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -115,9 +115,9 @@ fn request_permissions_tool_is_under_development() { } #[test] -fn tool_suggest_is_under_development() { - assert_eq!(Feature::ToolSuggest.stage(), Stage::UnderDevelopment); - assert_eq!(Feature::ToolSuggest.default_enabled(), false); +fn tool_suggest_is_stable_and_enabled_by_default() { + assert_eq!(Feature::ToolSuggest.stage(), Stage::Stable); + assert_eq!(Feature::ToolSuggest.default_enabled(), true); } #[test] @@ -138,6 +138,12 @@ fn image_generation_is_under_development() { assert_eq!(Feature::ImageGeneration.default_enabled(), false); } +#[test] +fn tool_call_mcp_elicitation_is_stable_and_enabled_by_default() { + assert_eq!(Feature::ToolCallMcpElicitation.stage(), Stage::Stable); + assert_eq!(Feature::ToolCallMcpElicitation.default_enabled(), true); +} + #[test] fn image_detail_original_feature_is_under_development() { assert_eq!( From 95ba76262059226193af10fdfbbb529000f8463c Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Tue, 24 Mar 2026 22:54:18 -0700 Subject: [PATCH 06/11] fix: support split carveouts in windows restricted-token sandbox (#14172) ## Summary - keep legacy Windows restricted-token sandboxing as the supported baseline - support the split-policy subset that restricted-token can enforce directly today - support full-disk read, the same writable root set as legacy `WorkspaceWrite`, and extra read-only carveouts under those writable roots via additional deny-write ACLs - continue to fail closed for unsupported split-only shapes, including explicit unreadable (`none`) carveouts, reopened writable descendants under read-only carveouts, and writable root sets that do not match the legacy workspace roots ## Example Given a filesystem policy like: ```toml ":root" = "read" ":cwd" = "write" "./docs" = "read" ``` the restricted-token backend can keep the workspace writable while denying writes under `docs` by layering an extra deny-write carveout on top of the legacy workspace-write roots. A policy like: ```toml "/workspace" = "write" "/workspace/docs" = "read" "/workspace/docs/tmp" = "write" ``` still fails closed, because the unelevated backend cannot reopen the nested writable descendant safely. ## Stack -> fix: support split carveouts in windows restricted-token sandbox #14172 fix: support split carveouts in windows elevated sandbox #14568 --- codex-rs/app-server/src/command_exec.rs | 60 ++-- codex-rs/core/README.md | 14 +- codex-rs/core/src/exec.rs | 268 +++++++++++---- codex-rs/core/src/exec_tests.rs | 306 ++++++++++++------ codex-rs/core/src/sandboxing/mod.rs | 38 +++ codex-rs/core/src/tasks/user_shell.rs | 1 + .../tools/runtimes/shell/unix_escalation.rs | 2 + codex-rs/windows-sandbox-rs/src/lib.rs | 34 +- 8 files changed, 538 insertions(+), 185 deletions(-) diff --git a/codex-rs/app-server/src/command_exec.rs b/codex-rs/app-server/src/command_exec.rs index 8f92cd0ba2..a1a042de6a 100644 --- a/codex-rs/app-server/src/command_exec.rs +++ b/codex-rs/app-server/src/command_exec.rs @@ -734,21 +734,21 @@ mod tests { access: ReadOnlyAccess::FullAccess, network_access: false, }; - ExecRequest { - command: vec!["cmd".to_string()], - cwd: PathBuf::from("."), - env: HashMap::new(), - network: None, - expiration: ExecExpiration::DefaultTimeout, - capture_policy: codex_core::exec::ExecCapturePolicy::ShellTool, - sandbox: SandboxType::WindowsRestrictedToken, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - windows_sandbox_private_desktop: false, - sandbox_policy: sandbox_policy.clone(), - file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy), - network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy), - arg0: None, - } + ExecRequest::new( + vec!["cmd".to_string()], + PathBuf::from("."), + HashMap::new(), + /*network*/ None, + ExecExpiration::DefaultTimeout, + codex_core::exec::ExecCapturePolicy::ShellTool, + SandboxType::WindowsRestrictedToken, + WindowsSandboxLevel::Disabled, + /*windows_sandbox_private_desktop*/ false, + sandbox_policy.clone(), + FileSystemSandboxPolicy::from(&sandbox_policy), + NetworkSandboxPolicy::from(&sandbox_policy), + /*arg0*/ None, + ) } #[tokio::test] @@ -846,21 +846,21 @@ mod tests { outgoing: Arc::new(OutgoingMessageSender::new(tx)), request_id: request_id.clone(), process_id: Some("proc-100".to_string()), - exec_request: ExecRequest { - command: vec!["sh".to_string(), "-lc".to_string(), "sleep 30".to_string()], - cwd: PathBuf::from("."), - env: HashMap::new(), - network: None, - expiration: ExecExpiration::Cancellation(CancellationToken::new()), - capture_policy: codex_core::exec::ExecCapturePolicy::ShellTool, - sandbox: SandboxType::None, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - windows_sandbox_private_desktop: false, - sandbox_policy: sandbox_policy.clone(), - file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy), - network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy), - arg0: None, - }, + exec_request: ExecRequest::new( + vec!["sh".to_string(), "-lc".to_string(), "sleep 30".to_string()], + PathBuf::from("."), + HashMap::new(), + /*network*/ None, + ExecExpiration::Cancellation(CancellationToken::new()), + codex_core::exec::ExecCapturePolicy::ShellTool, + SandboxType::None, + WindowsSandboxLevel::Disabled, + /*windows_sandbox_private_desktop*/ false, + sandbox_policy.clone(), + FileSystemSandboxPolicy::from(&sandbox_policy), + NetworkSandboxPolicy::from(&sandbox_policy), + /*arg0*/ None, + ), started_network_proxy: None, tty: false, stream_stdin: false, diff --git a/codex-rs/core/README.md b/codex-rs/core/README.md index c27ec5460e..c154ef463d 100644 --- a/codex-rs/core/README.md +++ b/codex-rs/core/README.md @@ -51,7 +51,6 @@ Expects the binary containing `codex-core` to run the equivalent of `codex sandb Legacy `SandboxPolicy` / `sandbox_mode` configs are still supported on Linux. They can continue to use the legacy Landlock path when the split filesystem policy is sandbox-equivalent to the legacy model after `cwd` resolution. - Split filesystem policies that need direct `FileSystemSandboxPolicy` enforcement, such as read-only or denied carveouts under a broader writable root, automatically route through bubblewrap. The legacy Landlock path is used @@ -83,13 +82,18 @@ backend-managed system read roots required for basic execution, such as `C:\ProgramData`. When it is `false`, those extra system roots are omitted. The unelevated restricted-token backend still supports the legacy full-read -Windows model only. Restricted read-only policies continue to fail closed there -instead of running with weaker read enforcement. +Windows model for legacy `ReadOnly` and `WorkspaceWrite` behavior. It also +supports a narrow split-filesystem subset: full-read split policies whose +writable roots still match the legacy `WorkspaceWrite` root set, but add extra +read-only carveouts under those writable roots. New `[permissions]` / split filesystem policies remain supported on Windows only when they round-trip through the legacy `SandboxPolicy` model without -changing semantics. Richer split-only carveouts still fail closed instead of -running with weaker enforcement. +changing semantics. Policies that would require direct read restriction, +explicit unreadable carveouts, reopened writable descendants under read-only +carveouts, different writable root sets, or split carveout support in the +elevated setup/runner backend still fail closed instead of running with weaker +enforcement. ### All Platforms diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 0229e1dd57..a4399ddc46 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -1,6 +1,7 @@ #[cfg(unix)] use std::os::unix::process::ExitStatusExt; +use std::collections::BTreeSet; use std::collections::HashMap; use std::io; use std::path::Path; @@ -32,7 +33,7 @@ use crate::spawn::StdioPolicy; use crate::spawn::spawn_child_async; use crate::text_encoding::bytes_to_string_smart; use codex_network_proxy::NetworkProxy; -#[cfg(any(target_os = "windows", test))] +use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::permissions::FileSystemSandboxKind; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; @@ -41,6 +42,7 @@ use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxTransformRequest; use codex_sandboxing::SandboxType; use codex_sandboxing::SandboxablePreference; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; use codex_utils_pty::process_group::kill_child_process_group; @@ -91,6 +93,17 @@ pub struct ExecParams { pub arg0: Option, } +/// Extra filesystem deny-write carveouts for the non-elevated Windows +/// restricted-token backend. +/// +/// These are applied on top of the legacy `WorkspaceWrite` allow set, so we +/// can support a narrow split-policy subset without changing legacy Windows +/// sandbox semantics. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct WindowsRestrictedTokenFilesystemOverlay { + pub(crate) additional_deny_write_paths: Vec, +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum ExecCapturePolicy { /// Shell-like execs keep the historical output cap and timeout behavior. @@ -269,7 +282,7 @@ pub fn build_exec_request( expiration, capture_policy, }; - let exec_req = manager + let mut exec_req = manager .transform(SandboxTransformRequest { command, policy: sandbox_policy, @@ -288,6 +301,16 @@ pub fn build_exec_request( }) .map(|request| ExecRequest::from_sandbox_exec_request(request, options)) .map_err(CodexErr::from)?; + exec_req.windows_restricted_token_filesystem_overlay = + resolve_windows_restricted_token_filesystem_overlay( + exec_req.sandbox, + &exec_req.sandbox_policy, + &exec_req.file_system_sandbox_policy, + exec_req.network_sandbox_policy, + sandbox_cwd, + exec_req.windows_sandbox_level, + ) + .map_err(CodexErr::UnsupportedOperation)?; Ok(exec_req) } @@ -310,6 +333,7 @@ pub(crate) async fn execute_exec_request( sandbox_policy: _sandbox_policy_from_env, file_system_sandbox_policy, network_sandbox_policy, + windows_restricted_token_filesystem_overlay, arg0, } = exec_request; let _ = _sandbox_policy_from_env; @@ -334,6 +358,7 @@ pub(crate) async fn execute_exec_request( sandbox, sandbox_policy, &file_system_sandbox_policy, + windows_restricted_token_filesystem_overlay.as_ref(), network_sandbox_policy, stdout_stream, after_spawn, @@ -413,11 +438,11 @@ fn record_windows_sandbox_spawn_failure( async fn exec_windows_sandbox( params: ExecParams, sandbox_policy: &SandboxPolicy, + windows_restricted_token_filesystem_overlay: Option<&WindowsRestrictedTokenFilesystemOverlay>, ) -> Result { use crate::config::find_codex_home; - use codex_protocol::config_types::WindowsSandboxLevel; - use codex_windows_sandbox::run_windows_sandbox_capture; use codex_windows_sandbox::run_windows_sandbox_capture_elevated; + use codex_windows_sandbox::run_windows_sandbox_capture_with_extra_deny_write_paths; let ExecParams { command, @@ -456,6 +481,15 @@ async fn exec_windows_sandbox( let command_path = command.first().cloned(); let sandbox_level = windows_sandbox_level; let use_elevated = matches!(sandbox_level, WindowsSandboxLevel::Elevated); + let additional_deny_write_paths = windows_restricted_token_filesystem_overlay + .map(|overlay| { + overlay + .additional_deny_write_paths + .iter() + .map(AbsolutePathBuf::to_path_buf) + .collect::>() + }) + .unwrap_or_default(); let spawn_res = tokio::task::spawn_blocking(move || { if use_elevated { run_windows_sandbox_capture_elevated( @@ -469,7 +503,7 @@ async fn exec_windows_sandbox( windows_sandbox_private_desktop, ) } else { - run_windows_sandbox_capture( + run_windows_sandbox_capture_with_extra_deny_write_paths( policy_str.as_str(), &sandbox_cwd, codex_home.as_ref(), @@ -477,6 +511,7 @@ async fn exec_windows_sandbox( &cwd, env, timeout_ms, + &additional_deny_write_paths, windows_sandbox_private_desktop, ) } @@ -785,29 +820,25 @@ impl Default for ExecToolCallOutput { } } -#[cfg_attr(not(target_os = "windows"), allow(unused_variables))] +#[allow(clippy::too_many_arguments)] async fn exec( params: ExecParams, - sandbox: SandboxType, - sandbox_policy: &SandboxPolicy, - file_system_sandbox_policy: &FileSystemSandboxPolicy, + _sandbox: SandboxType, + _sandbox_policy: &SandboxPolicy, + _file_system_sandbox_policy: &FileSystemSandboxPolicy, + _windows_restricted_token_filesystem_overlay: Option<&WindowsRestrictedTokenFilesystemOverlay>, network_sandbox_policy: NetworkSandboxPolicy, stdout_stream: Option, after_spawn: Option>, ) -> Result { #[cfg(target_os = "windows")] - if sandbox == SandboxType::WindowsRestrictedToken { - let support = windows_restricted_token_sandbox_support( - sandbox, - params.windows_sandbox_level, - sandbox_policy, - file_system_sandbox_policy, - network_sandbox_policy, - ); - if let Some(reason) = support.unsupported_reason { - return Err(CodexErr::Io(io::Error::other(reason))); - } - return exec_windows_sandbox(params, sandbox_policy).await; + if _sandbox == SandboxType::WindowsRestrictedToken { + return exec_windows_sandbox( + params, + _sandbox_policy, + _windows_restricted_token_filesystem_overlay, + ) + .await; } let ExecParams { command, @@ -852,54 +883,175 @@ async fn exec( } #[cfg_attr(not(target_os = "windows"), allow(dead_code))] -#[derive(Debug, PartialEq, Eq)] -struct WindowsRestrictedTokenSandboxSupport { - should_use: bool, - unsupported_reason: Option, -} - -#[cfg(any(target_os = "windows", test))] -fn windows_restricted_token_sandbox_support( +fn should_use_windows_restricted_token_sandbox( sandbox: SandboxType, - windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel, sandbox_policy: &SandboxPolicy, file_system_sandbox_policy: &FileSystemSandboxPolicy, - network_sandbox_policy: NetworkSandboxPolicy, -) -> WindowsRestrictedTokenSandboxSupport { - if sandbox != SandboxType::WindowsRestrictedToken { - return WindowsRestrictedTokenSandboxSupport { - should_use: false, - unsupported_reason: None, - }; - } - - // Windows currently reuses SandboxType::WindowsRestrictedToken for both - // the legacy restricted-token backend and the elevated setup/runner path. - // The sandbox level decides whether restricted read-only policies are - // supported. - let should_use = file_system_sandbox_policy.kind == FileSystemSandboxKind::Restricted +) -> bool { + sandbox == SandboxType::WindowsRestrictedToken + && file_system_sandbox_policy.kind == FileSystemSandboxKind::Restricted && !matches!( sandbox_policy, SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } ) - && (matches!( - windows_sandbox_level, - codex_protocol::config_types::WindowsSandboxLevel::Elevated - ) || sandbox_policy.has_full_disk_read_access()); +} - let unsupported_reason = if should_use { - None - } else { - Some(format!( +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) fn unsupported_windows_restricted_token_sandbox_reason( + sandbox: SandboxType, + sandbox_policy: &SandboxPolicy, + file_system_sandbox_policy: &FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, + sandbox_policy_cwd: &Path, + windows_sandbox_level: WindowsSandboxLevel, +) -> Option { + resolve_windows_restricted_token_filesystem_overlay( + sandbox, + sandbox_policy, + file_system_sandbox_policy, + network_sandbox_policy, + sandbox_policy_cwd, + windows_sandbox_level, + ) + .err() +} + +pub(crate) fn resolve_windows_restricted_token_filesystem_overlay( + sandbox: SandboxType, + sandbox_policy: &SandboxPolicy, + file_system_sandbox_policy: &FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, + sandbox_policy_cwd: &Path, + windows_sandbox_level: WindowsSandboxLevel, +) -> std::result::Result, String> { + if sandbox != SandboxType::WindowsRestrictedToken { + return Ok(None); + } + + let needs_direct_runtime_enforcement = file_system_sandbox_policy + .needs_direct_runtime_enforcement(network_sandbox_policy, sandbox_policy_cwd); + + if should_use_windows_restricted_token_sandbox( + sandbox, + sandbox_policy, + file_system_sandbox_policy, + ) && !needs_direct_runtime_enforcement + { + return Ok(None); + } + + if !should_use_windows_restricted_token_sandbox( + sandbox, + sandbox_policy, + file_system_sandbox_policy, + ) { + return Err(format!( "windows sandbox backend cannot enforce file_system={:?}, network={network_sandbox_policy:?}, legacy_policy={sandbox_policy:?}; refusing to run unsandboxed", file_system_sandbox_policy.kind, - )) - }; - - WindowsRestrictedTokenSandboxSupport { - should_use, - unsupported_reason, + )); } + + if windows_sandbox_level != WindowsSandboxLevel::RestrictedToken { + return Err( + "windows elevated sandbox backend cannot enforce split filesystem permissions directly; refusing to run unsandboxed" + .to_string(), + ); + } + + if !file_system_sandbox_policy.has_full_disk_read_access() { + return Err( + "windows unelevated restricted-token sandbox cannot enforce split filesystem read restrictions directly; refusing to run unsandboxed" + .to_string(), + ); + } + + if !file_system_sandbox_policy + .get_unreadable_roots_with_cwd(sandbox_policy_cwd) + .is_empty() + { + return Err( + "windows unelevated restricted-token sandbox cannot enforce unreadable split filesystem carveouts directly; refusing to run unsandboxed" + .to_string(), + ); + } + + let legacy_writable_roots = sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd); + let split_writable_roots = + file_system_sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd); + let legacy_root_paths: BTreeSet = legacy_writable_roots + .iter() + .map(|root| normalize_windows_overlay_path(root.root.as_path())) + .collect::>()?; + let split_root_paths: BTreeSet = split_writable_roots + .iter() + .map(|root| normalize_windows_overlay_path(root.root.as_path())) + .collect::>()?; + + if legacy_root_paths != split_root_paths { + return Err( + "windows unelevated restricted-token sandbox cannot enforce split writable root sets directly; refusing to run unsandboxed" + .to_string(), + ); + } + + for writable_root in &split_writable_roots { + for read_only_subpath in &writable_root.read_only_subpaths { + if split_writable_roots.iter().any(|candidate| { + candidate.root.as_path() != writable_root.root.as_path() + && candidate + .root + .as_path() + .starts_with(read_only_subpath.as_path()) + }) { + return Err( + "windows unelevated restricted-token sandbox cannot reopen writable descendants under read-only carveouts directly; refusing to run unsandboxed" + .to_string(), + ); + } + } + } + + let mut additional_deny_write_paths = BTreeSet::new(); + for split_root in &split_writable_roots { + let split_root_path = normalize_windows_overlay_path(split_root.root.as_path())?; + let Some(legacy_root) = legacy_writable_roots.iter().find(|candidate| { + normalize_windows_overlay_path(candidate.root.as_path()) + .is_ok_and(|candidate_path| candidate_path == split_root_path) + }) else { + return Err( + "windows unelevated restricted-token sandbox cannot enforce split writable root sets directly; refusing to run unsandboxed" + .to_string(), + ); + }; + + for read_only_subpath in &split_root.read_only_subpaths { + if !legacy_root + .read_only_subpaths + .iter() + .any(|candidate| candidate == read_only_subpath) + { + additional_deny_write_paths + .insert(normalize_windows_overlay_path(read_only_subpath.as_path())?); + } + } + } + + if additional_deny_write_paths.is_empty() { + return Ok(None); + } + + Ok(Some(WindowsRestrictedTokenFilesystemOverlay { + additional_deny_write_paths: additional_deny_write_paths + .into_iter() + .map(|path| AbsolutePathBuf::from_absolute_path(path).map_err(|err| err.to_string())) + .collect::>()?, + })) +} + +fn normalize_windows_overlay_path(path: &Path) -> std::result::Result { + AbsolutePathBuf::from_absolute_path(dunce::simplified(path)) + .map(AbsolutePathBuf::into_path_buf) + .map_err(|err| err.to_string()) } /// Consumes the output of a child process according to the configured capture diff --git a/codex-rs/core/src/exec_tests.rs b/codex-rs/core/src/exec_tests.rs index 6f452d6e8a..074db95e1c 100644 --- a/codex-rs/core/src/exec_tests.rs +++ b/codex-rs/core/src/exec_tests.rs @@ -269,6 +269,7 @@ async fn exec_full_buffer_capture_ignores_expiration() -> Result<()> { SandboxType::None, &SandboxPolicy::DangerFullAccess, &FileSystemSandboxPolicy::unrestricted(), + /*windows_restricted_token_filesystem_overlay*/ None, NetworkSandboxPolicy::Enabled, /*stdout_stream*/ None, /*after_spawn*/ None, @@ -308,6 +309,7 @@ async fn exec_full_buffer_capture_keeps_io_drain_timeout_when_descendant_holds_p SandboxType::None, &SandboxPolicy::DangerFullAccess, &FileSystemSandboxPolicy::unrestricted(), + /*windows_restricted_token_filesystem_overlay*/ None, NetworkSandboxPolicy::Enabled, /*stdout_stream*/ None, /*after_spawn*/ None, @@ -376,42 +378,30 @@ fn windows_restricted_token_skips_external_sandbox_policies() { let policy = SandboxPolicy::ExternalSandbox { network_access: codex_protocol::protocol::NetworkAccess::Restricted, }; - let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]); + let file_system_policy = FileSystemSandboxPolicy::from(&policy); assert_eq!( - windows_restricted_token_sandbox_support( + should_use_windows_restricted_token_sandbox( SandboxType::WindowsRestrictedToken, - WindowsSandboxLevel::Disabled, &policy, &file_system_policy, - NetworkSandboxPolicy::Restricted, ), - WindowsRestrictedTokenSandboxSupport { - should_use: false, - unsupported_reason: Some( - "windows sandbox backend cannot enforce file_system=Restricted, network=Restricted, legacy_policy=ExternalSandbox { network_access: Restricted }; refusing to run unsandboxed".to_string() - ), - } + false ); } #[test] fn windows_restricted_token_runs_for_legacy_restricted_policies() { let policy = SandboxPolicy::new_read_only_policy(); - let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]); + let file_system_policy = FileSystemSandboxPolicy::from(&policy); assert_eq!( - windows_restricted_token_sandbox_support( + should_use_windows_restricted_token_sandbox( SandboxType::WindowsRestrictedToken, - WindowsSandboxLevel::Disabled, &policy, &file_system_policy, - NetworkSandboxPolicy::Restricted, ), - WindowsRestrictedTokenSandboxSupport { - should_use: true, - unsupported_reason: None, - } + true ); } @@ -421,70 +411,39 @@ fn windows_restricted_token_rejects_network_only_restrictions() { network_access: codex_protocol::protocol::NetworkAccess::Restricted, }; let file_system_policy = FileSystemSandboxPolicy::unrestricted(); + let sandbox_policy_cwd = std::env::current_dir().expect("cwd"); assert_eq!( - windows_restricted_token_sandbox_support( - SandboxType::WindowsRestrictedToken, - WindowsSandboxLevel::Disabled, - &policy, - &file_system_policy, - NetworkSandboxPolicy::Restricted, - ), - WindowsRestrictedTokenSandboxSupport { - should_use: false, - unsupported_reason: Some( - "windows sandbox backend cannot enforce file_system=Unrestricted, network=Restricted, legacy_policy=ExternalSandbox { network_access: Restricted }; refusing to run unsandboxed".to_string() + unsupported_windows_restricted_token_sandbox_reason( + SandboxType::WindowsRestrictedToken, + &policy, + &file_system_policy, + NetworkSandboxPolicy::Restricted, + &sandbox_policy_cwd, + WindowsSandboxLevel::RestrictedToken, ), - } - ); + Some( + "windows sandbox backend cannot enforce file_system=Unrestricted, network=Restricted, legacy_policy=ExternalSandbox { network_access: Restricted }; refusing to run unsandboxed".to_string() + ) + ); } #[test] fn windows_restricted_token_allows_legacy_restricted_policies() { let policy = SandboxPolicy::new_read_only_policy(); - let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]); - - assert_eq!( - windows_restricted_token_sandbox_support( - SandboxType::WindowsRestrictedToken, - WindowsSandboxLevel::Disabled, - &policy, - &file_system_policy, - NetworkSandboxPolicy::Restricted, - ), - WindowsRestrictedTokenSandboxSupport { - should_use: true, - unsupported_reason: None, - } - ); -} - -#[test] -fn windows_restricted_token_rejects_restricted_read_only_policies() { - let policy = SandboxPolicy::ReadOnly { - access: codex_protocol::protocol::ReadOnlyAccess::Restricted { - include_platform_defaults: true, - readable_roots: vec![], - }, - network_access: false, - }; let file_system_policy = FileSystemSandboxPolicy::from(&policy); + let sandbox_policy_cwd = std::env::current_dir().expect("cwd"); assert_eq!( - windows_restricted_token_sandbox_support( + unsupported_windows_restricted_token_sandbox_reason( SandboxType::WindowsRestrictedToken, - WindowsSandboxLevel::Disabled, &policy, &file_system_policy, NetworkSandboxPolicy::Restricted, + &sandbox_policy_cwd, + WindowsSandboxLevel::RestrictedToken, ), - WindowsRestrictedTokenSandboxSupport { - should_use: false, - unsupported_reason: Some( - "windows sandbox backend cannot enforce file_system=Restricted, network=Restricted, legacy_policy=ReadOnly { access: Restricted { include_platform_defaults: true, readable_roots: [] }, network_access: false }; refusing to run unsandboxed".to_string() - ), - }, - "restricted-token should fail closed for restricted read-only policies" + None ); } @@ -494,50 +453,213 @@ fn windows_restricted_token_allows_legacy_workspace_write_policies() { writable_roots: vec![], read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, }; let file_system_policy = FileSystemSandboxPolicy::from(&policy); + let sandbox_policy_cwd = std::env::current_dir().expect("cwd"); assert_eq!( - windows_restricted_token_sandbox_support( + unsupported_windows_restricted_token_sandbox_reason( SandboxType::WindowsRestrictedToken, - WindowsSandboxLevel::Disabled, &policy, &file_system_policy, NetworkSandboxPolicy::Restricted, + &sandbox_policy_cwd, + WindowsSandboxLevel::RestrictedToken, ), - WindowsRestrictedTokenSandboxSupport { - should_use: true, - unsupported_reason: None, - } + None ); } #[test] -fn windows_elevated_sandbox_allows_restricted_read_only_policies() { - let policy = SandboxPolicy::ReadOnly { - access: codex_protocol::protocol::ReadOnlyAccess::Restricted { - include_platform_defaults: true, - readable_roots: vec![], - }, +fn windows_restricted_token_rejects_split_only_filesystem_policies() { + let temp_dir = tempfile::TempDir::new().expect("tempdir"); + let docs = temp_dir.path().join("docs"); + std::fs::create_dir_all(&docs).expect("create docs"); + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, }; - let file_system_policy = FileSystemSandboxPolicy::from(&policy); + let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Write, + }, + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Path { + path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs) + .expect("absolute docs"), + }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + ]); assert_eq!( - windows_restricted_token_sandbox_support( + unsupported_windows_restricted_token_sandbox_reason( SandboxType::WindowsRestrictedToken, - WindowsSandboxLevel::Elevated, &policy, &file_system_policy, NetworkSandboxPolicy::Restricted, + temp_dir.path(), + WindowsSandboxLevel::RestrictedToken, ), - WindowsRestrictedTokenSandboxSupport { - should_use: true, - unsupported_reason: None, + Some( + "windows unelevated restricted-token sandbox cannot enforce split filesystem read restrictions directly; refusing to run unsandboxed" + .to_string() + ) + ); +} + +#[test] +fn windows_restricted_token_rejects_root_write_read_only_carveouts() { + let temp_dir = tempfile::TempDir::new().expect("tempdir"); + let docs = temp_dir.path().join("docs"); + std::fs::create_dir_all(&docs).expect("create docs"); + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::Root, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Write, }, - "elevated Windows sandbox should keep restricted read-only support enabled" + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Path { + path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs) + .expect("absolute docs"), + }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + ]); + + assert_eq!( + unsupported_windows_restricted_token_sandbox_reason( + SandboxType::WindowsRestrictedToken, + &policy, + &file_system_policy, + NetworkSandboxPolicy::Restricted, + temp_dir.path(), + WindowsSandboxLevel::RestrictedToken, + ), + Some( + "windows unelevated restricted-token sandbox cannot enforce split writable root sets directly; refusing to run unsandboxed" + .to_string() + ) + ); +} + +#[test] +fn windows_restricted_token_supports_full_read_split_write_read_carveouts() { + let temp_dir = tempfile::TempDir::new().expect("tempdir"); + let cwd = dunce::canonicalize(temp_dir.path()).expect("canonicalize temp dir"); + let docs = cwd.join("docs"); + std::fs::create_dir_all(&docs).expect("create docs"); + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::Root, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Write, + }, + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Path { + path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs) + .expect("absolute docs"), + }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + ]); + + assert_eq!( + resolve_windows_restricted_token_filesystem_overlay( + SandboxType::WindowsRestrictedToken, + &policy, + &file_system_policy, + NetworkSandboxPolicy::Restricted, + &cwd, + WindowsSandboxLevel::RestrictedToken, + ), + Ok(Some(WindowsRestrictedTokenFilesystemOverlay { + additional_deny_write_paths: vec![ + codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs) + .expect("absolute docs"), + ], + })) + ); +} + +#[test] +fn windows_elevated_rejects_split_write_read_carveouts() { + let temp_dir = tempfile::TempDir::new().expect("tempdir"); + let docs = temp_dir.path().join("docs"); + std::fs::create_dir_all(&docs).expect("create docs"); + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; + let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::Root, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Write, + }, + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Path { + path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs) + .expect("absolute docs"), + }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + ]); + + assert_eq!( + unsupported_windows_restricted_token_sandbox_reason( + SandboxType::WindowsRestrictedToken, + &policy, + &file_system_policy, + NetworkSandboxPolicy::Restricted, + temp_dir.path(), + WindowsSandboxLevel::Elevated, + ), + Some( + "windows elevated sandbox backend cannot enforce split filesystem permissions directly; refusing to run unsandboxed" + .to_string() + ) ); } @@ -549,7 +671,7 @@ fn process_exec_tool_call_uses_platform_sandbox_for_network_only_restrictions() select_process_exec_tool_sandbox_type( &FileSystemSandboxPolicy::unrestricted(), NetworkSandboxPolicy::Restricted, - WindowsSandboxLevel::Disabled, + codex_protocol::config_types::WindowsSandboxLevel::Disabled, false, ), expected @@ -581,16 +703,17 @@ async fn kill_child_process_group_kills_grandchildren_on_timeout() -> Result<()> "-c".to_string(), "sleep 60 & echo $!; sleep 60".to_string(), ]; + let cwd = std::env::current_dir()?; let env: HashMap = std::env::vars().collect(); let params = ExecParams { command, - cwd: std::env::current_dir()?, + cwd, expiration: 500.into(), capture_policy: ExecCapturePolicy::ShellTool, env, network: None, sandbox_permissions: SandboxPermissions::UseDefault, - windows_sandbox_level: WindowsSandboxLevel::Disabled, + windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, justification: None, arg0: None, @@ -601,6 +724,7 @@ async fn kill_child_process_group_kills_grandchildren_on_timeout() -> Result<()> SandboxType::None, &SandboxPolicy::new_read_only_policy(), &FileSystemSandboxPolicy::from(&SandboxPolicy::new_read_only_policy()), + None, NetworkSandboxPolicy::Restricted, None, None, @@ -648,7 +772,7 @@ async fn process_exec_tool_call_respects_cancellation_token() -> Result<()> { env, network: None, sandbox_permissions: SandboxPermissions::UseDefault, - windows_sandbox_level: WindowsSandboxLevel::Disabled, + windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, justification: None, arg0: None, diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index 81f2a0af2c..26f4f733c6 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -11,6 +11,7 @@ use crate::exec::ExecCapturePolicy; use crate::exec::ExecExpiration; use crate::exec::ExecToolCallOutput; use crate::exec::StdoutStream; +use crate::exec::WindowsRestrictedTokenFilesystemOverlay; use crate::exec::execute_exec_request; #[cfg(target_os = "macos")] use crate::spawn::CODEX_SANDBOX_ENV_VAR; @@ -46,10 +47,46 @@ pub struct ExecRequest { pub sandbox_policy: SandboxPolicy, pub file_system_sandbox_policy: FileSystemSandboxPolicy, pub network_sandbox_policy: NetworkSandboxPolicy, + pub(crate) windows_restricted_token_filesystem_overlay: + Option, pub arg0: Option, } impl ExecRequest { + #[allow(clippy::too_many_arguments)] + pub fn new( + command: Vec, + cwd: PathBuf, + env: HashMap, + network: Option, + expiration: ExecExpiration, + capture_policy: ExecCapturePolicy, + sandbox: SandboxType, + windows_sandbox_level: WindowsSandboxLevel, + windows_sandbox_private_desktop: bool, + sandbox_policy: SandboxPolicy, + file_system_sandbox_policy: FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, + arg0: Option, + ) -> Self { + Self { + command, + cwd, + env, + network, + expiration, + capture_policy, + sandbox, + windows_sandbox_level, + windows_sandbox_private_desktop, + sandbox_policy, + file_system_sandbox_policy, + network_sandbox_policy, + windows_restricted_token_filesystem_overlay: None, + arg0, + } + } + pub(crate) fn from_sandbox_exec_request( request: SandboxExecRequest, options: ExecOptions, @@ -94,6 +131,7 @@ impl ExecRequest { sandbox_policy, file_system_sandbox_policy, network_sandbox_policy, + windows_restricted_token_filesystem_overlay: None, arg0, } } diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 682902f2d6..e118d38095 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -175,6 +175,7 @@ pub(crate) async fn execute_user_shell_command( sandbox_policy: sandbox_policy.clone(), file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy), network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy), + windows_restricted_token_filesystem_overlay: None, arg0: None, }; 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 8c4f24ed05..4e107232d0 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -137,6 +137,7 @@ pub(super) async fn try_run_zsh_fork( sandbox_policy, file_system_sandbox_policy, network_sandbox_policy, + windows_restricted_token_filesystem_overlay: _windows_restricted_token_filesystem_overlay, arg0, } = sandbox_exec_request; let ParsedShellCommand { script, login, .. } = extract_shell_script(&command)?; @@ -909,6 +910,7 @@ impl ShellCommandExecutor for CoreShellCommandExecutor { sandbox_policy: self.sandbox_policy.clone(), file_system_sandbox_policy: self.file_system_sandbox_policy.clone(), network_sandbox_policy: self.network_sandbox_policy, + windows_restricted_token_filesystem_overlay: None, arg0: self.arg0.clone(), }, /*stdout_stream*/ None, diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index cb4be52750..73193a207d 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -144,6 +144,8 @@ pub use token::get_current_token_for_restriction; #[cfg(target_os = "windows")] pub use windows_impl::run_windows_sandbox_capture; #[cfg(target_os = "windows")] +pub use windows_impl::run_windows_sandbox_capture_with_extra_deny_write_paths; +#[cfg(target_os = "windows")] pub use windows_impl::run_windows_sandbox_legacy_preflight; #[cfg(target_os = "windows")] pub use windows_impl::CaptureResult; @@ -259,6 +261,30 @@ mod windows_impl { #[allow(clippy::too_many_arguments)] pub fn run_windows_sandbox_capture( + policy_json_or_preset: &str, + sandbox_policy_cwd: &Path, + codex_home: &Path, + command: Vec, + cwd: &Path, + env_map: HashMap, + timeout_ms: Option, + use_private_desktop: bool, + ) -> Result { + run_windows_sandbox_capture_with_extra_deny_write_paths( + policy_json_or_preset, + sandbox_policy_cwd, + codex_home, + command, + cwd, + env_map, + timeout_ms, + &[], + use_private_desktop, + ) + } + + #[allow(clippy::too_many_arguments)] + pub fn run_windows_sandbox_capture_with_extra_deny_write_paths( policy_json_or_preset: &str, sandbox_policy_cwd: &Path, codex_home: &Path, @@ -266,6 +292,7 @@ mod windows_impl { cwd: &Path, mut env_map: HashMap, timeout_ms: Option, + additional_deny_write_paths: &[PathBuf], use_private_desktop: bool, ) -> Result { let policy = parse_policy(policy_json_or_preset)?; @@ -335,8 +362,13 @@ mod windows_impl { } let persist_aces = is_workspace_write; - let AllowDenyPaths { allow, deny } = + let AllowDenyPaths { allow, mut deny } = compute_allow_paths(&policy, sandbox_policy_cwd, ¤t_dir, &env_map); + for path in additional_deny_write_paths { + if path.exists() { + deny.insert(path.clone()); + } + } let canonical_cwd = canonicalize_path(¤t_dir); let mut guards: Vec<(PathBuf, *mut c_void)> = Vec::new(); unsafe { From c0ffd000dd6d246be139b888b316531c80d6538a Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 25 Mar 2026 00:28:07 -0600 Subject: [PATCH 07/11] Fix stale turn steering fallback in tui_app_server (#15714) This PR adds code to recover from a narrow app-server timing race where a follow-up can be sent after the previous turn has already ended but before the TUI has observed that completion. Instead of surfacing turn/steer failed: no active turn to steer, the client now treats that as a stale active-turn cache and falls back to starting a fresh turn, matching the intended submit behavior more closely. This is similar to the strategy employed by other app server clients (notably, the IDE extension and desktop app). This race exists because the current app-server API makes the client choose between two separate RPCs, turn/steer and turn/start, based on its local view of whether a turn is still active. That view is replicated from asynchronous notifications, so it can be stale for a brief window. The server may already have ended the turn while the client still believes it is in progress. Since the choice is made client-side rather than atomically on the server, tui_app_server can occasionally send turn/steer for a turn that no longer exists. --- codex-rs/tui_app_server/src/app.rs | 50 ++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/codex-rs/tui_app_server/src/app.rs b/codex-rs/tui_app_server/src/app.rs index 29da170b82..b1773d0ac2 100644 --- a/codex-rs/tui_app_server/src/app.rs +++ b/codex-rs/tui_app_server/src/app.rs @@ -621,6 +621,10 @@ impl ThreadEventStore { fn active_turn_id(&self) -> Option<&str> { self.active_turn_id.as_deref() } + + fn clear_active_turn_id(&mut self) { + self.active_turn_id = None; + } } #[derive(Debug)] @@ -986,6 +990,13 @@ fn active_turn_not_steerable_turn_error(error: &TypedRequestError) -> Option bool { + let TypedRequestError::Server { source, .. } = error else { + return false; + }; + source.message == "no active turn to steer" +} + impl App { pub fn chatwidget_init_for_forked_or_resumed_thread( &self, @@ -2021,23 +2032,32 @@ impl App { collaboration_mode, personality, } => { + let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { match app_server .turn_steer(thread_id, turn_id, items.to_vec()) .await { - Ok(_) => {} + Ok(_) => return Ok(true), Err(error) => { if let Some(turn_error) = active_turn_not_steerable_turn_error(&error) { if !self.chat_widget.enqueue_rejected_steer() { self.chat_widget.add_error_message(turn_error.message); } + return Ok(true); + } else if active_turn_missing_steer_error(&error) { + if let Some(channel) = self.thread_event_channels.get(&thread_id) { + let mut store = channel.store.lock().await; + store.clear_active_turn_id(); + } + should_start_turn = true; } else { return Err(error.into()); } } } - } else { + } + if should_start_turn { app_server .turn_start( thread_id, @@ -8043,6 +8063,17 @@ guardian_approval = true assert_eq!(refreshed_store.active_turn_id(), Some("turn-2")); } + #[test] + fn thread_event_store_clear_active_turn_id_resets_cached_turn() { + let mut store = ThreadEventStore::new(8); + let thread_id = ThreadId::new(); + store.push_notification(turn_started_notification(thread_id, "turn-1")); + + store.clear_active_turn_id(); + + assert_eq!(store.active_turn_id(), None); + } + #[test] fn thread_event_store_rebase_preserves_resolved_request_state() { let thread_id = ThreadId::new(); @@ -8273,6 +8304,21 @@ guardian_approval = true ); } + #[test] + fn active_turn_missing_steer_error_detects_stale_turn_race() { + let error = TypedRequestError::Server { + method: "turn/steer".to_string(), + source: JSONRPCErrorError { + code: -32602, + message: "no active turn to steer".to_string(), + data: None, + }, + }; + + assert!(active_turn_missing_steer_error(&error)); + assert_eq!(active_turn_not_steerable_turn_error(&error), None); + } + #[test] fn select_model_availability_nux_uses_existing_model_order_as_priority() { let mut presets = all_model_presets(); From e590fad50b832bebc68e7f18067cad5fe13a7d85 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Wed, 25 Mar 2026 00:00:25 -0700 Subject: [PATCH 08/11] [plugins] Add a flag for tool search. (#15722) - [x] Add a flag for tool search. --- codex-rs/app-server/src/config_api.rs | 8 ++- .../suite/v2/experimental_feature_list.rs | 43 ++++++++++++++-- codex-rs/core/config.schema.json | 6 +++ codex-rs/core/src/tools/spec.rs | 3 +- codex-rs/core/src/tools/spec_tests.rs | 25 +++++++++- codex-rs/core/tests/suite/search_tool.rs | 49 ++++++++++++++++++- codex-rs/features/src/lib.rs | 8 +++ codex-rs/features/src/tests.rs | 6 +++ 8 files changed, 141 insertions(+), 7 deletions(-) diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index 9ade74914d..4aa2982a81 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -40,7 +40,13 @@ use std::sync::RwLock; use toml::Value as TomlValue; use tracing::warn; -const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &["apps", "plugins"]; +const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[ + "apps", + "plugins", + "tool_search", + "tool_suggest", + "tool_call_mcp_elicitation", +]; #[async_trait] pub(crate) trait UserConfigReloader: Send + Sync { diff --git a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs index 7bbcc36ee2..3063830d5a 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs @@ -163,14 +163,24 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re .await?; let actual = set_experimental_feature_enablement( &mut mcp, - BTreeMap::from([("plugins".to_string(), true)]), + BTreeMap::from([ + ("plugins".to_string(), true), + ("tool_search".to_string(), true), + ("tool_suggest".to_string(), true), + ("tool_call_mcp_elicitation".to_string(), false), + ]), ) .await?; assert_eq!( actual, ExperimentalFeatureEnablementSetResponse { - enablement: BTreeMap::from([("plugins".to_string(), true)]), + enablement: BTreeMap::from([ + ("plugins".to_string(), true), + ("tool_search".to_string(), true), + ("tool_suggest".to_string(), true), + ("tool_call_mcp_elicitation".to_string(), false), + ]), } ); @@ -190,6 +200,27 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re .and_then(|features| features.get("plugins")), Some(&json!(true)) ); + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("tool_search")), + Some(&json!(true)) + ); + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("tool_suggest")), + Some(&json!(true)) + ); + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("tool_call_mcp_elicitation")), + Some(&json!(false)) + ); Ok(()) } @@ -249,7 +280,13 @@ async fn experimental_feature_enablement_set_rejects_non_allowlisted_feature() - "{}", error.message ); - assert!(error.message.contains("apps, plugins"), "{}", error.message); + assert!( + error + .message + .contains("apps, plugins, tool_search, tool_suggest, tool_call_mcp_elicitation"), + "{}", + error.message + ); Ok(()) } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 0b7cf3ced9..3e2d7ff689 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -485,6 +485,9 @@ "tool_call_mcp_elicitation": { "type": "boolean" }, + "tool_search": { + "type": "boolean" + }, "tool_suggest": { "type": "boolean" }, @@ -2099,6 +2102,9 @@ "tool_call_mcp_elicitation": { "type": "boolean" }, + "tool_search": { + "type": "boolean" + }, "tool_suggest": { "type": "boolean" }, diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 77c2954067..5686b4bb78 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -388,7 +388,8 @@ impl ToolsConfig { let include_request_user_input = !matches!(session_source, SessionSource::SubAgent(_)); let include_default_mode_request_user_input = include_request_user_input && features.enabled(Feature::DefaultModeRequestUserInput); - let include_search_tool = model_info.supports_search_tool; + let include_search_tool = + model_info.supports_search_tool && features.enabled(Feature::ToolSearch); let include_tool_suggest = include_search_tool && features.enabled(Feature::ToolSuggest) && features.enabled(Feature::Apps) diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index c114761761..36a0990393 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -1858,6 +1858,7 @@ fn search_tool_description_lists_each_codex_apps_connector_once() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); features.enable(Feature::Apps); + features.enable(Feature::ToolSearch); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -1976,7 +1977,7 @@ fn search_tool_description_lists_each_codex_apps_connector_once() { } #[test] -fn search_tool_requires_model_capability_only() { +fn search_tool_requires_model_capability_and_feature_flag() { let model_info = search_capable_model_info(); let app_tools = Some(HashMap::from([( "mcp__codex_apps__calendar_create_event".to_string(), @@ -2012,6 +2013,22 @@ fn search_tool_requires_model_capability_only() { }); let (tools, _) = build_specs(&tools_config, None, app_tools.clone(), &[]).build(); assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME); + + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }); + let (tools, _) = build_specs(&tools_config, None, app_tools.clone(), &[]).build(); + assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME); + + let mut features = Features::with_defaults(); + features.enable(Feature::ToolSearch); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -2030,6 +2047,7 @@ fn search_tool_requires_model_capability_only() { fn tool_suggest_is_not_registered_without_feature_flag() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); + features.enable(Feature::ToolSearch); features.disable(Feature::ToolSuggest); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { @@ -2073,6 +2091,7 @@ fn tool_suggest_requires_apps_and_plugins_features() { for disabled_feature in [Feature::Apps, Feature::Plugins] { let mut features = Features::with_defaults(); + features.enable(Feature::ToolSearch); features.enable(Feature::ToolSuggest); features.enable(Feature::Apps); features.enable(Feature::Plugins); @@ -2110,6 +2129,7 @@ fn search_tool_description_handles_no_enabled_apps() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); features.enable(Feature::Apps); + features.enable(Feature::ToolSearch); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -2136,6 +2156,7 @@ fn search_tool_description_falls_back_to_connector_name_without_description() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); features.enable(Feature::Apps); + features.enable(Feature::ToolSearch); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -2184,6 +2205,7 @@ fn search_tool_registers_namespaced_app_tool_aliases() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); features.enable(Feature::Apps); + features.enable(Feature::ToolSearch); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -2250,6 +2272,7 @@ fn tool_suggest_description_lists_discoverable_tools() { let mut features = Features::with_defaults(); features.enable(Feature::Apps); features.enable(Feature::Plugins); + features.enable(Feature::ToolSearch); features.enable(Feature::ToolSuggest); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 0eee2f1d3d..423c57a8c6 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -86,7 +86,7 @@ fn tool_search_output_tools(request: &ResponsesRequest, call_id: &str) -> Vec TestCodexBuilder { test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) @@ -169,6 +177,45 @@ async fn search_tool_flag_adds_tool_search() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tool_search_disabled_by_default_exposes_apps_tools_directly() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount_searchable(&server).await?; + let 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(move |config| { + configure_apps_without_tool_search(config, apps_server.chatgpt_base_url.as_str()) + }); + let test = builder.build(&server).await?; + + test.submit_turn_with_policies( + "list tools", + AskForApproval::Never, + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let body = mock.single_request().body_json(); + let tools = tool_names(&body); + assert!(!tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME)); + assert!(tools.iter().any(|name| name == CALENDAR_CREATE_TOOL)); + assert!(tools.iter().any(|name| name == CALENDAR_LIST_TOOL)); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn search_tool_is_hidden_for_api_key_auth() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index be98fac69a..ccde6a5e54 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -142,6 +142,8 @@ pub enum Feature { SpawnCsv, /// Enable apps. Apps, + /// Enable the tool_search tool for apps. + ToolSearch, /// Enable discoverable tool suggestions for apps. ToolSuggest, /// Enable plugins. @@ -717,6 +719,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::ToolSearch, + key: "tool_search", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::ToolSuggest, key: "tool_suggest", diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 7c37ef6175..ecfa87b41b 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -120,6 +120,12 @@ fn tool_suggest_is_stable_and_enabled_by_default() { assert_eq!(Feature::ToolSuggest.default_enabled(), true); } +#[test] +fn tool_search_is_under_development_and_disabled_by_default() { + assert_eq!(Feature::ToolSearch.stage(), Stage::UnderDevelopment); + assert_eq!(Feature::ToolSearch.default_enabled(), false); +} + #[test] fn use_linux_sandbox_bwrap_is_a_removed_feature_key() { assert_eq!( From f5dccab5cf9674f46c8bd2cc9606d9d6cd94fadb Mon Sep 17 00:00:00 2001 From: xl-openai Date: Wed, 25 Mar 2026 01:55:10 -0700 Subject: [PATCH 09/11] Update plugin creator skill. (#15734) Add support for home-local plugin + fix policy. --- .../src/assets/samples/plugin-creator/SKILL.md | 12 ++++++++++++ .../references/plugin-json-spec.md | 10 +++++++--- .../scripts/create_basic_plugin.py | 16 +++++++++++++--- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md b/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md index 28d47b7548..5108f549a7 100644 --- a/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md +++ b/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md @@ -26,6 +26,15 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py ` as the root and use: + +```bash +python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin \ + --path ~/plugins \ + --marketplace-path ~/.agents/plugins/marketplace.json \ + --with-marketplace +``` + 4. Generate/adjust optional companion folders as needed: ```bash @@ -37,6 +46,7 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin - ## What this skill creates +- If the user has not made the plugin location explicit, ask whether they want a repo-local plugin or a home-local plugin before generating marketplace entries. - Creates plugin root at `///`. - Always creates `///.codex-plugin/plugin.json`. - Fills the manifest with the full schema shape, placeholder values, and the complete `interface` section. @@ -58,6 +68,8 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin - ## Marketplace workflow - `marketplace.json` always lives at `/.agents/plugins/marketplace.json`. +- For a home-local plugin, use the same convention with `` as the root: + `~/.agents/plugins/marketplace.json` plus `./plugins/`. - Marketplace root metadata supports top-level `name` plus optional `interface.displayName`. - Treat plugin order in `plugins[]` as render order in Codex. Append new entries unless a user explicitly asks to reorder the list. - `displayName` belongs inside the marketplace `interface` object, not individual `plugins[]` entries. diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md b/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md index 3db14d332a..6831aae9db 100644 --- a/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md +++ b/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md @@ -115,8 +115,10 @@ "source": "local", "path": "./plugins/linear" }, - "installPolicy": "AVAILABLE", - "authPolicy": "ON_INSTALL", + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, "category": "Productivity" } ] @@ -142,7 +144,9 @@ - `source` (`string`): Use `local` for this repo workflow. - `path` (`string`): Relative plugin path based on the marketplace root. - Repo plugin: `./plugins/` - - Local plugin in `~/.agents/plugins/marketplace.json`: `./.codex/plugins/` + - Local plugin in `~/.agents/plugins/marketplace.json`: `./plugins/` + - The same relative path convention is used for both repo-rooted and home-rooted marketplaces. + - Example: with `~/.agents/plugins/marketplace.json`, `./plugins/` resolves to `~/plugins/`. - `policy` (`object`): Marketplace policy block. Always include it. - `installation` (`string`): Availability policy. - Allowed values: `NOT_AVAILABLE`, `AVAILABLE`, `INSTALLED_BY_DEFAULT` diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py index 5a61093164..87c2e17d4c 100755 --- a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py +++ b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py @@ -191,7 +191,10 @@ def parse_args() -> argparse.Namespace: parser.add_argument( "--path", default=str(DEFAULT_PLUGIN_PARENT), - help="Parent directory for plugin creation (defaults to /plugins)", + help=( + "Parent directory for plugin creation (defaults to /plugins). " + "When using a home-rooted marketplace, use /plugins." + ), ) parser.add_argument("--with-skills", action="store_true", help="Create skills/ directory") parser.add_argument("--with-hooks", action="store_true", help="Create hooks/ directory") @@ -202,12 +205,19 @@ def parse_args() -> argparse.Namespace: parser.add_argument( "--with-marketplace", action="store_true", - help="Create or update /.agents/plugins/marketplace.json", + help=( + "Create or update /.agents/plugins/marketplace.json. " + "Marketplace entries always point to ./plugins/ relative to the " + "marketplace root." + ), ) parser.add_argument( "--marketplace-path", default=str(DEFAULT_MARKETPLACE_PATH), - help="Path to marketplace.json (defaults to /.agents/plugins/marketplace.json)", + help=( + "Path to marketplace.json (defaults to /.agents/plugins/marketplace.json). " + "For a home-rooted marketplace, use /.agents/plugins/marketplace.json." + ), ) parser.add_argument( "--install-policy", From 047ea642d2989f4095a6dc5070aaa818554e550e Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 25 Mar 2026 13:34:43 +0000 Subject: [PATCH 10/11] chore: tty metric (#15766) --- codex-rs/core/src/tools/handlers/unified_exec.rs | 11 +++++++++++ codex-rs/otel/src/metrics/names.rs | 1 + 2 files changed, 12 insertions(+) diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 109ac713c4..54543277f5 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -25,6 +25,8 @@ use crate::unified_exec::UnifiedExecProcessManager; use crate::unified_exec::WriteStdinRequest; use async_trait::async_trait; use codex_features::Feature; +use codex_otel::SessionTelemetry; +use codex_otel::metrics::names::TOOL_CALL_UNIFIED_EXEC_METRIC; use codex_protocol::models::PermissionProfile; use serde::Deserialize; use std::path::PathBuf; @@ -260,6 +262,7 @@ impl ToolHandler for UnifiedExecHandler { }); } + emit_unified_exec_tty_metric(&turn.session_telemetry, tty); manager .exec_command( ExecCommandRequest { @@ -323,6 +326,14 @@ impl ToolHandler for UnifiedExecHandler { } } +fn emit_unified_exec_tty_metric(session_telemetry: &SessionTelemetry, tty: bool) { + session_telemetry.counter( + TOOL_CALL_UNIFIED_EXEC_METRIC, + /*inc*/ 1, + &[("tty", if tty { "true" } else { "false" })], + ); +} + pub(crate) fn get_command( args: &ExecCommandArgs, session_shell: Arc, diff --git a/codex-rs/otel/src/metrics/names.rs b/codex-rs/otel/src/metrics/names.rs index 58a82afea5..d4f5760d5c 100644 --- a/codex-rs/otel/src/metrics/names.rs +++ b/codex-rs/otel/src/metrics/names.rs @@ -1,5 +1,6 @@ pub const TOOL_CALL_COUNT_METRIC: &str = "codex.tool.call"; pub const TOOL_CALL_DURATION_METRIC: &str = "codex.tool.call.duration_ms"; +pub const TOOL_CALL_UNIFIED_EXEC_METRIC: &str = "codex.tool.unified_exec"; pub const API_CALL_COUNT_METRIC: &str = "codex.api_request"; pub const API_CALL_DURATION_METRIC: &str = "codex.api_request.duration_ms"; pub const SSE_EVENT_COUNT_METRIC: &str = "codex.sse_event"; From 32c4993c8a17cb0608d747f268e3d543745de962 Mon Sep 17 00:00:00 2001 From: Fouad Matin <169186268+fouad-openai@users.noreply.github.com> Date: Wed, 25 Mar 2026 07:55:41 -0700 Subject: [PATCH 11/11] fix(core): default approval behavior for mcp missing annotations (#15519) - Changed `requires_mcp_tool_approval` to apply MCP spec defaults when annotations are missing. - Unannotated tools now default to: - `readOnlyHint = false` - `destructiveHint = true` - `openWorldHint = true` - This means unannotated MCP tools now go through approval/ARC monitoring instead of silently bypassing it. - Explicitly read-only tools still skip approval unless they are also explicitly marked destructive. **Previous behavior** Failed open for missing annotations, which was unsafe for custom MCP tools that omitted or forgot annotations. --------- Co-authored-by: colby-oai <228809017+colby-oai@users.noreply.github.com> --- codex-rs/core/src/mcp_tool_call.rs | 19 +++- codex-rs/core/src/mcp_tool_call_tests.rs | 86 ++++++++++++++++++- .../core/tests/common/apps_test_server.rs | 11 +++ .../rmcp-client/src/bin/test_stdio_server.rs | 19 ++-- .../src/bin/test_streamable_http_server.rs | 7 +- 5 files changed, 127 insertions(+), 15 deletions(-) diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index f82cc73bb5..18a4843578 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -495,7 +495,7 @@ async fn maybe_request_mcp_tool_approval( approval_mode: AppToolApproval, ) -> Option { let annotations = metadata.and_then(|metadata| metadata.annotations.as_ref()); - let approval_required = annotations.is_some_and(requires_mcp_tool_approval); + let approval_required = requires_mcp_tool_approval(annotations); let mut monitor_reason = None; let auto_approved_by_policy = approval_mode == AppToolApproval::Approve || (approval_mode == AppToolApproval::Auto && is_full_access_mode(turn_context)); @@ -1299,12 +1299,23 @@ async fn persist_codex_app_tool_approval( .await } -fn requires_mcp_tool_approval(annotations: &ToolAnnotations) -> bool { - if annotations.destructive_hint == Some(true) { +fn requires_mcp_tool_approval(annotations: Option<&ToolAnnotations>) -> bool { + let destructive_hint = annotations.and_then(|annotations| annotations.destructive_hint); + if destructive_hint == Some(true) { return true; } - annotations.read_only_hint == Some(false) && annotations.open_world_hint == Some(true) + let read_only_hint = annotations + .and_then(|annotations| annotations.read_only_hint) + .unwrap_or(false); + if read_only_hint { + return false; + } + + destructive_hint.unwrap_or(true) + || annotations + .and_then(|annotations| annotations.open_world_hint) + .unwrap_or(true) } async fn notify_mcp_tool_call_skip( diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 0ac37f9ce8..9d7d1c3a41 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -64,19 +64,30 @@ fn prompt_options( #[test] fn approval_required_when_read_only_false_and_destructive() { let annotations = annotations(Some(false), Some(true), None); - assert_eq!(requires_mcp_tool_approval(&annotations), true); + assert_eq!(requires_mcp_tool_approval(Some(&annotations)), true); } #[test] fn approval_required_when_read_only_false_and_open_world() { let annotations = annotations(Some(false), None, Some(true)); - assert_eq!(requires_mcp_tool_approval(&annotations), true); + assert_eq!(requires_mcp_tool_approval(Some(&annotations)), true); } #[test] fn approval_required_when_destructive_even_if_read_only_true() { let annotations = annotations(Some(true), Some(true), Some(true)); - assert_eq!(requires_mcp_tool_approval(&annotations), true); + assert_eq!(requires_mcp_tool_approval(Some(&annotations)), true); +} + +#[test] +fn approval_required_when_annotations_are_absent() { + assert_eq!(requires_mcp_tool_approval(None), true); +} + +#[test] +fn approval_not_required_when_read_only_and_other_hints_are_absent() { + let annotations = annotations(Some(true), None, None); + assert_eq!(requires_mcp_tool_approval(Some(&annotations)), false); } #[test] @@ -1067,6 +1078,75 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_for_model() { ); } +#[tokio::test] +async fn approve_mode_blocks_when_arc_returns_interrupt_without_annotations() { + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/safety/arc")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "outcome": "steer-model", + "short_reason": "needs approval", + "rationale": "high-risk action", + "risk_score": 96, + "risk_level": "critical", + "evidence": [{ + "message": "dangerous_tool", + "why": "high-risk action", + }], + }))) + .expect(1) + .mount(&server) + .await; + + let (session, mut turn_context) = make_session_and_context().await; + turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth( + crate::CodexAuth::create_dummy_chatgpt_auth_for_testing(), + )); + let mut config = (*turn_context.config).clone(); + config.chatgpt_base_url = server.uri(); + turn_context.config = Arc::new(config); + + let session = Arc::new(session); + let turn_context = Arc::new(turn_context); + let invocation = McpInvocation { + server: CODEX_APPS_MCP_SERVER_NAME.to_string(), + tool: "dangerous_tool".to_string(), + arguments: Some(serde_json::json!({ "id": 1 })), + }; + let metadata = McpToolApprovalMetadata { + annotations: None, + connector_id: Some("calendar".to_string()), + connector_name: Some("Calendar".to_string()), + connector_description: Some("Manage events".to_string()), + tool_title: Some("Dangerous Tool".to_string()), + tool_description: Some("Performs a risky action.".to_string()), + codex_apps_meta: None, + }; + + let decision = maybe_request_mcp_tool_approval( + &session, + &turn_context, + "call-3", + &invocation, + Some(&metadata), + AppToolApproval::Approve, + ) + .await; + + assert_eq!( + decision, + Some(McpToolApprovalDecision::BlockedBySafetyMonitor( + "Tool call was cancelled because of safety risks: high-risk action".to_string(), + )) + ); +} + #[tokio::test] async fn full_access_auto_mode_blocks_when_arc_returns_interrupt_for_model() { use wiremock::Mock; diff --git a/codex-rs/core/tests/common/apps_test_server.rs b/codex-rs/core/tests/common/apps_test_server.rs index 450a170b2a..da5499c573 100644 --- a/codex-rs/core/tests/common/apps_test_server.rs +++ b/codex-rs/core/tests/common/apps_test_server.rs @@ -185,6 +185,11 @@ impl Respond for CodexAppsJsonRpcResponder { { "name": "calendar_create_event", "description": "Create a calendar event.", + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "openWorldHint": false + }, "inputSchema": { "type": "object", "properties": { @@ -209,6 +214,9 @@ impl Respond for CodexAppsJsonRpcResponder { { "name": "calendar_list_events", "description": "List calendar events.", + "annotations": { + "readOnlyHint": true + }, "inputSchema": { "type": "object", "properties": { @@ -241,6 +249,9 @@ impl Respond for CodexAppsJsonRpcResponder { tools.push(json!({ "name": format!("calendar_timezone_option_{index}"), "description": format!("Read timezone option {index}."), + "annotations": { + "readOnlyHint": true + }, "inputSchema": { "type": "object", "properties": { diff --git a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs index cd08307767..d9202babae 100644 --- a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs @@ -22,6 +22,7 @@ use rmcp::model::ResourceTemplate; use rmcp::model::ServerCapabilities; use rmcp::model::ServerInfo; use rmcp::model::Tool; +use rmcp::model::ToolAnnotations; use serde::Deserialize; use serde_json::json; use tokio::task; @@ -85,11 +86,13 @@ impl TestToolServer { })) .expect("echo tool schema should deserialize"); - Tool::new( + let mut tool = Tool::new( Cow::Borrowed(name), Cow::Borrowed(description), Arc::new(schema), - ) + ); + tool.annotations = Some(ToolAnnotations::new().read_only(true)); + tool } fn image_tool() -> Tool { @@ -101,11 +104,13 @@ impl TestToolServer { })) .expect("image tool schema should deserialize"); - Tool::new( + let mut tool = Tool::new( Cow::Borrowed("image"), Cow::Borrowed("Return a single image content block."), Arc::new(schema), - ) + ); + tool.annotations = Some(ToolAnnotations::new().read_only(true)); + tool } /// Tool intended for manual testing of Codex TUI rendering for MCP image tool results. @@ -154,13 +159,15 @@ impl TestToolServer { })) .expect("image_scenario tool schema should deserialize"); - Tool::new( + let mut tool = Tool::new( Cow::Borrowed("image_scenario"), Cow::Borrowed( "Return content blocks for manual testing of MCP image rendering scenarios.", ), Arc::new(schema), - ) + ); + tool.annotations = Some(ToolAnnotations::new().read_only(true)); + tool } fn memo_resource() -> Resource { diff --git a/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs b/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs index 284e1194c3..50ea06c7fa 100644 --- a/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs @@ -38,6 +38,7 @@ use rmcp::model::ResourceTemplate; use rmcp::model::ServerCapabilities; use rmcp::model::ServerInfo; use rmcp::model::Tool; +use rmcp::model::ToolAnnotations; use rmcp::transport::StreamableHttpServerConfig; use rmcp::transport::StreamableHttpService; use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; @@ -84,11 +85,13 @@ impl TestToolServer { })) .expect("echo tool schema should deserialize"); - Tool::new( + let mut tool = Tool::new( Cow::Borrowed("echo"), Cow::Borrowed("Echo back the provided message and include environment data."), Arc::new(schema), - ) + ); + tool.annotations = Some(ToolAnnotations::new().read_only(true)); + tool } fn memo_resource() -> Resource {