Enforce exact values from managed config requirements (#34597)

## What changed

- Apply managed values for `sqlite_home`, `log_dir`, `model_catalog_json`,
  `check_for_update_on_startup`, `allow_login_shell`, the report-submission
  opt-in, and `windows.sandbox_private_desktop` to the runtime configuration,
  with source-aware warnings when they override configured values.
- Expose these values through `configRequirements/read`, return them from
  `config/read`, include them in session config locks, and show their sources in
  the TUI config debug view.
- Reject app-server writes that overlap an exact managed requirement with
  `configRequirementReadonly` while leaving unrelated sibling keys writable.

## Testing

- Cover runtime overrides, requirement API mapping, config reads and writes,
  session lock serialization, cross-platform path URIs, and debug output.

GitOrigin-RevId: d4e23b6fd7775c1a6dfe2eab313cdcee50b07081
This commit is contained in:
Abhinav
2026-07-21 19:08:58 +00:00
committed by copyberry
parent 7442f5f932
commit ee71c4a90f
34 changed files with 1566 additions and 71 deletions
@@ -8542,6 +8542,12 @@
"null"
]
},
"allowLoginShell": {
"type": [
"boolean",
"null"
]
},
"allowManagedHooksOnly": {
"type": [
"boolean",
@@ -8599,6 +8605,12 @@
"null"
]
},
"checkForUpdateOnStartup": {
"type": [
"boolean",
"null"
]
},
"computerUse": {
"anyOf": [
{
@@ -8634,6 +8646,28 @@
"null"
]
},
"feedback": {
"anyOf": [
{
"$ref": "#/definitions/v2/FeedbackRequirements"
},
{
"type": "null"
}
]
},
"logDir": {
"type": [
"string",
"null"
]
},
"modelCatalogJson": {
"type": [
"string",
"null"
]
},
"models": {
"anyOf": [
{
@@ -8643,6 +8677,18 @@
"type": "null"
}
]
},
"sqliteHome": {
"type": [
"string",
"null"
]
},
"windowsSandboxPrivateDesktop": {
"type": [
"boolean",
"null"
]
}
},
"type": "object"
@@ -9943,6 +9989,17 @@
],
"type": "string"
},
"FeedbackRequirements": {
"properties": {
"enabled": {
"type": [
"boolean",
"null"
]
}
},
"type": "object"
},
"FeedbackUploadParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -13724,6 +13781,9 @@
}
]
},
"PathUri": {
"type": "string"
},
"PermissionProfileListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -4720,6 +4720,12 @@
"null"
]
},
"allowLoginShell": {
"type": [
"boolean",
"null"
]
},
"allowManagedHooksOnly": {
"type": [
"boolean",
@@ -4777,6 +4783,12 @@
"null"
]
},
"checkForUpdateOnStartup": {
"type": [
"boolean",
"null"
]
},
"computerUse": {
"anyOf": [
{
@@ -4812,6 +4824,28 @@
"null"
]
},
"feedback": {
"anyOf": [
{
"$ref": "#/definitions/FeedbackRequirements"
},
{
"type": "null"
}
]
},
"logDir": {
"type": [
"string",
"null"
]
},
"modelCatalogJson": {
"type": [
"string",
"null"
]
},
"models": {
"anyOf": [
{
@@ -4821,6 +4855,18 @@
"type": "null"
}
]
},
"sqliteHome": {
"type": [
"string",
"null"
]
},
"windowsSandboxPrivateDesktop": {
"type": [
"boolean",
"null"
]
}
},
"type": "object"
@@ -6121,6 +6167,17 @@
],
"type": "string"
},
"FeedbackRequirements": {
"properties": {
"enabled": {
"type": [
"boolean",
"null"
]
}
},
"type": "object"
},
"FeedbackUploadParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -10066,6 +10123,9 @@
}
]
},
"PathUri": {
"type": "string"
},
"PermissionProfileListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -78,6 +78,12 @@
"null"
]
},
"allowLoginShell": {
"type": [
"boolean",
"null"
]
},
"allowManagedHooksOnly": {
"type": [
"boolean",
@@ -135,6 +141,12 @@
"null"
]
},
"checkForUpdateOnStartup": {
"type": [
"boolean",
"null"
]
},
"computerUse": {
"anyOf": [
{
@@ -170,6 +182,28 @@
"null"
]
},
"feedback": {
"anyOf": [
{
"$ref": "#/definitions/FeedbackRequirements"
},
{
"type": "null"
}
]
},
"logDir": {
"type": [
"string",
"null"
]
},
"modelCatalogJson": {
"type": [
"string",
"null"
]
},
"models": {
"anyOf": [
{
@@ -179,6 +213,18 @@
"type": "null"
}
]
},
"sqliteHome": {
"type": [
"string",
"null"
]
},
"windowsSandboxPrivateDesktop": {
"type": [
"boolean",
"null"
]
}
},
"type": "object"
@@ -292,6 +338,17 @@
],
"type": "object"
},
"FeedbackRequirements": {
"properties": {
"enabled": {
"type": [
"boolean",
"null"
]
}
},
"type": "object"
},
"ManagedHooksRequirements": {
"properties": {
"PermissionRequest": {
@@ -0,0 +1,30 @@
// 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.
/**
* An immutable, cross-platform representation of a `file:` URI.
*
* Only the `file:` scheme is currently accepted. Construction validates the
* URL, and the URI cannot be mutated after construction. [`Self::basename`],
* [`Self::parent`], and [`Self::join`] operate on URI path segments without
* interpreting them using the operating system running Codex. Fallback URIs
* created by [`Self::from_abs_path`] are opaque to these lexical operations.
*
* `file:` paths retain their URI spelling so they can be parsed independently
* of the current host. A local POSIX `file:` URI can also retain
* percent-encoded non-UTF-8 bytes for lossless native round trips.
*
* Like [VS Code resources], path operations use `/` URI separators on every
* host. Lexical path operations preserve a URL authority without interpreting
* Windows drive or UNC roots from path text. Native path normalization,
* filesystem aliases, symlinks, case sensitivity, and Unicode normalization
* are not resolved.
*
* Serde represents a `PathUri` as its canonical URI string. Deserialization
* accepts only valid `file:` URI strings. These strings round-trip through
* their canonical URL form, including encoded non-UTF-8 path bytes.
*
* [VS Code resources]: https://github.com/microsoft/vscode/blob/main/src/vs/base/common/resources.ts
*/
export type PathUri = string;
+1
View File
@@ -55,6 +55,7 @@ export type { MultiAgentMode } from "./MultiAgentMode";
export type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment";
export type { NetworkPolicyRuleAction } from "./NetworkPolicyRuleAction";
export type { ParsedCommand } from "./ParsedCommand";
export type { PathUri } from "./PathUri";
export type { Personality } from "./Personality";
export type { PlanType } from "./PlanType";
export type { RealtimeConversationVersion } from "./RealtimeConversationVersion";
@@ -1,12 +1,14 @@
// 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 { PathUri } from "../PathUri";
import type { WebSearchMode } from "../WebSearchMode";
import type { AskForApproval } from "./AskForApproval";
import type { ComputerUseRequirements } from "./ComputerUseRequirements";
import type { FeedbackRequirements } from "./FeedbackRequirements";
import type { ModelsRequirements } from "./ModelsRequirements";
import type { ResidencyRequirement } from "./ResidencyRequirement";
import type { SandboxMode } from "./SandboxMode";
import type { WindowsSandboxSetupMode } from "./WindowsSandboxSetupMode";
export type ConfigRequirements = {allowedApprovalPolicies: Array<AskForApproval> | null, allowedSandboxModes: Array<SandboxMode> | null, allowedWindowsSandboxImplementations: Array<WindowsSandboxSetupMode> | null, allowedPermissionProfiles: { [key in string]?: boolean } | null, defaultPermissions: string | null, allowedWebSearchModes: Array<WebSearchMode> | null, allowManagedHooksOnly: boolean | null, allowAppshots: boolean | null, allowRemoteControl: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null, models: ModelsRequirements | null};
export type ConfigRequirements = {allowedApprovalPolicies: Array<AskForApproval> | null, allowedSandboxModes: Array<SandboxMode> | null, allowedWindowsSandboxImplementations: Array<WindowsSandboxSetupMode> | null, allowedPermissionProfiles: { [key in string]?: boolean } | null, defaultPermissions: string | null, allowedWebSearchModes: Array<WebSearchMode> | null, allowManagedHooksOnly: boolean | null, allowAppshots: boolean | null, allowRemoteControl: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null, models: ModelsRequirements | null, sqliteHome: PathUri | null, logDir: PathUri | null, modelCatalogJson: PathUri | null, checkForUpdateOnStartup: boolean | null, allowLoginShell: boolean | null, feedback: FeedbackRequirements | null, windowsSandboxPrivateDesktop: boolean | null};
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type FeedbackRequirements = { enabled: boolean | null, };
@@ -129,6 +129,7 @@ export type { ExternalAgentConfigMigrationItem } from "./ExternalAgentConfigMigr
export type { ExternalAgentConfigMigrationItemType } from "./ExternalAgentConfigMigrationItemType";
export type { ExternalAgentImportedConnectorCandidate } from "./ExternalAgentImportedConnectorCandidate";
export type { ExternalAgentImportedConnectorSource } from "./ExternalAgentImportedConnectorSource";
export type { FeedbackRequirements } from "./FeedbackRequirements";
export type { FeedbackUploadParams } from "./FeedbackUploadParams";
export type { FeedbackUploadResponse } from "./FeedbackUploadResponse";
export type { FileChangeApprovalDecision } from "./FileChangeApprovalDecision";
@@ -46,7 +46,6 @@ const JSON_V1_ALLOWLIST: &[&str] = &["InitializeParams", "InitializeResponse"];
const EXPERIMENTAL_CLIENT_METHOD_DEPENDENCY_TYPES: &[&str] = &[
"EnvironmentShellInfo",
"EnvironmentStatusKind",
"PathUri",
"RemoteControlClient",
"RemoteControlClientsListOrder",
"ThreadBackgroundTerminal",
@@ -12,6 +12,7 @@ use codex_protocol::config_types::WebSearchMode;
use codex_protocol::config_types::WebSearchToolConfig;
use codex_protocol::openai_models::ReasoningEffort;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
@@ -337,6 +338,7 @@ pub struct ConfigWriteResponse {
#[ts(export_to = "v2/")]
pub enum ConfigWriteErrorCode {
ConfigLayerReadonly,
ConfigRequirementReadonly,
ConfigVersionConflict,
ConfigValidationError,
ConfigPathNotFound,
@@ -392,6 +394,16 @@ pub struct ConfigRequirements {
#[experimental("configRequirements/read.network")]
pub network: Option<NetworkRequirements>,
pub models: Option<ModelsRequirements>,
#[schemars(with = "Option<String>")]
pub sqlite_home: Option<PathUri>,
#[schemars(with = "Option<String>")]
pub log_dir: Option<PathUri>,
#[schemars(with = "Option<String>")]
pub model_catalog_json: Option<PathUri>,
pub check_for_update_on_startup: Option<bool>,
pub allow_login_shell: Option<bool>,
pub feedback: Option<FeedbackRequirements>,
pub windows_sandbox_private_desktop: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
@@ -410,6 +422,13 @@ pub struct NewThreadModelDefaults {
pub service_tier: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct FeedbackRequirements {
pub enabled: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -1807,11 +1807,46 @@ fn config_requirements_granular_allowed_approval_policy_is_marked_experimental()
enforce_residency: None,
network: None,
models: None,
sqlite_home: None,
log_dir: None,
model_catalog_json: None,
check_for_update_on_startup: None,
allow_login_shell: None,
feedback: None,
windows_sandbox_private_desktop: None,
});
assert_eq!(reason, Some("askForApproval.granular"));
}
#[test]
fn config_requirements_read_accepts_foreign_path_uris() {
let response: ConfigRequirementsReadResponse = serde_json::from_value(json!({
"requirements": {
"sqliteHome": "file:///C:/Users/alice/.codex/state",
"logDir": "file:///C:/Users/alice/.codex/logs",
"modelCatalogJson": "file:///C:/Users/alice/.codex/models.json"
}
}))
.expect("requirements response with foreign paths should deserialize");
let requirements = response
.requirements
.expect("requirements should be present");
assert_eq!(
requirements.sqlite_home,
Some(PathUri::parse("file:///C:/Users/alice/.codex/state").expect("valid URI"))
);
assert_eq!(
requirements.log_dir,
Some(PathUri::parse("file:///C:/Users/alice/.codex/logs").expect("valid URI"))
);
assert_eq!(
requirements.model_catalog_json,
Some(PathUri::parse("file:///C:/Users/alice/.codex/models.json").expect("valid URI"))
);
}
#[test]
fn client_request_thread_start_granular_approval_policy_is_marked_experimental() {
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(
@@ -16,6 +16,14 @@ fn typescript_schema_fixtures_match_generated() -> Result<()> {
.context("generate in-memory typescript schema fixtures")?;
assert_schema_trees_match("typescript", &fixture_tree, &generated_tree)?;
let config_requirements = generated_tree
.get(Path::new("v2/ConfigRequirements.ts"))
.context("generated ConfigRequirements.ts should exist")?;
anyhow::ensure!(
!String::from_utf8_lossy(config_requirements).contains("../PathUri")
|| generated_tree.contains_key(Path::new("PathUri.ts")),
"stable ConfigRequirements.ts imports PathUri but PathUri.ts was not generated"
);
Ok(())
}
+3 -3
View File
@@ -242,13 +242,13 @@ Example with notification opt-out:
- `mcpServer/tool/call` — call a tool on a thread's configured MCP server by `threadId`, `server`, `tool`, optional `arguments`, and optional `_meta`, returning the MCP tool result.
- `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional absolute `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`.
- `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id.
- `config/read` — fetch the effective config on disk after resolving config layering, including opaque `desktop` values stored in `config.toml`.
- `config/read` — fetch the runtime-effective config after resolving config layering and managed requirements, including opaque `desktop` values stored in `config.toml`.
- `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome`, optional `cwds`, and an optional `migrationSource` selector. Omitted, `null`, or unrecognized migration-source values retain the default behavior. The deprecated optional `source` field remains accepted for compatibility but does not select the migration source. Each detected item includes `cwd` (`null` for home), and multi-item migrations may additionally include structured `details` with plugin ids, skill names, memory, session metadata, or other artifact names.
- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any `details` returned by detect. Pass the same optional `migrationSource` used for detection so the server reads from the matching source; omitted, `null`, or unrecognized values retain the default behavior. The optional `source` identifies the product that initiated the import, while the optional opaque `providerId` attributes analytics to the provider selected by that product without affecting migration-source selection. The response acknowledges the synchronous import phase with an `importId`. Expected migration failures are reported as per-item failures rather than JSON-RPC errors, so the server still returns that `importId` and emits `externalAgentConfig/import/completed` with the same ID once all synchronous and background work finishes. The completion notification contains type-level `itemTypeResults` with successes and failures, including raw failure messages for the client to report separately.
- `externalAgentConfig/import/readHistories` — read completed import histories and connector candidates detected from successfully imported session histories. Connector candidates include a normalized display `name`, the number of imported sessions that used the connector, and the source metadata field used for detection.
- `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface.
- `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface. Writes that overlap a managed requirement are rejected with `configRequirementReadonly`.
- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads, including multiple `desktop.*` edits.
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), the layered permission-profile allow map (`allowedPermissionProfiles`), the managed permission-profile default (`defaultPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), remote-control policy (`allowRemoteControl`; `false` force-disables remote control while `true` or `null` preserves existing behavior), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`, including each command handler's optional `additionalContextLimit`), `enforceResidency`, managed new-thread defaults (`models.newThread.model`, `models.newThread.modelReasoningEffort`, and `models.newThread.serviceTier`), and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`.
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including exact managed values (`sqliteHome`, `logDir`, `modelCatalogJson`, `checkForUpdateOnStartup`, `allowLoginShell`, `feedback.enabled`, and `windowsSandboxPrivateDesktop`), allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), the layered permission-profile allow map (`allowedPermissionProfiles`), the managed permission-profile default (`defaultPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), remote-control policy (`allowRemoteControl`; `false` force-disables remote control while `true` or `null` preserves existing behavior), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`, including each command handler's optional `additionalContextLimit`), `enforceResidency`, managed new-thread defaults (`models.newThread.model`, `models.newThread.modelReasoningEffort`, and `models.newThread.serviceTier`), and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`.
### Example: Start or resume a thread
@@ -130,19 +130,31 @@ impl ConfigManager {
};
let effective = layers.effective_config();
let effective_config_toml: ConfigToml = effective
let mut effective_config_toml: ConfigToml = effective
.try_into()
.map_err(|err| ConfigManagerError::toml("invalid configuration", err))?;
layers
.requirements_toml()
.apply_exact_to_config(&mut effective_config_toml);
effective_config_toml.allow_login_shell.get_or_insert(true);
let json_value = serde_json::to_value(&effective_config_toml)
.map_err(|err| ConfigManagerError::json("failed to serialize configuration", err))?;
let config: ApiConfig = serde_json::from_value(json_value)
.map_err(|err| ConfigManagerError::json("failed to deserialize configuration", err))?;
let mut origins = layers.origins();
origins.retain(|path, _| {
let segments = path.split('.').map(str::to_string).collect::<Vec<_>>();
layers
.requirements_toml()
.exact_requirement_for_config_path(&segments)
.is_none()
});
Ok(ConfigReadResponse {
config,
origins: layers
.origins()
origins: origins
.into_iter()
.map(|(path, metadata)| (path, config_layer_metadata_to_api(metadata)))
.collect(),
@@ -283,6 +295,15 @@ impl ConfigManager {
let mut segments = parse_key_path(&key_path).map_err(|message| {
ConfigManagerError::write(ConfigWriteErrorCode::ConfigValidationError, message)
})?;
if let Some(field) = layers
.requirements_toml()
.exact_requirement_for_config_path(&segments)
{
return Err(ConfigManagerError::write(
ConfigWriteErrorCode::ConfigRequirementReadonly,
format!("`{field}` is managed by requirements and cannot be changed"),
));
}
if (value.is_null() || matches!(strategy, MergeStrategy::Upsert))
&& let Some(pattern) = shell_environment_filter_entry(&user_config, &segments)
.map(|(pattern, _)| pattern.clone())
@@ -777,6 +777,164 @@ personality = true
);
}
#[tokio::test]
async fn write_value_rejects_exact_managed_requirement() {
let tmp = tempdir().expect("tempdir");
let path = tmp.path().join(CONFIG_TOML_FILE);
std::fs::write(&path, "allow_login_shell = true\n").unwrap();
let service = ConfigManager::new_for_tests(
tmp.path().to_path_buf(),
vec![],
LoaderOverrides::without_managed_config_for_tests(),
CloudConfigBundleFixture::loader_with_enterprise_requirement("allow_login_shell = false"),
);
let error = service
.write_value(ConfigValueWriteParams {
file_path: Some(path.display().to_string()),
key_path: "allow_login_shell".to_string(),
value: serde_json::json!(true),
merge_strategy: MergeStrategy::Replace,
expected_version: None,
})
.await
.expect_err("managed exact field should be read-only");
assert_eq!(
error.write_error_code(),
Some(ConfigWriteErrorCode::ConfigRequirementReadonly)
);
assert!(error.to_string().contains("`allow_login_shell`"));
assert_eq!(
std::fs::read_to_string(path).unwrap(),
"allow_login_shell = true\n"
);
}
fn toml_path(tmp: &Path, name: &str) -> String {
tmp.join(name).to_string_lossy().replace('\\', "\\\\")
}
#[tokio::test]
async fn read_omits_origins_for_exact_managed_values() {
for has_user_values in [true, false] {
let tmp = tempdir().expect("tempdir");
let user_config = if has_user_values {
format!(
r#"model = "user-model"
sqlite_home = "{}"
allow_login_shell = true
[feedback]
enabled = true
"#,
toml_path(tmp.path(), "user-sqlite"),
)
} else {
"model = \"user-model\"\n".to_string()
};
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), user_config).unwrap();
let requirements = format!(
r#"sqlite_home = "{}"
allow_login_shell = false
[feedback]
enabled = false
"#,
toml_path(tmp.path(), "managed-sqlite"),
);
let service = ConfigManager::new_for_tests(
tmp.path().to_path_buf(),
vec![],
LoaderOverrides::without_managed_config_for_tests(),
CloudConfigBundleFixture::loader_with_enterprise_requirement(requirements),
);
let response = service
.read(ConfigReadParams {
include_layers: false,
cwd: None,
})
.await
.expect("config read should succeed");
assert_eq!(
response.config.additional.get("sqlite_home"),
Some(&serde_json::json!(tmp.path().join("managed-sqlite")))
);
assert_eq!(
response.config.additional.get("allow_login_shell"),
Some(&serde_json::json!(false))
);
assert_eq!(
response.config.additional.get("feedback"),
Some(&serde_json::json!({"enabled": false}))
);
for path in ["sqlite_home", "allow_login_shell", "feedback.enabled"] {
assert!(!response.origins.contains_key(path), "origin for {path}");
}
assert!(response.origins.contains_key("model"));
}
}
#[tokio::test]
async fn read_materializes_default_allow_login_shell() {
let tmp = tempdir().expect("tempdir");
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "").unwrap();
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
let response = service
.read(ConfigReadParams {
include_layers: false,
cwd: None,
})
.await
.expect("config read should succeed");
assert_eq!(
response.config.additional.get("allow_login_shell"),
Some(&serde_json::json!(true))
);
}
#[tokio::test]
async fn write_value_allows_unmanaged_sibling_of_exact_requirement() {
let tmp = tempdir().expect("tempdir");
let path = tmp.path().join(CONFIG_TOML_FILE);
std::fs::write(&path, "").unwrap();
let service = ConfigManager::new_for_tests(
tmp.path().to_path_buf(),
vec![],
LoaderOverrides::without_managed_config_for_tests(),
CloudConfigBundleFixture::loader_with_enterprise_requirement(
r#"
[windows]
sandbox_private_desktop = false
"#,
),
);
service
.write_value(ConfigValueWriteParams {
file_path: Some(path.display().to_string()),
key_path: "windows.sandbox".to_string(),
value: serde_json::json!("elevated"),
merge_strategy: MergeStrategy::Replace,
expected_version: None,
})
.await
.expect("unmanaged sibling should remain writable");
assert!(
std::fs::read_to_string(path)
.unwrap()
.contains("sandbox = \"elevated\"")
);
}
#[tokio::test]
async fn read_reports_managed_overrides_user_and_session_flags() {
let tmp = tempdir().expect("tempdir");
@@ -21,6 +21,7 @@ use codex_app_server_protocol::ConfiguredHookHandler;
use codex_app_server_protocol::ConfiguredHookMatcherGroup;
use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams;
use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse;
use codex_app_server_protocol::FeedbackRequirements;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::ManagedHooksRequirements;
use codex_app_server_protocol::ModelProviderCapabilitiesReadResponse;
@@ -315,6 +316,11 @@ impl ConfigRequestProcessor {
}
fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigRequirements {
let windows_sandbox_private_desktop = requirements
.windows
.as_ref()
.and_then(|windows| windows.sandbox_private_desktop);
ConfigRequirements {
allowed_approval_policies: requirements.allowed_approval_policies.map(|policies| {
policies
@@ -384,6 +390,15 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR
service_tier: new_thread.service_tier,
}),
}),
sqlite_home: requirements.sqlite_home.map(Into::into),
log_dir: requirements.log_dir.map(Into::into),
model_catalog_json: requirements.model_catalog_json.map(Into::into),
check_for_update_on_startup: requirements.check_for_update_on_startup,
allow_login_shell: requirements.allow_login_shell,
feedback: requirements.feedback.map(|feedback| FeedbackRequirements {
enabled: feedback.enabled,
}),
windows_sandbox_private_desktop,
}
}
@@ -578,13 +593,17 @@ fn config_write_error(code: ConfigWriteErrorCode, message: impl Into<String>) ->
#[cfg(test)]
mod tests {
use super::map_requirements_toml_to_api;
use codex_app_server_protocol::FeedbackRequirements;
use codex_app_server_protocol::WindowsSandboxSetupMode;
use codex_config::ComputerUseRequirementsToml;
use codex_config::ConfigRequirementsToml;
use codex_config::ModelsRequirementsToml;
use codex_config::NewThreadModelDefaultsToml;
use codex_config::WindowsRequirementsToml;
use codex_config::types::FeedbackConfigToml;
use codex_protocol::openai_models::ReasoningEffort;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
@@ -694,6 +713,7 @@ mod tests {
codex_config::types::WindowsSandboxModeToml::Elevated,
codex_config::types::WindowsSandboxModeToml::Unelevated,
]),
sandbox_private_desktop: Some(false),
}),
..ConfigRequirementsToml::default()
});
@@ -705,5 +725,43 @@ mod tests {
WindowsSandboxSetupMode::Unelevated,
])
);
assert_eq!(mapped.windows_sandbox_private_desktop, Some(false));
}
#[test]
fn requirements_api_includes_exact_managed_values() {
let sqlite_home = AbsolutePathBuf::try_from(std::env::temp_dir().join("managed-state"))
.expect("managed sqlite home should be absolute");
let log_dir = AbsolutePathBuf::try_from(std::env::temp_dir().join("managed-logs"))
.expect("managed log dir should be absolute");
let model_catalog_json =
AbsolutePathBuf::try_from(std::env::temp_dir().join("managed-models.json"))
.expect("managed model catalog path should be absolute");
let mapped = map_requirements_toml_to_api(ConfigRequirementsToml {
sqlite_home: Some(sqlite_home.clone()),
log_dir: Some(log_dir.clone()),
model_catalog_json: Some(model_catalog_json.clone()),
check_for_update_on_startup: Some(false),
allow_login_shell: Some(false),
feedback: Some(FeedbackConfigToml {
enabled: Some(false),
}),
..ConfigRequirementsToml::default()
});
assert_eq!(mapped.sqlite_home, Some(PathUri::from(sqlite_home)));
assert_eq!(mapped.log_dir, Some(PathUri::from(log_dir)));
assert_eq!(
mapped.model_catalog_json,
Some(PathUri::from(model_catalog_json))
);
assert_eq!(mapped.check_for_update_on_startup, Some(false));
assert_eq!(mapped.allow_login_shell, Some(false));
assert_eq!(
mapped.feedback,
Some(FeedbackRequirements {
enabled: Some(false),
})
);
}
}
+310 -32
View File
@@ -20,9 +20,11 @@ use super::requirements_exec_policy::RequirementsExecPolicyToml;
use crate::Constrained;
use crate::ConstraintError;
use crate::ManagedHooksRequirementsToml;
use crate::config_toml::ConfigToml;
use crate::mcp_requirements::McpServerRequirement;
use crate::mcp_types::AppToolApproval;
use crate::permissions_toml::PermissionProfileToml;
use crate::types::FeedbackConfigToml;
use crate::types::WindowsSandboxModeToml;
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -145,10 +147,17 @@ impl<T> std::ops::DerefMut for ConstrainedWithSource<T> {
/// normalization.
#[derive(Debug, Clone, PartialEq)]
pub struct ConfigRequirements {
pub sqlite_home: Option<Sourced<AbsolutePathBuf>>,
pub log_dir: Option<Sourced<AbsolutePathBuf>>,
pub model_catalog_json: Option<Sourced<AbsolutePathBuf>>,
pub check_for_update_on_startup: Option<Sourced<bool>>,
pub allow_login_shell: Option<Sourced<bool>>,
pub feedback: Option<Sourced<FeedbackConfigToml>>,
pub approval_policy: ConstrainedWithSource<AskForApproval>,
pub approvals_reviewer: ConstrainedWithSource<ApprovalsReviewer>,
pub permission_profile: ConstrainedWithSource<PermissionProfile>,
pub windows_sandbox_mode: ConstrainedWithSource<Option<WindowsSandboxModeToml>>,
pub windows_sandbox_private_desktop: Option<Sourced<bool>>,
pub web_search_mode: ConstrainedWithSource<WebSearchMode>,
pub allow_managed_hooks_only: Option<Sourced<bool>>,
pub allow_appshots: Option<Sourced<bool>>,
@@ -172,6 +181,12 @@ pub struct ConfigRequirements {
impl Default for ConfigRequirements {
fn default() -> Self {
Self {
sqlite_home: None,
log_dir: None,
model_catalog_json: None,
check_for_update_on_startup: None,
allow_login_shell: None,
feedback: None,
approval_policy: ConstrainedWithSource::new(
Constrained::allow_any_from_default(),
/*source*/ None,
@@ -188,6 +203,7 @@ impl Default for ConfigRequirements {
Constrained::allow_any(/*initial_value*/ None),
/*source*/ None,
),
windows_sandbox_private_desktop: None,
web_search_mode: ConstrainedWithSource::new(
Constrained::allow_any(WebSearchMode::Cached),
/*source*/ None,
@@ -740,11 +756,12 @@ impl ComputerUseRequirementsToml {
#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
pub struct WindowsRequirementsToml {
pub allowed_sandbox_implementations: Option<Vec<WindowsSandboxModeToml>>,
pub sandbox_private_desktop: Option<bool>,
}
impl WindowsRequirementsToml {
pub fn is_empty(&self) -> bool {
self.allowed_sandbox_implementations.is_none()
self.allowed_sandbox_implementations.is_none() && self.sandbox_private_desktop.is_none()
}
}
@@ -845,6 +862,12 @@ pub(crate) fn merge_app_requirements_descending(
/// Base config deserialized from system `requirements.toml` or MDM.
#[derive(Deserialize, Debug, Clone, Default, PartialEq)]
pub struct ConfigRequirementsToml {
pub sqlite_home: Option<AbsolutePathBuf>,
pub log_dir: Option<AbsolutePathBuf>,
pub model_catalog_json: Option<AbsolutePathBuf>,
pub check_for_update_on_startup: Option<bool>,
pub allow_login_shell: Option<bool>,
pub feedback: Option<FeedbackConfigToml>,
pub allowed_approval_policies: Option<Vec<AskForApproval>>,
pub allowed_approvals_reviewers: Option<Vec<ApprovalsReviewer>>,
pub allowed_sandbox_modes: Option<Vec<SandboxModeRequirement>>,
@@ -929,6 +952,12 @@ impl<T> std::ops::Deref for Sourced<T> {
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ConfigRequirementsWithSources {
pub sqlite_home: Option<Sourced<AbsolutePathBuf>>,
pub log_dir: Option<Sourced<AbsolutePathBuf>>,
pub model_catalog_json: Option<Sourced<AbsolutePathBuf>>,
pub check_for_update_on_startup: Option<Sourced<bool>>,
pub allow_login_shell: Option<Sourced<bool>>,
pub feedback: Option<Sourced<FeedbackConfigToml>>,
pub allowed_approval_policies: Option<Sourced<Vec<AskForApproval>>>,
pub allowed_approvals_reviewers: Option<Sourced<Vec<ApprovalsReviewer>>>,
pub allowed_sandbox_modes: Option<Sourced<Vec<SandboxModeRequirement>>>,
@@ -973,6 +1002,12 @@ impl ConfigRequirementsWithSources {
// Destructure without `..` so adding fields to `ConfigRequirementsToml`
// forces this merge logic to be updated.
let ConfigRequirementsToml {
sqlite_home: _,
log_dir: _,
model_catalog_json: _,
check_for_update_on_startup: _,
allow_login_shell: _,
feedback: _,
allowed_approval_policies: _,
allowed_approvals_reviewers: _,
allowed_sandbox_modes: _,
@@ -1012,6 +1047,12 @@ impl ConfigRequirementsWithSources {
other,
source,
{
sqlite_home,
log_dir,
model_catalog_json,
check_for_update_on_startup,
allow_login_shell,
feedback,
allowed_approval_policies,
allowed_approvals_reviewers,
allowed_sandbox_modes,
@@ -1048,6 +1089,12 @@ impl ConfigRequirementsWithSources {
pub fn into_toml(self) -> ConfigRequirementsToml {
let ConfigRequirementsWithSources {
sqlite_home,
log_dir,
model_catalog_json,
check_for_update_on_startup,
allow_login_shell,
feedback,
allowed_approval_policies,
allowed_approvals_reviewers,
allowed_sandbox_modes,
@@ -1073,6 +1120,12 @@ impl ConfigRequirementsWithSources {
guardian_policy_config,
} = self;
ConfigRequirementsToml {
sqlite_home: sqlite_home.map(|sourced| sourced.value),
log_dir: log_dir.map(|sourced| sourced.value),
model_catalog_json: model_catalog_json.map(|sourced| sourced.value),
check_for_update_on_startup: check_for_update_on_startup.map(|sourced| sourced.value),
allow_login_shell: allow_login_shell.map(|sourced| sourced.value),
feedback: feedback.map(|sourced| sourced.value),
allowed_approval_policies: allowed_approval_policies.map(|sourced| sourced.value),
allowed_approvals_reviewers: allowed_approvals_reviewers.map(|sourced| sourced.value),
allowed_sandbox_modes: allowed_sandbox_modes.map(|sourced| sourced.value),
@@ -1165,7 +1218,16 @@ impl ConfigRequirementsToml {
}
pub fn is_empty(&self) -> bool {
self.allowed_approval_policies.is_none()
self.sqlite_home.is_none()
&& self.log_dir.is_none()
&& self.model_catalog_json.is_none()
&& self.check_for_update_on_startup.is_none()
&& self.allow_login_shell.is_none()
&& self
.feedback
.as_ref()
.is_none_or(|feedback| feedback == &FeedbackConfigToml::default())
&& self.allowed_approval_policies.is_none()
&& self.allowed_approvals_reviewers.is_none()
&& self.allowed_sandbox_modes.is_none()
&& self.allowed_permission_profiles.is_none()
@@ -1217,6 +1279,92 @@ impl ConfigRequirementsToml {
.as_deref()
.is_none_or(|value| value.trim().is_empty())
}
/// Applies the requirements whose values replace config values.
///
/// This projection is shared by config/read and config-lock export so
/// both surfaces describe the same behavior as the final runtime config.
pub fn apply_exact_to_config(&self, config: &mut ConfigToml) {
macro_rules! apply_exact {
($field:ident) => {
if let Some(value) = self.$field.as_ref() {
config.$field = Some(value.clone());
}
};
}
apply_exact!(sqlite_home);
apply_exact!(log_dir);
apply_exact!(model_catalog_json);
apply_exact!(check_for_update_on_startup);
apply_exact!(allow_login_shell);
if let Some(enabled) = self.feedback.as_ref().and_then(|feedback| feedback.enabled) {
config.feedback.get_or_insert_default().enabled = Some(enabled);
}
if let Some(sandbox_private_desktop) = self
.windows
.as_ref()
.and_then(|windows| windows.sandbox_private_desktop)
{
config
.windows
.get_or_insert_default()
.sandbox_private_desktop = Some(sandbox_private_desktop);
}
}
/// Returns the exact managed field affected by editing `segments`.
pub fn exact_requirement_for_config_path(&self, segments: &[String]) -> Option<&'static str> {
let managed_fields: [(bool, &[&str], &'static str); 7] = [
(self.sqlite_home.is_some(), &["sqlite_home"], "sqlite_home"),
(self.log_dir.is_some(), &["log_dir"], "log_dir"),
(
self.model_catalog_json.is_some(),
&["model_catalog_json"],
"model_catalog_json",
),
(
self.check_for_update_on_startup.is_some(),
&["check_for_update_on_startup"],
"check_for_update_on_startup",
),
(
self.allow_login_shell.is_some(),
&["allow_login_shell"],
"allow_login_shell",
),
(
self.feedback
.as_ref()
.and_then(|feedback| feedback.enabled)
.is_some(),
&["feedback", "enabled"],
"feedback.enabled",
),
(
self.windows
.as_ref()
.and_then(|windows| windows.sandbox_private_desktop)
.is_some(),
&["windows", "sandbox_private_desktop"],
"windows.sandbox_private_desktop",
),
];
managed_fields
.into_iter()
.find_map(|(is_managed, managed_path, field)| {
(is_managed && config_paths_overlap(segments, managed_path)).then_some(field)
})
}
}
fn config_paths_overlap(segments: &[String], managed_path: &[&str]) -> bool {
segments
.iter()
.zip(managed_path)
.all(|(segment, managed_segment)| segment == managed_segment)
}
fn validate_mcp_server_requirements(
@@ -1247,6 +1395,12 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
// defaults also remain there because they are initialization values,
// not runtime constraints.
let ConfigRequirementsWithSources {
sqlite_home,
log_dir,
model_catalog_json,
check_for_update_on_startup,
allow_login_shell,
feedback,
allowed_approval_policies,
allowed_approvals_reviewers,
allowed_sandbox_modes,
@@ -1388,42 +1542,60 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
/*source*/ None,
),
};
let windows_sandbox_mode = match windows {
let (windows_sandbox_mode, windows_sandbox_private_desktop) = match windows {
Some(Sourced {
value:
WindowsRequirementsToml {
allowed_sandbox_implementations: Some(implementations),
allowed_sandbox_implementations,
sandbox_private_desktop,
},
source: requirement_source,
}) => {
if implementations.is_empty() {
return Err(ConstraintError::empty_field(
"windows.allowed_sandbox_implementations",
));
}
// Prefer elevated when both Windows sandbox implementations are allowed.
let initial_value = if implementations.contains(&WindowsSandboxModeToml::Elevated) {
WindowsSandboxModeToml::Elevated
} else {
WindowsSandboxModeToml::Unelevated
};
let sandbox_private_desktop = sandbox_private_desktop
.map(|value| Sourced::new(value, requirement_source.clone()));
let sandbox_mode = match allowed_sandbox_implementations {
Some(implementations) => {
if implementations.is_empty() {
return Err(ConstraintError::empty_field(
"windows.allowed_sandbox_implementations",
));
}
// Prefer elevated when both Windows sandbox implementations are allowed.
let initial_value =
if implementations.contains(&WindowsSandboxModeToml::Elevated) {
WindowsSandboxModeToml::Elevated
} else {
WindowsSandboxModeToml::Unelevated
};
let requirement_source_for_error = requirement_source.clone();
let constrained =
Constrained::new(Some(initial_value), move |candidate| match candidate {
Some(candidate) if implementations.contains(candidate) => Ok(()),
_ => Err(ConstraintError::InvalidValue {
field_name: "windows.sandbox",
candidate: format!("{candidate:?}"),
allowed: format!("{implementations:?}"),
requirement_source: requirement_source_for_error.clone(),
}),
})?;
ConstrainedWithSource::new(constrained, Some(requirement_source))
let requirement_source_for_error = requirement_source.clone();
let constrained = Constrained::new(
Some(initial_value),
move |candidate| match candidate {
Some(candidate) if implementations.contains(candidate) => Ok(()),
_ => Err(ConstraintError::InvalidValue {
field_name: "windows.sandbox",
candidate: format!("{candidate:?}"),
allowed: format!("{implementations:?}"),
requirement_source: requirement_source_for_error.clone(),
}),
},
)?;
ConstrainedWithSource::new(constrained, Some(requirement_source))
}
None => ConstrainedWithSource::new(
Constrained::allow_any(/*initial_value*/ None),
/*source*/ None,
),
};
(sandbox_mode, sandbox_private_desktop)
}
Some(_) | None => ConstrainedWithSource::new(
Constrained::allow_any(/*initial_value*/ None),
/*source*/ None,
None => (
ConstrainedWithSource::new(
Constrained::allow_any(/*initial_value*/ None),
/*source*/ None,
),
None,
),
};
let exec_policy = match rules {
@@ -1550,10 +1722,17 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
});
let guardian_policy_config_source = guardian_policy_config.map(|sourced| sourced.source);
Ok(ConfigRequirements {
sqlite_home,
log_dir,
model_catalog_json,
check_for_update_on_startup,
allow_login_shell,
feedback,
approval_policy,
approvals_reviewer,
permission_profile,
windows_sandbox_mode,
windows_sandbox_private_desktop,
web_search_mode,
allow_managed_hooks_only,
allow_appshots,
@@ -1623,6 +1802,59 @@ mod tests {
)?)
}
#[test]
fn exact_requirement_for_config_path_matches_overlapping_paths() {
let managed_path = AbsolutePathBuf::try_from(std::env::temp_dir().join("managed"))
.expect("managed path should be absolute");
let requirements = ConfigRequirementsToml {
sqlite_home: Some(managed_path.clone()),
log_dir: Some(managed_path.clone()),
model_catalog_json: Some(managed_path),
check_for_update_on_startup: Some(false),
allow_login_shell: Some(false),
feedback: Some(FeedbackConfigToml {
enabled: Some(false),
}),
windows: Some(WindowsRequirementsToml {
sandbox_private_desktop: Some(false),
..Default::default()
}),
..Default::default()
};
let cases: &[(&[&str], Option<&str>)] = &[
(&["sqlite_home"], Some("sqlite_home")),
(&["log_dir"], Some("log_dir")),
(&["model_catalog_json"], Some("model_catalog_json")),
(
&["check_for_update_on_startup"],
Some("check_for_update_on_startup"),
),
(&["allow_login_shell"], Some("allow_login_shell")),
(&["feedback", "enabled"], Some("feedback.enabled")),
(
&["windows", "sandbox_private_desktop"],
Some("windows.sandbox_private_desktop"),
),
(&[], Some("sqlite_home")),
(&["feedback"], Some("feedback.enabled")),
(
&["windows", "sandbox_private_desktop", "value"],
Some("windows.sandbox_private_desktop"),
),
(&["feedback", "other"], None),
(&["windows", "sandbox"], None),
];
for (segments, expected) in cases {
let segments = segments.iter().map(ToString::to_string).collect::<Vec<_>>();
assert_eq!(
requirements.exact_requirement_for_config_path(&segments),
*expected,
"segments: {segments:?}"
);
}
}
#[test]
fn composite_requirement_source_flattens_and_deduplicates_sources() {
let mdm_source = RequirementSource::MdmManagedPreferences {
@@ -1644,6 +1876,12 @@ mod tests {
fn with_unknown_source(toml: ConfigRequirementsToml) -> ConfigRequirementsWithSources {
let ConfigRequirementsToml {
sqlite_home,
log_dir,
model_catalog_json,
check_for_update_on_startup,
allow_login_shell,
feedback,
allowed_approval_policies,
allowed_approvals_reviewers,
allowed_sandbox_modes,
@@ -1670,6 +1908,15 @@ mod tests {
guardian_policy_config,
} = toml;
ConfigRequirementsWithSources {
sqlite_home: sqlite_home.map(|value| Sourced::new(value, RequirementSource::Unknown)),
log_dir: log_dir.map(|value| Sourced::new(value, RequirementSource::Unknown)),
model_catalog_json: model_catalog_json
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
check_for_update_on_startup: check_for_update_on_startup
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
allow_login_shell: allow_login_shell
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
feedback: feedback.map(|value| Sourced::new(value, RequirementSource::Unknown)),
allowed_approval_policies: allowed_approval_policies
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
allowed_approvals_reviewers: allowed_approvals_reviewers
@@ -1909,6 +2156,20 @@ mod tests {
service_tier: Some("fast".to_string()),
}),
};
let sqlite_home = AbsolutePathBuf::try_from(std::env::temp_dir().join("managed-state"))
.expect("managed sqlite home should be absolute");
let log_dir = AbsolutePathBuf::try_from(std::env::temp_dir().join("managed-logs"))
.expect("managed log dir should be absolute");
let model_catalog_json =
AbsolutePathBuf::try_from(std::env::temp_dir().join("managed-models.json"))
.expect("managed model catalog path should be absolute");
let feedback = FeedbackConfigToml {
enabled: Some(false),
};
let windows = WindowsRequirementsToml {
allowed_sandbox_implementations: None,
sandbox_private_desktop: Some(true),
};
let enforce_residency = ResidencyRequirement::Us;
let enforce_source = source.clone();
let guardian_policy_config = "Use the company-managed guardian policy.".to_string();
@@ -1916,6 +2177,12 @@ mod tests {
// Intentionally constructed without `..Default::default()` so adding a new field to
// `ConfigRequirementsToml` forces this test to be updated.
let other = ConfigRequirementsToml {
sqlite_home: Some(sqlite_home.clone()),
log_dir: Some(log_dir.clone()),
model_catalog_json: Some(model_catalog_json.clone()),
check_for_update_on_startup: Some(false),
allow_login_shell: Some(false),
feedback: Some(feedback.clone()),
allowed_approval_policies: Some(allowed_approval_policies.clone()),
allowed_approvals_reviewers: Some(allowed_approvals_reviewers.clone()),
allowed_sandbox_modes: Some(allowed_sandbox_modes.clone()),
@@ -1927,7 +2194,7 @@ mod tests {
allow_appshots: Some(false),
allow_remote_control: Some(false),
computer_use: Some(computer_use.clone()),
windows: None,
windows: Some(windows.clone()),
feature_requirements: Some(feature_requirements.clone()),
hooks: None,
mcp_servers: None,
@@ -1947,6 +2214,15 @@ mod tests {
assert_eq!(
target,
ConfigRequirementsWithSources {
sqlite_home: Some(Sourced::new(sqlite_home, source.clone())),
log_dir: Some(Sourced::new(log_dir, source.clone())),
model_catalog_json: Some(Sourced::new(model_catalog_json, source.clone())),
check_for_update_on_startup: Some(Sourced::new(
/*value*/ false,
source.clone(),
)),
allow_login_shell: Some(Sourced::new(/*value*/ false, source.clone())),
feedback: Some(Sourced::new(feedback, source.clone())),
allowed_approval_policies: Some(Sourced::new(
allowed_approval_policies,
source.clone()
@@ -1975,7 +2251,7 @@ mod tests {
enforce_source.clone(),
)),
computer_use: Some(Sourced::new(computer_use, enforce_source.clone())),
windows: None,
windows: Some(Sourced::new(windows, enforce_source.clone())),
feature_requirements: Some(Sourced::new(
feature_requirements,
enforce_source.clone(),
@@ -2039,6 +2315,7 @@ mod tests {
permissions: None,
models: None,
guardian_policy_config: None,
..Default::default()
}
);
Ok(())
@@ -2095,6 +2372,7 @@ mod tests {
permissions: None,
models: None,
guardian_policy_config: None,
..Default::default()
}
);
Ok(())
-5
View File
@@ -67,10 +67,6 @@ const RESERVED_MODEL_PROVIDER_IDS: [&str; 4] = [
pub const DEFAULT_PROJECT_DOC_MAX_BYTES: usize = 32 * 1024;
const fn default_allow_login_shell() -> Option<bool> {
Some(true)
}
fn default_history() -> Option<History> {
Some(History::default())
}
@@ -193,7 +189,6 @@ pub struct ConfigToml {
/// If `false`, the model can never use a login shell: `login = true`
/// requests are rejected, and omitting `login` defaults to a non-login
/// shell.
#[serde(default = "default_allow_login_shell")]
pub allow_login_shell: Option<bool>,
/// Sandbox mode to use.
+3 -1
View File
@@ -157,12 +157,14 @@ pub async fn load_config_layers_state(
#[cfg(target_os = "macos")]
{
let managed_preferences_base_dir = AbsolutePathBuf::from_absolute_path(codex_home)?;
managed_preferences_requirements_layer = macos::load_managed_admin_requirements_layer(
overrides
.macos_managed_config_requirements_base64
.as_deref(),
)
.await?;
.await?
.map(|layer| layer.with_base_dir(managed_preferences_base_dir));
}
#[cfg(not(target_os = "macos"))]
{
@@ -77,6 +77,7 @@ impl ComposableRequirementsLayer {
.as_ref()
.and_then(|_| hostname_resolver());
requirements.apply_remote_sandbox_config(hostname.as_deref());
materialize_resolved_path_requirements(&mut regular_toml, &requirements)?;
materialize_remote_sandbox_config(&mut regular_toml, &requirements)?;
strip_special_fields(&mut regular_toml);
@@ -140,6 +141,30 @@ fn parse_layer_requirements(
}
}
fn materialize_resolved_path_requirements(
layer_toml: &mut TomlValue,
requirements: &ConfigRequirementsToml,
) -> Result<(), RequirementsCompositionError> {
let Some(table) = layer_toml.as_table_mut() else {
return Ok(());
};
for (key, value) in [
("sqlite_home", requirements.sqlite_home.as_ref()),
("log_dir", requirements.log_dir.as_ref()),
(
"model_catalog_json",
requirements.model_catalog_json.as_ref(),
),
] {
if let Some(value) = value {
table.insert(key.to_string(), toml_value_from_serializable(value)?);
}
}
Ok(())
}
fn materialize_remote_sandbox_config(
layer_toml: &mut TomlValue,
requirements: &ConfigRequirementsToml,
@@ -206,6 +206,12 @@ fn populate_merged_regular_fields_with_sources(
// Destructure without `..` so every new requirements field must choose
// whether it belongs in the regular TOML merge path or in a special merger.
let ConfigRequirementsToml {
sqlite_home,
log_dir,
model_catalog_json,
check_for_update_on_startup,
allow_login_shell,
feedback,
allowed_approval_policies,
allowed_approvals_reviewers,
allowed_sandbox_modes,
@@ -232,6 +238,15 @@ fn populate_merged_regular_fields_with_sources(
guardian_policy_config,
} = requirements;
set_sourced!(sqlite_home, &["sqlite_home"]);
set_sourced!(log_dir, &["log_dir"]);
set_sourced!(model_catalog_json, &["model_catalog_json"]);
set_sourced!(
check_for_update_on_startup,
&["check_for_update_on_startup"]
);
set_sourced!(allow_login_shell, &["allow_login_shell"]);
set_sourced!(feedback, &["feedback"]);
set_sourced!(allowed_approval_policies, &["allowed_approval_policies"]);
set_sourced!(
allowed_approvals_reviewers,
@@ -14,6 +14,7 @@ use pretty_assertions::assert_eq;
use std::cell::Cell;
use std::collections::BTreeMap;
use tempfile::TempDir;
use tempfile::tempdir;
fn layer(id: &str, name: &str, contents: &str) -> RequirementsLayerEntry {
RequirementsLayerEntry::from_toml(
@@ -149,6 +150,45 @@ service_tier = "fast"
);
}
#[test]
fn relative_paths_resolve_against_their_own_layer_base() {
let low_dir = tempdir().expect("low-priority requirements directory");
let high_dir = tempdir().expect("high-priority requirements directory");
let low_base = AbsolutePathBuf::from_absolute_path(low_dir.path()).expect("absolute low base");
let high_base =
AbsolutePathBuf::from_absolute_path(high_dir.path()).expect("absolute high base");
let composed = compose(vec![
layer(
"req_low",
"Low",
"sqlite_home = \"state\"\nlog_dir = \"low-logs\"",
)
.with_base_dir(low_base),
layer(
"req_high",
"High",
"log_dir = \"high-logs\"\nmodel_catalog_json = \"models.json\"",
)
.with_base_dir(high_base),
])
.expect("compose requirements")
.expect("requirements present");
assert_eq!(
composed.sqlite_home.as_deref(),
Some(low_dir.path().join("state").as_path())
);
assert_eq!(
composed.log_dir.as_deref(),
Some(high_dir.path().join("high-logs").as_path())
);
assert_eq!(
composed.model_catalog_json.as_deref(),
Some(high_dir.path().join("models.json").as_path())
);
}
#[test]
fn composition_strategy_applies_to_non_cloud_layers() {
let mdm_source = RequirementSource::MdmManagedPreferences {
-1
View File
@@ -4726,7 +4726,6 @@
"description": "Agent-related settings (thread limits, etc.)."
},
"allow_login_shell": {
"default": true,
"description": "Whether the model may request a login shell for shell-based tools. Default to `true`\n\nIf `true`, the model may request a login shell (`login = true`), and omitting `login` defaults to using a login shell. If `false`, the model can never use a login shell: `login = true` requests are rejected, and omitting `login` defaults to a non-login shell.",
"type": "boolean"
},
@@ -1164,6 +1164,56 @@ allowed_sandbox_modes = ["read-only"]
Ok(())
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn managed_preferences_requirements_resolve_paths_against_codex_home() -> anyhow::Result<()> {
use base64::Engine;
let tmp = tempdir()?;
let codex_home = tmp.path().join("codex-home");
std::fs::create_dir_all(&codex_home)?;
let mut loader_overrides =
LoaderOverrides::with_managed_config_path_for_tests(tmp.path().join("managed_config.toml"));
loader_overrides.macos_managed_config_requirements_base64 = Some(
base64::prelude::BASE64_STANDARD.encode(
r#"
sqlite_home = "state"
log_dir = "~/.codex/logs"
model_catalog_json = "models.json"
"#
.as_bytes(),
),
);
let layers = load_config_layers_state(
LOCAL_FS.as_ref(),
&codex_home,
Some(AbsolutePathBuf::try_from(tmp.path())?),
&[] as &[(String, TomlValue)],
loader_overrides,
&codex_config::NoopThreadConfigLoader,
)
.await?;
let expected_log_dir = AbsolutePathBuf::resolve_path_against_base("~/.codex/logs", &codex_home);
let requirements = layers.requirements_toml();
assert_eq!(
requirements.sqlite_home.as_deref(),
Some(codex_home.join("state").as_path())
);
assert_eq!(
requirements.log_dir.as_deref(),
Some(expected_log_dir.as_path())
);
assert_eq!(
requirements.model_catalog_json.as_deref(),
Some(codex_home.join("models.json").as_path())
);
Ok(())
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn managed_preferences_requirements_take_precedence() -> anyhow::Result<()> {
+137
View File
@@ -9093,6 +9093,12 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset()
let fixture = create_test_fixture()?;
let requirements_toml = codex_config::ConfigRequirementsToml {
sqlite_home: None,
log_dir: None,
model_catalog_json: None,
check_for_update_on_startup: None,
allow_login_shell: None,
feedback: None,
allowed_approval_policies: None,
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
@@ -11710,3 +11716,134 @@ fn test_tui_notification_condition_rejects_unknown_value() {
"unexpected error: {err}"
);
}
async fn load_with_enterprise_requirement(
codex_home: &TempDir,
requirements: impl Into<String>,
) -> std::io::Result<Config> {
ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cloud_config_bundle(
CloudConfigBundleFixture::loader_with_enterprise_requirement(requirements),
)
.build()
.await
}
#[tokio::test]
async fn exact_requirements_apply_to_runtime_config() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let catalog_path = codex_home.path().join("required-models.json");
let mut catalog = bundled_models_response()
.unwrap_or_else(|err| panic!("bundled models.json should parse: {err}"));
catalog.models = catalog.models.into_iter().take(1).collect();
std::fs::write(
&catalog_path,
serde_json::to_string(&catalog).expect("serialize catalog"),
)?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"
check_for_update_on_startup = true
allow_login_shell = true
[feedback]
enabled = true
[windows]
sandbox_private_desktop = true
"#,
)?;
let required_sqlite_home = codex_home.path().join("required-state");
let required_log_dir = codex_home.path().join("required-logs");
let requirements = format!(
r#"
sqlite_home = {:?}
log_dir = {:?}
model_catalog_json = {:?}
check_for_update_on_startup = false
allow_login_shell = false
[feedback]
enabled = false
[windows]
sandbox_private_desktop = false
"#,
required_sqlite_home.display(),
required_log_dir.display(),
catalog_path.display(),
);
let config = load_with_enterprise_requirement(&codex_home, requirements).await?;
assert_eq!(config.sqlite_home, required_sqlite_home);
assert_eq!(config.log_dir, required_log_dir);
assert_eq!(config.model_catalog, Some(catalog));
assert!(!config.check_for_update_on_startup);
assert!(!config.permissions.allow_login_shell);
assert!(!config.feedback_enabled);
assert!(!config.permissions.windows_sandbox_private_desktop);
assert!(config.startup_warnings.iter().any(|warning| {
warning.contains("Configured value for `check_for_update_on_startup` is overridden")
}));
Ok(())
}
#[tokio::test]
async fn absent_allow_login_shell_does_not_report_an_override() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let config = load_with_enterprise_requirement(&codex_home, "allow_login_shell = false").await?;
assert!(!config.permissions.allow_login_shell);
assert!(
config
.startup_warnings
.iter()
.all(|warning| !warning.contains("allow_login_shell"))
);
Ok(())
}
#[test]
fn sqlite_home_env_conflict_reports_an_override() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let required = AbsolutePathBuf::try_from(codex_home.path().join("required-state"))?;
let environment = codex_home.path().join("environment-state");
let requirement = Sourced::new(required.clone(), RequirementSource::Unknown);
let mut warnings = Vec::new();
super::requirements::push_sqlite_home_env_override_warning(
/*configured_sqlite_home*/ None,
Some(environment.as_path()),
Some(&requirement),
&mut warnings,
);
assert_eq!(
warnings,
vec![format!(
"Environment value for `$CODEX_SQLITE_HOME` is overridden by the required `sqlite_home` value {required:?} from {}.",
RequirementSource::Unknown
)]
);
warnings.clear();
super::requirements::push_sqlite_home_env_override_warning(
/*configured_sqlite_home*/ None,
Some(required.as_path()),
Some(&requirement),
&mut warnings,
);
assert!(warnings.is_empty());
super::requirements::push_sqlite_home_env_override_warning(
Some(&required),
Some(environment.as_path()),
Some(&requirement),
&mut warnings,
);
assert!(warnings.is_empty());
Ok(())
}
+30 -9
View File
@@ -153,6 +153,7 @@ mod network_proxy_spec;
mod otel;
mod permission_profile_catalog;
mod permissions;
mod requirements;
mod resolved_permission_profile;
#[cfg(test)]
mod schema;
@@ -3037,7 +3038,7 @@ impl Config {
pub(crate) async fn load_config_with_layer_stack(
fs: &dyn ExecutorFileSystem,
cfg: ConfigToml,
mut cfg: ConfigToml,
overrides: ConfigOverrides,
codex_home: AbsolutePathBuf,
config_layer_stack: ConfigLayerStack,
@@ -3058,13 +3059,31 @@ impl Config {
resolve_orchestrator_feature_enabled(orchestrator.and_then(|value| value.skills.as_ref()));
let orchestrator_mcp_enabled =
resolve_orchestrator_feature_enabled(orchestrator.and_then(|value| value.mcp.as_ref()));
// Ensure that every field of ConfigRequirements is applied to the final
// Config.
let mut startup_warnings = config_layer_stack
.startup_warnings()
.unwrap_or_default()
.to_vec();
let configured_sqlite_home = cfg.sqlite_home.clone();
requirements::apply_to_config(
&mut cfg,
config_layer_stack.requirements(),
&mut startup_warnings,
);
// Destructure every field to ensure ConfigRequirements additions are
// either applied above or handled while constructing the final Config.
let ConfigRequirements {
sqlite_home: _,
log_dir: _,
model_catalog_json: _,
check_for_update_on_startup: _,
allow_login_shell: _,
feedback: _,
approval_policy: mut constrained_approval_policy,
approvals_reviewer: mut constrained_approvals_reviewer,
permission_profile: mut constrained_permission_profile,
windows_sandbox_mode: mut constrained_windows_sandbox_mode,
windows_sandbox_private_desktop: _,
web_search_mode: mut constrained_web_search_mode,
allow_managed_hooks_only: _,
allow_appshots: _,
@@ -3082,11 +3101,6 @@ impl Config {
guardian_policy_config_source: _,
} = config_layer_stack.requirements().clone();
let mut startup_warnings = config_layer_stack
.startup_warnings()
.unwrap_or_default()
.to_vec();
// Destructure ConfigOverrides fully to ensure all overrides are applied.
let ConfigOverrides {
model,
@@ -3754,11 +3768,18 @@ impl Config {
.as_ref()
.map(AbsolutePathBuf::to_path_buf)
.unwrap_or_else(|| codex_home.join("log").to_path_buf());
let sqlite_home_env = resolve_sqlite_home_env(&resolved_cwd);
requirements::push_sqlite_home_env_override_warning(
configured_sqlite_home.as_ref(),
sqlite_home_env.as_deref(),
config_layer_stack.requirements().sqlite_home.as_ref(),
&mut startup_warnings,
);
let sqlite_home = cfg
.sqlite_home
.as_ref()
.map(AbsolutePathBuf::to_path_buf)
.or_else(|| resolve_sqlite_home_env(&resolved_cwd))
.or(sqlite_home_env)
.unwrap_or_else(|| codex_home.to_path_buf());
let original_permission_profile = permission_profile.clone();
apply_requirement_constrained_value(
+154
View File
@@ -0,0 +1,154 @@
use codex_config::ConfigRequirements;
use codex_config::RequirementSource;
use codex_config::Sourced;
use codex_config::config_toml::ConfigToml;
use codex_config::types::FeedbackConfigToml;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::path::Path;
/// Applies managed requirements to regular config before final config construction.
///
/// Managed values replace their configured counterparts, and conflicts produce
/// source-aware startup warnings.
pub(super) fn apply_to_config(
config: &mut ConfigToml,
requirements: &ConfigRequirements,
startup_warnings: &mut Vec<String>,
) {
macro_rules! apply_exact {
($field:ident) => {
apply_exact_requirement(
stringify!($field),
&mut config.$field,
requirements.$field.as_ref(),
startup_warnings,
);
};
}
apply_exact!(sqlite_home);
apply_exact!(log_dir);
apply_exact!(model_catalog_json);
apply_exact!(check_for_update_on_startup);
apply_exact!(allow_login_shell);
apply_feedback_requirement(
&mut config.feedback,
requirements.feedback.as_ref(),
startup_warnings,
);
if let Some(requirement) = requirements.windows_sandbox_private_desktop.as_ref() {
apply_exact_requirement(
"windows.sandbox_private_desktop",
&mut config
.windows
.get_or_insert_default()
.sandbox_private_desktop,
Some(requirement),
startup_warnings,
);
}
}
fn apply_exact_requirement<T>(
field_name: &'static str,
configured_value: &mut Option<T>,
requirement: Option<&Sourced<T>>,
startup_warnings: &mut Vec<String>,
) where
T: Clone + PartialEq + std::fmt::Debug,
{
let Some(Sourced { value, source }) = requirement else {
return;
};
if configured_value
.as_ref()
.is_some_and(|configured| configured != value)
{
tracing::warn!(
?source,
?value,
"configured value is overridden by an exact requirement for {field_name}"
);
startup_warnings.push(format!(
"Configured value for `{field_name}` is overridden by the required value {value:?} from {source}."
));
}
*configured_value = Some(value.clone());
}
fn replace_required_leaf<T: Clone + PartialEq>(
configured: &mut Option<T>,
required: &Option<T>,
) -> bool {
let Some(required) = required else {
return false;
};
let conflict = configured
.as_ref()
.is_some_and(|configured| configured != required);
*configured = Some(required.clone());
conflict
}
fn apply_feedback_requirement(
configured: &mut Option<FeedbackConfigToml>,
requirement: Option<&Sourced<FeedbackConfigToml>>,
startup_warnings: &mut Vec<String>,
) {
let Some(Sourced { value, source }) = requirement else {
return;
};
let FeedbackConfigToml { enabled } = value;
let configured = configured.get_or_insert_default();
let conflict = replace_required_leaf(&mut configured.enabled, enabled);
push_structured_requirement_override_warning("feedback", conflict, source, startup_warnings);
}
pub(super) fn push_sqlite_home_env_override_warning(
configured_sqlite_home: Option<&AbsolutePathBuf>,
sqlite_home_env: Option<&Path>,
requirement: Option<&Sourced<AbsolutePathBuf>>,
startup_warnings: &mut Vec<String>,
) {
if configured_sqlite_home.is_some() {
return;
}
let Some(sqlite_home_env) = sqlite_home_env else {
return;
};
let Some(Sourced { value, source }) = requirement else {
return;
};
if sqlite_home_env == value.as_path() {
return;
}
tracing::warn!(
?source,
?value,
"`CODEX_SQLITE_HOME` is overridden by an exact requirement for sqlite_home"
);
startup_warnings.push(format!(
"Environment value for `$CODEX_SQLITE_HOME` is overridden by the required `sqlite_home` value {value:?} from {source}."
));
}
/// Emits one source-aware warning when a structured requirement replaces one
/// or more configured values.
fn push_structured_requirement_override_warning(
field_name: &str,
conflict: bool,
source: &RequirementSource,
startup_warnings: &mut Vec<String>,
) {
if !conflict {
return;
}
tracing::warn!(
?source,
"configured values are overridden by requirements for {field_name}"
);
startup_warnings.push(format!(
"Configured values under `{field_name}` are overridden by requirements from {source}."
));
}
+103 -1
View File
@@ -93,13 +93,17 @@ fn session_configuration_to_lock_config_toml(
.effective_config()
.try_into()
.context("failed to deserialize effective config for config lock")?;
if config.config_lock_save_fields_resolved_from_model_catalog {
save_session_resolved_fields(sc, &mut lock_config);
}
save_config_resolved_fields(config, &mut lock_config)?;
drop_lockfile_inputs(&mut lock_config);
// Apply exact managed values last so cleanup cannot discard their runtime-effective values.
config
.config_layer_stack
.requirements_toml()
.apply_exact_to_config(&mut lock_config);
Ok(lock_config)
}
@@ -235,9 +239,23 @@ where
#[cfg(test)]
mod tests {
use super::*;
use codex_config::test_support::CloudConfigBundleFixture;
use codex_models_manager::bundled_models_response;
use pretty_assertions::assert_eq;
use std::path::Path;
use std::sync::Arc;
fn write_model_catalog(path: &Path) {
let mut catalog = bundled_models_response()
.unwrap_or_else(|err| panic!("bundled models.json should parse: {err}"));
catalog.models = catalog.models.into_iter().take(1).collect();
std::fs::write(
path,
serde_json::to_string(&catalog).expect("serialize model catalog"),
)
.expect("write model catalog");
}
#[tokio::test]
async fn lock_contains_prompts_and_materializes_features() {
let mut sc = crate::session::tests::make_session_configuration_for_tests().await;
@@ -395,6 +413,90 @@ mod tests {
assert_eq!(lock.approvals_reviewer, None);
}
#[tokio::test]
async fn lock_contains_exact_managed_requirements() {
let codex_home = tempfile::tempdir().expect("create temp dir");
let sqlite_home = codex_home.path().join("managed-state");
let log_dir = codex_home.path().join("managed-logs");
let catalog_path = codex_home.path().join("managed-models.json");
write_model_catalog(&catalog_path);
let requirements = format!(
r#"
sqlite_home = {:?}
log_dir = {:?}
model_catalog_json = {:?}
check_for_update_on_startup = false
allow_login_shell = false
[feedback]
enabled = false
[windows]
sandbox_private_desktop = false
"#,
sqlite_home.display(),
log_dir.display(),
catalog_path.display(),
);
let mut config = crate::config::ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cloud_config_bundle(
CloudConfigBundleFixture::loader_with_enterprise_requirement(requirements),
)
.build()
.await
.expect("config should load");
config.config_lock_save_fields_resolved_from_model_catalog = false;
let mut sc = crate::session::tests::make_session_configuration_for_tests().await;
sc.original_config_do_not_use = Arc::new(config);
let lockfile = sc.to_config_lockfile_toml().expect("lock should serialize");
let lock = &lockfile.config;
assert_eq!(lock.sqlite_home.as_deref(), Some(sqlite_home.as_path()));
assert_eq!(lock.log_dir.as_deref(), Some(log_dir.as_path()));
assert_eq!(
lock.model_catalog_json.as_deref(),
Some(catalog_path.as_path())
);
assert_eq!(lock.check_for_update_on_startup, Some(false));
assert_eq!(lock.allow_login_shell, Some(false));
assert_eq!(
lock.feedback.as_ref().and_then(|feedback| feedback.enabled),
Some(false)
);
assert_eq!(
lock.windows
.as_ref()
.and_then(|windows| windows.sandbox_private_desktop),
Some(false)
);
}
#[tokio::test]
async fn lock_drops_unmanaged_model_catalog_input() {
let codex_home = tempfile::tempdir().expect("create temp dir");
let catalog_path = codex_home.path().join("user-models.json");
write_model_catalog(&catalog_path);
let config = crate::config::ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cli_overrides(vec![(
"model_catalog_json".to_string(),
toml::Value::String(catalog_path.display().to_string()),
)])
.build()
.await
.expect("config should load");
let mut sc = crate::session::tests::make_session_configuration_for_tests().await;
sc.original_config_do_not_use = Arc::new(config);
let lockfile = sc.to_config_lockfile_toml().expect("lock should serialize");
assert_eq!(lockfile.config.model_catalog_json, None);
}
#[tokio::test]
async fn lock_validation_reports_config_diff() {
let sc = crate::session::tests::make_session_configuration_for_tests().await;
+33
View File
@@ -1244,6 +1244,39 @@ async fn user_shell_commands_do_not_inherit_managed_network_proxy() -> anyhow::R
Ok(())
}
#[tokio::test]
async fn user_shell_commands_remain_login_shells_when_model_login_shells_are_disabled()
-> anyhow::Result<()> {
let (session, rx) = make_session_with_config_and_rx(|config| {
config.permissions.allow_login_shell = false;
})
.await?;
let turn_context = session.new_default_turn().await;
let command = "echo managed-login-shell".to_string();
let expected_command = session
.user_shell()
.derive_exec_args(&command, /*use_login_shell*/ true);
execute_user_shell_command(
Arc::clone(&session),
turn_context,
command,
CancellationToken::new(),
UserShellCommandMode::StandaloneTurn,
)
.await;
loop {
let event = rx.recv().await.expect("channel open");
if let EventMsg::ExecCommandBegin(event) = event.msg {
assert_eq!(event.command, expected_command);
break;
}
}
Ok(())
}
#[tokio::test]
async fn get_base_instructions_no_user_content() {
let prompt_with_apply_patch_instructions =
+12 -12
View File
@@ -1419,21 +1419,21 @@ mod tests {
.expect("session flags layer stack");
assert_eq!(app.resume_model_settings(), expected);
app.config.config_layer_stack = ConfigLayerStack::default().with_user_config_profile(
&profile_path,
Some(&profile),
config,
);
app.config.config_layer_stack = ConfigLayerStack::default()
.with_user_config_profile(&profile_path, Some(&profile), config)
.expect("user config profile layer stack");
assert_eq!(app.resume_model_settings(), expected);
}
app.config.config_layer_stack = ConfigLayerStack::default().with_user_config(
&profile_path,
TomlValue::Table(toml::map::Map::from_iter([(
"model_reasoning_effort".to_string(),
TomlValue::String("high".to_string()),
)])),
);
app.config.config_layer_stack = ConfigLayerStack::default()
.with_user_config(
&profile_path,
TomlValue::Table(toml::map::Map::from_iter([(
"model_reasoning_effort".to_string(),
TomlValue::String("high".to_string()),
)])),
)
.expect("user config layer stack");
assert_eq!(
app.resume_model_settings(),
crate::app_server_session::ResumeModelSettings::RestoreFromThread
@@ -55,6 +55,7 @@ fn windows_sandbox_requirements_stack(
let requirements_toml = codex_config::ConfigRequirementsToml {
windows: Some(codex_config::WindowsRequirementsToml {
allowed_sandbox_implementations: Some(allowed_sandbox_implementations),
sandbox_private_desktop: None,
}),
..Default::default()
};
+118 -1
View File
@@ -160,6 +160,64 @@ fn render_debug_config_lines(
lines.push("Requirements:".bold().into());
let mut requirement_lines = Vec::new();
if let Some(sqlite_home) = requirements.sqlite_home.as_ref() {
requirement_lines.push(requirement_line(
"sqlite_home",
sqlite_home.value.display().to_string(),
Some(&sqlite_home.source),
));
}
if let Some(log_dir) = requirements.log_dir.as_ref() {
requirement_lines.push(requirement_line(
"log_dir",
log_dir.value.display().to_string(),
Some(&log_dir.source),
));
}
if let Some(model_catalog_json) = requirements.model_catalog_json.as_ref() {
requirement_lines.push(requirement_line(
"model_catalog_json",
model_catalog_json.value.display().to_string(),
Some(&model_catalog_json.source),
));
}
if let Some(check_for_update_on_startup) = requirements.check_for_update_on_startup.as_ref() {
requirement_lines.push(requirement_line(
"check_for_update_on_startup",
check_for_update_on_startup.value.to_string(),
Some(&check_for_update_on_startup.source),
));
}
if let Some(allow_login_shell) = requirements.allow_login_shell.as_ref() {
requirement_lines.push(requirement_line(
"allow_login_shell",
allow_login_shell.value.to_string(),
Some(&allow_login_shell.source),
));
}
if let Some(feedback) = requirements.feedback.as_ref()
&& let Some(enabled) = feedback.value.enabled
{
requirement_lines.push(requirement_line(
"feedback.enabled",
enabled.to_string(),
Some(&feedback.source),
));
}
if let Some(sandbox_private_desktop) = requirements.windows_sandbox_private_desktop.as_ref() {
requirement_lines.push(requirement_line(
"windows.sandbox_private_desktop",
sandbox_private_desktop.value.to_string(),
Some(&sandbox_private_desktop.source),
));
}
if let Some(policies) = requirements_toml.allowed_approval_policies.as_ref() {
let value = join_or_empty(policies.iter().map(ToString::to_string).collect::<Vec<_>>());
requirement_lines.push(requirement_line(
@@ -626,7 +684,9 @@ mod tests {
use codex_config::SandboxModeRequirement;
use codex_config::Sourced;
use codex_config::WebSearchModeRequirement;
use codex_config::WindowsRequirementsToml;
use codex_config::sandbox_mode_requirement_for_permission_profile;
use codex_config::types::FeedbackConfigToml;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::models::PermissionProfile;
@@ -749,8 +809,53 @@ interrupt_message = false
} else {
absolute_path("/home/alice/.gitconfig")
};
let sqlite_home = if cfg!(windows) {
absolute_path("C:\\Users\\alice\\.codex\\state")
} else {
absolute_path("/home/alice/.codex/state")
};
let log_dir = if cfg!(windows) {
absolute_path("C:\\Users\\alice\\.codex\\logs")
} else {
absolute_path("/home/alice/.codex/logs")
};
let model_catalog_json = if cfg!(windows) {
absolute_path("C:\\Users\\alice\\.codex\\models.json")
} else {
absolute_path("/home/alice/.codex/models.json")
};
let requirements = ConfigRequirements {
sqlite_home: Some(Sourced::new(
sqlite_home.clone(),
RequirementSource::LegacyManagedConfigTomlFromMdm,
)),
log_dir: Some(Sourced::new(
log_dir.clone(),
RequirementSource::LegacyManagedConfigTomlFromMdm,
)),
model_catalog_json: Some(Sourced::new(
model_catalog_json.clone(),
RequirementSource::LegacyManagedConfigTomlFromMdm,
)),
check_for_update_on_startup: Some(Sourced::new(
/*value*/ false,
RequirementSource::LegacyManagedConfigTomlFromMdm,
)),
allow_login_shell: Some(Sourced::new(
/*value*/ false,
RequirementSource::LegacyManagedConfigTomlFromMdm,
)),
feedback: Some(Sourced::new(
FeedbackConfigToml {
enabled: Some(false),
},
RequirementSource::LegacyManagedConfigTomlFromMdm,
)),
windows_sandbox_private_desktop: Some(Sourced::new(
/*value*/ false,
RequirementSource::LegacyManagedConfigTomlFromMdm,
)),
approval_policy: ConstrainedWithSource::new(
Constrained::allow_any(AskForApproval::OnRequest.to_core()),
Some(RequirementSource::LegacyManagedConfigTomlFromMdm),
@@ -828,6 +933,14 @@ interrupt_message = false
};
let requirements_toml = ConfigRequirementsToml {
sqlite_home: Some(sqlite_home),
log_dir: Some(log_dir),
model_catalog_json: Some(model_catalog_json),
check_for_update_on_startup: Some(false),
allow_login_shell: Some(false),
feedback: Some(FeedbackConfigToml {
enabled: Some(false),
}),
allowed_approval_policies: Some(vec![AskForApproval::OnRequest.to_core()]),
allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]),
allowed_sandbox_modes: Some(vec![SandboxModeRequirement::ReadOnly]),
@@ -839,7 +952,10 @@ interrupt_message = false
allow_appshots: Some(false),
allow_remote_control: Some(false),
computer_use: None,
windows: None,
windows: Some(WindowsRequirementsToml {
allowed_sandbox_implementations: None,
sandbox_private_desktop: Some(false),
}),
guardian_policy_config: Some("Use the managed guardian policy.".to_string()),
feature_requirements: Some(FeatureRequirementsToml {
entries: BTreeMap::from([("guardian_approval".to_string(), true)]),
@@ -1220,6 +1336,7 @@ approval_policy = "never"
network: None,
permissions: None,
models: None,
..ConfigRequirementsToml::default()
};
let stack = ConfigLayerStack::new(Vec::new(), requirements, requirements_toml)
+6 -1
View File
@@ -1166,7 +1166,12 @@ pub async fn run_main(
.config_layer_stack
.effective_config()
.as_table()
.is_some_and(|table| table.contains_key("log_dir"));
.is_some_and(|table| table.contains_key("log_dir"))
|| config
.config_layer_stack
.requirements_toml()
.log_dir
.is_some();
set_default_client_residency_requirement(config.enforce_residency.value());
@@ -8,6 +8,13 @@ Config layer stack (lowest precedence first):
1. user (/home/alice/.codex/config.toml) (enabled)
Requirements:
- sqlite_home: /home/alice/.codex/state (source: MDM managed_config.toml (legacy))
- log_dir: /home/alice/.codex/logs (source: MDM managed_config.toml (legacy))
- model_catalog_json: /home/alice/.codex/models.json (source: MDM managed_config.toml (legacy))
- check_for_update_on_startup: false (source: MDM managed_config.toml (legacy))
- allow_login_shell: false (source: MDM managed_config.toml (legacy))
- feedback.enabled: false (source: MDM managed_config.toml (legacy))
- windows.sandbox_private_desktop: false (source: MDM managed_config.toml (legacy))
- allowed_approval_policies: on-request (source: MDM managed_config.toml (legacy))
- allowed_approvals_reviewers: auto_review (source: MDM managed_config.toml (legacy))
- allowed_sandbox_modes: read-only (source: /etc/codex/requirements.toml)