Skip to main content

greentic_setup/engine/
executors.rs

1//! Step executor implementations for the setup engine.
2//!
3//! Each executor handles a specific `SetupStepKind`.
4
5use std::collections::{BTreeMap, BTreeSet};
6use std::fs::File;
7use std::io::Read;
8use std::path::{Path, PathBuf};
9use std::sync::{Arc, Mutex};
10
11use anyhow::{Context, anyhow};
12use serde_json::{Map as JsonMap, Value};
13use sha2::{Digest, Sha256};
14use zip::{ZipArchive, result::ZipError};
15
16use crate::plan::{ResolvedPackInfo, SetupPlanMetadata};
17use crate::{bundle, bundle_source::BundleSource, discovery};
18
19use super::plan_builders::compute_simple_hash;
20use super::types::SetupConfig;
21
22#[derive(Debug)]
23pub struct ApplyPackSetupReport {
24    pub provider_updates: usize,
25    pub pending_setup_actions: Vec<crate::setup_actions::SetupAction>,
26}
27
28/// Resolve the canonical set of secret-marked answer keys for a pack (B12a).
29///
30/// The source of truth is `pack_to_form_spec()`, which unions:
31/// - `setup.yaml` / `qa/*.json` questions with `secret: true`, and
32/// - entries from `assets/secret-requirements.json` / CBOR manifest.
33///
34/// Each key is normalized via `canonical_secret_name` so the redaction
35/// match logic below can mirror `seed_secret_requirement_aliases`'s
36/// suffix-matching (so `bot_token` answers satisfy a `webex_bot_token`
37/// requirement).
38///
39/// Returns `None` when the pack carries no setup metadata at all — the
40/// caller should then refuse to write the transitional artifacts for
41/// non-empty answers (B12a fail-closed contract).
42fn 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
53/// Match an answer key (post-normalization) against the secret-marked set.
54///
55/// This MUST mirror `qa::persist::seed_secret_requirement_aliases` exactly
56/// (`canonical_req_key.ends_with(&norm_cfg)`), so that the set of answers
57/// redacted from disk is identical to the set persisted to the dev secrets
58/// store as secrets. If redaction were narrower than seeding, a key the
59/// persist path treats as a secret would stay as plaintext on disk — a leak.
60///
61/// Match when the answer key's canonical form equals a secret key, or a
62/// secret key ends with it (forward direction only — so requirement
63/// `webex_bot_token` is satisfied by answer `bot_token`). The earlier
64/// version ALSO matched the reverse direction (`norm.ends_with(secret)`),
65/// which the persist path does not do; that over-matched (answer `bot_token`
66/// wrongly redacted for an unrelated secret `token`) and is dropped here.
67fn 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
74/// Drop secret-marked answer values entirely (B12a). Used for the on-disk
75/// `setup-answers.json` — its downstream readers in `greentic-start`
76/// (`messaging_app::inject_pack_setup_answers`,
77/// `ingress_dispatch::build_injected_config`) already source secret values
78/// from `SecretsManager`, so the key has no value to contribute. Dropping
79/// the key avoids putting any reference (URI or otherwise) into a JSON
80/// value slot consumers may treat as the raw credential.
81fn 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
95/// Replace secret-marked answer values with canonical `secrets://` URI
96/// references for the `config.envelope.cbor` artifact. Components that
97/// already consume the envelope's config via the URI-resolving pattern
98/// (e.g. greentic-start `notifier/config.rs` for state-redis) keep working
99/// unchanged; components that read the `<key>_b64` injection see the
100/// resolved plaintext from `SecretsManager` via `runner_host.get_secret`.
101fn 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
124/// Decide the secret-key set for redaction, applying the B12a fail-closed
125/// contract.
126///
127/// `resolved` carries a load-bearing `Option`:
128///   - `Some(set)` — the pack HAS classifiable metadata. An empty set means
129///     the pack legitimately declares zero secrets; proceed (write every
130///     answer as non-secret). This is NOT a failure.
131///   - `None` — no pack / no classifiable metadata at all. With non-empty
132///     answers we cannot tell which are secret, so fail closed rather than
133///     risk writing plaintext. With empty answers there's nothing to leak,
134///     so proceed with an empty set.
135fn 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
153/// Return true if `answers` is a JSON object with at least one non-null
154/// string-typed field — i.e. material that could plausibly be a secret.
155/// Used to decide whether the B12a fail-closed contract applies when the
156/// redaction metadata can't be resolved.
157fn answers_have_content(answers: &Value) -> bool {
158    let Some(map) = answers.as_object() else {
159        return false;
160    };
161    map.iter().any(|(key, v)| {
162        // Host-injected base URLs are never secrets. Exclude them so a pack whose
163        // *only* answer is an injected `public_base_url`/`oauth_callback_base_url`
164        // (e.g. an app pack with no setup metadata that merely received the base
165        // URL during injection) does not trip the B12a fail-closed guard and block
166        // the whole setup. Real secret material still triggers fail-closed.
167        if matches!(key.as_str(), "public_base_url" | "oauth_callback_base_url") {
168            return false;
169        }
170        match v {
171            Value::String(s) => !s.is_empty(),
172            Value::Null => false,
173            _ => true,
174        }
175    })
176}
177
178/// C7: attempt to emit a `pack-config-input.v1` file for one provider.
179/// Soft-fails on error — the C4.2 compat shim still serves these keys from
180/// DevStore.
181fn try_emit_pack_config_input(
182    bundle_path: &Path,
183    pack_path: &Path,
184    env: &str,
185    provider_id: &str,
186    answers: &Value,
187    trace_context: &str,
188) {
189    let Some(form_spec) = crate::setup_to_formspec::pack_to_form_spec(pack_path, provider_id)
190    else {
191        return;
192    };
193    let bundle_id = crate::qa::persist::infer_bundle_id(bundle_path);
194    if let Err(err) = crate::qa::persist::emit_pack_config_input(
195        bundle_path,
196        env,
197        &bundle_id,
198        provider_id,
199        answers,
200        &form_spec,
201    ) {
202        tracing::warn!(
203            provider_id = %provider_id,
204            env = %env,
205            error = %err,
206            "pack-config-input emission failed ({trace_context}); runtime falls back to DevStore via C4.2 compat shim",
207        );
208    }
209}
210
211/// Execute the CreateBundle step.
212pub fn execute_create_bundle(
213    bundle_path: &Path,
214    metadata: &SetupPlanMetadata,
215) -> anyhow::Result<()> {
216    bundle::create_demo_bundle_structure(bundle_path, metadata.bundle_name.as_deref())
217        .context("failed to create bundle structure")
218}
219
220/// Execute the ResolvePacks step.
221pub fn execute_resolve_packs(
222    _bundle_path: &Path,
223    metadata: &SetupPlanMetadata,
224) -> anyhow::Result<Vec<ResolvedPackInfo>> {
225    let mut resolved = Vec::new();
226    let mut failures = Vec::new();
227
228    for pack_ref in &metadata.pack_refs {
229        match resolve_pack_ref(pack_ref) {
230            Ok(resolved_path) => {
231                let canonical = resolved_path
232                    .canonicalize()
233                    .unwrap_or(resolved_path.clone());
234                let pack_meta = discovery::read_pack_meta(&canonical)?;
235                resolved.push(ResolvedPackInfo {
236                    source_ref: pack_ref.clone(),
237                    mapped_ref: canonical.display().to_string(),
238                    resolved_digest: compute_file_digest(&canonical)
239                        .unwrap_or_else(|_| format!("sha256:{}", compute_simple_hash(pack_ref))),
240                    pack_id: pack_meta.map(|meta| meta.pack_id).unwrap_or_else(|| {
241                        canonical
242                            .file_stem()
243                            .and_then(|s| s.to_str())
244                            .unwrap_or("unknown")
245                            .to_string()
246                    }),
247                    entry_flows: Vec::new(),
248                    cached_path: canonical.clone(),
249                    output_path: canonical,
250                });
251            }
252            Err(err) => {
253                failures.push(format!("{pack_ref}: {err}"));
254            }
255        }
256    }
257
258    if !failures.is_empty() {
259        anyhow::bail!(
260            "failed to resolve {} pack ref(s):\n{}",
261            failures.len(),
262            failures.join("\n")
263        );
264    }
265
266    Ok(resolved)
267}
268
269/// Execute the AddPacksToBundle step.
270pub fn execute_add_packs_to_bundle(
271    bundle_path: &Path,
272    resolved_packs: &[ResolvedPackInfo],
273) -> anyhow::Result<()> {
274    let mut metadata_entries = Vec::new();
275
276    for pack in resolved_packs {
277        // Determine target directory based on pack ID domain prefix
278        let target_dir = get_pack_target_dir(bundle_path, &pack.pack_id);
279        std::fs::create_dir_all(&target_dir)?;
280
281        let target_path = target_dir.join(format!("{}.gtpack", pack.pack_id));
282        let source_path = pack.cached_path.canonicalize().ok();
283        let existing_target_path = target_path.canonicalize().ok();
284        if pack.cached_path.exists() && source_path != existing_target_path {
285            std::fs::copy(&pack.cached_path, &target_path).with_context(|| {
286                format!(
287                    "failed to copy pack {} to {}",
288                    pack.cached_path.display(),
289                    target_path.display()
290                )
291            })?;
292        }
293
294        let reference = target_path
295            .strip_prefix(bundle_path)
296            .unwrap_or(&target_path)
297            .to_string_lossy()
298            .replace('\\', "/");
299        let kind = if reference.starts_with("providers/") {
300            bundle::BundleReferenceKind::ExtensionProvider
301        } else {
302            bundle::BundleReferenceKind::AppPack
303        };
304        metadata_entries.push(bundle::BundleReference {
305            kind,
306            reference,
307            digest: Some(pack.resolved_digest.clone()),
308        });
309    }
310
311    bundle::register_bundle_references(bundle_path, &metadata_entries, None)?;
312    Ok(())
313}
314
315/// Determine the target directory for a pack based on its ID.
316///
317/// Packs with domain prefixes (e.g., `messaging-telegram`, `events-webhook`)
318/// go to `providers/<domain>/`. Other packs go to `packs/`.
319pub fn get_pack_target_dir(bundle_path: &Path, pack_id: &str) -> PathBuf {
320    const DOMAIN_PREFIXES: &[&str] = &[
321        "messaging-",
322        "events-",
323        "oauth-",
324        "secrets-",
325        "mcp-",
326        "state-",
327    ];
328
329    for prefix in DOMAIN_PREFIXES {
330        if pack_id.starts_with(prefix) {
331            let domain = prefix.trim_end_matches('-');
332            return bundle_path.join("providers").join(domain);
333        }
334    }
335
336    // Default to packs/ for non-provider packs
337    bundle_path.join("packs")
338}
339
340/// Execute the ApplyPackSetup step.
341pub fn execute_apply_pack_setup(
342    bundle_path: &Path,
343    metadata: &SetupPlanMetadata,
344    config: &SetupConfig,
345) -> anyhow::Result<ApplyPackSetupReport> {
346    let mut count = 0;
347    let mut pending_setup_actions = Vec::new();
348
349    if !metadata.providers_remove.is_empty() {
350        count += execute_remove_provider_artifacts(bundle_path, &metadata.providers_remove)?;
351    }
352
353    // Auto-install provider packs that are referenced in setup_answers
354    // but not yet present in the bundle.
355    auto_install_provider_packs(bundle_path, metadata);
356
357    // Discover packs so we can find pack_path for secret alias seeding
358    let discovered = if bundle_path.exists() {
359        discovery::discover(bundle_path).ok()
360    } else {
361        None
362    };
363
364    let provider_ids = setup_provider_ids(metadata, discovered.as_ref());
365
366    // Persist setup answers to local config files and dev secrets store
367    for provider_id in provider_ids {
368        let empty_answers = Value::Object(serde_json::Map::new());
369        let answers = metadata
370            .setup_answers
371            .get(&provider_id)
372            .unwrap_or(&empty_answers);
373        let mut effective_answers = answers.clone();
374        let pack_path = discovered.as_ref().and_then(|d| {
375            d.find_setup_target(&provider_id)
376                .map(|p| p.pack_path.as_path())
377        });
378        if !crate::provider_state::provider_enabled(&effective_answers) {
379            let persisted_answers = crate::setup_actions::strip_setup_actions(&effective_answers);
380            let config_dir = bundle_path.join("state").join("config").join(&provider_id);
381            std::fs::create_dir_all(&config_dir)?;
382            let config_path = config_dir.join("setup-answers.json");
383            let content = serde_json::to_string_pretty(&persisted_answers)
384                .context("failed to serialize setup answers")?;
385            std::fs::write(&config_path, content).with_context(|| {
386                format!(
387                    "failed to write setup answers to: {}",
388                    config_path.display()
389                )
390            })?;
391            let env = crate::resolve_env(Some(&config.env));
392            let rt = tokio::runtime::Runtime::new()
393                .context("failed to create tokio runtime for secrets persistence")?;
394            rt.block_on(crate::qa::persist::persist_all_config_as_secrets(
395                bundle_path,
396                &env,
397                &config.tenant,
398                config.team.as_deref(),
399                &provider_id,
400                &persisted_answers,
401                pack_path,
402            ))?;
403            if let Some(pack_path) = pack_path {
404                crate::config_envelope::write_provider_config_envelope(
405                    &bundle_path.join(".providers"),
406                    &provider_id,
407                    "setup-input",
408                    &persisted_answers,
409                    pack_path,
410                    false,
411                )
412                .with_context(|| {
413                    format!(
414                        "failed to write provider config envelope for {} using {}",
415                        provider_id,
416                        pack_path.display()
417                    )
418                })?;
419                try_emit_pack_config_input(
420                    bundle_path,
421                    pack_path,
422                    &env,
423                    &provider_id,
424                    &persisted_answers,
425                    "setup-input path",
426                );
427            }
428            count += 1;
429            continue;
430        }
431        let mut setup_actions = crate::setup_actions::extract_setup_actions(
432            &provider_id,
433            &config.tenant,
434            config.team.as_deref(),
435            answers,
436        )?;
437        setup_actions.extend(extract_pack_setup_actions(
438            discovered.as_ref(),
439            &provider_id,
440            &config.tenant,
441            config.team.as_deref(),
442        )?);
443        defer_registration_actions_missing_inputs(&mut setup_actions, &effective_answers);
444        run_setup_action_registrations(SetupActionRegistrationContext {
445            bundle_path,
446            discovered: discovered.as_ref(),
447            provider_id: &provider_id,
448            config,
449            bundle_name: metadata.bundle_name.as_deref(),
450            public_base_url: metadata.static_routes.public_base_url.as_deref(),
451            answers: &mut effective_answers,
452            actions: &mut setup_actions,
453        })?;
454        hydrate_oauth_install_actions(&mut setup_actions, &effective_answers);
455        if !setup_actions.is_empty() {
456            crate::setup_actions::sign_pending_oauth_actions(bundle_path, &mut setup_actions)?;
457            crate::setup_actions::persist_setup_actions(bundle_path, &setup_actions)?;
458            pending_setup_actions.extend(setup_actions.clone());
459        }
460        // Slack's `app_redirect` deep link (the final "Add to Slack" -> DM with
461        // the bot) cannot resolve the workspace without a `team` parameter in
462        // multi-workspace / Enterprise Grid browsers. When a bot token answer is
463        // available, resolve the workspace once via auth.test and pin it.
464        let slack_team = slack_team_id_from_answers(&effective_answers);
465        if let Some(team) = slack_team.as_deref() {
466            apply_slack_team_to_app_url(&mut effective_answers, team);
467        }
468        let persisted_answers = crate::setup_actions::strip_setup_actions(&effective_answers);
469
470        // Write answers to provider config directory
471        let config_dir = bundle_path.join("state").join("config").join(&provider_id);
472        std::fs::create_dir_all(&config_dir)?;
473
474        // Resolve the pack path early so we can both discover secret-marked
475        // keys (to redact plaintext from the on-disk artifacts — B12a) and
476        // pass it to the envelope writer + secrets-persist path.
477        let pack_path = discovered.as_ref().and_then(|d| {
478            d.find_setup_target(&provider_id)
479                .map(|p| p.pack_path.as_path())
480        });
481        let env = crate::resolve_env(Some(&config.env));
482
483        // B12a fail-closed contract: resolve the secret-marked answer key
484        // set from the pack's `pack_to_form_spec` (the union of setup.yaml /
485        // qa/*.json `secret: true` questions and `secret-requirements.json`
486        // entries). The `Option` is load-bearing:
487        //   - `Some(set)` — the pack HAS a form spec. An empty set means the
488        //     pack legitimately declares zero secrets (e.g. only model/url
489        //     config); we proceed and write every answer as non-secret.
490        //   - `None` — the pack ships NO classifiable metadata at all (no
491        //     setup.yaml, no qa/*.json, no secret-requirements). We cannot
492        //     tell which answers are secret, so with non-empty answers we
493        //     fail closed rather than silently writing plaintext.
494        // A missing pack path is the same "can't classify" situation.
495        let resolved_secret_keys: Option<BTreeSet<String>> =
496            pack_path.and_then(|pp| resolve_secret_answer_keys(pp, &provider_id));
497        let secret_keys = secret_keys_or_fail_closed(resolved_secret_keys, answers, &provider_id)?;
498        let mut answers_for_disk = strip_secret_answer_keys(answers, &secret_keys);
499        if let Some(team) = slack_team.as_deref() {
500            apply_slack_team_to_app_url(&mut answers_for_disk, team);
501        }
502        let mut envelope_answers = redact_secret_answer_values_to_uri_refs(
503            answers,
504            &secret_keys,
505            &env,
506            &config.tenant,
507            config.team.as_deref(),
508            &provider_id,
509        );
510        if let Some(team) = slack_team.as_deref() {
511            apply_slack_team_to_app_url(&mut envelope_answers, team);
512        }
513
514        let config_path = config_dir.join("setup-answers.json");
515        let content = serde_json::to_string_pretty(&answers_for_disk)
516            .context("failed to serialize setup answers")?;
517        std::fs::write(&config_path, content).with_context(|| {
518            format!(
519                "failed to write setup answers to: {}",
520                config_path.display()
521            )
522        })?;
523
524        if config.verbose {
525            let team_display = config.team.as_deref().unwrap_or("(none)");
526            println!(
527                "  [secrets] scope: env={env}, tenant={}, team={team_display}, provider={provider_id}",
528                config.tenant
529            );
530            let example_uri = crate::canonical_secret_uri(
531                &env,
532                &config.tenant,
533                config.team.as_deref(),
534                &provider_id,
535                "_example_key",
536            );
537            println!("  [secrets] URI pattern: {example_uri}");
538            if let Some(config_map) = persisted_answers.as_object() {
539                let keys: Vec<&String> = config_map.keys().collect();
540                println!("  [secrets] answer keys: {keys:?}");
541            }
542        }
543        let rt = tokio::runtime::Runtime::new()
544            .context("failed to create tokio runtime for secrets persistence")?;
545        let persisted = rt.block_on(crate::qa::persist::persist_all_config_as_secrets(
546            bundle_path,
547            &env,
548            &config.tenant,
549            config.team.as_deref(),
550            &provider_id,
551            &persisted_answers,
552            pack_path,
553        ))?;
554        if config.verbose {
555            if persisted.is_empty() {
556                println!(
557                    "  [secrets] WARNING: 0 key(s) persisted for {provider_id} (all values empty?)"
558                );
559            } else {
560                println!(
561                    "  [secrets] persisted {} key(s) for {provider_id}: {:?}",
562                    persisted.len(),
563                    persisted
564                );
565            }
566        }
567
568        // Materialize a provider config envelope so runtime/provider ingest
569        // paths can read setup-applied config. After B12a the envelope carries
570        // `secrets://` URI references for secret-marked keys (matching the
571        // canonical URIs in the dev secrets store) instead of plaintext.
572        if let Some(pack_path) = pack_path {
573            crate::config_envelope::write_provider_config_envelope(
574                &bundle_path.join(".providers"),
575                &provider_id,
576                "setup-input",
577                &envelope_answers,
578                pack_path,
579                false,
580            )
581            .with_context(|| {
582                format!(
583                    "failed to write provider config envelope for {} using {}",
584                    provider_id,
585                    pack_path.display()
586                )
587            })?;
588        } else if config.verbose {
589            println!(
590                "  [config] WARNING: no resolved pack path for {provider_id}; skipped config envelope write"
591            );
592        }
593
594        // C7: emit pack-config-input.v1 for the enabled-provider path as
595        // well. Same soft-fail posture as the disabled-provider branch above.
596        if let Some(pack_path) = pack_path {
597            try_emit_pack_config_input(
598                bundle_path,
599                pack_path,
600                &env,
601                &provider_id,
602                &persisted_answers,
603                "apply-answers path",
604            );
605        }
606
607        // Sync OAuth answers to tenant config JSON for webchat-gui providers
608        match crate::tenant_config::sync_oauth_to_tenant_config(
609            bundle_path,
610            &config.tenant,
611            &provider_id,
612            &persisted_answers,
613        ) {
614            Ok(true) => {
615                if config.verbose {
616                    println!("  [oauth] updated tenant config for {provider_id}");
617                }
618            }
619            Ok(false) => {}
620            Err(e) => {
621                println!("  [oauth] WARNING: failed to update tenant config: {e}");
622            }
623        }
624
625        // Sync `skin` answer to tenant config JSON for webchat-gui providers
626        match crate::tenant_config::sync_skin_to_tenant_config(
627            bundle_path,
628            &config.tenant,
629            &provider_id,
630            &persisted_answers,
631        ) {
632            Ok(true) => {
633                if config.verbose {
634                    println!("  [skin] updated tenant config for {provider_id}");
635                }
636            }
637            Ok(false) => {}
638            Err(e) => {
639                println!("  [skin] WARNING: failed to update tenant config: {e}");
640            }
641        }
642
643        // Sync `nav_links_json` answer to tenant config JSON for webchat-gui providers
644        if provider_id.contains("webchat-gui") && config.verbose {
645            let preview = answers
646                .as_object()
647                .and_then(|m| m.get("nav_links"))
648                .map(|v| serde_json::to_string(v).unwrap_or_else(|_| "<unserializable>".into()))
649                .unwrap_or_else(|| "<absent>".into());
650            println!("  [nav_links] received answer for {provider_id}: {preview}");
651        }
652        match crate::tenant_config::sync_nav_links_to_tenant_config(
653            bundle_path,
654            &config.tenant,
655            &provider_id,
656            &persisted_answers,
657        ) {
658            Ok(true) => {
659                if config.verbose {
660                    println!("  [nav_links] updated tenant config for {provider_id}");
661                }
662            }
663            Ok(false) => {}
664            Err(e) => {
665                println!("  [nav_links] WARNING: failed to update tenant config: {e}");
666            }
667        }
668
669        // Register webhooks if the provider needs one (e.g. Telegram, Slack, Webex)
670        if let Some(result) = crate::webhook::register_webhook(
671            &provider_id,
672            &persisted_answers,
673            &config.tenant,
674            config.team.as_deref(),
675        ) {
676            let ok = result.get("ok").and_then(Value::as_bool).unwrap_or(false);
677            if ok {
678                println!("  [webhook] registered for {provider_id}");
679            } else {
680                let err = result
681                    .get("error")
682                    .and_then(Value::as_str)
683                    .unwrap_or("unknown");
684                println!("  [webhook] WARNING: registration failed for {provider_id}: {err}");
685            }
686        }
687
688        count += 1;
689    }
690
691    crate::platform_setup::persist_static_routes_artifact(bundle_path, &metadata.static_routes)?;
692    let _ = crate::deployment_targets::persist_explicit_deployment_targets(
693        bundle_path,
694        &metadata.deployment_targets,
695    );
696
697    // Print post-setup instructions for providers needing manual steps
698    let provider_configs: Vec<(String, Value)> = metadata
699        .setup_answers
700        .iter()
701        .filter(|(_, val)| crate::provider_state::provider_enabled(val))
702        .map(|(id, val)| (id.clone(), val.clone()))
703        .collect();
704    let team = config.team.as_deref().unwrap_or("default");
705    crate::webhook::print_post_setup_instructions(&provider_configs, &config.tenant, team);
706
707    Ok(ApplyPackSetupReport {
708        provider_updates: count,
709        pending_setup_actions,
710    })
711}
712
713fn setup_provider_ids(
714    metadata: &SetupPlanMetadata,
715    discovered: Option<&crate::discovery::DiscoveryResult>,
716) -> BTreeSet<String> {
717    let mut provider_ids: BTreeSet<String> = metadata.setup_answers.keys().cloned().collect();
718    if let Some(discovered) = discovered {
719        for provider in discovered.setup_targets() {
720            if let Ok(Some(spec)) = crate::setup_input::load_setup_spec(&provider.pack_path)
721                && !spec.setup_actions.is_empty()
722            {
723                provider_ids.insert(provider.provider_id.clone());
724            }
725        }
726    }
727    provider_ids
728}
729
730fn extract_pack_setup_actions(
731    discovered: Option<&crate::discovery::DiscoveryResult>,
732    provider_id: &str,
733    tenant: &str,
734    team: Option<&str>,
735) -> anyhow::Result<Vec<crate::setup_actions::SetupAction>> {
736    let Some(provider) = discovered.and_then(|d| d.find_setup_target(provider_id)) else {
737        return Ok(Vec::new());
738    };
739    let Some(spec) = crate::setup_input::load_setup_spec(&provider.pack_path)? else {
740        return Ok(Vec::new());
741    };
742    if spec.setup_actions.is_empty() {
743        return Ok(Vec::new());
744    }
745    let setup_actions = spec
746        .setup_actions
747        .into_iter()
748        .map(|mut action| {
749            if let Some(obj) = action.as_object_mut() {
750                obj.remove("provider_id");
751                obj.remove("tenant");
752                obj.remove("team");
753            }
754            action
755        })
756        .collect::<Vec<_>>();
757    let value = serde_json::json!({ "setup_actions": setup_actions });
758    crate::setup_actions::extract_setup_actions(provider_id, tenant, team, &value)
759}
760
761fn defer_registration_actions_missing_inputs(
762    actions: &mut Vec<crate::setup_actions::SetupAction>,
763    answers: &Value,
764) {
765    actions.retain(|action| {
766        if action.extra.get("registration").is_none() {
767            return true;
768        }
769        let registration_satisfied = match action.kind {
770            crate::setup_actions::SetupActionKind::OauthInstallButton => {
771                client_id_for_action(action, answers).is_some()
772            }
773            crate::setup_actions::SetupActionKind::OpenUrl => {
774                !registration_output_missing(action, answers)
775            }
776            _ => return true,
777        };
778        registration_satisfied
779            || registration_has_any_declared_input(action.extra.get("registration"), answers)
780    });
781}
782
783/// Whether an action's registration op has *not yet* produced any of its
784/// declared `*_output` fields — i.e. whether registration still needs to run.
785/// Used by kinds (like `open_url`) that have no `client_id` to key off of.
786fn registration_output_missing(
787    action: &crate::setup_actions::SetupAction,
788    answers: &Value,
789) -> bool {
790    let Some(registration_obj) = action.extra.get("registration").and_then(Value::as_object) else {
791        return true;
792    };
793    let Some(answers_obj) = answers.as_object() else {
794        return true;
795    };
796    !registration_obj.iter().any(|(key, field_value)| {
797        key.ends_with("_output")
798            && field_value
799                .as_str()
800                .map(str::trim)
801                .filter(|field_name| !field_name.is_empty())
802                .and_then(|field_name| answers_obj.get(field_name))
803                .is_some_and(|value| !is_empty_value(value))
804    })
805}
806
807fn registration_has_any_declared_input(registration: Option<&Value>, answers: &Value) -> bool {
808    let Some(registration_obj) = registration.and_then(Value::as_object) else {
809        return false;
810    };
811    let Some(answers_obj) = answers.as_object() else {
812        return false;
813    };
814    registration_obj.iter().any(|(key, field_value)| {
815        key.ends_with("_field")
816            && field_value
817                .as_str()
818                .map(str::trim)
819                .filter(|field_name| !field_name.is_empty())
820                .and_then(|field_name| answers_obj.get(field_name))
821                .is_some_and(|value| !is_empty_value(value))
822    })
823}
824
825struct SetupActionRegistrationContext<'a> {
826    bundle_path: &'a Path,
827    discovered: Option<&'a crate::discovery::DiscoveryResult>,
828    provider_id: &'a str,
829    config: &'a SetupConfig,
830    bundle_name: Option<&'a str>,
831    public_base_url: Option<&'a str>,
832    answers: &'a mut Value,
833    actions: &'a mut [crate::setup_actions::SetupAction],
834}
835
836fn run_setup_action_registrations(ctx: SetupActionRegistrationContext<'_>) -> anyhow::Result<()> {
837    let SetupActionRegistrationContext {
838        bundle_path,
839        discovered,
840        provider_id,
841        config,
842        bundle_name,
843        public_base_url,
844        answers,
845        actions,
846    } = ctx;
847
848    let Some(provider) = discovered.and_then(|d| d.find_setup_target(provider_id)) else {
849        if actions
850            .iter()
851            .any(|action| needs_setup_action_registration(action, answers))
852        {
853            anyhow::bail!("provider pack not found for setup action registration: {provider_id}");
854        }
855        return Ok(());
856    };
857
858    for action in actions {
859        if !needs_setup_action_registration(action, answers) {
860            // Registration may already have run in a prior setup pass (its
861            // `*_output` fields are already in `answers`), but `action.extra`
862            // is rebuilt fresh from the pack's setup.yaml on every run, so an
863            // `open_url` action's resolved `url` never carries over. Recompute
864            // it from the already-known answers instead of re-invoking
865            // registration.
866            if action.kind == crate::setup_actions::SetupActionKind::OpenUrl
867                && action.extra.get("registration").is_some()
868            {
869                resolve_open_url_action(action, answers)?;
870            }
871            continue;
872        }
873        let registration = action
874            .extra
875            .get("registration")
876            .cloned()
877            .ok_or_else(|| anyhow!("setup action registration metadata missing"))?;
878        let request = build_registration_request(
879            provider_id,
880            config,
881            bundle_name,
882            public_base_url,
883            answers,
884            action,
885            &registration,
886        )?;
887        let output = invoke_registration_operation(
888            bundle_path,
889            &provider.pack_path,
890            &registration,
891            &request,
892            config,
893        )
894        .with_context(|| {
895            format!(
896                "failed to run setup action registration {} for {}",
897                action.id, provider_id
898            )
899        })?;
900        if let Some(error) = registration_error_message(&output) {
901            anyhow::bail!(
902                "setup action registration {} returned an error: {}",
903                action.id,
904                error
905            );
906        }
907        merge_registration_output(action, answers, &registration, &output)?;
908        match action.kind {
909            crate::setup_actions::SetupActionKind::OauthInstallButton
910                if client_id_for_action(action, answers).is_none()
911                    && !authorize_url_has_query_key(
912                        action.authorize_url.as_deref(),
913                        "client_id",
914                    ) =>
915            {
916                anyhow::bail!(
917                    "setup action registration {} did not produce a client_id",
918                    action.id
919                );
920            }
921            crate::setup_actions::SetupActionKind::OpenUrl => {
922                resolve_open_url_action(action, answers)?;
923            }
924            _ => {}
925        }
926    }
927    Ok(())
928}
929
930fn needs_setup_action_registration(
931    action: &crate::setup_actions::SetupAction,
932    answers: &Value,
933) -> bool {
934    if action.extra.get("registration").is_none() {
935        return false;
936    }
937    match action.kind {
938        crate::setup_actions::SetupActionKind::OauthInstallButton => {
939            client_id_for_action(action, answers).is_none()
940                && !authorize_url_has_query_key(action.authorize_url.as_deref(), "client_id")
941        }
942        crate::setup_actions::SetupActionKind::OpenUrl => {
943            registration_output_missing(action, answers)
944        }
945        _ => false,
946    }
947}
948
949/// Resolve an `open_url` action's `url_template` (e.g.
950/// `https://api.slack.com/apps/{slack_app_id}/install-on-team?`) against the
951/// answers merged in by `merge_registration_output`, storing the result as
952/// `action.extra["url"]` for the setup UI to render as a plain link.
953fn resolve_open_url_action(
954    action: &mut crate::setup_actions::SetupAction,
955    answers: &Value,
956) -> anyhow::Result<()> {
957    let Some(template) = action
958        .extra
959        .get("url_template")
960        .and_then(Value::as_str)
961        .map(str::trim)
962        .filter(|value| !value.is_empty())
963    else {
964        return Ok(());
965    };
966    if !template.starts_with("https://") {
967        anyhow::bail!(
968            "setup action {} url_template must be an https:// URL",
969            action.id
970        );
971    }
972    let placeholder = regex::Regex::new(r"\{([A-Za-z0-9_.-]+)\}")
973        .expect("static url template placeholder regex is valid");
974    let answers_obj = answers.as_object();
975    let mut unresolved = false;
976    let resolved = placeholder.replace_all(template, |caps: &regex::Captures<'_>| {
977        let name = &caps[1];
978        let value = answers_obj
979            .and_then(|obj| obj.get(name))
980            .and_then(Value::as_str)
981            .map(str::trim)
982            .filter(|value| !value.is_empty());
983        match value {
984            Some(value) => percent_encode_url_component(value),
985            None => {
986                unresolved = true;
987                String::new()
988            }
989        }
990    });
991    if !unresolved {
992        action
993            .extra
994            .insert("url".into(), Value::String(resolved.into_owned()));
995    }
996    Ok(())
997}
998
999/// Percent-encodes a value for embedding in a URL path/query segment,
1000/// matching JavaScript's `encodeURIComponent` unreserved-character set.
1001fn percent_encode_url_component(value: &str) -> String {
1002    let mut out = String::with_capacity(value.len());
1003    for byte in value.bytes() {
1004        match byte {
1005            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1006                out.push(byte as char);
1007            }
1008            _ => out.push_str(&format!("%{byte:02X}")),
1009        }
1010    }
1011    out
1012}
1013
1014fn authorize_url_has_query_key(url: Option<&str>, key: &str) -> bool {
1015    url.and_then(|value| url::Url::parse(value).ok())
1016        .is_some_and(|parsed| parsed.query_pairs().any(|(candidate, _)| candidate == key))
1017}
1018
1019fn build_registration_request(
1020    provider_id: &str,
1021    config: &SetupConfig,
1022    bundle_name: Option<&str>,
1023    public_base_url: Option<&str>,
1024    answers: &Value,
1025    action: &crate::setup_actions::SetupAction,
1026    registration: &Value,
1027) -> anyhow::Result<Value> {
1028    let registration_obj = registration
1029        .as_object()
1030        .ok_or_else(|| anyhow!("setup action registration must be an object"))?;
1031    let answers_obj = answers
1032        .as_object()
1033        .ok_or_else(|| anyhow!("provider setup answers must be an object"))?;
1034    // For an OAuth developer-install action, the app manifest's `redirect_urls`
1035    // (written by this registration op) MUST equal the authorize `redirect_uri`
1036    // that `build_oauth_install_url` will later put in the install link — which is
1037    // the setup server's own callback base (`GREENTIC_SETUP_PUBLIC_BASE_URL`), not
1038    // the runtime's `public_base_url`. Prefer that base here so Slack sees a
1039    // redirect_uri it recognizes; otherwise the exchange fails with
1040    // `bad_redirect_uri` and no bot token is captured.
1041    let setup_callback_base =
1042        if action.kind == crate::setup_actions::SetupActionKind::OauthInstallButton {
1043            std::env::var("GREENTIC_SETUP_PUBLIC_BASE_URL")
1044                .ok()
1045                .map(|value| value.trim().trim_end_matches('/').to_string())
1046                .filter(|value| value.starts_with("https://"))
1047        } else {
1048            None
1049        };
1050    let effective_public_base_url =
1051        setup_callback_base
1052            .as_deref()
1053            .or(public_base_url)
1054            .or_else(|| {
1055                answers_obj
1056                    .get("public_base_url")
1057                    .and_then(Value::as_str)
1058                    .map(str::trim)
1059                    .filter(|value| !value.is_empty())
1060            });
1061    let effective_team = config.team.as_deref().unwrap_or("default");
1062    let mut input = JsonMap::new();
1063    input.insert("answers".into(), answers.clone());
1064    input.insert("provider_id".into(), Value::String(provider_id.to_string()));
1065    input.insert("tenant".into(), Value::String(config.tenant.clone()));
1066    input.insert("team".into(), Value::String(effective_team.to_string()));
1067    if let Some(public_base_url) = effective_public_base_url {
1068        input.insert(
1069            "public_base_url".into(),
1070            Value::String(public_base_url.to_string()),
1071        );
1072    }
1073    input.insert("action_id".into(), Value::String(action.id.clone()));
1074
1075    for (key, field_value) in registration_obj {
1076        let Some(input_name) = key.strip_suffix("_field") else {
1077            continue;
1078        };
1079        let Some(field_name) = field_value
1080            .as_str()
1081            .map(str::trim)
1082            .filter(|v| !v.is_empty())
1083        else {
1084            continue;
1085        };
1086        if let Some(value) = answers_obj
1087            .get(field_name)
1088            .filter(|value| !is_empty_value(value))
1089        {
1090            input.insert(field_name.to_string(), value.clone());
1091            input.insert(input_name.to_string(), value.clone());
1092        }
1093    }
1094
1095    if input.get("app_name").is_none()
1096        && let Some(app_name) = registration_app_name(action, bundle_name)
1097    {
1098        input.insert("app_name".into(), Value::String(app_name.clone()));
1099        if let Some(field_name) = registration_obj
1100            .get("app_name_field")
1101            .and_then(Value::as_str)
1102            .map(str::trim)
1103            .filter(|value| !value.is_empty())
1104        {
1105            input.insert(field_name.to_string(), Value::String(app_name));
1106        }
1107    }
1108
1109    let mut context = JsonMap::new();
1110    context.insert("provider_id".into(), Value::String(provider_id.to_string()));
1111    context.insert("tenant".into(), Value::String(config.tenant.clone()));
1112    context.insert("team".into(), Value::String(effective_team.to_string()));
1113    if let Some(public_base_url) = effective_public_base_url {
1114        context.insert(
1115            "public_base_url".into(),
1116            Value::String(public_base_url.to_string()),
1117        );
1118    }
1119    if let Some(app_name) = input.get("app_name") {
1120        context.insert("app_name".into(), app_name.clone());
1121    }
1122    input.insert("context".into(), Value::Object(context));
1123    Ok(Value::Object(input))
1124}
1125
1126fn registration_app_name(
1127    action: &crate::setup_actions::SetupAction,
1128    bundle_name: Option<&str>,
1129) -> Option<String> {
1130    let bundle_name = bundle_name
1131        .map(str::trim)
1132        .filter(|value| !value.is_empty())
1133        .unwrap_or("Greentic");
1134    if let Some(template) = action
1135        .extra
1136        .get("app_name_template")
1137        .and_then(Value::as_str)
1138        .map(str::trim)
1139        .filter(|value| !value.is_empty())
1140    {
1141        let rendered = template
1142            .replace("{{ bundle_name }}", bundle_name)
1143            .replace("{{bundle_name}}", bundle_name)
1144            .trim()
1145            .to_string();
1146        if !rendered.is_empty() {
1147            return Some(rendered);
1148        }
1149    }
1150    action
1151        .extra
1152        .get("default_app_name")
1153        .and_then(Value::as_str)
1154        .map(str::trim)
1155        .filter(|value| !value.is_empty())
1156        .map(ToString::to_string)
1157}
1158
1159fn invoke_registration_operation(
1160    bundle_path: &Path,
1161    pack_path: &Path,
1162    registration: &Value,
1163    request: &Value,
1164    config: &SetupConfig,
1165) -> anyhow::Result<Value> {
1166    let registration_obj = registration
1167        .as_object()
1168        .ok_or_else(|| anyhow!("setup component invocation must be an object"))?;
1169    let component_ref = registration_obj
1170        .get("component_ref")
1171        .and_then(Value::as_str)
1172        .map(str::trim)
1173        .filter(|value| !value.is_empty())
1174        .ok_or_else(|| anyhow!("setup component invocation missing component_ref"))?;
1175    let op = registration_obj
1176        .get("op")
1177        .and_then(Value::as_str)
1178        .map(str::trim)
1179        .filter(|value| !value.is_empty())
1180        .ok_or_else(|| anyhow!("setup component invocation missing op"))?;
1181
1182    if let Some(result) = registration_obj
1183        .get("result")
1184        .or_else(|| registration_obj.get("mock_result"))
1185        .or_else(|| registration_obj.get("outputs"))
1186    {
1187        return Ok(result.clone());
1188    }
1189
1190    if let Ok(component) = read_registration_component(pack_path, component_ref)
1191        && let Some(output) = invoke_json_registration_component(&component, op, request)
1192    {
1193        return Ok(output);
1194    }
1195
1196    invoke_wasm_registration_component(bundle_path, pack_path, component_ref, op, request, config)
1197}
1198
1199/// Resolve the Slack workspace (team) id for the bot token in `answers` via
1200/// `auth.test`, so `slack_app_url` can be pinned to the right workspace.
1201/// Returns `None` (best-effort) when there's no token, no `slack_app_url` to
1202/// enrich, the URL is already pinned, or the call fails.
1203fn slack_team_id_from_answers(answers: &Value) -> Option<String> {
1204    let obj = answers.as_object()?;
1205    let app_url = obj.get("slack_app_url").and_then(Value::as_str)?;
1206    if !app_url.contains("app_redirect") || app_url.contains("team=") {
1207        return None;
1208    }
1209    let token = obj
1210        .get("slack_bot_token")
1211        .or_else(|| obj.get("bot_token"))
1212        .and_then(Value::as_str)
1213        .map(str::trim)
1214        .filter(|t| t.starts_with("xoxb"))?;
1215    let mut response = crate::http_client::api_agent_any_status()
1216        .post("https://slack.com/api/auth.test")
1217        .header("Authorization", &format!("Bearer {token}"))
1218        .send_empty()
1219        .ok()?;
1220    let body: Value = response.body_mut().read_json().ok()?;
1221    if body.get("ok").and_then(Value::as_bool) != Some(true) {
1222        eprintln!(
1223            "[oauth-token] auth.test failed: {:?} — Add to Slack link stays unpinned",
1224            body.get("error").and_then(Value::as_str)
1225        );
1226        return None;
1227    }
1228    let team = body
1229        .get("team_id")
1230        .and_then(Value::as_str)
1231        .map(str::trim)
1232        .filter(|t| !t.is_empty())?
1233        .to_string();
1234    eprintln!("[oauth-token] auth.test ok: team_id={team} — pinning Add to Slack link");
1235    Some(team)
1236}
1237
1238/// Pin `slack_app_url` (an `app_redirect` link) to `team` and record
1239/// `slack_team_id`, so the final "Add to Slack" opens the bot's DM in the right
1240/// workspace instead of a workspace picker / app-not-found page.
1241fn apply_slack_team_to_app_url(answers: &mut Value, team: &str) {
1242    let Some(obj) = answers.as_object_mut() else {
1243        return;
1244    };
1245    if let Some(url) = obj
1246        .get("slack_app_url")
1247        .and_then(Value::as_str)
1248        .filter(|url| url.contains("app_redirect") && !url.contains("team="))
1249    {
1250        let separator = if url.contains('?') { '&' } else { '?' };
1251        let pinned = format!("{url}{separator}team={team}");
1252        obj.insert("slack_app_url".to_string(), Value::String(pinned));
1253    }
1254    obj.entry("slack_team_id".to_string())
1255        .or_insert_with(|| Value::String(team.to_string()));
1256}
1257
1258pub fn invoke_setup_component_operation(
1259    bundle_path: &Path,
1260    pack_path: &Path,
1261    component_ref: &str,
1262    op: &str,
1263    request: &Value,
1264    config: &SetupConfig,
1265) -> anyhow::Result<Value> {
1266    let registration = serde_json::json!({
1267        "component_ref": component_ref,
1268        "op": op,
1269    });
1270    invoke_registration_operation(bundle_path, pack_path, &registration, request, config)
1271}
1272
1273#[derive(Default)]
1274struct SetupRegistrationSecrets {
1275    /// Secrets written by the op during this invocation (e.g. freshly minted
1276    /// credentials). Takes precedence over the persisted store.
1277    values: Mutex<BTreeMap<String, Vec<u8>>>,
1278    /// Read-through to the bundle's persisted dev store so the op can recover
1279    /// previously-stored secrets on re-runs (e.g. the Slack signing secret,
1280    /// which Slack only returns when it *creates* an app — not on reuse).
1281    dev_store: Option<Arc<greentic_secrets_lib::DevStore>>,
1282}
1283
1284impl SetupRegistrationSecrets {
1285    fn with_dev_store(dev_store: Option<Arc<greentic_secrets_lib::DevStore>>) -> Self {
1286        Self {
1287            values: Mutex::new(BTreeMap::new()),
1288            dev_store,
1289        }
1290    }
1291}
1292
1293/// Candidate secret URIs to try when reading a previously-stored secret.
1294///
1295/// A secret URI is `secrets://<env>/<tenant>/<team>/<provider>/<key>`, and two
1296/// segments are normalized inconsistently across persist/read sites:
1297/// - **env** is branched between the current default [`crate::DEFAULT_ENV_ID`]
1298///   (`local`) and the legacy [`crate::LEGACY_ENV_ID`] (`dev`); a value stored
1299///   under one may be read under the other.
1300/// - **provider** is branched between the hyphenated pack id (`messaging-slack`)
1301///   and the underscore-normalized form the store uses (`messaging_slack`).
1302///
1303/// Emit the path as-is plus the env × provider variants so reuse can recover a
1304/// value regardless of which normalization was used when it was written.
1305fn secret_uri_candidates(path: &str) -> Vec<String> {
1306    let mut out = vec![path.to_string()];
1307    let Some(rest) = path.strip_prefix("secrets://") else {
1308        return out;
1309    };
1310    let segs: Vec<&str> = rest.splitn(5, '/').collect();
1311    if segs.len() != 5 {
1312        return out;
1313    }
1314    let (env, tenant, team, provider, key) = (segs[0], segs[1], segs[2], segs[3], segs[4]);
1315
1316    let env_variants: Vec<&str> = if env == crate::DEFAULT_ENV_ID {
1317        vec![crate::DEFAULT_ENV_ID, crate::LEGACY_ENV_ID]
1318    } else if env == crate::LEGACY_ENV_ID {
1319        vec![crate::LEGACY_ENV_ID, crate::DEFAULT_ENV_ID]
1320    } else {
1321        vec![env]
1322    };
1323    let mut provider_variants = vec![provider.to_string()];
1324    for alt in [provider.replace('-', "_"), provider.replace('_', "-")] {
1325        if alt != provider && !provider_variants.contains(&alt) {
1326            provider_variants.push(alt);
1327        }
1328    }
1329
1330    for env in &env_variants {
1331        for provider in &provider_variants {
1332            let candidate = format!("secrets://{env}/{tenant}/{team}/{provider}/{key}");
1333            if !out.contains(&candidate) {
1334                out.push(candidate);
1335            }
1336        }
1337    }
1338    out
1339}
1340
1341#[async_trait::async_trait]
1342impl greentic_secrets_lib::SecretsManager for SetupRegistrationSecrets {
1343    async fn read(&self, path: &str) -> greentic_secrets_lib::Result<Vec<u8>> {
1344        {
1345            let values = self.values.lock().map_err(|_| {
1346                greentic_secrets_lib::SecretError::Backend(
1347                    "setup component secrets lock poisoned".into(),
1348                )
1349            })?;
1350            if let Some(value) = values.get(path) {
1351                return Ok(value.clone());
1352            }
1353        }
1354        if let Some(store) = &self.dev_store {
1355            use greentic_secrets_lib::SecretsStore;
1356            for candidate in secret_uri_candidates(path) {
1357                match store.get(&candidate).await {
1358                    Ok(bytes) => return Ok(bytes),
1359                    Err(err) => {
1360                        tracing::debug!(candidate, error = %err, "setup secrets read miss");
1361                    }
1362                }
1363            }
1364        }
1365        Err(greentic_secrets_lib::SecretError::NotFound(
1366            path.to_string(),
1367        ))
1368    }
1369
1370    async fn write(&self, path: &str, bytes: &[u8]) -> greentic_secrets_lib::Result<()> {
1371        let mut values = self.values.lock().map_err(|_| {
1372            greentic_secrets_lib::SecretError::Backend(
1373                "setup component secrets lock poisoned".into(),
1374            )
1375        })?;
1376        values.insert(path.to_string(), bytes.to_vec());
1377        Ok(())
1378    }
1379
1380    async fn delete(&self, path: &str) -> greentic_secrets_lib::Result<()> {
1381        let mut values = self.values.lock().map_err(|_| {
1382            greentic_secrets_lib::SecretError::Backend(
1383                "setup component secrets lock poisoned".into(),
1384            )
1385        })?;
1386        values.remove(path);
1387        Ok(())
1388    }
1389}
1390
1391fn invoke_wasm_registration_component(
1392    bundle_path: &Path,
1393    pack_path: &Path,
1394    component_ref: &str,
1395    op: &str,
1396    request: &Value,
1397    config: &SetupConfig,
1398) -> anyhow::Result<Value> {
1399    use greentic_runner_host::component_api::node::{
1400        ExecCtx as ComponentExecCtx, TenantCtx as ComponentTenantCtx,
1401    };
1402    use greentic_runner_host::config::{OperatorPolicy, SecretsPolicy};
1403    use greentic_runner_host::pack::{ComponentResolution, PackRuntime};
1404    use greentic_runner_host::provider::ProviderBinding;
1405    use greentic_runner_host::storage::{new_session_store, new_state_store};
1406    use greentic_runner_host::{HostConfig, RunnerWasiPolicy};
1407    use std::sync::Arc;
1408
1409    let bindings_path = bundle_path
1410        .join("state")
1411        .join("config")
1412        .join("setup-component-bindings.yaml");
1413    if let Some(parent) = bindings_path.parent() {
1414        std::fs::create_dir_all(parent)?;
1415    }
1416    std::fs::write(
1417        &bindings_path,
1418        format!(
1419            r#"tenant: {}
1420flow_type_bindings:
1421  messaging:
1422    adapter: setup-component
1423    config: {{}}
1424    secrets: []
1425rate_limits: {{}}
1426retry: {{}}
1427timers: []
1428"#,
1429            config.tenant
1430        ),
1431    )
1432    .with_context(|| format!("write {}", bindings_path.display()))?;
1433
1434    let mut host_config = HostConfig::load_from_path(&bindings_path)
1435        .with_context(|| format!("load {}", bindings_path.display()))?;
1436    host_config.secrets_policy = SecretsPolicy::allow_all();
1437    host_config.operator_policy = OperatorPolicy::allow_all();
1438    let host_config = Arc::new(host_config);
1439
1440    let session_store = new_session_store();
1441    let state_store = new_state_store();
1442    // greentic-runner-host's provider-core-only enforcement defaults to ON and
1443    // denies every component secrets_store::get/put BEFORE consulting the secrets
1444    // manager. greentic-start disables it at startup; mirror that here or
1445    // registration ops can never read stored secrets (e.g. Slack's signing secret
1446    // or configuration tokens on app reuse) and fail with misleading
1447    // "<secret> is required" errors.
1448    unsafe { std::env::set_var("GREENTIC_PROVIDER_CORE_ONLY", "0") };
1449
1450    // Back the setup-component secrets with the bundle's persisted dev store so
1451    // registration ops can recover previously-stored secrets on re-runs (e.g. the
1452    // Slack signing secret on app reuse). Best-effort: an unreadable store just
1453    // means the op sees only what it writes this invocation (prior behavior).
1454    let dev_store = crate::secrets::open_dev_store(bundle_path)
1455        .ok()
1456        .map(Arc::new);
1457    let secrets: greentic_runner_host::secrets::DynSecretsManager =
1458        Arc::new(SetupRegistrationSecrets::with_dev_store(dev_store));
1459    let pack = greentic_runner_host::runtime::block_on(PackRuntime::load(
1460        pack_path,
1461        Arc::clone(&host_config),
1462        None,
1463        Some(pack_path),
1464        Some(Arc::clone(&session_store)),
1465        Some(Arc::clone(&state_store)),
1466        Arc::new(RunnerWasiPolicy::default()),
1467        secrets,
1468        None,
1469        false,
1470        ComponentResolution::default(),
1471    ))
1472    .with_context(|| format!("load setup component pack {}", pack_path.display()))?;
1473
1474    let exec_ctx = ComponentExecCtx {
1475        tenant: ComponentTenantCtx {
1476            tenant: config.tenant.clone(),
1477            team: config.team.clone(),
1478            user: None,
1479            trace_id: None,
1480            i18n_id: None,
1481            correlation_id: Some(format!("setup-component:{component_ref}:{op}")),
1482            deadline_unix_ms: None,
1483            attempt: 1,
1484            idempotency_key: Some(format!("setup-component:{component_ref}:{op}")),
1485        },
1486        i18n_id: None,
1487        flow_id: format!("setup-component/{op}"),
1488        node_id: Some(component_ref.to_string()),
1489    };
1490    let input_json = serde_json::to_vec(request)?;
1491    let binding = ProviderBinding {
1492        provider_id: Some(component_ref.to_string()),
1493        provider_type: component_ref.to_string(),
1494        component_ref: component_ref.to_string(),
1495        export: "schema-core-api".to_string(),
1496        world: "greentic:provider/schema-core@1.0.0".to_string(),
1497        config_json: None,
1498        pack_ref: None,
1499    };
1500    match greentic_runner_host::runtime::block_on(pack.invoke_provider(
1501        &binding,
1502        exec_ctx.clone(),
1503        op,
1504        input_json,
1505    )) {
1506        Ok(output) => Ok(output),
1507        Err(provider_err) => {
1508            let input_json = serde_json::to_string(request)?;
1509            greentic_runner_host::runtime::block_on(pack.invoke_component(
1510                component_ref,
1511                exec_ctx,
1512                op,
1513                None,
1514                input_json,
1515            ))
1516            .with_context(|| {
1517                format!(
1518                    "invoke setup component '{component_ref}' op '{op}' (provider path failed: {provider_err})"
1519                )
1520            })
1521        }
1522    }
1523}
1524
1525fn read_registration_component(pack_path: &Path, component_ref: &str) -> anyhow::Result<Value> {
1526    let file = File::open(pack_path).with_context(|| format!("open {}", pack_path.display()))?;
1527    let mut archive = match ZipArchive::new(file) {
1528        Ok(archive) => archive,
1529        Err(ZipError::InvalidArchive(_)) | Err(ZipError::UnsupportedArchive(_)) => {
1530            anyhow::bail!("{} is not a zip pack", pack_path.display())
1531        }
1532        Err(err) => return Err(err.into()),
1533    };
1534    let candidates = registration_component_candidates(component_ref);
1535    for candidate in candidates {
1536        match archive.by_name(&candidate) {
1537            Ok(mut entry) => {
1538                let mut raw = String::new();
1539                entry
1540                    .read_to_string(&mut raw)
1541                    .with_context(|| format!("read setup component {candidate}"))?;
1542                return serde_json::from_str(&raw)
1543                    .or_else(|_| serde_yaml_bw::from_str(&raw))
1544                    .with_context(|| format!("parse setup component {candidate}"));
1545            }
1546            Err(ZipError::FileNotFound) => continue,
1547            Err(err) => return Err(err.into()),
1548        }
1549    }
1550    anyhow::bail!(
1551        "setup component_ref '{}' not found in {}",
1552        component_ref,
1553        pack_path.display()
1554    )
1555}
1556
1557fn registration_component_candidates(component_ref: &str) -> Vec<String> {
1558    let trimmed = component_ref.trim().trim_start_matches("./");
1559    let mut candidates = vec![trimmed.to_string()];
1560    if !trimmed.ends_with(".json") && !trimmed.ends_with(".yaml") && !trimmed.ends_with(".yml") {
1561        candidates.push(format!("{trimmed}.json"));
1562        candidates.push(format!("components/{trimmed}.json"));
1563        candidates.push(format!("assets/{trimmed}.json"));
1564        candidates.push(format!("assets/components/{trimmed}.json"));
1565    }
1566    candidates.sort();
1567    candidates.dedup();
1568    candidates
1569}
1570
1571fn invoke_json_registration_component(
1572    component: &Value,
1573    op: &str,
1574    request: &Value,
1575) -> Option<Value> {
1576    let obj = component.as_object()?;
1577    if let Some(operations) = obj.get("operations").and_then(Value::as_object)
1578        && let Some(operation) = operations.get(op)
1579    {
1580        return operation_result(operation, request);
1581    }
1582    if let Some(ops) = obj.get("ops").and_then(Value::as_array) {
1583        for operation in ops {
1584            if operation.get("op").and_then(Value::as_str) == Some(op)
1585                || operation.get("name").and_then(Value::as_str) == Some(op)
1586                || operation.get("id").and_then(Value::as_str) == Some(op)
1587            {
1588                return operation_result(operation, request);
1589            }
1590        }
1591    }
1592    obj.get(op)
1593        .and_then(|operation| operation_result(operation, request))
1594}
1595
1596fn operation_result(operation: &Value, request: &Value) -> Option<Value> {
1597    if let Some(result) = operation
1598        .get("result")
1599        .or_else(|| operation.get("output"))
1600        .or_else(|| operation.get("outputs"))
1601    {
1602        return Some(result.clone());
1603    }
1604    if operation.get("echo_request").and_then(Value::as_bool) == Some(true) {
1605        return Some(request.clone());
1606    }
1607    if operation.is_object() {
1608        return Some(operation.clone());
1609    }
1610    None
1611}
1612
1613fn merge_registration_output(
1614    action: &mut crate::setup_actions::SetupAction,
1615    answers: &mut Value,
1616    registration: &Value,
1617    output: &Value,
1618) -> anyhow::Result<()> {
1619    let registration_obj = registration
1620        .as_object()
1621        .ok_or_else(|| anyhow!("setup action registration must be an object"))?;
1622    let output_obj = output
1623        .as_object()
1624        .ok_or_else(|| anyhow!("setup action registration output must be an object"))?;
1625    let answers_obj = answers
1626        .as_object_mut()
1627        .ok_or_else(|| anyhow!("provider setup answers must be an object"))?;
1628
1629    for (mapping_key, source_value) in registration_obj {
1630        let Some(generic_key) = mapping_key.strip_suffix("_output") else {
1631            continue;
1632        };
1633        let Some(source_key) = source_value
1634            .as_str()
1635            .map(str::trim)
1636            .filter(|value| !value.is_empty())
1637        else {
1638            continue;
1639        };
1640        let Some(value) = output_obj
1641            .get(source_key)
1642            .or_else(|| output_obj.get(generic_key))
1643            .filter(|value| !is_empty_value(value))
1644            .cloned()
1645        else {
1646            continue;
1647        };
1648        answers_obj.insert(source_key.to_string(), value.clone());
1649        answers_obj.insert(generic_key.to_string(), value.clone());
1650        if generic_key == "client_id" {
1651            if let Some(client_id_field) =
1652                action.extra.get("client_id_field").and_then(Value::as_str)
1653            {
1654                answers_obj.insert(client_id_field.to_string(), value.clone());
1655            }
1656            action.extra.insert("client_id".into(), value);
1657        } else {
1658            action.extra.insert(generic_key.to_string(), value);
1659        }
1660    }
1661    Ok(())
1662}
1663
1664fn registration_error_message(output: &Value) -> Option<String> {
1665    if output.get("ok").and_then(Value::as_bool) == Some(false) {
1666        return output
1667            .get("error")
1668            .and_then(Value::as_str)
1669            .map(ToString::to_string)
1670            .or_else(|| Some(output.to_string()));
1671    }
1672    None
1673}
1674
1675fn is_empty_value(value: &Value) -> bool {
1676    match value {
1677        Value::Null => true,
1678        Value::String(value) => value.trim().is_empty(),
1679        Value::Array(values) => values.is_empty(),
1680        Value::Object(values) => values.is_empty(),
1681        Value::Bool(_) | Value::Number(_) => false,
1682    }
1683}
1684
1685fn hydrate_oauth_install_actions(
1686    actions: &mut [crate::setup_actions::SetupAction],
1687    answers: &Value,
1688) {
1689    for action in actions {
1690        if action.kind != crate::setup_actions::SetupActionKind::OauthInstallButton {
1691            continue;
1692        }
1693        let client_id = client_id_for_action(action, answers);
1694        let Some(authorize_url) = action.authorize_url.as_mut() else {
1695            continue;
1696        };
1697        let Ok(mut parsed) = url::Url::parse(authorize_url) else {
1698            continue;
1699        };
1700        if !parsed.query_pairs().any(|(key, _)| key == "client_id")
1701            && let Some(client_id) = client_id
1702        {
1703            parsed
1704                .query_pairs_mut()
1705                .append_pair("client_id", &client_id);
1706        }
1707        if !parsed.query_pairs().any(|(key, _)| key == "scope")
1708            && let Some(scopes) = action.extra.get("scopes").and_then(Value::as_array)
1709        {
1710            let scope = scopes
1711                .iter()
1712                .filter_map(Value::as_str)
1713                .map(str::trim)
1714                .filter(|value| !value.is_empty())
1715                .collect::<Vec<_>>()
1716                .join(",");
1717            if !scope.is_empty() {
1718                parsed.query_pairs_mut().append_pair("scope", &scope);
1719            }
1720        }
1721        *authorize_url = parsed.to_string();
1722    }
1723}
1724
1725fn client_id_for_action(
1726    action: &crate::setup_actions::SetupAction,
1727    answers: &Value,
1728) -> Option<String> {
1729    let obj = answers.as_object()?;
1730    let mut keys = Vec::new();
1731    if let Some(field) = action.extra.get("client_id_field").and_then(Value::as_str) {
1732        keys.push(field);
1733    }
1734    keys.extend(["client_id", "oauth_client_id"]);
1735    keys.into_iter().find_map(|key| {
1736        obj.get(key)
1737            .and_then(Value::as_str)
1738            .map(str::trim)
1739            .filter(|value| !value.is_empty())
1740            .map(ToString::to_string)
1741    })
1742}
1743
1744fn compute_file_digest(path: &Path) -> anyhow::Result<String> {
1745    let bytes = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
1746    let digest = Sha256::digest(bytes);
1747    let encoded = digest
1748        .iter()
1749        .map(|byte| format!("{byte:02x}"))
1750        .collect::<String>();
1751    Ok(format!("sha256:{encoded}"))
1752}
1753
1754fn resolve_pack_ref(pack_ref: &str) -> anyhow::Result<PathBuf> {
1755    let source = BundleSource::parse(pack_ref)?;
1756    let resolved = source.resolve()?;
1757
1758    if resolved.extension().and_then(|ext| ext.to_str()) != Some("gtpack") {
1759        anyhow::bail!(
1760            "resolved pack ref is not a .gtpack file: {}",
1761            resolved.display()
1762        );
1763    }
1764
1765    Ok(resolved)
1766}
1767
1768/// Remove provider artifacts and config directories.
1769pub fn execute_remove_provider_artifacts(
1770    bundle_path: &Path,
1771    providers_remove: &[String],
1772) -> anyhow::Result<usize> {
1773    let mut removed = 0usize;
1774    let discovered = discovery::discover(bundle_path).ok();
1775    for provider_id in providers_remove {
1776        if let Some(discovered) = discovered.as_ref()
1777            && let Some(provider) = discovered
1778                .providers
1779                .iter()
1780                .find(|provider| provider.provider_id == *provider_id)
1781        {
1782            if provider.pack_path.exists() {
1783                std::fs::remove_file(&provider.pack_path).with_context(|| {
1784                    format!(
1785                        "failed to remove provider pack {}",
1786                        provider.pack_path.display()
1787                    )
1788                })?;
1789            }
1790            removed += 1;
1791        } else {
1792            let target_dir = get_pack_target_dir(bundle_path, provider_id);
1793            let target_path = target_dir.join(format!("{provider_id}.gtpack"));
1794            if target_path.exists() {
1795                std::fs::remove_file(&target_path).with_context(|| {
1796                    format!("failed to remove provider pack {}", target_path.display())
1797                })?;
1798                removed += 1;
1799            }
1800        }
1801
1802        let config_dir = bundle_path.join("state").join("config").join(provider_id);
1803        if config_dir.exists() {
1804            std::fs::remove_dir_all(&config_dir).with_context(|| {
1805                format!(
1806                    "failed to remove provider config dir {}",
1807                    config_dir.display()
1808                )
1809            })?;
1810        }
1811    }
1812    Ok(removed)
1813}
1814
1815/// Search sibling bundles for provider packs referenced in setup_answers
1816/// and install them into this bundle if missing.
1817///
1818/// "Missing" is determined by pack_id, not filename: a pack file with any
1819/// filename that declares the matching pack_id in its manifest counts as
1820/// already installed. Otherwise a custom-named pack (e.g. a tenant-specific
1821/// build placed alongside the canonical name) gets clobbered every time
1822/// setup runs.
1823pub fn auto_install_provider_packs(bundle_path: &Path, metadata: &SetupPlanMetadata) {
1824    let bundle_abs =
1825        std::fs::canonicalize(bundle_path).unwrap_or_else(|_| bundle_path.to_path_buf());
1826
1827    let installed_ids: std::collections::HashSet<String> = discovery::discover(bundle_path)
1828        .map(|d| {
1829            d.providers
1830                .into_iter()
1831                .chain(d.app_packs)
1832                .map(|p| p.provider_id)
1833                .collect()
1834        })
1835        .unwrap_or_default();
1836
1837    for provider_id in metadata.setup_answers.keys() {
1838        if installed_ids.contains(provider_id) {
1839            continue;
1840        }
1841        let target_dir = get_pack_target_dir(bundle_path, provider_id);
1842        let target_path = target_dir.join(format!("{provider_id}.gtpack"));
1843        if target_path.exists() {
1844            continue;
1845        }
1846
1847        // Determine the provider domain from the ID
1848        let domain = domain_from_provider_id(provider_id);
1849
1850        // Search for the pack in sibling bundles and build output
1851        if let Some(source) = find_provider_pack_source(provider_id, domain, &bundle_abs) {
1852            if let Err(err) = std::fs::create_dir_all(&target_dir) {
1853                eprintln!(
1854                    "  [provider] WARNING: failed to create {}: {err}",
1855                    target_dir.display()
1856                );
1857                continue;
1858            }
1859            match std::fs::copy(&source, &target_path) {
1860                Ok(_) => println!(
1861                    "  [provider] installed {provider_id}.gtpack from {}",
1862                    source.display()
1863                ),
1864                Err(err) => eprintln!(
1865                    "  [provider] WARNING: failed to copy {}: {err}",
1866                    source.display()
1867                ),
1868            }
1869        } else {
1870            eprintln!("  [provider] WARNING: {provider_id}.gtpack not found in sibling bundles");
1871        }
1872    }
1873}
1874
1875/// Extract domain from a provider ID (e.g. "messaging-telegram" → "messaging").
1876pub fn domain_from_provider_id(provider_id: &str) -> &str {
1877    const DOMAIN_PREFIXES: &[&str] = &[
1878        "messaging-",
1879        "events-",
1880        "oauth-",
1881        "secrets-",
1882        "mcp-",
1883        "state-",
1884        "telemetry-",
1885    ];
1886    for prefix in DOMAIN_PREFIXES {
1887        if provider_id.starts_with(prefix) {
1888            return prefix.trim_end_matches('-');
1889        }
1890    }
1891    "messaging" // default
1892}
1893
1894/// Search known locations for a provider pack file.
1895///
1896/// Search order:
1897/// 1. Sibling bundle directories: `../<bundle>/providers/<domain>/<id>.gtpack`
1898/// 2. Build output: `../greentic-messaging-providers/target/packs/<id>.gtpack`
1899pub fn find_provider_pack_source(
1900    provider_id: &str,
1901    domain: &str,
1902    bundle_abs: &Path,
1903) -> Option<PathBuf> {
1904    let parent = bundle_abs.parent()?;
1905    let filename = format!("{provider_id}.gtpack");
1906
1907    // 1. Sibling bundles
1908    if let Ok(entries) = std::fs::read_dir(parent) {
1909        for entry in entries.flatten() {
1910            let sibling = entry.path();
1911            if sibling == *bundle_abs || !sibling.is_dir() {
1912                continue;
1913            }
1914            let candidate = sibling.join("providers").join(domain).join(&filename);
1915            if candidate.is_file() {
1916                return Some(candidate);
1917            }
1918        }
1919    }
1920
1921    // 2. Build output from greentic-messaging-providers
1922    for ancestor in parent.ancestors().take(4) {
1923        let candidate = ancestor
1924            .join("greentic-messaging-providers")
1925            .join("target")
1926            .join("packs")
1927            .join(&filename);
1928        if candidate.is_file() {
1929            return Some(candidate);
1930        }
1931    }
1932
1933    None
1934}
1935
1936/// Execute the WriteGmapRules step.
1937pub fn execute_write_gmap_rules(
1938    bundle_path: &Path,
1939    metadata: &SetupPlanMetadata,
1940) -> anyhow::Result<()> {
1941    for tenant_sel in &metadata.tenants {
1942        let gmap_path =
1943            bundle::gmap_path(bundle_path, &tenant_sel.tenant, tenant_sel.team.as_deref());
1944
1945        if let Some(parent) = gmap_path.parent() {
1946            std::fs::create_dir_all(parent)?;
1947        }
1948
1949        // Build gmap content from allow_paths
1950        let mut content = String::new();
1951        if tenant_sel.allow_paths.is_empty() {
1952            content.push_str("_ = forbidden\n");
1953        } else {
1954            for path in &tenant_sel.allow_paths {
1955                content.push_str(&format!("{} = allowed\n", path));
1956            }
1957            content.push_str("_ = forbidden\n");
1958        }
1959
1960        std::fs::write(&gmap_path, content)
1961            .with_context(|| format!("failed to write gmap: {}", gmap_path.display()))?;
1962    }
1963    Ok(())
1964}
1965
1966/// Execute the CopyResolvedManifest step.
1967pub fn execute_copy_resolved_manifests(
1968    bundle_path: &Path,
1969    metadata: &SetupPlanMetadata,
1970) -> anyhow::Result<Vec<PathBuf>> {
1971    let mut manifests = Vec::new();
1972    let resolved_dir = bundle_path.join("resolved");
1973    std::fs::create_dir_all(&resolved_dir)?;
1974
1975    for tenant_sel in &metadata.tenants {
1976        let filename =
1977            bundle::resolved_manifest_filename(&tenant_sel.tenant, tenant_sel.team.as_deref());
1978        let manifest_path = resolved_dir.join(&filename);
1979
1980        // Create an empty manifest placeholder if it doesn't exist
1981        if !manifest_path.exists() {
1982            std::fs::write(&manifest_path, "# Resolved manifest placeholder\n")?;
1983        }
1984        manifests.push(manifest_path);
1985    }
1986
1987    Ok(manifests)
1988}
1989
1990/// Execute the ValidateBundle step.
1991pub fn execute_validate_bundle(bundle_path: &Path) -> anyhow::Result<()> {
1992    bundle::validate_bundle_exists(bundle_path)
1993}
1994
1995/// Execute the BuildFlowIndex step.
1996///
1997/// Scans all flows in the bundle, builds a TF-IDF index and a routing-compatible
1998/// index, and optionally generates intents.md documentation.
1999/// Output is written to `bundle/state/indexes/`.
2000///
2001/// Requires the `fast2flow` feature AND the `fast2flow-bundle` crate wired as a
2002/// dependency.  Until `fast2flow-bundle` is published or vendored, this is a
2003/// no-op stub that logs a skip message.
2004pub fn execute_build_flow_index(_bundle_path: &Path, _config: &SetupConfig) -> anyhow::Result<()> {
2005    tracing::debug!("fast2flow indexing skipped (fast2flow-bundle not available)");
2006    Ok(())
2007}
2008
2009#[cfg(test)]
2010mod tests {
2011    use super::*;
2012    use crate::platform_setup::StaticRoutesPolicy;
2013    use std::collections::BTreeSet;
2014
2015    #[test]
2016    fn secret_uri_candidates_cover_env_and_provider_branches() {
2017        // Stored under the current default env + underscore provider; a read that
2018        // resolves to the legacy env + hyphenated provider must still find it.
2019        let cands =
2020            secret_uri_candidates("secrets://dev/demo/_/messaging-slack/slack_signing_secret");
2021        assert!(
2022            cands
2023                .contains(&"secrets://dev/demo/_/messaging-slack/slack_signing_secret".to_string())
2024        );
2025        assert!(
2026            cands.contains(
2027                &"secrets://local/demo/_/messaging_slack/slack_signing_secret".to_string()
2028            )
2029        );
2030        assert!(
2031            cands.contains(
2032                &"secrets://local/demo/_/messaging-slack/slack_signing_secret".to_string()
2033            )
2034        );
2035        assert!(
2036            cands
2037                .contains(&"secrets://dev/demo/_/messaging_slack/slack_signing_secret".to_string())
2038        );
2039        // First candidate is always the path as-is.
2040        assert_eq!(
2041            cands[0],
2042            "secrets://dev/demo/_/messaging-slack/slack_signing_secret"
2043        );
2044    }
2045
2046    #[test]
2047    fn secret_uri_candidates_do_not_alias_custom_env() {
2048        let cands = secret_uri_candidates("secrets://prod/acme/team/messaging_slack/bot_token");
2049        // Provider still branches, but a non-default/non-legacy env is never
2050        // remapped to dev/local.
2051        assert!(cands.contains(&"secrets://prod/acme/team/messaging_slack/bot_token".to_string()));
2052        assert!(cands.contains(&"secrets://prod/acme/team/messaging-slack/bot_token".to_string()));
2053        assert!(
2054            !cands
2055                .iter()
2056                .any(|c| c.contains("/dev/") || c.contains("/local/"))
2057        );
2058    }
2059
2060    #[test]
2061    fn secret_uri_candidates_passthrough_non_secret_paths() {
2062        assert_eq!(
2063            secret_uri_candidates("SLACK_BOT_TOKEN"),
2064            vec!["SLACK_BOT_TOKEN".to_string()]
2065        );
2066    }
2067
2068    fn empty_metadata(pack_refs: Vec<String>) -> SetupPlanMetadata {
2069        SetupPlanMetadata {
2070            bundle_name: None,
2071            pack_refs,
2072            tenants: Vec::new(),
2073            default_assignments: Vec::new(),
2074            providers: Vec::new(),
2075            update_ops: BTreeSet::new(),
2076            remove_targets: BTreeSet::new(),
2077            packs_remove: Vec::new(),
2078            providers_remove: Vec::new(),
2079            tenants_remove: Vec::new(),
2080            access_changes: Vec::new(),
2081            static_routes: StaticRoutesPolicy::default(),
2082            deployment_targets: Vec::new(),
2083            setup_answers: serde_json::Map::new(),
2084            tunnel: None,
2085            telemetry: None,
2086        }
2087    }
2088
2089    #[test]
2090    fn resolve_packs_errors_when_any_pack_ref_fails() {
2091        let metadata = empty_metadata(vec!["/definitely/missing/example.gtpack".to_string()]);
2092        let err = execute_resolve_packs(Path::new("."), &metadata).unwrap_err();
2093        let message = err.to_string();
2094
2095        assert!(message.contains("failed to resolve 1 pack ref"));
2096        assert!(message.contains("/definitely/missing/example.gtpack"));
2097    }
2098
2099    /// Regression: a custom-named pack whose manifest declares the matching
2100    /// pack_id must satisfy `auto_install_provider_packs`. Filename-only
2101    /// detection caused tenant-specific builds (e.g. `*-3aigent.gtpack`) to
2102    /// be clobbered by the canonical name on every setup run.
2103    #[test]
2104    fn auto_install_skips_when_pack_id_matches_under_custom_filename() {
2105        use std::io::Write;
2106        use zip::write::{FileOptions, ZipWriter};
2107
2108        let temp = tempfile::tempdir().expect("tempdir");
2109        let bundle = temp.path().join("bundle");
2110        let messaging_dir = bundle.join("providers").join("messaging");
2111        std::fs::create_dir_all(&messaging_dir).expect("create messaging dir");
2112
2113        let custom_pack = messaging_dir.join("messaging-webchat-gui-3aigent.gtpack");
2114        let file = std::fs::File::create(&custom_pack).expect("create pack file");
2115        let mut writer = ZipWriter::new(file);
2116        let options: FileOptions<'_, ()> =
2117            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
2118        writer
2119            .start_file("pack.manifest.json", options)
2120            .expect("start manifest");
2121        writer
2122            .write_all(
2123                serde_json::json!({
2124                    "pack_id": "messaging-webchat-gui",
2125                    "display_name": "WebChat GUI",
2126                })
2127                .to_string()
2128                .as_bytes(),
2129            )
2130            .expect("write manifest");
2131        writer.finish().expect("finish zip");
2132
2133        let canonical_pack = messaging_dir.join("messaging-webchat-gui.gtpack");
2134        assert!(!canonical_pack.exists(), "precondition: canonical absent");
2135
2136        let mut metadata = empty_metadata(vec![]);
2137        metadata.setup_answers.insert(
2138            "messaging-webchat-gui".to_string(),
2139            serde_json::Value::Object(serde_json::Map::new()),
2140        );
2141
2142        auto_install_provider_packs(&bundle, &metadata);
2143
2144        assert!(
2145            custom_pack.exists(),
2146            "custom-named pack must be left in place"
2147        );
2148        assert!(
2149            !canonical_pack.exists(),
2150            "must not auto-install canonical-named duplicate when pack_id already present"
2151        );
2152    }
2153
2154    fn secret_keys_for(keys: &[&str]) -> BTreeSet<String> {
2155        keys.iter()
2156            .map(|k| crate::secret_name::canonical_secret_name(k))
2157            .collect()
2158    }
2159
2160    #[test]
2161    fn envelope_redaction_replaces_secret_values_with_canonical_uri_refs() {
2162        let secret_keys = secret_keys_for(&["api_key", "oauth_client_secret"]);
2163
2164        let answers = serde_json::json!({
2165            "model": "gpt-4o-mini",
2166            "api_key": "sk-PLAINTEXT-MUST-NOT-LEAK",
2167            "oauth_client_secret": "PLAINTEXT-OAUTH-SECRET",
2168            "non_secret_url": "https://api.openai.com/v1"
2169        });
2170
2171        let redacted = redact_secret_answer_values_to_uri_refs(
2172            &answers,
2173            &secret_keys,
2174            "dev",
2175            "demo",
2176            Some("default"),
2177            "openai-llm",
2178        );
2179
2180        let map = redacted.as_object().expect("object");
2181        assert_eq!(map["model"].as_str(), Some("gpt-4o-mini"));
2182        assert_eq!(
2183            map["non_secret_url"].as_str(),
2184            Some("https://api.openai.com/v1")
2185        );
2186        // `canonical_secret_uri` collapses the literal "default" team into
2187        // the wildcard segment `_` (via `greentic_secrets_lib::normalize_team`).
2188        assert_eq!(
2189            map["api_key"].as_str(),
2190            Some("secrets://dev/demo/_/openai_llm/api_key"),
2191            "secret value must be replaced with canonical secrets:// URI",
2192        );
2193        assert_eq!(
2194            map["oauth_client_secret"].as_str(),
2195            Some("secrets://dev/demo/_/openai_llm/oauth_client_secret"),
2196        );
2197
2198        let json = serde_json::to_string(&redacted).expect("serialize");
2199        assert!(
2200            !json.contains("PLAINTEXT-MUST-NOT-LEAK"),
2201            "api_key plaintext leaked into envelope JSON: {json}",
2202        );
2203        assert!(
2204            !json.contains("PLAINTEXT-OAUTH-SECRET"),
2205            "oauth_client_secret plaintext leaked into envelope JSON: {json}",
2206        );
2207    }
2208
2209    #[test]
2210    fn setup_answers_redaction_drops_secret_keys_entirely() {
2211        // setup-answers.json's downstream readers in greentic-start skip
2212        // secret-marked keys (PR #179) and fetch from `SecretsManager`
2213        // instead, so the producer drops them from this artifact — no
2214        // value or URI ref appears in the JSON value slot.
2215        let secret_keys = secret_keys_for(&["api_key"]);
2216        let answers = serde_json::json!({
2217            "model": "gpt-4o-mini",
2218            "api_key": "sk-PLAINTEXT-MUST-NOT-LEAK"
2219        });
2220
2221        let stripped = strip_secret_answer_keys(&answers, &secret_keys);
2222        let map = stripped.as_object().expect("object");
2223        assert_eq!(map["model"].as_str(), Some("gpt-4o-mini"));
2224        assert!(
2225            !map.contains_key("api_key"),
2226            "secret key must be removed entirely from setup-answers",
2227        );
2228        let json = serde_json::to_string(&stripped).expect("serialize");
2229        assert!(
2230            !json.contains("PLAINTEXT-MUST-NOT-LEAK"),
2231            "plaintext leaked into setup-answers: {json}",
2232        );
2233        assert!(
2234            !json.contains("secrets://"),
2235            "setup-answers must not carry URI refs either — readers fetch via SecretsManager",
2236        );
2237    }
2238
2239    #[test]
2240    fn is_secret_answer_key_matches_aliases_via_canonical_suffix() {
2241        // Mirrors `qa::persist::seed_secret_requirement_aliases` (Codex
2242        // F3): a `webex_bot_token` requirement is satisfied by an answer
2243        // key `bot_token`, so redaction must match it too (forward direction:
2244        // secret key ends with answer key).
2245        let secret_keys = secret_keys_for(&["webex_bot_token"]);
2246        assert!(is_secret_answer_key("bot_token", &secret_keys));
2247        assert!(is_secret_answer_key("BOT_TOKEN", &secret_keys));
2248        assert!(is_secret_answer_key("webex_bot_token", &secret_keys));
2249        // Non-aliases must not match.
2250        assert!(!is_secret_answer_key("model", &secret_keys));
2251        assert!(!is_secret_answer_key("bot_url", &secret_keys));
2252    }
2253
2254    #[test]
2255    fn is_secret_answer_key_does_not_over_match_reverse_direction() {
2256        // xhigh review C4: the previous symmetric `norm.ends_with(secret)`
2257        // direction over-matched. A pack whose ONLY secret is the short key
2258        // `token` must NOT cause an unrelated longer answer `bot_token` to be
2259        // redacted — `seed_secret_requirement_aliases` would not seed it
2260        // either (it matches `requirement.ends_with(answer)`, not the
2261        // reverse), so redaction must stay consistent and leave it alone.
2262        let secret_keys = secret_keys_for(&["token"]);
2263        assert!(is_secret_answer_key("token", &secret_keys));
2264        assert!(
2265            !is_secret_answer_key("bot_token", &secret_keys),
2266            "answer key longer than the secret key must not match (reverse direction removed)",
2267        );
2268        assert!(!is_secret_answer_key("refresh_token", &secret_keys));
2269    }
2270
2271    #[test]
2272    fn is_secret_answer_key_punctuation_only_key_does_not_match_unrelated_secret() {
2273        // `canonical_secret_name` maps empty/punctuation-only keys to the
2274        // sentinel "secret"; it must not collide with an unrelated secret
2275        // key like `api_key`.
2276        let secret_keys = secret_keys_for(&["api_key"]);
2277        assert!(!is_secret_answer_key("", &secret_keys));
2278        assert!(!is_secret_answer_key("---", &secret_keys));
2279    }
2280
2281    #[test]
2282    fn alias_answer_key_redacted_in_setup_answers_and_envelope() {
2283        // End-to-end check for Codex F3: requirement `webex_bot_token`,
2284        // operator-supplied key `bot_token`.
2285        let secret_keys = secret_keys_for(&["webex_bot_token"]);
2286        let answers = serde_json::json!({"bot_token": "T0K3N-MUST-NOT-LEAK"});
2287
2288        let stripped = strip_secret_answer_keys(&answers, &secret_keys);
2289        assert!(
2290            stripped.as_object().unwrap().is_empty(),
2291            "alias-matched secret key must be dropped from setup-answers",
2292        );
2293
2294        let envelope = redact_secret_answer_values_to_uri_refs(
2295            &answers,
2296            &secret_keys,
2297            "dev",
2298            "demo",
2299            None,
2300            "messaging-webex",
2301        );
2302        assert_eq!(
2303            envelope["bot_token"].as_str(),
2304            Some("secrets://dev/demo/_/messaging_webex/bot_token"),
2305        );
2306        let json = serde_json::to_string(&envelope).unwrap();
2307        assert!(!json.contains("T0K3N-MUST-NOT-LEAK"));
2308    }
2309
2310    #[test]
2311    fn secret_keys_fail_closed_distinguishes_none_from_empty_set() {
2312        let content = serde_json::json!({"model": "gpt-4o"});
2313        let empty = serde_json::json!({});
2314
2315        // xhigh review C3: a pack WITH a form spec that declares zero secrets
2316        // resolves to Some(empty) and MUST proceed (write all answers as
2317        // non-secret) — not bail.
2318        let r = secret_keys_or_fail_closed(Some(BTreeSet::new()), &content, "p").unwrap();
2319        assert!(r.is_empty(), "Some(empty) proceeds with no redaction");
2320
2321        // Some(nonempty) passes the set through.
2322        let set = secret_keys_for(&["api_key"]);
2323        let r = secret_keys_or_fail_closed(Some(set.clone()), &content, "p").unwrap();
2324        assert_eq!(r, set);
2325
2326        // None + content => fail closed (can't classify, won't risk plaintext).
2327        assert!(secret_keys_or_fail_closed(None, &content, "p").is_err());
2328
2329        // None + empty answers => nothing to leak, proceed.
2330        assert!(
2331            secret_keys_or_fail_closed(None, &empty, "p")
2332                .unwrap()
2333                .is_empty()
2334        );
2335    }
2336
2337    #[test]
2338    fn answers_have_content_distinguishes_empty_from_meaningful() {
2339        assert!(!answers_have_content(&serde_json::json!({})));
2340        assert!(!answers_have_content(&serde_json::json!({"a": null})));
2341        assert!(!answers_have_content(&serde_json::json!({"a": ""})));
2342        assert!(answers_have_content(&serde_json::json!({"a": "value"})));
2343        assert!(answers_have_content(&serde_json::json!({"a": 42})));
2344        assert!(answers_have_content(&serde_json::json!({"a": true})));
2345        assert!(answers_have_content(&serde_json::json!({"a": ["x"]})));
2346    }
2347}