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
479pub fn resolve_agent_tool_requirements(
489 pack_dir: &Path,
490 agents: &std::collections::BTreeMap<String, serde_json::Value>,
491 cache_dir: &Path,
492 offline: bool,
493) -> Result<std::collections::BTreeMap<String, Vec<crate::setup_gen::ToolSecretReq>>> {
494 use std::collections::{BTreeMap, BTreeSet};
495
496 let mut used: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
498 for (agent_name, agent) in agents {
499 let Some(tools) = agent.get("tools").and_then(|t| t.as_array()) else {
500 continue;
501 };
502 for tool in tools {
503 let (Some(ext_id), Some(tool_name)) = (
504 tool.get("extension_id").and_then(|e| e.as_str()),
505 tool.get("tool_name").and_then(|n| n.as_str()),
506 ) else {
507 tracing::warn!(
508 agent = %agent_name,
509 "skipping malformed agent tool entry: missing extension_id or tool_name"
510 );
511 continue;
512 };
513 used.entry(ext_id.to_string())
514 .or_default()
515 .insert(tool_name.to_string());
516 }
517 }
518
519 let mut out = BTreeMap::new();
520 for (ext_id, tool_names) in &used {
521 let raw_ref = format!("ext://{ext_id}#component");
524 let (_ext_ref, dep) = lookup_ext_dependency(pack_dir, &raw_ref).with_context(|| {
525 format!("resolve tool extension '{ext_id}' for credential form generation")
526 })?;
527 let zip_bytes = acquire_extension_bytes(&dep.source, cache_dir, offline, None)
528 .with_context(|| format!("acquire .gtxpack for tool extension '{ext_id}'"))?;
529 let describe_bytes = read_describe_from_gtxpack(ext_id, &zip_bytes)?;
530 let names: Vec<String> = tool_names.iter().cloned().collect();
531 let secret_requirements =
532 crate::setup_gen::extract_tool_secret_requirements(&describe_bytes, &names)?;
533 out.insert(ext_id.clone(), secret_requirements);
534 }
535 Ok(out)
536}
537
538#[cfg(test)]
539mod describe_tests {
540 use super::*;
541 use std::io::Write;
542
543 fn gtxpack_with_describe(describe: &str) -> Vec<u8> {
544 let mut buf = Vec::new();
545 {
546 let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
547 zip.start_file("describe.json", zip::write::FileOptions::<()>::default())
548 .unwrap();
549 zip.write_all(describe.as_bytes()).unwrap();
550 zip.finish().unwrap();
551 }
552 buf
553 }
554
555 #[test]
556 fn reads_describe_json_entry_from_gtxpack() {
557 let bytes = gtxpack_with_describe(r#"{"contributions":{"tools":[]}}"#);
558 let body = read_describe_from_gtxpack("greentic.tavily", &bytes).unwrap();
559 assert!(String::from_utf8_lossy(&body).contains("contributions"));
560 }
561}
562
563#[cfg(test)]
564mod tests {
565 use super::*;
566
567 #[test]
568 fn parse_ext_ref_happy() {
569 let r = parse_ext_ref("ext://greentic.http#component").expect("valid ref");
570 assert_eq!(r.extension_id, "greentic.http");
571 }
572
573 #[test]
574 fn parse_ext_ref_wrong_scheme() {
575 let err = parse_ext_ref("oci://foo#component").expect_err("wrong scheme");
576 assert!(err.to_string().contains("ext://"));
577 }
578
579 #[test]
580 fn parse_ext_ref_no_fragment() {
581 let err = parse_ext_ref("ext://greentic.http").expect_err("no fragment");
582 assert!(err.to_string().contains("#component"));
583 }
584
585 #[test]
586 fn parse_ext_ref_wrong_fragment() {
587 let err = parse_ext_ref("ext://greentic.http#other").expect_err("wrong fragment");
588 assert!(err.to_string().contains("#component"));
589 }
590
591 #[test]
592 fn parse_ext_ref_empty_id() {
593 let err = parse_ext_ref("ext://#component").expect_err("empty id");
594 assert!(err.to_string().contains("must not be empty"));
595 }
596}