From d46638770bf62922a8774dbf3fc0dbdd61ae41a3 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 14 May 2026 18:06:33 -0700 Subject: [PATCH] app-server: use permission ids and runtime workspace roots --- .../analytics/src/analytics_client_tests.rs | 2 + codex-rs/analytics/src/client_tests.rs | 3 + .../schema/json/ClientRequest.json | 59 -------- .../codex_app_server_protocol.schemas.json | 59 -------- .../codex_app_server_protocol.v2.schemas.json | 59 -------- .../schema/json/v2/ThreadForkParams.json | 63 -------- .../schema/json/v2/ThreadResumeParams.json | 63 -------- .../schema/json/v2/ThreadStartParams.json | 59 -------- .../schema/json/v2/TurnStartParams.json | 59 -------- .../v2/PermissionProfileModificationParams.ts | 6 - .../v2/PermissionProfileSelectionParams.ts | 6 - .../schema/typescript/v2/index.ts | 2 - .../src/protocol/common.rs | 2 + .../src/protocol/v2/permissions.rs | 117 ++++++++++++--- .../src/protocol/v2/tests.rs | 56 +++++++ .../src/protocol/v2/thread.rs | 50 +++++-- .../src/protocol/v2/turn.rs | 14 +- codex-rs/app-server/README.md | 14 +- .../src/message_processor_tracing_tests.rs | 1 + codex-rs/app-server/src/request_processors.rs | 1 - .../request_processors/thread_lifecycle.rs | 2 + .../request_processors/thread_processor.rs | 30 ++++ .../thread_processor_tests.rs | 3 + .../src/request_processors/thread_summary.rs | 26 ++-- .../thread_summary_tests.rs | 40 +++++ .../src/request_processors/turn_processor.rs | 64 +++++++- .../app-server/tests/suite/v2/skills_list.rs | 1 + .../tests/suite/v2/thread_resume.rs | 73 +++++++++ .../app-server/tests/suite/v2/thread_start.rs | 42 ++++++ .../app-server/tests/suite/v2/turn_start.rs | 12 +- codex-rs/core/src/codex_thread.rs | 8 + codex-rs/core/src/config/config_tests.rs | 5 + codex-rs/core/src/config/mod.rs | 18 ++- .../src/config/resolved_permission_profile.rs | 14 ++ codex-rs/core/src/session/handlers.rs | 6 + codex-rs/core/src/session/mod.rs | 1 + codex-rs/core/src/session/session.rs | 29 +++- codex-rs/core/src/session/tests.rs | 54 +++++++ codex-rs/core/src/session/turn_context.rs | 8 + codex-rs/exec/src/lib.rs | 37 +++-- codex-rs/exec/src/lib_tests.rs | 21 +-- codex-rs/protocol/src/protocol.rs | 10 ++ codex-rs/tui/src/app/config_persistence.rs | 1 + codex-rs/tui/src/app/tests.rs | 8 + codex-rs/tui/src/app/thread_events.rs | 1 + codex-rs/tui/src/app/thread_session_state.rs | 2 + codex-rs/tui/src/app_server_session.rs | 139 ++++++++++++------ codex-rs/tui/src/chatwidget/session_flow.rs | 24 +-- .../chatwidget/tests/composer_submission.rs | 9 ++ .../tui/src/chatwidget/tests/exec_flow.rs | 1 + .../src/chatwidget/tests/history_replay.rs | 10 ++ .../tui/src/chatwidget/tests/permissions.rs | 2 + .../tui/src/chatwidget/tests/plan_mode.rs | 2 + .../src/chatwidget/tests/status_and_layout.rs | 1 + codex-rs/tui/src/history_cell.rs | 1 + codex-rs/tui/src/session_state.rs | 1 + 56 files changed, 799 insertions(+), 602 deletions(-) delete mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileModificationParams.ts delete mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSelectionParams.ts diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 5b4a72229b6b..7b9ab4f9d66d 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -201,6 +201,7 @@ fn sample_thread_start_response( model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + runtime_workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, @@ -257,6 +258,7 @@ fn sample_thread_resume_response_with_source( model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + runtime_workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index 71c46d808a30..885875346d6a 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -153,6 +153,7 @@ fn sample_thread_start_response() -> ClientResponsePayload { model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + runtime_workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, @@ -170,6 +171,7 @@ fn sample_thread_resume_response() -> ClientResponsePayload { model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + runtime_workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, @@ -187,6 +189,7 @@ fn sample_thread_fork_response() -> ClientResponsePayload { model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + runtime_workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index a6fe99b35e98..f40d16feb64b 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1850,31 +1850,6 @@ } ] }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, "PermissionProfileNetworkPermissions": { "properties": { "enabled": { @@ -1886,40 +1861,6 @@ ], "type": "object" }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "Personality": { "enum": [ "none", 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 8bcd2edd6d87..5810cd8dfebf 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 @@ -11732,31 +11732,6 @@ } ] }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/v2/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, "PermissionProfileNetworkPermissions": { "properties": { "enabled": { @@ -11768,40 +11743,6 @@ ], "type": "object" }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/v2/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "Personality": { "enum": [ "none", 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 a7b8a260077a..b7e03172ed8b 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 @@ -8281,31 +8281,6 @@ } ] }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, "PermissionProfileNetworkPermissions": { "properties": { "enabled": { @@ -8317,40 +8292,6 @@ ], "type": "object" }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "Personality": { "enum": [ "none", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json index 29d67403cd5b..102cfa029982 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json @@ -1,10 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { - "AbsolutePathBuf": { - "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", - "type": "string" - }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ @@ -64,65 +60,6 @@ } ] }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "SandboxMode": { "enum": [ "read-only", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index 5f07fe0149db..27674afc7b16 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -1,10 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { - "AbsolutePathBuf": { - "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", - "type": "string" - }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ @@ -298,65 +294,6 @@ } ] }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "Personality": { "enum": [ "none", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json index 9a60049a61fb..99b25490ab14 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -90,65 +90,6 @@ ], "type": "object" }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "Personality": { "enum": [ "none", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index 1ef33d4301bd..086d3c6ed1f3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -114,65 +114,6 @@ ], "type": "string" }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "Personality": { "enum": [ "none", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileModificationParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileModificationParams.ts deleted file mode 100644 index c619edcea81f..000000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileModificationParams.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AbsolutePathBuf } from "../AbsolutePathBuf"; - -export type PermissionProfileModificationParams = { "type": "additionalWritableRoot", path: AbsolutePathBuf, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSelectionParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSelectionParams.ts deleted file mode 100644 index a415bd0028ed..000000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSelectionParams.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { PermissionProfileModificationParams } from "./PermissionProfileModificationParams"; - -export type PermissionProfileSelectionParams = { "type": "profile", id: string, modifications?: Array | null, }; 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 0a6d868ad0fc..beb1973bb985 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -257,9 +257,7 @@ export type { PatchChangeKind } from "./PatchChangeKind"; export type { PermissionGrantScope } from "./PermissionGrantScope"; export type { PermissionProfile } from "./PermissionProfile"; export type { PermissionProfileFileSystemPermissions } from "./PermissionProfileFileSystemPermissions"; -export type { PermissionProfileModificationParams } from "./PermissionProfileModificationParams"; export type { PermissionProfileNetworkPermissions } from "./PermissionProfileNetworkPermissions"; -export type { PermissionProfileSelectionParams } from "./PermissionProfileSelectionParams"; export type { PermissionsRequestApprovalParams } from "./PermissionsRequestApprovalParams"; export type { PermissionsRequestApprovalResponse } from "./PermissionsRequestApprovalResponse"; export type { PlanDeltaNotification } from "./PlanDeltaNotification"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 89f60f979b98..e0cd330fffee 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2296,6 +2296,7 @@ mod tests { model_provider: "openai".to_string(), service_tier: None, cwd, + runtime_workspace_roots: Vec::new(), instruction_sources: vec![absolute_path("/tmp/AGENTS.md")], approval_policy: v2::AskForApproval::OnFailure, approvals_reviewer: v2::ApprovalsReviewer::User, @@ -2340,6 +2341,7 @@ mod tests { "modelProvider": "openai", "serviceTier": null, "cwd": absolute_path_string("tmp"), + "runtimeWorkspaceRoots": [], "instructionSources": [absolute_path_string("tmp/AGENTS.md")], "approvalPolicy": "on-failure", "approvalsReviewer": "user", diff --git a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs index 0796ee4e893a..faf264411eb7 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs @@ -21,7 +21,9 @@ use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequest use codex_utils_absolute_path::AbsolutePathBuf; use schemars::JsonSchema; use serde::Deserialize; +use serde::Deserializer; use serde::Serialize; +use serde::Serializer; use std::num::NonZeroUsize; use std::path::PathBuf; use ts_rs::TS; @@ -456,31 +458,100 @@ impl From for CoreActivePermissionProfile { } } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum PermissionProfileSelectionParams { - /// Select a named built-in or user-defined profile and optionally apply - /// bounded modifications that Codex knows how to validate. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Profile { - id: String, - #[ts(optional = nullable)] - modifications: Option>, - }, +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PermissionProfileSelectionParams { + id: String, + legacy_additional_writable_roots: Vec, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum PermissionProfileModificationParams { - /// Additional concrete directory that should be writable. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - AdditionalWritableRoot { path: AbsolutePathBuf }, +impl PermissionProfileSelectionParams { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + legacy_additional_writable_roots: Vec::new(), + } + } + + pub fn id(&self) -> &str { + &self.id + } + + pub fn into_id(self) -> String { + self.id + } + + pub fn legacy_additional_writable_roots(&self) -> &[AbsolutePathBuf] { + &self.legacy_additional_writable_roots + } +} + +impl From for PermissionProfileSelectionParams { + fn from(id: String) -> Self { + Self::new(id) + } +} + +impl Serialize for PermissionProfileSelectionParams { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.id) + } +} + +impl<'de> Deserialize<'de> for PermissionProfileSelectionParams { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum Wire { + Id(String), + LegacyProfile { + #[serde(rename = "type")] + _type: LegacyPermissionProfileSelectionType, + id: String, + #[serde(default)] + modifications: Option>, + }, + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + enum LegacyPermissionProfileSelectionType { + Profile, + } + + #[derive(Deserialize)] + #[serde(tag = "type", rename_all = "camelCase")] + enum LegacyPermissionProfileModificationParams { + #[serde(rename_all = "camelCase")] + AdditionalWritableRoot { path: AbsolutePathBuf }, + } + + match Wire::deserialize(deserializer)? { + Wire::Id(id) => Ok(Self::new(id)), + Wire::LegacyProfile { + id, modifications, .. + } => { + let legacy_additional_writable_roots = modifications + .unwrap_or_default() + .into_iter() + .map(|modification| match modification { + LegacyPermissionProfileModificationParams::AdditionalWritableRoot { + path, + } => path, + }) + .collect(); + Ok(Self { + id, + legacy_additional_writable_roots, + }) + } + } + } } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index f7041cc721a8..784381dacc93 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -655,6 +655,61 @@ fn permissions_request_approval_response_accepts_strict_auto_review() { assert_eq!(response.strict_auto_review, Some(true)); } +#[test] +fn permission_profile_selection_accepts_legacy_object_shape() { + let additional_root = absolute_path("additional-root"); + let params = json!({ + "permissions": { + "type": "profile", + "id": ":workspace", + "modifications": [ + { + "type": "additionalWritableRoot", + "path": additional_root.clone(), + } + ], + }, + }); + + let start: ThreadStartParams = + serde_json::from_value(params.clone()).expect("thread/start params deserialize"); + assert_legacy_permission_profile_selection(start.permissions, &additional_root); + + let resume: ThreadResumeParams = serde_json::from_value(json!({ + "threadId": "thread-1", + "permissions": params["permissions"].clone(), + })) + .expect("thread/resume params deserialize"); + assert_legacy_permission_profile_selection(resume.permissions, &additional_root); + + let fork: ThreadForkParams = serde_json::from_value(json!({ + "threadId": "thread-1", + "permissions": params["permissions"].clone(), + })) + .expect("thread/fork params deserialize"); + assert_legacy_permission_profile_selection(fork.permissions, &additional_root); + + let turn: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread-1", + "input": [], + "permissions": params["permissions"].clone(), + })) + .expect("turn/start params deserialize"); + assert_legacy_permission_profile_selection(turn.permissions, &additional_root); +} + +fn assert_legacy_permission_profile_selection( + selection: Option, + additional_root: &AbsolutePathBuf, +) { + let selection = selection.expect("permissions should be present"); + assert_eq!(selection.id(), ":workspace"); + assert_eq!( + selection.legacy_additional_writable_roots(), + &[additional_root.clone()] + ); +} + #[test] fn fs_get_metadata_response_round_trips_minimal_fields() { let response = FsGetMetadataResponse { @@ -3469,6 +3524,7 @@ fn turn_start_params_preserve_explicit_null_service_tier() { responsesapi_client_metadata: None, environments: None, cwd: None, + runtime_workspace_roots: None, approval_policy: None, approvals_reviewer: None, sandbox_policy: None, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index 458722b3a21c..a3321436f622 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -107,6 +107,11 @@ pub struct ThreadStartParams { pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, + /// Replace the thread's runtime workspace roots. Relative paths are + /// resolved against the effective cwd for the thread. + #[experimental("thread/start.runtimeWorkspaceRoots")] + #[ts(optional = nullable)] + pub runtime_workspace_roots: Option>, #[experimental(nested)] #[ts(optional = nullable)] pub approval_policy: Option, @@ -116,10 +121,10 @@ pub struct ThreadStartParams { pub approvals_reviewer: Option, #[ts(optional = nullable)] pub sandbox: Option, - /// Named profile selection for this thread. Cannot be combined with - /// `sandbox`. Use bounded `modifications` for supported turn/thread - /// adjustments instead of replacing the full permissions profile. + /// Named profile id for this thread. Cannot be combined with `sandbox`. #[experimental("thread/start.permissions")] + #[schemars(with = "Option")] + #[ts(type = "string | null")] #[ts(optional = nullable)] pub permissions: Option, #[ts(optional = nullable)] @@ -195,6 +200,11 @@ pub struct ThreadStartResponse { pub model_provider: String, pub service_tier: Option, pub cwd: AbsolutePathBuf, + /// Thread-scoped runtime workspace roots used to materialize + /// `:workspace_roots`. + #[experimental("thread/start.runtimeWorkspaceRoots")] + #[serde(default)] + pub runtime_workspace_roots: Vec, /// Instruction source files currently loaded for this thread. #[serde(default)] pub instruction_sources: Vec, @@ -264,6 +274,11 @@ pub struct ThreadResumeParams { pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, + /// Replace the thread's runtime workspace roots. Relative paths are + /// resolved against the effective cwd for the thread. + #[experimental("thread/resume.runtimeWorkspaceRoots")] + #[ts(optional = nullable)] + pub runtime_workspace_roots: Option>, #[experimental(nested)] #[ts(optional = nullable)] pub approval_policy: Option, @@ -273,10 +288,11 @@ pub struct ThreadResumeParams { pub approvals_reviewer: Option, #[ts(optional = nullable)] pub sandbox: Option, - /// Named profile selection for the resumed thread. Cannot be combined - /// with `sandbox`. Use bounded `modifications` for supported thread - /// adjustments instead of replacing the full permissions profile. + /// Named profile id for the resumed thread. Cannot be combined with + /// `sandbox`. #[experimental("thread/resume.permissions")] + #[schemars(with = "Option")] + #[ts(type = "string | null")] #[ts(optional = nullable)] pub permissions: Option, #[ts(optional = nullable)] @@ -310,6 +326,11 @@ pub struct ThreadResumeResponse { pub model_provider: String, pub service_tier: Option, pub cwd: AbsolutePathBuf, + /// Thread-scoped runtime workspace roots used to materialize + /// `:workspace_roots`. + #[experimental("thread/resume.runtimeWorkspaceRoots")] + #[serde(default)] + pub runtime_workspace_roots: Vec, /// Instruction source files currently loaded for this thread. #[serde(default)] pub instruction_sources: Vec, @@ -370,6 +391,11 @@ pub struct ThreadForkParams { pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, + /// Replace the thread's runtime workspace roots. Relative paths are + /// resolved against the effective cwd for the thread. + #[experimental("thread/fork.runtimeWorkspaceRoots")] + #[ts(optional = nullable)] + pub runtime_workspace_roots: Option>, #[experimental(nested)] #[ts(optional = nullable)] pub approval_policy: Option, @@ -379,10 +405,11 @@ pub struct ThreadForkParams { pub approvals_reviewer: Option, #[ts(optional = nullable)] pub sandbox: Option, - /// Named profile selection for the forked thread. Cannot be combined with - /// `sandbox`. Use bounded `modifications` for supported thread - /// adjustments instead of replacing the full permissions profile. + /// Named profile id for the forked thread. Cannot be combined with + /// `sandbox`. #[experimental("thread/fork.permissions")] + #[schemars(with = "Option")] + #[ts(type = "string | null")] #[ts(optional = nullable)] pub permissions: Option, #[ts(optional = nullable)] @@ -419,6 +446,11 @@ pub struct ThreadForkResponse { pub model_provider: String, pub service_tier: Option, pub cwd: AbsolutePathBuf, + /// Thread-scoped runtime workspace roots used to materialize + /// `:workspace_roots`. + #[experimental("thread/fork.runtimeWorkspaceRoots")] + #[serde(default)] + pub runtime_workspace_roots: Vec, /// Instruction source files currently loaded for this thread. #[serde(default)] pub instruction_sources: Vec, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs index 61a09bfbf53f..8f4cd04e2d16 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -64,6 +64,12 @@ pub struct TurnStartParams { /// Override the working directory for this turn and subsequent turns. #[ts(optional = nullable)] pub cwd: Option, + /// Replace the thread's runtime workspace roots for this turn and + /// subsequent turns. Relative paths are resolved against the effective + /// cwd for the turn. + #[experimental("turn/start.runtimeWorkspaceRoots")] + #[ts(optional = nullable)] + pub runtime_workspace_roots: Option>, /// Override the approval policy for this turn and subsequent turns. #[experimental(nested)] #[ts(optional = nullable)] @@ -75,11 +81,11 @@ pub struct TurnStartParams { /// Override the sandbox policy for this turn and subsequent turns. #[ts(optional = nullable)] pub sandbox_policy: Option, - /// Select a named permissions profile for this turn and subsequent turns. - /// Cannot be combined with `sandboxPolicy`. Use bounded `modifications` - /// for supported turn adjustments instead of replacing the full - /// permissions profile. + /// Select a named permissions profile id for this turn and subsequent + /// turns. Cannot be combined with `sandboxPolicy`. #[experimental("turn/start.permissions")] + #[schemars(with = "Option")] + #[ts(type = "string | null")] #[ts(optional = nullable)] pub permissions: Option, /// Override the model for this turn and subsequent turns. diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 19d4fef01ad4..0f40ad67c8a0 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -130,10 +130,10 @@ Example with notification opt-out: ## API Overview -- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. For permissions, prefer experimental `permissions` profile selection; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. +- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; relative paths resolve against the effective thread cwd. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. - `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`. - `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`. -- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read response `permissionProfile` for the exact active runtime permissions and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. +- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots, `permissionProfile` for the exact active runtime permissions, and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. - `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. - `thread/loaded/list` — list the thread ids currently loaded in memory. - `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. @@ -156,7 +156,7 @@ Example with notification opt-out: - `thread/shellCommand` — run a user-initiated `!` shell command against a thread; this runs unsandboxed with full access rather than inheriting the thread sandbox policy. Returns `{}` immediately while progress streams through standard turn/item notifications and any active turn receives the formatted output in its message stream. - `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted. - `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success. -- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. Prefer experimental `permissions` profile selection for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". +- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; relative paths resolve against the effective turn cwd. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". - `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success. - `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. Review and manual compaction turns reject `turn/steer`. - `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. @@ -236,7 +236,9 @@ Start a fresh thread when you need a new Codex conversation. "approvalPolicy": "never", "sandbox": "workspaceWrite", // Prefer experimental profile selection: - // "permissions": { "type": "profile", "id": ":workspace" } + // "permissions": ":workspace" + // Experimental runtime roots for :workspace_roots materialization: + // "runtimeWorkspaceRoots": ["/Users/me/project", "/Users/me/openai"], // Do not send both "sandbox" and "permissions". "personality": "friendly", "serviceName": "my_app_server_client", // optional metrics tag (`service_name`) @@ -649,7 +651,9 @@ You can optionally specify config overrides on the new turn. If specified, these "networkAccess": true }, // Prefer experimental profile selection: - // "permissions": { "type": "profile", "id": ":workspace" } + // "permissions": ":workspace" + // Experimental runtime roots for :workspace_roots materialization: + // "runtimeWorkspaceRoots": ["/Users/me/project", "/Users/me/openai"], // Do not send both "sandboxPolicy" and "permissions". "model": "gpt-5.1-codex", "effort": "medium", diff --git a/codex-rs/app-server/src/message_processor_tracing_tests.rs b/codex-rs/app-server/src/message_processor_tracing_tests.rs index c955d06ba289..a9625d3086ce 100644 --- a/codex-rs/app-server/src/message_processor_tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor_tracing_tests.rs @@ -659,6 +659,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { }], responsesapi_client_metadata: None, cwd: None, + runtime_workspace_roots: None, approval_policy: None, sandbox_policy: None, permissions: None, diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 2c20c1816a19..611678a71313 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -103,7 +103,6 @@ use codex_app_server_protocol::MockExperimentalMethodParams; use codex_app_server_protocol::MockExperimentalMethodResponse; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; -use codex_app_server_protocol::PermissionProfileModificationParams; use codex_app_server_protocol::PermissionProfileSelectionParams; use codex_app_server_protocol::PluginDetail; use codex_app_server_protocol::PluginInstallParams; diff --git a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs index 7dab206d8519..c64066ee93ca 100644 --- a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs +++ b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs @@ -604,6 +604,7 @@ pub(super) async fn handle_pending_thread_resume_request( permission_profile, active_permission_profile, cwd, + workspace_roots, reasoning_effort, .. } = pending.config_snapshot; @@ -620,6 +621,7 @@ pub(super) async fn handle_pending_thread_resume_request( model_provider: model_provider_id, service_tier, cwd, + runtime_workspace_roots: workspace_roots, instruction_sources, approval_policy: approval_policy.into(), approvals_reviewer: approvals_reviewer.into(), diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 85b2e5757108..3038feeef11e 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -59,6 +59,25 @@ fn collect_resume_override_mismatches( )); } } + if let Some(requested_runtime_workspace_roots) = request.runtime_workspace_roots.as_ref() { + let base_cwd = request + .cwd + .as_deref() + .map(|cwd| { + AbsolutePathBuf::resolve_path_against_base(cwd, config_snapshot.cwd.as_path()) + }) + .unwrap_or_else(|| config_snapshot.cwd.clone()); + let requested_runtime_workspace_roots = requested_runtime_workspace_roots + .iter() + .map(|path| AbsolutePathBuf::resolve_path_against_base(path, base_cwd.as_path())) + .collect::>(); + if requested_runtime_workspace_roots != config_snapshot.workspace_roots { + mismatch_details.push(format!( + "runtime_workspace_roots requested={requested_runtime_workspace_roots:?} active={:?}", + config_snapshot.workspace_roots + )); + } + } if let Some(requested_approval) = request.approval_policy.as_ref() { let active_approval: AskForApproval = config_snapshot.approval_policy.into(); if requested_approval != &active_approval { @@ -804,6 +823,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + runtime_workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -837,6 +857,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + runtime_workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -1173,6 +1194,7 @@ impl ThreadRequestProcessor { model_provider: config_snapshot.model_provider_id, service_tier: config_snapshot.service_tier, cwd: config_snapshot.cwd, + runtime_workspace_roots: config_snapshot.workspace_roots, instruction_sources, approval_policy: config_snapshot.approval_policy.into(), approvals_reviewer: config_snapshot.approvals_reviewer.into(), @@ -1214,6 +1236,7 @@ impl ThreadRequestProcessor { model_provider: Option, service_tier: Option>, cwd: Option, + runtime_workspace_roots: Option>, approval_policy: Option, approvals_reviewer: Option, sandbox: Option, @@ -1227,6 +1250,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd: cwd.map(PathBuf::from), + workspace_roots: runtime_workspace_roots, approval_policy: approval_policy .map(codex_app_server_protocol::AskForApproval::to_core), approvals_reviewer: approvals_reviewer @@ -2351,6 +2375,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + runtime_workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -2386,6 +2411,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + runtime_workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -2523,6 +2549,7 @@ impl ThreadRequestProcessor { model_provider: session_configured.model_provider_id, service_tier: session_configured.service_tier, cwd: session_configured.cwd, + runtime_workspace_roots: config_snapshot.workspace_roots, instruction_sources, approval_policy: session_configured.approval_policy.into(), approvals_reviewer: session_configured.approvals_reviewer.into(), @@ -2987,6 +3014,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + runtime_workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -3052,6 +3080,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + runtime_workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -3181,6 +3210,7 @@ impl ThreadRequestProcessor { model_provider: session_configured.model_provider_id, service_tier: session_configured.service_tier, cwd: session_configured.cwd, + runtime_workspace_roots: config_snapshot.workspace_roots, instruction_sources, approval_policy: session_configured.approval_policy.into(), approvals_reviewer: session_configured.approvals_reviewer.into(), diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index f59daab2fbd4..c2e6b9a55e1d 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -636,6 +636,7 @@ mod thread_processor_behavior_tests { model_provider: None, service_tier: Some(Some("priority".to_string())), cwd: None, + runtime_workspace_roots: None, approval_policy: None, approvals_reviewer: None, sandbox: None, @@ -656,6 +657,8 @@ mod thread_processor_behavior_tests { permission_profile: codex_protocol::models::PermissionProfile::Disabled, active_permission_profile: None, cwd, + workspace_roots: Vec::new(), + profile_workspace_roots: Vec::new(), ephemeral: false, reasoning_effort: None, personality: None, diff --git a/codex-rs/app-server/src/request_processors/thread_summary.rs b/codex-rs/app-server/src/request_processors/thread_summary.rs index 875bd3deaf97..63ceeb55e2a6 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -179,19 +179,23 @@ pub(super) fn apply_permission_profile_selection_to_config_overrides( overrides: &mut ConfigOverrides, permissions: Option, ) { - let Some(PermissionProfileSelectionParams::Profile { id, modifications }) = permissions else { + let Some(selection) = permissions else { return; }; - overrides.default_permissions = Some(id); - overrides - .additional_writable_roots - .extend(modifications.unwrap_or_default().into_iter().map( - |modification| match modification { - PermissionProfileModificationParams::AdditionalWritableRoot { path } => { - path.to_path_buf() - } - }, - )); + overrides.default_permissions = Some(selection.id().to_string()); + if selection.legacy_additional_writable_roots().is_empty() { + return; + } + + let legacy_roots = selection + .legacy_additional_writable_roots() + .iter() + .map(AbsolutePathBuf::to_path_buf); + if let Some(workspace_roots) = overrides.workspace_roots.as_mut() { + workspace_roots.extend(legacy_roots); + } else { + overrides.additional_writable_roots.extend(legacy_roots); + } } pub(super) fn thread_response_sandbox_policy( diff --git a/codex-rs/app-server/src/request_processors/thread_summary_tests.rs b/codex-rs/app-server/src/request_processors/thread_summary_tests.rs index f8902e132d54..7fda62bef957 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary_tests.rs @@ -66,3 +66,43 @@ fn extract_conversation_summary_prefers_plain_user_messages() -> Result<()> { assert_eq!(summary, expected); Ok(()) } + +#[test] +fn legacy_permission_profile_modifications_extend_runtime_roots() -> Result<()> { + let root = if cfg!(windows) { + AbsolutePathBuf::try_from("C:\\workspace-extra")? + } else { + AbsolutePathBuf::try_from("/workspace-extra")? + }; + let selection = serde_json::from_value::(json!({ + "type": "profile", + "id": ":workspace", + "modifications": [ + { + "type": "additionalWritableRoot", + "path": root.clone(), + } + ], + }))?; + + let mut overrides = ConfigOverrides::default(); + apply_permission_profile_selection_to_config_overrides(&mut overrides, Some(selection.clone())); + assert_eq!( + overrides.default_permissions, + Some(":workspace".to_string()) + ); + assert_eq!( + overrides.additional_writable_roots, + vec![root.to_path_buf()] + ); + + let mut overrides = ConfigOverrides { + workspace_roots: Some(Vec::new()), + ..ConfigOverrides::default() + }; + apply_permission_profile_selection_to_config_overrides(&mut overrides, Some(selection)); + assert_eq!(overrides.additional_writable_roots, Vec::::new()); + assert_eq!(overrides.workspace_roots, Some(vec![root.to_path_buf()])); + + Ok(()) +} diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 110406cc9ead..54fc0383e44b 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -16,6 +16,20 @@ pub(crate) struct TurnRequestProcessor { skills_watcher: Arc, } +fn resolve_runtime_workspace_roots( + workspace_roots: Vec, + base_cwd: &AbsolutePathBuf, +) -> Vec { + let mut resolved_roots = Vec::new(); + for path in workspace_roots { + let root = AbsolutePathBuf::resolve_path_against_base(path, base_cwd.as_path()); + if !resolved_roots.iter().any(|existing| existing == &root) { + resolved_roots.push(root); + } + } + resolved_roots +} + impl TurnRequestProcessor { #[allow(clippy::too_many_arguments)] pub(crate) fn new( @@ -355,8 +369,16 @@ impl TurnRequestProcessor { .map(V2UserInput::into_core) .collect(); let turn_has_input = !mapped_items.is_empty(); + let runtime_workspace_roots_request = params.runtime_workspace_roots.clone(); + let snapshot = if params.permissions.is_some() || runtime_workspace_roots_request.is_some() + { + Some(thread.config_snapshot().await) + } else { + None + }; let has_any_overrides = params.cwd.is_some() + || runtime_workspace_roots_request.is_some() || params.approval_policy.is_some() || params.approvals_reviewer.is_some() || params.sandbox_policy.is_some() @@ -375,16 +397,45 @@ impl TurnRequestProcessor { } let cwd = params.cwd; + let runtime_workspace_roots = if let Some(workspace_roots) = + runtime_workspace_roots_request.clone() + { + let Some(snapshot) = snapshot.as_ref() else { + return Err(internal_error( + "turn/start runtime workspace roots missing thread snapshot", + )); + }; + let base_cwd = cwd + .as_ref() + .map(|cwd| AbsolutePathBuf::resolve_path_against_base(cwd, snapshot.cwd.as_path())) + .unwrap_or_else(|| snapshot.cwd.clone()); + Some(resolve_runtime_workspace_roots(workspace_roots, &base_cwd)) + } else { + None + }; let approval_policy = params.approval_policy.map(AskForApproval::to_core); let approvals_reviewer = params .approvals_reviewer .map(codex_app_server_protocol::ApprovalsReviewer::to_core); let sandbox_policy = params.sandbox_policy.map(|p| p.to_core()); - let (permission_profile, active_permission_profile) = + let (permission_profile, active_permission_profile, profile_workspace_roots) = if let Some(permissions) = params.permissions { - let snapshot = thread.config_snapshot().await; + let Some(snapshot) = snapshot.as_ref() else { + return Err(internal_error( + "turn/start permission selection missing thread snapshot", + )); + }; let mut overrides = ConfigOverrides { cwd: cwd.clone(), + workspace_roots: Some(runtime_workspace_roots_request.clone().unwrap_or_else( + || { + snapshot + .workspace_roots + .iter() + .map(AbsolutePathBuf::to_path_buf) + .collect() + }, + )), codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), ..Default::default() @@ -413,11 +464,12 @@ impl TurnRequestProcessor { ))); } ( - Some(config.permissions.effective_permission_profile()), + Some(config.permissions.permission_profile().clone()), config.permissions.active_permission_profile(), + Some(config.permissions.profile_workspace_roots().to_vec()), ) } else { - (None, None) + (None, None, None) }; let model = params.model; let effort = params.effort.map(Some); @@ -432,11 +484,13 @@ impl TurnRequestProcessor { thread .validate_turn_context_overrides(CodexThreadTurnContextOverrides { cwd: cwd.clone(), + workspace_roots: runtime_workspace_roots.clone(), approval_policy, approvals_reviewer, sandbox_policy: sandbox_policy.clone(), permission_profile: permission_profile.clone(), active_permission_profile: active_permission_profile.clone(), + profile_workspace_roots: profile_workspace_roots.clone(), windows_sandbox_level: None, model: model.clone(), effort, @@ -457,6 +511,8 @@ impl TurnRequestProcessor { final_output_json_schema: params.output_schema, responsesapi_client_metadata: params.responsesapi_client_metadata, cwd, + workspace_roots: runtime_workspace_roots, + profile_workspace_roots, approval_policy, approvals_reviewer, sandbox_policy, diff --git a/codex-rs/app-server/tests/suite/v2/skills_list.rs b/codex-rs/app-server/tests/suite/v2/skills_list.rs index 416b2515adf0..db982bc5d0b8 100644 --- a/codex-rs/app-server/tests/suite/v2/skills_list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills_list.rs @@ -614,6 +614,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<( model_provider: None, service_tier: None, cwd: None, + runtime_workspace_roots: None, approval_policy: None, approvals_reviewer: None, sandbox: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 64dfe0beb710..ff2ccec49cca 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -184,6 +184,79 @@ async fn thread_resume_rejects_unmaterialized_thread() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_updates_runtime_workspace_roots_for_loaded_thread() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let extra_root_tmp = TempDir::new()?; + let extra_root = extra_root_tmp.path().join("extra-root"); + std::fs::create_dir_all(&extra_root)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.4".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + runtime_workspace_roots: Some(vec![extra_root.clone(), extra_root.join(".")]), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id, + exclude_turns: true, + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { + runtime_workspace_roots, + .. + } = to_response::(resume_resp)?; + + assert_eq!( + runtime_workspace_roots, + vec![AbsolutePathBuf::from_absolute_path(extra_root)?] + ); + + Ok(()) +} + #[tokio::test] async fn thread_goal_get_rejects_unmaterialized_thread() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 78155d8c9a00..c69ddf9cb49e 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -236,6 +236,48 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_start_resolves_runtime_workspace_roots_against_cwd() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + + let cwd_tmp = TempDir::new()?; + let cwd = cwd_tmp.path().to_path_buf(); + let relative_root = PathBuf::from("extra-root"); + std::fs::create_dir_all(cwd.join(&relative_root))?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let req_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(cwd.to_string_lossy().to_string()), + runtime_workspace_roots: Some(vec![relative_root.clone()]), + ..Default::default() + }) + .await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??; + let ThreadStartResponse { + cwd: response_cwd, + runtime_workspace_roots, + .. + } = to_response::(resp)?; + + assert_eq!(response_cwd, cwd.abs()); + assert_eq!( + runtime_workspace_roots, + vec![cwd_tmp.path().join(relative_root).abs()] + ); + + Ok(()) +} + #[tokio::test] async fn thread_start_rejects_unknown_environment_as_invalid_request() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 314be8a6ff68..b54112ea1bfc 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -34,7 +34,6 @@ use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PatchApplyStatus; use codex_app_server_protocol::PatchChangeKind; -use codex_app_server_protocol::PermissionProfileSelectionParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerRequestResolvedNotification; @@ -780,10 +779,11 @@ async fn turn_start_rejects_invalid_permission_selection_before_starting_turn() text: "Hello".to_string(), text_elements: Vec::new(), }], - permissions: Some(PermissionProfileSelectionParams::Profile { - id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(), - modifications: None, - }), + permissions: Some( + BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS + .to_string() + .into(), + ), ..Default::default() }) .await?; @@ -1891,6 +1891,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { }], responsesapi_client_metadata: None, cwd: Some(first_cwd.clone()), + runtime_workspace_roots: None, approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), approvals_reviewer: None, sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::WorkspaceWrite { @@ -1932,6 +1933,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { }], responsesapi_client_metadata: None, cwd: Some(second_cwd.clone()), + runtime_workspace_roots: None, approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), approvals_reviewer: None, sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess), diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index be5b0ccb886b..1a2d5ed710ab 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -59,6 +59,8 @@ pub struct ThreadConfigSnapshot { pub permission_profile: PermissionProfile, pub active_permission_profile: Option, pub cwd: AbsolutePathBuf, + pub workspace_roots: Vec, + pub profile_workspace_roots: Vec, pub ephemeral: bool, pub reasoning_effort: Option, pub personality: Option, @@ -82,6 +84,8 @@ impl ThreadConfigSnapshot { #[derive(Clone, Default)] pub struct CodexThreadTurnContextOverrides { pub cwd: Option, + pub workspace_roots: Option>, + pub profile_workspace_roots: Option>, pub approval_policy: Option, pub approvals_reviewer: Option, pub sandbox_policy: Option, @@ -258,6 +262,8 @@ impl CodexThread { ) -> ConstraintResult<()> { let CodexThreadTurnContextOverrides { cwd, + workspace_roots, + profile_workspace_roots, approval_policy, approvals_reviewer, sandbox_policy, @@ -283,6 +289,8 @@ impl CodexThread { let updates = SessionSettingsUpdate { cwd, + workspace_roots, + profile_workspace_roots, approval_policy, approvals_reviewer, sandbox_policy, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index b35582c6a561..14a3d754690c 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1905,6 +1905,11 @@ async fn workspace_profile_applies_rules_to_runtime_and_profile_workspace_roots( ) .await?; + assert_eq!( + config.effective_workspace_roots(), + vec![cwd.abs(), runtime_root.abs(), profile_root.abs()] + ); + let policy = config.permissions.file_system_sandbox_policy(); for root in [cwd.abs(), runtime_root.abs(), profile_root.abs()] { assert!( diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 6c913256128f..8fd385259b48 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -394,6 +394,10 @@ impl Permissions { &self.workspace_roots } + pub fn profile_workspace_roots(&self) -> &[AbsolutePathBuf] { + self.permission_profile_state.profile_workspace_roots() + } + fn materialized_permission_profile(&self) -> PermissionProfile { self.permission_profile() .clone() @@ -1227,6 +1231,13 @@ impl Config { Ok(()) } + pub fn effective_workspace_roots(&self) -> Vec { + let mut workspace_roots = self.workspace_roots.clone(); + workspace_roots.extend(self.permissions.profile_workspace_roots().iter().cloned()); + dedupe_absolute_paths(&mut workspace_roots); + workspace_roots + } + pub fn to_models_manager_config(&self) -> ModelsManagerConfig { ModelsManagerConfig { model_context_window: self.model_context_window, @@ -2579,6 +2590,7 @@ impl Config { permission_profile, file_system_sandbox_policy, mut active_permission_profile, + mut profile_workspace_roots, ) = if let Some(mut permission_profile) = permission_profile { let (mut file_system_sandbox_policy, network_sandbox_policy) = permission_profile.to_runtime_permissions(); @@ -2631,6 +2643,7 @@ impl Config { permission_profile, file_system_sandbox_policy, None, + Vec::new(), ) } else if profiles_are_active { let default_permissions = default_permissions.unwrap_or_else(|| { @@ -2721,6 +2734,7 @@ impl Config { permission_profile, file_system_sandbox_policy, active_permission_profile, + configured_workspace_roots, ) } else { let configured_network_proxy_config = NetworkProxyConfig::default(); @@ -2779,6 +2793,7 @@ impl Config { permission_profile, file_system_sandbox_policy, None, + Vec::new(), ) }; if enable_network_proxy && permission_profile.network_sandbox_policy().is_enabled() { @@ -3202,6 +3217,7 @@ impl Config { // The selected profile no longer describes the effective // permissions after requirements forced a fallback. active_permission_profile = None; + profile_workspace_roots.clear(); } apply_requirement_constrained_value( "web_search_mode", @@ -3275,7 +3291,7 @@ impl Config { let permission_profile_state = PermissionProfileState::from_constrained_active_profile( constrained_permission_profile.value, active_permission_profile, - Vec::new(), + profile_workspace_roots, ) .map_err(std::io::Error::from)?; let otel = otel::resolve_config(cfg.otel.unwrap_or_default(), &mut startup_warnings); diff --git a/codex-rs/core/src/config/resolved_permission_profile.rs b/codex-rs/core/src/config/resolved_permission_profile.rs index 54e36da2f826..c7cf264dae60 100644 --- a/codex-rs/core/src/config/resolved_permission_profile.rs +++ b/codex-rs/core/src/config/resolved_permission_profile.rs @@ -132,6 +132,14 @@ impl ResolvedPermissionProfile { }), } } + + pub(crate) fn profile_workspace_roots(&self) -> &[AbsolutePathBuf] { + match self { + Self::Legacy(_) => &[], + Self::BuiltIn(profile) => &profile.profile_workspace_roots, + Self::Named(profile) => &profile.profile_workspace_roots, + } + } } #[derive(Debug, Clone, PartialEq)] @@ -200,6 +208,12 @@ impl PermissionProfileState { .active_permission_profile() } + pub(crate) fn profile_workspace_roots(&self) -> &[AbsolutePathBuf] { + self.resolved_permission_profile + .get() + .profile_workspace_roots() + } + pub(crate) fn can_set_legacy_permission_profile( &self, permission_profile: &PermissionProfile, diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 5cb37f982852..a26b9bb3f6fe 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -146,6 +146,8 @@ pub(super) async fn user_input_or_turn_inner( approval_policy: Some(approval_policy), approvals_reviewer, sandbox_policy: Some(sandbox_policy), + workspace_roots: None, + profile_workspace_roots: None, permission_profile, active_permission_profile: None, windows_sandbox_level: None, @@ -163,6 +165,8 @@ pub(super) async fn user_input_or_turn_inner( } Op::UserInputWithTurnContext { cwd, + workspace_roots, + profile_workspace_roots, approval_policy, approvals_reviewer, sandbox_policy, @@ -195,6 +199,8 @@ pub(super) async fn user_input_or_turn_inner( items, SessionSettingsUpdate { cwd, + workspace_roots, + profile_workspace_roots, approval_policy, approvals_reviewer, sandbox_policy, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 21b98a94963c..88c2fd19b051 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -621,6 +621,7 @@ impl Codex { permission_profile_state: session_permission_profile_state_from_config(&config)?, windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, environments: environment_selections.to_selections(), diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 17ec91e7df51..4a84818cfd98 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -63,9 +63,9 @@ pub(crate) struct SessionConfiguration { /// When to escalate for approval for execution pub(super) approval_policy: Constrained, pub(super) approvals_reviewer: ApprovalsReviewer, - /// Permission profile state for the session. Keep the constrained profile - /// and selected profile id in sync by using the methods below instead of - /// mutating the fields independently. + /// Permission profile state for the session. Keep the constrained profile, + /// active profile id, and profile-defined workspace roots in sync by using + /// the methods below instead of mutating the fields independently. pub(super) permission_profile_state: PermissionProfileState, pub(super) windows_sandbox_level: WindowsSandboxLevel, @@ -74,6 +74,9 @@ pub(crate) struct SessionConfiguration { /// execution sandbox are resolved against this directory **instead** of /// the process-wide current working directory. pub(super) cwd: AbsolutePathBuf, + /// Thread-scoped runtime workspace roots for materializing symbolic + /// workspace permissions at session runtime. + pub(super) workspace_roots: Vec, /// Directory containing all Codex state for this session. pub(super) codex_home: AbsolutePathBuf, /// Optional user-facing name for the thread, updated during the session. @@ -107,13 +110,20 @@ impl SessionConfiguration { } pub(super) fn permission_profile(&self) -> PermissionProfile { - self.permission_profile_state.permission_profile().clone() + self.permission_profile_state + .permission_profile() + .clone() + .materialize_project_roots_with_workspace_roots(&self.workspace_roots) } pub(super) fn active_permission_profile(&self) -> Option { self.permission_profile_state.active_permission_profile() } + pub(super) fn profile_workspace_roots(&self) -> &[AbsolutePathBuf] { + self.permission_profile_state.profile_workspace_roots() + } + pub(super) fn apply_permission_profile_to_permissions( &self, permissions: &mut crate::config::Permissions, @@ -164,6 +174,8 @@ impl SessionConfiguration { permission_profile: self.permission_profile(), active_permission_profile: self.active_permission_profile(), cwd: self.cwd.clone(), + workspace_roots: self.workspace_roots.clone(), + profile_workspace_roots: self.profile_workspace_roots().to_vec(), ephemeral: self.original_config_do_not_use.ephemeral, reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, @@ -243,6 +255,9 @@ impl SessionConfiguration { let cwd_changed = absolute_cwd.as_path() != self.cwd.as_path(); next_configuration.cwd = absolute_cwd; + if let Some(workspace_roots) = updates.workspace_roots.clone() { + next_configuration.workspace_roots = workspace_roots; + } if let Some(permission_profile) = updates.permission_profile.clone() { let active_permission_profile = @@ -256,6 +271,7 @@ impl SessionConfiguration { next_configuration.set_permission_profile_projection( permission_profile, active_permission_profile, + updates.profile_workspace_roots.clone().unwrap_or_default(), Some(¤t_file_system_sandbox_policy), )?; } else if let Some(sandbox_policy) = updates.sandbox_policy.clone() { @@ -311,6 +327,7 @@ impl SessionConfiguration { &mut self, permission_profile: PermissionProfile, active_permission_profile: Option, + profile_workspace_roots: Vec, preserve_deny_reads_from: Option<&FileSystemSandboxPolicy>, ) -> ConstraintResult<()> { let enforcement = permission_profile.enforcement(); @@ -329,7 +346,7 @@ impl SessionConfiguration { self.permission_profile_state.set_active_permission_profile( effective_permission_profile, active_permission_profile, - Vec::new(), + profile_workspace_roots, ) } } @@ -337,6 +354,8 @@ impl SessionConfiguration { #[derive(Default, Clone)] pub(crate) struct SessionSettingsUpdate { pub(crate) cwd: Option, + pub(crate) workspace_roots: Option>, + pub(crate) profile_workspace_roots: Option>, pub(crate) approval_policy: Option, pub(crate) approvals_reviewer: Option, pub(crate) sandbox_policy: Option, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 81012419cb5d..ec4e1af5fd77 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -36,6 +36,7 @@ use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::TrustLevel; use codex_protocol::exec_output::ExecToolCallOutput; +use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::FunctionCallOutputBody; @@ -2917,6 +2918,7 @@ async fn set_rate_limits_retains_previous_credits() { permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, environments: Vec::new(), @@ -3020,6 +3022,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, environments: Vec::new(), @@ -3492,6 +3495,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, environments: Vec::new(), @@ -3675,6 +3679,49 @@ async fn session_configuration_apply_permission_profile_accepts_direct_write_roo ); } +#[tokio::test] +async fn session_configuration_apply_rebinds_symbolic_profile_to_updated_workspace_roots() { + let mut session_configuration = make_session_configuration_for_tests().await; + let old_root = tempfile::tempdir().expect("create old root"); + let new_root = tempfile::tempdir().expect("create new root"); + let profile_root = tempfile::tempdir().expect("create profile root"); + let old_root = old_root.path().abs(); + let new_root = new_root.path().abs(); + let profile_root = profile_root.path().abs(); + session_configuration.workspace_roots = vec![old_root.clone()]; + + let file_system_sandbox_policy = + FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + }, + access: FileSystemAccessMode::Write, + }]); + let permission_profile = PermissionProfile::from_runtime_permissions( + &file_system_sandbox_policy, + NetworkSandboxPolicy::Restricted, + ); + + let updated = session_configuration + .apply(&SessionSettingsUpdate { + workspace_roots: Some(vec![new_root.clone()]), + permission_profile: Some(permission_profile), + active_permission_profile: Some(ActivePermissionProfile::new("dev")), + profile_workspace_roots: Some(vec![profile_root.clone()]), + ..Default::default() + }) + .expect("permission profile update should succeed"); + + let updated_policy = updated.file_system_sandbox_policy(); + assert!(updated_policy.can_write_path_with_cwd(new_root.as_path(), updated.cwd.as_path())); + assert!(!updated_policy.can_write_path_with_cwd(old_root.as_path(), updated.cwd.as_path())); + assert_eq!( + updated.active_permission_profile(), + Some(ActivePermissionProfile::new("dev")) + ); + assert_eq!(updated.profile_workspace_roots(), &[profile_root]); +} + #[cfg_attr(windows, ignore)] #[tokio::test] async fn new_default_turn_uses_config_aware_skills_for_role_overrides() { @@ -4032,6 +4079,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, environments: Vec::new(), @@ -4140,6 +4188,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, environments: default_environments, @@ -4373,6 +4422,7 @@ async fn make_session_with_config_and_rx( permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, environments: default_environments, @@ -4475,6 +4525,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx( permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, environments: default_environments, @@ -5124,6 +5175,8 @@ fn op_kind_distinguishes_turn_ops() { final_output_json_schema: None, responsesapi_client_metadata: None, cwd: None, + workspace_roots: None, + profile_workspace_roots: None, approval_policy: None, approvals_reviewer: None, sandbox_policy: None, @@ -5991,6 +6044,7 @@ where permission_profile_state: config.permissions.permission_profile_state().clone(), windows_sandbox_level: WindowsSandboxLevel::from_config(&config), cwd: config.cwd.clone(), + workspace_roots: config.workspace_roots.clone(), codex_home: config.codex_home.clone(), thread_name: None, environments: default_environments, diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index eefa6bc9da3f..cab18b856133 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -432,6 +432,10 @@ impl Session { let config = session_configuration.original_config_do_not_use.clone(); let mut per_turn_config = (*config).clone(); per_turn_config.cwd = cwd; + per_turn_config.workspace_roots = session_configuration.workspace_roots.clone(); + per_turn_config + .permissions + .set_workspace_roots(session_configuration.workspace_roots.clone()); per_turn_config.model_reasoning_effort = session_configuration.collaboration_mode.reasoning_effort(); per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary; @@ -466,6 +470,10 @@ impl Session { Self::build_per_turn_config(session_configuration, session_configuration.cwd.clone()); config.model = Some(session_configuration.collaboration_mode.model().to_string()); config.permissions.approval_policy = session_configuration.approval_policy.clone(); + config.workspace_roots = session_configuration.workspace_roots.clone(); + config + .permissions + .set_workspace_roots(session_configuration.workspace_roots.clone()); config } diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index a71ca5fa3fe3..9f7174ec68e2 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -24,7 +24,6 @@ use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::McpServerElicitationAction; use codex_app_server_protocol::McpServerElicitationRequestResponse; -use codex_app_server_protocol::PermissionProfileModificationParams; use codex_app_server_protocol::PermissionProfileSelectionParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ReviewStartParams; @@ -790,6 +789,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { responsesapi_client_metadata: None, environments: None, cwd: Some(default_cwd), + runtime_workspace_roots: None, approval_policy: Some(default_approval_policy.into()), approvals_reviewer: None, sandbox_policy: None, @@ -961,6 +961,13 @@ fn thread_start_params_from_config(config: &Config) -> ThreadStartParams { model: config.model.clone(), model_provider: Some(config.model_provider_id.clone()), cwd: Some(config.cwd.to_string_lossy().to_string()), + runtime_workspace_roots: Some( + config + .workspace_roots + .iter() + .map(AbsolutePathBuf::to_path_buf) + .collect(), + ), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(config), sandbox: sandbox.flatten(), @@ -984,6 +991,13 @@ fn thread_resume_params_from_config(config: &Config, thread_id: String) -> Threa model: config.model.clone(), model_provider: Some(config.model_provider_id.clone()), cwd: Some(config.cwd.to_string_lossy().to_string()), + runtime_workspace_roots: Some( + config + .workspace_roots + .iter() + .map(AbsolutePathBuf::to_path_buf) + .collect(), + ), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(config), sandbox: sandbox.flatten(), @@ -997,30 +1011,13 @@ fn permissions_selection_from_config(config: &Config) -> Option PermissionProfileSelectionParams { - let modifications = workspace_roots - .iter() - .filter(|root| root.as_path() != cwd) - .cloned() - .map(|path| PermissionProfileModificationParams::AdditionalWritableRoot { path }) - .collect::>(); - PermissionProfileSelectionParams::Profile { - id: active.id, - modifications: (!modifications.is_empty()).then_some(modifications), - } + PermissionProfileSelectionParams::new(active.id) } fn sandbox_mode_from_permission_profile( diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 2aa26634355e..367bba5d0578 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -459,24 +459,14 @@ async fn thread_start_params_include_review_policy_when_auto_review_is_enabled() } #[test] -fn active_profile_selection_includes_extra_workspace_roots_as_modifications() { - let cwd = test_path_buf("/workspace/project").abs(); - let extra_root = test_path_buf("/workspace/cache").abs(); - - let selection = permissions_selection_from_active_profile( - ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE), - cwd.as_path(), - &[cwd.clone(), extra_root.clone()], - ); +fn active_profile_selection_uses_profile_id_only() { + let selection = permissions_selection_from_active_profile(ActivePermissionProfile::new( + BUILT_IN_PERMISSION_PROFILE_WORKSPACE, + )); assert_eq!( selection, - PermissionProfileSelectionParams::Profile { - id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(), - modifications: Some(vec![ - PermissionProfileModificationParams::AdditionalWritableRoot { path: extra_root } - ]), - } + PermissionProfileSelectionParams::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE) ); } @@ -583,6 +573,7 @@ fn sample_thread_start_response() -> ThreadStartResponse { model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + runtime_workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: codex_app_server_protocol::AskForApproval::OnRequest, approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::AutoReview, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 91fd02d8583e..fac2616d0401 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -462,6 +462,16 @@ pub enum Op { #[serde(skip_serializing_if = "Option::is_none")] cwd: Option, + /// Updated runtime workspace roots used to materialize symbolic + /// `:workspace_roots` filesystem permissions. + #[serde(skip_serializing_if = "Option::is_none")] + workspace_roots: Option>, + + /// Updated profile-defined workspace roots for status summaries and + /// per-turn config reconstruction. + #[serde(skip_serializing_if = "Option::is_none")] + profile_workspace_roots: Option>, + /// Updated command approval policy. #[serde(skip_serializing_if = "Option::is_none")] approval_policy: Option, diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index d77e3b31408e..8d02e6f1e496 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -661,6 +661,7 @@ mod tests { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: next_cwd.clone().abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 25269deadf5f..fa54abfe209b 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3686,6 +3686,7 @@ async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/tmp/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::High), message_history: None, @@ -3934,6 +3935,7 @@ fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: cwd.abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, @@ -4509,6 +4511,7 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, @@ -4572,6 +4575,7 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, @@ -4664,6 +4668,7 @@ async fn backtrack_resubmit_preserves_data_image_urls_in_user_turn() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, @@ -4899,6 +4904,7 @@ async fn refreshed_snapshot_session_persists_resumed_turns() { )]; let resumed_session = ThreadSessionState { cwd: test_path_buf("/tmp/refreshed").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), ..initial_session.clone() }; @@ -5063,6 +5069,7 @@ async fn new_session_requests_shutdown_for_previous_conversation() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, @@ -5184,6 +5191,7 @@ async fn clear_only_ui_reset_preserves_chat_session_state() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/tmp/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index 431bf5f804cb..30f68dc64099 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -352,6 +352,7 @@ mod tests { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: cwd.abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index 6afbc2de38f9..73744ab94a28 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -72,6 +72,7 @@ impl App { permission_profile: permission_profile.clone(), active_permission_profile: active_permission_profile.clone(), cwd: thread.cwd.clone(), + runtime_workspace_roots: self.config.workspace_roots.clone(), instruction_source_paths: Vec::new(), reasoning_effort: self.chat_widget.current_reasoning_effort(), message_history: None, @@ -148,6 +149,7 @@ mod tests { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: cwd.abs(), + runtime_workspace_roots: vec![cwd.abs()], instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 6a0af955ba89..0470d24db6de 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -34,7 +34,6 @@ use codex_app_server_protocol::MemoryResetResponse; use codex_app_server_protocol::Model as ApiModel; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; -use codex_app_server_protocol::PermissionProfileModificationParams; use codex_app_server_protocol::PermissionProfileSelectionParams; use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::RequestId; @@ -577,6 +576,12 @@ impl AppServerSession { responsesapi_client_metadata: None, environments: None, cwd: Some(cwd), + runtime_workspace_roots: Some( + workspace_roots + .iter() + .map(AbsolutePathBuf::to_path_buf) + .collect(), + ), approval_policy: Some(approval_policy), approvals_reviewer: Some(approvals_reviewer.into()), sandbox_policy, @@ -1175,34 +1180,22 @@ fn sandbox_mode_from_permission_profile( fn permissions_selection_from_active_profile( active: ActivePermissionProfile, - cwd: &std::path::Path, - workspace_roots: &[AbsolutePathBuf], ) -> PermissionProfileSelectionParams { - let modifications = workspace_roots - .iter() - .filter(|root| root.as_path() != cwd) - .cloned() - .map(|path| PermissionProfileModificationParams::AdditionalWritableRoot { path }) - .collect::>(); - PermissionProfileSelectionParams::Profile { - id: active.id, - modifications: (!modifications.is_empty()).then_some(modifications), - } + PermissionProfileSelectionParams::new(active.id) } fn turn_permissions_overrides( permission_profile: &PermissionProfile, active_permission_profile: Option, cwd: &std::path::Path, - workspace_roots: &[AbsolutePathBuf], + _workspace_roots: &[AbsolutePathBuf], thread_params_mode: ThreadParamsMode, ) -> ( Option, Option, ) { let permissions = if matches!(thread_params_mode, ThreadParamsMode::Embedded) { - active_permission_profile - .map(|active| permissions_selection_from_active_profile(active, cwd, workspace_roots)) + active_permission_profile.map(permissions_selection_from_active_profile) } else { None }; @@ -1231,13 +1224,7 @@ fn permissions_selection_from_config( config .permissions .active_permission_profile() - .map(|active| { - permissions_selection_from_active_profile( - active, - config.cwd.as_path(), - config.permissions.user_visible_workspace_roots(), - ) - }) + .map(permissions_selection_from_active_profile) } fn thread_start_params_from_config( @@ -1261,6 +1248,13 @@ fn thread_start_params_from_config( model_provider: thread_params_mode.model_provider_from_config(config), service_tier: service_tier_override_from_config(config), cwd: thread_cwd_from_config(config, thread_params_mode, remote_cwd_override), + runtime_workspace_roots: Some( + config + .workspace_roots + .iter() + .map(AbsolutePathBuf::to_path_buf) + .collect(), + ), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(config), sandbox, @@ -1296,6 +1290,13 @@ fn thread_resume_params_from_config( model_provider: thread_params_mode.model_provider_from_config(&config), service_tier: service_tier_override_from_config(&config), cwd: thread_cwd_from_config(&config, thread_params_mode, remote_cwd_override), + runtime_workspace_roots: Some( + config + .workspace_roots + .iter() + .map(AbsolutePathBuf::to_path_buf) + .collect(), + ), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(&config), sandbox, @@ -1328,6 +1329,13 @@ fn thread_fork_params_from_config( model_provider: thread_params_mode.model_provider_from_config(&config), service_tier: service_tier_override_from_config(&config), cwd: thread_cwd_from_config(&config, thread_params_mode, remote_cwd_override), + runtime_workspace_roots: Some( + config + .workspace_roots + .iter() + .map(AbsolutePathBuf::to_path_buf) + .collect(), + ), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(&config), sandbox, @@ -1425,6 +1433,7 @@ async fn thread_session_state_from_thread_start_response( permission_profile, response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), + response.runtime_workspace_roots.clone(), response.instruction_sources.clone(), response.reasoning_effort, config, @@ -1457,6 +1466,7 @@ async fn thread_session_state_from_thread_resume_response( permission_profile, response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), + response.runtime_workspace_roots.clone(), response.instruction_sources.clone(), response.reasoning_effort, config, @@ -1489,6 +1499,7 @@ async fn thread_session_state_from_thread_fork_response( permission_profile, response.active_permission_profile.clone().map(Into::into), response.cwd.clone(), + response.runtime_workspace_roots.clone(), response.instruction_sources.clone(), response.reasoning_effort, config, @@ -1531,6 +1542,7 @@ async fn thread_session_state_from_thread_response( permission_profile: PermissionProfile, active_permission_profile: Option, cwd: AbsolutePathBuf, + runtime_workspace_roots: Vec, instruction_source_paths: Vec, reasoning_effort: Option, config: &Config, @@ -1558,6 +1570,7 @@ async fn thread_session_state_from_thread_response( permission_profile, active_permission_profile, cwd, + runtime_workspace_roots, instruction_source_paths, reasoning_effort, message_history: Some(MessageHistoryMetadata { @@ -1637,19 +1650,23 @@ mod tests { ); assert_eq!(params.cwd, Some(config.cwd.to_string_lossy().to_string())); + assert_eq!( + params.runtime_workspace_roots, + Some( + config + .workspace_roots + .iter() + .map(AbsolutePathBuf::to_path_buf) + .collect() + ) + ); assert_eq!(params.sandbox, None); assert_eq!( params.permissions, config .permissions .active_permission_profile() - .map(|active| { - permissions_selection_from_active_profile( - active, - config.cwd.as_path(), - config.permissions.user_visible_workspace_roots(), - ) - }) + .map(permissions_selection_from_active_profile) ); assert_eq!(params.model_provider, Some(config.model_provider_id)); assert_eq!(params.thread_source, Some(ThreadSource::User)); @@ -1676,11 +1693,8 @@ mod tests { let active_permission_profile = ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE); let workspace_roots = vec![cwd.clone()]; - let expected_permissions = permissions_selection_from_active_profile( - active_permission_profile.clone(), - cwd.as_path(), - &workspace_roots, - ); + let expected_permissions = + permissions_selection_from_active_profile(active_permission_profile.clone()); let (sandbox_policy, permissions) = turn_permissions_overrides( &PermissionProfile::workspace_write(), @@ -1695,12 +1709,12 @@ mod tests { } #[test] - fn embedded_turn_permissions_include_extra_workspace_roots_as_modifications() { + fn embedded_turn_permissions_select_profile_id_only() { let cwd = test_path_buf("/workspace/project").abs(); let extra_root = test_path_buf("/workspace/cache").abs(); let active_permission_profile = ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE); - let workspace_roots = vec![cwd.clone(), extra_root.clone()]; + let workspace_roots = vec![cwd.clone(), extra_root]; let (sandbox_policy, permissions) = turn_permissions_overrides( &PermissionProfile::workspace_write(), @@ -1713,14 +1727,9 @@ mod tests { assert_eq!(sandbox_policy, None); assert_eq!( permissions, - Some(PermissionProfileSelectionParams::Profile { - id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(), - modifications: Some(vec![ - PermissionProfileModificationParams::AdditionalWritableRoot { - path: extra_root - } - ]), - }) + Some(PermissionProfileSelectionParams::new( + BUILT_IN_PERMISSION_PROFILE_WORKSPACE + )) ); } @@ -1777,6 +1786,13 @@ mod tests { &config.permissions.effective_permission_profile(), config.cwd.as_path(), ); + let expected_runtime_workspace_roots = Some( + config + .workspace_roots + .iter() + .map(AbsolutePathBuf::to_path_buf) + .collect::>(), + ); let start = thread_start_params_from_config( &config, @@ -1800,6 +1816,18 @@ mod tests { assert_eq!(start.cwd, None); assert_eq!(resume.cwd, None); assert_eq!(fork.cwd, None); + assert_eq!( + start.runtime_workspace_roots, + expected_runtime_workspace_roots + ); + assert_eq!( + resume.runtime_workspace_roots, + expected_runtime_workspace_roots + ); + assert_eq!( + fork.runtime_workspace_roots, + expected_runtime_workspace_roots + ); assert_eq!(start.model_provider, None); assert_eq!(resume.model_provider, None); assert_eq!(fork.model_provider, None); @@ -2070,6 +2098,10 @@ mod tests { model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp/project").abs(), + runtime_workspace_roots: vec![ + test_path_buf("/tmp/project").abs(), + test_path_buf("/tmp/project/extra").abs(), + ], instruction_sources: vec![test_path_buf("/tmp/project/AGENTS.md").abs()], approval_policy: codex_app_server_protocol::AskForApproval::Never, approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::User, @@ -2090,6 +2122,10 @@ mod tests { .await .expect("resume response should map"); assert_eq!(started.session.forked_from_id, Some(forked_from_id)); + assert_eq!( + started.session.runtime_workspace_roots, + response.runtime_workspace_roots + ); assert_eq!( started.session.instruction_source_paths, response.instruction_sources @@ -2097,6 +2133,17 @@ mod tests { assert_eq!(started.session.permission_profile, read_only_profile); assert_eq!(started.turns.len(), 1); assert_eq!(started.turns[0], response.thread.turns[0]); + + let mut empty_roots_response = response; + empty_roots_response.runtime_workspace_roots = Vec::new(); + let started = started_thread_from_resume_response( + empty_roots_response, + &config, + ThreadParamsMode::Remote, + ) + .await + .expect("resume response should map"); + assert_eq!(started.session.runtime_workspace_roots, Vec::new()); } #[tokio::test] @@ -2193,6 +2240,7 @@ mod tests { /*active_permission_profile*/ None, test_path_buf("/tmp/project").abs(), Vec::new(), + Vec::new(), /*reasoning_effort*/ None, &config, ) @@ -2227,6 +2275,7 @@ mod tests { /*active_permission_profile*/ None, test_path_buf("/tmp/project").abs(), Vec::new(), + Vec::new(), /*reasoning_effort*/ None, &config, ) diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs index 932263bcc16c..5dfb2090f743 100644 --- a/codex-rs/tui/src/chatwidget/session_flow.rs +++ b/codex-rs/tui/src/chatwidget/session_flow.rs @@ -32,26 +32,12 @@ impl ChatWidget { self.forked_from = session.forked_from_id; self.current_rollout_path = session.rollout_path.clone(); self.current_cwd = Some(session.cwd.to_path_buf()); - let previous_cwd = self.config.cwd.clone(); - let previous_workspace_roots = self.config.workspace_roots.clone(); self.config.cwd = session.cwd.clone(); - if !self.config.workspace_roots_explicit { - let mut workspace_roots = vec![session.cwd.clone()]; - if previous_workspace_roots - .iter() - .any(|root| root == &previous_cwd) - { - for root in previous_workspace_roots { - if root != previous_cwd - && !workspace_roots.iter().any(|existing| existing == &root) - { - workspace_roots.push(root); - } - } - } - self.config.workspace_roots = workspace_roots.clone(); - self.config.permissions.set_workspace_roots(workspace_roots); - } + let runtime_workspace_roots = session.runtime_workspace_roots.clone(); + self.config.workspace_roots = runtime_workspace_roots.clone(); + self.config + .permissions + .set_workspace_roots(runtime_workspace_roots); self.effective_service_tier = session.service_tier.clone(); if let Err(err) = self .config diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 95e8317e9c92..96477c8d382f 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -28,6 +28,7 @@ async fn submission_preserves_text_elements_and_local_images() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -131,6 +132,7 @@ async fn submission_includes_configured_permission_profile() { permission_profile: expected_permission_profile.clone(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -180,6 +182,7 @@ async fn submission_keeps_profile_when_legacy_projection_is_external() { permission_profile: expected_permission_profile.clone(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -221,6 +224,7 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -314,6 +318,7 @@ async fn enter_with_only_remote_images_submits_user_turn() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -377,6 +382,7 @@ async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -415,6 +421,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -453,6 +460,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -494,6 +502,7 @@ async fn submission_prefers_selected_duplicate_skill_path() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 2655c8cf9506..d54783615c0b 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -957,6 +957,7 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 6e3ead97bbd4..7034398fc3ba 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -29,6 +29,7 @@ async fn resumed_initial_messages_render_history() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -99,6 +100,7 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -167,6 +169,7 @@ async fn replayed_user_message_preserves_remote_image_urls() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -266,6 +269,7 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { permission_profile: expected_permission_profile, active_permission_profile: None, cwd: expected_cwd.clone(), + runtime_workspace_roots: vec![expected_cwd.clone()], instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -300,6 +304,7 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { assert_eq!( chat.config_ref().permissions.effective_permission_profile(), updated_profile + .clone() .materialize_project_roots_with_workspace_roots(std::slice::from_ref(&expected_cwd)), "effective permissions should still use the current thread runtime workspace roots" ); @@ -380,6 +385,7 @@ async fn session_configured_external_sandbox_keeps_external_runtime_policy() { permission_profile: expected_permission_profile, active_permission_profile: None, cwd: test_path_buf("/home/user/external").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -420,6 +426,7 @@ async fn replayed_user_message_with_only_remote_images_renders_history_cell() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -474,6 +481,7 @@ async fn replayed_user_message_with_only_local_images_renders_history_cell() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -744,6 +752,7 @@ async fn replayed_reasoning_item_hides_raw_reasoning_when_disabled() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_project_path().abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, @@ -789,6 +798,7 @@ async fn replayed_reasoning_item_shows_raw_reasoning_when_enabled() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_project_path().abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index df3615c0fd3a..4c66041271f5 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -584,6 +584,7 @@ async fn permissions_selection_marks_auto_review_current_after_session_configure permission_profile: PermissionProfile::workspace_write(), active_permission_profile: None, cwd: test_project_path().abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, @@ -631,6 +632,7 @@ async fn permissions_selection_marks_auto_review_current_with_custom_workspace_w permission_profile, active_permission_profile: None, cwd, + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index e9fdb6d987ba..b97695e800a2 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1217,6 +1217,7 @@ async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, @@ -1403,6 +1404,7 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 53b60e0d8d40..acd6b7111bc2 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -2230,6 +2230,7 @@ async fn session_configured_clears_goal_status_footer() { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), message_history: None, diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index d78d3f86b009..dabf78805f4f 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -4068,6 +4068,7 @@ mod tests { permission_profile: PermissionProfile::read_only(), active_permission_profile: None, cwd: test_path_buf("/tmp/project").abs(), + runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, message_history: None, diff --git a/codex-rs/tui/src/session_state.rs b/codex-rs/tui/src/session_state.rs index e4d2dbab96ba..a672b47d2896 100644 --- a/codex-rs/tui/src/session_state.rs +++ b/codex-rs/tui/src/session_state.rs @@ -42,6 +42,7 @@ pub(crate) struct ThreadSessionState { /// when the server knows it. pub(crate) active_permission_profile: Option, pub(crate) cwd: AbsolutePathBuf, + pub(crate) runtime_workspace_roots: Vec, pub(crate) instruction_source_paths: Vec, pub(crate) reasoning_effort: Option, pub(crate) message_history: Option,