SaylorTwift HF Staff commited on
Commit
bb5a19b
Β·
verified Β·
1 Parent(s): a980f24

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes. Β  See raw diff
Files changed (50) hide show
  1. codex-rs/config/examples/generate-proto.rs +19 -0
  2. codex-rs/config/scripts/generate-proto.sh +38 -0
  3. codex-rs/config/src/application_requirements_tests.rs +251 -0
  4. codex-rs/config/src/auth_policy.rs +61 -0
  5. codex-rs/config/src/browser_computer_use_requirements.rs +116 -0
  6. codex-rs/config/src/browser_use_tests.rs +52 -0
  7. codex-rs/config/src/cloud_config_bundle_tests.rs +259 -0
  8. codex-rs/config/src/cloud_config_layers.rs +151 -0
  9. codex-rs/config/src/codex_home_symlink.rs +28 -0
  10. codex-rs/config/src/computer_use.rs +39 -0
  11. codex-rs/config/src/computer_use_tests.rs +54 -0
  12. codex-rs/config/src/config_requirements.rs +0 -0
  13. codex-rs/config/src/config_toml.rs +1073 -0
  14. codex-rs/config/src/constraint.rs +344 -0
  15. codex-rs/config/src/diagnostics.rs +495 -0
  16. codex-rs/config/src/filesystem_constraints.rs +62 -0
  17. codex-rs/config/src/fingerprint.rs +84 -0
  18. codex-rs/config/src/hook_config.rs +256 -0
  19. codex-rs/config/src/in_app_browser_requirements.rs +14 -0
  20. codex-rs/config/src/key_aliases.rs +59 -0
  21. codex-rs/config/src/lib.rs +218 -0
  22. codex-rs/config/src/mcp_edit.rs +50 -0
  23. codex-rs/config/src/mcp_ema_tests.rs +309 -0
  24. codex-rs/config/src/mcp_requirements_tests.rs +225 -0
  25. codex-rs/config/src/mcp_types.rs +626 -0
  26. codex-rs/config/src/mcp_types_tests.rs +663 -0
  27. codex-rs/config/src/merge.rs +236 -0
  28. codex-rs/config/src/merge_tests.rs +692 -0
  29. codex-rs/config/src/model_provider_requirements_tests.rs +99 -0
  30. codex-rs/config/src/overrides.rs +99 -0
  31. codex-rs/config/src/path_context.rs +115 -0
  32. codex-rs/config/src/path_context_tests.rs +255 -0
  33. codex-rs/config/src/permissions_toml.rs +600 -0
  34. codex-rs/config/src/project_root_markers.rs +50 -0
  35. codex-rs/config/src/requirements_exec_policy.rs +201 -0
  36. codex-rs/config/src/schema.rs +292 -0
  37. codex-rs/config/src/skills_config.rs +215 -0
  38. codex-rs/config/src/skills_config_tests.rs +242 -0
  39. codex-rs/config/src/state.rs +630 -0
  40. codex-rs/config/src/state_tests.rs +398 -0
  41. codex-rs/config/src/strict_config_tests.rs +203 -0
  42. codex-rs/config/src/test_support.rs +80 -0
  43. codex-rs/config/src/thread_config.rs +319 -0
  44. codex-rs/config/src/types.rs +1057 -0
  45. codex-rs/config/src/types_tests.rs +106 -0
  46. codex-rs/install-context/src/bundle_tests.rs +76 -0
  47. codex-rs/install-context/src/lib.rs +915 -0
  48. codex-rs/tui/assets/inline_visualization/visualize.css +867 -0
  49. codex-rs/tui/assets/inline_visualization/visualize.html +239 -0
  50. codex-rs/tui/frames/blocks/frame_1.txt +17 -0
codex-rs/config/examples/generate-proto.rs ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::path::PathBuf;
2
+
3
+ fn main() -> Result<(), Box<dyn std::error::Error>> {
4
+ let Some(proto_dir_arg) = std::env::args().nth(1) else {
5
+ eprintln!("Usage: generate-proto <proto-dir>");
6
+ std::process::exit(1);
7
+ };
8
+
9
+ let proto_dir = PathBuf::from(proto_dir_arg);
10
+ let proto_file = proto_dir.join("codex.thread_config.v1.proto");
11
+
12
+ tonic_prost_build::configure()
13
+ .build_client(true)
14
+ .build_server(true)
15
+ .out_dir(&proto_dir)
16
+ .compile_protos(&[proto_file], &[proto_dir])?;
17
+
18
+ Ok(())
19
+ }
codex-rs/config/scripts/generate-proto.sh ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ repo_root="$(cd "$script_dir/../../.." && pwd)"
6
+ proto_dir="$repo_root/codex-rs/config/src/thread_config/proto"
7
+ generated="$proto_dir/codex.thread_config.v1.rs"
8
+ tmpdir="$(mktemp -d)"
9
+
10
+ cleanup() {
11
+ rm -rf "$tmpdir"
12
+ }
13
+ trap cleanup EXIT
14
+
15
+ (
16
+ cd "$repo_root/codex-rs"
17
+ CARGO_TARGET_DIR="$tmpdir/target" cargo run \
18
+ -p codex-config \
19
+ --example generate-proto \
20
+ -- "$proto_dir"
21
+ )
22
+
23
+ if ! sed -n '2p' "$generated" | grep -q 'clippy::trivially_copy_pass_by_ref'; then
24
+ {
25
+ sed -n '1p' "$generated"
26
+ printf '#![allow(clippy::trivially_copy_pass_by_ref)]\n'
27
+ sed '1d' "$generated"
28
+ } > "$tmpdir/generated.rs"
29
+ mv "$tmpdir/generated.rs" "$generated"
30
+ fi
31
+
32
+ rustfmt --edition 2024 "$generated"
33
+
34
+ awk '
35
+ NR == 3 && previous ~ /clippy::trivially_copy_pass_by_ref/ && $0 != "" { print "" }
36
+ { print; previous = $0 }
37
+ ' "$generated" > "$tmpdir/formatted.rs"
38
+ mv "$tmpdir/formatted.rs" "$generated"
codex-rs/config/src/application_requirements_tests.rs ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Application requirements use managed precedence and deny unlisted destinations.
2
+
3
+ use super::*;
4
+ use crate::ConfigRequirements;
5
+ use crate::ConfigRequirementsToml;
6
+ use crate::ConfigRequirementsWithSources;
7
+ use crate::RequirementSource;
8
+ use crate::RequirementsLayerEntry;
9
+ use crate::Sourced;
10
+ use crate::compose_requirements_for_hostname;
11
+ use pretty_assertions::assert_eq;
12
+
13
+ const INSTALLATION: &str = r#"
14
+ [application.network.domains]
15
+ "shared.example.com" = "allow"
16
+ "installed.example.com" = "allow"
17
+ "blocked.example.com" = "deny"
18
+ "#;
19
+ const WORKSPACE: &str = r#"
20
+ [application.network.domains]
21
+ "shared.example.com" = "allow"
22
+ "workspace.example.com" = "allow"
23
+ "blocked.example.com" = "allow"
24
+ "#;
25
+ const MERGED: &str = r#"
26
+ [application.network.domains]
27
+ "shared.example.com" = "allow"
28
+ "installed.example.com" = "allow"
29
+ "workspace.example.com" = "allow"
30
+ "blocked.example.com" = "allow"
31
+ "#;
32
+
33
+ fn parse(contents: &str) -> ConfigRequirementsToml {
34
+ toml::from_str(contents).expect("valid application requirements")
35
+ }
36
+
37
+ fn cloud_source() -> RequirementSource {
38
+ RequirementSource::EnterpriseManaged {
39
+ id: "workspace".to_string(),
40
+ name: "Workspace policy".to_string(),
41
+ }
42
+ }
43
+
44
+ fn managed_source() -> RequirementSource {
45
+ RequirementSource::MdmManagedPreferences {
46
+ domain: "com.openai.codex".to_string(),
47
+ key: "requirements".to_string(),
48
+ }
49
+ }
50
+
51
+ #[test]
52
+ fn application_network_defaults_to_enabled_and_denies_unlisted_domains() {
53
+ assert!(parse("[application]").is_empty());
54
+ let requirements = parse("[application.network]");
55
+ assert!(!requirements.is_empty());
56
+ assert_eq!(
57
+ requirements.application,
58
+ Some(ApplicationRequirementsToml {
59
+ network: Some(ApplicationNetworkRequirementsToml {
60
+ enabled: true,
61
+ domains: BTreeMap::new(),
62
+ }),
63
+ }),
64
+ );
65
+ assert_eq!(
66
+ parse("[application.network.domains]\n\"EXAMPLE.com.\" = \"deny\""),
67
+ parse("[application.network.domains]\n\"example.com\" = \"deny\""),
68
+ );
69
+ }
70
+
71
+ #[test]
72
+ fn application_network_is_preserved_from_each_managed_source() {
73
+ for source in [
74
+ RequirementSource::SystemRequirementsToml {
75
+ file: crate::AbsolutePathBuf::try_from(std::env::temp_dir().join("requirements.toml"))
76
+ .expect("absolute requirements path"),
77
+ },
78
+ managed_source(),
79
+ cloud_source(),
80
+ ] {
81
+ let requirements = compose_requirements_for_hostname(
82
+ [RequirementsLayerEntry::from_toml(
83
+ source.clone(),
84
+ INSTALLATION,
85
+ )],
86
+ /*hostname*/ None,
87
+ )
88
+ .expect("compose application requirements")
89
+ .expect("application requirements are not empty");
90
+ assert_eq!(
91
+ requirements,
92
+ ConfigRequirementsWithSources {
93
+ application: Some(Sourced::new(
94
+ parse(INSTALLATION).application.expect("application"),
95
+ source,
96
+ )),
97
+ ..Default::default()
98
+ },
99
+ );
100
+ let normalized: ConfigRequirements =
101
+ requirements.clone().try_into().expect("runtime policy");
102
+ assert_eq!(normalized.application, requirements.application);
103
+ }
104
+ }
105
+
106
+ #[test]
107
+ fn application_network_uses_regular_toml_precedence() {
108
+ let installed_wins = MERGED.replace(
109
+ "\"blocked.example.com\" = \"allow\"",
110
+ "\"blocked.example.com\" = \"deny\"",
111
+ );
112
+ let disabled_installation = format!("[application.network]\nenabled = false\n{INSTALLATION}");
113
+ for (low, high, expected) in [
114
+ (INSTALLATION, WORKSPACE, MERGED),
115
+ (WORKSPACE, INSTALLATION, installed_wins.as_str()),
116
+ (
117
+ INSTALLATION,
118
+ "[application.network]\nenabled = false",
119
+ disabled_installation.as_str(),
120
+ ),
121
+ (
122
+ disabled_installation.as_str(),
123
+ "[application.network]\nenabled = true",
124
+ INSTALLATION,
125
+ ),
126
+ (
127
+ "[application.network]\nenabled = false",
128
+ INSTALLATION,
129
+ disabled_installation.as_str(),
130
+ ),
131
+ (INSTALLATION, "[application.network]", INSTALLATION),
132
+ (INSTALLATION, "", INSTALLATION),
133
+ ("[application]", INSTALLATION, INSTALLATION),
134
+ ("[application.network]", WORKSPACE, WORKSPACE),
135
+ (
136
+ "[application.network.domains]\n'example.com' = 'allow'",
137
+ "[application.network.domains]\n'EXAMPLE.COM.' = 'deny'",
138
+ "[application.network.domains]\n'example.com' = 'deny'",
139
+ ),
140
+ (
141
+ "[application.network.domains]\n'EXAMPLE.COM.' = 'deny'",
142
+ "[application.network.domains]\n'example.com' = 'allow'",
143
+ "[application.network.domains]\n'example.com' = 'allow'",
144
+ ),
145
+ ] {
146
+ let composed = compose_requirements_for_hostname(
147
+ [
148
+ RequirementsLayerEntry::from_toml(managed_source(), low),
149
+ RequirementsLayerEntry::from_toml(cloud_source(), high),
150
+ ],
151
+ /*hostname*/ None,
152
+ )
153
+ .expect("compose application requirements")
154
+ .expect("configured requirements");
155
+ assert_eq!(
156
+ composed.into_toml(),
157
+ parse(expected),
158
+ "low: {low}, high: {high}"
159
+ );
160
+ }
161
+ }
162
+
163
+ #[test]
164
+ fn legacy_application_merge_uses_whole_table_precedence() {
165
+ for (high, low) in [
166
+ (INSTALLATION, WORKSPACE),
167
+ (WORKSPACE, INSTALLATION),
168
+ ("[application.network]\nenabled = false", INSTALLATION),
169
+ ("[application]", INSTALLATION),
170
+ ] {
171
+ let mut composed = ConfigRequirementsWithSources::default();
172
+ composed.merge_unset_fields(managed_source(), parse(high));
173
+ composed.merge_unset_fields(cloud_source(), parse(low));
174
+ assert_eq!(
175
+ composed,
176
+ ConfigRequirementsWithSources {
177
+ application: Some(Sourced::new(
178
+ parse(high).application.expect("application"),
179
+ managed_source(),
180
+ )),
181
+ ..Default::default()
182
+ },
183
+ );
184
+ }
185
+ let mut composed = ConfigRequirementsWithSources::default();
186
+ composed.merge_unset_fields(managed_source(), parse(""));
187
+ composed.merge_unset_fields(cloud_source(), parse(INSTALLATION));
188
+ assert_eq!(composed.into_toml(), parse(INSTALLATION));
189
+ }
190
+
191
+ #[test]
192
+ fn invalid_application_policy_is_rejected_before_layer_overrides() {
193
+ for invalid in [
194
+ "[application.network]\nenabled = 'true'",
195
+ "[application.network]\nallowed_domains = ['example.com']",
196
+ "[application.network.domains]\n'example.com' = 'prompt'",
197
+ "[application.network.domains]\n'*.example.com' = 'allow'",
198
+ "[application.network.domains]\n'https://example.com' = 'allow'",
199
+ "[application.network.domains]\n'example.com:443' = 'allow'",
200
+ "[application.network.domains]\n'example..com' = 'allow'",
201
+ "[application.network.domains]\n' example.com' = 'allow'",
202
+ "[application.network.domains]\n'EXAMPLE.com' = 'allow'\n'example.com.' = 'deny'",
203
+ ] {
204
+ assert!(
205
+ toml::from_str::<ConfigRequirementsToml>(invalid).is_err(),
206
+ "{invalid}"
207
+ );
208
+ assert!(
209
+ compose_requirements_for_hostname(
210
+ [
211
+ RequirementsLayerEntry::from_toml(cloud_source(), invalid),
212
+ RequirementsLayerEntry::from_toml(managed_source(), INSTALLATION),
213
+ ],
214
+ /*hostname*/ None,
215
+ )
216
+ .is_err(),
217
+ "{invalid}"
218
+ );
219
+ }
220
+ }
221
+
222
+ #[test]
223
+ fn cloud_bundle_application_network_uses_managed_precedence() {
224
+ let base_dir =
225
+ crate::AbsolutePathBuf::try_from(std::env::temp_dir()).expect("absolute base directory");
226
+ let bundle = crate::CloudConfigBundle {
227
+ requirements_toml: crate::CloudRequirementsTomlBundle {
228
+ enterprise_managed: vec![crate::CloudRequirementsFragment {
229
+ id: "workspace".to_string(),
230
+ name: "Workspace".to_string(),
231
+ contents: WORKSPACE.to_string(),
232
+ }],
233
+ },
234
+ ..Default::default()
235
+ };
236
+ let mut layers = crate::CloudConfigBundleLayers::from_bundle(bundle, &base_dir)
237
+ .expect("load cloud bundle")
238
+ .enterprise_managed_requirements;
239
+ layers.push(RequirementsLayerEntry::from_toml(
240
+ managed_source(),
241
+ INSTALLATION,
242
+ ));
243
+ let composed = compose_requirements_for_hostname(layers, /*hostname*/ None)
244
+ .expect("compose cloud and installation requirements")
245
+ .expect("requirements");
246
+ let expected = MERGED.replace(
247
+ "\"blocked.example.com\" = \"allow\"",
248
+ "\"blocked.example.com\" = \"deny\"",
249
+ );
250
+ assert_eq!(composed.into_toml(), parse(&expected));
251
+ }
codex-rs/config/src/auth_policy.rs ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use codex_protocol::config_types::ForcedLoginMethod;
2
+
3
+ /// Authentication restrictions supplied by locally managed requirements.
4
+ #[derive(Debug, Clone, Default, PartialEq, Eq)]
5
+ pub struct ManagedAuthPolicy {
6
+ pub allowed_login_methods: Option<Vec<ForcedLoginMethod>>,
7
+ pub allowed_chatgpt_workspaces: Option<Vec<String>>,
8
+ }
9
+
10
+ impl ManagedAuthPolicy {
11
+ pub fn allows_login_method(
12
+ &self,
13
+ method: ForcedLoginMethod,
14
+ forced_login_method: Option<ForcedLoginMethod>,
15
+ forced_workspaces: Option<&[String]>,
16
+ ) -> bool {
17
+ forced_login_method.is_none_or(|forced| forced == method)
18
+ && self
19
+ .allowed_login_methods
20
+ .as_ref()
21
+ .is_none_or(|allowed| allowed.contains(&method))
22
+ && (method != ForcedLoginMethod::Chatgpt
23
+ || self
24
+ .effective_chatgpt_workspaces(forced_workspaces)
25
+ .is_none_or(|workspaces| !workspaces.is_empty()))
26
+ }
27
+
28
+ pub fn allowed_login_methods(
29
+ &self,
30
+ forced_login_method: Option<ForcedLoginMethod>,
31
+ forced_workspaces: Option<&[String]>,
32
+ ) -> Vec<ForcedLoginMethod> {
33
+ [ForcedLoginMethod::Api, ForcedLoginMethod::Chatgpt]
34
+ .into_iter()
35
+ .filter(|method| {
36
+ self.allows_login_method(*method, forced_login_method, forced_workspaces)
37
+ })
38
+ .collect()
39
+ }
40
+
41
+ pub fn effective_chatgpt_workspaces(
42
+ &self,
43
+ forced_workspaces: Option<&[String]>,
44
+ ) -> Option<Vec<String>> {
45
+ match (
46
+ forced_workspaces,
47
+ self.allowed_chatgpt_workspaces.as_deref(),
48
+ ) {
49
+ (Some(forced), Some(allowed)) => Some(
50
+ forced
51
+ .iter()
52
+ .filter(|workspace| allowed.contains(workspace))
53
+ .cloned()
54
+ .collect(),
55
+ ),
56
+ (Some(forced), None) => Some(forced.to_vec()),
57
+ (None, Some(allowed)) => Some(allowed.to_vec()),
58
+ (None, None) => None,
59
+ }
60
+ }
61
+ }
codex-rs/config/src/browser_computer_use_requirements.rs ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use schemars::JsonSchema;
2
+ use serde::Deserialize;
3
+ use serde::Serialize;
4
+ use std::collections::BTreeMap;
5
+
6
+ #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema)]
7
+ #[serde(rename_all = "lowercase")]
8
+ pub enum AllowDenyRequirementToml {
9
+ Allow,
10
+ Deny,
11
+ }
12
+
13
+ #[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
14
+ pub struct BrowserUseOriginPolicyToml {
15
+ pub access: Option<AllowDenyRequirementToml>,
16
+ pub downloads: Option<AllowDenyRequirementToml>,
17
+ pub uploads: Option<AllowDenyRequirementToml>,
18
+ pub full_cdp_access: Option<AllowDenyRequirementToml>,
19
+ pub auto_review: Option<AllowDenyRequirementToml>,
20
+ pub persistent_approval: Option<bool>,
21
+ pub access_approval_lifetime: Option<BrowserUseAccessApprovalLifetimeToml>,
22
+ }
23
+
24
+ impl BrowserUseOriginPolicyToml {
25
+ fn is_empty(&self) -> bool {
26
+ self.access.is_none()
27
+ && self.downloads.is_none()
28
+ && self.uploads.is_none()
29
+ && self.full_cdp_access.is_none()
30
+ && self.auto_review.is_none()
31
+ && self.persistent_approval.is_none()
32
+ && self.access_approval_lifetime.is_none()
33
+ }
34
+ }
35
+
36
+ #[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
37
+ #[serde(rename_all = "lowercase")]
38
+ pub enum BrowserUseAccessApprovalLifetimeToml {
39
+ Turn,
40
+ Thread,
41
+ }
42
+
43
+ #[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
44
+ pub struct BrowserUseRequirementsToml {
45
+ pub allow_webmcp: Option<bool>,
46
+ pub allow_history_access: Option<bool>,
47
+ pub disable_auto_review: Option<bool>,
48
+ pub allow_global_persistent_approval: Option<bool>,
49
+ pub default_origin_policy: Option<BrowserUseOriginPolicyToml>,
50
+ pub origins: Option<BTreeMap<String, BrowserUseOriginPolicyToml>>,
51
+ }
52
+
53
+ impl BrowserUseRequirementsToml {
54
+ pub fn is_empty(&self) -> bool {
55
+ self.allow_webmcp.is_none()
56
+ && self.allow_history_access.is_none()
57
+ && self.disable_auto_review.is_none()
58
+ && self.allow_global_persistent_approval.is_none()
59
+ && self
60
+ .default_origin_policy
61
+ .as_ref()
62
+ .is_none_or(BrowserUseOriginPolicyToml::is_empty)
63
+ && self
64
+ .origins
65
+ .as_ref()
66
+ .is_none_or(|origins| origins.values().all(BrowserUseOriginPolicyToml::is_empty))
67
+ }
68
+ }
69
+
70
+ #[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
71
+ pub struct ComputerUseMacosRequirementsToml {
72
+ pub bundle_ids: Option<BTreeMap<String, AllowDenyRequirementToml>>,
73
+ }
74
+
75
+ #[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
76
+ pub struct ComputerUseWindowsRequirementsToml {
77
+ pub aumids: Option<BTreeMap<String, AllowDenyRequirementToml>>,
78
+ pub exes: Option<Vec<ComputerUseWindowsExeRequirementToml>>,
79
+ }
80
+
81
+ #[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
82
+ pub struct ComputerUseWindowsExeRequirementToml {
83
+ pub publisher_name: String,
84
+ pub product_name: String,
85
+ pub binary_name: Option<String>,
86
+ pub access: AllowDenyRequirementToml,
87
+ }
88
+
89
+ #[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
90
+ pub struct ComputerUseRequirementsToml {
91
+ pub allow_locked_computer_use: Option<bool>,
92
+ pub allow_persistent_approval: Option<bool>,
93
+ pub default_app_access: Option<AllowDenyRequirementToml>,
94
+ pub macos: Option<ComputerUseMacosRequirementsToml>,
95
+ pub windows: Option<ComputerUseWindowsRequirementsToml>,
96
+ }
97
+
98
+ impl ComputerUseRequirementsToml {
99
+ pub fn is_empty(&self) -> bool {
100
+ self.allow_locked_computer_use.is_none()
101
+ && self.allow_persistent_approval.is_none()
102
+ && self.default_app_access.is_none()
103
+ && self
104
+ .macos
105
+ .as_ref()
106
+ .is_none_or(|macos| macos.bundle_ids.as_ref().is_none_or(BTreeMap::is_empty))
107
+ && self.windows.as_ref().is_none_or(|windows| {
108
+ windows.aumids.as_ref().is_none_or(BTreeMap::is_empty)
109
+ && windows.exes.as_ref().is_none_or(Vec::is_empty)
110
+ })
111
+ }
112
+ }
113
+
114
+ #[cfg(test)]
115
+ #[path = "browser_computer_use_requirements_tests.rs"]
116
+ mod tests;
codex-rs/config/src/browser_use_tests.rs ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use super::*;
2
+ use crate::AllowDenyRequirementToml;
3
+ use crate::config_toml::ConfigToml;
4
+ use pretty_assertions::assert_eq;
5
+
6
+ #[test]
7
+ fn browser_use_origin_policies_round_trip() {
8
+ let config: ConfigToml = toml::from_str(
9
+ r#"
10
+ [browser_use]
11
+ allow_history_access = true
12
+
13
+ [browser_use.default_origin_policy]
14
+ access = "deny"
15
+ downloads = "allow"
16
+ uploads = "deny"
17
+ full_cdp_access = "allow"
18
+
19
+ [browser_use.origins."https://example.com"]
20
+ access = "allow"
21
+ downloads = "deny"
22
+ uploads = "allow"
23
+ full_cdp_access = "deny"
24
+ "#,
25
+ )
26
+ .expect("browser use config should deserialize");
27
+
28
+ let expected = BrowserUseConfigToml {
29
+ allow_history_access: Some(true),
30
+ default_origin_policy: Some(BrowserUseOriginPolicyConfigToml {
31
+ access: Some(AllowDenyRequirementToml::Deny),
32
+ downloads: Some(AllowDenyRequirementToml::Allow),
33
+ uploads: Some(AllowDenyRequirementToml::Deny),
34
+ full_cdp_access: Some(AllowDenyRequirementToml::Allow),
35
+ }),
36
+ origins: Some(BTreeMap::from([(
37
+ "https://example.com".to_string(),
38
+ BrowserUseOriginPolicyConfigToml {
39
+ access: Some(AllowDenyRequirementToml::Allow),
40
+ downloads: Some(AllowDenyRequirementToml::Deny),
41
+ uploads: Some(AllowDenyRequirementToml::Allow),
42
+ full_cdp_access: Some(AllowDenyRequirementToml::Deny),
43
+ },
44
+ )])),
45
+ };
46
+ assert_eq!(config.browser_use, Some(expected.clone()));
47
+
48
+ let serialized = toml::to_string(&config).expect("browser use config should serialize");
49
+ let reparsed: ConfigToml =
50
+ toml::from_str(&serialized).expect("serialized browser use config should deserialize");
51
+ assert_eq!(reparsed.browser_use, Some(expected));
52
+ }
codex-rs/config/src/cloud_config_bundle_tests.rs ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use super::*;
2
+ use crate::AbsolutePathBufGuard;
3
+ use crate::ConfigLayerSource;
4
+ use crate::ConfigRequirementsToml;
5
+ use crate::FilesystemDenyReadPattern;
6
+ use crate::SandboxModeRequirement;
7
+ use crate::compose_requirements;
8
+ use crate::compose_requirements_for_hostname;
9
+ use crate::config_requirements::FilesystemRequirementsToml;
10
+ use crate::config_requirements::PermissionsRequirementsToml;
11
+ use crate::config_toml::ConfigToml;
12
+ use crate::types::SandboxWorkspaceWrite;
13
+ use codex_protocol::protocol::AskForApproval;
14
+ use pretty_assertions::assert_eq;
15
+ use std::sync::Arc;
16
+ use std::sync::RwLock;
17
+ use std::sync::atomic::AtomicUsize;
18
+ use std::sync::atomic::Ordering;
19
+ use tempfile::tempdir;
20
+
21
+ #[tokio::test]
22
+ async fn shared_future_runs_once() {
23
+ let counter = Arc::new(AtomicUsize::new(0));
24
+ let counter_clone = Arc::clone(&counter);
25
+ let loader = CloudConfigBundleLoader::new(async move {
26
+ counter_clone.fetch_add(1, Ordering::SeqCst);
27
+ Ok(Some(CloudConfigBundle::default()))
28
+ });
29
+ let cloned_loader = loader.clone();
30
+
31
+ let (first, second) = tokio::join!(loader.get(), cloned_loader.get());
32
+ assert_eq!(first, second);
33
+ assert_eq!(loader.get().await, first);
34
+ assert_eq!(counter.load(Ordering::SeqCst), 1);
35
+ }
36
+
37
+ #[tokio::test]
38
+ async fn getter_returns_latest_result_across_clones() {
39
+ let initial_error = CloudConfigBundleLoadError::new(
40
+ CloudConfigBundleLoadErrorCode::RequestFailed,
41
+ /*status_code*/ None,
42
+ "initial load failed",
43
+ );
44
+ let latest = Arc::new(RwLock::new(Err(initial_error.clone())));
45
+ let getter_latest = Arc::clone(&latest);
46
+ let loader = CloudConfigBundleLoader::from_getter(move || {
47
+ let latest = Arc::clone(&getter_latest);
48
+ async move { latest.read().expect("bundle state lock").clone() }
49
+ });
50
+ let cloned_loader = loader.clone();
51
+
52
+ assert_eq!(loader.get().await, Err(initial_error));
53
+
54
+ let bundle = CloudConfigBundle {
55
+ config_toml: CloudConfigTomlBundle {
56
+ enterprise_managed: vec![CloudConfigFragment {
57
+ id: "managed".to_string(),
58
+ name: "Managed".to_string(),
59
+ contents: "model = \"managed\"".to_string(),
60
+ }],
61
+ },
62
+ ..Default::default()
63
+ };
64
+ *latest.write().expect("bundle state lock") = Ok(Some(bundle.clone()));
65
+
66
+ assert_eq!(cloned_loader.get().await, Ok(Some(bundle)));
67
+ assert_eq!(CloudConfigBundleLoader::default().get().await, Ok(None));
68
+
69
+ *latest.write().expect("bundle state lock") = Ok(None);
70
+
71
+ assert_eq!(loader.get().await, Ok(None));
72
+ }
73
+
74
+ #[test]
75
+ fn bundle_layers_preserve_enterprise_managed_bucket_order() {
76
+ let tempdir = tempdir().expect("tempdir");
77
+ let base_dir = AbsolutePathBuf::from_absolute_path(tempdir.path()).expect("absolute path");
78
+ let layers = CloudConfigBundleLayers::from_bundle(
79
+ CloudConfigBundle {
80
+ config_toml: CloudConfigTomlBundle {
81
+ enterprise_managed: vec![
82
+ CloudConfigFragment {
83
+ id: "cfg_high".to_string(),
84
+ name: "High config".to_string(),
85
+ contents: "model = \"high\"".to_string(),
86
+ },
87
+ CloudConfigFragment {
88
+ id: "cfg_low".to_string(),
89
+ name: "Low config".to_string(),
90
+ contents: "model = \"low\"".to_string(),
91
+ },
92
+ ],
93
+ },
94
+ requirements_toml: CloudRequirementsTomlBundle {
95
+ enterprise_managed: vec![
96
+ CloudRequirementsFragment {
97
+ id: "req_high".to_string(),
98
+ name: "High requirements".to_string(),
99
+ contents: "allowed_approval_policies = [\"on-request\"]".to_string(),
100
+ },
101
+ CloudRequirementsFragment {
102
+ id: "req_low".to_string(),
103
+ name: "Low requirements".to_string(),
104
+ contents: "allowed_approval_policies = [\"never\"]".to_string(),
105
+ },
106
+ ],
107
+ },
108
+ },
109
+ &base_dir,
110
+ )
111
+ .expect("bundle should be converted into layers");
112
+
113
+ assert_eq!(
114
+ layers
115
+ .enterprise_managed_config
116
+ .iter()
117
+ .map(|layer| layer.name.clone())
118
+ .collect::<Vec<_>>(),
119
+ vec![
120
+ ConfigLayerSource::EnterpriseManaged {
121
+ id: "cfg_low".to_string(),
122
+ name: "Low config".to_string(),
123
+ },
124
+ ConfigLayerSource::EnterpriseManaged {
125
+ id: "cfg_high".to_string(),
126
+ name: "High config".to_string(),
127
+ },
128
+ ]
129
+ );
130
+ assert_eq!(
131
+ compose_requirements(layers.enterprise_managed_requirements)
132
+ .expect("requirements should compose")
133
+ .expect("requirements should be present")
134
+ .into_toml(),
135
+ ConfigRequirementsToml {
136
+ allowed_approval_policies: Some(vec![AskForApproval::OnRequest]),
137
+ ..Default::default()
138
+ }
139
+ );
140
+ }
141
+
142
+ #[test]
143
+ fn bundle_layers_can_strict_validate_enterprise_managed_config() {
144
+ let tempdir = tempdir().expect("tempdir");
145
+ let base_dir = AbsolutePathBuf::from_absolute_path(tempdir.path()).expect("absolute path");
146
+ let err = CloudConfigBundleLayers::from_bundle_strict_config(
147
+ CloudConfigBundle {
148
+ config_toml: CloudConfigTomlBundle {
149
+ enterprise_managed: vec![CloudConfigFragment {
150
+ id: "cfg".to_string(),
151
+ name: "Cloud config".to_string(),
152
+ contents: "unknown_key = true".to_string(),
153
+ }],
154
+ },
155
+ requirements_toml: CloudRequirementsTomlBundle {
156
+ enterprise_managed: Vec::new(),
157
+ },
158
+ },
159
+ &base_dir,
160
+ )
161
+ .expect_err("strict config should reject unknown fields");
162
+
163
+ assert_eq!(
164
+ err,
165
+ CloudConfigLayerError::Invalid {
166
+ fragment: crate::CloudConfigFragmentSource {
167
+ id: "cfg".to_string(),
168
+ name: "Cloud config".to_string(),
169
+ },
170
+ message: "unknown configuration field `unknown_key`".to_string(),
171
+ }
172
+ );
173
+ }
174
+
175
+ #[test]
176
+ fn bundle_layers_resolve_paths_and_requirements_for_the_execution_host() {
177
+ let temp_dir = tempdir().expect("temporary directories");
178
+ let executor_home = temp_dir.path().join("executor-home");
179
+ let executor_codex_home = AbsolutePathBuf::from_absolute_path(executor_home.join(".codex"))
180
+ .expect("absolute executor Codex home");
181
+ let bundle = CloudConfigBundle {
182
+ config_toml: CloudConfigTomlBundle {
183
+ enterprise_managed: vec![CloudConfigFragment {
184
+ id: "config".to_string(),
185
+ name: "Executor config".to_string(),
186
+ contents: r#"
187
+ [sandbox_workspace_write]
188
+ writable_roots = ["~/cloud-root", "./relative-root"]
189
+ "#
190
+ .to_string(),
191
+ }],
192
+ },
193
+ requirements_toml: CloudRequirementsTomlBundle {
194
+ enterprise_managed: vec![CloudRequirementsFragment {
195
+ id: "requirements".to_string(),
196
+ name: "Executor requirements".to_string(),
197
+ contents: r#"
198
+ [permissions.filesystem]
199
+ deny_read = ["~/private"]
200
+
201
+ [[remote_sandbox_config]]
202
+ hostname_patterns = ["executor-*"]
203
+ allowed_sandbox_modes = ["read-only"]
204
+ "#
205
+ .to_string(),
206
+ }],
207
+ },
208
+ };
209
+
210
+ let (config, requirements) = AbsolutePathBufGuard::with_home_directory(&executor_home, || {
211
+ let layers =
212
+ CloudConfigBundleLayers::from_bundle_strict_config(bundle, &executor_codex_home)
213
+ .expect("executor bundle should convert into layers");
214
+ let config: ConfigToml = layers.enterprise_managed_config[0]
215
+ .config
216
+ .clone()
217
+ .try_into()
218
+ .expect("deserialize executor config");
219
+ let requirements = compose_requirements_for_hostname(
220
+ layers.enterprise_managed_requirements,
221
+ Some("executor-01"),
222
+ )
223
+ .expect("compose executor requirements")
224
+ .expect("executor requirements should be present")
225
+ .into_toml();
226
+ (config, requirements)
227
+ });
228
+
229
+ assert_eq!(
230
+ config.sandbox_workspace_write,
231
+ Some(SandboxWorkspaceWrite {
232
+ writable_roots: vec![
233
+ AbsolutePathBuf::from_absolute_path(executor_home.join("cloud-root"))
234
+ .expect("absolute cloud root"),
235
+ AbsolutePathBuf::from_absolute_path(
236
+ executor_codex_home.as_path().join("relative-root"),
237
+ )
238
+ .expect("absolute relative root"),
239
+ ],
240
+ ..Default::default()
241
+ })
242
+ );
243
+ assert_eq!(
244
+ requirements,
245
+ ConfigRequirementsToml {
246
+ allowed_sandbox_modes: Some(vec![SandboxModeRequirement::ReadOnly]),
247
+ permissions: Some(PermissionsRequirementsToml {
248
+ filesystem: Some(FilesystemRequirementsToml {
249
+ deny_read: Some(vec![FilesystemDenyReadPattern::from(
250
+ AbsolutePathBuf::from_absolute_path(executor_home.join("private"))
251
+ .expect("absolute private root"),
252
+ )]),
253
+ }),
254
+ ..Default::default()
255
+ }),
256
+ ..Default::default()
257
+ }
258
+ );
259
+ }
codex-rs/config/src/cloud_config_layers.rs ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Conversion from cloud-delivered config TOML fragments into config stack layers.
2
+ //!
3
+ //! Backend fragments arrive in backend priority order. This module parses each
4
+ //! fragment, resolves relative path fields against the cloud config base
5
+ //! directory, and returns layers in `ConfigLayerStack` order.
6
+
7
+ use crate::ConfigLayerEntry;
8
+ use crate::ConfigLayerSource;
9
+ use crate::TomlValue;
10
+ use crate::config_toml::ConfigToml;
11
+ use crate::loader::resolve_relative_paths_in_config_toml;
12
+ use crate::strict_config::config_error_from_ignored_toml_value_fields_for_source_name;
13
+ use codex_utils_absolute_path::AbsolutePathBuf;
14
+ use codex_utils_absolute_path::AbsolutePathBufGuard;
15
+ use serde::Deserialize;
16
+ use serde::Serialize;
17
+ use std::fmt;
18
+ use std::io;
19
+ use thiserror::Error;
20
+
21
+ /// Config fragment delivered by the cloud config bundle.
22
+ ///
23
+ /// The bundle orders fragments from highest precedence to lowest precedence.
24
+ /// This module returns config layers in stack order, so callers can append the
25
+ /// result between system and user config without re-sorting.
26
+ #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
27
+ pub struct CloudConfigFragment {
28
+ pub id: String,
29
+ pub name: String,
30
+ pub contents: String,
31
+ }
32
+
33
+ impl CloudConfigFragment {
34
+ fn source_ref(&self) -> CloudConfigFragmentSource {
35
+ CloudConfigFragmentSource {
36
+ id: self.id.clone(),
37
+ name: self.name.clone(),
38
+ }
39
+ }
40
+ }
41
+
42
+ #[derive(Clone, Debug, PartialEq, Eq)]
43
+ pub struct CloudConfigFragmentSource {
44
+ pub id: String,
45
+ pub name: String,
46
+ }
47
+
48
+ impl fmt::Display for CloudConfigFragmentSource {
49
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50
+ write!(f, "{} ({})", self.name, self.id)
51
+ }
52
+ }
53
+
54
+ #[derive(Debug, Error, PartialEq, Eq)]
55
+ pub enum CloudConfigLayerError {
56
+ #[error("failed to parse cloud config fragment {fragment}: {message}")]
57
+ Parse {
58
+ fragment: CloudConfigFragmentSource,
59
+ message: String,
60
+ },
61
+ #[error("invalid cloud config fragment {fragment}: {message}")]
62
+ Invalid {
63
+ fragment: CloudConfigFragmentSource,
64
+ message: String,
65
+ },
66
+ }
67
+
68
+ pub fn cloud_config_layers_from_fragments(
69
+ fragments: impl IntoIterator<Item = CloudConfigFragment>,
70
+ base_dir: &AbsolutePathBuf,
71
+ ) -> Result<Vec<ConfigLayerEntry>, CloudConfigLayerError> {
72
+ cloud_config_layers_from_fragments_impl(fragments, base_dir, /*strict_config*/ false)
73
+ }
74
+
75
+ pub(crate) fn cloud_config_layers_from_fragments_strict(
76
+ fragments: impl IntoIterator<Item = CloudConfigFragment>,
77
+ base_dir: &AbsolutePathBuf,
78
+ ) -> Result<Vec<ConfigLayerEntry>, CloudConfigLayerError> {
79
+ cloud_config_layers_from_fragments_impl(fragments, base_dir, /*strict_config*/ true)
80
+ }
81
+
82
+ fn cloud_config_layers_from_fragments_impl(
83
+ fragments: impl IntoIterator<Item = CloudConfigFragment>,
84
+ base_dir: &AbsolutePathBuf,
85
+ strict_config: bool,
86
+ ) -> Result<Vec<ConfigLayerEntry>, CloudConfigLayerError> {
87
+ let mut layers = Vec::new();
88
+ for fragment in fragments {
89
+ let source_ref = fragment.source_ref();
90
+ let raw_toml = fragment.contents;
91
+ let value: TomlValue =
92
+ toml::from_str(&raw_toml).map_err(|err| CloudConfigLayerError::Parse {
93
+ fragment: source_ref.clone(),
94
+ message: err.to_string(),
95
+ })?;
96
+ if strict_config {
97
+ validate_fragment_strictly(&source_ref, &raw_toml, &value, base_dir)?;
98
+ }
99
+ let resolved =
100
+ resolve_relative_paths_in_config_toml(value, base_dir.as_path()).map_err(|err| {
101
+ CloudConfigLayerError::Invalid {
102
+ fragment: source_ref.clone(),
103
+ message: err.to_string(),
104
+ }
105
+ })?;
106
+ layers.push(ConfigLayerEntry::new_with_raw_toml(
107
+ ConfigLayerSource::EnterpriseManaged {
108
+ id: fragment.id,
109
+ name: fragment.name,
110
+ },
111
+ resolved,
112
+ raw_toml,
113
+ base_dir.clone(),
114
+ ));
115
+ }
116
+
117
+ // Bundle fragments arrive highest-priority first, while ConfigLayerStack
118
+ // folds lowest-priority to highest-priority.
119
+ layers.reverse();
120
+ Ok(layers)
121
+ }
122
+
123
+ fn validate_fragment_strictly(
124
+ source_ref: &CloudConfigFragmentSource,
125
+ raw_toml: &str,
126
+ value: &TomlValue,
127
+ base_dir: &AbsolutePathBuf,
128
+ ) -> Result<(), CloudConfigLayerError> {
129
+ let _guard = AbsolutePathBufGuard::new(base_dir.as_path());
130
+ if let Some(config_error) = config_error_from_ignored_toml_value_fields_for_source_name::<
131
+ ConfigToml,
132
+ >(&source_ref.to_string(), raw_toml, value.clone())
133
+ {
134
+ return Err(CloudConfigLayerError::Invalid {
135
+ fragment: source_ref.clone(),
136
+ message: config_error.message,
137
+ });
138
+ }
139
+
140
+ Ok(())
141
+ }
142
+
143
+ impl From<CloudConfigLayerError> for io::Error {
144
+ fn from(error: CloudConfigLayerError) -> Self {
145
+ io::Error::new(io::ErrorKind::InvalidData, error)
146
+ }
147
+ }
148
+
149
+ #[cfg(test)]
150
+ #[path = "cloud_config_layers_tests.rs"]
151
+ mod tests;
codex-rs/config/src/codex_home_symlink.rs ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Resolves the host's symlink opt-out from its already-loaded user config.
2
+
3
+ use crate::ConfigLayerSource;
4
+ use crate::ConfigLayerStack;
5
+ use codex_utils_absolute_path::AbsolutePathBuf;
6
+
7
+ /// Returns the opted-in home without consulting project config or rereading config files.
8
+ pub fn allowed_symlinked_codex_home(
9
+ config_layer_stack: &ConfigLayerStack,
10
+ codex_home: &AbsolutePathBuf,
11
+ ) -> Option<AbsolutePathBuf> {
12
+ let enabled = config_layer_stack
13
+ .layers_low_to_high()
14
+ .find(|layer| matches!(layer.name, ConfigLayerSource::User { profile: None, .. }))?
15
+ .config
16
+ .get("allow_symlinked_codex_home")?
17
+ .as_bool()?;
18
+ if !enabled {
19
+ return None;
20
+ }
21
+
22
+ // Preserve a configured alias only when lexical normalization did not change its target.
23
+ let alias = std::env::var_os("CODEX_HOME")
24
+ .filter(|path| !path.is_empty())
25
+ .and_then(|path| AbsolutePathBuf::from_absolute_path(path).ok())
26
+ .filter(|path| path.canonicalize().ok().as_ref() == Some(codex_home));
27
+ Some(alias.unwrap_or_else(|| codex_home.clone()))
28
+ }
codex-rs/config/src/computer_use.rs ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::AllowDenyRequirementToml;
2
+ use schemars::JsonSchema;
3
+ use serde::Deserialize;
4
+ use serde::Serialize;
5
+ use std::collections::BTreeMap;
6
+
7
+ #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
8
+ #[schemars(deny_unknown_fields)]
9
+ pub struct ComputerUseConfigToml {
10
+ pub default_app_access: Option<AllowDenyRequirementToml>,
11
+ pub macos: Option<ComputerUseMacosConfigToml>,
12
+ pub windows: Option<ComputerUseWindowsConfigToml>,
13
+ }
14
+
15
+ #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
16
+ #[schemars(deny_unknown_fields)]
17
+ pub struct ComputerUseMacosConfigToml {
18
+ pub bundle_ids: Option<BTreeMap<String, AllowDenyRequirementToml>>,
19
+ }
20
+
21
+ #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
22
+ #[schemars(deny_unknown_fields)]
23
+ pub struct ComputerUseWindowsConfigToml {
24
+ pub aumids: Option<BTreeMap<String, AllowDenyRequirementToml>>,
25
+ pub exes: Option<Vec<ComputerUseWindowsExeConfigToml>>,
26
+ }
27
+
28
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
29
+ #[schemars(deny_unknown_fields)]
30
+ pub struct ComputerUseWindowsExeConfigToml {
31
+ pub publisher_name: String,
32
+ pub product_name: String,
33
+ pub binary_name: Option<String>,
34
+ pub access: AllowDenyRequirementToml,
35
+ }
36
+
37
+ #[cfg(test)]
38
+ #[path = "computer_use_tests.rs"]
39
+ mod tests;
codex-rs/config/src/computer_use_tests.rs ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use super::*;
2
+ use crate::config_toml::ConfigToml;
3
+ use pretty_assertions::assert_eq;
4
+
5
+ #[test]
6
+ fn computer_use_config_round_trips() {
7
+ let config: ConfigToml = toml::from_str(
8
+ r#"
9
+ [computer_use]
10
+ default_app_access = "deny"
11
+
12
+ [computer_use.macos.bundle_ids]
13
+ "com.apple.Safari" = "allow"
14
+
15
+ [computer_use.windows.aumids]
16
+ "Microsoft.Paint_8wekyb3d8bbwe!App" = "deny"
17
+
18
+ [[computer_use.windows.exes]]
19
+ publisher_name = "CN=Google LLC"
20
+ product_name = "Google Chrome"
21
+ binary_name = "chrome.exe"
22
+ access = "allow"
23
+ "#,
24
+ )
25
+ .expect("computer use config should deserialize");
26
+
27
+ let expected = ComputerUseConfigToml {
28
+ default_app_access: Some(AllowDenyRequirementToml::Deny),
29
+ macos: Some(ComputerUseMacosConfigToml {
30
+ bundle_ids: Some(BTreeMap::from([(
31
+ "com.apple.Safari".to_string(),
32
+ AllowDenyRequirementToml::Allow,
33
+ )])),
34
+ }),
35
+ windows: Some(ComputerUseWindowsConfigToml {
36
+ aumids: Some(BTreeMap::from([(
37
+ "Microsoft.Paint_8wekyb3d8bbwe!App".to_string(),
38
+ AllowDenyRequirementToml::Deny,
39
+ )])),
40
+ exes: Some(vec![ComputerUseWindowsExeConfigToml {
41
+ publisher_name: "CN=Google LLC".to_string(),
42
+ product_name: "Google Chrome".to_string(),
43
+ binary_name: Some("chrome.exe".to_string()),
44
+ access: AllowDenyRequirementToml::Allow,
45
+ }]),
46
+ }),
47
+ };
48
+ assert_eq!(config.computer_use, Some(expected.clone()));
49
+
50
+ let serialized = toml::to_string(&config).expect("computer use config should serialize");
51
+ let reparsed: ConfigToml =
52
+ toml::from_str(&serialized).expect("serialized computer use config should deserialize");
53
+ assert_eq!(reparsed.computer_use, Some(expected));
54
+ }
codex-rs/config/src/config_requirements.rs ADDED
The diff for this file is too large to render. See raw diff
 
codex-rs/config/src/config_toml.rs ADDED
@@ -0,0 +1,1073 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Schema-heavy configuration TOML types used by Codex.
2
+
3
+ use std::collections::BTreeMap;
4
+ use std::collections::HashMap;
5
+ use std::num::NonZeroU64;
6
+ use std::path::Path;
7
+
8
+ use crate::HooksToml;
9
+ use crate::browser_use::BrowserUseConfigToml;
10
+ use crate::computer_use::ComputerUseConfigToml;
11
+ use crate::permissions_toml::PermissionsToml;
12
+ use crate::profile_toml::ConfigProfile;
13
+ use crate::types::AnalyticsConfigToml;
14
+ use crate::types::ApprovalsReviewer;
15
+ use crate::types::AppsConfigToml;
16
+ use crate::types::AuthCredentialsStoreMode;
17
+ use crate::types::FeedbackConfigToml;
18
+ use crate::types::History;
19
+ use crate::types::MarketplaceConfig;
20
+ use crate::types::McpEnterpriseManagedAuthConfig;
21
+ use crate::types::McpServerConfig;
22
+ use crate::types::MemoriesToml;
23
+ use crate::types::Notice;
24
+ use crate::types::OAuthCredentialsStoreMode;
25
+ use crate::types::OtelConfigToml;
26
+ use crate::types::PluginConfig;
27
+ use crate::types::SandboxWorkspaceWrite;
28
+ use crate::types::ShellEnvironmentPolicyToml;
29
+ use crate::types::SkillsConfig;
30
+ use crate::types::ToolSuggestConfig;
31
+ use crate::types::Tui;
32
+ use crate::types::UriBasedFileOpener;
33
+ use crate::types::WindowsToml;
34
+ use codex_features::FeaturesToml;
35
+ use codex_model_provider_info::AMAZON_BEDROCK_PROVIDER_ID;
36
+ use codex_model_provider_info::AMAZON_BEDROCK_RUNTIME_PROVIDER_ID;
37
+ use codex_model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID;
38
+ use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID;
39
+ use codex_model_provider_info::ModelProviderInfo;
40
+ use codex_model_provider_info::OLLAMA_CHAT_PROVIDER_REMOVED_ERROR;
41
+ use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID;
42
+ use codex_model_provider_info::OPENAI_PROVIDER_ID;
43
+ use codex_protocol::config_types::AutoCompactTokenLimitScope;
44
+ use codex_protocol::config_types::ForcedLoginMethod;
45
+ use codex_protocol::config_types::Personality;
46
+ use codex_protocol::config_types::ReasoningSummary;
47
+ use codex_protocol::config_types::SandboxMode;
48
+ use codex_protocol::config_types::TrustLevel;
49
+ use codex_protocol::config_types::Verbosity;
50
+ use codex_protocol::config_types::WebSearchMode;
51
+ use codex_protocol::config_types::WebSearchToolConfig;
52
+ use codex_protocol::config_types::WindowsSandboxLevel;
53
+ use codex_protocol::models::PermissionProfile;
54
+ use codex_protocol::openai_models::ReasoningEffort;
55
+ use codex_protocol::permissions::NetworkSandboxPolicy;
56
+ use codex_protocol::protocol::AskForApproval;
57
+ use codex_utils_absolute_path::AbsolutePathBuf;
58
+ use codex_utils_path::normalize_for_path_comparison;
59
+ use schemars::JsonSchema;
60
+ use serde::Deserialize;
61
+ use serde::Deserializer;
62
+ use serde::Serialize;
63
+ use serde::de::Error as SerdeError;
64
+ use serde_json::Value as JsonValue;
65
+
66
+ const RESERVED_MODEL_PROVIDER_IDS: [&str; 5] = [
67
+ AMAZON_BEDROCK_PROVIDER_ID,
68
+ AMAZON_BEDROCK_RUNTIME_PROVIDER_ID,
69
+ OPENAI_PROVIDER_ID,
70
+ OLLAMA_OSS_PROVIDER_ID,
71
+ LMSTUDIO_OSS_PROVIDER_ID,
72
+ ];
73
+
74
+ pub const DEFAULT_PROJECT_DOC_MAX_BYTES: usize = 32 * 1024;
75
+
76
+ fn default_history() -> Option<History> {
77
+ Some(History::default())
78
+ }
79
+
80
+ const fn default_project_doc_max_bytes() -> Option<usize> {
81
+ Some(DEFAULT_PROJECT_DOC_MAX_BYTES)
82
+ }
83
+
84
+ fn default_project_doc_fallback_filenames() -> Option<Vec<String>> {
85
+ Some(Vec::new())
86
+ }
87
+
88
+ const fn default_hide_agent_reasoning() -> Option<bool> {
89
+ Some(false)
90
+ }
91
+
92
+ const fn default_true() -> bool {
93
+ true
94
+ }
95
+
96
+ /// Backward-compatible shape for ChatGPT workspace login restrictions in config.toml.
97
+ #[derive(Serialize, Debug, Clone, PartialEq, JsonSchema)]
98
+ #[serde(untagged)]
99
+ pub enum ForcedChatgptWorkspaceIds {
100
+ Single(String),
101
+ Multiple(Vec<String>),
102
+ }
103
+
104
+ impl ForcedChatgptWorkspaceIds {
105
+ pub fn into_vec(self) -> Vec<String> {
106
+ match self {
107
+ Self::Single(value) => vec![value],
108
+ Self::Multiple(values) => values,
109
+ }
110
+ }
111
+ }
112
+
113
+ impl<'de> Deserialize<'de> for ForcedChatgptWorkspaceIds {
114
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
115
+ where
116
+ D: Deserializer<'de>,
117
+ {
118
+ #[derive(Deserialize)]
119
+ #[serde(untagged)]
120
+ enum Repr {
121
+ Single(String),
122
+ Multiple(Vec<String>),
123
+ }
124
+
125
+ match Repr::deserialize(deserializer)? {
126
+ Repr::Single(value) if value.contains(',') => Err(D::Error::custom(
127
+ "forced_chatgpt_workspace_id must be a single workspace ID string or a TOML list \
128
+ of strings; comma-separated strings are not supported. Use \
129
+ `forced_chatgpt_workspace_id = [\"123e4567-e89b-42d3-a456-426614174000\", \
130
+ \"123e4567-e89b-42d3-a456-426614174001\"]` instead.",
131
+ )),
132
+ Repr::Single(value) => Ok(Self::Single(value)),
133
+ Repr::Multiple(values) => Ok(Self::Multiple(values)),
134
+ }
135
+ }
136
+ }
137
+
138
+ /// Orchestrator-owned feature settings.
139
+ #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
140
+ #[schemars(deny_unknown_fields)]
141
+ pub struct OrchestratorToml {
142
+ pub skills: Option<OrchestratorFeatureToml>,
143
+ pub mcp: Option<OrchestratorFeatureToml>,
144
+ }
145
+
146
+ /// Settings for a feature owned by the orchestrator.
147
+ #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
148
+ #[schemars(deny_unknown_fields)]
149
+ pub struct OrchestratorFeatureToml {
150
+ pub enabled: Option<bool>,
151
+ }
152
+
153
+ /// Base config deserialized from ~/.codex/config.toml.
154
+ #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)]
155
+ #[schemars(deny_unknown_fields)]
156
+ pub struct ConfigToml {
157
+ /// Optional override of model selection.
158
+ pub model: Option<String>,
159
+ /// Review model override used by the `/review` feature.
160
+ pub review_model: Option<String>,
161
+
162
+ /// Provider to use from the model_providers map.
163
+ pub model_provider: Option<String>,
164
+
165
+ /// Size of the context window for the model, in tokens.
166
+ pub model_context_window: Option<i64>,
167
+
168
+ /// Token usage threshold triggering auto-compaction of conversation history.
169
+ pub model_auto_compact_token_limit: Option<i64>,
170
+
171
+ /// Controls whether the auto-compaction limit applies to the full context or
172
+ /// only to tokens after the carried prefix in the current compaction window.
173
+ pub model_auto_compact_token_limit_scope: Option<AutoCompactTokenLimitScope>,
174
+
175
+ /// Default approval policy for executing commands.
176
+ #[schemars(with = "Option<crate::schema::ConfigAskForApproval>")]
177
+ pub approval_policy: Option<AskForApproval>,
178
+
179
+ /// Configures who approval requests are routed to for review once they have
180
+ /// been escalated. This does not disable separate safety checks such as
181
+ /// ARC.
182
+ pub approvals_reviewer: Option<ApprovalsReviewer>,
183
+
184
+ /// Optional policy instructions for the guardian auto-reviewer.
185
+ #[serde(default)]
186
+ pub auto_review: Option<AutoReviewToml>,
187
+
188
+ pub browser_use: Option<BrowserUseConfigToml>,
189
+
190
+ pub computer_use: Option<ComputerUseConfigToml>,
191
+
192
+ #[serde(default)]
193
+ pub shell_environment_policy: ShellEnvironmentPolicyToml,
194
+
195
+ /// Whether the model may request a login shell for shell-based tools.
196
+ /// Default to `true`
197
+ ///
198
+ /// If `true`, the model may request a login shell (`login = true`), and
199
+ /// omitting `login` defaults to using a login shell.
200
+ /// If `false`, the model can never use a login shell: `login = true`
201
+ /// requests are rejected, and omitting `login` defaults to a non-login
202
+ /// shell.
203
+ pub allow_login_shell: Option<bool>,
204
+
205
+ /// Sandbox mode to use.
206
+ pub sandbox_mode: Option<SandboxMode>,
207
+
208
+ /// Allow macOS sandbox writable roots at or beneath CODEX_HOME to traverse
209
+ /// symlinks. Read only from the host's user config at startup; defaults to false.
210
+ /// This grants no write access by itself, but trusts symlink targets even if
211
+ /// they change between commands or lie outside CODEX_HOME.
212
+ /// This setting has no effect on Linux or Windows.
213
+ pub allow_symlinked_codex_home: Option<bool>,
214
+
215
+ /// Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`.
216
+ pub sandbox_workspace_write: Option<SandboxWorkspaceWrite>,
217
+
218
+ /// Default permissions profile to apply. Names starting with `:` refer to
219
+ /// built-in profiles; other names are resolved from the `[permissions]`
220
+ /// table.
221
+ pub default_permissions: Option<String>,
222
+
223
+ /// Named permissions profiles.
224
+ #[serde(default)]
225
+ pub permissions: Option<PermissionsToml>,
226
+
227
+ /// Optional external command to spawn for end-user notifications.
228
+ #[serde(default)]
229
+ pub notify: Option<Vec<String>>,
230
+
231
+ /// System instructions.
232
+ pub instructions: Option<String>,
233
+
234
+ /// Developer instructions inserted as a `developer` role message.
235
+ #[serde(default)]
236
+ pub developer_instructions: Option<String>,
237
+
238
+ /// Whether to inject the `<permissions instructions>` developer block.
239
+ pub include_permissions_instructions: Option<bool>,
240
+
241
+ /// Whether to inject the `<apps_instructions>` developer block.
242
+ pub include_apps_instructions: Option<bool>,
243
+
244
+ /// Whether to inject the `<collaboration_mode>` developer block.
245
+ pub include_collaboration_mode_instructions: Option<bool>,
246
+
247
+ /// Whether to inject the `<environment_context>` user block.
248
+ pub include_environment_context: Option<bool>,
249
+
250
+ /// Optional path to a file containing model instructions that will override
251
+ /// the built-in instructions for the selected model. Users are STRONGLY
252
+ /// DISCOURAGED from using this field, as deviating from the instructions
253
+ /// sanctioned by Codex will likely degrade model performance.
254
+ pub model_instructions_file: Option<AbsolutePathBuf>,
255
+
256
+ /// Compact prompt used for history compaction.
257
+ pub compact_prompt: Option<String>,
258
+
259
+ /// When set, restricts ChatGPT login to one or more workspace identifiers.
260
+ #[serde(default)]
261
+ pub forced_chatgpt_workspace_id: Option<ForcedChatgptWorkspaceIds>,
262
+
263
+ /// When set, restricts the login mechanism users may use.
264
+ #[serde(default)]
265
+ pub forced_login_method: Option<ForcedLoginMethod>,
266
+
267
+ /// Preferred backend for storing CLI auth credentials.
268
+ /// file (default): Use a file in the Codex home directory.
269
+ /// keyring: Use an OS-specific keyring service.
270
+ /// auto: Use the keyring if available, otherwise use a file.
271
+ #[serde(default)]
272
+ pub cli_auth_credentials_store: Option<AuthCredentialsStoreMode>,
273
+
274
+ /// Definition for MCP servers that Codex can reach out to for tool calls.
275
+ #[serde(default)]
276
+ // Uses the raw MCP input shape (custom deserialization) rather than `McpServerConfig`.
277
+ #[schemars(schema_with = "crate::schema::mcp_servers_schema")]
278
+ pub mcp_servers: HashMap<String, McpServerConfig>,
279
+
280
+ /// Trusted enterprise IdP shared by EMA-enabled MCP servers and plugins.
281
+ #[serde(default)]
282
+ pub mcp_enterprise_managed_auth: Option<McpEnterpriseManagedAuthConfig>,
283
+
284
+ /// Preferred backend for storing MCP OAuth credentials.
285
+ /// keyring: Use an OS-specific keyring service.
286
+ /// https://github.com/openai/codex/blob/main/codex-rs/rmcp-client/src/oauth.rs#L2
287
+ /// file: Use a file in the Codex home directory.
288
+ /// auto (default): Use the OS-specific keyring service if available, otherwise use a file.
289
+ #[serde(default)]
290
+ pub mcp_oauth_credentials_store: Option<OAuthCredentialsStoreMode>,
291
+
292
+ /// Optional fixed port for the local HTTP callback server used during MCP OAuth login.
293
+ /// When unset, Codex will bind to an ephemeral port chosen by the OS.
294
+ pub mcp_oauth_callback_port: Option<u16>,
295
+
296
+ /// Optional redirect URI to use during MCP OAuth login.
297
+ /// When set, this URI is used in the OAuth authorization request instead
298
+ /// of the local listener address. The local callback listener still binds
299
+ /// to 127.0.0.1 (using `mcp_oauth_callback_port` when provided).
300
+ pub mcp_oauth_callback_url: Option<String>,
301
+
302
+ /// Milliseconds to wait for optional MCP servers while building the initial tool catalog.
303
+ ///
304
+ /// Defaults to 1000. Set to 0 to disable the shared grace and wait for each
305
+ /// server's configured `startup_timeout_sec` instead.
306
+ pub mcp_optional_startup_grace_ms: Option<u64>,
307
+
308
+ /// User-defined provider entries that extend the built-in list. Built-in
309
+ /// IDs cannot be overridden.
310
+ #[serde(default, deserialize_with = "deserialize_model_providers")]
311
+ pub model_providers: HashMap<String, ModelProviderInfo>,
312
+
313
+ /// Maximum total bytes of project instruction content across all selected environments.
314
+ #[serde(default = "default_project_doc_max_bytes")]
315
+ pub project_doc_max_bytes: Option<usize>,
316
+
317
+ /// Ordered list of fallback filenames to look for when AGENTS.md is missing.
318
+ #[serde(default = "default_project_doc_fallback_filenames")]
319
+ pub project_doc_fallback_filenames: Option<Vec<String>>,
320
+
321
+ /// Token budget applied when storing tool/function outputs in the context manager.
322
+ pub tool_output_token_limit: Option<usize>,
323
+
324
+ /// Maximum poll window for background terminal output (`write_stdin`), in milliseconds.
325
+ /// Default: `300000` (5 minutes).
326
+ pub background_terminal_max_timeout: Option<u64>,
327
+
328
+ /// Seconds a thread must have no subscribers and no activity before app-server
329
+ /// unloads it. Defaults to 60; zero unloads immediately. Changes require a server restart.
330
+ pub thread_unload_delay_secs: Option<u64>,
331
+
332
+ /// Deprecated: ignored.
333
+ #[schemars(skip)]
334
+ pub js_repl_node_path: Option<AbsolutePathBuf>,
335
+
336
+ /// Deprecated: ignored.
337
+ #[schemars(skip)]
338
+ pub js_repl_node_module_dirs: Option<Vec<AbsolutePathBuf>>,
339
+
340
+ /// Profile to use from the `profiles` map.
341
+ pub profile: Option<String>,
342
+
343
+ /// Named profiles to facilitate switching between different configurations.
344
+ #[serde(default)]
345
+ pub profiles: HashMap<String, ConfigProfile>,
346
+
347
+ /// Settings that govern if and what will be written to `~/.codex/history.jsonl`.
348
+ #[serde(default = "default_history")]
349
+ pub history: Option<History>,
350
+
351
+ /// Directory where Codex stores the SQLite state DB.
352
+ /// Defaults to `$CODEX_SQLITE_HOME` when set. Otherwise uses `$CODEX_HOME`.
353
+ pub sqlite_home: Option<AbsolutePathBuf>,
354
+
355
+ /// Directory where Codex writes log files. Setting this value explicitly
356
+ /// also enables the TUI text log in this directory.
357
+ /// Defaults to `$CODEX_HOME/log`.
358
+ pub log_dir: Option<AbsolutePathBuf>,
359
+
360
+ /// Optional URI-based file opener. If set, citations to files in the model
361
+ /// output will be hyperlinked using the specified URI scheme.
362
+ pub file_opener: Option<UriBasedFileOpener>,
363
+
364
+ /// Collection of settings that are specific to the TUI.
365
+ pub tui: Option<Tui>,
366
+
367
+ /// When set to `true`, `AgentReasoning` events will be hidden from the
368
+ /// UI/output. Defaults to `false`.
369
+ #[serde(default = "default_hide_agent_reasoning")]
370
+ pub hide_agent_reasoning: Option<bool>,
371
+
372
+ /// When set to `true`, `AgentReasoningRawContentEvent` events will be shown in the UI/output.
373
+ /// Defaults to `false`.
374
+ pub show_raw_agent_reasoning: Option<bool>,
375
+
376
+ pub model_reasoning_effort: Option<ReasoningEffort>,
377
+ pub plan_mode_reasoning_effort: Option<ReasoningEffort>,
378
+ pub model_reasoning_summary: Option<ReasoningSummary>,
379
+ /// Optional verbosity control for GPT-5 models (Responses API `text.verbosity`).
380
+ pub model_verbosity: Option<Verbosity>,
381
+
382
+ /// Optional path to a JSON model catalog (applied on startup only).
383
+ /// Per-thread `config` overrides are accepted but do not reapply this (no-ops).
384
+ pub model_catalog_json: Option<AbsolutePathBuf>,
385
+
386
+ /// Deprecated: `friendly` and `pragmatic` no longer select a style.
387
+ pub personality: Option<Personality>,
388
+
389
+ /// Optional explicit service tier request id for new turns (for example
390
+ /// `default`, `priority`, or `flex`; legacy `fast` also works).
391
+ pub service_tier: Option<String>,
392
+
393
+ /// Base URL for requests to ChatGPT (as opposed to the OpenAI API).
394
+ pub chatgpt_base_url: Option<String>,
395
+
396
+ /// Optional product SKU forwarded on host-owned Codex Apps MCP requests.
397
+ pub apps_mcp_product_sku: Option<String>,
398
+
399
+ /// Bounded, product-owned metadata attached to every Responses API request.
400
+ pub responses_api_metadata: Option<BTreeMap<String, String>>,
401
+
402
+ /// Orchestrator-owned feature settings.
403
+ pub orchestrator: Option<OrchestratorToml>,
404
+
405
+ /// Base URL override for the built-in `openai` model provider.
406
+ pub openai_base_url: Option<String>,
407
+
408
+ /// Machine-local realtime audio device preferences used by realtime voice.
409
+ #[serde(default)]
410
+ pub audio: Option<RealtimeAudioToml>,
411
+
412
+ /// Experimental / do not use. Overrides only the realtime conversation
413
+ /// websocket transport base URL (the `Op::RealtimeConversation`
414
+ /// `/v1/realtime`
415
+ /// connection) without changing normal provider HTTP requests.
416
+ pub experimental_realtime_ws_base_url: Option<String>,
417
+ /// Experimental / do not use. Overrides only the WebRTC realtime call
418
+ /// creation base URL. This is separate from `experimental_realtime_ws_base_url`
419
+ /// because WebRTC call creation is HTTP, while sideband control is websocket.
420
+ pub experimental_realtime_webrtc_call_base_url: Option<String>,
421
+ /// Experimental / do not use. Selects the realtime websocket model/snapshot
422
+ /// used for the `Op::RealtimeConversation` connection.
423
+ pub experimental_realtime_ws_model: Option<String>,
424
+ /// Experimental / do not use. Realtime websocket session selection.
425
+ /// `version` controls v1/v2 and `type` controls conversational/transcription.
426
+ #[serde(default)]
427
+ pub realtime: Option<RealtimeToml>,
428
+ /// Experimental / do not use. Overrides only the realtime conversation
429
+ /// websocket transport instructions (the `Op::RealtimeConversation`
430
+ /// `/ws` session.update instructions) without changing normal prompts.
431
+ pub experimental_realtime_ws_backend_prompt: Option<String>,
432
+ /// Experimental / do not use. Replaces the synthesized realtime startup
433
+ /// context appended to websocket session instructions. An empty string
434
+ /// disables startup context injection entirely.
435
+ pub experimental_realtime_ws_startup_context: Option<String>,
436
+ /// Experimental / do not use. Replaces the built-in realtime start
437
+ /// instructions inserted into developer messages when realtime becomes
438
+ /// active.
439
+ pub experimental_realtime_start_instructions: Option<String>,
440
+
441
+ /// Removed. Former remote thread-store endpoint setting kept only so we can
442
+ /// fail fast instead of silently falling back to local persistence.
443
+ #[schemars(skip)]
444
+ pub experimental_thread_store_endpoint: Option<String>,
445
+
446
+ /// Experimental / do not use. Selects the thread store implementation.
447
+ pub experimental_thread_store: Option<ThreadStoreToml>,
448
+ pub projects: Option<HashMap<String, ProjectConfig>>,
449
+
450
+ /// Controls the web search tool mode: disabled, cached, indexed, or live.
451
+ pub web_search: Option<WebSearchMode>,
452
+
453
+ /// Nested tools section for feature toggles
454
+ pub tools: Option<ToolsToml>,
455
+
456
+ /// Additional discoverable tools that can be suggested for installation.
457
+ pub tool_suggest: Option<ToolSuggestConfig>,
458
+
459
+ /// Agent-related settings (thread limits, etc.).
460
+ pub agents: Option<AgentsToml>,
461
+
462
+ /// Goal-related settings.
463
+ pub goals: Option<GoalsToml>,
464
+
465
+ /// Memories subsystem settings.
466
+ pub memories: Option<MemoriesToml>,
467
+
468
+ /// User-level skill config entries keyed by SKILL.md path.
469
+ pub skills: Option<SkillsConfig>,
470
+
471
+ /// Lifecycle hooks configured inline in TOML plus user-level overrides.
472
+ pub hooks: Option<HooksToml>,
473
+
474
+ /// User-level plugin config entries keyed by plugin name.
475
+ #[serde(default)]
476
+ pub plugins: HashMap<String, PluginConfig>,
477
+
478
+ /// User-level marketplace entries keyed by marketplace name.
479
+ #[serde(default)]
480
+ pub marketplaces: HashMap<String, MarketplaceConfig>,
481
+
482
+ /// Centralized feature flags (new). Prefer this over individual toggles.
483
+ #[serde(default)]
484
+ // Injects known feature keys into the schema and forbids unknown keys.
485
+ #[schemars(schema_with = "crate::schema::features_schema")]
486
+ pub features: Option<FeaturesToml>,
487
+
488
+ /// Suppress warnings about unstable (under development) features.
489
+ pub suppress_unstable_features_warning: Option<bool>,
490
+
491
+ /// Compatibility-only settings retained so legacy `ghost_snapshot`
492
+ /// config still loads.
493
+ #[serde(default)]
494
+ pub ghost_snapshot: Option<GhostSnapshotToml>,
495
+
496
+ /// Markers used to detect the project root when searching parent
497
+ /// directories for `.codex` folders. Defaults to [".git"] when unset.
498
+ #[serde(default)]
499
+ pub project_root_markers: Option<Vec<String>>,
500
+
501
+ /// When `true`, checks for Codex updates on startup and surfaces update prompts.
502
+ /// Set to `false` only if your Codex updates are centrally managed.
503
+ /// Defaults to `true`.
504
+ pub check_for_update_on_startup: Option<bool>,
505
+
506
+ /// Legacy fallback for `tui.disable_paste_burst`. Prefer the setting under `[tui]`.
507
+ pub disable_paste_burst: Option<bool>,
508
+
509
+ /// When `false`, disables analytics across Codex product surfaces in this machine.
510
+ /// Defaults to `true`.
511
+ pub analytics: Option<AnalyticsConfigToml>,
512
+
513
+ /// When `false`, disables feedback collection across Codex product surfaces.
514
+ /// Defaults to `true`.
515
+ pub feedback: Option<FeedbackConfigToml>,
516
+
517
+ /// Settings for app-specific controls.
518
+ #[serde(default)]
519
+ pub apps: Option<AppsConfigToml>,
520
+
521
+ /// Opaque desktop settings stored alongside the rest of config.toml.
522
+ #[serde(default)]
523
+ pub desktop: Option<HashMap<String, JsonValue>>,
524
+
525
+ /// OTEL configuration.
526
+ pub otel: Option<OtelConfigToml>,
527
+
528
+ /// Windows-specific configuration.
529
+ #[serde(default)]
530
+ pub windows: Option<WindowsToml>,
531
+
532
+ /// Collection of in-product notices (different from notifications)
533
+ /// See [`crate::types::Notice`] for more details
534
+ pub notice: Option<Notice>,
535
+
536
+ pub experimental_compact_prompt_file: Option<AbsolutePathBuf>,
537
+ pub experimental_use_unified_exec_tool: Option<bool>,
538
+ /// Preferred OSS provider for local models, e.g. "lmstudio" or "ollama".
539
+ pub oss_provider: Option<String>,
540
+ }
541
+
542
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
543
+ #[serde(tag = "type", rename_all = "snake_case")]
544
+ pub enum ThreadStoreToml {
545
+ Local {},
546
+ #[schemars(skip)]
547
+ InMemory {
548
+ id: String,
549
+ },
550
+ }
551
+
552
+ #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
553
+ pub struct AutoReviewToml {
554
+ /// Additional policy instructions inserted into the guardian prompt.
555
+ pub policy: Option<String>,
556
+ /// Experimental full Guardian prompt template containing the tenant policy placeholder.
557
+ pub experimental_policy_template: Option<String>,
558
+ }
559
+
560
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
561
+ #[schemars(deny_unknown_fields)]
562
+ pub struct ProjectConfig {
563
+ pub trust_level: Option<TrustLevel>,
564
+ }
565
+
566
+ impl ProjectConfig {
567
+ pub fn is_trusted(&self) -> bool {
568
+ matches!(self.trust_level, Some(TrustLevel::Trusted))
569
+ }
570
+
571
+ pub fn is_untrusted(&self) -> bool {
572
+ matches!(self.trust_level, Some(TrustLevel::Untrusted))
573
+ }
574
+ }
575
+
576
+ #[derive(Debug, Clone, Default, PartialEq, Eq)]
577
+ pub struct RealtimeAudioConfig {
578
+ pub microphone: Option<String>,
579
+ pub speaker: Option<String>,
580
+ }
581
+
582
+ #[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq, JsonSchema)]
583
+ #[serde(rename_all = "snake_case")]
584
+ pub enum RealtimeWsMode {
585
+ #[default]
586
+ Conversational,
587
+ Transcription,
588
+ }
589
+
590
+ #[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq, JsonSchema)]
591
+ #[serde(rename_all = "snake_case")]
592
+ pub enum RealtimeTransport {
593
+ #[default]
594
+ #[serde(rename = "webrtc")]
595
+ WebRtc,
596
+ Websocket,
597
+ }
598
+
599
+ pub use codex_protocol::protocol::RealtimeConversationVersion as RealtimeWsVersion;
600
+ pub use codex_protocol::protocol::RealtimeVoice;
601
+
602
+ #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
603
+ #[schemars(deny_unknown_fields)]
604
+ pub struct RealtimeConfig {
605
+ pub version: RealtimeWsVersion,
606
+ #[serde(rename = "type")]
607
+ pub session_type: RealtimeWsMode,
608
+ pub transport: RealtimeTransport,
609
+ pub voice: Option<RealtimeVoice>,
610
+ }
611
+
612
+ #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
613
+ #[schemars(deny_unknown_fields)]
614
+ pub struct RealtimeToml {
615
+ pub version: Option<RealtimeWsVersion>,
616
+ #[serde(rename = "type")]
617
+ pub session_type: Option<RealtimeWsMode>,
618
+ pub transport: Option<RealtimeTransport>,
619
+ pub voice: Option<RealtimeVoice>,
620
+ }
621
+
622
+ #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
623
+ #[schemars(deny_unknown_fields)]
624
+ pub struct RealtimeAudioToml {
625
+ pub microphone: Option<String>,
626
+ pub speaker: Option<String>,
627
+ }
628
+
629
+ #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)]
630
+ #[schemars(deny_unknown_fields)]
631
+ pub struct ToolsToml {
632
+ #[serde(
633
+ default,
634
+ deserialize_with = "deserialize_optional_web_search_tool_config"
635
+ )]
636
+ pub web_search: Option<WebSearchToolConfig>,
637
+ pub experimental_request_user_input: Option<ExperimentalRequestUserInput>,
638
+ pub update_plan: Option<UpdatePlanToolConfig>,
639
+ }
640
+
641
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
642
+ #[schemars(deny_unknown_fields)]
643
+ pub struct ExperimentalRequestUserInput {
644
+ #[serde(default = "default_true")]
645
+ pub enabled: bool,
646
+ }
647
+
648
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
649
+ #[schemars(deny_unknown_fields)]
650
+ pub struct UpdatePlanToolConfig {
651
+ #[serde(default)]
652
+ pub enabled: bool,
653
+ }
654
+
655
+ #[derive(Deserialize)]
656
+ #[serde(untagged)]
657
+ enum WebSearchToolConfigInput {
658
+ Enabled(bool),
659
+ Config(WebSearchToolConfig),
660
+ }
661
+
662
+ fn deserialize_optional_web_search_tool_config<'de, D>(
663
+ deserializer: D,
664
+ ) -> Result<Option<WebSearchToolConfig>, D::Error>
665
+ where
666
+ D: Deserializer<'de>,
667
+ {
668
+ let value = Option::<WebSearchToolConfigInput>::deserialize(deserializer)?;
669
+
670
+ Ok(match value {
671
+ None => None,
672
+ Some(WebSearchToolConfigInput::Enabled(enabled)) => {
673
+ let _ = enabled;
674
+ None
675
+ }
676
+ Some(WebSearchToolConfigInput::Config(config)) => Some(config),
677
+ })
678
+ }
679
+
680
+ #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
681
+ #[schemars(deny_unknown_fields)]
682
+ pub struct GoalsToml {
683
+ /// Maximum token budget allowed for a goal and default budget for new goals.
684
+ pub max_goal_token_budget: Option<NonZeroU64>,
685
+ }
686
+
687
+ #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
688
+ #[schemars(deny_unknown_fields)]
689
+ pub struct AgentsToml {
690
+ /// Whether multi-agent tools are enabled. Defaults to true.
691
+ /// An enabled `features.multi_agent_v2` setting takes precedence.
692
+ pub enabled: Option<bool>,
693
+ /// Maximum number of spawned agent threads that can be open concurrently per session.
694
+ /// When unset, the selected multi-agent backend uses its default.
695
+ #[serde(alias = "max_threads")]
696
+ #[schemars(range(min = 1))]
697
+ pub max_concurrent_threads_per_session: Option<usize>,
698
+ /// Maximum nesting depth for V1 agent threads. Ignored by V2.
699
+ pub max_depth: Option<i32>,
700
+ /// Default model for spawned subagents when the spawn call does not select one.
701
+ pub default_subagent_model: Option<String>,
702
+ /// Default reasoning effort for spawned subagents when the spawn call does not select one.
703
+ pub default_subagent_reasoning_effort: Option<ReasoningEffort>,
704
+ /// Removed agent-job setting retained as a no-op for compatibility.
705
+ #[schemars(skip)]
706
+ pub job_max_runtime_seconds: Option<u64>,
707
+ /// Whether to record a model-visible message when an agent turn is interrupted.
708
+ /// Defaults to true.
709
+ pub interrupt_message: Option<bool>,
710
+
711
+ /// User-defined role declarations keyed by role name.
712
+ ///
713
+ /// Example:
714
+ /// ```toml
715
+ /// [agents.researcher]
716
+ /// description = "Research-focused role."
717
+ /// config_file = "./agents/researcher.toml"
718
+ /// nickname_candidates = ["Herodotus", "Ibn Battuta"]
719
+ /// ```
720
+ #[serde(default, flatten)]
721
+ pub roles: BTreeMap<String, AgentRoleToml>,
722
+ }
723
+
724
+ #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
725
+ #[schemars(deny_unknown_fields)]
726
+ pub struct AgentRoleToml {
727
+ /// Human-facing role documentation used in spawn tool guidance.
728
+ /// Required unless supplied by the referenced agent role file.
729
+ pub description: Option<String>,
730
+
731
+ /// Path to a role-specific config layer.
732
+ /// Relative paths are resolved relative to the `config.toml` that defines them.
733
+ pub config_file: Option<AbsolutePathBuf>,
734
+
735
+ /// Candidate nicknames for agents spawned with this role.
736
+ pub nickname_candidates: Option<Vec<String>>,
737
+ }
738
+
739
+ #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
740
+ #[schemars(deny_unknown_fields)]
741
+ pub struct GhostSnapshotToml {
742
+ /// Legacy no-op setting retained for compatibility.
743
+ #[serde(alias = "ignore_untracked_files_over_bytes")]
744
+ pub ignore_large_untracked_files: Option<i64>,
745
+ /// Legacy no-op setting retained for compatibility.
746
+ #[serde(alias = "large_untracked_dir_warning_threshold")]
747
+ pub ignore_large_untracked_dirs: Option<i64>,
748
+ /// Legacy no-op setting retained for compatibility.
749
+ pub disable_warnings: Option<bool>,
750
+ }
751
+
752
+ impl ConfigToml {
753
+ /// Derive the effective permission profile from legacy sandbox config.
754
+ ///
755
+ /// Call this only after ruling out `default_permissions`: named
756
+ /// `[permissions]` profiles must be compiled through the permissions
757
+ /// profile pipeline, not reconstructed from `sandbox_mode`.
758
+ pub async fn derive_permission_profile(
759
+ &self,
760
+ sandbox_mode_override: Option<SandboxMode>,
761
+ windows_sandbox_level: WindowsSandboxLevel,
762
+ active_project: Option<&ProjectConfig>,
763
+ permission_profile_constraint: Option<&crate::Constrained<PermissionProfile>>,
764
+ ) -> PermissionProfile {
765
+ let configured_sandbox_mode = sandbox_mode_override.or(self.sandbox_mode);
766
+ let resolved_sandbox_mode = configured_sandbox_mode
767
+ .or_else(|| {
768
+ // If no sandbox_mode is set but this directory has a trust decision,
769
+ // default to workspace-write except on unsandboxed Windows where we
770
+ // default to read-only.
771
+ active_project
772
+ .filter(|project| project.is_trusted() || project.is_untrusted())
773
+ .map(|_| {
774
+ if cfg!(target_os = "windows")
775
+ && windows_sandbox_level == WindowsSandboxLevel::Disabled
776
+ {
777
+ SandboxMode::ReadOnly
778
+ } else {
779
+ SandboxMode::WorkspaceWrite
780
+ }
781
+ })
782
+ })
783
+ .unwrap_or_default();
784
+ let effective_sandbox_mode = if cfg!(target_os = "windows")
785
+ // If the experimental Windows sandbox is enabled, do not force a downgrade.
786
+ && windows_sandbox_level == WindowsSandboxLevel::Disabled
787
+ && matches!(resolved_sandbox_mode, SandboxMode::WorkspaceWrite)
788
+ {
789
+ SandboxMode::ReadOnly
790
+ } else {
791
+ resolved_sandbox_mode
792
+ };
793
+
794
+ let permission_profile = match effective_sandbox_mode {
795
+ SandboxMode::ReadOnly => PermissionProfile::read_only(),
796
+ SandboxMode::WorkspaceWrite => match self.sandbox_workspace_write.as_ref() {
797
+ Some(SandboxWorkspaceWrite {
798
+ writable_roots,
799
+ network_access,
800
+ exclude_tmpdir_env_var,
801
+ exclude_slash_tmp,
802
+ }) => {
803
+ let network_policy = if *network_access {
804
+ NetworkSandboxPolicy::Enabled
805
+ } else {
806
+ NetworkSandboxPolicy::Restricted
807
+ };
808
+ PermissionProfile::workspace_write_with(
809
+ writable_roots,
810
+ network_policy,
811
+ *exclude_tmpdir_env_var,
812
+ *exclude_slash_tmp,
813
+ )
814
+ }
815
+ None => PermissionProfile::workspace_write(),
816
+ },
817
+ SandboxMode::DangerFullAccess => PermissionProfile::Disabled,
818
+ };
819
+ if configured_sandbox_mode.is_none()
820
+ && let Some(constraint) = permission_profile_constraint
821
+ && let Err(err) = constraint.can_set(&permission_profile)
822
+ {
823
+ tracing::warn!(
824
+ error = %err,
825
+ "default sandbox policy is disallowed by requirements; falling back to required default"
826
+ );
827
+ PermissionProfile::read_only()
828
+ } else {
829
+ permission_profile
830
+ }
831
+ }
832
+
833
+ /// Resolves the cwd to an existing project, or returns None if ConfigToml
834
+ /// does not contain a project corresponding to cwd or the resolved git repo
835
+ /// root for cwd.
836
+ pub fn get_active_project(
837
+ &self,
838
+ resolved_cwd: &Path,
839
+ repo_root: Option<&Path>,
840
+ ) -> Option<ProjectConfig> {
841
+ let projects = self.projects.as_ref()?;
842
+
843
+ for normalized_cwd in normalized_project_lookup_keys(resolved_cwd) {
844
+ if let Some(project_config) = project_config_for_lookup_key(projects, &normalized_cwd) {
845
+ return Some(project_config);
846
+ }
847
+ }
848
+
849
+ if let Some(repo_root) = repo_root {
850
+ for normalized_repo_root in normalized_project_lookup_keys(repo_root) {
851
+ if let Some(project_config_for_root) =
852
+ project_config_for_lookup_key(projects, &normalized_repo_root)
853
+ {
854
+ return Some(project_config_for_root);
855
+ }
856
+ }
857
+ }
858
+
859
+ None
860
+ }
861
+ }
862
+
863
+ /// Canonicalize the path and convert it to a string to be used as a key in the
864
+ /// projects trust map. On Windows, strips UNC, when possible, to try to ensure
865
+ /// that different paths that point to the same location have the same key.
866
+ fn normalized_project_lookup_keys(path: &Path) -> Vec<String> {
867
+ let normalized_path = normalize_project_lookup_key(path.to_string_lossy().to_string());
868
+ let normalized_canonical_path = normalize_project_lookup_key(
869
+ normalize_for_path_comparison(path)
870
+ .unwrap_or_else(|_| path.to_path_buf())
871
+ .to_string_lossy()
872
+ .to_string(),
873
+ );
874
+ if normalized_path == normalized_canonical_path {
875
+ vec![normalized_canonical_path]
876
+ } else {
877
+ vec![normalized_canonical_path, normalized_path]
878
+ }
879
+ }
880
+
881
+ fn normalize_project_lookup_key(key: String) -> String {
882
+ if cfg!(windows) {
883
+ key.to_ascii_lowercase()
884
+ } else {
885
+ key
886
+ }
887
+ }
888
+
889
+ fn project_config_for_lookup_key(
890
+ projects: &HashMap<String, ProjectConfig>,
891
+ lookup_key: &str,
892
+ ) -> Option<ProjectConfig> {
893
+ if let Some(project_config) = projects.get(lookup_key) {
894
+ return Some(project_config.clone());
895
+ }
896
+
897
+ let mut normalized_matches: Vec<_> = projects
898
+ .iter()
899
+ .filter(|(key, _)| normalize_project_lookup_key((*key).clone()) == lookup_key)
900
+ .collect();
901
+ normalized_matches.sort_by_key(|(key, _)| *key);
902
+ normalized_matches
903
+ .first()
904
+ .map(|(_, project_config)| (**project_config).clone())
905
+ }
906
+
907
+ pub fn validate_reserved_model_provider_ids(
908
+ model_providers: &HashMap<String, ModelProviderInfo>,
909
+ ) -> Result<(), String> {
910
+ let mut conflicts = model_providers
911
+ .keys()
912
+ .filter(|key| {
913
+ !matches!(
914
+ key.as_str(),
915
+ AMAZON_BEDROCK_PROVIDER_ID | AMAZON_BEDROCK_RUNTIME_PROVIDER_ID
916
+ ) && RESERVED_MODEL_PROVIDER_IDS.contains(&key.as_str())
917
+ })
918
+ .map(|key| format!("`{key}`"))
919
+ .collect::<Vec<_>>();
920
+ conflicts.sort_unstable();
921
+ if conflicts.is_empty() {
922
+ Ok(())
923
+ } else {
924
+ Err(format!(
925
+ "model_providers contains reserved built-in provider IDs: {}. \
926
+ Built-in providers cannot be overridden. Rename your custom provider (for example, `openai-custom`).",
927
+ conflicts.join(", ")
928
+ ))
929
+ }
930
+ }
931
+
932
+ pub fn validate_model_providers(
933
+ model_providers: &HashMap<String, ModelProviderInfo>,
934
+ ) -> Result<(), String> {
935
+ validate_reserved_model_provider_ids(model_providers)?;
936
+ for (key, provider) in model_providers {
937
+ if matches!(
938
+ key.as_str(),
939
+ AMAZON_BEDROCK_PROVIDER_ID | AMAZON_BEDROCK_RUNTIME_PROVIDER_ID
940
+ ) {
941
+ provider
942
+ .validate_bedrock_override()
943
+ .map_err(|message| format!("model_providers.{key} {message}"))?;
944
+ } else {
945
+ if provider.aws.is_some() {
946
+ return Err(format!(
947
+ "model_providers.{key}: provider aws is only supported for \
948
+ `{AMAZON_BEDROCK_PROVIDER_ID}` or `{AMAZON_BEDROCK_RUNTIME_PROVIDER_ID}`"
949
+ ));
950
+ }
951
+ if provider.name.trim().is_empty() {
952
+ return Err(format!(
953
+ "model_providers.{key}: provider name must not be empty"
954
+ ));
955
+ }
956
+ }
957
+ provider
958
+ .validate()
959
+ .map_err(|message| format!("model_providers.{key}: {message}"))?;
960
+ }
961
+ Ok(())
962
+ }
963
+
964
+ fn deserialize_model_providers<'de, D>(
965
+ deserializer: D,
966
+ ) -> Result<HashMap<String, ModelProviderInfo>, D::Error>
967
+ where
968
+ D: serde::Deserializer<'de>,
969
+ {
970
+ let model_providers = HashMap::<String, ModelProviderInfo>::deserialize(deserializer)?;
971
+ validate_model_providers(&model_providers).map_err(serde::de::Error::custom)?;
972
+ Ok(model_providers)
973
+ }
974
+
975
+ #[cfg(test)]
976
+ #[path = "bedrock_runtime_tests.rs"]
977
+ mod bedrock_runtime_tests;
978
+
979
+ pub fn validate_oss_provider(provider: &str) -> std::io::Result<()> {
980
+ match provider {
981
+ LMSTUDIO_OSS_PROVIDER_ID | OLLAMA_OSS_PROVIDER_ID => Ok(()),
982
+ LEGACY_OLLAMA_CHAT_PROVIDER_ID => Err(std::io::Error::new(
983
+ std::io::ErrorKind::InvalidInput,
984
+ OLLAMA_CHAT_PROVIDER_REMOVED_ERROR,
985
+ )),
986
+ _ => Err(std::io::Error::new(
987
+ std::io::ErrorKind::InvalidInput,
988
+ format!(
989
+ "Invalid OSS provider '{provider}'. Must be one of: {LMSTUDIO_OSS_PROVIDER_ID}, {OLLAMA_OSS_PROVIDER_ID}"
990
+ ),
991
+ )),
992
+ }
993
+ }
994
+
995
+ #[cfg(test)]
996
+ mod tests {
997
+ use super::*;
998
+ use pretty_assertions::assert_eq;
999
+
1000
+ const WORKSPACE_ID_A: &str = "123e4567-e89b-42d3-a456-426614174000";
1001
+ const WORKSPACE_ID_B: &str = "123e4567-e89b-42d3-a456-426614174001";
1002
+
1003
+ #[test]
1004
+ fn thread_unload_delay_requires_nonnegative_seconds() {
1005
+ for value in ["-1", "1.5", "\"60\""] {
1006
+ let error =
1007
+ toml::from_str::<ConfigToml>(&format!("thread_unload_delay_secs = {value}"))
1008
+ .expect_err("idle timeout must be a nonnegative integer");
1009
+ assert!(error.to_string().contains("thread_unload_delay_secs"));
1010
+ }
1011
+ }
1012
+
1013
+ #[test]
1014
+ fn forced_chatgpt_workspace_id_accepts_single_string() {
1015
+ let config: ConfigToml = toml::from_str(&format!(
1016
+ r#"forced_chatgpt_workspace_id = "{WORKSPACE_ID_A}""#
1017
+ ))
1018
+ .expect("single workspace id should deserialize");
1019
+
1020
+ assert_eq!(
1021
+ config
1022
+ .forced_chatgpt_workspace_id
1023
+ .expect("workspace id should be set")
1024
+ .into_vec(),
1025
+ vec![WORKSPACE_ID_A.to_string()]
1026
+ );
1027
+ }
1028
+
1029
+ #[test]
1030
+ fn forced_chatgpt_workspace_id_accepts_string_list() {
1031
+ let config: ConfigToml = toml::from_str(&format!(
1032
+ r#"forced_chatgpt_workspace_id = ["{WORKSPACE_ID_A}", "{WORKSPACE_ID_B}"]"#
1033
+ ))
1034
+ .expect("workspace id list should deserialize");
1035
+
1036
+ assert_eq!(
1037
+ config
1038
+ .forced_chatgpt_workspace_id
1039
+ .expect("workspace ids should be set")
1040
+ .into_vec(),
1041
+ vec![WORKSPACE_ID_A.to_string(), WORKSPACE_ID_B.to_string()]
1042
+ );
1043
+ }
1044
+
1045
+ #[test]
1046
+ fn forced_chatgpt_workspace_id_rejects_comma_separated_string() {
1047
+ let err = toml::from_str::<ConfigToml>(&format!(
1048
+ r#"forced_chatgpt_workspace_id = "{WORKSPACE_ID_A},{WORKSPACE_ID_B}""#
1049
+ ))
1050
+ .expect_err("comma-separated string should be rejected");
1051
+
1052
+ let message = err.to_string();
1053
+ assert!(message.contains("TOML list of strings"));
1054
+ assert!(message.contains("comma-separated strings are not supported"));
1055
+ }
1056
+
1057
+ #[test]
1058
+ fn amazon_bedrock_auth_command_must_not_be_empty() {
1059
+ let err = toml::from_str::<ConfigToml>(
1060
+ r#"
1061
+ [model_providers.amazon-bedrock.auth]
1062
+ command = " "
1063
+ "#,
1064
+ )
1065
+ .expect_err("empty Amazon Bedrock auth command should be rejected");
1066
+
1067
+ assert!(
1068
+ err.to_string().contains(
1069
+ "model_providers.amazon-bedrock: provider auth.command must not be empty"
1070
+ )
1071
+ );
1072
+ }
1073
+ }
codex-rs/config/src/constraint.rs ADDED
@@ -0,0 +1,344 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::fmt;
2
+ use std::sync::Arc;
3
+
4
+ use crate::config_requirements::RequirementSource;
5
+ use thiserror::Error;
6
+
7
+ #[derive(Debug, Error, PartialEq, Eq)]
8
+ pub enum ConstraintError {
9
+ #[error(
10
+ "invalid value for `{field_name}`: `{candidate}` is not in the allowed set {allowed} (set by {requirement_source})"
11
+ )]
12
+ InvalidValue {
13
+ field_name: &'static str,
14
+ candidate: String,
15
+ allowed: String,
16
+ requirement_source: RequirementSource,
17
+ },
18
+
19
+ #[error("To use model `{model}`, you need to use auto review.")]
20
+ AutoReviewRequired { model: String },
21
+
22
+ #[error("field `{field_name}` cannot be empty")]
23
+ EmptyField { field_name: String },
24
+
25
+ #[error("invalid rules in requirements (set by {requirement_source}): {reason}")]
26
+ ExecPolicyParse {
27
+ requirement_source: RequirementSource,
28
+ reason: String,
29
+ },
30
+
31
+ #[error(
32
+ "invalid requirement for MCP server `{server_name}` (set by {requirement_source}): {reason}"
33
+ )]
34
+ McpServerRequirementParse {
35
+ server_name: String,
36
+ requirement_source: RequirementSource,
37
+ reason: String,
38
+ },
39
+ }
40
+
41
+ impl ConstraintError {
42
+ pub fn empty_field(field_name: impl Into<String>) -> Self {
43
+ Self::EmptyField {
44
+ field_name: field_name.into(),
45
+ }
46
+ }
47
+ }
48
+
49
+ pub type ConstraintResult<T> = Result<T, ConstraintError>;
50
+
51
+ impl From<ConstraintError> for std::io::Error {
52
+ fn from(err: ConstraintError) -> Self {
53
+ std::io::Error::new(std::io::ErrorKind::InvalidInput, err)
54
+ }
55
+ }
56
+
57
+ type ConstraintValidator<T> = dyn Fn(&T) -> ConstraintResult<()> + Send + Sync;
58
+ /// A ConstraintNormalizer is a function which transforms a value into another of the same type.
59
+ /// `Constrained` uses normalizers to transform values to satisfy constraints or enforce values.
60
+ type ConstraintNormalizer<T> = dyn Fn(T) -> T + Send + Sync;
61
+
62
+ #[derive(Clone)]
63
+ pub struct Constrained<T> {
64
+ value: T,
65
+ validator: Arc<ConstraintValidator<T>>,
66
+ normalizer: Option<Arc<ConstraintNormalizer<T>>>,
67
+ }
68
+
69
+ impl<T: Send + Sync> Constrained<T> {
70
+ pub fn new(
71
+ initial_value: T,
72
+ validator: impl Fn(&T) -> ConstraintResult<()> + Send + Sync + 'static,
73
+ ) -> ConstraintResult<Self> {
74
+ let validator: Arc<ConstraintValidator<T>> = Arc::new(validator);
75
+ validator(&initial_value)?;
76
+ Ok(Self {
77
+ value: initial_value,
78
+ validator,
79
+ normalizer: None,
80
+ })
81
+ }
82
+
83
+ /// normalized creates a `Constrained` value with a normalizer function and a validator that allows any value.
84
+ pub fn normalized(
85
+ initial_value: T,
86
+ normalizer: impl Fn(T) -> T + Send + Sync + 'static,
87
+ ) -> ConstraintResult<Self> {
88
+ let validator: Arc<ConstraintValidator<T>> = Arc::new(|_| Ok(()));
89
+ let normalizer: Arc<ConstraintNormalizer<T>> = Arc::new(normalizer);
90
+ let normalized = normalizer(initial_value);
91
+ validator(&normalized)?;
92
+ Ok(Self {
93
+ value: normalized,
94
+ validator,
95
+ normalizer: Some(normalizer),
96
+ })
97
+ }
98
+
99
+ pub fn allow_any(initial_value: T) -> Self {
100
+ Self {
101
+ value: initial_value,
102
+ validator: Arc::new(|_| Ok(())),
103
+ normalizer: None,
104
+ }
105
+ }
106
+
107
+ pub fn allow_only(only_value: T) -> Self
108
+ where
109
+ T: Clone + fmt::Debug + PartialEq + 'static,
110
+ {
111
+ let allowed_value = only_value.clone();
112
+ Self {
113
+ value: only_value,
114
+ validator: Arc::new(move |candidate| {
115
+ if candidate == &allowed_value {
116
+ Ok(())
117
+ } else {
118
+ Err(ConstraintError::InvalidValue {
119
+ field_name: "<unknown>",
120
+ candidate: format!("{candidate:?}"),
121
+ allowed: format!("[{allowed_value:?}]"),
122
+ requirement_source: RequirementSource::Unknown,
123
+ })
124
+ }
125
+ }),
126
+ normalizer: None,
127
+ }
128
+ }
129
+
130
+ /// Allow any value of T, using T's Default as the initial value.
131
+ pub fn allow_any_from_default() -> Self
132
+ where
133
+ T: Default,
134
+ {
135
+ Self::allow_any(T::default())
136
+ }
137
+
138
+ pub fn get(&self) -> &T {
139
+ &self.value
140
+ }
141
+
142
+ pub fn value(&self) -> T
143
+ where
144
+ T: Copy,
145
+ {
146
+ self.value
147
+ }
148
+
149
+ pub fn can_set(&self, candidate: &T) -> ConstraintResult<()> {
150
+ (self.validator)(candidate)
151
+ }
152
+
153
+ /// Composes an additional validator onto the current constraint.
154
+ ///
155
+ /// The existing value must satisfy the combined validator before it is installed.
156
+ pub fn add_validator(
157
+ &mut self,
158
+ validator: impl Fn(&T) -> ConstraintResult<()> + Send + Sync + 'static,
159
+ ) -> ConstraintResult<()>
160
+ where
161
+ T: 'static,
162
+ {
163
+ let existing_validator = self.validator.clone();
164
+ let combined_validator: Arc<ConstraintValidator<T>> = Arc::new(move |candidate| {
165
+ existing_validator(candidate)?;
166
+ validator(candidate)
167
+ });
168
+
169
+ combined_validator(&self.value)?;
170
+ self.validator = combined_validator;
171
+ Ok(())
172
+ }
173
+
174
+ pub fn set(&mut self, value: T) -> ConstraintResult<()> {
175
+ let value = if let Some(normalizer) = &self.normalizer {
176
+ normalizer(value)
177
+ } else {
178
+ value
179
+ };
180
+ (self.validator)(&value)?;
181
+ self.value = value;
182
+ Ok(())
183
+ }
184
+ }
185
+
186
+ impl<T> std::ops::Deref for Constrained<T> {
187
+ type Target = T;
188
+
189
+ fn deref(&self) -> &Self::Target {
190
+ &self.value
191
+ }
192
+ }
193
+
194
+ impl<T: fmt::Debug> fmt::Debug for Constrained<T> {
195
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
196
+ f.debug_struct("Constrained")
197
+ .field("value", &self.value)
198
+ .finish()
199
+ }
200
+ }
201
+
202
+ impl<T: PartialEq> PartialEq for Constrained<T> {
203
+ fn eq(&self, other: &Self) -> bool {
204
+ self.value == other.value
205
+ }
206
+ }
207
+
208
+ #[cfg(test)]
209
+ mod tests {
210
+ use super::*;
211
+ use pretty_assertions::assert_eq;
212
+
213
+ fn invalid_value(candidate: impl Into<String>, allowed: impl Into<String>) -> ConstraintError {
214
+ ConstraintError::InvalidValue {
215
+ field_name: "<unknown>",
216
+ candidate: candidate.into(),
217
+ allowed: allowed.into(),
218
+ requirement_source: RequirementSource::Unknown,
219
+ }
220
+ }
221
+
222
+ #[test]
223
+ fn constrained_allow_any_accepts_any_value() {
224
+ let mut constrained = Constrained::allow_any(/*initial_value*/ 5);
225
+ constrained
226
+ .set(/*value*/ -10)
227
+ .expect("allow any accepts all values");
228
+ assert_eq!(constrained.value(), -10);
229
+ }
230
+
231
+ #[test]
232
+ fn constrained_allow_any_default_uses_default_value() {
233
+ let constrained = Constrained::<i32>::allow_any_from_default();
234
+ assert_eq!(constrained.value(), 0);
235
+ }
236
+
237
+ #[test]
238
+ fn constrained_allow_only_rejects_different_values() {
239
+ let mut constrained = Constrained::allow_only(/*only_value*/ 5);
240
+ constrained
241
+ .set(/*value*/ 5)
242
+ .expect("allowed value should be accepted");
243
+
244
+ let err = constrained
245
+ .set(/*value*/ 6)
246
+ .expect_err("different value should be rejected");
247
+ assert_eq!(err, invalid_value("6", "[5]"));
248
+ assert_eq!(constrained.value(), 5);
249
+ }
250
+
251
+ #[test]
252
+ fn constrained_normalizer_applies_on_init_and_set() -> anyhow::Result<()> {
253
+ let mut constrained =
254
+ Constrained::normalized(/*initial_value*/ -1, |value| value.max(0))?;
255
+ assert_eq!(constrained.value(), 0);
256
+ constrained.set(/*value*/ -5)?;
257
+ assert_eq!(constrained.value(), 0);
258
+ constrained.set(/*value*/ 10)?;
259
+ assert_eq!(constrained.value(), 10);
260
+ Ok(())
261
+ }
262
+
263
+ #[test]
264
+ fn constrained_add_validator_composes_with_existing_validator() -> anyhow::Result<()> {
265
+ let mut constrained = Constrained::new(/*initial_value*/ 5, |value: &i32| {
266
+ if *value >= 0 {
267
+ Ok(())
268
+ } else {
269
+ Err(ConstraintError::empty_field("value"))
270
+ }
271
+ })?;
272
+ constrained.add_validator(|value| {
273
+ if *value <= 10 {
274
+ Ok(())
275
+ } else {
276
+ Err(ConstraintError::empty_field("value"))
277
+ }
278
+ })?;
279
+
280
+ assert_eq!(constrained.can_set(&7), Ok(()));
281
+ assert_eq!(
282
+ constrained.can_set(&11),
283
+ Err(ConstraintError::empty_field("value"))
284
+ );
285
+ assert_eq!(
286
+ constrained.can_set(&-1),
287
+ Err(ConstraintError::empty_field("value"))
288
+ );
289
+
290
+ Ok(())
291
+ }
292
+
293
+ #[test]
294
+ fn constrained_new_rejects_invalid_initial_value() {
295
+ let result = Constrained::new(/*initial_value*/ 0, |value| {
296
+ if *value > 0 {
297
+ Ok(())
298
+ } else {
299
+ Err(invalid_value(value.to_string(), "positive values"))
300
+ }
301
+ });
302
+
303
+ assert_eq!(result, Err(invalid_value("0", "positive values")));
304
+ }
305
+
306
+ #[test]
307
+ fn constrained_set_rejects_invalid_value_and_leaves_previous() {
308
+ let mut constrained = Constrained::new(/*initial_value*/ 1, |value| {
309
+ if *value > 0 {
310
+ Ok(())
311
+ } else {
312
+ Err(invalid_value(value.to_string(), "positive values"))
313
+ }
314
+ })
315
+ .expect("initial value should be accepted");
316
+
317
+ let err = constrained
318
+ .set(/*value*/ -5)
319
+ .expect_err("negative values should be rejected");
320
+ assert_eq!(err, invalid_value("-5", "positive values"));
321
+ assert_eq!(constrained.value(), 1);
322
+ }
323
+
324
+ #[test]
325
+ fn constrained_can_set_allows_probe_without_setting() {
326
+ let constrained = Constrained::new(/*initial_value*/ 1, |value| {
327
+ if *value > 0 {
328
+ Ok(())
329
+ } else {
330
+ Err(invalid_value(value.to_string(), "positive values"))
331
+ }
332
+ })
333
+ .expect("initial value should be accepted");
334
+
335
+ constrained
336
+ .can_set(&2)
337
+ .expect("can_set should accept positive value");
338
+ let err = constrained
339
+ .can_set(&-1)
340
+ .expect_err("can_set should reject negative value");
341
+ assert_eq!(err, invalid_value("-1", "positive values"));
342
+ assert_eq!(constrained.value(), 1);
343
+ }
344
+ }
codex-rs/config/src/diagnostics.rs ADDED
@@ -0,0 +1,495 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Helpers for mapping config parse/validation failures to file locations and
2
+ //! rendering them in a user-friendly way.
3
+
4
+ use crate::ConfigLayerEntry;
5
+ use crate::ConfigLayerSource;
6
+ use crate::ConfigLayerStack;
7
+ use crate::format_config_layer_source;
8
+ use codex_utils_absolute_path::AbsolutePathBufGuard;
9
+ use serde::de::DeserializeOwned;
10
+ use serde_path_to_error::Path as SerdePath;
11
+ use serde_path_to_error::Segment as SerdeSegment;
12
+ use std::fmt;
13
+ use std::fmt::Write;
14
+ use std::io;
15
+ use std::path::Path;
16
+ use std::path::PathBuf;
17
+ use toml_edit::Document;
18
+ use toml_edit::Item;
19
+ use toml_edit::Table;
20
+ use toml_edit::Value;
21
+
22
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
23
+ pub struct TextPosition {
24
+ pub line: usize,
25
+ pub column: usize,
26
+ }
27
+
28
+ /// Text range in 1-based line/column coordinates.
29
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
30
+ pub struct TextRange {
31
+ pub start: TextPosition,
32
+ pub end: TextPosition,
33
+ }
34
+
35
+ #[derive(Debug, Clone, PartialEq, Eq)]
36
+ pub struct ConfigError {
37
+ pub path: PathBuf,
38
+ pub range: TextRange,
39
+ pub message: String,
40
+ }
41
+
42
+ impl ConfigError {
43
+ pub fn new(path: PathBuf, range: TextRange, message: impl Into<String>) -> Self {
44
+ Self {
45
+ path,
46
+ range,
47
+ message: message.into(),
48
+ }
49
+ }
50
+ }
51
+
52
+ #[derive(Debug)]
53
+ pub struct ConfigLoadError {
54
+ error: ConfigError,
55
+ source: Option<toml::de::Error>,
56
+ }
57
+
58
+ impl ConfigLoadError {
59
+ pub fn new(error: ConfigError, source: Option<toml::de::Error>) -> Self {
60
+ Self { error, source }
61
+ }
62
+
63
+ pub fn config_error(&self) -> &ConfigError {
64
+ &self.error
65
+ }
66
+ }
67
+
68
+ impl fmt::Display for ConfigLoadError {
69
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70
+ write!(
71
+ f,
72
+ "{}:{}:{}: {}",
73
+ self.error.path.display(),
74
+ self.error.range.start.line,
75
+ self.error.range.start.column,
76
+ self.error.message
77
+ )
78
+ }
79
+ }
80
+
81
+ impl std::error::Error for ConfigLoadError {
82
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
83
+ self.source
84
+ .as_ref()
85
+ .map(|err| err as &dyn std::error::Error)
86
+ }
87
+ }
88
+
89
+ #[derive(Clone, Copy)]
90
+ pub(crate) enum ConfigDiagnosticSource<'a> {
91
+ Path(&'a Path),
92
+ DisplayName(&'a str),
93
+ }
94
+
95
+ impl ConfigDiagnosticSource<'_> {
96
+ pub(crate) fn to_path_buf(self) -> PathBuf {
97
+ match self {
98
+ ConfigDiagnosticSource::Path(path) => path.to_path_buf(),
99
+ ConfigDiagnosticSource::DisplayName(name) => PathBuf::from(name),
100
+ }
101
+ }
102
+ }
103
+
104
+ pub fn io_error_from_config_error(
105
+ kind: io::ErrorKind,
106
+ error: ConfigError,
107
+ source: Option<toml::de::Error>,
108
+ ) -> io::Error {
109
+ io::Error::new(kind, ConfigLoadError::new(error, source))
110
+ }
111
+
112
+ pub fn config_error_from_toml(
113
+ path: impl AsRef<Path>,
114
+ contents: &str,
115
+ err: toml::de::Error,
116
+ ) -> ConfigError {
117
+ config_error_from_toml_for_source(ConfigDiagnosticSource::Path(path.as_ref()), contents, err)
118
+ }
119
+
120
+ pub(crate) fn config_error_from_toml_for_source(
121
+ source: ConfigDiagnosticSource<'_>,
122
+ contents: &str,
123
+ err: toml::de::Error,
124
+ ) -> ConfigError {
125
+ let range = err
126
+ .span()
127
+ .map(|span| text_range_from_span(contents, span))
128
+ .unwrap_or_else(default_range);
129
+ ConfigError::new(source.to_path_buf(), range, err.message())
130
+ }
131
+
132
+ pub fn config_error_from_typed_toml<T: DeserializeOwned>(
133
+ path: impl AsRef<Path>,
134
+ contents: &str,
135
+ ) -> Option<ConfigError> {
136
+ config_error_from_typed_toml_for_source::<T>(
137
+ ConfigDiagnosticSource::Path(path.as_ref()),
138
+ contents,
139
+ )
140
+ }
141
+
142
+ fn config_error_from_typed_toml_for_source<T: DeserializeOwned>(
143
+ source: ConfigDiagnosticSource<'_>,
144
+ contents: &str,
145
+ ) -> Option<ConfigError> {
146
+ let deserializer = match toml::de::Deserializer::parse(contents) {
147
+ Ok(deserializer) => deserializer,
148
+ Err(err) => return Some(config_error_from_toml_for_source(source, contents, err)),
149
+ };
150
+
151
+ let result: Result<T, _> = serde_path_to_error::deserialize(deserializer);
152
+ match result {
153
+ Ok(_) => None,
154
+ Err(err) => {
155
+ let path_hint = err.path().clone();
156
+ let toml_err: toml::de::Error = err.into_inner();
157
+ let range = span_for_config_path(contents, &path_hint)
158
+ .or_else(|| toml_err.span())
159
+ .map(|span| text_range_from_span(contents, span))
160
+ .unwrap_or_else(default_range);
161
+ Some(ConfigError::new(
162
+ source.to_path_buf(),
163
+ range,
164
+ toml_err.message(),
165
+ ))
166
+ }
167
+ }
168
+ }
169
+
170
+ pub async fn first_layer_config_error<T: DeserializeOwned>(
171
+ layers: &ConfigLayerStack,
172
+ config_toml_file: &str,
173
+ ) -> Option<ConfigError> {
174
+ // When the merged config fails schema validation, we surface the first concrete
175
+ // per-file error to point users at a specific file and range rather than an
176
+ // opaque merged-layer failure.
177
+ first_layer_config_error_for_entries::<T, _>(layers.layers_low_to_high(), config_toml_file)
178
+ .await
179
+ }
180
+
181
+ pub async fn first_layer_config_error_from_entries<T: DeserializeOwned>(
182
+ layers: &[ConfigLayerEntry],
183
+ config_toml_file: &str,
184
+ ) -> Option<ConfigError> {
185
+ first_layer_config_error_for_entries::<T, _>(layers.iter(), config_toml_file).await
186
+ }
187
+
188
+ async fn first_layer_config_error_for_entries<'a, T: DeserializeOwned, I>(
189
+ layers: I,
190
+ config_toml_file: &str,
191
+ ) -> Option<ConfigError>
192
+ where
193
+ I: IntoIterator<Item = &'a ConfigLayerEntry>,
194
+ {
195
+ for layer in layers {
196
+ if layer.is_disabled() {
197
+ continue;
198
+ }
199
+ if let Some(contents) = layer.raw_toml() {
200
+ let source_name = format_config_layer_source(&layer.name, config_toml_file);
201
+ let Some(base_dir) = layer.raw_toml_base_dir() else {
202
+ tracing::debug!(
203
+ "Skipping raw TOML diagnostics for {source_name} because it has no base directory"
204
+ );
205
+ continue;
206
+ };
207
+ // Match the base directory used when the raw non-file layer was
208
+ // parsed into the runtime layer so diagnostics resolve relative
209
+ // path fields with the same semantics.
210
+ let _absolute_path_base = AbsolutePathBufGuard::new(base_dir.as_path());
211
+ if let Some(error) = config_error_from_typed_toml_for_source::<T>(
212
+ ConfigDiagnosticSource::DisplayName(&source_name),
213
+ contents,
214
+ ) {
215
+ return Some(error);
216
+ }
217
+ continue;
218
+ }
219
+
220
+ let Some(path) = config_path_for_layer(layer, config_toml_file) else {
221
+ continue;
222
+ };
223
+ let contents = match tokio::fs::read_to_string(&path).await {
224
+ Ok(contents) => contents,
225
+ Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
226
+ Err(err) => {
227
+ tracing::debug!("Failed to read config file {}: {err}", path.display());
228
+ continue;
229
+ }
230
+ };
231
+
232
+ let Some(parent) = path.parent() else {
233
+ tracing::debug!("Config file {} has no parent directory", path.display());
234
+ continue;
235
+ };
236
+ let _guard = AbsolutePathBufGuard::new(parent);
237
+ if let Some(error) = config_error_from_typed_toml::<T>(&path, &contents) {
238
+ return Some(error);
239
+ }
240
+ }
241
+
242
+ None
243
+ }
244
+
245
+ fn config_path_for_layer(layer: &ConfigLayerEntry, config_toml_file: &str) -> Option<PathBuf> {
246
+ match &layer.name {
247
+ ConfigLayerSource::PackagedDefaults { file } => Some(file.to_path_buf()),
248
+ ConfigLayerSource::System { file } => Some(file.to_path_buf()),
249
+ ConfigLayerSource::User { file, .. } => Some(file.to_path_buf()),
250
+ ConfigLayerSource::Project { dot_codex_folder } => {
251
+ Some(dot_codex_folder.as_path().join(config_toml_file))
252
+ }
253
+ ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => Some(file.to_path_buf()),
254
+ ConfigLayerSource::Mdm { .. }
255
+ | ConfigLayerSource::EnterpriseManaged { .. }
256
+ | ConfigLayerSource::SessionFlags
257
+ | ConfigLayerSource::LegacyManagedConfigTomlFromMdm => None,
258
+ }
259
+ }
260
+
261
+ pub(crate) fn text_range_from_span(contents: &str, span: std::ops::Range<usize>) -> TextRange {
262
+ let start = position_for_offset(contents, span.start);
263
+ let end_index = if span.end > span.start {
264
+ span.end - 1
265
+ } else {
266
+ span.end
267
+ };
268
+ let end = position_for_offset(contents, end_index);
269
+ TextRange { start, end }
270
+ }
271
+
272
+ pub fn format_config_error(error: &ConfigError, contents: &str) -> String {
273
+ let mut output = String::new();
274
+ let start = error.range.start;
275
+ let _ = writeln!(
276
+ output,
277
+ "{}:{}:{}: {}",
278
+ error.path.display(),
279
+ start.line,
280
+ start.column,
281
+ error.message
282
+ );
283
+
284
+ let line_index = start.line.saturating_sub(1);
285
+ let line = match contents.lines().nth(line_index) {
286
+ Some(line) => line.trim_end_matches('\r'),
287
+ None => return output.trim_end().to_string(),
288
+ };
289
+
290
+ let line_number = start.line;
291
+ let gutter = line_number.to_string().len();
292
+ let _ = writeln!(output, "{:width$} |", "", width = gutter);
293
+ let _ = writeln!(output, "{line_number:>gutter$} | {line}");
294
+
295
+ let highlight_len = if error.range.end.line == error.range.start.line
296
+ && error.range.end.column >= error.range.start.column
297
+ {
298
+ error.range.end.column - error.range.start.column + 1
299
+ } else {
300
+ 1
301
+ };
302
+ let spaces = " ".repeat(start.column.saturating_sub(1));
303
+ let carets = "^".repeat(highlight_len.max(1));
304
+ let _ = writeln!(output, "{:width$} | {spaces}{carets}", "", width = gutter);
305
+ output.trim_end().to_string()
306
+ }
307
+
308
+ pub fn format_config_error_with_source(error: &ConfigError) -> String {
309
+ match std::fs::read_to_string(&error.path) {
310
+ Ok(contents) => format_config_error(error, &contents),
311
+ Err(_) => format_config_error(error, ""),
312
+ }
313
+ }
314
+
315
+ fn position_for_offset(contents: &str, index: usize) -> TextPosition {
316
+ let bytes = contents.as_bytes();
317
+ if bytes.is_empty() {
318
+ return TextPosition { line: 1, column: 1 };
319
+ }
320
+
321
+ let safe_index = index.min(bytes.len().saturating_sub(1));
322
+ let column_offset = index.saturating_sub(safe_index);
323
+ let index = safe_index;
324
+
325
+ let line_start = bytes[..index]
326
+ .iter()
327
+ .rposition(|byte| *byte == b'\n')
328
+ .map(|pos| pos + 1)
329
+ .unwrap_or(0);
330
+ let line = bytes[..line_start]
331
+ .iter()
332
+ .filter(|byte| **byte == b'\n')
333
+ .count();
334
+
335
+ let column = std::str::from_utf8(&bytes[line_start..=index])
336
+ .map(|slice| slice.chars().count().saturating_sub(1))
337
+ .unwrap_or_else(|_| index - line_start);
338
+ let column = column + column_offset;
339
+
340
+ TextPosition {
341
+ line: line + 1,
342
+ column: column + 1,
343
+ }
344
+ }
345
+
346
+ pub(crate) fn default_range() -> TextRange {
347
+ let position = TextPosition { line: 1, column: 1 };
348
+ TextRange {
349
+ start: position,
350
+ end: position,
351
+ }
352
+ }
353
+
354
+ enum TomlNode<'a> {
355
+ Item(&'a Item),
356
+ Table(&'a Table),
357
+ Value(&'a Value),
358
+ }
359
+
360
+ fn span_for_path(contents: &str, path: &SerdePath) -> Option<std::ops::Range<usize>> {
361
+ let doc = contents.parse::<Document<String>>().ok()?;
362
+ let node = node_for_path(doc.as_item(), path)?;
363
+ match node {
364
+ TomlNode::Item(item) => item.span(),
365
+ TomlNode::Table(table) => table.span(),
366
+ TomlNode::Value(value) => value.span(),
367
+ }
368
+ }
369
+
370
+ pub(crate) fn span_for_config_path(
371
+ contents: &str,
372
+ path: &SerdePath,
373
+ ) -> Option<std::ops::Range<usize>> {
374
+ if is_features_table_path(path)
375
+ && let Some(span) = span_for_features_value(contents)
376
+ {
377
+ return Some(span);
378
+ }
379
+ span_for_path(contents, path)
380
+ }
381
+
382
+ pub(crate) fn span_for_toml_key_path(
383
+ contents: &str,
384
+ path: &[String],
385
+ ) -> Option<std::ops::Range<usize>> {
386
+ let doc = contents.parse::<Document<String>>().ok()?;
387
+ let mut node = TomlNode::Item(doc.as_item());
388
+ for (index, segment) in path.iter().enumerate() {
389
+ if index + 1 == path.len() {
390
+ let key_span = match &node {
391
+ TomlNode::Item(item) => item
392
+ .as_table_like()
393
+ .and_then(|table| table.get_key_value(segment))
394
+ .and_then(|(key, _)| key.span()),
395
+ TomlNode::Table(table) => {
396
+ table.get_key_value(segment).and_then(|(key, _)| key.span())
397
+ }
398
+ TomlNode::Value(Value::InlineTable(table)) => {
399
+ table.get_key_value(segment).and_then(|(key, _)| key.span())
400
+ }
401
+ _ => None,
402
+ };
403
+ if key_span.is_some() {
404
+ return key_span;
405
+ }
406
+ }
407
+
408
+ if let Some(next) = map_child(&node, segment) {
409
+ node = next;
410
+ continue;
411
+ }
412
+
413
+ let index = segment.parse::<usize>().ok()?;
414
+ node = seq_child(&node, index)?;
415
+ }
416
+
417
+ match node {
418
+ TomlNode::Item(item) => item.span(),
419
+ TomlNode::Table(table) => table.span(),
420
+ TomlNode::Value(value) => value.span(),
421
+ }
422
+ }
423
+
424
+ fn is_features_table_path(path: &SerdePath) -> bool {
425
+ let mut segments = path.iter();
426
+ matches!(segments.next(), Some(SerdeSegment::Map { key }) if key == "features")
427
+ && segments.next().is_none()
428
+ }
429
+
430
+ fn span_for_features_value(contents: &str) -> Option<std::ops::Range<usize>> {
431
+ let doc = contents.parse::<Document<String>>().ok()?;
432
+ let root = doc.as_item().as_table_like()?;
433
+ let features_item = root.get("features")?;
434
+ let features_table = features_item.as_table_like()?;
435
+ for (_, item) in features_table.iter() {
436
+ match item {
437
+ Item::Value(Value::Boolean(_)) => continue,
438
+ Item::Value(value) => return value.span(),
439
+ Item::Table(table) => return table.span(),
440
+ Item::ArrayOfTables(array) => return array.span(),
441
+ Item::None => continue,
442
+ }
443
+ }
444
+ None
445
+ }
446
+
447
+ fn node_for_path<'a>(item: &'a Item, path: &SerdePath) -> Option<TomlNode<'a>> {
448
+ let segments: Vec<_> = path.iter().cloned().collect();
449
+ let mut node = TomlNode::Item(item);
450
+ let mut index = 0;
451
+ while index < segments.len() {
452
+ match &segments[index] {
453
+ SerdeSegment::Map { key } | SerdeSegment::Enum { variant: key } => {
454
+ if let Some(next) = map_child(&node, key) {
455
+ node = next;
456
+ index += 1;
457
+ continue;
458
+ }
459
+
460
+ if index + 1 < segments.len() {
461
+ index += 1;
462
+ continue;
463
+ }
464
+ return None;
465
+ }
466
+ SerdeSegment::Seq { index: seq_index } => {
467
+ node = seq_child(&node, *seq_index)?;
468
+ index += 1;
469
+ }
470
+ SerdeSegment::Unknown => return None,
471
+ }
472
+ }
473
+ Some(node)
474
+ }
475
+
476
+ fn map_child<'a>(node: &TomlNode<'a>, key: &str) -> Option<TomlNode<'a>> {
477
+ match node {
478
+ TomlNode::Item(item) => {
479
+ let table = item.as_table_like()?;
480
+ table.get(key).map(TomlNode::Item)
481
+ }
482
+ TomlNode::Table(table) => table.get(key).map(TomlNode::Item),
483
+ TomlNode::Value(Value::InlineTable(table)) => table.get(key).map(TomlNode::Value),
484
+ _ => None,
485
+ }
486
+ }
487
+
488
+ fn seq_child<'a>(node: &TomlNode<'a>, index: usize) -> Option<TomlNode<'a>> {
489
+ match node {
490
+ TomlNode::Item(Item::Value(Value::Array(array))) => array.get(index).map(TomlNode::Value),
491
+ TomlNode::Item(Item::ArrayOfTables(array)) => array.get(index).map(TomlNode::Table),
492
+ TomlNode::Value(Value::Array(array)) => array.get(index).map(TomlNode::Value),
493
+ _ => None,
494
+ }
495
+ }
codex-rs/config/src/filesystem_constraints.rs ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Convert resolved filesystem denials into portable policy entries.
2
+
3
+ use crate::FilesystemConstraints;
4
+ use codex_protocol::permissions::FileSystemAccessMode;
5
+ use codex_protocol::permissions::FileSystemPath;
6
+ use codex_protocol::permissions::FileSystemSandboxEntry;
7
+ use codex_protocol::permissions::FileSystemSandboxPolicy;
8
+ use codex_utils_path_uri::LegacyAppPathString;
9
+ use codex_utils_path_uri::LegacyAppPathStringError;
10
+ use codex_utils_path_uri::PathConvention;
11
+ use codex_utils_path_uri::PathUri;
12
+ use std::io;
13
+
14
+ impl FilesystemConstraints {
15
+ /// Adds denials without changing existing entries or other policy settings.
16
+ /// `convention` must match the executor facts used to resolve these requirements.
17
+ /// Literal paths become URIs; glob spellings and entry order are preserved.
18
+ /// Invalid denials return an error before the policy is changed.
19
+ pub fn apply_to_policy(
20
+ &self,
21
+ policy: &mut FileSystemSandboxPolicy,
22
+ convention: PathConvention,
23
+ ) -> io::Result<()> {
24
+ let entries = self
25
+ .deny_read
26
+ .iter()
27
+ .map(|deny_read| {
28
+ PathUri::validate_config_path_text(deny_read.as_str(), convention)?;
29
+ let path = if deny_read.contains_glob() {
30
+ FileSystemPath::GlobPattern {
31
+ pattern: deny_read.as_str().to_string(),
32
+ }
33
+ } else {
34
+ let mut path = LegacyAppPathString::from_string(deny_read.as_str())
35
+ .to_path_uri(convention)?;
36
+ path.validate_config_path(convention)?;
37
+ // Match Core's existing URI spelling for UNC share roots.
38
+ if path.to_url().host_str().is_some() {
39
+ path = path.join(".")?;
40
+ }
41
+ path.into()
42
+ };
43
+ Ok(FileSystemSandboxEntry::new(
44
+ path,
45
+ FileSystemAccessMode::Deny,
46
+ ))
47
+ })
48
+ .collect::<Result<Vec<_>, LegacyAppPathStringError>>()
49
+ .map_err(|error| {
50
+ io::Error::new(
51
+ io::ErrorKind::InvalidInput,
52
+ format!("invalid permissions.filesystem.deny_read path: {error}"),
53
+ )
54
+ })?;
55
+ for entry in entries {
56
+ if !policy.entries.contains(&entry) {
57
+ policy.entries.push(entry);
58
+ }
59
+ }
60
+ Ok(())
61
+ }
62
+ }
codex-rs/config/src/fingerprint.rs ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::ConfigLayerMetadata;
2
+ use crate::merge::is_structured_feature_path;
3
+ use serde_json::Value as JsonValue;
4
+ use sha2::Digest;
5
+ use sha2::Sha256;
6
+ use std::collections::HashMap;
7
+ use toml::Value as TomlValue;
8
+
9
+ pub(super) fn record_origins(
10
+ value: &TomlValue,
11
+ meta: &ConfigLayerMetadata,
12
+ path: &mut Vec<String>,
13
+ origins: &mut HashMap<String, ConfigLayerMetadata>,
14
+ include: &impl Fn(&[String]) -> bool,
15
+ ) {
16
+ match value {
17
+ TomlValue::Table(table) => {
18
+ for (key, val) in table {
19
+ path.push(key.clone());
20
+ record_origins(val, meta, path, origins, include);
21
+ path.pop();
22
+ }
23
+ }
24
+ TomlValue::Array(items) => {
25
+ for (idx, item) in (0_i32..).zip(items.iter()) {
26
+ path.push(idx.to_string());
27
+ record_origins(item, meta, path, origins, include);
28
+ path.pop();
29
+ }
30
+ }
31
+ _ => {
32
+ if !path.is_empty() {
33
+ if !include(path) {
34
+ return;
35
+ }
36
+ if matches!(value, TomlValue::Boolean(_)) && is_structured_feature_path(path) {
37
+ if path
38
+ .last()
39
+ .is_some_and(|feature| feature == "network_proxy")
40
+ {
41
+ origins.insert(path.join("."), meta.clone());
42
+ }
43
+ path.push("enabled".to_string());
44
+ origins.insert(path.join("."), meta.clone());
45
+ path.pop();
46
+ return;
47
+ }
48
+ origins.insert(path.join("."), meta.clone());
49
+ }
50
+ }
51
+ }
52
+ }
53
+
54
+ pub fn version_for_toml(value: &TomlValue) -> String {
55
+ let json = serde_json::to_value(value).unwrap_or(JsonValue::Null);
56
+ let canonical = canonical_json(&json);
57
+ let serialized = serde_json::to_vec(&canonical).unwrap_or_default();
58
+ let mut hasher = Sha256::new();
59
+ hasher.update(serialized);
60
+ let hash = hasher.finalize();
61
+ let hex = hash
62
+ .iter()
63
+ .map(|byte| format!("{byte:02x}"))
64
+ .collect::<String>();
65
+ format!("sha256:{hex}")
66
+ }
67
+
68
+ fn canonical_json(value: &JsonValue) -> JsonValue {
69
+ match value {
70
+ JsonValue::Object(map) => {
71
+ let mut sorted = serde_json::Map::new();
72
+ let mut keys = map.keys().cloned().collect::<Vec<_>>();
73
+ keys.sort();
74
+ for key in keys {
75
+ if let Some(val) = map.get(&key) {
76
+ sorted.insert(key, canonical_json(val));
77
+ }
78
+ }
79
+ JsonValue::Object(sorted)
80
+ }
81
+ JsonValue::Array(items) => JsonValue::Array(items.iter().map(canonical_json).collect()),
82
+ other => other.clone(),
83
+ }
84
+ }
codex-rs/config/src/hook_config.rs ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::collections::BTreeMap;
2
+ use std::path::Path;
3
+ use std::path::PathBuf;
4
+
5
+ use codex_protocol::protocol::HookEventName;
6
+ use schemars::JsonSchema;
7
+ use serde::Deserialize;
8
+ use serde::Serialize;
9
+
10
+ #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
11
+ #[serde(deny_unknown_fields)]
12
+ pub struct HooksFile {
13
+ #[serde(default, skip_serializing_if = "Option::is_none")]
14
+ pub description: Option<String>,
15
+ #[serde(default)]
16
+ pub hooks: HookEventsToml,
17
+ }
18
+
19
+ #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
20
+ pub struct HooksToml {
21
+ #[serde(flatten)]
22
+ pub events: HookEventsToml,
23
+ #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
24
+ pub state: BTreeMap<String, HookStateToml>,
25
+ }
26
+
27
+ #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
28
+ pub struct HookStateToml {
29
+ #[serde(default, skip_serializing_if = "Option::is_none")]
30
+ pub enabled: Option<bool>,
31
+ #[serde(default, skip_serializing_if = "Option::is_none")]
32
+ pub trusted_hash: Option<String>,
33
+ }
34
+
35
+ #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
36
+ pub struct HookEventsToml {
37
+ #[serde(rename = "PreToolUse", default)]
38
+ pub pre_tool_use: Vec<MatcherGroup>,
39
+ #[serde(rename = "PermissionRequest", default)]
40
+ pub permission_request: Vec<MatcherGroup>,
41
+ #[serde(rename = "PostToolUse", default)]
42
+ pub post_tool_use: Vec<MatcherGroup>,
43
+ #[serde(rename = "PreCompact", default)]
44
+ pub pre_compact: Vec<MatcherGroup>,
45
+ #[serde(rename = "PostCompact", default)]
46
+ pub post_compact: Vec<MatcherGroup>,
47
+ #[serde(rename = "SessionStart", default)]
48
+ pub session_start: Vec<MatcherGroup>,
49
+ #[serde(rename = "SessionEnd", default)]
50
+ pub session_end: Vec<MatcherGroup>,
51
+ #[serde(rename = "UserPromptSubmit", default)]
52
+ pub user_prompt_submit: Vec<MatcherGroup>,
53
+ #[serde(rename = "SubagentStart", default)]
54
+ pub subagent_start: Vec<MatcherGroup>,
55
+ #[serde(rename = "SubagentStop", default)]
56
+ pub subagent_stop: Vec<MatcherGroup>,
57
+ #[serde(rename = "Stop", default)]
58
+ pub stop: Vec<MatcherGroup>,
59
+ #[serde(rename = "Interrupt", default)]
60
+ pub interrupt: Vec<MatcherGroup>,
61
+ }
62
+
63
+ impl HookEventsToml {
64
+ pub fn is_empty(&self) -> bool {
65
+ let Self {
66
+ pre_tool_use,
67
+ permission_request,
68
+ post_tool_use,
69
+ pre_compact,
70
+ post_compact,
71
+ session_start,
72
+ session_end,
73
+ user_prompt_submit,
74
+ subagent_start,
75
+ subagent_stop,
76
+ stop,
77
+ interrupt,
78
+ } = self;
79
+ pre_tool_use.is_empty()
80
+ && permission_request.is_empty()
81
+ && post_tool_use.is_empty()
82
+ && pre_compact.is_empty()
83
+ && post_compact.is_empty()
84
+ && session_start.is_empty()
85
+ && session_end.is_empty()
86
+ && user_prompt_submit.is_empty()
87
+ && subagent_start.is_empty()
88
+ && subagent_stop.is_empty()
89
+ && stop.is_empty()
90
+ && interrupt.is_empty()
91
+ }
92
+
93
+ pub fn handler_count(&self) -> usize {
94
+ let Self {
95
+ pre_tool_use,
96
+ permission_request,
97
+ post_tool_use,
98
+ pre_compact,
99
+ post_compact,
100
+ session_start,
101
+ session_end,
102
+ user_prompt_submit,
103
+ subagent_start,
104
+ subagent_stop,
105
+ stop,
106
+ interrupt,
107
+ } = self;
108
+ [
109
+ pre_tool_use,
110
+ permission_request,
111
+ post_tool_use,
112
+ pre_compact,
113
+ post_compact,
114
+ session_start,
115
+ session_end,
116
+ user_prompt_submit,
117
+ subagent_start,
118
+ subagent_stop,
119
+ stop,
120
+ interrupt,
121
+ ]
122
+ .into_iter()
123
+ .flatten()
124
+ .map(|group| group.hooks.len())
125
+ .sum()
126
+ }
127
+
128
+ pub fn into_matcher_groups(mut self) -> [(HookEventName, Vec<MatcherGroup>); 12] {
129
+ self.matcher_groups_mut()
130
+ .map(|(event, groups)| (event, std::mem::take(groups)))
131
+ }
132
+
133
+ pub fn matcher_groups_mut(&mut self) -> [(HookEventName, &mut Vec<MatcherGroup>); 12] {
134
+ use HookEventName as Event;
135
+
136
+ [
137
+ (Event::PreToolUse, &mut self.pre_tool_use),
138
+ (Event::PermissionRequest, &mut self.permission_request),
139
+ (Event::PostToolUse, &mut self.post_tool_use),
140
+ (Event::PreCompact, &mut self.pre_compact),
141
+ (Event::PostCompact, &mut self.post_compact),
142
+ (Event::SessionStart, &mut self.session_start),
143
+ (Event::SessionEnd, &mut self.session_end),
144
+ (Event::UserPromptSubmit, &mut self.user_prompt_submit),
145
+ (Event::SubagentStart, &mut self.subagent_start),
146
+ (Event::SubagentStop, &mut self.subagent_stop),
147
+ (Event::Stop, &mut self.stop),
148
+ (Event::Interrupt, &mut self.interrupt),
149
+ ]
150
+ }
151
+ }
152
+
153
+ #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
154
+ pub struct MatcherGroup {
155
+ #[serde(default)]
156
+ pub matcher: Option<String>,
157
+ #[serde(default)]
158
+ pub hooks: Vec<HookHandlerConfig>,
159
+ }
160
+
161
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
162
+ #[serde(tag = "type")]
163
+ pub enum HookHandlerConfig {
164
+ #[serde(rename = "command")]
165
+ Command {
166
+ command: String,
167
+ #[serde(default, rename = "commandWindows", alias = "command_windows")]
168
+ command_windows: Option<String>,
169
+ #[serde(default, rename = "timeout")]
170
+ timeout_sec: Option<u64>,
171
+ #[serde(default)]
172
+ r#async: bool,
173
+ #[serde(default, rename = "statusMessage")]
174
+ status_message: Option<String>,
175
+ /// Approximate token threshold for spilling this hook's `additionalContext` to disk.
176
+ /// Unset uses 2,500 tokens; `0` disables spilling for this hook. The threshold is
177
+ /// evaluated against the original context; a spilled preview also includes recovery
178
+ /// metadata.
179
+ #[serde(
180
+ default,
181
+ rename = "additionalContextLimit",
182
+ skip_serializing_if = "Option::is_none"
183
+ )]
184
+ additional_context_limit: Option<usize>,
185
+ },
186
+ #[serde(rename = "mcp_tool")]
187
+ McpTool {
188
+ server: String,
189
+ tool: String,
190
+ #[serde(default, deserialize_with = "deserialize_mcp_tool_input")]
191
+ input: serde_json::Map<String, serde_json::Value>,
192
+ #[serde(default, rename = "timeout")]
193
+ timeout_sec: Option<u64>,
194
+ #[serde(default, rename = "statusMessage")]
195
+ status_message: Option<String>,
196
+ },
197
+ #[serde(rename = "prompt")]
198
+ Prompt {},
199
+ #[serde(rename = "agent")]
200
+ Agent {},
201
+ }
202
+
203
+ // Reject values such as null that cannot be represented in TOML for trust hashing.
204
+ fn deserialize_mcp_tool_input<'de, D>(
205
+ deserializer: D,
206
+ ) -> Result<serde_json::Map<String, serde_json::Value>, D::Error>
207
+ where
208
+ D: serde::Deserializer<'de>,
209
+ {
210
+ let input = serde_json::Map::deserialize(deserializer)?;
211
+ toml::Value::try_from(&input).map_err(|error| {
212
+ serde::de::Error::custom(format!(
213
+ "MCP hook input must be representable as TOML: {error}"
214
+ ))
215
+ })?;
216
+ Ok(input)
217
+ }
218
+
219
+ #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
220
+ pub struct ManagedHooksRequirementsToml {
221
+ pub managed_dir: Option<PathBuf>,
222
+ pub windows_managed_dir: Option<PathBuf>,
223
+ #[serde(flatten)]
224
+ pub hooks: HookEventsToml,
225
+ }
226
+
227
+ impl ManagedHooksRequirementsToml {
228
+ pub fn is_empty(&self) -> bool {
229
+ let Self {
230
+ managed_dir,
231
+ windows_managed_dir,
232
+ hooks,
233
+ } = self;
234
+ managed_dir.is_none() && windows_managed_dir.is_none() && hooks.is_empty()
235
+ }
236
+
237
+ pub fn handler_count(&self) -> usize {
238
+ self.hooks.handler_count()
239
+ }
240
+
241
+ pub fn managed_dir_for_current_platform(&self) -> Option<&Path> {
242
+ #[cfg(windows)]
243
+ {
244
+ self.windows_managed_dir.as_deref()
245
+ }
246
+
247
+ #[cfg(not(windows))]
248
+ {
249
+ self.managed_dir.as_deref()
250
+ }
251
+ }
252
+ }
253
+
254
+ #[cfg(test)]
255
+ #[path = "hooks_tests.rs"]
256
+ mod tests;
codex-rs/config/src/in_app_browser_requirements.rs ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use serde::Deserialize;
2
+
3
+ /// Managed requirements for the interactive in-app browser, not agent Browser Use.
4
+ #[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
5
+ pub struct InAppBrowserRequirementsToml {
6
+ /// Whether external browser settings may be imported. Only `Some(false)`
7
+ /// denies import; omission is effectively `true`. Keep omission unset while
8
+ /// composing managed layers so it cannot replace an explicit requirement.
9
+ pub allow_external_browser_settings_import: Option<bool>,
10
+ }
11
+
12
+ #[cfg(test)]
13
+ #[path = "in_app_browser_requirements_tests.rs"]
14
+ mod tests;
codex-rs/config/src/key_aliases.rs ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use toml::Value as TomlValue;
2
+ use toml::map::Map as TomlMap;
3
+
4
+ #[derive(Debug, Clone, Copy)]
5
+ struct ConfigKeyAlias {
6
+ table_path: &'static [&'static str],
7
+ legacy_key: &'static str,
8
+ canonical_key: &'static str,
9
+ }
10
+
11
+ const CONFIG_KEY_ALIASES: &[ConfigKeyAlias] = &[
12
+ ConfigKeyAlias {
13
+ table_path: &["memories"],
14
+ legacy_key: "no_memories_if_mcp_or_web_search",
15
+ canonical_key: "disable_on_external_context",
16
+ },
17
+ ConfigKeyAlias {
18
+ table_path: &["agents"],
19
+ legacy_key: "max_threads",
20
+ canonical_key: "max_concurrent_threads_per_session",
21
+ },
22
+ ];
23
+
24
+ pub(crate) fn normalize_key_aliases(path: &[String], table: &mut TomlMap<String, TomlValue>) {
25
+ for alias in CONFIG_KEY_ALIASES {
26
+ if path
27
+ .iter()
28
+ .map(String::as_str)
29
+ .eq(alias.table_path.iter().copied())
30
+ && let Some(value) = table.remove(alias.legacy_key)
31
+ {
32
+ table
33
+ .entry(alias.canonical_key.to_string())
34
+ .or_insert(value);
35
+ }
36
+ }
37
+ }
38
+
39
+ pub(crate) fn normalized_with_key_aliases(value: &TomlValue, path: &[String]) -> TomlValue {
40
+ match value {
41
+ TomlValue::Table(table) => {
42
+ let mut normalized = TomlMap::new();
43
+ for (key, child) in table {
44
+ let mut child_path = path.to_vec();
45
+ child_path.push(key.clone());
46
+ normalized.insert(key.clone(), normalized_with_key_aliases(child, &child_path));
47
+ }
48
+ normalize_key_aliases(path, &mut normalized);
49
+ TomlValue::Table(normalized)
50
+ }
51
+ TomlValue::Array(items) => TomlValue::Array(
52
+ items
53
+ .iter()
54
+ .map(|item| normalized_with_key_aliases(item, path))
55
+ .collect(),
56
+ ),
57
+ _ => value.clone(),
58
+ }
59
+ }
codex-rs/config/src/lib.rs ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ mod application_requirements;
2
+ mod auth_policy;
3
+ mod browser_computer_use_requirements;
4
+ mod browser_use;
5
+ mod cloud_config_bundle;
6
+ mod cloud_config_layers;
7
+ #[cfg(target_os = "macos")]
8
+ mod codex_home_symlink;
9
+ mod computer_use;
10
+ mod config_layer_source;
11
+ mod config_requirements;
12
+ pub mod config_toml;
13
+ mod constraint;
14
+ mod diagnostics;
15
+ mod filesystem_constraints;
16
+ mod fingerprint;
17
+ mod hook_config;
18
+ mod host_name;
19
+ mod in_app_browser_requirements;
20
+ mod key_aliases;
21
+ pub mod loader;
22
+ mod marketplace_edit;
23
+ mod mcp_edit;
24
+ mod mcp_ema;
25
+ mod mcp_requirements;
26
+ mod mcp_types;
27
+ mod merge;
28
+ mod model_provider_requirements;
29
+ mod overrides;
30
+ mod path_context;
31
+ pub mod permissions_toml;
32
+ mod plugin_edit;
33
+ pub mod profile_toml;
34
+ mod project_root_markers;
35
+ mod requirements_exec_policy;
36
+ mod requirements_layers;
37
+ pub mod schema;
38
+ mod shell_environment_policy;
39
+ mod skills_config;
40
+ mod state;
41
+ mod strict_config;
42
+ pub mod test_support;
43
+ mod thread_config;
44
+ mod tui_keymap;
45
+ pub mod types;
46
+
47
+ pub const CONFIG_TOML_FILE: &str = "config.toml";
48
+
49
+ pub use application_requirements::ApplicationNetworkRequirementsToml;
50
+ pub use application_requirements::ApplicationRequirementsToml;
51
+ pub use auth_policy::ManagedAuthPolicy;
52
+ pub use browser_computer_use_requirements::AllowDenyRequirementToml;
53
+ pub use browser_computer_use_requirements::BrowserUseAccessApprovalLifetimeToml;
54
+ pub use browser_computer_use_requirements::BrowserUseOriginPolicyToml;
55
+ pub use browser_computer_use_requirements::BrowserUseRequirementsToml;
56
+ pub use browser_computer_use_requirements::ComputerUseMacosRequirementsToml;
57
+ pub use browser_computer_use_requirements::ComputerUseRequirementsToml;
58
+ pub use browser_computer_use_requirements::ComputerUseWindowsExeRequirementToml;
59
+ pub use browser_computer_use_requirements::ComputerUseWindowsRequirementsToml;
60
+ pub use browser_use::BrowserUseConfigToml;
61
+ pub use browser_use::BrowserUseOriginPolicyConfigToml;
62
+ pub use cloud_config_bundle::CloudConfigBundle;
63
+ pub use cloud_config_bundle::CloudConfigBundleLayers;
64
+ pub use cloud_config_bundle::CloudConfigBundleLoadError;
65
+ pub use cloud_config_bundle::CloudConfigBundleLoadErrorCode;
66
+ pub use cloud_config_bundle::CloudConfigBundleLoader;
67
+ pub use cloud_config_bundle::CloudConfigTomlBundle;
68
+ pub use cloud_config_bundle::CloudRequirementsFragment;
69
+ pub use cloud_config_bundle::CloudRequirementsTomlBundle;
70
+ pub use cloud_config_layers::CloudConfigFragment;
71
+ pub use cloud_config_layers::CloudConfigFragmentSource;
72
+ pub use cloud_config_layers::CloudConfigLayerError;
73
+ pub use cloud_config_layers::cloud_config_layers_from_fragments;
74
+ pub use codex_execpolicy::RequirementsExecPolicy;
75
+ #[cfg(target_os = "macos")]
76
+ pub use codex_home_symlink::allowed_symlinked_codex_home;
77
+ pub use codex_protocol::config_types::ProfileV2Name;
78
+ pub use codex_protocol::config_types::ProfileV2NameParseError;
79
+ pub use codex_protocol::config_types::ToolExposureSurface;
80
+ pub use codex_protocol::mcp_policy::McpServerCommandMatcher;
81
+ pub use codex_protocol::mcp_policy::McpServerIdentity;
82
+ pub use codex_protocol::mcp_policy::McpServerRequirement;
83
+ pub use codex_protocol::mcp_policy::McpServerValueMatcher;
84
+ pub use codex_protocol::mcp_policy::PluginMcpRequirements as PluginRequirementsToml;
85
+ pub use codex_utils_absolute_path::AbsolutePathBuf;
86
+ pub use codex_utils_absolute_path::AbsolutePathBufGuard;
87
+ pub use computer_use::ComputerUseConfigToml;
88
+ pub use computer_use::ComputerUseMacosConfigToml;
89
+ pub use computer_use::ComputerUseWindowsConfigToml;
90
+ pub use computer_use::ComputerUseWindowsExeConfigToml;
91
+ pub use config_layer_source::ConfigLayer;
92
+ pub use config_layer_source::ConfigLayerMetadata;
93
+ pub use config_layer_source::ConfigLayerSource;
94
+ pub use config_layer_source::format_config_layer_source;
95
+ pub use config_requirements::AppRequirementToml;
96
+ pub use config_requirements::AppToolRequirementToml;
97
+ pub use config_requirements::AppToolResultSourceFormat;
98
+ pub use config_requirements::AppToolResultSourceRequirementToml;
99
+ pub use config_requirements::AppToolsRequirementsToml;
100
+ pub use config_requirements::AppsRequirementsToml;
101
+ pub use config_requirements::AutoReviewRequirementsToml;
102
+ pub use config_requirements::ConfigRequirements;
103
+ pub use config_requirements::ConfigRequirementsToml;
104
+ pub use config_requirements::ConfigRequirementsWithSources;
105
+ pub use config_requirements::ConstrainedWithSource;
106
+ pub use config_requirements::FeatureRequirementsToml;
107
+ pub use config_requirements::FilesystemConstraints;
108
+ pub use config_requirements::FilesystemDenyReadPattern;
109
+ pub use config_requirements::MarketplaceAllowedSourceKind;
110
+ pub use config_requirements::MarketplaceAllowedSourceToml;
111
+ pub use config_requirements::MarketplaceRequirementsToml;
112
+ pub use config_requirements::ModelsRequirementsToml;
113
+ pub use config_requirements::NetworkConstraints;
114
+ pub use config_requirements::NetworkDomainPermissionToml;
115
+ pub use config_requirements::NetworkDomainPermissionsToml;
116
+ pub use config_requirements::NetworkHeaderInjectionToml;
117
+ pub use config_requirements::NetworkRequirementsToml;
118
+ pub use config_requirements::NetworkUnixSocketPermissionToml;
119
+ pub use config_requirements::NetworkUnixSocketPermissionsToml;
120
+ pub use config_requirements::NewThreadModelDefaultsToml;
121
+ pub use config_requirements::RemoteSandboxConfigToml;
122
+ pub use config_requirements::RequirementSource;
123
+ pub use config_requirements::ResidencyRequirement;
124
+ pub use config_requirements::SandboxModeRequirement;
125
+ pub use config_requirements::Sourced;
126
+ pub use config_requirements::WebSearchModeRequirement;
127
+ pub use config_requirements::WindowsRequirementsToml;
128
+ pub use config_requirements::sandbox_mode_requirement_for_permission_profile;
129
+ pub use constraint::Constrained;
130
+ pub use constraint::ConstraintError;
131
+ pub use constraint::ConstraintResult;
132
+ pub use diagnostics::ConfigError;
133
+ pub use diagnostics::ConfigLoadError;
134
+ pub use diagnostics::TextPosition;
135
+ pub use diagnostics::TextRange;
136
+ pub use diagnostics::config_error_from_toml;
137
+ pub use diagnostics::config_error_from_typed_toml;
138
+ pub use diagnostics::first_layer_config_error;
139
+ pub use diagnostics::first_layer_config_error_from_entries;
140
+ pub use diagnostics::format_config_error;
141
+ pub use diagnostics::format_config_error_with_source;
142
+ pub use diagnostics::io_error_from_config_error;
143
+ pub use fingerprint::version_for_toml;
144
+ pub use hook_config::HookEventsToml;
145
+ pub use hook_config::HookHandlerConfig;
146
+ pub use hook_config::HookStateToml;
147
+ pub use hook_config::HooksFile;
148
+ pub use hook_config::HooksToml;
149
+ pub use hook_config::ManagedHooksRequirementsToml;
150
+ pub use hook_config::MatcherGroup;
151
+ pub use host_name::host_name;
152
+ pub use host_name::os_host_name;
153
+ pub use in_app_browser_requirements::InAppBrowserRequirementsToml;
154
+ pub use marketplace_edit::MarketplaceConfigUpdate;
155
+ pub use marketplace_edit::RemoveMarketplaceConfigOutcome;
156
+ pub use marketplace_edit::record_user_marketplace;
157
+ pub use marketplace_edit::remove_user_marketplace;
158
+ pub use marketplace_edit::remove_user_marketplace_config;
159
+ pub use mcp_edit::load_global_mcp_servers;
160
+ pub use mcp_ema::McpEmaRegistration;
161
+ pub use mcp_ema::McpEnterpriseManagedAuthConfig;
162
+ pub use mcp_ema::McpServerIdpOAuthConfig;
163
+ pub use mcp_types::AppToolApproval;
164
+ pub use mcp_types::DEFAULT_MCP_SERVER_ENVIRONMENT_ID;
165
+ pub use mcp_types::McpServerAuth;
166
+ pub use mcp_types::McpServerConfig;
167
+ pub use mcp_types::McpServerDisabledReason;
168
+ pub use mcp_types::McpServerEnvVar;
169
+ pub use mcp_types::McpServerOAuthConfig;
170
+ pub use mcp_types::McpServerToolConfig;
171
+ pub use mcp_types::McpServerTransportConfig;
172
+ pub use mcp_types::RawMcpServerConfig;
173
+ pub use merge::ShellEnvironmentPolicyFilterRepresentation;
174
+ pub use merge::is_structured_feature_path;
175
+ pub use merge::merge_toml_values;
176
+ pub use merge::shell_environment_filter_entry;
177
+ pub use overrides::build_cli_overrides_layer;
178
+ pub use path_context::ConfigPathContext;
179
+ pub use plugin_edit::PluginConfigEdit;
180
+ pub use plugin_edit::apply_user_plugin_config_edits;
181
+ pub use plugin_edit::clear_user_plugin;
182
+ pub use plugin_edit::set_user_plugin_enabled;
183
+ pub use project_root_markers::default_project_root_markers;
184
+ pub use project_root_markers::project_root_markers_from_config;
185
+ pub use requirements_exec_policy::RequirementsExecPolicyDecisionToml;
186
+ pub use requirements_exec_policy::RequirementsExecPolicyParseError;
187
+ pub use requirements_exec_policy::RequirementsExecPolicyPatternTokenToml;
188
+ pub use requirements_exec_policy::RequirementsExecPolicyPrefixRuleToml;
189
+ pub use requirements_exec_policy::RequirementsExecPolicyToml;
190
+ pub use requirements_layers::RequirementsLayerEntry;
191
+ pub use requirements_layers::compose_requirements;
192
+ pub use requirements_layers::compose_requirements_for_hostname;
193
+ pub use shell_environment_policy::validate_shell_environment_policy_filter_config;
194
+ pub use skills_config::BundledSkillsConfig;
195
+ pub use skills_config::SkillConfig;
196
+ pub use skills_config::SkillConfigRule;
197
+ pub use skills_config::SkillConfigRuleSelector;
198
+ pub use skills_config::SkillConfigRules;
199
+ pub use skills_config::SkillsConfig;
200
+ pub use skills_config::bundled_skills_enabled_from_stack;
201
+ pub use skills_config::skill_config_rules_from_stack;
202
+ pub use state::ConfigLayerEntry;
203
+ pub use state::ConfigLayerStack;
204
+ pub use state::ConfigLoadOptions;
205
+ pub use state::LoaderOverrides;
206
+ pub use strict_config::config_error_from_ignored_toml_fields;
207
+ pub use thread_config::NoopThreadConfigLoader;
208
+ pub use thread_config::RemoteThreadConfigLoader;
209
+ pub use thread_config::SessionThreadConfig;
210
+ pub use thread_config::StaticThreadConfigLoader;
211
+ pub use thread_config::ThreadConfigContext;
212
+ pub use thread_config::ThreadConfigLoadError;
213
+ pub use thread_config::ThreadConfigLoadErrorCode;
214
+ pub use thread_config::ThreadConfigLoader;
215
+ pub use thread_config::ThreadConfigLoaderFuture;
216
+ pub use thread_config::ThreadConfigSource;
217
+ pub use thread_config::UserThreadConfig;
218
+ pub use toml::Value as TomlValue;
codex-rs/config/src/mcp_edit.rs ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::collections::BTreeMap;
2
+ use std::io::ErrorKind;
3
+ use std::path::Path;
4
+
5
+ use toml::Value as TomlValue;
6
+
7
+ use crate::CONFIG_TOML_FILE;
8
+ use crate::McpServerConfig;
9
+
10
+ pub async fn load_global_mcp_servers(
11
+ codex_home: &Path,
12
+ ) -> std::io::Result<BTreeMap<String, McpServerConfig>> {
13
+ let config_path = codex_home.join(CONFIG_TOML_FILE);
14
+ let raw = match tokio::fs::read_to_string(&config_path).await {
15
+ Ok(raw) => raw,
16
+ Err(err) if err.kind() == ErrorKind::NotFound => return Ok(BTreeMap::new()),
17
+ Err(err) => return Err(err),
18
+ };
19
+ let parsed = toml::from_str::<TomlValue>(&raw)
20
+ .map_err(|err| std::io::Error::new(ErrorKind::InvalidData, err))?;
21
+ let Some(servers_value) = parsed.get("mcp_servers") else {
22
+ return Ok(BTreeMap::new());
23
+ };
24
+
25
+ ensure_no_inline_bearer_tokens(servers_value)?;
26
+
27
+ servers_value
28
+ .clone()
29
+ .try_into()
30
+ .map_err(|err| std::io::Error::new(ErrorKind::InvalidData, err))
31
+ }
32
+
33
+ fn ensure_no_inline_bearer_tokens(value: &TomlValue) -> std::io::Result<()> {
34
+ let Some(servers_table) = value.as_table() else {
35
+ return Ok(());
36
+ };
37
+
38
+ for (server_name, server_value) in servers_table {
39
+ if let Some(server_table) = server_value.as_table()
40
+ && server_table.contains_key("bearer_token")
41
+ {
42
+ let message = format!(
43
+ "mcp_servers.{server_name} uses unsupported `bearer_token`; set `bearer_token_env_var`."
44
+ );
45
+ return Err(std::io::Error::new(ErrorKind::InvalidData, message));
46
+ }
47
+ }
48
+
49
+ Ok(())
50
+ }
codex-rs/config/src/mcp_ema_tests.rs ADDED
@@ -0,0 +1,309 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Enterprise registration provenance checks.
2
+
3
+ use super::*;
4
+ use crate::AbsolutePathBuf;
5
+ use crate::ConfigLayerEntry;
6
+ use crate::ConfigRequirements;
7
+ use crate::ConfigRequirementsToml;
8
+ use pretty_assertions::assert_eq;
9
+
10
+ fn stack(layers: Vec<(ConfigLayerSource, &str)>) -> ConfigLayerStack {
11
+ ConfigLayerStack::new(
12
+ layers
13
+ .into_iter()
14
+ .map(|(source, value)| ConfigLayerEntry::new(source, toml::from_str(value).unwrap()))
15
+ .collect(),
16
+ ConfigRequirements::default(),
17
+ ConfigRequirementsToml::default(),
18
+ )
19
+ .unwrap()
20
+ }
21
+
22
+ fn local_sources(name: &str) -> (ConfigLayerSource, ConfigLayerSource, ConfigLayerSource) {
23
+ let path = AbsolutePathBuf::from_absolute_path(std::env::temp_dir().join(name)).unwrap();
24
+ (
25
+ ConfigLayerSource::System { file: path.clone() },
26
+ ConfigLayerSource::User {
27
+ file: path.clone(),
28
+ profile: None,
29
+ },
30
+ ConfigLayerSource::Project {
31
+ dot_codex_folder: path,
32
+ },
33
+ )
34
+ }
35
+
36
+ fn validate_effective_ema(stack: &ConfigLayerStack) -> std::io::Result<()> {
37
+ let servers = stack
38
+ .effective_config()
39
+ .get("mcp_servers")
40
+ .cloned()
41
+ .unwrap_or_else(|| toml::Value::Table(Default::default()))
42
+ .try_into::<std::collections::HashMap<String, McpServerConfig>>()
43
+ .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))?;
44
+ validate_ema_auth_sources(stack, &servers)
45
+ }
46
+
47
+ fn valid_ema_layers(layers: Vec<(ConfigLayerSource, &str)>) -> bool {
48
+ validate_effective_ema(&stack(layers)).is_ok()
49
+ }
50
+
51
+ #[test]
52
+ fn ema_profiles_and_auth_modes_preserve_non_project_authority() {
53
+ let (system, user, project) = local_sources("ema-config");
54
+ let trusted = "[mcp_enterprise_managed_auth.idp]\nissuer = 'https://idp.example'\nclient_id = 'enterprise'";
55
+ let replacement =
56
+ "[mcp_enterprise_managed_auth.idp]\nissuer = 'https://other.example'\nclient_id = 'other'";
57
+ let expected = McpEnterpriseManagedAuthConfig {
58
+ idp: McpServerIdpOAuthConfig {
59
+ issuer: "https://idp.example".into(),
60
+ client_id: "enterprise".into(),
61
+ },
62
+ };
63
+ for (layers, selected) in [
64
+ (
65
+ vec![
66
+ (system.clone(), trusted),
67
+ (user.clone(), replacement),
68
+ (project.clone(), replacement),
69
+ ],
70
+ Some(expected.clone()),
71
+ ),
72
+ (
73
+ vec![(user.clone(), trusted), (project.clone(), replacement)],
74
+ Some(expected.clone()),
75
+ ),
76
+ (vec![(project, trusted)], None),
77
+ (
78
+ vec![
79
+ (system, trusted),
80
+ (
81
+ user,
82
+ "[mcp_enterprise_managed_auth.idp]\nclient_id = 'partial'",
83
+ ),
84
+ ],
85
+ Some(expected),
86
+ ),
87
+ ] {
88
+ assert_eq!(
89
+ McpEnterpriseManagedAuthConfig::from_config_layers(
90
+ &stack(layers),
91
+ /*fallback*/ None
92
+ )
93
+ .unwrap(),
94
+ selected
95
+ );
96
+ }
97
+ let incomplete = stack(vec![(
98
+ ConfigLayerSource::SessionFlags,
99
+ "[mcp_enterprise_managed_auth.idp]\nclient_id = 'partial'",
100
+ )]);
101
+ assert!(
102
+ McpEnterpriseManagedAuthConfig::from_config_layers(&incomplete, /*fallback*/ None).is_err()
103
+ );
104
+
105
+ let server: McpServerConfig = toml::from_str("url='https://resource.example'\nauth='ema_auth'\n[oauth.idp]\nissuer='https://other.example'\nclient_id='other'").unwrap();
106
+ assert_eq!(server.oauth_idp(), None);
107
+ }
108
+
109
+ #[test]
110
+ fn ema_registrations_require_one_atomic_non_project_source() {
111
+ let (system, user, project) = local_sources("ema-registration-sources");
112
+ for (section, authorization, protected_changes) in [
113
+ (
114
+ "mcp_servers.enterprise",
115
+ r#"
116
+ auth='ema_auth'
117
+ oauth_resource='https://resource.example'
118
+ oauth.client_id='resource-client'
119
+ oauth.authorization_server_issuer='https://as.example'
120
+ "#,
121
+ &[
122
+ "oauth_resource='https://other.example'",
123
+ "oauth.client_id='other-client'",
124
+ "oauth.authorization_server_issuer='https://other-as.example'",
125
+ ] as &[&str],
126
+ ),
127
+ (
128
+ "plugins.\"sample@test\".mcp_servers.enterprise.ema_auth",
129
+ r#"
130
+ resource='https://resource.example'
131
+ client_id='resource-client'
132
+ authorization_server_issuer='https://as.example'
133
+ "#,
134
+ &[
135
+ "resource='https://other.example'",
136
+ "client_id='other-client'",
137
+ "authorization_server_issuer='https://other-as.example'",
138
+ ],
139
+ ),
140
+ ] {
141
+ let registration = format!(
142
+ "[{section}]\nurl='https://resource.example/mcp'\nscopes=['tools']\n{authorization}"
143
+ );
144
+ for (source, allowed) in [
145
+ (system.clone(), true),
146
+ (user.clone(), true),
147
+ (project.clone(), false),
148
+ ] {
149
+ assert_eq!(
150
+ valid_ema_layers(vec![(source, registration.as_str())]),
151
+ allowed,
152
+ "{section}"
153
+ );
154
+ }
155
+ for change in protected_changes.iter().copied().chain([
156
+ "url='https://other.example/mcp'",
157
+ "url='https://resource.example/mcp/other'",
158
+ "scopes=['admin']",
159
+ ]) {
160
+ let overlay = format!("[{section}]\n{change}");
161
+ assert!(
162
+ !valid_ema_layers(vec![
163
+ (system.clone(), &registration),
164
+ (project.clone(), &overlay)
165
+ ]),
166
+ "{section}: {change}"
167
+ );
168
+ }
169
+ let higher = registration.replace("tools", "managed-tools");
170
+ assert!(
171
+ !valid_ema_layers(vec![
172
+ (system.clone(), &registration),
173
+ (user.clone(), &higher),
174
+ (project.clone(), &registration)
175
+ ]),
176
+ "project rollback: {section}"
177
+ );
178
+ let policy_section = section.strip_suffix(".ema_auth").unwrap_or(section);
179
+ let policy = format!(
180
+ "[{policy_section}]\nenabled=false\n[{policy_section}.tools.read]\napproval_mode='prompt'"
181
+ );
182
+ assert!(valid_ema_layers(vec![
183
+ (system.clone(), &registration),
184
+ (project.clone(), &policy)
185
+ ]));
186
+ if section.ends_with(".ema_auth") {
187
+ for required in [
188
+ "url='https://resource.example/mcp'\n",
189
+ "resource='https://resource.example'\n",
190
+ ] {
191
+ assert!(
192
+ !valid_ema_layers(vec![(system.clone(), &registration.replace(required, ""))]),
193
+ "missing {required}"
194
+ );
195
+ }
196
+ } else {
197
+ assert!(valid_ema_layers(vec![
198
+ (system.clone(), &registration),
199
+ (project.clone(), "[mcp_servers.enterprise]\nauth='ema_auth'")
200
+ ]));
201
+ }
202
+ }
203
+ }
204
+
205
+ #[test]
206
+ fn project_cannot_reenable_trusted_disabled_ema_registration() {
207
+ let (system, user, project) = local_sources("ema-disabled-source");
208
+ let trusted = "[mcp_servers.enterprise]\nurl='https://resource.example/mcp'\nauth='ema_auth'\nenabled=false";
209
+ let project_enable = "[mcp_servers.enterprise]\nenabled=true";
210
+ assert!(!valid_ema_layers(vec![
211
+ (system.clone(), trusted),
212
+ (project.clone(), project_enable)
213
+ ]));
214
+ assert!(valid_ema_layers(vec![
215
+ (system.clone(), trusted),
216
+ (user, project_enable)
217
+ ]));
218
+ let enabled = trusted.replace("enabled=false", "enabled=true");
219
+ assert!(valid_ema_layers(vec![
220
+ (system, &enabled),
221
+ (project, "[mcp_servers.enterprise]\nenabled=false")
222
+ ]));
223
+ }
224
+
225
+ #[test]
226
+ fn project_cannot_reenable_trusted_disabled_ema_plugin() {
227
+ let (system, _, project) = local_sources("ema-disabled-plugin");
228
+ let trusted = r#"
229
+ [plugins."sample@test".mcp_servers.enterprise]
230
+ enabled=false
231
+ [plugins."sample@test".mcp_servers.enterprise.ema_auth]
232
+ url='https://resource.example/mcp'
233
+ client_id='resource-client'
234
+ authorization_server_issuer='https://as.example'
235
+ resource='https://resource.example'
236
+ "#;
237
+ let project_enable = "[plugins.\"sample@test\".mcp_servers.enterprise]\nenabled=true";
238
+ assert!(!valid_ema_layers(vec![
239
+ (system.clone(), trusted),
240
+ (project.clone(), project_enable)
241
+ ]));
242
+ assert!(valid_ema_layers(vec![
243
+ (system, trusted),
244
+ (
245
+ project,
246
+ "[plugins.\"sample@test\".mcp_servers.enterprise]\nenabled=false"
247
+ )
248
+ ]));
249
+ }
250
+
251
+ #[test]
252
+ fn non_project_auth_changes_and_ordinary_oauth_remain_allowed() {
253
+ let (system, user, project) = local_sources("ema-auth-downgrade");
254
+ for (base_auth, source) in [("ema_auth", user), ("oauth", project)] {
255
+ let base = format!(
256
+ "[mcp_servers.enterprise]\nurl='https://resource.example/mcp'\nauth='{base_auth}'"
257
+ );
258
+ assert!(
259
+ validate_effective_ema(&stack(vec![
260
+ (system.clone(), &base),
261
+ (
262
+ source,
263
+ "[mcp_servers.enterprise]\nauth='oauth'\nenabled=false"
264
+ ),
265
+ ]))
266
+ .is_ok()
267
+ );
268
+ }
269
+ }
270
+
271
+ #[test]
272
+ fn xaa_opt_in_requires_a_non_project_source() {
273
+ let (_, user, project) = local_sources("xaa-sources");
274
+ let session = ConfigLayerSource::SessionFlags;
275
+ let enabled = "[features]\nuse_xaa=true";
276
+
277
+ for (source, allowed) in [(user, true), (session, true), (project.clone(), false)] {
278
+ assert_eq!(
279
+ validate_xaa_opt_in_source(&stack(vec![(source, enabled)]), /*xaa_enabled*/ true,)
280
+ .is_ok(),
281
+ allowed
282
+ );
283
+ }
284
+ assert!(
285
+ validate_xaa_opt_in_source(&stack(vec![(project, enabled)]), /*xaa_enabled*/ false,)
286
+ .is_ok()
287
+ );
288
+ }
289
+
290
+ #[test]
291
+ fn ema_rejects_alternate_credentials_and_executor_custody() {
292
+ for extra in [
293
+ "bearer_token_env_var='TOKEN'",
294
+ "http_headers.Authorization='secret'",
295
+ "http_headers.Accept='application/json'",
296
+ "env_http_headers.X-Key='TOKEN'",
297
+ "http_headers_helper='get-headers'",
298
+ "environment_id='remote'",
299
+ ] {
300
+ let server: McpServerConfig = toml::from_str(&format!(
301
+ "url='https://resource.example'\nauth='ema_auth'\n{extra}"
302
+ ))
303
+ .unwrap();
304
+ assert!(server.validate_ema_auth_transport().is_err(), "{extra}");
305
+ }
306
+ let server: McpServerConfig =
307
+ toml::from_str("url='https://resource.example'\nauth='ema_auth'\nhttp_headers={}").unwrap();
308
+ assert_eq!(server.validate_ema_auth_transport(), Ok(()));
309
+ }
codex-rs/config/src/mcp_requirements_tests.rs ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use super::*;
2
+ use crate::McpServerCommandMatcher;
3
+ use crate::mcp_types::McpServerConfig;
4
+ use pretty_assertions::assert_eq;
5
+ use std::collections::HashMap;
6
+
7
+ fn stdio_server(command: &str, args: &[&str]) -> McpServerConfig {
8
+ McpServerConfig {
9
+ auth: Default::default(),
10
+ transport: McpServerTransportConfig::Stdio {
11
+ command: command.to_string(),
12
+ args: args.iter().map(ToString::to_string).collect(),
13
+ env: None,
14
+ env_vars: Vec::new(),
15
+ cwd: None,
16
+ },
17
+ environment_id: crate::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
18
+ enabled: true,
19
+ required: false,
20
+ supports_parallel_tool_calls: false,
21
+ omit_tools_from: None,
22
+ disabled_reason: None,
23
+ startup_timeout_sec: None,
24
+ tool_timeout_sec: None,
25
+ default_tools_approval_mode: None,
26
+ enabled_tools: None,
27
+ disabled_tools: None,
28
+ scopes: None,
29
+ oauth: None,
30
+ oauth_resource: None,
31
+ tools: HashMap::new(),
32
+ }
33
+ }
34
+
35
+ #[test]
36
+ fn command_matcher_matches_exact_positional_arguments() {
37
+ let requirement = McpServerRequirement::Command(McpServerCommandMatcher {
38
+ executable: "company-cli".to_string(),
39
+ args: vec![
40
+ McpServerValueMatcher::Exact {
41
+ value: "mcp".to_string(),
42
+ },
43
+ McpServerValueMatcher::Regex {
44
+ expression: r"https://[a-z]+\.example\.com".to_string(),
45
+ },
46
+ ],
47
+ });
48
+
49
+ assert!(
50
+ stdio_server("company-cli", &["mcp", "https://pricing.example.com"])
51
+ .matches_requirement(&requirement)
52
+ );
53
+ assert!(
54
+ !stdio_server("company-cli", &["https://pricing.example.com", "mcp"])
55
+ .matches_requirement(&requirement)
56
+ );
57
+ assert!(
58
+ !stdio_server(
59
+ "company-cli",
60
+ &["mcp", "https://pricing.example.com", "--verbose"]
61
+ )
62
+ .matches_requirement(&requirement)
63
+ );
64
+ assert!(
65
+ !stdio_server(
66
+ "/usr/local/bin/company-cli",
67
+ &["mcp", "https://pricing.example.com"]
68
+ )
69
+ .matches_requirement(&requirement)
70
+ );
71
+ }
72
+
73
+ #[test]
74
+ fn regex_matcher_requires_a_full_value_match() {
75
+ let matcher = McpServerValueMatcher::Regex {
76
+ expression: "mcp".to_string(),
77
+ };
78
+
79
+ assert!(matches_value(&matcher, "mcp"));
80
+ assert!(!matches_value(&matcher, "mcp-proxy"));
81
+ assert!(!matches_value(&matcher, "prefix-mcp"));
82
+ }
83
+
84
+ #[test]
85
+ fn regex_matcher_allows_a_later_alternative_to_match_the_full_value() {
86
+ let matcher = McpServerValueMatcher::Regex {
87
+ expression: r"https://api\.example\.com|https://api\.example\.com/mcp".to_string(),
88
+ };
89
+
90
+ assert!(matches_value(&matcher, "https://api.example.com/mcp"));
91
+ }
92
+
93
+ #[test]
94
+ fn regex_matcher_validation_rejects_expression_that_cannot_be_wrapped() {
95
+ let matcher = McpServerValueMatcher::Regex {
96
+ expression: "(?x)mcp # trailing comment".to_string(),
97
+ };
98
+
99
+ let err = validate_value_matcher(&matcher)
100
+ .expect_err("expression should not be valid for full-value matching");
101
+ assert!(
102
+ err.contains("cannot be used for full-value matching"),
103
+ "{err}"
104
+ );
105
+ }
106
+
107
+ #[test]
108
+ fn legacy_command_identity_keeps_ignoring_arguments() {
109
+ let requirement: McpServerRequirement = toml::from_str(
110
+ r#"
111
+ [identity]
112
+ command = "company-cli"
113
+ "#,
114
+ )
115
+ .expect("legacy command identity");
116
+
117
+ assert!(
118
+ stdio_server("company-cli", &["any", "arguments", "remain", "allowed"])
119
+ .matches_requirement(&requirement)
120
+ );
121
+ assert!(!stdio_server("different-cli", &[]).matches_requirement(&requirement));
122
+ }
123
+
124
+ #[test]
125
+ fn requirement_deserializes_command_and_url_matcher_shapes() {
126
+ let command: McpServerRequirement = toml::from_str(
127
+ r#"
128
+ [identity]
129
+ command = { executable = "company-cli", args = [
130
+ { match = "exact", value = "mcp" },
131
+ { match = "regex", expression = '^https://[a-z]+\.example\.com$' },
132
+ ] }
133
+ "#,
134
+ )
135
+ .expect("command matcher");
136
+ let url: McpServerRequirement = toml::from_str(
137
+ r#"
138
+ [identity]
139
+ url = { match = "prefix", value = "https://mcp.example.com/" }
140
+ "#,
141
+ )
142
+ .expect("URL matcher");
143
+
144
+ assert_eq!(
145
+ command,
146
+ McpServerRequirement::Command(McpServerCommandMatcher {
147
+ executable: "company-cli".to_string(),
148
+ args: vec![
149
+ McpServerValueMatcher::Exact {
150
+ value: "mcp".to_string(),
151
+ },
152
+ McpServerValueMatcher::Regex {
153
+ expression: r"^https://[a-z]+\.example\.com$".to_string(),
154
+ },
155
+ ],
156
+ })
157
+ );
158
+ assert_eq!(
159
+ url,
160
+ McpServerRequirement::Url(McpServerValueMatcher::Prefix {
161
+ value: "https://mcp.example.com/".to_string(),
162
+ })
163
+ );
164
+ }
165
+
166
+ #[test]
167
+ fn requirement_rejects_matchers_outside_identity() {
168
+ for contents in [
169
+ r#"
170
+ command = "company-cli"
171
+ "#,
172
+ r#"
173
+ command = { executable = "company-cli", args = [] }
174
+ "#,
175
+ r#"
176
+ url = { match = "prefix", value = "https://mcp.example.com/" }
177
+ "#,
178
+ ] {
179
+ let err = toml::from_str::<McpServerRequirement>(contents)
180
+ .expect_err("MCP server requirements should use the identity key");
181
+ assert!(
182
+ err.to_string().contains("missing field `identity`"),
183
+ "{err}"
184
+ );
185
+ }
186
+ }
187
+
188
+ #[test]
189
+ fn matcher_identity_rejects_unknown_fields() {
190
+ for contents in [
191
+ r#"
192
+ [identity]
193
+ unknown = "value"
194
+ command = { executable = "company-cli", args = [] }
195
+ "#,
196
+ r#"
197
+ [identity]
198
+ command = { executable = "company-cli", args = [], unknown = "value" }
199
+ "#,
200
+ ] {
201
+ toml::from_str::<McpServerRequirement>(contents)
202
+ .expect_err("matcher identities should reject unknown fields");
203
+ }
204
+ }
205
+
206
+ #[test]
207
+ fn identity_requirement_keeps_ignoring_unrelated_sibling_fields() {
208
+ let requirement: McpServerRequirement = toml::from_str(
209
+ r#"
210
+ unrelated = "ignored"
211
+ [identity]
212
+ command = "company-cli"
213
+ "#,
214
+ )
215
+ .expect("legacy identity with unrelated sibling field");
216
+
217
+ assert_eq!(
218
+ requirement,
219
+ McpServerRequirement::Identity {
220
+ identity: McpServerIdentity::Command {
221
+ command: "company-cli".to_string(),
222
+ },
223
+ }
224
+ );
225
+ }
codex-rs/config/src/mcp_types.rs ADDED
@@ -0,0 +1,626 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! MCP server configuration types.
2
+
3
+ use std::borrow::Cow;
4
+ use std::collections::HashMap;
5
+ use std::fmt;
6
+ use std::num::NonZeroUsize;
7
+ use std::time::Duration;
8
+
9
+ use base64::Engine;
10
+ use base64::engine::general_purpose::URL_SAFE_NO_PAD;
11
+ use codex_protocol::config_types::ToolExposureSurface;
12
+ use codex_utils_path_uri::LegacyAppPathString;
13
+ use schemars::JsonSchema;
14
+ use serde::Deserialize;
15
+ use serde::Deserializer;
16
+ use serde::Serialize;
17
+ use serde::de::Error as SerdeError;
18
+
19
+ use crate::McpEmaRegistration;
20
+ use crate::RequirementSource;
21
+
22
+ /// Effective MCP environment id when config omits `environment_id`.
23
+ pub const DEFAULT_MCP_SERVER_ENVIRONMENT_ID: &str = "local";
24
+
25
+ #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema)]
26
+ #[serde(rename_all = "snake_case")]
27
+ pub enum AppToolApproval {
28
+ #[default]
29
+ Auto,
30
+ Prompt,
31
+ Writes,
32
+ Approve,
33
+ }
34
+
35
+ impl AppToolApproval {
36
+ /// Requires approval whenever either policy could require it.
37
+ ///
38
+ /// `Auto` and `Writes` are incomparable: each can require approval for a
39
+ /// tool the other would approve. Their conservative intersection is `Prompt`.
40
+ pub fn restrict_to(self, requested: Self) -> Self {
41
+ match (self, requested) {
42
+ (Self::Prompt, _) | (_, Self::Prompt) => Self::Prompt,
43
+ (Self::Approve, mode) | (mode, Self::Approve) => mode,
44
+ (Self::Auto, Self::Auto) => Self::Auto,
45
+ (Self::Writes, Self::Writes) => Self::Writes,
46
+ (Self::Auto, Self::Writes) | (Self::Writes, Self::Auto) => Self::Prompt,
47
+ }
48
+ }
49
+ }
50
+
51
+ /// Human-readable reason a configured MCP server was disabled after requirements
52
+ /// were applied.
53
+ ///
54
+ /// `Display` is intentionally implemented for CLI/TUI status output; avoid
55
+ /// relying on `Debug` because enum variant syntax is not part of the user-facing
56
+ /// message contract.
57
+ #[derive(Debug, Clone, PartialEq, Eq)]
58
+ pub enum McpServerDisabledReason {
59
+ /// The server is disabled, but there is no more specific user-facing reason.
60
+ Unknown,
61
+ /// The server was disabled by config requirements from the given source.
62
+ Requirements { source: RequirementSource },
63
+ /// Enterprise authorization was rejected for this registration, not its name.
64
+ EmaRegistration,
65
+ }
66
+
67
+ impl fmt::Display for McpServerDisabledReason {
68
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69
+ match self {
70
+ McpServerDisabledReason::Unknown => write!(f, "unknown"),
71
+ McpServerDisabledReason::Requirements { source } => {
72
+ write!(f, "requirements ({source})")
73
+ }
74
+ McpServerDisabledReason::EmaRegistration => {
75
+ write!(f, "invalid enterprise registration")
76
+ }
77
+ }
78
+ }
79
+ }
80
+
81
+ /// Per-tool settings for a single MCP server tool.
82
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
83
+ #[schemars(deny_unknown_fields)]
84
+ pub struct McpServerToolConfig {
85
+ /// Approval mode for this tool.
86
+ #[serde(default, skip_serializing_if = "Option::is_none")]
87
+ pub approval_mode: Option<AppToolApproval>,
88
+
89
+ /// Token budget for this tool's output, before the standard 20% serialization allowance.
90
+ #[serde(default, skip_serializing_if = "Option::is_none")]
91
+ pub output_token_limit: Option<NonZeroUsize>,
92
+ }
93
+
94
+ impl McpServerToolConfig {
95
+ /// Applies the stricter explicit output budget without changing approval policy.
96
+ pub fn restrict_output_token_limit(&mut self, limit: Option<NonZeroUsize>) {
97
+ self.output_token_limit = self.output_token_limit.into_iter().chain(limit).min();
98
+ }
99
+ }
100
+
101
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
102
+ #[serde(untagged, deny_unknown_fields)]
103
+ pub enum McpServerEnvVar {
104
+ Name(String),
105
+ Config {
106
+ name: String,
107
+ #[serde(default, skip_serializing_if = "Option::is_none")]
108
+ source: Option<String>,
109
+ },
110
+ }
111
+
112
+ impl McpServerEnvVar {
113
+ pub fn name(&self) -> &str {
114
+ match self {
115
+ McpServerEnvVar::Name(name) => name,
116
+ McpServerEnvVar::Config { name, .. } => name,
117
+ }
118
+ }
119
+
120
+ pub fn source(&self) -> Option<&str> {
121
+ match self {
122
+ McpServerEnvVar::Name(_) => None,
123
+ McpServerEnvVar::Config { source, .. } => source.as_deref(),
124
+ }
125
+ }
126
+
127
+ pub fn is_remote_source(&self) -> bool {
128
+ self.source() == Some("remote")
129
+ }
130
+
131
+ pub fn validate_source(&self) -> Result<(), String> {
132
+ match self.source() {
133
+ None | Some("local") | Some("remote") => Ok(()),
134
+ Some(source) => Err(format!(
135
+ "unsupported env_vars source `{source}`; expected `local` or `remote`"
136
+ )),
137
+ }
138
+ }
139
+ }
140
+
141
+ impl From<String> for McpServerEnvVar {
142
+ fn from(value: String) -> Self {
143
+ Self::Name(value)
144
+ }
145
+ }
146
+
147
+ impl From<&str> for McpServerEnvVar {
148
+ fn from(value: &str) -> Self {
149
+ Self::Name(value.to_string())
150
+ }
151
+ }
152
+
153
+ impl AsRef<str> for McpServerEnvVar {
154
+ fn as_ref(&self) -> &str {
155
+ self.name()
156
+ }
157
+ }
158
+
159
+ /// Client settings for MCP OAuth login or enterprise token exchange.
160
+ #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
161
+ #[schemars(deny_unknown_fields)]
162
+ pub struct McpServerOAuthConfig {
163
+ /// Explicit OAuth client identifier to present during authorization and token exchange.
164
+ #[serde(default, skip_serializing_if = "Option::is_none")]
165
+ pub client_id: Option<String>,
166
+
167
+ /// Registered callback URL associated with this OAuth client.
168
+ #[serde(default, skip_serializing_if = "Option::is_none")]
169
+ pub callback_url: Option<String>,
170
+
171
+ /// Fixed callback port that takes precedence over Codex's global OAuth callback port.
172
+ #[serde(default, skip_serializing_if = "Option::is_none")]
173
+ pub callback_port: Option<u16>,
174
+
175
+ /// Expected resource authorization server issuer for EMA token exchange.
176
+ #[serde(default, skip_serializing_if = "Option::is_none")]
177
+ pub authorization_server_issuer: Option<String>,
178
+
179
+ /// Host-resolved authorization; never accepted from a server or plugin declaration.
180
+ #[serde(skip)]
181
+ #[schemars(skip)]
182
+ pub ema_registration: Option<McpEmaRegistration>,
183
+
184
+ /// Host-policy rejection retained until catalog finalization; never deserialized.
185
+ #[serde(skip)]
186
+ #[schemars(skip)]
187
+ pub ema_registration_error: Option<&'static str>,
188
+ }
189
+
190
+ /// Authentication flow for an HTTP MCP server. Explicit credentials take
191
+ /// precedence for OAuth and ChatGPT; EMA rejects alternate credentials and fallback.
192
+ #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
193
+ #[serde(rename_all = "snake_case")]
194
+ pub enum McpServerAuth {
195
+ /// Use stored MCP OAuth credentials when available. Starting an OAuth login
196
+ /// is a separate operation.
197
+ #[default]
198
+ #[serde(rename = "oauth")]
199
+ OAuth,
200
+ /// Use the current ChatGPT session for servers on the trusted first-party
201
+ /// ChatGPT origin. If no ChatGPT session provider is available, startup can
202
+ /// still fall back to stored OAuth credentials.
203
+ #[serde(rename = "chatgpt")]
204
+ ChatGpt,
205
+ /// Exchange an enterprise IdP refresh token for resource-specific authorization.
206
+ /// Alternate credentials and ordinary OAuth fallback are not permitted.
207
+ #[serde(rename = "ema_auth")]
208
+ EmaAuth,
209
+ }
210
+
211
+ impl McpServerAuth {
212
+ fn is_default(&self) -> bool {
213
+ self == &Self::default()
214
+ }
215
+ }
216
+
217
+ #[derive(Serialize, Debug, Clone, PartialEq)]
218
+ pub struct McpServerConfig {
219
+ #[serde(flatten)]
220
+ pub transport: McpServerTransportConfig,
221
+
222
+ /// Authentication flow, including an explicit no-fallback EMA mode.
223
+ #[serde(default, skip_serializing_if = "McpServerAuth::is_default")]
224
+ pub auth: McpServerAuth,
225
+
226
+ /// Effective environment id for where Codex should start this MCP server.
227
+ pub environment_id: String,
228
+
229
+ /// When `false`, Codex skips initializing this MCP server.
230
+ #[serde(default = "default_enabled")]
231
+ pub enabled: bool,
232
+
233
+ /// When `true`, `codex exec` exits with an error if this MCP server fails to initialize.
234
+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
235
+ pub required: bool,
236
+
237
+ /// When `true`, every tool from this server is advertised as safe for parallel tool calls.
238
+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
239
+ pub supports_parallel_tool_calls: bool,
240
+
241
+ /// Model-facing surfaces from which this server's tools must be omitted.
242
+ /// `None` leaves lower-priority configuration unchanged; an empty list clears it.
243
+ #[serde(default, skip_serializing_if = "Option::is_none")]
244
+ pub omit_tools_from: Option<Vec<ToolExposureSurface>>,
245
+
246
+ /// Reason this server was disabled after applying requirements.
247
+ #[serde(skip)]
248
+ pub disabled_reason: Option<McpServerDisabledReason>,
249
+
250
+ /// Startup timeout in seconds for initializing MCP server & initially listing tools.
251
+ #[serde(
252
+ default,
253
+ with = "option_duration_secs",
254
+ skip_serializing_if = "Option::is_none"
255
+ )]
256
+ pub startup_timeout_sec: Option<Duration>,
257
+
258
+ /// Default timeout for MCP tool calls initiated via this server.
259
+ #[serde(default, with = "option_duration_secs")]
260
+ pub tool_timeout_sec: Option<Duration>,
261
+
262
+ /// Approval mode for tools in this server unless a tool override exists.
263
+ #[serde(default, skip_serializing_if = "Option::is_none")]
264
+ pub default_tools_approval_mode: Option<AppToolApproval>,
265
+
266
+ /// Explicit allow-list of tools exposed from this server. When set, only these tools will be registered.
267
+ #[serde(default, skip_serializing_if = "Option::is_none")]
268
+ pub enabled_tools: Option<Vec<String>>,
269
+
270
+ /// Explicit deny-list of tools. These tools will be removed after applying `enabled_tools`.
271
+ #[serde(default, skip_serializing_if = "Option::is_none")]
272
+ pub disabled_tools: Option<Vec<String>>,
273
+
274
+ /// Optional scopes requested during MCP login or EMA token exchange.
275
+ #[serde(default, skip_serializing_if = "Option::is_none")]
276
+ pub scopes: Option<Vec<String>>,
277
+
278
+ /// Optional client settings for MCP login or EMA token exchange.
279
+ #[serde(default, skip_serializing_if = "Option::is_none")]
280
+ pub oauth: Option<McpServerOAuthConfig>,
281
+
282
+ /// Optional resource parameter for MCP login or EMA token exchange (RFC 8707).
283
+ #[serde(default, skip_serializing_if = "Option::is_none")]
284
+ pub oauth_resource: Option<String>,
285
+
286
+ /// Per-tool settings keyed by tool name.
287
+ #[serde(default, skip_serializing_if = "HashMap::is_empty")]
288
+ pub tools: HashMap<String, McpServerToolConfig>,
289
+ }
290
+
291
+ impl McpServerConfig {
292
+ pub fn is_local_environment(&self) -> bool {
293
+ self.environment_id == DEFAULT_MCP_SERVER_ENVIRONMENT_ID
294
+ }
295
+
296
+ /// Keeps local OAuth credentials compatible while reserving managed credential namespaces.
297
+ pub fn oauth_credential_name<'a>(&self, server_name: &'a str) -> Cow<'a, str> {
298
+ if self.is_local_environment() {
299
+ if server_name.starts_with("executor:")
300
+ || server_name.starts_with("local:")
301
+ || server_name.starts_with("ema-idp:")
302
+ {
303
+ Cow::Owned(format!("local:{server_name}"))
304
+ } else {
305
+ Cow::Borrowed(server_name)
306
+ }
307
+ } else {
308
+ let environment = URL_SAFE_NO_PAD.encode(self.environment_id.as_bytes());
309
+ let server = URL_SAFE_NO_PAD.encode(server_name.as_bytes());
310
+ Cow::Owned(format!("executor:{environment}:{server}"))
311
+ }
312
+ }
313
+
314
+ pub fn oauth_client_id(&self) -> Option<&str> {
315
+ self.oauth
316
+ .as_ref()
317
+ .and_then(|oauth| oauth.client_id.as_deref())
318
+ }
319
+
320
+ pub fn oauth_callback_port(&self, global_callback_port: Option<u16>) -> Option<u16> {
321
+ let callback_port = self.oauth.as_ref().and_then(|oauth| oauth.callback_port);
322
+ if let Some(callback_port) = callback_port {
323
+ tracing::info!(
324
+ callback_port,
325
+ ?global_callback_port,
326
+ "using plugin-specific MCP OAuth callback port instead of the global callback port"
327
+ );
328
+ }
329
+ callback_port.or(global_callback_port)
330
+ }
331
+ }
332
+
333
+ /// Raw MCP config shape used for deserialization and supported-field JSON
334
+ /// Schema generation.
335
+ ///
336
+ /// Fields that are accepted only to produce targeted validation errors should
337
+ /// be skipped in the generated schema.
338
+ ///
339
+ /// Keep `TryFrom<RawMcpServerConfig> for McpServerConfig` exhaustively
340
+ /// destructuring this struct so new TOML fields cannot be added here without
341
+ /// updating the validation/mapping logic that produces [`McpServerConfig`].
342
+ #[derive(Deserialize, Clone, JsonSchema)]
343
+ #[schemars(deny_unknown_fields)]
344
+ pub struct RawMcpServerConfig {
345
+ // stdio
346
+ pub command: Option<String>,
347
+ #[serde(default)]
348
+ pub args: Option<Vec<String>>,
349
+ #[serde(default)]
350
+ pub env: Option<HashMap<String, String>>,
351
+ #[serde(default)]
352
+ pub env_vars: Option<Vec<McpServerEnvVar>>,
353
+ #[serde(default)]
354
+ pub cwd: Option<LegacyAppPathString>,
355
+ pub http_headers: Option<HashMap<String, String>>,
356
+ #[serde(default)]
357
+ pub env_http_headers: Option<HashMap<String, String>>,
358
+
359
+ // streamable_http
360
+ pub url: Option<String>,
361
+ #[schemars(skip)]
362
+ pub bearer_token: Option<String>,
363
+ pub bearer_token_env_var: Option<String>,
364
+ pub http_headers_helper: Option<String>,
365
+
366
+ // shared
367
+ #[serde(default)]
368
+ pub environment_id: Option<String>,
369
+ #[serde(default)]
370
+ pub auth: Option<McpServerAuth>,
371
+ #[serde(default)]
372
+ pub startup_timeout_sec: Option<f64>,
373
+ #[serde(default)]
374
+ pub startup_timeout_ms: Option<u64>,
375
+ #[serde(default, with = "option_duration_secs")]
376
+ #[schemars(with = "Option<f64>")]
377
+ pub tool_timeout_sec: Option<Duration>,
378
+ #[serde(default)]
379
+ pub enabled: Option<bool>,
380
+ #[serde(default)]
381
+ pub required: Option<bool>,
382
+ #[serde(default)]
383
+ pub supports_parallel_tool_calls: Option<bool>,
384
+ #[serde(default)]
385
+ pub omit_tools_from: Option<Vec<ToolExposureSurface>>,
386
+ #[serde(default)]
387
+ pub default_tools_approval_mode: Option<AppToolApproval>,
388
+ #[serde(default)]
389
+ pub enabled_tools: Option<Vec<String>>,
390
+ #[serde(default)]
391
+ pub disabled_tools: Option<Vec<String>>,
392
+ #[serde(default)]
393
+ pub scopes: Option<Vec<String>>,
394
+ #[serde(default)]
395
+ pub oauth: Option<McpServerOAuthConfig>,
396
+ #[serde(default)]
397
+ pub oauth_resource: Option<String>,
398
+ /// Legacy display-name field accepted for backward compatibility.
399
+ #[serde(default, rename = "name")]
400
+ pub _name: Option<String>,
401
+ #[serde(default)]
402
+ pub tools: Option<HashMap<String, McpServerToolConfig>>,
403
+ }
404
+
405
+ impl TryFrom<RawMcpServerConfig> for McpServerConfig {
406
+ type Error = String;
407
+
408
+ fn try_from(raw: RawMcpServerConfig) -> Result<Self, Self::Error> {
409
+ let RawMcpServerConfig {
410
+ command,
411
+ args,
412
+ env,
413
+ env_vars,
414
+ cwd,
415
+ http_headers,
416
+ env_http_headers,
417
+ url,
418
+ bearer_token,
419
+ bearer_token_env_var,
420
+ http_headers_helper,
421
+ environment_id,
422
+ auth,
423
+ startup_timeout_sec,
424
+ startup_timeout_ms,
425
+ tool_timeout_sec,
426
+ enabled,
427
+ required,
428
+ supports_parallel_tool_calls,
429
+ omit_tools_from,
430
+ default_tools_approval_mode,
431
+ enabled_tools,
432
+ disabled_tools,
433
+ scopes,
434
+ oauth,
435
+ oauth_resource,
436
+ _name: _,
437
+ tools,
438
+ } = raw;
439
+
440
+ let startup_timeout_sec = match (startup_timeout_sec, startup_timeout_ms) {
441
+ (Some(sec), _) => {
442
+ Some(Duration::try_from_secs_f64(sec).map_err(|err| err.to_string())?)
443
+ }
444
+ (None, Some(ms)) => Some(Duration::from_millis(ms)),
445
+ (None, None) => None,
446
+ };
447
+
448
+ fn throw_if_set<T>(transport: &str, field: &str, value: Option<&T>) -> Result<(), String> {
449
+ if value.is_none() {
450
+ return Ok(());
451
+ }
452
+ Err(format!("{field} is not supported for {transport}"))
453
+ }
454
+
455
+ let transport = if let Some(command) = command {
456
+ throw_if_set("stdio", "url", url.as_ref())?;
457
+ throw_if_set(
458
+ "stdio",
459
+ "bearer_token_env_var",
460
+ bearer_token_env_var.as_ref(),
461
+ )?;
462
+ throw_if_set("stdio", "bearer_token", bearer_token.as_ref())?;
463
+ throw_if_set("stdio", "http_headers_helper", http_headers_helper.as_ref())?;
464
+ throw_if_set("stdio", "http_headers", http_headers.as_ref())?;
465
+ throw_if_set("stdio", "env_http_headers", env_http_headers.as_ref())?;
466
+ throw_if_set("stdio", "oauth", oauth.as_ref())?;
467
+ throw_if_set("stdio", "oauth_resource", oauth_resource.as_ref())?;
468
+ throw_if_set("stdio", "auth", auth.as_ref())?;
469
+ let env_vars = env_vars.unwrap_or_default();
470
+ for env_var in &env_vars {
471
+ env_var.validate_source()?;
472
+ }
473
+ McpServerTransportConfig::Stdio {
474
+ command,
475
+ args: args.unwrap_or_default(),
476
+ env,
477
+ env_vars,
478
+ cwd,
479
+ }
480
+ } else if let Some(url) = url {
481
+ throw_if_set("streamable_http", "args", args.as_ref())?;
482
+ throw_if_set("streamable_http", "env", env.as_ref())?;
483
+ throw_if_set("streamable_http", "env_vars", env_vars.as_ref())?;
484
+ throw_if_set("streamable_http", "cwd", cwd.as_ref())?;
485
+ throw_if_set("streamable_http", "bearer_token", bearer_token.as_ref())?;
486
+ if http_headers_helper
487
+ .as_deref()
488
+ .is_some_and(|command| command.trim().is_empty())
489
+ {
490
+ return Err("http_headers_helper must not be empty".to_string());
491
+ }
492
+ if environment_id
493
+ .as_deref()
494
+ .is_some_and(|environment_id| environment_id != DEFAULT_MCP_SERVER_ENVIRONMENT_ID)
495
+ && http_headers_helper.is_some()
496
+ {
497
+ return Err(
498
+ "http_headers_helper is only supported for local MCP servers".to_string(),
499
+ );
500
+ }
501
+ McpServerTransportConfig::StreamableHttp {
502
+ url,
503
+ bearer_token_env_var,
504
+ http_headers,
505
+ env_http_headers,
506
+ http_headers_helper,
507
+ }
508
+ } else {
509
+ return Err("invalid transport".to_string());
510
+ };
511
+
512
+ let environment_id =
513
+ environment_id.unwrap_or_else(|| DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string());
514
+ let auth = auth.unwrap_or_default();
515
+ if !matches!(auth, McpServerAuth::EmaAuth)
516
+ && oauth
517
+ .as_ref()
518
+ .is_some_and(|oauth| oauth.authorization_server_issuer.is_some())
519
+ {
520
+ return Err(
521
+ "oauth.authorization_server_issuer requires auth = \"ema_auth\"".to_string(),
522
+ );
523
+ }
524
+
525
+ Ok(Self {
526
+ transport,
527
+ auth,
528
+ environment_id,
529
+ startup_timeout_sec,
530
+ tool_timeout_sec,
531
+ enabled: enabled.unwrap_or_else(default_enabled),
532
+ required: required.unwrap_or_default(),
533
+ supports_parallel_tool_calls: supports_parallel_tool_calls.unwrap_or_default(),
534
+ omit_tools_from,
535
+ disabled_reason: None,
536
+ default_tools_approval_mode,
537
+ enabled_tools,
538
+ disabled_tools,
539
+ scopes,
540
+ oauth,
541
+ oauth_resource,
542
+ tools: tools.unwrap_or_default(),
543
+ })
544
+ }
545
+ }
546
+
547
+ impl<'de> Deserialize<'de> for McpServerConfig {
548
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
549
+ where
550
+ D: Deserializer<'de>,
551
+ {
552
+ RawMcpServerConfig::deserialize(deserializer)?
553
+ .try_into()
554
+ .map_err(SerdeError::custom)
555
+ }
556
+ }
557
+
558
+ const fn default_enabled() -> bool {
559
+ true
560
+ }
561
+
562
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)]
563
+ #[serde(untagged, deny_unknown_fields, rename_all = "snake_case")]
564
+ pub enum McpServerTransportConfig {
565
+ /// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio
566
+ Stdio {
567
+ command: String,
568
+ #[serde(default)]
569
+ args: Vec<String>,
570
+ #[serde(default, skip_serializing_if = "Option::is_none")]
571
+ env: Option<HashMap<String, String>>,
572
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
573
+ env_vars: Vec<McpServerEnvVar>,
574
+ #[serde(default, skip_serializing_if = "Option::is_none")]
575
+ cwd: Option<LegacyAppPathString>,
576
+ },
577
+ /// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http
578
+ StreamableHttp {
579
+ url: String,
580
+ /// Name of the environment variable to read for an HTTP bearer token.
581
+ /// When set, requests will include the token via `Authorization: Bearer <token>`.
582
+ /// The actual secret value must be provided via the environment.
583
+ #[serde(default, skip_serializing_if = "Option::is_none")]
584
+ bearer_token_env_var: Option<String>,
585
+ /// Additional HTTP headers to include in requests to this server.
586
+ #[serde(default, skip_serializing_if = "Option::is_none")]
587
+ http_headers: Option<HashMap<String, String>>,
588
+ /// HTTP headers where the value is sourced from an environment variable.
589
+ #[serde(default, skip_serializing_if = "Option::is_none")]
590
+ env_http_headers: Option<HashMap<String, String>>,
591
+ /// Local-only shell command that prints a JSON object of dynamic HTTP headers.
592
+ /// The command may be visible to local process inspection; do not embed credentials.
593
+ #[serde(default, skip_serializing_if = "Option::is_none")]
594
+ http_headers_helper: Option<String>,
595
+ },
596
+ }
597
+
598
+ mod option_duration_secs {
599
+ use serde::Deserialize;
600
+ use serde::Deserializer;
601
+ use serde::Serializer;
602
+ use std::time::Duration;
603
+
604
+ pub fn serialize<S>(value: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error>
605
+ where
606
+ S: Serializer,
607
+ {
608
+ match value {
609
+ Some(duration) => serializer.serialize_some(&duration.as_secs_f64()),
610
+ None => serializer.serialize_none(),
611
+ }
612
+ }
613
+
614
+ pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
615
+ where
616
+ D: Deserializer<'de>,
617
+ {
618
+ let secs = Option::<f64>::deserialize(deserializer)?;
619
+ secs.map(|secs| Duration::try_from_secs_f64(secs).map_err(serde::de::Error::custom))
620
+ .transpose()
621
+ }
622
+ }
623
+
624
+ #[cfg(test)]
625
+ #[path = "mcp_types_tests.rs"]
626
+ mod tests;
codex-rs/config/src/mcp_types_tests.rs ADDED
@@ -0,0 +1,663 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use super::*;
2
+ use codex_utils_path_uri::LegacyAppPathString;
3
+ use pretty_assertions::assert_eq;
4
+ use std::collections::HashMap;
5
+ use std::path::Path;
6
+
7
+ #[test]
8
+ fn app_tool_approval_restrictions_never_weaken_either_policy() {
9
+ use AppToolApproval::Approve;
10
+ use AppToolApproval::Auto;
11
+ use AppToolApproval::Prompt;
12
+ use AppToolApproval::Writes;
13
+
14
+ let modes = [Approve, Auto, Writes, Prompt];
15
+ let expected = [
16
+ [Approve, Auto, Writes, Prompt],
17
+ [Auto, Auto, Prompt, Prompt],
18
+ [Writes, Prompt, Writes, Prompt],
19
+ [Prompt, Prompt, Prompt, Prompt],
20
+ ];
21
+
22
+ for (parent_index, parent) in modes.into_iter().enumerate() {
23
+ for (requested_index, requested) in modes.into_iter().enumerate() {
24
+ assert_eq!(
25
+ parent.restrict_to(requested),
26
+ expected[parent_index][requested_index],
27
+ "parent: {parent:?}, requested: {requested:?}",
28
+ );
29
+ }
30
+ }
31
+ }
32
+
33
+ #[test]
34
+ fn deserialize_stdio_command_server_config() {
35
+ let cfg: McpServerConfig = toml::from_str(
36
+ r#"
37
+ command = "echo"
38
+ "#,
39
+ )
40
+ .expect("should deserialize command config");
41
+
42
+ assert_eq!(
43
+ cfg.transport,
44
+ McpServerTransportConfig::Stdio {
45
+ command: "echo".to_string(),
46
+ args: vec![],
47
+ env: None,
48
+ env_vars: Vec::new(),
49
+ cwd: None,
50
+ }
51
+ );
52
+ assert!(cfg.enabled);
53
+ assert!(!cfg.required);
54
+ assert_eq!(cfg.omit_tools_from, None);
55
+ assert!(cfg.enabled_tools.is_none());
56
+ assert!(cfg.disabled_tools.is_none());
57
+ }
58
+
59
+ #[test]
60
+ fn deserialize_stdio_command_server_config_with_args() {
61
+ let cfg: McpServerConfig = toml::from_str(
62
+ r#"
63
+ command = "echo"
64
+ args = ["hello", "world"]
65
+ "#,
66
+ )
67
+ .expect("should deserialize command config");
68
+
69
+ assert_eq!(
70
+ cfg.transport,
71
+ McpServerTransportConfig::Stdio {
72
+ command: "echo".to_string(),
73
+ args: vec!["hello".to_string(), "world".to_string()],
74
+ env: None,
75
+ env_vars: Vec::new(),
76
+ cwd: None,
77
+ }
78
+ );
79
+ assert!(cfg.enabled);
80
+ }
81
+
82
+ #[test]
83
+ fn deserialize_remote_stdio_server_accepts_foreign_absolute_cwd() {
84
+ #[cfg(not(windows))]
85
+ let cwd = r"C:\Users\openai\share";
86
+ #[cfg(windows)]
87
+ let cwd = "/home/openai/share";
88
+ let expected_cwd = LegacyAppPathString::from_path(Path::new(cwd));
89
+ let cfg: McpServerConfig = match toml::from_str(&format!(
90
+ r#"
91
+ command = "echo"
92
+ environment_id = "remote"
93
+ cwd = {cwd:?}
94
+ "#
95
+ )) {
96
+ Ok(cfg) => cfg,
97
+ Err(error) => panic!("remote stdio MCP should accept absolute cwd: {error}"),
98
+ };
99
+
100
+ assert_eq!(
101
+ cfg.transport,
102
+ McpServerTransportConfig::Stdio {
103
+ command: "echo".to_string(),
104
+ args: vec![],
105
+ env: None,
106
+ env_vars: Vec::new(),
107
+ cwd: Some(expected_cwd),
108
+ }
109
+ );
110
+ }
111
+
112
+ #[test]
113
+ fn deserialize_stdio_command_server_config_with_arg_with_args_and_env() {
114
+ let cfg: McpServerConfig = toml::from_str(
115
+ r#"
116
+ command = "echo"
117
+ args = ["hello", "world"]
118
+ env = { "FOO" = "BAR" }
119
+ "#,
120
+ )
121
+ .expect("should deserialize command config");
122
+
123
+ assert_eq!(
124
+ cfg.transport,
125
+ McpServerTransportConfig::Stdio {
126
+ command: "echo".to_string(),
127
+ args: vec!["hello".to_string(), "world".to_string()],
128
+ env: Some(HashMap::from([("FOO".to_string(), "BAR".to_string())])),
129
+ env_vars: Vec::new(),
130
+ cwd: None,
131
+ }
132
+ );
133
+ assert!(cfg.enabled);
134
+ }
135
+
136
+ #[test]
137
+ fn deserialize_stdio_command_server_config_with_env_vars() {
138
+ let cfg: McpServerConfig = toml::from_str(
139
+ r#"
140
+ command = "echo"
141
+ env_vars = ["FOO", "BAR"]
142
+ "#,
143
+ )
144
+ .expect("should deserialize command config with env_vars");
145
+
146
+ assert_eq!(
147
+ cfg.transport,
148
+ McpServerTransportConfig::Stdio {
149
+ command: "echo".to_string(),
150
+ args: vec![],
151
+ env: None,
152
+ env_vars: vec!["FOO".into(), "BAR".into()],
153
+ cwd: None,
154
+ }
155
+ );
156
+ }
157
+
158
+ #[test]
159
+ fn deserialize_stdio_command_server_config_with_env_var_sources() {
160
+ let cfg: McpServerConfig = toml::from_str(
161
+ r#"
162
+ command = "echo"
163
+ env_vars = [
164
+ "LEGACY_TOKEN",
165
+ { name = "LOCAL_TOKEN", source = "local" },
166
+ { name = "REMOTE_TOKEN", source = "remote" },
167
+ ]
168
+ "#,
169
+ )
170
+ .expect("should deserialize command config with sourced env_vars");
171
+
172
+ assert_eq!(
173
+ cfg.transport,
174
+ McpServerTransportConfig::Stdio {
175
+ command: "echo".to_string(),
176
+ args: vec![],
177
+ env: None,
178
+ env_vars: vec![
179
+ McpServerEnvVar::Name("LEGACY_TOKEN".to_string()),
180
+ McpServerEnvVar::Config {
181
+ name: "LOCAL_TOKEN".to_string(),
182
+ source: Some("local".to_string()),
183
+ },
184
+ McpServerEnvVar::Config {
185
+ name: "REMOTE_TOKEN".to_string(),
186
+ source: Some("remote".to_string()),
187
+ },
188
+ ],
189
+ cwd: None,
190
+ }
191
+ );
192
+ }
193
+
194
+ #[test]
195
+ fn deserialize_stdio_command_server_config_rejects_unknown_env_var_source() {
196
+ let err = toml::from_str::<McpServerConfig>(
197
+ r#"
198
+ command = "echo"
199
+ env_vars = [{ name = "TOKEN", source = "elsewhere" }]
200
+ "#,
201
+ )
202
+ .expect_err("unsupported env var source should be rejected");
203
+
204
+ assert!(
205
+ err.to_string()
206
+ .contains("unsupported env_vars source `elsewhere`"),
207
+ "unexpected error: {err}"
208
+ );
209
+ }
210
+
211
+ #[test]
212
+ fn deserialize_stdio_command_server_config_with_cwd() {
213
+ let cfg: McpServerConfig = toml::from_str(
214
+ r#"
215
+ command = "echo"
216
+ cwd = "/tmp"
217
+ "#,
218
+ )
219
+ .expect("should deserialize command config with cwd");
220
+
221
+ assert_eq!(
222
+ cfg.transport,
223
+ McpServerTransportConfig::Stdio {
224
+ command: "echo".to_string(),
225
+ args: vec![],
226
+ env: None,
227
+ env_vars: Vec::new(),
228
+ cwd: Some(LegacyAppPathString::from_path(Path::new("/tmp"))),
229
+ }
230
+ );
231
+ }
232
+
233
+ #[test]
234
+ fn deserialize_disabled_server_config() {
235
+ let cfg: McpServerConfig = toml::from_str(
236
+ r#"
237
+ command = "echo"
238
+ enabled = false
239
+ "#,
240
+ )
241
+ .expect("should deserialize disabled server config");
242
+
243
+ assert!(!cfg.enabled);
244
+ assert!(!cfg.required);
245
+ }
246
+
247
+ #[test]
248
+ fn deserialize_required_server_config() {
249
+ let cfg: McpServerConfig = toml::from_str(
250
+ r#"
251
+ command = "echo"
252
+ required = true
253
+ "#,
254
+ )
255
+ .expect("should deserialize required server config");
256
+
257
+ assert!(cfg.required);
258
+ }
259
+
260
+ #[test]
261
+ fn deserialize_streamable_http_server_config() {
262
+ let cfg: McpServerConfig = toml::from_str(
263
+ r#"
264
+ url = "https://example.com/mcp"
265
+ "#,
266
+ )
267
+ .expect("should deserialize http config");
268
+
269
+ assert_eq!(
270
+ cfg.transport,
271
+ McpServerTransportConfig::StreamableHttp {
272
+ url: "https://example.com/mcp".to_string(),
273
+ bearer_token_env_var: None,
274
+ http_headers: None,
275
+ env_http_headers: None,
276
+ http_headers_helper: None,
277
+ }
278
+ );
279
+ assert!(cfg.enabled);
280
+ }
281
+
282
+ #[test]
283
+ fn deserialize_streamable_http_server_config_with_env_var() {
284
+ let cfg: McpServerConfig = toml::from_str(
285
+ r#"
286
+ url = "https://example.com/mcp"
287
+ bearer_token_env_var = "GITHUB_TOKEN"
288
+ "#,
289
+ )
290
+ .expect("should deserialize http config");
291
+
292
+ assert_eq!(
293
+ cfg.transport,
294
+ McpServerTransportConfig::StreamableHttp {
295
+ url: "https://example.com/mcp".to_string(),
296
+ bearer_token_env_var: Some("GITHUB_TOKEN".to_string()),
297
+ http_headers: None,
298
+ env_http_headers: None,
299
+ http_headers_helper: None,
300
+ }
301
+ );
302
+ assert!(cfg.enabled);
303
+ }
304
+
305
+ #[test]
306
+ fn deserialize_streamable_http_server_config_with_headers() {
307
+ let cfg: McpServerConfig = toml::from_str(
308
+ r#"
309
+ url = "https://example.com/mcp"
310
+ http_headers = { "X-Foo" = "bar" }
311
+ env_http_headers = { "X-Token" = "TOKEN_ENV" }
312
+ http_headers_helper = "auth-cli headers"
313
+ "#,
314
+ )
315
+ .expect("should deserialize http config with headers");
316
+
317
+ assert_eq!(
318
+ cfg.transport,
319
+ McpServerTransportConfig::StreamableHttp {
320
+ url: "https://example.com/mcp".to_string(),
321
+ bearer_token_env_var: None,
322
+ http_headers: Some(HashMap::from([("X-Foo".to_string(), "bar".to_string())])),
323
+ env_http_headers: Some(HashMap::from([(
324
+ "X-Token".to_string(),
325
+ "TOKEN_ENV".to_string()
326
+ )])),
327
+ http_headers_helper: Some("auth-cli headers".to_string()),
328
+ }
329
+ );
330
+ }
331
+
332
+ #[test]
333
+ fn rejects_http_headers_helper_outside_local_http_servers() {
334
+ for contents in [
335
+ "command = \"server\"\nhttp_headers_helper = \"auth-cli headers\"",
336
+ "url = \"https://example.com/mcp\"\nhttp_headers_helper = \" \"",
337
+ "url = \"https://example.com/mcp\"\nenvironment_id = \"remote\"\nhttp_headers_helper = \"auth-cli headers\"",
338
+ ] {
339
+ toml::from_str::<McpServerConfig>(contents).expect_err("invalid helper placement");
340
+ }
341
+ }
342
+
343
+ #[test]
344
+ fn deserialize_streamable_http_server_config_with_oauth_resource() {
345
+ let cfg: McpServerConfig = toml::from_str(
346
+ r#"
347
+ url = "https://example.com/mcp"
348
+ oauth_resource = "https://api.example.com"
349
+ "#,
350
+ )
351
+ .expect("should deserialize http config with oauth_resource");
352
+
353
+ assert_eq!(
354
+ cfg.oauth_resource,
355
+ Some("https://api.example.com".to_string())
356
+ );
357
+ }
358
+
359
+ #[test]
360
+ fn deserialize_streamable_http_server_config_with_oauth_client_id() {
361
+ let cfg: McpServerConfig = toml::from_str(
362
+ r#"
363
+ url = "https://example.com/mcp"
364
+
365
+ [oauth]
366
+ client_id = "eci-prd-pub-codex-123"
367
+ callback_url = "http://127.0.0.1/callback/registered"
368
+ callback_port = 9876
369
+ "#,
370
+ )
371
+ .expect("should deserialize http config with oauth client id");
372
+
373
+ assert_eq!(
374
+ cfg.oauth,
375
+ Some(McpServerOAuthConfig {
376
+ client_id: Some("eci-prd-pub-codex-123".to_string()),
377
+ callback_url: Some("http://127.0.0.1/callback/registered".to_string()),
378
+ callback_port: Some(9876),
379
+ ..Default::default()
380
+ })
381
+ );
382
+ }
383
+
384
+ #[test]
385
+ fn oauth_callback_port_prefers_server_port_over_global_port() {
386
+ let cfg: McpServerConfig = toml::from_str(
387
+ r#"
388
+ url = "https://example.com/mcp"
389
+
390
+ [oauth]
391
+ callback_port = 9876
392
+ "#,
393
+ )
394
+ .expect("should deserialize http config with oauth callback port");
395
+
396
+ assert_eq!(cfg.oauth_callback_port(Some(4321)), Some(9876));
397
+ }
398
+
399
+ #[test]
400
+ fn oauth_callback_port_falls_back_to_global_port() {
401
+ let cfg: McpServerConfig = toml::from_str(
402
+ r#"
403
+ url = "https://example.com/mcp"
404
+ "#,
405
+ )
406
+ .expect("should deserialize http config without oauth callback port");
407
+
408
+ assert_eq!(cfg.oauth_callback_port(Some(4321)), Some(4321));
409
+ assert_eq!(cfg.oauth_callback_port(/*global_callback_port*/ None), None);
410
+ }
411
+
412
+ #[test]
413
+ fn deserialize_server_config_with_tool_filters() {
414
+ let cfg: McpServerConfig = toml::from_str(
415
+ r#"
416
+ command = "echo"
417
+ enabled_tools = ["allowed"]
418
+ disabled_tools = ["blocked"]
419
+ "#,
420
+ )
421
+ .expect("should deserialize tool filters");
422
+
423
+ assert_eq!(cfg.enabled_tools, Some(vec!["allowed".to_string()]));
424
+ assert_eq!(cfg.disabled_tools, Some(vec!["blocked".to_string()]));
425
+ }
426
+
427
+ #[test]
428
+ fn deserialize_server_config_with_parallel_tool_calls() {
429
+ let cfg: McpServerConfig = toml::from_str(
430
+ r#"
431
+ command = "echo"
432
+ supports_parallel_tool_calls = true
433
+ "#,
434
+ )
435
+ .expect("should deserialize supports_parallel_tool_calls");
436
+
437
+ assert!(cfg.supports_parallel_tool_calls);
438
+ }
439
+
440
+ #[test]
441
+ fn serialize_round_trips_server_config_with_omitted_tool_exposure_surfaces() {
442
+ for omitted_surfaces in [
443
+ vec![],
444
+ vec![ToolExposureSurface::CodeMode],
445
+ vec![ToolExposureSurface::Deferred],
446
+ vec![ToolExposureSurface::Direct],
447
+ vec![ToolExposureSurface::CodeMode, ToolExposureSurface::Deferred],
448
+ vec![ToolExposureSurface::CodeMode, ToolExposureSurface::Direct],
449
+ vec![ToolExposureSurface::Deferred, ToolExposureSurface::Direct],
450
+ vec![
451
+ ToolExposureSurface::CodeMode,
452
+ ToolExposureSurface::Deferred,
453
+ ToolExposureSurface::Direct,
454
+ ],
455
+ ] {
456
+ let serialized_surfaces = omitted_surfaces
457
+ .iter()
458
+ .map(|surface| format!("\"{surface}\""))
459
+ .collect::<Vec<_>>()
460
+ .join(", ");
461
+ let config = format!("command = \"echo\"\nomit_tools_from = [{serialized_surfaces}]\n");
462
+ let cfg: McpServerConfig =
463
+ toml::from_str(&config).expect("should deserialize omitted MCP exposure surfaces");
464
+ assert_eq!(cfg.omit_tools_from, Some(omitted_surfaces.clone()));
465
+
466
+ let serialized = toml::to_string(&cfg).expect("should serialize MCP config");
467
+ assert!(serialized.contains(&format!("omit_tools_from = [{serialized_surfaces}]")));
468
+
469
+ let round_tripped: McpServerConfig =
470
+ toml::from_str(&serialized).expect("should deserialize serialized MCP config");
471
+ assert_eq!(round_tripped, cfg);
472
+ }
473
+ }
474
+
475
+ #[test]
476
+ fn deserialize_server_config_with_default_tool_approval_mode() {
477
+ let cfg: McpServerConfig = toml::from_str(
478
+ r#"
479
+ command = "echo"
480
+ default_tools_approval_mode = "approve"
481
+
482
+ [tools.search]
483
+ approval_mode = "prompt"
484
+ output_token_limit = 30000
485
+ "#,
486
+ )
487
+ .expect("should deserialize default tool approval mode");
488
+
489
+ assert_eq!(
490
+ cfg.default_tools_approval_mode,
491
+ Some(AppToolApproval::Approve)
492
+ );
493
+ assert_eq!(
494
+ cfg.tools.get("search"),
495
+ Some(&McpServerToolConfig {
496
+ approval_mode: Some(AppToolApproval::Prompt),
497
+ output_token_limit: std::num::NonZeroUsize::new(30_000),
498
+ })
499
+ );
500
+
501
+ let serialized = toml::to_string(&cfg).expect("should serialize MCP config");
502
+ assert!(serialized.contains("default_tools_approval_mode = \"approve\""));
503
+ assert!(serialized.contains("output_token_limit = 30000"));
504
+
505
+ let round_tripped: McpServerConfig =
506
+ toml::from_str(&serialized).expect("should deserialize serialized MCP config");
507
+ assert_eq!(round_tripped, cfg);
508
+ }
509
+
510
+ #[test]
511
+ fn deserialize_rejects_nonpositive_mcp_tool_output_limits() {
512
+ for output_token_limit in [0, -1] {
513
+ let config = format!(
514
+ "command = \"echo\"\n[tools.search]\noutput_token_limit = {output_token_limit}\n"
515
+ );
516
+ let error = toml::from_str::<McpServerConfig>(&config)
517
+ .expect_err("MCP tool output limit must be positive");
518
+ assert!(error.to_string().contains("output_token_limit"));
519
+ }
520
+ }
521
+
522
+ #[test]
523
+ fn serialize_round_trips_server_config_with_parallel_tool_calls() {
524
+ let cfg: McpServerConfig = toml::from_str(
525
+ r#"
526
+ command = "echo"
527
+ supports_parallel_tool_calls = true
528
+ tool_timeout_sec = 2.0
529
+ "#,
530
+ )
531
+ .expect("should deserialize supports_parallel_tool_calls");
532
+
533
+ let serialized = toml::to_string(&cfg).expect("should serialize MCP config");
534
+ assert!(serialized.contains("supports_parallel_tool_calls = true"));
535
+
536
+ let round_tripped: McpServerConfig =
537
+ toml::from_str(&serialized).expect("should deserialize serialized MCP config");
538
+ assert_eq!(round_tripped, cfg);
539
+ }
540
+
541
+ #[test]
542
+ fn deserialize_ignores_unknown_server_fields() {
543
+ let cfg: McpServerConfig = toml::from_str(
544
+ r#"
545
+ command = "echo"
546
+ trust_level = "trusted"
547
+ "#,
548
+ )
549
+ .expect("should ignore unknown server fields");
550
+
551
+ assert_eq!(
552
+ cfg,
553
+ McpServerConfig {
554
+ auth: Default::default(),
555
+ transport: McpServerTransportConfig::Stdio {
556
+ command: "echo".to_string(),
557
+ args: vec![],
558
+ env: None,
559
+ env_vars: Vec::new(),
560
+ cwd: None,
561
+ },
562
+ environment_id: crate::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
563
+ enabled: true,
564
+ required: false,
565
+ supports_parallel_tool_calls: false,
566
+ omit_tools_from: None,
567
+ disabled_reason: None,
568
+ startup_timeout_sec: None,
569
+ tool_timeout_sec: None,
570
+ default_tools_approval_mode: None,
571
+ enabled_tools: None,
572
+ disabled_tools: None,
573
+ scopes: None,
574
+ oauth: None,
575
+ oauth_resource: None,
576
+ tools: HashMap::new(),
577
+ }
578
+ );
579
+ }
580
+
581
+ #[test]
582
+ fn deserialize_rejects_command_and_url() {
583
+ toml::from_str::<McpServerConfig>(
584
+ r#"
585
+ command = "echo"
586
+ url = "https://example.com"
587
+ "#,
588
+ )
589
+ .expect_err("should reject command+url");
590
+ }
591
+
592
+ #[test]
593
+ fn deserialize_rejects_env_for_http_transport() {
594
+ toml::from_str::<McpServerConfig>(
595
+ r#"
596
+ url = "https://example.com"
597
+ env = { "FOO" = "BAR" }
598
+ "#,
599
+ )
600
+ .expect_err("should reject env for http transport");
601
+ }
602
+
603
+ #[test]
604
+ fn deserialize_rejects_headers_for_stdio() {
605
+ toml::from_str::<McpServerConfig>(
606
+ r#"
607
+ command = "echo"
608
+ http_headers = { "X-Foo" = "bar" }
609
+ "#,
610
+ )
611
+ .expect_err("should reject http_headers for stdio transport");
612
+
613
+ toml::from_str::<McpServerConfig>(
614
+ r#"
615
+ command = "echo"
616
+ env_http_headers = { "X-Foo" = "BAR_ENV" }
617
+ "#,
618
+ )
619
+ .expect_err("should reject env_http_headers for stdio transport");
620
+
621
+ let err = toml::from_str::<McpServerConfig>(
622
+ r#"
623
+ command = "echo"
624
+ oauth = { client_id = "eci-prd-pub-codex-123" }
625
+ "#,
626
+ )
627
+ .expect_err("should reject oauth for stdio transport");
628
+
629
+ assert!(
630
+ err.to_string().contains("oauth is not supported for stdio"),
631
+ "unexpected error: {err}"
632
+ );
633
+
634
+ let err = toml::from_str::<McpServerConfig>(
635
+ r#"
636
+ command = "echo"
637
+ oauth_resource = "https://api.example.com"
638
+ "#,
639
+ )
640
+ .expect_err("should reject oauth_resource for stdio transport");
641
+
642
+ assert!(
643
+ err.to_string()
644
+ .contains("oauth_resource is not supported for stdio"),
645
+ "unexpected error: {err}"
646
+ );
647
+ }
648
+
649
+ #[test]
650
+ fn deserialize_rejects_inline_bearer_token_field() {
651
+ let err = toml::from_str::<McpServerConfig>(
652
+ r#"
653
+ url = "https://example.com"
654
+ bearer_token = "secret"
655
+ "#,
656
+ )
657
+ .expect_err("should reject bearer_token field");
658
+
659
+ assert!(
660
+ err.to_string().contains("bearer_token is not supported"),
661
+ "unexpected error: {err}"
662
+ );
663
+ }
codex-rs/config/src/merge.rs ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::key_aliases::normalize_key_aliases;
2
+ use crate::key_aliases::normalized_with_key_aliases;
3
+ use codex_network_proxy::normalize_host;
4
+ use toml::Value as TomlValue;
5
+
6
+ /// The mutually exclusive shell-environment filter representations.
7
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
8
+ pub enum ShellEnvironmentPolicyFilterRepresentation {
9
+ Filters,
10
+ Legacy,
11
+ }
12
+
13
+ impl ShellEnvironmentPolicyFilterRepresentation {
14
+ /// Returns the representation selected by a policy table, including empty fields.
15
+ pub fn from_policy(policy: &TomlValue) -> Option<Self> {
16
+ let policy = policy.as_table()?;
17
+ if policy.contains_key("filters") {
18
+ Some(Self::Filters)
19
+ } else if policy.contains_key("exclude") || policy.contains_key("include_only") {
20
+ Some(Self::Legacy)
21
+ } else {
22
+ None
23
+ }
24
+ }
25
+
26
+ /// Returns the representation addressed by a dotted config path.
27
+ pub fn from_path(path: &[String]) -> Option<Self> {
28
+ match path {
29
+ [policy, field, ..] if policy == "shell_environment_policy" => match field.as_str() {
30
+ "filters" => Some(Self::Filters),
31
+ "exclude" | "include_only" => Some(Self::Legacy),
32
+ _ => None,
33
+ },
34
+ _ => None,
35
+ }
36
+ }
37
+
38
+ /// Returns the representation selected by an edit at `path`.
39
+ pub fn from_edit(path: &[String], value: &TomlValue) -> Option<Self> {
40
+ if matches!(path, [policy] if policy == "shell_environment_policy") {
41
+ Self::from_policy(value)
42
+ } else {
43
+ Self::from_path(path)
44
+ }
45
+ }
46
+
47
+ /// Returns the policy fields that must be removed when this representation is selected.
48
+ pub fn displaced_fields(self) -> &'static [&'static str] {
49
+ match self {
50
+ Self::Filters => &["exclude", "include_only"],
51
+ Self::Legacy => &["filters"],
52
+ }
53
+ }
54
+ }
55
+
56
+ /// Merge config `overlay` into `base`, giving `overlay` precedence.
57
+ pub fn merge_toml_values(base: &mut TomlValue, overlay: &TomlValue) {
58
+ merge_toml_values_at_path(base, overlay, &mut Vec::new());
59
+ }
60
+
61
+ pub fn is_structured_feature_path<S: AsRef<str>>(path: &[S]) -> bool {
62
+ let (features, feature) = match path {
63
+ [profiles, _, features, feature] if profiles.as_ref() == "profiles" => (features, feature),
64
+ [features, feature] => (features, feature),
65
+ _ => return false,
66
+ };
67
+
68
+ features.as_ref() == "features"
69
+ && matches!(
70
+ feature.as_ref(),
71
+ "multi_agent_v2" | "network_proxy" | "sleep_tool"
72
+ )
73
+ }
74
+
75
+ fn merge_toml_values_at_path(base: &mut TomlValue, overlay: &TomlValue, path: &mut Vec<String>) {
76
+ replace_shell_environment_policy_filter_representation(base, overlay, path);
77
+
78
+ if is_structured_feature_path(path) {
79
+ if let TomlValue::Boolean(enabled) = base
80
+ && overlay.is_table()
81
+ {
82
+ *base = TomlValue::Table(toml::map::Map::from_iter([(
83
+ "enabled".to_string(),
84
+ TomlValue::Boolean(*enabled),
85
+ )]));
86
+ } else if let TomlValue::Table(table) = base
87
+ && let TomlValue::Boolean(enabled) = overlay
88
+ {
89
+ table.insert("enabled".to_string(), TomlValue::Boolean(*enabled));
90
+ return;
91
+ }
92
+ }
93
+
94
+ if let TomlValue::Table(overlay_table) = overlay
95
+ && let TomlValue::Table(base_table) = base
96
+ {
97
+ normalize_key_aliases(path, base_table);
98
+ let mut overlay_table = overlay_table.clone();
99
+ normalize_key_aliases(path, &mut overlay_table);
100
+ if is_network_domains_path(path) {
101
+ normalize_network_domain_keys(base_table);
102
+ normalize_network_domain_keys(&mut overlay_table);
103
+ }
104
+ if is_shell_environment_filters_path(path) {
105
+ normalize_case_insensitive_keys(base_table);
106
+ normalize_case_insensitive_keys(&mut overlay_table);
107
+ }
108
+ if path.split_last().is_some_and(|(field, feature_path)| {
109
+ field == "credentials" && is_structured_feature_path(feature_path)
110
+ }) {
111
+ for (provider_id, provider) in &overlay_table {
112
+ let Some(overlay_sources) = provider.get("env").and_then(TomlValue::as_array)
113
+ else {
114
+ continue;
115
+ };
116
+ base_table.retain(|existing_id, existing| {
117
+ existing_id == provider_id
118
+ || !overlay_table
119
+ .get(existing_id)
120
+ .and_then(|provider| provider.get("env"))
121
+ .or_else(|| existing.get("env"))
122
+ .and_then(TomlValue::as_array)
123
+ .is_some_and(|existing_sources| {
124
+ existing_sources.iter().filter_map(TomlValue::as_str).any(
125
+ |existing_source| {
126
+ overlay_sources.iter().filter_map(TomlValue::as_str).any(
127
+ |overlay_source| {
128
+ existing_source == overlay_source
129
+ || cfg!(windows)
130
+ && existing_source
131
+ .eq_ignore_ascii_case(overlay_source)
132
+ },
133
+ )
134
+ },
135
+ )
136
+ })
137
+ });
138
+ }
139
+ }
140
+ for (key, value) in overlay_table {
141
+ path.push(key.clone());
142
+ if let Some(existing) = base_table.get_mut(&key) {
143
+ merge_toml_values_at_path(existing, &value, path);
144
+ } else {
145
+ base_table.insert(key, normalized_with_key_aliases(&value, path));
146
+ }
147
+ path.pop();
148
+ }
149
+ } else {
150
+ *base = normalized_with_key_aliases(overlay, path);
151
+ }
152
+ }
153
+
154
+ fn is_shell_environment_filters_path(path: &[String]) -> bool {
155
+ matches!(
156
+ path,
157
+ [policy, filters]
158
+ if policy == "shell_environment_policy" && filters == "filters"
159
+ )
160
+ }
161
+
162
+ /// Switching between legacy arrays and keyed filters replaces lower filter
163
+ /// fields instead of attempting to reconcile the two representations. Legacy
164
+ /// arrays already replace wholesale, so reconciling them would add merge
165
+ /// semantics that the legacy representation never supported.
166
+ fn replace_shell_environment_policy_filter_representation(
167
+ base: &mut TomlValue,
168
+ overlay: &TomlValue,
169
+ path: &[String],
170
+ ) {
171
+ if !matches!(path, [policy] if policy == "shell_environment_policy") {
172
+ return;
173
+ }
174
+ let Some(overlay_representation) =
175
+ ShellEnvironmentPolicyFilterRepresentation::from_policy(overlay)
176
+ else {
177
+ return;
178
+ };
179
+ let TomlValue::Table(base) = base else {
180
+ return;
181
+ };
182
+
183
+ for field in overlay_representation.displaced_fields() {
184
+ base.remove(*field);
185
+ }
186
+ }
187
+
188
+ /// Looks up a shell-environment filter pattern while ignoring case.
189
+ pub fn shell_environment_filter_entry<'a>(
190
+ root: &'a TomlValue,
191
+ path: &[String],
192
+ ) -> Option<(&'a String, &'a TomlValue)> {
193
+ let [policy, filters, pattern] = path else {
194
+ return None;
195
+ };
196
+ if policy != "shell_environment_policy" || filters != "filters" {
197
+ return None;
198
+ }
199
+
200
+ let pattern = pattern.to_lowercase();
201
+ root.get(policy)?
202
+ .get(filters)?
203
+ .as_table()?
204
+ .iter()
205
+ .find(|(candidate, _)| candidate.to_lowercase() == pattern)
206
+ }
207
+
208
+ fn is_network_domains_path(path: &[String]) -> bool {
209
+ matches!(
210
+ path,
211
+ [permissions, _, network, domains]
212
+ if permissions == "permissions" && network == "network" && domains == "domains"
213
+ ) || matches!(
214
+ path,
215
+ [application, network, domains]
216
+ if application == "application" && network == "network" && domains == "domains"
217
+ )
218
+ }
219
+
220
+ fn normalize_network_domain_keys(table: &mut toml::map::Map<String, TomlValue>) {
221
+ let entries = std::mem::take(table);
222
+ for (pattern, value) in entries {
223
+ table.insert(normalize_host(&pattern), value);
224
+ }
225
+ }
226
+
227
+ fn normalize_case_insensitive_keys(table: &mut toml::map::Map<String, TomlValue>) {
228
+ let entries = std::mem::take(table);
229
+ for (key, value) in entries {
230
+ table.insert(key.to_lowercase(), value);
231
+ }
232
+ }
233
+
234
+ #[cfg(test)]
235
+ #[path = "merge_tests.rs"]
236
+ mod tests;
codex-rs/config/src/merge_tests.rs ADDED
@@ -0,0 +1,692 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use super::*;
2
+ use crate::config_toml::AgentsToml;
3
+ use crate::config_toml::ConfigToml;
4
+ use crate::types::MemoriesToml;
5
+ use pretty_assertions::assert_eq;
6
+
7
+ fn parse_toml(value: &str) -> TomlValue {
8
+ toml::from_str(value).expect("TOML should parse")
9
+ }
10
+
11
+ #[test]
12
+ fn merge_toml_values_normalizes_legacy_key_from_base_layer() {
13
+ let mut base = parse_toml(
14
+ r#"
15
+ [memories]
16
+ no_memories_if_mcp_or_web_search = false
17
+ "#,
18
+ );
19
+ let overlay = parse_toml(
20
+ r#"
21
+ [memories]
22
+ disable_on_external_context = true
23
+ "#,
24
+ );
25
+
26
+ merge_toml_values(&mut base, &overlay);
27
+
28
+ let expected = parse_toml(
29
+ r#"
30
+ [memories]
31
+ disable_on_external_context = true
32
+ "#,
33
+ );
34
+ assert_eq!(base, expected);
35
+
36
+ let config: ConfigToml = base.try_into().expect("merged config should deserialize");
37
+ assert_eq!(
38
+ config.memories,
39
+ Some(MemoriesToml {
40
+ disable_on_external_context: Some(true),
41
+ ..Default::default()
42
+ })
43
+ );
44
+ }
45
+
46
+ #[test]
47
+ fn merge_toml_values_normalizes_legacy_key_from_overlay_layer() {
48
+ let mut base = parse_toml(
49
+ r#"
50
+ [memories]
51
+ disable_on_external_context = false
52
+ "#,
53
+ );
54
+ let overlay = parse_toml(
55
+ r#"
56
+ [memories]
57
+ no_memories_if_mcp_or_web_search = true
58
+ "#,
59
+ );
60
+
61
+ merge_toml_values(&mut base, &overlay);
62
+
63
+ let expected = parse_toml(
64
+ r#"
65
+ [memories]
66
+ disable_on_external_context = true
67
+ "#,
68
+ );
69
+ assert_eq!(base, expected);
70
+
71
+ let config: ConfigToml = base.try_into().expect("merged config should deserialize");
72
+ assert_eq!(
73
+ config.memories,
74
+ Some(MemoriesToml {
75
+ disable_on_external_context: Some(true),
76
+ ..Default::default()
77
+ })
78
+ );
79
+ }
80
+
81
+ #[test]
82
+ fn merge_toml_values_prefers_canonical_key_when_one_layer_has_both_names() {
83
+ let mut base = TomlValue::Table(toml::map::Map::new());
84
+ let overlay = parse_toml(
85
+ r#"
86
+ [memories]
87
+ disable_on_external_context = true
88
+ no_memories_if_mcp_or_web_search = false
89
+ "#,
90
+ );
91
+
92
+ merge_toml_values(&mut base, &overlay);
93
+
94
+ let expected = parse_toml(
95
+ r#"
96
+ [memories]
97
+ disable_on_external_context = true
98
+ "#,
99
+ );
100
+ assert_eq!(base, expected);
101
+ }
102
+
103
+ #[test]
104
+ fn merge_toml_values_normalizes_legacy_agents_key_across_layers() {
105
+ let mut base = parse_toml(
106
+ r#"
107
+ [agents]
108
+ max_threads = 4
109
+ "#,
110
+ );
111
+ let overlay = parse_toml(
112
+ r#"
113
+ [agents]
114
+ max_concurrent_threads_per_session = 7
115
+ "#,
116
+ );
117
+
118
+ merge_toml_values(&mut base, &overlay);
119
+
120
+ let expected = parse_toml(
121
+ r#"
122
+ [agents]
123
+ max_concurrent_threads_per_session = 7
124
+ "#,
125
+ );
126
+ assert_eq!(base, expected);
127
+
128
+ let config: ConfigToml = base.try_into().expect("merged config should deserialize");
129
+ assert_eq!(
130
+ config.agents,
131
+ Some(AgentsToml {
132
+ max_concurrent_threads_per_session: Some(7),
133
+ ..Default::default()
134
+ })
135
+ );
136
+ }
137
+
138
+ #[test]
139
+ fn merge_toml_values_normalizes_legacy_agents_key_from_overlay() {
140
+ let mut base = parse_toml(
141
+ r#"
142
+ [agents]
143
+ max_concurrent_threads_per_session = 4
144
+ "#,
145
+ );
146
+ let overlay = parse_toml(
147
+ r#"
148
+ [agents]
149
+ max_threads = 7
150
+ "#,
151
+ );
152
+
153
+ merge_toml_values(&mut base, &overlay);
154
+
155
+ let expected = parse_toml(
156
+ r#"
157
+ [agents]
158
+ max_concurrent_threads_per_session = 7
159
+ "#,
160
+ );
161
+ assert_eq!(base, expected);
162
+ }
163
+
164
+ /// Feature tables added above legacy toggles retain the lower layer's enabled state.
165
+ #[test]
166
+ fn merge_multi_agent_v2_table_preserves_legacy_boolean_toggle() {
167
+ for feature_path in ["features", "profiles.work.features"] {
168
+ let mut base = parse_toml(&format!("[{feature_path}]\nmulti_agent_v2 = true\n"));
169
+ let overlay = parse_toml(&format!(
170
+ "[{feature_path}.multi_agent_v2]\nsubagent_usage_hint_text = \"Delegate carefully.\"\n",
171
+ ));
172
+
173
+ merge_toml_values(&mut base, &overlay);
174
+
175
+ assert_eq!(
176
+ base,
177
+ parse_toml(&format!(
178
+ "[{feature_path}.multi_agent_v2]\nenabled = true\nsubagent_usage_hint_text = \"Delegate carefully.\"\n",
179
+ ))
180
+ );
181
+ }
182
+ }
183
+
184
+ /// Legacy feature toggles update enabled state without discarding nested configuration.
185
+ #[test]
186
+ fn merge_multi_agent_v2_boolean_preserves_existing_feature_table() {
187
+ for feature_path in ["features", "profiles.work.features"] {
188
+ let mut base = parse_toml(&format!(
189
+ "[{feature_path}.multi_agent_v2]\nenabled = true\nsubagent_usage_hint_text = \"Delegate carefully.\"\n",
190
+ ));
191
+ let overlay = parse_toml(&format!("[{feature_path}]\nmulti_agent_v2 = false\n"));
192
+
193
+ merge_toml_values(&mut base, &overlay);
194
+
195
+ assert_eq!(
196
+ base,
197
+ parse_toml(&format!(
198
+ "[{feature_path}.multi_agent_v2]\nenabled = false\nsubagent_usage_hint_text = \"Delegate carefully.\"\n",
199
+ ))
200
+ );
201
+ }
202
+ }
203
+
204
+ /// Opaque desktop settings retain ordinary scalar/table replacement semantics.
205
+ #[test]
206
+ fn merge_multi_agent_v2_compatibility_excludes_opaque_desktop_paths() {
207
+ let cases = [
208
+ (
209
+ "[desktop.features.multi_agent_v2]\nenabled = true\n",
210
+ "[desktop.features]\nmulti_agent_v2 = false\n",
211
+ "[desktop.features]\nmulti_agent_v2 = false\n",
212
+ ),
213
+ (
214
+ "[desktop.features]\nmulti_agent_v2 = true\n",
215
+ "[desktop.features.multi_agent_v2]\ncustom = true\n",
216
+ "[desktop.features.multi_agent_v2]\ncustom = true\n",
217
+ ),
218
+ ];
219
+
220
+ for (base, overlay, expected) in cases {
221
+ let mut base = parse_toml(base);
222
+ merge_toml_values(&mut base, &parse_toml(overlay));
223
+ assert_eq!(base, parse_toml(expected));
224
+ }
225
+ }
226
+
227
+ /// CLI overrides preserve the multi-agent toggle and nested options in either ordering.
228
+ #[test]
229
+ fn multi_agent_v2_cli_overrides_preserve_boolean_and_nested_configuration() {
230
+ for feature_path in ["features", "profiles.work.features"] {
231
+ let instructions = (
232
+ format!("{feature_path}.multi_agent_v2.subagent_usage_hint_text"),
233
+ TomlValue::String("Delegate carefully.".to_string()),
234
+ );
235
+ let enabled = (
236
+ format!("{feature_path}.multi_agent_v2"),
237
+ TomlValue::Boolean(true),
238
+ );
239
+ let feature_table = (
240
+ format!("{feature_path}.multi_agent_v2"),
241
+ parse_toml("subagent_usage_hint_text = \"Delegate carefully.\"\n"),
242
+ );
243
+ let expected = parse_toml(&format!(
244
+ "[{feature_path}.multi_agent_v2]\nenabled = true\nsubagent_usage_hint_text = \"Delegate carefully.\"\n",
245
+ ));
246
+
247
+ for overrides in [
248
+ vec![enabled.clone(), instructions.clone()],
249
+ vec![instructions, enabled.clone()],
250
+ vec![enabled.clone(), feature_table.clone()],
251
+ vec![feature_table, enabled],
252
+ ] {
253
+ assert_eq!(crate::build_cli_overrides_layer(&overrides), expected);
254
+ }
255
+ }
256
+ }
257
+
258
+ #[test]
259
+ fn sleep_tool_overrides_preserve_disabled_state_and_mode() {
260
+ for feature_path in ["features", "profiles.work.features"] {
261
+ let disabled = (
262
+ format!("{feature_path}.sleep_tool"),
263
+ TomlValue::Boolean(false),
264
+ );
265
+ let mode = (
266
+ format!("{feature_path}.sleep_tool.mode"),
267
+ TomlValue::String("always_on".to_string()),
268
+ );
269
+ let expected = parse_toml(&format!(
270
+ "[{feature_path}.sleep_tool]\nenabled = false\nmode = \"always_on\"\n",
271
+ ));
272
+ for overrides in [vec![disabled.clone(), mode.clone()], vec![mode, disabled]] {
273
+ assert_eq!(crate::build_cli_overrides_layer(&overrides), expected);
274
+ }
275
+
276
+ let boolean_layer = parse_toml(&format!("[{feature_path}]\nsleep_tool = false\n"));
277
+ let table_layer = parse_toml(&format!(
278
+ "[{feature_path}.sleep_tool]\nmode = \"always_on\"\n",
279
+ ));
280
+ for (mut base, overlay) in [
281
+ (boolean_layer.clone(), table_layer.clone()),
282
+ (table_layer, boolean_layer),
283
+ ] {
284
+ merge_toml_values(&mut base, &overlay);
285
+ assert_eq!(base, expected);
286
+ }
287
+ }
288
+ }
289
+
290
+ #[test]
291
+ fn network_proxy_feature_overrides_preserve_credential_broker_configuration() {
292
+ let enabled = (
293
+ "features.network_proxy".to_string(),
294
+ TomlValue::Boolean(true),
295
+ );
296
+ let broker = (
297
+ "features.network_proxy.credential_broker".to_string(),
298
+ TomlValue::Boolean(true),
299
+ );
300
+ let expected =
301
+ parse_toml("[features.network_proxy]\nenabled = true\ncredential_broker = true\n");
302
+
303
+ for overrides in [vec![enabled.clone(), broker.clone()], vec![broker, enabled]] {
304
+ assert_eq!(crate::build_cli_overrides_layer(&overrides), expected);
305
+ }
306
+
307
+ let mut base = parse_toml("[features]\nnetwork_proxy = true\n");
308
+ merge_toml_values(
309
+ &mut base,
310
+ &parse_toml("[features.network_proxy]\ncredential_broker = true\n"),
311
+ );
312
+ assert_eq!(base, expected);
313
+ }
314
+
315
+ #[test]
316
+ fn higher_priority_credential_provider_replaces_overlapping_sources() {
317
+ for (feature_path, base_source, overlay_source, keep_default) in [
318
+ ("features", "VENDOR_TOKEN", "VENDOR_TOKEN", false),
319
+ (
320
+ "profiles.work.features",
321
+ "VENDOR_TOKEN",
322
+ "VENDOR_TOKEN",
323
+ false,
324
+ ),
325
+ ("features", "vendor_token", "VENDOR_TOKEN", !cfg!(windows)),
326
+ ] {
327
+ let default_provider = format!(
328
+ "[{feature_path}.network_proxy.credentials.default]\n\
329
+ env = ['{base_source}']\n\
330
+ url_prefixes = ['https://default.example']\n"
331
+ );
332
+ let shared_provider = format!(
333
+ "[{feature_path}.network_proxy.credentials.shared]\n\
334
+ env = ['SHARED_TOKEN']\n\
335
+ patterns = ['shared_[a-z]+']\n"
336
+ );
337
+ let mut base = parse_toml(&format!("{default_provider}\n{shared_provider}"));
338
+ let overlay = parse_toml(&format!(
339
+ "[{feature_path}.network_proxy.credentials.override]\n\
340
+ env = ['{overlay_source}']\n\
341
+ url_prefixes = ['https://override.example']\n\
342
+ [{feature_path}.network_proxy.credentials.shared]\n\
343
+ url_prefixes = ['https://shared.example']\n"
344
+ ));
345
+
346
+ merge_toml_values(&mut base, &overlay);
347
+
348
+ let retained_default = if keep_default { &default_provider } else { "" };
349
+ let expected = parse_toml(&format!(
350
+ "{retained_default}\n\
351
+ [{feature_path}.network_proxy.credentials.override]\n\
352
+ env = ['{overlay_source}']\n\
353
+ url_prefixes = ['https://override.example']\n\
354
+ [{feature_path}.network_proxy.credentials.shared]\n\
355
+ env = ['SHARED_TOKEN']\n\
356
+ patterns = ['shared_[a-z]+']\n\
357
+ url_prefixes = ['https://shared.example']\n"
358
+ ));
359
+ assert_eq!(base, expected, "feature path: {feature_path}");
360
+ }
361
+ }
362
+
363
+ #[test]
364
+ fn credential_provider_remapping_preserves_inherited_settings() {
365
+ let base = parse_toml(
366
+ r#"
367
+ [features.network_proxy.credentials.a]
368
+ env = ["OLD_AUTH"]
369
+ patterns = ["acme_[a-z]{24}"]
370
+ url_prefixes = ["https://a.example"]
371
+ auth = ["token"]
372
+ [features.network_proxy.credentials.b]
373
+ env = ["OTHER_AUTH"]
374
+ patterns = ["other_[a-z]{24}"]
375
+ url_prefixes = ["https://b.example"]
376
+ auth = ["bearer"]
377
+ "#,
378
+ );
379
+
380
+ for new_a in ["NEW_AUTH", "OTHER_AUTH"] {
381
+ let mut merged = base.clone();
382
+ let mut expected = base.clone();
383
+ let providers = &mut expected["features"]["network_proxy"]["credentials"];
384
+ providers["a"]["env"] = TomlValue::Array(vec![new_a.into()]);
385
+ providers["b"]["env"] = TomlValue::Array(vec!["OLD_AUTH".into()]);
386
+ let overlay = parse_toml(&format!(
387
+ "[features.network_proxy.credentials.a]\n\
388
+ env = ['{new_a}']\n\
389
+ [features.network_proxy.credentials.b]\n\
390
+ env = ['OLD_AUTH']\n"
391
+ ));
392
+
393
+ merge_toml_values(&mut merged, &overlay);
394
+
395
+ assert_eq!(merged, expected);
396
+ }
397
+ }
398
+
399
+ /// Repeated opaque desktop overrides continue to replace their previous value.
400
+ #[test]
401
+ fn multi_agent_v2_cli_compatibility_excludes_opaque_desktop_paths() {
402
+ let path = "desktop.features.multi_agent_v2".to_string();
403
+ let enabled = (path.clone(), TomlValue::Boolean(true));
404
+ let feature_table = (path, parse_toml("custom = true\n"));
405
+
406
+ assert_eq!(
407
+ crate::build_cli_overrides_layer(&[enabled.clone(), feature_table.clone()]),
408
+ parse_toml("[desktop.features.multi_agent_v2]\ncustom = true\n")
409
+ );
410
+ assert_eq!(
411
+ crate::build_cli_overrides_layer(&[feature_table, enabled]),
412
+ parse_toml("[desktop.features]\nmulti_agent_v2 = true\n")
413
+ );
414
+ }
415
+
416
+ #[test]
417
+ fn merge_toml_values_normalizes_permission_network_domains_before_overlaying() {
418
+ let mut base = parse_toml(
419
+ r#"
420
+ [permissions.dev.network.domains]
421
+ "example.com" = "deny"
422
+ "#,
423
+ );
424
+ let overlay = parse_toml(
425
+ r#"
426
+ [permissions.dev.network.domains]
427
+ "EXAMPLE.COM" = "allow"
428
+ "#,
429
+ );
430
+
431
+ merge_toml_values(&mut base, &overlay);
432
+
433
+ let expected = parse_toml(
434
+ r#"
435
+ [permissions.dev.network.domains]
436
+ "example.com" = "allow"
437
+ "#,
438
+ );
439
+ assert_eq!(base, expected);
440
+ }
441
+
442
+ #[test]
443
+ fn shell_environment_policy_legacy_array_overlay_replaces_legacy_array() {
444
+ let mut base = parse_toml(
445
+ r#"
446
+ [shell_environment_policy]
447
+ exclude = ["LOW_*", "SHARED_*"]
448
+ "#,
449
+ );
450
+ let overlay = parse_toml(
451
+ r#"
452
+ [shell_environment_policy]
453
+ exclude = ["HIGH_*"]
454
+ "#,
455
+ );
456
+
457
+ merge_toml_values(&mut base, &overlay);
458
+
459
+ assert_eq!(base, overlay);
460
+ }
461
+
462
+ #[test]
463
+ fn shell_environment_policy_set_overlay_preserves_case_distinct_keys() {
464
+ let mut base = parse_toml(
465
+ r#"
466
+ [features.network_proxy.credentials.stripe]
467
+ env = ["STRIPE_API_KEY"]
468
+ patterns = ["stripe_[a-z]+"]
469
+ url_prefix_from_env = "STRIPE_HOST"
470
+
471
+ [shell_environment_policy.set]
472
+ stripe_api_key = "stale"
473
+ stripe_host = "stale.example"
474
+ foo = "lowercase"
475
+ "#,
476
+ );
477
+ let overlay = parse_toml(
478
+ r#"
479
+ [shell_environment_policy.set]
480
+ STRIPE_API_KEY = "trusted"
481
+ STRIPE_HOST = "trusted.example"
482
+ FOO = "uppercase"
483
+ "#,
484
+ );
485
+
486
+ merge_toml_values(&mut base, &overlay);
487
+
488
+ let values = base["shell_environment_policy"]["set"]
489
+ .as_table()
490
+ .expect("shell environment overrides");
491
+ assert_eq!(
492
+ values.get("foo").and_then(TomlValue::as_str),
493
+ Some("lowercase")
494
+ );
495
+ assert_eq!(
496
+ values.get("FOO").and_then(TomlValue::as_str),
497
+ Some("uppercase")
498
+ );
499
+ assert_eq!(values.len(), 6);
500
+ }
501
+
502
+ #[test]
503
+ fn later_credential_provider_preserves_case_distinct_environment_overrides() {
504
+ let mut base = parse_toml(
505
+ "[shell_environment_policy.set]\n\
506
+ stripe_api_key = 'stale'\n\
507
+ stripe_host = 'stale.example'",
508
+ );
509
+ let override_layer = parse_toml(
510
+ "[shell_environment_policy.set]\n\
511
+ STRIPE_API_KEY = 'previous'\n\
512
+ STRIPE_HOST = 'previous.example'",
513
+ );
514
+ let final_override_layer = parse_toml(
515
+ "[shell_environment_policy.set]\n\
516
+ stripe_api_key = 'trusted'\n\
517
+ stripe_host = 'current.example'",
518
+ );
519
+ let provider_layer = parse_toml(
520
+ "[features.network_proxy.credentials.stripe]\n\
521
+ env = ['STRIPE_API_KEY']\n\
522
+ url_prefix_from_env = 'STRIPE_HOST'",
523
+ );
524
+
525
+ merge_toml_values(&mut base, &override_layer);
526
+ merge_toml_values(&mut base, &final_override_layer);
527
+ merge_toml_values(&mut base, &provider_layer);
528
+
529
+ assert_eq!(
530
+ base["shell_environment_policy"]["set"],
531
+ parse_toml(
532
+ "stripe_api_key = 'trusted'\n\
533
+ stripe_host = 'current.example'\n\
534
+ STRIPE_API_KEY = 'previous'\n\
535
+ STRIPE_HOST = 'previous.example'",
536
+ )
537
+ );
538
+ }
539
+
540
+ #[test]
541
+ fn shell_environment_policy_filters_overlay_merges_by_key_case_insensitively() {
542
+ let mut base = parse_toml(
543
+ r#"
544
+ [shell_environment_policy.filters]
545
+ "FLIP_*" = "exclude"
546
+ "KEEP_*" = "include"
547
+ "#,
548
+ );
549
+ let overlay = parse_toml(
550
+ r#"
551
+ [shell_environment_policy.filters]
552
+ "ADD_*" = "exclude"
553
+ "flip_*" = "include"
554
+ "#,
555
+ );
556
+
557
+ merge_toml_values(&mut base, &overlay);
558
+
559
+ assert_eq!(
560
+ base,
561
+ parse_toml(
562
+ r#"
563
+ [shell_environment_policy.filters]
564
+ "add_*" = "exclude"
565
+ "flip_*" = "include"
566
+ "keep_*" = "include"
567
+ "#,
568
+ )
569
+ );
570
+ }
571
+
572
+ #[test]
573
+ fn shell_environment_policy_filters_overlay_merges_unicode_keys_case_insensitively() {
574
+ let mut base = parse_toml(
575
+ r#"
576
+ [shell_environment_policy.filters]
577
+ "Π‘Π•ΠšΠ Π•Π’_*" = "exclude"
578
+ "#,
579
+ );
580
+ let overlay = parse_toml(
581
+ r#"
582
+ [shell_environment_policy.filters]
583
+ "сСкрСт_*" = "include"
584
+ "#,
585
+ );
586
+
587
+ merge_toml_values(&mut base, &overlay);
588
+
589
+ assert_eq!(base, overlay);
590
+ }
591
+
592
+ #[test]
593
+ fn shell_environment_policy_filters_replace_lower_legacy_filter_fields() {
594
+ let mut base = parse_toml(
595
+ r#"
596
+ [shell_environment_policy]
597
+ inherit = "core"
598
+ exclude = ["FLIP_TO_INCLUDE", "KEEP_EXCLUDED"]
599
+ include_only = ["FLIP_TO_EXCLUDE", "KEEP_INCLUDED"]
600
+ "#,
601
+ );
602
+ let overlay = parse_toml(
603
+ r#"
604
+ [shell_environment_policy.filters]
605
+ "ADD_INCLUDED" = "include"
606
+ "FLIP_TO_EXCLUDE" = "exclude"
607
+ "FLIP_TO_INCLUDE" = "include"
608
+ "#,
609
+ );
610
+
611
+ merge_toml_values(&mut base, &overlay);
612
+
613
+ assert_eq!(
614
+ base,
615
+ parse_toml(
616
+ r#"
617
+ [shell_environment_policy]
618
+ inherit = "core"
619
+
620
+ [shell_environment_policy.filters]
621
+ "ADD_INCLUDED" = "include"
622
+ "FLIP_TO_EXCLUDE" = "exclude"
623
+ "FLIP_TO_INCLUDE" = "include"
624
+ "#,
625
+ )
626
+ );
627
+ }
628
+
629
+ #[test]
630
+ fn shell_environment_policy_legacy_arrays_replace_lower_filters() {
631
+ let mut base = parse_toml(
632
+ r#"
633
+ [shell_environment_policy]
634
+ inherit = "core"
635
+
636
+ [shell_environment_policy.filters]
637
+ "FLIP_TO_EXCLUDE" = "include"
638
+ "LOW_EXCLUDED" = "exclude"
639
+ "KEEP_INCLUDED" = "include"
640
+ "#,
641
+ );
642
+ let overlay = parse_toml(
643
+ r#"
644
+ [shell_environment_policy]
645
+ exclude = ["FLIP_TO_EXCLUDE", "HIGH_EXCLUDED"]
646
+ "#,
647
+ );
648
+
649
+ merge_toml_values(&mut base, &overlay);
650
+
651
+ assert_eq!(
652
+ base,
653
+ parse_toml(
654
+ r#"
655
+ [shell_environment_policy]
656
+ inherit = "core"
657
+ exclude = ["FLIP_TO_EXCLUDE", "HIGH_EXCLUDED"]
658
+ "#,
659
+ )
660
+ );
661
+ }
662
+
663
+ #[test]
664
+ fn empty_shell_environment_filter_representations_replace_the_other_form() {
665
+ let cases = [
666
+ (
667
+ r#"[shell_environment_policy]
668
+ exclude = ["AWS_*"]
669
+ include_only = ["PATH"]
670
+ "#,
671
+ r#"[shell_environment_policy.filters]
672
+ "#,
673
+ ),
674
+ (
675
+ r#"[shell_environment_policy.filters]
676
+ "AWS_*" = "include"
677
+ "#,
678
+ r#"[shell_environment_policy]
679
+ exclude = []
680
+ "#,
681
+ ),
682
+ ];
683
+
684
+ for (base, overlay) in cases {
685
+ let mut base = parse_toml(base);
686
+ let overlay = parse_toml(overlay);
687
+
688
+ merge_toml_values(&mut base, &overlay);
689
+
690
+ assert_eq!(base, overlay);
691
+ }
692
+ }
codex-rs/config/src/model_provider_requirements_tests.rs ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Tests exact provider requirements across layers and config projections.
2
+
3
+ use crate::ConfigLayerEntry;
4
+ use crate::ConfigLayerSource;
5
+ use crate::ConfigLayerStack;
6
+ use crate::ConfigRequirements;
7
+ use crate::ConfigRequirementsToml;
8
+ use crate::RequirementSource;
9
+ use crate::RequirementsLayerEntry;
10
+ use crate::compose_requirements;
11
+ use pretty_assertions::assert_eq;
12
+
13
+ const REQUIRED: &str = r#"
14
+ model_provider = "gateway"
15
+ [model_providers.gateway]
16
+ name = "Managed gateway"
17
+ base_url = "https://gateway.example.test/v1"
18
+ requires_openai_auth = true
19
+ [model_providers.gateway.http_headers]
20
+ X-Managed = "yes"
21
+ "#;
22
+
23
+ #[test]
24
+ fn higher_requirements_merge_provider_fields_and_nested_tables() -> anyhow::Result<()> {
25
+ let cloud_source = RequirementSource::EnterpriseManaged {
26
+ id: "req_1".into(),
27
+ name: "Gateway".into(),
28
+ };
29
+ let requirements = compose_requirements([
30
+ RequirementsLayerEntry::from_toml(
31
+ RequirementSource::Unknown,
32
+ r#"
33
+ model_provider = "other"
34
+ [model_providers.gateway]
35
+ name = "System gateway"
36
+ base_url = "https://old.example.test"
37
+ env_key = "OLD_KEY"
38
+ supports_websockets = true
39
+ [model_providers.gateway.http_headers]
40
+ X-Old = "no"
41
+ X-Managed = "old"
42
+ [model_providers.other]
43
+ name = "Other provider"
44
+ "#,
45
+ ),
46
+ RequirementsLayerEntry::from_toml(cloud_source.clone(), REQUIRED),
47
+ ])?
48
+ .expect("provider requirements must not be discarded");
49
+ let normalized = ConfigRequirements::try_from(requirements.clone())?;
50
+ assert_eq!(
51
+ normalized
52
+ .model_provider
53
+ .as_ref()
54
+ .map(|requirement| &requirement.source),
55
+ Some(&cloud_source)
56
+ );
57
+ let requirements = requirements.into_toml();
58
+ let expected: ConfigRequirementsToml = toml::from_str(
59
+ r#"
60
+ model_provider = "gateway"
61
+ [model_providers.gateway]
62
+ name = "Managed gateway"
63
+ base_url = "https://gateway.example.test/v1"
64
+ requires_openai_auth = true
65
+ env_key = "OLD_KEY"
66
+ supports_websockets = true
67
+ [model_providers.gateway.http_headers]
68
+ X-Managed = "yes"
69
+ X-Old = "no"
70
+ [model_providers.other]
71
+ name = "Other provider"
72
+ "#,
73
+ )?;
74
+ assert_eq!(requirements, expected);
75
+ assert_eq!(
76
+ normalized
77
+ .model_providers
78
+ .map(|requirement| requirement.value),
79
+ requirements.model_providers
80
+ );
81
+ Ok(())
82
+ }
83
+
84
+ #[test]
85
+ fn required_provider_definitions_are_validated_without_local_defaults() -> anyhow::Result<()> {
86
+ for invalid in [
87
+ "[model_providers.gateway]\nbase_url = 'https://example.test'",
88
+ "[model_providers.openai]\nname = 'Reserved'",
89
+ "[model_providers.gateway]\nname = 'Gateway'\n[model_providers.gateway.aws]\nregion = 'us-east-1'",
90
+ ] {
91
+ let requirements = toml::from_str(invalid)?;
92
+ let local =
93
+ ConfigLayerEntry::new(ConfigLayerSource::SessionFlags, toml::from_str(REQUIRED)?);
94
+ let error = ConfigLayerStack::new(vec![local], ConfigRequirements::default(), requirements)
95
+ .expect_err("local config cannot repair an invalid required provider");
96
+ assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
97
+ }
98
+ Ok(())
99
+ }
codex-rs/config/src/overrides.rs ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::merge::is_structured_feature_path;
2
+ use crate::merge::merge_toml_values;
3
+ use toml::Value as TomlValue;
4
+
5
+ pub(crate) fn default_empty_table() -> TomlValue {
6
+ TomlValue::Table(Default::default())
7
+ }
8
+
9
+ pub fn build_cli_overrides_layer(cli_overrides: &[(String, TomlValue)]) -> TomlValue {
10
+ let mut root = default_empty_table();
11
+ for (path, value) in cli_overrides {
12
+ apply_toml_override(&mut root, path, value.clone());
13
+ }
14
+ root
15
+ }
16
+
17
+ /// Apply a single dotted-path override onto a TOML value.
18
+ fn apply_toml_override(root: &mut TomlValue, path: &str, value: TomlValue) {
19
+ use toml::value::Table;
20
+
21
+ let mut current = root;
22
+ let mut segments_iter = path.split('.').peekable();
23
+ let mut traversed_segments = Vec::new();
24
+
25
+ while let Some(segment) = segments_iter.next() {
26
+ traversed_segments.push(segment);
27
+ let is_last = segments_iter.peek().is_none();
28
+
29
+ if is_last {
30
+ match current {
31
+ TomlValue::Table(table) => {
32
+ if is_structured_feature_path(&traversed_segments)
33
+ && let Some(existing) = table.get_mut(segment)
34
+ {
35
+ match (&mut *existing, &value) {
36
+ (TomlValue::Table(feature), TomlValue::Boolean(enabled)) => {
37
+ feature.insert("enabled".to_string(), TomlValue::Boolean(*enabled));
38
+ return;
39
+ }
40
+ (TomlValue::Boolean(enabled), TomlValue::Table(_)) => {
41
+ *existing = TomlValue::Table(Table::from_iter([(
42
+ "enabled".to_string(),
43
+ TomlValue::Boolean(*enabled),
44
+ )]));
45
+ merge_toml_values(existing, &value);
46
+ return;
47
+ }
48
+ (TomlValue::Table(_), TomlValue::Table(_)) => {
49
+ merge_toml_values(existing, &value);
50
+ return;
51
+ }
52
+ (
53
+ TomlValue::String(_)
54
+ | TomlValue::Integer(_)
55
+ | TomlValue::Float(_)
56
+ | TomlValue::Boolean(_)
57
+ | TomlValue::Datetime(_)
58
+ | TomlValue::Array(_)
59
+ | TomlValue::Table(_),
60
+ _,
61
+ ) => {}
62
+ }
63
+ }
64
+ table.insert(segment.to_string(), value);
65
+ }
66
+ _ => {
67
+ let mut table = Table::new();
68
+ table.insert(segment.to_string(), value);
69
+ *current = TomlValue::Table(table);
70
+ }
71
+ }
72
+ return;
73
+ }
74
+
75
+ match current {
76
+ TomlValue::Table(table) => {
77
+ current = table
78
+ .entry(segment.to_string())
79
+ .or_insert_with(|| TomlValue::Table(Table::new()));
80
+ if is_structured_feature_path(&traversed_segments)
81
+ && let TomlValue::Boolean(enabled) = current
82
+ {
83
+ *current = TomlValue::Table(Table::from_iter([(
84
+ "enabled".to_string(),
85
+ TomlValue::Boolean(*enabled),
86
+ )]));
87
+ }
88
+ }
89
+ _ => {
90
+ *current = TomlValue::Table(Table::new());
91
+ if let TomlValue::Table(tbl) = current {
92
+ current = tbl
93
+ .entry(segment.to_string())
94
+ .or_insert_with(|| TomlValue::Table(Table::new()));
95
+ }
96
+ }
97
+ }
98
+ }
99
+ }
codex-rs/config/src/path_context.rs ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Path facts for configuration parsing. Native callers and callers resolving
2
+ //! another platform use the same resolver after fact capture.
3
+
4
+ use codex_utils_absolute_path::AbsolutePathBufGuard;
5
+ use codex_utils_path_uri::LegacyAppPathStringError;
6
+ use codex_utils_path_uri::PathConvention;
7
+ use codex_utils_path_uri::PathUri;
8
+ use std::cell::RefCell;
9
+
10
+ /// The owning environment's path convention, base directory, and home for configuration.
11
+ #[derive(Clone, Debug)]
12
+ pub struct ConfigPathContext {
13
+ convention: PathConvention,
14
+ base_dir: Option<PathUri>,
15
+ user_home_dir: Option<PathUri>,
16
+ }
17
+
18
+ impl ConfigPathContext {
19
+ /// Supplies the grammar and directories of the environment owning the layer.
20
+ /// Relative paths require a base; home-relative paths require a home directory.
21
+ pub fn new(
22
+ convention: PathConvention,
23
+ base_dir: Option<PathUri>,
24
+ user_home_dir: Option<PathUri>,
25
+ ) -> Self {
26
+ Self {
27
+ convention,
28
+ base_dir,
29
+ user_home_dir,
30
+ }
31
+ }
32
+
33
+ /// Returns the path grammar supplied by the owning environment.
34
+ pub fn convention(&self) -> PathConvention {
35
+ self.convention
36
+ }
37
+
38
+ /// Resolves configuration text to a URI using the supplied directory and home facts.
39
+ pub fn resolve_path(&self, input: &str) -> Result<PathUri, LegacyAppPathStringError> {
40
+ PathUri::resolve_config_path_uri(
41
+ input,
42
+ self.convention,
43
+ self.base_dir.as_ref(),
44
+ self.user_home_dir.as_ref(),
45
+ )
46
+ }
47
+
48
+ /// Resolves against a supplied base using this context's grammar and home.
49
+ pub fn resolve_against(
50
+ &self,
51
+ input: &str,
52
+ base: &PathUri,
53
+ ) -> Result<PathUri, LegacyAppPathStringError> {
54
+ PathUri::resolve_config_path_uri(
55
+ input,
56
+ self.convention,
57
+ Some(base),
58
+ self.user_home_dir.as_ref(),
59
+ )
60
+ }
61
+
62
+ pub(crate) fn enter(&self) -> PathContextGuard {
63
+ PathContextGuard(PATH_CONTEXT.with(|current| current.replace(Some(self.clone()))))
64
+ }
65
+ }
66
+
67
+ thread_local! {
68
+ static PATH_CONTEXT: RefCell<Option<ConfigPathContext>> = const { RefCell::new(None) };
69
+ }
70
+
71
+ pub(crate) struct PathContextGuard(Option<ConfigPathContext>);
72
+
73
+ impl Drop for PathContextGuard {
74
+ fn drop(&mut self) {
75
+ PATH_CONTEXT.with(|current| *current.borrow_mut() = self.0.take());
76
+ }
77
+ }
78
+
79
+ pub(crate) fn convention() -> PathConvention {
80
+ PATH_CONTEXT.with(|current| {
81
+ current
82
+ .borrow()
83
+ .as_ref()
84
+ .map_or_else(PathConvention::native, |context| context.convention)
85
+ })
86
+ }
87
+
88
+ pub(crate) fn resolve(input: &str) -> Result<String, String> {
89
+ let context = match PATH_CONTEXT.with(|current| current.borrow().clone()) {
90
+ Some(context) => context,
91
+ None => {
92
+ let convention = PathConvention::native();
93
+ let base = AbsolutePathBufGuard::deserialization_base(std::path::Path::new(input))
94
+ .map_err(str::to_owned)?
95
+ .map(PathUri::from_host_native_path)
96
+ .transpose()
97
+ .map_err(|error| error.to_string())?;
98
+ let home = convention
99
+ .home_relative_suffix(input)
100
+ .and_then(|_| AbsolutePathBufGuard::home_directory())
101
+ .map(PathUri::from_host_native_path)
102
+ .transpose()
103
+ .map_err(|error| error.to_string())?;
104
+ ConfigPathContext::new(convention, base, home)
105
+ }
106
+ };
107
+ context
108
+ .resolve_path(input)
109
+ .and_then(|path| path.to_config_path_string(context.convention()))
110
+ .map_err(|error| error.to_string())
111
+ }
112
+
113
+ #[cfg(test)]
114
+ #[path = "path_context_tests.rs"]
115
+ mod tests;
codex-rs/config/src/path_context_tests.rs ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Contracts for layer-owned denial facts, parser scoping, and policy conversion.
2
+
3
+ use super::ConfigPathContext;
4
+ use crate::FilesystemConstraints;
5
+ use crate::FilesystemDenyReadPattern;
6
+ use crate::RequirementSource;
7
+ use crate::RequirementsLayerEntry;
8
+ use crate::compose_requirements_for_hostname;
9
+ use codex_protocol::permissions::FileSystemAccessMode;
10
+ use codex_protocol::permissions::FileSystemPath;
11
+ use codex_protocol::permissions::FileSystemSandboxEntry;
12
+ use codex_protocol::permissions::FileSystemSandboxKind;
13
+ use codex_protocol::permissions::FileSystemSandboxPolicy;
14
+ use codex_utils_absolute_path::AbsolutePathBufGuard;
15
+ use codex_utils_path_uri::PathConvention;
16
+ use codex_utils_path_uri::PathUri;
17
+ use pretty_assertions::assert_eq;
18
+ use tempfile::tempdir;
19
+
20
+ #[test]
21
+ fn composition_applies_each_layers_base_and_home_to_policy() {
22
+ let native_home = tempdir().expect("native home");
23
+ let first = RequirementsLayerEntry::from_toml(
24
+ RequirementSource::Unknown,
25
+ "[permissions.filesystem]\ndeny_read = ['private', '~/secret', 'private', 'C:\\', '\\\\server\\share\\', 'private/*.key']",
26
+ )
27
+ .with_path_context(ConfigPathContext::new(
28
+ PathConvention::Windows,
29
+ Some(PathUri::parse("file:///C:/first").expect("first base")),
30
+ Some(PathUri::parse("file:///C:/Users/first").expect("first home")),
31
+ ));
32
+ let second = RequirementsLayerEntry::from_toml_value(
33
+ RequirementSource::Unknown,
34
+ toml::toml! { [permissions.filesystem] deny_read = ["private", "~/secret"] }.into(),
35
+ )
36
+ .with_path_context(ConfigPathContext::new(
37
+ PathConvention::Windows,
38
+ Some(PathUri::parse("file:///D:/second").expect("second base")),
39
+ Some(PathUri::parse("file:///D:/Users/second").expect("second home")),
40
+ ));
41
+ let composed = AbsolutePathBufGuard::with_home_directory(native_home.path(), || {
42
+ compose_requirements_for_hostname([first, second], /*hostname*/ None)
43
+ .expect("compose layers")
44
+ .expect("requirements present")
45
+ });
46
+ let constraints = FilesystemConstraints::from(composed.permissions.expect("permissions").value);
47
+ let mut expected = FileSystemSandboxPolicy {
48
+ kind: FileSystemSandboxKind::Restricted,
49
+ glob_scan_max_depth: Some(7),
50
+ entries: vec![FileSystemSandboxEntry::new(
51
+ PathUri::parse("file:///C:/allowed")
52
+ .expect("existing path")
53
+ .into(),
54
+ FileSystemAccessMode::Write,
55
+ )],
56
+ };
57
+ let mut policy = expected.clone();
58
+ expected.entries.extend(
59
+ [
60
+ "file:///D:/second/private",
61
+ "file:///D:/Users/second/secret",
62
+ "file:///C:/first/private",
63
+ "file:///C:/Users/first/secret",
64
+ "file:///C:/",
65
+ "file://server/share",
66
+ ]
67
+ .map(|path| {
68
+ FileSystemSandboxEntry::new(
69
+ PathUri::parse(path).expect("expected denial").into(),
70
+ FileSystemAccessMode::Deny,
71
+ )
72
+ }),
73
+ );
74
+ expected.entries.push(FileSystemSandboxEntry::new(
75
+ FileSystemPath::GlobPattern {
76
+ pattern: r"C:\first\private/*.key".to_string(),
77
+ },
78
+ FileSystemAccessMode::Deny,
79
+ ));
80
+ constraints
81
+ .apply_to_policy(&mut policy, PathConvention::Windows)
82
+ .expect("apply denials");
83
+ constraints
84
+ .apply_to_policy(&mut policy, PathConvention::Windows)
85
+ .expect("deduplicate existing denials");
86
+ assert_eq!(policy, expected);
87
+ assert!(
88
+ constraints
89
+ .apply_to_policy(&mut policy, PathConvention::Posix)
90
+ .is_err(),
91
+ "a foreign denial cannot be reinterpreted using the host convention",
92
+ );
93
+ assert_eq!(policy, expected);
94
+ }
95
+
96
+ #[test]
97
+ fn common_parser_uses_supplied_windows_drive_and_glob_separators() {
98
+ let context = ConfigPathContext::new(
99
+ PathConvention::Windows,
100
+ Some(PathUri::parse("file:///C:/base/cwd").expect("base")),
101
+ /*user_home_dir*/ None,
102
+ );
103
+ let _guard = context.enter();
104
+ for (input, expected) in [
105
+ (r"C:private\*.key", r"C:\base\cwd\private/*.key"),
106
+ (r"D:\*.key", r"D:\/*.key"),
107
+ (r"\private\*.key", r"C:\private/*.key"),
108
+ ] {
109
+ assert_eq!(
110
+ FilesystemDenyReadPattern::from_input(input)
111
+ .expect("resolve pattern")
112
+ .as_str(),
113
+ expected,
114
+ );
115
+ }
116
+ }
117
+
118
+ #[test]
119
+ fn windows_denial_globs_reject_ambiguous_roots_and_streams() {
120
+ let context = ConfigPathContext::new(
121
+ PathConvention::Windows,
122
+ Some(PathUri::parse("file://server/share/base").expect("UNC base")),
123
+ /*user_home_dir*/ None,
124
+ );
125
+ let _guard = context.enter();
126
+ for input in [
127
+ r"\\?\C:\private",
128
+ r"\\*\share\private",
129
+ r"D:*.key",
130
+ r"private\*\file:stream",
131
+ ] {
132
+ assert!(
133
+ FilesystemDenyReadPattern::from_input(input).is_err(),
134
+ "{input}"
135
+ );
136
+ }
137
+ }
138
+
139
+ #[test]
140
+ fn failed_nested_composition_restores_outer_context_and_then_native_resolution() {
141
+ let base = tempdir().expect("native base");
142
+ let _native_guard = AbsolutePathBufGuard::new(base.path());
143
+ let native = FilesystemDenyReadPattern::from_input("private");
144
+ {
145
+ let outer = ConfigPathContext::new(
146
+ PathConvention::Posix,
147
+ Some(PathUri::parse("file:///outer").expect("outer base")),
148
+ /*user_home_dir*/ None,
149
+ );
150
+ let _outer_guard = outer.enter();
151
+ let inner = RequirementsLayerEntry::from_toml(
152
+ RequirementSource::Unknown,
153
+ "[permissions.filesystem]\ndeny_read = ['relative']",
154
+ )
155
+ .with_path_context(ConfigPathContext::new(
156
+ PathConvention::Windows,
157
+ /*base_dir*/ None,
158
+ /*user_home_dir*/ None,
159
+ ));
160
+ assert!(compose_requirements_for_hostname([inner], /*hostname*/ None).is_err());
161
+ assert_eq!(
162
+ FilesystemDenyReadPattern::from_input("private")
163
+ .expect("outer context restored")
164
+ .as_str(),
165
+ "/outer/private",
166
+ );
167
+ }
168
+ assert_eq!(FilesystemDenyReadPattern::from_input("private"), native);
169
+ }
170
+
171
+ #[test]
172
+ fn native_guard_and_supplied_native_facts_use_the_same_parser() {
173
+ let base = tempdir().expect("base");
174
+ let home = tempdir().expect("home");
175
+ let _base_guard = AbsolutePathBufGuard::new(base.path());
176
+ AbsolutePathBufGuard::with_home_directory(home.path(), || {
177
+ let inputs = ["./private", "~/secret/*.txt"];
178
+ let native = inputs.map(FilesystemDenyReadPattern::from_input);
179
+ let context = ConfigPathContext::new(
180
+ PathConvention::native(),
181
+ Some(PathUri::from_host_native_path(base.path()).expect("native base URI")),
182
+ Some(PathUri::from_host_native_path(home.path()).expect("native home URI")),
183
+ );
184
+ let _context_guard = context.enter();
185
+ assert_eq!(inputs.map(FilesystemDenyReadPattern::from_input), native);
186
+ });
187
+ }
188
+
189
+ #[test]
190
+ fn missing_supplied_home_does_not_fall_back_to_native_home() {
191
+ let home = tempdir().expect("native home");
192
+ let layer = RequirementsLayerEntry::from_toml(
193
+ RequirementSource::Unknown,
194
+ "[permissions.filesystem]\ndeny_read = ['~/secret']",
195
+ )
196
+ .with_path_context(ConfigPathContext::new(
197
+ PathConvention::Windows,
198
+ Some(PathUri::parse("file:///C:/base").expect("base")),
199
+ /*user_home_dir*/ None,
200
+ ));
201
+ AbsolutePathBufGuard::with_home_directory(home.path(), || {
202
+ assert!(compose_requirements_for_hostname([layer], /*hostname*/ None).is_err());
203
+ });
204
+ }
205
+
206
+ #[test]
207
+ fn denial_resolution_rejects_glob_syntax_in_supplied_facts() {
208
+ for directory in ["file:///home/sam[1]", "file:///C:/Users/sam[1]"] {
209
+ let directory = PathUri::parse(directory).unwrap();
210
+ let context = ConfigPathContext::new(
211
+ directory.infer_path_convention().unwrap(),
212
+ Some(directory.clone()),
213
+ Some(directory),
214
+ );
215
+ // Literal denials also reject metacharacters introduced by path facts.
216
+ for input in [
217
+ "private/*.key",
218
+ "~/private/*.key",
219
+ "private/key",
220
+ "~/private/key",
221
+ ] {
222
+ let layer = RequirementsLayerEntry::from_toml(
223
+ RequirementSource::Unknown,
224
+ format!("[permissions.filesystem]\ndeny_read = ['{input}']"),
225
+ )
226
+ .with_path_context(context.clone());
227
+ assert!(compose_requirements_for_hostname([layer], /*hostname*/ None).is_err());
228
+ }
229
+ }
230
+ }
231
+
232
+ #[test]
233
+ fn native_and_supplied_denials_reject_the_same_unsafe_facts() {
234
+ let parent = tempdir().unwrap();
235
+ let directory = parent.path().join("sam[1]");
236
+ let _base_guard = AbsolutePathBufGuard::new(&directory);
237
+ AbsolutePathBufGuard::with_home_directory(&directory, || {
238
+ let inputs = [
239
+ "private/*.key",
240
+ "~/private/*.key",
241
+ "private/key",
242
+ "~/private/key",
243
+ ];
244
+ let native = inputs.map(FilesystemDenyReadPattern::from_input);
245
+ assert!(native.iter().all(Result::is_err));
246
+ let directory = PathUri::from_host_native_path(&directory).unwrap();
247
+ let context = ConfigPathContext::new(
248
+ PathConvention::native(),
249
+ Some(directory.clone()),
250
+ Some(directory),
251
+ );
252
+ let _context_guard = context.enter();
253
+ assert_eq!(inputs.map(FilesystemDenyReadPattern::from_input), native);
254
+ });
255
+ }
codex-rs/config/src/permissions_toml.rs ADDED
@@ -0,0 +1,600 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::collections::BTreeMap;
2
+
3
+ use crate::merge::merge_toml_values;
4
+ use codex_network_proxy::InjectedHeaderConfig;
5
+ use codex_network_proxy::MitmHookActionsConfig;
6
+ use codex_network_proxy::MitmHookBodyConfig;
7
+ use codex_network_proxy::MitmHookConfig;
8
+ use codex_network_proxy::MitmHookMatchConfig;
9
+ use codex_network_proxy::NetworkDomainPermission as ProxyNetworkDomainPermission;
10
+ use codex_network_proxy::NetworkMode;
11
+ use codex_network_proxy::NetworkProxyConfig;
12
+ use codex_network_proxy::NetworkUnixSocketPermission as ProxyNetworkUnixSocketPermission;
13
+ use codex_network_proxy::normalize_host;
14
+ use codex_protocol::permissions::FileSystemAccessMode;
15
+ use indexmap::IndexMap;
16
+ use schemars::JsonSchema;
17
+ use serde::Deserialize;
18
+ use serde::Serialize;
19
+ use thiserror::Error;
20
+ use toml::Value as TomlValue;
21
+
22
+ #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
23
+ pub struct PermissionsToml {
24
+ #[serde(flatten)]
25
+ pub entries: BTreeMap<String, PermissionProfileToml>,
26
+ }
27
+
28
+ impl PermissionsToml {
29
+ pub fn is_empty(&self) -> bool {
30
+ self.entries.is_empty()
31
+ }
32
+
33
+ /// Resolve `profile_name` and all of its `extends` ancestors into one TOML
34
+ /// profile.
35
+ ///
36
+ /// Parent profiles are merged before their children, so child keys override
37
+ /// matching parent keys before callers compile the profile into runtime
38
+ /// permissions. The returned profile keeps the selected profile's
39
+ /// declaration metadata, such as `description` and `extends`.
40
+ pub fn resolve_profile<F>(
41
+ &self,
42
+ profile_name: &str,
43
+ mut parent_profile: F,
44
+ ) -> Result<PermissionProfileToml, PermissionProfileResolutionError>
45
+ where
46
+ F: FnMut(&str) -> Option<PermissionProfileToml>,
47
+ {
48
+ let mut profile_names = Vec::new();
49
+ let mut profiles = Vec::new();
50
+ let mut next_profile_name = profile_name.to_string();
51
+ let mut referenced_by: Option<String> = None;
52
+
53
+ loop {
54
+ if let Some(cycle_start) = profile_names
55
+ .iter()
56
+ .position(|name| name == &next_profile_name)
57
+ {
58
+ let cycle = profile_names[cycle_start..]
59
+ .iter()
60
+ .cloned()
61
+ .chain(std::iter::once(next_profile_name))
62
+ .collect::<Vec<_>>();
63
+ return Err(PermissionProfileResolutionError::Cycle { cycle });
64
+ }
65
+
66
+ let profile = self
67
+ .entries
68
+ .get(&next_profile_name)
69
+ .cloned()
70
+ .or_else(|| parent_profile(&next_profile_name))
71
+ .ok_or_else(|| {
72
+ referenced_by.as_deref().map_or_else(
73
+ || PermissionProfileResolutionError::UndefinedProfile {
74
+ profile_name: next_profile_name.clone(),
75
+ },
76
+ |referenced_by| {
77
+ if next_profile_name.starts_with(':') {
78
+ PermissionProfileResolutionError::UnsupportedBuiltInParent {
79
+ profile_name: referenced_by.to_string(),
80
+ parent_profile_name: next_profile_name.clone(),
81
+ }
82
+ } else {
83
+ PermissionProfileResolutionError::UndefinedParent {
84
+ profile_name: referenced_by.to_string(),
85
+ parent_profile_name: next_profile_name.clone(),
86
+ }
87
+ }
88
+ },
89
+ )
90
+ })?;
91
+ let parent_profile_name = profile.extends.clone();
92
+
93
+ profile_names.push(next_profile_name.clone());
94
+
95
+ if let Some(parent_profile_name) = parent_profile_name {
96
+ profiles.push(profile);
97
+ referenced_by = Some(next_profile_name);
98
+ next_profile_name = parent_profile_name;
99
+ continue;
100
+ }
101
+
102
+ let profile = profiles
103
+ .into_iter()
104
+ .rev()
105
+ .try_fold(profile, merge_permission_profiles)?;
106
+ return Ok(profile);
107
+ }
108
+ }
109
+ }
110
+
111
+ #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
112
+ #[schemars(deny_unknown_fields)]
113
+ pub struct PermissionProfileToml {
114
+ pub description: Option<String>,
115
+ pub extends: Option<String>,
116
+ pub workspace_roots: Option<WorkspaceRootsToml>,
117
+ pub filesystem: Option<FilesystemPermissionsToml>,
118
+ pub network: Option<NetworkToml>,
119
+ }
120
+
121
+ #[derive(Debug, Clone, PartialEq, Eq, Error)]
122
+ pub enum PermissionProfileResolutionError {
123
+ #[error("default_permissions refers to undefined profile `{profile_name}`")]
124
+ UndefinedProfile { profile_name: String },
125
+ #[error(
126
+ "permissions profile `{profile_name}` extends undefined profile `{parent_profile_name}`"
127
+ )]
128
+ UndefinedParent {
129
+ profile_name: String,
130
+ parent_profile_name: String,
131
+ },
132
+ #[error(
133
+ "permissions profile `{profile_name}` cannot extend unsupported built-in profile `{parent_profile_name}`"
134
+ )]
135
+ UnsupportedBuiltInParent {
136
+ profile_name: String,
137
+ parent_profile_name: String,
138
+ },
139
+ #[error(
140
+ "permissions profile inheritance cycle detected: {}",
141
+ cycle.join(" -> ")
142
+ )]
143
+ Cycle { cycle: Vec<String> },
144
+ #[error("failed to serialize permissions profile while resolving inheritance: {source}")]
145
+ SerializeProfileToml {
146
+ #[source]
147
+ source: toml::ser::Error,
148
+ },
149
+ #[error(
150
+ "failed to deserialize merged permissions profile while resolving inheritance: {source}"
151
+ )]
152
+ DeserializeProfileToml {
153
+ #[source]
154
+ source: toml::de::Error,
155
+ },
156
+ }
157
+
158
+ fn merge_permission_profiles(
159
+ mut parent: PermissionProfileToml,
160
+ mut child: PermissionProfileToml,
161
+ ) -> Result<PermissionProfileToml, PermissionProfileResolutionError> {
162
+ let merges_network_domains = parent
163
+ .network
164
+ .as_ref()
165
+ .and_then(|network| network.domains.as_ref())
166
+ .is_some()
167
+ && child
168
+ .network
169
+ .as_ref()
170
+ .and_then(|network| network.domains.as_ref())
171
+ .is_some();
172
+
173
+ // Description and inheritance metadata belong to the selected profile
174
+ // declaration, so an inherited profile must not fill those gaps.
175
+ parent.description = None;
176
+ parent.extends = None;
177
+
178
+ if merges_network_domains {
179
+ normalize_profile_network_domains(&mut parent);
180
+ normalize_profile_network_domains(&mut child);
181
+ }
182
+
183
+ let mut merged = TomlValue::try_from(parent)
184
+ .map_err(|source| PermissionProfileResolutionError::SerializeProfileToml { source })?;
185
+ let child = TomlValue::try_from(child)
186
+ .map_err(|source| PermissionProfileResolutionError::SerializeProfileToml { source })?;
187
+ merge_toml_values(&mut merged, &child);
188
+ merged
189
+ .try_into()
190
+ .map_err(|source| PermissionProfileResolutionError::DeserializeProfileToml { source })
191
+ }
192
+
193
+ fn normalize_profile_network_domains(profile: &mut PermissionProfileToml) {
194
+ let Some(domains) = profile
195
+ .network
196
+ .as_mut()
197
+ .and_then(|network| network.domains.as_mut())
198
+ else {
199
+ return;
200
+ };
201
+
202
+ let entries = std::mem::take(&mut domains.entries);
203
+ domains.entries = entries
204
+ .into_iter()
205
+ .map(|(pattern, permission)| (normalize_host(&pattern), permission))
206
+ .collect();
207
+ }
208
+
209
+ #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
210
+ pub struct WorkspaceRootsToml {
211
+ #[serde(flatten)]
212
+ pub entries: BTreeMap<String, bool>,
213
+ }
214
+
215
+ impl WorkspaceRootsToml {
216
+ pub fn enabled_roots(&self) -> impl Iterator<Item = &String> {
217
+ self.entries
218
+ .iter()
219
+ .filter_map(|(path, enabled)| (*enabled).then_some(path))
220
+ }
221
+ }
222
+
223
+ #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
224
+ pub struct FilesystemPermissionsToml {
225
+ /// Optional maximum depth for expanding unreadable glob patterns on
226
+ /// platforms that snapshot glob matches before sandbox startup.
227
+ #[schemars(range(min = 1))]
228
+ pub glob_scan_max_depth: Option<usize>,
229
+ #[serde(flatten)]
230
+ pub entries: BTreeMap<String, FilesystemPermissionToml>,
231
+ }
232
+
233
+ impl FilesystemPermissionsToml {
234
+ pub fn is_empty(&self) -> bool {
235
+ self.entries.is_empty()
236
+ }
237
+ }
238
+
239
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
240
+ #[serde(untagged)]
241
+ pub enum FilesystemPermissionToml {
242
+ Access(FileSystemAccessMode),
243
+ Scoped(BTreeMap<String, FileSystemAccessMode>),
244
+ }
245
+
246
+ #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
247
+ pub struct NetworkDomainPermissionsToml {
248
+ #[serde(flatten)]
249
+ pub entries: BTreeMap<String, NetworkDomainPermissionToml>,
250
+ }
251
+
252
+ impl NetworkDomainPermissionsToml {
253
+ pub fn allowed_domains(&self) -> Option<Vec<String>> {
254
+ let allowed_domains: Vec<String> = self
255
+ .entries
256
+ .iter()
257
+ .filter(|(_, permission)| matches!(permission, NetworkDomainPermissionToml::Allow))
258
+ .map(|(pattern, _)| pattern.clone())
259
+ .collect();
260
+ (!allowed_domains.is_empty()).then_some(allowed_domains)
261
+ }
262
+
263
+ pub fn denied_domains(&self) -> Option<Vec<String>> {
264
+ let denied_domains: Vec<String> = self
265
+ .entries
266
+ .iter()
267
+ .filter(|(_, permission)| matches!(permission, NetworkDomainPermissionToml::Deny))
268
+ .map(|(pattern, _)| pattern.clone())
269
+ .collect();
270
+ (!denied_domains.is_empty()).then_some(denied_domains)
271
+ }
272
+ }
273
+
274
+ #[derive(
275
+ Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, JsonSchema,
276
+ )]
277
+ #[serde(rename_all = "lowercase")]
278
+ pub enum NetworkDomainPermissionToml {
279
+ Allow,
280
+ Deny,
281
+ }
282
+
283
+ impl std::fmt::Display for NetworkDomainPermissionToml {
284
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
285
+ let permission = match self {
286
+ Self::Allow => "allow",
287
+ Self::Deny => "deny",
288
+ };
289
+ f.write_str(permission)
290
+ }
291
+ }
292
+
293
+ #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
294
+ pub struct NetworkUnixSocketPermissionsToml {
295
+ #[serde(flatten)]
296
+ pub entries: BTreeMap<String, NetworkUnixSocketPermissionToml>,
297
+ }
298
+
299
+ impl NetworkUnixSocketPermissionsToml {
300
+ pub fn allow_unix_sockets(&self) -> Vec<String> {
301
+ self.entries
302
+ .iter()
303
+ .filter(|(_, permission)| matches!(permission, NetworkUnixSocketPermissionToml::Allow))
304
+ .map(|(path, _)| path.clone())
305
+ .collect()
306
+ }
307
+ }
308
+
309
+ #[derive(
310
+ Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, JsonSchema,
311
+ )]
312
+ #[serde(rename_all = "lowercase")]
313
+ pub enum NetworkUnixSocketPermissionToml {
314
+ Allow,
315
+ Deny,
316
+ }
317
+
318
+ impl std::fmt::Display for NetworkUnixSocketPermissionToml {
319
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
320
+ let permission = match self {
321
+ Self::Allow => "allow",
322
+ Self::Deny => "deny",
323
+ };
324
+ f.write_str(permission)
325
+ }
326
+ }
327
+
328
+ #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
329
+ #[schemars(deny_unknown_fields)]
330
+ pub struct NetworkToml {
331
+ pub enabled: Option<bool>,
332
+ pub proxy_url: Option<String>,
333
+ pub enable_socks5: Option<bool>,
334
+ pub socks_url: Option<String>,
335
+ pub enable_socks5_udp: Option<bool>,
336
+ pub allow_upstream_proxy: Option<bool>,
337
+ pub dangerously_allow_non_loopback_proxy: Option<bool>,
338
+ pub dangerously_allow_all_unix_sockets: Option<bool>,
339
+ #[schemars(with = "Option<NetworkModeSchema>")]
340
+ pub mode: Option<NetworkMode>,
341
+ pub domains: Option<NetworkDomainPermissionsToml>,
342
+ pub unix_sockets: Option<NetworkUnixSocketPermissionsToml>,
343
+ pub allow_local_binding: Option<bool>,
344
+ pub mitm: Option<NetworkMitmToml>,
345
+ }
346
+
347
+ #[derive(Serialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
348
+ #[schemars(deny_unknown_fields)]
349
+ pub struct NetworkMitmToml {
350
+ #[schemars(with = "Option<BTreeMap<String, NetworkMitmHookToml>>")]
351
+ pub hooks: Option<IndexMap<String, NetworkMitmHookToml>>,
352
+ #[schemars(with = "Option<BTreeMap<String, NetworkMitmActionToml>>")]
353
+ pub actions: Option<IndexMap<String, NetworkMitmActionToml>>,
354
+ }
355
+
356
+ #[derive(Deserialize)]
357
+ #[serde(deny_unknown_fields)]
358
+ struct NetworkMitmTomlUnchecked {
359
+ pub hooks: Option<IndexMap<String, NetworkMitmHookToml>>,
360
+ pub actions: Option<IndexMap<String, NetworkMitmActionToml>>,
361
+ }
362
+
363
+ #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
364
+ #[schemars(deny_unknown_fields)]
365
+ pub struct NetworkMitmHookToml {
366
+ pub host: String,
367
+ pub methods: Vec<String>,
368
+ pub path_prefixes: Vec<String>,
369
+ #[serde(default)]
370
+ pub query: BTreeMap<String, Vec<String>>,
371
+ #[serde(default)]
372
+ pub headers: BTreeMap<String, Vec<String>>,
373
+ #[schemars(with = "Option<MitmHookBodyConfigSchema>")]
374
+ pub body: Option<MitmHookBodyConfig>,
375
+ pub action: Vec<String>,
376
+ }
377
+
378
+ #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
379
+ #[serde(rename_all = "lowercase")]
380
+ enum NetworkModeSchema {
381
+ Limited,
382
+ Full,
383
+ }
384
+
385
+ #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
386
+ #[serde(default)]
387
+ pub struct NetworkMitmActionToml {
388
+ pub strip_request_headers: Vec<String>,
389
+ pub inject_request_headers: Vec<NetworkMitmInjectedHeaderToml>,
390
+ }
391
+
392
+ #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
393
+ #[serde(default)]
394
+ pub struct NetworkMitmInjectedHeaderToml {
395
+ pub name: String,
396
+ pub secret_env_var: Option<String>,
397
+ pub secret_file: Option<String>,
398
+ pub prefix: Option<String>,
399
+ }
400
+
401
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
402
+ #[serde(transparent)]
403
+ struct MitmHookBodyConfigSchema(pub serde_json::Value);
404
+
405
+ impl<'de> Deserialize<'de> for NetworkMitmToml {
406
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
407
+ where
408
+ D: serde::Deserializer<'de>,
409
+ {
410
+ let unchecked = NetworkMitmTomlUnchecked::deserialize(deserializer)?;
411
+ let mitm = Self {
412
+ hooks: unchecked.hooks,
413
+ actions: unchecked.actions,
414
+ };
415
+ mitm.validate_action_definitions()
416
+ .map_err(serde::de::Error::custom)?;
417
+ Ok(mitm)
418
+ }
419
+ }
420
+
421
+ impl NetworkMitmToml {
422
+ pub fn validate_action_definitions(&self) -> Result<(), String> {
423
+ if let Some(actions) = self.actions.as_ref() {
424
+ for (action_name, action) in actions {
425
+ if action.is_empty() {
426
+ return Err(format!(
427
+ "network.mitm.actions.{action_name} must define at least one operation"
428
+ ));
429
+ }
430
+ }
431
+ }
432
+
433
+ let Some(hooks) = self.hooks.as_ref() else {
434
+ return Ok(());
435
+ };
436
+
437
+ for (hook_name, hook) in hooks {
438
+ if hook.action.is_empty() {
439
+ return Err(format!(
440
+ "network.mitm.hooks.{hook_name}.action must not be empty"
441
+ ));
442
+ }
443
+ }
444
+
445
+ Ok(())
446
+ }
447
+
448
+ pub fn to_runtime_hooks(
449
+ &self,
450
+ actions_by_name: Option<&IndexMap<String, NetworkMitmActionToml>>,
451
+ ) -> Vec<MitmHookConfig> {
452
+ self.hooks
453
+ .as_ref()
454
+ .map(|hooks| {
455
+ hooks
456
+ .values()
457
+ .map(|hook| hook.to_runtime(actions_by_name))
458
+ .collect()
459
+ })
460
+ .unwrap_or_default()
461
+ }
462
+ }
463
+
464
+ impl NetworkMitmActionToml {
465
+ pub fn is_empty(&self) -> bool {
466
+ self.strip_request_headers.is_empty() && self.inject_request_headers.is_empty()
467
+ }
468
+ }
469
+
470
+ impl NetworkToml {
471
+ pub fn apply_to_network_proxy_config(&self, config: &mut NetworkProxyConfig) {
472
+ if let Some(enabled) = self.enabled {
473
+ config.enabled = enabled;
474
+ }
475
+ if let Some(proxy_url) = self.proxy_url.as_ref() {
476
+ config.proxy_url = proxy_url.clone();
477
+ }
478
+ if let Some(enable_socks5) = self.enable_socks5 {
479
+ config.enable_socks5 = enable_socks5;
480
+ }
481
+ if let Some(socks_url) = self.socks_url.as_ref() {
482
+ config.socks_url = socks_url.clone();
483
+ }
484
+ if let Some(enable_socks5_udp) = self.enable_socks5_udp {
485
+ config.enable_socks5_udp = enable_socks5_udp;
486
+ }
487
+ if let Some(allow_upstream_proxy) = self.allow_upstream_proxy {
488
+ config.allow_upstream_proxy = allow_upstream_proxy;
489
+ }
490
+ if let Some(dangerously_allow_non_loopback_proxy) =
491
+ self.dangerously_allow_non_loopback_proxy
492
+ {
493
+ config.dangerously_allow_non_loopback_proxy = dangerously_allow_non_loopback_proxy;
494
+ }
495
+ if let Some(dangerously_allow_all_unix_sockets) = self.dangerously_allow_all_unix_sockets {
496
+ config.dangerously_allow_all_unix_sockets = dangerously_allow_all_unix_sockets;
497
+ }
498
+ if let Some(mode) = self.mode {
499
+ config.mode = mode;
500
+ }
501
+ if let Some(domains) = self.domains.as_ref() {
502
+ overlay_network_domain_permissions(config, domains);
503
+ }
504
+ if let Some(unix_sockets) = self.unix_sockets.as_ref() {
505
+ let mut proxy_unix_sockets = config.unix_sockets.take().unwrap_or_default();
506
+ for (path, permission) in &unix_sockets.entries {
507
+ let permission = match permission {
508
+ NetworkUnixSocketPermissionToml::Allow => {
509
+ ProxyNetworkUnixSocketPermission::Allow
510
+ }
511
+ NetworkUnixSocketPermissionToml::Deny => ProxyNetworkUnixSocketPermission::Deny,
512
+ };
513
+ proxy_unix_sockets.entries.insert(path.clone(), permission);
514
+ }
515
+ config.unix_sockets =
516
+ (!proxy_unix_sockets.entries.is_empty()).then_some(proxy_unix_sockets);
517
+ }
518
+ if let Some(allow_local_binding) = self.allow_local_binding {
519
+ config.allow_local_binding = allow_local_binding;
520
+ }
521
+ if let Some(mitm) = self.mitm.as_ref() {
522
+ config.mitm_hooks = mitm.to_runtime_hooks(mitm.actions.as_ref());
523
+ }
524
+ config.mitm = config.mode == NetworkMode::Limited || !config.mitm_hooks.is_empty();
525
+ }
526
+
527
+ pub fn to_network_proxy_config(&self) -> NetworkProxyConfig {
528
+ let mut config = NetworkProxyConfig::default();
529
+ self.apply_to_network_proxy_config(&mut config);
530
+ config
531
+ }
532
+ }
533
+
534
+ impl NetworkMitmHookToml {
535
+ fn to_runtime(
536
+ &self,
537
+ actions_by_name: Option<&IndexMap<String, NetworkMitmActionToml>>,
538
+ ) -> MitmHookConfig {
539
+ MitmHookConfig {
540
+ host: self.host.clone(),
541
+ matcher: MitmHookMatchConfig {
542
+ methods: self.methods.clone(),
543
+ path_prefixes: self.path_prefixes.clone(),
544
+ query: self.query.clone(),
545
+ headers: self.headers.clone(),
546
+ body: self.body.clone(),
547
+ },
548
+ actions: self.selected_actions(actions_by_name),
549
+ }
550
+ }
551
+
552
+ fn selected_actions(
553
+ &self,
554
+ actions_by_name: Option<&IndexMap<String, NetworkMitmActionToml>>,
555
+ ) -> MitmHookActionsConfig {
556
+ let Some(actions_by_name) = actions_by_name else {
557
+ return MitmHookActionsConfig::default();
558
+ };
559
+
560
+ let mut selected = MitmHookActionsConfig::default();
561
+ for action_name in &self.action {
562
+ if let Some(action) = actions_by_name.get(action_name) {
563
+ selected
564
+ .strip_request_headers
565
+ .extend(action.strip_request_headers.clone());
566
+ selected.inject_request_headers.extend(
567
+ action
568
+ .inject_request_headers
569
+ .iter()
570
+ .map(NetworkMitmInjectedHeaderToml::to_runtime),
571
+ );
572
+ }
573
+ }
574
+ selected
575
+ }
576
+ }
577
+
578
+ impl NetworkMitmInjectedHeaderToml {
579
+ fn to_runtime(&self) -> InjectedHeaderConfig {
580
+ InjectedHeaderConfig {
581
+ name: self.name.clone(),
582
+ secret_env_var: self.secret_env_var.clone(),
583
+ secret_file: self.secret_file.clone(),
584
+ prefix: self.prefix.clone(),
585
+ }
586
+ }
587
+ }
588
+
589
+ pub fn overlay_network_domain_permissions(
590
+ config: &mut NetworkProxyConfig,
591
+ domains: &NetworkDomainPermissionsToml,
592
+ ) {
593
+ for (pattern, permission) in &domains.entries {
594
+ let permission = match permission {
595
+ NetworkDomainPermissionToml::Allow => ProxyNetworkDomainPermission::Allow,
596
+ NetworkDomainPermissionToml::Deny => ProxyNetworkDomainPermission::Deny,
597
+ };
598
+ config.upsert_domain_permission(pattern.clone(), permission, normalize_host);
599
+ }
600
+ }
codex-rs/config/src/project_root_markers.rs ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::io;
2
+
3
+ use toml::Value as TomlValue;
4
+
5
+ const DEFAULT_PROJECT_ROOT_MARKERS: &[&str] = &[".git"];
6
+
7
+ /// Reads `project_root_markers` from a merged `config.toml` [toml::Value].
8
+ ///
9
+ /// Invariants:
10
+ /// - If `project_root_markers` is not specified, returns `Ok(None)`.
11
+ /// - If `project_root_markers` is specified, returns `Ok(Some(markers))` where
12
+ /// `markers` is a `Vec<String>` (including `Ok(Some(Vec::new()))` for an
13
+ /// empty array, which indicates that root detection should be disabled).
14
+ /// - Returns an error if `project_root_markers` is specified but is not an
15
+ /// array of strings.
16
+ pub fn project_root_markers_from_config(config: &TomlValue) -> io::Result<Option<Vec<String>>> {
17
+ let Some(table) = config.as_table() else {
18
+ return Ok(None);
19
+ };
20
+ let Some(markers_value) = table.get("project_root_markers") else {
21
+ return Ok(None);
22
+ };
23
+ let TomlValue::Array(entries) = markers_value else {
24
+ return Err(io::Error::new(
25
+ io::ErrorKind::InvalidData,
26
+ "project_root_markers must be an array of strings",
27
+ ));
28
+ };
29
+ if entries.is_empty() {
30
+ return Ok(Some(Vec::new()));
31
+ }
32
+ let mut markers = Vec::new();
33
+ for entry in entries {
34
+ let Some(marker) = entry.as_str() else {
35
+ return Err(io::Error::new(
36
+ io::ErrorKind::InvalidData,
37
+ "project_root_markers must be an array of strings",
38
+ ));
39
+ };
40
+ markers.push(marker.to_string());
41
+ }
42
+ Ok(Some(markers))
43
+ }
44
+
45
+ pub fn default_project_root_markers() -> Vec<String> {
46
+ DEFAULT_PROJECT_ROOT_MARKERS
47
+ .iter()
48
+ .map(ToString::to_string)
49
+ .collect()
50
+ }
codex-rs/config/src/requirements_exec_policy.rs ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use codex_execpolicy::Decision;
2
+ use codex_execpolicy::Policy;
3
+ use codex_execpolicy::RequirementsExecPolicy;
4
+ use codex_execpolicy::RuleRef;
5
+ use codex_execpolicy::rule::PatternToken;
6
+ use codex_execpolicy::rule::PrefixPattern;
7
+ use codex_execpolicy::rule::PrefixRule;
8
+ use multimap::MultiMap;
9
+ use serde::Deserialize;
10
+ use std::sync::Arc;
11
+ use thiserror::Error;
12
+
13
+ /// TOML representation of `[rules]` within `requirements.toml`.
14
+ #[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
15
+ pub struct RequirementsExecPolicyToml {
16
+ pub prefix_rules: Vec<RequirementsExecPolicyPrefixRuleToml>,
17
+ }
18
+
19
+ /// A TOML representation of the `prefix_rule(...)` Starlark builtin.
20
+ ///
21
+ /// This mirrors the builtin defined in `execpolicy/src/parser.rs`.
22
+ #[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
23
+ pub struct RequirementsExecPolicyPrefixRuleToml {
24
+ pub pattern: Vec<RequirementsExecPolicyPatternTokenToml>,
25
+ pub decision: Option<RequirementsExecPolicyDecisionToml>,
26
+ pub justification: Option<String>,
27
+ }
28
+
29
+ /// TOML-friendly representation of a pattern token.
30
+ ///
31
+ /// Starlark supports either a string token or a list of alternative tokens at
32
+ /// each position, but TOML arrays cannot mix strings and arrays. Using an
33
+ /// array of tables sidesteps that restriction.
34
+ #[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
35
+ pub struct RequirementsExecPolicyPatternTokenToml {
36
+ pub token: Option<String>,
37
+ pub any_of: Option<Vec<String>>,
38
+ }
39
+
40
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
41
+ #[serde(rename_all = "kebab-case")]
42
+ pub enum RequirementsExecPolicyDecisionToml {
43
+ Allow,
44
+ Prompt,
45
+ Forbidden,
46
+ }
47
+
48
+ impl RequirementsExecPolicyDecisionToml {
49
+ fn as_decision(self) -> Decision {
50
+ match self {
51
+ Self::Allow => Decision::Allow,
52
+ Self::Prompt => Decision::Prompt,
53
+ Self::Forbidden => Decision::Forbidden,
54
+ }
55
+ }
56
+ }
57
+
58
+ #[derive(Debug, Error)]
59
+ pub enum RequirementsExecPolicyParseError {
60
+ #[error("rules prefix_rules cannot be empty")]
61
+ EmptyPrefixRules,
62
+
63
+ #[error("rules prefix_rule at index {rule_index} has an empty pattern")]
64
+ EmptyPattern { rule_index: usize },
65
+
66
+ #[error(
67
+ "rules prefix_rule at index {rule_index} has an invalid pattern token at index {token_index}: {reason}"
68
+ )]
69
+ InvalidPatternToken {
70
+ rule_index: usize,
71
+ token_index: usize,
72
+ reason: String,
73
+ },
74
+
75
+ #[error("rules prefix_rule at index {rule_index} has an empty justification")]
76
+ EmptyJustification { rule_index: usize },
77
+
78
+ #[error("rules prefix_rule at index {rule_index} is missing a decision")]
79
+ MissingDecision { rule_index: usize },
80
+
81
+ #[error(
82
+ "rules prefix_rule at index {rule_index} has decision 'allow', which is not permitted in requirements.toml: Codex merges these rules with other config and uses the most restrictive result (use 'prompt' or 'forbidden')"
83
+ )]
84
+ AllowDecisionNotAllowed { rule_index: usize },
85
+ }
86
+
87
+ impl RequirementsExecPolicyToml {
88
+ /// Convert requirements TOML rules into the internal `.rules`
89
+ /// representation used by `codex-execpolicy`.
90
+ pub fn to_policy(&self) -> Result<Policy, RequirementsExecPolicyParseError> {
91
+ if self.prefix_rules.is_empty() {
92
+ return Err(RequirementsExecPolicyParseError::EmptyPrefixRules);
93
+ }
94
+
95
+ let mut rules_by_program: MultiMap<String, RuleRef> = MultiMap::new();
96
+
97
+ for (rule_index, rule) in self.prefix_rules.iter().enumerate() {
98
+ if let Some(justification) = &rule.justification
99
+ && justification.trim().is_empty()
100
+ {
101
+ return Err(RequirementsExecPolicyParseError::EmptyJustification { rule_index });
102
+ }
103
+
104
+ if rule.pattern.is_empty() {
105
+ return Err(RequirementsExecPolicyParseError::EmptyPattern { rule_index });
106
+ }
107
+
108
+ let pattern_tokens = rule
109
+ .pattern
110
+ .iter()
111
+ .enumerate()
112
+ .map(|(token_index, token)| parse_pattern_token(token, rule_index, token_index))
113
+ .collect::<Result<Vec<_>, _>>()?;
114
+
115
+ let decision = match rule.decision {
116
+ Some(RequirementsExecPolicyDecisionToml::Allow) => {
117
+ return Err(RequirementsExecPolicyParseError::AllowDecisionNotAllowed {
118
+ rule_index,
119
+ });
120
+ }
121
+ Some(decision) => decision.as_decision(),
122
+ None => {
123
+ return Err(RequirementsExecPolicyParseError::MissingDecision { rule_index });
124
+ }
125
+ };
126
+ let justification = rule.justification.clone();
127
+
128
+ let (first_token, remaining_tokens) = pattern_tokens
129
+ .split_first()
130
+ .ok_or(RequirementsExecPolicyParseError::EmptyPattern { rule_index })?;
131
+
132
+ let rest: Arc<[PatternToken]> = remaining_tokens.to_vec().into();
133
+
134
+ for head in first_token.alternatives() {
135
+ let rule: RuleRef = Arc::new(PrefixRule {
136
+ pattern: PrefixPattern {
137
+ first: Arc::from(head.as_str()),
138
+ rest: rest.clone(),
139
+ },
140
+ decision,
141
+ justification: justification.clone(),
142
+ });
143
+ rules_by_program.insert(head.clone(), rule);
144
+ }
145
+ }
146
+
147
+ Ok(Policy::new(rules_by_program))
148
+ }
149
+
150
+ pub(crate) fn to_requirements_policy(
151
+ &self,
152
+ ) -> Result<RequirementsExecPolicy, RequirementsExecPolicyParseError> {
153
+ self.to_policy().map(RequirementsExecPolicy::new)
154
+ }
155
+ }
156
+
157
+ fn parse_pattern_token(
158
+ token: &RequirementsExecPolicyPatternTokenToml,
159
+ rule_index: usize,
160
+ token_index: usize,
161
+ ) -> Result<PatternToken, RequirementsExecPolicyParseError> {
162
+ match (&token.token, &token.any_of) {
163
+ (Some(single), None) => {
164
+ if single.trim().is_empty() {
165
+ return Err(RequirementsExecPolicyParseError::InvalidPatternToken {
166
+ rule_index,
167
+ token_index,
168
+ reason: "token cannot be empty".to_string(),
169
+ });
170
+ }
171
+ Ok(PatternToken::Single(single.clone()))
172
+ }
173
+ (None, Some(alternatives)) => {
174
+ if alternatives.is_empty() {
175
+ return Err(RequirementsExecPolicyParseError::InvalidPatternToken {
176
+ rule_index,
177
+ token_index,
178
+ reason: "any_of cannot be empty".to_string(),
179
+ });
180
+ }
181
+ if alternatives.iter().any(|alt| alt.trim().is_empty()) {
182
+ return Err(RequirementsExecPolicyParseError::InvalidPatternToken {
183
+ rule_index,
184
+ token_index,
185
+ reason: "any_of cannot include empty tokens".to_string(),
186
+ });
187
+ }
188
+ Ok(PatternToken::Alts(alternatives.clone()))
189
+ }
190
+ (Some(_), Some(_)) => Err(RequirementsExecPolicyParseError::InvalidPatternToken {
191
+ rule_index,
192
+ token_index,
193
+ reason: "set either token or any_of, not both".to_string(),
194
+ }),
195
+ (None, None) => Err(RequirementsExecPolicyParseError::InvalidPatternToken {
196
+ rule_index,
197
+ token_index,
198
+ reason: "set either token or any_of".to_string(),
199
+ }),
200
+ }
201
+ }
codex-rs/config/src/schema.rs ADDED
@@ -0,0 +1,292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::config_toml::ConfigToml;
2
+ use crate::types::RawMcpServerConfig;
3
+ use codex_features::FEATURES;
4
+ use codex_features::legacy_feature_keys;
5
+ use codex_protocol::protocol::GranularApprovalConfig;
6
+ use schemars::JsonSchema;
7
+ use schemars::r#gen::SchemaGenerator;
8
+ use schemars::r#gen::SchemaSettings;
9
+ use schemars::schema::InstanceType;
10
+ use schemars::schema::ObjectValidation;
11
+ use schemars::schema::RootSchema;
12
+ use schemars::schema::Schema;
13
+ use schemars::schema::SchemaObject;
14
+ use schemars::schema::SubschemaValidation;
15
+ use serde_json::Map;
16
+ use serde_json::Value;
17
+ use std::path::Path;
18
+
19
+ /// Determines the conditions under which the user is consulted to approve
20
+ /// running the command proposed by Codex.
21
+ #[allow(dead_code)]
22
+ #[derive(JsonSchema)]
23
+ #[schemars(rename = "AskForApproval")]
24
+ #[serde(rename_all = "kebab-case")]
25
+ pub(crate) enum ConfigAskForApproval {
26
+ /// The model decides when to ask the user for approval.
27
+ OnRequest,
28
+
29
+ /// Fine-grained controls for individual approval flows.
30
+ ///
31
+ /// When a field is `true`, commands in that category are allowed. When it
32
+ /// is `false`, those requests are automatically rejected instead of shown
33
+ /// to the user.
34
+ Granular(GranularApprovalConfig),
35
+
36
+ /// Never ask the user to approve commands. Failures are immediately returned
37
+ /// to the model, and never escalated to the user for approval.
38
+ Never,
39
+ }
40
+
41
+ /// Schema for the `[features]` map with known + legacy keys only.
42
+ pub fn features_schema(schema_gen: &mut SchemaGenerator) -> Schema {
43
+ let mut object = SchemaObject {
44
+ instance_type: Some(InstanceType::Object.into()),
45
+ ..Default::default()
46
+ };
47
+
48
+ let mut validation = ObjectValidation::default();
49
+ for feature in FEATURES {
50
+ if feature.id == codex_features::Feature::Artifact {
51
+ continue;
52
+ }
53
+ if feature.id == codex_features::Feature::CodeMode {
54
+ validation.properties.insert(
55
+ feature.key.to_string(),
56
+ schema_gen.subschema_for::<codex_features::FeatureToml<
57
+ codex_features::CodeModeConfigToml,
58
+ >>(),
59
+ );
60
+ continue;
61
+ }
62
+ if feature.id == codex_features::Feature::CodeModeHost {
63
+ validation.properties.insert(
64
+ feature.key.to_string(),
65
+ schema_gen.subschema_for::<codex_features::FeatureToml<
66
+ codex_features::CodeModeHostConfigToml,
67
+ >>(),
68
+ );
69
+ continue;
70
+ }
71
+ if feature.id == codex_features::Feature::NonPrefixedMcpToolNames {
72
+ validation.properties.insert(
73
+ feature.key.to_string(),
74
+ schema_gen.subschema_for::<codex_features::FeatureToml<
75
+ codex_features::NonPrefixedMcpToolNamesConfigToml,
76
+ >>(),
77
+ );
78
+ continue;
79
+ }
80
+ if feature.id == codex_features::Feature::GuardianThreadContext {
81
+ // This setting is already part of the guardianv2 feature table.
82
+ continue;
83
+ }
84
+ if feature.id == codex_features::Feature::GuardianV2 {
85
+ validation.properties.insert(
86
+ feature.key.to_string(),
87
+ schema_gen.subschema_for::<codex_features::FeatureToml<
88
+ codex_features::GuardianV2ConfigToml,
89
+ >>(),
90
+ );
91
+ continue;
92
+ }
93
+ if feature.id == codex_features::Feature::MultiAgentV2 {
94
+ validation.properties.insert(
95
+ feature.key.to_string(),
96
+ schema_gen.subschema_for::<codex_features::FeatureToml<
97
+ codex_features::MultiAgentV2ConfigToml,
98
+ >>(),
99
+ );
100
+ continue;
101
+ }
102
+ if feature.id == codex_features::Feature::TokenBudget {
103
+ validation.properties.insert(
104
+ feature.key.to_string(),
105
+ schema_gen.subschema_for::<codex_features::FeatureToml<
106
+ codex_features::TokenBudgetConfigToml,
107
+ >>(),
108
+ );
109
+ continue;
110
+ }
111
+ if feature.id == codex_features::Feature::ContextManagement {
112
+ validation.properties.insert(
113
+ feature.key.to_string(),
114
+ schema_gen.subschema_for::<codex_features::FeatureToml<
115
+ codex_features::ContextManagementConfigToml,
116
+ >>(),
117
+ );
118
+ continue;
119
+ }
120
+ if feature.id == codex_features::Feature::RolloutBudget {
121
+ validation.properties.insert(
122
+ feature.key.to_string(),
123
+ schema_gen.subschema_for::<codex_features::FeatureToml<
124
+ codex_features::RolloutBudgetConfigToml,
125
+ >>(),
126
+ );
127
+ continue;
128
+ }
129
+ if feature.id == codex_features::Feature::CurrentTimeReminder {
130
+ validation.properties.insert(
131
+ feature.key.to_string(),
132
+ schema_gen.subschema_for::<codex_features::FeatureToml<
133
+ codex_features::CurrentTimeReminderConfigToml,
134
+ >>(),
135
+ );
136
+ continue;
137
+ }
138
+ if feature.id == codex_features::Feature::SleepTool {
139
+ validation.properties.insert(
140
+ feature.key.to_string(),
141
+ schema_gen.subschema_for::<codex_features::FeatureToml<
142
+ codex_features::SleepToolConfigToml,
143
+ >>(),
144
+ );
145
+ continue;
146
+ }
147
+ if feature.id == codex_features::Feature::AppsMcpPathOverride {
148
+ validation.properties.insert(
149
+ feature.key.to_string(),
150
+ removed_apps_mcp_path_override_schema(schema_gen),
151
+ );
152
+ continue;
153
+ }
154
+ if feature.id == codex_features::Feature::NetworkProxy {
155
+ validation.properties.insert(
156
+ feature.key.to_string(),
157
+ schema_gen.subschema_for::<codex_features::FeatureToml<
158
+ codex_features::NetworkProxyConfigToml,
159
+ >>(),
160
+ );
161
+ continue;
162
+ }
163
+ validation
164
+ .properties
165
+ .insert(feature.key.to_string(), schema_gen.subschema_for::<bool>());
166
+ }
167
+ for legacy_key in legacy_feature_keys() {
168
+ validation
169
+ .properties
170
+ .insert(legacy_key.to_string(), schema_gen.subschema_for::<bool>());
171
+ }
172
+ validation.properties.insert(
173
+ "tool_registry".to_string(),
174
+ schema_gen.subschema_for::<codex_features::ToolRegistryConfigToml>(),
175
+ );
176
+ validation.additional_properties = Some(Box::new(Schema::Bool(false)));
177
+ object.object = Some(Box::new(validation));
178
+
179
+ Schema::Object(object)
180
+ }
181
+
182
+ fn removed_apps_mcp_path_override_schema(schema_gen: &mut SchemaGenerator) -> Schema {
183
+ let mut config_validation = ObjectValidation::default();
184
+ config_validation
185
+ .properties
186
+ .insert("enabled".to_string(), schema_gen.subschema_for::<bool>());
187
+ config_validation
188
+ .properties
189
+ .insert("path".to_string(), schema_gen.subschema_for::<String>());
190
+ config_validation.additional_properties = Some(Box::new(Schema::Bool(false)));
191
+
192
+ let config = Schema::Object(SchemaObject {
193
+ instance_type: Some(InstanceType::Object.into()),
194
+ object: Some(Box::new(config_validation)),
195
+ ..Default::default()
196
+ });
197
+ Schema::Object(SchemaObject {
198
+ subschemas: Some(Box::new(SubschemaValidation {
199
+ any_of: Some(vec![schema_gen.subschema_for::<bool>(), config]),
200
+ ..Default::default()
201
+ })),
202
+ ..Default::default()
203
+ })
204
+ }
205
+
206
+ /// Schema for the `[mcp_servers]` map using the raw input shape.
207
+ pub fn mcp_servers_schema(schema_gen: &mut SchemaGenerator) -> Schema {
208
+ let mut object = SchemaObject {
209
+ instance_type: Some(InstanceType::Object.into()),
210
+ ..Default::default()
211
+ };
212
+
213
+ let validation = ObjectValidation {
214
+ additional_properties: Some(Box::new(schema_gen.subschema_for::<RawMcpServerConfig>())),
215
+ ..Default::default()
216
+ };
217
+ object.object = Some(Box::new(validation));
218
+
219
+ Schema::Object(object)
220
+ }
221
+
222
+ /// Build the config schema for `config.toml`.
223
+ pub fn config_schema() -> RootSchema {
224
+ let mut schema = SchemaSettings::draft07()
225
+ .with(|settings| {
226
+ settings.option_add_null_type = false;
227
+ })
228
+ .into_generator()
229
+ .into_root_schema_for::<ConfigToml>();
230
+ add_shell_environment_policy_constraints(&mut schema);
231
+ schema
232
+ }
233
+
234
+ fn add_shell_environment_policy_constraints(schema: &mut RootSchema) {
235
+ let Some(Schema::Object(policy)) = schema.definitions.get_mut("ShellEnvironmentPolicyToml")
236
+ else {
237
+ return;
238
+ };
239
+ let all_of = policy
240
+ .subschemas
241
+ .get_or_insert_default()
242
+ .all_of
243
+ .get_or_insert_default();
244
+ for fields in [["exclude", "filters"], ["filters", "include_only"]] {
245
+ all_of.push(Schema::Object(SchemaObject {
246
+ subschemas: Some(Box::new(SubschemaValidation {
247
+ not: Some(Box::new(Schema::Object(SchemaObject {
248
+ object: Some(Box::new(ObjectValidation {
249
+ required: fields.into_iter().map(str::to_string).collect(),
250
+ ..Default::default()
251
+ })),
252
+ ..Default::default()
253
+ }))),
254
+ ..Default::default()
255
+ })),
256
+ ..Default::default()
257
+ }));
258
+ }
259
+ }
260
+
261
+ /// Canonicalize a JSON value by sorting its keys.
262
+ pub fn canonicalize(value: &Value) -> Value {
263
+ match value {
264
+ Value::Array(items) => Value::Array(items.iter().map(canonicalize).collect()),
265
+ Value::Object(map) => {
266
+ let mut entries: Vec<_> = map.iter().collect();
267
+ entries.sort_by_key(|(key, _)| *key);
268
+ let mut sorted = Map::with_capacity(map.len());
269
+ for (key, child) in entries {
270
+ sorted.insert(key.clone(), canonicalize(child));
271
+ }
272
+ Value::Object(sorted)
273
+ }
274
+ _ => value.clone(),
275
+ }
276
+ }
277
+
278
+ /// Render the config schema as pretty-printed JSON.
279
+ pub fn config_schema_json() -> anyhow::Result<Vec<u8>> {
280
+ let schema = config_schema();
281
+ let value = serde_json::to_value(schema)?;
282
+ let value = canonicalize(&value);
283
+ let json = serde_json::to_vec_pretty(&value)?;
284
+ Ok(json)
285
+ }
286
+
287
+ /// Write the config schema fixture to disk.
288
+ pub fn write_config_schema(out_path: &Path) -> anyhow::Result<()> {
289
+ let json = config_schema_json()?;
290
+ std::fs::write(out_path, json)?;
291
+ Ok(())
292
+ }
codex-rs/config/src/skills_config.rs ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Skill-related configuration types shared across crates.
2
+
3
+ use std::collections::HashSet;
4
+ use std::num::NonZeroUsize;
5
+
6
+ use crate::ConfigLayerSource;
7
+ use crate::ConfigLayerStack;
8
+ use codex_utils_absolute_path::AbsolutePathBuf;
9
+ use schemars::JsonSchema;
10
+ use serde::Deserialize;
11
+ use serde::Serialize;
12
+ use tracing::warn;
13
+
14
+ const fn default_enabled() -> bool {
15
+ true
16
+ }
17
+
18
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
19
+ #[schemars(deny_unknown_fields)]
20
+ pub struct SkillConfig {
21
+ /// Path-based selector.
22
+ #[serde(default, skip_serializing_if = "Option::is_none")]
23
+ pub path: Option<AbsolutePathBuf>,
24
+ /// Name-based selector.
25
+ #[serde(default, skip_serializing_if = "Option::is_none")]
26
+ pub name: Option<String>,
27
+ pub enabled: bool,
28
+ }
29
+
30
+ #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
31
+ #[schemars(deny_unknown_fields)]
32
+ pub struct SkillsConfig {
33
+ #[serde(default, skip_serializing_if = "Option::is_none")]
34
+ pub bundled: Option<BundledSkillsConfig>,
35
+
36
+ /// Whether turns receive the automatic skills instructions block.
37
+ #[serde(default, skip_serializing_if = "Option::is_none")]
38
+ pub include_instructions: Option<bool>,
39
+
40
+ /// Maximum tokens used by the available-skills catalog. Defaults to 2% of
41
+ /// the model context window and is capped at 10,000 tokens when set.
42
+ #[serde(default, skip_serializing_if = "Option::is_none")]
43
+ pub max_context_tokens: Option<NonZeroUsize>,
44
+
45
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
46
+ pub config: Vec<SkillConfig>,
47
+ }
48
+
49
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
50
+ #[schemars(deny_unknown_fields)]
51
+ pub struct BundledSkillsConfig {
52
+ #[serde(default = "default_enabled")]
53
+ pub enabled: bool,
54
+ }
55
+
56
+ impl Default for BundledSkillsConfig {
57
+ fn default() -> Self {
58
+ Self { enabled: true }
59
+ }
60
+ }
61
+
62
+ impl TryFrom<toml::Value> for SkillsConfig {
63
+ type Error = toml::de::Error;
64
+
65
+ fn try_from(value: toml::Value) -> Result<Self, Self::Error> {
66
+ SkillsConfig::deserialize(value)
67
+ }
68
+ }
69
+
70
+ /// Selects configured skills by their name or canonical document path.
71
+ #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
72
+ pub enum SkillConfigRuleSelector {
73
+ Name(String),
74
+ Path(AbsolutePathBuf),
75
+ }
76
+
77
+ /// Enables or disables every skill matched by its selector.
78
+ #[derive(Debug, Clone, PartialEq, Eq, Hash)]
79
+ pub struct SkillConfigRule {
80
+ pub selector: SkillConfigRuleSelector,
81
+ pub enabled: bool,
82
+ }
83
+
84
+ /// Ordered effective skill enablement rules from configuration layers.
85
+ #[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
86
+ pub struct SkillConfigRules {
87
+ pub entries: Vec<SkillConfigRule>,
88
+ }
89
+
90
+ impl SkillConfigRules {
91
+ /// Applies rules in order; later rules override earlier rules for matching skills.
92
+ ///
93
+ /// Explicit path selectors remain effective even when no current skill matches.
94
+ pub fn resolve_disabled_paths<'a>(
95
+ &self,
96
+ skills: impl IntoIterator<Item = (&'a str, &'a AbsolutePathBuf)> + Clone,
97
+ ) -> HashSet<AbsolutePathBuf> {
98
+ let mut disabled_paths = HashSet::new();
99
+
100
+ for entry in &self.entries {
101
+ match &entry.selector {
102
+ SkillConfigRuleSelector::Path(path) => {
103
+ if entry.enabled {
104
+ disabled_paths.remove(path);
105
+ } else {
106
+ disabled_paths.insert(path.clone());
107
+ }
108
+ }
109
+ SkillConfigRuleSelector::Name(name) => {
110
+ for (skill_name, path) in skills.clone() {
111
+ if skill_name != name {
112
+ continue;
113
+ }
114
+ if entry.enabled {
115
+ disabled_paths.remove(path);
116
+ } else {
117
+ disabled_paths.insert(path.clone());
118
+ }
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ disabled_paths
125
+ }
126
+ }
127
+
128
+ /// Returns whether bundled skills are enabled by the effective configuration.
129
+ pub fn bundled_skills_enabled_from_stack(config_layer_stack: &ConfigLayerStack) -> bool {
130
+ let effective_config = config_layer_stack.effective_config();
131
+ let Some(skills_value) = effective_config
132
+ .as_table()
133
+ .and_then(|table| table.get("skills"))
134
+ else {
135
+ return true;
136
+ };
137
+
138
+ let skills: SkillsConfig = match skills_value.clone().try_into() {
139
+ Ok(skills) => skills,
140
+ Err(err) => {
141
+ warn!("invalid skills config: {err}");
142
+ return true;
143
+ }
144
+ };
145
+
146
+ skills.bundled.unwrap_or_default().enabled
147
+ }
148
+
149
+ /// Resolves skill enablement rules from user and session configuration layers.
150
+ pub fn skill_config_rules_from_stack(config_layer_stack: &ConfigLayerStack) -> SkillConfigRules {
151
+ let mut entries = Vec::new();
152
+ for layer in config_layer_stack.all_layers_low_to_high() {
153
+ if !matches!(
154
+ layer.name,
155
+ ConfigLayerSource::User { .. } | ConfigLayerSource::SessionFlags
156
+ ) {
157
+ continue;
158
+ }
159
+
160
+ let Some(skills_value) = layer.config.get("skills") else {
161
+ continue;
162
+ };
163
+ let skills: SkillsConfig = match skills_value.clone().try_into() {
164
+ Ok(skills) => skills,
165
+ Err(err) => {
166
+ warn!("invalid skills config: {err}");
167
+ continue;
168
+ }
169
+ };
170
+
171
+ for entry in skills.config {
172
+ let Some(selector) = skill_config_rule_selector(&entry) else {
173
+ continue;
174
+ };
175
+ // Preserve layer order so a later name selector can override an earlier path selector
176
+ // for the same loaded skill.
177
+ entries.retain(|entry: &SkillConfigRule| entry.selector != selector);
178
+ entries.push(SkillConfigRule {
179
+ selector,
180
+ enabled: entry.enabled,
181
+ });
182
+ }
183
+ }
184
+
185
+ SkillConfigRules { entries }
186
+ }
187
+
188
+ fn skill_config_rule_selector(entry: &SkillConfig) -> Option<SkillConfigRuleSelector> {
189
+ match (entry.path.as_ref(), entry.name.as_deref()) {
190
+ (Some(path), None) => Some(SkillConfigRuleSelector::Path(
191
+ path.canonicalize().unwrap_or_else(|_| path.clone()),
192
+ )),
193
+ (None, Some(name)) => {
194
+ let name = name.trim();
195
+ if name.is_empty() {
196
+ warn!("ignoring empty skills.config name override");
197
+ None
198
+ } else {
199
+ Some(SkillConfigRuleSelector::Name(name.to_string()))
200
+ }
201
+ }
202
+ (Some(_), Some(_)) => {
203
+ warn!("ignoring skills.config entry with both path and name selectors");
204
+ None
205
+ }
206
+ (None, None) => {
207
+ warn!("ignoring skills.config entry without a path or name selector");
208
+ None
209
+ }
210
+ }
211
+ }
212
+
213
+ #[cfg(test)]
214
+ #[path = "skills_config_tests.rs"]
215
+ mod tests;
codex-rs/config/src/skills_config_tests.rs ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::CONFIG_TOML_FILE;
2
+ use crate::ConfigLayerEntry;
3
+ use crate::ConfigLayerSource;
4
+ use crate::ConfigLayerStack;
5
+ use crate::ConfigRequirementsToml;
6
+ use codex_utils_absolute_path::AbsolutePathBuf;
7
+ use codex_utils_absolute_path::test_support::PathBufExt;
8
+ use pretty_assertions::assert_eq;
9
+ use tempfile::TempDir;
10
+
11
+ use super::SkillConfigRule;
12
+ use super::SkillConfigRuleSelector;
13
+ use super::SkillConfigRules;
14
+ use super::bundled_skills_enabled_from_stack;
15
+ use super::skill_config_rules_from_stack;
16
+
17
+ fn user_layer(codex_home: &TempDir, config: &str) -> ConfigLayerEntry {
18
+ let config_path = AbsolutePathBuf::try_from(codex_home.path().join(CONFIG_TOML_FILE))
19
+ .expect("absolute config path");
20
+ ConfigLayerEntry::new(
21
+ ConfigLayerSource::User {
22
+ file: config_path,
23
+ profile: None,
24
+ },
25
+ toml::from_str(config).expect("valid user config"),
26
+ )
27
+ }
28
+
29
+ fn stack(codex_home: &TempDir, user: &str, session: &str) -> ConfigLayerStack {
30
+ ConfigLayerStack::new(
31
+ vec![
32
+ user_layer(codex_home, user),
33
+ ConfigLayerEntry::new(
34
+ ConfigLayerSource::SessionFlags,
35
+ toml::from_str(session).expect("valid session config"),
36
+ ),
37
+ ],
38
+ Default::default(),
39
+ ConfigRequirementsToml::default(),
40
+ )
41
+ .expect("valid config stack")
42
+ }
43
+
44
+ fn path_toggle_config(path: &std::path::Path, enabled: bool) -> String {
45
+ let path = toml::Value::String(path.display().to_string());
46
+ format!(
47
+ r#"[[skills.config]]
48
+ path = {path}
49
+ enabled = {enabled}
50
+ "#
51
+ )
52
+ }
53
+
54
+ #[test]
55
+ fn bundled_skills_follow_effective_configuration() {
56
+ let codex_home = TempDir::new().expect("temp dir");
57
+
58
+ assert!(bundled_skills_enabled_from_stack(&stack(
59
+ &codex_home,
60
+ "",
61
+ ""
62
+ )));
63
+ assert!(!bundled_skills_enabled_from_stack(&stack(
64
+ &codex_home,
65
+ "[skills.bundled]\nenabled = false\n",
66
+ ""
67
+ )));
68
+ assert!(bundled_skills_enabled_from_stack(&stack(
69
+ &codex_home,
70
+ "[skills.bundled]\nenabled = false\n",
71
+ "[skills.bundled]\nenabled = true\n"
72
+ )));
73
+ }
74
+
75
+ #[test]
76
+ fn malformed_bundled_skills_config_defaults_to_enabled() {
77
+ let codex_home = TempDir::new().expect("temp dir");
78
+
79
+ assert!(bundled_skills_enabled_from_stack(&stack(
80
+ &codex_home,
81
+ "[skills]\nbundled = 'invalid'\n",
82
+ ""
83
+ )));
84
+ }
85
+
86
+ #[test]
87
+ fn session_flags_can_reenable_user_disabled_path() {
88
+ let codex_home = TempDir::new().expect("temp dir");
89
+ let skill_path = codex_home.path().join("skills/demo/SKILL.md");
90
+
91
+ assert_eq!(
92
+ skill_config_rules_from_stack(&stack(
93
+ &codex_home,
94
+ &path_toggle_config(&skill_path, /*enabled*/ false),
95
+ &path_toggle_config(&skill_path, /*enabled*/ true),
96
+ )),
97
+ SkillConfigRules {
98
+ entries: vec![SkillConfigRule {
99
+ selector: SkillConfigRuleSelector::Path(skill_path.abs()),
100
+ enabled: true,
101
+ }],
102
+ }
103
+ );
104
+ }
105
+
106
+ #[test]
107
+ fn session_flags_can_disable_user_enabled_path() {
108
+ let codex_home = TempDir::new().expect("temp dir");
109
+ let skill_path = codex_home.path().join("skills/demo/SKILL.md");
110
+
111
+ assert_eq!(
112
+ skill_config_rules_from_stack(&stack(
113
+ &codex_home,
114
+ &path_toggle_config(&skill_path, /*enabled*/ true),
115
+ &path_toggle_config(&skill_path, /*enabled*/ false),
116
+ )),
117
+ SkillConfigRules {
118
+ entries: vec![SkillConfigRule {
119
+ selector: SkillConfigRuleSelector::Path(skill_path.abs()),
120
+ enabled: false,
121
+ }],
122
+ }
123
+ );
124
+ }
125
+
126
+ #[test]
127
+ fn preserves_name_selectors() {
128
+ let codex_home = TempDir::new().expect("temp dir");
129
+
130
+ assert_eq!(
131
+ skill_config_rules_from_stack(&stack(
132
+ &codex_home,
133
+ r#"
134
+ [[skills.config]]
135
+ name = "github:yeet"
136
+ enabled = false
137
+ "#,
138
+ "",
139
+ )),
140
+ SkillConfigRules {
141
+ entries: vec![SkillConfigRule {
142
+ selector: SkillConfigRuleSelector::Name("github:yeet".to_string()),
143
+ enabled: false,
144
+ }],
145
+ }
146
+ );
147
+ }
148
+
149
+ #[test]
150
+ fn preserves_order_across_path_and_name_selectors() {
151
+ let codex_home = TempDir::new().expect("temp dir");
152
+ let skill_path = codex_home.path().join("skills/demo/SKILL.md");
153
+
154
+ assert_eq!(
155
+ skill_config_rules_from_stack(&stack(
156
+ &codex_home,
157
+ &path_toggle_config(&skill_path, /*enabled*/ false),
158
+ r#"
159
+ [[skills.config]]
160
+ name = "github:yeet"
161
+ enabled = true
162
+ "#,
163
+ )),
164
+ SkillConfigRules {
165
+ entries: vec![
166
+ SkillConfigRule {
167
+ selector: SkillConfigRuleSelector::Path(skill_path.abs()),
168
+ enabled: false,
169
+ },
170
+ SkillConfigRule {
171
+ selector: SkillConfigRuleSelector::Name("github:yeet".to_string()),
172
+ enabled: true,
173
+ },
174
+ ],
175
+ }
176
+ );
177
+ }
178
+
179
+ #[test]
180
+ fn path_rule_disables_selected_path() {
181
+ let codex_home = TempDir::new().expect("temp dir");
182
+ let path = codex_home.path().join("disable-by-path/SKILL.md").abs();
183
+ let rules = SkillConfigRules {
184
+ entries: vec![SkillConfigRule {
185
+ selector: SkillConfigRuleSelector::Path(path.clone()),
186
+ enabled: false,
187
+ }],
188
+ };
189
+
190
+ assert_eq!(
191
+ rules.resolve_disabled_paths(std::iter::empty()),
192
+ [path].into_iter().collect()
193
+ );
194
+ }
195
+
196
+ #[test]
197
+ fn later_name_rule_reenables_path_disabled_skill() {
198
+ let codex_home = TempDir::new().expect("temp dir");
199
+ let path = codex_home.path().join("reenable-by-name/SKILL.md").abs();
200
+ let rules = SkillConfigRules {
201
+ entries: vec![
202
+ SkillConfigRule {
203
+ selector: SkillConfigRuleSelector::Path(path.clone()),
204
+ enabled: false,
205
+ },
206
+ SkillConfigRule {
207
+ selector: SkillConfigRuleSelector::Name("demo".to_string()),
208
+ enabled: true,
209
+ },
210
+ ],
211
+ };
212
+
213
+ assert_eq!(
214
+ rules.resolve_disabled_paths([("demo", &path)]),
215
+ Default::default()
216
+ );
217
+ }
218
+
219
+ #[test]
220
+ fn later_path_rule_reenables_one_skill_disabled_by_name() {
221
+ let codex_home = TempDir::new().expect("temp dir");
222
+ let root = codex_home.path().join("reenable-by-path");
223
+ let first_path = root.join("first/SKILL.md").abs();
224
+ let second_path = root.join("second/SKILL.md").abs();
225
+ let rules = SkillConfigRules {
226
+ entries: vec![
227
+ SkillConfigRule {
228
+ selector: SkillConfigRuleSelector::Name("demo".to_string()),
229
+ enabled: false,
230
+ },
231
+ SkillConfigRule {
232
+ selector: SkillConfigRuleSelector::Path(first_path.clone()),
233
+ enabled: true,
234
+ },
235
+ ],
236
+ };
237
+
238
+ assert_eq!(
239
+ rules.resolve_disabled_paths([("demo", &first_path), ("demo", &second_path)]),
240
+ [second_path].into_iter().collect()
241
+ );
242
+ }
codex-rs/config/src/state.rs ADDED
@@ -0,0 +1,630 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use crate::CONFIG_TOML_FILE;
2
+ use crate::config_requirements::ConfigRequirements;
3
+ use crate::config_requirements::ConfigRequirementsToml;
4
+ use crate::format_config_layer_source;
5
+
6
+ use super::fingerprint::record_origins;
7
+ use super::fingerprint::version_for_toml;
8
+ use super::key_aliases::normalized_with_key_aliases;
9
+ use super::merge::merge_toml_values;
10
+ use crate::CloudConfigBundleLoader;
11
+ use crate::ConfigLayer;
12
+ use crate::ConfigLayerMetadata;
13
+ use crate::ConfigLayerSource;
14
+ use crate::ProfileV2Name;
15
+ use crate::shell_environment_policy::validate_shell_environment_policy_filter_config;
16
+ use codex_utils_absolute_path::AbsolutePathBuf;
17
+ use serde_json::Value as JsonValue;
18
+ use std::collections::HashMap;
19
+ use std::path::Path;
20
+ use std::path::PathBuf;
21
+ use toml::Value as TomlValue;
22
+
23
+ /// User-facing config loading behavior that is not part of the config document.
24
+ #[derive(Debug, Default, Clone)]
25
+ pub struct ConfigLoadOptions {
26
+ pub loader_overrides: LoaderOverrides,
27
+ pub strict_config: bool,
28
+ pub cloud_config_bundle: CloudConfigBundleLoader,
29
+ }
30
+
31
+ impl From<LoaderOverrides> for ConfigLoadOptions {
32
+ fn from(loader_overrides: LoaderOverrides) -> Self {
33
+ Self {
34
+ loader_overrides,
35
+ strict_config: false,
36
+ cloud_config_bundle: CloudConfigBundleLoader::default(),
37
+ }
38
+ }
39
+ }
40
+
41
+ /// LoaderOverrides overrides managed configuration inputs (primarily for tests).
42
+ #[derive(Debug, Default, Clone)]
43
+ pub struct LoaderOverrides {
44
+ /// Optional configuration file supplied with the installed Codex package.
45
+ pub packaged_defaults_path: Option<AbsolutePathBuf>,
46
+ pub user_config_path: Option<AbsolutePathBuf>,
47
+ pub user_config_profile: Option<ProfileV2Name>,
48
+ pub managed_config_path: Option<PathBuf>,
49
+ pub system_config_path: Option<PathBuf>,
50
+ pub system_requirements_path: Option<PathBuf>,
51
+ pub ignore_managed_requirements: bool,
52
+ /// Remote app servers own their authentication policy independently.
53
+ pub ignore_login_requirements: bool,
54
+ pub ignore_user_config: bool,
55
+ /// Skip project-root discovery and all project configuration layers.
56
+ pub ignore_project_config: bool,
57
+ pub ignore_user_and_project_exec_policy_rules: bool,
58
+ //TODO(gt): Add a macos_ prefix to this field and remove the target_os check.
59
+ #[cfg(target_os = "macos")]
60
+ pub managed_preferences_base64: Option<String>,
61
+ pub macos_managed_config_requirements_base64: Option<String>,
62
+ }
63
+
64
+ impl LoaderOverrides {
65
+ /// Returns overrides that ignore host-managed configuration.
66
+ ///
67
+ /// This is intended for tests that should load only repo-controlled config fixtures.
68
+ pub fn without_managed_config_for_tests() -> Self {
69
+ let base = std::env::temp_dir().join("codex-config-tests");
70
+ Self {
71
+ packaged_defaults_path: None,
72
+ user_config_path: None,
73
+ user_config_profile: None,
74
+ managed_config_path: Some(base.join("managed_config.toml")),
75
+ system_config_path: Some(base.join("config.toml")),
76
+ system_requirements_path: Some(base.join("requirements.toml")),
77
+ ignore_managed_requirements: false,
78
+ ignore_login_requirements: false,
79
+ ignore_user_config: false,
80
+ ignore_project_config: false,
81
+ ignore_user_and_project_exec_policy_rules: false,
82
+ #[cfg(target_os = "macos")]
83
+ managed_preferences_base64: Some(String::new()),
84
+ macos_managed_config_requirements_base64: Some(String::new()),
85
+ }
86
+ }
87
+
88
+ /// Returns overrides with host MDM disabled and managed config loaded from
89
+ /// `managed_config_path`. System requirements are loaded from a sibling
90
+ /// `requirements.toml` fixture.
91
+ ///
92
+ /// This is intended for tests that supply an explicit managed config fixture.
93
+ pub fn with_managed_config_path_for_tests(managed_config_path: PathBuf) -> Self {
94
+ let system_requirements_path = managed_config_path.with_file_name("requirements.toml");
95
+ Self {
96
+ user_config_path: None,
97
+ user_config_profile: None,
98
+ managed_config_path: Some(managed_config_path),
99
+ system_requirements_path: Some(system_requirements_path),
100
+ ..Self::without_managed_config_for_tests()
101
+ }
102
+ }
103
+
104
+ pub fn user_config_path(&self, codex_home: &Path) -> std::io::Result<AbsolutePathBuf> {
105
+ match self.user_config_path.as_ref() {
106
+ Some(path) => Ok(path.clone()),
107
+ None => Ok(AbsolutePathBuf::resolve_path_against_base(
108
+ crate::CONFIG_TOML_FILE,
109
+ codex_home,
110
+ )),
111
+ }
112
+ }
113
+ }
114
+
115
+ #[derive(Debug, Clone, PartialEq)]
116
+ pub struct ConfigLayerEntry {
117
+ pub name: ConfigLayerSource,
118
+ pub config: TomlValue,
119
+ pub version: String,
120
+ pub disabled_reason: Option<String>,
121
+ raw_toml: Option<RawTomlLayer>,
122
+ hooks_config_folder_override: Option<AbsolutePathBuf>,
123
+ }
124
+
125
+ #[derive(Debug, Clone, PartialEq)]
126
+ struct RawTomlLayer {
127
+ contents: String,
128
+ base_dir: AbsolutePathBuf,
129
+ }
130
+
131
+ impl ConfigLayerEntry {
132
+ pub fn new(name: ConfigLayerSource, config: TomlValue) -> Self {
133
+ let version = version_for_toml(&config);
134
+ Self {
135
+ name,
136
+ config,
137
+ version,
138
+ disabled_reason: None,
139
+ raw_toml: None,
140
+ hooks_config_folder_override: None,
141
+ }
142
+ }
143
+
144
+ pub fn new_with_raw_toml(
145
+ name: ConfigLayerSource,
146
+ config: TomlValue,
147
+ raw_toml: String,
148
+ raw_toml_base_dir: AbsolutePathBuf,
149
+ ) -> Self {
150
+ let version = version_for_toml(&config);
151
+ Self {
152
+ name,
153
+ config,
154
+ version,
155
+ disabled_reason: None,
156
+ raw_toml: Some(RawTomlLayer {
157
+ contents: raw_toml,
158
+ base_dir: raw_toml_base_dir,
159
+ }),
160
+ hooks_config_folder_override: None,
161
+ }
162
+ }
163
+
164
+ pub fn new_disabled(
165
+ name: ConfigLayerSource,
166
+ config: TomlValue,
167
+ disabled_reason: impl Into<String>,
168
+ ) -> Self {
169
+ let version = version_for_toml(&config);
170
+ Self {
171
+ name,
172
+ config,
173
+ version,
174
+ disabled_reason: Some(disabled_reason.into()),
175
+ raw_toml: None,
176
+ hooks_config_folder_override: None,
177
+ }
178
+ }
179
+
180
+ pub fn is_disabled(&self) -> bool {
181
+ self.disabled_reason.is_some()
182
+ }
183
+
184
+ pub fn raw_toml(&self) -> Option<&str> {
185
+ self.raw_toml
186
+ .as_ref()
187
+ .map(|raw_toml| raw_toml.contents.as_str())
188
+ }
189
+
190
+ pub fn raw_toml_base_dir(&self) -> Option<&AbsolutePathBuf> {
191
+ self.raw_toml.as_ref().map(|raw_toml| &raw_toml.base_dir)
192
+ }
193
+
194
+ pub(crate) fn with_hooks_config_folder_override(
195
+ mut self,
196
+ hooks_config_folder_override: Option<AbsolutePathBuf>,
197
+ ) -> Self {
198
+ self.hooks_config_folder_override = hooks_config_folder_override;
199
+ self
200
+ }
201
+
202
+ pub fn metadata(&self) -> ConfigLayerMetadata {
203
+ ConfigLayerMetadata {
204
+ name: self.name.clone(),
205
+ version: self.version.clone(),
206
+ }
207
+ }
208
+
209
+ pub fn as_layer(&self) -> ConfigLayer {
210
+ ConfigLayer {
211
+ name: self.name.clone(),
212
+ version: self.version.clone(),
213
+ config: serde_json::to_value(&self.config).unwrap_or(JsonValue::Null),
214
+ disabled_reason: self.disabled_reason.clone(),
215
+ }
216
+ }
217
+
218
+ // Get the `.codex/` folder associated with this config layer, if any.
219
+ pub fn config_folder(&self) -> Option<AbsolutePathBuf> {
220
+ match &self.name {
221
+ ConfigLayerSource::PackagedDefaults { .. } => None,
222
+ ConfigLayerSource::Mdm { .. } => None,
223
+ ConfigLayerSource::System { file } => file.parent(),
224
+ ConfigLayerSource::EnterpriseManaged { .. } => None,
225
+ ConfigLayerSource::User { file, .. } => file.parent(),
226
+ ConfigLayerSource::Project { dot_codex_folder } => Some(dot_codex_folder.clone()),
227
+ ConfigLayerSource::SessionFlags => None,
228
+ ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => None,
229
+ ConfigLayerSource::LegacyManagedConfigTomlFromMdm => None,
230
+ }
231
+ }
232
+
233
+ /// Returns the `.codex/` folder that should be used for hook declarations.
234
+ ///
235
+ /// Project layers normally use their own config folder. Linked Git worktrees
236
+ /// can instead point hook discovery at the matching folder from the root
237
+ /// checkout while the rest of the project config still comes from the
238
+ /// worktree.
239
+ pub fn hooks_config_folder(&self) -> Option<AbsolutePathBuf> {
240
+ self.hooks_config_folder_override
241
+ .clone()
242
+ .or_else(|| self.config_folder())
243
+ }
244
+ }
245
+
246
+ #[derive(Debug, Clone, Default, PartialEq)]
247
+ pub struct ConfigLayerStack {
248
+ /// Cached TOML projection derived only from `requirements_toml`.
249
+ /// Construction validates provider definitions and reports serialization errors,
250
+ /// so `effective_config()` can replace complete entries without a fallible conversion.
251
+ model_provider_requirements: Option<TomlValue>,
252
+ /// Layers are listed from lowest precedence (base) to highest (top), so
253
+ /// later entries in the Vec override earlier ones.
254
+ layers: Vec<ConfigLayerEntry>,
255
+
256
+ /// Constraints that must be enforced when deriving a [Config] from the
257
+ /// layers.
258
+ requirements: ConfigRequirements,
259
+
260
+ /// Raw requirements data as loaded from requirements.toml/MDM/legacy
261
+ /// sources. This preserves the original allow-lists so they can be
262
+ /// surfaced via APIs.
263
+ requirements_toml: ConfigRequirementsToml,
264
+
265
+ /// Whether execpolicy should skip `.rules` files from user and project config-layer folders.
266
+ ignore_user_and_project_exec_policy_rules: bool,
267
+
268
+ /// Startup warnings discovered while building this stack.
269
+ ///
270
+ /// `None` means the loader did not check for stack-level warnings, while
271
+ /// `Some(vec![])` means it checked and found nothing to report.
272
+ startup_warnings: Option<Vec<String>>,
273
+ }
274
+
275
+ impl ConfigLayerStack {
276
+ pub fn new(
277
+ layers: Vec<ConfigLayerEntry>,
278
+ requirements: ConfigRequirements,
279
+ requirements_toml: ConfigRequirementsToml,
280
+ ) -> std::io::Result<Self> {
281
+ validate_enabled_config_layers(&layers)?;
282
+ verify_layer_ordering(&layers)?;
283
+ let model_provider_requirements = Some(crate::model_provider_requirements::to_config(
284
+ &requirements_toml,
285
+ )?);
286
+ Ok(Self {
287
+ model_provider_requirements,
288
+ layers,
289
+ requirements,
290
+ requirements_toml,
291
+ ignore_user_and_project_exec_policy_rules: false,
292
+ startup_warnings: None,
293
+ })
294
+ }
295
+
296
+ pub fn with_user_and_project_exec_policy_rules_ignored(
297
+ mut self,
298
+ ignore_user_and_project_exec_policy_rules: bool,
299
+ ) -> Self {
300
+ self.ignore_user_and_project_exec_policy_rules = ignore_user_and_project_exec_policy_rules;
301
+ self
302
+ }
303
+
304
+ pub fn ignore_user_and_project_exec_policy_rules(&self) -> bool {
305
+ self.ignore_user_and_project_exec_policy_rules
306
+ }
307
+
308
+ pub(crate) fn with_startup_warnings(mut self, startup_warnings: Vec<String>) -> Self {
309
+ self.startup_warnings = Some(startup_warnings);
310
+ self
311
+ }
312
+
313
+ pub fn startup_warnings(&self) -> Option<&[String]> {
314
+ self.startup_warnings.as_deref()
315
+ }
316
+
317
+ /// Returns the active raw user config layer, if any.
318
+ ///
319
+ /// This does not merge other config layers or apply any requirements. When
320
+ /// a profile-v2 layer is active, this returns that profile layer rather than
321
+ /// the base `$CODEX_HOME/config.toml` layer because the active layer is the
322
+ /// writable target for profile-aware edits.
323
+ pub fn get_active_user_layer(&self) -> Option<&ConfigLayerEntry> {
324
+ self.layers
325
+ .iter()
326
+ .rev()
327
+ .find(|layer| matches!(layer.name, ConfigLayerSource::User { .. }))
328
+ }
329
+
330
+ pub fn get_user_config_file(&self) -> Option<&AbsolutePathBuf> {
331
+ let layer = self.get_active_user_layer()?;
332
+ let ConfigLayerSource::User { file, .. } = &layer.name else {
333
+ return None;
334
+ };
335
+ Some(file)
336
+ }
337
+
338
+ /// Returns the merged config from enabled user layers only.
339
+ ///
340
+ /// When profile config is active, this includes the base user config followed
341
+ /// by the profile override config.
342
+ pub fn effective_user_config(&self) -> Option<TomlValue> {
343
+ let mut user_layers = self
344
+ .layers_low_to_high()
345
+ .filter(|layer| matches!(layer.name, ConfigLayerSource::User { .. }))
346
+ .peekable();
347
+ user_layers.peek()?;
348
+
349
+ let mut merged = TomlValue::Table(toml::map::Map::new());
350
+ for layer in user_layers {
351
+ merge_toml_values(&mut merged, &layer.config);
352
+ }
353
+ Some(merged)
354
+ }
355
+
356
+ pub fn requirements(&self) -> &ConfigRequirements {
357
+ &self.requirements
358
+ }
359
+
360
+ pub fn requirements_toml(&self) -> &ConfigRequirementsToml {
361
+ &self.requirements_toml
362
+ }
363
+
364
+ /// Creates a new [ConfigLayerStack] using the specified values to inject one
365
+ /// user layer into the stack. If such a layer already exists, it is replaced;
366
+ /// otherwise, it is inserted into the stack at the appropriate position
367
+ /// based on precedence rules. When the stack has both base and profile-v2
368
+ /// user layers, this updates only the layer whose file matches
369
+ /// `config_toml`.
370
+ pub fn with_user_config(
371
+ &self,
372
+ config_toml: &AbsolutePathBuf,
373
+ user_config: TomlValue,
374
+ ) -> std::io::Result<Self> {
375
+ let profile = self.layers.iter().find_map(|layer| match &layer.name {
376
+ ConfigLayerSource::User { file, profile } if file == config_toml => profile
377
+ .as_deref()
378
+ .and_then(|profile| profile.parse::<ProfileV2Name>().ok()),
379
+ _ => None,
380
+ });
381
+ self.with_user_config_profile(config_toml, profile.as_ref(), user_config)
382
+ }
383
+
384
+ pub fn with_user_config_profile(
385
+ &self,
386
+ config_toml: &AbsolutePathBuf,
387
+ profile: Option<&ProfileV2Name>,
388
+ user_config: TomlValue,
389
+ ) -> std::io::Result<Self> {
390
+ let user_layer = ConfigLayerEntry::new(
391
+ ConfigLayerSource::User {
392
+ file: config_toml.clone(),
393
+ profile: profile.map(ToString::to_string),
394
+ },
395
+ user_config,
396
+ );
397
+ validate_enabled_config_layers(std::slice::from_ref(&user_layer))?;
398
+
399
+ let mut layers = self.layers.clone();
400
+ if let Some(index) = layers.iter().position(|layer| {
401
+ matches!(
402
+ &layer.name,
403
+ ConfigLayerSource::User { file, .. } if file == config_toml
404
+ )
405
+ }) {
406
+ layers.remove(index);
407
+ }
408
+ match layers
409
+ .iter()
410
+ .position(|layer| layer.name.precedence() > user_layer.name.precedence())
411
+ {
412
+ Some(index) => layers.insert(index, user_layer),
413
+ None => layers.push(user_layer),
414
+ }
415
+ Ok(Self {
416
+ layers,
417
+ model_provider_requirements: self.model_provider_requirements.clone(),
418
+ requirements: self.requirements.clone(),
419
+ requirements_toml: self.requirements_toml.clone(),
420
+ ignore_user_and_project_exec_policy_rules: self
421
+ .ignore_user_and_project_exec_policy_rules,
422
+ startup_warnings: self.startup_warnings.clone(),
423
+ })
424
+ }
425
+
426
+ /// Returns a new stack with the user layer copied from `other`, preserving
427
+ /// every non-user layer already present in this stack.
428
+ pub fn with_user_layer_from(&self, other: &Self) -> Self {
429
+ let user_layers = other
430
+ .layers
431
+ .iter()
432
+ .filter(|layer| matches!(layer.name, ConfigLayerSource::User { .. }))
433
+ .cloned()
434
+ .collect::<Vec<_>>();
435
+ let mut layers = self
436
+ .layers
437
+ .iter()
438
+ .filter(|layer| !matches!(layer.name, ConfigLayerSource::User { .. }))
439
+ .cloned()
440
+ .collect::<Vec<_>>();
441
+ for user_layer in user_layers {
442
+ match layers
443
+ .iter()
444
+ .position(|layer| layer.name.precedence() > user_layer.name.precedence())
445
+ {
446
+ Some(index) => layers.insert(index, user_layer),
447
+ None => layers.push(user_layer),
448
+ }
449
+ }
450
+ Self {
451
+ layers,
452
+ model_provider_requirements: self.model_provider_requirements.clone(),
453
+ requirements: self.requirements.clone(),
454
+ requirements_toml: self.requirements_toml.clone(),
455
+ ignore_user_and_project_exec_policy_rules: self
456
+ .ignore_user_and_project_exec_policy_rules,
457
+ startup_warnings: self.startup_warnings.clone(),
458
+ }
459
+ }
460
+
461
+ /// Returns the merged config-layer view.
462
+ ///
463
+ /// Required provider definitions replace local entries before deserialization.
464
+ /// Selection and other requirements are applied when constructing the final config.
465
+ pub fn effective_config(&self) -> TomlValue {
466
+ let mut merged = TomlValue::Table(toml::map::Map::new());
467
+ for layer in self.layers_low_to_high() {
468
+ merge_toml_values(&mut merged, &layer.config);
469
+ }
470
+ if let Some(requirements) = &self.model_provider_requirements {
471
+ crate::model_provider_requirements::apply(&mut merged, requirements);
472
+ }
473
+ merged
474
+ }
475
+
476
+ /// Required provider selection used when building the effective configuration.
477
+ pub fn required_model_provider(&self) -> Option<&str> {
478
+ self.requirements_toml.model_provider.as_deref()
479
+ }
480
+
481
+ /// Returns field origins for the merged config-layer view.
482
+ ///
483
+ /// Requirement sources are tracked separately and are not included here.
484
+ pub fn origins(&self) -> HashMap<String, ConfigLayerMetadata> {
485
+ self.origins_with_path_filter(|_| true)
486
+ }
487
+
488
+ /// Filters origins using their original TOML key segments before formatting
489
+ /// them for the public API, where dots in quoted keys are ambiguous.
490
+ pub fn origins_with_path_filter(
491
+ &self,
492
+ include: impl Fn(&[String]) -> bool,
493
+ ) -> HashMap<String, ConfigLayerMetadata> {
494
+ let mut origins = HashMap::new();
495
+ let mut path = Vec::new();
496
+ let mut provider_paths = vec!["features.network_proxy.credentials.".to_string()];
497
+
498
+ for layer in self.layers_low_to_high() {
499
+ let config = normalized_with_key_aliases(&layer.config, &[]);
500
+ if let Some(profiles) = config.get("profiles").and_then(TomlValue::as_table) {
501
+ provider_paths.extend(
502
+ profiles
503
+ .keys()
504
+ .map(|name| format!("profiles.{name}.features.network_proxy.credentials.")),
505
+ );
506
+ }
507
+ record_origins(
508
+ &config,
509
+ &layer.metadata(),
510
+ &mut path,
511
+ &mut origins,
512
+ &include,
513
+ );
514
+ }
515
+
516
+ if let Some(layer) = self.layers_low_to_high().next_back() {
517
+ let effective = self.effective_config();
518
+ let mut effective_origins = HashMap::new();
519
+ record_origins(
520
+ &effective,
521
+ &layer.metadata(),
522
+ &mut path,
523
+ &mut effective_origins,
524
+ &include,
525
+ );
526
+ origins.retain(|path, _| {
527
+ !provider_paths.iter().any(|prefix| path.starts_with(prefix))
528
+ || effective_origins.contains_key(path)
529
+ });
530
+ }
531
+
532
+ origins
533
+ }
534
+
535
+ /// Returns enabled config layers from lowest precedence to highest.
536
+ ///
537
+ /// Requirement sources are tracked separately and are not included here.
538
+ pub fn layers_low_to_high(&self) -> impl DoubleEndedIterator<Item = &ConfigLayerEntry> {
539
+ self.all_layers_low_to_high()
540
+ .filter(|layer| !layer.is_disabled())
541
+ }
542
+
543
+ /// Returns enabled config layers from highest precedence to lowest.
544
+ ///
545
+ /// Requirement sources are tracked separately and are not included here.
546
+ pub fn layers_high_to_low(&self) -> impl DoubleEndedIterator<Item = &ConfigLayerEntry> {
547
+ self.layers_low_to_high().rev()
548
+ }
549
+
550
+ /// Returns all config layers, including disabled layers, from lowest
551
+ /// precedence to highest.
552
+ ///
553
+ /// Requirement sources are tracked separately and are not included here.
554
+ pub fn all_layers_low_to_high(&self) -> impl DoubleEndedIterator<Item = &ConfigLayerEntry> {
555
+ self.layers.iter()
556
+ }
557
+
558
+ /// Returns all config layers, including disabled layers, from highest
559
+ /// precedence to lowest.
560
+ ///
561
+ /// Requirement sources are tracked separately and are not included here.
562
+ pub fn all_layers_high_to_low(&self) -> impl DoubleEndedIterator<Item = &ConfigLayerEntry> {
563
+ self.all_layers_low_to_high().rev()
564
+ }
565
+ }
566
+
567
+ /// Validates before merging so mixed forms and malformed filter entries cannot be normalized away.
568
+ pub(crate) fn validate_enabled_config_layers(layers: &[ConfigLayerEntry]) -> std::io::Result<()> {
569
+ for layer in layers.iter().filter(|layer| !layer.is_disabled()) {
570
+ validate_shell_environment_policy_filter_config(&layer.config).map_err(|error| {
571
+ std::io::Error::new(
572
+ std::io::ErrorKind::InvalidData,
573
+ format!(
574
+ "invalid shell environment policy in {}: {error}",
575
+ format_config_layer_source(&layer.name, CONFIG_TOML_FILE)
576
+ ),
577
+ )
578
+ })?;
579
+ }
580
+ Ok(())
581
+ }
582
+
583
+ /// Ensures precedence ordering of config layers is correct.
584
+ fn verify_layer_ordering(layers: &[ConfigLayerEntry]) -> std::io::Result<()> {
585
+ if !layers.iter().map(|layer| &layer.name).is_sorted() {
586
+ return Err(std::io::Error::new(
587
+ std::io::ErrorKind::InvalidData,
588
+ "config layers are not in correct precedence order",
589
+ ));
590
+ }
591
+
592
+ // The previous check ensured `layers` is sorted by precedence, so now we
593
+ // further verify that project layers are ordered from root to cwd. Multiple
594
+ // user layers are allowed so a profile override can layer on top of the base
595
+ // user config.
596
+ let mut previous_project_dot_codex_folder: Option<&AbsolutePathBuf> = None;
597
+ for layer in layers {
598
+ if let ConfigLayerSource::Project {
599
+ dot_codex_folder: current_project_dot_codex_folder,
600
+ } = &layer.name
601
+ {
602
+ if let Some(previous) = previous_project_dot_codex_folder {
603
+ let Some(parent) = previous.as_path().parent() else {
604
+ return Err(std::io::Error::new(
605
+ std::io::ErrorKind::InvalidData,
606
+ "project layer has no parent directory",
607
+ ));
608
+ };
609
+ if previous == current_project_dot_codex_folder
610
+ || !current_project_dot_codex_folder
611
+ .as_path()
612
+ .ancestors()
613
+ .any(|ancestor| ancestor == parent)
614
+ {
615
+ return Err(std::io::Error::new(
616
+ std::io::ErrorKind::InvalidData,
617
+ "project layers are not ordered from root to cwd",
618
+ ));
619
+ }
620
+ }
621
+ previous_project_dot_codex_folder = Some(current_project_dot_codex_folder);
622
+ }
623
+ }
624
+
625
+ Ok(())
626
+ }
627
+
628
+ #[cfg(test)]
629
+ #[path = "state_tests.rs"]
630
+ mod tests;
codex-rs/config/src/state_tests.rs ADDED
@@ -0,0 +1,398 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use super::*;
2
+ use pretty_assertions::assert_eq;
3
+ use tempfile::TempDir;
4
+
5
+ fn test_user_config_path(temp_dir: &TempDir, file_name: &str) -> AbsolutePathBuf {
6
+ AbsolutePathBuf::from_absolute_path(temp_dir.path().join(file_name))
7
+ .expect("test user config path should be absolute")
8
+ }
9
+
10
+ #[test]
11
+ fn origins_use_canonical_key_aliases() {
12
+ let layer = ConfigLayerEntry::new(
13
+ ConfigLayerSource::SessionFlags,
14
+ toml::from_str(
15
+ r#"
16
+ [memories]
17
+ no_memories_if_mcp_or_web_search = true
18
+ "#,
19
+ )
20
+ .expect("config TOML should parse"),
21
+ );
22
+ let metadata = layer.metadata();
23
+ let stack = ConfigLayerStack::new(
24
+ vec![layer],
25
+ ConfigRequirements::default(),
26
+ ConfigRequirementsToml::default(),
27
+ )
28
+ .expect("single layer stack should be valid");
29
+
30
+ let origins = stack.origins();
31
+
32
+ assert_eq!(
33
+ origins.get("memories.disable_on_external_context"),
34
+ Some(&metadata)
35
+ );
36
+ assert!(
37
+ !origins.contains_key("memories.no_memories_if_mcp_or_web_search"),
38
+ "legacy key should be canonicalized before origin recording"
39
+ );
40
+ }
41
+
42
+ /// Legacy feature toggles own the semantic enabled leaf after layered merging.
43
+ #[test]
44
+ fn origins_attribute_multi_agent_v2_enabled_to_overriding_boolean_layer() {
45
+ let temp_dir = TempDir::new().expect("tempdir");
46
+ let user_layer = ConfigLayerEntry::new(
47
+ ConfigLayerSource::User {
48
+ file: test_user_config_path(&temp_dir, "config.toml"),
49
+ profile: None,
50
+ },
51
+ toml::from_str(
52
+ "[features.multi_agent_v2]\nenabled = true\nsubagent_usage_hint_text = \"keep\"\n",
53
+ )
54
+ .expect("user config"),
55
+ );
56
+ let user_metadata = user_layer.metadata();
57
+ let session_layer = ConfigLayerEntry::new(
58
+ ConfigLayerSource::SessionFlags,
59
+ toml::from_str("[features]\nmulti_agent_v2 = false\n").expect("session config"),
60
+ );
61
+ let session_metadata = session_layer.metadata();
62
+ let stack = ConfigLayerStack::new(
63
+ vec![user_layer, session_layer],
64
+ ConfigRequirements::default(),
65
+ ConfigRequirementsToml::default(),
66
+ )
67
+ .expect("layer stack should be valid");
68
+
69
+ let origins = stack.origins();
70
+
71
+ assert_eq!(
72
+ origins.get("features.multi_agent_v2.enabled"),
73
+ Some(&session_metadata)
74
+ );
75
+ assert_eq!(
76
+ origins.get("features.multi_agent_v2.subagent_usage_hint_text"),
77
+ Some(&user_metadata)
78
+ );
79
+ }
80
+
81
+ #[test]
82
+ fn origins_omit_displaced_credential_providers() {
83
+ let temp_dir = TempDir::new().expect("tempdir");
84
+ for scope in ["", "profiles.work."] {
85
+ let user = ConfigLayerEntry::new(
86
+ ConfigLayerSource::User {
87
+ file: test_user_config_path(&temp_dir, "config.toml"),
88
+ profile: None,
89
+ },
90
+ toml::from_str(&format!(
91
+ "[{scope}features.network_proxy.credentials.user_provider]\nenv = ['VENDOR_TOKEN']\n",
92
+ ))
93
+ .expect("user config"),
94
+ );
95
+ let session = ConfigLayerEntry::new(
96
+ ConfigLayerSource::SessionFlags,
97
+ toml::from_str(&format!(
98
+ "[{scope}features.network_proxy.credentials.session_provider]\nenv = ['VENDOR_TOKEN']\n",
99
+ ))
100
+ .expect("session config"),
101
+ );
102
+ let metadata = session.metadata();
103
+ let stack = ConfigLayerStack::new(
104
+ vec![user, session],
105
+ ConfigRequirements::default(),
106
+ ConfigRequirementsToml::default(),
107
+ )
108
+ .expect("valid layers");
109
+ assert_eq!(
110
+ stack.origins(),
111
+ HashMap::from([(
112
+ format!("{scope}features.network_proxy.credentials.session_provider.env.0"),
113
+ metadata
114
+ )])
115
+ );
116
+ }
117
+ }
118
+
119
+ #[test]
120
+ fn enabled_layers_validate_shell_environment_policy() {
121
+ let layer = ConfigLayerEntry::new(
122
+ ConfigLayerSource::SessionFlags,
123
+ toml::from_str(
124
+ r#"
125
+ [shell_environment_policy]
126
+ exclude = ["LEGACY_*"]
127
+
128
+ [shell_environment_policy.filters]
129
+ "CANONICAL_*" = "include"
130
+ "#,
131
+ )
132
+ .expect("session config"),
133
+ );
134
+
135
+ let error = ConfigLayerStack::new(
136
+ vec![layer],
137
+ ConfigRequirements::default(),
138
+ ConfigRequirementsToml::default(),
139
+ )
140
+ .expect_err("enabled layers should be validated");
141
+
142
+ assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
143
+ assert!(
144
+ error
145
+ .to_string()
146
+ .contains("cannot mix `filters` with legacy `exclude` or `include_only`")
147
+ );
148
+ }
149
+
150
+ #[test]
151
+ fn disabled_layers_do_not_validate_shell_environment_policy() {
152
+ let layer = ConfigLayerEntry::new_disabled(
153
+ ConfigLayerSource::Project {
154
+ dot_codex_folder: AbsolutePathBuf::from_absolute_path("/untrusted/.codex")
155
+ .expect("project path should be absolute"),
156
+ },
157
+ toml::from_str(
158
+ r#"
159
+ [shell_environment_policy]
160
+ exclude = ["LEGACY_*"]
161
+
162
+ [shell_environment_policy.filters]
163
+ "CANONICAL_*" = "include"
164
+ "#,
165
+ )
166
+ .expect("project config"),
167
+ "project is untrusted",
168
+ );
169
+
170
+ ConfigLayerStack::new(
171
+ vec![layer],
172
+ ConfigRequirements::default(),
173
+ ConfigRequirementsToml::default(),
174
+ )
175
+ .expect("disabled layers should not be validated");
176
+ }
177
+
178
+ #[test]
179
+ fn enabled_layers_only_validate_representation_sensitive_shell_policy_fields() {
180
+ let cases = [
181
+ r#"shell_environment_policy = 17"#,
182
+ r#"
183
+ [shell_environment_policy]
184
+ inherit = "invalid"
185
+ set = ["invalid"]
186
+ "#,
187
+ ];
188
+
189
+ for contents in cases {
190
+ let layer = ConfigLayerEntry::new(
191
+ ConfigLayerSource::SessionFlags,
192
+ toml::from_str(contents).expect("session config"),
193
+ );
194
+
195
+ ConfigLayerStack::new(
196
+ vec![layer],
197
+ ConfigRequirements::default(),
198
+ ConfigRequirementsToml::default(),
199
+ )
200
+ .expect("unrelated shell policy fields should retain normal overlay semantics");
201
+ }
202
+ }
203
+
204
+ #[test]
205
+ fn with_user_config_rejects_malformed_shell_policy_filter_fields() {
206
+ let temp_dir = TempDir::new().expect("tempdir");
207
+ let config_file = test_user_config_path(&temp_dir, "config.toml");
208
+ let cases = [
209
+ r#"
210
+ [shell_environment_policy]
211
+ exclude = ["SECRET_*", 17]
212
+ "#,
213
+ r#"
214
+ [shell_environment_policy.filters]
215
+ "SECRET_*" = "keep"
216
+ "#,
217
+ r#"
218
+ [shell_environment_policy]
219
+ exclude = ["SECRET_*"]
220
+
221
+ [shell_environment_policy.filters]
222
+ "PATH" = "include"
223
+ "#,
224
+ r#"
225
+ [shell_environment_policy.filters]
226
+ "SECRET_*" = "exclude"
227
+ "secret_*" = "include"
228
+ "#,
229
+ ];
230
+
231
+ for contents in cases {
232
+ let error = ConfigLayerStack::default()
233
+ .with_user_config(&config_file, toml::from_str(contents).expect("user config"))
234
+ .expect_err("malformed shell policy filter fields should be rejected");
235
+
236
+ assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
237
+ }
238
+ }
239
+
240
+ #[test]
241
+ fn active_user_layer_is_highest_precedence_user_layer() {
242
+ let temp_dir = TempDir::new().expect("tempdir");
243
+ let base_file = test_user_config_path(&temp_dir, "config.toml");
244
+ let profile_file = test_user_config_path(&temp_dir, "work.config.toml");
245
+ let base_layer = ConfigLayerEntry::new(
246
+ ConfigLayerSource::User {
247
+ file: base_file,
248
+ profile: None,
249
+ },
250
+ toml::from_str(
251
+ r#"
252
+ model = "base"
253
+ approval_policy = "on-request"
254
+ "#,
255
+ )
256
+ .expect("base config"),
257
+ );
258
+ let profile_layer = ConfigLayerEntry::new(
259
+ ConfigLayerSource::User {
260
+ file: profile_file.clone(),
261
+ profile: Some("work".to_string()),
262
+ },
263
+ toml::from_str(r#"model = "profile""#).expect("profile config"),
264
+ );
265
+ let stack = ConfigLayerStack::new(
266
+ vec![base_layer, profile_layer],
267
+ ConfigRequirements::default(),
268
+ ConfigRequirementsToml::default(),
269
+ )
270
+ .expect("multiple user layers should be valid");
271
+
272
+ assert_eq!(stack.get_user_config_file(), Some(&profile_file));
273
+ assert_eq!(
274
+ stack
275
+ .effective_user_config()
276
+ .expect("merged user config")
277
+ .get("model")
278
+ .and_then(toml::Value::as_str),
279
+ Some("profile")
280
+ );
281
+ assert_eq!(
282
+ stack
283
+ .effective_user_config()
284
+ .expect("merged user config")
285
+ .get("approval_policy")
286
+ .and_then(toml::Value::as_str),
287
+ Some("on-request")
288
+ );
289
+ }
290
+
291
+ #[test]
292
+ fn layer_iterators_preserve_precedence_and_disabled_layers() {
293
+ let temp_dir = TempDir::new().expect("tempdir");
294
+ let user_source = ConfigLayerSource::User {
295
+ file: test_user_config_path(&temp_dir, "config.toml"),
296
+ profile: None,
297
+ };
298
+ let project_source = ConfigLayerSource::Project {
299
+ dot_codex_folder: test_user_config_path(&temp_dir, ".codex"),
300
+ };
301
+ let session_source = ConfigLayerSource::SessionFlags;
302
+ let empty_config = TomlValue::Table(toml::map::Map::new());
303
+ let stack = ConfigLayerStack::new(
304
+ vec![
305
+ ConfigLayerEntry::new(user_source.clone(), empty_config.clone()),
306
+ ConfigLayerEntry::new_disabled(
307
+ project_source.clone(),
308
+ empty_config.clone(),
309
+ "project is untrusted",
310
+ ),
311
+ ConfigLayerEntry::new(session_source.clone(), empty_config),
312
+ ],
313
+ ConfigRequirements::default(),
314
+ ConfigRequirementsToml::default(),
315
+ )
316
+ .expect("layer stack should be valid");
317
+
318
+ assert_eq!(
319
+ stack
320
+ .layers_low_to_high()
321
+ .map(|layer| &layer.name)
322
+ .collect::<Vec<_>>(),
323
+ vec![&user_source, &session_source]
324
+ );
325
+ assert_eq!(
326
+ stack
327
+ .layers_high_to_low()
328
+ .map(|layer| &layer.name)
329
+ .collect::<Vec<_>>(),
330
+ vec![&session_source, &user_source]
331
+ );
332
+ assert_eq!(
333
+ stack
334
+ .all_layers_low_to_high()
335
+ .map(|layer| &layer.name)
336
+ .collect::<Vec<_>>(),
337
+ vec![&user_source, &project_source, &session_source]
338
+ );
339
+ assert_eq!(
340
+ stack
341
+ .all_layers_high_to_low()
342
+ .map(|layer| &layer.name)
343
+ .collect::<Vec<_>>(),
344
+ vec![&session_source, &project_source, &user_source]
345
+ );
346
+ }
347
+
348
+ #[test]
349
+ fn with_user_config_updates_matching_user_layer_without_replacing_active_profile() {
350
+ let temp_dir = TempDir::new().expect("tempdir");
351
+ let base_file = test_user_config_path(&temp_dir, "config.toml");
352
+ let profile_file = test_user_config_path(&temp_dir, "work.config.toml");
353
+ let base_layer = ConfigLayerEntry::new(
354
+ ConfigLayerSource::User {
355
+ file: base_file.clone(),
356
+ profile: None,
357
+ },
358
+ toml::from_str(r#"model = "base""#).expect("base config"),
359
+ );
360
+ let profile_layer = ConfigLayerEntry::new(
361
+ ConfigLayerSource::User {
362
+ file: profile_file.clone(),
363
+ profile: Some("work".to_string()),
364
+ },
365
+ toml::from_str(r#"approval_policy = "on-request""#).expect("profile config"),
366
+ );
367
+ let stack = ConfigLayerStack::new(
368
+ vec![base_layer, profile_layer],
369
+ ConfigRequirements::default(),
370
+ ConfigRequirementsToml::default(),
371
+ )
372
+ .expect("multiple user layers should be valid");
373
+
374
+ let updated = stack
375
+ .with_user_config(
376
+ &base_file,
377
+ toml::from_str(r#"model = "updated-base""#).expect("updated base config"),
378
+ )
379
+ .expect("updated user layer should be valid");
380
+
381
+ assert_eq!(updated.get_user_config_file(), Some(&profile_file));
382
+ assert_eq!(
383
+ updated
384
+ .effective_user_config()
385
+ .expect("merged user config")
386
+ .get("model")
387
+ .and_then(toml::Value::as_str),
388
+ Some("updated-base")
389
+ );
390
+ assert_eq!(
391
+ updated
392
+ .effective_user_config()
393
+ .expect("merged user config")
394
+ .get("approval_policy")
395
+ .and_then(toml::Value::as_str),
396
+ Some("on-request")
397
+ );
398
+ }
codex-rs/config/src/strict_config_tests.rs ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use super::*;
2
+ use crate::ConfigLayerEntry;
3
+ use crate::ConfigLayerSource;
4
+ use crate::ConfigRequirements;
5
+ use crate::config_toml::ConfigToml;
6
+ use crate::diagnostics::TextPosition;
7
+ use crate::diagnostics::TextRange;
8
+ use pretty_assertions::assert_eq;
9
+ use std::path::PathBuf;
10
+
11
+ #[test]
12
+ fn ignored_toml_field_errors_accept_non_file_source_names() {
13
+ let source_name = "com.openai.codex:config_toml_base64";
14
+ let contents = r#"
15
+ model = "gpt-5"
16
+ unknown_key = true"#;
17
+
18
+ let value = toml::from_str::<TomlValue>(contents).expect("valid TOML");
19
+ let error = config_error_from_ignored_toml_value_fields_for_source_name::<ConfigToml>(
20
+ source_name,
21
+ contents,
22
+ value,
23
+ )
24
+ .expect("unknown field error");
25
+
26
+ assert_eq!(
27
+ error,
28
+ ConfigError::new(
29
+ PathBuf::from(source_name),
30
+ TextRange {
31
+ start: TextPosition { line: 3, column: 1 },
32
+ end: TextPosition {
33
+ line: 3,
34
+ column: 11,
35
+ },
36
+ },
37
+ "unknown configuration field `unknown_key`",
38
+ )
39
+ );
40
+ }
41
+
42
+ #[test]
43
+ fn type_errors_take_precedence_over_ignored_fields() {
44
+ let path = Path::new("/tmp/config.toml");
45
+ let contents = r#"
46
+ model_context_window = "wide"
47
+ unknown_key = true"#;
48
+
49
+ let error =
50
+ config_error_from_ignored_toml_fields::<ConfigToml>(path, contents).expect("type error");
51
+
52
+ assert_eq!(
53
+ error,
54
+ ConfigError::new(
55
+ path.to_path_buf(),
56
+ TextRange {
57
+ start: TextPosition {
58
+ line: 2,
59
+ column: 24,
60
+ },
61
+ end: TextPosition {
62
+ line: 2,
63
+ column: 29,
64
+ },
65
+ },
66
+ "invalid type: string \"wide\", expected i64",
67
+ )
68
+ );
69
+ }
70
+
71
+ #[test]
72
+ fn strict_config_rejects_unknown_feature_key() {
73
+ let path = Path::new("/tmp/config.toml");
74
+ let contents = r#"
75
+ [features]
76
+ foo = true"#;
77
+
78
+ let error = config_error_from_ignored_toml_fields::<ConfigToml>(path, contents)
79
+ .expect("unknown feature error");
80
+
81
+ assert_eq!(
82
+ error,
83
+ ConfigError::new(
84
+ path.to_path_buf(),
85
+ TextRange {
86
+ start: TextPosition { line: 3, column: 1 },
87
+ end: TextPosition { line: 3, column: 3 },
88
+ },
89
+ "unknown configuration field `features.foo`",
90
+ )
91
+ );
92
+ }
93
+
94
+ #[test]
95
+ fn strict_config_accepts_tool_registry_config() {
96
+ let path = Path::new("/tmp/config.toml");
97
+
98
+ for contents in [
99
+ "[features.tool_registry]\nerror_on_tool_collisions = true\n",
100
+ "[profiles.work.features.tool_registry]\nerror_on_tool_collisions = true\n",
101
+ "[features.tool_registry]\nturn_metadata_includes_tool_info = true\n",
102
+ "[profiles.work.features.tool_registry]\nturn_metadata_includes_tool_info = true\n",
103
+ ] {
104
+ assert_eq!(
105
+ config_error_from_ignored_toml_fields::<ConfigToml>(path, contents),
106
+ None
107
+ );
108
+ }
109
+
110
+ assert!(
111
+ config_error_from_ignored_toml_fields::<ConfigToml>(
112
+ path,
113
+ "[features.tool_registry]\nunknown = true\n",
114
+ )
115
+ .is_some()
116
+ );
117
+ }
118
+
119
+ #[test]
120
+ fn strict_config_rejects_unknown_profile_feature_key() {
121
+ let path = Path::new("/tmp/config.toml");
122
+ let contents = r#"
123
+ [profiles.work.features]
124
+ foo = true"#;
125
+
126
+ let error = config_error_from_ignored_toml_fields::<ConfigToml>(path, contents)
127
+ .expect("unknown feature error");
128
+
129
+ assert_eq!(
130
+ error,
131
+ ConfigError::new(
132
+ path.to_path_buf(),
133
+ TextRange {
134
+ start: TextPosition { line: 3, column: 1 },
135
+ end: TextPosition { line: 3, column: 3 },
136
+ },
137
+ "unknown configuration field `profiles.work.features.foo`",
138
+ )
139
+ );
140
+ }
141
+
142
+ #[test]
143
+ fn strict_config_accepts_opaque_desktop_keys() {
144
+ let path = Path::new("/tmp/config.toml");
145
+ let contents = r#"
146
+ [desktop]
147
+ appearanceTheme = "dark"
148
+
149
+ [desktop.workspace]
150
+ collapsed = true"#;
151
+
152
+ let error = config_error_from_ignored_toml_fields::<ConfigToml>(path, contents);
153
+
154
+ assert_eq!(error, None);
155
+ }
156
+
157
+ fn layer(source: ConfigLayerSource, contents: &str) -> ConfigLayerEntry {
158
+ ConfigLayerEntry::new(source, toml::from_str(contents).unwrap())
159
+ }
160
+
161
+ #[test]
162
+ fn checks_merged_config_and_ignores_disabled_layers() {
163
+ let layers = vec![
164
+ layer(
165
+ ConfigLayerSource::EnterpriseManaged {
166
+ id: "cfg".into(),
167
+ name: "Defaults".into(),
168
+ },
169
+ r#"
170
+ [model_providers.custom]
171
+ name = "Custom"
172
+ wire_api = "responses"
173
+ unknown_timeout = 1
174
+ [model_providers.custom.http_headers]
175
+ custom_header = "accepted"
176
+ "#,
177
+ ),
178
+ ConfigLayerEntry::new_disabled(
179
+ ConfigLayerSource::SessionFlags,
180
+ toml::from_str("disabled_unknown = true").unwrap(),
181
+ "not trusted",
182
+ ),
183
+ layer(
184
+ ConfigLayerSource::SessionFlags,
185
+ r#"
186
+ [model_providers.custom]
187
+ unknown_timeout = 42
188
+ [profiles.work.features]
189
+ include_view_image_tool = false
190
+ "#,
191
+ ),
192
+ ];
193
+ let config = ConfigLayerStack::new(
194
+ layers,
195
+ ConfigRequirements::default(),
196
+ ConfigRequirementsToml::default(),
197
+ )
198
+ .unwrap();
199
+ assert_eq!(
200
+ ignored_config_warning(&config, &[]).unwrap(),
201
+ "Codex is ignoring 3 unrecognized configuration settings. Check for typos or deprecated settings.\n enterprise-managed (Defaults, cfg): `model_providers.custom.unknown_timeout` is ignored.\n session-flags: `model_providers.custom.unknown_timeout` is ignored.\n session-flags: `profiles.work.features.include_view_image_tool` is ignored. Use [features].view_image to configure the image tool."
202
+ );
203
+ }
codex-rs/config/src/test_support.rs ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Test-only helpers exposed for cross-crate integration tests.
2
+ //!
3
+ //! Production code should not depend on this module.
4
+
5
+ use crate::CloudConfigBundle;
6
+ use crate::CloudConfigBundleLoader;
7
+ use crate::CloudConfigFragment;
8
+ use crate::CloudRequirementsFragment;
9
+
10
+ #[derive(Debug, Clone, Default)]
11
+ pub struct CloudConfigBundleFixture {
12
+ bundle: CloudConfigBundle,
13
+ }
14
+
15
+ impl CloudConfigBundleFixture {
16
+ pub fn enterprise_requirement(contents: impl Into<String>) -> Self {
17
+ Self::default().add_enterprise_requirement(contents)
18
+ }
19
+
20
+ pub fn enterprise_config(contents: impl Into<String>) -> Self {
21
+ Self::default().add_enterprise_config(contents)
22
+ }
23
+
24
+ pub fn loader_with_enterprise_requirement(
25
+ contents: impl Into<String>,
26
+ ) -> CloudConfigBundleLoader {
27
+ Self::enterprise_requirement(contents).into_loader()
28
+ }
29
+
30
+ pub fn loader_with_enterprise_config(contents: impl Into<String>) -> CloudConfigBundleLoader {
31
+ Self::enterprise_config(contents).into_loader()
32
+ }
33
+
34
+ pub fn add_enterprise_requirement(mut self, contents: impl Into<String>) -> Self {
35
+ let index = self.bundle.requirements_toml.enterprise_managed.len() + 1;
36
+ self.bundle
37
+ .requirements_toml
38
+ .enterprise_managed
39
+ .push(CloudRequirementsFragment {
40
+ id: format!("req_{index}"),
41
+ name: if index == 1 {
42
+ "Base requirements".to_string()
43
+ } else {
44
+ format!("Requirements {index}")
45
+ },
46
+ contents: contents.into(),
47
+ });
48
+ self
49
+ }
50
+
51
+ pub fn add_enterprise_config(mut self, contents: impl Into<String>) -> Self {
52
+ let index = self.bundle.config_toml.enterprise_managed.len() + 1;
53
+ self.bundle
54
+ .config_toml
55
+ .enterprise_managed
56
+ .push(CloudConfigFragment {
57
+ id: format!("cfg_{index}"),
58
+ name: if index == 1 {
59
+ "Base config".to_string()
60
+ } else {
61
+ format!("Config {index}")
62
+ },
63
+ contents: contents.into(),
64
+ });
65
+ self
66
+ }
67
+
68
+ pub fn into_bundle(self) -> CloudConfigBundle {
69
+ self.bundle
70
+ }
71
+
72
+ pub fn into_loader(self) -> CloudConfigBundleLoader {
73
+ let bundle = self.into_bundle();
74
+ CloudConfigBundleLoader::new(async move { Ok(Some(bundle)) })
75
+ }
76
+ }
77
+
78
+ #[cfg(test)]
79
+ #[path = "test_support_tests.rs"]
80
+ mod tests;
codex-rs/config/src/thread_config.rs ADDED
@@ -0,0 +1,319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::collections::BTreeMap;
2
+ use std::collections::HashMap;
3
+ use std::future::Future;
4
+ use std::pin::Pin;
5
+
6
+ use crate::ConfigLayerSource;
7
+ use codex_model_provider_info::ModelProviderInfo;
8
+ use codex_utils_absolute_path::AbsolutePathBuf;
9
+ use thiserror::Error;
10
+ use toml::Value as TomlValue;
11
+
12
+ use crate::ConfigLayerEntry;
13
+
14
+ mod remote;
15
+
16
+ pub use remote::RemoteThreadConfigLoader;
17
+
18
+ /// Context available to implementations when loading thread-scoped config.
19
+ #[derive(Clone, Debug, Default, Eq, PartialEq)]
20
+ pub struct ThreadConfigContext {
21
+ pub thread_id: Option<String>,
22
+ pub cwd: Option<AbsolutePathBuf>,
23
+ }
24
+
25
+ /// Config values owned by the service that starts or manages the session.
26
+ #[derive(Clone, Debug, Default, PartialEq)]
27
+ pub struct SessionThreadConfig {
28
+ pub model_provider: Option<String>,
29
+ pub model_providers: HashMap<String, ModelProviderInfo>,
30
+ pub features: BTreeMap<String, bool>,
31
+ }
32
+
33
+ /// Config values owned by the authenticated user.
34
+ #[derive(Clone, Debug, Default, Eq, PartialEq)]
35
+ pub struct UserThreadConfig {}
36
+
37
+ /// A typed config payload paired with the authority that produced it.
38
+ #[derive(Clone, Debug, PartialEq)]
39
+ pub enum ThreadConfigSource {
40
+ Session(SessionThreadConfig),
41
+ User(UserThreadConfig),
42
+ }
43
+
44
+ /// Stable category for failures returned while loading thread config.
45
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
46
+ pub enum ThreadConfigLoadErrorCode {
47
+ Auth,
48
+ Timeout,
49
+ Parse,
50
+ RequestFailed,
51
+ Internal,
52
+ }
53
+
54
+ #[derive(Clone, Debug, Eq, Error, PartialEq)]
55
+ #[error("{message}")]
56
+ pub struct ThreadConfigLoadError {
57
+ code: ThreadConfigLoadErrorCode,
58
+ message: String,
59
+ status_code: Option<u16>,
60
+ }
61
+
62
+ impl ThreadConfigLoadError {
63
+ pub fn new(
64
+ code: ThreadConfigLoadErrorCode,
65
+ status_code: Option<u16>,
66
+ message: impl Into<String>,
67
+ ) -> Self {
68
+ Self {
69
+ code,
70
+ message: message.into(),
71
+ status_code,
72
+ }
73
+ }
74
+
75
+ pub fn code(&self) -> ThreadConfigLoadErrorCode {
76
+ self.code
77
+ }
78
+ }
79
+
80
+ /// Loads typed config sources for a new thread.
81
+ ///
82
+ /// Implementations should fetch only the source-specific config they own and
83
+ /// return typed payloads without applying precedence or merge rules. Callers
84
+ /// are responsible for resolving the returned sources into the effective
85
+ /// runtime config.
86
+ pub trait ThreadConfigLoader: Send + Sync {
87
+ /// Load source-specific typed config.
88
+ ///
89
+ /// Implementations should keep this method focused on fetching and parsing
90
+ /// their owned sources. Most callers should use [`Self::load_config_layers`]
91
+ /// so precedence and merging continue through the ordinary config layer
92
+ /// stack.
93
+ fn load(
94
+ &self,
95
+ context: ThreadConfigContext,
96
+ ) -> ThreadConfigLoaderFuture<'_, Vec<ThreadConfigSource>>;
97
+
98
+ fn load_config_layers(
99
+ &self,
100
+ context: ThreadConfigContext,
101
+ ) -> ThreadConfigLoaderFuture<'_, Vec<ConfigLayerEntry>> {
102
+ Box::pin(async move {
103
+ let sources = self.load(context).await?;
104
+ sources
105
+ .into_iter()
106
+ .map(thread_config_source_to_layer)
107
+ .collect::<Result<Vec<_>, _>>()
108
+ .map(|layers| layers.into_iter().flatten().collect())
109
+ })
110
+ }
111
+ }
112
+
113
+ pub type ThreadConfigLoaderFuture<'a, T> =
114
+ Pin<Box<dyn Future<Output = Result<T, ThreadConfigLoadError>> + Send + 'a>>;
115
+
116
+ /// Loader backed by a static set of typed thread config sources.
117
+ #[derive(Clone, Debug, Default, PartialEq)]
118
+ pub struct StaticThreadConfigLoader {
119
+ sources: Vec<ThreadConfigSource>,
120
+ }
121
+
122
+ impl StaticThreadConfigLoader {
123
+ pub fn new(sources: Vec<ThreadConfigSource>) -> Self {
124
+ Self { sources }
125
+ }
126
+ }
127
+
128
+ impl ThreadConfigLoader for StaticThreadConfigLoader {
129
+ fn load(
130
+ &self,
131
+ _context: ThreadConfigContext,
132
+ ) -> ThreadConfigLoaderFuture<'_, Vec<ThreadConfigSource>> {
133
+ Box::pin(async { Ok(self.sources.clone()) })
134
+ }
135
+ }
136
+
137
+ /// Loader used when no external thread config source is configured.
138
+ #[derive(Clone, Debug, Default)]
139
+ pub struct NoopThreadConfigLoader;
140
+
141
+ impl ThreadConfigLoader for NoopThreadConfigLoader {
142
+ fn load(
143
+ &self,
144
+ _context: ThreadConfigContext,
145
+ ) -> ThreadConfigLoaderFuture<'_, Vec<ThreadConfigSource>> {
146
+ Box::pin(async { Ok(Vec::new()) })
147
+ }
148
+ }
149
+
150
+ fn thread_config_source_to_layer(
151
+ source: ThreadConfigSource,
152
+ ) -> Result<Option<ConfigLayerEntry>, ThreadConfigLoadError> {
153
+ match source {
154
+ ThreadConfigSource::Session(config) => {
155
+ let config = session_thread_config_to_toml(config)?;
156
+ if is_empty_table(&config) {
157
+ Ok(None)
158
+ } else {
159
+ Ok(Some(ConfigLayerEntry::new(
160
+ ConfigLayerSource::SessionFlags,
161
+ config,
162
+ )))
163
+ }
164
+ }
165
+ // UserThreadConfig has no TOML-backed fields yet. When it grows one,
166
+ // fold it into the existing user layer instead of adding another
167
+ // ConfigLayerSource variant.
168
+ ThreadConfigSource::User(_config) => Ok(None),
169
+ }
170
+ }
171
+
172
+ fn is_empty_table(config: &TomlValue) -> bool {
173
+ config.as_table().is_some_and(toml::map::Map::is_empty)
174
+ }
175
+
176
+ fn session_thread_config_to_toml(
177
+ config: SessionThreadConfig,
178
+ ) -> Result<TomlValue, ThreadConfigLoadError> {
179
+ let mut table = toml::map::Map::new();
180
+
181
+ if let Some(model_provider) = config.model_provider {
182
+ table.insert(
183
+ "model_provider".to_string(),
184
+ TomlValue::String(model_provider),
185
+ );
186
+ }
187
+
188
+ if !config.model_providers.is_empty() {
189
+ let model_providers = TomlValue::try_from(config.model_providers).map_err(|err| {
190
+ ThreadConfigLoadError::new(
191
+ ThreadConfigLoadErrorCode::Parse,
192
+ /*status_code*/ None,
193
+ format!("failed to convert session model providers to config TOML: {err}"),
194
+ )
195
+ })?;
196
+ table.insert("model_providers".to_string(), model_providers);
197
+ }
198
+
199
+ if !config.features.is_empty() {
200
+ let features = config
201
+ .features
202
+ .into_iter()
203
+ .map(|(feature, enabled)| (feature, TomlValue::Boolean(enabled)))
204
+ .collect();
205
+ table.insert("features".to_string(), TomlValue::Table(features));
206
+ }
207
+
208
+ Ok(TomlValue::Table(table))
209
+ }
210
+
211
+ #[cfg(test)]
212
+ mod tests {
213
+ use codex_model_provider_info::ModelProviderInfo;
214
+ use codex_model_provider_info::WireApi;
215
+ use pretty_assertions::assert_eq;
216
+
217
+ use super::*;
218
+
219
+ #[tokio::test]
220
+ async fn loader_returns_session_and_user_sources() {
221
+ let loader = StaticThreadConfigLoader::new(vec![
222
+ ThreadConfigSource::Session(SessionThreadConfig {
223
+ model_provider: Some("local".to_string()),
224
+ model_providers: HashMap::from([("local".to_string(), test_provider("local"))]),
225
+ features: BTreeMap::from([("plugins".to_string(), false)]),
226
+ }),
227
+ ThreadConfigSource::User(UserThreadConfig::default()),
228
+ ]);
229
+
230
+ let sources = loader
231
+ .load(ThreadConfigContext {
232
+ thread_id: Some("thread-1".to_string()),
233
+ ..Default::default()
234
+ })
235
+ .await
236
+ .expect("thread config loads");
237
+
238
+ assert_eq!(
239
+ sources,
240
+ vec![
241
+ ThreadConfigSource::Session(SessionThreadConfig {
242
+ model_provider: Some("local".to_string()),
243
+ model_providers: HashMap::from([("local".to_string(), test_provider("local"))]),
244
+ features: BTreeMap::from([("plugins".to_string(), false)]),
245
+ }),
246
+ ThreadConfigSource::User(UserThreadConfig::default()),
247
+ ]
248
+ );
249
+ }
250
+
251
+ #[tokio::test]
252
+ async fn loader_translates_sources_to_config_layers() {
253
+ let loader = StaticThreadConfigLoader::new(vec![
254
+ ThreadConfigSource::User(UserThreadConfig::default()),
255
+ ThreadConfigSource::Session(SessionThreadConfig {
256
+ model_provider: Some("local".to_string()),
257
+ model_providers: HashMap::from([("local".to_string(), test_provider("local"))]),
258
+ features: BTreeMap::from([("plugins".to_string(), false)]),
259
+ }),
260
+ ]);
261
+ let layers = loader
262
+ .load_config_layers(ThreadConfigContext {
263
+ cwd: Some(
264
+ AbsolutePathBuf::from_absolute_path_checked(
265
+ std::env::temp_dir().join("project"),
266
+ )
267
+ .expect("absolute cwd"),
268
+ ),
269
+ ..Default::default()
270
+ })
271
+ .await
272
+ .expect("thread config layers load");
273
+
274
+ assert_eq!(
275
+ layers,
276
+ vec![ConfigLayerEntry::new(
277
+ ConfigLayerSource::SessionFlags,
278
+ toml::toml! {
279
+ model_provider = "local"
280
+
281
+ [model_providers.local]
282
+ name = "local"
283
+ base_url = "http://127.0.0.1:8061/api/codex"
284
+ wire_api = "responses"
285
+ requires_openai_auth = false
286
+ supports_websockets = true
287
+ supports_standalone_web_search = true
288
+
289
+ [features]
290
+ plugins = false
291
+ }
292
+ .into()
293
+ )]
294
+ );
295
+ }
296
+
297
+ fn test_provider(name: &str) -> ModelProviderInfo {
298
+ ModelProviderInfo {
299
+ name: name.to_string(),
300
+ base_url: Some("http://127.0.0.1:8061/api/codex".to_string()),
301
+ env_key: None,
302
+ env_key_instructions: None,
303
+ experimental_bearer_token: None,
304
+ auth: None,
305
+ aws: None,
306
+ wire_api: WireApi::Responses,
307
+ query_params: None,
308
+ http_headers: None,
309
+ env_http_headers: None,
310
+ request_max_retries: None,
311
+ stream_max_retries: None,
312
+ stream_idle_timeout_ms: None,
313
+ websocket_connect_timeout_ms: None,
314
+ requires_openai_auth: false,
315
+ supports_websockets: true,
316
+ supports_standalone_web_search: true,
317
+ }
318
+ }
319
+ }
codex-rs/config/src/types.rs ADDED
@@ -0,0 +1,1057 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Types used to define loaded and effective Codex configuration values.
2
+
3
+ // Note this file should generally be restricted to simple struct/enum
4
+ // definitions that do not contain business logic.
5
+
6
+ pub use crate::mcp_ema::McpEnterpriseManagedAuthConfig;
7
+ pub use crate::mcp_ema::McpServerIdpOAuthConfig;
8
+ pub use crate::mcp_types::AppToolApproval;
9
+ pub use crate::mcp_types::McpServerAuth;
10
+ pub use crate::mcp_types::McpServerConfig;
11
+ pub use crate::mcp_types::McpServerDisabledReason;
12
+ pub use crate::mcp_types::McpServerEnvVar;
13
+ pub use crate::mcp_types::McpServerOAuthConfig;
14
+ pub use crate::mcp_types::McpServerToolConfig;
15
+ pub use crate::mcp_types::McpServerTransportConfig;
16
+ pub use crate::mcp_types::RawMcpServerConfig;
17
+ pub use crate::shell_environment_policy::ShellEnvironmentPolicyToml;
18
+ pub use codex_protocol::config_types::AltScreenMode;
19
+ pub use codex_protocol::config_types::ApprovalsReviewer;
20
+ pub use codex_protocol::config_types::ModeKind;
21
+ pub use codex_protocol::config_types::Personality;
22
+ pub use codex_protocol::config_types::ServiceTier;
23
+ pub use codex_protocol::config_types::WebSearchMode;
24
+ use codex_utils_absolute_path::AbsolutePathBuf;
25
+ use std::collections::BTreeMap;
26
+ use std::collections::HashMap;
27
+ use std::fmt;
28
+
29
+ use schemars::JsonSchema;
30
+ use serde::Deserialize;
31
+ use serde::Serialize;
32
+
33
+ pub use crate::tui_keymap::KeybindingSpec;
34
+ pub use crate::tui_keymap::KeybindingsSpec;
35
+ pub use crate::tui_keymap::MAX_FUNCTION_KEY;
36
+ pub use crate::tui_keymap::TuiAgentsKeymap;
37
+ pub use crate::tui_keymap::TuiApprovalKeymap;
38
+ pub use crate::tui_keymap::TuiChatKeymap;
39
+ pub use crate::tui_keymap::TuiComposerKeymap;
40
+ pub use crate::tui_keymap::TuiEditorKeymap;
41
+ pub use crate::tui_keymap::TuiGlobalKeymap;
42
+ pub use crate::tui_keymap::TuiKeymap;
43
+ pub use crate::tui_keymap::TuiListKeymap;
44
+ pub use crate::tui_keymap::TuiPagerKeymap;
45
+ pub use crate::tui_keymap::TuiVimNormalKeymap;
46
+ pub use crate::tui_keymap::TuiVimOperatorKeymap;
47
+ pub use crate::tui_keymap::TuiVimSearchKeymap;
48
+
49
+ pub const DEFAULT_OTEL_ENVIRONMENT: &str = "dev";
50
+ pub const DEFAULT_MEMORIES_MAX_ROLLOUTS_PER_STARTUP: usize = 2;
51
+ pub const DEFAULT_MEMORIES_MAX_ROLLOUT_AGE_DAYS: i64 = 10;
52
+ pub const DEFAULT_MEMORIES_MIN_ROLLOUT_IDLE_HOURS: i64 = 6;
53
+ pub const DEFAULT_MEMORIES_MIN_RATE_LIMIT_REMAINING_PERCENT: i64 = 25;
54
+ pub const DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION: usize = 256;
55
+ pub const DEFAULT_MEMORIES_MAX_UNUSED_DAYS: i64 = 30;
56
+ const MIN_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION: usize = 1;
57
+ const MAX_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION: usize = 4096;
58
+ const MIN_MEMORIES_MAX_ROLLOUTS_PER_STARTUP: usize = 1;
59
+ const MAX_MEMORIES_MAX_ROLLOUTS_PER_STARTUP: usize = 128;
60
+
61
+ const fn default_enabled() -> bool {
62
+ true
63
+ }
64
+
65
+ /// Preferred layout for the resume/fork session picker.
66
+ #[derive(Serialize, Deserialize, Debug, Default, Copy, Clone, PartialEq, Eq, JsonSchema)]
67
+ #[serde(rename_all = "kebab-case")]
68
+ pub enum SessionPickerViewMode {
69
+ Comfortable,
70
+ #[default]
71
+ Dense,
72
+ }
73
+
74
+ impl SessionPickerViewMode {
75
+ pub const fn as_str(self) -> &'static str {
76
+ match self {
77
+ Self::Comfortable => "comfortable",
78
+ Self::Dense => "dense",
79
+ }
80
+ }
81
+ }
82
+
83
+ impl fmt::Display for SessionPickerViewMode {
84
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85
+ f.write_str(self.as_str())
86
+ }
87
+ }
88
+
89
+ /// Working directory to use when resuming or forking a session.
90
+ #[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq, JsonSchema)]
91
+ #[serde(rename_all = "kebab-case")]
92
+ pub enum ResumeCwdMode {
93
+ /// Use the directory where Codex was launched.
94
+ Current,
95
+ /// Use the latest working directory recorded in the selected session.
96
+ Session,
97
+ }
98
+
99
+ impl ResumeCwdMode {
100
+ pub const fn as_str(self) -> &'static str {
101
+ match self {
102
+ Self::Current => "current",
103
+ Self::Session => "session",
104
+ }
105
+ }
106
+ }
107
+
108
+ /// Determine where Codex should store CLI auth credentials.
109
+ #[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
110
+ #[serde(rename_all = "lowercase")]
111
+ pub enum AuthCredentialsStoreMode {
112
+ #[default]
113
+ /// Persist credentials in CODEX_HOME/auth.json.
114
+ File,
115
+ /// Persist credentials in the keyring. Fail if unavailable.
116
+ Keyring,
117
+ /// Use keyring when available; otherwise, fall back to a file in CODEX_HOME.
118
+ Auto,
119
+ /// Store credentials in memory only for the current process.
120
+ Ephemeral,
121
+ }
122
+
123
+ /// Determine where Codex should store and read MCP credentials.
124
+ #[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
125
+ #[serde(rename_all = "lowercase")]
126
+ pub enum OAuthCredentialsStoreMode {
127
+ /// Prefer `Keyring` and use `File` when keyring storage is unavailable.
128
+ /// Once an MCP client loads credentials from one store, that client keeps the resolved store
129
+ /// for its lifetime so refreshes cannot switch to a possibly stale credential source.
130
+ /// Credentials stored in the keyring will only be readable by Codex unless the user explicitly grants access via OS-level keyring access.
131
+ #[default]
132
+ Auto,
133
+ /// CODEX_HOME/.credentials.json
134
+ /// This file will be readable to Codex and other applications running as the same user.
135
+ File,
136
+ /// Keyring when available, otherwise fail.
137
+ Keyring,
138
+ }
139
+
140
+ /// Determine how auth credentials should use keyring-backed storage.
141
+ #[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
142
+ #[serde(rename_all = "lowercase")]
143
+ pub enum AuthKeyringBackendKind {
144
+ /// Store the serialized auth payload directly in the OS keyring.
145
+ Direct,
146
+ /// Store auth payloads in the local encrypted secrets file, with the file key in the OS keyring.
147
+ Secrets,
148
+ }
149
+
150
+ impl Default for AuthKeyringBackendKind {
151
+ fn default() -> Self {
152
+ if cfg!(windows) {
153
+ Self::Secrets
154
+ } else {
155
+ Self::Direct
156
+ }
157
+ }
158
+ }
159
+
160
+ #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema)]
161
+ #[serde(rename_all = "kebab-case")]
162
+ pub enum WindowsSandboxModeToml {
163
+ Elevated,
164
+ Unelevated,
165
+ }
166
+
167
+ #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
168
+ #[schemars(deny_unknown_fields)]
169
+ pub struct WindowsToml {
170
+ pub sandbox: Option<WindowsSandboxModeToml>,
171
+ /// Defaults to `true`. Set to `false` to launch the final sandboxed child
172
+ /// process on `Winsta0\\Default` instead of a private desktop.
173
+ pub sandbox_private_desktop: Option<bool>,
174
+ }
175
+
176
+ #[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, JsonSchema)]
177
+ pub enum UriBasedFileOpener {
178
+ #[serde(rename = "vscode")]
179
+ VsCode,
180
+
181
+ #[serde(rename = "vscode-insiders")]
182
+ VsCodeInsiders,
183
+
184
+ #[serde(rename = "windsurf")]
185
+ Windsurf,
186
+
187
+ #[serde(rename = "cursor")]
188
+ Cursor,
189
+
190
+ /// Option to disable the URI-based file opener.
191
+ #[serde(rename = "none")]
192
+ None,
193
+ }
194
+
195
+ /// Settings that govern if and what will be written to `~/.codex/history.jsonl`.
196
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
197
+ #[serde(default)]
198
+ #[schemars(deny_unknown_fields)]
199
+ pub struct History {
200
+ /// If true, history entries will not be written to disk.
201
+ pub persistence: HistoryPersistence,
202
+
203
+ /// If set, the maximum size of the history file in bytes. The oldest entries
204
+ /// are dropped once the file exceeds this limit.
205
+ pub max_bytes: Option<usize>,
206
+ }
207
+
208
+ #[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Default, JsonSchema)]
209
+ #[serde(rename_all = "kebab-case")]
210
+ pub enum HistoryPersistence {
211
+ /// Save all history entries to disk.
212
+ #[default]
213
+ SaveAll,
214
+ /// Do not write history to disk.
215
+ None,
216
+ }
217
+
218
+ // ===== Analytics configuration =====
219
+
220
+ /// Analytics settings loaded from config.toml. Fields are optional so we can apply defaults.
221
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
222
+ #[schemars(deny_unknown_fields)]
223
+ pub struct AnalyticsConfigToml {
224
+ /// When `false`, disables analytics across Codex product surfaces in this profile.
225
+ pub enabled: Option<bool>,
226
+ }
227
+
228
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
229
+ #[schemars(deny_unknown_fields)]
230
+ pub struct FeedbackConfigToml {
231
+ /// When `false`, disables the feedback flow across Codex product surfaces.
232
+ pub enabled: Option<bool>,
233
+ }
234
+
235
+ #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash, JsonSchema)]
236
+ #[serde(rename_all = "snake_case")]
237
+ pub enum ToolSuggestDiscoverableType {
238
+ Connector,
239
+ Plugin,
240
+ }
241
+
242
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash, JsonSchema)]
243
+ #[schemars(deny_unknown_fields)]
244
+ pub struct ToolSuggestDiscoverable {
245
+ #[serde(rename = "type")]
246
+ pub kind: ToolSuggestDiscoverableType,
247
+ pub id: String,
248
+ }
249
+
250
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash, JsonSchema)]
251
+ #[schemars(deny_unknown_fields)]
252
+ pub struct ToolSuggestDisabledTool {
253
+ #[serde(rename = "type")]
254
+ pub kind: ToolSuggestDiscoverableType,
255
+ pub id: String,
256
+ }
257
+
258
+ impl ToolSuggestDisabledTool {
259
+ pub fn plugin(id: impl Into<String>) -> Self {
260
+ Self {
261
+ kind: ToolSuggestDiscoverableType::Plugin,
262
+ id: id.into(),
263
+ }
264
+ }
265
+
266
+ pub fn connector(id: impl Into<String>) -> Self {
267
+ Self {
268
+ kind: ToolSuggestDiscoverableType::Connector,
269
+ id: id.into(),
270
+ }
271
+ }
272
+
273
+ pub fn normalized(&self) -> Option<Self> {
274
+ let id = self.id.trim();
275
+ (!id.is_empty()).then(|| Self {
276
+ kind: self.kind,
277
+ id: id.to_string(),
278
+ })
279
+ }
280
+ }
281
+
282
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
283
+ #[schemars(deny_unknown_fields)]
284
+ pub struct ToolSuggestConfig {
285
+ #[serde(default)]
286
+ pub discoverables: Vec<ToolSuggestDiscoverable>,
287
+ #[serde(default)]
288
+ pub disabled_tools: Vec<ToolSuggestDisabledTool>,
289
+ }
290
+
291
+ pub use codex_protocol::MemoryVersion;
292
+
293
+ /// Memories settings loaded from config.toml.
294
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
295
+ #[schemars(deny_unknown_fields)]
296
+ pub struct MemoriesToml {
297
+ /// Selects the memory pipeline; v1 remains the default.
298
+ pub version: Option<MemoryVersion>,
299
+ /// Generate both versions while the selected version supplies context.
300
+ pub dual_write: Option<bool>,
301
+ /// When `true`, external context sources mark the thread `memory_mode` as `"polluted"`.
302
+ #[serde(alias = "no_memories_if_mcp_or_web_search")]
303
+ pub disable_on_external_context: Option<bool>,
304
+ /// When `false`, newly created threads are stored with `memory_mode = "disabled"` in the state DB.
305
+ pub generate_memories: Option<bool>,
306
+ /// When `false`, skip injecting memory usage instructions into developer prompts.
307
+ pub use_memories: Option<bool>,
308
+ /// When `true`, expose dedicated memory tools through the extension tool surface.
309
+ pub dedicated_tools: Option<bool>,
310
+ /// Maximum number of recent raw memories retained for global consolidation.
311
+ #[schemars(range(min = 1, max = 4096))]
312
+ pub max_raw_memories_for_consolidation: Option<usize>,
313
+ /// Maximum number of days since a memory was last used before it becomes ineligible for phase 2 selection.
314
+ pub max_unused_days: Option<i64>,
315
+ /// Maximum age of the threads used for memories.
316
+ pub max_rollout_age_days: Option<i64>,
317
+ /// Maximum number of rollout candidates processed per pass.
318
+ #[schemars(range(min = 1, max = 128))]
319
+ pub max_rollouts_per_startup: Option<usize>,
320
+ /// Minimum idle time between last thread activity and memory creation (hours). > 12h recommended.
321
+ pub min_rollout_idle_hours: Option<i64>,
322
+ /// Minimum remaining percentage required in Codex rate-limit windows before memory startup runs.
323
+ #[schemars(range(min = 0, max = 100))]
324
+ pub min_rate_limit_remaining_percent: Option<i64>,
325
+ /// Model used for thread summarisation.
326
+ pub extract_model: Option<String>,
327
+ /// Model used for memory consolidation.
328
+ pub consolidation_model: Option<String>,
329
+ }
330
+
331
+ /// Effective memories settings after defaults are applied.
332
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
333
+ pub struct MemoriesConfig {
334
+ pub version: MemoryVersion,
335
+ pub dual_write: bool,
336
+ pub disable_on_external_context: bool,
337
+ pub generate_memories: bool,
338
+ pub use_memories: bool,
339
+ pub dedicated_tools: bool,
340
+ pub max_raw_memories_for_consolidation: usize,
341
+ pub max_unused_days: i64,
342
+ pub max_rollout_age_days: i64,
343
+ pub max_rollouts_per_startup: usize,
344
+ pub min_rollout_idle_hours: i64,
345
+ pub min_rate_limit_remaining_percent: i64,
346
+ pub extract_model: Option<String>,
347
+ pub consolidation_model: Option<String>,
348
+ }
349
+
350
+ impl Default for MemoriesConfig {
351
+ fn default() -> Self {
352
+ Self {
353
+ version: MemoryVersion::V1,
354
+ dual_write: false,
355
+ disable_on_external_context: false,
356
+ generate_memories: true,
357
+ use_memories: true,
358
+ dedicated_tools: false,
359
+ max_raw_memories_for_consolidation: DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION,
360
+ max_unused_days: DEFAULT_MEMORIES_MAX_UNUSED_DAYS,
361
+ max_rollout_age_days: DEFAULT_MEMORIES_MAX_ROLLOUT_AGE_DAYS,
362
+ max_rollouts_per_startup: DEFAULT_MEMORIES_MAX_ROLLOUTS_PER_STARTUP,
363
+ min_rollout_idle_hours: DEFAULT_MEMORIES_MIN_ROLLOUT_IDLE_HOURS,
364
+ min_rate_limit_remaining_percent: DEFAULT_MEMORIES_MIN_RATE_LIMIT_REMAINING_PERCENT,
365
+ extract_model: None,
366
+ consolidation_model: None,
367
+ }
368
+ }
369
+ }
370
+
371
+ impl From<MemoriesToml> for MemoriesConfig {
372
+ fn from(toml: MemoriesToml) -> Self {
373
+ let defaults = Self::default();
374
+ Self {
375
+ version: toml.version.unwrap_or(defaults.version),
376
+ dual_write: toml.dual_write.unwrap_or(defaults.dual_write),
377
+ disable_on_external_context: toml
378
+ .disable_on_external_context
379
+ .unwrap_or(defaults.disable_on_external_context),
380
+ generate_memories: toml.generate_memories.unwrap_or(defaults.generate_memories),
381
+ use_memories: toml.use_memories.unwrap_or(defaults.use_memories),
382
+ dedicated_tools: toml.dedicated_tools.unwrap_or(defaults.dedicated_tools),
383
+ max_raw_memories_for_consolidation: toml
384
+ .max_raw_memories_for_consolidation
385
+ .unwrap_or(defaults.max_raw_memories_for_consolidation)
386
+ .clamp(
387
+ MIN_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION,
388
+ MAX_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION,
389
+ ),
390
+ max_unused_days: toml
391
+ .max_unused_days
392
+ .unwrap_or(defaults.max_unused_days)
393
+ .clamp(0, 365),
394
+ max_rollout_age_days: toml
395
+ .max_rollout_age_days
396
+ .unwrap_or(defaults.max_rollout_age_days)
397
+ .clamp(0, 90),
398
+ max_rollouts_per_startup: toml
399
+ .max_rollouts_per_startup
400
+ .unwrap_or(defaults.max_rollouts_per_startup)
401
+ .clamp(
402
+ MIN_MEMORIES_MAX_ROLLOUTS_PER_STARTUP,
403
+ MAX_MEMORIES_MAX_ROLLOUTS_PER_STARTUP,
404
+ ),
405
+ min_rollout_idle_hours: toml
406
+ .min_rollout_idle_hours
407
+ .unwrap_or(defaults.min_rollout_idle_hours)
408
+ .clamp(1, 48),
409
+ min_rate_limit_remaining_percent: toml
410
+ .min_rate_limit_remaining_percent
411
+ .unwrap_or(defaults.min_rate_limit_remaining_percent)
412
+ .clamp(0, 100),
413
+ extract_model: toml.extract_model,
414
+ consolidation_model: toml.consolidation_model,
415
+ }
416
+ }
417
+ }
418
+
419
+ /// Default settings that apply to all apps.
420
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
421
+ #[schemars(deny_unknown_fields)]
422
+ pub struct AppsDefaultConfig {
423
+ /// When `false`, apps are disabled unless overridden by per-app settings.
424
+ #[serde(default = "default_enabled")]
425
+ pub enabled: bool,
426
+
427
+ /// Reviewer for approval prompts unless overridden by per-app settings.
428
+ #[serde(default, skip_serializing_if = "Option::is_none")]
429
+ pub approvals_reviewer: Option<ApprovalsReviewer>,
430
+
431
+ /// Whether tools with `destructive_hint = true` are allowed by default.
432
+ #[serde(
433
+ default = "default_enabled",
434
+ skip_serializing_if = "std::clone::Clone::clone"
435
+ )]
436
+ pub destructive_enabled: bool,
437
+
438
+ /// Whether tools with `open_world_hint = true` are allowed by default.
439
+ #[serde(
440
+ default = "default_enabled",
441
+ skip_serializing_if = "std::clone::Clone::clone"
442
+ )]
443
+ pub open_world_enabled: bool,
444
+
445
+ /// Approval mode for tools unless overridden by per-app or per-tool settings.
446
+ #[serde(default, skip_serializing_if = "Option::is_none")]
447
+ pub default_tools_approval_mode: Option<AppToolApproval>,
448
+ }
449
+
450
+ /// Per-tool settings for a single app tool.
451
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
452
+ #[schemars(deny_unknown_fields)]
453
+ pub struct AppToolConfig {
454
+ /// Whether this tool is enabled. `Some(true)` explicitly allows this tool.
455
+ #[serde(default, skip_serializing_if = "Option::is_none")]
456
+ pub enabled: Option<bool>,
457
+
458
+ /// Approval mode for this tool.
459
+ #[serde(default, skip_serializing_if = "Option::is_none")]
460
+ pub approval_mode: Option<AppToolApproval>,
461
+ }
462
+
463
+ /// Tool settings for a single app.
464
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
465
+ #[schemars(deny_unknown_fields)]
466
+ pub struct AppToolsConfig {
467
+ /// Per-tool overrides keyed by tool name (for example `repos/list`).
468
+ #[serde(default, flatten)]
469
+ pub tools: HashMap<String, AppToolConfig>,
470
+ }
471
+
472
+ /// Approval settings for a connected account within an app.
473
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
474
+ #[schemars(deny_unknown_fields)]
475
+ pub struct AppLinkConfig {
476
+ /// Reviewer for approval prompts from this account, overriding the app default.
477
+ #[serde(default, skip_serializing_if = "Option::is_none")]
478
+ pub approvals_reviewer: Option<ApprovalsReviewer>,
479
+
480
+ /// Approval mode for this account unless a tool override exists.
481
+ #[serde(default, skip_serializing_if = "Option::is_none")]
482
+ pub default_tools_approval_mode: Option<AppToolApproval>,
483
+ }
484
+
485
+ /// Account settings for a single app.
486
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
487
+ #[schemars(deny_unknown_fields)]
488
+ pub struct AppLinksConfig {
489
+ /// Per-account approval settings keyed by link ID.
490
+ #[serde(default, flatten)]
491
+ pub links: HashMap<String, AppLinkConfig>,
492
+ }
493
+
494
+ /// Config values for a single app/connector.
495
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
496
+ #[schemars(deny_unknown_fields)]
497
+ pub struct AppConfig {
498
+ /// When `false`, Codex does not surface this app.
499
+ #[serde(default = "default_enabled")]
500
+ pub enabled: bool,
501
+
502
+ /// Reviewer for approval prompts from this app, overriding the thread default.
503
+ #[serde(default, skip_serializing_if = "Option::is_none")]
504
+ pub approvals_reviewer: Option<ApprovalsReviewer>,
505
+
506
+ /// Whether tools with `destructive_hint = true` are allowed for this app.
507
+ #[serde(default, skip_serializing_if = "Option::is_none")]
508
+ pub destructive_enabled: Option<bool>,
509
+
510
+ /// Whether tools with `open_world_hint = true` are allowed for this app.
511
+ #[serde(default, skip_serializing_if = "Option::is_none")]
512
+ pub open_world_enabled: Option<bool>,
513
+
514
+ /// Approval mode for tools in this app unless a tool override exists.
515
+ #[serde(default, skip_serializing_if = "Option::is_none")]
516
+ pub default_tools_approval_mode: Option<AppToolApproval>,
517
+
518
+ /// Whether tools are enabled by default for this app.
519
+ #[serde(default, skip_serializing_if = "Option::is_none")]
520
+ pub default_tools_enabled: Option<bool>,
521
+
522
+ /// Per-tool settings for this app.
523
+ #[serde(default, skip_serializing_if = "Option::is_none")]
524
+ pub tools: Option<AppToolsConfig>,
525
+
526
+ /// Per-account approval settings keyed by link ID.
527
+ #[serde(default, skip_serializing_if = "Option::is_none")]
528
+ pub links: Option<AppLinksConfig>,
529
+ }
530
+
531
+ /// App/connector settings loaded from `config.toml`.
532
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
533
+ #[schemars(deny_unknown_fields)]
534
+ pub struct AppsConfigToml {
535
+ /// Default settings for all apps.
536
+ #[serde(default, rename = "_default", skip_serializing_if = "Option::is_none")]
537
+ pub default: Option<AppsDefaultConfig>,
538
+
539
+ /// Per-app settings keyed by app ID (for example `[apps.google_drive]`).
540
+ #[serde(default, flatten)]
541
+ pub apps: HashMap<String, AppConfig>,
542
+ }
543
+
544
+ // ===== OTEL configuration =====
545
+
546
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)]
547
+ #[serde(rename_all = "kebab-case")]
548
+ pub enum OtelHttpProtocol {
549
+ /// Binary payload
550
+ Binary,
551
+ /// JSON payload
552
+ Json,
553
+ }
554
+
555
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
556
+ #[schemars(deny_unknown_fields)]
557
+ #[serde(rename_all = "kebab-case")]
558
+ pub struct OtelTlsConfig {
559
+ pub ca_certificate: Option<AbsolutePathBuf>,
560
+ pub client_certificate: Option<AbsolutePathBuf>,
561
+ pub client_private_key: Option<AbsolutePathBuf>,
562
+ }
563
+
564
+ /// Which OTEL exporter to use.
565
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)]
566
+ #[schemars(deny_unknown_fields)]
567
+ #[serde(rename_all = "kebab-case")]
568
+ pub enum OtelExporterKind {
569
+ None,
570
+ Statsig,
571
+ OtlpHttp {
572
+ endpoint: String,
573
+ #[serde(default)]
574
+ headers: HashMap<String, String>,
575
+ protocol: OtelHttpProtocol,
576
+ #[serde(default)]
577
+ tls: Option<OtelTlsConfig>,
578
+ },
579
+ OtlpGrpc {
580
+ endpoint: String,
581
+ #[serde(default)]
582
+ headers: HashMap<String, String>,
583
+ #[serde(default)]
584
+ tls: Option<OtelTlsConfig>,
585
+ },
586
+ }
587
+
588
+ /// OTEL settings loaded from config.toml. Fields are optional so we can apply defaults.
589
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
590
+ #[schemars(deny_unknown_fields)]
591
+ pub struct OtelConfigToml {
592
+ /// Byte limit for tool-result log output; independent of model-visible output.
593
+ #[serde(default)]
594
+ pub tool_result: codex_protocol::config_types::ToolResultLogConfig,
595
+ /// Log user prompt in traces
596
+ pub log_user_prompt: Option<bool>,
597
+
598
+ /// Mark traces with environment (dev, staging, prod, test). Defaults to dev.
599
+ pub environment: Option<String>,
600
+
601
+ /// Optional log exporter
602
+ pub exporter: Option<OtelExporterKind>,
603
+
604
+ /// Optional trace exporter
605
+ pub trace_exporter: Option<OtelExporterKind>,
606
+
607
+ /// Optional metrics exporter
608
+ pub metrics_exporter: Option<OtelExporterKind>,
609
+
610
+ /// Attributes to add to every exported trace span.
611
+ pub span_attributes: Option<BTreeMap<String, String>>,
612
+
613
+ /// Semicolon-separated `key:value` fields to upsert into W3C tracestate members.
614
+ pub tracestate: Option<BTreeMap<String, BTreeMap<String, String>>>,
615
+ }
616
+
617
+ /// Effective OTEL settings after defaults are applied.
618
+ #[derive(Debug, Clone, PartialEq)]
619
+ pub struct OtelConfig {
620
+ pub tool_result: codex_protocol::config_types::ToolResultLogConfig,
621
+ pub log_user_prompt: bool,
622
+ pub environment: String,
623
+ pub exporter: OtelExporterKind,
624
+ pub trace_exporter: OtelExporterKind,
625
+ pub metrics_exporter: OtelExporterKind,
626
+ pub span_attributes: BTreeMap<String, String>,
627
+ pub tracestate: BTreeMap<String, BTreeMap<String, String>>,
628
+ }
629
+
630
+ impl Default for OtelConfig {
631
+ fn default() -> Self {
632
+ OtelConfig {
633
+ tool_result: Default::default(),
634
+ log_user_prompt: false,
635
+ environment: DEFAULT_OTEL_ENVIRONMENT.to_owned(),
636
+ exporter: OtelExporterKind::None,
637
+ trace_exporter: OtelExporterKind::None,
638
+ metrics_exporter: OtelExporterKind::Statsig,
639
+ span_attributes: BTreeMap::new(),
640
+ tracestate: BTreeMap::new(),
641
+ }
642
+ }
643
+ }
644
+
645
+ #[derive(Serialize, Debug, Clone, PartialEq, Eq, Deserialize, JsonSchema)]
646
+ #[serde(untagged)]
647
+ pub enum Notifications {
648
+ Enabled(bool),
649
+ Custom(Vec<String>),
650
+ }
651
+
652
+ impl Default for Notifications {
653
+ fn default() -> Self {
654
+ Self::Enabled(true)
655
+ }
656
+ }
657
+
658
+ #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, Default)]
659
+ #[serde(rename_all = "lowercase")]
660
+ pub enum NotificationMethod {
661
+ #[default]
662
+ Auto,
663
+ Osc9,
664
+ Bel,
665
+ }
666
+
667
+ impl fmt::Display for NotificationMethod {
668
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
669
+ match self {
670
+ NotificationMethod::Auto => write!(f, "auto"),
671
+ NotificationMethod::Osc9 => write!(f, "osc9"),
672
+ NotificationMethod::Bel => write!(f, "bel"),
673
+ }
674
+ }
675
+ }
676
+
677
+ #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, Default)]
678
+ #[serde(rename_all = "lowercase")]
679
+ pub enum NotificationCondition {
680
+ /// Emit TUI notifications only while the terminal is unfocused.
681
+ #[default]
682
+ Unfocused,
683
+ /// Emit TUI notifications regardless of terminal focus.
684
+ Always,
685
+ }
686
+
687
+ impl fmt::Display for NotificationCondition {
688
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
689
+ match self {
690
+ NotificationCondition::Unfocused => write!(f, "unfocused"),
691
+ NotificationCondition::Always => write!(f, "always"),
692
+ }
693
+ }
694
+ }
695
+
696
+ #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, Default)]
697
+ #[serde(rename_all = "kebab-case")]
698
+ pub enum TuiPetAnchor {
699
+ /// Anchor the pet to the bottom of the current TUI composer viewport.
700
+ #[default]
701
+ Composer,
702
+ /// Anchor the pet to the physical bottom of the terminal screen.
703
+ ScreenBottom,
704
+ }
705
+
706
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
707
+ #[schemars(deny_unknown_fields)]
708
+ pub struct TuiNotificationSettings {
709
+ /// Enable desktop notifications from the TUI.
710
+ /// Defaults to `true`.
711
+ #[serde(default, rename = "notifications")]
712
+ pub notifications: Notifications,
713
+
714
+ /// Notification method to use for terminal notifications.
715
+ /// Defaults to `auto`.
716
+ #[serde(default, rename = "notification_method")]
717
+ pub method: NotificationMethod,
718
+
719
+ /// Controls whether TUI notifications are delivered only when the terminal is unfocused or
720
+ /// regardless of focus. Defaults to `unfocused`.
721
+ #[serde(default, rename = "notification_condition")]
722
+ pub condition: NotificationCondition,
723
+ }
724
+
725
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
726
+ #[schemars(deny_unknown_fields)]
727
+ pub struct ModelAvailabilityNuxConfig {
728
+ /// Number of times a startup availability NUX has been shown per model slug.
729
+ #[serde(default, flatten)]
730
+ pub shown_count: HashMap<String, u32>,
731
+ }
732
+
733
+ /// Fallback resize-reflow row cap when Codex cannot identify a terminal-specific scrollback size.
734
+ pub const DEFAULT_TERMINAL_RESIZE_REFLOW_FALLBACK_MAX_ROWS: usize = 1_000;
735
+
736
+ /// Collection of settings that are specific to the TUI.
737
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
738
+ #[schemars(deny_unknown_fields)]
739
+ pub struct Tui {
740
+ #[serde(default, flatten)]
741
+ pub notification_settings: TuiNotificationSettings,
742
+
743
+ /// Enable animations (welcome screen, shimmer effects, spinners).
744
+ /// Defaults to `true`.
745
+ #[serde(default = "default_true")]
746
+ pub animations: bool,
747
+
748
+ /// Enable decorative effects such as Astra composer stars. Also requires animations.
749
+ /// Defaults to `true`.
750
+ #[serde(default = "default_true")]
751
+ pub whimsy: bool,
752
+
753
+ /// Show startup tooltips in the TUI welcome screen.
754
+ /// Defaults to `true`.
755
+ #[serde(default = "default_true")]
756
+ pub show_tooltips: bool,
757
+
758
+ /// Show an informational notice when the connected app server is an older stable release.
759
+ /// Defaults to `true`; this does not control compatibility errors or version status.
760
+ #[serde(default = "default_true")]
761
+ pub show_server_version_notice: bool,
762
+
763
+ /// Generate automatic conversation recaps when the terminal is unfocused.
764
+ /// Defaults to `true`. Disabling this leaves `/recap` available on demand.
765
+ #[serde(default = "default_true")]
766
+ pub auto_recap: bool,
767
+
768
+ /// When true, disables burst-paste detection for typed input entirely.
769
+ /// All characters are inserted as they are received, and no buffering
770
+ /// or placeholder replacement will occur for fast keypress bursts.
771
+ /// Overrides the legacy top-level `disable_paste_burst` setting. Defaults to `false`.
772
+ pub disable_paste_burst: Option<bool>,
773
+
774
+ /// Start the composer in Vim mode (`Normal`) by default.
775
+ /// Defaults to `false`.
776
+ #[serde(default)]
777
+ pub vim_mode_default: bool,
778
+
779
+ /// Escape returns from async questions to the composer, preserving the answer draft.
780
+ #[serde(default = "default_true")]
781
+ pub question_esc_back: bool,
782
+
783
+ /// Start the TUI in raw scrollback mode for copy-friendly transcript output.
784
+ /// Defaults to `false`.
785
+ #[serde(default)]
786
+ pub raw_output_mode: bool,
787
+
788
+ /// Controls whether the TUI uses the terminal's alternate screen buffer.
789
+ ///
790
+ /// - `auto` (default): Use alternate screen.
791
+ /// - `always`: Always use alternate screen.
792
+ /// - `never`: Never use alternate screen (inline mode only, preserves scrollback).
793
+ #[serde(default)]
794
+ pub alternate_screen: AltScreenMode,
795
+
796
+ /// Ordered list of status line item identifiers.
797
+ ///
798
+ /// When set, the TUI renders the selected items as the status line.
799
+ /// When unset, the TUI defaults to: `model-with-reasoning`, `current-dir`, and `thread-name`.
800
+ #[serde(default)]
801
+ pub status_line: Option<Vec<String>>,
802
+
803
+ /// Color status line items with colors derived from the active syntax theme.
804
+ /// Defaults to `true`.
805
+ #[serde(default = "default_true")]
806
+ pub status_line_use_colors: bool,
807
+
808
+ /// Ordered list of terminal title item identifiers.
809
+ ///
810
+ /// When set, the TUI renders the selected items into the terminal window/tab title.
811
+ /// When unset, the TUI defaults to: `activity`, `thread-name`, and `project-name`.
812
+ /// The `activity` item spins while working and shows an action-required
813
+ /// message when blocked on the user.
814
+ #[serde(default)]
815
+ pub terminal_title: Option<Vec<String>>,
816
+
817
+ /// Syntax highlighting theme name (kebab-case).
818
+ ///
819
+ /// When set, overrides automatic light/dark theme detection.
820
+ /// Use `/theme` in the TUI or see `$CODEX_HOME/themes` for custom themes.
821
+ #[serde(default)]
822
+ pub theme: Option<String>,
823
+
824
+ /// Pet id to preselect in the terminal pet picker.
825
+ ///
826
+ /// Custom pet ids resolve against CODEX_HOME/pets/<pet-id>/pet.json.
827
+ #[serde(default)]
828
+ pub pet: Option<String>,
829
+
830
+ /// Where the terminal pet should anchor vertically.
831
+ ///
832
+ /// Defaults to `composer`, which follows the current TUI composer viewport.
833
+ #[serde(default)]
834
+ pub pet_anchor: TuiPetAnchor,
835
+
836
+ /// Preferred layout for resume/fork session picker results.
837
+ #[serde(default)]
838
+ pub session_picker_view: Option<SessionPickerViewMode>,
839
+
840
+ /// Working directory to use when resuming or forking a session.
841
+ /// When unset, prompt if the current and session directories differ.
842
+ #[serde(default)]
843
+ pub resume_cwd: Option<ResumeCwdMode>,
844
+
845
+ /// Keybinding overrides for the TUI.
846
+ ///
847
+ /// This supports rebinding selected actions globally and by context.
848
+ /// Context bindings take precedence over `global` bindings.
849
+ #[serde(default)]
850
+ pub keymap: TuiKeymap,
851
+
852
+ /// Startup tooltip availability NUX state persisted by the TUI.
853
+ #[serde(default)]
854
+ pub model_availability_nux: ModelAvailabilityNuxConfig,
855
+
856
+ /// Trim terminal resize-reflow replay to the most recent rendered terminal rows when the
857
+ /// transcript exceeds this cap. Omit to use Codex's terminal-specific default. Set to `0` to
858
+ /// keep all rendered rows.
859
+ #[serde(default)]
860
+ #[schemars(range(min = 0))]
861
+ pub terminal_resize_reflow_max_rows: Option<usize>,
862
+ }
863
+
864
+ const fn default_true() -> bool {
865
+ true
866
+ }
867
+
868
+ /// Settings for notices we display to users via the tui and app-server clients
869
+ /// (primarily the Codex IDE extension). NOTE: these are different from
870
+ /// notifications - notices are warnings, NUX screens, acknowledgements, etc.
871
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
872
+ #[schemars(deny_unknown_fields)]
873
+ pub struct ExternalConfigMigrationPrompts {
874
+ /// Tracks whether home-level external config migration prompts are hidden.
875
+ pub home: Option<bool>,
876
+ /// Tracks the last time the home-level external config migration prompt was shown.
877
+ pub home_last_prompted_at: Option<i64>,
878
+ /// Tracks which project paths have opted out of external config migration prompts.
879
+ #[serde(default)]
880
+ pub projects: BTreeMap<String, bool>,
881
+ /// Tracks the last time a project-level external config migration prompt was shown.
882
+ #[serde(default)]
883
+ pub project_last_prompted_at: BTreeMap<String, i64>,
884
+ }
885
+
886
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
887
+ #[schemars(deny_unknown_fields)]
888
+ pub struct Notice {
889
+ /// Tracks whether the user has acknowledged the full access warning prompt.
890
+ pub hide_full_access_warning: Option<bool>,
891
+ /// Tracks whether the user has acknowledged the Windows world-writable directories warning.
892
+ pub hide_world_writable_warning: Option<bool>,
893
+ /// Tracks whether the user opted out of Codex-managed fast defaults.
894
+ pub fast_default_opt_out: Option<bool>,
895
+ /// Tracks whether the user opted out of the rate limit model switch reminder.
896
+ pub hide_rate_limit_model_nudge: Option<bool>,
897
+ /// Tracks whether the user has seen the model migration prompt
898
+ pub hide_gpt5_1_migration_prompt: Option<bool>,
899
+ /// Tracks whether the user has seen the gpt-5.1-codex-max migration prompt
900
+ #[serde(rename = "hide_gpt-5.1-codex-max_migration_prompt")]
901
+ pub hide_gpt_5_1_codex_max_migration_prompt: Option<bool>,
902
+ /// Tracks acknowledged model migrations as old->new model slug mappings.
903
+ #[serde(default)]
904
+ pub model_migrations: BTreeMap<String, String>,
905
+ /// Tracks scopes where external config migration prompts should be suppressed.
906
+ #[serde(default)]
907
+ pub external_config_migration_prompts: ExternalConfigMigrationPrompts,
908
+ }
909
+
910
+ pub use crate::skills_config::BundledSkillsConfig;
911
+ pub use crate::skills_config::SkillConfig;
912
+ pub use crate::skills_config::SkillsConfig;
913
+
914
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
915
+ #[schemars(deny_unknown_fields)]
916
+ pub struct PluginConfig {
917
+ #[serde(default = "default_enabled")]
918
+ pub enabled: bool,
919
+
920
+ /// Per-MCP-server policy overlays for MCP servers contributed by this plugin.
921
+ #[serde(default, skip_serializing_if = "HashMap::is_empty")]
922
+ pub mcp_servers: HashMap<String, PluginMcpServerConfig>,
923
+ }
924
+
925
+ /// Policy settings for a plugin-provided MCP server.
926
+ ///
927
+ /// This intentionally excludes transport settings: plugin manifests own how the
928
+ /// MCP server is launched, while host config owns enablement, auth, and tool policy.
929
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
930
+ #[schemars(deny_unknown_fields)]
931
+ pub struct PluginMcpServerConfig {
932
+ /// When `false`, Codex skips initializing this plugin MCP server.
933
+ #[serde(default = "default_enabled")]
934
+ pub enabled: bool,
935
+
936
+ /// Host-configured EMA registration; the plugin still owns its endpoint.
937
+ #[serde(default, skip_serializing_if = "Option::is_none")]
938
+ pub ema_auth: Option<PluginMcpServerEmaAuthConfig>,
939
+
940
+ /// Approval mode for tools in this server unless a tool override exists.
941
+ #[serde(default, skip_serializing_if = "Option::is_none")]
942
+ pub default_tools_approval_mode: Option<AppToolApproval>,
943
+
944
+ /// Explicit allow-list of tools exposed from this server.
945
+ #[serde(default, skip_serializing_if = "Option::is_none")]
946
+ pub enabled_tools: Option<Vec<String>>,
947
+
948
+ /// Explicit deny-list of tools. These tools are removed after applying `enabled_tools`.
949
+ #[serde(default, skip_serializing_if = "Option::is_none")]
950
+ pub disabled_tools: Option<Vec<String>>,
951
+
952
+ /// Per-tool policy settings keyed by tool name.
953
+ #[serde(default, skip_serializing_if = "HashMap::is_empty")]
954
+ pub tools: HashMap<String, McpServerToolConfig>,
955
+ }
956
+
957
+ impl Default for PluginMcpServerConfig {
958
+ fn default() -> Self {
959
+ Self {
960
+ enabled: true,
961
+ ema_auth: None,
962
+ default_tools_approval_mode: None,
963
+ enabled_tools: None,
964
+ disabled_tools: None,
965
+ tools: HashMap::new(),
966
+ }
967
+ }
968
+ }
969
+
970
+ /// Resource registration applied through an existing per-plugin policy overlay.
971
+ /// The enterprise IdP is selected separately by trusted host configuration.
972
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
973
+ #[serde(deny_unknown_fields)]
974
+ pub struct PluginMcpServerEmaAuthConfig {
975
+ /// Exact plugin endpoint approved by the host; never overrides the declaration.
976
+ pub url: String,
977
+ pub client_id: String,
978
+ pub authorization_server_issuer: String,
979
+ #[serde(default)]
980
+ pub scopes: Vec<String>,
981
+ pub resource: String,
982
+ }
983
+
984
+ impl PluginMcpServerEmaAuthConfig {
985
+ pub fn apply(&self, server: &mut McpServerConfig) {
986
+ let registration_error = if self.resource.trim().is_empty() {
987
+ Some("plugin EMA registration requires a resource")
988
+ } else if !server.matches_requirement(&crate::McpServerRequirement::Identity {
989
+ identity: crate::McpServerIdentity::Url {
990
+ url: self.url.clone(),
991
+ },
992
+ }) {
993
+ Some("plugin endpoint does not match its EMA registration")
994
+ } else {
995
+ None
996
+ };
997
+ if registration_error.is_some() && server.enabled {
998
+ server.enabled = false;
999
+ server.disabled_reason = Some(crate::McpServerDisabledReason::EmaRegistration);
1000
+ }
1001
+ server.auth = McpServerAuth::EmaAuth;
1002
+ let oauth = server.oauth.get_or_insert_default();
1003
+ oauth.client_id = Some(self.client_id.clone());
1004
+ oauth.authorization_server_issuer = Some(self.authorization_server_issuer.clone());
1005
+ server.scopes = Some(self.scopes.clone());
1006
+ oauth.ema_registration = None;
1007
+ oauth.ema_registration_error = registration_error;
1008
+ server.oauth_resource = Some(self.resource.clone());
1009
+ }
1010
+ }
1011
+
1012
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
1013
+ #[schemars(deny_unknown_fields)]
1014
+ pub struct MarketplaceConfig {
1015
+ /// Last time Codex successfully added or refreshed this marketplace.
1016
+ #[serde(default)]
1017
+ pub last_updated: Option<String>,
1018
+ /// Git revision Codex last successfully activated for this marketplace.
1019
+ #[serde(default)]
1020
+ pub last_revision: Option<String>,
1021
+ /// Source kind used to install this marketplace.
1022
+ #[serde(default)]
1023
+ pub source_type: Option<MarketplaceSourceType>,
1024
+ /// Source location used when the marketplace was added.
1025
+ #[serde(default)]
1026
+ pub source: Option<String>,
1027
+ /// Git ref to check out when `source_type` is `git`.
1028
+ #[serde(default, rename = "ref")]
1029
+ pub ref_name: Option<String>,
1030
+ /// Sparse checkout paths used when `source_type` is `git`.
1031
+ #[serde(default)]
1032
+ pub sparse_paths: Option<Vec<String>>,
1033
+ }
1034
+
1035
+ #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema)]
1036
+ #[serde(rename_all = "snake_case")]
1037
+ pub enum MarketplaceSourceType {
1038
+ Git,
1039
+ Local,
1040
+ }
1041
+
1042
+ #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
1043
+ #[schemars(deny_unknown_fields)]
1044
+ pub struct SandboxWorkspaceWrite {
1045
+ #[serde(default)]
1046
+ pub writable_roots: Vec<AbsolutePathBuf>,
1047
+ #[serde(default)]
1048
+ pub network_access: bool,
1049
+ #[serde(default)]
1050
+ pub exclude_tmpdir_env_var: bool,
1051
+ #[serde(default)]
1052
+ pub exclude_slash_tmp: bool,
1053
+ }
1054
+
1055
+ #[cfg(test)]
1056
+ #[path = "types_tests.rs"]
1057
+ mod tests;
codex-rs/config/src/types_tests.rs ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use super::*;
2
+ use pretty_assertions::assert_eq;
3
+
4
+ #[test]
5
+ fn deserialize_skill_config_with_name_selector() {
6
+ let cfg: SkillConfig = toml::from_str(
7
+ r#"
8
+ name = "github:yeet"
9
+ enabled = false
10
+ "#,
11
+ )
12
+ .expect("should deserialize skill config with name selector");
13
+
14
+ assert_eq!(cfg.name.as_deref(), Some("github:yeet"));
15
+ assert_eq!(cfg.path, None);
16
+ assert!(!cfg.enabled);
17
+ }
18
+
19
+ #[test]
20
+ fn deserialize_skill_config_with_path_selector() {
21
+ let tempdir = tempfile::tempdir().expect("tempdir");
22
+ let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md");
23
+ let cfg: SkillConfig = toml::from_str(&format!(
24
+ r#"
25
+ path = {path:?}
26
+ enabled = false
27
+ "#,
28
+ path = skill_path.display().to_string(),
29
+ ))
30
+ .expect("should deserialize skill config with path selector");
31
+
32
+ assert_eq!(
33
+ cfg,
34
+ SkillConfig {
35
+ path: Some(
36
+ AbsolutePathBuf::from_absolute_path(&skill_path)
37
+ .expect("skill path should be absolute"),
38
+ ),
39
+ name: None,
40
+ enabled: false,
41
+ }
42
+ );
43
+ }
44
+
45
+ #[test]
46
+ fn memories_config_clamps_count_limits_to_nonzero_values() {
47
+ let config = MemoriesConfig::from(MemoriesToml {
48
+ max_raw_memories_for_consolidation: Some(0),
49
+ max_rollouts_per_startup: Some(0),
50
+ ..Default::default()
51
+ });
52
+
53
+ assert_eq!(
54
+ config,
55
+ MemoriesConfig {
56
+ max_raw_memories_for_consolidation: 1,
57
+ max_rollouts_per_startup: 1,
58
+ ..MemoriesConfig::default()
59
+ }
60
+ );
61
+ }
62
+
63
+ #[test]
64
+ fn memories_config_clamps_rate_limit_remaining_threshold() {
65
+ let config = MemoriesConfig::from(MemoriesToml {
66
+ min_rate_limit_remaining_percent: Some(101),
67
+ ..Default::default()
68
+ });
69
+ assert_eq!(
70
+ config,
71
+ MemoriesConfig {
72
+ min_rate_limit_remaining_percent: 100,
73
+ ..MemoriesConfig::default()
74
+ }
75
+ );
76
+
77
+ let config = MemoriesConfig::from(MemoriesToml {
78
+ min_rate_limit_remaining_percent: Some(-1),
79
+ ..Default::default()
80
+ });
81
+ assert_eq!(
82
+ config,
83
+ MemoriesConfig {
84
+ min_rate_limit_remaining_percent: 0,
85
+ ..MemoriesConfig::default()
86
+ }
87
+ );
88
+ }
89
+
90
+ #[test]
91
+ fn memories_version_selects_pipeline_without_changing_other_defaults() {
92
+ for (source, version) in [
93
+ ("", MemoryVersion::V1),
94
+ ("version = \"v2\"", MemoryVersion::V2),
95
+ ] {
96
+ let parsed: MemoriesToml = toml::from_str(source).expect("parse memories config");
97
+ assert_eq!(
98
+ MemoriesConfig::from(parsed),
99
+ MemoriesConfig {
100
+ version,
101
+ ..Default::default()
102
+ }
103
+ );
104
+ }
105
+ assert!(toml::from_str::<MemoriesToml>("version = \"v3\"").is_err());
106
+ }
codex-rs/install-context/src/bundle_tests.rs ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Ensure a provisioned CLI still discovers its outer package and install method.
2
+
3
+ use super::*;
4
+ use pretty_assertions::assert_eq;
5
+ use std::fs;
6
+
7
+ #[test]
8
+ fn bundle_executable_preserves_package_layout_and_install_method() -> std::io::Result<()> {
9
+ let home = tempfile::tempdir()?;
10
+ let package = home.path().join("packages/standalone/releases/test");
11
+ let executable = package.join("CodexCLI.app/Contents/MacOS/codex");
12
+ fs::create_dir_all(executable.parent().unwrap())?;
13
+ fs::write(&executable, "")?;
14
+ for directory in [BIN_DIRNAME, RESOURCES_DIRNAME, PATH_DIRNAME] {
15
+ fs::create_dir_all(package.join(directory))?;
16
+ }
17
+ fs::write(
18
+ package.join(PACKAGE_METADATA_FILENAME),
19
+ r#"{"version":"1.2.3"}"#,
20
+ )?;
21
+ let package = canonical_absolute_path(&package).unwrap();
22
+ let bin_dir = package.join(BIN_DIRNAME);
23
+ let resources_dir = package.join(RESOURCES_DIRNAME);
24
+ let path_dir = package.join(PATH_DIRNAME);
25
+ let context = InstallContext::from_exe_with_codex_home(
26
+ /*is_macos*/ true,
27
+ /*current_exe*/ Some(&executable),
28
+ /*method_override*/ None,
29
+ /*codex_home*/ Some(home.path()),
30
+ );
31
+ assert_eq!(
32
+ context,
33
+ InstallContext {
34
+ method: InstallMethod::Standalone {
35
+ release_dir: package.clone(),
36
+ resources_dir: Some(resources_dir.clone()),
37
+ platform: standalone_platform(),
38
+ },
39
+ package_layout: Some(CodexPackageLayout {
40
+ package_dir: package,
41
+ bin_dir,
42
+ resources_dir: Some(resources_dir),
43
+ path_dir: Some(path_dir),
44
+ }),
45
+ }
46
+ );
47
+ Ok(())
48
+ }
49
+
50
+ #[cfg(windows)]
51
+ #[test]
52
+ fn winget_root_requires_metadata_for_the_actual_executable() -> std::io::Result<()> {
53
+ let temp = tempfile::tempdir()?;
54
+ let package = canonical_absolute_path(temp.path()).unwrap();
55
+ let name = "codex-x86_64-pc-windows-msvc.exe";
56
+ let executable = package.join(name);
57
+ fs::write(&executable, "signed CLI")?;
58
+ for directory in [RESOURCES_DIRNAME, PATH_DIRNAME] {
59
+ fs::create_dir_all(package.join(directory))?;
60
+ }
61
+ assert_eq!(CodexPackageLayout::from_exe(executable.as_path()), None);
62
+ for entrypoint in ["other.exe", "bin/codex.exe", name] {
63
+ fs::write(
64
+ package.join(PACKAGE_METADATA_FILENAME),
65
+ serde_json::json!({"layoutVersion": 1, "entrypoint": entrypoint}).to_string(),
66
+ )?;
67
+ let expected = (entrypoint == name).then(|| CodexPackageLayout {
68
+ package_dir: package.clone(),
69
+ bin_dir: package.clone(),
70
+ resources_dir: Some(package.join(RESOURCES_DIRNAME)),
71
+ path_dir: Some(package.join(PATH_DIRNAME)),
72
+ });
73
+ assert_eq!(CodexPackageLayout::from_exe(executable.as_path()), expected);
74
+ }
75
+ Ok(())
76
+ }
codex-rs/install-context/src/lib.rs ADDED
@@ -0,0 +1,915 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::ffi::OsStr;
2
+ use std::path::Path;
3
+ use std::path::PathBuf;
4
+ use std::sync::OnceLock;
5
+
6
+ use codex_utils_absolute_path::AbsolutePathBuf;
7
+ use semver::Version;
8
+ use serde::Deserialize;
9
+
10
+ const BIN_DIRNAME: &str = "bin";
11
+ const CODE_MODE_HOST_EXECUTABLE_NAME: &str = if cfg!(windows) {
12
+ "codex-code-mode-host.exe"
13
+ } else {
14
+ "codex-code-mode-host"
15
+ };
16
+ const PACKAGE_METADATA_FILENAME: &str = "codex-package.json";
17
+ const PATH_DIRNAME: &str = "codex-path";
18
+ const RELEASES_DIRNAME: &str = "releases";
19
+ const RESOURCES_DIRNAME: &str = "codex-resources";
20
+ const STANDALONE_PACKAGES_DIRNAME: &str = "standalone";
21
+ const ZSH_DIRNAME: &str = "zsh";
22
+ static INSTALL_CONTEXT: OnceLock<InstallContext> = OnceLock::new();
23
+
24
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
25
+ pub enum StandalonePlatform {
26
+ Unix,
27
+ Windows,
28
+ }
29
+
30
+ #[derive(Clone, Debug, Eq, PartialEq)]
31
+ pub struct CodexPackageLayout {
32
+ /// The package root that contains the metadata file and layout directories.
33
+ pub package_dir: AbsolutePathBuf,
34
+ /// Directory containing the Codex entrypoint executable.
35
+ pub bin_dir: AbsolutePathBuf,
36
+ /// Directory containing managed helper binaries and data files, when present.
37
+ pub resources_dir: Option<AbsolutePathBuf>,
38
+ /// Folder that should be prepended to the PATH, when present.
39
+ pub path_dir: Option<AbsolutePathBuf>,
40
+ }
41
+
42
+ /// Version metadata recorded in a bundled Codex runtime package.
43
+ #[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
44
+ pub struct CodexPackageManifest {
45
+ pub version: Version,
46
+ }
47
+
48
+ #[derive(Clone, Debug, Eq, PartialEq)]
49
+ pub struct InstallContext {
50
+ pub method: InstallMethod,
51
+ pub package_layout: Option<CodexPackageLayout>,
52
+ }
53
+
54
+ #[derive(Clone, Debug, Eq, PartialEq)]
55
+ pub enum InstallMethod {
56
+ Standalone {
57
+ /// The managed standalone release directory. Legacy installs use paths
58
+ /// such as
59
+ /// `~/.codex/packages/standalone/releases/0.111.0-x86_64-unknown-linux-musl`.
60
+ /// Package-layout installs use the package root that contains `bin/`,
61
+ /// `codex-resources/`, and `codex-path/`.
62
+ release_dir: AbsolutePathBuf,
63
+ /// The bundled resource directory for managed dependencies.
64
+ resources_dir: Option<AbsolutePathBuf>,
65
+ /// The platform of the standalone release, either `Unix` or `Windows`.
66
+ platform: StandalonePlatform,
67
+ },
68
+ /// A Codex binary launched through the npm-managed `codex.js` shim.
69
+ Npm,
70
+ /// A Codex binary launched through the bun-managed `codex.js` shim.
71
+ Bun,
72
+ /// A Codex binary launched through the pnpm-managed `codex.js` shim.
73
+ Pnpm,
74
+ /// A Codex binary launched through the Vite+-managed `codex.js` shim.
75
+ VitePlus,
76
+ /// A Codex binary that appears to come from a Homebrew install prefix.
77
+ Brew,
78
+ /// Any other execution environment.
79
+ ///
80
+ /// This commonly covers `cargo run`, app-bundled Codex binaries, custom
81
+ /// internal launchers, and tests that execute Codex from an arbitrary path.
82
+ Other,
83
+ }
84
+
85
+ impl InstallContext {
86
+ pub fn from_exe(
87
+ is_macos: bool,
88
+ current_exe: Option<&Path>,
89
+ method_override: Option<InstallMethod>,
90
+ ) -> Self {
91
+ let codex_home = codex_utils_home_dir::find_codex_home().ok();
92
+ Self::from_exe_with_codex_home(
93
+ is_macos,
94
+ current_exe,
95
+ method_override,
96
+ codex_home.as_deref(),
97
+ )
98
+ }
99
+
100
+ fn from_exe_with_codex_home(
101
+ is_macos: bool,
102
+ current_exe: Option<&Path>,
103
+ method_override: Option<InstallMethod>,
104
+ codex_home: Option<&Path>,
105
+ ) -> Self {
106
+ let package_layout = current_exe.and_then(CodexPackageLayout::from_exe);
107
+ let method = if let Some(method) = method_override {
108
+ method
109
+ } else if let Some(exe_path) = current_exe {
110
+ install_method_from_exe(exe_path, codex_home, package_layout.as_ref(), is_macos)
111
+ } else {
112
+ InstallMethod::Other
113
+ };
114
+
115
+ Self {
116
+ method,
117
+ package_layout,
118
+ }
119
+ }
120
+
121
+ pub fn current() -> &'static Self {
122
+ INSTALL_CONTEXT.get_or_init(|| {
123
+ let current_exe = std::env::current_exe().ok();
124
+ let method_override = if std::env::var_os("CODEX_MANAGED_BY_VITE_PLUS").is_some() {
125
+ Some(InstallMethod::VitePlus)
126
+ } else if std::env::var_os("CODEX_MANAGED_BY_PNPM").is_some() {
127
+ Some(InstallMethod::Pnpm)
128
+ } else if std::env::var_os("CODEX_MANAGED_BY_NPM").is_some() {
129
+ Some(InstallMethod::Npm)
130
+ } else if std::env::var_os("CODEX_MANAGED_BY_BUN").is_some() {
131
+ Some(InstallMethod::Bun)
132
+ } else {
133
+ None
134
+ };
135
+ Self::from_exe(
136
+ cfg!(target_os = "macos"),
137
+ current_exe.as_deref(),
138
+ method_override,
139
+ )
140
+ })
141
+ }
142
+
143
+ /// Read the manifest for the package that contains the current executable.
144
+ pub fn package_manifest(&self) -> Option<CodexPackageManifest> {
145
+ let package_layout = self.package_layout.as_ref()?;
146
+ let manifest =
147
+ std::fs::read_to_string(package_layout.package_dir.join(PACKAGE_METADATA_FILENAME))
148
+ .ok()?;
149
+ serde_json::from_str(&manifest).ok()
150
+ }
151
+
152
+ pub fn rg_command(&self) -> PathBuf {
153
+ if let Some(package_layout) = &self.package_layout
154
+ && let Some(path_dir) = &package_layout.path_dir
155
+ {
156
+ let bundled_rg = path_dir.join(default_rg_command());
157
+ if bundled_rg.is_file() {
158
+ return bundled_rg.into_path_buf();
159
+ }
160
+ }
161
+
162
+ if let InstallMethod::Standalone {
163
+ resources_dir: Some(resources_dir),
164
+ ..
165
+ } = &self.method
166
+ {
167
+ let bundled_rg = resources_dir.join(default_rg_command());
168
+ if bundled_rg.is_file() {
169
+ return bundled_rg.into_path_buf();
170
+ }
171
+ }
172
+
173
+ default_rg_command()
174
+ }
175
+
176
+ pub fn code_mode_host_program(&self) -> PathBuf {
177
+ // prefer the one packed under codex-resources
178
+ self.bundled_resource(CODE_MODE_HOST_EXECUTABLE_NAME)
179
+ .map_or_else(
180
+ || self.code_mode_host_program_from_exe(std::env::current_exe().ok().as_deref()),
181
+ AbsolutePathBuf::into_path_buf,
182
+ )
183
+ }
184
+
185
+ fn code_mode_host_program_from_exe(&self, current_exe: Option<&Path>) -> PathBuf {
186
+ let executable_dir = if let Some(package_layout) = &self.package_layout {
187
+ Some(package_layout.bin_dir.clone())
188
+ } else if let InstallMethod::Standalone { release_dir, .. } = &self.method {
189
+ Some(release_dir.clone())
190
+ } else {
191
+ current_exe
192
+ .and_then(Path::parent)
193
+ .and_then(canonical_absolute_path)
194
+ };
195
+ if let Some(executable_dir) = executable_dir {
196
+ let executable = executable_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME);
197
+ if executable.is_file() {
198
+ return executable.into_path_buf();
199
+ }
200
+ }
201
+
202
+ current_exe
203
+ .and_then(Path::parent)
204
+ .map(|parent| parent.join(CODE_MODE_HOST_EXECUTABLE_NAME))
205
+ .unwrap_or_else(|| PathBuf::from(CODE_MODE_HOST_EXECUTABLE_NAME))
206
+ }
207
+
208
+ pub fn bundled_resource(&self, file_name: impl AsRef<Path>) -> Option<AbsolutePathBuf> {
209
+ if let Some(package_layout) = &self.package_layout
210
+ && let Some(resources_dir) = &package_layout.resources_dir
211
+ {
212
+ let resource = resources_dir.join(file_name.as_ref());
213
+ if resource.is_file() {
214
+ return Some(resource);
215
+ }
216
+ }
217
+
218
+ if let InstallMethod::Standalone {
219
+ resources_dir: Some(resources_dir),
220
+ ..
221
+ } = &self.method
222
+ {
223
+ let resource = resources_dir.join(file_name);
224
+ if resource.is_file() {
225
+ return Some(resource);
226
+ }
227
+ }
228
+
229
+ None
230
+ }
231
+
232
+ pub fn bundled_zsh_path(&self) -> Option<AbsolutePathBuf> {
233
+ if cfg!(windows) {
234
+ None
235
+ } else {
236
+ self.bundled_resource(zsh_resource_path())
237
+ }
238
+ }
239
+
240
+ pub fn bundled_zsh_bin_dir(&self) -> Option<AbsolutePathBuf> {
241
+ self.bundled_zsh_path()?.parent()
242
+ }
243
+ }
244
+
245
+ impl CodexPackageLayout {
246
+ fn from_exe(exe_path: &Path) -> Option<Self> {
247
+ let canonical_exe = canonical_absolute_path(exe_path)?;
248
+ let exe_dir = canonical_exe.parent()?;
249
+ // WinGet preserves a target-qualified executable at the package root.
250
+ // Only recognize that layout when metadata names this exact executable.
251
+ #[cfg(windows)]
252
+ if let Ok(contents) = std::fs::read(exe_dir.join(PACKAGE_METADATA_FILENAME))
253
+ && let Ok(metadata) = serde_json::from_slice::<serde_json::Value>(&contents)
254
+ && metadata["layoutVersion"] == 1
255
+ && metadata["entrypoint"].as_str().map(OsStr::new) == canonical_exe.file_name()
256
+ {
257
+ return Some(Self {
258
+ resources_dir: existing_dir(exe_dir.join(RESOURCES_DIRNAME)),
259
+ path_dir: existing_dir(exe_dir.join(PATH_DIRNAME)),
260
+ package_dir: exe_dir.clone(),
261
+ bin_dir: exe_dir,
262
+ });
263
+ }
264
+ match exe_dir.file_name() {
265
+ Some(name) if name == OsStr::new(BIN_DIRNAME) => Self::from_package_bin_dir(exe_dir),
266
+ Some(name) if name == OsStr::new(RESOURCES_DIRNAME) => {
267
+ let package_dir = exe_dir.parent()?;
268
+ Self::from_package_bin_dir(package_dir.join(BIN_DIRNAME))
269
+ }
270
+ Some(name) if name == OsStr::new("MacOS") => {
271
+ // A provisioned CLI keeps helpers and metadata in the outer
272
+ // package. current_exe points inside the bundle, not at bin/codex.
273
+ let contents = exe_dir.parent()?;
274
+ let bundle = contents.parent()?;
275
+ if contents.file_name()? != OsStr::new("Contents")
276
+ || bundle.file_name()? != OsStr::new("CodexCLI.app")
277
+ {
278
+ return None;
279
+ }
280
+ Self::from_package_bin_dir(bundle.parent()?.join(BIN_DIRNAME))
281
+ }
282
+ Some(_) | None => None,
283
+ }
284
+ }
285
+
286
+ fn from_package_bin_dir(bin_dir: AbsolutePathBuf) -> Option<Self> {
287
+ if !bin_dir.is_dir() {
288
+ return None;
289
+ }
290
+ let package_dir = bin_dir.parent()?;
291
+ if !package_dir.join(PACKAGE_METADATA_FILENAME).is_file() {
292
+ return None;
293
+ }
294
+
295
+ Some(Self {
296
+ resources_dir: existing_dir(package_dir.join(RESOURCES_DIRNAME)),
297
+ path_dir: existing_dir(package_dir.join(PATH_DIRNAME)),
298
+ package_dir,
299
+ bin_dir,
300
+ })
301
+ }
302
+ }
303
+
304
+ fn install_method_from_exe(
305
+ exe_path: &Path,
306
+ codex_home: Option<&Path>,
307
+ package_layout: Option<&CodexPackageLayout>,
308
+ is_macos: bool,
309
+ ) -> InstallMethod {
310
+ if let Some(standalone_method) = standalone_install_method(exe_path, codex_home, package_layout)
311
+ {
312
+ return standalone_method;
313
+ }
314
+
315
+ if is_macos && (exe_path.starts_with("/opt/homebrew") || exe_path.starts_with("/usr/local")) {
316
+ InstallMethod::Brew
317
+ } else {
318
+ InstallMethod::Other
319
+ }
320
+ }
321
+
322
+ fn standalone_install_method(
323
+ exe_path: &Path,
324
+ codex_home: Option<&Path>,
325
+ package_layout: Option<&CodexPackageLayout>,
326
+ ) -> Option<InstallMethod> {
327
+ let canonical_codex_home = canonical_absolute_path(codex_home?)?;
328
+ let release_dir = if let Some(package_layout) = package_layout {
329
+ package_layout.package_dir.clone()
330
+ } else {
331
+ canonical_absolute_path(exe_path)?.parent()?
332
+ };
333
+ let releases_root = canonical_codex_home
334
+ .join("packages")
335
+ .join(STANDALONE_PACKAGES_DIRNAME)
336
+ .join(RELEASES_DIRNAME);
337
+ if !release_dir.starts_with(releases_root.as_path()) {
338
+ return None;
339
+ }
340
+
341
+ let resources_dir = release_dir.join(RESOURCES_DIRNAME);
342
+ Some(InstallMethod::Standalone {
343
+ release_dir,
344
+ resources_dir: resources_dir.is_dir().then_some(resources_dir),
345
+ platform: standalone_platform(),
346
+ })
347
+ }
348
+
349
+ fn canonical_absolute_path(path: &Path) -> Option<AbsolutePathBuf> {
350
+ let canonical_path = std::fs::canonicalize(path).ok()?;
351
+ AbsolutePathBuf::from_absolute_path(canonical_path).ok()
352
+ }
353
+
354
+ fn standalone_platform() -> StandalonePlatform {
355
+ if cfg!(windows) {
356
+ StandalonePlatform::Windows
357
+ } else {
358
+ StandalonePlatform::Unix
359
+ }
360
+ }
361
+
362
+ fn existing_dir(path: AbsolutePathBuf) -> Option<AbsolutePathBuf> {
363
+ path.is_dir().then_some(path)
364
+ }
365
+
366
+ fn default_rg_command() -> PathBuf {
367
+ if cfg!(windows) {
368
+ PathBuf::from("rg.exe")
369
+ } else {
370
+ PathBuf::from("rg")
371
+ }
372
+ }
373
+
374
+ fn zsh_resource_path() -> PathBuf {
375
+ PathBuf::from(ZSH_DIRNAME).join(BIN_DIRNAME).join("zsh")
376
+ }
377
+
378
+ #[cfg(test)]
379
+ #[path = "bundle_tests.rs"]
380
+ mod bundle_tests;
381
+
382
+ #[cfg(test)]
383
+ mod tests {
384
+ use super::*;
385
+ use pretty_assertions::assert_eq;
386
+ use std::fs;
387
+
388
+ const TEST_RESOURCE_NAME: &str = "codex-test-helper";
389
+
390
+ #[test]
391
+ fn code_mode_host_program_prefers_package_resource_over_legacy_binary() -> std::io::Result<()> {
392
+ let package_dir = tempfile::tempdir()?;
393
+ let bin_dir = package_dir.path().join(BIN_DIRNAME);
394
+ let resources_dir = package_dir.path().join(RESOURCES_DIRNAME);
395
+ fs::create_dir_all(&bin_dir)?;
396
+ fs::create_dir_all(&resources_dir)?;
397
+ fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?;
398
+ let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" });
399
+ let resource_host = resources_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME);
400
+ fs::write(&exe_path, "")?;
401
+ fs::write(bin_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME), "legacy host")?;
402
+ fs::write(&resource_host, "managed host")?;
403
+
404
+ let context = InstallContext::from_exe(
405
+ /*is_macos*/ false,
406
+ /*current_exe*/ Some(&exe_path),
407
+ /*method_override*/ None,
408
+ );
409
+
410
+ assert_eq!(
411
+ context.code_mode_host_program(),
412
+ AbsolutePathBuf::from_absolute_path(resource_host.canonicalize()?)?.into_path_buf()
413
+ );
414
+ Ok(())
415
+ }
416
+
417
+ #[test]
418
+ fn code_mode_host_program_uses_package_resource_without_legacy_binary() -> std::io::Result<()> {
419
+ let package_dir = tempfile::tempdir()?;
420
+ let bin_dir = package_dir.path().join(BIN_DIRNAME);
421
+ let resources_dir = package_dir.path().join(RESOURCES_DIRNAME);
422
+ fs::create_dir_all(&bin_dir)?;
423
+ fs::create_dir_all(&resources_dir)?;
424
+ fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?;
425
+ let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" });
426
+ let resource_host = resources_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME);
427
+ fs::write(&exe_path, "")?;
428
+ fs::write(&resource_host, "managed host")?;
429
+
430
+ let context = InstallContext::from_exe(
431
+ /*is_macos*/ false,
432
+ /*current_exe*/ Some(&exe_path),
433
+ /*method_override*/ None,
434
+ );
435
+
436
+ assert_eq!(
437
+ context.code_mode_host_program(),
438
+ AbsolutePathBuf::from_absolute_path(resource_host.canonicalize()?)?.into_path_buf()
439
+ );
440
+ Ok(())
441
+ }
442
+
443
+ #[test]
444
+ fn code_mode_host_program_ignores_resource_directory_and_uses_legacy_binary()
445
+ -> std::io::Result<()> {
446
+ let package_dir = tempfile::tempdir()?;
447
+ let bin_dir = package_dir.path().join(BIN_DIRNAME);
448
+ let resources_dir = package_dir.path().join(RESOURCES_DIRNAME);
449
+ fs::create_dir_all(&bin_dir)?;
450
+ fs::create_dir_all(resources_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME))?;
451
+ fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?;
452
+ let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" });
453
+ let legacy_host = bin_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME);
454
+ fs::write(&exe_path, "")?;
455
+ fs::write(&legacy_host, "legacy host")?;
456
+
457
+ let context = InstallContext::from_exe(
458
+ /*is_macos*/ false,
459
+ /*current_exe*/ Some(&exe_path),
460
+ /*method_override*/ None,
461
+ );
462
+
463
+ assert_eq!(
464
+ context.code_mode_host_program(),
465
+ AbsolutePathBuf::from_absolute_path(legacy_host.canonicalize()?)?.into_path_buf()
466
+ );
467
+ Ok(())
468
+ }
469
+
470
+ #[test]
471
+ fn detects_standalone_install_from_release_layout() -> std::io::Result<()> {
472
+ let codex_home = tempfile::tempdir()?;
473
+ let release_dir = codex_home
474
+ .path()
475
+ .join("packages/standalone/releases/1.2.3-x86_64-unknown-linux-musl");
476
+ let resources_dir = release_dir.join(RESOURCES_DIRNAME);
477
+ fs::create_dir_all(&resources_dir)?;
478
+ let exe_path = release_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" });
479
+ fs::write(&exe_path, "")?;
480
+ fs::write(release_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME), "")?;
481
+ fs::write(
482
+ resources_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME),
483
+ "managed host",
484
+ )?;
485
+ fs::write(resources_dir.join(default_rg_command()), "")?;
486
+ fs::write(resources_dir.join(TEST_RESOURCE_NAME), "")?;
487
+ let canonical_release_dir =
488
+ AbsolutePathBuf::from_absolute_path(release_dir.canonicalize()?)?;
489
+ let canonical_resources_dir =
490
+ AbsolutePathBuf::from_absolute_path(resources_dir.canonicalize()?)?;
491
+
492
+ let context = InstallContext::from_exe_with_codex_home(
493
+ /*is_macos*/ false,
494
+ /*current_exe*/ Some(&exe_path),
495
+ /*method_override*/ None,
496
+ /*codex_home*/ Some(codex_home.path()),
497
+ );
498
+ assert_eq!(
499
+ context,
500
+ InstallContext {
501
+ method: InstallMethod::Standalone {
502
+ release_dir: canonical_release_dir.clone(),
503
+ resources_dir: Some(canonical_resources_dir.clone()),
504
+ platform: standalone_platform(),
505
+ },
506
+ package_layout: None,
507
+ }
508
+ );
509
+ assert_eq!(
510
+ context.code_mode_host_program_from_exe(Some(&exe_path)),
511
+ canonical_release_dir
512
+ .join(CODE_MODE_HOST_EXECUTABLE_NAME)
513
+ .into_path_buf()
514
+ );
515
+ assert_eq!(
516
+ context.code_mode_host_program(),
517
+ canonical_resources_dir
518
+ .join(CODE_MODE_HOST_EXECUTABLE_NAME)
519
+ .into_path_buf()
520
+ );
521
+ assert_eq!(
522
+ context.bundled_resource(TEST_RESOURCE_NAME),
523
+ Some(canonical_resources_dir.join(TEST_RESOURCE_NAME))
524
+ );
525
+ Ok(())
526
+ }
527
+
528
+ #[test]
529
+ fn standalone_rg_falls_back_when_resources_are_missing() -> std::io::Result<()> {
530
+ let codex_home = tempfile::tempdir()?;
531
+ let release_dir = codex_home
532
+ .path()
533
+ .join("packages/standalone/releases/1.2.3-x86_64-unknown-linux-musl");
534
+ fs::create_dir_all(&release_dir)?;
535
+ let exe_path = release_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" });
536
+ fs::write(&exe_path, "")?;
537
+
538
+ let context = InstallContext::from_exe_with_codex_home(
539
+ /*is_macos*/ false,
540
+ /*current_exe*/ Some(&exe_path),
541
+ /*method_override*/ None,
542
+ /*codex_home*/ Some(codex_home.path()),
543
+ );
544
+ assert_eq!(context.rg_command(), default_rg_command());
545
+ Ok(())
546
+ }
547
+
548
+ #[test]
549
+ fn detects_package_layout_independently_from_install_method() -> std::io::Result<()> {
550
+ let package_dir = tempfile::tempdir()?;
551
+ let bin_dir = package_dir.path().join(BIN_DIRNAME);
552
+ let resources_dir = package_dir.path().join(RESOURCES_DIRNAME);
553
+ let path_dir = package_dir.path().join(PATH_DIRNAME);
554
+ fs::create_dir_all(&bin_dir)?;
555
+ fs::create_dir_all(&resources_dir)?;
556
+ fs::create_dir_all(&path_dir)?;
557
+ fs::write(
558
+ package_dir.path().join(PACKAGE_METADATA_FILENAME),
559
+ r#"{
560
+ "layoutVersion": 1,
561
+ "version": "1.2.3",
562
+ "target": "x86_64-unknown-linux-musl",
563
+ "variant": "codex",
564
+ "entrypoint": "bin/codex",
565
+ "resourcesDir": "codex-resources",
566
+ "pathDir": "codex-path"
567
+ }
568
+ "#,
569
+ )?;
570
+ let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" });
571
+ fs::write(&exe_path, "")?;
572
+ fs::write(bin_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME), "")?;
573
+ fs::write(resources_dir.join(TEST_RESOURCE_NAME), "")?;
574
+ fs::write(path_dir.join(default_rg_command()), "")?;
575
+ if !cfg!(windows) {
576
+ let zsh_path = resources_dir.join(zsh_resource_path());
577
+ fs::create_dir_all(zsh_path.parent().expect("zsh path should have parent"))?;
578
+ fs::write(&zsh_path, "")?;
579
+ }
580
+ let canonical_package_dir =
581
+ AbsolutePathBuf::from_absolute_path(package_dir.path().canonicalize()?)?;
582
+ let canonical_bin_dir = AbsolutePathBuf::from_absolute_path(bin_dir.canonicalize()?)?;
583
+ let canonical_resources_dir =
584
+ AbsolutePathBuf::from_absolute_path(resources_dir.canonicalize()?)?;
585
+ let canonical_path_dir = AbsolutePathBuf::from_absolute_path(path_dir.canonicalize()?)?;
586
+ let package_layout = CodexPackageLayout {
587
+ package_dir: canonical_package_dir,
588
+ bin_dir: canonical_bin_dir.clone(),
589
+ resources_dir: Some(canonical_resources_dir.clone()),
590
+ path_dir: Some(canonical_path_dir.clone()),
591
+ };
592
+
593
+ let context = InstallContext::from_exe_with_codex_home(
594
+ /*is_macos*/ false,
595
+ /*current_exe*/ Some(&exe_path),
596
+ /*method_override*/ None,
597
+ /*codex_home*/ None,
598
+ );
599
+ assert_eq!(
600
+ context,
601
+ InstallContext {
602
+ method: InstallMethod::Other,
603
+ package_layout: Some(package_layout),
604
+ }
605
+ );
606
+ assert_eq!(
607
+ context.package_manifest(),
608
+ Some(CodexPackageManifest {
609
+ version: Version::new(1, 2, 3),
610
+ })
611
+ );
612
+ assert_eq!(
613
+ context.code_mode_host_program_from_exe(Some(&exe_path)),
614
+ canonical_bin_dir
615
+ .join(CODE_MODE_HOST_EXECUTABLE_NAME)
616
+ .into_path_buf()
617
+ );
618
+ assert_eq!(
619
+ context.code_mode_host_program(),
620
+ canonical_bin_dir
621
+ .join(CODE_MODE_HOST_EXECUTABLE_NAME)
622
+ .into_path_buf()
623
+ );
624
+ assert_eq!(
625
+ context.rg_command(),
626
+ canonical_path_dir
627
+ .join(default_rg_command())
628
+ .into_path_buf()
629
+ );
630
+ assert_eq!(
631
+ context.bundled_resource(TEST_RESOURCE_NAME),
632
+ Some(canonical_resources_dir.join(TEST_RESOURCE_NAME))
633
+ );
634
+ if cfg!(windows) {
635
+ assert_eq!(context.bundled_zsh_path(), None);
636
+ assert_eq!(context.bundled_zsh_bin_dir(), None);
637
+ } else {
638
+ assert_eq!(
639
+ context.bundled_zsh_path(),
640
+ Some(canonical_resources_dir.join(zsh_resource_path()))
641
+ );
642
+ assert_eq!(
643
+ context.bundled_zsh_bin_dir(),
644
+ Some(canonical_resources_dir.join(ZSH_DIRNAME).join(BIN_DIRNAME))
645
+ );
646
+ }
647
+ Ok(())
648
+ }
649
+
650
+ #[cfg(unix)]
651
+ #[test]
652
+ fn code_mode_host_program_accepts_symlinks_to_files() -> std::io::Result<()> {
653
+ let package_dir = tempfile::tempdir()?;
654
+ let bin_dir = package_dir.path().join(BIN_DIRNAME);
655
+ fs::create_dir_all(&bin_dir)?;
656
+ fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?;
657
+ let exe_path = bin_dir.join("codex");
658
+ let executable_target = package_dir.path().join("host-target");
659
+ let executable_path = bin_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME);
660
+ fs::write(&exe_path, "")?;
661
+ fs::write(&executable_target, "")?;
662
+ std::os::unix::fs::symlink(&executable_target, &executable_path)?;
663
+ let canonical_bin_dir = AbsolutePathBuf::from_absolute_path(bin_dir.canonicalize()?)?;
664
+
665
+ let context = InstallContext::from_exe(
666
+ /*is_macos*/ false,
667
+ /*current_exe*/ Some(&exe_path),
668
+ /*method_override*/ None,
669
+ );
670
+
671
+ assert_eq!(
672
+ context.code_mode_host_program_from_exe(Some(&exe_path)),
673
+ canonical_bin_dir
674
+ .join(CODE_MODE_HOST_EXECUTABLE_NAME)
675
+ .into_path_buf()
676
+ );
677
+ Ok(())
678
+ }
679
+
680
+ #[test]
681
+ fn standalone_package_layout_keeps_standalone_install_method() -> std::io::Result<()> {
682
+ let codex_home = tempfile::tempdir()?;
683
+ let package_dir = codex_home
684
+ .path()
685
+ .join("packages/standalone/releases/1.2.3-x86_64-unknown-linux-musl");
686
+ let bin_dir = package_dir.join(BIN_DIRNAME);
687
+ let resources_dir = package_dir.join(RESOURCES_DIRNAME);
688
+ let path_dir = package_dir.join(PATH_DIRNAME);
689
+ fs::create_dir_all(&bin_dir)?;
690
+ fs::create_dir_all(&resources_dir)?;
691
+ fs::create_dir_all(&path_dir)?;
692
+ fs::write(package_dir.join(PACKAGE_METADATA_FILENAME), "{}")?;
693
+ let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" });
694
+ fs::write(&exe_path, "")?;
695
+ fs::write(resources_dir.join(TEST_RESOURCE_NAME), "")?;
696
+ fs::write(path_dir.join(default_rg_command()), "")?;
697
+ let canonical_package_dir =
698
+ AbsolutePathBuf::from_absolute_path(package_dir.canonicalize()?)?;
699
+ let canonical_bin_dir = AbsolutePathBuf::from_absolute_path(bin_dir.canonicalize()?)?;
700
+ let canonical_resources_dir =
701
+ AbsolutePathBuf::from_absolute_path(resources_dir.canonicalize()?)?;
702
+ let canonical_path_dir = AbsolutePathBuf::from_absolute_path(path_dir.canonicalize()?)?;
703
+
704
+ let context = InstallContext::from_exe_with_codex_home(
705
+ /*is_macos*/ false,
706
+ /*current_exe*/ Some(&exe_path),
707
+ /*method_override*/ None,
708
+ /*codex_home*/ Some(codex_home.path()),
709
+ );
710
+ assert_eq!(
711
+ context,
712
+ InstallContext {
713
+ method: InstallMethod::Standalone {
714
+ release_dir: canonical_package_dir.clone(),
715
+ resources_dir: Some(canonical_resources_dir.clone()),
716
+ platform: standalone_platform(),
717
+ },
718
+ package_layout: Some(CodexPackageLayout {
719
+ package_dir: canonical_package_dir,
720
+ bin_dir: canonical_bin_dir,
721
+ resources_dir: Some(canonical_resources_dir.clone()),
722
+ path_dir: Some(canonical_path_dir.clone()),
723
+ }),
724
+ }
725
+ );
726
+ assert_eq!(
727
+ context.rg_command(),
728
+ canonical_path_dir
729
+ .join(default_rg_command())
730
+ .into_path_buf()
731
+ );
732
+ assert_eq!(
733
+ context.bundled_resource(TEST_RESOURCE_NAME),
734
+ Some(canonical_resources_dir.join(TEST_RESOURCE_NAME))
735
+ );
736
+ Ok(())
737
+ }
738
+
739
+ #[test]
740
+ fn npm_managed_package_keeps_package_layout() -> std::io::Result<()> {
741
+ let package_dir = tempfile::tempdir()?;
742
+ let bin_dir = package_dir.path().join(BIN_DIRNAME);
743
+ let path_dir = package_dir.path().join(PATH_DIRNAME);
744
+ fs::create_dir_all(&bin_dir)?;
745
+ fs::create_dir_all(&path_dir)?;
746
+ fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?;
747
+ let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" });
748
+ fs::write(&exe_path, "")?;
749
+ fs::write(path_dir.join(default_rg_command()), "")?;
750
+ let canonical_path_dir = AbsolutePathBuf::from_absolute_path(path_dir.canonicalize()?)?;
751
+
752
+ let context = InstallContext::from_exe(
753
+ /*is_macos*/ false,
754
+ /*current_exe*/ Some(&exe_path),
755
+ /*method_override*/ Some(InstallMethod::Npm),
756
+ );
757
+ assert_eq!(context.method, InstallMethod::Npm);
758
+ assert!(context.package_layout.is_some());
759
+ assert_eq!(
760
+ context.rg_command(),
761
+ canonical_path_dir
762
+ .join(default_rg_command())
763
+ .into_path_buf()
764
+ );
765
+ Ok(())
766
+ }
767
+
768
+ #[test]
769
+ fn standalone_package_rg_falls_back_when_codex_path_is_missing() -> std::io::Result<()> {
770
+ let package_dir = tempfile::tempdir()?;
771
+ let bin_dir = package_dir.path().join(BIN_DIRNAME);
772
+ fs::create_dir_all(&bin_dir)?;
773
+ fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?;
774
+ let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" });
775
+ fs::write(&exe_path, "")?;
776
+
777
+ let context = InstallContext::from_exe_with_codex_home(
778
+ /*is_macos*/ false,
779
+ /*current_exe*/ Some(&exe_path),
780
+ /*method_override*/ None,
781
+ /*codex_home*/ None,
782
+ );
783
+ assert_eq!(context.rg_command(), default_rg_command());
784
+ Ok(())
785
+ }
786
+
787
+ #[test]
788
+ fn bundled_file_lookups_ignore_directories() -> std::io::Result<()> {
789
+ let package_dir = tempfile::tempdir()?;
790
+ let bin_dir = package_dir.path().join(BIN_DIRNAME);
791
+ let resources_dir = package_dir.path().join(RESOURCES_DIRNAME);
792
+ let path_dir = package_dir.path().join(PATH_DIRNAME);
793
+ fs::create_dir_all(&bin_dir)?;
794
+ fs::create_dir_all(bin_dir.join(CODE_MODE_HOST_EXECUTABLE_NAME))?;
795
+ fs::create_dir_all(resources_dir.join(TEST_RESOURCE_NAME))?;
796
+ fs::create_dir_all(path_dir.join(default_rg_command()))?;
797
+ fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?;
798
+ let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" });
799
+ fs::write(&exe_path, "")?;
800
+ let fallback_exe_path = package_dir.path().join("fallback-codex");
801
+ fs::write(&fallback_exe_path, "")?;
802
+
803
+ let context = InstallContext::from_exe_with_codex_home(
804
+ /*is_macos*/ false,
805
+ /*current_exe*/ Some(&exe_path),
806
+ /*method_override*/ None,
807
+ /*codex_home*/ None,
808
+ );
809
+ assert_eq!(
810
+ context.code_mode_host_program_from_exe(Some(&fallback_exe_path)),
811
+ package_dir.path().join(CODE_MODE_HOST_EXECUTABLE_NAME)
812
+ );
813
+ assert_eq!(context.rg_command(), default_rg_command());
814
+ assert_eq!(context.bundled_resource(TEST_RESOURCE_NAME), None);
815
+ Ok(())
816
+ }
817
+
818
+ #[test]
819
+ fn code_mode_host_program_is_next_to_the_executable_even_when_missing() {
820
+ let context = InstallContext {
821
+ method: InstallMethod::Other,
822
+ package_layout: None,
823
+ };
824
+
825
+ assert_eq!(
826
+ context.code_mode_host_program_from_exe(Some(Path::new("/opt/codex/bin/codex"))),
827
+ PathBuf::from("/opt/codex/bin").join(CODE_MODE_HOST_EXECUTABLE_NAME)
828
+ );
829
+ }
830
+
831
+ #[test]
832
+ fn code_mode_host_program_falls_back_to_its_name_when_executable_is_unknown() {
833
+ let context = InstallContext {
834
+ method: InstallMethod::Other,
835
+ package_layout: None,
836
+ };
837
+
838
+ assert_eq!(
839
+ context.code_mode_host_program_from_exe(/*current_exe*/ None),
840
+ PathBuf::from(CODE_MODE_HOST_EXECUTABLE_NAME)
841
+ );
842
+ }
843
+
844
+ #[test]
845
+ fn package_manager_method_overrides_take_precedence() {
846
+ let vite_plus_context = InstallContext::from_exe(
847
+ /*is_macos*/ false,
848
+ /*current_exe*/ Some(Path::new("/tmp/codex")),
849
+ /*method_override*/ Some(InstallMethod::VitePlus),
850
+ );
851
+ assert_eq!(
852
+ vite_plus_context,
853
+ InstallContext {
854
+ method: InstallMethod::VitePlus,
855
+ package_layout: None,
856
+ }
857
+ );
858
+
859
+ let pnpm_context = InstallContext::from_exe(
860
+ /*is_macos*/ false,
861
+ /*current_exe*/ Some(Path::new("/tmp/codex")),
862
+ /*method_override*/ Some(InstallMethod::Pnpm),
863
+ );
864
+ assert_eq!(
865
+ pnpm_context,
866
+ InstallContext {
867
+ method: InstallMethod::Pnpm,
868
+ package_layout: None,
869
+ }
870
+ );
871
+
872
+ let npm_context = InstallContext::from_exe(
873
+ /*is_macos*/ false,
874
+ /*current_exe*/ Some(Path::new("/tmp/codex")),
875
+ /*method_override*/ Some(InstallMethod::Npm),
876
+ );
877
+ assert_eq!(
878
+ npm_context,
879
+ InstallContext {
880
+ method: InstallMethod::Npm,
881
+ package_layout: None,
882
+ }
883
+ );
884
+
885
+ let bun_context = InstallContext::from_exe(
886
+ /*is_macos*/ false,
887
+ /*current_exe*/ Some(Path::new("/tmp/codex")),
888
+ /*method_override*/ Some(InstallMethod::Bun),
889
+ );
890
+ assert_eq!(
891
+ bun_context,
892
+ InstallContext {
893
+ method: InstallMethod::Bun,
894
+ package_layout: None,
895
+ }
896
+ );
897
+ }
898
+
899
+ #[test]
900
+ fn brew_is_detected_on_macos_prefixes() {
901
+ let context = InstallContext::from_exe_with_codex_home(
902
+ /*is_macos*/ true,
903
+ /*current_exe*/ Some(Path::new("/opt/homebrew/bin/codex")),
904
+ /*method_override*/ None,
905
+ /*codex_home*/ None,
906
+ );
907
+ assert_eq!(
908
+ context,
909
+ InstallContext {
910
+ method: InstallMethod::Brew,
911
+ package_layout: None,
912
+ }
913
+ );
914
+ }
915
+ }
codex-rs/tui/assets/inline_visualization/visualize.css ADDED
@@ -0,0 +1,867 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ color-scheme: light dark;
3
+ background-color: var(
4
+ --background,
5
+ var(--color-background-primary, light-dark(rgb(255 255 255), rgb(24 24 24)))
6
+ ) !important;
7
+
8
+ /* Agent-facing contract; keep in sync with SKILL.md. */
9
+ --background: var(
10
+ --color-background-primary,
11
+ light-dark(rgb(255 255 255), rgb(24 24 24))
12
+ );
13
+ --foreground: var(
14
+ --color-text-primary,
15
+ light-dark(rgb(26 28 31), rgb(255 255 255))
16
+ );
17
+ --card: color-mix(in oklab, var(--foreground) 5%, var(--background));
18
+ --card-foreground: var(
19
+ --color-text-primary,
20
+ light-dark(rgb(26 28 31), rgb(255 255 255))
21
+ );
22
+ --popover: var(
23
+ --color-background-secondary,
24
+ light-dark(rgb(255 255 255), rgb(45 45 45))
25
+ );
26
+ --popover-foreground: var(
27
+ --color-text-primary,
28
+ light-dark(rgb(26 28 31), rgb(255 255 255))
29
+ );
30
+ --primary: var(
31
+ --color-text-info,
32
+ light-dark(rgb(51 156 255), rgb(131 195 255))
33
+ );
34
+ --primary-foreground: var(
35
+ --color-text-inverse,
36
+ light-dark(rgb(255 255 255), rgb(13 13 13))
37
+ );
38
+ --secondary: var(
39
+ --color-background-secondary,
40
+ light-dark(rgb(255 255 255 / 96%), rgb(54 54 54 / 96%))
41
+ );
42
+ --secondary-foreground: var(
43
+ --color-text-primary,
44
+ light-dark(rgb(26 28 31), rgb(255 255 255))
45
+ );
46
+ --muted: color-mix(in srgb, var(--foreground) 10%, transparent);
47
+ --muted-foreground: var(
48
+ --color-text-secondary,
49
+ light-dark(rgb(26 28 31 / 49.4%), rgb(255 255 255 / 49.8%))
50
+ );
51
+ --accent: var(
52
+ --color-background-info,
53
+ light-dark(rgb(229 242 255), rgb(13 39 63))
54
+ );
55
+ --accent-foreground: var(
56
+ --color-text-info,
57
+ light-dark(rgb(51 156 255), rgb(131 195 255))
58
+ );
59
+ --destructive: var(
60
+ --color-text-warning,
61
+ light-dark(rgb(226 85 7), rgb(255 133 73))
62
+ );
63
+ --border: var(
64
+ --color-border-secondary,
65
+ light-dark(rgb(26 28 31 / 8%), rgb(255 255 255 / 8.2%))
66
+ );
67
+ --input: var(
68
+ --color-border-primary,
69
+ light-dark(
70
+ rgb(26 28 31 / 11.8%),
71
+ color-mix(in oklab, rgb(0 0 0) 10%, transparent)
72
+ )
73
+ );
74
+ --ring: var(
75
+ --color-ring-primary,
76
+ light-dark(rgb(51 156 255), rgb(131 195 255 / 76%))
77
+ );
78
+ --font-size-base: var(--font-text-md-size, 14px);
79
+ --blue: light-dark(rgb(51 156 255), rgb(51 156 255));
80
+ --orange: light-dark(rgb(226 85 7), rgb(251 106 34));
81
+ --green: light-dark(rgb(0 162 64), rgb(64 201 119));
82
+ --red: light-dark(rgb(224 46 42), rgb(255 103 100));
83
+ --purple: light-dark(rgb(146 79 247), rgb(173 123 249));
84
+ --yellow: light-dark(rgb(255 195 0), rgb(255 210 64));
85
+ --viz-series-1: var(--primary);
86
+ --viz-series-2: light-dark(rgb(243 136 59), rgb(245 154 86));
87
+ --viz-series-3: light-dark(rgb(93 201 119), rgb(116 213 139));
88
+ --viz-series-4: light-dark(rgb(235 119 177), rgb(240 143 192));
89
+ --viz-series-5: light-dark(rgb(155 121 236), rgb(170 145 239));
90
+ --viz-series-6: light-dark(rgb(58 185 177), rgb(90 203 194));
91
+
92
+ /* Internal implementation variables; not part of the agent contract. */
93
+ --font-sans: -apple-system, system-ui, "Segoe UI", sans-serif;
94
+ --font-mono:
95
+ ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono",
96
+ monospace;
97
+ --font-size-normal: max(11px, var(--font-size-base));
98
+ --font-size-tooltip: calc(var(--font-size-base) - 1px);
99
+ --font-size-small: max(11px, calc(var(--font-size-base) - 2px));
100
+ --font-size-h1: calc(var(--font-size-normal) * 1.7142857143);
101
+ --font-size-h2: calc(var(--font-size-normal) * 1.4285714286);
102
+ --font-size-h3: calc(var(--font-size-normal) * 1.2857142857);
103
+ --font-weight-normal: 430;
104
+ --font-weight-medium: 500;
105
+ --line-height-normal: calc(var(--font-size-normal) * 1.5);
106
+ --line-height-tooltip: calc(var(--font-size-tooltip) * 1.4285714286);
107
+ --line-height-small: calc(var(--font-size-small) + 4px);
108
+ --radius: var(--border-radius-lg, 12.5px);
109
+ --radius-sm: calc(var(--radius) * 0.6);
110
+ --radius-md: calc(var(--radius) * 0.8);
111
+ --radius-lg: var(--radius);
112
+ --radius-2xl: calc(var(--radius) * 1.6);
113
+ --radius-full: 9999px;
114
+ --shadow-sm: 0 1px 2px -1px rgb(0 0 0 / 8%);
115
+ --checkmark-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 17 17'%3E%3Cpath d='M12.8961 3.64101C13.1297 3.41418 13.4984 3.37523 13.7779 3.56581C14.0571 3.75635 14.1554 4.11331 14.0299 4.41347L13.9615 4.53847L7.71151 13.7045C7.59411 13.8767 7.4063 13.9877 7.19881 14.0072C6.99136 14.0267 6.78564 13.9533 6.63826 13.806L2.88826 10.056L2.79842 9.9457C2.6192 9.67407 2.64927 9.30496 2.88826 9.06581C3.12738 8.82669 3.49647 8.79676 3.76815 8.97597L3.8785 9.06581L7.03084 12.2182L12.8053 3.74941L12.8961 3.64101Z'/%3E%3C/svg%3E");
116
+
117
+ /* Legacy aliases; not part of the current agent contract. */
118
+ --viz-bg: transparent;
119
+ --viz-panel: var(--card);
120
+ --viz-border: var(--border);
121
+ --viz-text: var(--foreground);
122
+ --viz-muted: var(--muted-foreground);
123
+ --viz-accent: var(--primary);
124
+ --viz-accent-text: var(--primary-foreground);
125
+ --viz-accent-bg: var(--accent);
126
+ --viz-font-size: var(--font-size-base);
127
+ --viz-warning: var(--destructive);
128
+ }
129
+
130
+ :root[data-theme="light"] {
131
+ color-scheme: light;
132
+ }
133
+
134
+ :root[data-theme="dark"] {
135
+ color-scheme: dark;
136
+ }
137
+
138
+ * {
139
+ box-sizing: border-box;
140
+ }
141
+
142
+ html > body {
143
+ /* Preserve MCP aliases for older inline renderers without creating :root cycles. */
144
+ --color-background-primary: var(
145
+ --background,
146
+ light-dark(rgb(255 255 255), rgb(24 24 24))
147
+ );
148
+ --color-text-primary: var(
149
+ --foreground,
150
+ light-dark(rgb(26 28 31), rgb(255 255 255))
151
+ );
152
+ --color-border-secondary: var(
153
+ --border,
154
+ light-dark(rgb(26 28 31 / 8%), rgb(255 255 255 / 8.2%))
155
+ );
156
+
157
+ margin: 0;
158
+ padding: 5px;
159
+ color: var(--foreground);
160
+ background: transparent !important;
161
+ font-family: var(--font-sans);
162
+ font-size: var(--font-size-normal);
163
+ font-weight: var(--font-weight-normal);
164
+ line-height: var(--line-height-normal);
165
+ }
166
+
167
+ a {
168
+ color: color-mix(in srgb, var(--viz-accent) 80%, var(--viz-text) 20%);
169
+ cursor: pointer;
170
+ font-weight: var(--font-weight-medium, 500);
171
+ text-decoration: none;
172
+ text-underline-offset: 2px;
173
+ }
174
+
175
+ a:is(:hover, :focus-visible) {
176
+ text-decoration-line: underline;
177
+ text-decoration-style: dashed;
178
+ text-decoration-thickness: 0.5px;
179
+ }
180
+
181
+ h1,
182
+ h2,
183
+ h3,
184
+ h4,
185
+ h5,
186
+ h6,
187
+ p {
188
+ margin-block: 0;
189
+ }
190
+
191
+ h1 {
192
+ font-size: var(--font-size-h1);
193
+ font-weight: var(--font-weight-medium);
194
+ line-height: 1.25;
195
+ }
196
+
197
+ h2 {
198
+ font-size: var(--font-size-h2);
199
+ font-weight: var(--font-weight-medium);
200
+ line-height: 1.25;
201
+ }
202
+
203
+ h3,
204
+ h4,
205
+ h5,
206
+ h6 {
207
+ font-size: var(--font-size-h3);
208
+ font-weight: var(--font-weight-medium);
209
+ line-height: 1.3;
210
+ }
211
+
212
+ b,
213
+ strong,
214
+ th {
215
+ font-weight: var(--font-weight-medium);
216
+ }
217
+
218
+ code:not(pre code) {
219
+ display: inline;
220
+ padding: 1px 6px;
221
+ border-radius: var(--radius-sm);
222
+ corner-shape: superellipse(1.5);
223
+ background: var(--muted);
224
+ font-family: var(--font-mono);
225
+ font-size: 0.92em;
226
+ overflow-wrap: anywhere;
227
+ -webkit-box-decoration-break: clone;
228
+ box-decoration-break: clone;
229
+ word-break: break-word;
230
+ }
231
+
232
+ .table-responsive {
233
+ width: 100%;
234
+ overflow-x: auto;
235
+ scrollbar-width: thin;
236
+ }
237
+
238
+ .table {
239
+ width: 100%;
240
+ border-collapse: collapse;
241
+ color: var(--foreground);
242
+ font: inherit;
243
+ text-align: start;
244
+ }
245
+
246
+ .table :is(th, td) {
247
+ padding-block: 10px;
248
+ padding-inline: 0 24px;
249
+ overflow-wrap: anywhere;
250
+ border-bottom: 1px solid var(--border);
251
+ text-align: start;
252
+ vertical-align: top;
253
+ }
254
+
255
+ .table-responsive > .table :is(th, td) {
256
+ overflow-wrap: break-word;
257
+ }
258
+
259
+ .table :is(th, td):last-child {
260
+ padding-inline-end: 0;
261
+ }
262
+
263
+ .table :is(caption, thead th) {
264
+ font-weight: 600;
265
+ }
266
+
267
+ .table thead th {
268
+ padding-block: 8px;
269
+ border-bottom-color: color-mix(in srgb, var(--foreground) 16%, transparent);
270
+ }
271
+
272
+ .table tbody tr:last-child :is(th, td) {
273
+ border-bottom: 0;
274
+ }
275
+
276
+ .table.table-sm :is(th, td) {
277
+ padding-block: 6px;
278
+ }
279
+
280
+ .table.table-sm :is(th, td):not(:last-child) {
281
+ padding-inline-end: 16px;
282
+ }
283
+
284
+ .table :is(.text-end, [align="right"]) {
285
+ text-align: end;
286
+ font-variant-numeric: tabular-nums;
287
+ }
288
+
289
+ .table :is(.text-center, [align="center"]) {
290
+ text-align: center;
291
+ }
292
+
293
+ .table .text-nowrap {
294
+ white-space: nowrap;
295
+ }
296
+
297
+ #widget {
298
+ display: flex;
299
+ flex-direction: column;
300
+ gap: 12px;
301
+ width: 100%;
302
+ padding: 0;
303
+ background: transparent !important;
304
+ }
305
+
306
+ .card {
307
+ min-width: 0;
308
+ padding: 12px;
309
+ overflow: hidden;
310
+ overflow-wrap: break-word;
311
+ border-radius: var(--radius-2xl);
312
+ corner-shape: superellipse(1.5);
313
+ color: var(--card-foreground);
314
+ background: var(--card);
315
+ }
316
+
317
+ #widget > :not(.card) {
318
+ width: 100% !important;
319
+ max-width: none !important;
320
+ margin: 0 !important;
321
+ padding: 0 !important;
322
+ border: 0 !important;
323
+ border-radius: 0 !important;
324
+ background: transparent !important;
325
+ box-shadow: none !important;
326
+ }
327
+
328
+ .tooltip {
329
+ position: fixed;
330
+ z-index: 50;
331
+ top: 0;
332
+ left: 0;
333
+ width: max-content;
334
+ max-width: min(
335
+ 20rem,
336
+ var(--tooltip-available-width, calc(100vw - 10px)),
337
+ calc(100vw - 10px)
338
+ );
339
+ max-height: min(
340
+ var(--tooltip-available-height, calc(100vh - 10px)),
341
+ calc(100vh - 10px)
342
+ );
343
+ padding: 4px 8px;
344
+ border: 1px solid var(--border);
345
+ border-radius: var(--radius-lg);
346
+ corner-shape: superellipse(1.5);
347
+ color: var(--popover-foreground);
348
+ background: var(--popover);
349
+ box-shadow: none;
350
+ font-size: var(--font-size-tooltip);
351
+ line-height: var(--line-height-tooltip);
352
+ overflow-wrap: break-word;
353
+ white-space: normal;
354
+ pointer-events: none;
355
+ user-select: none;
356
+ }
357
+
358
+ .viz-grid {
359
+ display: grid;
360
+ grid-template-columns: repeat(auto-fit, minmax(max(180px, 24%), 1fr));
361
+ gap: 10px;
362
+ }
363
+
364
+ .viz-stat {
365
+ display: flex;
366
+ flex-direction: column;
367
+ gap: 2px;
368
+ }
369
+
370
+ .viz-stat-value {
371
+ font-size: var(--font-size-h2);
372
+ font-weight: var(--font-weight-medium);
373
+ line-height: 1.25;
374
+ }
375
+
376
+ .viz-row {
377
+ display: flex;
378
+ flex-wrap: wrap;
379
+ align-items: center;
380
+ gap: 10px;
381
+ }
382
+
383
+ .viz-badge {
384
+ padding: 3px 8px;
385
+ border-radius: var(--radius-full);
386
+ color: var(--accent-foreground);
387
+ background: var(--accent);
388
+ font-weight: var(--font-weight-medium);
389
+ }
390
+
391
+ small,
392
+ .text-small,
393
+ .viz-badge {
394
+ font-size: var(--font-size-small);
395
+ line-height: var(--line-height-small);
396
+ }
397
+
398
+ .text-muted {
399
+ color: var(--muted-foreground);
400
+ }
401
+
402
+ .sr-only {
403
+ position: absolute;
404
+ width: 1px;
405
+ height: 1px;
406
+ padding: 0;
407
+ overflow: hidden;
408
+ clip: rect(0, 0, 0, 0);
409
+ white-space: nowrap;
410
+ border: 0;
411
+ }
412
+
413
+ .viz-controls {
414
+ display: flex;
415
+ flex-wrap: wrap;
416
+ align-items: center;
417
+ gap: 8px;
418
+ }
419
+
420
+ .viz-controls > .form-label {
421
+ display: grid;
422
+ min-width: min(100%, 260px);
423
+ flex: 1 1 280px;
424
+ grid-template-columns: minmax(0, 1fr) auto;
425
+ align-items: baseline;
426
+ gap: 2px 12px;
427
+ margin-bottom: 0;
428
+ }
429
+
430
+ .viz-controls > .form-label > :is(.form-control, .form-range, .form-select) {
431
+ grid-column: 1 / -1;
432
+ }
433
+
434
+ .btn,
435
+ .form-check-input,
436
+ .form-control,
437
+ .form-range,
438
+ .form-select {
439
+ font: inherit;
440
+ }
441
+
442
+ .btn {
443
+ appearance: button;
444
+ display: inline-flex;
445
+ inline-size: fit-content;
446
+ max-inline-size: 100%;
447
+ min-height: 28px;
448
+ align-items: center;
449
+ justify-content: center;
450
+ gap: 4px;
451
+ margin: 0;
452
+ padding: 0 8px;
453
+ -webkit-app-region: no-drag;
454
+ border: 1px solid var(--input);
455
+ border-radius: var(--radius-lg);
456
+ corner-shape: superellipse(1.5);
457
+ color: var(--secondary-foreground);
458
+ background: var(--secondary);
459
+ cursor: var(--cursor-interaction, pointer);
460
+ text-align: center;
461
+ text-decoration: none;
462
+ white-space: nowrap;
463
+ user-select: none;
464
+ -webkit-font-smoothing: antialiased;
465
+ }
466
+
467
+ .btn:is(.btn-block, .viz-tile) {
468
+ inline-size: 100%;
469
+ }
470
+
471
+ .btn.viz-tile {
472
+ min-width: 0;
473
+ overflow-wrap: anywhere;
474
+ white-space: normal;
475
+ }
476
+
477
+ a.btn {
478
+ cursor: pointer;
479
+ text-decoration: none;
480
+ }
481
+
482
+ .btn:not(:disabled):hover {
483
+ background: color-mix(in srgb, var(--foreground) 6%, var(--secondary));
484
+ }
485
+
486
+ .btn-primary {
487
+ border-color: transparent;
488
+ color: var(--primary-foreground);
489
+ background: var(--foreground);
490
+ background-clip: padding-box;
491
+ }
492
+
493
+ .btn-primary .text-muted {
494
+ color: color-mix(in srgb, var(--primary-foreground) 50%, transparent);
495
+ }
496
+
497
+ .btn-primary:not(:disabled):hover {
498
+ background: color-mix(in srgb, var(--foreground) 80%, transparent);
499
+ background-clip: padding-box;
500
+ }
501
+
502
+ .btn-ghost {
503
+ border-color: transparent;
504
+ color: var(--muted-foreground);
505
+ background: transparent;
506
+ }
507
+
508
+ .btn-ghost:not(:disabled):hover {
509
+ color: var(--foreground);
510
+ background: color-mix(in srgb, var(--foreground) 6%, var(--secondary));
511
+ }
512
+
513
+ .btn:disabled {
514
+ cursor: not-allowed;
515
+ opacity: 0.4;
516
+ }
517
+
518
+ [data-lucide] {
519
+ stroke-width: 1.6;
520
+ }
521
+
522
+ .form-label {
523
+ display: block;
524
+ margin-bottom: 6px;
525
+ color: var(--foreground);
526
+ }
527
+
528
+ .form-control {
529
+ display: block;
530
+ width: 100%;
531
+ min-height: 28px;
532
+ padding: 0 8px;
533
+ outline: none;
534
+ border: 1px solid var(--input);
535
+ border-radius: var(--radius-lg);
536
+ corner-shape: superellipse(1.5);
537
+ color: var(--foreground);
538
+ background: var(--secondary);
539
+ }
540
+
541
+ .form-control::placeholder {
542
+ color: var(--muted-foreground);
543
+ }
544
+
545
+ .form-control[type="file"] {
546
+ padding: 0;
547
+ overflow: hidden;
548
+ cursor: var(--cursor-interaction, pointer);
549
+ }
550
+
551
+ .form-control[type="file"]::file-selector-button {
552
+ min-height: 26px;
553
+ margin-right: 8px;
554
+ padding: 0 8px;
555
+ border: 0;
556
+ border-right: 1px solid var(--input);
557
+ color: var(--secondary-foreground);
558
+ background: var(--secondary);
559
+ cursor: inherit;
560
+ font: inherit;
561
+ }
562
+
563
+ .form-control[type="file"]:not(:disabled):hover::file-selector-button {
564
+ background: color-mix(in srgb, var(--foreground) 6%, var(--secondary));
565
+ }
566
+
567
+ .form-control-color[type="color"] {
568
+ width: 40px;
569
+ height: 28px;
570
+ padding: 3px;
571
+ cursor: var(--cursor-interaction, pointer);
572
+ }
573
+
574
+ .form-control-color[type="color"]::-webkit-color-swatch-wrapper {
575
+ padding: 0;
576
+ }
577
+
578
+ .form-control-color[type="color"]::-webkit-color-swatch {
579
+ border: 0;
580
+ border-radius: calc(var(--radius-lg) - 4px);
581
+ corner-shape: superellipse(1.5);
582
+ }
583
+
584
+ textarea.form-control {
585
+ height: auto;
586
+ min-height: 72px;
587
+ padding: 8px 10px;
588
+ resize: vertical;
589
+ }
590
+
591
+ .form-control:focus-visible {
592
+ border-color: var(--ring);
593
+ box-shadow: inset 0 0 0 1px var(--ring);
594
+ }
595
+
596
+ .form-control:disabled,
597
+ .form-select:disabled {
598
+ cursor: not-allowed;
599
+ opacity: 0.4;
600
+ }
601
+
602
+ .form-select {
603
+ appearance: none;
604
+ display: block;
605
+ width: 100%;
606
+ min-height: 28px;
607
+ margin: 0;
608
+ padding: 0 32px 0 8px;
609
+ outline: none;
610
+ border: 1px solid var(--input);
611
+ border-radius: var(--radius-lg);
612
+ corner-shape: superellipse(1.5);
613
+ color: var(--foreground);
614
+ background-color: var(--secondary);
615
+ background-image:
616
+ linear-gradient(45deg, transparent 50%, var(--muted-foreground) 50%),
617
+ linear-gradient(135deg, var(--muted-foreground) 50%, transparent 50%);
618
+ background-position:
619
+ calc(100% - 14px) 50%,
620
+ calc(100% - 10px) 50%;
621
+ background-repeat: no-repeat;
622
+ background-size: 4px 4px;
623
+ cursor: var(--cursor-interaction, default);
624
+ }
625
+
626
+ .form-select:not(:disabled):hover {
627
+ background-color: color-mix(in srgb, var(--foreground) 6%, var(--secondary));
628
+ }
629
+
630
+ .form-select:focus-visible {
631
+ border-color: var(--ring);
632
+ box-shadow: inset 0 0 0 1px var(--ring);
633
+ }
634
+
635
+ .form-check {
636
+ display: flex;
637
+ min-height: 20px;
638
+ align-items: center;
639
+ gap: 6px;
640
+ }
641
+
642
+ .form-check-input {
643
+ appearance: none;
644
+ width: 14px;
645
+ height: 14px;
646
+ flex: 0 0 auto;
647
+ margin: 0;
648
+ border: 1px solid var(--input);
649
+ color: var(--primary-foreground);
650
+ background-color: transparent;
651
+ cursor: var(--cursor-interaction, default);
652
+ transition:
653
+ background-color 150ms,
654
+ border-color 150ms,
655
+ box-shadow 150ms;
656
+ }
657
+
658
+ .form-check:not(.form-switch) .form-check-input[type="checkbox"] {
659
+ border-color: var(--input);
660
+ border-radius: var(--radius-sm);
661
+ corner-shape: superellipse(1.5);
662
+ background-color: var(--secondary);
663
+ box-shadow: var(--shadow-sm);
664
+ }
665
+
666
+ .form-check:not(.form-switch)
667
+ .form-check-input:not(:disabled):not(:checked):hover {
668
+ background-color: var(--card);
669
+ }
670
+
671
+ .form-check:not(.form-switch) .form-check-input[type="checkbox"]:checked {
672
+ border-color: var(--primary);
673
+ background-color: var(--primary);
674
+ }
675
+
676
+ .form-check:not(.form-switch)
677
+ .form-check-input[type="checkbox"]:checked::before {
678
+ display: block;
679
+ width: 100%;
680
+ height: 100%;
681
+ background: var(--primary-foreground);
682
+ content: "";
683
+ mask: var(--checkmark-image) center / 12px 12px no-repeat;
684
+ }
685
+
686
+ .form-check-input[type="radio"] {
687
+ width: 14px;
688
+ height: 14px;
689
+ border-radius: var(--radius-full);
690
+ }
691
+
692
+ .form-check-input[type="radio"]:checked {
693
+ border: 2px solid var(--primary);
694
+ background:
695
+ radial-gradient(circle, var(--primary-foreground) 0 2.5px, transparent 3px),
696
+ var(--primary);
697
+ }
698
+
699
+ .form-check:not(.form-switch) .form-check-input:focus-visible {
700
+ outline: 2px solid var(--ring);
701
+ outline-offset: 2px;
702
+ }
703
+
704
+ .form-check:not(.form-switch) .form-check-input:disabled {
705
+ cursor: not-allowed;
706
+ pointer-events: none;
707
+ }
708
+
709
+ .form-check:not(.form-switch) .form-check-input:disabled + .form-check-label {
710
+ cursor: not-allowed;
711
+ }
712
+
713
+ .form-switch .form-check-input:disabled,
714
+ .form-switch .form-check-input:disabled + .form-check-label {
715
+ cursor: not-allowed;
716
+ opacity: 0.6;
717
+ }
718
+
719
+ .form-check-label {
720
+ color: var(--foreground);
721
+ cursor: var(--cursor-interaction, default);
722
+ }
723
+
724
+ .form-switch .form-check-input {
725
+ position: relative;
726
+ width: 32px;
727
+ height: 20px;
728
+ border: 0;
729
+ border-radius: var(--radius-full);
730
+ background: var(--muted);
731
+ box-shadow: none;
732
+ transition: background-color 200ms cubic-bezier(0, 0, 0.2, 1);
733
+ }
734
+
735
+ .form-switch .form-check-input::before {
736
+ position: absolute;
737
+ top: 50%;
738
+ left: 0;
739
+ width: 16px;
740
+ height: 16px;
741
+ box-sizing: border-box;
742
+ border: 1px solid light-dark(var(--primary-foreground), var(--foreground));
743
+ border-radius: var(--radius-full);
744
+ background: light-dark(var(--primary-foreground), var(--foreground));
745
+ box-shadow: var(--shadow-sm);
746
+ content: "";
747
+ transform: translate(2px, -50%);
748
+ transition: transform 200ms cubic-bezier(0, 0, 0.2, 1);
749
+ }
750
+
751
+ .form-switch .form-check-input:checked {
752
+ background: var(--primary);
753
+ }
754
+
755
+ .form-switch .form-check-input:checked::before {
756
+ transform: translate(14px, -50%);
757
+ }
758
+
759
+ .form-switch .form-check-input:focus-visible {
760
+ box-shadow: 0 0 0 2px var(--ring);
761
+ }
762
+
763
+ .form-range {
764
+ appearance: none;
765
+ display: block;
766
+ width: 100%;
767
+ height: 28px;
768
+ flex: 1;
769
+ margin: 0;
770
+ padding: 0;
771
+ outline: none;
772
+ border: 0;
773
+ accent-color: var(--primary);
774
+ background: linear-gradient(
775
+ color-mix(in srgb, var(--foreground) 7%, transparent),
776
+ color-mix(in srgb, var(--foreground) 7%, transparent)
777
+ )
778
+ center / 100% 2px no-repeat;
779
+ }
780
+
781
+ .form-range::-webkit-slider-runnable-track {
782
+ height: 28px;
783
+ background: transparent;
784
+ }
785
+
786
+ .form-range::-webkit-slider-thumb {
787
+ appearance: none;
788
+ width: 20px;
789
+ height: 20px;
790
+ margin-top: 4px;
791
+ border: 1px solid var(--border);
792
+ border-radius: var(--radius-full);
793
+ background: light-dark(var(--primary-foreground), var(--foreground));
794
+ }
795
+
796
+ .form-range:focus-visible::-webkit-slider-thumb {
797
+ border-color: var(--ring);
798
+ box-shadow: inset 0 0 0 1px var(--ring);
799
+ }
800
+
801
+ .form-range::-moz-range-track {
802
+ height: 28px;
803
+ background: transparent;
804
+ }
805
+
806
+ .form-range::-moz-range-thumb {
807
+ width: 20px;
808
+ height: 20px;
809
+ border: 1px solid var(--border);
810
+ border-radius: var(--radius-full);
811
+ background: light-dark(var(--primary-foreground), var(--foreground));
812
+ }
813
+
814
+ .form-range:focus-visible::-moz-range-thumb {
815
+ border-color: var(--ring);
816
+ box-shadow: inset 0 0 0 1px var(--ring);
817
+ }
818
+
819
+ .form-range:disabled {
820
+ cursor: not-allowed;
821
+ opacity: 0.4;
822
+ }
823
+
824
+ .btn:not(.btn-primary, .viz-tile):is(
825
+ [aria-pressed="true"],
826
+ [aria-selected="true"],
827
+ .is-selected
828
+ ) {
829
+ border-color: var(--primary);
830
+ color: var(--primary-foreground);
831
+ background: var(--primary);
832
+ }
833
+
834
+ .btn.viz-tile:is([aria-pressed="true"], [aria-selected="true"], .is-selected) {
835
+ border-color: var(--primary);
836
+ box-shadow: inset 0 0 0 1px var(--primary);
837
+ }
838
+
839
+ .btn:focus-visible {
840
+ outline: 2px solid var(--ring);
841
+ outline-offset: 2px;
842
+ }
843
+
844
+ svg {
845
+ display: block;
846
+ max-width: 100%;
847
+ height: auto;
848
+ }
849
+
850
+ #widget > svg {
851
+ width: 100%;
852
+ }
853
+
854
+ /* Model-authored chart rules can otherwise stretch icons after these styles load. */
855
+ svg.lucide {
856
+ display: block;
857
+ width: 16px !important;
858
+ height: 16px !important;
859
+ flex: none;
860
+ margin: 0 !important;
861
+ stroke-width: 1.6;
862
+ }
863
+
864
+ .text-warning,
865
+ .text-destructive {
866
+ color: var(--destructive);
867
+ }
codex-rs/tui/assets/inline_visualization/visualize.html ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!--__INLINE_VISUALIZATION_FRAGMENT__-->
2
+ <script src="https://unpkg.com/@floating-ui/core@1.7.3/dist/floating-ui.core.umd.min.js"></script>
3
+ <script src="https://unpkg.com/@floating-ui/dom@1.7.4/dist/floating-ui.dom.umd.min.js"></script>
4
+ <script>
5
+ (() => {
6
+ if (window.FloatingUIDOM == null) {
7
+ return;
8
+ }
9
+ const { autoUpdate, computePosition, flip, offset, shift, size } =
10
+ window.FloatingUIDOM;
11
+ const tooltipPlacements = new Set(["top", "right", "bottom", "left"]);
12
+ const tooltipId = `codex-visualization-tooltip-${
13
+ window.crypto?.randomUUID?.() ?? Date.now().toString(36)
14
+ }`;
15
+ const tooltipGap = 5;
16
+ const hoverDelay = 700;
17
+ const skipDelay = 300;
18
+ let activeTrigger = null;
19
+ let pendingTrigger = null;
20
+ let tooltip = null;
21
+ let openTimeout = null;
22
+ let autoUpdateCleanup = null;
23
+ let positionRequest = 0;
24
+ let skipDelayUntil = 0;
25
+
26
+ const getTrigger = (target) =>
27
+ target?.nodeType === Node.ELEMENT_NODE
28
+ ? target.closest("[data-tooltip]")
29
+ : null;
30
+ const containsTarget = (trigger, target) =>
31
+ target?.nodeType != null && trigger.contains(target);
32
+ const getContent = (trigger) =>
33
+ trigger.getAttribute("data-tooltip")?.trim() ?? "";
34
+ const getPlacement = (trigger) => {
35
+ const placement = trigger.getAttribute("data-tooltip-placement");
36
+ return tooltipPlacements.has(placement) ? placement : "top";
37
+ };
38
+ const getTooltip = () => {
39
+ if (tooltip == null) {
40
+ tooltip = document.createElement("div");
41
+ tooltip.id = tooltipId;
42
+ tooltip.className = "tooltip";
43
+ tooltip.setAttribute("role", "tooltip");
44
+ }
45
+ return tooltip;
46
+ };
47
+ const setDescribedBy = (trigger, described) => {
48
+ const ids = new Set(
49
+ (trigger.getAttribute("aria-describedby") ?? "")
50
+ .split(/\s+/)
51
+ .filter(Boolean),
52
+ );
53
+ if (described) {
54
+ ids.add(tooltipId);
55
+ } else {
56
+ ids.delete(tooltipId);
57
+ }
58
+ if (ids.size === 0) {
59
+ trigger.removeAttribute("aria-describedby");
60
+ } else {
61
+ trigger.setAttribute("aria-describedby", [...ids].join(" "));
62
+ }
63
+ };
64
+ const clearOpenTimeout = () => {
65
+ if (openTimeout != null) {
66
+ window.clearTimeout(openTimeout);
67
+ openTimeout = null;
68
+ }
69
+ pendingTrigger = null;
70
+ };
71
+ const closeTooltip = (activateSkipDelay = true) => {
72
+ clearOpenTimeout();
73
+ positionRequest += 1;
74
+ autoUpdateCleanup?.();
75
+ autoUpdateCleanup = null;
76
+ if (activeTrigger == null) {
77
+ return;
78
+ }
79
+ setDescribedBy(activeTrigger, false);
80
+ activeTrigger = null;
81
+ tooltip?.remove();
82
+ if (activateSkipDelay) {
83
+ skipDelayUntil = Date.now() + skipDelay;
84
+ }
85
+ };
86
+ const updatePosition = (trigger, floating) => {
87
+ const request = ++positionRequest;
88
+ void computePosition(trigger, floating, {
89
+ middleware: [
90
+ offset(tooltipGap),
91
+ flip({ padding: tooltipGap }),
92
+ shift({ padding: tooltipGap }),
93
+ size({
94
+ padding: tooltipGap,
95
+ apply({ availableHeight, availableWidth, elements }) {
96
+ elements.floating.style.setProperty(
97
+ "--tooltip-available-width",
98
+ `${Math.max(0, availableWidth)}px`,
99
+ );
100
+ elements.floating.style.setProperty(
101
+ "--tooltip-available-height",
102
+ `${Math.max(0, availableHeight)}px`,
103
+ );
104
+ },
105
+ }),
106
+ ],
107
+ placement: getPlacement(trigger),
108
+ strategy: "fixed",
109
+ })
110
+ .then(({ x, y }) => {
111
+ if (activeTrigger !== trigger || request !== positionRequest) {
112
+ return;
113
+ }
114
+ const scale = window.devicePixelRatio || 1;
115
+ floating.style.transform = `translate(${Math.round(x * scale) / scale}px, ${Math.round(y * scale) / scale}px)`;
116
+ floating.style.visibility = "visible";
117
+ })
118
+ .catch(() => {
119
+ if (activeTrigger === trigger && request === positionRequest) {
120
+ closeTooltip(false);
121
+ }
122
+ });
123
+ };
124
+ const openTooltip = (trigger) => {
125
+ const content = getContent(trigger);
126
+ if (!trigger.isConnected || content.length === 0) {
127
+ return;
128
+ }
129
+ clearOpenTimeout();
130
+ if (activeTrigger === trigger) {
131
+ return;
132
+ }
133
+ closeTooltip();
134
+ const floating = getTooltip();
135
+ floating.textContent = content;
136
+ floating.style.visibility = "hidden";
137
+ floating.style.transform = "translate(0, 0)";
138
+ document.body.appendChild(floating);
139
+ activeTrigger = trigger;
140
+ setDescribedBy(trigger, true);
141
+ autoUpdateCleanup = autoUpdate(trigger, floating, () => {
142
+ updatePosition(trigger, floating);
143
+ });
144
+ };
145
+ const requestOpen = (trigger, immediate = false) => {
146
+ if (
147
+ getContent(trigger).length === 0 ||
148
+ activeTrigger === trigger ||
149
+ pendingTrigger === trigger
150
+ ) {
151
+ return;
152
+ }
153
+ clearOpenTimeout();
154
+ pendingTrigger = trigger;
155
+ const delay =
156
+ immediate || activeTrigger != null || Date.now() < skipDelayUntil
157
+ ? 0
158
+ : hoverDelay;
159
+ if (delay === 0) {
160
+ openTooltip(trigger);
161
+ return;
162
+ }
163
+ openTimeout = window.setTimeout(() => {
164
+ openTimeout = null;
165
+ pendingTrigger = null;
166
+ openTooltip(trigger);
167
+ }, delay);
168
+ };
169
+ const handlePointerOpen = (event) => {
170
+ if (event.pointerType === "touch") {
171
+ return;
172
+ }
173
+ const trigger = getTrigger(event.target);
174
+ if (trigger == null || containsTarget(trigger, event.relatedTarget)) {
175
+ return;
176
+ }
177
+ requestOpen(trigger);
178
+ };
179
+ const handleLeave = (event) => {
180
+ const trigger = getTrigger(event.target);
181
+ if (trigger == null || containsTarget(trigger, event.relatedTarget)) {
182
+ return;
183
+ }
184
+ if (activeTrigger === trigger || pendingTrigger === trigger) {
185
+ closeTooltip();
186
+ }
187
+ };
188
+ const handleFocusIn = (event) => {
189
+ const trigger = getTrigger(event.target);
190
+ if (trigger?.matches(":focus-visible")) {
191
+ requestOpen(trigger, true);
192
+ }
193
+ };
194
+ const handleKeyDown = (event) => {
195
+ if (event.key === "Escape") {
196
+ closeTooltip();
197
+ }
198
+ };
199
+ const handleContextMenu = (event) => {
200
+ const trigger = getTrigger(event.target);
201
+ if (activeTrigger === trigger || pendingTrigger === trigger) {
202
+ closeTooltip();
203
+ }
204
+ };
205
+ const destroy = () => {
206
+ closeTooltip(false);
207
+ document.removeEventListener("pointerover", handlePointerOpen);
208
+ document.removeEventListener("pointerout", handleLeave);
209
+ document.removeEventListener("focusin", handleFocusIn);
210
+ document.removeEventListener("focusout", handleLeave);
211
+ document.removeEventListener("keydown", handleKeyDown);
212
+ document.removeEventListener("contextmenu", handleContextMenu);
213
+ tooltip = null;
214
+ };
215
+
216
+ document.addEventListener("pointerover", handlePointerOpen);
217
+ document.addEventListener("pointerout", handleLeave);
218
+ document.addEventListener("focusin", handleFocusIn);
219
+ document.addEventListener("focusout", handleLeave);
220
+ document.addEventListener("keydown", handleKeyDown);
221
+ document.addEventListener("contextmenu", handleContextMenu);
222
+ window.addEventListener("pagehide", destroy, { once: true });
223
+ })();
224
+ </script>
225
+ <script id="codex-visualization-lucide" async src="https://unpkg.com/lucide@1.17.0/dist/umd/lucide.js"></script>
226
+ <script>
227
+ (() => {
228
+ const initialize = () => {
229
+ globalThis.lucide?.createIcons({ attrs: { width: 16, height: 16 } });
230
+ };
231
+ if (globalThis.lucide != null) {
232
+ initialize();
233
+ return;
234
+ }
235
+ document
236
+ .getElementById("codex-visualization-lucide")
237
+ ?.addEventListener("load", initialize, { once: true });
238
+ })();
239
+ </script>
codex-rs/tui/frames/blocks/frame_1.txt ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ β–’β–“β–’β–“β–’β–ˆβ–ˆβ–’β–’β–ˆβ–ˆβ–’
3
+ β–’β–’β–ˆβ–“β–ˆβ–’β–ˆβ–“β–ˆβ–’β–’β–‘β–‘β–’β–’ β–’ β–ˆβ–’
4
+ β–ˆβ–‘β–ˆβ–‘β–ˆβ–ˆβ–ˆ β–’β–‘ β–‘ β–ˆβ–‘ β–‘β–’β–‘β–‘β–‘β–ˆ
5
+ β–“β–ˆβ–’β–’β–ˆβ–ˆβ–ˆβ–ˆβ–’ β–“β–ˆβ–‘β–“β–‘β–ˆ
6
+ β–’β–’β–“β–“β–ˆβ–’β–‘β–’β–‘β–’β–’ β–“β–‘β–’β–’β–ˆ
7
+ β–‘β–ˆ β–ˆβ–‘ β–‘β–ˆβ–“β–“β–‘β–‘β–ˆ β–ˆβ–“β–’β–‘β–‘β–ˆ
8
+ β–ˆβ–’ β–“β–ˆ β–ˆβ–’β–‘β–ˆβ–“ β–‘β–’ β–‘β–“β–‘
9
+ β–‘β–‘β–’β–‘β–‘ β–ˆβ–“β–“β–‘β–“β–‘β–ˆ β–‘β–‘
10
+ β–‘β–’β–‘β–ˆβ–‘ β–“β–‘β–‘β–’β–’β–‘ β–“β–‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–’β–ˆβ–ˆ β–’ β–‘
11
+ β–’β–‘β–“β–ˆ β–’β–“β–ˆβ–‘ β–“β–ˆ β–‘ β–‘β–’β–’β–’β–“β–“β–ˆβ–ˆβ–ˆβ–‘β–“β–ˆβ–“β–ˆβ–‘
12
+ β–’β–’β–’ β–’ β–’β–’β–ˆβ–“β–“β–‘ β–‘β–’β–ˆβ–ˆβ–ˆβ–ˆ β–’β–ˆ β–“β–ˆβ–“β–’β–“
13
+ β–ˆβ–’β–ˆ β–ˆ β–‘ β–ˆβ–ˆβ–“β–ˆβ–’β–‘
14
+ β–’β–’β–ˆβ–‘β–’β–ˆβ–’ β–’β–’β–’β–ˆβ–‘β–’β–ˆ
15
+ β–’β–ˆβ–ˆβ–’β–’ β–ˆβ–ˆβ–“β–“β–’β–“β–“β–“β–’β–ˆβ–ˆβ–’β–ˆβ–‘β–ˆ
16
+ β–‘β–ˆ β–ˆβ–‘β–‘β–‘β–’β–’β–’β–ˆβ–’β–“β–ˆβ–ˆ
17
+