1#![forbid(unsafe_code)]
41
42use std::io::{Cursor, Read as _};
43use std::path::{Path, PathBuf};
44use std::time::Duration;
45
46use anyhow::{Context, Result, anyhow, bail};
47use serde::{Deserialize, Serialize};
48use sha2::{Digest, Sha256};
49use tokio::runtime::Handle;
50
51use crate::extension_refs::{
52 ExtensionDependency, ExtensionDependencySource, PackExtensionsFile, read_extensions_file,
53};
54
55const STORE_URL_ENV: &str = "GREENTIC_STORE_URL";
58
59#[derive(Debug, Clone)]
61pub struct ExtRef {
62 pub extension_id: String,
64}
65
66pub fn parse_ext_ref(raw: &str) -> Result<ExtRef> {
71 let rest = raw.strip_prefix("ext://").ok_or_else(|| {
72 anyhow::anyhow!("ext:// component ref must start with 'ext://' (got '{raw}')")
73 })?;
74 let (id, fragment) = rest.split_once('#').ok_or_else(|| {
75 anyhow::anyhow!(
76 "ext:// component ref must have the form 'ext://<id>#component' (got '{raw}')"
77 )
78 })?;
79 if fragment != "component" {
80 bail!("ext:// component ref fragment must be '#component' (got '#{fragment}')");
81 }
82 if id.trim().is_empty() {
83 bail!("ext:// component ref extension id must not be empty (got '{raw}')");
84 }
85 Ok(ExtRef {
86 extension_id: id.to_string(),
87 })
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct GtxpackComponentSidecar {
93 pub component: GtxpackComponentEntry,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct GtxpackComponentEntry {
100 pub id: String,
102 pub asset: String,
104 pub digest: String,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct StoreRef {
111 pub name: String,
113 pub version: String,
115}
116
117pub fn parse_store_ref(raw: &str) -> Result<StoreRef> {
122 let rest = raw.strip_prefix("store://").ok_or_else(|| {
123 anyhow!("store:// extension ref must start with 'store://' (got '{raw}')")
124 })?;
125 let (name, version) = rest.split_once('@').ok_or_else(|| {
126 anyhow!(
127 "store:// extension ref must pin a version as 'store://<name>@<version>' (got '{raw}')"
128 )
129 })?;
130 if name.trim().is_empty() {
131 bail!("store:// extension ref name must not be empty (got '{raw}')");
132 }
133 if version.trim().is_empty() {
134 bail!("store:// extension ref must pin a non-empty version (got '{raw}')");
135 }
136 Ok(StoreRef {
137 name: name.to_string(),
138 version: version.to_string(),
139 })
140}
141
142pub fn store_artifact_url(store_base: &str, name: &str, version: &str) -> String {
146 let base = store_base.trim_end_matches('/');
147 format!("{base}/api/v1/extensions/{name}/{version}/artifact")
148}
149
150pub fn resolve_ext_component(pack_dir: &Path, raw_ref: &str) -> Result<(Vec<u8>, String)> {
160 let (ext_ref, dep) = lookup_ext_dependency(pack_dir, raw_ref)?;
161 let zip_bytes = read_local_extension_source(&dep.source)
162 .with_context(|| format!("resolve source for extension '{}'", dep.id))?;
163 extract_and_verify_bytes(&ext_ref.extension_id, &zip_bytes)
164}
165
166pub fn resolve_ext_component_with_dist(
176 pack_dir: &Path,
177 raw_ref: &str,
178 cache_dir: &Path,
179 offline: bool,
180 handle: Option<&Handle>,
181) -> Result<(Vec<u8>, String)> {
182 let (ext_ref, dep) = lookup_ext_dependency(pack_dir, raw_ref)?;
183 let zip_bytes = acquire_extension_bytes(&dep.source, cache_dir, offline, handle)
184 .with_context(|| format!("acquire source for extension '{}'", dep.id))?;
185 extract_and_verify_bytes(&ext_ref.extension_id, &zip_bytes)
186}
187
188fn lookup_ext_dependency(pack_dir: &Path, raw_ref: &str) -> Result<(ExtRef, ExtensionDependency)> {
191 let ext_ref = parse_ext_ref(raw_ref)?;
192 let extensions_path = pack_dir.join("pack.extensions.json");
193 let extensions = read_extensions_file(&extensions_path)
194 .with_context(|| format!("read pack.extensions.json from {}", pack_dir.display()))?;
195 let dep = find_extension_dep(&extensions, &ext_ref.extension_id)
196 .with_context(|| {
197 format!(
198 "ext:// component ref names extension '{}' not declared in pack.extensions.json",
199 ext_ref.extension_id
200 )
201 })?
202 .clone();
203 Ok((ext_ref, dep))
204}
205
206fn find_extension_dep<'a>(
207 file: &'a PackExtensionsFile,
208 id: &str,
209) -> Option<&'a ExtensionDependency> {
210 file.extensions.iter().find(|dep| dep.id == id)
211}
212
213fn read_local_extension_source(source: &ExtensionDependencySource) -> Result<Vec<u8>> {
218 let raw = source.reference.as_str();
219 if let Some(path) = local_path_for_source(raw) {
220 return std::fs::read(&path)
221 .with_context(|| format!("read extension .gtxpack at {}", path.display()));
222 }
223 bail!(
224 "ext:// component resolver here only supports file:// or bare local extension sources, got '{raw}' (use the dist-aware resolver for store://)"
225 );
226}
227
228fn local_path_for_source(raw: &str) -> Option<PathBuf> {
230 if let Some(path_str) = raw.strip_prefix("file://") {
231 return Some(PathBuf::from(path_str));
232 }
233 if !raw.contains("://") {
234 return Some(PathBuf::from(raw));
235 }
236 None
237}
238
239fn acquire_extension_bytes(
245 source: &ExtensionDependencySource,
246 cache_dir: &Path,
247 offline: bool,
248 _handle: Option<&Handle>,
249) -> Result<Vec<u8>> {
250 let raw = source.reference.as_str();
251 if local_path_for_source(raw).is_some() {
252 return read_local_extension_source(source);
253 }
254 if raw.starts_with("store://") {
255 let store_ref = parse_store_ref(raw)?;
256 return acquire_store_extension_bytes(&store_ref, cache_dir, offline);
257 }
258 if raw.starts_with("oci://") {
259 bail!(
263 "oci:// extension acquisition not yet supported (no producer); declare the extension with a store:// or file:// source instead (got '{raw}')"
264 );
265 }
266 bail!("unsupported extension source scheme for ext:// resolution: '{raw}'");
267}
268
269fn acquire_store_extension_bytes(
271 store_ref: &StoreRef,
272 cache_dir: &Path,
273 offline: bool,
274) -> Result<Vec<u8>> {
275 if offline {
276 return read_cached_store_artifact(cache_dir, store_ref).ok_or_else(|| {
279 anyhow!(
280 "offline: no cached artifact for store extension '{}@{}' under the cache dir; run online once to populate the cache",
281 store_ref.name,
282 store_ref.version
283 )
284 });
285 }
286
287 let store_base = std::env::var(STORE_URL_ENV).map_err(|_| {
288 anyhow!(
289 "{STORE_URL_ENV} is not set; it must name the store base URL to acquire store:// extension '{}@{}'",
290 store_ref.name,
291 store_ref.version
292 )
293 })?;
294 download_store_artifact(&store_base, store_ref, cache_dir)
295}
296
297pub fn download_store_artifact(
304 store_base: &str,
305 store_ref: &StoreRef,
306 cache_dir: &Path,
307) -> Result<Vec<u8>> {
308 let url = store_artifact_url(store_base, &store_ref.name, &store_ref.version);
309
310 let (bytes, advertised_sha) = http_get_artifact(&url)?;
311 let actual_sha = hex::encode(Sha256::digest(&bytes));
312 if let Some(advertised) = advertised_sha.as_deref()
313 && !advertised.eq_ignore_ascii_case(&actual_sha)
314 {
315 bail!(
316 "store artifact integrity check failed for '{}@{}': x-artifact-sha256 advertises '{}' but body hashes to '{}'",
317 store_ref.name,
318 store_ref.version,
319 advertised,
320 actual_sha
321 );
322 }
323
324 cache_store_artifact(cache_dir, store_ref, &actual_sha, &bytes)?;
326 Ok(bytes)
327}
328
329fn store_artifact_cache_dir(cache_dir: &Path) -> PathBuf {
331 cache_dir.join("ext-store")
332}
333
334fn store_ref_cache_key(store_ref: &StoreRef) -> String {
336 let sanitized =
337 format!("{}@{}", store_ref.name, store_ref.version).replace(['/', '\\', ':', '@'], "_");
338 format!("{sanitized}.gtxpack")
339}
340
341fn cache_store_artifact(
344 cache_dir: &Path,
345 store_ref: &StoreRef,
346 archive_sha: &str,
347 bytes: &[u8],
348) -> Result<()> {
349 let dir = store_artifact_cache_dir(cache_dir);
350 std::fs::create_dir_all(&dir)
351 .with_context(|| format!("create store artifact cache dir {}", dir.display()))?;
352 let sha_path = dir.join(format!("sha256-{archive_sha}.gtxpack"));
353 std::fs::write(&sha_path, bytes)
354 .with_context(|| format!("write store artifact cache {}", sha_path.display()))?;
355 let ref_path = dir.join(store_ref_cache_key(store_ref));
356 std::fs::write(&ref_path, bytes)
357 .with_context(|| format!("write store artifact cache {}", ref_path.display()))?;
358 Ok(())
359}
360
361fn read_cached_store_artifact(cache_dir: &Path, store_ref: &StoreRef) -> Option<Vec<u8>> {
363 let path = store_artifact_cache_dir(cache_dir).join(store_ref_cache_key(store_ref));
364 std::fs::read(path).ok()
365}
366
367fn http_get_artifact(url: &str) -> Result<(Vec<u8>, Option<String>)> {
373 let url = url.to_string();
374 std::thread::spawn(move || -> Result<(Vec<u8>, Option<String>)> {
375 let client = reqwest::blocking::Client::builder()
376 .connect_timeout(Duration::from_secs(5))
377 .timeout(Duration::from_secs(60))
378 .build()
379 .context("build HTTP client for store extension artifact")?;
380 let response = client
381 .get(&url)
382 .send()
383 .with_context(|| format!("request store extension artifact {url}"))?;
384 if response.status() != reqwest::StatusCode::OK {
385 bail!(
386 "store extension artifact {url} request failed with status {}",
387 response.status()
388 );
389 }
390 let advertised_sha = response
391 .headers()
392 .get("x-artifact-sha256")
393 .and_then(|value| value.to_str().ok())
394 .map(|value| value.trim().to_string());
395 let bytes = response
396 .bytes()
397 .with_context(|| format!("read store extension artifact response {url}"))?;
398 Ok((bytes.to_vec(), advertised_sha))
399 })
400 .join()
401 .map_err(|_| anyhow!("store artifact download thread panicked"))?
402}
403
404pub fn extract_and_verify_bytes(extension_id: &str, zip_bytes: &[u8]) -> Result<(Vec<u8>, String)> {
407 let cursor = Cursor::new(zip_bytes);
408 let mut archive = zip::ZipArchive::new(cursor)
409 .with_context(|| format!("open extension .gtxpack ZIP for '{extension_id}'"))?;
410
411 let sidecar: GtxpackComponentSidecar = {
413 let mut entry = archive.by_name("component.json").map_err(|_| {
414 anyhow!(
415 "extension '{extension_id}' does not embed a runtime component: 'component.json' not found in .gtxpack"
416 )
417 })?;
418 let mut buf = Vec::new();
419 entry
420 .read_to_end(&mut buf)
421 .with_context(|| format!("read component.json for '{extension_id}'"))?;
422 serde_json::from_slice(&buf)
423 .with_context(|| format!("parse component.json for '{extension_id}'"))?
424 };
425
426 let asset_path = sidecar.component.asset.as_str();
428 if asset_path.trim().is_empty() {
429 bail!(
430 "extension '{extension_id}' does not embed a runtime component: 'component.json' component.asset is empty"
431 );
432 }
433
434 let wasm_bytes = {
436 let mut entry = archive.by_name(asset_path).map_err(|_| {
437 anyhow!(
438 "extension '{extension_id}' does not embed a runtime component: asset '{asset_path}' not found in .gtxpack"
439 )
440 })?;
441 let mut buf = Vec::new();
442 entry
443 .read_to_end(&mut buf)
444 .with_context(|| format!("read asset '{asset_path}' for '{extension_id}'"))?;
445 buf
446 };
447
448 let actual_digest = format!("sha256:{}", hex::encode(Sha256::digest(&wasm_bytes)));
450
451 let expected_digest = sidecar.component.digest.as_str();
453 if actual_digest != expected_digest {
454 bail!(
455 "embedded component digest mismatch for extension '{extension_id}': component.json advertises '{expected_digest}' but extracted wasm hashes to '{actual_digest}'"
456 );
457 }
458
459 Ok((wasm_bytes, actual_digest))
460}
461
462pub fn read_describe_from_gtxpack(extension_id: &str, zip_bytes: &[u8]) -> Result<Vec<u8>> {
467 let cursor = Cursor::new(zip_bytes);
468 let mut archive = zip::ZipArchive::new(cursor)
469 .with_context(|| format!("open extension .gtxpack ZIP for '{extension_id}'"))?;
470 let mut file = archive
471 .by_name("describe.json")
472 .with_context(|| format!("extension '{extension_id}' .gtxpack has no describe.json"))?;
473 let mut body = Vec::new();
474 file.read_to_end(&mut body)
475 .with_context(|| format!("read describe.json from '{extension_id}' .gtxpack"))?;
476 Ok(body)
477}
478
479const NON_EXTENSION_TOOL_PREFIXES: [&str; 4] = ["flow:", "sorla:", "component:", "mcp:"];
498
499fn is_extension_tool_ref(ext_id: &str) -> bool {
502 !NON_EXTENSION_TOOL_PREFIXES
503 .iter()
504 .any(|prefix| ext_id.starts_with(prefix))
505}
506
507pub fn resolve_agent_tool_requirements(
520 pack_dir: &Path,
521 agents: &std::collections::BTreeMap<String, serde_json::Value>,
522 cache_dir: &Path,
523 offline: bool,
524) -> Result<std::collections::BTreeMap<String, Vec<crate::setup_gen::ToolSecretReq>>> {
525 use std::collections::{BTreeMap, BTreeSet};
526
527 let mut used: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
529 for (agent_name, agent) in agents {
530 let Some(tools) = agent.get("tools").and_then(|t| t.as_array()) else {
531 continue;
532 };
533 for tool in tools {
534 let (Some(ext_id), Some(tool_name)) = (
535 tool.get("extension_id").and_then(|e| e.as_str()),
536 tool.get("tool_name").and_then(|n| n.as_str()),
537 ) else {
538 tracing::warn!(
539 agent = %agent_name,
540 "skipping malformed agent tool entry: missing extension_id or tool_name"
541 );
542 continue;
543 };
544 if !is_extension_tool_ref(ext_id) {
545 tracing::debug!(
546 agent = %agent_name, tool = %ext_id,
547 "skipping non-extension tool ref for credential form generation"
548 );
549 continue;
550 }
551 used.entry(ext_id.to_string())
552 .or_default()
553 .insert(tool_name.to_string());
554 }
555 }
556
557 let mut out = BTreeMap::new();
558 for (ext_id, tool_names) in &used {
559 let raw_ref = format!("ext://{ext_id}#component");
562 let (_ext_ref, dep) = lookup_ext_dependency(pack_dir, &raw_ref).with_context(|| {
563 format!("resolve tool extension '{ext_id}' for credential form generation")
564 })?;
565 let zip_bytes = acquire_extension_bytes(&dep.source, cache_dir, offline, None)
566 .with_context(|| format!("acquire .gtxpack for tool extension '{ext_id}'"))?;
567 let describe_bytes = read_describe_from_gtxpack(ext_id, &zip_bytes)?;
568 let names: Vec<String> = tool_names.iter().cloned().collect();
569 let secret_requirements =
570 crate::setup_gen::extract_tool_secret_requirements(&describe_bytes, &names)?;
571 out.insert(ext_id.clone(), secret_requirements);
572 }
573 Ok(out)
574}
575
576#[cfg(test)]
577mod describe_tests {
578 use super::*;
579 use std::io::Write;
580
581 fn gtxpack_with_describe(describe: &str) -> Vec<u8> {
582 let mut buf = Vec::new();
583 {
584 let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
585 zip.start_file("describe.json", zip::write::FileOptions::<()>::default())
586 .unwrap();
587 zip.write_all(describe.as_bytes()).unwrap();
588 zip.finish().unwrap();
589 }
590 buf
591 }
592
593 #[test]
594 fn reads_describe_json_entry_from_gtxpack() {
595 let bytes = gtxpack_with_describe(r#"{"contributions":{"tools":[]}}"#);
596 let body = read_describe_from_gtxpack("greentic.tavily", &bytes).unwrap();
597 assert!(String::from_utf8_lossy(&body).contains("contributions"));
598 }
599}
600
601#[cfg(test)]
602mod tests {
603 use super::*;
604
605 #[test]
606 fn parse_ext_ref_happy() {
607 let r = parse_ext_ref("ext://greentic.http#component").expect("valid ref");
608 assert_eq!(r.extension_id, "greentic.http");
609 }
610
611 #[test]
612 fn parse_ext_ref_wrong_scheme() {
613 let err = parse_ext_ref("oci://foo#component").expect_err("wrong scheme");
614 assert!(err.to_string().contains("ext://"));
615 }
616
617 #[test]
618 fn parse_ext_ref_no_fragment() {
619 let err = parse_ext_ref("ext://greentic.http").expect_err("no fragment");
620 assert!(err.to_string().contains("#component"));
621 }
622
623 #[test]
624 fn parse_ext_ref_wrong_fragment() {
625 let err = parse_ext_ref("ext://greentic.http#other").expect_err("wrong fragment");
626 assert!(err.to_string().contains("#component"));
627 }
628
629 #[test]
630 fn parse_ext_ref_empty_id() {
631 let err = parse_ext_ref("ext://#component").expect_err("empty id");
632 assert!(err.to_string().contains("must not be empty"));
633 }
634}
635
636#[cfg(test)]
637mod non_extension_tool_ref_tests {
638 use super::*;
639 use serde_json::json;
640
641 fn agent_with_tools(
642 tools: serde_json::Value,
643 ) -> std::collections::BTreeMap<String, serde_json::Value> {
644 let mut agents = std::collections::BTreeMap::new();
645 agents.insert("assistant".to_string(), json!({ "tools": tools }));
646 agents
647 }
648
649 #[test]
650 fn bare_ids_are_extension_refs() {
651 assert!(is_extension_tool_ref("greentic.adaptive-cards"));
652 assert!(is_extension_tool_ref("some.vendor.extension"));
653 }
654
655 #[test]
656 fn run_time_resolved_kinds_are_not_extension_refs() {
657 assert!(!is_extension_tool_ref("flow:get_weather"));
660 assert!(!is_extension_tool_ref("sorla:my-pack"));
661 assert!(!is_extension_tool_ref("component:oci://ghcr.io/x/y"));
662 assert!(!is_extension_tool_ref("mcp:my-server"));
663 }
664
665 #[test]
670 fn a_worker_with_only_non_extension_tools_needs_no_extensions_file() {
671 let agents = agent_with_tools(json!([
672 { "extension_id": "flow:get_weather", "tool_name": "get_weather" },
673 { "extension_id": "sorla:billing", "tool_name": "refund" },
674 ]));
675 let dir = tempfile::tempdir().expect("tempdir");
677 let out = resolve_agent_tool_requirements(dir.path(), &agents, dir.path(), true)
678 .expect("non-extension tool refs must not require an extensions file");
679 assert!(
680 out.is_empty(),
681 "non-extension refs contribute no extension secret requirements"
682 );
683 }
684
685 #[test]
686 fn a_real_extension_ref_is_still_resolved_and_still_errors_when_undeclared() {
687 let agents = agent_with_tools(json!([
688 { "extension_id": "flow:get_weather", "tool_name": "get_weather" },
689 { "extension_id": "greentic.some-extension", "tool_name": "do_thing" },
690 ]));
691 let dir = tempfile::tempdir().expect("tempdir");
692 let err = resolve_agent_tool_requirements(dir.path(), &agents, dir.path(), true)
693 .expect_err("an undeclared extension must still fail");
694 let msg = format!("{err:#}");
695 assert!(
696 msg.contains("greentic.some-extension"),
697 "the error must name the real extension, not the skipped flow: ref; got: {msg}"
698 );
699 assert!(
700 !msg.contains("flow:get_weather"),
701 "a skipped non-extension ref must never appear in the error; got: {msg}"
702 );
703 }
704}