Skip to main content

heartbit_core/
execution_context.rs

1//! Per-request execution context threaded through tool dispatch.
2//!
3//! Every `Tool::execute` call receives an `&ExecutionContext`. The context
4//! carries tenant/user identity, the workspace root, and resolvers for
5//! per-tenant secrets and audit sinks. It is constructed at the request
6//! boundary (CLI command, Restate workflow activity, daemon dispatch) and
7//! threaded through the agent runner unchanged.
8
9use std::future::Future;
10use std::path::PathBuf;
11use std::pin::Pin;
12use std::sync::Arc;
13
14use crate::error::Error;
15
16/// Per-request context carried into every tool invocation.
17#[derive(Clone, Default)]
18pub struct ExecutionContext {
19    /// Tenant identifier (multi-tenant deployments). `None` outside of multi-tenant flows.
20    pub tenant_id: Option<String>,
21    /// User identifier on whose behalf the agent runs. `None` outside of authenticated flows.
22    pub user_id: Option<String>,
23    /// Workspace root for filesystem-aware tools. `None` when no workspace is configured.
24    pub workspace: Option<PathBuf>,
25    /// Resolver for per-tenant secrets (API keys, OAuth tokens). `None` when no resolver is configured.
26    pub credentials: Option<Arc<dyn CredentialResolver>>,
27    /// Sink for tool-level audit records. `None` when no audit sink is configured.
28    pub audit_sink: Option<Arc<dyn AuditSink>>,
29}
30
31impl std::fmt::Debug for ExecutionContext {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        f.debug_struct("ExecutionContext")
34            .field("tenant_id", &self.tenant_id)
35            .field("user_id", &self.user_id)
36            .field("workspace", &self.workspace)
37            .field(
38                "credentials",
39                &self.credentials.as_ref().map(|_| "<resolver>"),
40            )
41            .field("audit_sink", &self.audit_sink.as_ref().map(|_| "<sink>"))
42            .finish()
43    }
44}
45
46/// Resolves a named secret (API key, token) for the current tenant.
47pub trait CredentialResolver: Send + Sync {
48    /// Resolve a secret by logical name (e.g. `"X_API_KEY"`).
49    fn resolve(
50        &self,
51        name: &str,
52    ) -> Pin<Box<dyn Future<Output = Result<Secret, Error>> + Send + '_>>;
53}
54
55/// Receives per-tool audit records emitted by tools that opt in.
56pub trait AuditSink: Send + Sync {
57    /// Record a structured audit entry. Implementations must not block.
58    fn record(
59        &self,
60        record: serde_json::Value,
61    ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + '_>>;
62}
63
64/// A secret value with redacted `Debug`/`Display` formatting.
65#[derive(Clone)]
66pub struct Secret(String);
67
68impl Secret {
69    /// Wrap a secret string. Use `expose()` to read the inner value.
70    pub fn new(value: impl Into<String>) -> Self {
71        Self(value.into())
72    }
73
74    /// Read the inner secret string. Caller is responsible for not logging the result.
75    pub fn expose(&self) -> &str {
76        &self.0
77    }
78}
79
80impl std::fmt::Debug for Secret {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        write!(f, "Secret(<redacted>)")
83    }
84}
85
86impl std::fmt::Display for Secret {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        write!(f, "<redacted>")
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn execution_context_default_has_no_identity() {
98        let ctx = ExecutionContext::default();
99        assert!(ctx.tenant_id.is_none());
100        assert!(ctx.user_id.is_none());
101        assert!(ctx.workspace.is_none());
102        assert!(ctx.credentials.is_none());
103        assert!(ctx.audit_sink.is_none());
104    }
105
106    #[test]
107    fn execution_context_clone_preserves_fields() {
108        let ctx = ExecutionContext {
109            tenant_id: Some("tenant-1".into()),
110            user_id: Some("user-2".into()),
111            workspace: Some(PathBuf::from("/tmp/ws")),
112            credentials: None,
113            audit_sink: None,
114        };
115        let cloned = ctx.clone();
116        assert_eq!(cloned.tenant_id.as_deref(), Some("tenant-1"));
117        assert_eq!(cloned.user_id.as_deref(), Some("user-2"));
118        assert_eq!(cloned.workspace, Some(PathBuf::from("/tmp/ws")));
119    }
120
121    #[test]
122    fn secret_debug_redacts() {
123        let s = Secret::new("super-secret-token");
124        let debug = format!("{:?}", s);
125        assert!(!debug.contains("super-secret-token"));
126        assert!(debug.contains("<redacted>"));
127    }
128
129    #[test]
130    fn secret_display_redacts() {
131        let s = Secret::new("super-secret-token");
132        let display = format!("{}", s);
133        assert!(!display.contains("super-secret-token"));
134        assert!(display.contains("<redacted>"));
135    }
136
137    #[test]
138    fn secret_expose_returns_inner() {
139        let s = Secret::new("super-secret-token");
140        assert_eq!(s.expose(), "super-secret-token");
141    }
142
143    #[test]
144    fn execution_context_debug_does_not_leak_resolver_internals() {
145        struct DummyResolver;
146        impl CredentialResolver for DummyResolver {
147            fn resolve(
148                &self,
149                _name: &str,
150            ) -> Pin<Box<dyn Future<Output = Result<Secret, Error>> + Send + '_>> {
151                Box::pin(async { Ok(Secret::new("x")) })
152            }
153        }
154
155        let ctx = ExecutionContext {
156            credentials: Some(Arc::new(DummyResolver)),
157            ..ExecutionContext::default()
158        };
159        let debug = format!("{:?}", ctx);
160        assert!(debug.contains("<resolver>"));
161        assert!(!debug.contains("DummyResolver"));
162    }
163}