use std::path::{Path, PathBuf};
use std::sync::Arc;
use greentic_extension_sdk_contract::{DescribeJson, ExtensionKind};
use wasmtime::Store;
use wasmtime::component::{Component, HasSelf, Instance, Linker};
use crate::health::ExtensionHealth;
use crate::host_state::HostState;
use crate::pool::InstancePool;
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct ExtensionId(pub String);
impl ExtensionId {
#[must_use]
pub fn from_describe(describe: &DescribeJson) -> Self {
Self(describe.metadata.id.clone())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<&str> for ExtensionId {
fn from(s: &str) -> Self {
Self(s.to_string())
}
}
impl From<String> for ExtensionId {
fn from(s: String) -> Self {
Self(s)
}
}
pub struct LoadedExtension {
pub id: ExtensionId,
pub describe: Arc<DescribeJson>,
pub kind: ExtensionKind,
pub source_dir: PathBuf,
pub component: Component,
pub pool: InstancePool,
pub health: ExtensionHealth,
}
pub(crate) fn migrate_value_if_v1(value: serde_json::Value) -> anyhow::Result<serde_json::Value> {
const V1_API_VERSION: &str = "greentic.ai/v1";
let is_v1 = value.get("apiVersion").and_then(serde_json::Value::as_str) == Some(V1_API_VERSION);
if is_v1 {
let (migrated, _report) = greentic_extension_sdk_contract::migrate_v0_4_x_value(&value)
.map_err(|e| anyhow::anyhow!("migrate v1 describe.json to v2: {e}"))?;
Ok(migrated)
} else {
Ok(value)
}
}
pub(crate) fn describe_from_value(value: serde_json::Value) -> anyhow::Result<DescribeJson> {
Ok(serde_json::from_value(migrate_value_if_v1(value)?)?)
}
impl LoadedExtension {
pub fn load_from_dir(engine: &wasmtime::Engine, source_dir: &Path) -> anyhow::Result<Self> {
let describe_path = source_dir.join("describe.json");
let describe_bytes = std::fs::read(&describe_path)?;
let describe_value: serde_json::Value = serde_json::from_slice(&describe_bytes)?;
let describe_value = migrate_value_if_v1(describe_value)?;
greentic_extension_sdk_contract::schema::validate_describe_json(&describe_value)
.map_err(|e| anyhow::anyhow!("invalid describe.json: {e}"))?;
let describe: DescribeJson = serde_json::from_value(describe_value)?;
let id = ExtensionId::from_describe(&describe);
let wasm_path = wasm_component_path(&describe, source_dir)?;
let component = Component::from_file(engine, &wasm_path)?;
let pool = InstancePool::new(2);
let kind = describe.kind;
Ok(Self {
id,
describe: Arc::new(describe),
kind,
source_dir: source_dir.to_path_buf(),
component,
pool,
health: ExtensionHealth::Healthy,
})
}
}
impl LoadedExtension {
pub fn build_store_and_instance(
&self,
engine: &wasmtime::Engine,
host_overrides: HostOverrides,
ctx: &crate::host_ports::HostCallContext,
) -> anyhow::Result<(Store<HostState>, Instance)> {
use crate::host_bindings::greentic::extension_host::{
broker, http, i18n, llm, logging, secrets,
};
let mut linker: Linker<HostState> = Linker::new(engine);
wasmtime_wasi::p2::add_to_linker_sync(&mut linker)?;
logging::add_to_linker::<HostState, HasSelf<HostState>>(&mut linker, |s| s)?;
i18n::add_to_linker::<HostState, HasSelf<HostState>>(&mut linker, |s| s)?;
secrets::add_to_linker::<HostState, HasSelf<HostState>>(&mut linker, |s| s)?;
broker::add_to_linker::<HostState, HasSelf<HostState>>(&mut linker, |s| s)?;
http::add_to_linker::<HostState, HasSelf<HostState>>(&mut linker, |s| s)?;
llm::add_to_linker::<HostState, HasSelf<HostState>>(&mut linker, |s| s)?;
crate::host_bindings::design_v04::greentic::oauth_broker::broker_v1::add_to_linker::<
HostState,
HasSelf<HostState>,
>(&mut linker, |s| s)?;
let url_matcher = effective_url_matcher(
&self.describe.runtime.permissions.network,
host_overrides.url_matcher,
);
let state = HostState::builder(
self.id.as_str().to_string(),
self.describe.runtime.permissions.clone(),
)
.translator(host_overrides.translator)
.secrets_backend(host_overrides.secrets_backend)
.http_client(host_overrides.http_client)
.llm_port(host_overrides.llm_port)
.call_ctx(ctx.clone())
.url_matcher(url_matcher)
.runtime_weak(host_overrides.runtime_weak)
.call_depth_start(host_overrides.call_depth_start)
.oauth_config(host_overrides.oauth_config.clone())
.build();
let mut store = Store::new(engine, state);
let instance = linker.instantiate(&mut store, &self.component)?;
Ok((store, instance))
}
}
fn wasm_component_path(describe: &DescribeJson, source_dir: &Path) -> anyhow::Result<PathBuf> {
let design_wasm = source_dir.join("extension.wasm");
if design_wasm.exists() {
return Ok(design_wasm);
}
let mut iter = describe.runtime.components.iter();
let Some((id, component)) = iter.next() else {
anyhow::bail!("describe.runtime.components must declare at least one entry");
};
if iter.next().is_some() {
anyhow::bail!(
"describe.runtime.components has more than one entry; multi-component dispatch is not yet implemented"
);
}
let gtpack = component.gtpack.as_ref().ok_or_else(|| {
anyhow::anyhow!(
"describe.runtime.components[{id:?}].gtpack must be set for source-dir loads (OCI-only deploy is not yet supported)",
)
})?;
Ok(source_dir.join(gtpack.file.as_str()))
}
pub(crate) fn effective_url_matcher(
declared_patterns: &[String],
override_matcher: crate::url_matcher::UrlMatcher,
) -> crate::url_matcher::UrlMatcher {
if declared_patterns.is_empty() {
return override_matcher;
}
let mut patterns: Vec<String> = declared_patterns.to_vec();
let mut allow_loopback_http = false;
patterns.retain(|p| {
if let Some(host) = http_pattern_host(p) {
if is_loopback_host(host) {
allow_loopback_http = true;
true
} else {
tracing::warn!(
pattern = %p,
"dropping non-loopback http url pattern; plain http is only honoured for loopback hosts"
);
false
}
} else {
true
}
});
crate::url_matcher::UrlMatcher::from_patterns(patterns).with_allow_http(allow_loopback_http)
}
fn http_pattern_host(pattern: &str) -> Option<&str> {
let rest = pattern.strip_prefix("http://")?;
let host_and_port = rest.split('/').next().unwrap_or(rest);
let host_and_port = host_and_port.rsplit('@').next().unwrap_or(host_and_port);
let host = if let Some(bracket_end) = host_and_port.find(']') {
&host_and_port[..=bracket_end]
} else {
host_and_port.split(':').next().unwrap_or(host_and_port)
};
Some(host.trim_start_matches("*."))
}
fn is_loopback_host(host: &str) -> bool {
let host = host.trim_start_matches('[').trim_end_matches(']');
host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1"
}
pub type LoadedExtensionRef = Arc<LoadedExtension>;
#[derive(Clone)]
pub struct HostOverrides {
pub translator: std::sync::Arc<dyn crate::host_ports::Translator>,
pub secrets_backend: std::sync::Arc<dyn crate::host_ports::SecretsBackend>,
pub http_client: Option<reqwest::blocking::Client>,
pub llm_port: Option<std::sync::Arc<dyn crate::host_ports::LlmPort>>,
pub url_matcher: crate::url_matcher::UrlMatcher,
pub runtime_weak: std::sync::Weak<crate::runtime::ExtensionRuntime>,
pub call_depth_start: u32,
pub oauth_config: Option<crate::oauth::OAuthBrokerConfig>,
}
impl std::fmt::Debug for HostOverrides {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HostOverrides")
.field("translator", &"<dyn Translator>")
.field("secrets_backend", &"<dyn SecretsBackend>")
.field(
"http_client",
&self.http_client.as_ref().map(|_| "<Client>"),
)
.field("llm_port", &self.llm_port.as_ref().map(|_| "<dyn LlmPort>"))
.field("url_matcher", &self.url_matcher)
.field(
"runtime_weak",
&self
.runtime_weak
.upgrade()
.map(|_| "<Arc<ExtensionRuntime>>"),
)
.field("call_depth_start", &self.call_depth_start)
.field(
"oauth_config",
&self.oauth_config.as_ref().map(|_| "<OAuthBrokerConfig>"),
)
.finish()
}
}
impl HostOverrides {
#[must_use]
pub fn defaults_for_tests() -> Self {
Self::default()
}
}
impl Default for HostOverrides {
fn default() -> Self {
Self {
translator: std::sync::Arc::new(crate::host_ports::KeyTranslator),
secrets_backend: std::sync::Arc::new(crate::host_ports::InMemorySecrets::new()),
http_client: None,
llm_port: None,
url_matcher: crate::url_matcher::UrlMatcher::default(),
runtime_weak: std::sync::Weak::new(),
call_depth_start: 0,
oauth_config: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::url_matcher::UrlMatcher;
const AC_V1_DESCRIBE: &str = include_str!("testdata/ac_v1_describe.json");
#[test]
fn v1_describe_survives_validate_after_migration() {
let value: serde_json::Value =
serde_json::from_str(AC_V1_DESCRIBE).expect("fixture is valid JSON");
let migrated = migrate_value_if_v1(value).expect("v1 migrates");
greentic_extension_sdk_contract::schema::validate_describe_json(&migrated)
.expect("migrated describe passes the v2 schema");
let describe: DescribeJson =
serde_json::from_value(migrated).expect("migrated describe deserializes");
assert_eq!(describe.metadata.id, "greentic.adaptive-cards");
}
#[test]
fn describe_from_value_migrates_the_bundled_v1_describe() {
let value: serde_json::Value =
serde_json::from_str(AC_V1_DESCRIBE).expect("fixture is valid JSON");
assert_eq!(
value.get("apiVersion").and_then(|v| v.as_str()),
Some("greentic.ai/v1"),
"fixture must be a v1 describe for this test to mean anything"
);
let describe = describe_from_value(value).expect("bundled v1 describe must migrate + load");
assert_eq!(describe.metadata.id, "greentic.adaptive-cards");
}
fn empty_override() -> UrlMatcher {
UrlMatcher::default()
}
fn override_with_pattern(pattern: &str) -> UrlMatcher {
UrlMatcher::from_patterns(vec![pattern.to_string()])
}
#[test]
fn declared_patterns_allow_declared_host_and_deny_undeclared() {
let declared = vec!["https://api.github.com/*".to_string()];
let matcher = effective_url_matcher(&declared, empty_override());
assert!(
matcher.is_allowed("https://api.github.com/repos/org/repo"),
"declared host must be allowed"
);
assert!(
!matcher.is_allowed("https://evil.com/"),
"undeclared host must be denied even though host override is empty"
);
}
#[test]
fn empty_declaration_falls_back_to_host_override() {
let override_matcher = override_with_pattern("https://allowed.com/*");
let matcher = effective_url_matcher(&[], override_matcher);
assert!(
matcher.is_allowed("https://allowed.com/path"),
"host-override host must be reachable when declare is empty"
);
assert!(
!matcher.is_allowed("https://other.com/path"),
"host-override deny must still apply"
);
}
#[test]
fn declared_patterns_replace_not_union_host_override() {
let declared = vec!["https://api.github.com/*".to_string()];
let override_matcher = override_with_pattern("https://operator-allowed.com/*");
let matcher = effective_url_matcher(&declared, override_matcher);
assert!(
matcher.is_allowed("https://api.github.com/repos/org/repo"),
"declared host must be allowed"
);
assert!(
!matcher.is_allowed("https://operator-allowed.com/anything"),
"operator override must NOT bleed through when declaration is non-empty"
);
}
#[test]
fn empty_declaration_and_empty_override_denies_everything() {
let matcher = effective_url_matcher(&[], empty_override());
assert!(
!matcher.is_allowed("https://api.github.com/anything"),
"empty declaration + empty override must produce deny-all matcher"
);
}
#[test]
fn declared_http_loopback_127_allows_plain_http() {
let declared = vec!["http://127.0.0.1:8787/*".to_string()];
let matcher = effective_url_matcher(&declared, empty_override());
assert!(
matcher.is_allowed("http://127.0.0.1:8787/execute"),
"declared http loopback pattern must permit plain http to that loopback"
);
}
#[test]
fn declared_http_loopback_localhost_allows_plain_http() {
let declared = vec!["http://localhost:8787/*".to_string()];
let matcher = effective_url_matcher(&declared, empty_override());
assert!(
matcher.is_allowed("http://localhost:8787/execute"),
"declared http localhost pattern must permit plain http to localhost"
);
}
#[test]
fn declared_http_non_loopback_stays_denied() {
let declared = vec!["http://evil.com/*".to_string()];
let matcher = effective_url_matcher(&declared, empty_override());
assert!(
!matcher.is_allowed("http://evil.com/anything"),
"plain http must stay denied for a non-loopback declared host"
);
}
#[test]
fn mixed_loopback_http_and_https_host() {
let declared = vec![
"http://127.0.0.1:8787/*".to_string(),
"https://api.example.com/*".to_string(),
];
let matcher = effective_url_matcher(&declared, empty_override());
assert!(
matcher.is_allowed("http://127.0.0.1:8787/execute"),
"loopback http must be allowed in a mixed declaration"
);
assert!(
matcher.is_allowed("https://api.example.com/v1/foo"),
"declared https host must stay reachable"
);
assert!(
!matcher.is_allowed("http://api.example.com/v1/foo"),
"plain http to the https-only host must stay denied even with loopback http enabled"
);
}
#[test]
fn declared_http_ipv6_loopback_allows_plain_http() {
let declared = vec!["http://[::1]:8787/*".to_string()];
let matcher = effective_url_matcher(&declared, empty_override());
assert!(
matcher.is_allowed("http://[::1]:8787/x"),
"declared http IPv6 loopback pattern must permit plain http to [::1]"
);
assert!(
!matcher.is_allowed("http://evil.com/x"),
"IPv6 loopback opt-in must not permit plain http to non-loopback hosts"
);
}
#[test]
fn adversarial_fake_ipv6_bracket_host_is_denied() {
let declared = vec!["http://[::1].evil.com/*".to_string()];
let matcher = effective_url_matcher(&declared, empty_override());
assert!(
!matcher.is_allowed("http://[::1].evil.com/x"),
"malformed bracketed host must not be allowed"
);
assert!(
!matcher.is_allowed("http://[::1]/x"),
"bad pattern must not accidentally allow real IPv6 loopback"
);
}
}