1use anyhow::{Context, Result, anyhow};
2use greentic_pack::reader::open_pack;
3use greentic_runner_host::RunnerWasiPolicy;
4use greentic_runner_host::config::{
5 FlowRetryConfig, HostConfig, OperatorPolicy, RateLimits, SecretsPolicy, StateStorePolicy,
6 WebhookPolicy,
7};
8use greentic_runner_host::pack::{ComponentResolution, FlowDescriptor, PackMetadata, PackRuntime};
9use greentic_runner_host::runner::engine::{ExecutionObserver, FlowContext, FlowEngine, NodeEvent};
10pub use greentic_runner_host::runner::mocks::{
11 HttpMock, HttpMockMode, KvMock, MocksConfig, SecretsMock, TelemetryMock, TimeMock, ToolsMock,
12};
13use greentic_runner_host::runner::mocks::{MockEventSink, MockLayer};
14use greentic_runner_host::secrets::{DynSecretsManager, default_manager};
15use greentic_runner_host::storage::{new_session_store, new_state_store};
16use greentic_runner_host::trace::TraceConfig;
17use greentic_runner_host::validate::ValidationConfig;
18use parking_lot::Mutex;
19use runner_core::normalize_under_root;
20use serde::{Deserialize, Serialize};
21use serde_json::{Map as JsonMap, Value, json};
22use std::collections::{BTreeMap, HashMap};
23use std::fmt;
24use std::fs::{self, File};
25use std::io::{BufWriter, Write};
26use std::path::{Path, PathBuf};
27use std::sync::Arc;
28use std::time::Instant;
29use time::OffsetDateTime;
30use time::format_description::well_known::Rfc3339;
31use tokio::runtime::Runtime;
32use tracing::{info, warn};
33use uuid::Uuid;
34
35const PROVIDER_ID_DEV: &str = "greentic-dev";
36
37pub type TranscriptHook = Arc<dyn Fn(&Value) + Send + Sync>;
39
40#[derive(Clone, Debug, Serialize, Deserialize, Default)]
42pub struct OtlpHook {
43 pub endpoint: String,
44 #[serde(default)]
45 pub headers: Vec<(String, String)>,
46 #[serde(default)]
47 pub sample_all: bool,
48}
49
50#[derive(Clone, Debug)]
52#[non_exhaustive]
53pub enum Profile {
54 Dev(DevProfile),
55}
56
57impl Default for Profile {
58 fn default() -> Self {
59 Self::Dev(DevProfile::default())
60 }
61}
62
63#[derive(Clone, Debug)]
65pub struct DevProfile {
66 pub tenant_id: String,
67 pub team_id: String,
68 pub user_id: String,
69 pub max_node_wall_time_ms: u64,
70 pub max_run_wall_time_ms: u64,
71}
72
73impl Default for DevProfile {
74 fn default() -> Self {
75 Self {
76 tenant_id: "local-dev".to_string(),
77 team_id: "default".to_string(),
78 user_id: "developer".to_string(),
79 max_node_wall_time_ms: 30_000,
80 max_run_wall_time_ms: 600_000,
81 }
82 }
83}
84
85#[derive(Clone, Debug, Default)]
87pub struct TenantContext {
88 pub tenant_id: Option<String>,
89 pub team_id: Option<String>,
90 pub user_id: Option<String>,
91 pub session_id: Option<String>,
92}
93
94impl TenantContext {
95 pub fn default_local() -> Self {
96 Self {
97 tenant_id: Some("local-dev".into()),
98 team_id: Some("default".into()),
99 user_id: Some("developer".into()),
100 session_id: None,
101 }
102 }
103}
104
105#[derive(Clone)]
107pub struct RunOptions {
108 pub profile: Profile,
109 pub entry_flow: Option<String>,
110 pub input: Value,
111 pub ctx: TenantContext,
112 pub transcript: Option<TranscriptHook>,
113 pub otlp: Option<OtlpHook>,
114 pub mocks: MocksConfig,
115 pub artifacts_dir: Option<PathBuf>,
116 pub signing: SigningPolicy,
117 pub components_dir: Option<PathBuf>,
118 pub components_map: HashMap<String, PathBuf>,
119 pub dist_offline: bool,
120 pub dist_cache_dir: Option<PathBuf>,
121 pub allow_missing_hash: bool,
122 pub secrets_manager: Option<DynSecretsManager>,
125 pub cross_pack_resolver:
128 Option<Arc<dyn greentic_runner_host::runner::engine::CrossPackResolver>>,
129}
130
131impl Default for RunOptions {
132 fn default() -> Self {
133 desktop_defaults()
134 }
135}
136
137impl fmt::Debug for RunOptions {
138 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139 f.debug_struct("RunOptions")
140 .field("profile", &self.profile)
141 .field("entry_flow", &self.entry_flow)
142 .field("input", &self.input)
143 .field("ctx", &self.ctx)
144 .field("transcript", &self.transcript.is_some())
145 .field("otlp", &self.otlp)
146 .field("mocks", &self.mocks)
147 .field("artifacts_dir", &self.artifacts_dir)
148 .field("signing", &self.signing)
149 .field("components_dir", &self.components_dir)
150 .field("components_map_len", &self.components_map.len())
151 .field("dist_offline", &self.dist_offline)
152 .field("dist_cache_dir", &self.dist_cache_dir)
153 .field("allow_missing_hash", &self.allow_missing_hash)
154 .field("secrets_manager", &self.secrets_manager.is_some())
155 .finish()
156 }
157}
158
159#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
160pub enum SigningPolicy {
161 Strict,
162 DevOk,
163}
164
165#[derive(Clone, Debug)]
167pub struct Runner {
168 base: RunOptions,
169}
170
171impl Runner {
172 pub fn new() -> Self {
173 Self {
174 base: desktop_defaults(),
175 }
176 }
177
178 pub fn profile(mut self, profile: Profile) -> Self {
179 self.base.profile = profile;
180 self
181 }
182
183 pub fn with_mocks(mut self, mocks: MocksConfig) -> Self {
184 self.base.mocks = mocks;
185 self
186 }
187
188 pub fn configure(mut self, f: impl FnOnce(&mut RunOptions)) -> Self {
189 f(&mut self.base);
190 self
191 }
192
193 pub fn run_pack<P: AsRef<Path>>(&self, pack_path: P) -> Result<RunResult> {
194 run_pack_with_options(pack_path, self.base.clone())
195 }
196
197 pub fn run_pack_with<P: AsRef<Path>>(
198 &self,
199 pack_path: P,
200 f: impl FnOnce(&mut RunOptions),
201 ) -> Result<RunResult> {
202 let mut opts = self.base.clone();
203 f(&mut opts);
204 run_pack_with_options(pack_path, opts)
205 }
206
207 pub async fn run_pack_async<P: AsRef<Path>>(&self, pack_path: P) -> Result<RunResult> {
208 run_pack_with_options_async(pack_path, self.base.clone()).await
209 }
210
211 pub async fn run_pack_with_async<P: AsRef<Path>>(
212 &self,
213 pack_path: P,
214 f: impl FnOnce(&mut RunOptions),
215 ) -> Result<RunResult> {
216 let mut opts = self.base.clone();
217 f(&mut opts);
218 run_pack_with_options_async(pack_path, opts).await
219 }
220}
221
222impl Default for Runner {
223 fn default() -> Self {
224 Self::new()
225 }
226}
227
228pub fn run_pack_with_options<P: AsRef<Path>>(pack_path: P, opts: RunOptions) -> Result<RunResult> {
230 let runtime = Runtime::new().context("failed to create tokio runtime")?;
231 runtime.block_on(run_pack_async(pack_path.as_ref(), opts))
232}
233
234pub async fn run_pack_with_options_async<P: AsRef<Path>>(
236 pack_path: P,
237 opts: RunOptions,
238) -> Result<RunResult> {
239 run_pack_async(pack_path.as_ref(), opts).await
240}
241
242pub fn desktop_defaults() -> RunOptions {
244 let otlp = std::env::var("OTLP_ENDPOINT")
245 .ok()
246 .map(|endpoint| OtlpHook {
247 endpoint,
248 headers: Vec::new(),
249 sample_all: true,
250 });
251 RunOptions {
252 profile: Profile::Dev(DevProfile::default()),
253 entry_flow: None,
254 input: json!({}),
255 ctx: TenantContext::default_local(),
256 transcript: None,
257 otlp,
258 mocks: MocksConfig::default(),
259 artifacts_dir: None,
260 signing: SigningPolicy::DevOk,
261 components_dir: None,
262 components_map: HashMap::new(),
263 dist_offline: false,
264 dist_cache_dir: None,
265 allow_missing_hash: false,
266 secrets_manager: None,
267 cross_pack_resolver: None,
268 }
269}
270
271fn resolve_secrets_manager(opts: &RunOptions) -> Result<DynSecretsManager> {
272 if let Some(manager) = opts.secrets_manager.clone() {
273 Ok(manager)
274 } else {
275 default_manager().context("failed to initialise secrets backend")
276 }
277}
278
279async fn run_pack_async(pack_path: &Path, opts: RunOptions) -> Result<RunResult> {
280 let pack_path = normalize_pack_path(pack_path)?;
281 let resolved_profile = resolve_profile(&opts.profile, &opts.ctx);
282 if let Some(otlp) = &opts.otlp {
283 apply_otlp_hook(otlp);
284 }
285
286 let directories = prepare_run_dirs(opts.artifacts_dir.clone())?;
287 info!(run_dir = %directories.root.display(), "prepared desktop run directory");
288
289 let mock_layer = Arc::new(MockLayer::new(opts.mocks.clone(), &directories.root)?);
290
291 let recorder = Arc::new(RunRecorder::new(
292 directories.clone(),
293 &resolved_profile,
294 None,
295 PackMetadata::fallback(&pack_path),
296 opts.transcript.clone(),
297 )?);
298
299 let mock_sink: Arc<dyn MockEventSink> = recorder.clone();
300 mock_layer.register_sink(mock_sink);
301
302 let mut pack_http_flags: Vec<bool> = Vec::new();
306 if pack_path.is_file() {
307 match open_pack(&pack_path, to_reader_policy(opts.signing)) {
308 Ok(load) => {
309 let meta = &load.manifest.meta;
310 recorder.update_pack_metadata(PackMetadata {
311 pack_id: meta.pack_id.clone(),
312 version: meta.version.to_string(),
313 entry_flows: meta.entry_flows.clone(),
314 secret_requirements: Vec::new(),
315 });
316 if let Some(manifest) = load.gpack_manifest.as_ref() {
317 pack_http_flags = manifest
318 .components
319 .iter()
320 .map(|component| {
321 component
322 .capabilities
323 .host
324 .http
325 .as_ref()
326 .is_some_and(|http| http.client)
327 })
328 .collect();
329 }
330 }
331 Err(err) => {
332 recorder.record_verify_event("error", &err.message)?;
333 if opts.signing == SigningPolicy::DevOk && is_signature_error(&err.message) {
334 warn!(error = %err.message, "continuing despite signature error (dev policy)");
335 } else {
336 return Err(anyhow!("pack verification failed: {}", err.message));
337 }
338 }
339 }
340 } else {
341 tracing::debug!(
342 path = %pack_path.display(),
343 "skipping pack verification for directory input"
344 );
345 }
346
347 let kill_switch = desktop_http_kill_switch_active();
348 let http_enabled = derive_http_enabled(&pack_http_flags, kill_switch);
349 let http_component_count = pack_http_flags.iter().filter(|flag| **flag).count();
350 if http_enabled {
351 info!(
352 total_component_count = pack_http_flags.len(),
353 http_component_count,
354 "enabling HTTP client gate based on component manifest capabilities"
355 );
356 } else if kill_switch {
357 info!(
358 env_var = DESKTOP_HTTP_DISABLE_ENV,
359 "HTTP client gate forced off by desktop kill-switch"
360 );
361 } else {
362 tracing::debug!(
363 total_component_count = pack_http_flags.len(),
364 "HTTP client gate disabled: no component manifest declares host.http.client"
365 );
366 }
367 let host_config = Arc::new(build_host_config(
368 &resolved_profile,
369 &directories,
370 http_enabled,
371 ));
372 let mut component_resolution = ComponentResolution::default();
373 if let Some(dir) = opts.components_dir.clone() {
374 component_resolution.materialized_root = Some(dir);
375 } else if pack_path.is_dir() {
376 component_resolution.materialized_root = Some(pack_path.clone());
377 }
378 component_resolution.overrides = opts.components_map.clone();
379 component_resolution.dist_offline = opts.dist_offline;
380 component_resolution.dist_cache_dir = opts.dist_cache_dir.clone();
381 component_resolution.allow_missing_hash = opts.allow_missing_hash;
382 let archive_source = if pack_path
383 .extension()
384 .and_then(|ext| ext.to_str())
385 .map(|ext| ext.eq_ignore_ascii_case("gtpack"))
386 .unwrap_or(false)
387 {
388 Some(&pack_path)
389 } else {
390 None
391 };
392
393 let session_store = new_session_store();
394 let state_store = new_state_store();
395 let secrets_manager = resolve_secrets_manager(&opts)?;
396 let pack = Arc::new(
397 PackRuntime::load(
398 &pack_path,
399 Arc::clone(&host_config),
400 Some(Arc::clone(&mock_layer)),
401 archive_source.map(|p| p as &Path),
402 Some(Arc::clone(&session_store)),
403 Some(Arc::clone(&state_store)),
404 Arc::new(RunnerWasiPolicy::default()),
405 secrets_manager,
406 host_config.oauth_broker_config(),
407 false,
408 component_resolution,
409 )
410 .await
411 .with_context(|| format!("failed to load pack {}", pack_path.display()))?,
412 );
413 recorder.update_pack_metadata(pack.metadata().clone());
414
415 let flows = pack
416 .list_flows()
417 .await
418 .context("failed to enumerate flows")?;
419 let entry_flow_id = resolve_entry_flow(opts.entry_flow.clone(), pack.metadata(), &flows)?;
420 recorder.set_flow_id(&entry_flow_id);
421
422 let mut engine = FlowEngine::new(vec![Arc::clone(&pack)], Arc::clone(&host_config))
423 .await
424 .context("failed to prime flow engine")?;
425 if let Some(resolver) = opts.cross_pack_resolver.clone() {
426 engine.set_cross_pack_resolver(resolver);
427 }
428
429 let started_at = OffsetDateTime::now_utc();
430 let tenant_str = host_config.tenant.clone();
431 let session_id_owned = resolved_profile.session_id.clone();
432 let provider_id_owned = resolved_profile.provider_id.clone();
433 let recorder_ref: &RunRecorder = &recorder;
434 let mock_ref: &MockLayer = &mock_layer;
435 let ctx = FlowContext {
436 tenant: &tenant_str,
437 pack_id: pack.metadata().pack_id.as_str(),
438 flow_id: &entry_flow_id,
439 node_id: None,
440 tool: None,
441 action: Some("run_pack"),
442 session_id: Some(session_id_owned.as_str()),
443 provider_id: Some(provider_id_owned.as_str()),
444 retry_config: host_config.retry_config().into(),
445 attempt: 1,
446 observer: Some(recorder_ref),
447 mocks: Some(mock_ref),
448 };
449
450 let execution = engine.execute(ctx, opts.input.clone()).await;
451 let finished_at = OffsetDateTime::now_utc();
452
453 let status = match execution {
454 Ok(result) => match result.status {
455 greentic_runner_host::runner::engine::FlowStatus::Completed => RunCompletion::Ok,
456 greentic_runner_host::runner::engine::FlowStatus::Waiting(wait) => {
457 let reason = wait
458 .reason
459 .unwrap_or_else(|| "flow paused unexpectedly".to_string());
460 RunCompletion::Err(anyhow::anyhow!(reason))
461 }
462 },
463 Err(err) => RunCompletion::Err(err),
464 };
465
466 let result = recorder.finalise(status, started_at, finished_at)?;
467
468 let run_json_path = directories.root.join("run.json");
469 fs::write(&run_json_path, serde_json::to_vec_pretty(&result)?)
470 .with_context(|| format!("failed to write run summary {}", run_json_path.display()))?;
471
472 Ok(result)
473}
474
475fn apply_otlp_hook(hook: &OtlpHook) {
476 info!(
477 endpoint = %hook.endpoint,
478 sample_all = hook.sample_all,
479 headers = %hook.headers.len(),
480 "OTLP hook requested (set OTEL_* env vars before invoking run_pack)"
481 );
482}
483
484fn normalize_pack_path(path: &Path) -> Result<PathBuf> {
485 if path.is_absolute() {
486 let parent = path
487 .parent()
488 .ok_or_else(|| anyhow!("pack path {} has no parent", path.display()))?;
489 let root = parent
490 .canonicalize()
491 .with_context(|| format!("failed to canonicalize {}", parent.display()))?;
492 let file = path
493 .file_name()
494 .ok_or_else(|| anyhow!("pack path {} has no file name", path.display()))?;
495 return normalize_under_root(&root, Path::new(file));
496 }
497
498 let cwd = std::env::current_dir().context("failed to resolve current directory")?;
499 let base = if let Some(parent) = path.parent() {
500 cwd.join(parent)
501 } else {
502 cwd
503 };
504 let root = base
505 .canonicalize()
506 .with_context(|| format!("failed to canonicalize {}", base.display()))?;
507 let file = path
508 .file_name()
509 .ok_or_else(|| anyhow!("pack path {} has no file name", path.display()))?;
510 normalize_under_root(&root, Path::new(file))
511}
512
513fn prepare_run_dirs(root_override: Option<PathBuf>) -> Result<RunDirectories> {
514 let root = if let Some(dir) = root_override {
515 dir
516 } else {
517 let timestamp = OffsetDateTime::now_utc()
518 .format(&Rfc3339)
519 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
520 .replace(':', "-");
521 let short_id = Uuid::new_v4()
522 .to_string()
523 .chars()
524 .take(6)
525 .collect::<String>();
526 PathBuf::from(".greentic")
527 .join("runs")
528 .join(format!("{timestamp}_{short_id}"))
529 };
530
531 fs::create_dir_all(&root).with_context(|| format!("failed to create {}", root.display()))?;
532 let logs = root.join("logs");
533 let resolved = root.join("resolved_config");
534 fs::create_dir_all(&logs).with_context(|| format!("failed to create {}", logs.display()))?;
535 fs::create_dir_all(&resolved)
536 .with_context(|| format!("failed to create {}", resolved.display()))?;
537
538 Ok(RunDirectories {
539 root,
540 logs,
541 resolved,
542 })
543}
544
545fn resolve_entry_flow(
546 override_id: Option<String>,
547 metadata: &PackMetadata,
548 flows: &[FlowDescriptor],
549) -> Result<String> {
550 if let Some(flow) = override_id {
551 return Ok(flow);
552 }
553 if let Some(first) = metadata.entry_flows.first() {
554 return Ok(first.clone());
555 }
556 flows
557 .first()
558 .map(|f| f.id.clone())
559 .ok_or_else(|| anyhow!("pack does not declare any flows"))
560}
561
562fn is_signature_error(message: &str) -> bool {
563 message.to_ascii_lowercase().contains("signature")
564}
565
566fn to_reader_policy(policy: SigningPolicy) -> greentic_pack::reader::SigningPolicy {
567 match policy {
568 SigningPolicy::Strict => greentic_pack::reader::SigningPolicy::Strict,
569 SigningPolicy::DevOk => greentic_pack::reader::SigningPolicy::DevOk,
570 }
571}
572
573fn resolve_profile(profile: &Profile, ctx: &TenantContext) -> ResolvedProfile {
574 match profile {
575 Profile::Dev(dev) => ResolvedProfile {
576 tenant_id: ctx
577 .tenant_id
578 .clone()
579 .unwrap_or_else(|| dev.tenant_id.clone()),
580 team_id: ctx.team_id.clone().unwrap_or_else(|| dev.team_id.clone()),
581 user_id: ctx.user_id.clone().unwrap_or_else(|| dev.user_id.clone()),
582 session_id: ctx
583 .session_id
584 .clone()
585 .unwrap_or_else(|| Uuid::new_v4().to_string()),
586 provider_id: PROVIDER_ID_DEV.to_string(),
587 max_node_wall_time_ms: dev.max_node_wall_time_ms,
588 max_run_wall_time_ms: dev.max_run_wall_time_ms,
589 },
590 }
591}
592
593const DESKTOP_HTTP_DISABLE_ENV: &str = "GREENTIC_DESKTOP_HTTP_DISABLE";
597
598fn derive_http_enabled(component_http_flags: &[bool], kill_switch: bool) -> bool {
605 !kill_switch && component_http_flags.iter().any(|flag| *flag)
606}
607
608fn parse_kill_switch(raw: Option<&str>) -> bool {
614 matches!(
615 raw.map(|value| value.trim().to_ascii_lowercase())
616 .as_deref(),
617 Some("1" | "true" | "yes" | "on")
618 )
619}
620
621fn desktop_http_kill_switch_active() -> bool {
624 parse_kill_switch(std::env::var(DESKTOP_HTTP_DISABLE_ENV).ok().as_deref())
625}
626
627fn build_host_config(
628 profile: &ResolvedProfile,
629 dirs: &RunDirectories,
630 http_enabled: bool,
631) -> HostConfig {
632 HostConfig {
633 tenant: profile.tenant_id.clone(),
634 bindings_path: dirs.resolved.join("dev.bindings.yaml"),
635 flow_type_bindings: HashMap::new(),
636 rate_limits: RateLimits::default(),
637 retry: FlowRetryConfig::default(),
638 http_enabled,
639 secrets_policy: SecretsPolicy::allow_all(),
640 state_store_policy: StateStorePolicy::default(),
641 webhook_policy: WebhookPolicy::default(),
642 timers: Vec::new(),
643 oauth: None,
644 mocks: None,
645 pack_bindings: Vec::new(),
646 env_passthrough: Vec::new(),
647 trace: TraceConfig::from_env(),
648 validation: ValidationConfig::from_env(),
649 operator_policy: OperatorPolicy::allow_all(),
650 }
651}
652
653#[allow(dead_code)]
654#[derive(Clone, Debug)]
655struct ResolvedProfile {
656 tenant_id: String,
657 team_id: String,
658 user_id: String,
659 session_id: String,
660 provider_id: String,
661 max_node_wall_time_ms: u64,
662 max_run_wall_time_ms: u64,
663}
664
665#[derive(Clone, Debug, Serialize, Deserialize)]
666pub struct RunResult {
667 pub session_id: String,
668 pub pack_id: String,
669 pub pack_version: String,
670 pub flow_id: String,
671 pub started_at_utc: String,
672 pub finished_at_utc: String,
673 pub status: RunStatus,
674 pub error: Option<String>,
675 pub node_summaries: Vec<NodeSummary>,
676 pub failures: BTreeMap<String, NodeFailure>,
677 pub artifacts_dir: PathBuf,
678}
679
680#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
681pub enum RunStatus {
682 Success,
683 PartialFailure,
684 Failure,
685}
686
687#[derive(Clone, Debug, Serialize, Deserialize)]
688pub struct NodeSummary {
689 pub node_id: String,
690 pub component: String,
691 pub status: NodeStatus,
692 pub duration_ms: u64,
693}
694
695#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
696pub enum NodeStatus {
697 Ok,
698 Skipped,
699 Error,
700}
701
702#[derive(Clone, Debug, Serialize, Deserialize)]
703pub struct NodeFailure {
704 pub code: String,
705 pub message: String,
706 pub details: Value,
707 pub transcript_offsets: (u64, u64),
708 pub log_paths: Vec<PathBuf>,
709}
710
711#[derive(Clone)]
712struct RunDirectories {
713 root: PathBuf,
714 logs: PathBuf,
715 resolved: PathBuf,
716}
717
718enum RunCompletion {
719 Ok,
720 Err(anyhow::Error),
721}
722
723struct RunRecorder {
724 directories: RunDirectories,
725 profile: ResolvedProfile,
726 flow_id: Mutex<String>,
727 pack_meta: Mutex<PackMetadata>,
728 transcript: Mutex<TranscriptWriter>,
729 state: Mutex<RunRecorderState>,
730}
731
732impl RunRecorder {
733 fn new(
734 dirs: RunDirectories,
735 profile: &ResolvedProfile,
736 flow_id: Option<String>,
737 pack_meta: PackMetadata,
738 hook: Option<TranscriptHook>,
739 ) -> Result<Self> {
740 let transcript_path = dirs.root.join("transcript.jsonl");
741 let file = File::create(&transcript_path)
742 .with_context(|| format!("failed to open {}", transcript_path.display()))?;
743 Ok(Self {
744 directories: dirs,
745 profile: profile.clone(),
746 flow_id: Mutex::new(flow_id.unwrap_or_else(|| "unknown".into())),
747 pack_meta: Mutex::new(pack_meta),
748 transcript: Mutex::new(TranscriptWriter::new(BufWriter::new(file), hook)),
749 state: Mutex::new(RunRecorderState::default()),
750 })
751 }
752
753 fn finalise(
754 &self,
755 completion: RunCompletion,
756 started_at: OffsetDateTime,
757 finished_at: OffsetDateTime,
758 ) -> Result<RunResult> {
759 let status = match &completion {
760 RunCompletion::Ok => RunStatus::Success,
761 RunCompletion::Err(_) => RunStatus::Failure,
762 };
763
764 let mut runtime_error = None;
765 if let RunCompletion::Err(err) = completion {
766 warn!(error = %err, "pack execution failed");
767 eprintln!("pack execution failed: {err}");
768 runtime_error = Some(err.to_string());
769 }
770
771 let started = started_at
772 .format(&Rfc3339)
773 .unwrap_or_else(|_| started_at.to_string());
774 let finished = finished_at
775 .format(&Rfc3339)
776 .unwrap_or_else(|_| finished_at.to_string());
777
778 let state = self.state.lock();
779 let mut summaries = Vec::new();
780 let mut failures = BTreeMap::new();
781 for node_id in &state.order {
782 if let Some(record) = state.nodes.get(node_id) {
783 let duration = record.duration_ms.unwrap_or(0);
784 summaries.push(NodeSummary {
785 node_id: node_id.clone(),
786 component: record.component.clone(),
787 status: record.status.clone(),
788 duration_ms: duration,
789 });
790 if record.status == NodeStatus::Error {
791 let start_offset = record.transcript_start.unwrap_or(0);
792 let err_offset = record.transcript_error.unwrap_or(start_offset);
793 failures.insert(
794 node_id.clone(),
795 NodeFailure {
796 code: record
797 .failure_code
798 .clone()
799 .unwrap_or_else(|| "component-failed".into()),
800 message: record
801 .failure_message
802 .clone()
803 .unwrap_or_else(|| "node failed".into()),
804 details: record
805 .failure_details
806 .clone()
807 .unwrap_or_else(|| json!({ "node": node_id })),
808 transcript_offsets: (start_offset, err_offset),
809 log_paths: record.log_paths.clone(),
810 },
811 );
812 }
813 }
814 }
815
816 let mut final_status = status;
817 if final_status == RunStatus::Success && !failures.is_empty() {
818 final_status = RunStatus::PartialFailure;
819 }
820
821 if let Some(error) = &runtime_error {
822 failures.insert(
823 "_runtime".into(),
824 NodeFailure {
825 code: "runtime-error".into(),
826 message: error.clone(),
827 details: json!({ "stage": "execute" }),
828 transcript_offsets: (0, 0),
829 log_paths: Vec::new(),
830 },
831 );
832 }
833
834 let pack_meta = self.pack_meta.lock();
835 let flow_id = self.flow_id.lock().clone();
836 Ok(RunResult {
837 session_id: self.profile.session_id.clone(),
838 pack_id: pack_meta.pack_id.clone(),
839 pack_version: pack_meta.version.clone(),
840 flow_id,
841 started_at_utc: started,
842 finished_at_utc: finished,
843 status: final_status,
844 error: runtime_error,
845 node_summaries: summaries,
846 failures,
847 artifacts_dir: self.directories.root.clone(),
848 })
849 }
850
851 fn set_flow_id(&self, flow_id: &str) {
852 *self.flow_id.lock() = flow_id.to_string();
853 }
854
855 fn update_pack_metadata(&self, meta: PackMetadata) {
856 *self.pack_meta.lock() = meta;
857 }
858
859 fn current_flow_id(&self) -> String {
860 self.flow_id.lock().clone()
861 }
862
863 fn record_verify_event(&self, status: &str, message: &str) -> Result<()> {
864 let timestamp = OffsetDateTime::now_utc();
865 let event = json!({
866 "ts": timestamp
867 .format(&Rfc3339)
868 .unwrap_or_else(|_| timestamp.to_string()),
869 "session_id": self.profile.session_id,
870 "flow_id": self.current_flow_id(),
871 "node_id": Value::Null,
872 "component": "verify.pack",
873 "phase": "verify",
874 "status": status,
875 "inputs": Value::Null,
876 "outputs": Value::Null,
877 "error": json!({ "message": message }),
878 "metrics": Value::Null,
879 "schema_id": Value::Null,
880 "defaults_applied": Value::Null,
881 "redactions": Value::Array(Vec::new()),
882 });
883 self.transcript.lock().write(&event).map(|_| ())
884 }
885
886 fn write_mock_event(&self, capability: &str, provider: &str, payload: Value) -> Result<()> {
887 let timestamp = OffsetDateTime::now_utc();
888 let event = json!({
889 "ts": timestamp
890 .format(&Rfc3339)
891 .unwrap_or_else(|_| timestamp.to_string()),
892 "session_id": self.profile.session_id,
893 "flow_id": self.current_flow_id(),
894 "node_id": Value::Null,
895 "component": format!("mock::{capability}"),
896 "phase": "mock",
897 "status": "ok",
898 "inputs": json!({ "capability": capability, "provider": provider }),
899 "outputs": payload,
900 "error": Value::Null,
901 "metrics": Value::Null,
902 "schema_id": Value::Null,
903 "defaults_applied": Value::Null,
904 "redactions": Value::Array(Vec::new()),
905 });
906 self.transcript.lock().write(&event).map(|_| ())
907 }
908
909 fn handle_node_start(&self, event: &NodeEvent<'_>) -> Result<()> {
910 let timestamp = OffsetDateTime::now_utc();
911 let (redacted_payload, redactions) = redact_value(event.payload, "$.inputs.payload");
912 let inputs = json!({
913 "payload": redacted_payload,
914 "context": {
915 "tenant_id": self.profile.tenant_id.as_str(),
916 "team_id": self.profile.team_id.as_str(),
917 "user_id": self.profile.user_id.as_str(),
918 }
919 });
920 let flow_id = self.current_flow_id();
921 let event_json = build_transcript_event(TranscriptEventArgs {
922 profile: &self.profile,
923 flow_id: &flow_id,
924 node_id: event.node_id,
925 component: &event.node.component,
926 phase: "start",
927 status: "ok",
928 timestamp,
929 inputs,
930 outputs: Value::Null,
931 error: Value::Null,
932 redactions,
933 });
934 let (start_offset, _) = self.transcript.lock().write(&event_json)?;
935
936 let mut state = self.state.lock();
937 let node_key = event.node_id.to_string();
938 if !state.order.iter().any(|id| id == &node_key) {
939 state.order.push(node_key.clone());
940 }
941 let entry = state.nodes.entry(node_key).or_insert_with(|| {
942 NodeExecutionRecord::new(event.node.component.clone(), &self.directories)
943 });
944 entry.start_instant = Some(Instant::now());
945 entry.status = NodeStatus::Ok;
946 entry.transcript_start = Some(start_offset);
947 Ok(())
948 }
949
950 fn handle_node_end(&self, event: &NodeEvent<'_>, output: &Value) -> Result<()> {
951 let timestamp = OffsetDateTime::now_utc();
952 let (redacted_output, redactions) = redact_value(output, "$.outputs");
953 let flow_id = self.current_flow_id();
954 let event_json = build_transcript_event(TranscriptEventArgs {
955 profile: &self.profile,
956 flow_id: &flow_id,
957 node_id: event.node_id,
958 component: &event.node.component,
959 phase: "end",
960 status: "ok",
961 timestamp,
962 inputs: Value::Null,
963 outputs: redacted_output,
964 error: Value::Null,
965 redactions,
966 });
967 self.transcript.lock().write(&event_json)?;
968
969 let mut state = self.state.lock();
970 if let Some(entry) = state.nodes.get_mut(event.node_id)
971 && let Some(started) = entry.start_instant.take()
972 {
973 entry.duration_ms = Some(started.elapsed().as_millis() as u64);
974 }
975 Ok(())
976 }
977
978 fn handle_node_error(
979 &self,
980 event: &NodeEvent<'_>,
981 error: &dyn std::error::Error,
982 ) -> Result<()> {
983 let timestamp = OffsetDateTime::now_utc();
984 let error_message = error.to_string();
985 let error_json = json!({
986 "code": "component-failed",
987 "message": error_message,
988 "details": {
989 "node": event.node_id,
990 }
991 });
992 let flow_id = self.current_flow_id();
993 let event_json = build_transcript_event(TranscriptEventArgs {
994 profile: &self.profile,
995 flow_id: &flow_id,
996 node_id: event.node_id,
997 component: &event.node.component,
998 phase: "error",
999 status: "error",
1000 timestamp,
1001 inputs: Value::Null,
1002 outputs: Value::Null,
1003 error: error_json.clone(),
1004 redactions: Vec::new(),
1005 });
1006 let (_, end_offset) = self.transcript.lock().write(&event_json)?;
1007
1008 let mut state = self.state.lock();
1009 if let Some(entry) = state.nodes.get_mut(event.node_id) {
1010 entry.status = NodeStatus::Error;
1011 if let Some(started) = entry.start_instant.take() {
1012 entry.duration_ms = Some(started.elapsed().as_millis() as u64);
1013 }
1014 entry.transcript_error = Some(end_offset);
1015 entry.failure_code = Some("component-failed".to_string());
1016 entry.failure_message = Some(error_message.clone());
1017 entry.failure_details = Some(error_json);
1018 let log_path = self
1019 .directories
1020 .logs
1021 .join(format!("{}.stderr.log", sanitize_id(event.node_id)));
1022 if let Ok(mut file) = File::create(&log_path) {
1023 let _ = writeln!(file, "{error_message}");
1024 }
1025 entry.log_paths.push(log_path);
1026 }
1027 Ok(())
1028 }
1029}
1030
1031impl ExecutionObserver for RunRecorder {
1032 fn on_node_start(&self, event: &NodeEvent<'_>) {
1033 if let Err(err) = self.handle_node_start(event) {
1034 warn!(node = event.node_id, error = %err, "failed to record node start");
1035 }
1036 }
1037
1038 fn on_node_end(&self, event: &NodeEvent<'_>, output: &Value) {
1039 if let Err(err) = self.handle_node_end(event, output) {
1040 warn!(node = event.node_id, error = %err, "failed to record node end");
1041 }
1042 }
1043
1044 fn on_node_error(&self, event: &NodeEvent<'_>, error: &dyn std::error::Error) {
1045 if let Err(err) = self.handle_node_error(event, error) {
1046 warn!(node = event.node_id, error = %err, "failed to record node error");
1047 }
1048 }
1049}
1050
1051impl MockEventSink for RunRecorder {
1052 fn on_mock_event(&self, capability: &str, provider: &str, payload: &Value) {
1053 if let Err(err) = self.write_mock_event(capability, provider, payload.clone()) {
1054 warn!(?capability, ?provider, error = %err, "failed to record mock event");
1055 }
1056 }
1057}
1058
1059#[derive(Default)]
1060struct RunRecorderState {
1061 nodes: BTreeMap<String, NodeExecutionRecord>,
1062 order: Vec<String>,
1063}
1064
1065#[derive(Clone)]
1066struct NodeExecutionRecord {
1067 component: String,
1068 status: NodeStatus,
1069 duration_ms: Option<u64>,
1070 transcript_start: Option<u64>,
1071 transcript_error: Option<u64>,
1072 log_paths: Vec<PathBuf>,
1073 failure_code: Option<String>,
1074 failure_message: Option<String>,
1075 failure_details: Option<Value>,
1076 start_instant: Option<Instant>,
1077}
1078
1079impl NodeExecutionRecord {
1080 fn new(component: String, _dirs: &RunDirectories) -> Self {
1081 Self {
1082 component,
1083 status: NodeStatus::Ok,
1084 duration_ms: None,
1085 transcript_start: None,
1086 transcript_error: None,
1087 log_paths: Vec::new(),
1088 failure_code: None,
1089 failure_message: None,
1090 failure_details: None,
1091 start_instant: None,
1092 }
1093 }
1094}
1095
1096struct TranscriptWriter {
1097 writer: BufWriter<File>,
1098 offset: u64,
1099 hook: Option<TranscriptHook>,
1100}
1101
1102impl TranscriptWriter {
1103 fn new(writer: BufWriter<File>, hook: Option<TranscriptHook>) -> Self {
1104 Self {
1105 writer,
1106 offset: 0,
1107 hook,
1108 }
1109 }
1110
1111 fn write(&mut self, value: &Value) -> Result<(u64, u64)> {
1112 let line = serde_json::to_vec(value)?;
1113 let start = self.offset;
1114 self.writer.write_all(&line)?;
1115 self.writer.write_all(b"\n")?;
1116 self.writer.flush()?;
1117 self.offset += line.len() as u64 + 1;
1118 if let Some(hook) = &self.hook {
1119 hook(value);
1120 }
1121 Ok((start, self.offset))
1122 }
1123}
1124
1125struct TranscriptEventArgs<'a> {
1126 profile: &'a ResolvedProfile,
1127 flow_id: &'a str,
1128 node_id: &'a str,
1129 component: &'a str,
1130 phase: &'a str,
1131 status: &'a str,
1132 timestamp: OffsetDateTime,
1133 inputs: Value,
1134 outputs: Value,
1135 error: Value,
1136 redactions: Vec<String>,
1137}
1138
1139fn build_transcript_event(args: TranscriptEventArgs<'_>) -> Value {
1140 let ts = args
1141 .timestamp
1142 .format(&Rfc3339)
1143 .unwrap_or_else(|_| args.timestamp.to_string());
1144 json!({
1145 "ts": ts,
1146 "session_id": args.profile.session_id.as_str(),
1147 "flow_id": args.flow_id,
1148 "node_id": args.node_id,
1149 "component": args.component,
1150 "phase": args.phase,
1151 "status": args.status,
1152 "inputs": args.inputs,
1153 "outputs": args.outputs,
1154 "error": args.error,
1155 "metrics": {
1156 "duration_ms": null,
1157 "cpu_time_ms": null,
1158 "mem_peak_bytes": null,
1159 },
1160 "schema_id": Value::Null,
1161 "defaults_applied": Value::Array(Vec::new()),
1162 "redactions": args.redactions,
1163 })
1164}
1165
1166fn redact_value(value: &Value, base: &str) -> (Value, Vec<String>) {
1167 let mut paths = Vec::new();
1168 let redacted = redact_recursive(value, base, &mut paths);
1169 (redacted, paths)
1170}
1171
1172fn redact_recursive(value: &Value, path: &str, acc: &mut Vec<String>) -> Value {
1173 match value {
1174 Value::Object(map) => {
1175 let mut new_map = JsonMap::new();
1176 for (key, val) in map {
1177 let child_path = format!("{path}.{key}");
1178 if is_sensitive_key(key) {
1179 acc.push(child_path);
1180 new_map.insert(key.clone(), Value::String("__REDACTED__".into()));
1181 } else {
1182 new_map.insert(key.clone(), redact_recursive(val, &child_path, acc));
1183 }
1184 }
1185 Value::Object(new_map)
1186 }
1187 Value::Array(items) => {
1188 let mut new_items = Vec::new();
1189 for (idx, item) in items.iter().enumerate() {
1190 let child_path = format!("{path}[{idx}]");
1191 new_items.push(redact_recursive(item, &child_path, acc));
1192 }
1193 Value::Array(new_items)
1194 }
1195 other => other.clone(),
1196 }
1197}
1198
1199fn is_sensitive_key(key: &str) -> bool {
1200 let lower = key.to_ascii_lowercase();
1201 const MARKERS: [&str; 5] = ["secret", "token", "password", "authorization", "cookie"];
1202 MARKERS.iter().any(|marker| lower.contains(marker))
1203}
1204
1205fn sanitize_id(value: &str) -> String {
1206 value
1207 .chars()
1208 .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
1209 .collect()
1210}
1211
1212#[cfg(test)]
1213mod tests {
1214 use super::*;
1215 use std::sync::Arc;
1216
1217 use async_trait::async_trait;
1218 use greentic_runner_host::secrets::DynSecretsManager;
1219 use greentic_secrets_lib::{SecretError, SecretsManager};
1220 use serde_json::json;
1221 use tempfile::TempDir;
1222
1223 fn sample_metadata() -> PackMetadata {
1224 PackMetadata {
1225 pack_id: "pack.demo".into(),
1226 version: "1.0.0".into(),
1227 entry_flows: vec!["entry.flow".into()],
1228 secret_requirements: Vec::new(),
1229 }
1230 }
1231
1232 fn sample_profile() -> ResolvedProfile {
1233 ResolvedProfile {
1234 tenant_id: "tenant".into(),
1235 team_id: "team".into(),
1236 user_id: "user".into(),
1237 session_id: "session-1".into(),
1238 provider_id: PROVIDER_ID_DEV.into(),
1239 max_node_wall_time_ms: 1000,
1240 max_run_wall_time_ms: 2000,
1241 }
1242 }
1243
1244 struct NoopSecretsManager;
1245
1246 #[async_trait]
1247 impl SecretsManager for NoopSecretsManager {
1248 async fn read(&self, path: &str) -> Result<Vec<u8>, SecretError> {
1249 Err(SecretError::NotFound(path.to_string()))
1250 }
1251
1252 async fn write(&self, _path: &str, _bytes: &[u8]) -> Result<(), SecretError> {
1253 Ok(())
1254 }
1255
1256 async fn delete(&self, _path: &str) -> Result<(), SecretError> {
1257 Ok(())
1258 }
1259 }
1260
1261 #[test]
1262 fn resolve_entry_flow_prefers_override_then_metadata_then_flows() {
1263 let metadata = sample_metadata();
1264 let flows = vec![FlowDescriptor {
1265 id: "fallback.flow".into(),
1266 flow_type: "message".into(),
1267 pack_id: "pack.demo".into(),
1268 profile: "default".into(),
1269 version: "1.0.0".into(),
1270 description: None,
1271 }];
1272
1273 assert_eq!(
1274 resolve_entry_flow(Some("manual.flow".into()), &metadata, &flows).unwrap(),
1275 "manual.flow"
1276 );
1277 assert_eq!(
1278 resolve_entry_flow(None, &metadata, &flows).unwrap(),
1279 "entry.flow"
1280 );
1281 assert!(
1282 resolve_entry_flow(
1283 None,
1284 &PackMetadata {
1285 entry_flows: Vec::new(),
1286 ..metadata
1287 },
1288 &flows
1289 )
1290 .is_ok()
1291 );
1292 }
1293
1294 #[test]
1295 fn normalize_pack_path_accepts_relative_files_inside_cwd() {
1296 let temp = tempfile::tempdir_in(std::env::current_dir().expect("cwd")).expect("tempdir");
1297 let pack = temp.path().join("demo.gtpack");
1298 fs::write(&pack, b"pack").expect("pack file");
1299 let relative = pack
1300 .strip_prefix(std::env::current_dir().expect("cwd"))
1301 .expect("relative")
1302 .to_path_buf();
1303
1304 let normalized = normalize_pack_path(&relative).expect("normalized path");
1305
1306 assert_eq!(normalized, pack.canonicalize().expect("canonical"));
1307 }
1308
1309 #[test]
1310 fn prepare_run_dirs_creates_logs_and_resolved_subdirs() {
1311 let temp = TempDir::new().expect("tempdir");
1312 let root = temp.path().join("artifacts");
1313 let dirs = prepare_run_dirs(Some(root.clone())).expect("run dirs");
1314
1315 assert_eq!(dirs.root, root);
1316 assert!(dirs.logs.is_dir());
1317 assert!(dirs.resolved.is_dir());
1318 }
1319
1320 #[test]
1321 fn redact_value_masks_nested_sensitive_fields() {
1322 let (redacted, paths) = redact_value(
1323 &json!({
1324 "token": "abc",
1325 "nested": { "authorization_header": "secret" },
1326 "items": [{ "password_hint": "nope" }]
1327 }),
1328 "$",
1329 );
1330
1331 assert_eq!(redacted["token"], "__REDACTED__");
1332 assert_eq!(redacted["nested"]["authorization_header"], "__REDACTED__");
1333 assert_eq!(redacted["items"][0]["password_hint"], "__REDACTED__");
1334 assert!(paths.contains(&"$.token".to_string()));
1335 assert!(paths.contains(&"$.nested.authorization_header".to_string()));
1336 }
1337
1338 #[test]
1339 fn helper_functions_preserve_ids_and_transcript_shape() {
1340 assert_eq!(sanitize_id("flow demo/1"), "flow-demo-1");
1341 assert!(is_signature_error("Signature verification failed"));
1342 assert_eq!(
1343 to_reader_policy(SigningPolicy::DevOk),
1344 greentic_pack::reader::SigningPolicy::DevOk
1345 );
1346
1347 let profile = sample_profile();
1348 let event = build_transcript_event(TranscriptEventArgs {
1349 profile: &profile,
1350 flow_id: "flow.demo",
1351 node_id: "node.demo",
1352 component: "component.demo",
1353 phase: "invoke",
1354 status: "ok",
1355 timestamp: OffsetDateTime::now_utc(),
1356 inputs: json!({"input": true}),
1357 outputs: json!({"output": true}),
1358 error: Value::Null,
1359 redactions: vec!["$.token".into()],
1360 });
1361 assert_eq!(event["session_id"], "session-1");
1362 assert_eq!(event["flow_id"], "flow.demo");
1363 assert_eq!(event["redactions"][0], "$.token");
1364 }
1365
1366 #[test]
1367 fn resolve_profile_uses_context_overrides() {
1368 let profile = resolve_profile(
1369 &Profile::Dev(DevProfile::default()),
1370 &TenantContext {
1371 tenant_id: Some("tenant-x".into()),
1372 team_id: Some("team-x".into()),
1373 user_id: Some("user-x".into()),
1374 session_id: Some("session-x".into()),
1375 },
1376 );
1377
1378 assert_eq!(profile.tenant_id, "tenant-x");
1379 assert_eq!(profile.team_id, "team-x");
1380 assert_eq!(profile.user_id, "user-x");
1381 assert_eq!(profile.session_id, "session-x");
1382 assert_eq!(profile.provider_id, PROVIDER_ID_DEV);
1383 }
1384
1385 #[test]
1386 fn desktop_defaults_and_runner_builder_preserve_overrides() {
1387 let defaults = desktop_defaults();
1388 assert_eq!(defaults.signing, SigningPolicy::DevOk);
1389 assert_eq!(defaults.ctx.tenant_id.as_deref(), Some("local-dev"));
1390
1391 let runner = Runner::new()
1392 .with_mocks(MocksConfig {
1393 telemetry: Some(TelemetryMock),
1394 ..MocksConfig::default()
1395 })
1396 .configure(|opts| {
1397 opts.entry_flow = Some("entry.flow".into());
1398 opts.input = json!({"demo": true});
1399 });
1400
1401 let rendered = format!("{:?}", runner.base);
1402 assert!(rendered.contains("entry.flow"));
1403 assert!(runner.base.mocks.telemetry.is_some());
1404 assert_eq!(runner.base.input, json!({"demo": true}));
1405 }
1406
1407 #[test]
1408 fn resolve_entry_flow_errors_when_pack_has_no_flows() {
1409 let metadata = PackMetadata {
1410 entry_flows: Vec::new(),
1411 ..sample_metadata()
1412 };
1413 assert!(resolve_entry_flow(None, &metadata, &[]).is_err());
1414 }
1415
1416 #[test]
1417 fn build_host_config_enables_local_dev_defaults() {
1418 let temp = TempDir::new().expect("tempdir");
1419 let dirs = prepare_run_dirs(Some(temp.path().join("run"))).expect("dirs");
1420 let config = build_host_config(&sample_profile(), &dirs, false);
1421
1422 assert_eq!(config.tenant, "tenant");
1423 assert!(!config.http_enabled);
1424 assert!(config.secrets_policy.is_allowed("any.secret"));
1425 assert!(
1426 config
1427 .operator_policy
1428 .allows_provider(Some("provider"), "provider")
1429 );
1430 }
1431
1432 #[test]
1433 fn build_host_config_honours_http_enabled_flag() {
1434 let temp = TempDir::new().expect("tempdir");
1435 let dirs = prepare_run_dirs(Some(temp.path().join("run"))).expect("dirs");
1436 let enabled = build_host_config(&sample_profile(), &dirs, true);
1437 assert!(
1438 enabled.http_enabled,
1439 "http_enabled should propagate when derived from manifest"
1440 );
1441
1442 let disabled = build_host_config(&sample_profile(), &dirs, false);
1443 assert!(!disabled.http_enabled);
1444 }
1445
1446 #[test]
1447 fn derive_http_enabled_true_when_any_component_declares_http_client() {
1448 assert!(derive_http_enabled(&[false, true], false));
1449 }
1450
1451 #[test]
1452 fn derive_http_enabled_false_when_no_component_declares_http_client() {
1453 assert!(!derive_http_enabled(&[false, false], false));
1454 }
1455
1456 #[test]
1457 fn derive_http_enabled_false_for_empty_components() {
1458 assert!(!derive_http_enabled(&[], false));
1459 }
1460
1461 #[test]
1462 fn derive_http_enabled_kill_switch_forces_disable() {
1463 assert!(
1464 !derive_http_enabled(&[true], true),
1465 "kill-switch must override positive manifest declaration"
1466 );
1467 }
1468
1469 #[test]
1470 fn parse_kill_switch_matches_truthy_and_falsy_inputs() {
1471 let cases: &[(Option<&str>, bool)] = &[
1473 (Some("1"), true),
1474 (Some("true"), true),
1475 (Some("YES"), true),
1476 (Some(" on "), true),
1477 (Some("On"), true),
1478 (Some("0"), false),
1479 (Some("false"), false),
1480 (Some("bogus"), false),
1481 (Some(""), false),
1482 (None, false),
1483 ];
1484
1485 for (raw, expected) in cases {
1486 assert_eq!(
1487 parse_kill_switch(*raw),
1488 *expected,
1489 "parse_kill_switch({raw:?}) should be {expected}"
1490 );
1491 }
1492 }
1493
1494 #[test]
1495 fn redact_value_leaves_non_sensitive_payloads_unchanged() {
1496 let original = json!({"public": {"value": 1}});
1497 let (redacted, paths) = redact_value(&original, "$");
1498 assert_eq!(redacted, original);
1499 assert!(paths.is_empty());
1500 }
1501
1502 #[test]
1503 fn resolve_secrets_manager_prefers_injected_override_even_in_prod() {
1504 let previous_env = std::env::var("GREENTIC_ENV").ok();
1505 unsafe {
1506 std::env::set_var("GREENTIC_ENV", "prod");
1507 }
1508
1509 let without_override = resolve_secrets_manager(&RunOptions::default());
1510 assert!(
1511 without_override.is_err(),
1512 "expected prod desktop default backend to be rejected"
1513 );
1514
1515 let injected: DynSecretsManager = Arc::new(NoopSecretsManager);
1516 let with_override = resolve_secrets_manager(&RunOptions {
1517 secrets_manager: Some(Arc::clone(&injected)),
1518 ..RunOptions::default()
1519 });
1520 assert!(
1521 with_override.is_ok(),
1522 "expected injected secrets manager to bypass desktop default backend"
1523 );
1524
1525 match previous_env {
1526 Some(value) => unsafe {
1527 std::env::set_var("GREENTIC_ENV", value);
1528 },
1529 None => unsafe {
1530 std::env::remove_var("GREENTIC_ENV");
1531 },
1532 }
1533 }
1534}