1mod checkpoint;
6mod checkpoint_resume;
7mod checkpoint_transfer;
8mod context_decision;
9mod context_protocol;
10mod outcome;
11mod outcome_transport;
12mod source_recheck;
13mod source_replay;
14mod task_scope;
15pub use checkpoint::HostCheckpointRequest;
16
17#[cfg(unix)]
24pub fn admit_foreign_document_narrative(
25 document: &str,
26 value: &serde_json::Value,
27 tool: &'static str,
28) -> anyhow::Result<()> {
29 checkpoint_transfer::admit_foreign_document_narrative(document, value, tool)
30}
31use checkpoint_resume::CheckpointResumeTarget;
32pub use checkpoint_resume::HostCheckpointResumeRequest;
33use checkpoint_transfer::CheckpointImportAdmission;
34pub use checkpoint_transfer::{HostCheckpointImportRequest, HostCheckpointPackageRequest};
35pub use outcome::HostOutcomeRequest;
36
37use std::{
38 fs::File,
39 io::Read,
40 path::{Path, PathBuf},
41 time::{Duration, Instant},
42};
43
44use ed25519_dalek::SigningKey;
45use lean_ctx_protocol::{
46 AcceptanceState, ContextBalanceV1, DecisionRecordV1, EngineInvocationV1,
47 EngineObservationStatusV1, EngineObservationV1, EngineValueClassificationV1, ExecutionPlanV1,
48 ReceiptChainLinkV1, ReceiptEvidenceKindV1, ReceiptEvidenceRefV1, ReceiptOutcomeLinkV1,
49 ReceiptTerminalStatusV1, ReceiptValueClassificationV1, ReceiptValueV1, Sha256Digest,
50 SignatureStatus, TaskEnvelopeV1, UtcTimestamp,
51};
52use serde::Deserialize;
53use sha2::{Digest, Sha256};
54use zeroize::Zeroizing;
55
56use super::{
57 CanonicalReceiptRecordV1, ExecutionEvent, ExecutionLedgerStore, PublishedCanonicalReceipt,
58 ReceiptSignerAdmissionV1, record_canonical_engine_receipt,
59};
60use crate::core::execution_ledger::producer::validate_signer_admission;
61use crate::core::{
62 canonical::canonical_serialize,
63 context_kernel::autopilot::TaskAutopilotDecision,
64 engine_interface::{
65 MAX_TRANSPORT_BUDGET_TOKENS, persist_engine_artifact_content, verified_output_view,
66 },
67};
68
69const HOST_LIMIT: usize = 16 * 1024;
70
71#[derive(Deserialize)]
72#[serde(deny_unknown_fields)]
73struct HostSettings {
74 schema_version: u32,
75 #[serde(deserialize_with = "deserialize_secret")]
76 signing_key_hex: Zeroizing<String>,
77 signer: SignerAdmission,
78 ledger_path: PathBuf,
79 #[serde(default)]
81 allow_context_decision_signing: bool,
82 #[serde(default)]
84 allow_outcome_signing: bool,
85 #[serde(default)]
87 allow_checkpoint_signing: bool,
88 #[serde(default)]
90 checkpoint_resume: Option<CheckpointResumeTarget>,
91 #[serde(default)]
93 allow_checkpoint_transfer_export: bool,
94 #[serde(default)]
96 checkpoint_import: Option<CheckpointImportAdmission>,
97 #[serde(default)]
98 checkpoint_continue: Option<checkpoint_transfer::CheckpointContinueAdmission>,
99 #[serde(default)]
101 checkpoint_source_files: Option<Vec<source_recheck::ReceivingFileBinding>>,
102 #[serde(default)]
103 checkpoint_source_providers: Option<Vec<source_recheck::ReceivingProviderBinding>>,
104 #[serde(default)]
106 task_scope: Option<task_scope::HostTaskScope>,
107}
108
109fn deserialize_secret<'de, D: serde::Deserializer<'de>>(
110 deserializer: D,
111) -> Result<Zeroizing<String>, D::Error> {
112 String::deserialize(deserializer).map(Zeroizing::new)
113}
114
115#[derive(Deserialize)]
116#[serde(deny_unknown_fields)]
117pub(super) struct SignerAdmission {
118 key_id: String,
119 public_key_digest: Sha256Digest,
120 admitted_at: UtcTimestamp,
121 expires_at: UtcTimestamp,
122 revoked_at: Option<UtcTimestamp>,
123}
124
125impl SignerAdmission {
126 pub(crate) fn snapshot(&self) -> ReceiptSignerAdmissionV1 {
127 ReceiptSignerAdmissionV1 {
128 key_id: self.key_id.clone(),
129 public_key_digest: self.public_key_digest.clone(),
130 admitted_at: self.admitted_at.clone(),
131 expires_at: self.expires_at.clone(),
132 revoked_at: self.revoked_at.clone(),
133 }
134 }
135}
136
137pub struct HostReceiptAuthority {
143 signing_key: SigningKey,
144 signer_admission: ReceiptSignerAdmissionV1,
145 ledger: ExecutionLedgerStore,
146 allow_context_decision_signing: bool,
147 allow_outcome_signing: bool,
148 allow_checkpoint_signing: bool,
149 checkpoint_resume: Option<CheckpointResumeTarget>,
150 allow_checkpoint_transfer_export: bool,
151 checkpoint_import: Option<CheckpointImportAdmission>,
152 checkpoint_continue: Option<checkpoint_transfer::CheckpointContinueAdmission>,
153 checkpoint_source_files: Option<Vec<source_recheck::ReceivingFileBinding>>,
154 checkpoint_source_providers: Option<Vec<source_recheck::ReceivingProviderBinding>>,
155 task_scope: Option<task_scope::HostTaskScope>,
156}
157
158pub struct HostReceiptAttempt {
160 task: TaskEnvelopeV1,
161 plan: ExecutionPlanV1,
162 context_decision: Option<DecisionRecordV1>,
163 context: Option<TaskAutopilotDecision>,
164 _task_lock: File,
165}
166
167impl HostReceiptAttempt {
168 pub fn context_decision(&self) -> Option<&DecisionRecordV1> {
169 self.context_decision.as_ref()
170 }
171}
172
173impl HostReceiptAuthority {
174 pub fn from_reader(input: &mut dyn Read) -> Result<Self, &'static str> {
176 Self::from_reader_scoped(input, None)
177 }
178
179 pub fn from_reader_scoped(
182 input: &mut dyn Read,
183 ledger_root: Option<&Path>,
184 ) -> Result<Self, &'static str> {
185 let bytes = read_bounded(input, HOST_LIMIT, "invalid_host_configuration")?;
186 let settings: HostSettings =
187 serde_json::from_slice(&bytes).map_err(|_| "invalid_host_configuration")?;
188 let key = parse_key(settings.signing_key_hex.as_str())?;
189 let admission = settings.signer.snapshot();
190 validate_settings(&settings, &admission)?;
191 validate_admission(&admission, &key)?;
192 let ledger = match ledger_root {
193 Some(root) => {
194 let relative = settings
195 .ledger_path
196 .strip_prefix(root)
197 .map_err(|_| "host_ledger_outside_root")?;
198 ExecutionLedgerStore::new_verified(root, relative)
199 .map_err(|_| "invalid_host_ledger_root")?
200 }
201 None => ExecutionLedgerStore::new(settings.ledger_path),
202 };
203 Ok(Self {
204 signing_key: key,
205 signer_admission: admission,
206 ledger,
207 allow_context_decision_signing: settings.allow_context_decision_signing,
208 allow_outcome_signing: settings.allow_outcome_signing,
209 allow_checkpoint_signing: settings.allow_checkpoint_signing,
210 checkpoint_resume: settings.checkpoint_resume,
211 allow_checkpoint_transfer_export: settings.allow_checkpoint_transfer_export,
212 checkpoint_import: settings.checkpoint_import,
213 checkpoint_continue: settings.checkpoint_continue,
214 checkpoint_source_files: settings.checkpoint_source_files,
215 checkpoint_source_providers: settings.checkpoint_source_providers,
216 task_scope: settings.task_scope,
217 })
218 }
219
220 pub fn validate_current(&self) -> Result<(), &'static str> {
222 if !valid_key_id(&self.signer_admission.key_id) {
223 return Err("invalid_host_configuration");
224 }
225 validate_admission(&self.signer_admission, &self.signing_key)
226 }
227
228 pub fn require_context_decision_signing(&self) -> Result<(), &'static str> {
230 self.validate_current()?;
231 if !self.allow_context_decision_signing {
232 return Err("host_context_decision_not_authorized");
233 }
234 Ok(())
235 }
236
237 pub fn begin(
239 &self,
240 task: &TaskEnvelopeV1,
241 plan: &ExecutionPlanV1,
242 ) -> Result<HostReceiptAttempt, &'static str> {
243 self.begin_with_context(task, plan, None)
244 }
245
246 pub fn begin_with_context(
248 &self,
249 task: &TaskEnvelopeV1,
250 plan: &ExecutionPlanV1,
251 context: Option<&TaskAutopilotDecision>,
252 ) -> Result<HostReceiptAttempt, &'static str> {
253 self.validate_current()?;
254 task.validate().map_err(|_| "invalid_host_task")?;
255 plan.validate().map_err(|_| "invalid_host_plan")?;
256 if task.task_id != plan.task_id
257 || plan
258 .executor_agent_id
259 .as_ref()
260 .is_some_and(|agent| agent != &task.agent_id)
261 || plan
262 .context_token_limit()
263 .is_some_and(|limit| limit == 0 || limit > MAX_TRANSPORT_BUDGET_TOKENS)
264 {
265 return Err("invalid_host_plan");
266 }
267
268 let task_lock = task_lock(task)?;
269 if !self
270 .ledger
271 .by_task_verified(task.task_id.as_str())
272 .map_err(|_| "host_ledger_unavailable")?
273 .is_empty()
274 {
275 return Err("host_task_already_recorded");
276 }
277 let context_decision = if self.allow_context_decision_signing {
278 Some(context_decision::capture(
279 self,
280 task,
281 plan,
282 context.ok_or("host_context_decision_missing")?,
283 )?)
284 } else {
285 None
286 };
287 record_attempt(task, &self.ledger)?;
288 Ok(HostReceiptAttempt {
289 task: task.clone(),
290 plan: plan.clone(),
291 context_decision,
292 context: self
293 .allow_context_decision_signing
294 .then(|| context.cloned())
295 .flatten(),
296 _task_lock: task_lock,
297 })
298 }
299
300 pub fn publish(
302 &self,
303 attempt: &HostReceiptAttempt,
304 invocation: &EngineInvocationV1,
305 observation: &EngineObservationV1,
306 delivered: &str,
307 ) -> Result<PublishedCanonicalReceipt, &'static str> {
308 self.validate_current()?;
309 if attempt.task.task_id != attempt.plan.task_id {
310 return Err("invalid_host_plan");
311 }
312 if observation.status != EngineObservationStatusV1::Succeeded {
313 return Err("host_engine_evidence_unavailable");
314 }
315
316 let view = verified_output_view(invocation, observation)
317 .map_err(|_| "host_engine_evidence_unavailable")?;
318 if view.text != delivered {
319 return Err("host_engine_evidence_unavailable");
320 }
321
322 let measurement = |name: &str| -> Result<u64, &'static str> {
323 observation
324 .measurements
325 .iter()
326 .find(|entry| {
327 entry.name == name
328 && entry.unit == "token"
329 && entry.classification == EngineValueClassificationV1::Measured
330 })
331 .and_then(|entry| entry.value)
332 .ok_or("host_engine_measurement_unavailable")
333 };
334 let original_tokens = measurement("input_tokens")?;
335 let delivered_tokens = measurement("output_tokens")?;
336 if attempt
337 .plan
338 .context_token_limit()
339 .is_some_and(|limit| delivered_tokens > limit)
340 {
341 return Err("host_context_budget_exceeded");
342 }
343
344 let receipt_link = observation
345 .receipt_link
346 .as_ref()
347 .ok_or("host_engine_evidence_unavailable")?;
348 let observation_digest = digest(&canonical_serialize(observation))?;
349 let values = [
350 ("input_tokens", original_tokens),
351 ("output_tokens", delivered_tokens),
352 ]
353 .into_iter()
354 .map(|(name, value)| ReceiptValueV1 {
355 name: name.into(),
356 unit: "token".into(),
357 classification: ReceiptValueClassificationV1::Measured,
358 value: Some(value),
359 evidence_digests: vec![observation_digest.clone()],
360 formula_digest: None,
361 price_table_digest: None,
362 reconciliation_digest: None,
363 })
364 .chain([ReceiptValueV1 {
365 name: "provider_cost_micros".into(),
366 unit: "microusd".into(),
367 classification: ReceiptValueClassificationV1::Unavailable,
368 value: None,
369 evidence_digests: vec![],
370 formula_digest: None,
371 price_table_digest: None,
372 reconciliation_digest: None,
373 }])
374 .collect();
375 let chain_id = digest(&canonical_serialize(&(
376 "host-receipt-chain-v1",
377 &attempt.task,
378 &attempt.plan,
379 )))?;
380 let mut evidence_refs = vec![ReceiptEvidenceRefV1 {
381 kind: ReceiptEvidenceKindV1::Measurement,
382 uri: lean_ctx_protocol::ProtocolReference::new(format!(
383 "artifact://engine/receipts/{}",
384 receipt_link.receipt_digest.hex(),
385 ))
386 .map_err(|_| "host_engine_evidence_unavailable")?,
387 digest: receipt_link.receipt_digest.clone(),
388 media_type: "application/json".into(),
389 signature_status: SignatureStatus::NotSigned,
390 }];
391 if let Some(decision) = &attempt.context_decision {
392 let decision_digest = digest(&canonical_serialize(decision))?;
395 evidence_refs.push(ReceiptEvidenceRefV1 {
396 kind: ReceiptEvidenceKindV1::Runtime,
397 uri: lean_ctx_protocol::ProtocolReference::new(format!(
398 "artifact://execution/evidence/{}",
399 decision_digest.hex(),
400 ))
401 .map_err(|_| "host_context_decision_invalid")?,
402 digest: decision_digest,
403 media_type: "application/json".into(),
404 signature_status: SignatureStatus::Unverified,
405 });
406 }
407 record_canonical_engine_receipt(
408 &attempt.task,
409 &attempt.plan,
410 invocation,
411 observation,
412 CanonicalReceiptRecordV1 {
413 context_balance: ContextBalanceV1 {
414 original_tokens,
415 materialized_tokens: delivered_tokens,
416 delivered_tokens,
417 provider_billed_tokens: 0,
418 },
419 status: ReceiptTerminalStatusV1::Succeeded,
420 values,
421 outcome: ReceiptOutcomeLinkV1 {
422 state: AcceptanceState::Unknown,
423 outcome_id: None,
424 outcome_ref: None,
425 acceptance_evidence_digest: None,
426 },
427 evidence_refs,
428 chain: ReceiptChainLinkV1 {
429 chain_id: chain_id.as_str().into(),
430 sequence_number: 1,
431 previous_receipt_id: None,
432 previous_signature_digest: None,
433 },
434 issued_at: now()?,
435 signer_admission: self.signer_admission.clone(),
436 },
437 &self.signing_key,
438 &self.ledger,
439 )
440 .map_err(|_| "host_receipt_publication_failed")
441 }
442}
443
444fn validate_settings(
445 settings: &HostSettings,
446 admission: &ReceiptSignerAdmissionV1,
447) -> Result<(), &'static str> {
448 if settings.schema_version != 1
449 || !settings.ledger_path.is_absolute()
450 || !valid_key_id(&admission.key_id)
451 {
452 return Err("invalid_host_configuration");
453 }
454 Ok(())
455}
456
457fn validate_admission(
458 admission: &ReceiptSignerAdmissionV1,
459 key: &SigningKey,
460) -> Result<(), &'static str> {
461 validate_signer_admission(admission, &key.verifying_key(), &now()?)
462 .map_err(|_| "host_signer_not_admitted")
463}
464
465fn valid_key_id(value: &str) -> bool {
466 !value.is_empty()
467 && value.len() <= 128
468 && value.as_bytes()[0].is_ascii_alphanumeric()
469 && value
470 .bytes()
471 .all(|byte| byte.is_ascii_alphanumeric() || b"._:/-".contains(&byte))
472 && !value.starts_with("base64:")
473 && !value.starts_with("hex:")
474}
475
476fn record_attempt(
477 task: &TaskEnvelopeV1,
478 ledger: &ExecutionLedgerStore,
479) -> Result<(), &'static str> {
480 let bytes = canonical_serialize(task);
481 let task_ref = digest(&bytes)?;
482 drop(
483 persist_engine_artifact_content("execution/evidence", task_ref.hex(), "json", &bytes)
484 .map_err(|_| "host_task_evidence_unavailable")?,
485 );
486 if !ledger
487 .append_if_new(ExecutionEvent::TaskStarted {
488 task_id: task.task_id.as_str().into(),
489 trace_id: task.trace_id.as_str().into(),
490 envelope_ref: task_ref.as_str().into(),
491 timestamp: task.created_at.as_str().into(),
492 sequence_number: 0,
493 prev_hash: String::new(),
494 })
495 .map_err(|_| "host_ledger_unavailable")?
496 {
497 return Err("host_task_already_recorded");
498 }
499 Ok(())
500}
501
502#[cfg(not(windows))]
503fn open_task_lock_file(identity: &str, bytes: &[u8]) -> Result<File, String> {
504 persist_engine_artifact_content("execution/host-locks", identity, "json", bytes)
505}
506
507#[cfg(not(windows))]
508fn publish_task_identity(_identity: &str, _bytes: &[u8]) -> Result<(), String> {
509 Ok(())
510}
511
512#[cfg(windows)]
516fn open_task_lock_file(identity: &str, _bytes: &[u8]) -> Result<File, String> {
517 crate::core::engine_interface::open_engine_artifact_lock("execution/host-locks", identity)
518}
519
520#[cfg(windows)]
524fn publish_task_identity(identity: &str, bytes: &[u8]) -> Result<(), String> {
525 persist_engine_artifact_content("execution/host-locks", identity, "json", bytes).map(drop)
526}
527
528fn task_lock_identity(task: &TaskEnvelopeV1) -> Result<(String, Vec<u8>), &'static str> {
529 let bytes = canonical_serialize(&(
530 "host-receipt-task-v1",
531 &task.project_id,
532 &task.tenant_id,
533 &task.task_id,
534 ));
535 let identity = digest(&bytes)?;
536 Ok((identity.hex().to_owned(), bytes))
537}
538
539fn task_lock_unavailable(step: &str, cause: &str) -> &'static str {
541 #[cfg(test)]
542 eprintln!("host task lock {step} failed: {cause}");
543 #[cfg(not(test))]
544 let _ = (step, cause);
545 "host_task_lock_unavailable"
546}
547
548fn task_lock(task: &TaskEnvelopeV1) -> Result<File, &'static str> {
549 let (identity, bytes) = task_lock_identity(task)?;
550 let file = open_task_lock_file(&identity, &bytes)
551 .map_err(|error| task_lock_unavailable("open", &error))?;
552 let started = Instant::now();
553 loop {
554 match fs2::FileExt::try_lock_exclusive(&file) {
555 Ok(()) => {
556 publish_task_identity(&identity, &bytes)
557 .map_err(|error| task_lock_unavailable("publish", &error))?;
558 return Ok(file);
559 }
560 Err(error)
561 if crate::core::file_lock::is_contended(&error)
562 && started.elapsed() < Duration::from_secs(2) =>
563 {
564 std::thread::sleep(Duration::from_millis(10));
565 }
566 Err(_) => return Err("host_task_busy"),
567 }
568 }
569}
570
571fn read_bounded(
572 input: &mut dyn Read,
573 limit: usize,
574 code: &'static str,
575) -> Result<Zeroizing<Vec<u8>>, &'static str> {
576 let mut bytes = Zeroizing::new(Vec::new());
577 input
578 .take(limit as u64 + 1)
579 .read_to_end(&mut bytes)
580 .map_err(|_| code)?;
581 if bytes.len() > limit {
582 return Err(code);
583 }
584 Ok(bytes)
585}
586
587pub fn parse_key(value: &str) -> Result<SigningKey, &'static str> {
588 if value.len() != 64
589 || !value
590 .bytes()
591 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
592 {
593 return Err("invalid_signing_key");
594 }
595 let mut bytes = Zeroizing::new([0_u8; 32]);
596 for (index, byte) in bytes.iter_mut().enumerate() {
597 *byte = u8::from_str_radix(&value[index * 2..index * 2 + 2], 16)
598 .map_err(|_| "invalid_signing_key")?;
599 }
600 Ok(SigningKey::from_bytes(&bytes))
601}
602
603fn now() -> Result<UtcTimestamp, &'static str> {
604 UtcTimestamp::new(chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
605 .map_err(|_| "host_clock_unavailable")
606}
607
608pub fn digest(bytes: &[u8]) -> Result<Sha256Digest, &'static str> {
609 Sha256Digest::new(format!(
610 "sha256:{}",
611 crate::core::agent_identity::hex_encode(&Sha256::digest(bytes))
612 ))
613 .map_err(|_| "host_digest_unavailable")
614}
615
616#[cfg(test)]
617mod tests {
618 use super::*;
619
620 #[test]
621 fn task_lock_is_bounded_and_reusable_after_owner_exit() {
622 let _data = crate::core::data_dir::isolated_data_dir();
623 let task: TaskEnvelopeV1 = serde_json::from_value(serde_json::json!({
624 "schema_version":1,"task_id":"host-lock-task","trace_id":"host-lock-trace",
625 "project_id":"host-project","session_id":"host-session","agent_id":"host-agent",
626 "complexity":"unknown","created_at":"2026-01-01T00:00:00Z"
627 }))
628 .unwrap();
629 let owner = task_lock(&task).unwrap();
630 assert_eq!(task_lock(&task).err(), Some("host_task_busy"));
631 drop(owner);
632 assert!(task_lock(&task).is_ok());
633 }
634}