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