1use std::collections::{BTreeMap, BTreeSet};
6use std::fs::File;
7use std::io::Read;
8use std::path::{Path, PathBuf};
9use std::sync::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.values().any(|v| match v {
162 Value::String(s) => !s.is_empty(),
163 Value::Null => false,
164 _ => true,
165 })
166}
167
168fn try_emit_pack_config_input(
172 bundle_path: &Path,
173 pack_path: &Path,
174 env: &str,
175 provider_id: &str,
176 answers: &Value,
177 trace_context: &str,
178) {
179 let Some(form_spec) = crate::setup_to_formspec::pack_to_form_spec(pack_path, provider_id)
180 else {
181 return;
182 };
183 let bundle_id = crate::qa::persist::infer_bundle_id(bundle_path);
184 if let Err(err) = crate::qa::persist::emit_pack_config_input(
185 bundle_path,
186 env,
187 &bundle_id,
188 provider_id,
189 answers,
190 &form_spec,
191 ) {
192 tracing::warn!(
193 provider_id = %provider_id,
194 env = %env,
195 error = %err,
196 "pack-config-input emission failed ({trace_context}); runtime falls back to DevStore via C4.2 compat shim",
197 );
198 }
199}
200
201pub fn execute_create_bundle(
203 bundle_path: &Path,
204 metadata: &SetupPlanMetadata,
205) -> anyhow::Result<()> {
206 bundle::create_demo_bundle_structure(bundle_path, metadata.bundle_name.as_deref())
207 .context("failed to create bundle structure")
208}
209
210pub fn execute_resolve_packs(
212 _bundle_path: &Path,
213 metadata: &SetupPlanMetadata,
214) -> anyhow::Result<Vec<ResolvedPackInfo>> {
215 let mut resolved = Vec::new();
216 let mut failures = Vec::new();
217
218 for pack_ref in &metadata.pack_refs {
219 match resolve_pack_ref(pack_ref) {
220 Ok(resolved_path) => {
221 let canonical = resolved_path
222 .canonicalize()
223 .unwrap_or(resolved_path.clone());
224 let pack_meta = discovery::read_pack_meta(&canonical)?;
225 resolved.push(ResolvedPackInfo {
226 source_ref: pack_ref.clone(),
227 mapped_ref: canonical.display().to_string(),
228 resolved_digest: compute_file_digest(&canonical)
229 .unwrap_or_else(|_| format!("sha256:{}", compute_simple_hash(pack_ref))),
230 pack_id: pack_meta.map(|meta| meta.pack_id).unwrap_or_else(|| {
231 canonical
232 .file_stem()
233 .and_then(|s| s.to_str())
234 .unwrap_or("unknown")
235 .to_string()
236 }),
237 entry_flows: Vec::new(),
238 cached_path: canonical.clone(),
239 output_path: canonical,
240 });
241 }
242 Err(err) => {
243 failures.push(format!("{pack_ref}: {err}"));
244 }
245 }
246 }
247
248 if !failures.is_empty() {
249 anyhow::bail!(
250 "failed to resolve {} pack ref(s):\n{}",
251 failures.len(),
252 failures.join("\n")
253 );
254 }
255
256 Ok(resolved)
257}
258
259pub fn execute_add_packs_to_bundle(
261 bundle_path: &Path,
262 resolved_packs: &[ResolvedPackInfo],
263) -> anyhow::Result<()> {
264 let mut metadata_entries = Vec::new();
265
266 for pack in resolved_packs {
267 let target_dir = get_pack_target_dir(bundle_path, &pack.pack_id);
269 std::fs::create_dir_all(&target_dir)?;
270
271 let target_path = target_dir.join(format!("{}.gtpack", pack.pack_id));
272 if pack.cached_path.exists() && !target_path.exists() {
273 std::fs::copy(&pack.cached_path, &target_path).with_context(|| {
274 format!(
275 "failed to copy pack {} to {}",
276 pack.cached_path.display(),
277 target_path.display()
278 )
279 })?;
280 }
281
282 let reference = target_path
283 .strip_prefix(bundle_path)
284 .unwrap_or(&target_path)
285 .to_string_lossy()
286 .replace('\\', "/");
287 let kind = if reference.starts_with("providers/") {
288 bundle::BundleReferenceKind::ExtensionProvider
289 } else {
290 bundle::BundleReferenceKind::AppPack
291 };
292 metadata_entries.push(bundle::BundleReference {
293 kind,
294 reference,
295 digest: Some(pack.resolved_digest.clone()),
296 });
297 }
298
299 bundle::register_bundle_references(bundle_path, &metadata_entries, None)?;
300 Ok(())
301}
302
303pub fn get_pack_target_dir(bundle_path: &Path, pack_id: &str) -> PathBuf {
308 const DOMAIN_PREFIXES: &[&str] = &[
309 "messaging-",
310 "events-",
311 "oauth-",
312 "secrets-",
313 "mcp-",
314 "state-",
315 ];
316
317 for prefix in DOMAIN_PREFIXES {
318 if pack_id.starts_with(prefix) {
319 let domain = prefix.trim_end_matches('-');
320 return bundle_path.join("providers").join(domain);
321 }
322 }
323
324 bundle_path.join("packs")
326}
327
328pub fn execute_apply_pack_setup(
330 bundle_path: &Path,
331 metadata: &SetupPlanMetadata,
332 config: &SetupConfig,
333) -> anyhow::Result<ApplyPackSetupReport> {
334 let mut count = 0;
335 let mut pending_setup_actions = Vec::new();
336
337 if !metadata.providers_remove.is_empty() {
338 count += execute_remove_provider_artifacts(bundle_path, &metadata.providers_remove)?;
339 }
340
341 auto_install_provider_packs(bundle_path, metadata);
344
345 let discovered = if bundle_path.exists() {
347 discovery::discover(bundle_path).ok()
348 } else {
349 None
350 };
351
352 let provider_ids = setup_provider_ids(metadata, discovered.as_ref());
353
354 for provider_id in provider_ids {
356 let empty_answers = Value::Object(serde_json::Map::new());
357 let answers = metadata
358 .setup_answers
359 .get(&provider_id)
360 .unwrap_or(&empty_answers);
361 let mut effective_answers = answers.clone();
362 let pack_path = discovered.as_ref().and_then(|d| {
363 d.find_setup_target(&provider_id)
364 .map(|p| p.pack_path.as_path())
365 });
366 if !crate::provider_state::provider_enabled(&effective_answers) {
367 let persisted_answers = crate::setup_actions::strip_setup_actions(&effective_answers);
368 let config_dir = bundle_path.join("state").join("config").join(&provider_id);
369 std::fs::create_dir_all(&config_dir)?;
370 let config_path = config_dir.join("setup-answers.json");
371 let content = serde_json::to_string_pretty(&persisted_answers)
372 .context("failed to serialize setup answers")?;
373 std::fs::write(&config_path, content).with_context(|| {
374 format!(
375 "failed to write setup answers to: {}",
376 config_path.display()
377 )
378 })?;
379 let env = crate::resolve_env(Some(&config.env));
380 let rt = tokio::runtime::Runtime::new()
381 .context("failed to create tokio runtime for secrets persistence")?;
382 rt.block_on(crate::qa::persist::persist_all_config_as_secrets(
383 bundle_path,
384 &env,
385 &config.tenant,
386 config.team.as_deref(),
387 &provider_id,
388 &persisted_answers,
389 pack_path,
390 ))?;
391 if let Some(pack_path) = pack_path {
392 crate::config_envelope::write_provider_config_envelope(
393 &bundle_path.join(".providers"),
394 &provider_id,
395 "setup-input",
396 &persisted_answers,
397 pack_path,
398 false,
399 )
400 .with_context(|| {
401 format!(
402 "failed to write provider config envelope for {} using {}",
403 provider_id,
404 pack_path.display()
405 )
406 })?;
407 try_emit_pack_config_input(
408 bundle_path,
409 pack_path,
410 &env,
411 &provider_id,
412 &persisted_answers,
413 "setup-input path",
414 );
415 }
416 count += 1;
417 continue;
418 }
419 let mut setup_actions = crate::setup_actions::extract_setup_actions(
420 &provider_id,
421 &config.tenant,
422 config.team.as_deref(),
423 answers,
424 )?;
425 setup_actions.extend(extract_pack_setup_actions(
426 discovered.as_ref(),
427 &provider_id,
428 &config.tenant,
429 config.team.as_deref(),
430 )?);
431 defer_registration_actions_missing_inputs(&mut setup_actions, &effective_answers);
432 run_setup_action_registrations(SetupActionRegistrationContext {
433 bundle_path,
434 discovered: discovered.as_ref(),
435 provider_id: &provider_id,
436 config,
437 bundle_name: metadata.bundle_name.as_deref(),
438 public_base_url: metadata.static_routes.public_base_url.as_deref(),
439 answers: &mut effective_answers,
440 actions: &mut setup_actions,
441 })?;
442 hydrate_oauth_install_actions(&mut setup_actions, &effective_answers);
443 if !setup_actions.is_empty() {
444 crate::setup_actions::sign_pending_oauth_actions(bundle_path, &mut setup_actions)?;
445 crate::setup_actions::persist_setup_actions(bundle_path, &setup_actions)?;
446 pending_setup_actions.extend(setup_actions.clone());
447 }
448 let persisted_answers = crate::setup_actions::strip_setup_actions(&effective_answers);
449
450 let config_dir = bundle_path.join("state").join("config").join(&provider_id);
452 std::fs::create_dir_all(&config_dir)?;
453
454 let pack_path = discovered.as_ref().and_then(|d| {
458 d.find_setup_target(&provider_id)
459 .map(|p| p.pack_path.as_path())
460 });
461 let env = crate::resolve_env(Some(&config.env));
462
463 let resolved_secret_keys: Option<BTreeSet<String>> =
476 pack_path.and_then(|pp| resolve_secret_answer_keys(pp, &provider_id));
477 let secret_keys = secret_keys_or_fail_closed(resolved_secret_keys, answers, &provider_id)?;
478 let answers_for_disk = strip_secret_answer_keys(answers, &secret_keys);
479 let envelope_answers = redact_secret_answer_values_to_uri_refs(
480 answers,
481 &secret_keys,
482 &env,
483 &config.tenant,
484 config.team.as_deref(),
485 &provider_id,
486 );
487
488 let config_path = config_dir.join("setup-answers.json");
489 let content = serde_json::to_string_pretty(&answers_for_disk)
490 .context("failed to serialize setup answers")?;
491 std::fs::write(&config_path, content).with_context(|| {
492 format!(
493 "failed to write setup answers to: {}",
494 config_path.display()
495 )
496 })?;
497
498 if config.verbose {
499 let team_display = config.team.as_deref().unwrap_or("(none)");
500 println!(
501 " [secrets] scope: env={env}, tenant={}, team={team_display}, provider={provider_id}",
502 config.tenant
503 );
504 let example_uri = crate::canonical_secret_uri(
505 &env,
506 &config.tenant,
507 config.team.as_deref(),
508 &provider_id,
509 "_example_key",
510 );
511 println!(" [secrets] URI pattern: {example_uri}");
512 if let Some(config_map) = persisted_answers.as_object() {
513 let keys: Vec<&String> = config_map.keys().collect();
514 println!(" [secrets] answer keys: {keys:?}");
515 }
516 }
517 let rt = tokio::runtime::Runtime::new()
518 .context("failed to create tokio runtime for secrets persistence")?;
519 let persisted = rt.block_on(crate::qa::persist::persist_all_config_as_secrets(
520 bundle_path,
521 &env,
522 &config.tenant,
523 config.team.as_deref(),
524 &provider_id,
525 &persisted_answers,
526 pack_path,
527 ))?;
528 if config.verbose {
529 if persisted.is_empty() {
530 println!(
531 " [secrets] WARNING: 0 key(s) persisted for {provider_id} (all values empty?)"
532 );
533 } else {
534 println!(
535 " [secrets] persisted {} key(s) for {provider_id}: {:?}",
536 persisted.len(),
537 persisted
538 );
539 }
540 }
541
542 if let Some(pack_path) = pack_path {
547 crate::config_envelope::write_provider_config_envelope(
548 &bundle_path.join(".providers"),
549 &provider_id,
550 "setup-input",
551 &envelope_answers,
552 pack_path,
553 false,
554 )
555 .with_context(|| {
556 format!(
557 "failed to write provider config envelope for {} using {}",
558 provider_id,
559 pack_path.display()
560 )
561 })?;
562 } else if config.verbose {
563 println!(
564 " [config] WARNING: no resolved pack path for {provider_id}; skipped config envelope write"
565 );
566 }
567
568 if let Some(pack_path) = pack_path {
571 try_emit_pack_config_input(
572 bundle_path,
573 pack_path,
574 &env,
575 &provider_id,
576 &persisted_answers,
577 "apply-answers path",
578 );
579 }
580
581 match crate::tenant_config::sync_oauth_to_tenant_config(
583 bundle_path,
584 &config.tenant,
585 &provider_id,
586 &persisted_answers,
587 ) {
588 Ok(true) => {
589 if config.verbose {
590 println!(" [oauth] updated tenant config for {provider_id}");
591 }
592 }
593 Ok(false) => {}
594 Err(e) => {
595 println!(" [oauth] WARNING: failed to update tenant config: {e}");
596 }
597 }
598
599 match crate::tenant_config::sync_skin_to_tenant_config(
601 bundle_path,
602 &config.tenant,
603 &provider_id,
604 &persisted_answers,
605 ) {
606 Ok(true) => {
607 if config.verbose {
608 println!(" [skin] updated tenant config for {provider_id}");
609 }
610 }
611 Ok(false) => {}
612 Err(e) => {
613 println!(" [skin] WARNING: failed to update tenant config: {e}");
614 }
615 }
616
617 if provider_id.contains("webchat-gui") && config.verbose {
619 let preview = answers
620 .as_object()
621 .and_then(|m| m.get("nav_links"))
622 .map(|v| serde_json::to_string(v).unwrap_or_else(|_| "<unserializable>".into()))
623 .unwrap_or_else(|| "<absent>".into());
624 println!(" [nav_links] received answer for {provider_id}: {preview}");
625 }
626 match crate::tenant_config::sync_nav_links_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!(" [nav_links] updated tenant config for {provider_id}");
635 }
636 }
637 Ok(false) => {}
638 Err(e) => {
639 println!(" [nav_links] WARNING: failed to update tenant config: {e}");
640 }
641 }
642
643 if let Some(result) = crate::webhook::register_webhook(
645 &provider_id,
646 &persisted_answers,
647 &config.tenant,
648 config.team.as_deref(),
649 ) {
650 let ok = result.get("ok").and_then(Value::as_bool).unwrap_or(false);
651 if ok {
652 println!(" [webhook] registered for {provider_id}");
653 } else {
654 let err = result
655 .get("error")
656 .and_then(Value::as_str)
657 .unwrap_or("unknown");
658 println!(" [webhook] WARNING: registration failed for {provider_id}: {err}");
659 }
660 }
661
662 count += 1;
663 }
664
665 crate::platform_setup::persist_static_routes_artifact(bundle_path, &metadata.static_routes)?;
666 let _ = crate::deployment_targets::persist_explicit_deployment_targets(
667 bundle_path,
668 &metadata.deployment_targets,
669 );
670
671 let provider_configs: Vec<(String, Value)> = metadata
673 .setup_answers
674 .iter()
675 .filter(|(_, val)| crate::provider_state::provider_enabled(val))
676 .map(|(id, val)| (id.clone(), val.clone()))
677 .collect();
678 let team = config.team.as_deref().unwrap_or("default");
679 crate::webhook::print_post_setup_instructions(&provider_configs, &config.tenant, team);
680
681 Ok(ApplyPackSetupReport {
682 provider_updates: count,
683 pending_setup_actions,
684 })
685}
686
687fn setup_provider_ids(
688 metadata: &SetupPlanMetadata,
689 discovered: Option<&crate::discovery::DiscoveryResult>,
690) -> BTreeSet<String> {
691 let mut provider_ids: BTreeSet<String> = metadata.setup_answers.keys().cloned().collect();
692 if let Some(discovered) = discovered {
693 for provider in discovered.setup_targets() {
694 if let Ok(Some(spec)) = crate::setup_input::load_setup_spec(&provider.pack_path)
695 && !spec.setup_actions.is_empty()
696 {
697 provider_ids.insert(provider.provider_id.clone());
698 }
699 }
700 }
701 provider_ids
702}
703
704fn extract_pack_setup_actions(
705 discovered: Option<&crate::discovery::DiscoveryResult>,
706 provider_id: &str,
707 tenant: &str,
708 team: Option<&str>,
709) -> anyhow::Result<Vec<crate::setup_actions::SetupAction>> {
710 let Some(provider) = discovered.and_then(|d| d.find_setup_target(provider_id)) else {
711 return Ok(Vec::new());
712 };
713 let Some(spec) = crate::setup_input::load_setup_spec(&provider.pack_path)? else {
714 return Ok(Vec::new());
715 };
716 if spec.setup_actions.is_empty() {
717 return Ok(Vec::new());
718 }
719 let setup_actions = spec
720 .setup_actions
721 .into_iter()
722 .map(|mut action| {
723 if let Some(obj) = action.as_object_mut() {
724 obj.remove("provider_id");
725 obj.remove("tenant");
726 obj.remove("team");
727 }
728 action
729 })
730 .collect::<Vec<_>>();
731 let value = serde_json::json!({ "setup_actions": setup_actions });
732 crate::setup_actions::extract_setup_actions(provider_id, tenant, team, &value)
733}
734
735fn defer_registration_actions_missing_inputs(
736 actions: &mut Vec<crate::setup_actions::SetupAction>,
737 answers: &Value,
738) {
739 actions.retain(|action| {
740 !(action.kind == crate::setup_actions::SetupActionKind::OauthInstallButton
741 && action.extra.get("registration").is_some()
742 && client_id_for_action(action, answers).is_none()
743 && !registration_has_any_declared_input(action.extra.get("registration"), answers))
744 });
745}
746
747fn registration_has_any_declared_input(registration: Option<&Value>, answers: &Value) -> bool {
748 let Some(registration_obj) = registration.and_then(Value::as_object) else {
749 return false;
750 };
751 let Some(answers_obj) = answers.as_object() else {
752 return false;
753 };
754 registration_obj.iter().any(|(key, field_value)| {
755 key.ends_with("_field")
756 && field_value
757 .as_str()
758 .map(str::trim)
759 .filter(|field_name| !field_name.is_empty())
760 .and_then(|field_name| answers_obj.get(field_name))
761 .is_some_and(|value| !is_empty_value(value))
762 })
763}
764
765struct SetupActionRegistrationContext<'a> {
766 bundle_path: &'a Path,
767 discovered: Option<&'a crate::discovery::DiscoveryResult>,
768 provider_id: &'a str,
769 config: &'a SetupConfig,
770 bundle_name: Option<&'a str>,
771 public_base_url: Option<&'a str>,
772 answers: &'a mut Value,
773 actions: &'a mut [crate::setup_actions::SetupAction],
774}
775
776fn run_setup_action_registrations(ctx: SetupActionRegistrationContext<'_>) -> anyhow::Result<()> {
777 let SetupActionRegistrationContext {
778 bundle_path,
779 discovered,
780 provider_id,
781 config,
782 bundle_name,
783 public_base_url,
784 answers,
785 actions,
786 } = ctx;
787
788 let Some(provider) = discovered.and_then(|d| d.find_setup_target(provider_id)) else {
789 if actions
790 .iter()
791 .any(|action| needs_setup_action_registration(action, answers))
792 {
793 anyhow::bail!("provider pack not found for setup action registration: {provider_id}");
794 }
795 return Ok(());
796 };
797
798 for action in actions {
799 if !needs_setup_action_registration(action, answers) {
800 continue;
801 }
802 let registration = action
803 .extra
804 .get("registration")
805 .cloned()
806 .ok_or_else(|| anyhow!("setup action registration metadata missing"))?;
807 let request = build_registration_request(
808 provider_id,
809 config,
810 bundle_name,
811 public_base_url,
812 answers,
813 action,
814 ®istration,
815 )?;
816 let output = invoke_registration_operation(
817 bundle_path,
818 &provider.pack_path,
819 ®istration,
820 &request,
821 config,
822 )
823 .with_context(|| {
824 format!(
825 "failed to run setup action registration {} for {}",
826 action.id, provider_id
827 )
828 })?;
829 if let Some(error) = registration_error_message(&output) {
830 anyhow::bail!(
831 "setup action registration {} returned an error: {}",
832 action.id,
833 error
834 );
835 }
836 merge_registration_output(action, answers, ®istration, &output)?;
837 if client_id_for_action(action, answers).is_none()
838 && !authorize_url_has_query_key(action.authorize_url.as_deref(), "client_id")
839 {
840 anyhow::bail!(
841 "setup action registration {} did not produce a client_id",
842 action.id
843 );
844 }
845 }
846 Ok(())
847}
848
849fn needs_setup_action_registration(
850 action: &crate::setup_actions::SetupAction,
851 answers: &Value,
852) -> bool {
853 action.kind == crate::setup_actions::SetupActionKind::OauthInstallButton
854 && action.extra.get("registration").is_some()
855 && client_id_for_action(action, answers).is_none()
856 && !authorize_url_has_query_key(action.authorize_url.as_deref(), "client_id")
857}
858
859fn authorize_url_has_query_key(url: Option<&str>, key: &str) -> bool {
860 url.and_then(|value| url::Url::parse(value).ok())
861 .is_some_and(|parsed| parsed.query_pairs().any(|(candidate, _)| candidate == key))
862}
863
864fn build_registration_request(
865 provider_id: &str,
866 config: &SetupConfig,
867 bundle_name: Option<&str>,
868 public_base_url: Option<&str>,
869 answers: &Value,
870 action: &crate::setup_actions::SetupAction,
871 registration: &Value,
872) -> anyhow::Result<Value> {
873 let registration_obj = registration
874 .as_object()
875 .ok_or_else(|| anyhow!("setup action registration must be an object"))?;
876 let answers_obj = answers
877 .as_object()
878 .ok_or_else(|| anyhow!("provider setup answers must be an object"))?;
879 let effective_public_base_url = public_base_url.or_else(|| {
880 answers_obj
881 .get("public_base_url")
882 .and_then(Value::as_str)
883 .map(str::trim)
884 .filter(|value| !value.is_empty())
885 });
886 let effective_team = config.team.as_deref().unwrap_or("default");
887 let mut input = JsonMap::new();
888 input.insert("answers".into(), answers.clone());
889 input.insert("provider_id".into(), Value::String(provider_id.to_string()));
890 input.insert("tenant".into(), Value::String(config.tenant.clone()));
891 input.insert("team".into(), Value::String(effective_team.to_string()));
892 if let Some(public_base_url) = effective_public_base_url {
893 input.insert(
894 "public_base_url".into(),
895 Value::String(public_base_url.to_string()),
896 );
897 }
898 input.insert("action_id".into(), Value::String(action.id.clone()));
899
900 for (key, field_value) in registration_obj {
901 let Some(input_name) = key.strip_suffix("_field") else {
902 continue;
903 };
904 let Some(field_name) = field_value
905 .as_str()
906 .map(str::trim)
907 .filter(|v| !v.is_empty())
908 else {
909 continue;
910 };
911 if let Some(value) = answers_obj
912 .get(field_name)
913 .filter(|value| !is_empty_value(value))
914 {
915 input.insert(field_name.to_string(), value.clone());
916 input.insert(input_name.to_string(), value.clone());
917 }
918 }
919
920 if input.get("app_name").is_none()
921 && let Some(app_name) = registration_app_name(action, bundle_name)
922 {
923 input.insert("app_name".into(), Value::String(app_name.clone()));
924 if let Some(field_name) = registration_obj
925 .get("app_name_field")
926 .and_then(Value::as_str)
927 .map(str::trim)
928 .filter(|value| !value.is_empty())
929 {
930 input.insert(field_name.to_string(), Value::String(app_name));
931 }
932 }
933
934 let mut context = JsonMap::new();
935 context.insert("provider_id".into(), Value::String(provider_id.to_string()));
936 context.insert("tenant".into(), Value::String(config.tenant.clone()));
937 context.insert("team".into(), Value::String(effective_team.to_string()));
938 if let Some(public_base_url) = effective_public_base_url {
939 context.insert(
940 "public_base_url".into(),
941 Value::String(public_base_url.to_string()),
942 );
943 }
944 if let Some(app_name) = input.get("app_name") {
945 context.insert("app_name".into(), app_name.clone());
946 }
947 input.insert("context".into(), Value::Object(context));
948 Ok(Value::Object(input))
949}
950
951fn registration_app_name(
952 action: &crate::setup_actions::SetupAction,
953 bundle_name: Option<&str>,
954) -> Option<String> {
955 let bundle_name = bundle_name
956 .map(str::trim)
957 .filter(|value| !value.is_empty())
958 .unwrap_or("Greentic");
959 if let Some(template) = action
960 .extra
961 .get("app_name_template")
962 .and_then(Value::as_str)
963 .map(str::trim)
964 .filter(|value| !value.is_empty())
965 {
966 let rendered = template
967 .replace("{{ bundle_name }}", bundle_name)
968 .replace("{{bundle_name}}", bundle_name)
969 .trim()
970 .to_string();
971 if !rendered.is_empty() {
972 return Some(rendered);
973 }
974 }
975 action
976 .extra
977 .get("default_app_name")
978 .and_then(Value::as_str)
979 .map(str::trim)
980 .filter(|value| !value.is_empty())
981 .map(ToString::to_string)
982}
983
984fn invoke_registration_operation(
985 bundle_path: &Path,
986 pack_path: &Path,
987 registration: &Value,
988 request: &Value,
989 config: &SetupConfig,
990) -> anyhow::Result<Value> {
991 let registration_obj = registration
992 .as_object()
993 .ok_or_else(|| anyhow!("setup action registration must be an object"))?;
994 let component_ref = registration_obj
995 .get("component_ref")
996 .and_then(Value::as_str)
997 .map(str::trim)
998 .filter(|value| !value.is_empty())
999 .ok_or_else(|| anyhow!("setup action registration missing component_ref"))?;
1000 let op = registration_obj
1001 .get("op")
1002 .and_then(Value::as_str)
1003 .map(str::trim)
1004 .filter(|value| !value.is_empty())
1005 .ok_or_else(|| anyhow!("setup action registration missing op"))?;
1006
1007 if let Some(result) = registration_obj
1008 .get("result")
1009 .or_else(|| registration_obj.get("mock_result"))
1010 .or_else(|| registration_obj.get("outputs"))
1011 {
1012 return Ok(result.clone());
1013 }
1014
1015 if let Ok(component) = read_registration_component(pack_path, component_ref)
1016 && let Some(output) = invoke_json_registration_component(&component, op, request)
1017 {
1018 return Ok(output);
1019 }
1020
1021 invoke_wasm_registration_component(bundle_path, pack_path, component_ref, op, request, config)
1022}
1023
1024pub fn invoke_setup_component_operation(
1025 bundle_path: &Path,
1026 pack_path: &Path,
1027 component_ref: &str,
1028 op: &str,
1029 request: &Value,
1030 config: &SetupConfig,
1031) -> anyhow::Result<Value> {
1032 let registration = serde_json::json!({
1033 "component_ref": component_ref,
1034 "op": op,
1035 });
1036 invoke_registration_operation(bundle_path, pack_path, ®istration, request, config)
1037}
1038
1039#[derive(Debug, Default)]
1040struct SetupRegistrationSecrets {
1041 values: Mutex<BTreeMap<String, Vec<u8>>>,
1042}
1043
1044#[async_trait::async_trait]
1045impl greentic_secrets_lib::SecretsManager for SetupRegistrationSecrets {
1046 async fn read(&self, path: &str) -> greentic_secrets_lib::Result<Vec<u8>> {
1047 let values = self.values.lock().map_err(|_| {
1048 greentic_secrets_lib::SecretError::Backend(
1049 "setup registration secrets lock poisoned".into(),
1050 )
1051 })?;
1052 values
1053 .get(path)
1054 .cloned()
1055 .ok_or_else(|| greentic_secrets_lib::SecretError::NotFound(path.to_string()))
1056 }
1057
1058 async fn write(&self, path: &str, bytes: &[u8]) -> greentic_secrets_lib::Result<()> {
1059 let mut values = self.values.lock().map_err(|_| {
1060 greentic_secrets_lib::SecretError::Backend(
1061 "setup registration secrets lock poisoned".into(),
1062 )
1063 })?;
1064 values.insert(path.to_string(), bytes.to_vec());
1065 Ok(())
1066 }
1067
1068 async fn delete(&self, path: &str) -> greentic_secrets_lib::Result<()> {
1069 let mut values = self.values.lock().map_err(|_| {
1070 greentic_secrets_lib::SecretError::Backend(
1071 "setup registration secrets lock poisoned".into(),
1072 )
1073 })?;
1074 values.remove(path);
1075 Ok(())
1076 }
1077}
1078
1079fn invoke_wasm_registration_component(
1080 bundle_path: &Path,
1081 pack_path: &Path,
1082 component_ref: &str,
1083 op: &str,
1084 request: &Value,
1085 config: &SetupConfig,
1086) -> anyhow::Result<Value> {
1087 use greentic_runner_host::component_api::node::{
1088 ExecCtx as ComponentExecCtx, TenantCtx as ComponentTenantCtx,
1089 };
1090 use greentic_runner_host::config::{OperatorPolicy, SecretsPolicy};
1091 use greentic_runner_host::pack::{ComponentResolution, PackRuntime};
1092 use greentic_runner_host::provider::ProviderBinding;
1093 use greentic_runner_host::storage::{new_session_store, new_state_store};
1094 use greentic_runner_host::{HostConfig, RunnerWasiPolicy};
1095 use std::sync::Arc;
1096
1097 let bindings_path = bundle_path
1098 .join("state")
1099 .join("config")
1100 .join("setup-registration-bindings.yaml");
1101 if let Some(parent) = bindings_path.parent() {
1102 std::fs::create_dir_all(parent)?;
1103 }
1104 std::fs::write(
1105 &bindings_path,
1106 format!(
1107 r#"tenant: {}
1108flow_type_bindings:
1109 messaging:
1110 adapter: setup-registration
1111 config: {{}}
1112 secrets: []
1113rate_limits: {{}}
1114retry: {{}}
1115timers: []
1116"#,
1117 config.tenant
1118 ),
1119 )
1120 .with_context(|| format!("write {}", bindings_path.display()))?;
1121
1122 let mut host_config = HostConfig::load_from_path(&bindings_path)
1123 .with_context(|| format!("load {}", bindings_path.display()))?;
1124 host_config.secrets_policy = SecretsPolicy::allow_all();
1125 host_config.operator_policy = OperatorPolicy::allow_all();
1126 let host_config = Arc::new(host_config);
1127
1128 let session_store = new_session_store();
1129 let state_store = new_state_store();
1130 let secrets: greentic_runner_host::secrets::DynSecretsManager =
1131 Arc::new(SetupRegistrationSecrets::default());
1132 let pack = greentic_runner_host::runtime::block_on(PackRuntime::load(
1133 pack_path,
1134 Arc::clone(&host_config),
1135 None,
1136 Some(pack_path),
1137 Some(Arc::clone(&session_store)),
1138 Some(Arc::clone(&state_store)),
1139 Arc::new(RunnerWasiPolicy::default()),
1140 secrets,
1141 None,
1142 false,
1143 ComponentResolution::default(),
1144 ))
1145 .with_context(|| format!("load registration pack {}", pack_path.display()))?;
1146
1147 let exec_ctx = ComponentExecCtx {
1148 tenant: ComponentTenantCtx {
1149 tenant: config.tenant.clone(),
1150 team: config.team.clone(),
1151 user: None,
1152 trace_id: None,
1153 i18n_id: None,
1154 correlation_id: Some(format!("setup-action-registration:{component_ref}:{op}")),
1155 deadline_unix_ms: None,
1156 attempt: 1,
1157 idempotency_key: Some(format!("setup-action-registration:{component_ref}:{op}")),
1158 },
1159 i18n_id: None,
1160 flow_id: format!("setup-action-registration/{op}"),
1161 node_id: Some(component_ref.to_string()),
1162 };
1163 let input_json = serde_json::to_vec(request)?;
1164 let binding = ProviderBinding {
1165 provider_id: Some(component_ref.to_string()),
1166 provider_type: component_ref.to_string(),
1167 component_ref: component_ref.to_string(),
1168 export: "schema-core-api".to_string(),
1169 world: "greentic:provider/schema-core@1.0.0".to_string(),
1170 config_json: None,
1171 pack_ref: None,
1172 };
1173 match greentic_runner_host::runtime::block_on(pack.invoke_provider(
1174 &binding,
1175 exec_ctx.clone(),
1176 op,
1177 input_json,
1178 )) {
1179 Ok(output) => Ok(output),
1180 Err(provider_err) => {
1181 let input_json = serde_json::to_string(request)?;
1182 greentic_runner_host::runtime::block_on(pack.invoke_component(
1183 component_ref,
1184 exec_ctx,
1185 op,
1186 None,
1187 input_json,
1188 ))
1189 .with_context(|| {
1190 format!(
1191 "invoke registration component '{component_ref}' op '{op}' (provider path failed: {provider_err})"
1192 )
1193 })
1194 }
1195 }
1196}
1197
1198fn read_registration_component(pack_path: &Path, component_ref: &str) -> anyhow::Result<Value> {
1199 let file = File::open(pack_path).with_context(|| format!("open {}", pack_path.display()))?;
1200 let mut archive = match ZipArchive::new(file) {
1201 Ok(archive) => archive,
1202 Err(ZipError::InvalidArchive(_)) | Err(ZipError::UnsupportedArchive(_)) => {
1203 anyhow::bail!("{} is not a zip pack", pack_path.display())
1204 }
1205 Err(err) => return Err(err.into()),
1206 };
1207 let candidates = registration_component_candidates(component_ref);
1208 for candidate in candidates {
1209 match archive.by_name(&candidate) {
1210 Ok(mut entry) => {
1211 let mut raw = String::new();
1212 entry
1213 .read_to_string(&mut raw)
1214 .with_context(|| format!("read registration component {candidate}"))?;
1215 return serde_json::from_str(&raw)
1216 .or_else(|_| serde_yaml_bw::from_str(&raw))
1217 .with_context(|| format!("parse registration component {candidate}"));
1218 }
1219 Err(ZipError::FileNotFound) => continue,
1220 Err(err) => return Err(err.into()),
1221 }
1222 }
1223 anyhow::bail!(
1224 "registration component_ref '{}' not found in {}",
1225 component_ref,
1226 pack_path.display()
1227 )
1228}
1229
1230fn registration_component_candidates(component_ref: &str) -> Vec<String> {
1231 let trimmed = component_ref.trim().trim_start_matches("./");
1232 let mut candidates = vec![trimmed.to_string()];
1233 if !trimmed.ends_with(".json") && !trimmed.ends_with(".yaml") && !trimmed.ends_with(".yml") {
1234 candidates.push(format!("{trimmed}.json"));
1235 candidates.push(format!("components/{trimmed}.json"));
1236 candidates.push(format!("assets/{trimmed}.json"));
1237 candidates.push(format!("assets/components/{trimmed}.json"));
1238 }
1239 candidates.sort();
1240 candidates.dedup();
1241 candidates
1242}
1243
1244fn invoke_json_registration_component(
1245 component: &Value,
1246 op: &str,
1247 request: &Value,
1248) -> Option<Value> {
1249 let obj = component.as_object()?;
1250 if let Some(operations) = obj.get("operations").and_then(Value::as_object)
1251 && let Some(operation) = operations.get(op)
1252 {
1253 return operation_result(operation, request);
1254 }
1255 if let Some(ops) = obj.get("ops").and_then(Value::as_array) {
1256 for operation in ops {
1257 if operation.get("op").and_then(Value::as_str) == Some(op)
1258 || operation.get("name").and_then(Value::as_str) == Some(op)
1259 || operation.get("id").and_then(Value::as_str) == Some(op)
1260 {
1261 return operation_result(operation, request);
1262 }
1263 }
1264 }
1265 obj.get(op)
1266 .and_then(|operation| operation_result(operation, request))
1267}
1268
1269fn operation_result(operation: &Value, request: &Value) -> Option<Value> {
1270 if let Some(result) = operation
1271 .get("result")
1272 .or_else(|| operation.get("output"))
1273 .or_else(|| operation.get("outputs"))
1274 {
1275 return Some(result.clone());
1276 }
1277 if operation.get("echo_request").and_then(Value::as_bool) == Some(true) {
1278 return Some(request.clone());
1279 }
1280 if operation.is_object() {
1281 return Some(operation.clone());
1282 }
1283 None
1284}
1285
1286fn merge_registration_output(
1287 action: &mut crate::setup_actions::SetupAction,
1288 answers: &mut Value,
1289 registration: &Value,
1290 output: &Value,
1291) -> anyhow::Result<()> {
1292 let registration_obj = registration
1293 .as_object()
1294 .ok_or_else(|| anyhow!("setup action registration must be an object"))?;
1295 let output_obj = output
1296 .as_object()
1297 .ok_or_else(|| anyhow!("setup action registration output must be an object"))?;
1298 let answers_obj = answers
1299 .as_object_mut()
1300 .ok_or_else(|| anyhow!("provider setup answers must be an object"))?;
1301
1302 for (mapping_key, source_value) in registration_obj {
1303 let Some(generic_key) = mapping_key.strip_suffix("_output") else {
1304 continue;
1305 };
1306 let Some(source_key) = source_value
1307 .as_str()
1308 .map(str::trim)
1309 .filter(|value| !value.is_empty())
1310 else {
1311 continue;
1312 };
1313 let Some(value) = output_obj
1314 .get(source_key)
1315 .or_else(|| output_obj.get(generic_key))
1316 .filter(|value| !is_empty_value(value))
1317 .cloned()
1318 else {
1319 continue;
1320 };
1321 answers_obj.insert(source_key.to_string(), value.clone());
1322 answers_obj.insert(generic_key.to_string(), value.clone());
1323 if generic_key == "client_id" {
1324 if let Some(client_id_field) =
1325 action.extra.get("client_id_field").and_then(Value::as_str)
1326 {
1327 answers_obj.insert(client_id_field.to_string(), value.clone());
1328 }
1329 action.extra.insert("client_id".into(), value);
1330 } else {
1331 action.extra.insert(generic_key.to_string(), value);
1332 }
1333 }
1334 Ok(())
1335}
1336
1337fn registration_error_message(output: &Value) -> Option<String> {
1338 if output.get("ok").and_then(Value::as_bool) == Some(false) {
1339 return output
1340 .get("error")
1341 .and_then(Value::as_str)
1342 .map(ToString::to_string)
1343 .or_else(|| Some(output.to_string()));
1344 }
1345 None
1346}
1347
1348fn is_empty_value(value: &Value) -> bool {
1349 match value {
1350 Value::Null => true,
1351 Value::String(value) => value.trim().is_empty(),
1352 Value::Array(values) => values.is_empty(),
1353 Value::Object(values) => values.is_empty(),
1354 Value::Bool(_) | Value::Number(_) => false,
1355 }
1356}
1357
1358fn hydrate_oauth_install_actions(
1359 actions: &mut [crate::setup_actions::SetupAction],
1360 answers: &Value,
1361) {
1362 for action in actions {
1363 if action.kind != crate::setup_actions::SetupActionKind::OauthInstallButton {
1364 continue;
1365 }
1366 let client_id = client_id_for_action(action, answers);
1367 let Some(authorize_url) = action.authorize_url.as_mut() else {
1368 continue;
1369 };
1370 let Ok(mut parsed) = url::Url::parse(authorize_url) else {
1371 continue;
1372 };
1373 if !parsed.query_pairs().any(|(key, _)| key == "client_id")
1374 && let Some(client_id) = client_id
1375 {
1376 parsed
1377 .query_pairs_mut()
1378 .append_pair("client_id", &client_id);
1379 }
1380 if !parsed.query_pairs().any(|(key, _)| key == "scope")
1381 && let Some(scopes) = action.extra.get("scopes").and_then(Value::as_array)
1382 {
1383 let scope = scopes
1384 .iter()
1385 .filter_map(Value::as_str)
1386 .map(str::trim)
1387 .filter(|value| !value.is_empty())
1388 .collect::<Vec<_>>()
1389 .join(",");
1390 if !scope.is_empty() {
1391 parsed.query_pairs_mut().append_pair("scope", &scope);
1392 }
1393 }
1394 *authorize_url = parsed.to_string();
1395 }
1396}
1397
1398fn client_id_for_action(
1399 action: &crate::setup_actions::SetupAction,
1400 answers: &Value,
1401) -> Option<String> {
1402 let obj = answers.as_object()?;
1403 let mut keys = Vec::new();
1404 if let Some(field) = action.extra.get("client_id_field").and_then(Value::as_str) {
1405 keys.push(field);
1406 }
1407 keys.extend(["client_id", "oauth_client_id"]);
1408 keys.into_iter().find_map(|key| {
1409 obj.get(key)
1410 .and_then(Value::as_str)
1411 .map(str::trim)
1412 .filter(|value| !value.is_empty())
1413 .map(ToString::to_string)
1414 })
1415}
1416
1417fn compute_file_digest(path: &Path) -> anyhow::Result<String> {
1418 let bytes = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
1419 let digest = Sha256::digest(bytes);
1420 let encoded = digest
1421 .iter()
1422 .map(|byte| format!("{byte:02x}"))
1423 .collect::<String>();
1424 Ok(format!("sha256:{encoded}"))
1425}
1426
1427fn resolve_pack_ref(pack_ref: &str) -> anyhow::Result<PathBuf> {
1428 let source = BundleSource::parse(pack_ref)?;
1429 let resolved = source.resolve()?;
1430
1431 if resolved.extension().and_then(|ext| ext.to_str()) != Some("gtpack") {
1432 anyhow::bail!(
1433 "resolved pack ref is not a .gtpack file: {}",
1434 resolved.display()
1435 );
1436 }
1437
1438 Ok(resolved)
1439}
1440
1441pub fn execute_remove_provider_artifacts(
1443 bundle_path: &Path,
1444 providers_remove: &[String],
1445) -> anyhow::Result<usize> {
1446 let mut removed = 0usize;
1447 let discovered = discovery::discover(bundle_path).ok();
1448 for provider_id in providers_remove {
1449 if let Some(discovered) = discovered.as_ref()
1450 && let Some(provider) = discovered
1451 .providers
1452 .iter()
1453 .find(|provider| provider.provider_id == *provider_id)
1454 {
1455 if provider.pack_path.exists() {
1456 std::fs::remove_file(&provider.pack_path).with_context(|| {
1457 format!(
1458 "failed to remove provider pack {}",
1459 provider.pack_path.display()
1460 )
1461 })?;
1462 }
1463 removed += 1;
1464 } else {
1465 let target_dir = get_pack_target_dir(bundle_path, provider_id);
1466 let target_path = target_dir.join(format!("{provider_id}.gtpack"));
1467 if target_path.exists() {
1468 std::fs::remove_file(&target_path).with_context(|| {
1469 format!("failed to remove provider pack {}", target_path.display())
1470 })?;
1471 removed += 1;
1472 }
1473 }
1474
1475 let config_dir = bundle_path.join("state").join("config").join(provider_id);
1476 if config_dir.exists() {
1477 std::fs::remove_dir_all(&config_dir).with_context(|| {
1478 format!(
1479 "failed to remove provider config dir {}",
1480 config_dir.display()
1481 )
1482 })?;
1483 }
1484 }
1485 Ok(removed)
1486}
1487
1488pub fn auto_install_provider_packs(bundle_path: &Path, metadata: &SetupPlanMetadata) {
1497 let bundle_abs =
1498 std::fs::canonicalize(bundle_path).unwrap_or_else(|_| bundle_path.to_path_buf());
1499
1500 let installed_ids: std::collections::HashSet<String> = discovery::discover(bundle_path)
1501 .map(|d| {
1502 d.providers
1503 .into_iter()
1504 .chain(d.app_packs)
1505 .map(|p| p.provider_id)
1506 .collect()
1507 })
1508 .unwrap_or_default();
1509
1510 for provider_id in metadata.setup_answers.keys() {
1511 if installed_ids.contains(provider_id) {
1512 continue;
1513 }
1514 let target_dir = get_pack_target_dir(bundle_path, provider_id);
1515 let target_path = target_dir.join(format!("{provider_id}.gtpack"));
1516 if target_path.exists() {
1517 continue;
1518 }
1519
1520 let domain = domain_from_provider_id(provider_id);
1522
1523 if let Some(source) = find_provider_pack_source(provider_id, domain, &bundle_abs) {
1525 if let Err(err) = std::fs::create_dir_all(&target_dir) {
1526 eprintln!(
1527 " [provider] WARNING: failed to create {}: {err}",
1528 target_dir.display()
1529 );
1530 continue;
1531 }
1532 match std::fs::copy(&source, &target_path) {
1533 Ok(_) => println!(
1534 " [provider] installed {provider_id}.gtpack from {}",
1535 source.display()
1536 ),
1537 Err(err) => eprintln!(
1538 " [provider] WARNING: failed to copy {}: {err}",
1539 source.display()
1540 ),
1541 }
1542 } else {
1543 eprintln!(" [provider] WARNING: {provider_id}.gtpack not found in sibling bundles");
1544 }
1545 }
1546}
1547
1548pub fn domain_from_provider_id(provider_id: &str) -> &str {
1550 const DOMAIN_PREFIXES: &[&str] = &[
1551 "messaging-",
1552 "events-",
1553 "oauth-",
1554 "secrets-",
1555 "mcp-",
1556 "state-",
1557 "telemetry-",
1558 ];
1559 for prefix in DOMAIN_PREFIXES {
1560 if provider_id.starts_with(prefix) {
1561 return prefix.trim_end_matches('-');
1562 }
1563 }
1564 "messaging" }
1566
1567pub fn find_provider_pack_source(
1573 provider_id: &str,
1574 domain: &str,
1575 bundle_abs: &Path,
1576) -> Option<PathBuf> {
1577 let parent = bundle_abs.parent()?;
1578 let filename = format!("{provider_id}.gtpack");
1579
1580 if let Ok(entries) = std::fs::read_dir(parent) {
1582 for entry in entries.flatten() {
1583 let sibling = entry.path();
1584 if sibling == *bundle_abs || !sibling.is_dir() {
1585 continue;
1586 }
1587 let candidate = sibling.join("providers").join(domain).join(&filename);
1588 if candidate.is_file() {
1589 return Some(candidate);
1590 }
1591 }
1592 }
1593
1594 for ancestor in parent.ancestors().take(4) {
1596 let candidate = ancestor
1597 .join("greentic-messaging-providers")
1598 .join("target")
1599 .join("packs")
1600 .join(&filename);
1601 if candidate.is_file() {
1602 return Some(candidate);
1603 }
1604 }
1605
1606 None
1607}
1608
1609pub fn execute_write_gmap_rules(
1611 bundle_path: &Path,
1612 metadata: &SetupPlanMetadata,
1613) -> anyhow::Result<()> {
1614 for tenant_sel in &metadata.tenants {
1615 let gmap_path =
1616 bundle::gmap_path(bundle_path, &tenant_sel.tenant, tenant_sel.team.as_deref());
1617
1618 if let Some(parent) = gmap_path.parent() {
1619 std::fs::create_dir_all(parent)?;
1620 }
1621
1622 let mut content = String::new();
1624 if tenant_sel.allow_paths.is_empty() {
1625 content.push_str("_ = forbidden\n");
1626 } else {
1627 for path in &tenant_sel.allow_paths {
1628 content.push_str(&format!("{} = allowed\n", path));
1629 }
1630 content.push_str("_ = forbidden\n");
1631 }
1632
1633 std::fs::write(&gmap_path, content)
1634 .with_context(|| format!("failed to write gmap: {}", gmap_path.display()))?;
1635 }
1636 Ok(())
1637}
1638
1639pub fn execute_copy_resolved_manifests(
1641 bundle_path: &Path,
1642 metadata: &SetupPlanMetadata,
1643) -> anyhow::Result<Vec<PathBuf>> {
1644 let mut manifests = Vec::new();
1645 let resolved_dir = bundle_path.join("resolved");
1646 std::fs::create_dir_all(&resolved_dir)?;
1647
1648 for tenant_sel in &metadata.tenants {
1649 let filename =
1650 bundle::resolved_manifest_filename(&tenant_sel.tenant, tenant_sel.team.as_deref());
1651 let manifest_path = resolved_dir.join(&filename);
1652
1653 if !manifest_path.exists() {
1655 std::fs::write(&manifest_path, "# Resolved manifest placeholder\n")?;
1656 }
1657 manifests.push(manifest_path);
1658 }
1659
1660 Ok(manifests)
1661}
1662
1663pub fn execute_validate_bundle(bundle_path: &Path) -> anyhow::Result<()> {
1665 bundle::validate_bundle_exists(bundle_path)
1666}
1667
1668pub fn execute_build_flow_index(_bundle_path: &Path, _config: &SetupConfig) -> anyhow::Result<()> {
1678 tracing::debug!("fast2flow indexing skipped (fast2flow-bundle not available)");
1679 Ok(())
1680}
1681
1682#[cfg(test)]
1683mod tests {
1684 use super::*;
1685 use crate::platform_setup::StaticRoutesPolicy;
1686 use std::collections::BTreeSet;
1687
1688 fn empty_metadata(pack_refs: Vec<String>) -> SetupPlanMetadata {
1689 SetupPlanMetadata {
1690 bundle_name: None,
1691 pack_refs,
1692 tenants: Vec::new(),
1693 default_assignments: Vec::new(),
1694 providers: Vec::new(),
1695 update_ops: BTreeSet::new(),
1696 remove_targets: BTreeSet::new(),
1697 packs_remove: Vec::new(),
1698 providers_remove: Vec::new(),
1699 tenants_remove: Vec::new(),
1700 access_changes: Vec::new(),
1701 static_routes: StaticRoutesPolicy::default(),
1702 deployment_targets: Vec::new(),
1703 setup_answers: serde_json::Map::new(),
1704 tunnel: None,
1705 telemetry: None,
1706 }
1707 }
1708
1709 #[test]
1710 fn resolve_packs_errors_when_any_pack_ref_fails() {
1711 let metadata = empty_metadata(vec!["/definitely/missing/example.gtpack".to_string()]);
1712 let err = execute_resolve_packs(Path::new("."), &metadata).unwrap_err();
1713 let message = err.to_string();
1714
1715 assert!(message.contains("failed to resolve 1 pack ref"));
1716 assert!(message.contains("/definitely/missing/example.gtpack"));
1717 }
1718
1719 #[test]
1724 fn auto_install_skips_when_pack_id_matches_under_custom_filename() {
1725 use std::io::Write;
1726 use zip::write::{FileOptions, ZipWriter};
1727
1728 let temp = tempfile::tempdir().expect("tempdir");
1729 let bundle = temp.path().join("bundle");
1730 let messaging_dir = bundle.join("providers").join("messaging");
1731 std::fs::create_dir_all(&messaging_dir).expect("create messaging dir");
1732
1733 let custom_pack = messaging_dir.join("messaging-webchat-gui-3aigent.gtpack");
1734 let file = std::fs::File::create(&custom_pack).expect("create pack file");
1735 let mut writer = ZipWriter::new(file);
1736 let options: FileOptions<'_, ()> =
1737 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
1738 writer
1739 .start_file("pack.manifest.json", options)
1740 .expect("start manifest");
1741 writer
1742 .write_all(
1743 serde_json::json!({
1744 "pack_id": "messaging-webchat-gui",
1745 "display_name": "WebChat GUI",
1746 })
1747 .to_string()
1748 .as_bytes(),
1749 )
1750 .expect("write manifest");
1751 writer.finish().expect("finish zip");
1752
1753 let canonical_pack = messaging_dir.join("messaging-webchat-gui.gtpack");
1754 assert!(!canonical_pack.exists(), "precondition: canonical absent");
1755
1756 let mut metadata = empty_metadata(vec![]);
1757 metadata.setup_answers.insert(
1758 "messaging-webchat-gui".to_string(),
1759 serde_json::Value::Object(serde_json::Map::new()),
1760 );
1761
1762 auto_install_provider_packs(&bundle, &metadata);
1763
1764 assert!(
1765 custom_pack.exists(),
1766 "custom-named pack must be left in place"
1767 );
1768 assert!(
1769 !canonical_pack.exists(),
1770 "must not auto-install canonical-named duplicate when pack_id already present"
1771 );
1772 }
1773
1774 fn secret_keys_for(keys: &[&str]) -> BTreeSet<String> {
1775 keys.iter()
1776 .map(|k| crate::secret_name::canonical_secret_name(k))
1777 .collect()
1778 }
1779
1780 #[test]
1781 fn envelope_redaction_replaces_secret_values_with_canonical_uri_refs() {
1782 let secret_keys = secret_keys_for(&["api_key", "oauth_client_secret"]);
1783
1784 let answers = serde_json::json!({
1785 "model": "gpt-4o-mini",
1786 "api_key": "sk-PLAINTEXT-MUST-NOT-LEAK",
1787 "oauth_client_secret": "PLAINTEXT-OAUTH-SECRET",
1788 "non_secret_url": "https://api.openai.com/v1"
1789 });
1790
1791 let redacted = redact_secret_answer_values_to_uri_refs(
1792 &answers,
1793 &secret_keys,
1794 "dev",
1795 "demo",
1796 Some("default"),
1797 "openai-llm",
1798 );
1799
1800 let map = redacted.as_object().expect("object");
1801 assert_eq!(map["model"].as_str(), Some("gpt-4o-mini"));
1802 assert_eq!(
1803 map["non_secret_url"].as_str(),
1804 Some("https://api.openai.com/v1")
1805 );
1806 assert_eq!(
1809 map["api_key"].as_str(),
1810 Some("secrets://dev/demo/_/openai-llm/api_key"),
1811 "secret value must be replaced with canonical secrets:// URI",
1812 );
1813 assert_eq!(
1814 map["oauth_client_secret"].as_str(),
1815 Some("secrets://dev/demo/_/openai-llm/oauth_client_secret"),
1816 );
1817
1818 let json = serde_json::to_string(&redacted).expect("serialize");
1819 assert!(
1820 !json.contains("PLAINTEXT-MUST-NOT-LEAK"),
1821 "api_key plaintext leaked into envelope JSON: {json}",
1822 );
1823 assert!(
1824 !json.contains("PLAINTEXT-OAUTH-SECRET"),
1825 "oauth_client_secret plaintext leaked into envelope JSON: {json}",
1826 );
1827 }
1828
1829 #[test]
1830 fn setup_answers_redaction_drops_secret_keys_entirely() {
1831 let secret_keys = secret_keys_for(&["api_key"]);
1836 let answers = serde_json::json!({
1837 "model": "gpt-4o-mini",
1838 "api_key": "sk-PLAINTEXT-MUST-NOT-LEAK"
1839 });
1840
1841 let stripped = strip_secret_answer_keys(&answers, &secret_keys);
1842 let map = stripped.as_object().expect("object");
1843 assert_eq!(map["model"].as_str(), Some("gpt-4o-mini"));
1844 assert!(
1845 !map.contains_key("api_key"),
1846 "secret key must be removed entirely from setup-answers",
1847 );
1848 let json = serde_json::to_string(&stripped).expect("serialize");
1849 assert!(
1850 !json.contains("PLAINTEXT-MUST-NOT-LEAK"),
1851 "plaintext leaked into setup-answers: {json}",
1852 );
1853 assert!(
1854 !json.contains("secrets://"),
1855 "setup-answers must not carry URI refs either — readers fetch via SecretsManager",
1856 );
1857 }
1858
1859 #[test]
1860 fn is_secret_answer_key_matches_aliases_via_canonical_suffix() {
1861 let secret_keys = secret_keys_for(&["webex_bot_token"]);
1866 assert!(is_secret_answer_key("bot_token", &secret_keys));
1867 assert!(is_secret_answer_key("BOT_TOKEN", &secret_keys));
1868 assert!(is_secret_answer_key("webex_bot_token", &secret_keys));
1869 assert!(!is_secret_answer_key("model", &secret_keys));
1871 assert!(!is_secret_answer_key("bot_url", &secret_keys));
1872 }
1873
1874 #[test]
1875 fn is_secret_answer_key_does_not_over_match_reverse_direction() {
1876 let secret_keys = secret_keys_for(&["token"]);
1883 assert!(is_secret_answer_key("token", &secret_keys));
1884 assert!(
1885 !is_secret_answer_key("bot_token", &secret_keys),
1886 "answer key longer than the secret key must not match (reverse direction removed)",
1887 );
1888 assert!(!is_secret_answer_key("refresh_token", &secret_keys));
1889 }
1890
1891 #[test]
1892 fn is_secret_answer_key_punctuation_only_key_does_not_match_unrelated_secret() {
1893 let secret_keys = secret_keys_for(&["api_key"]);
1897 assert!(!is_secret_answer_key("", &secret_keys));
1898 assert!(!is_secret_answer_key("---", &secret_keys));
1899 }
1900
1901 #[test]
1902 fn alias_answer_key_redacted_in_setup_answers_and_envelope() {
1903 let secret_keys = secret_keys_for(&["webex_bot_token"]);
1906 let answers = serde_json::json!({"bot_token": "T0K3N-MUST-NOT-LEAK"});
1907
1908 let stripped = strip_secret_answer_keys(&answers, &secret_keys);
1909 assert!(
1910 stripped.as_object().unwrap().is_empty(),
1911 "alias-matched secret key must be dropped from setup-answers",
1912 );
1913
1914 let envelope = redact_secret_answer_values_to_uri_refs(
1915 &answers,
1916 &secret_keys,
1917 "dev",
1918 "demo",
1919 None,
1920 "messaging-webex",
1921 );
1922 assert_eq!(
1923 envelope["bot_token"].as_str(),
1924 Some("secrets://dev/demo/_/messaging-webex/bot_token"),
1925 );
1926 let json = serde_json::to_string(&envelope).unwrap();
1927 assert!(!json.contains("T0K3N-MUST-NOT-LEAK"));
1928 }
1929
1930 #[test]
1931 fn secret_keys_fail_closed_distinguishes_none_from_empty_set() {
1932 let content = serde_json::json!({"model": "gpt-4o"});
1933 let empty = serde_json::json!({});
1934
1935 let r = secret_keys_or_fail_closed(Some(BTreeSet::new()), &content, "p").unwrap();
1939 assert!(r.is_empty(), "Some(empty) proceeds with no redaction");
1940
1941 let set = secret_keys_for(&["api_key"]);
1943 let r = secret_keys_or_fail_closed(Some(set.clone()), &content, "p").unwrap();
1944 assert_eq!(r, set);
1945
1946 assert!(secret_keys_or_fail_closed(None, &content, "p").is_err());
1948
1949 assert!(
1951 secret_keys_or_fail_closed(None, &empty, "p")
1952 .unwrap()
1953 .is_empty()
1954 );
1955 }
1956
1957 #[test]
1958 fn answers_have_content_distinguishes_empty_from_meaningful() {
1959 assert!(!answers_have_content(&serde_json::json!({})));
1960 assert!(!answers_have_content(&serde_json::json!({"a": null})));
1961 assert!(!answers_have_content(&serde_json::json!({"a": ""})));
1962 assert!(answers_have_content(&serde_json::json!({"a": "value"})));
1963 assert!(answers_have_content(&serde_json::json!({"a": 42})));
1964 assert!(answers_have_content(&serde_json::json!({"a": true})));
1965 assert!(answers_have_content(&serde_json::json!({"a": ["x"]})));
1966 }
1967}