From ae1251b77e4501c4c6fc28e5e90867f2d324911a Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Mon, 23 Mar 2026 21:30:25 -0700 Subject: [PATCH 1/6] update --- .../schema/json/ClientRequest.json | 39 ++++ .../codex_app_server_protocol.schemas.json | 58 +++++ .../codex_app_server_protocol.v2.schemas.json | 58 +++++ ...ExperimentalFeatureOverridesSetParams.json | 17 ++ ...perimentalFeatureOverridesSetResponse.json | 17 ++ .../schema/typescript/ClientRequest.ts | 3 +- .../ExperimentalFeatureOverridesSetParams.ts | 12 ++ ...ExperimentalFeatureOverridesSetResponse.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 | 143 +++++++++++- codex-rs/app-server/src/config_api.rs | 203 +++++++++++++++++- codex-rs/app-server/src/message_processor.rs | 37 ++++ .../app-server/tests/common/mcp_process.rs | 10 + .../suite/v2/experimental_feature_list.rs | 112 ++++++++++ 17 files changed, 722 insertions(+), 22 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureOverridesSetParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureOverridesSetResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureOverridesSetParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureOverridesSetResponse.ts diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 903b26b80b05..8b29b005e1bc 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -545,6 +545,21 @@ }, "type": "object" }, + "ExperimentalFeatureOverridesSetParams": { + "properties": { + "overrides": { + "additionalProperties": { + "type": "boolean" + }, + "description": "Process-wide feature flag overrides keyed by canonical feature name.\n\nThis replaces the current in-memory override set. Send an empty map to clear all overrides.", + "type": "object" + } + }, + "required": [ + "overrides" + ], + "type": "object" + }, "ExternalAgentConfigDetectParams": { "properties": { "cwds": { @@ -4216,6 +4231,30 @@ "title": "ExperimentalFeature/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "experimentalFeature/overrides/set" + ], + "title": "ExperimentalFeature/overrides/setRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ExperimentalFeatureOverridesSetParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ExperimentalFeature/overrides/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 a644549325a5..48a14fc88991 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 @@ -1099,6 +1099,30 @@ "title": "ExperimentalFeature/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "experimentalFeature/overrides/set" + ], + "title": "ExperimentalFeature/overrides/setRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ExperimentalFeatureOverridesSetParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ExperimentalFeature/overrides/setRequest", + "type": "object" + }, { "properties": { "id": { @@ -7214,6 +7238,40 @@ "title": "ExperimentalFeatureListResponse", "type": "object" }, + "ExperimentalFeatureOverridesSetParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "overrides": { + "additionalProperties": { + "type": "boolean" + }, + "description": "Process-wide feature flag overrides keyed by canonical feature name.\n\nThis replaces the current in-memory override set. Send an empty map to clear all overrides.", + "type": "object" + } + }, + "required": [ + "overrides" + ], + "title": "ExperimentalFeatureOverridesSetParams", + "type": "object" + }, + "ExperimentalFeatureOverridesSetResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "overrides": { + "additionalProperties": { + "type": "boolean" + }, + "description": "The active process-wide feature flag overrides after the update.", + "type": "object" + } + }, + "required": [ + "overrides" + ], + "title": "ExperimentalFeatureOverridesSetResponse", + "type": "object" + }, "ExperimentalFeatureStage": { "oneOf": [ { 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 71e60345296d..706b9e28b62d 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 @@ -1633,6 +1633,30 @@ "title": "ExperimentalFeature/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "experimentalFeature/overrides/set" + ], + "title": "ExperimentalFeature/overrides/setRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ExperimentalFeatureOverridesSetParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ExperimentalFeature/overrides/setRequest", + "type": "object" + }, { "properties": { "id": { @@ -3807,6 +3831,40 @@ "title": "ExperimentalFeatureListResponse", "type": "object" }, + "ExperimentalFeatureOverridesSetParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "overrides": { + "additionalProperties": { + "type": "boolean" + }, + "description": "Process-wide feature flag overrides keyed by canonical feature name.\n\nThis replaces the current in-memory override set. Send an empty map to clear all overrides.", + "type": "object" + } + }, + "required": [ + "overrides" + ], + "title": "ExperimentalFeatureOverridesSetParams", + "type": "object" + }, + "ExperimentalFeatureOverridesSetResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "overrides": { + "additionalProperties": { + "type": "boolean" + }, + "description": "The active process-wide feature flag overrides after the update.", + "type": "object" + } + }, + "required": [ + "overrides" + ], + "title": "ExperimentalFeatureOverridesSetResponse", + "type": "object" + }, "ExperimentalFeatureStage": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureOverridesSetParams.json b/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureOverridesSetParams.json new file mode 100644 index 000000000000..9d18f84697d1 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureOverridesSetParams.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "overrides": { + "additionalProperties": { + "type": "boolean" + }, + "description": "Process-wide feature flag overrides keyed by canonical feature name.\n\nThis replaces the current in-memory override set. Send an empty map to clear all overrides.", + "type": "object" + } + }, + "required": [ + "overrides" + ], + "title": "ExperimentalFeatureOverridesSetParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureOverridesSetResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureOverridesSetResponse.json new file mode 100644 index 000000000000..4e39ce6f753a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureOverridesSetResponse.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "overrides": { + "additionalProperties": { + "type": "boolean" + }, + "description": "The active process-wide feature flag overrides after the update.", + "type": "object" + } + }, + "required": [ + "overrides" + ], + "title": "ExperimentalFeatureOverridesSetResponse", + "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 5e03a26ca2db..977e897c2d9d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -17,6 +17,7 @@ import type { ConfigBatchWriteParams } from "./v2/ConfigBatchWriteParams"; import type { ConfigReadParams } from "./v2/ConfigReadParams"; import type { ConfigValueWriteParams } from "./v2/ConfigValueWriteParams"; import type { ExperimentalFeatureListParams } from "./v2/ExperimentalFeatureListParams"; +import type { ExperimentalFeatureOverridesSetParams } from "./v2/ExperimentalFeatureOverridesSetParams"; import type { ExternalAgentConfigDetectParams } from "./v2/ExternalAgentConfigDetectParams"; import type { ExternalAgentConfigImportParams } from "./v2/ExternalAgentConfigImportParams"; import type { FeedbackUploadParams } from "./v2/FeedbackUploadParams"; @@ -61,4 +62,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": "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": "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/overrides/set", id: RequestId, params: ExperimentalFeatureOverridesSetParams, } | { "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/ExperimentalFeatureOverridesSetParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureOverridesSetParams.ts new file mode 100644 index 000000000000..1bf877d589f0 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureOverridesSetParams.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 ExperimentalFeatureOverridesSetParams = { +/** + * Process-wide feature flag overrides keyed by canonical feature name. + * + * This replaces the current in-memory override set. Send an empty map to + * clear all overrides. + */ +overrides: { [key in string]?: boolean }, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureOverridesSetResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureOverridesSetResponse.ts new file mode 100644 index 000000000000..01f78068be45 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureOverridesSetResponse.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 ExperimentalFeatureOverridesSetResponse = { +/** + * The active process-wide feature flag overrides after the update. + */ +overrides: { [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 c649aec06af6..16d85a087403 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -83,6 +83,8 @@ export type { ExecPolicyAmendment } from "./ExecPolicyAmendment"; export type { ExperimentalFeature } from "./ExperimentalFeature"; export type { ExperimentalFeatureListParams } from "./ExperimentalFeatureListParams"; export type { ExperimentalFeatureListResponse } from "./ExperimentalFeatureListResponse"; +export type { ExperimentalFeatureOverridesSetParams } from "./ExperimentalFeatureOverridesSetParams"; +export type { ExperimentalFeatureOverridesSetResponse } from "./ExperimentalFeatureOverridesSetResponse"; export type { ExperimentalFeatureStage } from "./ExperimentalFeatureStage"; export type { ExternalAgentConfigDetectParams } from "./ExternalAgentConfigDetectParams"; export type { ExternalAgentConfigDetectResponse } from "./ExternalAgentConfigDetectResponse"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 56897566d94a..8c06fc9070e2 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -394,6 +394,10 @@ client_request_definitions! { params: v2::ExperimentalFeatureListParams, response: v2::ExperimentalFeatureListResponse, }, + ExperimentalFeatureOverridesSet => "experimentalFeature/overrides/set" { + params: v2::ExperimentalFeatureOverridesSetParams, + response: v2::ExperimentalFeatureOverridesSetResponse, + }, #[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 3e30f8132379..09c1bf8e9155 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 ExperimentalFeatureOverridesSetParams { + /// Process-wide feature flag overrides keyed by canonical feature name. + /// + /// This replaces the current in-memory override set. Send an empty map to + /// clear all overrides. + pub overrides: std::collections::BTreeMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExperimentalFeatureOverridesSetResponse { + /// The active process-wide feature flag overrides after the update. + pub overrides: 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 ace7f2bd4333..3e19149c2efe 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -161,6 +161,7 @@ Example with notification opt-out: - `fs/copy` — copy between absolute paths; directory copies require `recursive: true`. - `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/overrides/set` — update the in-memory process-wide feature flag override map. For each feature, explicit `config.toml` values and cloud requirement pins win over these runtime overrides, and runtime overrides win over startup CLI feature flags until cleared or the server restarts. - `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 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 947a49ff2f45..5edf81a205d8 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1,6 +1,10 @@ use crate::bespoke_event_handling::apply_bespoke_event_handling; use crate::command_exec::CommandExecManager; use crate::command_exec::StartCommandExecParams; +use crate::config_api::has_feature_cli_overrides; +use crate::config_api::merge_feature_cli_overrides; +use crate::config_api::non_feature_cli_overrides; +use crate::config_api::protected_feature_keys; 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 +287,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 +376,8 @@ pub(crate) struct CodexMessageProcessor { outgoing: Arc, arg0_paths: Arg0DispatchPaths, config: Arc, - cli_overrides: Vec<(String, TomlValue)>, + cli_overrides: Arc>>, + feature_flag_overrides: Arc>>, cloud_requirements: Arc>, active_login: Arc>>, pending_thread_unloads: Arc>>, @@ -415,7 +421,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) feature_flag_overrides: Arc>>, pub(crate) cloud_requirements: Arc>, pub(crate) feedback: CodexFeedback, pub(crate) log_db: Option, @@ -479,6 +486,7 @@ impl CodexMessageProcessor { arg0_paths, config, cli_overrides, + feature_flag_overrides, cloud_requirements, feedback, log_db, @@ -490,6 +498,7 @@ impl CodexMessageProcessor { arg0_paths, config, cli_overrides, + feature_flag_overrides, cloud_requirements, active_login: Arc::new(Mutex::new(None)), pending_thread_unloads: Arc::new(Mutex::new(HashSet::new())), @@ -510,7 +519,10 @@ 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.effective_cli_overrides(fallback_cwd.clone(), cloud_requirements.clone()) + .await?, + ) .fallback_cwd(fallback_cwd) .cloud_requirements(cloud_requirements) .build() @@ -532,6 +544,52 @@ 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_feature_flag_overrides(&self) -> BTreeMap { + self.feature_flag_overrides + .read() + .map(|guard| guard.clone()) + .unwrap_or_default() + } + + async fn effective_cli_overrides( + &self, + fallback_cwd: Option, + cloud_requirements: CloudRequirementsLoader, + ) -> Result, JSONRPCErrorError> { + let cli_overrides = self.current_cli_overrides(); + let feature_flag_overrides = self.current_feature_flag_overrides(); + if !has_feature_cli_overrides(&cli_overrides) && feature_flag_overrides.is_empty() { + return Ok(cli_overrides); + } + + let config = codex_core::config::ConfigBuilder::default() + .codex_home(self.config.codex_home.clone()) + .cli_overrides(non_feature_cli_overrides(&cli_overrides)) + .fallback_cwd(fallback_cwd) + .cloud_requirements(cloud_requirements) + .build() + .await + .map_err(|err| JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to resolve feature override precedence: {err}"), + data: None, + })?; + let protected_features = protected_feature_keys(&config.config_layer_stack); + + Ok(merge_feature_cli_overrides( + cli_overrides, + feature_flag_overrides, + &protected_features, + )) + } + /// 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( @@ -877,7 +935,8 @@ impl CodexMessageProcessor { } ClientRequest::ConfigRead { .. } | ClientRequest::ConfigValueWrite { .. } - | ClientRequest::ConfigBatchWrite { .. } => { + | ClientRequest::ConfigBatchWrite { .. } + | ClientRequest::ExperimentalFeatureOverridesSet { .. } => { warn!("Config request reached CodexMessageProcessor unexpectedly"); } ClientRequest::FsReadFile { .. } @@ -1076,7 +1135,21 @@ 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 = match self + .effective_cli_overrides( + /*fallback_cwd*/ None, + self.current_cloud_requirements(), + ) + .await + { + Ok(cli_overrides) => cli_overrides, + Err(err) => { + warn!( + "failed to resolve feature overrides for login completion: {err:?}" + ); + self.current_cli_overrides() + } + }; let auth_url = server.auth_url.clone(); tokio::spawn(async move { let (success, error_msg) = match tokio::time::timeout( @@ -1265,7 +1338,19 @@ impl CodexMessageProcessor { self.config.codex_home.clone(), ); sync_default_client_residency_requirement( - &self.cli_overrides, + &match self + .effective_cli_overrides( + /*fallback_cwd*/ None, + self.current_cloud_requirements(), + ) + .await + { + Ok(cli_overrides) => cli_overrides, + Err(err) => { + warn!("failed to resolve feature overrides for residency sync: {err:?}"); + self.current_cli_overrides() + } + }, self.cloud_requirements.as_ref(), ) .await; @@ -1870,8 +1955,17 @@ impl CodexMessageProcessor { personality, ); typesafe_overrides.ephemeral = ephemeral; - let cli_overrides = self.cli_overrides.clone(); let cloud_requirements = self.current_cloud_requirements(); + let cli_overrides = match self + .effective_cli_overrides(/*fallback_cwd*/ None, cloud_requirements.clone()) + .await + { + Ok(cli_overrides) => cli_overrides, + Err(err) => { + self.outgoing.send_error(request_id, err).await; + return; + } + }; let listener_task_context = ListenerTaskContext { thread_manager: Arc::clone(&self.thread_manager), thread_state_manager: self.thread_state_manager.clone(), @@ -3468,8 +3562,18 @@ impl CodexMessageProcessor { // Derive a Config using the same logic as new conversation, honoring overrides if provided. let cloud_requirements = self.current_cloud_requirements(); + let effective_cli_overrides = match self + .effective_cli_overrides(history_cwd.clone(), cloud_requirements.clone()) + .await + { + Ok(cli_overrides) => cli_overrides, + Err(err) => { + self.outgoing.send_error(request_id, err).await; + return; + } + }; let config = match derive_config_for_cwd( - &self.cli_overrides, + &effective_cli_overrides, request_overrides, typesafe_overrides, history_cwd, @@ -4010,8 +4114,18 @@ 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 effective_cli_overrides = match self + .effective_cli_overrides(history_cwd.clone(), cloud_requirements.clone()) + .await + { + Ok(cli_overrides) => cli_overrides, + Err(err) => { + self.outgoing.send_error(request_id, err).await; + return; + } + }; let config = match derive_config_for_cwd( - &self.cli_overrides, + &effective_cli_overrides, request_overrides, typesafe_overrides, history_cwd, @@ -7225,12 +7339,21 @@ 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 = match self + .effective_cli_overrides(Some(command_cwd.clone()), cloud_requirements.clone()) + .await + { + Ok(cli_overrides) => cli_overrides, + Err(err) => { + warn!("failed to resolve feature overrides for windows sandbox setup: {err:?}"); + self.current_cli_overrides() + } + }; let outgoing = Arc::clone(&self.outgoing); let connection_id = request_id.connection_id; diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index bc82e9152e8a..31c398607bce 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -9,6 +9,8 @@ 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::ExperimentalFeatureOverridesSetParams; +use codex_app_server_protocol::ExperimentalFeatureOverridesSetResponse; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::NetworkRequirements; use codex_app_server_protocol::SandboxMode; @@ -24,9 +26,13 @@ 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; @@ -56,7 +62,8 @@ impl UserConfigReloader for ThreadManager { #[derive(Clone)] pub(crate) struct ConfigApi { codex_home: PathBuf, - cli_overrides: Vec<(String, TomlValue)>, + cli_overrides: Arc>>, + feature_flag_overrides: Arc>>, loader_overrides: LoaderOverrides, cloud_requirements: Arc>, user_config_reloader: Arc, @@ -66,7 +73,8 @@ pub(crate) struct ConfigApi { impl ConfigApi { pub(crate) fn new( codex_home: PathBuf, - cli_overrides: Vec<(String, TomlValue)>, + cli_overrides: Arc>>, + feature_flag_overrides: Arc>>, loader_overrides: LoaderOverrides, cloud_requirements: Arc>, user_config_reloader: Arc, @@ -75,6 +83,7 @@ impl ConfigApi { Self { codex_home, cli_overrides, + feature_flag_overrides, loader_overrides, cloud_requirements, user_config_reloader, @@ -82,32 +91,91 @@ impl ConfigApi { } } - fn config_service(&self) -> ConfigService { + async fn config_service( + &self, + fallback_cwd: Option, + ) -> Result { let cloud_requirements = self .cloud_requirements .read() .map(|guard| guard.clone()) .unwrap_or_default(); - ConfigService::new( + Ok(ConfigService::new( self.codex_home.clone(), - self.cli_overrides.clone(), + self.effective_cli_overrides(fallback_cwd, cloud_requirements.clone()) + .await?, self.loader_overrides.clone(), cloud_requirements, - ) + )) + } + + fn current_cli_overrides(&self) -> Vec<(String, TomlValue)> { + self.cli_overrides + .read() + .map(|guard| guard.clone()) + .unwrap_or_default() + } + + fn current_feature_flag_overrides(&self) -> BTreeMap { + self.feature_flag_overrides + .read() + .map(|guard| guard.clone()) + .unwrap_or_default() + } + + async fn effective_cli_overrides( + &self, + fallback_cwd: Option, + cloud_requirements: CloudRequirementsLoader, + ) -> Result, JSONRPCErrorError> { + let cli_overrides = self.current_cli_overrides(); + let feature_flag_overrides = self.current_feature_flag_overrides(); + if !has_feature_cli_overrides(&cli_overrides) && feature_flag_overrides.is_empty() { + return Ok(cli_overrides); + } + + let mut config = codex_core::config::ConfigBuilder::default() + .codex_home(self.codex_home.clone()) + .cli_overrides(non_feature_cli_overrides(&cli_overrides)) + .loader_overrides(self.loader_overrides.clone()) + .fallback_cwd(fallback_cwd) + .cloud_requirements(cloud_requirements) + .build() + .await + .map_err(|err| JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to resolve feature override precedence: {err}"), + data: None, + })?; + config.codex_linux_sandbox_exe = None; + config.main_execve_wrapper_exe = None; + let protected_features = protected_feature_keys(&config.config_layer_stack); + + Ok(merge_feature_cli_overrides( + cli_overrides, + feature_flag_overrides, + &protected_features, + )) } 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); + self.config_service(fallback_cwd) + .await? + .read(params) + .await + .map_err(map_error) } pub(crate) async fn config_requirements_read( &self, ) -> Result { let requirements = self - .config_service() + .config_service(/*fallback_cwd*/ None) + .await? .read_requirements() .await .map_err(map_error)? @@ -123,7 +191,8 @@ impl ConfigApi { let pending_changes = collect_plugin_enabled_candidates([(¶ms.key_path, ¶ms.value)].into_iter()); let response = self - .config_service() + .config_service(/*fallback_cwd*/ None) + .await? .write_value(params) .await .map_err(map_error)?; @@ -143,7 +212,8 @@ impl ConfigApi { .map(|edit| (&edit.key_path, &edit.value)), ); let response = self - .config_service() + .config_service(/*fallback_cwd*/ None) + .await? .batch_write(params) .await .map_err(map_error)?; @@ -154,6 +224,54 @@ impl ConfigApi { Ok(response) } + pub(crate) async fn set_experimental_feature_overrides( + &self, + params: ExperimentalFeatureOverridesSetParams, + ) -> Result { + let ExperimentalFeatureOverridesSetParams { overrides } = params; + for key in overrides.keys() { + if canonical_feature_for_key(key).is_some() { + continue; + } + + let message = if let Some(feature) = feature_for_key(key) { + format!( + "invalid feature override `{key}`: use canonical feature key `{}`", + feature.key() + ) + } else { + format!("invalid feature override `{key}`") + }; + return Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message, + data: None, + }); + } + + if let Ok(mut current) = self.feature_flag_overrides.write() { + *current = overrides.clone(); + } else { + return Err(JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: "failed to update feature flag overrides".to_string(), + data: None, + }); + } + + self.config_service(/*fallback_cwd*/ None) + .await? + .read(ConfigReadParams { + include_layers: false, + cwd: None, + }) + .await + .map_err(map_error)?; + self.user_config_reloader.reload_user_config().await; + + Ok(ExperimentalFeatureOverridesSetResponse { overrides }) + } + 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 +288,68 @@ impl ConfigApi { } } +pub(crate) fn has_feature_cli_overrides(cli_overrides: &[(String, TomlValue)]) -> bool { + cli_overrides + .iter() + .any(|(key, _)| key.starts_with("features.")) +} + +pub(crate) fn non_feature_cli_overrides( + cli_overrides: &[(String, TomlValue)], +) -> Vec<(String, TomlValue)> { + cli_overrides + .iter() + .filter(|(key, _)| !key.starts_with("features.")) + .cloned() + .collect() +} + +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 merge_feature_cli_overrides( + cli_overrides: Vec<(String, TomlValue)>, + feature_flag_overrides: BTreeMap, + protected_features: &BTreeSet, +) -> Vec<(String, TomlValue)> { + let mut merged = cli_overrides + .into_iter() + .filter(|(key, _)| { + let Some(feature) = key.strip_prefix("features.") else { + return true; + }; + !protected_features.contains(feature) && !feature_flag_overrides.contains_key(feature) + }) + .collect::>(); + + merged.extend( + feature_flag_overrides + .into_iter() + .filter(|(feature, _)| !protected_features.contains(feature)) + .map(|(name, enabled)| (format!("features.{name}"), TomlValue::Boolean(enabled))), + ); + + merged +} + fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigRequirements { ConfigRequirements { allowed_approval_policies: requirements.allowed_approval_policies.map(|policies| { @@ -406,7 +586,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 287fe797551b..3bb0d6d94d41 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; @@ -28,6 +29,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::ExperimentalFeatureOverridesSetParams; use codex_app_server_protocol::ExternalAgentConfigDetectParams; use codex_app_server_protocol::ExternalAgentConfigImportParams; use codex_app_server_protocol::FsCopyParams; @@ -232,6 +234,8 @@ impl MessageProcessor { .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); + let cli_overrides = Arc::new(RwLock::new(cli_overrides)); + let feature_flag_overrides = 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(), @@ -240,6 +244,7 @@ impl MessageProcessor { arg0_paths, config: Arc::clone(&config), cli_overrides: cli_overrides.clone(), + feature_flag_overrides: feature_flag_overrides.clone(), cloud_requirements: cloud_requirements.clone(), feedback, log_db, @@ -252,6 +257,7 @@ impl MessageProcessor { let config_api = ConfigApi::new( config.codex_home.clone(), cli_overrides, + feature_flag_overrides, loader_overrides, cloud_requirements, thread_manager, @@ -686,6 +692,16 @@ impl MessageProcessor { ) .await; } + ClientRequest::ExperimentalFeatureOverridesSet { request_id, params } => { + self.handle_experimental_feature_overrides_set( + ConnectionRequestId { + connection_id, + request_id, + }, + params, + ) + .await; + } ClientRequest::ConfigRequirementsRead { request_id, params: _, @@ -824,6 +840,27 @@ impl MessageProcessor { } } + async fn handle_experimental_feature_overrides_set( + &self, + request_id: ConnectionRequestId, + params: ExperimentalFeatureOverridesSetParams, + ) { + match self + .config_api + .set_experimental_feature_overrides(params) + .await + { + Ok(response) => { + self.codex_message_processor.clear_plugin_related_caches(); + self.codex_message_processor + .maybe_start_plugin_startup_tasks_for_latest_config() + .await; + self.outgoing.send_response(request_id, response).await; + } + Err(error) => self.outgoing.send_error(request_id, error).await, + } + } + async fn handle_config_requirements_read(&self, request_id: ConnectionRequestId) { match self.config_api.config_requirements_read().await { Ok(response) => self.outgoing.send_response(request_id, response).await, diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 1a132ccee116..f95b42ec5376 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -463,6 +463,16 @@ impl McpProcess { self.send_request("experimentalFeature/list", params).await } + /// Send an `experimentalFeature/overrides/set` JSON-RPC request. + pub async fn send_experimental_feature_overrides_set_request( + &mut self, + params: codex_app_server_protocol::ExperimentalFeatureOverridesSetParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("experimentalFeature/overrides/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 7ff5f6fe3996..51369e5d2984 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,9 +3,13 @@ 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::ExperimentalFeatureListParams; use codex_app_server_protocol::ExperimentalFeatureListResponse; +use codex_app_server_protocol::ExperimentalFeatureOverridesSetParams; +use codex_app_server_protocol::ExperimentalFeatureOverridesSetResponse; use codex_app_server_protocol::ExperimentalFeatureStage; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -13,6 +17,8 @@ use codex_core::config::ConfigBuilder; use codex_features::FEATURES; use codex_features::Stage; use pretty_assertions::assert_eq; +use serde_json::json; +use std::collections::BTreeMap; use tempfile::TempDir; use tokio::time::timeout; @@ -82,3 +88,109 @@ async fn experimental_feature_list_returns_feature_metadata_with_stage() -> Resu assert_eq!(actual, expected); Ok(()) } + +#[tokio::test] +async fn experimental_feature_overrides_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 request_id = mcp + .send_experimental_feature_overrides_set_request(ExperimentalFeatureOverridesSetParams { + overrides: BTreeMap::from([("apps".to_string(), true)]), + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let actual = to_response::(response)?; + assert_eq!( + actual, + ExperimentalFeatureOverridesSetResponse { + overrides: BTreeMap::from([("apps".to_string(), true)]), + } + ); + + for cwd in [None, Some(project_cwd.display().to_string())] { + let request_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: false, + cwd, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ConfigReadResponse { config, .. } = to_response(response)?; + + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("apps")), + Some(&json!(true)) + ); + } + + Ok(()) +} + +#[tokio::test] +async fn experimental_feature_overrides_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 request_id = mcp + .send_experimental_feature_overrides_set_request(ExperimentalFeatureOverridesSetParams { + overrides: BTreeMap::from([("apps".to_string(), true)]), + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let actual = to_response::(response)?; + assert_eq!( + actual, + ExperimentalFeatureOverridesSetResponse { + overrides: BTreeMap::from([("apps".to_string(), true)]), + } + ); + + let request_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: false, + cwd: None, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ConfigReadResponse { config, .. } = to_response(response)?; + + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("apps")), + Some(&json!(false)) + ); + + Ok(()) +} From 86ebb353f61632f103f02770d61ddb87c468d4ec Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Mon, 23 Mar 2026 22:07:09 -0700 Subject: [PATCH 2/6] update --- .../app-server/src/codex_message_processor.rs | 143 ++++++++++-------- 1 file changed, 80 insertions(+), 63 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 29e8912f9a82..fd9facb9d79d 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -591,6 +591,42 @@ impl CodexMessageProcessor { )) } + async fn effective_cli_overrides_or_current( + &self, + fallback_cwd: Option, + cloud_requirements: CloudRequirementsLoader, + context: &str, + ) -> Vec<(String, TomlValue)> { + match self + .effective_cli_overrides(fallback_cwd, cloud_requirements) + .await + { + Ok(cli_overrides) => cli_overrides, + Err(err) => { + warn!("failed to resolve feature overrides for {context}: {err:?}"); + self.current_cli_overrides() + } + } + } + + async fn effective_cli_overrides_or_send_error( + &self, + request_id: &ConnectionRequestId, + fallback_cwd: Option, + cloud_requirements: CloudRequirementsLoader, + ) -> Option> { + match self + .effective_cli_overrides(fallback_cwd, cloud_requirements) + .await + { + Ok(cli_overrides) => Some(cli_overrides), + Err(err) => { + self.outgoing.send_error(request_id.clone(), err).await; + None + } + } + } + /// 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( @@ -1136,21 +1172,13 @@ 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 = match self - .effective_cli_overrides( + let cli_overrides = self + .effective_cli_overrides_or_current( /*fallback_cwd*/ None, self.current_cloud_requirements(), + "login completion", ) - .await - { - Ok(cli_overrides) => cli_overrides, - Err(err) => { - warn!( - "failed to resolve feature overrides for login completion: {err:?}" - ); - self.current_cli_overrides() - } - }; + .await; let auth_url = server.auth_url.clone(); tokio::spawn(async move { let (success, error_msg) = match tokio::time::timeout( @@ -1338,23 +1366,15 @@ impl CodexMessageProcessor { self.config.chatgpt_base_url.clone(), self.config.codex_home.clone(), ); - sync_default_client_residency_requirement( - &match self - .effective_cli_overrides( - /*fallback_cwd*/ None, - self.current_cloud_requirements(), - ) - .await - { - Ok(cli_overrides) => cli_overrides, - Err(err) => { - warn!("failed to resolve feature overrides for residency sync: {err:?}"); - self.current_cli_overrides() - } - }, - self.cloud_requirements.as_ref(), - ) - .await; + let cli_overrides = self + .effective_cli_overrides_or_current( + /*fallback_cwd*/ None, + self.current_cloud_requirements(), + "residency sync", + ) + .await; + sync_default_client_residency_requirement(&cli_overrides, self.cloud_requirements.as_ref()) + .await; self.outgoing .send_response(request_id, LoginAccountResponse::ChatgptAuthTokens {}) @@ -1957,15 +1977,15 @@ impl CodexMessageProcessor { ); typesafe_overrides.ephemeral = ephemeral; let cloud_requirements = self.current_cloud_requirements(); - let cli_overrides = match self - .effective_cli_overrides(/*fallback_cwd*/ None, cloud_requirements.clone()) + let Some(cli_overrides) = self + .effective_cli_overrides_or_send_error( + &request_id, + typesafe_overrides.cwd.clone(), + cloud_requirements.clone(), + ) .await - { - Ok(cli_overrides) => cli_overrides, - Err(err) => { - self.outgoing.send_error(request_id, err).await; - return; - } + else { + return; }; let listener_task_context = ListenerTaskContext { thread_manager: Arc::clone(&self.thread_manager), @@ -3563,15 +3583,15 @@ impl CodexMessageProcessor { // Derive a Config using the same logic as new conversation, honoring overrides if provided. let cloud_requirements = self.current_cloud_requirements(); - let effective_cli_overrides = match self - .effective_cli_overrides(history_cwd.clone(), cloud_requirements.clone()) + let Some(effective_cli_overrides) = self + .effective_cli_overrides_or_send_error( + &request_id, + history_cwd.clone(), + cloud_requirements.clone(), + ) .await - { - Ok(cli_overrides) => cli_overrides, - Err(err) => { - self.outgoing.send_error(request_id, err).await; - return; - } + else { + return; }; let config = match derive_config_for_cwd( &effective_cli_overrides, @@ -4115,15 +4135,15 @@ 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 effective_cli_overrides = match self - .effective_cli_overrides(history_cwd.clone(), cloud_requirements.clone()) + let Some(effective_cli_overrides) = self + .effective_cli_overrides_or_send_error( + &request_id, + history_cwd.clone(), + cloud_requirements.clone(), + ) .await - { - Ok(cli_overrides) => cli_overrides, - Err(err) => { - self.outgoing.send_error(request_id, err).await; - return; - } + else { + return; }; let config = match derive_config_for_cwd( &effective_cli_overrides, @@ -7345,16 +7365,13 @@ impl CodexMessageProcessor { .cwd .map(PathBuf::from) .unwrap_or_else(|| config.cwd.clone()); - let cli_overrides = match self - .effective_cli_overrides(Some(command_cwd.clone()), cloud_requirements.clone()) - .await - { - Ok(cli_overrides) => cli_overrides, - Err(err) => { - warn!("failed to resolve feature overrides for windows sandbox setup: {err:?}"); - self.current_cli_overrides() - } - }; + let cli_overrides = self + .effective_cli_overrides_or_current( + Some(command_cwd.clone()), + cloud_requirements.clone(), + "windows sandbox setup", + ) + .await; let outgoing = Arc::clone(&self.outgoing); let connection_id = request_id.connection_id; From 6c1406ab6856136430158d40100e6209c7a2d1cf Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Mon, 23 Mar 2026 22:36:08 -0700 Subject: [PATCH 3/6] update --- .../app-server/src/codex_message_processor.rs | 51 +++++-------------- 1 file changed, 12 insertions(+), 39 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index fd9facb9d79d..79ed97bc7c1d 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -609,24 +609,6 @@ impl CodexMessageProcessor { } } - async fn effective_cli_overrides_or_send_error( - &self, - request_id: &ConnectionRequestId, - fallback_cwd: Option, - cloud_requirements: CloudRequirementsLoader, - ) -> Option> { - match self - .effective_cli_overrides(fallback_cwd, cloud_requirements) - .await - { - Ok(cli_overrides) => Some(cli_overrides), - Err(err) => { - self.outgoing.send_error(request_id.clone(), err).await; - None - } - } - } - /// 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( @@ -1977,16 +1959,13 @@ impl CodexMessageProcessor { ); typesafe_overrides.ephemeral = ephemeral; let cloud_requirements = self.current_cloud_requirements(); - let Some(cli_overrides) = self - .effective_cli_overrides_or_send_error( - &request_id, + let cli_overrides = self + .effective_cli_overrides_or_current( typesafe_overrides.cwd.clone(), cloud_requirements.clone(), + "thread start", ) - .await - else { - return; - }; + .await; let listener_task_context = ListenerTaskContext { thread_manager: Arc::clone(&self.thread_manager), thread_state_manager: self.thread_state_manager.clone(), @@ -3583,16 +3562,13 @@ impl CodexMessageProcessor { // Derive a Config using the same logic as new conversation, honoring overrides if provided. let cloud_requirements = self.current_cloud_requirements(); - let Some(effective_cli_overrides) = self - .effective_cli_overrides_or_send_error( - &request_id, + let effective_cli_overrides = self + .effective_cli_overrides_or_current( history_cwd.clone(), cloud_requirements.clone(), + "thread resume", ) - .await - else { - return; - }; + .await; let config = match derive_config_for_cwd( &effective_cli_overrides, request_overrides, @@ -4135,16 +4111,13 @@ 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 Some(effective_cli_overrides) = self - .effective_cli_overrides_or_send_error( - &request_id, + let effective_cli_overrides = self + .effective_cli_overrides_or_current( history_cwd.clone(), cloud_requirements.clone(), + "thread fork", ) - .await - else { - return; - }; + .await; let config = match derive_config_for_cwd( &effective_cli_overrides, request_overrides, From e617112d5da120bb17bcdb9826baa5168b99825f Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Mon, 23 Mar 2026 22:49:55 -0700 Subject: [PATCH 4/6] update --- codex-rs/app-server/src/config_api.rs | 15 ++- codex-rs/app-server/src/message_processor.rs | 32 ++++--- .../suite/v2/experimental_feature_list.rs | 94 ++++++++----------- 3 files changed, 64 insertions(+), 77 deletions(-) diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index 31c398607bce..6b5ebbd742b9 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -134,7 +134,7 @@ impl ConfigApi { return Ok(cli_overrides); } - let mut config = codex_core::config::ConfigBuilder::default() + let config = codex_core::config::ConfigBuilder::default() .codex_home(self.codex_home.clone()) .cli_overrides(non_feature_cli_overrides(&cli_overrides)) .loader_overrides(self.loader_overrides.clone()) @@ -147,8 +147,6 @@ impl ConfigApi { message: format!("failed to resolve feature override precedence: {err}"), data: None, })?; - config.codex_linux_sandbox_exe = None; - config.main_execve_wrapper_exe = None; let protected_features = protected_feature_keys(&config.config_layer_stack); Ok(merge_feature_cli_overrides( @@ -249,15 +247,14 @@ impl ConfigApi { }); } - if let Ok(mut current) = self.feature_flag_overrides.write() { - *current = overrides.clone(); - } else { - return Err(JSONRPCErrorError { + *self + .feature_flag_overrides + .write() + .map_err(|_| JSONRPCErrorError { code: INTERNAL_ERROR_CODE, message: "failed to update feature flag overrides".to_string(), data: None, - }); - } + })? = overrides.clone(); self.config_service(/*fallback_cwd*/ None) .await? diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 3bb0d6d94d41..a45f5ecb7cfd 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -828,16 +828,8 @@ impl MessageProcessor { request_id: ConnectionRequestId, params: ConfigBatchWriteParams, ) { - match self.config_api.batch_write(params).await { - Ok(response) => { - self.codex_message_processor.clear_plugin_related_caches(); - self.codex_message_processor - .maybe_start_plugin_startup_tasks_for_latest_config() - .await; - self.outgoing.send_response(request_id, response).await; - } - Err(error) => self.outgoing.send_error(request_id, error).await, - } + self.handle_config_mutation_result(request_id, self.config_api.batch_write(params).await) + .await; } async fn handle_experimental_feature_overrides_set( @@ -845,11 +837,21 @@ impl MessageProcessor { request_id: ConnectionRequestId, params: ExperimentalFeatureOverridesSetParams, ) { - match self - .config_api - .set_experimental_feature_overrides(params) - .await - { + self.handle_config_mutation_result( + request_id, + self.config_api + .set_experimental_feature_overrides(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/suite/v2/experimental_feature_list.rs b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs index 51369e5d2984..afc744a3a6ee 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 @@ -17,6 +17,7 @@ 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; @@ -40,13 +41,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| { @@ -99,17 +94,9 @@ async fn experimental_feature_overrides_set_applies_to_global_and_thread_config_ let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - let request_id = mcp - .send_experimental_feature_overrides_set_request(ExperimentalFeatureOverridesSetParams { - overrides: BTreeMap::from([("apps".to_string(), true)]), - }) - .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 = + set_experimental_feature_overrides(&mut mcp, BTreeMap::from([("apps".to_string(), true)])) + .await?; assert_eq!( actual, ExperimentalFeatureOverridesSetResponse { @@ -118,18 +105,7 @@ async fn experimental_feature_overrides_set_applies_to_global_and_thread_config_ ); for cwd in [None, Some(project_cwd.display().to_string())] { - let request_id = mcp - .send_config_read_request(ConfigReadParams { - include_layers: false, - cwd, - }) - .await?; - let response: JSONRPCResponse = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(request_id)), - ) - .await??; - let ConfigReadResponse { config, .. } = to_response(response)?; + let ConfigReadResponse { config, .. } = read_config(&mut mcp, cwd).await?; assert_eq!( config @@ -153,17 +129,9 @@ async fn experimental_feature_overrides_set_does_not_override_user_config() -> R let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - let request_id = mcp - .send_experimental_feature_overrides_set_request(ExperimentalFeatureOverridesSetParams { - overrides: BTreeMap::from([("apps".to_string(), true)]), - }) - .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 = + set_experimental_feature_overrides(&mut mcp, BTreeMap::from([("apps".to_string(), true)])) + .await?; assert_eq!( actual, ExperimentalFeatureOverridesSetResponse { @@ -171,18 +139,7 @@ async fn experimental_feature_overrides_set_does_not_override_user_config() -> R } ); - let request_id = mcp - .send_config_read_request(ConfigReadParams { - include_layers: false, - cwd: None, - }) - .await?; - let response: JSONRPCResponse = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(request_id)), - ) - .await??; - let ConfigReadResponse { config, .. } = to_response(response)?; + let ConfigReadResponse { config, .. } = read_config(&mut mcp, /*cwd*/ None).await?; assert_eq!( config @@ -194,3 +151,34 @@ async fn experimental_feature_overrides_set_does_not_override_user_config() -> R Ok(()) } + +async fn set_experimental_feature_overrides( + mcp: &mut McpProcess, + overrides: BTreeMap, +) -> Result { + let request_id = mcp + .send_experimental_feature_overrides_set_request(ExperimentalFeatureOverridesSetParams { + overrides, + }) + .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 15e15e01be9e0d1d02b349bf609715f77c0f246f Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Tue, 24 Mar 2026 11:49:45 -0700 Subject: [PATCH 5/6] update --- .../schema/json/ClientRequest.json | 38 ++--- .../codex_app_server_protocol.schemas.json | 76 +++++----- .../codex_app_server_protocol.v2.schemas.json | 76 +++++----- ...xperimentalFeatureEnablementSetParams.json | 17 +++ ...rimentalFeatureEnablementSetResponse.json} | 8 +- ...ExperimentalFeatureOverridesSetParams.json | 17 --- .../schema/typescript/ClientRequest.ts | 4 +- .../ExperimentalFeatureEnablementSetParams.ts | 12 ++ ...perimentalFeatureEnablementSetResponse.ts} | 6 +- .../ExperimentalFeatureOverridesSetParams.ts | 12 -- .../schema/typescript/v2/index.ts | 4 +- .../src/protocol/common.rs | 6 +- .../app-server-protocol/src/protocol/v2.rs | 16 +-- codex-rs/app-server/README.md | 2 +- .../app-server/src/codex_message_processor.rs | 22 +-- codex-rs/app-server/src/config_api.rs | 96 +++++++------ codex-rs/app-server/src/message_processor.rs | 16 +-- .../app-server/tests/common/mcp_process.rs | 8 +- .../suite/v2/experimental_feature_list.rs | 134 +++++++++++++++--- 19 files changed, 328 insertions(+), 242 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureEnablementSetParams.json rename codex-rs/app-server-protocol/schema/json/v2/{ExperimentalFeatureOverridesSetResponse.json => ExperimentalFeatureEnablementSetResponse.json} (55%) delete mode 100644 codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureOverridesSetParams.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureEnablementSetParams.ts rename codex-rs/app-server-protocol/schema/typescript/v2/{ExperimentalFeatureOverridesSetResponse.ts => ExperimentalFeatureEnablementSetResponse.ts} (62%) delete mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureOverridesSetParams.ts diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 8b29b005e1bc..d662f08651c4 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": { @@ -545,21 +560,6 @@ }, "type": "object" }, - "ExperimentalFeatureOverridesSetParams": { - "properties": { - "overrides": { - "additionalProperties": { - "type": "boolean" - }, - "description": "Process-wide feature flag overrides keyed by canonical feature name.\n\nThis replaces the current in-memory override set. Send an empty map to clear all overrides.", - "type": "object" - } - }, - "required": [ - "overrides" - ], - "type": "object" - }, "ExternalAgentConfigDetectParams": { "properties": { "cwds": { @@ -4238,13 +4238,13 @@ }, "method": { "enum": [ - "experimentalFeature/overrides/set" + "experimentalFeature/enablement/set" ], - "title": "ExperimentalFeature/overrides/setRequestMethod", + "title": "ExperimentalFeature/enablement/setRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ExperimentalFeatureOverridesSetParams" + "$ref": "#/definitions/ExperimentalFeatureEnablementSetParams" } }, "required": [ @@ -4252,7 +4252,7 @@ "method", "params" ], - "title": "ExperimentalFeature/overrides/setRequest", + "title": "ExperimentalFeature/enablement/setRequest", "type": "object" }, { 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 48a14fc88991..32934cbfb652 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 @@ -1106,13 +1106,13 @@ }, "method": { "enum": [ - "experimentalFeature/overrides/set" + "experimentalFeature/enablement/set" ], - "title": "ExperimentalFeature/overrides/setRequestMethod", + "title": "ExperimentalFeature/enablement/setRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/v2/ExperimentalFeatureOverridesSetParams" + "$ref": "#/definitions/v2/ExperimentalFeatureEnablementSetParams" } }, "required": [ @@ -1120,7 +1120,7 @@ "method", "params" ], - "title": "ExperimentalFeature/overrides/setRequest", + "title": "ExperimentalFeature/enablement/setRequest", "type": "object" }, { @@ -7192,6 +7192,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": { @@ -7238,40 +7272,6 @@ "title": "ExperimentalFeatureListResponse", "type": "object" }, - "ExperimentalFeatureOverridesSetParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "overrides": { - "additionalProperties": { - "type": "boolean" - }, - "description": "Process-wide feature flag overrides keyed by canonical feature name.\n\nThis replaces the current in-memory override set. Send an empty map to clear all overrides.", - "type": "object" - } - }, - "required": [ - "overrides" - ], - "title": "ExperimentalFeatureOverridesSetParams", - "type": "object" - }, - "ExperimentalFeatureOverridesSetResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "overrides": { - "additionalProperties": { - "type": "boolean" - }, - "description": "The active process-wide feature flag overrides after the update.", - "type": "object" - } - }, - "required": [ - "overrides" - ], - "title": "ExperimentalFeatureOverridesSetResponse", - "type": "object" - }, "ExperimentalFeatureStage": { "oneOf": [ { 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 706b9e28b62d..34a8b568c2b0 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 @@ -1640,13 +1640,13 @@ }, "method": { "enum": [ - "experimentalFeature/overrides/set" + "experimentalFeature/enablement/set" ], - "title": "ExperimentalFeature/overrides/setRequestMethod", + "title": "ExperimentalFeature/enablement/setRequestMethod", "type": "string" }, "params": { - "$ref": "#/definitions/ExperimentalFeatureOverridesSetParams" + "$ref": "#/definitions/ExperimentalFeatureEnablementSetParams" } }, "required": [ @@ -1654,7 +1654,7 @@ "method", "params" ], - "title": "ExperimentalFeature/overrides/setRequest", + "title": "ExperimentalFeature/enablement/setRequest", "type": "object" }, { @@ -3785,6 +3785,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": { @@ -3831,40 +3865,6 @@ "title": "ExperimentalFeatureListResponse", "type": "object" }, - "ExperimentalFeatureOverridesSetParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "overrides": { - "additionalProperties": { - "type": "boolean" - }, - "description": "Process-wide feature flag overrides keyed by canonical feature name.\n\nThis replaces the current in-memory override set. Send an empty map to clear all overrides.", - "type": "object" - } - }, - "required": [ - "overrides" - ], - "title": "ExperimentalFeatureOverridesSetParams", - "type": "object" - }, - "ExperimentalFeatureOverridesSetResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "overrides": { - "additionalProperties": { - "type": "boolean" - }, - "description": "The active process-wide feature flag overrides after the update.", - "type": "object" - } - }, - "required": [ - "overrides" - ], - "title": "ExperimentalFeatureOverridesSetResponse", - "type": "object" - }, "ExperimentalFeatureStage": { "oneOf": [ { 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 000000000000..9d6bcec932e6 --- /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/ExperimentalFeatureOverridesSetResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureEnablementSetResponse.json similarity index 55% rename from codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureOverridesSetResponse.json rename to codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureEnablementSetResponse.json index 4e39ce6f753a..9cdbf0691f0b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureOverridesSetResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureEnablementSetResponse.json @@ -1,17 +1,17 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "overrides": { + "enablement": { "additionalProperties": { "type": "boolean" }, - "description": "The active process-wide feature flag overrides after the update.", + "description": "Feature enablement entries updated by this request.", "type": "object" } }, "required": [ - "overrides" + "enablement" ], - "title": "ExperimentalFeatureOverridesSetResponse", + "title": "ExperimentalFeatureEnablementSetResponse", "type": "object" } \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureOverridesSetParams.json b/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureOverridesSetParams.json deleted file mode 100644 index 9d18f84697d1..000000000000 --- a/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureOverridesSetParams.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "overrides": { - "additionalProperties": { - "type": "boolean" - }, - "description": "Process-wide feature flag overrides keyed by canonical feature name.\n\nThis replaces the current in-memory override set. Send an empty map to clear all overrides.", - "type": "object" - } - }, - "required": [ - "overrides" - ], - "title": "ExperimentalFeatureOverridesSetParams", - "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 977e897c2d9d..5e2b988eaf21 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -16,8 +16,8 @@ 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 { ExperimentalFeatureOverridesSetParams } from "./v2/ExperimentalFeatureOverridesSetParams"; import type { ExternalAgentConfigDetectParams } from "./v2/ExternalAgentConfigDetectParams"; import type { ExternalAgentConfigImportParams } from "./v2/ExternalAgentConfigImportParams"; import type { FeedbackUploadParams } from "./v2/FeedbackUploadParams"; @@ -62,4 +62,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": "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/overrides/set", id: RequestId, params: ExperimentalFeatureOverridesSetParams, } | { "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": "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 000000000000..cd9ced0b3ab3 --- /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/ExperimentalFeatureOverridesSetResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureEnablementSetResponse.ts similarity index 62% rename from codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureOverridesSetResponse.ts rename to codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureEnablementSetResponse.ts index 01f78068be45..903576d9bc20 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureOverridesSetResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureEnablementSetResponse.ts @@ -2,8 +2,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ExperimentalFeatureOverridesSetResponse = { +export type ExperimentalFeatureEnablementSetResponse = { /** - * The active process-wide feature flag overrides after the update. + * Feature enablement entries updated by this request. */ -overrides: { [key in string]?: boolean }, }; +enablement: { [key in string]?: boolean }, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureOverridesSetParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureOverridesSetParams.ts deleted file mode 100644 index 1bf877d589f0..000000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureOverridesSetParams.ts +++ /dev/null @@ -1,12 +0,0 @@ -// 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 ExperimentalFeatureOverridesSetParams = { -/** - * Process-wide feature flag overrides keyed by canonical feature name. - * - * This replaces the current in-memory override set. Send an empty map to - * clear all overrides. - */ -overrides: { [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 16d85a087403..ae86747d7d33 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -81,10 +81,10 @@ 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 { ExperimentalFeatureOverridesSetParams } from "./ExperimentalFeatureOverridesSetParams"; -export type { ExperimentalFeatureOverridesSetResponse } from "./ExperimentalFeatureOverridesSetResponse"; export type { ExperimentalFeatureStage } from "./ExperimentalFeatureStage"; export type { ExternalAgentConfigDetectParams } from "./ExternalAgentConfigDetectParams"; export type { ExternalAgentConfigDetectResponse } from "./ExternalAgentConfigDetectResponse"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 8c06fc9070e2..0eff37b3f49f 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -394,9 +394,9 @@ client_request_definitions! { params: v2::ExperimentalFeatureListParams, response: v2::ExperimentalFeatureListResponse, }, - ExperimentalFeatureOverridesSet => "experimentalFeature/overrides/set" { - params: v2::ExperimentalFeatureOverridesSetParams, - response: v2::ExperimentalFeatureOverridesSetResponse, + ExperimentalFeatureEnablementSet => "experimentalFeature/enablement/set" { + params: v2::ExperimentalFeatureEnablementSetParams, + response: v2::ExperimentalFeatureEnablementSetResponse, }, #[experimental("collaborationMode/list")] /// Lists collaboration mode presets. diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 09c1bf8e9155..bd4793c5dcf3 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1929,20 +1929,20 @@ pub struct ExperimentalFeatureListResponse { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] -pub struct ExperimentalFeatureOverridesSetParams { - /// Process-wide feature flag overrides keyed by canonical feature name. +pub struct ExperimentalFeatureEnablementSetParams { + /// Process-wide runtime feature enablement keyed by canonical feature name. /// - /// This replaces the current in-memory override set. Send an empty map to - /// clear all overrides. - pub overrides: std::collections::BTreeMap, + /// 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 ExperimentalFeatureOverridesSetResponse { - /// The active process-wide feature flag overrides after the update. - pub overrides: std::collections::BTreeMap, +pub struct ExperimentalFeatureEnablementSetResponse { + /// Feature enablement entries updated by this request. + pub enablement: std::collections::BTreeMap, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index cd961d21a70b..d6d0574dc3f1 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -161,7 +161,7 @@ Example with notification opt-out: - `fs/copy` — copy between absolute paths; directory copies require `recursive: true`. - `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/overrides/set` — update the in-memory process-wide feature flag override map. For each feature, explicit `config.toml` values and cloud requirement pins win over these runtime overrides, and runtime overrides win over startup CLI feature flags until cleared or the server restarts. +- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `plugins`). For each feature, explicit `config.toml` values and cloud requirement pins win over these runtime settings, and runtime settings win over startup CLI feature flags until updated again or the server restarts. - `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 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 79ed97bc7c1d..59df6e0bf907 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1,8 +1,8 @@ use crate::bespoke_event_handling::apply_bespoke_event_handling; use crate::command_exec::CommandExecManager; use crate::command_exec::StartCommandExecParams; +use crate::config_api::filter_protected_feature_cli_overrides; use crate::config_api::has_feature_cli_overrides; -use crate::config_api::merge_feature_cli_overrides; use crate::config_api::non_feature_cli_overrides; use crate::config_api::protected_feature_keys; use crate::error_code::INPUT_TOO_LARGE_ERROR_CODE; @@ -288,7 +288,6 @@ 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; @@ -378,7 +377,6 @@ pub(crate) struct CodexMessageProcessor { arg0_paths: Arg0DispatchPaths, config: Arc, cli_overrides: Arc>>, - feature_flag_overrides: Arc>>, cloud_requirements: Arc>, active_login: Arc>>, pending_thread_unloads: Arc>>, @@ -423,7 +421,6 @@ pub(crate) struct CodexMessageProcessorArgs { pub(crate) arg0_paths: Arg0DispatchPaths, pub(crate) config: Arc, pub(crate) cli_overrides: Arc>>, - pub(crate) feature_flag_overrides: Arc>>, pub(crate) cloud_requirements: Arc>, pub(crate) feedback: CodexFeedback, pub(crate) log_db: Option, @@ -487,7 +484,6 @@ impl CodexMessageProcessor { arg0_paths, config, cli_overrides, - feature_flag_overrides, cloud_requirements, feedback, log_db, @@ -499,7 +495,6 @@ impl CodexMessageProcessor { arg0_paths, config, cli_overrides, - feature_flag_overrides, cloud_requirements, active_login: Arc::new(Mutex::new(None)), pending_thread_unloads: Arc::new(Mutex::new(HashSet::new())), @@ -552,21 +547,13 @@ impl CodexMessageProcessor { .unwrap_or_default() } - fn current_feature_flag_overrides(&self) -> BTreeMap { - self.feature_flag_overrides - .read() - .map(|guard| guard.clone()) - .unwrap_or_default() - } - async fn effective_cli_overrides( &self, fallback_cwd: Option, cloud_requirements: CloudRequirementsLoader, ) -> Result, JSONRPCErrorError> { let cli_overrides = self.current_cli_overrides(); - let feature_flag_overrides = self.current_feature_flag_overrides(); - if !has_feature_cli_overrides(&cli_overrides) && feature_flag_overrides.is_empty() { + if !has_feature_cli_overrides(&cli_overrides) { return Ok(cli_overrides); } @@ -584,9 +571,8 @@ impl CodexMessageProcessor { })?; let protected_features = protected_feature_keys(&config.config_layer_stack); - Ok(merge_feature_cli_overrides( + Ok(filter_protected_feature_cli_overrides( cli_overrides, - feature_flag_overrides, &protected_features, )) } @@ -955,7 +941,7 @@ impl CodexMessageProcessor { ClientRequest::ConfigRead { .. } | ClientRequest::ConfigValueWrite { .. } | ClientRequest::ConfigBatchWrite { .. } - | ClientRequest::ExperimentalFeatureOverridesSet { .. } => { + | ClientRequest::ExperimentalFeatureEnablementSet { .. } => { warn!("Config request reached CodexMessageProcessor unexpectedly"); } ClientRequest::FsReadFile { .. } diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index 6b5ebbd742b9..4033108a4710 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -9,8 +9,8 @@ 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::ExperimentalFeatureOverridesSetParams; -use codex_app_server_protocol::ExperimentalFeatureOverridesSetResponse; +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; @@ -31,7 +31,6 @@ 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; @@ -39,6 +38,8 @@ 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); @@ -63,7 +64,6 @@ impl UserConfigReloader for ThreadManager { pub(crate) struct ConfigApi { codex_home: PathBuf, cli_overrides: Arc>>, - feature_flag_overrides: Arc>>, loader_overrides: LoaderOverrides, cloud_requirements: Arc>, user_config_reloader: Arc, @@ -74,7 +74,6 @@ impl ConfigApi { pub(crate) fn new( codex_home: PathBuf, cli_overrides: Arc>>, - feature_flag_overrides: Arc>>, loader_overrides: LoaderOverrides, cloud_requirements: Arc>, user_config_reloader: Arc, @@ -83,7 +82,6 @@ impl ConfigApi { Self { codex_home, cli_overrides, - feature_flag_overrides, loader_overrides, cloud_requirements, user_config_reloader, @@ -116,21 +114,13 @@ impl ConfigApi { .unwrap_or_default() } - fn current_feature_flag_overrides(&self) -> BTreeMap { - self.feature_flag_overrides - .read() - .map(|guard| guard.clone()) - .unwrap_or_default() - } - async fn effective_cli_overrides( &self, fallback_cwd: Option, cloud_requirements: CloudRequirementsLoader, ) -> Result, JSONRPCErrorError> { let cli_overrides = self.current_cli_overrides(); - let feature_flag_overrides = self.current_feature_flag_overrides(); - if !has_feature_cli_overrides(&cli_overrides) && feature_flag_overrides.is_empty() { + if !has_feature_cli_overrides(&cli_overrides) { return Ok(cli_overrides); } @@ -149,9 +139,8 @@ impl ConfigApi { })?; let protected_features = protected_feature_keys(&config.config_layer_stack); - Ok(merge_feature_cli_overrides( + Ok(filter_protected_feature_cli_overrides( cli_overrides, - feature_flag_overrides, &protected_features, )) } @@ -222,23 +211,34 @@ impl ConfigApi { Ok(response) } - pub(crate) async fn set_experimental_feature_overrides( + pub(crate) async fn set_experimental_feature_enablement( &self, - params: ExperimentalFeatureOverridesSetParams, - ) -> Result { - let ExperimentalFeatureOverridesSetParams { overrides } = params; - for key in overrides.keys() { + params: ExperimentalFeatureEnablementSetParams, + ) -> Result { + let ExperimentalFeatureEnablementSetParams { enablement } = params; + for key in enablement.keys() { if canonical_feature_for_key(key).is_some() { - continue; + 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 override `{key}`: use canonical feature key `{}`", + "invalid feature enablement `{key}`: use canonical feature key `{}`", feature.key() ) } else { - format!("invalid feature override `{key}`") + format!("invalid feature enablement `{key}`") }; return Err(JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, @@ -247,14 +247,27 @@ impl ConfigApi { }); } - *self - .feature_flag_overrides - .write() - .map_err(|_| JSONRPCErrorError { + if enablement.is_empty() { + return Ok(ExperimentalFeatureEnablementSetResponse { enablement }); + } + + let feature_keys = enablement + .keys() + .map(|name| format!("features.{name}")) + .collect::>(); + { + let mut cli_overrides = self.cli_overrides.write().map_err(|_| JSONRPCErrorError { code: INTERNAL_ERROR_CODE, - message: "failed to update feature flag overrides".to_string(), + message: "failed to update feature enablement".to_string(), data: None, - })? = overrides.clone(); + })?; + cli_overrides.retain(|(key, _)| !feature_keys.contains(key)); + cli_overrides.extend( + enablement.iter().map(|(name, enabled)| { + (format!("features.{name}"), TomlValue::Boolean(*enabled)) + }), + ); + } self.config_service(/*fallback_cwd*/ None) .await? @@ -266,7 +279,7 @@ impl ConfigApi { .map_err(map_error)?; self.user_config_reloader.reload_user_config().await; - Ok(ExperimentalFeatureOverridesSetResponse { overrides }) + Ok(ExperimentalFeatureEnablementSetResponse { enablement }) } fn emit_plugin_toggle_events(&self, pending_changes: std::collections::BTreeMap) { @@ -322,29 +335,19 @@ pub(crate) fn protected_feature_keys( protected_features } -pub(crate) fn merge_feature_cli_overrides( +pub(crate) fn filter_protected_feature_cli_overrides( cli_overrides: Vec<(String, TomlValue)>, - feature_flag_overrides: BTreeMap, protected_features: &BTreeSet, ) -> Vec<(String, TomlValue)> { - let mut merged = cli_overrides + cli_overrides .into_iter() .filter(|(key, _)| { let Some(feature) = key.strip_prefix("features.") else { return true; }; - !protected_features.contains(feature) && !feature_flag_overrides.contains_key(feature) + !protected_features.contains(feature) }) - .collect::>(); - - merged.extend( - feature_flag_overrides - .into_iter() - .filter(|(feature, _)| !protected_features.contains(feature)) - .map(|(name, enabled)| (format!("features.{name}"), TomlValue::Boolean(enabled))), - ); - - merged + .collect() } fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigRequirements { @@ -584,7 +587,6 @@ mod tests { let config_api = ConfigApi::new( codex_home.path().to_path_buf(), 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 a45f5ecb7cfd..c8aae7edb3f7 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1,4 +1,3 @@ -use std::collections::BTreeMap; use std::collections::HashSet; use std::future::Future; use std::sync::Arc; @@ -29,7 +28,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::ExperimentalFeatureOverridesSetParams; +use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams; use codex_app_server_protocol::ExternalAgentConfigDetectParams; use codex_app_server_protocol::ExternalAgentConfigImportParams; use codex_app_server_protocol::FsCopyParams; @@ -235,7 +234,6 @@ impl MessageProcessor { .set_analytics_events_client(analytics_events_client.clone()); let cli_overrides = Arc::new(RwLock::new(cli_overrides)); - let feature_flag_overrides = 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(), @@ -244,7 +242,6 @@ impl MessageProcessor { arg0_paths, config: Arc::clone(&config), cli_overrides: cli_overrides.clone(), - feature_flag_overrides: feature_flag_overrides.clone(), cloud_requirements: cloud_requirements.clone(), feedback, log_db, @@ -257,7 +254,6 @@ impl MessageProcessor { let config_api = ConfigApi::new( config.codex_home.clone(), cli_overrides, - feature_flag_overrides, loader_overrides, cloud_requirements, thread_manager, @@ -692,8 +688,8 @@ impl MessageProcessor { ) .await; } - ClientRequest::ExperimentalFeatureOverridesSet { request_id, params } => { - self.handle_experimental_feature_overrides_set( + ClientRequest::ExperimentalFeatureEnablementSet { request_id, params } => { + self.handle_experimental_feature_enablement_set( ConnectionRequestId { connection_id, request_id, @@ -832,15 +828,15 @@ impl MessageProcessor { .await; } - async fn handle_experimental_feature_overrides_set( + async fn handle_experimental_feature_enablement_set( &self, request_id: ConnectionRequestId, - params: ExperimentalFeatureOverridesSetParams, + params: ExperimentalFeatureEnablementSetParams, ) { self.handle_config_mutation_result( request_id, self.config_api - .set_experimental_feature_overrides(params) + .set_experimental_feature_enablement(params) .await, ) .await; diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index f95b42ec5376..648c493f6917 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -463,13 +463,13 @@ impl McpProcess { self.send_request("experimentalFeature/list", params).await } - /// Send an `experimentalFeature/overrides/set` JSON-RPC request. - pub async fn send_experimental_feature_overrides_set_request( + /// Send an `experimentalFeature/enablement/set` JSON-RPC request. + pub async fn send_experimental_feature_enablement_set_request( &mut self, - params: codex_app_server_protocol::ExperimentalFeatureOverridesSetParams, + params: codex_app_server_protocol::ExperimentalFeatureEnablementSetParams, ) -> anyhow::Result { let params = Some(serde_json::to_value(params)?); - self.send_request("experimentalFeature/overrides/set", params) + self.send_request("experimentalFeature/enablement/set", params) .await } 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 afc744a3a6ee..7bbcc36ee275 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 @@ -6,11 +6,12 @@ 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::ExperimentalFeatureOverridesSetParams; -use codex_app_server_protocol::ExperimentalFeatureOverridesSetResponse; 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; @@ -85,8 +86,8 @@ async fn experimental_feature_list_returns_feature_metadata_with_stage() -> Resu } #[tokio::test] -async fn experimental_feature_overrides_set_applies_to_global_and_thread_config_reads() -> Result<()> -{ +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)?; @@ -95,12 +96,12 @@ async fn experimental_feature_overrides_set_applies_to_global_and_thread_config_ timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let actual = - set_experimental_feature_overrides(&mut mcp, BTreeMap::from([("apps".to_string(), true)])) + set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)])) .await?; assert_eq!( actual, - ExperimentalFeatureOverridesSetResponse { - overrides: BTreeMap::from([("apps".to_string(), true)]), + ExperimentalFeatureEnablementSetResponse { + enablement: BTreeMap::from([("apps".to_string(), true)]), } ); @@ -120,7 +121,7 @@ async fn experimental_feature_overrides_set_applies_to_global_and_thread_config_ } #[tokio::test] -async fn experimental_feature_overrides_set_does_not_override_user_config() -> Result<()> { +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"), @@ -130,12 +131,12 @@ async fn experimental_feature_overrides_set_does_not_override_user_config() -> R timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let actual = - set_experimental_feature_overrides(&mut mcp, BTreeMap::from([("apps".to_string(), true)])) + set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)])) .await?; assert_eq!( actual, - ExperimentalFeatureOverridesSetResponse { - overrides: BTreeMap::from([("apps".to_string(), true)]), + ExperimentalFeatureEnablementSetResponse { + enablement: BTreeMap::from([("apps".to_string(), true)]), } ); @@ -152,13 +153,114 @@ async fn experimental_feature_overrides_set_does_not_override_user_config() -> R Ok(()) } -async fn set_experimental_feature_overrides( +#[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, - overrides: BTreeMap, -) -> Result { + enablement: BTreeMap, +) -> Result { let request_id = mcp - .send_experimental_feature_overrides_set_request(ExperimentalFeatureOverridesSetParams { - overrides, + .send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams { + enablement, }) .await?; read_response(mcp, request_id).await From 9f17b94e616c1d06de5f6ce4dd9b5a676b5f2bf8 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Tue, 24 Mar 2026 17:00:33 -0700 Subject: [PATCH 6/6] update --- codex-rs/app-server/README.md | 2 +- .../app-server/src/codex_message_processor.rs | 143 +++------- codex-rs/app-server/src/config_api.rs | 244 +++++++++++------- codex-rs/app-server/src/message_processor.rs | 4 + 4 files changed, 199 insertions(+), 194 deletions(-) diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 3e0762ff959e..cf9fd809cea9 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -161,7 +161,7 @@ Example with notification opt-out: - `fs/copy` — copy between absolute paths; directory copies require `recursive: true`. - `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, explicit `config.toml` values and cloud requirement pins win over these runtime settings, and runtime settings win over startup CLI feature flags until updated again or the server restarts. +- `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 ab82e45e0b48..41f97aaa2f82 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1,10 +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::filter_protected_feature_cli_overrides; -use crate::config_api::has_feature_cli_overrides; -use crate::config_api::non_feature_cli_overrides; -use crate::config_api::protected_feature_keys; +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; @@ -287,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; @@ -376,6 +374,7 @@ pub(crate) struct CodexMessageProcessor { arg0_paths: Arg0DispatchPaths, config: Arc, cli_overrides: Arc>>, + runtime_feature_enablement: Arc>>, cloud_requirements: Arc>, active_login: Arc>>, pending_thread_unloads: Arc>>, @@ -420,6 +419,7 @@ pub(crate) struct CodexMessageProcessorArgs { pub(crate) arg0_paths: Arg0DispatchPaths, pub(crate) config: Arc, 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, @@ -483,6 +483,7 @@ impl CodexMessageProcessor { arg0_paths, config, cli_overrides, + runtime_feature_enablement, cloud_requirements, feedback, log_db, @@ -494,6 +495,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())), @@ -514,10 +516,7 @@ impl CodexMessageProcessor { ) -> Result { let cloud_requirements = self.current_cloud_requirements(); let mut config = codex_core::config::ConfigBuilder::default() - .cli_overrides( - self.effective_cli_overrides(fallback_cwd.clone(), cloud_requirements.clone()) - .await?, - ) + .cli_overrides(self.current_cli_overrides()) .fallback_cwd(fallback_cwd) .cloud_requirements(cloud_requirements) .build() @@ -527,6 +526,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) @@ -546,52 +546,11 @@ impl CodexMessageProcessor { .unwrap_or_default() } - async fn effective_cli_overrides( - &self, - fallback_cwd: Option, - cloud_requirements: CloudRequirementsLoader, - ) -> Result, JSONRPCErrorError> { - let cli_overrides = self.current_cli_overrides(); - if !has_feature_cli_overrides(&cli_overrides) { - return Ok(cli_overrides); - } - - let config = codex_core::config::ConfigBuilder::default() - .codex_home(self.config.codex_home.clone()) - .cli_overrides(non_feature_cli_overrides(&cli_overrides)) - .fallback_cwd(fallback_cwd) - .cloud_requirements(cloud_requirements) - .build() - .await - .map_err(|err| JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to resolve feature override precedence: {err}"), - data: None, - })?; - let protected_features = protected_feature_keys(&config.config_layer_stack); - - Ok(filter_protected_feature_cli_overrides( - cli_overrides, - &protected_features, - )) - } - - async fn effective_cli_overrides_or_current( - &self, - fallback_cwd: Option, - cloud_requirements: CloudRequirementsLoader, - context: &str, - ) -> Vec<(String, TomlValue)> { - match self - .effective_cli_overrides(fallback_cwd, cloud_requirements) - .await - { - Ok(cli_overrides) => cli_overrides, - Err(err) => { - warn!("failed to resolve feature overrides for {context}: {err:?}"); - self.current_cli_overrides() - } - } + 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, @@ -1139,13 +1098,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 - .effective_cli_overrides_or_current( - /*fallback_cwd*/ None, - self.current_cloud_requirements(), - "login completion", - ) - .await; + 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( @@ -1333,13 +1286,7 @@ impl CodexMessageProcessor { self.config.chatgpt_base_url.clone(), self.config.codex_home.clone(), ); - let cli_overrides = self - .effective_cli_overrides_or_current( - /*fallback_cwd*/ None, - self.current_cloud_requirements(), - "residency sync", - ) - .await; + let cli_overrides = self.current_cli_overrides(); sync_default_client_residency_requirement(&cli_overrides, self.cloud_requirements.as_ref()) .await; @@ -1944,13 +1891,7 @@ impl CodexMessageProcessor { ); typesafe_overrides.ephemeral = ephemeral; let cloud_requirements = self.current_cloud_requirements(); - let cli_overrides = self - .effective_cli_overrides_or_current( - typesafe_overrides.cwd.clone(), - cloud_requirements.clone(), - "thread start", - ) - .await; + 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(), @@ -1960,10 +1901,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, @@ -2029,6 +1972,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>, @@ -2045,6 +1989,7 @@ impl CodexMessageProcessor { typesafe_overrides, &cloud_requirements, &listener_task_context.codex_home, + &runtime_feature_enablement, ) .await { @@ -3547,20 +3492,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 effective_cli_overrides = self - .effective_cli_overrides_or_current( - history_cwd.clone(), - cloud_requirements.clone(), - "thread resume", - ) - .await; + let cli_overrides = self.current_cli_overrides(); + let runtime_feature_enablement = self.current_runtime_feature_enablement(); let config = match derive_config_for_cwd( - &effective_cli_overrides, + &cli_overrides, request_overrides, typesafe_overrides, history_cwd, &cloud_requirements, &self.config.codex_home, + &runtime_feature_enablement, ) .await { @@ -4096,20 +4037,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 effective_cli_overrides = self - .effective_cli_overrides_or_current( - history_cwd.clone(), - cloud_requirements.clone(), - "thread fork", - ) - .await; + let cli_overrides = self.current_cli_overrides(); + let runtime_feature_enablement = self.current_runtime_feature_enablement(); let config = match derive_config_for_cwd( - &effective_cli_overrides, + &cli_overrides, request_overrides, typesafe_overrides, history_cwd, &cloud_requirements, &self.config.codex_home, + &runtime_feature_enablement, ) .await { @@ -7293,13 +7230,8 @@ impl CodexMessageProcessor { .cwd .map(PathBuf::from) .unwrap_or_else(|| config.cwd.clone()); - let cli_overrides = self - .effective_cli_overrides_or_current( - Some(command_cwd.clone()), - cloud_requirements.clone(), - "windows sandbox setup", - ) - .await; + 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; @@ -7314,6 +7246,7 @@ impl CodexMessageProcessor { Some(command_cwd.clone()), &cloud_requirements, &config.codex_home, + &runtime_feature_enablement, ) .await; let setup_result = match derived_config { @@ -7943,6 +7876,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() @@ -7955,13 +7889,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( @@ -7971,6 +7907,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() @@ -7983,14 +7920,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 4033108a4710..9ade74914d70 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -16,6 +16,7 @@ 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; @@ -31,6 +32,7 @@ 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; @@ -64,6 +66,7 @@ impl UserConfigReloader for ThreadManager { pub(crate) struct ConfigApi { codex_home: PathBuf, cli_overrides: Arc>>, + runtime_feature_enablement: Arc>>, loader_overrides: LoaderOverrides, cloud_requirements: Arc>, user_config_reloader: Arc, @@ -74,6 +77,7 @@ impl ConfigApi { pub(crate) fn new( codex_home: PathBuf, cli_overrides: Arc>>, + runtime_feature_enablement: Arc>>, loader_overrides: LoaderOverrides, cloud_requirements: Arc>, user_config_reloader: Arc, @@ -82,6 +86,7 @@ impl ConfigApi { Self { codex_home, cli_overrides, + runtime_feature_enablement, loader_overrides, cloud_requirements, user_config_reloader, @@ -89,22 +94,13 @@ impl ConfigApi { } } - async fn config_service( - &self, - fallback_cwd: Option, - ) -> Result { - let cloud_requirements = self - .cloud_requirements - .read() - .map(|guard| guard.clone()) - .unwrap_or_default(); - Ok(ConfigService::new( + fn config_service(&self) -> ConfigService { + ConfigService::new( self.codex_home.clone(), - self.effective_cli_overrides(fallback_cwd, cloud_requirements.clone()) - .await?, + self.current_cli_overrides(), self.loader_overrides.clone(), - cloud_requirements, - )) + self.current_cloud_requirements(), + ) } fn current_cli_overrides(&self) -> Vec<(String, TomlValue)> { @@ -114,22 +110,30 @@ impl ConfigApi { .unwrap_or_default() } - async fn effective_cli_overrides( + 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, - cloud_requirements: CloudRequirementsLoader, - ) -> Result, JSONRPCErrorError> { - let cli_overrides = self.current_cli_overrides(); - if !has_feature_cli_overrides(&cli_overrides) { - return Ok(cli_overrides); - } - - let config = codex_core::config::ConfigBuilder::default() + ) -> Result { + let mut config = codex_core::config::ConfigBuilder::default() .codex_home(self.codex_home.clone()) - .cli_overrides(non_feature_cli_overrides(&cli_overrides)) + .cli_overrides(self.current_cli_overrides()) .loader_overrides(self.loader_overrides.clone()) .fallback_cwd(fallback_cwd) - .cloud_requirements(cloud_requirements) + .cloud_requirements(self.current_cloud_requirements()) .build() .await .map_err(|err| JSONRPCErrorError { @@ -137,12 +141,8 @@ impl ConfigApi { message: format!("failed to resolve feature override precedence: {err}"), data: None, })?; - let protected_features = protected_feature_keys(&config.config_layer_stack); - - Ok(filter_protected_feature_cli_overrides( - cli_overrides, - &protected_features, - )) + apply_runtime_feature_enablement(&mut config, &self.current_runtime_feature_enablement()); + Ok(config) } pub(crate) async fn read( @@ -150,19 +150,39 @@ impl ConfigApi { params: ConfigReadParams, ) -> Result { let fallback_cwd = params.cwd.as_ref().map(PathBuf::from); - self.config_service(fallback_cwd) - .await? + let mut response = self + .config_service() .read(params) .await - .map_err(map_error) + .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( &self, ) -> Result { let requirements = self - .config_service(/*fallback_cwd*/ None) - .await? + .config_service() .read_requirements() .await .map_err(map_error)? @@ -178,8 +198,7 @@ impl ConfigApi { let pending_changes = collect_plugin_enabled_candidates([(¶ms.key_path, ¶ms.value)].into_iter()); let response = self - .config_service(/*fallback_cwd*/ None) - .await? + .config_service() .write_value(params) .await .map_err(map_error)?; @@ -199,8 +218,7 @@ impl ConfigApi { .map(|edit| (&edit.key_path, &edit.value)), ); let response = self - .config_service(/*fallback_cwd*/ None) - .await? + .config_service() .batch_write(params) .await .map_err(map_error)?; @@ -251,32 +269,23 @@ impl ConfigApi { return Ok(ExperimentalFeatureEnablementSetResponse { enablement }); } - let feature_keys = enablement - .keys() - .map(|name| format!("features.{name}")) - .collect::>(); { - let mut cli_overrides = self.cli_overrides.write().map_err(|_| JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: "failed to update feature enablement".to_string(), - data: None, - })?; - cli_overrides.retain(|(key, _)| !feature_keys.contains(key)); - cli_overrides.extend( - enablement.iter().map(|(name, enabled)| { - (format!("features.{name}"), TomlValue::Boolean(*enabled)) - }), + 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.config_service(/*fallback_cwd*/ None) - .await? - .read(ConfigReadParams { - include_layers: false, - cwd: None, - }) - .await - .map_err(map_error)?; + self.load_latest_config(/*fallback_cwd*/ None).await?; self.user_config_reloader.reload_user_config().await; Ok(ExperimentalFeatureEnablementSetResponse { enablement }) @@ -298,22 +307,6 @@ impl ConfigApi { } } -pub(crate) fn has_feature_cli_overrides(cli_overrides: &[(String, TomlValue)]) -> bool { - cli_overrides - .iter() - .any(|(key, _)| key.starts_with("features.")) -} - -pub(crate) fn non_feature_cli_overrides( - cli_overrides: &[(String, TomlValue)], -) -> Vec<(String, TomlValue)> { - cli_overrides - .iter() - .filter(|(key, _)| !key.starts_with("features.")) - .cloned() - .collect() -} - pub(crate) fn protected_feature_keys( config_layer_stack: &codex_core::config_loader::ConfigLayerStack, ) -> BTreeSet { @@ -335,19 +328,26 @@ pub(crate) fn protected_feature_keys( protected_features } -pub(crate) fn filter_protected_feature_cli_overrides( - cli_overrides: Vec<(String, TomlValue)>, - protected_features: &BTreeSet, -) -> Vec<(String, TomlValue)> { - cli_overrides - .into_iter() - .filter(|(key, _)| { - let Some(feature) = key.strip_prefix("features.") else { - return true; - }; - !protected_features.contains(feature) - }) - .collect() +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 { @@ -445,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; @@ -572,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"); @@ -587,6 +648,7 @@ mod tests { let config_api = ConfigApi::new( codex_home.path().to_path_buf(), 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 c14ae6ec5ec4..4511efcf8520 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; @@ -223,6 +224,7 @@ impl MessageProcessor { .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(), @@ -231,6 +233,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, @@ -243,6 +246,7 @@ impl MessageProcessor { let config_api = ConfigApi::new( config.codex_home.clone(), cli_overrides, + runtime_feature_enablement, loader_overrides, cloud_requirements, thread_manager,