Skip to main content

agent_sdk_core/testing/
isolation.rs

1//! Deterministic test-kit helpers for SDK consumers. Use these fakes and harnesses to
2//! exercise public contracts without live providers, real stores, product UI, network
3//! telemetry, or wall-clock-dependent infrastructure. They mutate only their
4//! in-memory state unless noted. This file contains the isolation portion of that
5//! contract.
6//!
7use std::sync::{Arc, Mutex};
8
9use crate::{
10    domain::{AgentError, ContentRef},
11    package_isolation::{
12        IsolatedProcessRef, IsolationAdapterSessionRef, IsolationRuntimeRef, IsolationSessionRef,
13        MountRef, NetworkNamespaceRef, PreparedEnvironmentRef, ProcessIoStreamRef,
14        ProcessStatsSnapshotRef, RootfsRef, SecretMountRef,
15    },
16    ports_isolation::{
17        CleanupRequest, CleanupResult, CleanupStatus, DetachTransferRequest, DetachTransferResult,
18        EnvironmentPrepareRequest, ImageResolution, ImageResolveRequest, IsolationCapabilityReport,
19        IsolationRuntime, MountPlan, MountResolveRequest, NetworkPrepareRequest, ProcessIoFrame,
20        ProcessIoRequest, ProcessIoStream, ProcessSignalRequest, ProcessSignalResult,
21        ProcessStartRequest, ProcessStartResult, ProcessStatsRequest, ProcessStatsSnapshot,
22        ReclaimRequest, ReclaimResult, RootfsPrepareRequest, SecretMaterializationPlan,
23        SecretPrepareRequest, SessionPrepareRequest,
24    },
25};
26
27#[derive(Clone, Debug)]
28/// In-memory fake isolation runtime fixture for SDK conformance tests.
29/// Use it to script deterministic behavior in memory; any transcript or endpoint mutation is documented on the method that performs it.
30pub struct FakeIsolationRuntime {
31    report: IsolationCapabilityReport,
32    calls: Arc<Mutex<Vec<String>>>,
33    cleanup_status: CleanupStatus,
34}
35
36impl FakeIsolationRuntime {
37    /// Returns this value with its report setting replaced. The method
38    /// follows builder-style data construction and does not execute
39    /// external work.
40    pub fn with_report(report: IsolationCapabilityReport) -> Self {
41        Self {
42            report,
43            calls: Arc::new(Mutex::new(Vec::new())),
44            cleanup_status: CleanupStatus::Completed,
45        }
46    }
47
48    /// Builds the unsupported host value.
49    /// This is data construction and performs no I/O, journal append, event publication, or
50    /// process work.
51    pub fn unsupported_host(
52        adapter_ref: impl Into<IsolationRuntimeRef>,
53        missing: impl IntoIterator<Item = impl Into<String>>,
54    ) -> Self {
55        Self::with_report(IsolationCapabilityReport::unsupported(adapter_ref, missing))
56    }
57
58    /// Builds the host process only value.
59    /// This is data construction and performs no I/O, journal append, event publication, or
60    /// process work.
61    pub fn host_process_only(adapter_ref: impl Into<IsolationRuntimeRef>) -> Self {
62        Self::with_report(IsolationCapabilityReport::host_process(adapter_ref))
63    }
64
65    /// Returns this value with its cleanup status setting replaced. The
66    /// method follows builder-style data construction and does not
67    /// execute external work.
68    pub fn with_cleanup_status(mut self, cleanup_status: CleanupStatus) -> Self {
69        self.cleanup_status = cleanup_status;
70        self
71    }
72
73    /// Operates on in-memory or journal-derived testing::isolation state for
74    /// diagnostics and repair evidence. It does not create a second run loop
75    /// or product workflow owner.
76    pub fn calls(&self) -> Vec<String> {
77        self.calls.lock().expect("fake isolation calls").clone()
78    }
79
80    /// Returns the call count currently held by this value.
81    /// This reads deterministic in-memory test state and performs no external I/O.
82    pub fn call_count(&self) -> usize {
83        self.calls().len()
84    }
85
86    /// Start process call count.
87    /// This reads the fake adapter call counter and does not start or signal a process.
88    pub fn start_process_call_count(&self) -> usize {
89        self.calls()
90            .into_iter()
91            .filter(|call| call == "start_process")
92            .count()
93    }
94
95    fn push_call(&self, call: impl Into<String>) {
96        self.calls
97            .lock()
98            .expect("fake isolation calls")
99            .push(call.into());
100    }
101}
102
103impl IsolationRuntime for FakeIsolationRuntime {
104    fn runtime_ref(&self) -> &IsolationRuntimeRef {
105        &self.report.adapter_ref
106    }
107
108    fn capability_report(&self) -> Result<IsolationCapabilityReport, AgentError> {
109        self.push_call("capability_report");
110        Ok(self.report.clone())
111    }
112
113    fn prepare_session(
114        &self,
115        _request: SessionPrepareRequest,
116    ) -> Result<IsolationSessionRef, AgentError> {
117        self.push_call("prepare_session");
118        Ok(IsolationSessionRef::new("session.fake.isolation"))
119    }
120
121    fn resolve_image(&self, request: ImageResolveRequest) -> Result<ImageResolution, AgentError> {
122        self.push_call("resolve_image");
123        Ok(ImageResolution {
124            image_ref: request.image.image_ref,
125            digest: "sha256:fake.image".to_string(),
126            redacted_credential_alias: Some("credential.alias.redacted".to_string()),
127        })
128    }
129
130    fn prepare_rootfs(&self, _request: RootfsPrepareRequest) -> Result<RootfsRef, AgentError> {
131        self.push_call("prepare_rootfs");
132        Ok(RootfsRef::new("rootfs.fake.isolation"))
133    }
134
135    fn resolve_mounts(&self, _request: MountResolveRequest) -> Result<MountPlan, AgentError> {
136        self.push_call("resolve_mounts");
137        Ok(MountPlan {
138            mounts: vec![MountRef::new("mount.workspace.primary")],
139            expanded_exposure_audits: vec!["workspace snapshot mounted by alias".to_string()],
140        })
141    }
142
143    fn configure_network(
144        &self,
145        _request: NetworkPrepareRequest,
146    ) -> Result<NetworkNamespaceRef, AgentError> {
147        self.push_call("configure_network");
148        Ok(NetworkNamespaceRef::new("network.fake.isolation"))
149    }
150
151    fn prepare_secrets(
152        &self,
153        request: SecretPrepareRequest,
154    ) -> Result<SecretMaterializationPlan, AgentError> {
155        self.push_call("prepare_secrets");
156        Ok(SecretMaterializationPlan {
157            secret_mount_refs: request
158                .environment
159                .spec
160                .secrets
161                .secret_mounts
162                .iter()
163                .map(|secret| secret.mount_ref.clone())
164                .collect::<Vec<SecretMountRef>>(),
165            teardown_required: true,
166        })
167    }
168
169    fn prepare_environment(
170        &self,
171        _request: EnvironmentPrepareRequest,
172    ) -> Result<PreparedEnvironmentRef, AgentError> {
173        self.push_call("prepare_environment");
174        Ok(PreparedEnvironmentRef::new("prepared.fake.isolation"))
175    }
176
177    fn start_process(
178        &self,
179        request: ProcessStartRequest,
180    ) -> Result<ProcessStartResult, AgentError> {
181        self.push_call("start_process");
182        let process_ref = IsolatedProcessRef::new(format!(
183            "process.ref.{}",
184            request.process.process_id.as_str()
185        ));
186        Ok(ProcessStartResult {
187            process_ref,
188            adapter_session_ref: Some(IsolationAdapterSessionRef::new(
189                "adapter.session.fake.isolation",
190            )),
191            terminal_status: crate::EffectTerminalStatus::Completed,
192            external_operation_id: Some("external.fake.process.start".to_string()),
193            io_frames: vec![
194                ProcessIoFrame {
195                    stream_ref: ProcessIoStreamRef::new("stream.stdout.fake"),
196                    stream: ProcessIoStream::Stdout,
197                    cursor: 1,
198                    byte_count: 23,
199                    content_hash: "sha256:fake.stdout".to_string(),
200                    content_refs: vec![ContentRef::new("content.isolation.stdout")],
201                    raw_content_present: false,
202                    truncated: false,
203                    redacted_summary: "stdout captured as content ref".to_string(),
204                },
205                ProcessIoFrame {
206                    stream_ref: ProcessIoStreamRef::new("stream.stderr.fake"),
207                    stream: ProcessIoStream::Stderr,
208                    cursor: 1,
209                    byte_count: 0,
210                    content_hash: "sha256:fake.stderr.empty".to_string(),
211                    content_refs: Vec::new(),
212                    raw_content_present: false,
213                    truncated: false,
214                    redacted_summary: "stderr empty".to_string(),
215                },
216            ],
217            redacted_summary: "isolated process started".to_string(),
218        })
219    }
220
221    fn stream_io(&self, _request: ProcessIoRequest) -> Result<ProcessIoFrame, AgentError> {
222        self.push_call("stream_io");
223        Ok(ProcessIoFrame {
224            stream_ref: ProcessIoStreamRef::new("stream.stdout.fake"),
225            stream: ProcessIoStream::Stdout,
226            cursor: 1,
227            byte_count: 23,
228            content_hash: "sha256:fake.stdout".to_string(),
229            content_refs: vec![ContentRef::new("content.isolation.stdout")],
230            raw_content_present: false,
231            truncated: false,
232            redacted_summary: "stdout captured as content ref".to_string(),
233        })
234    }
235
236    fn signal_process(
237        &self,
238        request: ProcessSignalRequest,
239    ) -> Result<ProcessSignalResult, AgentError> {
240        self.push_call("signal_process");
241        Ok(ProcessSignalResult {
242            process_ref: request.process_ref,
243            signal: request.signal,
244            delivered: true,
245            redacted_summary: "signal delivered to isolated process".to_string(),
246        })
247    }
248
249    fn collect_stats(
250        &self,
251        request: ProcessStatsRequest,
252    ) -> Result<ProcessStatsSnapshot, AgentError> {
253        self.push_call("collect_stats");
254        Ok(ProcessStatsSnapshot {
255            snapshot_ref: ProcessStatsSnapshotRef::new("stats.fake.isolation"),
256            process_ref: request.process_ref,
257            cpu_millis: Some(10),
258            memory_bytes: Some(1024),
259            process_count: Some(1),
260            filesystem_bytes: Some(2048),
261            network_bytes: Some(0),
262            exit_code: Some(0),
263            redacted_summary: "fake process stats counters".to_string(),
264        })
265    }
266
267    fn cleanup(&self, request: CleanupRequest) -> Result<CleanupResult, AgentError> {
268        self.push_call("cleanup");
269        let redacted_summary = match self.cleanup_status {
270            CleanupStatus::Completed => "isolation cleanup completed",
271            CleanupStatus::RepairNeeded => "isolation cleanup requires host repair",
272        };
273        Ok(CleanupResult {
274            cleanup_plan_ref: request.cleanup_plan_ref,
275            status: self.cleanup_status,
276            external_operation_id: Some("external.fake.cleanup".to_string()),
277            redacted_summary: redacted_summary.to_string(),
278        })
279    }
280
281    fn detach(&self, _request: DetachTransferRequest) -> Result<DetachTransferResult, AgentError> {
282        self.push_call("detach");
283        Ok(DetachTransferResult {
284            host_ack_ref: "host.ack.fake.detach".to_string(),
285            redacted_summary: "detach acknowledged by fake host".to_string(),
286        })
287    }
288
289    fn reclaim(&self, request: ReclaimRequest) -> Result<ReclaimResult, AgentError> {
290        self.push_call("reclaim");
291        Ok(ReclaimResult {
292            ticket_ref: request.ticket_ref,
293            status: CleanupStatus::Completed,
294            redacted_summary: "fake reclaim completed".to_string(),
295        })
296    }
297}