diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index e245c17954..9227a97d55 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -524,6 +524,21 @@ ], "type": "object" }, + "ExperimentalFeatureEnablementSetParams": { + "properties": { + "enablement": { + "additionalProperties": { + "type": "boolean" + }, + "description": "Process-wide runtime feature enablement keyed by canonical feature name.\n\nOnly named features are updated. Omitted features are left unchanged. Send an empty map for a no-op.", + "type": "object" + } + }, + "required": [ + "enablement" + ], + "type": "object" + }, "ExperimentalFeatureListParams": { "properties": { "cursor": { @@ -4294,6 +4309,30 @@ "title": "ExperimentalFeature/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "experimentalFeature/enablement/set" + ], + "title": "ExperimentalFeature/enablement/setRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ExperimentalFeatureEnablementSetParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ExperimentalFeature/enablement/setRequest", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index ad79d0987e..d4269f5239 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -1151,6 +1151,30 @@ "title": "ExperimentalFeature/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "experimentalFeature/enablement/set" + ], + "title": "ExperimentalFeature/enablement/setRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ExperimentalFeatureEnablementSetParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ExperimentalFeature/enablement/setRequest", + "type": "object" + }, { "properties": { "id": { @@ -7249,6 +7273,40 @@ ], "type": "object" }, + "ExperimentalFeatureEnablementSetParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enablement": { + "additionalProperties": { + "type": "boolean" + }, + "description": "Process-wide runtime feature enablement keyed by canonical feature name.\n\nOnly named features are updated. Omitted features are left unchanged. Send an empty map for a no-op.", + "type": "object" + } + }, + "required": [ + "enablement" + ], + "title": "ExperimentalFeatureEnablementSetParams", + "type": "object" + }, + "ExperimentalFeatureEnablementSetResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enablement": { + "additionalProperties": { + "type": "boolean" + }, + "description": "Feature enablement entries updated by this request.", + "type": "object" + } + }, + "required": [ + "enablement" + ], + "title": "ExperimentalFeatureEnablementSetResponse", + "type": "object" + }, "ExperimentalFeatureListParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 30bb2efb22..acf36d6c3c 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -1681,6 +1681,30 @@ "title": "ExperimentalFeature/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "experimentalFeature/enablement/set" + ], + "title": "ExperimentalFeature/enablement/setRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ExperimentalFeatureEnablementSetParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ExperimentalFeature/enablement/setRequest", + "type": "object" + }, { "properties": { "id": { @@ -3809,6 +3833,40 @@ ], "type": "object" }, + "ExperimentalFeatureEnablementSetParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enablement": { + "additionalProperties": { + "type": "boolean" + }, + "description": "Process-wide runtime feature enablement keyed by canonical feature name.\n\nOnly named features are updated. Omitted features are left unchanged. Send an empty map for a no-op.", + "type": "object" + } + }, + "required": [ + "enablement" + ], + "title": "ExperimentalFeatureEnablementSetParams", + "type": "object" + }, + "ExperimentalFeatureEnablementSetResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enablement": { + "additionalProperties": { + "type": "boolean" + }, + "description": "Feature enablement entries updated by this request.", + "type": "object" + } + }, + "required": [ + "enablement" + ], + "title": "ExperimentalFeatureEnablementSetResponse", + "type": "object" + }, "ExperimentalFeatureListParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureEnablementSetParams.json b/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureEnablementSetParams.json new file mode 100644 index 0000000000..9d6bcec932 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureEnablementSetParams.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enablement": { + "additionalProperties": { + "type": "boolean" + }, + "description": "Process-wide runtime feature enablement keyed by canonical feature name.\n\nOnly named features are updated. Omitted features are left unchanged. Send an empty map for a no-op.", + "type": "object" + } + }, + "required": [ + "enablement" + ], + "title": "ExperimentalFeatureEnablementSetParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureEnablementSetResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureEnablementSetResponse.json new file mode 100644 index 0000000000..9cdbf0691f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureEnablementSetResponse.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enablement": { + "additionalProperties": { + "type": "boolean" + }, + "description": "Feature enablement entries updated by this request.", + "type": "object" + } + }, + "required": [ + "enablement" + ], + "title": "ExperimentalFeatureEnablementSetResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index 3c77652cec..e33a986359 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -16,6 +16,7 @@ import type { CommandExecWriteParams } from "./v2/CommandExecWriteParams"; import type { ConfigBatchWriteParams } from "./v2/ConfigBatchWriteParams"; import type { ConfigReadParams } from "./v2/ConfigReadParams"; import type { ConfigValueWriteParams } from "./v2/ConfigValueWriteParams"; +import type { ExperimentalFeatureEnablementSetParams } from "./v2/ExperimentalFeatureEnablementSetParams"; import type { ExperimentalFeatureListParams } from "./v2/ExperimentalFeatureListParams"; import type { ExternalAgentConfigDetectParams } from "./v2/ExternalAgentConfigDetectParams"; import type { ExternalAgentConfigImportParams } from "./v2/ExternalAgentConfigImportParams"; @@ -63,4 +64,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureEnablementSetParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureEnablementSetParams.ts new file mode 100644 index 0000000000..cd9ced0b3a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureEnablementSetParams.ts @@ -0,0 +1,12 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ExperimentalFeatureEnablementSetParams = { +/** + * Process-wide runtime feature enablement keyed by canonical feature name. + * + * Only named features are updated. Omitted features are left unchanged. + * Send an empty map for a no-op. + */ +enablement: { [key in string]?: boolean }, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureEnablementSetResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureEnablementSetResponse.ts new file mode 100644 index 0000000000..903576d9bc --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureEnablementSetResponse.ts @@ -0,0 +1,9 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ExperimentalFeatureEnablementSetResponse = { +/** + * Feature enablement entries updated by this request. + */ +enablement: { [key in string]?: boolean }, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 2ee447bba2..d0b1b88198 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -81,6 +81,8 @@ export type { DynamicToolSpec } from "./DynamicToolSpec"; export type { ErrorNotification } from "./ErrorNotification"; export type { ExecPolicyAmendment } from "./ExecPolicyAmendment"; export type { ExperimentalFeature } from "./ExperimentalFeature"; +export type { ExperimentalFeatureEnablementSetParams } from "./ExperimentalFeatureEnablementSetParams"; +export type { ExperimentalFeatureEnablementSetResponse } from "./ExperimentalFeatureEnablementSetResponse"; export type { ExperimentalFeatureListParams } from "./ExperimentalFeatureListParams"; export type { ExperimentalFeatureListResponse } from "./ExperimentalFeatureListResponse"; export type { ExperimentalFeatureStage } from "./ExperimentalFeatureStage"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 80cb24f0ff..5a9215f2ee 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -392,6 +392,10 @@ client_request_definitions! { params: v2::ExperimentalFeatureListParams, response: v2::ExperimentalFeatureListResponse, }, + ExperimentalFeatureEnablementSet => "experimentalFeature/enablement/set" { + params: v2::ExperimentalFeatureEnablementSetParams, + response: v2::ExperimentalFeatureEnablementSetResponse, + }, #[experimental("collaborationMode/list")] /// Lists collaboration mode presets. CollaborationModeList => "collaborationMode/list" { diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index fab3fe3190..67c4e95856 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1926,6 +1926,25 @@ pub struct ExperimentalFeatureListResponse { pub next_cursor: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExperimentalFeatureEnablementSetParams { + /// Process-wide runtime feature enablement keyed by canonical feature name. + /// + /// Only named features are updated. Omitted features are left unchanged. + /// Send an empty map for a no-op. + pub enablement: std::collections::BTreeMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExperimentalFeatureEnablementSetResponse { + /// Feature enablement entries updated by this request. + pub enablement: std::collections::BTreeMap, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index b877c4a035..1e186c0d5e 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -164,6 +164,7 @@ Example with notification opt-out: - `fs/changed` — notification emitted when watched paths change, including the `watchId` and `changedPaths`. - `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`. +- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `plugins`). For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). - `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category. Pass `forceRemoteSync: true` to refresh curated plugin state before listing (**under development; do not call from production clients yet**). diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 9da89bb2b4..c9f077fdf4 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1,6 +1,7 @@ use crate::bespoke_event_handling::apply_bespoke_event_handling; use crate::command_exec::CommandExecManager; use crate::command_exec::StartCommandExecParams; +use crate::config_api::apply_runtime_feature_enablement; use crate::error_code::INPUT_TOO_LARGE_ERROR_CODE; use crate::error_code::INTERNAL_ERROR_CODE; use crate::error_code::INVALID_PARAMS_ERROR_CODE; @@ -286,6 +287,7 @@ use codex_state::log_db::LogDbLayer; use codex_utils_absolute_path::AbsolutePathBuf; 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; @@ -374,7 +376,8 @@ pub(crate) struct CodexMessageProcessor { outgoing: Arc, arg0_paths: Arg0DispatchPaths, config: Arc, - cli_overrides: Vec<(String, TomlValue)>, + cli_overrides: Arc>>, + runtime_feature_enablement: Arc>>, cloud_requirements: Arc>, active_login: Arc>>, pending_thread_unloads: Arc>>, @@ -425,7 +428,8 @@ pub(crate) struct CodexMessageProcessorArgs { pub(crate) outgoing: Arc, pub(crate) arg0_paths: Arg0DispatchPaths, pub(crate) config: Arc, - pub(crate) cli_overrides: Vec<(String, TomlValue)>, + pub(crate) cli_overrides: Arc>>, + pub(crate) runtime_feature_enablement: Arc>>, pub(crate) cloud_requirements: Arc>, pub(crate) feedback: CodexFeedback, pub(crate) log_db: Option, @@ -489,6 +493,7 @@ impl CodexMessageProcessor { arg0_paths, config, cli_overrides, + runtime_feature_enablement, cloud_requirements, feedback, log_db, @@ -500,6 +505,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())), @@ -520,7 +526,7 @@ impl CodexMessageProcessor { ) -> Result { let cloud_requirements = self.current_cloud_requirements(); let mut config = codex_core::config::ConfigBuilder::default() - .cli_overrides(self.cli_overrides.clone()) + .cli_overrides(self.current_cli_overrides()) .fallback_cwd(fallback_cwd) .cloud_requirements(cloud_requirements) .build() @@ -530,6 +536,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) @@ -542,6 +549,20 @@ impl CodexMessageProcessor { .unwrap_or_default() } + fn current_cli_overrides(&self) -> Vec<(String, TomlValue)> { + self.cli_overrides + .read() + .map(|guard| guard.clone()) + .unwrap_or_default() + } + + fn current_runtime_feature_enablement(&self) -> BTreeMap { + self.runtime_feature_enablement + .read() + .map(|guard| guard.clone()) + .unwrap_or_default() + } + /// If a client sends `developer_instructions: null` during a mode switch, /// use the built-in instructions for that mode. fn normalize_turn_start_collaboration_mode( @@ -887,7 +908,8 @@ impl CodexMessageProcessor { } ClientRequest::ConfigRead { .. } | ClientRequest::ConfigValueWrite { .. } - | ClientRequest::ConfigBatchWrite { .. } => { + | ClientRequest::ConfigBatchWrite { .. } + | ClientRequest::ExperimentalFeatureEnablementSet { .. } => { warn!("Config request reached CodexMessageProcessor unexpectedly"); } ClientRequest::FsReadFile { .. } @@ -1088,7 +1110,7 @@ impl CodexMessageProcessor { let cloud_requirements = self.cloud_requirements.clone(); let chatgpt_base_url = self.config.chatgpt_base_url.clone(); let codex_home = self.config.codex_home.clone(); - let cli_overrides = self.cli_overrides.clone(); + let cli_overrides = self.current_cli_overrides(); let auth_url = server.auth_url.clone(); tokio::spawn(async move { let (success, error_msg) = match tokio::time::timeout( @@ -1276,11 +1298,9 @@ impl CodexMessageProcessor { self.config.chatgpt_base_url.clone(), self.config.codex_home.clone(), ); - sync_default_client_residency_requirement( - &self.cli_overrides, - self.cloud_requirements.as_ref(), - ) - .await; + let cli_overrides = self.current_cli_overrides(); + sync_default_client_residency_requirement(&cli_overrides, self.cloud_requirements.as_ref()) + .await; self.outgoing .send_response(request_id, LoginAccountResponse::ChatgptAuthTokens {}) @@ -1895,8 +1915,8 @@ impl CodexMessageProcessor { personality, ); typesafe_overrides.ephemeral = ephemeral; - let cli_overrides = self.cli_overrides.clone(); let cloud_requirements = self.current_cloud_requirements(); + let cli_overrides = self.current_cli_overrides(); let listener_task_context = ListenerTaskContext { thread_manager: Arc::clone(&self.thread_manager), thread_state_manager: self.thread_state_manager.clone(), @@ -1906,10 +1926,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, @@ -1982,6 +2004,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>, @@ -1998,6 +2021,7 @@ impl CodexMessageProcessor { typesafe_overrides, &cloud_requirements, &listener_task_context.codex_home, + &runtime_feature_enablement, ) .await { @@ -3500,13 +3524,16 @@ impl CodexMessageProcessor { // Derive a Config using the same logic as new conversation, honoring overrides if provided. let cloud_requirements = self.current_cloud_requirements(); + let cli_overrides = self.current_cli_overrides(); + let runtime_feature_enablement = self.current_runtime_feature_enablement(); let config = match derive_config_for_cwd( - &self.cli_overrides, + &cli_overrides, request_overrides, typesafe_overrides, history_cwd, &cloud_requirements, &self.config.codex_home, + &runtime_feature_enablement, ) .await { @@ -4042,13 +4069,16 @@ impl CodexMessageProcessor { typesafe_overrides.ephemeral = ephemeral.then_some(true); // Derive a Config using the same logic as new conversation, honoring overrides if provided. let cloud_requirements = self.current_cloud_requirements(); + let cli_overrides = self.current_cli_overrides(); + let runtime_feature_enablement = self.current_runtime_feature_enablement(); let config = match derive_config_for_cwd( - &self.cli_overrides, + &cli_overrides, request_overrides, typesafe_overrides, history_cwd, &cloud_requirements, &self.config.codex_home, + &runtime_feature_enablement, ) .await { @@ -7277,12 +7307,13 @@ impl CodexMessageProcessor { WindowsSandboxSetupMode::Unelevated => CoreWindowsSandboxSetupMode::Unelevated, }; let config = Arc::clone(&self.config); - let cli_overrides = self.cli_overrides.clone(); let cloud_requirements = self.current_cloud_requirements(); let command_cwd = params .cwd .map(PathBuf::from) .unwrap_or_else(|| config.cwd.clone()); + let cli_overrides = self.current_cli_overrides(); + let runtime_feature_enablement = self.current_runtime_feature_enablement(); let outgoing = Arc::clone(&self.outgoing); let connection_id = request_id.connection_id; @@ -7297,6 +7328,7 @@ impl CodexMessageProcessor { Some(command_cwd.clone()), &cloud_requirements, &config.codex_home, + &runtime_feature_enablement, ) .await; let setup_result = match derived_config { @@ -7926,6 +7958,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() @@ -7938,13 +7971,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( @@ -7954,6 +7989,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() @@ -7966,14 +8002,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/command_exec.rs b/codex-rs/app-server/src/command_exec.rs index 8f92cd0ba2..a1a042de6a 100644 --- a/codex-rs/app-server/src/command_exec.rs +++ b/codex-rs/app-server/src/command_exec.rs @@ -734,21 +734,21 @@ mod tests { access: ReadOnlyAccess::FullAccess, network_access: false, }; - ExecRequest { - command: vec!["cmd".to_string()], - cwd: PathBuf::from("."), - env: HashMap::new(), - network: None, - expiration: ExecExpiration::DefaultTimeout, - capture_policy: codex_core::exec::ExecCapturePolicy::ShellTool, - sandbox: SandboxType::WindowsRestrictedToken, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - windows_sandbox_private_desktop: false, - sandbox_policy: sandbox_policy.clone(), - file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy), - network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy), - arg0: None, - } + ExecRequest::new( + vec!["cmd".to_string()], + PathBuf::from("."), + HashMap::new(), + /*network*/ None, + ExecExpiration::DefaultTimeout, + codex_core::exec::ExecCapturePolicy::ShellTool, + SandboxType::WindowsRestrictedToken, + WindowsSandboxLevel::Disabled, + /*windows_sandbox_private_desktop*/ false, + sandbox_policy.clone(), + FileSystemSandboxPolicy::from(&sandbox_policy), + NetworkSandboxPolicy::from(&sandbox_policy), + /*arg0*/ None, + ) } #[tokio::test] @@ -846,21 +846,21 @@ mod tests { outgoing: Arc::new(OutgoingMessageSender::new(tx)), request_id: request_id.clone(), process_id: Some("proc-100".to_string()), - exec_request: ExecRequest { - command: vec!["sh".to_string(), "-lc".to_string(), "sleep 30".to_string()], - cwd: PathBuf::from("."), - env: HashMap::new(), - network: None, - expiration: ExecExpiration::Cancellation(CancellationToken::new()), - capture_policy: codex_core::exec::ExecCapturePolicy::ShellTool, - sandbox: SandboxType::None, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - windows_sandbox_private_desktop: false, - sandbox_policy: sandbox_policy.clone(), - file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy), - network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy), - arg0: None, - }, + exec_request: ExecRequest::new( + vec!["sh".to_string(), "-lc".to_string(), "sleep 30".to_string()], + PathBuf::from("."), + HashMap::new(), + /*network*/ None, + ExecExpiration::Cancellation(CancellationToken::new()), + codex_core::exec::ExecCapturePolicy::ShellTool, + SandboxType::None, + WindowsSandboxLevel::Disabled, + /*windows_sandbox_private_desktop*/ false, + sandbox_policy.clone(), + FileSystemSandboxPolicy::from(&sandbox_policy), + NetworkSandboxPolicy::from(&sandbox_policy), + /*arg0*/ None, + ), started_network_proxy: None, tty: false, stream_stdin: false, diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index c27cc4d76d..2be0bb859c 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -9,11 +9,14 @@ use codex_app_server_protocol::ConfigRequirementsReadResponse; use codex_app_server_protocol::ConfigValueWriteParams; use codex_app_server_protocol::ConfigWriteErrorCode; use codex_app_server_protocol::ConfigWriteResponse; +use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams; +use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::NetworkRequirements; use codex_app_server_protocol::SandboxMode; use codex_core::AnalyticsEventsClient; use codex_core::ThreadManager; +use codex_core::config::Config; use codex_core::config::ConfigService; use codex_core::config::ConfigServiceError; use codex_core::config_loader::CloudRequirementsLoader; @@ -24,15 +27,27 @@ use codex_core::config_loader::SandboxModeRequirement as CoreSandboxModeRequirem use codex_core::plugins::PluginId; use codex_core::plugins::collect_plugin_enabled_candidates; use codex_core::plugins::installed_plugin_telemetry_metadata; +use codex_features::canonical_feature_for_key; +use codex_features::feature_for_key; use codex_protocol::config_types::WebSearchMode; use codex_protocol::protocol::Op; use serde_json::json; +use std::collections::BTreeMap; +use std::collections::BTreeSet; use std::path::PathBuf; use std::sync::Arc; use std::sync::RwLock; use toml::Value as TomlValue; use tracing::warn; +const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[ + "apps", + "plugins", + "tool_search", + "tool_suggest", + "tool_call_mcp_elicitation", +]; + #[async_trait] pub(crate) trait UserConfigReloader: Send + Sync { async fn reload_user_config(&self); @@ -56,7 +71,8 @@ impl UserConfigReloader for ThreadManager { #[derive(Clone)] pub(crate) struct ConfigApi { codex_home: PathBuf, - cli_overrides: Vec<(String, TomlValue)>, + cli_overrides: Arc>>, + runtime_feature_enablement: Arc>>, loader_overrides: LoaderOverrides, cloud_requirements: Arc>, user_config_reloader: Arc, @@ -66,7 +82,8 @@ pub(crate) struct ConfigApi { impl ConfigApi { pub(crate) fn new( codex_home: PathBuf, - cli_overrides: Vec<(String, TomlValue)>, + cli_overrides: Arc>>, + runtime_feature_enablement: Arc>>, loader_overrides: LoaderOverrides, cloud_requirements: Arc>, user_config_reloader: Arc, @@ -75,6 +92,7 @@ impl ConfigApi { Self { codex_home, cli_overrides, + runtime_feature_enablement, loader_overrides, cloud_requirements, user_config_reloader, @@ -83,24 +101,87 @@ impl ConfigApi { } fn config_service(&self) -> ConfigService { - let cloud_requirements = self - .cloud_requirements - .read() - .map(|guard| guard.clone()) - .unwrap_or_default(); ConfigService::new( self.codex_home.clone(), - self.cli_overrides.clone(), + self.current_cli_overrides(), self.loader_overrides.clone(), - cloud_requirements, + self.current_cloud_requirements(), ) } + fn current_cli_overrides(&self) -> Vec<(String, TomlValue)> { + self.cli_overrides + .read() + .map(|guard| guard.clone()) + .unwrap_or_default() + } + + fn current_runtime_feature_enablement(&self) -> BTreeMap { + self.runtime_feature_enablement + .read() + .map(|guard| guard.clone()) + .unwrap_or_default() + } + + fn current_cloud_requirements(&self) -> CloudRequirementsLoader { + self.cloud_requirements + .read() + .map(|guard| guard.clone()) + .unwrap_or_default() + } + + async fn load_latest_config( + &self, + fallback_cwd: Option, + ) -> Result { + let mut config = codex_core::config::ConfigBuilder::default() + .codex_home(self.codex_home.clone()) + .cli_overrides(self.current_cli_overrides()) + .loader_overrides(self.loader_overrides.clone()) + .fallback_cwd(fallback_cwd) + .cloud_requirements(self.current_cloud_requirements()) + .build() + .await + .map_err(|err| JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to resolve feature override precedence: {err}"), + data: None, + })?; + apply_runtime_feature_enablement(&mut config, &self.current_runtime_feature_enablement()); + Ok(config) + } + pub(crate) async fn read( &self, params: ConfigReadParams, ) -> Result { - self.config_service().read(params).await.map_err(map_error) + let fallback_cwd = params.cwd.as_ref().map(PathBuf::from); + let mut response = self + .config_service() + .read(params) + .await + .map_err(map_error)?; + let config = self.load_latest_config(fallback_cwd).await?; + for feature_key in SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT { + let Some(feature) = feature_for_key(feature_key) else { + continue; + }; + let features = response + .config + .additional + .entry("features".to_string()) + .or_insert_with(|| json!({})); + if !features.is_object() { + *features = json!({}); + } + if let Some(features) = features.as_object_mut() { + features.insert( + (*feature_key).to_string(), + json!(config.features.enabled(feature)), + ); + } + } + Ok(response) } pub(crate) async fn config_requirements_read( @@ -154,6 +235,68 @@ impl ConfigApi { Ok(response) } + pub(crate) async fn set_experimental_feature_enablement( + &self, + params: ExperimentalFeatureEnablementSetParams, + ) -> Result { + let ExperimentalFeatureEnablementSetParams { enablement } = params; + for key in enablement.keys() { + if canonical_feature_for_key(key).is_some() { + if SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.contains(&key.as_str()) { + continue; + } + + return Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!( + "unsupported feature enablement `{key}`: currently supported features are {}", + SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.join(", ") + ), + data: None, + }); + } + + let message = if let Some(feature) = feature_for_key(key) { + format!( + "invalid feature enablement `{key}`: use canonical feature key `{}`", + feature.key() + ) + } else { + format!("invalid feature enablement `{key}`") + }; + return Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message, + data: None, + }); + } + + if enablement.is_empty() { + return Ok(ExperimentalFeatureEnablementSetResponse { enablement }); + } + + { + let mut runtime_feature_enablement = + self.runtime_feature_enablement + .write() + .map_err(|_| JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: "failed to update feature enablement".to_string(), + data: None, + })?; + runtime_feature_enablement.extend( + enablement + .iter() + .map(|(name, enabled)| (name.clone(), *enabled)), + ); + } + + self.load_latest_config(/*fallback_cwd*/ None).await?; + self.user_config_reloader.reload_user_config().await; + + Ok(ExperimentalFeatureEnablementSetResponse { enablement }) + } + fn emit_plugin_toggle_events(&self, pending_changes: std::collections::BTreeMap) { for (plugin_id, enabled) in pending_changes { let Ok(plugin_id) = PluginId::parse(&plugin_id) else { @@ -170,6 +313,49 @@ impl ConfigApi { } } +pub(crate) fn protected_feature_keys( + config_layer_stack: &codex_core::config_loader::ConfigLayerStack, +) -> BTreeSet { + let mut protected_features = config_layer_stack + .effective_config() + .get("features") + .and_then(toml::Value::as_table) + .map(|features| features.keys().cloned().collect::>()) + .unwrap_or_default(); + + if let Some(feature_requirements) = config_layer_stack + .requirements_toml() + .feature_requirements + .as_ref() + { + protected_features.extend(feature_requirements.entries.keys().cloned()); + } + + protected_features +} + +pub(crate) fn apply_runtime_feature_enablement( + config: &mut Config, + runtime_feature_enablement: &BTreeMap, +) { + let protected_features = protected_feature_keys(&config.config_layer_stack); + for (name, enabled) in runtime_feature_enablement { + if protected_features.contains(name) { + continue; + } + let Some(feature) = feature_for_key(name) else { + continue; + }; + if let Err(err) = config.features.set_enabled(feature, *enabled) { + warn!( + feature = name, + error = %err, + "failed to apply runtime feature enablement" + ); + } + } +} + fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigRequirements { ConfigRequirements { allowed_approval_policies: requirements.allowed_approval_policies.map(|policies| { @@ -267,6 +453,7 @@ mod tests { use codex_core::AuthManager; use codex_core::CodexAuth; 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; @@ -394,6 +581,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"); @@ -409,7 +656,8 @@ mod tests { let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); 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 7f7123895c..feca709513 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::collections::HashSet; use std::future::Future; use std::sync::Arc; @@ -30,6 +31,7 @@ use codex_app_server_protocol::ConfigReadParams; use codex_app_server_protocol::ConfigValueWriteParams; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::ExperimentalApi; +use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams; use codex_app_server_protocol::ExternalAgentConfigDetectParams; use codex_app_server_protocol::ExternalAgentConfigImportParams; use codex_app_server_protocol::FsCopyParams; @@ -229,6 +231,8 @@ impl MessageProcessor { .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); + let cli_overrides = Arc::new(RwLock::new(cli_overrides)); + let runtime_feature_enablement = Arc::new(RwLock::new(BTreeMap::new())); let cloud_requirements = Arc::new(RwLock::new(cloud_requirements)); let codex_message_processor = CodexMessageProcessor::new(CodexMessageProcessorArgs { auth_manager: auth_manager.clone(), @@ -237,6 +241,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, @@ -249,6 +254,7 @@ impl MessageProcessor { let config_api = ConfigApi::new( config.codex_home.clone(), cli_overrides, + runtime_feature_enablement, loader_overrides, cloud_requirements, thread_manager, @@ -703,6 +709,16 @@ impl MessageProcessor { ) .await; } + ClientRequest::ExperimentalFeatureEnablementSet { request_id, params } => { + self.handle_experimental_feature_enablement_set( + ConnectionRequestId { + connection_id, + request_id, + }, + params, + ) + .await; + } ClientRequest::ConfigRequirementsRead { request_id, params: _, @@ -851,7 +867,30 @@ impl MessageProcessor { request_id: ConnectionRequestId, params: ConfigBatchWriteParams, ) { - match self.config_api.batch_write(params).await { + self.handle_config_mutation_result(request_id, self.config_api.batch_write(params).await) + .await; + } + + async fn handle_experimental_feature_enablement_set( + &self, + request_id: ConnectionRequestId, + params: ExperimentalFeatureEnablementSetParams, + ) { + self.handle_config_mutation_result( + request_id, + self.config_api + .set_experimental_feature_enablement(params) + .await, + ) + .await; + } + + async fn handle_config_mutation_result( + &self, + request_id: ConnectionRequestId, + result: std::result::Result, + ) { + match result { Ok(response) => { self.codex_message_processor.clear_plugin_related_caches(); self.codex_message_processor diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 067e06ec43..fe21cdafff 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -465,6 +465,16 @@ impl McpProcess { self.send_request("experimentalFeature/list", params).await } + /// Send an `experimentalFeature/enablement/set` JSON-RPC request. + pub async fn send_experimental_feature_enablement_set_request( + &mut self, + params: codex_app_server_protocol::ExperimentalFeatureEnablementSetParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("experimentalFeature/enablement/set", params) + .await + } + /// Send an `app/list` JSON-RPC request. pub async fn send_apps_list_request(&mut self, params: AppsListParams) -> anyhow::Result { let params = Some(serde_json::to_value(params)?); diff --git a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs index 7ff5f6fe39..3063830d5a 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs @@ -3,16 +3,24 @@ use std::time::Duration; use anyhow::Result; use app_test_support::McpProcess; use app_test_support::to_response; +use codex_app_server_protocol::ConfigReadParams; +use codex_app_server_protocol::ConfigReadResponse; use codex_app_server_protocol::ExperimentalFeature; +use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams; +use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse; use codex_app_server_protocol::ExperimentalFeatureListParams; use codex_app_server_protocol::ExperimentalFeatureListResponse; use codex_app_server_protocol::ExperimentalFeatureStage; +use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_core::config::ConfigBuilder; use codex_features::FEATURES; use codex_features::Stage; use pretty_assertions::assert_eq; +use serde::de::DeserializeOwned; +use serde_json::json; +use std::collections::BTreeMap; use tempfile::TempDir; use tokio::time::timeout; @@ -34,13 +42,7 @@ async fn experimental_feature_list_returns_feature_metadata_with_stage() -> Resu .send_experimental_feature_list_request(ExperimentalFeatureListParams::default()) .await?; - let response: JSONRPCResponse = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(request_id)), - ) - .await??; - - let actual = to_response::(response)?; + let actual = read_response::(&mut mcp, request_id).await?; let expected_data = FEATURES .iter() .map(|spec| { @@ -82,3 +84,240 @@ async fn experimental_feature_list_returns_feature_metadata_with_stage() -> Resu assert_eq!(actual, expected); Ok(()) } + +#[tokio::test] +async fn experimental_feature_enablement_set_applies_to_global_and_thread_config_reads() +-> Result<()> { + let codex_home = TempDir::new()?; + let project_cwd = codex_home.path().join("project"); + std::fs::create_dir_all(&project_cwd)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let actual = + set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)])) + .await?; + assert_eq!( + actual, + ExperimentalFeatureEnablementSetResponse { + enablement: BTreeMap::from([("apps".to_string(), true)]), + } + ); + + for cwd in [None, Some(project_cwd.display().to_string())] { + let ConfigReadResponse { config, .. } = read_config(&mut mcp, cwd).await?; + + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("apps")), + Some(&json!(true)) + ); + } + + Ok(()) +} + +#[tokio::test] +async fn experimental_feature_enablement_set_does_not_override_user_config() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + "[features]\napps = false\n", + )?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let actual = + set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)])) + .await?; + assert_eq!( + actual, + ExperimentalFeatureEnablementSetResponse { + enablement: BTreeMap::from([("apps".to_string(), true)]), + } + ); + + let ConfigReadResponse { config, .. } = read_config(&mut mcp, /*cwd*/ None).await?; + + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("apps")), + Some(&json!(false)) + ); + + Ok(()) +} + +#[tokio::test] +async fn experimental_feature_enablement_set_only_updates_named_features() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)])) + .await?; + let actual = set_experimental_feature_enablement( + &mut mcp, + BTreeMap::from([ + ("plugins".to_string(), true), + ("tool_search".to_string(), true), + ("tool_suggest".to_string(), true), + ("tool_call_mcp_elicitation".to_string(), false), + ]), + ) + .await?; + + assert_eq!( + actual, + ExperimentalFeatureEnablementSetResponse { + enablement: BTreeMap::from([ + ("plugins".to_string(), true), + ("tool_search".to_string(), true), + ("tool_suggest".to_string(), true), + ("tool_call_mcp_elicitation".to_string(), false), + ]), + } + ); + + 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)) + ); + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("tool_search")), + Some(&json!(true)) + ); + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("tool_suggest")), + Some(&json!(true)) + ); + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("tool_call_mcp_elicitation")), + Some(&json!(false)) + ); + + Ok(()) +} + +#[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, tool_search, tool_suggest, tool_call_mcp_elicitation"), + "{}", + error.message + ); + + Ok(()) +} + +async fn set_experimental_feature_enablement( + mcp: &mut McpProcess, + enablement: BTreeMap, +) -> Result { + let request_id = mcp + .send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams { + enablement, + }) + .await?; + read_response(mcp, request_id).await +} + +async fn read_config(mcp: &mut McpProcess, cwd: Option) -> Result { + let request_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: false, + cwd, + }) + .await?; + read_response(mcp, request_id).await +} + +async fn read_response(mcp: &mut McpProcess, request_id: i64) -> Result { + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + to_response(response) +} diff --git a/codex-rs/core/README.md b/codex-rs/core/README.md index c27ec5460e..c154ef463d 100644 --- a/codex-rs/core/README.md +++ b/codex-rs/core/README.md @@ -51,7 +51,6 @@ Expects the binary containing `codex-core` to run the equivalent of `codex sandb Legacy `SandboxPolicy` / `sandbox_mode` configs are still supported on Linux. They can continue to use the legacy Landlock path when the split filesystem policy is sandbox-equivalent to the legacy model after `cwd` resolution. - Split filesystem policies that need direct `FileSystemSandboxPolicy` enforcement, such as read-only or denied carveouts under a broader writable root, automatically route through bubblewrap. The legacy Landlock path is used @@ -83,13 +82,18 @@ backend-managed system read roots required for basic execution, such as `C:\ProgramData`. When it is `false`, those extra system roots are omitted. The unelevated restricted-token backend still supports the legacy full-read -Windows model only. Restricted read-only policies continue to fail closed there -instead of running with weaker read enforcement. +Windows model for legacy `ReadOnly` and `WorkspaceWrite` behavior. It also +supports a narrow split-filesystem subset: full-read split policies whose +writable roots still match the legacy `WorkspaceWrite` root set, but add extra +read-only carveouts under those writable roots. New `[permissions]` / split filesystem policies remain supported on Windows only when they round-trip through the legacy `SandboxPolicy` model without -changing semantics. Richer split-only carveouts still fail closed instead of -running with weaker enforcement. +changing semantics. Policies that would require direct read restriction, +explicit unreadable carveouts, reopened writable descendants under read-only +carveouts, different writable root sets, or split carveout support in the +elevated setup/runner backend still fail closed instead of running with weaker +enforcement. ### All Platforms diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 0b7cf3ced9..3e2d7ff689 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -485,6 +485,9 @@ "tool_call_mcp_elicitation": { "type": "boolean" }, + "tool_search": { + "type": "boolean" + }, "tool_suggest": { "type": "boolean" }, @@ -2099,6 +2102,9 @@ "tool_call_mcp_elicitation": { "type": "boolean" }, + "tool_search": { + "type": "boolean" + }, "tool_suggest": { "type": "boolean" }, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 84e48a07b6..4bf30ad665 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -2220,15 +2220,6 @@ impl Session { self.persist_rollout_items(&rollout_items).await; } - // Append the current session's initial context after the reconstructed history. - let initial_context = self.build_initial_context(&turn_context).await; - self.record_conversation_items(&turn_context, &initial_context) - .await; - { - let mut state = self.state.lock().await; - state.set_reference_context_item(Some(turn_context.to_turn_context_item())); - } - // Forked threads should remain file-backed immediately after startup. self.ensure_rollout_materialized().await; diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 4b2514d912..424d352221 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -1115,18 +1115,12 @@ async fn recompute_token_usage_updates_model_context_window() { #[tokio::test] async fn record_initial_history_reconstructs_forked_transcript() { let (session, turn_context) = make_session_and_context().await; - let (rollout_items, mut expected) = sample_rollout(&session, &turn_context).await; + let (rollout_items, expected) = sample_rollout(&session, &turn_context).await; session .record_initial_history(InitialHistory::Forked(rollout_items)) .await; - let reconstruction_turn = session.new_default_turn().await; - expected.extend( - session - .build_initial_context(reconstruction_turn.as_ref()) - .await, - ); let history = session.state.lock().await.clone_history(); assert_eq!(expected, history.raw_items()); } @@ -1244,7 +1238,7 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< let request = first_forked_request.single_request(); let snapshot = context_snapshot::format_labeled_requests_snapshot( - "First request after fork when fork startup changes approval policy and the first forked turn changes approval policy again and enters plan mode.", + "First request after fork when startup preserves the parent baseline, the fork changes approval policy, and the first forked turn enters plan mode.", &[("First Forked Turn Request", &request)], &ContextSnapshotOptions::default() .render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 96 }) @@ -1309,7 +1303,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { text_elements: Vec::new(), }, )), - RolloutItem::TurnContext(previous_context_item), + RolloutItem::TurnContext(previous_context_item.clone()), RolloutItem::EventMsg(EventMsg::TurnComplete( codex_protocol::protocol::TurnCompleteEvent { turn_id, @@ -1322,6 +1316,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { .record_initial_history(InitialHistory::Forked(rollout_items)) .await; + let history = session.clone_history().await; assert_eq!( session.previous_turn_settings().await, Some(PreviousTurnSettings { @@ -1329,6 +1324,13 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { realtime_active: Some(turn_context.realtime_active), }) ); + assert_eq!(history.raw_items(), &[]); + assert_eq!( + serde_json::to_value(session.reference_context_item().await) + .expect("serialize fork reference context item"), + serde_json::to_value(Some(previous_context_item)) + .expect("serialize expected reference context item") + ); } #[tokio::test] diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index c122187901..34fd41c083 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -193,6 +193,10 @@ pub(crate) fn build_settings_update_items( exec_policy: &Policy, personality_feature_enabled: bool, ) -> Vec { + // TODO(ccunningham): build_settings_update_items still does not cover every + // model-visible item emitted by build_initial_context. Persist the remaining + // inputs or add explicit replay events so fork/resume can diff everything + // deterministically. let contextual_user_message = build_environment_update_item(previous, next, shell); let developer_update_sections = [ // Keep model-switch instructions first so model-specific guidance is read before diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 0229e1dd57..a4399ddc46 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -1,6 +1,7 @@ #[cfg(unix)] use std::os::unix::process::ExitStatusExt; +use std::collections::BTreeSet; use std::collections::HashMap; use std::io; use std::path::Path; @@ -32,7 +33,7 @@ use crate::spawn::StdioPolicy; use crate::spawn::spawn_child_async; use crate::text_encoding::bytes_to_string_smart; use codex_network_proxy::NetworkProxy; -#[cfg(any(target_os = "windows", test))] +use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::permissions::FileSystemSandboxKind; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; @@ -41,6 +42,7 @@ use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxTransformRequest; use codex_sandboxing::SandboxType; use codex_sandboxing::SandboxablePreference; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; use codex_utils_pty::process_group::kill_child_process_group; @@ -91,6 +93,17 @@ pub struct ExecParams { pub arg0: Option, } +/// Extra filesystem deny-write carveouts for the non-elevated Windows +/// restricted-token backend. +/// +/// These are applied on top of the legacy `WorkspaceWrite` allow set, so we +/// can support a narrow split-policy subset without changing legacy Windows +/// sandbox semantics. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct WindowsRestrictedTokenFilesystemOverlay { + pub(crate) additional_deny_write_paths: Vec, +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum ExecCapturePolicy { /// Shell-like execs keep the historical output cap and timeout behavior. @@ -269,7 +282,7 @@ pub fn build_exec_request( expiration, capture_policy, }; - let exec_req = manager + let mut exec_req = manager .transform(SandboxTransformRequest { command, policy: sandbox_policy, @@ -288,6 +301,16 @@ pub fn build_exec_request( }) .map(|request| ExecRequest::from_sandbox_exec_request(request, options)) .map_err(CodexErr::from)?; + exec_req.windows_restricted_token_filesystem_overlay = + resolve_windows_restricted_token_filesystem_overlay( + exec_req.sandbox, + &exec_req.sandbox_policy, + &exec_req.file_system_sandbox_policy, + exec_req.network_sandbox_policy, + sandbox_cwd, + exec_req.windows_sandbox_level, + ) + .map_err(CodexErr::UnsupportedOperation)?; Ok(exec_req) } @@ -310,6 +333,7 @@ pub(crate) async fn execute_exec_request( sandbox_policy: _sandbox_policy_from_env, file_system_sandbox_policy, network_sandbox_policy, + windows_restricted_token_filesystem_overlay, arg0, } = exec_request; let _ = _sandbox_policy_from_env; @@ -334,6 +358,7 @@ pub(crate) async fn execute_exec_request( sandbox, sandbox_policy, &file_system_sandbox_policy, + windows_restricted_token_filesystem_overlay.as_ref(), network_sandbox_policy, stdout_stream, after_spawn, @@ -413,11 +438,11 @@ fn record_windows_sandbox_spawn_failure( async fn exec_windows_sandbox( params: ExecParams, sandbox_policy: &SandboxPolicy, + windows_restricted_token_filesystem_overlay: Option<&WindowsRestrictedTokenFilesystemOverlay>, ) -> Result { use crate::config::find_codex_home; - use codex_protocol::config_types::WindowsSandboxLevel; - use codex_windows_sandbox::run_windows_sandbox_capture; use codex_windows_sandbox::run_windows_sandbox_capture_elevated; + use codex_windows_sandbox::run_windows_sandbox_capture_with_extra_deny_write_paths; let ExecParams { command, @@ -456,6 +481,15 @@ async fn exec_windows_sandbox( let command_path = command.first().cloned(); let sandbox_level = windows_sandbox_level; let use_elevated = matches!(sandbox_level, WindowsSandboxLevel::Elevated); + let additional_deny_write_paths = windows_restricted_token_filesystem_overlay + .map(|overlay| { + overlay + .additional_deny_write_paths + .iter() + .map(AbsolutePathBuf::to_path_buf) + .collect::>() + }) + .unwrap_or_default(); let spawn_res = tokio::task::spawn_blocking(move || { if use_elevated { run_windows_sandbox_capture_elevated( @@ -469,7 +503,7 @@ async fn exec_windows_sandbox( windows_sandbox_private_desktop, ) } else { - run_windows_sandbox_capture( + run_windows_sandbox_capture_with_extra_deny_write_paths( policy_str.as_str(), &sandbox_cwd, codex_home.as_ref(), @@ -477,6 +511,7 @@ async fn exec_windows_sandbox( &cwd, env, timeout_ms, + &additional_deny_write_paths, windows_sandbox_private_desktop, ) } @@ -785,29 +820,25 @@ impl Default for ExecToolCallOutput { } } -#[cfg_attr(not(target_os = "windows"), allow(unused_variables))] +#[allow(clippy::too_many_arguments)] async fn exec( params: ExecParams, - sandbox: SandboxType, - sandbox_policy: &SandboxPolicy, - file_system_sandbox_policy: &FileSystemSandboxPolicy, + _sandbox: SandboxType, + _sandbox_policy: &SandboxPolicy, + _file_system_sandbox_policy: &FileSystemSandboxPolicy, + _windows_restricted_token_filesystem_overlay: Option<&WindowsRestrictedTokenFilesystemOverlay>, network_sandbox_policy: NetworkSandboxPolicy, stdout_stream: Option, after_spawn: Option>, ) -> Result { #[cfg(target_os = "windows")] - if sandbox == SandboxType::WindowsRestrictedToken { - let support = windows_restricted_token_sandbox_support( - sandbox, - params.windows_sandbox_level, - sandbox_policy, - file_system_sandbox_policy, - network_sandbox_policy, - ); - if let Some(reason) = support.unsupported_reason { - return Err(CodexErr::Io(io::Error::other(reason))); - } - return exec_windows_sandbox(params, sandbox_policy).await; + if _sandbox == SandboxType::WindowsRestrictedToken { + return exec_windows_sandbox( + params, + _sandbox_policy, + _windows_restricted_token_filesystem_overlay, + ) + .await; } let ExecParams { command, @@ -852,54 +883,175 @@ async fn exec( } #[cfg_attr(not(target_os = "windows"), allow(dead_code))] -#[derive(Debug, PartialEq, Eq)] -struct WindowsRestrictedTokenSandboxSupport { - should_use: bool, - unsupported_reason: Option, -} - -#[cfg(any(target_os = "windows", test))] -fn windows_restricted_token_sandbox_support( +fn should_use_windows_restricted_token_sandbox( sandbox: SandboxType, - windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel, sandbox_policy: &SandboxPolicy, file_system_sandbox_policy: &FileSystemSandboxPolicy, - network_sandbox_policy: NetworkSandboxPolicy, -) -> WindowsRestrictedTokenSandboxSupport { - if sandbox != SandboxType::WindowsRestrictedToken { - return WindowsRestrictedTokenSandboxSupport { - should_use: false, - unsupported_reason: None, - }; - } - - // Windows currently reuses SandboxType::WindowsRestrictedToken for both - // the legacy restricted-token backend and the elevated setup/runner path. - // The sandbox level decides whether restricted read-only policies are - // supported. - let should_use = file_system_sandbox_policy.kind == FileSystemSandboxKind::Restricted +) -> bool { + sandbox == SandboxType::WindowsRestrictedToken + && file_system_sandbox_policy.kind == FileSystemSandboxKind::Restricted && !matches!( sandbox_policy, SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } ) - && (matches!( - windows_sandbox_level, - codex_protocol::config_types::WindowsSandboxLevel::Elevated - ) || sandbox_policy.has_full_disk_read_access()); +} - let unsupported_reason = if should_use { - None - } else { - Some(format!( +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) fn unsupported_windows_restricted_token_sandbox_reason( + sandbox: SandboxType, + sandbox_policy: &SandboxPolicy, + file_system_sandbox_policy: &FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, + sandbox_policy_cwd: &Path, + windows_sandbox_level: WindowsSandboxLevel, +) -> Option { + resolve_windows_restricted_token_filesystem_overlay( + sandbox, + sandbox_policy, + file_system_sandbox_policy, + network_sandbox_policy, + sandbox_policy_cwd, + windows_sandbox_level, + ) + .err() +} + +pub(crate) fn resolve_windows_restricted_token_filesystem_overlay( + sandbox: SandboxType, + sandbox_policy: &SandboxPolicy, + file_system_sandbox_policy: &FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, + sandbox_policy_cwd: &Path, + windows_sandbox_level: WindowsSandboxLevel, +) -> std::result::Result, String> { + if sandbox != SandboxType::WindowsRestrictedToken { + return Ok(None); + } + + let needs_direct_runtime_enforcement = file_system_sandbox_policy + .needs_direct_runtime_enforcement(network_sandbox_policy, sandbox_policy_cwd); + + if should_use_windows_restricted_token_sandbox( + sandbox, + sandbox_policy, + file_system_sandbox_policy, + ) && !needs_direct_runtime_enforcement + { + return Ok(None); + } + + if !should_use_windows_restricted_token_sandbox( + sandbox, + sandbox_policy, + file_system_sandbox_policy, + ) { + return Err(format!( "windows sandbox backend cannot enforce file_system={:?}, network={network_sandbox_policy:?}, legacy_policy={sandbox_policy:?}; refusing to run unsandboxed", file_system_sandbox_policy.kind, - )) - }; - - WindowsRestrictedTokenSandboxSupport { - should_use, - unsupported_reason, + )); } + + if windows_sandbox_level != WindowsSandboxLevel::RestrictedToken { + return Err( + "windows elevated sandbox backend cannot enforce split filesystem permissions directly; refusing to run unsandboxed" + .to_string(), + ); + } + + if !file_system_sandbox_policy.has_full_disk_read_access() { + return Err( + "windows unelevated restricted-token sandbox cannot enforce split filesystem read restrictions directly; refusing to run unsandboxed" + .to_string(), + ); + } + + if !file_system_sandbox_policy + .get_unreadable_roots_with_cwd(sandbox_policy_cwd) + .is_empty() + { + return Err( + "windows unelevated restricted-token sandbox cannot enforce unreadable split filesystem carveouts directly; refusing to run unsandboxed" + .to_string(), + ); + } + + let legacy_writable_roots = sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd); + let split_writable_roots = + file_system_sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd); + let legacy_root_paths: BTreeSet = legacy_writable_roots + .iter() + .map(|root| normalize_windows_overlay_path(root.root.as_path())) + .collect::>()?; + let split_root_paths: BTreeSet = split_writable_roots + .iter() + .map(|root| normalize_windows_overlay_path(root.root.as_path())) + .collect::>()?; + + if legacy_root_paths != split_root_paths { + return Err( + "windows unelevated restricted-token sandbox cannot enforce split writable root sets directly; refusing to run unsandboxed" + .to_string(), + ); + } + + for writable_root in &split_writable_roots { + for read_only_subpath in &writable_root.read_only_subpaths { + if split_writable_roots.iter().any(|candidate| { + candidate.root.as_path() != writable_root.root.as_path() + && candidate + .root + .as_path() + .starts_with(read_only_subpath.as_path()) + }) { + return Err( + "windows unelevated restricted-token sandbox cannot reopen writable descendants under read-only carveouts directly; refusing to run unsandboxed" + .to_string(), + ); + } + } + } + + let mut additional_deny_write_paths = BTreeSet::new(); + for split_root in &split_writable_roots { + let split_root_path = normalize_windows_overlay_path(split_root.root.as_path())?; + let Some(legacy_root) = legacy_writable_roots.iter().find(|candidate| { + normalize_windows_overlay_path(candidate.root.as_path()) + .is_ok_and(|candidate_path| candidate_path == split_root_path) + }) else { + return Err( + "windows unelevated restricted-token sandbox cannot enforce split writable root sets directly; refusing to run unsandboxed" + .to_string(), + ); + }; + + for read_only_subpath in &split_root.read_only_subpaths { + if !legacy_root + .read_only_subpaths + .iter() + .any(|candidate| candidate == read_only_subpath) + { + additional_deny_write_paths + .insert(normalize_windows_overlay_path(read_only_subpath.as_path())?); + } + } + } + + if additional_deny_write_paths.is_empty() { + return Ok(None); + } + + Ok(Some(WindowsRestrictedTokenFilesystemOverlay { + additional_deny_write_paths: additional_deny_write_paths + .into_iter() + .map(|path| AbsolutePathBuf::from_absolute_path(path).map_err(|err| err.to_string())) + .collect::>()?, + })) +} + +fn normalize_windows_overlay_path(path: &Path) -> std::result::Result { + AbsolutePathBuf::from_absolute_path(dunce::simplified(path)) + .map(AbsolutePathBuf::into_path_buf) + .map_err(|err| err.to_string()) } /// Consumes the output of a child process according to the configured capture diff --git a/codex-rs/core/src/exec_tests.rs b/codex-rs/core/src/exec_tests.rs index 6f452d6e8a..074db95e1c 100644 --- a/codex-rs/core/src/exec_tests.rs +++ b/codex-rs/core/src/exec_tests.rs @@ -269,6 +269,7 @@ async fn exec_full_buffer_capture_ignores_expiration() -> Result<()> { SandboxType::None, &SandboxPolicy::DangerFullAccess, &FileSystemSandboxPolicy::unrestricted(), + /*windows_restricted_token_filesystem_overlay*/ None, NetworkSandboxPolicy::Enabled, /*stdout_stream*/ None, /*after_spawn*/ None, @@ -308,6 +309,7 @@ async fn exec_full_buffer_capture_keeps_io_drain_timeout_when_descendant_holds_p SandboxType::None, &SandboxPolicy::DangerFullAccess, &FileSystemSandboxPolicy::unrestricted(), + /*windows_restricted_token_filesystem_overlay*/ None, NetworkSandboxPolicy::Enabled, /*stdout_stream*/ None, /*after_spawn*/ None, @@ -376,42 +378,30 @@ fn windows_restricted_token_skips_external_sandbox_policies() { let policy = SandboxPolicy::ExternalSandbox { network_access: codex_protocol::protocol::NetworkAccess::Restricted, }; - let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]); + let file_system_policy = FileSystemSandboxPolicy::from(&policy); assert_eq!( - windows_restricted_token_sandbox_support( + should_use_windows_restricted_token_sandbox( SandboxType::WindowsRestrictedToken, - WindowsSandboxLevel::Disabled, &policy, &file_system_policy, - NetworkSandboxPolicy::Restricted, ), - WindowsRestrictedTokenSandboxSupport { - should_use: false, - unsupported_reason: Some( - "windows sandbox backend cannot enforce file_system=Restricted, network=Restricted, legacy_policy=ExternalSandbox { network_access: Restricted }; refusing to run unsandboxed".to_string() - ), - } + false ); } #[test] fn windows_restricted_token_runs_for_legacy_restricted_policies() { let policy = SandboxPolicy::new_read_only_policy(); - let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]); + let file_system_policy = FileSystemSandboxPolicy::from(&policy); assert_eq!( - windows_restricted_token_sandbox_support( + should_use_windows_restricted_token_sandbox( SandboxType::WindowsRestrictedToken, - WindowsSandboxLevel::Disabled, &policy, &file_system_policy, - NetworkSandboxPolicy::Restricted, ), - WindowsRestrictedTokenSandboxSupport { - should_use: true, - unsupported_reason: None, - } + true ); } @@ -421,70 +411,39 @@ fn windows_restricted_token_rejects_network_only_restrictions() { network_access: codex_protocol::protocol::NetworkAccess::Restricted, }; let file_system_policy = FileSystemSandboxPolicy::unrestricted(); + let sandbox_policy_cwd = std::env::current_dir().expect("cwd"); assert_eq!( - windows_restricted_token_sandbox_support( - SandboxType::WindowsRestrictedToken, - WindowsSandboxLevel::Disabled, - &policy, - &file_system_policy, - NetworkSandboxPolicy::Restricted, - ), - WindowsRestrictedTokenSandboxSupport { - should_use: false, - unsupported_reason: Some( - "windows sandbox backend cannot enforce file_system=Unrestricted, network=Restricted, legacy_policy=ExternalSandbox { network_access: Restricted }; refusing to run unsandboxed".to_string() + unsupported_windows_restricted_token_sandbox_reason( + SandboxType::WindowsRestrictedToken, + &policy, + &file_system_policy, + NetworkSandboxPolicy::Restricted, + &sandbox_policy_cwd, + WindowsSandboxLevel::RestrictedToken, ), - } - ); + Some( + "windows sandbox backend cannot enforce file_system=Unrestricted, network=Restricted, legacy_policy=ExternalSandbox { network_access: Restricted }; refusing to run unsandboxed".to_string() + ) + ); } #[test] fn windows_restricted_token_allows_legacy_restricted_policies() { let policy = SandboxPolicy::new_read_only_policy(); - let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]); - - assert_eq!( - windows_restricted_token_sandbox_support( - SandboxType::WindowsRestrictedToken, - WindowsSandboxLevel::Disabled, - &policy, - &file_system_policy, - NetworkSandboxPolicy::Restricted, - ), - WindowsRestrictedTokenSandboxSupport { - should_use: true, - unsupported_reason: None, - } - ); -} - -#[test] -fn windows_restricted_token_rejects_restricted_read_only_policies() { - let policy = SandboxPolicy::ReadOnly { - access: codex_protocol::protocol::ReadOnlyAccess::Restricted { - include_platform_defaults: true, - readable_roots: vec![], - }, - network_access: false, - }; let file_system_policy = FileSystemSandboxPolicy::from(&policy); + let sandbox_policy_cwd = std::env::current_dir().expect("cwd"); assert_eq!( - windows_restricted_token_sandbox_support( + unsupported_windows_restricted_token_sandbox_reason( SandboxType::WindowsRestrictedToken, - WindowsSandboxLevel::Disabled, &policy, &file_system_policy, NetworkSandboxPolicy::Restricted, + &sandbox_policy_cwd, + WindowsSandboxLevel::RestrictedToken, ), - WindowsRestrictedTokenSandboxSupport { - should_use: false, - unsupported_reason: Some( - "windows sandbox backend cannot enforce file_system=Restricted, network=Restricted, legacy_policy=ReadOnly { access: Restricted { include_platform_defaults: true, readable_roots: [] }, network_access: false }; refusing to run unsandboxed".to_string() - ), - }, - "restricted-token should fail closed for restricted read-only policies" + None ); } @@ -494,50 +453,213 @@ fn windows_restricted_token_allows_legacy_workspace_write_policies() { writable_roots: vec![], read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, }; let file_system_policy = FileSystemSandboxPolicy::from(&policy); + let sandbox_policy_cwd = std::env::current_dir().expect("cwd"); assert_eq!( - windows_restricted_token_sandbox_support( + unsupported_windows_restricted_token_sandbox_reason( SandboxType::WindowsRestrictedToken, - WindowsSandboxLevel::Disabled, &policy, &file_system_policy, NetworkSandboxPolicy::Restricted, + &sandbox_policy_cwd, + WindowsSandboxLevel::RestrictedToken, ), - WindowsRestrictedTokenSandboxSupport { - should_use: true, - unsupported_reason: None, - } + None ); } #[test] -fn windows_elevated_sandbox_allows_restricted_read_only_policies() { - let policy = SandboxPolicy::ReadOnly { - access: codex_protocol::protocol::ReadOnlyAccess::Restricted { - include_platform_defaults: true, - readable_roots: vec![], - }, +fn windows_restricted_token_rejects_split_only_filesystem_policies() { + let temp_dir = tempfile::TempDir::new().expect("tempdir"); + let docs = temp_dir.path().join("docs"); + std::fs::create_dir_all(&docs).expect("create docs"); + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, }; - let file_system_policy = FileSystemSandboxPolicy::from(&policy); + let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Write, + }, + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Path { + path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs) + .expect("absolute docs"), + }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + ]); assert_eq!( - windows_restricted_token_sandbox_support( + unsupported_windows_restricted_token_sandbox_reason( SandboxType::WindowsRestrictedToken, - WindowsSandboxLevel::Elevated, &policy, &file_system_policy, NetworkSandboxPolicy::Restricted, + temp_dir.path(), + WindowsSandboxLevel::RestrictedToken, ), - WindowsRestrictedTokenSandboxSupport { - should_use: true, - unsupported_reason: None, + Some( + "windows unelevated restricted-token sandbox cannot enforce split filesystem read restrictions directly; refusing to run unsandboxed" + .to_string() + ) + ); +} + +#[test] +fn windows_restricted_token_rejects_root_write_read_only_carveouts() { + let temp_dir = tempfile::TempDir::new().expect("tempdir"); + let docs = temp_dir.path().join("docs"); + std::fs::create_dir_all(&docs).expect("create docs"); + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::Root, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Write, }, - "elevated Windows sandbox should keep restricted read-only support enabled" + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Path { + path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs) + .expect("absolute docs"), + }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + ]); + + assert_eq!( + unsupported_windows_restricted_token_sandbox_reason( + SandboxType::WindowsRestrictedToken, + &policy, + &file_system_policy, + NetworkSandboxPolicy::Restricted, + temp_dir.path(), + WindowsSandboxLevel::RestrictedToken, + ), + Some( + "windows unelevated restricted-token sandbox cannot enforce split writable root sets directly; refusing to run unsandboxed" + .to_string() + ) + ); +} + +#[test] +fn windows_restricted_token_supports_full_read_split_write_read_carveouts() { + let temp_dir = tempfile::TempDir::new().expect("tempdir"); + let cwd = dunce::canonicalize(temp_dir.path()).expect("canonicalize temp dir"); + let docs = cwd.join("docs"); + std::fs::create_dir_all(&docs).expect("create docs"); + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::Root, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Write, + }, + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Path { + path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs) + .expect("absolute docs"), + }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + ]); + + assert_eq!( + resolve_windows_restricted_token_filesystem_overlay( + SandboxType::WindowsRestrictedToken, + &policy, + &file_system_policy, + NetworkSandboxPolicy::Restricted, + &cwd, + WindowsSandboxLevel::RestrictedToken, + ), + Ok(Some(WindowsRestrictedTokenFilesystemOverlay { + additional_deny_write_paths: vec![ + codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs) + .expect("absolute docs"), + ], + })) + ); +} + +#[test] +fn windows_elevated_rejects_split_write_read_carveouts() { + let temp_dir = tempfile::TempDir::new().expect("tempdir"); + let docs = temp_dir.path().join("docs"); + std::fs::create_dir_all(&docs).expect("create docs"); + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; + let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::Root, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Write, + }, + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Path { + path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs) + .expect("absolute docs"), + }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + ]); + + assert_eq!( + unsupported_windows_restricted_token_sandbox_reason( + SandboxType::WindowsRestrictedToken, + &policy, + &file_system_policy, + NetworkSandboxPolicy::Restricted, + temp_dir.path(), + WindowsSandboxLevel::Elevated, + ), + Some( + "windows elevated sandbox backend cannot enforce split filesystem permissions directly; refusing to run unsandboxed" + .to_string() + ) ); } @@ -549,7 +671,7 @@ fn process_exec_tool_call_uses_platform_sandbox_for_network_only_restrictions() select_process_exec_tool_sandbox_type( &FileSystemSandboxPolicy::unrestricted(), NetworkSandboxPolicy::Restricted, - WindowsSandboxLevel::Disabled, + codex_protocol::config_types::WindowsSandboxLevel::Disabled, false, ), expected @@ -581,16 +703,17 @@ async fn kill_child_process_group_kills_grandchildren_on_timeout() -> Result<()> "-c".to_string(), "sleep 60 & echo $!; sleep 60".to_string(), ]; + let cwd = std::env::current_dir()?; let env: HashMap = std::env::vars().collect(); let params = ExecParams { command, - cwd: std::env::current_dir()?, + cwd, expiration: 500.into(), capture_policy: ExecCapturePolicy::ShellTool, env, network: None, sandbox_permissions: SandboxPermissions::UseDefault, - windows_sandbox_level: WindowsSandboxLevel::Disabled, + windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, justification: None, arg0: None, @@ -601,6 +724,7 @@ async fn kill_child_process_group_kills_grandchildren_on_timeout() -> Result<()> SandboxType::None, &SandboxPolicy::new_read_only_policy(), &FileSystemSandboxPolicy::from(&SandboxPolicy::new_read_only_policy()), + None, NetworkSandboxPolicy::Restricted, None, None, @@ -648,7 +772,7 @@ async fn process_exec_tool_call_respects_cancellation_token() -> Result<()> { env, network: None, sandbox_permissions: SandboxPermissions::UseDefault, - windows_sandbox_level: WindowsSandboxLevel::Disabled, + windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, justification: None, arg0: None, diff --git a/codex-rs/core/src/plugins/discoverable_tests.rs b/codex-rs/core/src/plugins/discoverable_tests.rs index cb2ac15493..fd9df9ddf6 100644 --- a/codex-rs/core/src/plugins/discoverable_tests.rs +++ b/codex-rs/core/src/plugins/discoverable_tests.rs @@ -44,6 +44,12 @@ async fn list_tool_suggest_discoverable_plugins_returns_empty_when_plugins_featu let codex_home = tempdir().expect("tempdir should succeed"); let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path()); write_openai_curated_marketplace(&curated_root, &["slack"]); + write_file( + &codex_home.path().join(crate::config::CONFIG_TOML_FILE), + r#"[features] +plugins = false +"#, + ); let config = load_plugins_config(codex_home.path()).await; let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index 81f2a0af2c..26f4f733c6 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -11,6 +11,7 @@ use crate::exec::ExecCapturePolicy; use crate::exec::ExecExpiration; use crate::exec::ExecToolCallOutput; use crate::exec::StdoutStream; +use crate::exec::WindowsRestrictedTokenFilesystemOverlay; use crate::exec::execute_exec_request; #[cfg(target_os = "macos")] use crate::spawn::CODEX_SANDBOX_ENV_VAR; @@ -46,10 +47,46 @@ pub struct ExecRequest { pub sandbox_policy: SandboxPolicy, pub file_system_sandbox_policy: FileSystemSandboxPolicy, pub network_sandbox_policy: NetworkSandboxPolicy, + pub(crate) windows_restricted_token_filesystem_overlay: + Option, pub arg0: Option, } impl ExecRequest { + #[allow(clippy::too_many_arguments)] + pub fn new( + command: Vec, + cwd: PathBuf, + env: HashMap, + network: Option, + expiration: ExecExpiration, + capture_policy: ExecCapturePolicy, + sandbox: SandboxType, + windows_sandbox_level: WindowsSandboxLevel, + windows_sandbox_private_desktop: bool, + sandbox_policy: SandboxPolicy, + file_system_sandbox_policy: FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, + arg0: Option, + ) -> Self { + Self { + command, + cwd, + env, + network, + expiration, + capture_policy, + sandbox, + windows_sandbox_level, + windows_sandbox_private_desktop, + sandbox_policy, + file_system_sandbox_policy, + network_sandbox_policy, + windows_restricted_token_filesystem_overlay: None, + arg0, + } + } + pub(crate) fn from_sandbox_exec_request( request: SandboxExecRequest, options: ExecOptions, @@ -94,6 +131,7 @@ impl ExecRequest { sandbox_policy, file_system_sandbox_policy, network_sandbox_policy, + windows_restricted_token_filesystem_overlay: None, arg0, } } diff --git a/codex-rs/core/src/snapshots/codex_core__codex_tests__fork_startup_context_then_first_turn_diff.snap b/codex-rs/core/src/snapshots/codex_core__codex_tests__fork_startup_context_then_first_turn_diff.snap index 90bb82d409..d55f29a2a7 100644 --- a/codex-rs/core/src/snapshots/codex_core__codex_tests__fork_startup_context_then_first_turn_diff.snap +++ b/codex-rs/core/src/snapshots/codex_core__codex_tests__fork_startup_context_then_first_turn_diff.snap @@ -1,17 +1,15 @@ --- source: core/src/codex_tests.rs -assertion_line: 1282 +assertion_line: 1254 expression: snapshot --- -Scenario: First request after fork when fork startup changes approval policy and the first forked turn changes approval policy again and enters plan mode. +Scenario: First request after fork when startup preserves the parent baseline, the fork changes approval policy, and the first forked turn enters plan mode. ## First Forked Turn Request 00:message/developer: 01:message/user:> 02:message/user:fork seed -03:message/developer: -04:message/user:> -05:message/developer[2]: +03:message/developer[2]: [01] [02] Fork turn collaboration instructions. -06:message/user:after fork +04:message/user:after fork diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 682902f2d6..e118d38095 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -175,6 +175,7 @@ pub(crate) async fn execute_user_shell_command( sandbox_policy: sandbox_policy.clone(), file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy), network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy), + windows_restricted_token_filesystem_overlay: None, arg0: None, }; diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index a90fe16b09..83dcef94be 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -138,6 +138,7 @@ pub(super) async fn try_run_zsh_fork( sandbox_policy, file_system_sandbox_policy, network_sandbox_policy, + windows_restricted_token_filesystem_overlay: _windows_restricted_token_filesystem_overlay, arg0, } = sandbox_exec_request; let ParsedShellCommand { script, login, .. } = extract_shell_script(&command)?; @@ -918,6 +919,7 @@ impl ShellCommandExecutor for CoreShellCommandExecutor { sandbox_policy: self.sandbox_policy.clone(), file_system_sandbox_policy: self.file_system_sandbox_policy.clone(), network_sandbox_policy: self.network_sandbox_policy, + windows_restricted_token_filesystem_overlay: None, arg0: self.arg0.clone(), }, /*stdout_stream*/ None, diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 77c2954067..5686b4bb78 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -388,7 +388,8 @@ impl ToolsConfig { let include_request_user_input = !matches!(session_source, SessionSource::SubAgent(_)); let include_default_mode_request_user_input = include_request_user_input && features.enabled(Feature::DefaultModeRequestUserInput); - let include_search_tool = model_info.supports_search_tool; + let include_search_tool = + model_info.supports_search_tool && features.enabled(Feature::ToolSearch); let include_tool_suggest = include_search_tool && features.enabled(Feature::ToolSuggest) && features.enabled(Feature::Apps) diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index dd806f887b..36a0990393 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -1858,6 +1858,7 @@ fn search_tool_description_lists_each_codex_apps_connector_once() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); features.enable(Feature::Apps); + features.enable(Feature::ToolSearch); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -1976,7 +1977,7 @@ fn search_tool_description_lists_each_codex_apps_connector_once() { } #[test] -fn search_tool_requires_model_capability_only() { +fn search_tool_requires_model_capability_and_feature_flag() { let model_info = search_capable_model_info(); let app_tools = Some(HashMap::from([( "mcp__codex_apps__calendar_create_event".to_string(), @@ -2012,6 +2013,22 @@ fn search_tool_requires_model_capability_only() { }); let (tools, _) = build_specs(&tools_config, None, app_tools.clone(), &[]).build(); assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME); + + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }); + let (tools, _) = build_specs(&tools_config, None, app_tools.clone(), &[]).build(); + assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME); + + let mut features = Features::with_defaults(); + features.enable(Feature::ToolSearch); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -2030,8 +2047,8 @@ fn search_tool_requires_model_capability_only() { fn tool_suggest_is_not_registered_without_feature_flag() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); - features.enable(Feature::Apps); - features.enable(Feature::Plugins); + features.enable(Feature::ToolSearch); + features.disable(Feature::ToolSuggest); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -2074,12 +2091,11 @@ fn tool_suggest_requires_apps_and_plugins_features() { for disabled_feature in [Feature::Apps, Feature::Plugins] { let mut features = Features::with_defaults(); + features.enable(Feature::ToolSearch); features.enable(Feature::ToolSuggest); - for feature in [Feature::Apps, Feature::Plugins] { - if feature != disabled_feature { - features.enable(feature); - } - } + features.enable(Feature::Apps); + features.enable(Feature::Plugins); + features.disable(disabled_feature); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -2113,6 +2129,7 @@ fn search_tool_description_handles_no_enabled_apps() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); features.enable(Feature::Apps); + features.enable(Feature::ToolSearch); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -2139,6 +2156,7 @@ fn search_tool_description_falls_back_to_connector_name_without_description() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); features.enable(Feature::Apps); + features.enable(Feature::ToolSearch); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -2187,6 +2205,7 @@ fn search_tool_registers_namespaced_app_tool_aliases() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); features.enable(Feature::Apps); + features.enable(Feature::ToolSearch); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -2253,6 +2272,7 @@ fn tool_suggest_description_lists_discoverable_tools() { let mut features = Features::with_defaults(); features.enable(Feature::Apps); features.enable(Feature::Plugins); + features.enable(Feature::ToolSearch); features.enable(Feature::ToolSuggest); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index a16e460204..e24cb74d7c 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -125,9 +125,8 @@ async fn fork_thread_twice_drops_to_first_message() { // GetHistory on fork1 flushed; the file is ready. let fork1_items = read_items(&fork1_path); - assert!(fork1_items.len() > expected_after_first.len()); pretty_assertions::assert_eq!( - serde_json::to_value(&fork1_items[..expected_after_first.len()]).unwrap(), + serde_json::to_value(&fork1_items).unwrap(), serde_json::to_value(&expected_after_first).unwrap() ); @@ -156,9 +155,8 @@ async fn fork_thread_twice_drops_to_first_message() { .unwrap_or(0); let expected_after_second: Vec = fork1_items[..cut_last_on_fork1].to_vec(); let fork2_items = read_items(&fork2_path); - assert!(fork2_items.len() > expected_after_second.len()); pretty_assertions::assert_eq!( - serde_json::to_value(&fork2_items[..expected_after_second.len()]).unwrap(), + serde_json::to_value(&fork2_items).unwrap(), serde_json::to_value(&expected_after_second).unwrap() ); } diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index 30180c8cf6..e71a158797 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -443,14 +443,14 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { let body4 = req4.single_request().body_json(); let input4 = body4["input"].as_array().expect("input array"); let permissions_fork = permissions_texts(input4); - assert_eq!(permissions_fork.len(), permissions_base.len() + 2); + assert_eq!(permissions_fork.len(), permissions_base.len() + 1); assert_eq!( &permissions_fork[..permissions_base.len()], permissions_base.as_slice() ); let new_permissions = &permissions_fork[permissions_base.len()..]; - assert_eq!(new_permissions.len(), 2); - assert_eq!(new_permissions[0], new_permissions[1]); + assert_eq!(new_permissions.len(), 1); + assert_eq!(permissions_fork, permissions_resume); assert!(!permissions_base.contains(&new_permissions[0])); Ok(()) diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 0eee2f1d3d..423c57a8c6 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -86,7 +86,7 @@ fn tool_search_output_tools(request: &ResponsesRequest, call_id: &str) -> Vec TestCodexBuilder { test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) @@ -169,6 +177,45 @@ async fn search_tool_flag_adds_tool_search() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tool_search_disabled_by_default_exposes_apps_tools_directly() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount_searchable(&server).await?; + let mock = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ]), + ) + .await; + + let mut builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(move |config| { + configure_apps_without_tool_search(config, apps_server.chatgpt_base_url.as_str()) + }); + let test = builder.build(&server).await?; + + test.submit_turn_with_policies( + "list tools", + AskForApproval::Never, + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let body = mock.single_request().body_json(); + let tools = tool_names(&body); + assert!(!tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME)); + assert!(tools.iter().any(|name| name == CALENDAR_CREATE_TOOL)); + assert!(tools.iter().any(|name| name == CALENDAR_LIST_TOOL)); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn search_tool_is_hidden_for_api_key_auth() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index f49428cec5..ccde6a5e54 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -142,6 +142,8 @@ pub enum Feature { SpawnCsv, /// Enable apps. Apps, + /// Enable the tool_search tool for apps. + ToolSearch, /// Enable discoverable tool suggestions for apps. ToolSuggest, /// Enable plugins. @@ -714,24 +716,26 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::Apps, key: "apps", - stage: Stage::Experimental { - name: "Apps", - menu_description: "Use a connected ChatGPT App using \"$\". Install Apps via /apps command. Restart Codex after enabling.", - announcement: "NEW: Use ChatGPT Apps (Connectors) in Codex via $ mentions. Enable in /experimental and restart Codex!", - }, + stage: Stage::Stable, + default_enabled: true, + }, + FeatureSpec { + id: Feature::ToolSearch, + key: "tool_search", + stage: Stage::UnderDevelopment, default_enabled: false, }, FeatureSpec { id: Feature::ToolSuggest, key: "tool_suggest", - stage: Stage::UnderDevelopment, - default_enabled: false, + stage: Stage::Stable, + default_enabled: true, }, FeatureSpec { id: Feature::Plugins, key: "plugins", - stage: Stage::UnderDevelopment, - default_enabled: false, + stage: Stage::Stable, + default_enabled: true, }, FeatureSpec { id: Feature::ImageGeneration, @@ -782,8 +786,8 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::ToolCallMcpElicitation, key: "tool_call_mcp_elicitation", - stage: Stage::UnderDevelopment, - default_enabled: false, + stage: Stage::Stable, + default_enabled: true, }, FeatureSpec { id: Feature::Personality, diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index faf02b083e..ecfa87b41b 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -115,9 +115,15 @@ fn request_permissions_tool_is_under_development() { } #[test] -fn tool_suggest_is_under_development() { - assert_eq!(Feature::ToolSuggest.stage(), Stage::UnderDevelopment); - assert_eq!(Feature::ToolSuggest.default_enabled(), false); +fn tool_suggest_is_stable_and_enabled_by_default() { + assert_eq!(Feature::ToolSuggest.stage(), Stage::Stable); + assert_eq!(Feature::ToolSuggest.default_enabled(), true); +} + +#[test] +fn tool_search_is_under_development_and_disabled_by_default() { + assert_eq!(Feature::ToolSearch.stage(), Stage::UnderDevelopment); + assert_eq!(Feature::ToolSearch.default_enabled(), false); } #[test] @@ -138,6 +144,12 @@ fn image_generation_is_under_development() { assert_eq!(Feature::ImageGeneration.default_enabled(), false); } +#[test] +fn tool_call_mcp_elicitation_is_stable_and_enabled_by_default() { + assert_eq!(Feature::ToolCallMcpElicitation.stage(), Stage::Stable); + assert_eq!(Feature::ToolCallMcpElicitation.default_enabled(), true); +} + #[test] fn image_detail_original_feature_is_under_development() { assert_eq!( diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index ce08cc8791..120b7237ee 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -420,13 +420,6 @@ impl ChatWidget { } else { "Not installed yet." }; - let description = app - .description - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - let mut header = ColumnRenderable::new(); header.push(Line::from("Plugins".bold())); header.push(Line::from( @@ -437,12 +430,7 @@ impl ChatWidget { )); header.push(Line::from(status_label.dim())); - let mut items = vec![SelectionItem { - name: app.name.clone(), - description, - is_disabled: true, - ..Default::default() - }]; + let mut items = Vec::new(); if let Some(install_url) = app.install_url.clone() { let install_label = if is_installed { diff --git a/codex-rs/tui_app_server/src/app.rs b/codex-rs/tui_app_server/src/app.rs index 29da170b82..b1773d0ac2 100644 --- a/codex-rs/tui_app_server/src/app.rs +++ b/codex-rs/tui_app_server/src/app.rs @@ -621,6 +621,10 @@ impl ThreadEventStore { fn active_turn_id(&self) -> Option<&str> { self.active_turn_id.as_deref() } + + fn clear_active_turn_id(&mut self) { + self.active_turn_id = None; + } } #[derive(Debug)] @@ -986,6 +990,13 @@ fn active_turn_not_steerable_turn_error(error: &TypedRequestError) -> Option bool { + let TypedRequestError::Server { source, .. } = error else { + return false; + }; + source.message == "no active turn to steer" +} + impl App { pub fn chatwidget_init_for_forked_or_resumed_thread( &self, @@ -2021,23 +2032,32 @@ impl App { collaboration_mode, personality, } => { + let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { match app_server .turn_steer(thread_id, turn_id, items.to_vec()) .await { - Ok(_) => {} + Ok(_) => return Ok(true), Err(error) => { if let Some(turn_error) = active_turn_not_steerable_turn_error(&error) { if !self.chat_widget.enqueue_rejected_steer() { self.chat_widget.add_error_message(turn_error.message); } + return Ok(true); + } else if active_turn_missing_steer_error(&error) { + if let Some(channel) = self.thread_event_channels.get(&thread_id) { + let mut store = channel.store.lock().await; + store.clear_active_turn_id(); + } + should_start_turn = true; } else { return Err(error.into()); } } } - } else { + } + if should_start_turn { app_server .turn_start( thread_id, @@ -8043,6 +8063,17 @@ guardian_approval = true assert_eq!(refreshed_store.active_turn_id(), Some("turn-2")); } + #[test] + fn thread_event_store_clear_active_turn_id_resets_cached_turn() { + let mut store = ThreadEventStore::new(8); + let thread_id = ThreadId::new(); + store.push_notification(turn_started_notification(thread_id, "turn-1")); + + store.clear_active_turn_id(); + + assert_eq!(store.active_turn_id(), None); + } + #[test] fn thread_event_store_rebase_preserves_resolved_request_state() { let thread_id = ThreadId::new(); @@ -8273,6 +8304,21 @@ guardian_approval = true ); } + #[test] + fn active_turn_missing_steer_error_detects_stale_turn_race() { + let error = TypedRequestError::Server { + method: "turn/steer".to_string(), + source: JSONRPCErrorError { + code: -32602, + message: "no active turn to steer".to_string(), + data: None, + }, + }; + + assert!(active_turn_missing_steer_error(&error)); + assert_eq!(active_turn_not_steerable_turn_error(&error), None); + } + #[test] fn select_model_availability_nux_uses_existing_model_order_as_priority() { let mut presets = all_model_presets(); diff --git a/codex-rs/tui_app_server/src/chatwidget/plugins.rs b/codex-rs/tui_app_server/src/chatwidget/plugins.rs index ce08cc8791..120b7237ee 100644 --- a/codex-rs/tui_app_server/src/chatwidget/plugins.rs +++ b/codex-rs/tui_app_server/src/chatwidget/plugins.rs @@ -420,13 +420,6 @@ impl ChatWidget { } else { "Not installed yet." }; - let description = app - .description - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - let mut header = ColumnRenderable::new(); header.push(Line::from("Plugins".bold())); header.push(Line::from( @@ -437,12 +430,7 @@ impl ChatWidget { )); header.push(Line::from(status_label.dim())); - let mut items = vec![SelectionItem { - name: app.name.clone(), - description, - is_disabled: true, - ..Default::default() - }]; + let mut items = Vec::new(); if let Some(install_url) = app.install_url.clone() { let install_label = if is_installed { diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index cb4be52750..73193a207d 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -144,6 +144,8 @@ pub use token::get_current_token_for_restriction; #[cfg(target_os = "windows")] pub use windows_impl::run_windows_sandbox_capture; #[cfg(target_os = "windows")] +pub use windows_impl::run_windows_sandbox_capture_with_extra_deny_write_paths; +#[cfg(target_os = "windows")] pub use windows_impl::run_windows_sandbox_legacy_preflight; #[cfg(target_os = "windows")] pub use windows_impl::CaptureResult; @@ -259,6 +261,30 @@ mod windows_impl { #[allow(clippy::too_many_arguments)] pub fn run_windows_sandbox_capture( + policy_json_or_preset: &str, + sandbox_policy_cwd: &Path, + codex_home: &Path, + command: Vec, + cwd: &Path, + env_map: HashMap, + timeout_ms: Option, + use_private_desktop: bool, + ) -> Result { + run_windows_sandbox_capture_with_extra_deny_write_paths( + policy_json_or_preset, + sandbox_policy_cwd, + codex_home, + command, + cwd, + env_map, + timeout_ms, + &[], + use_private_desktop, + ) + } + + #[allow(clippy::too_many_arguments)] + pub fn run_windows_sandbox_capture_with_extra_deny_write_paths( policy_json_or_preset: &str, sandbox_policy_cwd: &Path, codex_home: &Path, @@ -266,6 +292,7 @@ mod windows_impl { cwd: &Path, mut env_map: HashMap, timeout_ms: Option, + additional_deny_write_paths: &[PathBuf], use_private_desktop: bool, ) -> Result { let policy = parse_policy(policy_json_or_preset)?; @@ -335,8 +362,13 @@ mod windows_impl { } let persist_aces = is_workspace_write; - let AllowDenyPaths { allow, deny } = + let AllowDenyPaths { allow, mut deny } = compute_allow_paths(&policy, sandbox_policy_cwd, ¤t_dir, &env_map); + for path in additional_deny_write_paths { + if path.exists() { + deny.insert(path.clone()); + } + } let canonical_cwd = canonicalize_path(¤t_dir); let mut guards: Vec<(PathBuf, *mut c_void)> = Vec::new(); unsafe { diff --git a/sdk/python/tests/test_real_app_server_integration.py b/sdk/python/tests/test_real_app_server_integration.py index b5e37c444c..3c114f1202 100644 --- a/sdk/python/tests/test_real_app_server_integration.py +++ b/sdk/python/tests/test_real_app_server_integration.py @@ -505,7 +505,7 @@ def test_real_examples_run_and_assert( out = result.stdout if folder == "01_quickstart_constructor": - assert "Status:" in out and "Text:" in out + assert "Server:" in out and "Items:" in out and "Text:" in out assert "Server: unknown" not in out elif folder == "02_turn_run": assert "thread_id:" in out and "turn_id:" in out and "status:" in out