1#![deny(unsafe_code)]
2use std::collections::{HashMap, HashSet};
10use std::fs;
11use std::path::PathBuf;
12use std::sync::Arc;
13use std::time::Duration;
14
15use crate::secrets::SecretsBackend;
16use anyhow::{Context, Result, anyhow};
17use greentic_config::ResolvedConfig;
18use greentic_config_types::TelemetryExporterKind;
19use greentic_config_types::{
20 NetworkConfig, PackSourceConfig, PacksConfig, PathsConfig, TelemetryConfig,
21};
22use greentic_telemetry::export::{ExportConfig as TelemetryExportConfig, ExportMode, Sampling};
23use runner_core::env::PackConfig;
24use serde_json::json;
25use tokio::signal;
26
27pub mod boot;
28pub mod cache;
29pub mod component_api;
30pub mod config;
31pub mod engine;
32pub mod extension_provider;
33pub mod fault;
34#[cfg(feature = "greentic-x-provider")]
35pub mod greentic_x_provider;
36pub mod gtbind;
37pub mod http;
38pub mod identify_hint;
39pub mod metrics;
40pub mod operator_metrics;
41pub mod operator_registry;
42pub mod pack;
43pub mod provider;
44pub mod provider_core;
45pub mod provider_core_only;
46pub mod routing;
47pub mod runner;
48pub mod runtime;
49pub mod runtime_refs;
50pub mod runtime_wasmtime;
51pub mod secrets;
52pub(crate) mod secrets_broker;
53pub mod sql;
54pub mod storage;
55pub mod telemetry;
56pub mod telemetry_scan;
57#[cfg(feature = "fault-injection")]
58pub mod testing;
59pub mod trace;
60pub mod validate;
61pub mod verify;
62pub mod wasi;
63pub mod watcher;
64
65mod activity;
66mod host;
67pub mod oauth;
68
69pub use activity::{Activity, ActivityKind, WelcomeFlowHint};
70pub use config::HostConfig;
71pub use gtbind::{PackBinding, TenantBindings};
72pub use host::TelemetryCfg;
73pub use host::{HostBuilder, RunnerHost, TenantHandle};
74pub use wasi::{PreopenSpec, RunnerWasiPolicy};
75
76pub use greentic_types::{EnvId, FlowId, PackId, TenantCtx, TenantId};
77
78pub use http::auth::AdminAuth;
79pub use routing::RoutingConfig;
80use routing::TenantRouting;
81pub use runner::HostServer;
82
83#[cfg(test)]
84pub(crate) mod test_support {
85 use super::*;
86 use crate::config::{OperatorPolicy, SecretsPolicy};
87 use crate::runtime::TenantRuntime;
88 use crate::secrets::default_manager;
89 use crate::storage::{new_session_store, new_state_store, session_host_from, state_host_from};
90 use crate::trace::TraceConfig;
91 use crate::validate::ValidationConfig;
92 use tempfile::TempDir;
93
94 pub(crate) fn fixture_pack_path() -> PathBuf {
95 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
96 .join("../../examples/packs/demo.gtpack")
97 .canonicalize()
98 .expect("fixture pack path")
99 }
100
101 fn minimal_config(workspace: &std::path::Path) -> Result<Arc<HostConfig>> {
102 let bindings_path = workspace.join("bindings.yaml");
103 std::fs::write(
104 &bindings_path,
105 r#"
106tenant: demo
107flow_type_bindings: {}
108rate_limits: {}
109retry: {}
110timers: []
111"#,
112 )?;
113 let mut config =
114 HostConfig::load_from_path(&bindings_path).context("load minimal host bindings")?;
115 config.secrets_policy = SecretsPolicy::allow_all();
116 config.operator_policy = OperatorPolicy::allow_all();
117 config.trace = TraceConfig::from_env();
118 config.validation = ValidationConfig::from_env();
119 Ok(Arc::new(config))
120 }
121
122 pub(crate) async fn build_test_runtime() -> Result<(TempDir, Arc<TenantRuntime>)> {
123 let workspace = TempDir::new().context("temp workspace")?;
124 let config = minimal_config(workspace.path())?;
125 let session_store = new_session_store();
126 let session_host = session_host_from(Arc::clone(&session_store));
127 let state_store = new_state_store();
128 let state_host = state_host_from(Arc::clone(&state_store));
129 let secrets = default_manager()?;
130 let pack_path = fixture_pack_path();
131 let runtime = TenantRuntime::load(
132 &pack_path,
133 config,
134 None,
135 Some(&pack_path),
136 None,
137 Arc::new(RunnerWasiPolicy::new()),
138 session_host,
139 Arc::clone(&session_store),
140 Arc::clone(&state_store),
141 state_host,
142 secrets,
143 )
144 .await?;
145 Ok((workspace, runtime))
146 }
147}
148
149#[derive(Clone)]
151pub struct RunnerConfig {
152 pub tenant_bindings: HashMap<String, TenantBindings>,
153 pub pack: PackConfig,
154 pub port: u16,
155 pub refresh_interval: Duration,
156 pub routing: RoutingConfig,
157 pub admin: AdminAuth,
158 pub telemetry: Option<TelemetryCfg>,
159 pub secrets_backend: SecretsBackend,
160 pub wasi_policy: RunnerWasiPolicy,
161 pub resolved_config: ResolvedConfig,
162 pub trace: trace::TraceConfig,
163 pub validation: validate::ValidationConfig,
164}
165
166impl RunnerConfig {
167 pub fn from_config(resolved_config: ResolvedConfig, bindings: Vec<PathBuf>) -> Result<Self> {
169 if bindings.is_empty() {
170 anyhow::bail!("at least one gtbind file is required");
171 }
172 let tenant_bindings = gtbind::load_gtbinds(&bindings)?;
173 if tenant_bindings.is_empty() {
174 anyhow::bail!("no gtbind files loaded");
175 }
176 let mut pack = pack_config_from(
177 &resolved_config.config.packs,
178 &resolved_config.config.paths,
179 &resolved_config.config.network,
180 )?;
181 maybe_write_gtbind_index(&tenant_bindings, &resolved_config.config.paths, &mut pack)?;
182 let refresh = parse_refresh_interval(std::env::var("PACK_REFRESH_INTERVAL").ok())?;
183 let port = std::env::var("PORT")
184 .ok()
185 .and_then(|value| value.parse().ok())
186 .unwrap_or(8080);
187 let default_tenant = resolved_config
188 .config
189 .dev
190 .as_ref()
191 .map(|dev| dev.default_tenant.clone())
192 .unwrap_or_else(|| crate::routing::DEFAULT_TENANT.into());
193 let routing = RoutingConfig::from_env_with_default(default_tenant);
194 let paths = &resolved_config.config.paths;
195 ensure_paths_exist(paths)?;
196 let mut wasi_policy = default_wasi_policy(paths);
197 let mut env_allow = HashSet::new();
198 for binding in tenant_bindings.values() {
199 env_allow.extend(binding.env_passthrough.iter().cloned());
200 }
201 for key in env_allow {
202 wasi_policy = wasi_policy.allow_env(key);
203 }
204
205 let admin = AdminAuth::new(resolved_config.config.services.as_ref().and_then(|s| {
206 s.events
207 .as_ref()
208 .and_then(|svc| svc.headers.as_ref())
209 .and_then(|headers| headers.get("x-admin-token").cloned())
210 }));
211 let secrets_backend = SecretsBackend::from_config(&resolved_config.config.secrets)?;
212 Ok(Self {
213 tenant_bindings,
214 pack,
215 port,
216 refresh_interval: refresh,
217 routing,
218 admin,
219 telemetry: telemetry_from(&resolved_config.config.telemetry),
220 secrets_backend,
221 wasi_policy,
222 resolved_config,
223 trace: trace::TraceConfig::from_env(),
224 validation: validate::ValidationConfig::from_env(),
225 })
226 }
227
228 pub fn with_port(mut self, port: u16) -> Self {
230 self.port = port;
231 self
232 }
233
234 pub fn with_wasi_policy(mut self, policy: RunnerWasiPolicy) -> Self {
235 self.wasi_policy = policy;
236 self
237 }
238}
239
240fn maybe_write_gtbind_index(
241 tenant_bindings: &HashMap<String, TenantBindings>,
242 paths: &PathsConfig,
243 pack: &mut PackConfig,
244) -> Result<()> {
245 let mut uses_locators = false;
246 for binding in tenant_bindings.values() {
247 for pack_binding in &binding.packs {
248 if pack_binding.pack_locator.is_some() {
249 uses_locators = true;
250 }
251 }
252 }
253 if !uses_locators {
254 return Ok(());
255 }
256
257 let mut entries = serde_json::Map::new();
258 for binding in tenant_bindings.values() {
259 let mut packs = Vec::new();
260 for pack_binding in &binding.packs {
261 let locator = pack_binding.pack_locator.as_ref().ok_or_else(|| {
262 anyhow::anyhow!(
263 "gtbind {} missing pack_locator for pack {}",
264 binding.tenant,
265 pack_binding.pack_id
266 )
267 })?;
268 let (name, version_or_digest) =
269 pack_binding.pack_ref.split_once('@').ok_or_else(|| {
270 anyhow::anyhow!(
271 "gtbind {} invalid pack_ref {} (expected name@version)",
272 binding.tenant,
273 pack_binding.pack_ref
274 )
275 })?;
276 if name != pack_binding.pack_id {
277 anyhow::bail!(
278 "gtbind {} pack_ref {} does not match pack_id {}",
279 binding.tenant,
280 pack_binding.pack_ref,
281 pack_binding.pack_id
282 );
283 }
284 let mut entry = serde_json::Map::new();
285 entry.insert("name".to_string(), json!(name));
286 if version_or_digest.contains(':') {
287 entry.insert("digest".to_string(), json!(version_or_digest));
288 } else {
289 entry.insert("version".to_string(), json!(version_or_digest));
290 }
291 entry.insert("locator".to_string(), json!(locator));
292 packs.push(serde_json::Value::Object(entry));
293 }
294 let main_pack = packs
295 .first()
296 .cloned()
297 .ok_or_else(|| anyhow::anyhow!("gtbind {} has no packs", binding.tenant))?;
298 let overlays = packs.into_iter().skip(1).collect::<Vec<_>>();
299 entries.insert(
300 binding.tenant.clone(),
301 json!({
302 "main_pack": main_pack,
303 "overlays": overlays,
304 }),
305 );
306 }
307
308 let index_path = paths.greentic_root.join("packs").join("gtbind.index.json");
309 if let Some(parent) = index_path.parent() {
310 fs::create_dir_all(parent)
311 .with_context(|| format!("failed to create {}", parent.display()))?;
312 }
313 let serialized = serde_json::to_vec_pretty(&serde_json::Value::Object(entries))?;
314 fs::write(&index_path, serialized)
315 .with_context(|| format!("failed to write {}", index_path.display()))?;
316 pack.index_location = runner_core::env::IndexLocation::File(index_path);
317 Ok(())
318}
319
320fn parse_refresh_interval(value: Option<String>) -> Result<Duration> {
321 let raw = value.unwrap_or_else(|| "30s".into());
322 humantime::parse_duration(&raw).map_err(|err| anyhow!("invalid PACK_REFRESH_INTERVAL: {err}"))
323}
324
325fn default_wasi_policy(paths: &PathsConfig) -> RunnerWasiPolicy {
326 let mut policy = RunnerWasiPolicy::default()
327 .with_env("GREENTIC_ROOT", paths.greentic_root.display().to_string())
328 .with_env("GREENTIC_STATE_DIR", paths.state_dir.display().to_string())
329 .with_env("GREENTIC_CACHE_DIR", paths.cache_dir.display().to_string())
330 .with_env("GREENTIC_LOGS_DIR", paths.logs_dir.display().to_string());
331 policy = policy
332 .with_preopen(PreopenSpec::new(&paths.state_dir, "/state"))
333 .with_preopen(PreopenSpec::new(&paths.cache_dir, "/cache"))
334 .with_preopen(PreopenSpec::new(&paths.logs_dir, "/logs"));
335 policy
336}
337
338fn ensure_paths_exist(paths: &PathsConfig) -> Result<()> {
339 for dir in [
340 &paths.greentic_root,
341 &paths.state_dir,
342 &paths.cache_dir,
343 &paths.logs_dir,
344 ] {
345 fs::create_dir_all(dir)
346 .with_context(|| format!("failed to ensure directory {}", dir.display()))?;
347 }
348 Ok(())
349}
350
351fn pack_config_from(
352 packs: &Option<PacksConfig>,
353 paths: &PathsConfig,
354 network: &NetworkConfig,
355) -> Result<PackConfig> {
356 if let Some(cfg) = packs {
357 let cache_dir = cfg.cache_dir.clone();
358 let index_location = match &cfg.source {
359 PackSourceConfig::LocalIndex { path } => {
360 runner_core::env::IndexLocation::File(path.clone())
361 }
362 PackSourceConfig::HttpIndex { url } => {
363 runner_core::env::IndexLocation::from_value(url)?
364 }
365 PackSourceConfig::OciRegistry { reference } => {
366 runner_core::env::IndexLocation::from_value(reference)?
367 }
368 };
369 let public_key = cfg
370 .trust
371 .as_ref()
372 .and_then(|trust| trust.public_keys.first().cloned());
373 return Ok(PackConfig {
374 source: runner_core::env::PackSource::Fs,
375 index_location,
376 cache_dir,
377 public_key,
378 network: Some(network.clone()),
379 });
380 }
381 let mut cfg = PackConfig::default_for_paths(paths)?;
382 cfg.network = Some(network.clone());
383 Ok(cfg)
384}
385
386fn telemetry_from(cfg: &TelemetryConfig) -> Option<TelemetryCfg> {
387 telemetry_from_env(
388 cfg,
389 std::env::var("OTLP_HEADERS").ok(),
390 std::env::var("OTEL_SERVICE_NAME").ok(),
391 )
392}
393
394fn telemetry_from_env(
399 cfg: &TelemetryConfig,
400 otlp_headers: Option<String>,
401 service_name: Option<String>,
402) -> Option<TelemetryCfg> {
403 if !cfg.enabled || matches!(cfg.exporter, TelemetryExporterKind::None) {
404 return None;
405 }
406 let mut export = TelemetryExportConfig::json_default();
407 export.mode = match cfg.exporter {
408 TelemetryExporterKind::Otlp => ExportMode::OtlpGrpc,
409 TelemetryExporterKind::Stdout => ExportMode::JsonStdout,
410 TelemetryExporterKind::Gcp => ExportMode::GcpCloudTrace,
411 TelemetryExporterKind::Azure => ExportMode::AzureAppInsights,
412 TelemetryExporterKind::Aws => ExportMode::AwsXRay,
413 TelemetryExporterKind::None => return None,
414 };
415 export.endpoint = cfg.endpoint.clone();
416 export.sampling = Sampling::TraceIdRatio(cfg.sampling as f64);
417
418 match greentic_telemetry::presets::parse_headers_from_env(otlp_headers) {
434 Ok(headers) => {
435 if !headers.is_empty() {
436 export.headers = headers;
437 }
438 }
439 Err(err) => {
444 tracing::warn!(
445 error = %err,
446 "OTLP_HEADERS could not be parsed; exporting without headers"
447 );
448 }
449 }
450
451 Some(TelemetryCfg {
452 config: greentic_telemetry::TelemetryConfig {
453 service_name: service_name
464 .filter(|s| !s.trim().is_empty())
465 .unwrap_or_else(|| "greentic-runner".into()),
466 },
467 export,
468 })
469}
470
471#[cfg(feature = "agentic-worker")]
485fn maybe_spawn_inproc_agentic_serve() {
486 use crate::runner::agent_node::{
487 load_process_agent_configs, serve_agentic, should_serve_agentic_inproc,
488 };
489
490 if !should_serve_agentic_inproc(|key| std::env::var(key).ok()) {
491 return;
492 }
493
494 let nats_url = std::env::var("GREENTIC_EVENTS_NATS_URL").unwrap_or_default();
496 let agents = load_process_agent_configs();
497 if agents.is_empty() {
498 tracing::warn!(
499 "GREENTIC_AGENTIC_SERVE_INPROC set but no process-level agents found in \
500 GREENTIC_AGENT_MANIFESTS_DIR; in-process agentic serve skipped"
501 );
502 return;
503 }
504
505 let agent_count = agents.len();
506 tracing::info!(
507 agent_count,
508 nats_url = %nats_url,
509 "starting in-process agentic serve (GREENTIC_AGENTIC_SERVE_INPROC)"
510 );
511 tokio::spawn(async move {
512 if let Err(error) = serve_agentic(&nats_url, agents).await {
513 tracing::warn!(error = %error, "in-process agentic serve stopped with error");
514 }
515 });
516}
517
518pub async fn run(cfg: RunnerConfig) -> Result<()> {
520 let RunnerConfig {
521 tenant_bindings,
522 pack,
523 port,
524 refresh_interval,
525 routing,
526 admin,
527 telemetry,
528 secrets_backend,
529 wasi_policy,
530 resolved_config: _resolved_config,
531 trace,
532 validation,
533 } = cfg;
534
535 let mut builder = HostBuilder::new();
536 for bindings in tenant_bindings.into_values() {
537 let mut host_config = HostConfig::from_gtbind(bindings);
538 host_config.trace = trace.clone();
539 host_config.validation = validation.clone();
540 builder = builder.with_config(host_config);
541 }
542 if let Some(telemetry) = telemetry.clone() {
543 builder = builder.with_telemetry(telemetry);
544 }
545 builder = builder
546 .with_wasi_policy(wasi_policy.clone())
547 .with_secrets_manager(
548 secrets_backend
549 .build_manager()
550 .context("failed to initialise secrets backend")?,
551 );
552
553 let host = Arc::new(builder.build()?);
554 host.start().await?;
555
556 #[cfg(feature = "agentic-worker")]
562 maybe_spawn_inproc_agentic_serve();
563
564 let (watcher, reload_handle) =
565 watcher::start_pack_watcher(Arc::clone(&host), pack.clone(), refresh_interval).await?;
566
567 let routing = TenantRouting::new(routing.clone());
568 let server = HostServer::new(
569 port,
570 host.active_packs(),
571 routing,
572 host.health_state(),
573 Some(reload_handle),
574 admin.clone(),
575 Arc::clone(&host),
576 )?;
577
578 tokio::select! {
579 result = server.serve() => {
580 result?;
581 }
582 _ = signal::ctrl_c() => {
583 tracing::info!("received shutdown signal");
584 }
585 }
586
587 drop(watcher);
588 host.stop().await?;
589 Ok(())
590}
591
592#[cfg(test)]
593mod tests {
594 use super::*;
595 use crate::gtbind::{PackBinding, TenantBindings};
596 use greentic_config_types::PackTrustConfig;
597 use tempfile::TempDir;
598
599 fn paths(temp: &TempDir) -> PathsConfig {
600 PathsConfig {
601 greentic_root: temp.path().join("greentic"),
602 state_dir: temp.path().join("state"),
603 cache_dir: temp.path().join("cache"),
604 logs_dir: temp.path().join("logs"),
605 }
606 }
607
608 #[test]
609 fn parse_refresh_interval_uses_default_and_rejects_invalid_values() {
610 assert_eq!(
611 parse_refresh_interval(None).expect("default interval"),
612 Duration::from_secs(30)
613 );
614 assert!(parse_refresh_interval(Some("not-a-duration".into())).is_err());
615 }
616
617 #[test]
618 fn ensure_paths_exist_creates_expected_directories() {
619 let temp = TempDir::new().expect("tempdir");
620 let paths = paths(&temp);
621 ensure_paths_exist(&paths).expect("create directories");
622
623 assert!(paths.greentic_root.is_dir());
624 assert!(paths.state_dir.is_dir());
625 assert!(paths.cache_dir.is_dir());
626 assert!(paths.logs_dir.is_dir());
627 }
628
629 #[test]
630 fn maybe_write_gtbind_index_writes_locator_index() {
631 let temp = TempDir::new().expect("tempdir");
632 let paths = paths(&temp);
633 fs::create_dir_all(&paths.greentic_root).expect("greentic root");
634 let mut pack = PackConfig::default_for_paths(&paths).expect("pack config");
635 let tenant_bindings = HashMap::from([(
636 "demo".to_string(),
637 TenantBindings {
638 tenant: "demo".into(),
639 packs: vec![
640 PackBinding {
641 pack_id: "pack.main".into(),
642 pack_ref: "pack.main@1.0.0".into(),
643 pack_locator: Some("fs:///packs/main.gtpack".into()),
644 flows: vec!["main".into()],
645 },
646 PackBinding {
647 pack_id: "pack.overlay".into(),
648 pack_ref: "pack.overlay@sha256:abcd".into(),
649 pack_locator: Some("fs:///packs/overlay.gtpack".into()),
650 flows: vec![],
651 },
652 ],
653 env_passthrough: vec![],
654 },
655 )]);
656
657 maybe_write_gtbind_index(&tenant_bindings, &paths, &mut pack).expect("write gtbind index");
658
659 let index_path = paths.greentic_root.join("packs").join("gtbind.index.json");
660 let json: serde_json::Value =
661 serde_json::from_slice(&fs::read(&index_path).expect("read index")).expect("json");
662 assert_eq!(
663 json["demo"]["main_pack"]["locator"],
664 "fs:///packs/main.gtpack"
665 );
666 assert_eq!(json["demo"]["overlays"][0]["digest"], "sha256:abcd");
667 match pack.index_location {
668 runner_core::env::IndexLocation::File(path) => assert_eq!(path, index_path),
669 runner_core::env::IndexLocation::Remote(_) => panic!("expected generated file index"),
670 }
671 }
672
673 #[test]
674 fn pack_config_from_prefers_configured_pack_source() {
675 let temp = TempDir::new().expect("tempdir");
676 let paths = paths(&temp);
677 let packs = Some(PacksConfig {
678 source: PackSourceConfig::HttpIndex {
679 url: "https://example.com/index.json".into(),
680 },
681 cache_dir: temp.path().join("packs-cache"),
682 index_cache_ttl_secs: None,
683 trust: Some(PackTrustConfig {
684 public_keys: vec!["ed25519:test".into()],
685 require_signatures: true,
686 }),
687 });
688 let config = pack_config_from(&packs, &paths, &NetworkConfig::default())
689 .expect("pack config from packs");
690
691 match config.index_location {
692 runner_core::env::IndexLocation::Remote(url) => {
693 assert_eq!(url.as_str(), "https://example.com/index.json");
694 }
695 runner_core::env::IndexLocation::File(_) => panic!("expected remote index"),
696 }
697 assert_eq!(config.public_key.as_deref(), Some("ed25519:test"));
698 assert!(config.network.is_some());
699 }
700
701 #[test]
702 fn default_wasi_policy_sets_expected_env_and_preopens() {
703 let temp = TempDir::new().expect("tempdir");
704 let paths = paths(&temp);
705 let policy = default_wasi_policy(&paths);
706
707 assert_eq!(
708 policy.env_set.get("GREENTIC_ROOT"),
709 Some(&paths.greentic_root.display().to_string())
710 );
711 assert_eq!(policy.preopens.len(), 3);
712 assert_eq!(policy.preopens[0].guest_path, "/state");
713 assert_eq!(policy.preopens[1].guest_path, "/cache");
714 assert_eq!(policy.preopens[2].guest_path, "/logs");
715 }
716
717 #[test]
718 fn telemetry_from_is_disabled_without_exporter() {
719 assert!(telemetry_from(&TelemetryConfig::default()).is_none());
720 }
721
722 #[test]
723 fn telemetry_from_env_honours_the_telemetry_environment() {
724 fn enabled() -> TelemetryConfig {
725 TelemetryConfig {
726 enabled: true,
727 exporter: TelemetryExporterKind::Otlp,
728 ..Default::default()
729 }
730 }
731
732 let bare = telemetry_from_env(&enabled(), None, None).expect("enabled yields a config");
735 assert!(bare.export.headers.is_empty());
736 assert_eq!(bare.config.service_name, "greentic-runner");
737
738 let set = telemetry_from_env(
739 &enabled(),
740 Some("authorization=Bearer tok,x-team=alpha".into()),
741 Some("greentic-designer-worker".into()),
742 )
743 .expect("enabled yields a config");
744 assert_eq!(
745 set.export.headers.get("authorization").map(String::as_str),
746 Some("Bearer tok"),
747 "an authenticated collector cannot be reached without this header"
748 );
749 assert_eq!(
750 set.export.headers.get("x-team").map(String::as_str),
751 Some("alpha")
752 );
753 assert_eq!(set.config.service_name, "greentic-designer-worker");
754
755 let blank = telemetry_from_env(&enabled(), None, Some(" ".into()))
757 .expect("enabled yields a config");
758 assert_eq!(blank.config.service_name, "greentic-runner");
759
760 let malformed =
763 telemetry_from_env(&enabled(), Some("this-has-no-equals-sign".into()), None)
764 .expect("malformed headers must not disable telemetry");
765 assert!(malformed.export.headers.is_empty());
766 }
767}