Add managed Desktop workspace storage requirements (#55965)

## What changed

- Add `application.storage.default_project_parent` and `application.storage.projectless_workspace_root` requirements for new Desktop workspaces. Require absolute paths and reject unknown storage keys.
- Apply managed paths to `desktop.defaultProjectParent` and `desktop.projectlessWorkspaceRoot`, overriding local preferences and rejecting writes to managed settings or their containing table while preserving unrelated Desktop settings.
- Expose storage requirements through `config/requirements/read` and update generated JSON schemas, TypeScript types, and Python models.

## Testing

Add coverage for storage validation, per-setting layer precedence, config overrides, write protection, and requirements API responses. Adjust daemon shutdown test deadlines and TUI test synchronization, paste handling, and future allocation.

GitOrigin-RevId: 44b93fe69e6a471c3f485cbf5f9c9ebd34ac3234
This commit is contained in:
acrognale-oai
2026-10-21 19:06:57 +00:00
committed by copyberry
parent 067249a283
commit 57232f111c
25 changed files with 571 additions and 41 deletions
@@ -357,7 +357,8 @@ async fn shutdown_grace_child() {
let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("install shutdown handler");
tokio::fs::write(&ready, "").await.expect("ready marker");
let _ = tokio::time::timeout(Duration::from_secs(8), async {
// Outlive the parent's stop deadline so this watchdog cannot look like a forced stop.
let _ = tokio::time::timeout(Duration::from_secs(/*secs*/ 30), async {
#[cfg(unix)]
terminate.recv().await;
#[cfg(windows)]
@@ -433,11 +434,11 @@ async fn shutdown_grace_handles_process_exit() {
pid_file,
/*restore_release*/ None,
);
let result = tokio::time::timeout(
Duration::from_secs(3),
backend.stop_with_grace(grace_seconds),
)
.await;
// Cover the backend's full stop budget, plus scheduling headroom on busy runners.
let stop_timeout =
super::STOP_FORCE_TIMEOUT + Duration::from_secs(u64::from(grace_seconds) + 5);
let result =
tokio::time::timeout(stop_timeout, backend.stop_with_grace(grace_seconds)).await;
let still_running = backend
.record_is_active(&record)
.await
@@ -7780,6 +7780,42 @@
"type": "null"
}
]
},
"storage": {
"anyOf": [
{
"$ref": "#/definitions/v2/ApplicationStorageRequirements"
},
{
"type": "null"
}
]
}
},
"type": "object"
},
"ApplicationStorageRequirements": {
"description": "Managed storage settings for new Desktop workspaces, not filesystem access restrictions.",
"properties": {
"defaultProjectParent": {
"anyOf": [
{
"$ref": "#/definitions/v2/LegacyAppPathString"
},
{
"type": "null"
}
]
},
"projectlessWorkspaceRoot": {
"anyOf": [
{
"$ref": "#/definitions/v2/LegacyAppPathString"
},
{
"type": "null"
}
]
}
},
"type": "object"
@@ -1015,6 +1015,42 @@
"type": "null"
}
]
},
"storage": {
"anyOf": [
{
"$ref": "#/definitions/ApplicationStorageRequirements"
},
{
"type": "null"
}
]
}
},
"type": "object"
},
"ApplicationStorageRequirements": {
"description": "Managed storage settings for new Desktop workspaces, not filesystem access restrictions.",
"properties": {
"defaultProjectParent": {
"anyOf": [
{
"$ref": "#/definitions/LegacyAppPathString"
},
{
"type": "null"
}
]
},
"projectlessWorkspaceRoot": {
"anyOf": [
{
"$ref": "#/definitions/LegacyAppPathString"
},
{
"type": "null"
}
]
}
},
"type": "object"
@@ -38,6 +38,42 @@
"type": "null"
}
]
},
"storage": {
"anyOf": [
{
"$ref": "#/definitions/ApplicationStorageRequirements"
},
{
"type": "null"
}
]
}
},
"type": "object"
},
"ApplicationStorageRequirements": {
"description": "Managed storage settings for new Desktop workspaces, not filesystem access restrictions.",
"properties": {
"defaultProjectParent": {
"anyOf": [
{
"$ref": "#/definitions/LegacyAppPathString"
},
{
"type": "null"
}
]
},
"projectlessWorkspaceRoot": {
"anyOf": [
{
"$ref": "#/definitions/LegacyAppPathString"
},
{
"type": "null"
}
]
}
},
"type": "object"
@@ -808,6 +844,9 @@
},
"type": "object"
},
"LegacyAppPathString": {
"type": "string"
},
"ManagedHooksRequirements": {
"properties": {
"Interrupt": {
@@ -2,5 +2,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ApplicationNetworkRequirements } from "./ApplicationNetworkRequirements";
import type { ApplicationStorageRequirements } from "./ApplicationStorageRequirements";
export type ApplicationRequirements = { network: ApplicationNetworkRequirements | null, };
export type ApplicationRequirements = { network: ApplicationNetworkRequirements | null, storage: ApplicationStorageRequirements | null, };
@@ -0,0 +1,9 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { LegacyAppPathString } from "../LegacyAppPathString";
/**
* Managed storage settings for new Desktop workspaces, not filesystem access restrictions.
*/
export type ApplicationStorageRequirements = { defaultProjectParent: LegacyAppPathString | null, projectlessWorkspaceRoot: LegacyAppPathString | null, };
@@ -34,6 +34,7 @@ export type { AppToolSummary } from "./AppToolSummary";
export type { AppToolsConfig } from "./AppToolsConfig";
export type { ApplicationNetworkRequirements } from "./ApplicationNetworkRequirements";
export type { ApplicationRequirements } from "./ApplicationRequirements";
export type { ApplicationStorageRequirements } from "./ApplicationStorageRequirements";
export type { ApprovalsReviewer } from "./ApprovalsReviewer";
export type { AppsConfig } from "./AppsConfig";
export type { AppsDefaultConfig } from "./AppsDefaultConfig";
@@ -1,8 +1,9 @@
//! Managed requirements for application traffic, separate from agent permissions.
//! Managed application network and storage requirements, separate from agent permissions.
use super::NetworkDomainPermission;
use crate::JsonSchema;
use crate::TS;
use codex_utils_path_uri::LegacyAppPathString;
use serde::Deserialize;
use serde::Serialize;
use std::collections::BTreeMap;
@@ -12,6 +13,7 @@ use std::collections::BTreeMap;
#[ts(export_to = "v2/")]
pub struct ApplicationRequirements {
pub network: Option<ApplicationNetworkRequirements>,
pub storage: Option<ApplicationStorageRequirements>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
@@ -22,3 +24,12 @@ pub struct ApplicationNetworkRequirements {
pub enabled: bool,
pub domains: BTreeMap<String, NetworkDomainPermission>,
}
/// Managed storage settings for new Desktop workspaces, not filesystem access restrictions.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ApplicationStorageRequirements {
pub default_project_parent: Option<LegacyAppPathString>,
pub projectless_workspace_root: Option<LegacyAppPathString>,
}
@@ -2810,3 +2810,68 @@ async fn permission_config_reload_merges_session_layers() -> Result<()> {
);
Ok(())
}
/// Cloud storage requirements override user config and reject leaf or whole-table writes without touching the file.
#[tokio::test]
async fn cloud_desktop_storage_requirements_control_reads_and_writes() {
let tmp = tempdir().expect("tempdir");
let path = tmp.path().join(CONFIG_TOML_FILE);
let original = "[desktop]\ndefaultProjectParent = 'user'\nprojectlessWorkspaceRoot = 'user'\nlocaleOverride = 'en-US'\n";
std::fs::write(&path, original).unwrap();
let service = ConfigManager::new_for_tests(
tmp.path().to_path_buf(),
vec![],
LoaderOverrides::without_managed_config_for_tests(),
CloudConfigBundleFixture::loader_with_enterprise_requirement(format!(
"[application.storage]\ndefault_project_parent = \"{}\"\nprojectless_workspace_root = \"{}\"",
toml_path(tmp.path(), "projects"),
toml_path(tmp.path(), "chats"),
)),
);
let response = service
.read(ConfigReadParams {
include_layers: false,
cwd: None,
})
.await
.unwrap();
assert_eq!(
serde_json::to_value(response.config.desktop).unwrap(),
serde_json::json!({
"defaultProjectParent": tmp.path().join("projects"),
"projectlessWorkspaceRoot": tmp.path().join("chats"),
"localeOverride": "en-US",
})
);
for key in [
"desktop.defaultProjectParent",
"desktop.projectlessWorkspaceRoot",
"desktop",
] {
let error = service
.write_value(ConfigValueWriteParams {
file_path: Some(path.display().to_string()),
key_path: key.into(),
value: serde_json::Value::Null,
merge_strategy: MergeStrategy::Replace,
expected_version: None,
})
.await
.expect_err("managed storage is read-only");
assert_eq!(
error.write_error_code(),
Some(ConfigWriteErrorCode::ConfigRequirementReadonly)
);
}
assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
service
.write_value(ConfigValueWriteParams {
file_path: Some(path.display().to_string()),
key_path: "desktop.localeOverride".into(),
value: serde_json::json!("fr-FR"),
merge_strategy: MergeStrategy::Replace,
expected_version: None,
})
.await
.expect("unmanaged desktop settings stay writable");
}
@@ -1,3 +1,4 @@
use codex_app_server_protocol::ApplicationStorageRequirements;
use std::sync::Arc;
use crate::config_manager::ConfigManager;
@@ -462,6 +463,14 @@ pub(crate) fn map_requirements_to_api(
allowed_login_methods: Some(allowed_login_methods),
application: requirements.application.map(|application| {
codex_app_server_protocol::ApplicationRequirements {
storage: application
.storage
.map(|storage| ApplicationStorageRequirements {
default_project_parent: storage.default_project_parent.map(Into::into),
projectless_workspace_root: storage
.projectless_workspace_root
.map(Into::into),
}),
network: application.network.map(|network| {
codex_app_server_protocol::ApplicationNetworkRequirements {
enabled: network.enabled,
@@ -28,14 +28,14 @@ async fn read_requirements(server: &mut TestAppServer) -> Result<Value> {
async fn application_network_defaults_and_null_base_url_are_exposed() -> Result<()> {
for (contents, application) in [
("", Value::Null),
("[application]", json!({"network": null})),
("[application]", json!({"network": null, "storage": null})),
(
"[application.network]",
json!({"network": {"enabled": true, "domains": {}}}),
json!({"network": {"enabled": true, "domains": {}}, "storage": null}),
),
(
"[application.network]\nenabled = false",
json!({"network": {"enabled": false, "domains": {}}}),
json!({"network": {"enabled": false, "domains": {}}, "storage": null}),
),
] {
let home = TempDir::new()?;
@@ -88,7 +88,7 @@ chatgpt_base_url = "https://managed.example.com/backend-api/"
&wire["requirements"]["chatgptBaseUrl"]
),
(
&json!({"network": {"enabled": true, "domains": {"gov.example.com": "allow", "github.com": "deny"}}}),
&json!({"network": {"enabled": true, "domains": {"gov.example.com": "allow", "github.com": "deny"}}, "storage": null}),
&json!({"github.com": "allow"}),
&json!("https://managed.example.com/backend-api/")
),
@@ -0,0 +1,35 @@
//! Desktop storage policies are exposed through the public requirements API.
use anyhow::Result;
use app_test_support::TestAppServer;
use pretty_assertions::assert_eq;
use serde_json::json;
/// Desktop can obtain enforced storage paths before creating its first workspace.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn desktop_storage_requirements_are_exposed() -> Result<()> {
let home = tempfile::tempdir()?;
let projects = home.path().join("projects");
let chats = home.path().join("chats");
std::fs::write(
home.path().join("requirements.toml"),
format!(
"[application.storage]\ndefault_project_parent = '{}'\nprojectless_workspace_root = '{}'",
projects.display(),
chats.display(),
),
)?;
let mut server = TestAppServer::builder()
.with_codex_home(home.path())
.build_initialized()
.await?;
let id = server.send_config_requirements_read_request().await?;
let response: serde_json::Value = server.read_response(id).await?;
assert_eq!(
response["requirements"]["application"]["storage"],
json!({
"defaultProjectParent": projects, "projectlessWorkspaceRoot": chats,
})
);
Ok(())
}
@@ -28,6 +28,7 @@ mod config_model_provider_requirements;
mod config_requirements_application;
#[path = "config_requirements_browser_use_tests.rs"]
mod config_requirements_browser_use;
mod config_requirements_desktop;
#[path = "config_requirements_exec_tests.rs"]
mod config_requirements_exec;
mod config_requirements_in_app_browser;
@@ -280,7 +280,7 @@ async fn login_and_workspace_switch_notify_after_routing_is_ready_then_logout_cl
requirements["requirements"]["application"],
json!({"network": {
"enabled": false, "domains": {format!("{account}.example"): "allow"}
}})
}, "storage": null})
);
}
let request = server.send_logout_account_request().await?;
@@ -10,6 +10,8 @@ use std::collections::BTreeMap;
#[serde(deny_unknown_fields)]
pub struct ApplicationRequirementsToml {
pub network: Option<ApplicationNetworkRequirementsToml>,
// Keep optional storage paths out of config-loading futures' inline state.
pub storage: Option<Box<crate::ApplicationStorageRequirementsToml>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -56,6 +56,7 @@ fn application_network_defaults_to_enabled_and_denies_unlisted_domains() {
assert_eq!(
requirements.application,
Some(ApplicationRequirementsToml {
storage: None,
network: Some(ApplicationNetworkRequirementsToml {
enabled: true,
domains: BTreeMap::new(),
@@ -0,0 +1,74 @@
//! Managed locations for newly created Desktop workspaces. These requirements
//! fix the storage settings; they do not restrict access to other directories.
use crate::AbsolutePathBuf;
use crate::config_toml::ConfigToml;
use serde::Deserialize;
use serde_json::Value;
#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ApplicationStorageRequirementsToml {
#[serde(default, deserialize_with = "deserialize_absolute_path")]
pub default_project_parent: Option<AbsolutePathBuf>,
#[serde(default, deserialize_with = "deserialize_absolute_path")]
pub projectless_workspace_root: Option<AbsolutePathBuf>,
}
impl ApplicationStorageRequirementsToml {
pub(crate) fn is_empty(&self) -> bool {
self.default_project_parent.is_none() && self.projectless_workspace_root.is_none()
}
pub(crate) fn apply_to_config(&self, config: &mut ConfigToml) {
for (key, path) in [
("defaultProjectParent", &self.default_project_parent),
("projectlessWorkspaceRoot", &self.projectless_workspace_root),
] {
if let Some(path) = path {
config.desktop.get_or_insert_default().insert(
key.to_string(),
Value::String(path.to_string_lossy().into_owned()),
);
}
}
}
pub(crate) fn exact_requirement_for_config_path(
&self,
segments: &[String],
) -> Option<&'static str> {
[
(
self.default_project_parent.is_some(),
"defaultProjectParent",
"desktop.defaultProjectParent",
),
(
self.projectless_workspace_root.is_some(),
"projectlessWorkspaceRoot",
"desktop.projectlessWorkspaceRoot",
),
]
.into_iter()
.find_map(|(managed, key, name)| {
let path = ["desktop", key];
(managed && segments.iter().zip(path).all(|(left, right)| left == right))
.then_some(name)
})
}
}
#[cfg(test)]
#[path = "application_storage_requirements_tests.rs"]
mod tests;
// Requirements must not resolve relative to a machine-specific config directory.
fn deserialize_absolute_path<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<AbsolutePathBuf>, D::Error> {
let path = std::path::PathBuf::deserialize(deserializer)?;
AbsolutePathBuf::from_absolute_path_checked(path)
.map(Some)
.map_err(serde::de::Error::custom)
}
@@ -0,0 +1,170 @@
//! Exercises managed storage projection, validation, and cloud layer precedence.
use crate::ConfigRequirementsToml;
use crate::config_toml::ConfigToml;
use pretty_assertions::assert_eq;
use serde_json::json;
/// Managed paths override local preferences without replacing unrelated desktop settings.
#[test]
fn storage_requirements_project_and_protect_only_managed_settings() {
let home = tempfile::tempdir().unwrap();
let parent = home.path().join("projects");
let scratch = home.path().join("scratch");
let requirements: ConfigRequirementsToml = toml::from_str(&format!(
"[application.storage]\ndefault_project_parent = '{}'\nprojectless_workspace_root = '{}'\n",
parent.display(),
scratch.display()
))
.unwrap();
let mut config: ConfigToml = toml::from_str(
"[desktop]\ndefaultProjectParent = 'local'\nprojectlessWorkspaceRoot = 'local'\nlocaleOverride = 'fr-FR'"
).unwrap();
requirements.apply_exact_to_config(&mut config);
assert_eq!(
serde_json::to_value(config.desktop).unwrap(),
json!({
"defaultProjectParent": parent,
"projectlessWorkspaceRoot": scratch,
"localeOverride": "fr-FR"
})
);
for (segments, expected) in [
(vec![], Some("desktop.defaultProjectParent")),
(vec!["desktop"], Some("desktop.defaultProjectParent")),
(
vec!["desktop", "defaultProjectParent"],
Some("desktop.defaultProjectParent"),
),
(
vec!["desktop", "projectlessWorkspaceRoot"],
Some("desktop.projectlessWorkspaceRoot"),
),
(vec!["desktop", "localeOverride"], None),
(vec!["model"], None),
] {
let segments = segments.into_iter().map(str::to_owned).collect::<Vec<_>>();
assert_eq!(
requirements.exact_requirement_for_config_path(&segments),
expected
);
}
assert!(!requirements.is_empty());
}
/// Invalid managed paths must fail parsing rather than silently selecting Documents.
#[test]
fn storage_requirements_reject_relative_paths_and_non_path_values() {
for contents in [
"[application.storage]\ndefault_project_parent = 'relative'",
"[application.storage]\nprojectless_workspace_root = 'relative'",
"[application.storage]\ndefault_project_parent = false",
"[application.storage]\ndefault_project_parent = '${HOME}/Codex'",
] {
assert!(toml::from_str::<ConfigRequirementsToml>(contents).is_err());
}
}
/// Misspelled policy keys must not silently leave storage unmanaged.
#[test]
fn storage_requirements_reject_unknown_keys() {
for key in ["default_project_parrent", "projectless_workspace_rot"] {
let contents = format!("[application.storage]\n{key} = 'ignored'");
let error = toml::from_str::<ConfigRequirementsToml>(&contents).unwrap_err();
assert!(error.message().contains(&format!("unknown field `{key}`")));
}
}
/// Cloud fragments merge per setting without losing independent fields from lower-priority layers.
#[test]
fn cloud_storage_layers_preserve_independent_fields_and_precedence() {
use crate::AbsolutePathBuf;
use crate::CloudConfigBundle;
use crate::CloudConfigBundleLayers;
use crate::CloudRequirementsFragment;
use crate::RequirementSource;
use crate::RequirementsLayerEntry;
use crate::compose_requirements;
let home = tempfile::tempdir().unwrap();
let base = AbsolutePathBuf::from_absolute_path(home.path()).unwrap();
let projects = home.path().join("projects");
let chats = home.path().join("chats");
let local = home.path().join("local");
let mut bundle = CloudConfigBundle::default();
bundle.requirements_toml.enterprise_managed = vec![
CloudRequirementsFragment {
id: "high".into(),
name: "Projects".into(),
contents: format!(
"[application.network]\nenabled = false\n[application.storage]\ndefault_project_parent = '{}'",
projects.display()
),
},
CloudRequirementsFragment {
id: "low".into(),
name: "Storage".into(),
contents: format!(
"[application.storage]\ndefault_project_parent = '{}'\nprojectless_workspace_root = '{}'",
home.path().display(),
chats.display()
),
},
];
let mut layers = CloudConfigBundleLayers::from_bundle(bundle, &base)
.unwrap()
.enterprise_managed_requirements;
let requirements = compose_requirements(layers.clone())
.unwrap()
.unwrap()
.into_toml();
assert!(
!requirements
.application
.as_ref()
.unwrap()
.network
.as_ref()
.unwrap()
.enabled
);
assert_eq!(
requirements
.application
.unwrap()
.storage
.map(|storage| *storage),
Some(super::ApplicationStorageRequirementsToml {
default_project_parent: Some(AbsolutePathBuf::from_absolute_path(&projects).unwrap()),
projectless_workspace_root: Some(AbsolutePathBuf::from_absolute_path(&chats).unwrap()),
})
);
layers.push(RequirementsLayerEntry::from_toml(
RequirementSource::Unknown,
format!(
"[application.storage]\ndefault_project_parent = '{}'",
local.display()
),
));
let requirements = compose_requirements(layers).unwrap().unwrap().into_toml();
assert!(
!requirements
.application
.as_ref()
.unwrap()
.network
.as_ref()
.unwrap()
.enabled
);
assert_eq!(
requirements
.application
.unwrap()
.storage
.map(|storage| *storage),
Some(super::ApplicationStorageRequirementsToml {
default_project_parent: Some(AbsolutePathBuf::from_absolute_path(local).unwrap()),
projectless_workspace_root: Some(AbsolutePathBuf::from_absolute_path(chats).unwrap()),
})
);
}
+22 -4
View File
@@ -1575,10 +1575,13 @@ impl ConfigRequirementsToml {
&& self.rules.is_none()
&& self.enforce_residency.is_none()
&& self.network.is_none()
&& self
.application
.as_ref()
.is_none_or(|application| application.network.is_none())
&& self.application.as_ref().is_none_or(|application| {
application.network.is_none()
&& application
.storage
.as_deref()
.is_none_or(crate::ApplicationStorageRequirementsToml::is_empty)
})
&& self.permissions.is_none()
&& self.auto_review.as_ref().is_none_or(|auto_review| {
auto_review.ignore_rules.as_ref().is_none_or(Vec::is_empty)
@@ -1618,6 +1621,13 @@ impl ConfigRequirementsToml {
apply_exact!(chatgpt_base_url);
apply_exact!(sqlite_home);
apply_exact!(log_dir);
if let Some(storage) = self
.application
.as_ref()
.and_then(|application| application.storage.as_ref())
{
storage.apply_to_config(config);
}
apply_exact!(model_catalog_json);
apply_exact!(model_provider);
if let Some(providers) = &self.model_providers {
@@ -1641,6 +1651,14 @@ impl ConfigRequirementsToml {
/// Returns the exact managed field affected by editing `segments`.
pub fn exact_requirement_for_config_path(&self, segments: &[String]) -> Option<&'static str> {
if let Some(field) = self
.application
.as_ref()
.and_then(|application| application.storage.as_ref())
.and_then(|storage| storage.exact_requirement_for_config_path(segments))
{
return Some(field);
}
if self.model_providers.as_ref().is_some_and(|providers| {
providers
.keys()
+2
View File
@@ -1,4 +1,5 @@
mod application_requirements;
mod application_storage_requirements;
mod auth_policy;
mod browser_computer_use_requirements;
mod browser_use;
@@ -55,6 +56,7 @@ pub const CONFIG_TOML_FILE: &str = "config.toml";
pub use application_requirements::ApplicationNetworkRequirementsToml;
pub use application_requirements::ApplicationRequirementsToml;
pub use application_storage_requirements::ApplicationStorageRequirementsToml;
pub use auth_policy::ManagedAuthPolicy;
pub use browser_computer_use_requirements::AllowDenyRequirementToml;
pub use browser_computer_use_requirements::BrowserUseAccessApprovalLifetimeToml;
@@ -416,16 +416,17 @@ async fn custom_permission_selection_scenario() -> Result<()> {
}))?]).await?;
let mut tui = crate::tui::test_support::make_test_tui()?;
while events.try_recv().is_ok() {}
let side = server
.fork_side_thread(
let side = Box::pin(
server.fork_side_thread(
&app.local_settings,
app.config.clone(),
thread_id,
app.agents_overview
.requested_permission_profiles
.get(&thread_id),
)
.await?;
),
)
.await?;
assert_eq!(
(
side.session.permission_profile,
@@ -440,11 +441,11 @@ async fn custom_permission_selection_scenario() -> Result<()> {
// Both the accepted request and its confirmed descendant remain server-owned.
for _ in 0..2 {
let previous_thread_id = app.chat_widget.thread_id();
app.handle_event(
Box::pin(app.handle_event(
&mut tui,
&mut server,
AppEvent::ForkCurrentSession { name: None },
)
))
.await?;
assert_ne!(app.chat_widget.thread_id(), previous_thread_id);
assert_eq!(
+21 -16
View File
@@ -73,7 +73,7 @@ async fn picker_side_worktree_fork_and_cd_run_on_the_production_stack() -> Resul
// Title generation can include the user prompt and race the actual user turn.
let _title = responses::mount_sse_once_match(
&server,
body_string_contains(r#"\"thread_source\":\"system\""#),
body_string_contains(r#"\"thread_source\":\"thread_title\""#),
responses::sse(vec![
responses::ev_assistant_message("title", r#"{"title":"Stack transition test"}"#),
responses::ev_completed("title-response"),
@@ -109,6 +109,7 @@ async fn picker_side_worktree_fork_and_cd_run_on_the_production_stack() -> Resul
)?;
terminal.wait_for_startup()?;
terminal.wait_for_screen("STACK_SAVED_HISTORY")?;
terminal.wait_for_screen("Worked for")?;
terminal.wait_for_screen("Ask Codex to do anything")?;
// Starting and closing a side conversation both rebuild the displayed chat widget.
@@ -139,13 +140,11 @@ async fn picker_side_worktree_fork_and_cd_run_on_the_production_stack() -> Resul
terminal.wait_for_screen("Continue current conversation")?;
terminal.write_input(b"1")?;
let forked = wait_for_owned_worktrees(&mut terminal, &worktrees, &root, /*count*/ 1)?;
let fork_bucket = forked[0].root.parent().expect("bucket");
// The header changes before the queued history replay has finished attaching.
// Wait for the transition acknowledgement before submitting another command.
terminal.wait_for_screen(&format!(
"/{}/",
fork_bucket
.file_name()
.expect("forked worktree bucket name")
.to_string_lossy()
"Working directory changed to: {}",
forked[0].root.display()
))?;
terminal.ensure_running()?;
@@ -170,23 +169,29 @@ async fn picker_side_worktree_fork_and_cd_run_on_the_production_stack() -> Resul
worktrees.owner(&forked[0].root)? != worktrees.owner(&second.root)?,
"new worktree reused the forked thread owner"
);
let second_bucket = second.root.parent().expect("bucket");
terminal.wait_for_screen(&format!(
"/{}/",
second_bucket
.file_name()
.expect("new worktree bucket name")
.to_string_lossy()
"Working directory changed to: {}",
second.root.display()
))?;
terminal.ensure_running()?;
Ok(())
}
fn submit(terminal: &mut PtyCodex, input: &str) -> Result<()> {
terminal.write_input(input.as_bytes())?;
// Send one paste event so slashes in arguments cannot accept a completion
// while earlier characters are still buffered by paste-burst detection.
terminal.write_input(format!("\x1b[200~{input}\x1b[201~").as_bytes())?;
terminal.wait_for_screen(&input.replacen('/', "/ ", 1))?;
// Bulk PTY writes resemble a paste; wait out the Enter suppression window.
std::thread::sleep(Duration::from_millis(/*millis*/ 250));
// Thread startup can keep the composer in queue mode after the history is visible.
let deadline = Instant::now() + Duration::from_secs(/*secs*/ 30);
while terminal.screen_contains("tab to queue message") {
ensure!(
Instant::now() < deadline,
"composer remained busy before {input:?}"
);
terminal.read_output(Duration::from_millis(/*millis*/ 50))?;
terminal.ensure_running()?;
}
terminal.write_input(b"\r")?;
Ok(())
}
@@ -6950,11 +6950,16 @@ class ApplicationNetworkRequirements(BaseModel):
]
class ApplicationRequirements(BaseModel):
class ApplicationStorageRequirements(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
network: ApplicationNetworkRequirements | None = None
default_project_parent: Annotated[
LegacyAppPathString | None, Field(alias="defaultProjectParent")
] = None
projectless_workspace_root: Annotated[
LegacyAppPathString | None, Field(alias="projectlessWorkspaceRoot")
] = None
class AppsConfig(BaseModel):
@@ -10792,6 +10797,14 @@ class AppListUpdatedNotification(BaseModel):
data: list[AppInfo]
class ApplicationRequirements(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
network: ApplicationNetworkRequirements | None = None
storage: ApplicationStorageRequirements | None = None
class AppsListResponse(BaseModel):
model_config = ConfigDict(
populate_by_name=True,