1use std::collections::{BTreeMap, BTreeSet};
6use std::fs::File;
7use std::io::Read;
8use std::path::{Path, PathBuf};
9use std::sync::{Arc, Mutex};
10
11use anyhow::{Context, anyhow};
12use serde_json::{Map as JsonMap, Value};
13use sha2::{Digest, Sha256};
14use zip::{ZipArchive, result::ZipError};
15
16use crate::plan::{ResolvedPackInfo, SetupPlanMetadata};
17use crate::{bundle, bundle_source::BundleSource, discovery};
18
19use super::plan_builders::compute_simple_hash;
20use super::types::SetupConfig;
21
22#[derive(Debug)]
23pub struct ApplyPackSetupReport {
24 pub provider_updates: usize,
25 pub pending_setup_actions: Vec<crate::setup_actions::SetupAction>,
26}
27
28fn resolve_secret_answer_keys(pack_path: &Path, provider_id: &str) -> Option<BTreeSet<String>> {
43 let form = crate::setup_to_formspec::pack_to_form_spec(pack_path, provider_id)?;
44 let secret_ids = form
45 .questions
46 .iter()
47 .filter(|q| q.secret)
48 .map(|q| crate::secret_name::canonical_secret_name(&q.id))
49 .collect::<BTreeSet<String>>();
50 Some(secret_ids)
51}
52
53fn is_secret_answer_key(answer_key: &str, secret_keys: &BTreeSet<String>) -> bool {
68 let norm = crate::secret_name::canonical_secret_name(answer_key);
69 secret_keys
70 .iter()
71 .any(|secret| secret == &norm || secret.ends_with(&norm))
72}
73
74fn strip_secret_answer_keys(answers: &Value, secret_keys: &BTreeSet<String>) -> Value {
82 let Some(map) = answers.as_object() else {
83 return answers.clone();
84 };
85 let mut filtered = serde_json::Map::with_capacity(map.len());
86 for (key, value) in map {
87 if is_secret_answer_key(key, secret_keys) {
88 continue;
89 }
90 filtered.insert(key.clone(), value.clone());
91 }
92 Value::Object(filtered)
93}
94
95fn redact_secret_answer_values_to_uri_refs(
102 answers: &Value,
103 secret_keys: &BTreeSet<String>,
104 env: &str,
105 tenant: &str,
106 team: Option<&str>,
107 provider_id: &str,
108) -> Value {
109 let Some(map) = answers.as_object() else {
110 return answers.clone();
111 };
112 let mut filtered = serde_json::Map::with_capacity(map.len());
113 for (key, value) in map {
114 if is_secret_answer_key(key, secret_keys) {
115 let uri = crate::canonical_secret_uri(env, tenant, team, provider_id, key);
116 filtered.insert(key.clone(), Value::String(uri));
117 } else {
118 filtered.insert(key.clone(), value.clone());
119 }
120 }
121 Value::Object(filtered)
122}
123
124fn secret_keys_or_fail_closed(
136 resolved: Option<BTreeSet<String>>,
137 answers: &Value,
138 provider_id: &str,
139) -> anyhow::Result<BTreeSet<String>> {
140 match resolved {
141 Some(set) => Ok(set),
142 None if answers_have_content(answers) => anyhow::bail!(
143 "B12a: refusing to write setup-answers for `{provider_id}` — the pack ships no \
144 classifiable setup metadata (no setup.yaml / qa/*.json / secret-requirements), so \
145 we can't tell which answers are secrets and won't risk writing plaintext. \
146 Install/repair the pack with a setup.yaml (`secret: true` flags) or an \
147 `assets/secret-requirements.json`, or pass an explicit pack ref, then retry.",
148 ),
149 None => Ok(BTreeSet::new()),
150 }
151}
152
153fn answers_have_content(answers: &Value) -> bool {
158 let Some(map) = answers.as_object() else {
159 return false;
160 };
161 map.iter().any(|(key, v)| {
162 if matches!(key.as_str(), "public_base_url" | "oauth_callback_base_url") {
168 return false;
169 }
170 match v {
171 Value::String(s) => !s.is_empty(),
172 Value::Null => false,
173 _ => true,
174 }
175 })
176}
177
178fn try_emit_pack_config_input(
182 bundle_path: &Path,
183 pack_path: &Path,
184 env: &str,
185 provider_id: &str,
186 answers: &Value,
187 trace_context: &str,
188) {
189 let Some(form_spec) = crate::setup_to_formspec::pack_to_form_spec(pack_path, provider_id)
190 else {
191 return;
192 };
193 let bundle_id = crate::qa::persist::infer_bundle_id(bundle_path);
194 if let Err(err) = crate::qa::persist::emit_pack_config_input(
195 bundle_path,
196 env,
197 &bundle_id,
198 provider_id,
199 answers,
200 &form_spec,
201 ) {
202 tracing::warn!(
203 provider_id = %provider_id,
204 env = %env,
205 error = %err,
206 "pack-config-input emission failed ({trace_context}); runtime falls back to DevStore via C4.2 compat shim",
207 );
208 }
209}
210
211pub fn execute_create_bundle(
213 bundle_path: &Path,
214 metadata: &SetupPlanMetadata,
215) -> anyhow::Result<()> {
216 bundle::create_demo_bundle_structure(bundle_path, metadata.bundle_name.as_deref())
217 .context("failed to create bundle structure")
218}
219
220pub fn execute_resolve_packs(
222 _bundle_path: &Path,
223 metadata: &SetupPlanMetadata,
224) -> anyhow::Result<Vec<ResolvedPackInfo>> {
225 let mut resolved = Vec::new();
226 let mut failures = Vec::new();
227
228 for pack_ref in &metadata.pack_refs {
229 match resolve_pack_ref(pack_ref) {
230 Ok(resolved_path) => {
231 let canonical = resolved_path
232 .canonicalize()
233 .unwrap_or(resolved_path.clone());
234 let pack_meta = discovery::read_pack_meta(&canonical)?;
235 resolved.push(ResolvedPackInfo {
236 source_ref: pack_ref.clone(),
237 mapped_ref: canonical.display().to_string(),
238 resolved_digest: compute_file_digest(&canonical)
239 .unwrap_or_else(|_| format!("sha256:{}", compute_simple_hash(pack_ref))),
240 pack_id: pack_meta.map(|meta| meta.pack_id).unwrap_or_else(|| {
241 canonical
242 .file_stem()
243 .and_then(|s| s.to_str())
244 .unwrap_or("unknown")
245 .to_string()
246 }),
247 entry_flows: Vec::new(),
248 cached_path: canonical.clone(),
249 output_path: canonical,
250 });
251 }
252 Err(err) => {
253 failures.push(format!("{pack_ref}: {err}"));
254 }
255 }
256 }
257
258 if !failures.is_empty() {
259 anyhow::bail!(
260 "failed to resolve {} pack ref(s):\n{}",
261 failures.len(),
262 failures.join("\n")
263 );
264 }
265
266 Ok(resolved)
267}
268
269pub fn execute_add_packs_to_bundle(
271 bundle_path: &Path,
272 resolved_packs: &[ResolvedPackInfo],
273) -> anyhow::Result<()> {
274 let mut metadata_entries = Vec::new();
275
276 for pack in resolved_packs {
277 let target_dir = get_pack_target_dir(bundle_path, &pack.pack_id);
279 std::fs::create_dir_all(&target_dir)?;
280
281 let target_path = target_dir.join(format!("{}.gtpack", pack.pack_id));
282 let source_path = pack.cached_path.canonicalize().ok();
283 let existing_target_path = target_path.canonicalize().ok();
284 if pack.cached_path.exists() && source_path != existing_target_path {
285 std::fs::copy(&pack.cached_path, &target_path).with_context(|| {
286 format!(
287 "failed to copy pack {} to {}",
288 pack.cached_path.display(),
289 target_path.display()
290 )
291 })?;
292 }
293
294 let reference = target_path
295 .strip_prefix(bundle_path)
296 .unwrap_or(&target_path)
297 .to_string_lossy()
298 .replace('\\', "/");
299 let kind = if reference.starts_with("providers/") {
300 bundle::BundleReferenceKind::ExtensionProvider
301 } else {
302 bundle::BundleReferenceKind::AppPack
303 };
304 metadata_entries.push(bundle::BundleReference {
305 kind,
306 reference,
307 digest: Some(pack.resolved_digest.clone()),
308 });
309 }
310
311 bundle::register_bundle_references(bundle_path, &metadata_entries, None)?;
312 Ok(())
313}
314
315pub fn get_pack_target_dir(bundle_path: &Path, pack_id: &str) -> PathBuf {
320 const DOMAIN_PREFIXES: &[&str] = &[
321 "messaging-",
322 "events-",
323 "oauth-",
324 "secrets-",
325 "mcp-",
326 "state-",
327 ];
328
329 for prefix in DOMAIN_PREFIXES {
330 if pack_id.starts_with(prefix) {
331 let domain = prefix.trim_end_matches('-');
332 return bundle_path.join("providers").join(domain);
333 }
334 }
335
336 bundle_path.join("packs")
338}
339
340pub fn execute_apply_pack_setup(
342 bundle_path: &Path,
343 metadata: &SetupPlanMetadata,
344 config: &SetupConfig,
345) -> anyhow::Result<ApplyPackSetupReport> {
346 let mut count = 0;
347 let mut pending_setup_actions = Vec::new();
348
349 if !metadata.providers_remove.is_empty() {
350 count += execute_remove_provider_artifacts(bundle_path, &metadata.providers_remove)?;
351 }
352
353 auto_install_provider_packs(bundle_path, metadata);
356
357 let discovered = if bundle_path.exists() {
359 discovery::discover(bundle_path).ok()
360 } else {
361 None
362 };
363
364 let provider_ids = setup_provider_ids(metadata, discovered.as_ref());
365
366 for provider_id in provider_ids {
368 let empty_answers = Value::Object(serde_json::Map::new());
369 let answers = metadata
370 .setup_answers
371 .get(&provider_id)
372 .unwrap_or(&empty_answers);
373 let mut effective_answers = answers.clone();
374 let pack_path = discovered.as_ref().and_then(|d| {
375 d.find_setup_target(&provider_id)
376 .map(|p| p.pack_path.as_path())
377 });
378 if !crate::provider_state::provider_enabled(&effective_answers) {
379 let persisted_answers = crate::setup_actions::strip_setup_actions(&effective_answers);
380 let config_dir = bundle_path.join("state").join("config").join(&provider_id);
381 std::fs::create_dir_all(&config_dir)?;
382 let config_path = config_dir.join("setup-answers.json");
383 let content = serde_json::to_string_pretty(&persisted_answers)
384 .context("failed to serialize setup answers")?;
385 std::fs::write(&config_path, content).with_context(|| {
386 format!(
387 "failed to write setup answers to: {}",
388 config_path.display()
389 )
390 })?;
391 let env = crate::resolve_env(Some(&config.env));
392 let rt = tokio::runtime::Runtime::new()
393 .context("failed to create tokio runtime for secrets persistence")?;
394 rt.block_on(crate::qa::persist::persist_all_config_as_secrets(
395 bundle_path,
396 &env,
397 &config.tenant,
398 config.team.as_deref(),
399 &provider_id,
400 &persisted_answers,
401 pack_path,
402 ))?;
403 if let Some(pack_path) = pack_path {
404 crate::config_envelope::write_provider_config_envelope(
405 &bundle_path.join(".providers"),
406 &provider_id,
407 "setup-input",
408 &persisted_answers,
409 pack_path,
410 false,
411 )
412 .with_context(|| {
413 format!(
414 "failed to write provider config envelope for {} using {}",
415 provider_id,
416 pack_path.display()
417 )
418 })?;
419 try_emit_pack_config_input(
420 bundle_path,
421 pack_path,
422 &env,
423 &provider_id,
424 &persisted_answers,
425 "setup-input path",
426 );
427 }
428 count += 1;
429 continue;
430 }
431 let mut setup_actions = crate::setup_actions::extract_setup_actions(
432 &provider_id,
433 &config.tenant,
434 config.team.as_deref(),
435 answers,
436 )?;
437 setup_actions.extend(extract_pack_setup_actions(
438 discovered.as_ref(),
439 &provider_id,
440 &config.tenant,
441 config.team.as_deref(),
442 )?);
443 defer_registration_actions_missing_inputs(&mut setup_actions, &effective_answers);
444 run_setup_action_registrations(SetupActionRegistrationContext {
445 bundle_path,
446 discovered: discovered.as_ref(),
447 provider_id: &provider_id,
448 config,
449 bundle_name: metadata.bundle_name.as_deref(),
450 public_base_url: metadata.static_routes.public_base_url.as_deref(),
451 answers: &mut effective_answers,
452 actions: &mut setup_actions,
453 })?;
454 hydrate_oauth_install_actions(&mut setup_actions, &effective_answers);
455 if !setup_actions.is_empty() {
456 crate::setup_actions::sign_pending_oauth_actions(bundle_path, &mut setup_actions)?;
457 crate::setup_actions::persist_setup_actions(bundle_path, &setup_actions)?;
458 pending_setup_actions.extend(setup_actions.clone());
459 }
460 let slack_team = slack_team_id_from_answers(&effective_answers);
465 if let Some(team) = slack_team.as_deref() {
466 apply_slack_team_to_app_url(&mut effective_answers, team);
467 }
468 let persisted_answers = crate::setup_actions::strip_setup_actions(&effective_answers);
469
470 let config_dir = bundle_path.join("state").join("config").join(&provider_id);
472 std::fs::create_dir_all(&config_dir)?;
473
474 let pack_path = discovered.as_ref().and_then(|d| {
478 d.find_setup_target(&provider_id)
479 .map(|p| p.pack_path.as_path())
480 });
481 let env = crate::resolve_env(Some(&config.env));
482
483 let resolved_secret_keys: Option<BTreeSet<String>> =
496 pack_path.and_then(|pp| resolve_secret_answer_keys(pp, &provider_id));
497 let secret_keys = secret_keys_or_fail_closed(resolved_secret_keys, answers, &provider_id)?;
498 let mut answers_for_disk = strip_secret_answer_keys(answers, &secret_keys);
499 if let Some(team) = slack_team.as_deref() {
500 apply_slack_team_to_app_url(&mut answers_for_disk, team);
501 }
502 let mut envelope_answers = redact_secret_answer_values_to_uri_refs(
503 answers,
504 &secret_keys,
505 &env,
506 &config.tenant,
507 config.team.as_deref(),
508 &provider_id,
509 );
510 if let Some(team) = slack_team.as_deref() {
511 apply_slack_team_to_app_url(&mut envelope_answers, team);
512 }
513
514 let config_path = config_dir.join("setup-answers.json");
515 let content = serde_json::to_string_pretty(&answers_for_disk)
516 .context("failed to serialize setup answers")?;
517 std::fs::write(&config_path, content).with_context(|| {
518 format!(
519 "failed to write setup answers to: {}",
520 config_path.display()
521 )
522 })?;
523
524 if config.verbose {
525 let team_display = config.team.as_deref().unwrap_or("(none)");
526 println!(
527 " [secrets] scope: env={env}, tenant={}, team={team_display}, provider={provider_id}",
528 config.tenant
529 );
530 let example_uri = crate::canonical_secret_uri(
531 &env,
532 &config.tenant,
533 config.team.as_deref(),
534 &provider_id,
535 "_example_key",
536 );
537 println!(" [secrets] URI pattern: {example_uri}");
538 if let Some(config_map) = persisted_answers.as_object() {
539 let keys: Vec<&String> = config_map.keys().collect();
540 println!(" [secrets] answer keys: {keys:?}");
541 }
542 }
543 let rt = tokio::runtime::Runtime::new()
544 .context("failed to create tokio runtime for secrets persistence")?;
545 let persisted = rt.block_on(crate::qa::persist::persist_all_config_as_secrets(
546 bundle_path,
547 &env,
548 &config.tenant,
549 config.team.as_deref(),
550 &provider_id,
551 &persisted_answers,
552 pack_path,
553 ))?;
554 if config.verbose {
555 if persisted.is_empty() {
556 println!(
557 " [secrets] WARNING: 0 key(s) persisted for {provider_id} (all values empty?)"
558 );
559 } else {
560 println!(
561 " [secrets] persisted {} key(s) for {provider_id}: {:?}",
562 persisted.len(),
563 persisted
564 );
565 }
566 }
567
568 if let Some(pack_path) = pack_path {
573 crate::config_envelope::write_provider_config_envelope(
574 &bundle_path.join(".providers"),
575 &provider_id,
576 "setup-input",
577 &envelope_answers,
578 pack_path,
579 false,
580 )
581 .with_context(|| {
582 format!(
583 "failed to write provider config envelope for {} using {}",
584 provider_id,
585 pack_path.display()
586 )
587 })?;
588 } else if config.verbose {
589 println!(
590 " [config] WARNING: no resolved pack path for {provider_id}; skipped config envelope write"
591 );
592 }
593
594 if let Some(pack_path) = pack_path {
597 try_emit_pack_config_input(
598 bundle_path,
599 pack_path,
600 &env,
601 &provider_id,
602 &persisted_answers,
603 "apply-answers path",
604 );
605 }
606
607 match crate::tenant_config::sync_oauth_to_tenant_config(
609 bundle_path,
610 &config.tenant,
611 &provider_id,
612 &persisted_answers,
613 ) {
614 Ok(true) => {
615 if config.verbose {
616 println!(" [oauth] updated tenant config for {provider_id}");
617 }
618 }
619 Ok(false) => {}
620 Err(e) => {
621 println!(" [oauth] WARNING: failed to update tenant config: {e}");
622 }
623 }
624
625 match crate::tenant_config::sync_skin_to_tenant_config(
627 bundle_path,
628 &config.tenant,
629 &provider_id,
630 &persisted_answers,
631 ) {
632 Ok(true) => {
633 if config.verbose {
634 println!(" [skin] updated tenant config for {provider_id}");
635 }
636 }
637 Ok(false) => {}
638 Err(e) => {
639 println!(" [skin] WARNING: failed to update tenant config: {e}");
640 }
641 }
642
643 if provider_id.contains("webchat-gui") && config.verbose {
645 let preview = answers
646 .as_object()
647 .and_then(|m| m.get("nav_links"))
648 .map(|v| serde_json::to_string(v).unwrap_or_else(|_| "<unserializable>".into()))
649 .unwrap_or_else(|| "<absent>".into());
650 println!(" [nav_links] received answer for {provider_id}: {preview}");
651 }
652 match crate::tenant_config::sync_nav_links_to_tenant_config(
653 bundle_path,
654 &config.tenant,
655 &provider_id,
656 &persisted_answers,
657 ) {
658 Ok(true) => {
659 if config.verbose {
660 println!(" [nav_links] updated tenant config for {provider_id}");
661 }
662 }
663 Ok(false) => {}
664 Err(e) => {
665 println!(" [nav_links] WARNING: failed to update tenant config: {e}");
666 }
667 }
668
669 if let Some(result) = crate::webhook::register_webhook(
671 &provider_id,
672 &persisted_answers,
673 &config.tenant,
674 config.team.as_deref(),
675 ) {
676 let ok = result.get("ok").and_then(Value::as_bool).unwrap_or(false);
677 if ok {
678 println!(" [webhook] registered for {provider_id}");
679 } else {
680 let err = result
681 .get("error")
682 .and_then(Value::as_str)
683 .unwrap_or("unknown");
684 println!(" [webhook] WARNING: registration failed for {provider_id}: {err}");
685 }
686 }
687
688 count += 1;
689 }
690
691 crate::platform_setup::persist_static_routes_artifact(bundle_path, &metadata.static_routes)?;
692 let _ = crate::deployment_targets::persist_explicit_deployment_targets(
693 bundle_path,
694 &metadata.deployment_targets,
695 );
696
697 let provider_configs: Vec<(String, Value)> = metadata
699 .setup_answers
700 .iter()
701 .filter(|(_, val)| crate::provider_state::provider_enabled(val))
702 .map(|(id, val)| (id.clone(), val.clone()))
703 .collect();
704 let team = config.team.as_deref().unwrap_or("default");
705 crate::webhook::print_post_setup_instructions(&provider_configs, &config.tenant, team);
706
707 Ok(ApplyPackSetupReport {
708 provider_updates: count,
709 pending_setup_actions,
710 })
711}
712
713fn setup_provider_ids(
714 metadata: &SetupPlanMetadata,
715 discovered: Option<&crate::discovery::DiscoveryResult>,
716) -> BTreeSet<String> {
717 let mut provider_ids: BTreeSet<String> = metadata.setup_answers.keys().cloned().collect();
718 if let Some(discovered) = discovered {
719 for provider in discovered.setup_targets() {
720 if let Ok(Some(spec)) = crate::setup_input::load_setup_spec(&provider.pack_path)
721 && !spec.setup_actions.is_empty()
722 {
723 provider_ids.insert(provider.provider_id.clone());
724 }
725 }
726 }
727 provider_ids
728}
729
730fn extract_pack_setup_actions(
731 discovered: Option<&crate::discovery::DiscoveryResult>,
732 provider_id: &str,
733 tenant: &str,
734 team: Option<&str>,
735) -> anyhow::Result<Vec<crate::setup_actions::SetupAction>> {
736 let Some(provider) = discovered.and_then(|d| d.find_setup_target(provider_id)) else {
737 return Ok(Vec::new());
738 };
739 let Some(spec) = crate::setup_input::load_setup_spec(&provider.pack_path)? else {
740 return Ok(Vec::new());
741 };
742 if spec.setup_actions.is_empty() {
743 return Ok(Vec::new());
744 }
745 let setup_actions = spec
746 .setup_actions
747 .into_iter()
748 .map(|mut action| {
749 if let Some(obj) = action.as_object_mut() {
750 obj.remove("provider_id");
751 obj.remove("tenant");
752 obj.remove("team");
753 }
754 action
755 })
756 .collect::<Vec<_>>();
757 let value = serde_json::json!({ "setup_actions": setup_actions });
758 crate::setup_actions::extract_setup_actions(provider_id, tenant, team, &value)
759}
760
761fn defer_registration_actions_missing_inputs(
762 actions: &mut Vec<crate::setup_actions::SetupAction>,
763 answers: &Value,
764) {
765 actions.retain(|action| {
766 if action.extra.get("registration").is_none() {
767 return true;
768 }
769 let registration_satisfied = match action.kind {
770 crate::setup_actions::SetupActionKind::OauthInstallButton => {
771 client_id_for_action(action, answers).is_some()
772 }
773 crate::setup_actions::SetupActionKind::OpenUrl => {
774 !registration_output_missing(action, answers)
775 }
776 _ => return true,
777 };
778 registration_satisfied
779 || registration_has_any_declared_input(action.extra.get("registration"), answers)
780 });
781}
782
783fn registration_output_missing(
787 action: &crate::setup_actions::SetupAction,
788 answers: &Value,
789) -> bool {
790 let Some(registration_obj) = action.extra.get("registration").and_then(Value::as_object) else {
791 return true;
792 };
793 let Some(answers_obj) = answers.as_object() else {
794 return true;
795 };
796 !registration_obj.iter().any(|(key, field_value)| {
797 key.ends_with("_output")
798 && field_value
799 .as_str()
800 .map(str::trim)
801 .filter(|field_name| !field_name.is_empty())
802 .and_then(|field_name| answers_obj.get(field_name))
803 .is_some_and(|value| !is_empty_value(value))
804 })
805}
806
807fn registration_has_any_declared_input(registration: Option<&Value>, answers: &Value) -> bool {
808 let Some(registration_obj) = registration.and_then(Value::as_object) else {
809 return false;
810 };
811 let Some(answers_obj) = answers.as_object() else {
812 return false;
813 };
814 registration_obj.iter().any(|(key, field_value)| {
815 key.ends_with("_field")
816 && field_value
817 .as_str()
818 .map(str::trim)
819 .filter(|field_name| !field_name.is_empty())
820 .and_then(|field_name| answers_obj.get(field_name))
821 .is_some_and(|value| !is_empty_value(value))
822 })
823}
824
825struct SetupActionRegistrationContext<'a> {
826 bundle_path: &'a Path,
827 discovered: Option<&'a crate::discovery::DiscoveryResult>,
828 provider_id: &'a str,
829 config: &'a SetupConfig,
830 bundle_name: Option<&'a str>,
831 public_base_url: Option<&'a str>,
832 answers: &'a mut Value,
833 actions: &'a mut [crate::setup_actions::SetupAction],
834}
835
836fn run_setup_action_registrations(ctx: SetupActionRegistrationContext<'_>) -> anyhow::Result<()> {
837 let SetupActionRegistrationContext {
838 bundle_path,
839 discovered,
840 provider_id,
841 config,
842 bundle_name,
843 public_base_url,
844 answers,
845 actions,
846 } = ctx;
847
848 let Some(provider) = discovered.and_then(|d| d.find_setup_target(provider_id)) else {
849 if actions
850 .iter()
851 .any(|action| needs_setup_action_registration(action, answers))
852 {
853 anyhow::bail!("provider pack not found for setup action registration: {provider_id}");
854 }
855 return Ok(());
856 };
857
858 for action in actions {
859 if !needs_setup_action_registration(action, answers) {
860 if action.kind == crate::setup_actions::SetupActionKind::OpenUrl
867 && action.extra.get("registration").is_some()
868 {
869 resolve_open_url_action(action, answers)?;
870 }
871 continue;
872 }
873 let registration = action
874 .extra
875 .get("registration")
876 .cloned()
877 .ok_or_else(|| anyhow!("setup action registration metadata missing"))?;
878 let request = build_registration_request(
879 provider_id,
880 config,
881 bundle_name,
882 public_base_url,
883 answers,
884 action,
885 ®istration,
886 )?;
887 let output = invoke_registration_operation(
888 bundle_path,
889 &provider.pack_path,
890 ®istration,
891 &request,
892 config,
893 )
894 .with_context(|| {
895 format!(
896 "failed to run setup action registration {} for {}",
897 action.id, provider_id
898 )
899 })?;
900 if let Some(error) = registration_error_message(&output) {
901 anyhow::bail!(
902 "setup action registration {} returned an error: {}",
903 action.id,
904 error
905 );
906 }
907 merge_registration_output(action, answers, ®istration, &output)?;
908 match action.kind {
909 crate::setup_actions::SetupActionKind::OauthInstallButton
910 if client_id_for_action(action, answers).is_none()
911 && !authorize_url_has_query_key(
912 action.authorize_url.as_deref(),
913 "client_id",
914 ) =>
915 {
916 anyhow::bail!(
917 "setup action registration {} did not produce a client_id",
918 action.id
919 );
920 }
921 crate::setup_actions::SetupActionKind::OpenUrl => {
922 resolve_open_url_action(action, answers)?;
923 }
924 _ => {}
925 }
926 }
927 Ok(())
928}
929
930fn needs_setup_action_registration(
931 action: &crate::setup_actions::SetupAction,
932 answers: &Value,
933) -> bool {
934 if action.extra.get("registration").is_none() {
935 return false;
936 }
937 match action.kind {
938 crate::setup_actions::SetupActionKind::OauthInstallButton => {
939 client_id_for_action(action, answers).is_none()
940 && !authorize_url_has_query_key(action.authorize_url.as_deref(), "client_id")
941 }
942 crate::setup_actions::SetupActionKind::OpenUrl => {
943 registration_output_missing(action, answers)
944 }
945 _ => false,
946 }
947}
948
949fn resolve_open_url_action(
954 action: &mut crate::setup_actions::SetupAction,
955 answers: &Value,
956) -> anyhow::Result<()> {
957 let Some(template) = action
958 .extra
959 .get("url_template")
960 .and_then(Value::as_str)
961 .map(str::trim)
962 .filter(|value| !value.is_empty())
963 else {
964 return Ok(());
965 };
966 if !template.starts_with("https://") {
967 anyhow::bail!(
968 "setup action {} url_template must be an https:// URL",
969 action.id
970 );
971 }
972 let placeholder = regex::Regex::new(r"\{([A-Za-z0-9_.-]+)\}")
973 .expect("static url template placeholder regex is valid");
974 let answers_obj = answers.as_object();
975 let mut unresolved = false;
976 let resolved = placeholder.replace_all(template, |caps: ®ex::Captures<'_>| {
977 let name = &caps[1];
978 let value = answers_obj
979 .and_then(|obj| obj.get(name))
980 .and_then(Value::as_str)
981 .map(str::trim)
982 .filter(|value| !value.is_empty());
983 match value {
984 Some(value) => percent_encode_url_component(value),
985 None => {
986 unresolved = true;
987 String::new()
988 }
989 }
990 });
991 if !unresolved {
992 action
993 .extra
994 .insert("url".into(), Value::String(resolved.into_owned()));
995 }
996 Ok(())
997}
998
999fn percent_encode_url_component(value: &str) -> String {
1002 let mut out = String::with_capacity(value.len());
1003 for byte in value.bytes() {
1004 match byte {
1005 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1006 out.push(byte as char);
1007 }
1008 _ => out.push_str(&format!("%{byte:02X}")),
1009 }
1010 }
1011 out
1012}
1013
1014fn authorize_url_has_query_key(url: Option<&str>, key: &str) -> bool {
1015 url.and_then(|value| url::Url::parse(value).ok())
1016 .is_some_and(|parsed| parsed.query_pairs().any(|(candidate, _)| candidate == key))
1017}
1018
1019fn build_registration_request(
1020 provider_id: &str,
1021 config: &SetupConfig,
1022 bundle_name: Option<&str>,
1023 public_base_url: Option<&str>,
1024 answers: &Value,
1025 action: &crate::setup_actions::SetupAction,
1026 registration: &Value,
1027) -> anyhow::Result<Value> {
1028 let registration_obj = registration
1029 .as_object()
1030 .ok_or_else(|| anyhow!("setup action registration must be an object"))?;
1031 let answers_obj = answers
1032 .as_object()
1033 .ok_or_else(|| anyhow!("provider setup answers must be an object"))?;
1034 let setup_callback_base =
1042 if action.kind == crate::setup_actions::SetupActionKind::OauthInstallButton {
1043 std::env::var("GREENTIC_SETUP_PUBLIC_BASE_URL")
1044 .ok()
1045 .map(|value| value.trim().trim_end_matches('/').to_string())
1046 .filter(|value| value.starts_with("https://"))
1047 } else {
1048 None
1049 };
1050 let effective_public_base_url =
1051 setup_callback_base
1052 .as_deref()
1053 .or(public_base_url)
1054 .or_else(|| {
1055 answers_obj
1056 .get("public_base_url")
1057 .and_then(Value::as_str)
1058 .map(str::trim)
1059 .filter(|value| !value.is_empty())
1060 });
1061 let effective_team = config.team.as_deref().unwrap_or("default");
1062 let mut input = JsonMap::new();
1063 input.insert("answers".into(), answers.clone());
1064 input.insert("provider_id".into(), Value::String(provider_id.to_string()));
1065 input.insert("tenant".into(), Value::String(config.tenant.clone()));
1066 input.insert("team".into(), Value::String(effective_team.to_string()));
1067 if let Some(public_base_url) = effective_public_base_url {
1068 input.insert(
1069 "public_base_url".into(),
1070 Value::String(public_base_url.to_string()),
1071 );
1072 }
1073 input.insert("action_id".into(), Value::String(action.id.clone()));
1074
1075 for (key, field_value) in registration_obj {
1076 let Some(input_name) = key.strip_suffix("_field") else {
1077 continue;
1078 };
1079 let Some(field_name) = field_value
1080 .as_str()
1081 .map(str::trim)
1082 .filter(|v| !v.is_empty())
1083 else {
1084 continue;
1085 };
1086 if let Some(value) = answers_obj
1087 .get(field_name)
1088 .filter(|value| !is_empty_value(value))
1089 {
1090 input.insert(field_name.to_string(), value.clone());
1091 input.insert(input_name.to_string(), value.clone());
1092 }
1093 }
1094
1095 if input.get("app_name").is_none()
1096 && let Some(app_name) = registration_app_name(action, bundle_name)
1097 {
1098 input.insert("app_name".into(), Value::String(app_name.clone()));
1099 if let Some(field_name) = registration_obj
1100 .get("app_name_field")
1101 .and_then(Value::as_str)
1102 .map(str::trim)
1103 .filter(|value| !value.is_empty())
1104 {
1105 input.insert(field_name.to_string(), Value::String(app_name));
1106 }
1107 }
1108
1109 let mut context = JsonMap::new();
1110 context.insert("provider_id".into(), Value::String(provider_id.to_string()));
1111 context.insert("tenant".into(), Value::String(config.tenant.clone()));
1112 context.insert("team".into(), Value::String(effective_team.to_string()));
1113 if let Some(public_base_url) = effective_public_base_url {
1114 context.insert(
1115 "public_base_url".into(),
1116 Value::String(public_base_url.to_string()),
1117 );
1118 }
1119 if let Some(app_name) = input.get("app_name") {
1120 context.insert("app_name".into(), app_name.clone());
1121 }
1122 input.insert("context".into(), Value::Object(context));
1123 Ok(Value::Object(input))
1124}
1125
1126fn registration_app_name(
1127 action: &crate::setup_actions::SetupAction,
1128 bundle_name: Option<&str>,
1129) -> Option<String> {
1130 let bundle_name = bundle_name
1131 .map(str::trim)
1132 .filter(|value| !value.is_empty())
1133 .unwrap_or("Greentic");
1134 if let Some(template) = action
1135 .extra
1136 .get("app_name_template")
1137 .and_then(Value::as_str)
1138 .map(str::trim)
1139 .filter(|value| !value.is_empty())
1140 {
1141 let rendered = template
1142 .replace("{{ bundle_name }}", bundle_name)
1143 .replace("{{bundle_name}}", bundle_name)
1144 .trim()
1145 .to_string();
1146 if !rendered.is_empty() {
1147 return Some(rendered);
1148 }
1149 }
1150 action
1151 .extra
1152 .get("default_app_name")
1153 .and_then(Value::as_str)
1154 .map(str::trim)
1155 .filter(|value| !value.is_empty())
1156 .map(ToString::to_string)
1157}
1158
1159fn invoke_registration_operation(
1160 bundle_path: &Path,
1161 pack_path: &Path,
1162 registration: &Value,
1163 request: &Value,
1164 config: &SetupConfig,
1165) -> anyhow::Result<Value> {
1166 let registration_obj = registration
1167 .as_object()
1168 .ok_or_else(|| anyhow!("setup component invocation must be an object"))?;
1169 let component_ref = registration_obj
1170 .get("component_ref")
1171 .and_then(Value::as_str)
1172 .map(str::trim)
1173 .filter(|value| !value.is_empty())
1174 .ok_or_else(|| anyhow!("setup component invocation missing component_ref"))?;
1175 let op = registration_obj
1176 .get("op")
1177 .and_then(Value::as_str)
1178 .map(str::trim)
1179 .filter(|value| !value.is_empty())
1180 .ok_or_else(|| anyhow!("setup component invocation missing op"))?;
1181
1182 if let Some(result) = registration_obj
1183 .get("result")
1184 .or_else(|| registration_obj.get("mock_result"))
1185 .or_else(|| registration_obj.get("outputs"))
1186 {
1187 return Ok(result.clone());
1188 }
1189
1190 if let Ok(component) = read_registration_component(pack_path, component_ref)
1191 && let Some(output) = invoke_json_registration_component(&component, op, request)
1192 {
1193 return Ok(output);
1194 }
1195
1196 invoke_wasm_registration_component(bundle_path, pack_path, component_ref, op, request, config)
1197}
1198
1199fn slack_team_id_from_answers(answers: &Value) -> Option<String> {
1204 let obj = answers.as_object()?;
1205 let app_url = obj.get("slack_app_url").and_then(Value::as_str)?;
1206 if !app_url.contains("app_redirect") || app_url.contains("team=") {
1207 return None;
1208 }
1209 let token = obj
1210 .get("slack_bot_token")
1211 .or_else(|| obj.get("bot_token"))
1212 .and_then(Value::as_str)
1213 .map(str::trim)
1214 .filter(|t| t.starts_with("xoxb"))?;
1215 let mut response = crate::http_client::api_agent_any_status()
1216 .post("https://slack.com/api/auth.test")
1217 .header("Authorization", &format!("Bearer {token}"))
1218 .send_empty()
1219 .ok()?;
1220 let body: Value = response.body_mut().read_json().ok()?;
1221 if body.get("ok").and_then(Value::as_bool) != Some(true) {
1222 eprintln!(
1223 "[oauth-token] auth.test failed: {:?} — Add to Slack link stays unpinned",
1224 body.get("error").and_then(Value::as_str)
1225 );
1226 return None;
1227 }
1228 let team = body
1229 .get("team_id")
1230 .and_then(Value::as_str)
1231 .map(str::trim)
1232 .filter(|t| !t.is_empty())?
1233 .to_string();
1234 eprintln!("[oauth-token] auth.test ok: team_id={team} — pinning Add to Slack link");
1235 Some(team)
1236}
1237
1238fn apply_slack_team_to_app_url(answers: &mut Value, team: &str) {
1242 let Some(obj) = answers.as_object_mut() else {
1243 return;
1244 };
1245 if let Some(url) = obj
1246 .get("slack_app_url")
1247 .and_then(Value::as_str)
1248 .filter(|url| url.contains("app_redirect") && !url.contains("team="))
1249 {
1250 let separator = if url.contains('?') { '&' } else { '?' };
1251 let pinned = format!("{url}{separator}team={team}");
1252 obj.insert("slack_app_url".to_string(), Value::String(pinned));
1253 }
1254 obj.entry("slack_team_id".to_string())
1255 .or_insert_with(|| Value::String(team.to_string()));
1256}
1257
1258pub fn invoke_setup_component_operation(
1259 bundle_path: &Path,
1260 pack_path: &Path,
1261 component_ref: &str,
1262 op: &str,
1263 request: &Value,
1264 config: &SetupConfig,
1265) -> anyhow::Result<Value> {
1266 let registration = serde_json::json!({
1267 "component_ref": component_ref,
1268 "op": op,
1269 });
1270 invoke_registration_operation(bundle_path, pack_path, ®istration, request, config)
1271}
1272
1273#[derive(Default)]
1274struct SetupRegistrationSecrets {
1275 values: Mutex<BTreeMap<String, Vec<u8>>>,
1278 dev_store: Option<Arc<greentic_secrets_lib::DevStore>>,
1282}
1283
1284impl SetupRegistrationSecrets {
1285 fn with_dev_store(dev_store: Option<Arc<greentic_secrets_lib::DevStore>>) -> Self {
1286 Self {
1287 values: Mutex::new(BTreeMap::new()),
1288 dev_store,
1289 }
1290 }
1291}
1292
1293fn secret_uri_candidates(path: &str) -> Vec<String> {
1306 let mut out = vec![path.to_string()];
1307 let Some(rest) = path.strip_prefix("secrets://") else {
1308 return out;
1309 };
1310 let segs: Vec<&str> = rest.splitn(5, '/').collect();
1311 if segs.len() != 5 {
1312 return out;
1313 }
1314 let (env, tenant, team, provider, key) = (segs[0], segs[1], segs[2], segs[3], segs[4]);
1315
1316 let env_variants: Vec<&str> = if env == crate::DEFAULT_ENV_ID {
1317 vec![crate::DEFAULT_ENV_ID, crate::LEGACY_ENV_ID]
1318 } else if env == crate::LEGACY_ENV_ID {
1319 vec![crate::LEGACY_ENV_ID, crate::DEFAULT_ENV_ID]
1320 } else {
1321 vec![env]
1322 };
1323 let mut provider_variants = vec![provider.to_string()];
1324 for alt in [provider.replace('-', "_"), provider.replace('_', "-")] {
1325 if alt != provider && !provider_variants.contains(&alt) {
1326 provider_variants.push(alt);
1327 }
1328 }
1329
1330 for env in &env_variants {
1331 for provider in &provider_variants {
1332 let candidate = format!("secrets://{env}/{tenant}/{team}/{provider}/{key}");
1333 if !out.contains(&candidate) {
1334 out.push(candidate);
1335 }
1336 }
1337 }
1338 out
1339}
1340
1341#[async_trait::async_trait]
1342impl greentic_secrets_lib::SecretsManager for SetupRegistrationSecrets {
1343 async fn read(&self, path: &str) -> greentic_secrets_lib::Result<Vec<u8>> {
1344 {
1345 let values = self.values.lock().map_err(|_| {
1346 greentic_secrets_lib::SecretError::Backend(
1347 "setup component secrets lock poisoned".into(),
1348 )
1349 })?;
1350 if let Some(value) = values.get(path) {
1351 return Ok(value.clone());
1352 }
1353 }
1354 if let Some(store) = &self.dev_store {
1355 use greentic_secrets_lib::SecretsStore;
1356 for candidate in secret_uri_candidates(path) {
1357 match store.get(&candidate).await {
1358 Ok(bytes) => return Ok(bytes),
1359 Err(err) => {
1360 tracing::debug!(candidate, error = %err, "setup secrets read miss");
1361 }
1362 }
1363 }
1364 }
1365 Err(greentic_secrets_lib::SecretError::NotFound(
1366 path.to_string(),
1367 ))
1368 }
1369
1370 async fn write(&self, path: &str, bytes: &[u8]) -> greentic_secrets_lib::Result<()> {
1371 let mut values = self.values.lock().map_err(|_| {
1372 greentic_secrets_lib::SecretError::Backend(
1373 "setup component secrets lock poisoned".into(),
1374 )
1375 })?;
1376 values.insert(path.to_string(), bytes.to_vec());
1377 Ok(())
1378 }
1379
1380 async fn delete(&self, path: &str) -> greentic_secrets_lib::Result<()> {
1381 let mut values = self.values.lock().map_err(|_| {
1382 greentic_secrets_lib::SecretError::Backend(
1383 "setup component secrets lock poisoned".into(),
1384 )
1385 })?;
1386 values.remove(path);
1387 Ok(())
1388 }
1389}
1390
1391fn invoke_wasm_registration_component(
1392 bundle_path: &Path,
1393 pack_path: &Path,
1394 component_ref: &str,
1395 op: &str,
1396 request: &Value,
1397 config: &SetupConfig,
1398) -> anyhow::Result<Value> {
1399 use greentic_runner_host::component_api::node::{
1400 ExecCtx as ComponentExecCtx, TenantCtx as ComponentTenantCtx,
1401 };
1402 use greentic_runner_host::config::{OperatorPolicy, SecretsPolicy};
1403 use greentic_runner_host::pack::{ComponentResolution, PackRuntime};
1404 use greentic_runner_host::provider::ProviderBinding;
1405 use greentic_runner_host::storage::{new_session_store, new_state_store};
1406 use greentic_runner_host::{HostConfig, RunnerWasiPolicy};
1407 use std::sync::Arc;
1408
1409 let bindings_path = bundle_path
1410 .join("state")
1411 .join("config")
1412 .join("setup-component-bindings.yaml");
1413 if let Some(parent) = bindings_path.parent() {
1414 std::fs::create_dir_all(parent)?;
1415 }
1416 std::fs::write(
1417 &bindings_path,
1418 format!(
1419 r#"tenant: {}
1420flow_type_bindings:
1421 messaging:
1422 adapter: setup-component
1423 config: {{}}
1424 secrets: []
1425rate_limits: {{}}
1426retry: {{}}
1427timers: []
1428"#,
1429 config.tenant
1430 ),
1431 )
1432 .with_context(|| format!("write {}", bindings_path.display()))?;
1433
1434 let mut host_config = HostConfig::load_from_path(&bindings_path)
1435 .with_context(|| format!("load {}", bindings_path.display()))?;
1436 host_config.secrets_policy = SecretsPolicy::allow_all();
1437 host_config.operator_policy = OperatorPolicy::allow_all();
1438 let host_config = Arc::new(host_config);
1439
1440 let session_store = new_session_store();
1441 let state_store = new_state_store();
1442 unsafe { std::env::set_var("GREENTIC_PROVIDER_CORE_ONLY", "0") };
1449
1450 let dev_store = crate::secrets::open_dev_store(bundle_path)
1455 .ok()
1456 .map(Arc::new);
1457 let secrets: greentic_runner_host::secrets::DynSecretsManager =
1458 Arc::new(SetupRegistrationSecrets::with_dev_store(dev_store));
1459 let pack = greentic_runner_host::runtime::block_on(PackRuntime::load(
1460 pack_path,
1461 Arc::clone(&host_config),
1462 None,
1463 Some(pack_path),
1464 Some(Arc::clone(&session_store)),
1465 Some(Arc::clone(&state_store)),
1466 Arc::new(RunnerWasiPolicy::default()),
1467 secrets,
1468 None,
1469 false,
1470 ComponentResolution::default(),
1471 ))
1472 .with_context(|| format!("load setup component pack {}", pack_path.display()))?;
1473
1474 let exec_ctx = ComponentExecCtx {
1475 tenant: ComponentTenantCtx {
1476 tenant: config.tenant.clone(),
1477 team: config.team.clone(),
1478 user: None,
1479 trace_id: None,
1480 i18n_id: None,
1481 correlation_id: Some(format!("setup-component:{component_ref}:{op}")),
1482 deadline_unix_ms: None,
1483 attempt: 1,
1484 idempotency_key: Some(format!("setup-component:{component_ref}:{op}")),
1485 },
1486 i18n_id: None,
1487 flow_id: format!("setup-component/{op}"),
1488 node_id: Some(component_ref.to_string()),
1489 };
1490 let input_json = serde_json::to_vec(request)?;
1491 let binding = ProviderBinding {
1492 provider_id: Some(component_ref.to_string()),
1493 provider_type: component_ref.to_string(),
1494 component_ref: component_ref.to_string(),
1495 export: "schema-core-api".to_string(),
1496 world: "greentic:provider/schema-core@1.0.0".to_string(),
1497 config_json: None,
1498 pack_ref: None,
1499 };
1500 match greentic_runner_host::runtime::block_on(pack.invoke_provider(
1501 &binding,
1502 exec_ctx.clone(),
1503 op,
1504 input_json,
1505 )) {
1506 Ok(output) => Ok(output),
1507 Err(provider_err) => {
1508 let input_json = serde_json::to_string(request)?;
1509 greentic_runner_host::runtime::block_on(pack.invoke_component(
1510 component_ref,
1511 exec_ctx,
1512 op,
1513 None,
1514 input_json,
1515 ))
1516 .with_context(|| {
1517 format!(
1518 "invoke setup component '{component_ref}' op '{op}' (provider path failed: {provider_err})"
1519 )
1520 })
1521 }
1522 }
1523}
1524
1525fn read_registration_component(pack_path: &Path, component_ref: &str) -> anyhow::Result<Value> {
1526 let file = File::open(pack_path).with_context(|| format!("open {}", pack_path.display()))?;
1527 let mut archive = match ZipArchive::new(file) {
1528 Ok(archive) => archive,
1529 Err(ZipError::InvalidArchive(_)) | Err(ZipError::UnsupportedArchive(_)) => {
1530 anyhow::bail!("{} is not a zip pack", pack_path.display())
1531 }
1532 Err(err) => return Err(err.into()),
1533 };
1534 let candidates = registration_component_candidates(component_ref);
1535 for candidate in candidates {
1536 match archive.by_name(&candidate) {
1537 Ok(mut entry) => {
1538 let mut raw = String::new();
1539 entry
1540 .read_to_string(&mut raw)
1541 .with_context(|| format!("read setup component {candidate}"))?;
1542 return serde_json::from_str(&raw)
1543 .or_else(|_| serde_yaml_bw::from_str(&raw))
1544 .with_context(|| format!("parse setup component {candidate}"));
1545 }
1546 Err(ZipError::FileNotFound) => continue,
1547 Err(err) => return Err(err.into()),
1548 }
1549 }
1550 anyhow::bail!(
1551 "setup component_ref '{}' not found in {}",
1552 component_ref,
1553 pack_path.display()
1554 )
1555}
1556
1557fn registration_component_candidates(component_ref: &str) -> Vec<String> {
1558 let trimmed = component_ref.trim().trim_start_matches("./");
1559 let mut candidates = vec![trimmed.to_string()];
1560 if !trimmed.ends_with(".json") && !trimmed.ends_with(".yaml") && !trimmed.ends_with(".yml") {
1561 candidates.push(format!("{trimmed}.json"));
1562 candidates.push(format!("components/{trimmed}.json"));
1563 candidates.push(format!("assets/{trimmed}.json"));
1564 candidates.push(format!("assets/components/{trimmed}.json"));
1565 }
1566 candidates.sort();
1567 candidates.dedup();
1568 candidates
1569}
1570
1571fn invoke_json_registration_component(
1572 component: &Value,
1573 op: &str,
1574 request: &Value,
1575) -> Option<Value> {
1576 let obj = component.as_object()?;
1577 if let Some(operations) = obj.get("operations").and_then(Value::as_object)
1578 && let Some(operation) = operations.get(op)
1579 {
1580 return operation_result(operation, request);
1581 }
1582 if let Some(ops) = obj.get("ops").and_then(Value::as_array) {
1583 for operation in ops {
1584 if operation.get("op").and_then(Value::as_str) == Some(op)
1585 || operation.get("name").and_then(Value::as_str) == Some(op)
1586 || operation.get("id").and_then(Value::as_str) == Some(op)
1587 {
1588 return operation_result(operation, request);
1589 }
1590 }
1591 }
1592 obj.get(op)
1593 .and_then(|operation| operation_result(operation, request))
1594}
1595
1596fn operation_result(operation: &Value, request: &Value) -> Option<Value> {
1597 if let Some(result) = operation
1598 .get("result")
1599 .or_else(|| operation.get("output"))
1600 .or_else(|| operation.get("outputs"))
1601 {
1602 return Some(result.clone());
1603 }
1604 if operation.get("echo_request").and_then(Value::as_bool) == Some(true) {
1605 return Some(request.clone());
1606 }
1607 if operation.is_object() {
1608 return Some(operation.clone());
1609 }
1610 None
1611}
1612
1613fn merge_registration_output(
1614 action: &mut crate::setup_actions::SetupAction,
1615 answers: &mut Value,
1616 registration: &Value,
1617 output: &Value,
1618) -> anyhow::Result<()> {
1619 let registration_obj = registration
1620 .as_object()
1621 .ok_or_else(|| anyhow!("setup action registration must be an object"))?;
1622 let output_obj = output
1623 .as_object()
1624 .ok_or_else(|| anyhow!("setup action registration output must be an object"))?;
1625 let answers_obj = answers
1626 .as_object_mut()
1627 .ok_or_else(|| anyhow!("provider setup answers must be an object"))?;
1628
1629 for (mapping_key, source_value) in registration_obj {
1630 let Some(generic_key) = mapping_key.strip_suffix("_output") else {
1631 continue;
1632 };
1633 let Some(source_key) = source_value
1634 .as_str()
1635 .map(str::trim)
1636 .filter(|value| !value.is_empty())
1637 else {
1638 continue;
1639 };
1640 let Some(value) = output_obj
1641 .get(source_key)
1642 .or_else(|| output_obj.get(generic_key))
1643 .filter(|value| !is_empty_value(value))
1644 .cloned()
1645 else {
1646 continue;
1647 };
1648 answers_obj.insert(source_key.to_string(), value.clone());
1649 answers_obj.insert(generic_key.to_string(), value.clone());
1650 if generic_key == "client_id" {
1651 if let Some(client_id_field) =
1652 action.extra.get("client_id_field").and_then(Value::as_str)
1653 {
1654 answers_obj.insert(client_id_field.to_string(), value.clone());
1655 }
1656 action.extra.insert("client_id".into(), value);
1657 } else {
1658 action.extra.insert(generic_key.to_string(), value);
1659 }
1660 }
1661 Ok(())
1662}
1663
1664fn registration_error_message(output: &Value) -> Option<String> {
1665 if output.get("ok").and_then(Value::as_bool) == Some(false) {
1666 return output
1667 .get("error")
1668 .and_then(Value::as_str)
1669 .map(ToString::to_string)
1670 .or_else(|| Some(output.to_string()));
1671 }
1672 None
1673}
1674
1675fn is_empty_value(value: &Value) -> bool {
1676 match value {
1677 Value::Null => true,
1678 Value::String(value) => value.trim().is_empty(),
1679 Value::Array(values) => values.is_empty(),
1680 Value::Object(values) => values.is_empty(),
1681 Value::Bool(_) | Value::Number(_) => false,
1682 }
1683}
1684
1685fn hydrate_oauth_install_actions(
1686 actions: &mut [crate::setup_actions::SetupAction],
1687 answers: &Value,
1688) {
1689 for action in actions {
1690 if action.kind != crate::setup_actions::SetupActionKind::OauthInstallButton {
1691 continue;
1692 }
1693 let client_id = client_id_for_action(action, answers);
1694 let Some(authorize_url) = action.authorize_url.as_mut() else {
1695 continue;
1696 };
1697 let Ok(mut parsed) = url::Url::parse(authorize_url) else {
1698 continue;
1699 };
1700 if !parsed.query_pairs().any(|(key, _)| key == "client_id")
1701 && let Some(client_id) = client_id
1702 {
1703 parsed
1704 .query_pairs_mut()
1705 .append_pair("client_id", &client_id);
1706 }
1707 if !parsed.query_pairs().any(|(key, _)| key == "scope")
1708 && let Some(scopes) = action.extra.get("scopes").and_then(Value::as_array)
1709 {
1710 let scope = scopes
1711 .iter()
1712 .filter_map(Value::as_str)
1713 .map(str::trim)
1714 .filter(|value| !value.is_empty())
1715 .collect::<Vec<_>>()
1716 .join(",");
1717 if !scope.is_empty() {
1718 parsed.query_pairs_mut().append_pair("scope", &scope);
1719 }
1720 }
1721 *authorize_url = parsed.to_string();
1722 }
1723}
1724
1725fn client_id_for_action(
1726 action: &crate::setup_actions::SetupAction,
1727 answers: &Value,
1728) -> Option<String> {
1729 let obj = answers.as_object()?;
1730 let mut keys = Vec::new();
1731 if let Some(field) = action.extra.get("client_id_field").and_then(Value::as_str) {
1732 keys.push(field);
1733 }
1734 keys.extend(["client_id", "oauth_client_id"]);
1735 keys.into_iter().find_map(|key| {
1736 obj.get(key)
1737 .and_then(Value::as_str)
1738 .map(str::trim)
1739 .filter(|value| !value.is_empty())
1740 .map(ToString::to_string)
1741 })
1742}
1743
1744fn compute_file_digest(path: &Path) -> anyhow::Result<String> {
1745 let bytes = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
1746 let digest = Sha256::digest(bytes);
1747 let encoded = digest
1748 .iter()
1749 .map(|byte| format!("{byte:02x}"))
1750 .collect::<String>();
1751 Ok(format!("sha256:{encoded}"))
1752}
1753
1754fn resolve_pack_ref(pack_ref: &str) -> anyhow::Result<PathBuf> {
1755 let source = BundleSource::parse(pack_ref)?;
1756 let resolved = source.resolve()?;
1757
1758 if resolved.extension().and_then(|ext| ext.to_str()) != Some("gtpack") {
1759 anyhow::bail!(
1760 "resolved pack ref is not a .gtpack file: {}",
1761 resolved.display()
1762 );
1763 }
1764
1765 Ok(resolved)
1766}
1767
1768pub fn execute_remove_provider_artifacts(
1770 bundle_path: &Path,
1771 providers_remove: &[String],
1772) -> anyhow::Result<usize> {
1773 let mut removed = 0usize;
1774 let discovered = discovery::discover(bundle_path).ok();
1775 for provider_id in providers_remove {
1776 if let Some(discovered) = discovered.as_ref()
1777 && let Some(provider) = discovered
1778 .providers
1779 .iter()
1780 .find(|provider| provider.provider_id == *provider_id)
1781 {
1782 if provider.pack_path.exists() {
1783 std::fs::remove_file(&provider.pack_path).with_context(|| {
1784 format!(
1785 "failed to remove provider pack {}",
1786 provider.pack_path.display()
1787 )
1788 })?;
1789 }
1790 removed += 1;
1791 } else {
1792 let target_dir = get_pack_target_dir(bundle_path, provider_id);
1793 let target_path = target_dir.join(format!("{provider_id}.gtpack"));
1794 if target_path.exists() {
1795 std::fs::remove_file(&target_path).with_context(|| {
1796 format!("failed to remove provider pack {}", target_path.display())
1797 })?;
1798 removed += 1;
1799 }
1800 }
1801
1802 let config_dir = bundle_path.join("state").join("config").join(provider_id);
1803 if config_dir.exists() {
1804 std::fs::remove_dir_all(&config_dir).with_context(|| {
1805 format!(
1806 "failed to remove provider config dir {}",
1807 config_dir.display()
1808 )
1809 })?;
1810 }
1811 }
1812 Ok(removed)
1813}
1814
1815pub fn auto_install_provider_packs(bundle_path: &Path, metadata: &SetupPlanMetadata) {
1824 let bundle_abs =
1825 std::fs::canonicalize(bundle_path).unwrap_or_else(|_| bundle_path.to_path_buf());
1826
1827 let installed_ids: std::collections::HashSet<String> = discovery::discover(bundle_path)
1828 .map(|d| {
1829 d.providers
1830 .into_iter()
1831 .chain(d.app_packs)
1832 .map(|p| p.provider_id)
1833 .collect()
1834 })
1835 .unwrap_or_default();
1836
1837 for provider_id in metadata.setup_answers.keys() {
1838 if installed_ids.contains(provider_id) {
1839 continue;
1840 }
1841 let target_dir = get_pack_target_dir(bundle_path, provider_id);
1842 let target_path = target_dir.join(format!("{provider_id}.gtpack"));
1843 if target_path.exists() {
1844 continue;
1845 }
1846
1847 let domain = domain_from_provider_id(provider_id);
1849
1850 if let Some(source) = find_provider_pack_source(provider_id, domain, &bundle_abs) {
1852 if let Err(err) = std::fs::create_dir_all(&target_dir) {
1853 eprintln!(
1854 " [provider] WARNING: failed to create {}: {err}",
1855 target_dir.display()
1856 );
1857 continue;
1858 }
1859 match std::fs::copy(&source, &target_path) {
1860 Ok(_) => println!(
1861 " [provider] installed {provider_id}.gtpack from {}",
1862 source.display()
1863 ),
1864 Err(err) => eprintln!(
1865 " [provider] WARNING: failed to copy {}: {err}",
1866 source.display()
1867 ),
1868 }
1869 } else {
1870 eprintln!(" [provider] WARNING: {provider_id}.gtpack not found in sibling bundles");
1871 }
1872 }
1873}
1874
1875pub fn domain_from_provider_id(provider_id: &str) -> &str {
1877 const DOMAIN_PREFIXES: &[&str] = &[
1878 "messaging-",
1879 "events-",
1880 "oauth-",
1881 "secrets-",
1882 "mcp-",
1883 "state-",
1884 "telemetry-",
1885 ];
1886 for prefix in DOMAIN_PREFIXES {
1887 if provider_id.starts_with(prefix) {
1888 return prefix.trim_end_matches('-');
1889 }
1890 }
1891 "messaging" }
1893
1894pub fn find_provider_pack_source(
1900 provider_id: &str,
1901 domain: &str,
1902 bundle_abs: &Path,
1903) -> Option<PathBuf> {
1904 let parent = bundle_abs.parent()?;
1905 let filename = format!("{provider_id}.gtpack");
1906
1907 if let Ok(entries) = std::fs::read_dir(parent) {
1909 for entry in entries.flatten() {
1910 let sibling = entry.path();
1911 if sibling == *bundle_abs || !sibling.is_dir() {
1912 continue;
1913 }
1914 let candidate = sibling.join("providers").join(domain).join(&filename);
1915 if candidate.is_file() {
1916 return Some(candidate);
1917 }
1918 }
1919 }
1920
1921 for ancestor in parent.ancestors().take(4) {
1923 let candidate = ancestor
1924 .join("greentic-messaging-providers")
1925 .join("target")
1926 .join("packs")
1927 .join(&filename);
1928 if candidate.is_file() {
1929 return Some(candidate);
1930 }
1931 }
1932
1933 None
1934}
1935
1936pub fn execute_write_gmap_rules(
1938 bundle_path: &Path,
1939 metadata: &SetupPlanMetadata,
1940) -> anyhow::Result<()> {
1941 for tenant_sel in &metadata.tenants {
1942 let gmap_path =
1943 bundle::gmap_path(bundle_path, &tenant_sel.tenant, tenant_sel.team.as_deref());
1944
1945 if let Some(parent) = gmap_path.parent() {
1946 std::fs::create_dir_all(parent)?;
1947 }
1948
1949 let mut content = String::new();
1951 if tenant_sel.allow_paths.is_empty() {
1952 content.push_str("_ = forbidden\n");
1953 } else {
1954 for path in &tenant_sel.allow_paths {
1955 content.push_str(&format!("{} = allowed\n", path));
1956 }
1957 content.push_str("_ = forbidden\n");
1958 }
1959
1960 std::fs::write(&gmap_path, content)
1961 .with_context(|| format!("failed to write gmap: {}", gmap_path.display()))?;
1962 }
1963 Ok(())
1964}
1965
1966pub fn execute_copy_resolved_manifests(
1968 bundle_path: &Path,
1969 metadata: &SetupPlanMetadata,
1970) -> anyhow::Result<Vec<PathBuf>> {
1971 let mut manifests = Vec::new();
1972 let resolved_dir = bundle_path.join("resolved");
1973 std::fs::create_dir_all(&resolved_dir)?;
1974
1975 for tenant_sel in &metadata.tenants {
1976 let filename =
1977 bundle::resolved_manifest_filename(&tenant_sel.tenant, tenant_sel.team.as_deref());
1978 let manifest_path = resolved_dir.join(&filename);
1979
1980 if !manifest_path.exists() {
1982 std::fs::write(&manifest_path, "# Resolved manifest placeholder\n")?;
1983 }
1984 manifests.push(manifest_path);
1985 }
1986
1987 Ok(manifests)
1988}
1989
1990pub fn execute_validate_bundle(bundle_path: &Path) -> anyhow::Result<()> {
1992 bundle::validate_bundle_exists(bundle_path)
1993}
1994
1995pub fn execute_build_flow_index(_bundle_path: &Path, _config: &SetupConfig) -> anyhow::Result<()> {
2005 tracing::debug!("fast2flow indexing skipped (fast2flow-bundle not available)");
2006 Ok(())
2007}
2008
2009#[cfg(test)]
2010mod tests {
2011 use super::*;
2012 use crate::platform_setup::StaticRoutesPolicy;
2013 use std::collections::BTreeSet;
2014
2015 #[test]
2016 fn secret_uri_candidates_cover_env_and_provider_branches() {
2017 let cands =
2020 secret_uri_candidates("secrets://dev/demo/_/messaging-slack/slack_signing_secret");
2021 assert!(
2022 cands
2023 .contains(&"secrets://dev/demo/_/messaging-slack/slack_signing_secret".to_string())
2024 );
2025 assert!(
2026 cands.contains(
2027 &"secrets://local/demo/_/messaging_slack/slack_signing_secret".to_string()
2028 )
2029 );
2030 assert!(
2031 cands.contains(
2032 &"secrets://local/demo/_/messaging-slack/slack_signing_secret".to_string()
2033 )
2034 );
2035 assert!(
2036 cands
2037 .contains(&"secrets://dev/demo/_/messaging_slack/slack_signing_secret".to_string())
2038 );
2039 assert_eq!(
2041 cands[0],
2042 "secrets://dev/demo/_/messaging-slack/slack_signing_secret"
2043 );
2044 }
2045
2046 #[test]
2047 fn secret_uri_candidates_do_not_alias_custom_env() {
2048 let cands = secret_uri_candidates("secrets://prod/acme/team/messaging_slack/bot_token");
2049 assert!(cands.contains(&"secrets://prod/acme/team/messaging_slack/bot_token".to_string()));
2052 assert!(cands.contains(&"secrets://prod/acme/team/messaging-slack/bot_token".to_string()));
2053 assert!(
2054 !cands
2055 .iter()
2056 .any(|c| c.contains("/dev/") || c.contains("/local/"))
2057 );
2058 }
2059
2060 #[test]
2061 fn secret_uri_candidates_passthrough_non_secret_paths() {
2062 assert_eq!(
2063 secret_uri_candidates("SLACK_BOT_TOKEN"),
2064 vec!["SLACK_BOT_TOKEN".to_string()]
2065 );
2066 }
2067
2068 fn empty_metadata(pack_refs: Vec<String>) -> SetupPlanMetadata {
2069 SetupPlanMetadata {
2070 bundle_name: None,
2071 pack_refs,
2072 tenants: Vec::new(),
2073 default_assignments: Vec::new(),
2074 providers: Vec::new(),
2075 update_ops: BTreeSet::new(),
2076 remove_targets: BTreeSet::new(),
2077 packs_remove: Vec::new(),
2078 providers_remove: Vec::new(),
2079 tenants_remove: Vec::new(),
2080 access_changes: Vec::new(),
2081 static_routes: StaticRoutesPolicy::default(),
2082 deployment_targets: Vec::new(),
2083 setup_answers: serde_json::Map::new(),
2084 tunnel: None,
2085 telemetry: None,
2086 }
2087 }
2088
2089 #[test]
2090 fn resolve_packs_errors_when_any_pack_ref_fails() {
2091 let metadata = empty_metadata(vec!["/definitely/missing/example.gtpack".to_string()]);
2092 let err = execute_resolve_packs(Path::new("."), &metadata).unwrap_err();
2093 let message = err.to_string();
2094
2095 assert!(message.contains("failed to resolve 1 pack ref"));
2096 assert!(message.contains("/definitely/missing/example.gtpack"));
2097 }
2098
2099 #[test]
2104 fn auto_install_skips_when_pack_id_matches_under_custom_filename() {
2105 use std::io::Write;
2106 use zip::write::{FileOptions, ZipWriter};
2107
2108 let temp = tempfile::tempdir().expect("tempdir");
2109 let bundle = temp.path().join("bundle");
2110 let messaging_dir = bundle.join("providers").join("messaging");
2111 std::fs::create_dir_all(&messaging_dir).expect("create messaging dir");
2112
2113 let custom_pack = messaging_dir.join("messaging-webchat-gui-3aigent.gtpack");
2114 let file = std::fs::File::create(&custom_pack).expect("create pack file");
2115 let mut writer = ZipWriter::new(file);
2116 let options: FileOptions<'_, ()> =
2117 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
2118 writer
2119 .start_file("pack.manifest.json", options)
2120 .expect("start manifest");
2121 writer
2122 .write_all(
2123 serde_json::json!({
2124 "pack_id": "messaging-webchat-gui",
2125 "display_name": "WebChat GUI",
2126 })
2127 .to_string()
2128 .as_bytes(),
2129 )
2130 .expect("write manifest");
2131 writer.finish().expect("finish zip");
2132
2133 let canonical_pack = messaging_dir.join("messaging-webchat-gui.gtpack");
2134 assert!(!canonical_pack.exists(), "precondition: canonical absent");
2135
2136 let mut metadata = empty_metadata(vec![]);
2137 metadata.setup_answers.insert(
2138 "messaging-webchat-gui".to_string(),
2139 serde_json::Value::Object(serde_json::Map::new()),
2140 );
2141
2142 auto_install_provider_packs(&bundle, &metadata);
2143
2144 assert!(
2145 custom_pack.exists(),
2146 "custom-named pack must be left in place"
2147 );
2148 assert!(
2149 !canonical_pack.exists(),
2150 "must not auto-install canonical-named duplicate when pack_id already present"
2151 );
2152 }
2153
2154 fn secret_keys_for(keys: &[&str]) -> BTreeSet<String> {
2155 keys.iter()
2156 .map(|k| crate::secret_name::canonical_secret_name(k))
2157 .collect()
2158 }
2159
2160 #[test]
2161 fn envelope_redaction_replaces_secret_values_with_canonical_uri_refs() {
2162 let secret_keys = secret_keys_for(&["api_key", "oauth_client_secret"]);
2163
2164 let answers = serde_json::json!({
2165 "model": "gpt-4o-mini",
2166 "api_key": "sk-PLAINTEXT-MUST-NOT-LEAK",
2167 "oauth_client_secret": "PLAINTEXT-OAUTH-SECRET",
2168 "non_secret_url": "https://api.openai.com/v1"
2169 });
2170
2171 let redacted = redact_secret_answer_values_to_uri_refs(
2172 &answers,
2173 &secret_keys,
2174 "dev",
2175 "demo",
2176 Some("default"),
2177 "openai-llm",
2178 );
2179
2180 let map = redacted.as_object().expect("object");
2181 assert_eq!(map["model"].as_str(), Some("gpt-4o-mini"));
2182 assert_eq!(
2183 map["non_secret_url"].as_str(),
2184 Some("https://api.openai.com/v1")
2185 );
2186 assert_eq!(
2189 map["api_key"].as_str(),
2190 Some("secrets://dev/demo/_/openai_llm/api_key"),
2191 "secret value must be replaced with canonical secrets:// URI",
2192 );
2193 assert_eq!(
2194 map["oauth_client_secret"].as_str(),
2195 Some("secrets://dev/demo/_/openai_llm/oauth_client_secret"),
2196 );
2197
2198 let json = serde_json::to_string(&redacted).expect("serialize");
2199 assert!(
2200 !json.contains("PLAINTEXT-MUST-NOT-LEAK"),
2201 "api_key plaintext leaked into envelope JSON: {json}",
2202 );
2203 assert!(
2204 !json.contains("PLAINTEXT-OAUTH-SECRET"),
2205 "oauth_client_secret plaintext leaked into envelope JSON: {json}",
2206 );
2207 }
2208
2209 #[test]
2210 fn setup_answers_redaction_drops_secret_keys_entirely() {
2211 let secret_keys = secret_keys_for(&["api_key"]);
2216 let answers = serde_json::json!({
2217 "model": "gpt-4o-mini",
2218 "api_key": "sk-PLAINTEXT-MUST-NOT-LEAK"
2219 });
2220
2221 let stripped = strip_secret_answer_keys(&answers, &secret_keys);
2222 let map = stripped.as_object().expect("object");
2223 assert_eq!(map["model"].as_str(), Some("gpt-4o-mini"));
2224 assert!(
2225 !map.contains_key("api_key"),
2226 "secret key must be removed entirely from setup-answers",
2227 );
2228 let json = serde_json::to_string(&stripped).expect("serialize");
2229 assert!(
2230 !json.contains("PLAINTEXT-MUST-NOT-LEAK"),
2231 "plaintext leaked into setup-answers: {json}",
2232 );
2233 assert!(
2234 !json.contains("secrets://"),
2235 "setup-answers must not carry URI refs either — readers fetch via SecretsManager",
2236 );
2237 }
2238
2239 #[test]
2240 fn is_secret_answer_key_matches_aliases_via_canonical_suffix() {
2241 let secret_keys = secret_keys_for(&["webex_bot_token"]);
2246 assert!(is_secret_answer_key("bot_token", &secret_keys));
2247 assert!(is_secret_answer_key("BOT_TOKEN", &secret_keys));
2248 assert!(is_secret_answer_key("webex_bot_token", &secret_keys));
2249 assert!(!is_secret_answer_key("model", &secret_keys));
2251 assert!(!is_secret_answer_key("bot_url", &secret_keys));
2252 }
2253
2254 #[test]
2255 fn is_secret_answer_key_does_not_over_match_reverse_direction() {
2256 let secret_keys = secret_keys_for(&["token"]);
2263 assert!(is_secret_answer_key("token", &secret_keys));
2264 assert!(
2265 !is_secret_answer_key("bot_token", &secret_keys),
2266 "answer key longer than the secret key must not match (reverse direction removed)",
2267 );
2268 assert!(!is_secret_answer_key("refresh_token", &secret_keys));
2269 }
2270
2271 #[test]
2272 fn is_secret_answer_key_punctuation_only_key_does_not_match_unrelated_secret() {
2273 let secret_keys = secret_keys_for(&["api_key"]);
2277 assert!(!is_secret_answer_key("", &secret_keys));
2278 assert!(!is_secret_answer_key("---", &secret_keys));
2279 }
2280
2281 #[test]
2282 fn alias_answer_key_redacted_in_setup_answers_and_envelope() {
2283 let secret_keys = secret_keys_for(&["webex_bot_token"]);
2286 let answers = serde_json::json!({"bot_token": "T0K3N-MUST-NOT-LEAK"});
2287
2288 let stripped = strip_secret_answer_keys(&answers, &secret_keys);
2289 assert!(
2290 stripped.as_object().unwrap().is_empty(),
2291 "alias-matched secret key must be dropped from setup-answers",
2292 );
2293
2294 let envelope = redact_secret_answer_values_to_uri_refs(
2295 &answers,
2296 &secret_keys,
2297 "dev",
2298 "demo",
2299 None,
2300 "messaging-webex",
2301 );
2302 assert_eq!(
2303 envelope["bot_token"].as_str(),
2304 Some("secrets://dev/demo/_/messaging_webex/bot_token"),
2305 );
2306 let json = serde_json::to_string(&envelope).unwrap();
2307 assert!(!json.contains("T0K3N-MUST-NOT-LEAK"));
2308 }
2309
2310 #[test]
2311 fn secret_keys_fail_closed_distinguishes_none_from_empty_set() {
2312 let content = serde_json::json!({"model": "gpt-4o"});
2313 let empty = serde_json::json!({});
2314
2315 let r = secret_keys_or_fail_closed(Some(BTreeSet::new()), &content, "p").unwrap();
2319 assert!(r.is_empty(), "Some(empty) proceeds with no redaction");
2320
2321 let set = secret_keys_for(&["api_key"]);
2323 let r = secret_keys_or_fail_closed(Some(set.clone()), &content, "p").unwrap();
2324 assert_eq!(r, set);
2325
2326 assert!(secret_keys_or_fail_closed(None, &content, "p").is_err());
2328
2329 assert!(
2331 secret_keys_or_fail_closed(None, &empty, "p")
2332 .unwrap()
2333 .is_empty()
2334 );
2335 }
2336
2337 #[test]
2338 fn answers_have_content_distinguishes_empty_from_meaningful() {
2339 assert!(!answers_have_content(&serde_json::json!({})));
2340 assert!(!answers_have_content(&serde_json::json!({"a": null})));
2341 assert!(!answers_have_content(&serde_json::json!({"a": ""})));
2342 assert!(answers_have_content(&serde_json::json!({"a": "value"})));
2343 assert!(answers_have_content(&serde_json::json!({"a": 42})));
2344 assert!(answers_have_content(&serde_json::json!({"a": true})));
2345 assert!(answers_have_content(&serde_json::json!({"a": ["x"]})));
2346 }
2347}