1use std::path::{Path, PathBuf};
9
10pub mod context_plan;
11pub mod planning;
12pub mod receipt_sink;
13pub mod source_execution;
14
15#[cfg(test)]
16use lean_ctx_protocol::EngineInterfaceV1;
17use lean_ctx_protocol::{
18 CapabilityId, EngineFailureCodeV1, EngineFailureV1, EngineInvocationIdV1, EngineInvocationV1,
19 EngineMeasurementV1, EngineObservationStatusV1, EngineObservationV1, EngineOperationV1,
20 EnginePolicyAdmissionV1, EnginePolicyDecisionV1, EngineReceiptLinkV1,
21 EngineValueClassificationV1, ProtocolReference, ReceiptId, ResolvedLocalEngineIdentityV1,
22 SemanticVersion, Sha256Digest, V1_SCHEMA_VERSION,
23};
24use serde::{Deserialize, Serialize};
25use sha2::{Digest, Sha256};
26
27use crate::core::canonical;
28use crate::core::ocla::adapters::native_context::{
29 NativeContextAdapter, NativeContextInvocationFailure,
30};
31use crate::core::ocla::invocation::{CapabilityInput, CapabilityInvocation, PolicyConstraints};
32
33const ENGINE_ID: &str = "lean-ctx-local";
34pub const CAPABILITY_ID: &str = "capability://leanctx/context-optimization";
35pub const CAPABILITY_VERSION: &str = "1.0.0";
36const RECEIPT_DIRECTORY: &str = "engine-interface/v1/receipts";
37const OUTPUT_DIRECTORY: &str = "engine-interface/v1/outputs";
38const RECOVERY_DIRECTORY: &str = "engine-interface/v1/recovery";
39pub const ENGINE_TRANSPORT_VERSION: u32 = 1;
40pub const ENGINE_INTERFACE_VERSION: &str = "1.0.0";
41pub const ENGINE_TRANSPORT_POLICY_REF: &str = "policy:engine-transport-v1:admitted";
42const INPUT_REF_PREFIX: &str = "input:ctx-read-snapshot-sha256:";
43const SOURCE_REF_PREFIX: &str = "source:canonical-path-sha256:";
44const MAX_TRANSPORT_VIEW_BYTES: usize = 1024 * 1024;
45pub const MAX_TRANSPORT_BUDGET_TOKENS: u64 = MAX_TRANSPORT_VIEW_BYTES as u64;
46
47use super::engine_artifact as artifact_store;
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum EngineTransportError {
51 InvalidRequest,
52 UnsupportedMode,
53 UnsafeRoot,
54 SourceOutsideRoot,
55 SourceSymlink,
56 SourceUnavailable,
57 UnsupportedInput,
58 SourceChanged,
59 MalformedRecoveryRef,
60 ReceiptMismatch,
61 ObservationMismatch,
62 ViewTooLarge,
63 Internal,
64}
65
66impl EngineTransportError {
67 pub const fn code(self) -> &'static str {
68 match self {
69 Self::InvalidRequest => "invalid_request",
70 Self::UnsupportedMode => "unsupported_mode",
71 Self::UnsafeRoot => "unsafe_root",
72 Self::SourceOutsideRoot => "source_outside_root",
73 Self::SourceSymlink => "source_symlink",
74 Self::SourceUnavailable => "source_unavailable",
75 Self::UnsupportedInput => "unsupported_input",
76 Self::SourceChanged => "source_changed",
77 Self::MalformedRecoveryRef => "malformed_recovery_ref",
78 Self::ReceiptMismatch => "receipt_mismatch",
79 Self::ObservationMismatch => "observation_mismatch",
80 Self::ViewTooLarge => "view_too_large",
81 Self::Internal => "internal",
82 }
83 }
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct EngineTransportRecoveryDescriptor {
88 pub recovery_ref: ProtocolReference,
89 pub source_ref: ProtocolReference,
90 pub source_digest: Sha256Digest,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct EngineTransportView {
95 pub text: String,
96 pub output_ref: Option<ProtocolReference>,
97 pub output_digest: Option<Sha256Digest>,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct EngineTransportResult {
102 pub view: EngineTransportView,
103 pub invocation: Option<EngineInvocationV1>,
104 pub observation: Option<EngineObservationV1>,
105 pub recovery: EngineTransportRecoveryDescriptor,
106}
107
108pub fn persist_engine_artifact_content(
109 directory: &str,
110 digest: &str,
111 extension: &str,
112 bytes: &[u8],
113) -> Result<std::fs::File, String> {
114 artifact_store::persist_content(directory, digest, extension, bytes)
115}
116
117#[cfg(windows)]
118pub(crate) fn open_engine_artifact_lock(
119 directory: &str,
120 digest: &str,
121) -> Result<std::fs::File, String> {
122 artifact_store::open_lock(directory, digest)
123}
124
125#[derive(Clone, Debug)]
131struct NativeContextEngineRequest {
132 invocation_id: EngineInvocationIdV1,
133 input_ref: ProtocolReference,
134 input_digest: Sha256Digest,
135 source_refs: Vec<ProtocolReference>,
136 policy_admission: EnginePolicyAdmissionV1,
137 paths: Vec<String>,
138 mode: String,
139 budget_tokens: Option<u64>,
140 timeout_ms: u64,
141}
142
143impl NativeContextEngineRequest {
144 #[cfg(test)]
145 fn ctx_read_snapshot(
146 path: &str,
147 raw_input: &str,
148 timeout_ms: u64,
149 policy_admission: EnginePolicyAdmissionV1,
150 ) -> Result<(Self, String), String> {
151 let canonical_path = std::fs::canonicalize(path)
152 .map_err(|error| format!("resolve ctx_read Engine source: {error}"))?;
153 Self::ctx_read_snapshot_canonical(&canonical_path, raw_input, timeout_ms, policy_admission)
154 }
155
156 fn ctx_read_snapshot_canonical(
157 canonical_path: &Path,
158 raw_input: &str,
159 timeout_ms: u64,
160 policy_admission: EnginePolicyAdmissionV1,
161 ) -> Result<(Self, String), String> {
162 if !canonical_path.is_absolute() {
163 return Err("ctx_read Engine canonical source must be absolute".to_owned());
164 }
165 Self::ctx_read_identified(
166 canonical_path.to_string_lossy().into_owned(),
167 "source:canonical-path-sha256",
168 raw_input,
169 timeout_ms,
170 policy_admission,
171 )
172 }
173
174 fn ctx_read_rejection(
175 requested_path: &str,
176 timeout_ms: u64,
177 policy_admission: EnginePolicyAdmissionV1,
178 ) -> Result<Self, String> {
179 if !Path::new(requested_path).is_absolute() {
180 return Err("ctx_read Engine requested source must be absolute".to_owned());
181 }
182 Self::ctx_read_identified(
183 requested_path.to_owned(),
184 "source:requested-path-sha256",
185 "",
186 timeout_ms,
187 policy_admission,
188 )
189 .map(|(request, _)| request)
190 }
191
192 fn ctx_read_identified(
193 path_identity: String,
194 source_prefix: &str,
195 raw_input: &str,
196 timeout_ms: u64,
197 policy_admission: EnginePolicyAdmissionV1,
198 ) -> Result<(Self, String), String> {
199 let input = crate::core::redaction::redact_text_if_enabled(raw_input);
200 let raw_input_digest = sha256_digest(raw_input.as_bytes())?;
201 let input_digest = sha256_digest(input.as_bytes())?;
202 let input_ref = ProtocolReference::new(format!(
203 "input:ctx-read-snapshot-sha256:{}",
204 raw_input_digest.hex()
205 ))
206 .map_err(|error| error.to_string())?;
207 let path_digest = sha256_digest(path_identity.as_bytes())?;
208 let source_ref = ProtocolReference::new(format!("{source_prefix}:{}", path_digest.hex()))
209 .map_err(|error| error.to_string())?;
210 let invocation_seed = canonical::canonical_serialize(&CtxReadInvocationIdentityV1 {
211 engine_id: ENGINE_ID,
212 engine_version: env!("CARGO_PKG_VERSION"),
213 capability_id: CAPABILITY_ID,
214 capability_version: CAPABILITY_VERSION,
215 input_ref: input_ref.as_str(),
216 source_ref: source_ref.as_str(),
217 input_digest: input_digest.as_str(),
218 policy_ref: policy_admission.policy_ref.as_str(),
219 policy_decision: policy_admission.decision,
220 mode: "aggressive",
221 timeout_ms,
222 });
223 let invocation_digest = sha256_digest(&invocation_seed)?;
224 let request = Self {
225 invocation_id: EngineInvocationIdV1::new(format!(
226 "engine-invocation-{}",
227 &invocation_digest.hex()[..32]
228 ))
229 .map_err(|error| error.to_string())?,
230 input_ref: input_ref.clone(),
231 input_digest,
232 source_refs: vec![input_ref, source_ref],
233 policy_admission,
234 paths: vec![path_identity],
235 mode: "aggressive".to_owned(),
236 budget_tokens: None,
237 timeout_ms,
238 };
239 Ok((request, input))
240 }
241}
242
243#[derive(Serialize)]
244struct CtxReadInvocationIdentityV1<'a> {
245 engine_id: &'a str,
246 engine_version: &'a str,
247 capability_id: &'a str,
248 capability_version: &'a str,
249 input_ref: &'a str,
250 source_ref: &'a str,
251 input_digest: &'a str,
252 policy_ref: &'a str,
253 policy_decision: EnginePolicyDecisionV1,
254 mode: &'a str,
255 timeout_ms: u64,
256}
257
258pub struct NativeContextEngine {
260 adapter: NativeContextAdapter,
261}
262
263#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
265#[serde(deny_unknown_fields)]
266pub(crate) struct EngineReceiptArtifactV1 {
267 schema_version: u32,
268 invocation: EngineInvocationV1,
269 observation: EngineObservationV1,
270}
271
272#[derive(Debug, Clone, PartialEq, Eq)]
275pub(crate) struct VerifiedEngineReceiptV1 {
276 artifact: EngineReceiptArtifactV1,
277 digest: Sha256Digest,
278}
279
280impl VerifiedEngineReceiptV1 {
281 pub(crate) fn invocation(&self) -> &EngineInvocationV1 {
282 &self.artifact.invocation
283 }
284
285 pub(crate) fn observation(&self) -> &EngineObservationV1 {
286 &self.artifact.observation
287 }
288
289 pub(crate) fn digest(&self) -> &Sha256Digest {
290 &self.digest
291 }
292}
293
294pub(crate) fn canonical_engine_receipt_artifact_bytes(
296 invocation: &EngineInvocationV1,
297 observation: &EngineObservationV1,
298) -> Vec<u8> {
299 canonical::canonical_serialize(&EngineReceiptArtifactV1 {
300 schema_version: V1_SCHEMA_VERSION,
301 invocation: invocation.clone(),
302 observation: observation.clone(),
303 })
304}
305
306pub(crate) fn read_verified_engine_receipt(
308 digest: &Sha256Digest,
309 expected_invocation: &EngineInvocationV1,
310 expected_observation_without_link: &EngineObservationV1,
311) -> Result<VerifiedEngineReceiptV1, String> {
312 if expected_observation_without_link.receipt_link.is_some() {
313 return Err("expected Engine observation must omit receipt_link".to_owned());
314 }
315 expected_invocation
316 .validate()
317 .map_err(|error| format!("invalid expected Engine invocation: {error}"))?;
318 expected_observation_without_link
319 .validate_for(expected_invocation)
320 .map_err(|error| format!("invalid expected Engine observation: {error}"))?;
321
322 let bytes = artifact_store::read_content(RECEIPT_DIRECTORY, digest.hex(), "json")?;
323 let actual_digest = sha256_digest(&bytes)?;
324 if actual_digest != *digest {
325 return Err("Engine receipt artifact digest mismatch".to_owned());
326 }
327 let artifact = decode_canonical_engine_receipt_artifact(&bytes)?;
328 if artifact.schema_version != V1_SCHEMA_VERSION {
329 return Err("Engine receipt artifact schema_version must be 1".to_owned());
330 }
331 if artifact.observation.receipt_link.is_some() {
332 return Err("Engine receipt artifact must omit receipt_link".to_owned());
333 }
334 artifact
335 .invocation
336 .validate()
337 .map_err(|error| format!("invalid Engine receipt invocation: {error}"))?;
338 artifact
339 .observation
340 .validate_for(&artifact.invocation)
341 .map_err(|error| format!("invalid Engine receipt observation: {error}"))?;
342 if artifact.invocation != *expected_invocation {
343 return Err("Engine receipt invocation does not match expectation".to_owned());
344 }
345 if artifact.observation != *expected_observation_without_link {
346 return Err("Engine receipt observation does not match expectation".to_owned());
347 }
348 Ok(VerifiedEngineReceiptV1 {
349 artifact,
350 digest: digest.clone(),
351 })
352}
353
354fn decode_canonical_engine_receipt_artifact(
355 bytes: &[u8],
356) -> Result<EngineReceiptArtifactV1, String> {
357 let mut deserializer = serde_json::Deserializer::from_slice(bytes);
358 let artifact = EngineReceiptArtifactV1::deserialize(&mut deserializer)
359 .map_err(|error| format!("decode Engine receipt artifact: {error}"))?;
360 deserializer
361 .end()
362 .map_err(|error| format!("trailing Engine receipt artifact data: {error}"))?;
363 if canonical_engine_receipt_artifact_bytes(&artifact.invocation, &artifact.observation) != bytes
364 {
365 return Err("Engine receipt artifact is not canonical JSON".to_owned());
366 }
367 Ok(artifact)
368}
369
370#[derive(Serialize)]
371#[serde(deny_unknown_fields)]
372struct EngineRecoveryArtifactV1 {
373 schema_version: u32,
374 invocation: EngineInvocationV1,
375 observation: EngineObservationV1,
376 failure_class: &'static str,
377}
378
379impl NativeContextEngine {
380 pub fn with_root(root: impl AsRef<Path>) -> Result<Self, String> {
381 let root = crate::core::pathutil::canonicalize_secure(root.as_ref())
382 .map_err(|_| "ctx_read Engine root cannot be bound securely".to_owned())?;
383 Ok(Self {
384 adapter: NativeContextAdapter::with_root(root),
385 })
386 }
387
388 #[cfg(test)]
389 fn interface(&self) -> Result<EngineInterfaceV1, String> {
390 let interface = EngineInterfaceV1 {
391 schema_version: V1_SCHEMA_VERSION,
392 interface_version: SemanticVersion::new("1.0.0").map_err(|error| error.to_string())?,
393 engine: engine_identity()?,
394 supported_operations: vec![native_operation()?],
395 };
396 interface.validate().map_err(|error| error.to_string())?;
397 Ok(interface)
398 }
399
400 fn execute(
406 &self,
407 request: NativeContextEngineRequest,
408 ) -> Result<(EngineInvocationV1, EngineObservationV1), String> {
409 self.execute_inner(request, None)
410 }
411
412 fn execute_materialized(
417 &self,
418 request: NativeContextEngineRequest,
419 input: &str,
420 ) -> Result<(EngineInvocationV1, EngineObservationV1), String> {
421 self.execute_inner(request, Some(input))
422 }
423
424 #[cfg(test)]
426 pub(crate) fn execute_ctx_read_snapshot(
427 &self,
428 path: &str,
429 raw_input: &str,
430 policy_admission: EnginePolicyAdmissionV1,
431 ) -> Result<(EngineInvocationV1, EngineObservationV1), String> {
432 let rooted_path = crate::core::pathjail::jail_path(Path::new(path), self.adapter.root())
433 .map_err(|_| "ctx_read Engine source is outside its rooted boundary".to_owned())?;
434 self.execute_ctx_read_rooted_snapshot(
435 &rooted_path.to_string_lossy(),
436 raw_input,
437 policy_admission,
438 )
439 }
440
441 pub fn execute_ctx_read_rooted_snapshot(
442 &self,
443 rooted_path: &str,
444 raw_input: &str,
445 policy_admission: EnginePolicyAdmissionV1,
446 ) -> Result<(EngineInvocationV1, EngineObservationV1), String> {
447 self.execute_ctx_read_rooted_snapshot_with_budget(
448 rooted_path,
449 raw_input,
450 policy_admission,
451 None,
452 None,
453 )
454 }
455
456 pub fn execute_ctx_read_rooted_snapshot_with_plan(
457 &self,
458 rooted_path: &str,
459 raw_input: &str,
460 policy_admission: EnginePolicyAdmissionV1,
461 task: &lean_ctx_protocol::TaskEnvelopeV1,
462 plan: &lean_ctx_protocol::ExecutionPlanV1,
463 ) -> Result<(EngineInvocationV1, EngineObservationV1), String> {
464 planning::validate_native_plan(task, plan, &policy_admission)?;
465 self.execute_ctx_read_rooted_snapshot_with_budget(
466 rooted_path,
467 raw_input,
468 policy_admission,
469 plan.context_token_limit(),
470 Some((task, plan)),
471 )
472 }
473
474 fn execute_ctx_read_rooted_snapshot_with_budget(
475 &self,
476 rooted_path: &str,
477 raw_input: &str,
478 policy_admission: EnginePolicyAdmissionV1,
479 budget_tokens: Option<u64>,
480 binding: Option<(
481 &lean_ctx_protocol::TaskEnvelopeV1,
482 &lean_ctx_protocol::ExecutionPlanV1,
483 )>,
484 ) -> Result<(EngineInvocationV1, EngineObservationV1), String> {
485 let root = self.adapter.root();
486 let rooted_path = Path::new(rooted_path);
487 if !rooted_path.is_absolute() {
488 return Err("ctx_read Engine source is outside its rooted boundary".to_owned());
489 }
490 if contains_symlink_component(rooted_path, root) {
491 return Err("ctx_read Engine source is outside its rooted boundary".to_owned());
492 }
493 let rooted_path = crate::core::pathutil::canonicalize_secure(rooted_path)
500 .or_else(|error| {
501 if error.kind() == std::io::ErrorKind::NotFound {
502 unresolved_rooted_path(rooted_path, root).ok_or(error)
503 } else {
504 Err(error)
505 }
506 })
507 .map_err(|_| "ctx_read Engine source is outside its rooted boundary".to_owned())?;
508 if !rooted_path.starts_with(root) {
509 return Err("ctx_read Engine source is outside its rooted boundary".to_owned());
510 }
511 let (mut request, input) = NativeContextEngineRequest::ctx_read_snapshot_canonical(
512 &rooted_path,
513 raw_input,
514 30_000,
515 policy_admission,
516 )?;
517 if let Some(budget) = budget_tokens {
518 if budget == 0 {
519 return Err("ctx_read Engine budget must be positive".to_owned());
520 }
521 let identity = sha256_digest(&canonical::canonical_serialize(&(
522 "ctx-read-budget-v1",
523 &request.invocation_id,
524 budget,
525 )))?;
526 request.invocation_id =
527 EngineInvocationIdV1::new(format!("engine-invocation-{}", &identity.hex()[..32]))
528 .map_err(|error| error.to_string())?;
529 request.budget_tokens = Some(budget);
530 }
531 if let Some((task, plan)) = binding {
532 planning::persist_evidence(task)?;
534 planning::persist_evidence(plan)?;
535 let refs = planning::binding_refs(task, plan)?;
536 let identity = sha256_digest(&canonical::canonical_serialize(&(
537 "ctx-read-plan-v1",
538 &request.invocation_id,
539 &refs,
540 )))?;
541 request.invocation_id =
542 EngineInvocationIdV1::new(format!("engine-invocation-{}", &identity.hex()[..32]))
543 .map_err(|error| error.to_string())?;
544 request.source_refs.extend(refs);
545 }
546 self.execute_materialized(request, &input)
547 }
548
549 pub fn execute_ctx_read_rejection(
550 &self,
551 path: &str,
552 policy_admission: EnginePolicyAdmissionV1,
553 ) -> Result<(EngineInvocationV1, EngineObservationV1), String> {
554 if policy_admission.decision != EnginePolicyDecisionV1::Rejected {
555 return Err("ctx_read rejection requires a rejected policy admission".to_owned());
556 }
557 let request =
558 NativeContextEngineRequest::ctx_read_rejection(path, 30_000, policy_admission)?;
559 self.execute(request)
560 }
561
562 fn execute_inner(
563 &self,
564 request: NativeContextEngineRequest,
565 materialized_input: Option<&str>,
566 ) -> Result<(EngineInvocationV1, EngineObservationV1), String> {
567 let invocation = self.invocation_for(&request)?;
568 if request.policy_admission.decision == EnginePolicyDecisionV1::Rejected {
569 let observation = EngineObservationV1 {
570 schema_version: V1_SCHEMA_VERSION,
571 invocation_id: invocation.invocation_id.clone(),
572 status: EngineObservationStatusV1::Rejected,
573 output_ref: None,
574 output_digest: None,
575 source_lineage: invocation.source_refs.clone(),
576 measurements: Vec::new(),
577 failure: Some(EngineFailureV1 {
578 code: EngineFailureCodeV1::PolicyRejected,
579 retryable_by_host: false,
580 recovery_ref: None,
581 }),
582 receipt_link: None,
583 };
584 return self.persist_terminal(invocation, observation);
585 }
586
587 let native_invocation = CapabilityInvocation {
588 task_id: request.invocation_id.as_str().to_owned(),
589 capability_id: CAPABILITY_ID.to_owned(),
590 capability_version: CAPABILITY_VERSION.to_owned(),
591 input: CapabilityInput::ContextRequest {
592 paths: request.paths,
593 mode: request.mode,
594 budget_tokens: request.budget_tokens,
595 },
596 policy_constraints: PolicyConstraints::default(),
597 timeout_ms: request.timeout_ms,
598 };
599
600 let native_result = match materialized_input {
601 Some(input) => self
602 .adapter
603 .invoke_materialized_bounded(native_invocation, input.to_owned()),
604 None => self.adapter.invoke_with_output_identity(&native_invocation),
605 };
606 let observation = match native_result {
607 Ok(result) if result.input_digest == request.input_digest.as_str() => {
608 let output_digest = Sha256Digest::new(result.output_digest)
609 .map_err(|error| format!("native output digest is invalid: {error}"))?;
610 let output_ref = ProtocolReference::new(format!("output:{}", output_digest.hex()))
611 .map_err(|error| error.to_string())?;
612 match persist_output(output_digest.hex(), &result.output) {
613 Ok(()) => EngineObservationV1 {
614 schema_version: V1_SCHEMA_VERSION,
615 invocation_id: invocation.invocation_id.clone(),
616 status: EngineObservationStatusV1::Succeeded,
617 output_ref: Some(output_ref),
618 output_digest: Some(output_digest),
619 source_lineage: invocation.source_refs.clone(),
620 measurements: measured_observations(&result.result),
621 failure: None,
622 receipt_link: None,
623 },
624 Err(error) => {
625 tracing::warn!(%error, "failed to persist Engine output artifact");
626 storage_failure_observation(&invocation)
627 }
628 }
629 }
630 Ok(_) => source_integrity_failure(&invocation),
631 Err(failure) => adapter_failure(&invocation, &failure),
632 };
633 self.persist_terminal(invocation, observation)
634 }
635
636 fn invocation_for(
637 &self,
638 request: &NativeContextEngineRequest,
639 ) -> Result<EngineInvocationV1, String> {
640 let invocation = EngineInvocationV1 {
641 schema_version: V1_SCHEMA_VERSION,
642 invocation_id: request.invocation_id.clone(),
643 engine: engine_identity()?,
644 operation: native_operation()?,
645 input_ref: request.input_ref.clone(),
646 input_digest: request.input_digest.clone(),
647 source_refs: request.source_refs.clone(),
648 policy_admission: request.policy_admission.clone(),
649 };
650 invocation.validate().map_err(|error| error.to_string())?;
651 Ok(invocation)
652 }
653
654 fn persist_terminal(
655 &self,
656 invocation: EngineInvocationV1,
657 observation: EngineObservationV1,
658 ) -> Result<(EngineInvocationV1, EngineObservationV1), String> {
659 observation
660 .validate_for(&invocation)
661 .map_err(|error| format!("invalid Engine observation: {error}"))?;
662 let receipt_link = match persist_receipt(&invocation, &observation) {
663 Ok(receipt_link) => receipt_link,
664 Err(error) => {
665 let recovery_ref = persist_recovery(
666 &invocation,
667 &observation,
668 "receipt-persistence-failed",
669 )
670 .map_err(|recovery_error| {
671 format!(
672 "persist Engine receipt: {error}; persist Engine recovery artifact: {recovery_error}"
673 )
674 })?;
675 return Err(format!(
676 "persist Engine receipt: {error}; recovery_ref={}",
677 recovery_ref.as_str()
678 ));
679 }
680 };
681 receipt_sink::note_receipt(
684 self.adapter.root(),
685 receipt_link.receipt_id.as_str(),
686 &invocation,
687 &observation,
688 chrono::Utc::now(),
689 );
690 let mut observation = observation;
691 observation.receipt_link = Some(receipt_link);
692 observation
693 .validate_for(&invocation)
694 .map_err(|error| format!("invalid Engine observation receipt link: {error}"))?;
695 Ok((invocation, observation))
696 }
697}
698
699pub fn execute_transport_context_view(
701 root: &Path,
702 path: &str,
703) -> Result<EngineTransportResult, EngineTransportError> {
704 execute_transport_context_view_with_budget(root, path, None)
705}
706
707pub fn execute_transport_context_view_with_budget(
708 root: &Path,
709 path: &str,
710 budget_tokens: Option<u64>,
711) -> Result<EngineTransportResult, EngineTransportError> {
712 execute_transport_context_view_bound(root, path, budget_tokens, None)
713}
714
715pub fn execute_transport_context_view_with_plan(
716 root: &Path,
717 path: &str,
718 task: &lean_ctx_protocol::TaskEnvelopeV1,
719 plan: &lean_ctx_protocol::ExecutionPlanV1,
720) -> Result<EngineTransportResult, EngineTransportError> {
721 execute_transport_context_view_bound(root, path, plan.context_token_limit(), Some((task, plan)))
722}
723
724fn execute_transport_context_view_bound(
725 root: &Path,
726 path: &str,
727 budget_tokens: Option<u64>,
728 binding: Option<(
729 &lean_ctx_protocol::TaskEnvelopeV1,
730 &lean_ctx_protocol::ExecutionPlanV1,
731 )>,
732) -> Result<EngineTransportResult, EngineTransportError> {
733 if budget_tokens.is_some_and(|budget| budget == 0 || budget > MAX_TRANSPORT_BUDGET_TOKENS) {
734 return Err(EngineTransportError::InvalidRequest);
735 }
736 let root = bind_transport_root(root)?;
737 let (input, canonical_path) = read_transport_source(&root, path)?;
738 if input.len() > MAX_TRANSPORT_VIEW_BYTES {
739 return Err(EngineTransportError::ViewTooLarge);
740 }
741
742 let source_digest =
743 sha256_digest(input.as_bytes()).map_err(|_| EngineTransportError::Internal)?;
744 let source_ref = source_reference(&canonical_path)?;
745 let policy_admission = EnginePolicyAdmissionV1 {
746 policy_ref: ProtocolReference::new(ENGINE_TRANSPORT_POLICY_REF)
747 .map_err(|_| EngineTransportError::Internal)?,
748 decision: EnginePolicyDecisionV1::Admitted,
749 };
750 let engine =
751 NativeContextEngine::with_root(&root).map_err(|_| EngineTransportError::UnsafeRoot)?;
752 if let Some((task, plan)) = binding {
753 planning::validate_native_plan(task, plan, &policy_admission)
754 .map_err(|_| EngineTransportError::InvalidRequest)?;
755 }
756 let (invocation, observation) = engine
757 .execute_ctx_read_rooted_snapshot_with_budget(
758 &canonical_path,
759 &input,
760 policy_admission,
761 budget_tokens,
762 binding,
763 )
764 .map_err(|_| EngineTransportError::Internal)?;
765
766 let recovered_digest = parse_input_recovery_ref(&invocation.input_ref)?;
767 if recovered_digest != source_digest {
768 return Err(EngineTransportError::ObservationMismatch);
769 }
770 if !invocation.source_refs.contains(&source_ref) {
771 return Err(EngineTransportError::ObservationMismatch);
772 }
773
774 let view = verified_output_view(&invocation, &observation)?;
775 let recovery_ref = invocation.input_ref.clone();
776 Ok(EngineTransportResult {
777 view,
778 invocation: Some(invocation),
779 observation: Some(observation),
780 recovery: EngineTransportRecoveryDescriptor {
781 recovery_ref,
782 source_ref,
783 source_digest,
784 },
785 })
786}
787
788pub fn verified_output_view(
790 invocation: &EngineInvocationV1,
791 observation: &EngineObservationV1,
792) -> Result<EngineTransportView, EngineTransportError> {
793 if observation.status != EngineObservationStatusV1::Succeeded {
794 return Err(EngineTransportError::ObservationMismatch);
795 }
796 let receipt_link = observation
797 .receipt_link
798 .as_ref()
799 .ok_or(EngineTransportError::ReceiptMismatch)?;
800 if receipt_link.receipt_ref.as_str()
801 != format!("receipt:sha256:{}", receipt_link.receipt_digest.hex())
802 {
803 return Err(EngineTransportError::ReceiptMismatch);
804 }
805 let mut expected_observation = observation.clone();
806 expected_observation.receipt_link = None;
807 read_verified_engine_receipt(
808 &receipt_link.receipt_digest,
809 invocation,
810 &expected_observation,
811 )
812 .map_err(|_| EngineTransportError::ReceiptMismatch)?;
813
814 let output_digest = observation
815 .output_digest
816 .clone()
817 .ok_or(EngineTransportError::ObservationMismatch)?;
818 let expected_output_ref = format!("output:{}", output_digest.hex());
819 if observation
820 .output_ref
821 .as_ref()
822 .map(ProtocolReference::as_str)
823 != Some(expected_output_ref.as_str())
824 {
825 return Err(EngineTransportError::ObservationMismatch);
826 }
827 let output = artifact_store::read_content(OUTPUT_DIRECTORY, output_digest.hex(), "txt")
828 .map_err(|_| EngineTransportError::Internal)?;
829 if output.len() > MAX_TRANSPORT_VIEW_BYTES {
830 return Err(EngineTransportError::ViewTooLarge);
831 }
832 let output_text =
833 String::from_utf8(output).map_err(|_| EngineTransportError::UnsupportedInput)?;
834 Ok(EngineTransportView {
835 text: output_text,
836 output_ref: observation.output_ref.clone(),
837 output_digest: Some(output_digest),
838 })
839}
840
841pub fn recover_transport_source(
843 root: &Path,
844 path: &str,
845 recovery_ref: &ProtocolReference,
846 source_ref: &ProtocolReference,
847 source_digest: &Sha256Digest,
848) -> Result<EngineTransportResult, EngineTransportError> {
849 let root = bind_transport_root(root)?;
850 let expected_digest = parse_input_recovery_ref(recovery_ref)?;
851 if &expected_digest != source_digest {
852 return Err(EngineTransportError::MalformedRecoveryRef);
853 }
854
855 let (input, canonical_path) = read_transport_source(&root, path)?;
856 let actual_source_ref = source_reference(&canonical_path)?;
857 if &actual_source_ref != source_ref {
858 return Err(EngineTransportError::SourceChanged);
859 }
860 let actual_digest =
861 sha256_digest(input.as_bytes()).map_err(|_| EngineTransportError::Internal)?;
862 if &actual_digest != source_digest || actual_digest != expected_digest {
863 return Err(EngineTransportError::SourceChanged);
864 }
865 if input.len() > MAX_TRANSPORT_VIEW_BYTES {
866 return Err(EngineTransportError::ViewTooLarge);
867 }
868
869 Ok(EngineTransportResult {
870 view: EngineTransportView {
871 text: input,
872 output_ref: None,
873 output_digest: Some(actual_digest.clone()),
874 },
875 invocation: None,
876 observation: None,
877 recovery: EngineTransportRecoveryDescriptor {
878 recovery_ref: recovery_ref.clone(),
879 source_ref: source_ref.clone(),
880 source_digest: actual_digest,
881 },
882 })
883}
884
885pub fn bind_transport_root(root: &Path) -> Result<PathBuf, EngineTransportError> {
886 if root.as_os_str().is_empty() || crate::core::pathutil::is_broad_or_unsafe_root(root) {
887 return Err(EngineTransportError::UnsafeRoot);
888 }
889 let canonical = crate::core::pathutil::canonicalize_secure(root)
890 .map_err(|_| EngineTransportError::UnsafeRoot)?;
891 if !canonical.is_dir() || crate::core::pathutil::is_broad_or_unsafe_root(&canonical) {
892 return Err(EngineTransportError::UnsafeRoot);
893 }
894 Ok(canonical)
895}
896
897fn read_transport_source(
898 root: &Path,
899 path: &str,
900) -> Result<(String, String), EngineTransportError> {
901 if path.trim().is_empty() || path.as_bytes().contains(&0) {
902 return Err(EngineTransportError::InvalidRequest);
903 }
904 let requested = Path::new(path);
905 let candidate = if requested.is_absolute() {
906 requested.to_path_buf()
907 } else {
908 root.join(requested)
909 };
910 let jailed = crate::core::pathjail::jail_path(&candidate, root)
911 .map_err(|_| EngineTransportError::SourceOutsideRoot)?;
912 if relative_path_under_root(&candidate, root).is_none() && !jailed.starts_with(root) {
916 return Err(EngineTransportError::SourceOutsideRoot);
917 }
918 if contains_symlink_component(&candidate, root) {
919 return Err(EngineTransportError::SourceSymlink);
920 }
921 if !jailed.starts_with(root) {
926 return Err(EngineTransportError::SourceOutsideRoot);
927 }
928 let rooted = crate::core::rooted_read::read_file_lossy_rooted(
929 &jailed.to_string_lossy(),
930 &root.to_string_lossy(),
931 )
932 .map_err(|error| classify_source_error(&error))?;
933 let canonical = crate::core::pathutil::canonicalize_secure(Path::new(&rooted.canonical_path))
934 .map_err(|_| EngineTransportError::SourceOutsideRoot)?;
935 if !canonical.starts_with(root) {
936 return Err(EngineTransportError::SourceOutsideRoot);
937 }
938 Ok((rooted.content, canonical.to_string_lossy().into_owned()))
939}
940
941fn classify_source_error(error: &std::io::Error) -> EngineTransportError {
942 if error
943 .to_string()
944 .to_ascii_lowercase()
945 .contains("binary file")
946 {
947 EngineTransportError::UnsupportedInput
948 } else {
949 EngineTransportError::SourceUnavailable
950 }
951}
952
953fn contains_symlink_component(path: &Path, root: &Path) -> bool {
954 let Some(relative) = relative_path_under_root(path, root) else {
955 return true;
956 };
957 let mut current = root.to_path_buf();
958 for component in relative.components() {
959 match component {
960 std::path::Component::CurDir => {}
961 std::path::Component::ParentDir => {
962 if !current.pop() {
963 return true;
964 }
965 }
966 std::path::Component::Normal(name) => {
967 current.push(name);
968 if std::fs::symlink_metadata(¤t)
969 .is_ok_and(|metadata| crate::core::pathutil::is_symlink_or_reparse(&metadata))
970 {
971 return true;
972 }
973 }
974 std::path::Component::Prefix(_) | std::path::Component::RootDir => return true,
975 }
976 }
977 false
978}
979
980fn relative_path_under_root(path: &Path, root: &Path) -> Option<PathBuf> {
981 if let Ok(relative) = path.strip_prefix(root) {
982 return Some(relative.to_path_buf());
983 }
984
985 #[cfg(windows)]
986 {
987 use std::path::Component;
988
989 let normalized_path =
994 crate::core::pathutil::normalize_tool_path_lexical(&path.to_string_lossy())
995 .to_lowercase();
996 let normalized_root =
997 crate::core::pathutil::normalize_tool_path_lexical(&root.to_string_lossy())
998 .to_lowercase();
999 let path_components: Vec<_> = Path::new(&normalized_path).components().collect();
1000 let root_components: Vec<_> = Path::new(&normalized_root).components().collect();
1001 if path_components.len() < root_components.len()
1002 || !path_components
1003 .iter()
1004 .zip(&root_components)
1005 .all(|(path, root)| path.as_os_str() == root.as_os_str())
1006 {
1007 let canonical = crate::core::pathutil::canonicalize_secure(path).ok()?;
1011 if !canonical.starts_with(root) {
1012 return None;
1013 }
1014 return canonical.strip_prefix(root).ok().map(Path::to_path_buf);
1015 }
1016
1017 let mut relative = PathBuf::new();
1018 for component in path_components.into_iter().skip(root_components.len()) {
1019 match component {
1020 Component::CurDir => {}
1021 Component::ParentDir => relative.push(".."),
1022 Component::Normal(name) => relative.push(name),
1023 Component::Prefix(_) | Component::RootDir => return None,
1024 }
1025 }
1026 Some(relative)
1027 }
1028
1029 #[cfg(not(windows))]
1030 {
1031 None
1032 }
1033}
1034
1035fn unresolved_rooted_path(path: &Path, root: &Path) -> Option<PathBuf> {
1036 for ancestor in path.ancestors() {
1037 if let Ok(mut rooted) = crate::core::pathutil::canonicalize_secure(ancestor) {
1038 if !rooted.starts_with(root) {
1039 return None;
1040 }
1041 for component in path.strip_prefix(ancestor).ok()?.components() {
1042 match component {
1043 std::path::Component::CurDir => {}
1044 std::path::Component::ParentDir => {
1045 if rooted == root || !rooted.pop() {
1046 return None;
1047 }
1048 }
1049 std::path::Component::Normal(name) => rooted.push(name),
1050 std::path::Component::Prefix(_) | std::path::Component::RootDir => {
1051 return None;
1052 }
1053 }
1054 }
1055 return rooted.starts_with(root).then_some(rooted);
1056 }
1057 }
1058 None
1059}
1060
1061fn source_reference(canonical_path: &str) -> Result<ProtocolReference, EngineTransportError> {
1062 let digest =
1063 sha256_digest(canonical_path.as_bytes()).map_err(|_| EngineTransportError::Internal)?;
1064 ProtocolReference::new(format!("{SOURCE_REF_PREFIX}{}", digest.hex()))
1065 .map_err(|_| EngineTransportError::Internal)
1066}
1067
1068fn parse_input_recovery_ref(
1069 reference: &ProtocolReference,
1070) -> Result<Sha256Digest, EngineTransportError> {
1071 let Some(hex) = reference.as_str().strip_prefix(INPUT_REF_PREFIX) else {
1072 return Err(EngineTransportError::MalformedRecoveryRef);
1073 };
1074 Sha256Digest::new(format!("sha256:{hex}"))
1075 .map_err(|_| EngineTransportError::MalformedRecoveryRef)
1076}
1077
1078fn engine_identity() -> Result<ResolvedLocalEngineIdentityV1, String> {
1079 let identity = ResolvedLocalEngineIdentityV1 {
1080 engine_id: ENGINE_ID.to_owned(),
1081 engine_version: SemanticVersion::new(env!("CARGO_PKG_VERSION"))
1082 .map_err(|error| error.to_string())?,
1083 };
1084 identity.validate().map_err(|error| error.to_string())?;
1085 Ok(identity)
1086}
1087
1088fn native_operation() -> Result<EngineOperationV1, String> {
1089 Ok(EngineOperationV1 {
1090 capability_id: CapabilityId::new(CAPABILITY_ID).map_err(|error| error.to_string())?,
1091 capability_version: SemanticVersion::new(CAPABILITY_VERSION)
1092 .map_err(|error| error.to_string())?,
1093 })
1094}
1095
1096fn measured_observations(
1097 result: &crate::core::ocla::invocation::CapabilityResult,
1098) -> Vec<EngineMeasurementV1> {
1099 let mut measurements = vec![
1100 measured("input_tokens", "token", result.observation.input_tokens),
1101 measured("output_tokens", "token", result.output_tokens),
1102 ];
1103 for name in ["compression_saved_tokens", "compression_rate_milli"] {
1104 if let Some(value) = result.observation.metrics.get(name) {
1105 let unit = if name == "compression_saved_tokens" {
1106 "token"
1107 } else {
1108 "milliunit"
1109 };
1110 measurements.push(measured(name, unit, *value));
1111 }
1112 }
1113 measurements
1114}
1115
1116fn measured(name: &str, unit: &str, value: u64) -> EngineMeasurementV1 {
1117 EngineMeasurementV1 {
1118 name: name.to_owned(),
1119 unit: unit.to_owned(),
1120 classification: EngineValueClassificationV1::Measured,
1121 value: Some(value),
1122 }
1123}
1124
1125fn source_integrity_failure(invocation: &EngineInvocationV1) -> EngineObservationV1 {
1126 failed_observation(
1127 invocation,
1128 EngineFailureCodeV1::SourceIntegrityMismatch,
1129 Some(invocation.input_ref.clone()),
1130 )
1131}
1132
1133fn adapter_failure(
1134 invocation: &EngineInvocationV1,
1135 failure: &NativeContextInvocationFailure,
1136) -> EngineObservationV1 {
1137 let code = match failure {
1138 NativeContextInvocationFailure::SourceUnavailable(_) => {
1139 EngineFailureCodeV1::SourceUnavailable
1140 }
1141 NativeContextInvocationFailure::ResourceLimit(_) => EngineFailureCodeV1::ResourceLimit,
1142 NativeContextInvocationFailure::UnsupportedInput => {
1143 EngineFailureCodeV1::UnsupportedOperation
1144 }
1145 NativeContextInvocationFailure::InvalidRequest(_) => EngineFailureCodeV1::Internal,
1146 };
1147 let recovery_ref = matches!(code, EngineFailureCodeV1::SourceUnavailable)
1148 .then(|| invocation.input_ref.clone());
1149 failed_observation(invocation, code, recovery_ref)
1150}
1151
1152fn failed_observation(
1153 invocation: &EngineInvocationV1,
1154 code: EngineFailureCodeV1,
1155 recovery_ref: Option<ProtocolReference>,
1156) -> EngineObservationV1 {
1157 EngineObservationV1 {
1158 schema_version: V1_SCHEMA_VERSION,
1159 invocation_id: invocation.invocation_id.clone(),
1160 status: EngineObservationStatusV1::Failed,
1161 output_ref: None,
1162 output_digest: None,
1163 source_lineage: invocation.source_refs.clone(),
1164 measurements: Vec::new(),
1165 failure: Some(EngineFailureV1 {
1166 code,
1167 retryable_by_host: false,
1168 recovery_ref,
1169 }),
1170 receipt_link: None,
1171 }
1172}
1173
1174fn storage_failure_observation(invocation: &EngineInvocationV1) -> EngineObservationV1 {
1175 let mut observation = failed_observation(
1176 invocation,
1177 EngineFailureCodeV1::Internal,
1178 Some(invocation.input_ref.clone()),
1179 );
1180 if let Some(failure) = observation.failure.as_mut() {
1181 failure.retryable_by_host = true;
1182 }
1183 observation
1184}
1185
1186fn persist_output(digest: &str, bytes: &[u8]) -> Result<(), String> {
1187 persist_content(OUTPUT_DIRECTORY, digest, "txt", bytes)
1188}
1189
1190fn persist_receipt(
1191 invocation: &EngineInvocationV1,
1192 observation: &EngineObservationV1,
1193) -> Result<EngineReceiptLinkV1, String> {
1194 let bytes = canonical_engine_receipt_artifact_bytes(invocation, observation);
1195 let digest = sha256_digest(&bytes)?;
1196 persist_content(RECEIPT_DIRECTORY, digest.hex(), "json", &bytes)?;
1197 Ok(EngineReceiptLinkV1 {
1198 schema_version: V1_SCHEMA_VERSION,
1199 receipt_id: ReceiptId::new(format!("engine-receipt-{}", &digest.hex()[..32]))
1200 .map_err(|error| error.to_string())?,
1201 receipt_ref: ProtocolReference::new(format!("receipt:sha256:{}", digest.hex()))
1202 .map_err(|error| error.to_string())?,
1203 receipt_digest: digest,
1204 invocation_id: invocation.invocation_id.clone(),
1205 })
1206}
1207
1208fn persist_recovery(
1209 invocation: &EngineInvocationV1,
1210 observation: &EngineObservationV1,
1211 failure_class: &'static str,
1212) -> Result<ProtocolReference, String> {
1213 let artifact = EngineRecoveryArtifactV1 {
1214 schema_version: V1_SCHEMA_VERSION,
1215 invocation: invocation.clone(),
1216 observation: observation.clone(),
1217 failure_class,
1218 };
1219 let bytes = canonical::canonical_serialize(&artifact);
1220 let digest = sha256_digest(&bytes)?;
1221 persist_content(RECOVERY_DIRECTORY, digest.hex(), "json", &bytes)?;
1222 ProtocolReference::new(format!("recovery:sha256:{}", digest.hex()))
1223 .map_err(|error| error.to_string())
1224}
1225
1226fn persist_content(
1227 directory: &str,
1228 digest: &str,
1229 extension: &str,
1230 bytes: &[u8],
1231) -> Result<(), String> {
1232 artifact_store::persist_content(directory, digest, extension, bytes).map(|_| ())
1233}
1234
1235fn sha256_digest(bytes: &[u8]) -> Result<Sha256Digest, String> {
1236 Sha256Digest::new(format!(
1237 "sha256:{}",
1238 crate::core::agent_identity::hex_encode(&Sha256::digest(bytes))
1239 ))
1240 .map_err(|error| error.to_string())
1241}
1242
1243#[cfg(test)]
1244mod tests;