mirror of
https://github.com/openai/codex.git
synced 2026-10-22 17:58:46 +00:00
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:
@@ -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
|
||||
|
||||
+36
@@ -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"
|
||||
|
||||
+36
@@ -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"
|
||||
|
||||
+39
@@ -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": {
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+2
-1
@@ -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, };
|
||||
|
||||
+9
@@ -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()),
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user