Skip to main content

greentic_ext_runtime/
host_state.rs

1use std::sync::Arc;
2use std::sync::atomic::{AtomicU32, Ordering};
3
4use wasmtime::component::ResourceTable;
5use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
6
7use crate::host_bindings::greentic::extension_host::{broker, http, i18n, llm, logging, secrets};
8use crate::host_ports::{KeyTranslator, LlmPort, SecretsBackend, Translator};
9use crate::url_matcher::UrlMatcher;
10use greentic_extension_sdk_contract::describe::Permissions;
11
12/// Maximum number of nested `host.broker.call-extension` hops the runtime
13/// allows in a single dispatch chain. Mirrors `crate::broker::MAX_DEPTH`.
14pub const MAX_BROKER_DEPTH: u32 = 8;
15
16/// Per-Store host context. One `HostState` is built per WIT invocation —
17/// the dependencies live in `Arc`s so cloning is cheap.
18pub struct HostState {
19    pub extension_id: String,
20    pub permissions: Permissions,
21    pub call_depth: AtomicU32,
22    translator: Arc<dyn Translator>,
23    secrets_backend: Arc<dyn SecretsBackend>,
24    http_client: Option<reqwest::blocking::Client>,
25    llm_port: Option<Arc<dyn LlmPort>>,
26    /// Per-call caller context for this dispatch (tenant slug + authenticated
27    /// user email), threaded from the host's
28    /// [`crate::host_ports::HostCallContext`] and forwarded to host ports.
29    /// `Default` (all `None`) when the host runs single-tenant/dev.
30    call_ctx: crate::host_ports::HostCallContext,
31    url_matcher: UrlMatcher,
32    runtime_weak: std::sync::Weak<crate::runtime::ExtensionRuntime>,
33    // consumed by the oauth broker Host impl below
34    oauth_config: Option<crate::oauth::OAuthBrokerConfig>,
35    // WASI state — required because cargo-component-built WASM components
36    // implicitly import WASI interfaces (wasi:cli/environment etc.).
37    wasi: WasiCtx,
38    table: ResourceTable,
39}
40
41impl HostState {
42    /// Builder used by `LoadedExtension::build_store_and_instance` to
43    /// produce a `HostState` for a single dispatch.
44    #[must_use]
45    pub fn builder(extension_id: String, permissions: Permissions) -> HostStateBuilder {
46        HostStateBuilder {
47            extension_id,
48            permissions,
49            translator: Arc::new(KeyTranslator),
50            secrets_backend: Arc::new(crate::host_ports::InMemorySecrets::new()),
51            http_client: None,
52            llm_port: None,
53            call_ctx: crate::host_ports::HostCallContext::default(),
54            url_matcher: UrlMatcher::default(),
55            runtime_weak: std::sync::Weak::new(),
56            call_depth_start: 0,
57            oauth_config: None,
58        }
59    }
60
61    #[must_use]
62    pub fn translator(&self) -> &dyn Translator {
63        self.translator.as_ref()
64    }
65
66    #[must_use]
67    pub fn secrets_backend(&self) -> &dyn SecretsBackend {
68        self.secrets_backend.as_ref()
69    }
70
71    #[must_use]
72    pub fn url_matcher(&self) -> &UrlMatcher {
73        &self.url_matcher
74    }
75}
76
77/// Builder for [`HostState`]. Avoids a 7-positional-arg constructor.
78pub struct HostStateBuilder {
79    extension_id: String,
80    permissions: Permissions,
81    translator: Arc<dyn Translator>,
82    secrets_backend: Arc<dyn SecretsBackend>,
83    http_client: Option<reqwest::blocking::Client>,
84    llm_port: Option<Arc<dyn LlmPort>>,
85    call_ctx: crate::host_ports::HostCallContext,
86    url_matcher: UrlMatcher,
87    runtime_weak: std::sync::Weak<crate::runtime::ExtensionRuntime>,
88    call_depth_start: u32,
89    oauth_config: Option<crate::oauth::OAuthBrokerConfig>,
90}
91
92impl HostStateBuilder {
93    #[must_use]
94    pub fn translator(mut self, t: Arc<dyn Translator>) -> Self {
95        self.translator = t;
96        self
97    }
98    #[must_use]
99    pub fn secrets_backend(mut self, s: Arc<dyn SecretsBackend>) -> Self {
100        self.secrets_backend = s;
101        self
102    }
103    #[must_use]
104    pub fn http_client(mut self, c: Option<reqwest::blocking::Client>) -> Self {
105        self.http_client = c;
106        self
107    }
108    #[must_use]
109    pub fn llm_port(mut self, p: Option<Arc<dyn LlmPort>>) -> Self {
110        self.llm_port = p;
111        self
112    }
113    /// Set the per-call caller context (tenant slug + authenticated user
114    /// email) for this dispatch. Threaded into host ports (today the LLM port)
115    /// so the host can resolve per-tenant and validate per-user identity.
116    #[must_use]
117    pub fn call_ctx(mut self, ctx: crate::host_ports::HostCallContext) -> Self {
118        self.call_ctx = ctx;
119        self
120    }
121    #[must_use]
122    pub fn url_matcher(mut self, m: UrlMatcher) -> Self {
123        self.url_matcher = m;
124        self
125    }
126    #[must_use]
127    pub fn runtime_weak(mut self, w: std::sync::Weak<crate::runtime::ExtensionRuntime>) -> Self {
128        self.runtime_weak = w;
129        self
130    }
131    #[must_use]
132    pub fn call_depth_start(mut self, n: u32) -> Self {
133        self.call_depth_start = n;
134        self
135    }
136    #[must_use]
137    pub fn oauth_config(mut self, c: Option<crate::oauth::OAuthBrokerConfig>) -> Self {
138        self.oauth_config = c;
139        self
140    }
141
142    #[must_use]
143    pub fn build(self) -> HostState {
144        let wasi = WasiCtxBuilder::new().build();
145        let table = ResourceTable::new();
146        HostState {
147            extension_id: self.extension_id,
148            permissions: self.permissions,
149            call_depth: AtomicU32::new(self.call_depth_start),
150            translator: self.translator,
151            secrets_backend: self.secrets_backend,
152            http_client: self.http_client,
153            llm_port: self.llm_port,
154            call_ctx: self.call_ctx,
155            url_matcher: self.url_matcher,
156            runtime_weak: self.runtime_weak,
157            oauth_config: self.oauth_config,
158            wasi,
159            table,
160        }
161    }
162}
163
164/// Implement [`WasiView`] so that `wasmtime_wasi::p2::add_to_linker_sync` can wire
165/// WASI host functions. cargo-component adds WASI imports to every component it
166/// builds, even if the Rust source never calls them.
167impl WasiView for HostState {
168    fn ctx(&mut self) -> WasiCtxView<'_> {
169        WasiCtxView {
170            ctx: &mut self.wasi,
171            table: &mut self.table,
172        }
173    }
174}
175
176impl logging::Host for HostState {
177    fn log(&mut self, level: logging::Level, target: String, message: String) {
178        let ext = &self.extension_id;
179        match level {
180            logging::Level::Trace => tracing::trace!(%ext, %target, "{message}"),
181            logging::Level::Debug => tracing::debug!(%ext, %target, "{message}"),
182            logging::Level::Info => tracing::info!(%ext, %target, "{message}"),
183            logging::Level::Warn => tracing::warn!(%ext, %target, "{message}"),
184            logging::Level::Error => tracing::error!(%ext, %target, "{message}"),
185        }
186    }
187
188    fn log_kv(
189        &mut self,
190        level: logging::Level,
191        target: String,
192        message: String,
193        fields: Vec<(String, String)>,
194    ) {
195        let pairs: Vec<String> = fields.iter().map(|(k, v)| format!("{k}={v}")).collect();
196        let msg = if pairs.is_empty() {
197            message
198        } else {
199            format!("{message} {{{}}}", pairs.join(", "))
200        };
201        self.log(level, target, msg);
202    }
203}
204
205// i18n / secrets / broker / http impls are wired in tasks B.2, B.3,
206// B.4, B.5. The B.7 grep gate enforces that no impl falls back to the
207// historical stub sentinel string.
208
209impl i18n::Host for HostState {
210    fn t(&mut self, key: String) -> String {
211        self.translator.t(&key)
212    }
213    fn tf(&mut self, key: String, args: Vec<(String, String)>) -> String {
214        let borrowed: Vec<(&str, &str)> =
215            args.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
216        self.translator.tf(&key, &borrowed)
217    }
218}
219
220impl secrets::Host for HostState {
221    fn get(&mut self, uri: String) -> Result<String, String> {
222        // Permission check: every secret URI the extension reads must be
223        // declared verbatim or as a prefix in `permissions.secrets`. Strict
224        // prefix match (no glob today — that's a follow-up if needed).
225        let permitted = self
226            .permissions
227            .secrets
228            .iter()
229            .any(|allowed| uri == *allowed || uri.starts_with(&format!("{allowed}/")));
230        if !permitted {
231            tracing::warn!(
232                ext = %self.extension_id,
233                requested = %uri,
234                "secrets::get permission denied"
235            );
236            return Err(format!("permission denied for secret: {uri}"));
237        }
238        match self.secrets_backend.get(&uri) {
239            Ok(value) => Ok(value),
240            Err(crate::host_ports::SecretsError::NotFound(k)) => {
241                Err(format!("secret not found: {k}"))
242            }
243            Err(crate::host_ports::SecretsError::Backend(msg)) => {
244                tracing::error!(ext = %self.extension_id, %msg, "secrets backend error");
245                Err(format!("secrets backend error: {msg}"))
246            }
247        }
248    }
249}
250
251impl broker::Host for HostState {
252    fn call_extension(
253        &mut self,
254        kind: String,
255        target_id: String,
256        function: String,
257        _args_json: String,
258    ) -> Result<String, String> {
259        // 1. Permission check — declared kinds only.
260        if !self
261            .permissions
262            .call_extension_kinds
263            .iter()
264            .any(|k| k == &kind)
265        {
266            tracing::warn!(
267                ext = %self.extension_id,
268                requested_kind = %kind,
269                "broker::call_extension permission denied"
270            );
271            return Err(format!(
272                "{} may not call {kind} extensions",
273                self.extension_id
274            ));
275        }
276        // 2. Depth check — prevent unbounded recursion across host boundaries.
277        let depth = self.call_depth.load(Ordering::Relaxed);
278        if depth >= MAX_BROKER_DEPTH {
279            tracing::warn!(
280                ext = %self.extension_id,
281                depth,
282                "broker::call_extension max depth exceeded"
283            );
284            return Err(format!(
285                "max broker call depth exceeded ({depth} >= {MAX_BROKER_DEPTH})"
286            ));
287        }
288        // 3. Resolve runtime — `runtime_weak` is `Weak::new()` in unit tests
289        //    that use `HostState::builder(...).build()` without supplying a
290        //    runtime. Surface a clear error so test code distinguishes
291        //    "no runtime context" from "permission denied".
292        let Some(_rt) = self.runtime_weak.upgrade() else {
293            return Err("broker: no runtime context available".into());
294        };
295        // 4. Cross-extension dispatch — full implementation requires
296        //    `ExtensionRuntime::invoke_tool_with_depth(self: &Arc<Self>,
297        //    ...)` which cascades the runtime API to `&Arc<Self>` and
298        //    updates every caller. Deferred to a follow-up alongside the
299        //    WASM broker fixtures. Today's permission + depth check is
300        //    the security-load-bearing portion of B.5.
301        Err(format!(
302            "broker: dispatch to {target_id}.{function} pending (B.5 WASM round-trip)"
303        ))
304    }
305}
306
307impl http::Host for HostState {
308    fn fetch(&mut self, req: http::Request) -> Result<http::Response, String> {
309        // 1. Permission check via strict UrlMatcher.
310        if !self.url_matcher.is_allowed(&req.url) {
311            tracing::warn!(
312                ext = %self.extension_id,
313                url = %req.url,
314                "http::fetch permission denied"
315            );
316            return Err(format!("network not allowed for url: {}", req.url));
317        }
318
319        // 2. Build the reqwest request. `http_client` is `None` when the
320        //    host wasn't given one (typical for unit tests). Surface a
321        //    clean error rather than panic, and don't lazy-construct a
322        //    client here — see `HostOverrides` doc comment.
323        let client = self
324            .http_client
325            .as_ref()
326            .ok_or_else(|| "http client not configured for this runtime".to_string())?;
327        let method = match req.method.to_uppercase().as_str() {
328            "GET" => reqwest::Method::GET,
329            "POST" => reqwest::Method::POST,
330            "PUT" => reqwest::Method::PUT,
331            "DELETE" => reqwest::Method::DELETE,
332            "PATCH" => reqwest::Method::PATCH,
333            "HEAD" => reqwest::Method::HEAD,
334            other => return Err(format!("unsupported http method: {other}")),
335        };
336        let mut builder = client.request(method, &req.url);
337        for (k, v) in &req.headers {
338            builder = builder.header(k.as_str(), v.as_str());
339        }
340        if let Some(body) = req.body {
341            builder = builder.body(body);
342        }
343        // 3. Execute (blocking — wasmtime sync wiring expects sync host fns).
344        let resp = builder.send().map_err(|e| {
345            tracing::error!(ext = %self.extension_id, error = %e, "http::fetch transport error");
346            format!("http transport error: {e}")
347        })?;
348        let status = resp.status().as_u16();
349        let headers = resp
350            .headers()
351            .iter()
352            .filter_map(|(k, v)| {
353                v.to_str()
354                    .ok()
355                    .map(|s| (k.as_str().to_string(), s.to_string()))
356            })
357            .collect();
358        let body = resp
359            .bytes()
360            .map_err(|e| format!("http body error: {e}"))?
361            .to_vec();
362        Ok(http::Response {
363            status,
364            headers,
365            body,
366        })
367    }
368}
369
370impl llm::Host for HostState {
371    fn complete(&mut self, request: llm::LlmRequest) -> Result<llm::LlmResponse, String> {
372        // 1. Resolve the effective role from describe permissions. A `role_hint`
373        //    must be one the extension declared; with no hint we allow the sole
374        //    declared role and otherwise require disambiguation.
375        let declared = &self.permissions.llm_roles;
376        let role = match (&request.role_hint, declared.as_slice()) {
377            (Some(hint), roles) if roles.iter().any(|r| r == hint) => hint.clone(),
378            (Some(hint), _) => {
379                tracing::warn!(ext = %self.extension_id, requested = %hint, "llm role not permitted");
380                return Err(format!("llm role not permitted: {hint}"));
381            }
382            (None, [sole]) => sole.clone(),
383            (None, []) => {
384                return Err("llm role not permitted: extension declares no llm_roles".to_string());
385            }
386            (None, _many) => {
387                return Err(
388                    "llm role-hint required: extension declares multiple llm_roles".to_string(),
389                );
390            }
391        };
392
393        // 2. Resolve the port. Absent in unit tests and runtimes the host did
394        //    not wire for LLM use — surface a clean error rather than panic.
395        let Some(port) = self.llm_port.as_ref() else {
396            return Err("llm not configured for this runtime".to_string());
397        };
398
399        // 3. Map the WIT request onto the host port, call, map the response.
400        let port_req = crate::host_ports::LlmPortRequest {
401            system_prompt: request.system_prompt,
402            messages: request
403                .messages
404                .into_iter()
405                .map(|m| (m.role, m.content))
406                .collect(),
407            response_format: match request.response_format {
408                None | Some(llm::ResponseFormat::Text) => {
409                    crate::host_ports::LlmPortResponseFormat::Text
410                }
411                Some(llm::ResponseFormat::Json) => crate::host_ports::LlmPortResponseFormat::Json,
412                Some(llm::ResponseFormat::JsonSchema(s)) => {
413                    crate::host_ports::LlmPortResponseFormat::JsonSchema(s)
414                }
415            },
416        };
417        match port.complete(&self.extension_id, &self.call_ctx, &role, port_req) {
418            Ok(r) => Ok(llm::LlmResponse {
419                content: r.content,
420                total_tokens: r.total_tokens,
421            }),
422            Err(e) => {
423                tracing::warn!(ext = %self.extension_id, %role, error = %e, "llm port error");
424                Err(e.to_string())
425            }
426        }
427    }
428}
429
430impl crate::host_bindings::design_v04::greentic::oauth_broker::broker_v1::Host for HostState {
431    /// Retrieve a token for the given OAuth provider.
432    ///
433    /// Permission gate: the provider must be declared in
434    /// `permissions.oauth_providers`. If not, returns a JSON error string with
435    /// `"error": "permission_denied"` — fails closed.
436    ///
437    /// When permitted but no `oauth_config` or `http_client` is present (e.g.
438    /// the runtime was not configured with an OAuth broker), returns
439    /// `"error": "oauth_broker_unconfigured"`.
440    ///
441    /// The `shared_secret` is NEVER logged.
442    fn get_token(&mut self, provider_id: String, _subject: String, scopes: Vec<String>) -> String {
443        // Permission gate — mirror the secrets/network allowlist checks.
444        if !self
445            .permissions
446            .oauth_providers
447            .iter()
448            .any(|p| p == &provider_id)
449        {
450            tracing::warn!(
451                ext = %self.extension_id,
452                provider = %provider_id,
453                "oauth get-token permission denied"
454            );
455            return serde_json::json!({"error": "permission_denied", "provider": provider_id})
456                .to_string();
457        }
458
459        let Some(cfg) = self.oauth_config.clone() else {
460            return "{\"error\":\"oauth_broker_unconfigured\"}".to_string();
461        };
462        let Some(client) = self.http_client.clone() else {
463            return "{\"error\":\"oauth_broker_unconfigured\"}".to_string();
464        };
465
466        let req = crate::oauth::ResourceTokenRequest {
467            http_base_url: cfg.http_base_url,
468            env: cfg.env,
469            tenant: cfg.tenant,
470            team: cfg.team,
471            resource_id: provider_id.clone(),
472            scopes,
473        };
474
475        match crate::oauth::request_resource_token_blocking(
476            &client,
477            &req,
478            cfg.shared_secret.as_deref(),
479        ) {
480            Ok(resp) => serde_json::to_string(&resp)
481                .unwrap_or_else(|_| "{\"error\":\"encode_failed\"}".to_string()),
482            Err(e) => {
483                tracing::warn!(provider = %provider_id, error = %e, "oauth get-token failed");
484                "{\"error\":\"broker_request_failed\"}".to_string()
485            }
486        }
487    }
488
489    /// Build a consent URL for the given OAuth provider.
490    ///
491    /// Not yet implemented in the design-extension runtime — returns an empty
492    /// string. Consent flows are handled by the OAuth broker service directly;
493    /// this stub satisfies the WIT interface contract.
494    fn get_consent_url(
495        &mut self,
496        _provider_id: String,
497        _subject: String,
498        _scopes: Vec<String>,
499        _redirect_path: String,
500        _extra_json: String,
501    ) -> String {
502        String::new()
503    }
504
505    /// Exchange an authorization code for a token set.
506    ///
507    /// Not yet implemented in the design-extension runtime — returns an empty
508    /// string. Code exchange is handled by the OAuth broker service directly;
509    /// this stub satisfies the WIT interface contract.
510    fn exchange_code(
511        &mut self,
512        _provider_id: String,
513        _subject: String,
514        _code: String,
515        _redirect_path: String,
516    ) -> String {
517        String::new()
518    }
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524    use crate::host_bindings::greentic::extension_host::i18n::Host as I18nHost;
525    use crate::host_ports::Translator;
526    use std::sync::Arc;
527
528    struct EnglishToIndonesian;
529    impl Translator for EnglishToIndonesian {
530        fn t(&self, key: &str) -> String {
531            match key {
532                "greentic.test.hello" => "Halo dunia".to_string(),
533                _ => key.to_string(),
534            }
535        }
536        fn tf(&self, key: &str, args: &[(&str, &str)]) -> String {
537            let template = self.t(key);
538            args.iter()
539                .fold(template, |acc, (k, v)| acc.replace(&format!("{{{k}}}"), v))
540        }
541    }
542
543    fn host_with_translator(t: Arc<dyn Translator>) -> HostState {
544        HostState::builder("test-ext".to_string(), Permissions::default())
545            .translator(t)
546            .build()
547    }
548
549    #[test]
550    fn i18n_t_resolves_translated_value() {
551        let mut h = host_with_translator(Arc::new(EnglishToIndonesian));
552        let got = h.t("greentic.test.hello".to_string());
553        assert_eq!(got, "Halo dunia");
554    }
555
556    #[test]
557    fn i18n_t_falls_back_to_key_for_unknown() {
558        let mut h = host_with_translator(Arc::new(EnglishToIndonesian));
559        let got = h.t("missing.key".to_string());
560        assert_eq!(got, "missing.key");
561    }
562
563    #[test]
564    fn secrets_get_returns_value_when_permitted() {
565        use crate::host_bindings::greentic::extension_host::secrets::Host as SecretsHost;
566        use crate::host_ports::InMemorySecrets;
567
568        let mut backend = InMemorySecrets::default();
569        backend.insert("api.openai.com/api_key", "sk-real");
570        let mut perms = Permissions::default();
571        perms.secrets.push("api.openai.com/api_key".to_string());
572
573        let mut h = HostState::builder("test-ext".to_string(), perms)
574            .secrets_backend(Arc::new(backend))
575            .build();
576        let v = h.get("api.openai.com/api_key".to_string()).unwrap();
577        assert_eq!(v, "sk-real");
578    }
579
580    #[test]
581    fn secrets_get_denies_when_uri_not_in_permissions() {
582        use crate::host_bindings::greentic::extension_host::secrets::Host as SecretsHost;
583        use crate::host_ports::InMemorySecrets;
584
585        let mut backend = InMemorySecrets::default();
586        backend.insert("api.openai.com/api_key", "sk-real");
587        let perms = Permissions::default();
588
589        let mut h = HostState::builder("test-ext".to_string(), perms)
590            .secrets_backend(Arc::new(backend))
591            .build();
592        let err = h.get("api.openai.com/api_key".to_string()).unwrap_err();
593        assert!(
594            err.contains("permission denied"),
595            "expected permission denied, got: {err}"
596        );
597    }
598
599    #[test]
600    fn broker_denies_when_kind_not_in_permissions() {
601        use crate::host_bindings::greentic::extension_host::broker::Host as BrokerHost;
602
603        let mut perms = Permissions::default();
604        perms.call_extension_kinds.push("provider".to_string());
605        let mut h = HostState::builder("caller".into(), perms).build();
606
607        let err = h
608            .call_extension(
609                "design".into(),
610                "greentic.target".into(),
611                "do_something".into(),
612                "{}".into(),
613            )
614            .unwrap_err();
615        assert!(err.contains("may not call"), "got: {err}");
616    }
617
618    #[test]
619    fn broker_rejects_when_depth_exceeded() {
620        use crate::host_bindings::greentic::extension_host::broker::Host as BrokerHost;
621
622        let mut perms = Permissions::default();
623        perms.call_extension_kinds.push("design".to_string());
624        let mut h = HostState::builder("caller".into(), perms)
625            .call_depth_start(MAX_BROKER_DEPTH)
626            .build();
627        let err = h
628            .call_extension(
629                "design".into(),
630                "greentic.target".into(),
631                "do".into(),
632                "{}".into(),
633            )
634            .unwrap_err();
635        assert!(err.contains("max"), "expected depth error, got: {err}");
636    }
637
638    #[test]
639    fn http_fetch_denied_when_url_not_in_matcher() {
640        use crate::host_bindings::greentic::extension_host::http::{Host as HttpHost, Request};
641
642        let mut h = HostState::builder("test-ext".into(), Permissions::default())
643            .url_matcher(crate::url_matcher::UrlMatcher::from_patterns(vec![
644                "https://allowed.com/*".into(),
645            ]))
646            .build();
647        let req = Request {
648            method: "GET".into(),
649            url: "https://evil.com/".into(),
650            headers: vec![],
651            body: None,
652        };
653        let err = h.fetch(req).unwrap_err();
654        assert!(
655            err.contains("not allowed") || err.contains("permission denied"),
656            "got: {err}"
657        );
658    }
659
660    #[test]
661    fn secrets_get_surfaces_backend_not_found() {
662        use crate::host_bindings::greentic::extension_host::secrets::Host as SecretsHost;
663        use crate::host_ports::InMemorySecrets;
664
665        let backend = InMemorySecrets::default();
666        let mut perms = Permissions::default();
667        perms.secrets.push("api.openai.com/api_key".to_string());
668
669        let mut h = HostState::builder("test-ext".to_string(), perms)
670            .secrets_backend(Arc::new(backend))
671            .build();
672        let err = h.get("api.openai.com/api_key".to_string()).unwrap_err();
673        assert!(err.contains("not found"), "got: {err}");
674    }
675
676    /// In-test [`LlmPort`] that records the `extension_id` / `ctx` / `role`
677    /// it was called with and echoes the system prompt back. Asserts the host
678    /// resolved the expected role and threaded the expected tenant + user email
679    /// before forwarding.
680    ///
681    /// `expected_*` prefix is deliberate (these are the values the port asserts
682    /// against, not generic data), so the shared-prefix lint is silenced here.
683    #[allow(clippy::struct_field_names)]
684    struct FakeLlm {
685        expected_extension_id: String,
686        expected_tenant: Option<String>,
687        expected_user_email: Option<String>,
688        expected_role: String,
689    }
690
691    impl crate::host_ports::LlmPort for FakeLlm {
692        fn complete(
693            &self,
694            extension_id: &str,
695            ctx: &crate::host_ports::HostCallContext,
696            role: &str,
697            request: crate::host_ports::LlmPortRequest,
698        ) -> Result<crate::host_ports::LlmPortResponse, crate::host_ports::LlmPortError> {
699            assert_eq!(extension_id, self.expected_extension_id, "extension_id");
700            assert_eq!(ctx.tenant, self.expected_tenant, "tenant");
701            assert_eq!(ctx.user_email, self.expected_user_email, "user_email");
702            assert_eq!(role, self.expected_role, "role");
703            Ok(crate::host_ports::LlmPortResponse {
704                content: format!("echo:{}", request.system_prompt),
705                total_tokens: Some(7),
706            })
707        }
708    }
709
710    fn llm_request(role_hint: Option<&str>) -> llm::LlmRequest {
711        llm::LlmRequest {
712            role_hint: role_hint.map(str::to_string),
713            system_prompt: "you are a composer".to_string(),
714            messages: vec![],
715            response_format: None,
716        }
717    }
718
719    #[test]
720    fn llm_complete_resolves_sole_declared_role() {
721        use crate::host_bindings::greentic::extension_host::llm::Host as LlmHost;
722
723        let mut perms = Permissions::default();
724        perms.llm_roles.push("sorla_composer".to_string());
725        let port = Arc::new(FakeLlm {
726            expected_extension_id: "test-ext".to_string(),
727            expected_tenant: Some("acme".to_string()),
728            expected_user_email: None,
729            expected_role: "sorla_composer".to_string(),
730        });
731        let mut h = HostState::builder("test-ext".to_string(), perms)
732            .llm_port(Some(port))
733            .call_ctx(crate::host_ports::HostCallContext {
734                tenant: Some("acme".into()),
735                user_email: None,
736            })
737            .build();
738
739        let resp = h
740            .complete(llm_request(None))
741            .expect("complete should succeed");
742        assert_eq!(resp.content, "echo:you are a composer");
743        assert_eq!(resp.total_tokens, Some(7));
744    }
745
746    #[test]
747    fn llm_complete_passes_none_tenant_by_default() {
748        use crate::host_bindings::greentic::extension_host::llm::Host as LlmHost;
749
750        let mut perms = Permissions::default();
751        perms.llm_roles.push("sorla_composer".to_string());
752        let port = Arc::new(FakeLlm {
753            expected_extension_id: "test-ext".to_string(),
754            expected_tenant: None,
755            expected_user_email: None,
756            expected_role: "sorla_composer".to_string(),
757        });
758        // No `.call_ctx(...)` in the builder chain — the host runs
759        // single-tenant/dev, so the port must observe a default (all-`None`)
760        // context.
761        let mut h = HostState::builder("test-ext".to_string(), perms)
762            .llm_port(Some(port))
763            .build();
764
765        let resp = h
766            .complete(llm_request(None))
767            .expect("complete should succeed");
768        assert_eq!(resp.content, "echo:you are a composer");
769    }
770
771    #[test]
772    fn llm_complete_passes_user_email() {
773        use crate::host_bindings::greentic::extension_host::llm::Host as LlmHost;
774
775        let mut perms = Permissions::default();
776        perms.llm_roles.push("sorla_composer".to_string());
777        let port = Arc::new(FakeLlm {
778            expected_extension_id: "test-ext".to_string(),
779            expected_tenant: Some("acme".to_string()),
780            expected_user_email: Some("alice@acme.com".to_string()),
781            expected_role: "sorla_composer".to_string(),
782        });
783        let mut h = HostState::builder("test-ext".to_string(), perms)
784            .llm_port(Some(port))
785            .call_ctx(crate::host_ports::HostCallContext {
786                tenant: Some("acme".into()),
787                user_email: Some("alice@acme.com".into()),
788            })
789            .build();
790
791        let resp = h
792            .complete(llm_request(None))
793            .expect("complete should succeed");
794        assert_eq!(resp.content, "echo:you are a composer");
795    }
796
797    #[test]
798    fn llm_complete_rejects_undeclared_role() {
799        use crate::host_bindings::greentic::extension_host::llm::Host as LlmHost;
800
801        let perms = Permissions::default(); // no llm_roles declared
802        let port = Arc::new(FakeLlm {
803            expected_extension_id: "test-ext".to_string(),
804            expected_tenant: None,
805            expected_user_email: None,
806            expected_role: "sorla_composer".to_string(),
807        });
808        let mut h = HostState::builder("test-ext".to_string(), perms)
809            .llm_port(Some(port))
810            .build();
811
812        let err = h.complete(llm_request(Some("sorla_composer"))).unwrap_err();
813        assert!(err.contains("llm role not permitted"), "got: {err}");
814    }
815
816    #[test]
817    fn llm_complete_without_port_errors() {
818        use crate::host_bindings::greentic::extension_host::llm::Host as LlmHost;
819
820        let mut perms = Permissions::default();
821        perms.llm_roles.push("sorla_composer".to_string());
822        let mut h = HostState::builder("test-ext".to_string(), perms).build();
823
824        let err = h.complete(llm_request(None)).unwrap_err();
825        assert!(err.contains("llm not configured"), "got: {err}");
826    }
827
828    #[test]
829    fn llm_complete_requires_hint_when_multiple_roles() {
830        use crate::host_bindings::greentic::extension_host::llm::Host as LlmHost;
831
832        let mut perms = Permissions::default();
833        perms.llm_roles.push("a".to_string());
834        perms.llm_roles.push("b".to_string());
835        let port = Arc::new(FakeLlm {
836            expected_extension_id: "test-ext".to_string(),
837            expected_tenant: None,
838            expected_user_email: None,
839            expected_role: "unused".to_string(),
840        });
841        let mut h = HostState::builder("test-ext".to_string(), perms)
842            .llm_port(Some(port))
843            .build();
844
845        let err = h.complete(llm_request(None)).unwrap_err();
846        assert!(err.contains("role-hint required"), "got: {err}");
847    }
848
849    #[test]
850    fn i18n_tf_substitutes_named_args() {
851        struct GreetTranslator;
852        impl Translator for GreetTranslator {
853            fn t(&self, key: &str) -> String {
854                if key == "greentic.test.greet" {
855                    "Halo {name}!".to_string()
856                } else {
857                    key.to_string()
858                }
859            }
860            fn tf(&self, key: &str, args: &[(&str, &str)]) -> String {
861                let template = self.t(key);
862                args.iter()
863                    .fold(template, |acc, (k, v)| acc.replace(&format!("{{{k}}}"), v))
864            }
865        }
866        let mut h = host_with_translator(Arc::new(GreetTranslator));
867        let got = h.tf(
868            "greentic.test.greet".to_string(),
869            vec![("name".to_string(), "Bima".to_string())],
870        );
871        assert_eq!(got, "Halo Bima!");
872    }
873
874    #[test]
875    fn oauth_get_token_denied_when_provider_not_declared() {
876        use crate::host_bindings::design_v04::greentic::oauth_broker::broker_v1::Host as OAuthHost;
877        let mut h = HostState::builder("ext".into(), Permissions::default()).build();
878        let out = h.get_token("hubspot".into(), String::new(), vec![]);
879        assert!(out.contains("permission_denied"), "got: {out}");
880    }
881
882    #[test]
883    fn oauth_get_token_errors_when_unconfigured() {
884        use crate::host_bindings::design_v04::greentic::oauth_broker::broker_v1::Host as OAuthHost;
885        let mut perms = Permissions::default();
886        perms.oauth_providers.push("hubspot".into());
887        let mut h = HostState::builder("ext".into(), perms).build();
888        let out = h.get_token("hubspot".into(), String::new(), vec![]);
889        assert!(out.contains("oauth_broker_unconfigured"), "got: {out}");
890    }
891
892    #[test]
893    fn host_state_carries_oauth_config() {
894        use crate::oauth::OAuthBrokerConfig;
895        let cfg = OAuthBrokerConfig {
896            http_base_url: "https://oauth.example/".into(),
897            env: "dev".into(),
898            tenant: "acme".into(),
899            team: None,
900            shared_secret: Some("s".into()),
901        };
902        let h = HostState::builder("test-ext".to_string(), Permissions::default())
903            .oauth_config(Some(cfg.clone()))
904            .build();
905        assert_eq!(h.oauth_config.as_ref().unwrap().tenant, "acme");
906    }
907}