Implement profile inheritance

This commit is contained in:
2026-04-26 23:51:32 +02:00
parent 7f9b21ef4f
commit c77dbc10c3
5 changed files with 357 additions and 68 deletions
+17 -9
View File
@@ -1,10 +1,14 @@
# Layered settings: CLI > active profile > globals. `--profile` selects the # Merge rules (apply to every layering below): scalars replace, vec fields
# profile, otherwise `default-profile` below is used. Vec fields append across # append, profiles merge by name.
# layers; scalars replace.
# #
# `extra-config` optionally points to a second file layered on top of this one # Layers, lowest precedence first: globals -> active profile (with its
# using the same rules (scalars replace, vecs append, profiles merge by name). # ancestors folded in) -> CLI flags. `--profile` selects the active profile;
# Missing extra-config files are silently skipped; nesting is not supported. # otherwise the top-level `profile` below is used. Profiles can themselves set
# `profile = "parent"` to inherit from another profile.
#
# `extra-config` optionally points to a second file layered on top of this
# one. Missing extra-config files are silently skipped; nesting is not
# supported.
extra-config = "~/.config/agent-sandbox/extra.toml" extra-config = "~/.config/agent-sandbox/extra.toml"
@@ -41,13 +45,17 @@ env = [
# command = ["--model", "opus"] # default trailing args # command = ["--model", "opus"] # default trailing args
# bwrap-args = ["--tmpfs /opt/scratch"] # raw bwrap escape hatch # bwrap-args = ["--tmpfs /opt/scratch"] # raw bwrap escape hatch
default-profile = "claude" profile = "claude"
[profile.claude] [profiles.claude]
ro = ["~/.local/share/claude-code"] ro = ["~/.local/share/claude-code"]
rw = ["~/.config/claude"] rw = ["~/.config/claude"]
entrypoint = ["claude", "--allowedTools", "Bash(*)", "WebSearch", "WebFetch(*)", "mcp__*"]
[profiles.claude-yolo]
profile = "claude"
entrypoint = ["claude", "--dangerously-skip-permissions"] entrypoint = ["claude", "--dangerously-skip-permissions"]
[profile.codex] [profiles.codex]
ro = ["~/.local/share/codex-cli"] ro = ["~/.local/share/codex-cli"]
entrypoint = ["codex", "--dangerously-bypass-approvals-and-sandbox"] entrypoint = ["codex", "--dangerously-bypass-approvals-and-sandbox"]
+50 -16
View File
@@ -289,9 +289,7 @@ pub struct FileConfig {
#[serde(flatten)] #[serde(flatten)]
pub options: Options, pub options: Options,
#[serde(default)] #[serde(default)]
pub profile: HashMap<String, Options>, pub profiles: HashMap<String, Options>,
#[serde(rename = "default-profile", default)]
pub default_profile: Option<String>,
#[serde(rename = "extra-config", default)] #[serde(rename = "extra-config", default)]
pub extra_config: Option<PathBuf>, pub extra_config: Option<PathBuf>,
// Collects unrecognized keys; deny_unknown_fields is incompatible with flatten. // Collects unrecognized keys; deny_unknown_fields is incompatible with flatten.
@@ -339,18 +337,17 @@ impl FileConfig {
} }
fn merge_with(self, extra: FileConfig) -> FileConfig { fn merge_with(self, extra: FileConfig) -> FileConfig {
let mut profile = self.profile; let mut profiles = self.profiles;
for (profile_name, profile_options) in extra.profile { for (profile_name, profile_options) in extra.profiles {
let merged = match profile.remove(&profile_name) { let merged = match profiles.remove(&profile_name) {
Some(existing) => existing.merge_with(profile_options), Some(existing) => existing.merge_with(profile_options),
None => profile_options, None => profile_options,
}; };
profile.insert(profile_name, merged); profiles.insert(profile_name, merged);
} }
FileConfig { FileConfig {
options: self.options.merge_with(extra.options), options: self.options.merge_with(extra.options),
profile, profiles,
default_profile: extra.default_profile.or(self.default_profile),
extra_config: None, extra_config: None,
_unknown: HashMap::new(), _unknown: HashMap::new(),
} }
@@ -367,21 +364,57 @@ impl FileConfig {
Ok(config) Ok(config)
} }
fn resolve_profile(&self, name: Option<&str>) -> Result<Options, SandboxError> { fn resolve_profile(&self, selected: Option<&str>) -> Result<Options, SandboxError> {
match name.or(self.default_profile.as_deref()) { match selected.or(self.options.profile.as_deref()) {
Some(n) => self Some(leaf) => self.resolve_chain(leaf),
.profile
.get(n)
.cloned()
.ok_or_else(|| SandboxError::ProfileNotFound(n.to_string())),
None => Ok(Options::default()), None => Ok(Options::default()),
} }
} }
fn resolve_chain(&self, leaf: &str) -> Result<Options, SandboxError> {
let chain = self.collect_chain(leaf)?;
let merged = chain
.into_iter()
.rev()
.fold(Options::default(), |base, child| base.merge_with(child));
Ok(merged)
}
fn collect_chain(&self, leaf: &str) -> Result<Vec<Options>, SandboxError> {
let mut visited: Vec<String> = Vec::new();
let mut chain: Vec<Options> = Vec::new();
let mut current = leaf.to_string();
loop {
if visited.iter().any(|seen| seen == &current) {
visited.push(current);
return Err(SandboxError::ProfileCycle(visited));
}
let options = self
.profiles
.get(&current)
.cloned()
.ok_or_else(|| SandboxError::ProfileNotFound(current.clone()))?;
visited.push(current);
let parent = options.profile.clone();
chain.push(options);
match parent {
Some(p) => current = p,
None => return Ok(chain),
}
}
}
} }
#[derive(Deserialize, Default, Clone)] #[derive(Deserialize, Default, Clone)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)] #[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct Options { pub struct Options {
pub profile: Option<String>,
pub blacklist: Option<bool>, pub blacklist: Option<bool>,
pub whitelist: Option<bool>, pub whitelist: Option<bool>,
pub hardened: Option<bool>, pub hardened: Option<bool>,
@@ -410,6 +443,7 @@ impl Options {
fn merge_with(self, extra: Options) -> Options { fn merge_with(self, extra: Options) -> Options {
let (blacklist, whitelist) = pick_mode_flags(&self, &extra); let (blacklist, whitelist) = pick_mode_flags(&self, &extra);
Options { Options {
profile: extra.profile.or(self.profile),
blacklist, blacklist,
whitelist, whitelist,
hardened: extra.hardened.or(self.hardened), hardened: extra.hardened.or(self.hardened),
+8
View File
@@ -21,6 +21,7 @@ pub enum SandboxError {
source: toml::de::Error, source: toml::de::Error,
}, },
ProfileNotFound(String), ProfileNotFound(String),
ProfileCycle(Vec<String>),
ConflictingMode, ConflictingMode,
ConflictingEnvKey(String), ConflictingEnvKey(String),
InvalidEnvEntry(String), InvalidEnvEntry(String),
@@ -65,6 +66,13 @@ impl std::fmt::Display for SandboxError {
write!(f, "cannot parse config file '{}': {source}", path.display()) write!(f, "cannot parse config file '{}': {source}", path.display())
} }
Self::ProfileNotFound(name) => write!(f, "profile not found in config: {name}"), Self::ProfileNotFound(name) => write!(f, "profile not found in config: {name}"),
Self::ProfileCycle(chain) => {
write!(
f,
"profile inheritance cycle detected: {}",
chain.join(" -> ")
)
}
Self::ConflictingMode => write!( Self::ConflictingMode => write!(
f, f,
"config section sets both blacklist and whitelist to true" "config section sets both blacklist and whitelist to true"
+5 -5
View File
@@ -118,7 +118,7 @@ fn bwrap_arg_setenv_passes_through() {
fn config_entrypoint_appends_passthrough_args() { fn config_entrypoint_appends_passthrough_args() {
let cfg = ConfigFile::new( let cfg = ConfigFile::new(
r#" r#"
[profile.test] [profiles.test]
entrypoint = ["bash", "-c"] entrypoint = ["bash", "-c"]
"#, "#,
); );
@@ -139,7 +139,7 @@ fn config_entrypoint_appends_passthrough_args() {
fn config_entrypoint_falls_back_to_command_defaults() { fn config_entrypoint_falls_back_to_command_defaults() {
let cfg = ConfigFile::new( let cfg = ConfigFile::new(
r#" r#"
[profile.test] [profiles.test]
entrypoint = ["bash", "-c"] entrypoint = ["bash", "-c"]
command = ["echo default-args"] command = ["echo default-args"]
"#, "#,
@@ -160,7 +160,7 @@ fn config_entrypoint_falls_back_to_command_defaults() {
fn config_entrypoint_alone_without_command_or_passthrough() { fn config_entrypoint_alone_without_command_or_passthrough() {
let cfg = ConfigFile::new( let cfg = ConfigFile::new(
r#" r#"
[profile.test] [profiles.test]
entrypoint = ["bash", "-c", "echo entrypoint-only"] entrypoint = ["bash", "-c", "echo entrypoint-only"]
"#, "#,
); );
@@ -214,7 +214,7 @@ fn cli_entrypoint_overrides_config_entrypoint() {
fn config_command_alone_without_passthrough() { fn config_command_alone_without_passthrough() {
let cfg = ConfigFile::new( let cfg = ConfigFile::new(
r#" r#"
[profile.test] [profiles.test]
command = ["bash", "-c", "echo command-only"] command = ["bash", "-c", "echo command-only"]
"#, "#,
); );
@@ -234,7 +234,7 @@ fn config_command_alone_without_passthrough() {
fn config_command_replaced_by_passthrough() { fn config_command_replaced_by_passthrough() {
let cfg = ConfigFile::new( let cfg = ConfigFile::new(
r#" r#"
[profile.test] [profiles.test]
command = ["bash", "-c", "echo should-not-see-this"] command = ["bash", "-c", "echo should-not-see-this"]
"#, "#,
); );
+277 -38
View File
@@ -11,13 +11,13 @@ const FULL_CONFIG_TOML: &str = r#"
rw = ["/tmp/a", "/tmp/b"] rw = ["/tmp/a", "/tmp/b"]
command = "zsh" command = "zsh"
[profile.claude] [profiles.claude]
rw = ["/home/user/.config/claude"] rw = ["/home/user/.config/claude"]
ro = ["/etc/claude", "/etc/shared"] ro = ["/etc/claude", "/etc/shared"]
entrypoint = ["claude", "--dangerously-skip-permissions"] entrypoint = ["claude", "--dangerously-skip-permissions"]
command = ["bash", "-c", "echo hi"] command = ["bash", "-c", "echo hi"]
[profile.codex] [profiles.codex]
whitelist = true whitelist = true
dry-run = true dry-run = true
chdir = "/home/user/project" chdir = "/home/user/project"
@@ -50,13 +50,13 @@ fn unset_vecs_default_empty() {
#[test] #[test]
fn unset_scalars_are_none() { fn unset_scalars_are_none() {
assert_eq!(CONFIG.profile["claude"].hardened, None); assert_eq!(CONFIG.profiles["claude"].hardened, None);
} }
#[test] #[test]
fn profile_multi_element_vecs() { fn profile_multi_element_vecs() {
assert_paths( assert_paths(
&CONFIG.profile["claude"].ro, &CONFIG.profiles["claude"].ro,
&["/etc/claude", "/etc/shared"], &["/etc/claude", "/etc/shared"],
); );
} }
@@ -64,7 +64,7 @@ fn profile_multi_element_vecs() {
#[test] #[test]
fn profile_entrypoint_with_args() { fn profile_entrypoint_with_args() {
assert_command( assert_command(
&CONFIG.profile["claude"].entrypoint, &CONFIG.profiles["claude"].entrypoint,
&["claude", "--dangerously-skip-permissions"], &["claude", "--dangerously-skip-permissions"],
); );
} }
@@ -72,14 +72,14 @@ fn profile_entrypoint_with_args() {
#[test] #[test]
fn profile_command_with_args() { fn profile_command_with_args() {
assert_command( assert_command(
&CONFIG.profile["claude"].command, &CONFIG.profiles["claude"].command,
&["bash", "-c", "echo hi"], &["bash", "-c", "echo hi"],
); );
} }
#[test] #[test]
fn profile_kebab_case_scalars() { fn profile_kebab_case_scalars() {
let codex = &CONFIG.profile["codex"]; let codex = &CONFIG.profiles["codex"];
assert_eq!(codex.whitelist, Some(true)); assert_eq!(codex.whitelist, Some(true));
assert_eq!(codex.dry_run, Some(true)); assert_eq!(codex.dry_run, Some(true));
assert_eq!(codex.chdir, Some(PathBuf::from("/home/user/project"))); assert_eq!(codex.chdir, Some(PathBuf::from("/home/user/project")));
@@ -112,7 +112,7 @@ fn build_profile_overrides_globals() {
hardened: Some(true), hardened: Some(true),
..Options::default() ..Options::default()
}, },
profile: HashMap::from([( profiles: HashMap::from([(
"relaxed".into(), "relaxed".into(),
Options { Options {
hardened: Some(false), hardened: Some(false),
@@ -132,7 +132,7 @@ fn build_profile_overrides_globals() {
#[test] #[test]
fn build_cli_flag_overrides_profile() { fn build_cli_flag_overrides_profile() {
let file_config = FileConfig { let file_config = FileConfig {
profile: HashMap::from([( profiles: HashMap::from([(
"nonet".into(), "nonet".into(),
Options { Options {
unshare_net: Some(false), unshare_net: Some(false),
@@ -257,7 +257,7 @@ fn build_cli_no_dry_run_overrides_profile() {
#[test] #[test]
fn build_cli_mode_overrides_profile() { fn build_cli_mode_overrides_profile() {
let file_config = FileConfig { let file_config = FileConfig {
profile: HashMap::from([( profiles: HashMap::from([(
"wl".into(), "wl".into(),
Options { Options {
whitelist: Some(true), whitelist: Some(true),
@@ -282,7 +282,7 @@ fn build_rw_paths_accumulate() {
rw: vec!["/tmp".into()], rw: vec!["/tmp".into()],
..Options::default() ..Options::default()
}, },
profile: HashMap::from([( profiles: HashMap::from([(
"extra".into(), "extra".into(),
Options { Options {
rw: vec!["/usr".into()], rw: vec!["/usr".into()],
@@ -303,7 +303,7 @@ fn build_rw_paths_accumulate() {
#[test] #[test]
fn build_command_from_profile() { fn build_command_from_profile() {
let file_config = FileConfig { let file_config = FileConfig {
profile: HashMap::from([( profiles: HashMap::from([(
"test".into(), "test".into(),
Options { Options {
command: Some(CommandValue::WithArgs(vec![ command: Some(CommandValue::WithArgs(vec![
@@ -327,7 +327,7 @@ fn build_command_from_profile() {
#[test] #[test]
fn build_cli_entrypoint_overrides_profile() { fn build_cli_entrypoint_overrides_profile() {
let file_config = FileConfig { let file_config = FileConfig {
profile: HashMap::from([( profiles: HashMap::from([(
"test".into(), "test".into(),
Options { Options {
entrypoint: Some(CommandValue::Simple("/usr/bin/false".into())), entrypoint: Some(CommandValue::Simple("/usr/bin/false".into())),
@@ -410,10 +410,13 @@ fn build_no_file_config() {
} }
#[test] #[test]
fn build_default_profile_used_when_cli_absent() { fn build_top_level_profile_used_when_cli_absent() {
let file_config = FileConfig { let file_config = FileConfig {
default_profile: Some("auto".into()), options: Options {
profile: HashMap::from([( profile: Some("auto".into()),
..Options::default()
},
profiles: HashMap::from([(
"auto".into(), "auto".into(),
Options { Options {
hardened: Some(true), hardened: Some(true),
@@ -427,10 +430,13 @@ fn build_default_profile_used_when_cli_absent() {
} }
#[test] #[test]
fn build_cli_profile_overrides_default_profile() { fn build_cli_profile_overrides_top_level_profile() {
let file_config = FileConfig { let file_config = FileConfig {
default_profile: Some("auto".into()), options: Options {
profile: HashMap::from([ profile: Some("auto".into()),
..Options::default()
},
profiles: HashMap::from([
( (
"auto".into(), "auto".into(),
Options { Options {
@@ -457,9 +463,12 @@ fn build_cli_profile_overrides_default_profile() {
} }
#[test] #[test]
fn build_missing_default_profile_errors() { fn build_missing_top_level_profile_errors() {
let file_config = FileConfig { let file_config = FileConfig {
default_profile: Some("nope".into()), options: Options {
profile: Some("nope".into()),
..Options::default()
},
..FileConfig::default() ..FileConfig::default()
}; };
assert!(matches!( assert!(matches!(
@@ -480,6 +489,227 @@ fn build_missing_profile_errors() {
)); ));
} }
#[test]
fn inheritance_merges_parent_into_child() {
let file_config = FileConfig {
profiles: HashMap::from([
(
"base".into(),
Options {
hardened: Some(true),
unshare_net: Some(true),
env: vec!["FROM_PARENT=1".into()],
..Options::default()
},
),
(
"child".into(),
Options {
profile: Some("base".into()),
hardened: Some(false),
seccomp: Some(false),
env: vec!["FROM_CHILD=1".into()],
..Options::default()
},
),
]),
..FileConfig::default()
};
let args = Args {
profile: Some("child".into()),
..args_with_command()
};
let config = build(args, Some(file_config)).unwrap();
assert!(!config.hardened);
assert!(config.unshare_net);
assert!(!config.seccomp);
assert_eq!(
config.env,
vec![
EnvEntry::Set("FROM_PARENT".into(), "1".into()),
EnvEntry::Set("FROM_CHILD".into(), "1".into()),
]
);
}
#[test]
fn inheritance_walks_multi_level_chain() {
let file_config = FileConfig {
profiles: HashMap::from([
(
"grand".into(),
Options {
hardened: Some(true),
env: vec!["FROM_GRAND=1".into()],
..Options::default()
},
),
(
"parent".into(),
Options {
profile: Some("grand".into()),
env: vec!["FROM_PARENT=1".into()],
..Options::default()
},
),
(
"child".into(),
Options {
profile: Some("parent".into()),
env: vec!["FROM_CHILD=1".into()],
..Options::default()
},
),
]),
..FileConfig::default()
};
let args = Args {
profile: Some("child".into()),
..args_with_command()
};
let config = build(args, Some(file_config)).unwrap();
assert!(config.hardened);
assert_eq!(
config.env,
vec![
EnvEntry::Set("FROM_GRAND".into(), "1".into()),
EnvEntry::Set("FROM_PARENT".into(), "1".into()),
EnvEntry::Set("FROM_CHILD".into(), "1".into()),
]
);
}
#[test]
fn top_level_profile_walks_chain() {
let file_config = FileConfig {
options: Options {
profile: Some("child".into()),
..Options::default()
},
profiles: HashMap::from([
(
"base".into(),
Options {
hardened: Some(true),
..Options::default()
},
),
(
"child".into(),
Options {
profile: Some("base".into()),
..Options::default()
},
),
]),
..FileConfig::default()
};
let config = build(args_with_command(), Some(file_config)).unwrap();
assert!(config.hardened);
}
#[test]
fn inheritance_cycle_errors() {
let file_config = FileConfig {
profiles: HashMap::from([
(
"a".into(),
Options {
profile: Some("b".into()),
..Options::default()
},
),
(
"b".into(),
Options {
profile: Some("a".into()),
..Options::default()
},
),
]),
..FileConfig::default()
};
let args = Args {
profile: Some("a".into()),
..args_with_command()
};
assert!(matches!(
build(args, Some(file_config)),
Err(SandboxError::ProfileCycle(_))
));
}
#[test]
fn missing_parent_profile_errors() {
let file_config = FileConfig {
profiles: HashMap::from([(
"child".into(),
Options {
profile: Some("ghost".into()),
..Options::default()
},
)]),
..FileConfig::default()
};
let args = Args {
profile: Some("child".into()),
..args_with_command()
};
assert!(matches!(
build(args, Some(file_config)),
Err(SandboxError::ProfileNotFound(name)) if name == "ghost"
));
}
#[test]
fn inheritance_chain_spans_extra_config() {
let dir = TempDir::new().unwrap();
let base_path = dir.path().join("base.toml");
let extra_path = dir.path().join("extra.toml");
std::fs::write(
&extra_path,
r#"
[profiles.child]
profile = "base"
env = ["FROM_CHILD=1"]
"#,
)
.unwrap();
std::fs::write(
&base_path,
format!(
r#"
extra-config = "{}"
[profiles.base]
hardened = true
env = ["FROM_PARENT=1"]
"#,
extra_path.display()
),
)
.unwrap();
let file_config = FileConfig::load(&base_path).unwrap();
let args = Args {
profile: Some("child".into()),
..args_with_command()
};
let config = build(args, Some(file_config)).unwrap();
assert!(config.hardened);
assert_eq!(
config.env,
vec![
EnvEntry::Set("FROM_PARENT".into(), "1".into()),
EnvEntry::Set("FROM_CHILD".into(), "1".into()),
]
);
}
#[test] #[test]
fn build_conflicting_mode_errors() { fn build_conflicting_mode_errors() {
let file_config = FileConfig { let file_config = FileConfig {
@@ -700,7 +930,7 @@ fn build_env_accumulates_disjoint_keys() {
env: vec!["A=global".into(), "B".into()], env: vec!["A=global".into(), "B".into()],
..Options::default() ..Options::default()
}, },
profile: HashMap::from([( profiles: HashMap::from([(
"p".into(), "p".into(),
Options { Options {
env: vec!["C=profile".into()], env: vec!["C=profile".into()],
@@ -733,7 +963,7 @@ fn build_env_later_tier_overrides_earlier() {
env: vec!["A=global".into()], env: vec!["A=global".into()],
..Options::default() ..Options::default()
}, },
profile: HashMap::from([( profiles: HashMap::from([(
"p".into(), "p".into(),
Options { Options {
env: vec!["A=profile".into()], env: vec!["A=profile".into()],
@@ -818,7 +1048,7 @@ fn build_unsetenv_accumulates() {
unsetenv: vec!["G".into()], unsetenv: vec!["G".into()],
..Options::default() ..Options::default()
}, },
profile: HashMap::from([( profiles: HashMap::from([(
"p".into(), "p".into(),
Options { Options {
unsetenv: vec!["P".into()], unsetenv: vec!["P".into()],
@@ -843,7 +1073,7 @@ fn build_mask_accumulates() {
mask: vec![PathBuf::from("/tmp/a")], mask: vec![PathBuf::from("/tmp/a")],
..Options::default() ..Options::default()
}, },
profile: HashMap::from([( profiles: HashMap::from([(
"extra".into(), "extra".into(),
Options { Options {
mask: vec![PathBuf::from("/tmp/b")], mask: vec![PathBuf::from("/tmp/b")],
@@ -876,7 +1106,7 @@ fn unknown_option_rejected() {
#[test] #[test]
fn unknown_profile_option_rejected() { fn unknown_profile_option_rejected() {
let toml = r#" let toml = r#"
[profile.test] [profiles.test]
hardened = true hardened = true
frobnicate = 42 frobnicate = 42
"#; "#;
@@ -1081,7 +1311,7 @@ fn merge_options_lists_append_base_then_extra() {
#[test] #[test]
fn merge_file_config_profile_merged_by_name() { fn merge_file_config_profile_merged_by_name() {
let base = FileConfig { let base = FileConfig {
profile: HashMap::from([( profiles: HashMap::from([(
"claude".into(), "claude".into(),
Options { Options {
rw: vec!["/a".into()], rw: vec!["/a".into()],
@@ -1092,7 +1322,7 @@ fn merge_file_config_profile_merged_by_name() {
..FileConfig::default() ..FileConfig::default()
}; };
let extra = FileConfig { let extra = FileConfig {
profile: HashMap::from([ profiles: HashMap::from([
( (
"claude".into(), "claude".into(),
Options { Options {
@@ -1112,32 +1342,41 @@ fn merge_file_config_profile_merged_by_name() {
..FileConfig::default() ..FileConfig::default()
}; };
let merged = base.merge_with(extra); let merged = base.merge_with(extra);
assert_eq!(merged.profile["claude"].hardened, Some(true)); assert_eq!(merged.profiles["claude"].hardened, Some(true));
assert_eq!(merged.profile["claude"].rw, vec!["/a", "/b"]); assert_eq!(merged.profiles["claude"].rw, vec!["/a", "/b"]);
assert_eq!(merged.profile["codex"].unshare_net, Some(true)); assert_eq!(merged.profiles["codex"].unshare_net, Some(true));
} }
#[test] #[test]
fn merge_file_config_default_profile_extra_overrides() { fn merge_file_config_top_level_profile_extra_overrides() {
let base = FileConfig { let base = FileConfig {
default_profile: Some("claude".into()), options: Options {
profile: Some("claude".into()),
..Options::default()
},
..FileConfig::default() ..FileConfig::default()
}; };
let extra = FileConfig { let extra = FileConfig {
default_profile: Some("codex".into()), options: Options {
profile: Some("codex".into()),
..Options::default()
},
..FileConfig::default() ..FileConfig::default()
}; };
assert_eq!(base.merge_with(extra).default_profile, Some("codex".into())); assert_eq!(base.merge_with(extra).options.profile, Some("codex".into()));
} }
#[test] #[test]
fn merge_file_config_default_profile_extra_unset_inherits() { fn merge_file_config_top_level_profile_extra_unset_inherits() {
let base = FileConfig { let base = FileConfig {
default_profile: Some("claude".into()), options: Options {
profile: Some("claude".into()),
..Options::default()
},
..FileConfig::default() ..FileConfig::default()
}; };
assert_eq!( assert_eq!(
base.merge_with(FileConfig::default()).default_profile, base.merge_with(FileConfig::default()).options.profile,
Some("claude".into()) Some("claude".into())
); );
} }