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 reg_env = crate::resolve_env(Some(&config.env));
1455    let dev_store = crate::secrets::open_dev_store_for_env(bundle_path, &reg_env)
1456        .ok()
1457        .map(Arc::new);
1458    let secrets: greentic_runner_host::secrets::DynSecretsManager =
1459        Arc::new(SetupRegistrationSecrets::with_dev_store(dev_store));
1460    let pack = greentic_runner_host::runtime::block_on(PackRuntime::load(
1461        pack_path,
1462        Arc::clone(&host_config),
1463        None,
1464        Some(pack_path),
1465        Some(Arc::clone(&session_store)),
1466        Some(Arc::clone(&state_store)),
1467        Arc::new(RunnerWasiPolicy::default()),
1468        secrets,
1469        None,
1470        false,
1471        ComponentResolution::default(),
1472    ))
1473    .with_context(|| format!("load setup component pack {}", pack_path.display()))?;
1474
1475    let exec_ctx = ComponentExecCtx {
1476        tenant: ComponentTenantCtx {
1477            tenant: config.tenant.clone(),
1478            team: config.team.clone(),
1479            user: None,
1480            trace_id: None,
1481            i18n_id: None,
1482            correlation_id: Some(format!("setup-component:{component_ref}:{op}")),
1483            deadline_unix_ms: None,
1484            attempt: 1,
1485            idempotency_key: Some(format!("setup-component:{component_ref}:{op}")),
1486        },
1487        i18n_id: None,
1488        flow_id: format!("setup-component/{op}"),
1489        node_id: Some(component_ref.to_string()),
1490    };
1491    let input_json = serde_json::to_vec(request)?;
1492    let binding = ProviderBinding {
1493        provider_id: Some(component_ref.to_string()),
1494        provider_type: component_ref.to_string(),
1495        component_ref: component_ref.to_string(),
1496        export: "schema-core-api".to_string(),
1497        world: "greentic:provider/schema-core@1.0.0".to_string(),
1498        config_json: None,
1499        pack_ref: None,
1500    };
1501    match greentic_runner_host::runtime::block_on(pack.invoke_provider(
1502        &binding,
1503        exec_ctx.clone(),
1504        op,
1505        input_json,
1506    )) {
1507        Ok(output) => Ok(output),
1508        Err(provider_err) => {
1509            let input_json = serde_json::to_string(request)?;
1510            greentic_runner_host::runtime::block_on(pack.invoke_component(
1511                component_ref,
1512                exec_ctx,
1513                op,
1514                None,
1515                input_json,
1516            ))
1517            .with_context(|| {
1518                format!(
1519                    "invoke setup component '{component_ref}' op '{op}' (provider path failed: {provider_err})"
1520                )
1521            })
1522        }
1523    }
1524}
1525
1526fn read_registration_component(pack_path: &Path, component_ref: &str) -> anyhow::Result<Value> {
1527    let file = File::open(pack_path).with_context(|| format!("open {}", pack_path.display()))?;
1528    let mut archive = match ZipArchive::new(file) {
1529        Ok(archive) => archive,
1530        Err(ZipError::InvalidArchive(_)) | Err(ZipError::UnsupportedArchive(_)) => {
1531            anyhow::bail!("{} is not a zip pack", pack_path.display())
1532        }
1533        Err(err) => return Err(err.into()),
1534    };
1535    let candidates = registration_component_candidates(component_ref);
1536    for candidate in candidates {
1537        match archive.by_name(&candidate) {
1538            Ok(mut entry) => {
1539                let mut raw = String::new();
1540                entry
1541                    .read_to_string(&mut raw)
1542                    .with_context(|| format!("read setup component {candidate}"))?;
1543                return serde_json::from_str(&raw)
1544                    .or_else(|_| serde_yaml_bw::from_str(&raw))
1545                    .with_context(|| format!("parse setup component {candidate}"));
1546            }
1547            Err(ZipError::FileNotFound) => continue,
1548            Err(err) => return Err(err.into()),
1549        }
1550    }
1551    anyhow::bail!(
1552        "setup component_ref '{}' not found in {}",
1553        component_ref,
1554        pack_path.display()
1555    )
1556}
1557
1558fn registration_component_candidates(component_ref: &str) -> Vec<String> {
1559    let trimmed = component_ref.trim().trim_start_matches("./");
1560    let mut candidates = vec![trimmed.to_string()];
1561    if !trimmed.ends_with(".json") && !trimmed.ends_with(".yaml") && !trimmed.ends_with(".yml") {
1562        candidates.push(format!("{trimmed}.json"));
1563        candidates.push(format!("components/{trimmed}.json"));
1564        candidates.push(format!("assets/{trimmed}.json"));
1565        candidates.push(format!("assets/components/{trimmed}.json"));
1566    }
1567    candidates.sort();
1568    candidates.dedup();
1569    candidates
1570}
1571
1572fn invoke_json_registration_component(
1573    component: &Value,
1574    op: &str,
1575    request: &Value,
1576) -> Option<Value> {
1577    let obj = component.as_object()?;
1578    if let Some(operations) = obj.get("operations").and_then(Value::as_object)
1579        && let Some(operation) = operations.get(op)
1580    {
1581        return operation_result(operation, request);
1582    }
1583    if let Some(ops) = obj.get("ops").and_then(Value::as_array) {
1584        for operation in ops {
1585            if operation.get("op").and_then(Value::as_str) == Some(op)
1586                || operation.get("name").and_then(Value::as_str) == Some(op)
1587                || operation.get("id").and_then(Value::as_str) == Some(op)
1588            {
1589                return operation_result(operation, request);
1590            }
1591        }
1592    }
1593    obj.get(op)
1594        .and_then(|operation| operation_result(operation, request))
1595}
1596
1597fn operation_result(operation: &Value, request: &Value) -> Option<Value> {
1598    if let Some(result) = operation
1599        .get("result")
1600        .or_else(|| operation.get("output"))
1601        .or_else(|| operation.get("outputs"))
1602    {
1603        return Some(result.clone());
1604    }
1605    if operation.get("echo_request").and_then(Value::as_bool) == Some(true) {
1606        return Some(request.clone());
1607    }
1608    if operation.is_object() {
1609        return Some(operation.clone());
1610    }
1611    None
1612}
1613
1614fn merge_registration_output(
1615    action: &mut crate::setup_actions::SetupAction,
1616    answers: &mut Value,
1617    registration: &Value,
1618    output: &Value,
1619) -> anyhow::Result<()> {
1620    let registration_obj = registration
1621        .as_object()
1622        .ok_or_else(|| anyhow!("setup action registration must be an object"))?;
1623    let output_obj = output
1624        .as_object()
1625        .ok_or_else(|| anyhow!("setup action registration output must be an object"))?;
1626    let answers_obj = answers
1627        .as_object_mut()
1628        .ok_or_else(|| anyhow!("provider setup answers must be an object"))?;
1629
1630    for (mapping_key, source_value) in registration_obj {
1631        let Some(generic_key) = mapping_key.strip_suffix("_output") else {
1632            continue;
1633        };
1634        let Some(source_key) = source_value
1635            .as_str()
1636            .map(str::trim)
1637            .filter(|value| !value.is_empty())
1638        else {
1639            continue;
1640        };
1641        let Some(value) = output_obj
1642            .get(source_key)
1643            .or_else(|| output_obj.get(generic_key))
1644            .filter(|value| !is_empty_value(value))
1645            .cloned()
1646        else {
1647            continue;
1648        };
1649        answers_obj.insert(source_key.to_string(), value.clone());
1650        answers_obj.insert(generic_key.to_string(), value.clone());
1651        if generic_key == "client_id" {
1652            if let Some(client_id_field) =
1653                action.extra.get("client_id_field").and_then(Value::as_str)
1654            {
1655                answers_obj.insert(client_id_field.to_string(), value.clone());
1656            }
1657            action.extra.insert("client_id".into(), value);
1658        } else {
1659            action.extra.insert(generic_key.to_string(), value);
1660        }
1661    }
1662    Ok(())
1663}
1664
1665fn registration_error_message(output: &Value) -> Option<String> {
1666    if output.get("ok").and_then(Value::as_bool) == Some(false) {
1667        return output
1668            .get("error")
1669            .and_then(Value::as_str)
1670            .map(ToString::to_string)
1671            .or_else(|| Some(output.to_string()));
1672    }
1673    None
1674}
1675
1676fn is_empty_value(value: &Value) -> bool {
1677    match value {
1678        Value::Null => true,
1679        Value::String(value) => value.trim().is_empty(),
1680        Value::Array(values) => values.is_empty(),
1681        Value::Object(values) => values.is_empty(),
1682        Value::Bool(_) | Value::Number(_) => false,
1683    }
1684}
1685
1686fn hydrate_oauth_install_actions(
1687    actions: &mut [crate::setup_actions::SetupAction],
1688    answers: &Value,
1689) {
1690    for action in actions {
1691        if action.kind != crate::setup_actions::SetupActionKind::OauthInstallButton {
1692            continue;
1693        }
1694        let client_id = client_id_for_action(action, answers);
1695        let Some(authorize_url) = action.authorize_url.as_mut() else {
1696            continue;
1697        };
1698        let Ok(mut parsed) = url::Url::parse(authorize_url) else {
1699            continue;
1700        };
1701        if !parsed.query_pairs().any(|(key, _)| key == "client_id")
1702            && let Some(client_id) = client_id
1703        {
1704            parsed
1705                .query_pairs_mut()
1706                .append_pair("client_id", &client_id);
1707        }
1708        if !parsed.query_pairs().any(|(key, _)| key == "scope")
1709            && let Some(scopes) = action.extra.get("scopes").and_then(Value::as_array)
1710        {
1711            let scope = scopes
1712                .iter()
1713                .filter_map(Value::as_str)
1714                .map(str::trim)
1715                .filter(|value| !value.is_empty())
1716                .collect::<Vec<_>>()
1717                .join(",");
1718            if !scope.is_empty() {
1719                parsed.query_pairs_mut().append_pair("scope", &scope);
1720            }
1721        }
1722        *authorize_url = parsed.to_string();
1723    }
1724}
1725
1726fn client_id_for_action(
1727    action: &crate::setup_actions::SetupAction,
1728    answers: &Value,
1729) -> Option<String> {
1730    let obj = answers.as_object()?;
1731    let mut keys = Vec::new();
1732    if let Some(field) = action.extra.get("client_id_field").and_then(Value::as_str) {
1733        keys.push(field);
1734    }
1735    keys.extend(["client_id", "oauth_client_id"]);
1736    keys.into_iter().find_map(|key| {
1737        obj.get(key)
1738            .and_then(Value::as_str)
1739            .map(str::trim)
1740            .filter(|value| !value.is_empty())
1741            .map(ToString::to_string)
1742    })
1743}
1744
1745fn compute_file_digest(path: &Path) -> anyhow::Result<String> {
1746    let bytes = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
1747    let digest = Sha256::digest(bytes);
1748    let encoded = digest
1749        .iter()
1750        .map(|byte| format!("{byte:02x}"))
1751        .collect::<String>();
1752    Ok(format!("sha256:{encoded}"))
1753}
1754
1755fn resolve_pack_ref(pack_ref: &str) -> anyhow::Result<PathBuf> {
1756    let source = BundleSource::parse(pack_ref)?;
1757    let resolved = source.resolve()?;
1758
1759    if resolved.extension().and_then(|ext| ext.to_str()) != Some("gtpack") {
1760        anyhow::bail!(
1761            "resolved pack ref is not a .gtpack file: {}",
1762            resolved.display()
1763        );
1764    }
1765
1766    Ok(resolved)
1767}
1768
1769/// Remove provider artifacts and config directories.
1770pub fn execute_remove_provider_artifacts(
1771    bundle_path: &Path,
1772    providers_remove: &[String],
1773) -> anyhow::Result<usize> {
1774    let mut removed = 0usize;
1775    let discovered = discovery::discover(bundle_path).ok();
1776    for provider_id in providers_remove {
1777        if let Some(discovered) = discovered.as_ref()
1778            && let Some(provider) = discovered
1779                .providers
1780                .iter()
1781                .find(|provider| provider.provider_id == *provider_id)
1782        {
1783            if provider.pack_path.exists() {
1784                std::fs::remove_file(&provider.pack_path).with_context(|| {
1785                    format!(
1786                        "failed to remove provider pack {}",
1787                        provider.pack_path.display()
1788                    )
1789                })?;
1790            }
1791            removed += 1;
1792        } else {
1793            let target_dir = get_pack_target_dir(bundle_path, provider_id);
1794            let target_path = target_dir.join(format!("{provider_id}.gtpack"));
1795            if target_path.exists() {
1796                std::fs::remove_file(&target_path).with_context(|| {
1797                    format!("failed to remove provider pack {}", target_path.display())
1798                })?;
1799                removed += 1;
1800            }
1801        }
1802
1803        let config_dir = bundle_path.join("state").join("config").join(provider_id);
1804        if config_dir.exists() {
1805            std::fs::remove_dir_all(&config_dir).with_context(|| {
1806                format!(
1807                    "failed to remove provider config dir {}",
1808                    config_dir.display()
1809                )
1810            })?;
1811        }
1812    }
1813    Ok(removed)
1814}
1815
1816/// Search sibling bundles for provider packs referenced in setup_answers
1817/// and install them into this bundle if missing.
1818///
1819/// "Missing" is determined by pack_id, not filename: a pack file with any
1820/// filename that declares the matching pack_id in its manifest counts as
1821/// already installed. Otherwise a custom-named pack (e.g. a tenant-specific
1822/// build placed alongside the canonical name) gets clobbered every time
1823/// setup runs.
1824pub fn auto_install_provider_packs(bundle_path: &Path, metadata: &SetupPlanMetadata) {
1825    let bundle_abs =
1826        std::fs::canonicalize(bundle_path).unwrap_or_else(|_| bundle_path.to_path_buf());
1827
1828    let installed_ids: std::collections::HashSet<String> = discovery::discover(bundle_path)
1829        .map(|d| {
1830            d.providers
1831                .into_iter()
1832                .chain(d.app_packs)
1833                .map(|p| p.provider_id)
1834                .collect()
1835        })
1836        .unwrap_or_default();
1837
1838    for provider_id in metadata.setup_answers.keys() {
1839        if installed_ids.contains(provider_id) {
1840            continue;
1841        }
1842        let target_dir = get_pack_target_dir(bundle_path, provider_id);
1843        let target_path = target_dir.join(format!("{provider_id}.gtpack"));
1844        if target_path.exists() {
1845            continue;
1846        }
1847
1848        // Determine the provider domain from the ID
1849        let domain = domain_from_provider_id(provider_id);
1850
1851        // Search for the pack in sibling bundles and build output
1852        if let Some(source) = find_provider_pack_source(provider_id, domain, &bundle_abs) {
1853            if let Err(err) = std::fs::create_dir_all(&target_dir) {
1854                eprintln!(
1855                    "  [provider] WARNING: failed to create {}: {err}",
1856                    target_dir.display()
1857                );
1858                continue;
1859            }
1860            match std::fs::copy(&source, &target_path) {
1861                Ok(_) => println!(
1862                    "  [provider] installed {provider_id}.gtpack from {}",
1863                    source.display()
1864                ),
1865                Err(err) => eprintln!(
1866                    "  [provider] WARNING: failed to copy {}: {err}",
1867                    source.display()
1868                ),
1869            }
1870        } else {
1871            eprintln!("  [provider] WARNING: {provider_id}.gtpack not found in sibling bundles");
1872        }
1873    }
1874}
1875
1876/// Extract domain from a provider ID (e.g. "messaging-telegram" → "messaging").
1877pub fn domain_from_provider_id(provider_id: &str) -> &str {
1878    const DOMAIN_PREFIXES: &[&str] = &[
1879        "messaging-",
1880        "events-",
1881        "oauth-",
1882        "secrets-",
1883        "mcp-",
1884        "state-",
1885        "telemetry-",
1886    ];
1887    for prefix in DOMAIN_PREFIXES {
1888        if provider_id.starts_with(prefix) {
1889            return prefix.trim_end_matches('-');
1890        }
1891    }
1892    "messaging" // default
1893}
1894
1895/// Search known locations for a provider pack file.
1896///
1897/// Search order:
1898/// 1. Sibling bundle directories: `../<bundle>/providers/<domain>/<id>.gtpack`
1899/// 2. Build output: `../greentic-messaging-providers/target/packs/<id>.gtpack`
1900pub fn find_provider_pack_source(
1901    provider_id: &str,
1902    domain: &str,
1903    bundle_abs: &Path,
1904) -> Option<PathBuf> {
1905    let parent = bundle_abs.parent()?;
1906    let filename = format!("{provider_id}.gtpack");
1907
1908    // 1. Sibling bundles
1909    if let Ok(entries) = std::fs::read_dir(parent) {
1910        for entry in entries.flatten() {
1911            let sibling = entry.path();
1912            if sibling == *bundle_abs || !sibling.is_dir() {
1913                continue;
1914            }
1915            let candidate = sibling.join("providers").join(domain).join(&filename);
1916            if candidate.is_file() {
1917                return Some(candidate);
1918            }
1919        }
1920    }
1921
1922    // 2. Build output from greentic-messaging-providers
1923    for ancestor in parent.ancestors().take(4) {
1924        let candidate = ancestor
1925            .join("greentic-messaging-providers")
1926            .join("target")
1927            .join("packs")
1928            .join(&filename);
1929        if candidate.is_file() {
1930            return Some(candidate);
1931        }
1932    }
1933
1934    None
1935}
1936
1937/// Execute the WriteGmapRules step.
1938pub fn execute_write_gmap_rules(
1939    bundle_path: &Path,
1940    metadata: &SetupPlanMetadata,
1941) -> anyhow::Result<()> {
1942    for tenant_sel in &metadata.tenants {
1943        let gmap_path =
1944            bundle::gmap_path(bundle_path, &tenant_sel.tenant, tenant_sel.team.as_deref());
1945
1946        if let Some(parent) = gmap_path.parent() {
1947            std::fs::create_dir_all(parent)?;
1948        }
1949
1950        // Build gmap content from allow_paths
1951        let mut content = String::new();
1952        if tenant_sel.allow_paths.is_empty() {
1953            content.push_str("_ = forbidden\n");
1954        } else {
1955            for path in &tenant_sel.allow_paths {
1956                content.push_str(&format!("{} = allowed\n", path));
1957            }
1958            content.push_str("_ = forbidden\n");
1959        }
1960
1961        std::fs::write(&gmap_path, content)
1962            .with_context(|| format!("failed to write gmap: {}", gmap_path.display()))?;
1963    }
1964    Ok(())
1965}
1966
1967/// Execute the CopyResolvedManifest step.
1968pub fn execute_copy_resolved_manifests(
1969    bundle_path: &Path,
1970    metadata: &SetupPlanMetadata,
1971) -> anyhow::Result<Vec<PathBuf>> {
1972    let mut manifests = Vec::new();
1973    let resolved_dir = bundle_path.join("resolved");
1974    std::fs::create_dir_all(&resolved_dir)?;
1975
1976    for tenant_sel in &metadata.tenants {
1977        let filename =
1978            bundle::resolved_manifest_filename(&tenant_sel.tenant, tenant_sel.team.as_deref());
1979        let manifest_path = resolved_dir.join(&filename);
1980
1981        // Create an empty manifest placeholder if it doesn't exist
1982        if !manifest_path.exists() {
1983            std::fs::write(&manifest_path, "# Resolved manifest placeholder\n")?;
1984        }
1985        manifests.push(manifest_path);
1986    }
1987
1988    Ok(manifests)
1989}
1990
1991/// Execute the ValidateBundle step.
1992pub fn execute_validate_bundle(bundle_path: &Path) -> anyhow::Result<()> {
1993    bundle::validate_bundle_exists(bundle_path)
1994}
1995
1996/// Execute the BuildFlowIndex step.
1997///
1998/// Scans all flows in the bundle, builds a TF-IDF index and a routing-compatible
1999/// index, and optionally generates intents.md documentation.
2000/// Output is written to `bundle/state/indexes/`.
2001///
2002/// Requires the `fast2flow` feature AND the `fast2flow-bundle` crate wired as a
2003/// dependency.  Until `fast2flow-bundle` is published or vendored, this is a
2004/// no-op stub that logs a skip message.
2005pub fn execute_build_flow_index(_bundle_path: &Path, _config: &SetupConfig) -> anyhow::Result<()> {
2006    tracing::debug!("fast2flow indexing skipped (fast2flow-bundle not available)");
2007    Ok(())
2008}
2009
2010#[cfg(test)]
2011mod tests {
2012    use super::*;
2013    use crate::platform_setup::StaticRoutesPolicy;
2014    use std::collections::BTreeSet;
2015
2016    #[test]
2017    fn secret_uri_candidates_cover_env_and_provider_branches() {
2018        // Stored under the current default env + underscore provider; a read that
2019        // resolves to the legacy env + hyphenated provider must still find it.
2020        let cands =
2021            secret_uri_candidates("secrets://dev/demo/_/messaging-slack/slack_signing_secret");
2022        assert!(
2023            cands
2024                .contains(&"secrets://dev/demo/_/messaging-slack/slack_signing_secret".to_string())
2025        );
2026        assert!(
2027            cands.contains(
2028                &"secrets://local/demo/_/messaging_slack/slack_signing_secret".to_string()
2029            )
2030        );
2031        assert!(
2032            cands.contains(
2033                &"secrets://local/demo/_/messaging-slack/slack_signing_secret".to_string()
2034            )
2035        );
2036        assert!(
2037            cands
2038                .contains(&"secrets://dev/demo/_/messaging_slack/slack_signing_secret".to_string())
2039        );
2040        // First candidate is always the path as-is.
2041        assert_eq!(
2042            cands[0],
2043            "secrets://dev/demo/_/messaging-slack/slack_signing_secret"
2044        );
2045    }
2046
2047    #[test]
2048    fn secret_uri_candidates_do_not_alias_custom_env() {
2049        let cands = secret_uri_candidates("secrets://prod/acme/team/messaging_slack/bot_token");
2050        // Provider still branches, but a non-default/non-legacy env is never
2051        // remapped to dev/local.
2052        assert!(cands.contains(&"secrets://prod/acme/team/messaging_slack/bot_token".to_string()));
2053        assert!(cands.contains(&"secrets://prod/acme/team/messaging-slack/bot_token".to_string()));
2054        assert!(
2055            !cands
2056                .iter()
2057                .any(|c| c.contains("/dev/") || c.contains("/local/"))
2058        );
2059    }
2060
2061    #[test]
2062    fn secret_uri_candidates_passthrough_non_secret_paths() {
2063        assert_eq!(
2064            secret_uri_candidates("SLACK_BOT_TOKEN"),
2065            vec!["SLACK_BOT_TOKEN".to_string()]
2066        );
2067    }
2068
2069    fn empty_metadata(pack_refs: Vec<String>) -> SetupPlanMetadata {
2070        SetupPlanMetadata {
2071            bundle_name: None,
2072            pack_refs,
2073            tenants: Vec::new(),
2074            default_assignments: Vec::new(),
2075            providers: Vec::new(),
2076            update_ops: BTreeSet::new(),
2077            remove_targets: BTreeSet::new(),
2078            packs_remove: Vec::new(),
2079            providers_remove: Vec::new(),
2080            tenants_remove: Vec::new(),
2081            access_changes: Vec::new(),
2082            static_routes: StaticRoutesPolicy::default(),
2083            deployment_targets: Vec::new(),
2084            setup_answers: serde_json::Map::new(),
2085            tunnel: None,
2086            telemetry: None,
2087        }
2088    }
2089
2090    #[test]
2091    fn resolve_packs_errors_when_any_pack_ref_fails() {
2092        let metadata = empty_metadata(vec!["/definitely/missing/example.gtpack".to_string()]);
2093        let err = execute_resolve_packs(Path::new("."), &metadata).unwrap_err();
2094        let message = err.to_string();
2095
2096        assert!(message.contains("failed to resolve 1 pack ref"));
2097        assert!(message.contains("/definitely/missing/example.gtpack"));
2098    }
2099
2100    /// Regression: a custom-named pack whose manifest declares the matching
2101    /// pack_id must satisfy `auto_install_provider_packs`. Filename-only
2102    /// detection caused tenant-specific builds (e.g. `*-3aigent.gtpack`) to
2103    /// be clobbered by the canonical name on every setup run.
2104    #[test]
2105    fn auto_install_skips_when_pack_id_matches_under_custom_filename() {
2106        use std::io::Write;
2107        use zip::write::{FileOptions, ZipWriter};
2108
2109        let temp = tempfile::tempdir().expect("tempdir");
2110        let bundle = temp.path().join("bundle");
2111        let messaging_dir = bundle.join("providers").join("messaging");
2112        std::fs::create_dir_all(&messaging_dir).expect("create messaging dir");
2113
2114        let custom_pack = messaging_dir.join("messaging-webchat-gui-3aigent.gtpack");
2115        let file = std::fs::File::create(&custom_pack).expect("create pack file");
2116        let mut writer = ZipWriter::new(file);
2117        let options: FileOptions<'_, ()> =
2118            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
2119        writer
2120            .start_file("pack.manifest.json", options)
2121            .expect("start manifest");
2122        writer
2123            .write_all(
2124                serde_json::json!({
2125                    "pack_id": "messaging-webchat-gui",
2126                    "display_name": "WebChat GUI",
2127                })
2128                .to_string()
2129                .as_bytes(),
2130            )
2131            .expect("write manifest");
2132        writer.finish().expect("finish zip");
2133
2134        let canonical_pack = messaging_dir.join("messaging-webchat-gui.gtpack");
2135        assert!(!canonical_pack.exists(), "precondition: canonical absent");
2136
2137        let mut metadata = empty_metadata(vec![]);
2138        metadata.setup_answers.insert(
2139            "messaging-webchat-gui".to_string(),
2140            serde_json::Value::Object(serde_json::Map::new()),
2141        );
2142
2143        auto_install_provider_packs(&bundle, &metadata);
2144
2145        assert!(
2146            custom_pack.exists(),
2147            "custom-named pack must be left in place"
2148        );
2149        assert!(
2150            !canonical_pack.exists(),
2151            "must not auto-install canonical-named duplicate when pack_id already present"
2152        );
2153    }
2154
2155    fn secret_keys_for(keys: &[&str]) -> BTreeSet<String> {
2156        keys.iter()
2157            .map(|k| crate::secret_name::canonical_secret_name(k))
2158            .collect()
2159    }
2160
2161    #[test]
2162    fn envelope_redaction_replaces_secret_values_with_canonical_uri_refs() {
2163        let secret_keys = secret_keys_for(&["api_key", "oauth_client_secret"]);
2164
2165        let answers = serde_json::json!({
2166            "model": "gpt-4o-mini",
2167            "api_key": "sk-PLAINTEXT-MUST-NOT-LEAK",
2168            "oauth_client_secret": "PLAINTEXT-OAUTH-SECRET",
2169            "non_secret_url": "https://api.openai.com/v1"
2170        });
2171
2172        let redacted = redact_secret_answer_values_to_uri_refs(
2173            &answers,
2174            &secret_keys,
2175            "dev",
2176            "demo",
2177            Some("default"),
2178            "openai-llm",
2179        );
2180
2181        let map = redacted.as_object().expect("object");
2182        assert_eq!(map["model"].as_str(), Some("gpt-4o-mini"));
2183        assert_eq!(
2184            map["non_secret_url"].as_str(),
2185            Some("https://api.openai.com/v1")
2186        );
2187        // `canonical_secret_uri` collapses the literal "default" team into
2188        // the wildcard segment `_` (via `greentic_secrets_lib::normalize_team`).
2189        assert_eq!(
2190            map["api_key"].as_str(),
2191            Some("secrets://dev/demo/_/openai_llm/api_key"),
2192            "secret value must be replaced with canonical secrets:// URI",
2193        );
2194        assert_eq!(
2195            map["oauth_client_secret"].as_str(),
2196            Some("secrets://dev/demo/_/openai_llm/oauth_client_secret"),
2197        );
2198
2199        let json = serde_json::to_string(&redacted).expect("serialize");
2200        assert!(
2201            !json.contains("PLAINTEXT-MUST-NOT-LEAK"),
2202            "api_key plaintext leaked into envelope JSON: {json}",
2203        );
2204        assert!(
2205            !json.contains("PLAINTEXT-OAUTH-SECRET"),
2206            "oauth_client_secret plaintext leaked into envelope JSON: {json}",
2207        );
2208    }
2209
2210    #[test]
2211    fn setup_answers_redaction_drops_secret_keys_entirely() {
2212        // setup-answers.json's downstream readers in greentic-start skip
2213        // secret-marked keys (PR #179) and fetch from `SecretsManager`
2214        // instead, so the producer drops them from this artifact — no
2215        // value or URI ref appears in the JSON value slot.
2216        let secret_keys = secret_keys_for(&["api_key"]);
2217        let answers = serde_json::json!({
2218            "model": "gpt-4o-mini",
2219            "api_key": "sk-PLAINTEXT-MUST-NOT-LEAK"
2220        });
2221
2222        let stripped = strip_secret_answer_keys(&answers, &secret_keys);
2223        let map = stripped.as_object().expect("object");
2224        assert_eq!(map["model"].as_str(), Some("gpt-4o-mini"));
2225        assert!(
2226            !map.contains_key("api_key"),
2227            "secret key must be removed entirely from setup-answers",
2228        );
2229        let json = serde_json::to_string(&stripped).expect("serialize");
2230        assert!(
2231            !json.contains("PLAINTEXT-MUST-NOT-LEAK"),
2232            "plaintext leaked into setup-answers: {json}",
2233        );
2234        assert!(
2235            !json.contains("secrets://"),
2236            "setup-answers must not carry URI refs either — readers fetch via SecretsManager",
2237        );
2238    }
2239
2240    #[test]
2241    fn is_secret_answer_key_matches_aliases_via_canonical_suffix() {
2242        // Mirrors `qa::persist::seed_secret_requirement_aliases` (Codex
2243        // F3): a `webex_bot_token` requirement is satisfied by an answer
2244        // key `bot_token`, so redaction must match it too (forward direction:
2245        // secret key ends with answer key).
2246        let secret_keys = secret_keys_for(&["webex_bot_token"]);
2247        assert!(is_secret_answer_key("bot_token", &secret_keys));
2248        assert!(is_secret_answer_key("BOT_TOKEN", &secret_keys));
2249        assert!(is_secret_answer_key("webex_bot_token", &secret_keys));
2250        // Non-aliases must not match.
2251        assert!(!is_secret_answer_key("model", &secret_keys));
2252        assert!(!is_secret_answer_key("bot_url", &secret_keys));
2253    }
2254
2255    #[test]
2256    fn is_secret_answer_key_does_not_over_match_reverse_direction() {
2257        // xhigh review C4: the previous symmetric `norm.ends_with(secret)`
2258        // direction over-matched. A pack whose ONLY secret is the short key
2259        // `token` must NOT cause an unrelated longer answer `bot_token` to be
2260        // redacted — `seed_secret_requirement_aliases` would not seed it
2261        // either (it matches `requirement.ends_with(answer)`, not the
2262        // reverse), so redaction must stay consistent and leave it alone.
2263        let secret_keys = secret_keys_for(&["token"]);
2264        assert!(is_secret_answer_key("token", &secret_keys));
2265        assert!(
2266            !is_secret_answer_key("bot_token", &secret_keys),
2267            "answer key longer than the secret key must not match (reverse direction removed)",
2268        );
2269        assert!(!is_secret_answer_key("refresh_token", &secret_keys));
2270    }
2271
2272    #[test]
2273    fn is_secret_answer_key_punctuation_only_key_does_not_match_unrelated_secret() {
2274        // `canonical_secret_name` maps empty/punctuation-only keys to the
2275        // sentinel "secret"; it must not collide with an unrelated secret
2276        // key like `api_key`.
2277        let secret_keys = secret_keys_for(&["api_key"]);
2278        assert!(!is_secret_answer_key("", &secret_keys));
2279        assert!(!is_secret_answer_key("---", &secret_keys));
2280    }
2281
2282    #[test]
2283    fn alias_answer_key_redacted_in_setup_answers_and_envelope() {
2284        // End-to-end check for Codex F3: requirement `webex_bot_token`,
2285        // operator-supplied key `bot_token`.
2286        let secret_keys = secret_keys_for(&["webex_bot_token"]);
2287        let answers = serde_json::json!({"bot_token": "T0K3N-MUST-NOT-LEAK"});
2288
2289        let stripped = strip_secret_answer_keys(&answers, &secret_keys);
2290        assert!(
2291            stripped.as_object().unwrap().is_empty(),
2292            "alias-matched secret key must be dropped from setup-answers",
2293        );
2294
2295        let envelope = redact_secret_answer_values_to_uri_refs(
2296            &answers,
2297            &secret_keys,
2298            "dev",
2299            "demo",
2300            None,
2301            "messaging-webex",
2302        );
2303        assert_eq!(
2304            envelope["bot_token"].as_str(),
2305            Some("secrets://dev/demo/_/messaging_webex/bot_token"),
2306        );
2307        let json = serde_json::to_string(&envelope).unwrap();
2308        assert!(!json.contains("T0K3N-MUST-NOT-LEAK"));
2309    }
2310
2311    #[test]
2312    fn secret_keys_fail_closed_distinguishes_none_from_empty_set() {
2313        let content = serde_json::json!({"model": "gpt-4o"});
2314        let empty = serde_json::json!({});
2315
2316        // xhigh review C3: a pack WITH a form spec that declares zero secrets
2317        // resolves to Some(empty) and MUST proceed (write all answers as
2318        // non-secret) — not bail.
2319        let r = secret_keys_or_fail_closed(Some(BTreeSet::new()), &content, "p").unwrap();
2320        assert!(r.is_empty(), "Some(empty) proceeds with no redaction");
2321
2322        // Some(nonempty) passes the set through.
2323        let set = secret_keys_for(&["api_key"]);
2324        let r = secret_keys_or_fail_closed(Some(set.clone()), &content, "p").unwrap();
2325        assert_eq!(r, set);
2326
2327        // None + content => fail closed (can't classify, won't risk plaintext).
2328        assert!(secret_keys_or_fail_closed(None, &content, "p").is_err());
2329
2330        // None + empty answers => nothing to leak, proceed.
2331        assert!(
2332            secret_keys_or_fail_closed(None, &empty, "p")
2333                .unwrap()
2334                .is_empty()
2335        );
2336    }
2337
2338    #[test]
2339    fn answers_have_content_distinguishes_empty_from_meaningful() {
2340        assert!(!answers_have_content(&serde_json::json!({})));
2341        assert!(!answers_have_content(&serde_json::json!({"a": null})));
2342        assert!(!answers_have_content(&serde_json::json!({"a": ""})));
2343        assert!(answers_have_content(&serde_json::json!({"a": "value"})));
2344        assert!(answers_have_content(&serde_json::json!({"a": 42})));
2345        assert!(answers_have_content(&serde_json::json!({"a": true})));
2346        assert!(answers_have_content(&serde_json::json!({"a": ["x"]})));
2347    }
2348}