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