返回 CodeWhale
host_profile.rs
根目录 / crates / tui / src / core / engine / host_profile.rs
1 //! Local narrowing for the existing Engine. It grants no authority.
2 use super::*;
3 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
4 pub(crate) enum EngineHostProfile {
5 #[default]
6 Normal,
7 Acp,
8 Rlm,
9 Child,
10 }
11 impl EngineHostProfile {
12 pub(crate) fn is_acp(self) -> bool {
13 self == Self::Acp
14 }
15 }
16 /// Additional local narrowing on one exact admitted control. Neither request
17 /// JSON nor persistent configuration can choose it. Inherit preserves the base
18 /// host's ceiling (including Child); Acp never replaces that base authority.
19 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
20 pub(crate) enum TurnNarrowing {
21 #[default]
22 Inherit,
23 Acp,
24 }
25 impl TurnNarrowing {
26 pub(crate) fn is_acp(self) -> bool {
27 self == Self::Acp
28 }
29 pub(crate) fn request_fingerprint(self, ordinary: String) -> String {
30 match self {
31 Self::Inherit => ordinary,
32 Self::Acp => crate::hashing::sha256_hex(format!(
33 "runtime-turn-narrowing\u{1f}acp\u{1f}{ordinary}"
34 )),
35 }
36 }
37 }
38 impl Engine {
39 pub(super) fn is_acp_turn(&self) -> bool {
40 self.host_profile.is_acp() || self.turn_narrowing.is_acp()
41 }
42
43 pub(super) fn acp_tool_build(
44 &self,
45 authority: &TurnAuthority,
46 route: &TurnRouteContext,
47 allowed: Option<Vec<String>>,
48 ) -> TurnToolBuild {
49 let mut context = self.build_tool_context_for_turn(authority, route);
50 let requested = self
51 .api_config
52 .sandbox_backend
53 .as_deref()
54 .is_some_and(|kind| !kind.trim().is_empty() && !kind.eq_ignore_ascii_case("none"));
55 let backend = crate::sandbox::backend::create_backend(&self.api_config)
56 .ok()
57 .flatten()
58 .filter(|backend| backend.kind() != crate::sandbox::backend::SandboxKind::Unsupported)
59 .map(Arc::from);
60 let shell = authority.allow_shell
61 && self.turn_acp_shell_ceiling.unwrap_or(authority.allow_shell)
62 && authority.mode != AppMode::Plan
63 && self.api_config.allow_shell()
64 && self.config.features.enabled(Feature::ShellTool)
65 && (!requested || backend.is_some());
66 context = context.with_shell_policy(if shell {
67 crate::worker_profile::ShellPolicy::Full
68 } else {
69 crate::worker_profile::ShellPolicy::None
70 });
71 if let Some(backend) = backend {
72 context = context.with_sandbox_backend(backend);
73 }
74 let mut builder = ToolRegistryBuilder::new()
75 .with_file_tools()
76 .with_search_tools()
77 .with_git_tools();
78 if self.config.features.enabled(Feature::ApplyPatch) {
79 builder = builder.with_patch_tools();
80 }
81 if shell {
82 builder = builder.with_foreground_shell_tools();
83 }
84 let mut registry = builder.build(context);
85 if let Some(overrides) = self
86 .api_config
87 .tools
88 .as_ref()
89 .and_then(|tools| tools.overrides.as_ref())
90 {
91 for name in overrides.keys() {
92 remove_acp_overridden_builtin(&mut registry, name);
93 }
94 }
95 let allowed = Some(
96 registry
97 .names()
98 .into_iter()
99 .filter(|name| tool_catalog::tool_allowed(allowed.as_deref(), name))
100 .map(str::to_string)
101 .collect(),
102 );
103 let catalog = self.child_host.as_ref().map_or_else(
104 || registry.to_api_tools_with_cache(true),
105 |child| {
106 child
107 .authority
108 .tools_for_model(&registry, &child.authority.agent_type)
109 },
110 );
111 let always_load = catalog.iter().map(|tool| tool.name.clone()).collect();
112 TurnToolBuild {
113 surface: ToolSurfacePolicy::new(
114 registry,
115 Some(catalog),
116 authority.mode,
117 &always_load,
118 &[],
119 false,
120 allowed,
121 self.config.disallowed_tools.clone(),
122 self.config.max_tool_calls,
123 tool_catalog::ToolMode::Direct,
124 ),
125 mcp_tool_names: Vec::new(),
126 mcp: McpToolState::Disabled,
127 subagent_runtime_model: None,
128 mailbox: None,
129 plugin_tool_names: HashSet::new(),
130 }
131 }
132 }
133 fn remove_acp_overridden_builtin(registry: &mut crate::tools::ToolRegistry, tool_name: &str) {
134 let aliases: &[&str] = match tool_name {
135 "bash" | "Bash" | "exec_shell" => &["bash", "Bash", "exec_shell"],
136 "read" | "write" | "edit" | "File" | "read_file" | "write_file" | "edit_file" => &[
137 "read",
138 "write",
139 "edit",
140 "File",
141 "read_file",
142 "write_file",
143 "edit_file",
144 ],
145 "apply_patch" => &["apply_patch"],
146 _ => std::slice::from_ref(&tool_name),
147 };
148 for alias in aliases {
149 registry.remove_tool(alias);
150 }
151 }
152
153 #[cfg(test)]
154 mod tests {
155 use super::*;
156 fn fixture(workspace: &std::path::Path) -> (Engine, EngineHandle, TurnRouteContext) {
157 let api = Config {
158 allow_shell: Some(true),
159 ..Config::default()
160 }
161 .with_legacy_root(Some("local-acp-profile-fixture".into()), None);
162 let (mut engine, handle) = Engine::new(
163 EngineConfig {
164 workspace: workspace.to_path_buf(),
165 snapshots_enabled: false,
166 memory_enabled: false,
167 subagents_enabled: false,
168 ..Default::default()
169 },
170 &api,
171 );
172 engine.config.features.disable(Feature::Mcp);
173 engine.host_profile = EngineHostProfile::Acp;
174 let route = TurnRouteContext {
175 provider: crate::config::ProviderKind::Deepseek,
176 model: DEFAULT_TEXT_MODEL.into(),
177 capabilities: Default::default(),
178 limits: None,
179 client: engine.codewhale_client.clone(),
180 api_config: Box::new(api),
181 locale_tag: engine.config.locale_tag.clone(),
182 role_models: HashMap::new(),
183 auto_model: false,
184 reasoning_effort: None,
185 reasoning_effort_auto: false,
186 };
187 (engine, handle, route)
188 }
189 fn authority(mode: AppMode, shell: bool) -> TurnAuthority {
190 TurnAuthority::from_effective_fields(mode, shell, false, false, ApprovalMode::Suggest)
191 }
192 #[tokio::test(flavor = "current_thread")]
193 async fn acp_catalog_and_final_authority_exclude_hidden_runtime_lifecycles() {
194 let dir = tempfile::tempdir().unwrap();
195 let _home = crate::test_support::SealedHome::at(dir.path());
196 let (engine, _handle, route) = fixture(dir.path());
197 let build = engine.acp_tool_build(&authority(AppMode::Agent, false), &route, None);
198 assert!(build.surface.registry.get("read").is_some());
199 for name in [
200 "bash",
201 "Bash",
202 "exec_shell",
203 "exec_shell_interact",
204 "code_execution",
205 "js_execution",
206 "execute_tools",
207 "tool_search",
208 "Task",
209 "request_user_input",
210 "rlm",
211 ] {
212 assert!(build.surface.registry.get(name).is_none(), "{name}");
213 assert!(
214 !build.surface.catalog.iter().any(|tool| tool.name == name),
215 "{name}"
216 );
217 }
218 let unrelated = crate::tools::ToolRegistryBuilder::new()
219 .with_foreground_shell_tools()
220 .build(build.surface.registry.context().clone());
221 let err = unrelated
222 .execute_full("bash", serde_json::json!({"command":"printf forbidden"}))
223 .await
224 .unwrap_err();
225 assert!(matches!(err, ToolError::PermissionDenied { .. }));
226 }
227 #[tokio::test(flavor = "current_thread")]
228 async fn acp_final_cap_rejects_stateful_aliases_and_plan_writes() {
229 let dir = tempfile::tempdir().unwrap();
230 let _home = crate::test_support::SealedHome::at(dir.path());
231 let (engine, _handle, route) = fixture(dir.path());
232 let build = engine.acp_tool_build(&authority(AppMode::Agent, true), &route, None);
233 for input in [
234 serde_json::json!({"command":"sleep 5 &"}),
235 serde_json::json!({"command":"nohup true"}),
236 serde_json::json!({"command":"printf x","persist":true}),
237 serde_json::json!({"command":"printf x","interactive":true}),
238 serde_json::json!({"action":"start","command":"printf x"}),
239 ] {
240 assert!(
241 build
242 .surface
243 .registry
244 .execute_full("Bash", input)
245 .await
246 .is_err()
247 );
248 }
249 let plan = engine.acp_tool_build(&authority(AppMode::Plan, true), &route, None);
250 assert!(
251 plan.surface
252 .registry
253 .execute_full("write", serde_json::json!({"path":"no.txt","content":"x"}))
254 .await
255 .is_err()
256 );
257 assert!(!dir.path().join("no.txt").exists());
258 for command in [
259 "printf 'a&b'",
260 "printf a\\&b",
261 "true && true",
262 "printf x >&2",
263 ] {
264 assert!(
265 !crate::tools::shell::foreground_command_requests_detach(command),
266 "{command}"
267 );
268 }
269 }
270 #[tokio::test(flavor = "current_thread")]
271 async fn acp_override_removes_all_file_and_shell_compatibility_aliases() {
272 let dir = tempfile::tempdir().unwrap();
273 let _home = crate::test_support::SealedHome::at(dir.path());
274 let (mut engine, _handle, route) = fixture(dir.path());
275 engine.api_config.tools = Some(crate::config::ToolsConfig {
276 overrides: Some(HashMap::from([
277 ("write".into(), crate::config::ToolOverride::Disabled),
278 (
279 "exec_shell".into(),
280 crate::config::ToolOverride::Command {
281 command: "must-never-launch".into(),
282 args: None,
283 },
284 ),
285 ])),
286 ..Default::default()
287 });
288 let build = engine.acp_tool_build(&authority(AppMode::Agent, true), &route, None);
289 for name in [
290 "read",
291 "write",
292 "edit",
293 "File",
294 "read_file",
295 "write_file",
296 "edit_file",
297 "bash",
298 "Bash",
299 "exec_shell",
300 ] {
301 assert!(build.surface.registry.get(name).is_none(), "{name}");
302 }
303 }
304 #[tokio::test(flavor = "current_thread")]
305 async fn ordinary_engine_context_does_not_inherit_acp_narrowing_or_observations() {
306 let dir = tempfile::tempdir().unwrap();
307 let _home = crate::test_support::SealedHome::at(dir.path());
308 let (mut engine, _handle, route) = fixture(dir.path());
309 engine.host_profile = EngineHostProfile::Normal;
310 assert_eq!(
311 engine
312 .build_tool_context_for_turn(&authority(AppMode::Agent, true), &route)
313 .acp_host,
314 None
315 );
316 engine.host_profile = EngineHostProfile::Acp;
317 assert_eq!(
318 engine
319 .build_tool_context_for_turn(&authority(AppMode::Plan, true), &route)
320 .acp_host,
321 Some(AppMode::Plan)
322 );
323 assert!(matches!(
324 engine.goal_continuation_if_active(),
325 GoalContinuationAction::Inactive
326 ));
327 }
328 #[tokio::test(flavor = "current_thread")]
329 async fn acp_catalogs_keep_concurrent_workspaces_and_foreground_execution_independent() {
330 let dir = tempfile::tempdir().unwrap();
331 let _home = crate::test_support::SealedHome::at(dir.path());
332 let a = dir.path().join("a");
333 let b = dir.path().join("b");
334 std::fs::create_dir(&a).unwrap();
335 std::fs::create_dir(&b).unwrap();
336 std::fs::write(a.join("f.txt"), "workspace-a").unwrap();
337 std::fs::write(b.join("f.txt"), "workspace-b").unwrap();
338 let (ea, _ha, ra) = fixture(&a);
339 let (eb, _hb, rb) = fixture(&b);
340 let ba = ea.acp_tool_build(&authority(AppMode::Agent, true), &ra, None);
341 let bb = eb.acp_tool_build(&authority(AppMode::Agent, false), &rb, None);
342 let (oa, ob) = tokio::join!(
343 ba.surface
344 .registry
345 .execute_full("read", serde_json::json!({"path":"f.txt"})),
346 bb.surface
347 .registry
348 .execute_full("read", serde_json::json!({"path":"f.txt"}))
349 );
350 assert!(oa.unwrap().content.contains("workspace-a"));
351 assert!(ob.unwrap().content.contains("workspace-b"));
352 assert!(ba.surface.registry.get("bash").is_some());
353 assert!(bb.surface.registry.get("bash").is_none());
354 let output = ba
355 .surface
356 .registry
357 .execute_full(
358 "bash",
359 serde_json::json!({"command":"echo acp-foreground-marker"}),
360 )
361 .await
362 .unwrap();
363 assert!(output.content.contains("acp-foreground-marker"));
364 }
365 #[tokio::test(flavor = "current_thread")]
366 async fn requested_unavailable_external_sandbox_removes_acp_shell_without_fallback() {
367 let dir = tempfile::tempdir().unwrap();
368 let _home = crate::test_support::SealedHome::at(dir.path());
369 let (mut engine, _handle, route) = fixture(dir.path());
370 engine.api_config.sandbox_backend = Some("unsupported-fixture-backend".into());
371 let build = engine.acp_tool_build(&authority(AppMode::Agent, true), &route, None);
372 assert!(build.surface.registry.get("bash").is_none());
373 assert!(build.surface.registry.get("Bash").is_none());
374 assert_eq!(
375 build.surface.registry.context().shell_policy,
376 crate::worker_profile::ShellPolicy::None
377 );
378 }
379 #[test]
380 fn narrowed_request_fingerprint_keeps_ordinary_historical_bytes_and_separates_acp() {
381 let historical = crate::hashing::sha256_hex("ordinary canonical payload");
382 assert_eq!(
383 TurnNarrowing::Inherit.request_fingerprint(historical.clone()),
384 historical
385 );
386 let narrowed = TurnNarrowing::Acp.request_fingerprint(historical.clone());
387 assert_ne!(narrowed, historical);
388 assert_eq!(narrowed, TurnNarrowing::Acp.request_fingerprint(historical));
389 }
390 #[tokio::test(flavor = "current_thread")]
391 async fn effective_acp_catalog_keeps_normal_base_and_initial_shell_ceiling() {
392 let dir = tempfile::tempdir().unwrap();
393 let _home = crate::test_support::SealedHome::at(dir.path());
394 let (mut engine, _handle, route) = fixture(dir.path());
395 engine.host_profile = EngineHostProfile::Normal;
396 engine.config.max_steps = 97;
397 engine.config.goal_max_steps = Some(211);
398 engine.config.subagents_enabled = true;
399 engine.turn_narrowing = TurnNarrowing::Acp;
400 engine.turn_acp_shell_ceiling = Some(false);
401 let build = engine.acp_tool_build(&authority(AppMode::Agent, true), &route, None);
402 assert!(
403 build.surface.registry.get("bash").is_none(),
404 "later ordinary authority cannot raise the ACP client's initial terminal ceiling"
405 );
406 assert_eq!(engine.host_profile, EngineHostProfile::Normal);
407 assert_eq!(engine.config.max_steps, 97);
408 assert_eq!(engine.config.goal_max_steps, Some(211));
409 assert!(engine.config.subagents_enabled);
410 assert_eq!(
411 engine
412 .build_tool_context_for_turn(&authority(AppMode::Agent, true), &route)
413 .acp_host,
414 Some(AppMode::Agent)
415 );
416 engine.turn_narrowing = TurnNarrowing::Inherit;
417 engine.turn_acp_shell_ceiling = None;
418 assert_eq!(
419 engine
420 .build_tool_context_for_turn(&authority(AppMode::Agent, true), &route)
421 .acp_host,
422 None
423 );
424 }
425 #[tokio::test(flavor = "current_thread")]
426 async fn acp_defers_normal_boot_queue_without_losing_its_generation() {
427 let dir = tempfile::tempdir().unwrap();
428 let _home = crate::test_support::SealedHome::at(dir.path());
429 let (mut engine, _handle, route) = fixture(dir.path());
430 engine.host_profile = EngineHostProfile::Normal;
431 engine.turn_narrowing = TurnNarrowing::Acp;
432 engine.mcp_boot_generation = Some(1);
433 engine.mcp_boot_in_flight = true;
434 engine.session.pending_prefix_change_reason = None;
435 let (tx, rx) = tokio::sync::mpsc::channel(1);
436 engine.mcp_boot_rx = Some(rx);
437 tx.try_send(McpBootUpdate::Progress {
438 generation: 1,
439 authority_errors: Arc::new(HashMap::new()),
440 connection_errors: HashMap::new(),
441 connecting: vec!["ordinary-pending".into()],
442 })
443 .unwrap();
444 let build = engine.acp_tool_build(&authority(AppMode::Agent, false), &route, None);
445 let mut catalog = build.surface.catalog.clone();
446 let mut names = catalog.iter().map(|tool| tool.name.clone()).collect();
447 engine
448 .refresh_boot_mcp_catalog(&build.surface, &mut catalog, &mut names)
449 .await;
450 assert_eq!(engine.mcp_boot_rx.as_ref().unwrap().len(), 1);
451 assert!(engine.session.pending_prefix_change_reason.is_none());
452 engine.turn_narrowing = TurnNarrowing::Inherit;
453 engine
454 .refresh_boot_mcp_catalog(&build.surface, &mut catalog, &mut names)
455 .await;
456 assert_eq!(engine.mcp_boot_rx.as_ref().unwrap().len(), 0);
457 assert_eq!(
458 engine.session.pending_prefix_change_reason.as_deref(),
459 Some("mcp-session-boot")
460 );
461 }
462 #[tokio::test(flavor = "current_thread")]
463 async fn real_engine_queued_normal_acp_normal_controls_keep_exact_fifo_profile() {
464 use crate::llm_client::mock::{MockLlmClient, canned};
465 let dir = tempfile::tempdir().unwrap();
466 let _home = crate::test_support::SealedHome::at(dir.path());
467 let api = Config::default().with_legacy_root(Some("local-acp-queue-fixture".into()), None);
468 let model = Arc::new(MockLlmClient::new(vec![
469 canned::simple_text_turn("ordinary first"),
470 canned::simple_text_turn("ACP middle"),
471 canned::simple_text_turn("ordinary successor"),
472 ]));
473 let (mut engine, handle) = Engine::new_with_model_client(
474 EngineConfig {
475 workspace: dir.path().to_path_buf(),
476 snapshots_enabled: false,
477 memory_enabled: false,
478 max_steps: 97,
479 goal_max_steps: Some(211),
480 subagents_enabled: true,
481 ..Default::default()
482 },
483 &api,
484 model.clone(),
485 );
486 engine.config.features.disable(Feature::Mcp);
487 let message = |content: &str| {
488 Op::SendMessage(TurnSpec {
489 max_output_tokens: None,
490 content: content.into(),
491 images: Vec::new(),
492 mode: AppMode::Agent,
493 route: Box::new(
494 resolve_runtime_route_for_identity(
495 &api,
496 &api.active_provider_identity().unwrap(),
497 Some(DEFAULT_TEXT_MODEL),
498 )
499 .unwrap(),
500 ),
501 compaction: Box::new(CompactionConfig::default()),
502 initial_routed_usage: Box::default(),
503 goal_objective: None,
504 goal_token_budget: None,
505 goal_status: GoalStatus::Paused,
506 reasoning_effort: None,
507 reasoning_effort_auto: false,
508 auto_model: false,
509 allow_shell: false,
510 trust_mode: false,
511 auto_approve: false,
512 approval_mode: ApprovalMode::Suggest,
513 translation_enabled: false,
514 allowed_tools: None,
515 dynamic_tools: Vec::new(),
516 hook_executor: None,
517 verbosity: None,
518 provenance: UserInputProvenance::ExternalUser,
519 submission_id: None,
520 })
521 };
522 handle.send(message("ordinary first")).await.unwrap();
523 handle.send_reserved_acp_op(
524 handle.tx_op.clone().try_reserve_owned().unwrap(),
525 message("ACP middle"),
526 );
527 handle.send(message("ordinary successor")).await.unwrap();
528 assert_eq!(engine.host_profile, EngineHostProfile::Normal);
529 assert!(
530 !engine.is_acp_turn(),
531 "a queued ACP operation must not narrow the current owner"
532 );
533 let worker = tokio::spawn(Box::pin(engine.run()));
534 let mut declared_tool_changes = Vec::new();
535 tokio::time::timeout(Duration::from_secs(30), async {
536 let mut completed = 0;
537 while completed != 3 {
538 let event = handle
539 .rx_event
540 .write()
541 .await
542 .recv()
543 .await
544 .expect("Engine event");
545 match event {
546 Event::PrefixCacheChange {
547 changed: true,
548 tools_changed: true,
549 pin_reason,
550 ..
551 } => declared_tool_changes.push(pin_reason),
552 Event::TurnComplete { .. } => completed += 1,
553 _ => {}
554 }
555 }
556 })
557 .await
558 .expect("three actual queued turns");
559 assert_eq!(
560 declared_tool_changes,
561 ["change:tool_surface", "change:tool_surface"],
562 "both admitted profile transitions must have attributed prefix changes"
563 );
564 let requests = model.captured_requests();
565 assert_eq!(requests.len(), 3);
566 assert_ne!(requests[0].tools, requests[1].tools);
567 assert_eq!(
568 requests[0].tools, requests[2].tools,
569 "the exact queued ACP control must not narrow either ordinary neighbour"
570 );
571 handle.send(Op::Shutdown).await.unwrap();
572 tokio::time::timeout(Duration::from_secs(5), worker)
573 .await
574 .unwrap()
575 .unwrap();
576 }
577 }
578
578 lines RUST