返回 CodeWhale
roster_shadow_and_trust.rs
根目录 / crates / tui / src / fleet / tests / roster_shadow_and_trust.rs
1 use super::*;
2 use tempfile::TempDir;
3
4 fn write_profile(dir: &Path, filename: &str, contents: &str) {
5 std::fs::create_dir_all(dir).unwrap();
6 std::fs::write(dir.join(filename), contents).unwrap();
7 }
8
9 #[test]
10 fn workspace_shadow_of_personal_file_is_recorded_and_reported() {
11 // #5098: editing the personal builder.toml changed nothing because a
12 // project copy silently shadowed it. The roster must report that the
13 // shadowed personal file exists and is ignored.
14 let tmp = TempDir::new().unwrap();
15 let personal_dir = tmp.path().join("personal");
16 let workspace = tmp.path().join("workspace");
17 std::fs::create_dir_all(&workspace).unwrap();
18 write_profile(
19 &personal_dir,
20 "builder.toml",
21 "id = \"builder\"\nrole_hint = \"builder\"\nmodel = \"deepseek-v4-flash\"\n",
22 );
23 write_profile(
24 &workspace.join(".codewhale").join("agents"),
25 "builder.toml",
26 "id = \"builder\"\nrole_hint = \"builder\"\nmodel = \"deepseek-v4-pro\"\n",
27 );
28
29 let roster = FleetRoster::load_with_personal_dir(
30 &FleetConfigToml::default(),
31 &workspace,
32 Some(&personal_dir),
33 true,
34 );
35
36 let builder = roster.get("builder").expect("builder member");
37 assert_eq!(builder.origin, ProfileOrigin::Workspace);
38 let shadows: Vec<_> = roster.shadowed_for("builder").collect();
39 // The chain is built-in → personal → workspace; both displacements
40 // are recorded, and the file-on-file one names the ignored personal
41 // copy explicitly.
42 assert_eq!(shadows.len(), 2, "full shadow chain: {shadows:?}");
43 let shadow = shadows
44 .iter()
45 .find(|shadow| shadow.shadowed_origin == ProfileOrigin::Personal)
46 .expect("personal file shadow is recorded");
47 assert!(shadow.shadowed_source.ends_with("builder.toml"));
48 assert_eq!(shadow.winner_origin, ProfileOrigin::Workspace);
49 assert!(
50 shadows
51 .iter()
52 .any(|shadow| shadow.shadowed_origin == ProfileOrigin::BuiltIn),
53 "the built-in displacement is recorded too: {shadows:?}"
54 );
55 assert!(
56 roster.shadowed().iter().any(|s| s.id == "builder"),
57 "roster-level shadow log carries the record"
58 );
59
60 // The merged structure the UI and doctor read: every layer for the id,
61 // with the project copy marked as the winner.
62 let layers = roster.layers_for("builder");
63 assert_eq!(layers.len(), 3, "built-in + personal + project: {layers:?}");
64 assert!(layers[0].wins, "winner is first: {layers:?}");
65 assert_eq!(layers[0].origin, ProfileOrigin::Workspace);
66 assert!(
67 layers[0].source.ends_with("builder.toml"),
68 "winning source is the project file: {:?}",
69 layers[0].source
70 );
71 assert!(
72 layers
73 .iter()
74 .any(|layer| layer.origin == ProfileOrigin::Personal && !layer.wins),
75 "personal layer is present and ignored: {layers:?}"
76 );
77 assert!(
78 layers
79 .iter()
80 .any(|layer| layer.origin == ProfileOrigin::BuiltIn && !layer.wins),
81 "built-in layer is present and ignored: {layers:?}"
82 );
83
84 let report = roster.multi_layer_report();
85 let builder = report
86 .iter()
87 .find(|entry| entry.id == "builder")
88 .expect("multi-layer report names builder");
89 assert_eq!(builder.effective, ProfileOrigin::Workspace);
90 assert_eq!(builder.layers, layers);
91
92 let doctor = roster.doctor_layer_lines().join("\n");
93 assert!(
94 doctor.contains("builder: effective=project"),
95 "doctor names the winning layer: {doctor}"
96 );
97 assert!(
98 doctor.contains("personal ·") && doctor.contains("(ignored)"),
99 "doctor lists the ignored personal path: {doctor}"
100 );
101 assert!(
102 doctor.contains("project ·") && doctor.contains("(wins)"),
103 "doctor marks the project path as winning: {doctor}"
104 );
105 }
106
107 #[test]
108 fn project_scope_profiles_are_skipped_when_the_layer_is_not_trusted() {
109 // #5098: `load_workspace_agent_profiles_tolerant` applied no trust
110 // check — a cloned repo's .codewhale/agents/*.toml silently joined
111 // the dispatch roster. With project config disabled
112 // (`--no-project-config`), the whole layer stays out.
113 let tmp = TempDir::new().unwrap();
114 let workspace = tmp.path().join("workspace");
115 write_profile(
116 &workspace.join(".codewhale").join("agents"),
117 "builder.toml",
118 "id = \"builder\"\nrole_hint = \"builder\"\nmodel = \"gpt-5.6-luna\"\n",
119 );
120
121 let gated =
122 FleetRoster::load_with_personal_dir(&FleetConfigToml::default(), &workspace, None, false);
123 let builder = gated.get("builder").expect("built-in builder remains");
124 assert_eq!(
125 builder.origin,
126 ProfileOrigin::BuiltIn,
127 "untrusted project profile must not join the roster"
128 );
129 assert_ne!(
130 builder.profile.model.as_deref(),
131 Some("gpt-5.6-luna"),
132 "foreign project pin must not reach dispatch"
133 );
134
135 let trusted =
136 FleetRoster::load_with_personal_dir(&FleetConfigToml::default(), &workspace, None, true);
137 assert_eq!(
138 trusted.get("builder").expect("builder").origin,
139 ProfileOrigin::Workspace,
140 "trusted project profile wins as before"
141 );
142 }
143
143 lines RUST