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
12pub const MAX_BROKER_DEPTH: u32 = 8;
15
16pub 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 call_ctx: crate::host_ports::HostCallContext,
31 url_matcher: UrlMatcher,
32 runtime_weak: std::sync::Weak<crate::runtime::ExtensionRuntime>,
33 oauth_config: Option<crate::oauth::OAuthBrokerConfig>,
35 wasi: WasiCtx,
38 table: ResourceTable,
39}
40
41impl HostState {
42 #[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
77pub 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 #[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
164impl 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
205impl 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 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 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 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 let Some(_rt) = self.runtime_weak.upgrade() else {
293 return Err("broker: no runtime context available".into());
294 };
295 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 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 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 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 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 let Some(port) = self.llm_port.as_ref() else {
396 return Err("llm not configured for this runtime".to_string());
397 };
398
399 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 fn get_token(&mut self, provider_id: String, _subject: String, scopes: Vec<String>) -> String {
443 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 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 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 #[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 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(); 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}