1use std::collections::BTreeMap;
32use std::path::PathBuf;
33
34use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
35use serde::{Deserialize, Serialize};
36use sha2::{Digest, Sha256};
37
38#[derive(Debug, thiserror::Error)]
39pub enum BundleError {
40 #[error("manifest is not valid TOML: {0}")]
41 InvalidToml(String),
42 #[error("manifest is not valid JSON: {0}")]
43 InvalidJson(String),
44 #[error("manifest validation failed: {0}")]
45 Validation(String),
46 #[error("signature verification failed: {0}")]
47 SignatureInvalid(String),
48 #[error("publisher key is malformed: {0}")]
49 KeyMalformed(String),
50 #[error("missing publisher info on signed manifest")]
51 PublisherMissing,
52 #[error("bundle I/O error: {0}")]
53 Io(#[from] std::io::Error),
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct AgentManifest {
60 pub agent: AgentIdentity,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub publisher: Option<PublisherInfo>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub runtime: Option<RuntimeRequirements>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub lifecycle: Option<LifecyclePolicy>,
67 pub transport: TransportSpec,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub capabilities: Option<CapabilityDeclarations>,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct AgentIdentity {
75 pub id: String,
76 pub name: String,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub namespace: Option<String>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub version: Option<String>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub description: Option<String>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub license: Option<String>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub homepage: Option<String>,
87}
88
89#[derive(Debug, Clone, Default, Serialize, Deserialize)]
95pub struct PublisherInfo {
96 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub key_id: Option<String>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub signature: Option<String>,
100}
101
102#[derive(Debug, Clone, Default, Serialize, Deserialize)]
103#[serde(rename_all = "snake_case")]
104pub struct RuntimeRequirements {
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub car_min_version: Option<String>,
107 #[serde(default = "default_bundle_format_version")]
108 pub bundle_format_version: u32,
109}
110
111fn default_bundle_format_version() -> u32 {
112 1
113}
114
115#[derive(Debug, Clone, Default, Serialize, Deserialize)]
116pub struct LifecyclePolicy {
117 #[serde(default)]
118 pub stateful: bool,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub persistence: Option<String>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub default_inference_complexity: Option<String>,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
126#[serde(tag = "kind", rename_all = "snake_case")]
127pub enum TransportSpec {
128 PureData,
129 ExternalProcess(ExternalProcessTransport),
130}
131
132#[derive(Debug, Clone, Default, Serialize, Deserialize)]
133pub struct ExternalProcessTransport {
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub command: Option<String>,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub interpreter: Option<String>,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub sha256: Option<String>,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub binary_url: Option<String>,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub health_url: Option<String>,
165 #[serde(default)]
166 pub args: Vec<String>,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub cwd: Option<PathBuf>,
169 #[serde(default)]
170 pub env: BTreeMap<String, String>,
171 #[serde(default)]
172 pub restart: RestartPolicy,
173 #[serde(default = "default_max_restarts")]
174 pub max_restarts: u32,
175 #[serde(default = "default_backoff")]
176 pub backoff_secs: u64,
177 #[serde(default)]
178 pub auto_start: bool,
179 #[serde(default)]
180 pub token: String,
181}
182
183fn default_max_restarts() -> u32 {
184 10
185}
186
187fn default_backoff() -> u64 {
188 5
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
196#[serde(rename_all = "snake_case")]
197pub enum RestartPolicy {
198 Never,
199 #[default]
200 OnFailure,
201 Always,
202}
203
204#[derive(Debug, Clone, Default, Serialize, Deserialize)]
205pub struct CapabilityDeclarations {
206 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
207 pub required: BTreeMap<String, Vec<String>>,
208 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
209 pub optional: BTreeMap<String, Vec<String>>,
210 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
211 pub denied: BTreeMap<String, Vec<String>>,
212}
213
214impl AgentManifest {
215 pub fn is_pure_data(&self) -> bool {
216 matches!(self.transport, TransportSpec::PureData)
217 }
218
219 pub fn is_remote_service(&self) -> bool {
220 matches!(
221 &self.transport,
222 TransportSpec::ExternalProcess(t) if t.health_url.is_some() && t.command.is_none()
223 )
224 }
225
226 pub fn from_toml_str(text: &str) -> Result<Self, BundleError> {
230 toml::from_str(text).map_err(|e| BundleError::InvalidToml(e.to_string()))
231 }
232
233 pub fn to_toml_string(&self) -> Result<String, BundleError> {
236 toml::to_string_pretty(self).map_err(|e| BundleError::InvalidToml(e.to_string()))
237 }
238}
239
240pub fn canonical_manifest_bytes(manifest: &AgentManifest) -> Result<Vec<u8>, BundleError> {
260 let mut cleared = manifest.clone();
261 if let Some(pub_info) = cleared.publisher.as_mut() {
262 pub_info.signature = None;
263 }
264 serde_json::to_vec(&cleared).map_err(|e| BundleError::InvalidJson(e.to_string()))
269}
270
271pub fn manifest_digest_hex(manifest: &AgentManifest) -> Result<String, BundleError> {
275 let bytes = canonical_manifest_bytes(manifest)?;
276 let mut hasher = Sha256::new();
277 hasher.update(&bytes);
278 Ok(hex(&hasher.finalize()))
279}
280
281pub fn sha256_hex(bytes: &[u8]) -> String {
286 let mut hasher = Sha256::new();
287 hasher.update(bytes);
288 hex(&hasher.finalize())
289}
290
291pub fn verify_sha256(bytes: &[u8], expected_hex: &str) -> Result<(), BundleError> {
296 let actual = sha256_hex(bytes);
297 if actual.eq_ignore_ascii_case(expected_hex) {
298 Ok(())
299 } else {
300 Err(BundleError::SignatureInvalid(format!(
301 "sha256 mismatch: expected `{expected_hex}`, got `{actual}`"
302 )))
303 }
304}
305
306fn hex(bytes: &[u8]) -> String {
307 let mut out = String::with_capacity(bytes.len() * 2);
308 for b in bytes {
309 out.push_str(&format!("{:02x}", b));
310 }
311 out
312}
313
314pub fn sign_manifest(manifest: &mut AgentManifest, key: &SigningKey) -> Result<(), BundleError> {
330 let pub_info = manifest
331 .publisher
332 .get_or_insert_with(PublisherInfo::default);
333 pub_info.key_id = Some(encode_base64(key.verifying_key().as_bytes()));
334 pub_info.signature = None;
335 let bytes = canonical_manifest_bytes(manifest)?;
336 let signature = key.sign(&bytes);
337 manifest.publisher.as_mut().unwrap().signature = Some(encode_base64(&signature.to_bytes()));
339 Ok(())
340}
341
342pub fn verify_signature(manifest: &AgentManifest) -> Result<(), BundleError> {
349 let pub_info = manifest
350 .publisher
351 .as_ref()
352 .ok_or(BundleError::PublisherMissing)?;
353 let key_b64 = pub_info
354 .key_id
355 .as_deref()
356 .ok_or_else(|| BundleError::KeyMalformed("missing key_id".into()))?;
357 let sig_b64 = pub_info
358 .signature
359 .as_deref()
360 .ok_or_else(|| BundleError::SignatureInvalid("missing signature".into()))?;
361 let key_bytes = decode_base64(key_b64)
362 .map_err(|e| BundleError::KeyMalformed(format!("key_id base64: {e}")))?;
363 let key_arr: [u8; 32] = key_bytes
364 .as_slice()
365 .try_into()
366 .map_err(|_| BundleError::KeyMalformed("key_id must be 32 bytes".into()))?;
367 let verifying =
368 VerifyingKey::from_bytes(&key_arr).map_err(|e| BundleError::KeyMalformed(e.to_string()))?;
369 let sig_bytes = decode_base64(sig_b64)
370 .map_err(|e| BundleError::SignatureInvalid(format!("signature base64: {e}")))?;
371 let sig_arr: [u8; 64] = sig_bytes
372 .as_slice()
373 .try_into()
374 .map_err(|_| BundleError::SignatureInvalid("signature must be 64 bytes".into()))?;
375 let signature = Signature::from_bytes(&sig_arr);
376 let bytes = canonical_manifest_bytes(manifest)?;
377 verifying
378 .verify(&bytes, &signature)
379 .map_err(|e| BundleError::SignatureInvalid(e.to_string()))
380}
381
382pub fn verify_detached(
388 bytes: &[u8],
389 signature_b64: &str,
390 public_key_b64: &str,
391) -> Result<(), BundleError> {
392 let key_bytes = decode_base64(public_key_b64)
393 .map_err(|e| BundleError::KeyMalformed(format!("public key base64: {e}")))?;
394 let key_arr: [u8; 32] = key_bytes
395 .as_slice()
396 .try_into()
397 .map_err(|_| BundleError::KeyMalformed("public key must be 32 bytes".into()))?;
398 let verifying =
399 VerifyingKey::from_bytes(&key_arr).map_err(|e| BundleError::KeyMalformed(e.to_string()))?;
400 let sig_bytes = decode_base64(signature_b64)
401 .map_err(|e| BundleError::SignatureInvalid(format!("signature base64: {e}")))?;
402 let sig_arr: [u8; 64] = sig_bytes
403 .as_slice()
404 .try_into()
405 .map_err(|_| BundleError::SignatureInvalid("signature must be 64 bytes".into()))?;
406 let signature = Signature::from_bytes(&sig_arr);
407 verifying
408 .verify(bytes, &signature)
409 .map_err(|e| BundleError::SignatureInvalid(e.to_string()))
410}
411
412fn encode_base64(bytes: &[u8]) -> String {
413 use base64::Engine;
414 base64::engine::general_purpose::STANDARD.encode(bytes)
415}
416
417fn decode_base64(s: &str) -> Result<Vec<u8>, String> {
418 use base64::Engine;
419 base64::engine::general_purpose::STANDARD
420 .decode(s)
421 .map_err(|e| e.to_string())
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427 use ed25519_dalek::SigningKey;
428 use rand_core::OsRng;
429
430 fn sample_manifest() -> AgentManifest {
431 AgentManifest {
432 agent: AgentIdentity {
433 id: "ui-improver".into(),
434 name: "UI Improvement".into(),
435 namespace: Some("parslee".into()),
436 version: Some("0.1.0".into()),
437 description: Some("Issues A2UI patches".into()),
438 license: Some("Apache-2.0".into()),
439 homepage: None,
440 },
441 publisher: None,
442 runtime: Some(RuntimeRequirements {
443 car_min_version: Some("0.8.0".into()),
444 bundle_format_version: 1,
445 }),
446 lifecycle: Some(LifecyclePolicy {
447 stateful: true,
448 persistence: Some("host".into()),
449 default_inference_complexity: Some("low".into()),
450 }),
451 transport: TransportSpec::ExternalProcess(ExternalProcessTransport {
452 command: Some("/usr/local/bin/ui-improver".into()),
453 interpreter: None,
454 binary_url: None,
455 sha256: Some("abc123".into()),
456 health_url: None,
457 args: vec!["--mode".into(), "a2ui".into()],
458 cwd: None,
459 env: BTreeMap::new(),
460 restart: RestartPolicy::OnFailure,
461 max_restarts: 10,
462 backoff_secs: 5,
463 auto_start: false,
464 token: String::new(),
465 }),
466 capabilities: None,
467 }
468 }
469
470 #[test]
471 fn round_trip_through_toml() {
472 let m = sample_manifest();
473 let text = m.to_toml_string().unwrap();
474 let round = AgentManifest::from_toml_str(&text).unwrap();
475 assert_eq!(round.agent.id, m.agent.id);
476 assert_eq!(round.agent.namespace, m.agent.namespace);
477 match (&round.transport, &m.transport) {
478 (TransportSpec::ExternalProcess(a), TransportSpec::ExternalProcess(b)) => {
479 assert_eq!(a.command, b.command);
480 assert_eq!(a.sha256, b.sha256);
481 assert_eq!(a.args, b.args);
482 }
483 _ => panic!("transport kind drift after round-trip"),
484 }
485 }
486
487 #[test]
488 fn contrib_template_manifest_parses_external_process_command() {
489 let text = include_str!("../../../examples/contrib-template/manifest.toml");
490 let manifest = AgentManifest::from_toml_str(text).unwrap();
491
492 match manifest.transport {
493 TransportSpec::ExternalProcess(transport) => {
494 assert_eq!(
495 transport.command.as_deref(),
496 Some("/absolute/path/to/contrib-template/agent.sh")
497 );
498 }
499 TransportSpec::PureData => panic!("contrib template must use external_process"),
500 }
501 }
502
503 #[test]
504 fn canonical_bytes_clear_signature_for_signing() {
505 let mut m = sample_manifest();
506 m.publisher = Some(PublisherInfo {
507 key_id: Some("abc".into()),
508 signature: Some("REAL_SIG".into()),
509 });
510 let bytes = canonical_manifest_bytes(&m).unwrap();
511 let s = std::str::from_utf8(&bytes).unwrap();
512 assert!(!s.contains("REAL_SIG"));
515 assert!(s.contains("abc"));
517 }
518
519 #[test]
520 fn manifest_digest_is_stable() {
521 let m = sample_manifest();
522 let a = manifest_digest_hex(&m).unwrap();
523 let b = manifest_digest_hex(&m).unwrap();
524 assert_eq!(a, b);
525 assert_eq!(a.len(), 64); }
527
528 #[test]
529 fn sign_then_verify_round_trip() {
530 let mut m = sample_manifest();
531 let key = SigningKey::generate(&mut OsRng);
532 sign_manifest(&mut m, &key).unwrap();
533 verify_signature(&m).expect("freshly signed manifest must verify");
534 }
535
536 #[test]
537 fn verify_fails_on_tampered_manifest() {
538 let mut m = sample_manifest();
539 let key = SigningKey::generate(&mut OsRng);
540 sign_manifest(&mut m, &key).unwrap();
541 if let TransportSpec::ExternalProcess(ref mut t) = m.transport {
543 t.command = Some("/tmp/malicious".into());
544 }
545 let err = verify_signature(&m).expect_err("tampered manifest must fail verify");
546 assert!(matches!(err, BundleError::SignatureInvalid(_)));
547 }
548
549 #[test]
550 fn verify_fails_on_missing_publisher() {
551 let m = sample_manifest();
552 let err = verify_signature(&m).expect_err("unsigned manifest must error");
553 assert!(matches!(err, BundleError::PublisherMissing));
554 }
555
556 #[test]
557 fn verify_fails_on_wrong_key() {
558 let mut m = sample_manifest();
559 let key = SigningKey::generate(&mut OsRng);
560 sign_manifest(&mut m, &key).unwrap();
561 let other = SigningKey::generate(&mut OsRng);
563 m.publisher.as_mut().unwrap().key_id =
564 Some(encode_base64(other.verifying_key().as_bytes()));
565 let err = verify_signature(&m).expect_err("wrong key_id must fail verify");
566 assert!(matches!(err, BundleError::SignatureInvalid(_)));
567 }
568
569 #[test]
570 fn sha256_hex_matches_known_value() {
571 let empty = sha256_hex(b"");
573 assert_eq!(
574 empty,
575 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
576 );
577 let abc = sha256_hex(b"abc");
579 assert_eq!(
580 abc,
581 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
582 );
583 }
584
585 #[test]
586 fn verify_sha256_accepts_match_rejects_mismatch_and_is_case_insensitive() {
587 let bytes = b"agent-binary-payload";
588 let digest = sha256_hex(bytes);
589 verify_sha256(bytes, &digest).expect("matching digest must verify");
590 verify_sha256(bytes, &digest.to_uppercase())
591 .expect("verify is case-insensitive on the hex digest");
592 let err = verify_sha256(bytes, "00".repeat(32).as_str())
593 .expect_err("non-matching digest must fail");
594 assert!(matches!(err, BundleError::SignatureInvalid(_)));
595 }
596
597 #[test]
598 fn signing_is_idempotent_with_same_key() {
599 let mut a = sample_manifest();
602 let mut b = sample_manifest();
603 let key = SigningKey::generate(&mut OsRng);
604 sign_manifest(&mut a, &key).unwrap();
605 sign_manifest(&mut b, &key).unwrap();
606 assert_eq!(
607 a.publisher.as_ref().unwrap().signature,
608 b.publisher.as_ref().unwrap().signature
609 );
610 }
611}