From 594ece771c3314c2b47ea3e8ec54fe38d8c5a3cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabricio=20Gon=C3=A7alves=20da=20Silva?= Date: Tue, 11 Aug 2026 17:38:45 +0200 Subject: [PATCH 1/2] test(agentic-core): repair baseline runtime contracts --- src/apps/server/src/main.rs | 22 +++++++++++-------- src/crates/services/terminal/src/exec.rs | 27 +++++++++++++++++++++--- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/src/apps/server/src/main.rs b/src/apps/server/src/main.rs index 34b36dcc6..b178eadee 100644 --- a/src/apps/server/src/main.rs +++ b/src/apps/server/src/main.rs @@ -159,10 +159,10 @@ async fn main() -> Result<()> { }) .collect::>>()?; - // This is a narrow controller/observer capability. It deliberately does - // not initialize the Server Host's dormant Agent Runtime: authoritative - // sessions and execution stay inside the target-side `bitfun dispatch` - // worker. + // This dispatch state is a narrow controller/observer capability. It does + // not create a second Agent Runtime: browser WebSocket sessions use the + // shared in-process runtime initialized above, while detached dispatch + // execution stays inside the target-side `bitfun dispatch` worker. let path_manager = Arc::new(bitfun_core::infrastructure::PathManager::new()?); let ssh_data_dir = dirs::data_local_dir() .ok_or_else(|| anyhow::anyhow!("Could not resolve the local data directory"))? @@ -267,7 +267,7 @@ mod tests { } #[test] - fn agent_bootstrap_reuses_core_ownership_without_activating_the_http_shell() { + fn agent_bootstrap_reuses_core_ownership_without_creating_a_parallel_runtime() { let bootstrap = include_str!("bootstrap.rs"); assert!(bootstrap.contains("CoreRuntimeOwnership::embedded")); let coordinator = bootstrap @@ -292,12 +292,16 @@ mod tests { .next() .expect("Server production entrypoint"); assert!( - !main_source.contains("bootstrap::initialize"), - "the current read-only HTTP shell must not silently start an Agent Runtime" + main_source.contains("let server_state = bootstrap::initialize("), + "the Server Host must initialize the shared Agent Runtime used by WebSocket app-server sessions" ); assert!( - main_source.contains("DispatchHostState"), - "the lightweight Server Host should expose dispatch without booting an Agent Runtime" + main_source.contains("let bitfun_app_server = app_server::build("), + "the Server Host must build the in-process app-server from the initialized runtime" + ); + assert!( + main_source.contains(".layer(axum::Extension(bitfun_app_server))"), + "the Server Host must expose the initialized app-server to WebSocket routes" ); } } diff --git a/src/crates/services/terminal/src/exec.rs b/src/crates/services/terminal/src/exec.rs index c54b10598..4173933b8 100644 --- a/src/crates/services/terminal/src/exec.rs +++ b/src/crates/services/terminal/src/exec.rs @@ -1946,6 +1946,7 @@ mod tests { #[tokio::test] async fn delayed_poll_returns_unread_output_after_process_exit() { let manager = ExecProcessManager::default(); + let (lifecycle_tx, mut lifecycle_rx) = tokio::sync::mpsc::unbounded_channel(); #[cfg(windows)] let script = "echo first & powershell -NoProfile -Command \"Start-Sleep -Milliseconds 250\" & echo second"; #[cfg(not(windows))] @@ -1959,7 +1960,7 @@ mod tests { tty: false, yield_time_ms: Some(100), max_output_chars: Some(10_000), - lifecycle_tx: None, + lifecycle_tx: Some(lifecycle_tx), output_capture_tx: None, }) .await @@ -1970,7 +1971,24 @@ mod tests { .expect("process should still be running after first yield"); assert!(first.output.contains("first")); - tokio::time::sleep(std::time::Duration::from_millis(600)).await; + let running = tokio::time::timeout(std::time::Duration::from_secs(2), lifecycle_rx.recv()) + .await + .expect("running lifecycle event should arrive") + .expect("lifecycle channel should stay open"); + assert_eq!(running.session_id, session_id); + assert_eq!(running.status, ExecProcessLifecycleStatus::Running); + + let exited = tokio::time::timeout(std::time::Duration::from_secs(5), lifecycle_rx.recv()) + .await + .expect("exit lifecycle event should arrive") + .expect("lifecycle channel should stay open until exit"); + assert_eq!(exited.session_id, session_id); + assert_eq!(exited.status, ExecProcessLifecycleStatus::Exited); + assert_eq!( + exited.exit_code, + Some(0), + "a naturally exited process must retain its real exit code" + ); let second = manager .write_stdin(WriteStdinRequest { @@ -1983,8 +2001,11 @@ mod tests { .await .expect("poll should return unread output"); + assert!( + second.session_id.is_none(), + "the lifecycle exit event must not be published before the session is closed" + ); assert_eq!(second.exit_code, Some(0)); - assert!(second.session_id.is_none()); assert!(second.output.contains("second")); } From a45286c8ef8c967213c074cbe0ef95a976632d0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabricio=20Gon=C3=A7alves=20da=20Silva?= <245921561+livefgs-ux@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:33:01 +0200 Subject: [PATCH 2/2] feat(agentic-core): add shadow decision port contract --- .../src/agentic_decision_port.rs | 223 ++++++++++++++++++ src/crates/contracts/runtime-ports/src/lib.rs | 7 + .../tests/agentic_decision_port_contracts.rs | 139 +++++++++++ 3 files changed, 369 insertions(+) create mode 100644 src/crates/contracts/runtime-ports/src/agentic_decision_port.rs create mode 100644 src/crates/contracts/runtime-ports/tests/agentic_decision_port_contracts.rs diff --git a/src/crates/contracts/runtime-ports/src/agentic_decision_port.rs b/src/crates/contracts/runtime-ports/src/agentic_decision_port.rs new file mode 100644 index 000000000..922f5182a --- /dev/null +++ b/src/crates/contracts/runtime-ports/src/agentic_decision_port.rs @@ -0,0 +1,223 @@ +//! Shadow-only Agentic Core decision boundary. +//! +//! These DTOs let BitFun compare a future Agentic Core decision with existing +//! runtime ownership without adding a second executor, provider, permission +//! manager, session store, or UI truth source. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgenticDecisionSurface { + Desktop, + AppServer, + Cli, + PeerHost, + SdkHost, + Cron, + DispatchWorker, + DispatchController, + ToolApi, + McpApp, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgenticDecisionTargetKind { + SessionTurn, + ToolCall, + McpCall, + ProviderCall, + Job, + HostAdmin, + ReadOnly, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgenticDecisionPolicyResult { + Allow, + Deny, + ApprovalRequired, + NotEvaluated, + Conflict, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgenticDecisionIntent { + Execute, + Delegate, + Ask, + Pause, + Deny, + Resume, + Close, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgenticDecisionRouteClass { + GovernedRuntime, + SurfaceAdmin, + ReadOnly, + DirectGap, + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AgenticDecisionLineage { + pub decision_id: String, + pub core_run_id: String, + pub task_id: String, + pub action_id: String, + pub correlation_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub idempotency_key: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AgenticDecisionPortRequest { + pub lineage: AgenticDecisionLineage, + pub surface: AgenticDecisionSurface, + pub target_kind: AgenticDecisionTargetKind, + pub route: String, + pub intent: AgenticDecisionIntent, + pub policy_result: AgenticDecisionPolicyResult, + pub bitfun_policy_available: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval_id: Option, + #[serde(default)] + pub evidence_refs: Vec, + pub execute_physical: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgenticDecisionShadowStatus { + ShadowAllowed, + ShadowDenied, + ShadowConflict, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AgenticDecisionPortProjection { + pub adapter_version: String, + pub lineage: AgenticDecisionLineage, + pub surface: AgenticDecisionSurface, + pub target_kind: AgenticDecisionTargetKind, + pub route: String, + pub route_class: AgenticDecisionRouteClass, + pub intent: AgenticDecisionIntent, + pub policy_result: AgenticDecisionPolicyResult, + pub status: AgenticDecisionShadowStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub denial_reason: Option, + #[serde(default)] + pub evidence_refs: Vec, + pub execute_physical: bool, +} + +pub const AGENTIC_DECISION_PORT_ADAPTER_VERSION: &str = "bitfun-agentic-decision-port.v1"; + +impl AgenticDecisionPortRequest { + #[must_use] + pub fn project_shadow(self) -> AgenticDecisionPortProjection { + let route_class = classify_agentic_decision_route(self.surface, self.route.as_str()); + let denial_reason = self.shadow_denial_reason(route_class); + let status = match denial_reason.as_deref() { + Some("policy_conflict") => AgenticDecisionShadowStatus::ShadowConflict, + Some(_) => AgenticDecisionShadowStatus::ShadowDenied, + None => AgenticDecisionShadowStatus::ShadowAllowed, + }; + + AgenticDecisionPortProjection { + adapter_version: AGENTIC_DECISION_PORT_ADAPTER_VERSION.to_string(), + lineage: self.lineage, + surface: self.surface, + target_kind: self.target_kind, + route: self.route, + route_class, + intent: self.intent, + policy_result: self.policy_result, + status, + denial_reason, + evidence_refs: self.evidence_refs, + execute_physical: false, + } + } + + fn shadow_denial_reason(&self, route_class: AgenticDecisionRouteClass) -> Option { + if self.execute_physical { + return Some("shadow_mode_forbids_physical_execution".to_string()); + } + if route_class == AgenticDecisionRouteClass::DirectGap { + return Some(format!("route_gap:{}", self.route)); + } + if route_class == AgenticDecisionRouteClass::Unknown { + return Some(format!("unknown_route_owner:{}", self.route)); + } + if matches!(self.policy_result, AgenticDecisionPolicyResult::Conflict) { + return Some("policy_conflict".to_string()); + } + if matches!(self.intent, AgenticDecisionIntent::Execute) && !self.bitfun_policy_available { + return Some("bitfun_policy_unavailable".to_string()); + } + if matches!( + self.policy_result, + AgenticDecisionPolicyResult::ApprovalRequired + ) && self.approval_id.is_none() + { + return Some("approval_required_without_approval_id".to_string()); + } + if matches!( + self.target_kind, + AgenticDecisionTargetKind::SessionTurn + | AgenticDecisionTargetKind::ToolCall + | AgenticDecisionTargetKind::McpCall + ) && (self.lineage.session_id.is_none() || self.lineage.turn_id.is_none()) + { + return Some("missing_session_turn_lineage".to_string()); + } + None + } +} + +#[must_use] +pub fn classify_agentic_decision_route( + surface: AgenticDecisionSurface, + route: &str, +) -> AgenticDecisionRouteClass { + match (surface, route) { + (AgenticDecisionSurface::ToolApi, "execute_tool") => AgenticDecisionRouteClass::DirectGap, + (AgenticDecisionSurface::McpApp, "send_mcp_app_message.tools_call") => { + AgenticDecisionRouteClass::DirectGap + } + (AgenticDecisionSurface::McpApp, "send_mcp_app_message.resources_read" | "ping") => { + AgenticDecisionRouteClass::ReadOnly + } + (AgenticDecisionSurface::DispatchController, _) => { + AgenticDecisionRouteClass::SurfaceAdmin + } + ( + AgenticDecisionSurface::Desktop + | AgenticDecisionSurface::AppServer + | AgenticDecisionSurface::Cli + | AgenticDecisionSurface::PeerHost + | AgenticDecisionSurface::SdkHost + | AgenticDecisionSurface::Cron + | AgenticDecisionSurface::DispatchWorker, + _, + ) => AgenticDecisionRouteClass::GovernedRuntime, + _ => AgenticDecisionRouteClass::Unknown, + } +} diff --git a/src/crates/contracts/runtime-ports/src/lib.rs b/src/crates/contracts/runtime-ports/src/lib.rs index 28c0c5b00..7541f2b88 100644 --- a/src/crates/contracts/runtime-ports/src/lib.rs +++ b/src/crates/contracts/runtime-ports/src/lib.rs @@ -18,10 +18,17 @@ pub use bitfun_core_types::{ }; mod local_workspace_snapshot; +mod agentic_decision_port; #[cfg(feature = "permission")] mod permission; mod plugin; mod script_tool; +pub use agentic_decision_port::{ + classify_agentic_decision_route, AgenticDecisionIntent, AgenticDecisionLineage, + AgenticDecisionPolicyResult, AgenticDecisionPortProjection, AgenticDecisionPortRequest, + AgenticDecisionRouteClass, AgenticDecisionShadowStatus, AgenticDecisionSurface, + AgenticDecisionTargetKind, AGENTIC_DECISION_PORT_ADAPTER_VERSION, +}; #[cfg(feature = "permission")] pub use bitfun_product_domains::tool_permissions::{ deserialize_optional_permission_mode, resolve_child_permission_policy, resolve_permission_mode, diff --git a/src/crates/contracts/runtime-ports/tests/agentic_decision_port_contracts.rs b/src/crates/contracts/runtime-ports/tests/agentic_decision_port_contracts.rs new file mode 100644 index 000000000..29006f797 --- /dev/null +++ b/src/crates/contracts/runtime-ports/tests/agentic_decision_port_contracts.rs @@ -0,0 +1,139 @@ +use bitfun_runtime_ports::{ + AgenticDecisionIntent, AgenticDecisionLineage, AgenticDecisionPolicyResult, + AgenticDecisionPortRequest, AgenticDecisionRouteClass, AgenticDecisionShadowStatus, + AgenticDecisionSurface, AgenticDecisionTargetKind, +}; + +fn lineage() -> AgenticDecisionLineage { + AgenticDecisionLineage { + decision_id: "decision-1".to_string(), + core_run_id: "run-1".to_string(), + task_id: "task-1".to_string(), + action_id: "action-1".to_string(), + correlation_id: "corr-1".to_string(), + session_id: Some("session-1".to_string()), + turn_id: Some("turn-1".to_string()), + idempotency_key: Some("idem-1".to_string()), + } +} + +fn request( + surface: AgenticDecisionSurface, + target_kind: AgenticDecisionTargetKind, + route: &str, +) -> AgenticDecisionPortRequest { + AgenticDecisionPortRequest { + lineage: lineage(), + surface, + target_kind, + route: route.to_string(), + intent: AgenticDecisionIntent::Execute, + policy_result: AgenticDecisionPolicyResult::Allow, + bitfun_policy_available: true, + approval_id: None, + evidence_refs: vec!["contract:test".to_string()], + execute_physical: false, + } +} + +#[test] +fn desktop_turn_projects_as_shadow_only_governed_runtime() { + let projection = request( + AgenticDecisionSurface::Desktop, + AgenticDecisionTargetKind::SessionTurn, + "start_dialog_turn", + ) + .project_shadow(); + + assert_eq!( + projection.route_class, + AgenticDecisionRouteClass::GovernedRuntime + ); + assert_eq!(projection.status, AgenticDecisionShadowStatus::ShadowAllowed); + assert_eq!(projection.denial_reason, None); + assert!(!projection.execute_physical); +} + +#[test] +fn direct_tool_route_fails_closed_without_physical_execution() { + let projection = request( + AgenticDecisionSurface::ToolApi, + AgenticDecisionTargetKind::ToolCall, + "execute_tool", + ) + .project_shadow(); + + assert_eq!(projection.route_class, AgenticDecisionRouteClass::DirectGap); + assert_eq!(projection.status, AgenticDecisionShadowStatus::ShadowDenied); + assert_eq!( + projection.denial_reason.as_deref(), + Some("route_gap:execute_tool") + ); + assert!(!projection.execute_physical); +} + +#[test] +fn mcp_app_tool_call_fails_closed_but_resource_read_is_not_tool_execution() { + let tool_projection = request( + AgenticDecisionSurface::McpApp, + AgenticDecisionTargetKind::McpCall, + "send_mcp_app_message.tools_call", + ) + .project_shadow(); + + assert_eq!( + tool_projection.route_class, + AgenticDecisionRouteClass::DirectGap + ); + assert_eq!( + tool_projection.denial_reason.as_deref(), + Some("route_gap:send_mcp_app_message.tools_call") + ); + + let read_projection = request( + AgenticDecisionSurface::McpApp, + AgenticDecisionTargetKind::ReadOnly, + "send_mcp_app_message.resources_read", + ) + .project_shadow(); + + assert_eq!(read_projection.route_class, AgenticDecisionRouteClass::ReadOnly); + assert_eq!(read_projection.status, AgenticDecisionShadowStatus::ShadowAllowed); +} + +#[test] +fn shadow_mode_denies_physical_execution_even_on_governed_routes() { + let mut request = request( + AgenticDecisionSurface::Cron, + AgenticDecisionTargetKind::SessionTurn, + "scheduled_job_turn", + ); + request.execute_physical = true; + + let projection = request.project_shadow(); + + assert_eq!(projection.status, AgenticDecisionShadowStatus::ShadowDenied); + assert_eq!( + projection.denial_reason.as_deref(), + Some("shadow_mode_forbids_physical_execution") + ); + assert!(!projection.execute_physical); +} + +#[test] +fn session_bound_projection_requires_session_and_turn_lineage() { + let mut request = request( + AgenticDecisionSurface::AppServer, + AgenticDecisionTargetKind::SessionTurn, + "submit_dialog_turn", + ); + request.lineage.turn_id = None; + + let projection = request.project_shadow(); + + assert_eq!(projection.status, AgenticDecisionShadowStatus::ShadowDenied); + assert_eq!( + projection.denial_reason.as_deref(), + Some("missing_session_turn_lineage") + ); +}