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