Skip to main content

greentic_setup/
setup_machine.rs

1//! Provider-declared setup state machines.
2//!
3//! This module is the first slice of the provider setup-machine work: pack
4//! metadata loading, contract validation, stable setup-state paths, and the
5//! first conservative execution slice for resumable provider setup.
6
7use std::collections::{BTreeMap, BTreeSet, VecDeque};
8use std::io::Read;
9use std::path::{Path, PathBuf};
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use anyhow::Context;
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use sha2::{Digest, Sha256};
16
17use crate::discovery;
18use crate::doctor::{Diagnostic, DiagnosticSeverity, DoctorReport};
19
20pub const SETUP_MACHINE_EXTENSION: &str = "greentic.setup.machine.v1";
21pub const SETUP_MACHINE_SCHEMA_URL: &str = "https://schemas.greentic.ai/setup-machine/v1";
22pub const SETUP_BACKEND_CONTRACT_EXTENSION: &str = "greentic.setup.backend-contract.v1";
23pub const SETUP_WEB_COMPONENT_EXTENSION: &str = "greentic.setup.web-component.v1";
24pub const SETUP_I18N_EXTENSION: &str = "greentic.setup.i18n.v1";
25pub const HTTP_ROUTES_EXTENSION: &str = "greentic.http-routes.v1";
26const SETUP_MACHINE_SCHEMA: &str = include_str!("../schemas/setup-machine.v1.schema.json");
27const SETUP_BACKEND_CONTRACT_SCHEMA: &str =
28    include_str!("../schemas/setup-backend-contract.v1.schema.json");
29
30const TERMINAL_SUCCESS: &str = "complete";
31const TERMINAL_FAILED: &str = "failed";
32const SUPPORTED_SETUP_STATE_SCHEMA_VERSION: u32 = 1;
33
34const SUPPORTED_STEP_KINDS: &[&str] = &[
35    "qa_form",
36    "platform_public_url",
37    "cloudflare_tunnel",
38    "oauth_device_code",
39    "oauth_authorization_code",
40    "http_probe",
41    "http_json",
42    "select_from_json",
43    "generate_file",
44    "download_file",
45    "manual_action",
46    "provider_component_call",
47    "persist_runtime_config",
48];
49
50const SUPPORTED_BACKEND_EXECUTOR_KINDS: &[&str] = &[
51    "oauth_device_code",
52    "microsoft_graph_application",
53    "provider_http",
54    "microsoft_graph_teams_app_catalog_publish",
55    "microsoft_graph_teams_app_user_install",
56    "runtime_observation",
57];
58
59#[derive(Clone, Debug, Serialize, Deserialize)]
60pub struct SetupMachine {
61    pub version: u32,
62    pub id: String,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub display_name: Option<String>,
65    pub entry_step: String,
66    #[serde(default)]
67    pub steps: Vec<SetupMachineStep>,
68    #[serde(flatten)]
69    pub extra: serde_json::Map<String, Value>,
70}
71
72#[derive(Clone, Debug, Serialize, Deserialize)]
73pub struct SetupMachineStep {
74    pub id: String,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub title: Option<String>,
77    pub kind: String,
78    #[serde(default)]
79    pub requires: Vec<String>,
80    #[serde(default)]
81    pub outputs: Vec<Value>,
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub on_success: Option<String>,
84    #[serde(default)]
85    pub on_failure: Vec<Value>,
86    #[serde(default)]
87    pub recover: Vec<Value>,
88    #[serde(flatten)]
89    pub extra: serde_json::Map<String, Value>,
90}
91
92#[derive(Clone, Debug, Serialize, Deserialize)]
93pub struct SetupMachineState {
94    pub schema_version: u32,
95    pub provider_id: String,
96    pub tenant: String,
97    pub team: String,
98    pub machine_id: String,
99    pub machine_version: u32,
100    pub status: SetupMachineStatus,
101    pub current_step: Option<String>,
102    #[serde(default)]
103    pub completed_steps: Vec<String>,
104    #[serde(default)]
105    pub failed_step: Option<String>,
106    #[serde(default)]
107    pub answers_hash: Option<String>,
108    #[serde(default)]
109    pub pack_fingerprint: Option<String>,
110    #[serde(default)]
111    pub outputs: Value,
112    #[serde(default)]
113    pub created_resources: Vec<Value>,
114    #[serde(default)]
115    pub last_error: Option<Value>,
116    #[serde(default)]
117    pub updated_at: Option<String>,
118}
119
120#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
121#[serde(rename_all = "snake_case")]
122pub enum SetupMachineStatus {
123    NotStarted,
124    Running,
125    Paused,
126    Complete,
127    Failed,
128}
129
130#[derive(Clone, Debug, Serialize, Deserialize)]
131pub struct SetupMachineEvent {
132    pub ts: String,
133    pub level: String,
134    pub event: String,
135    pub provider_id: String,
136    pub tenant: String,
137    pub team: String,
138    pub machine_id: String,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub step_id: Option<String>,
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub correlation_id: Option<String>,
143    #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
144    pub fields: serde_json::Map<String, Value>,
145}
146
147pub fn load_setup_machine_from_pack(pack_path: &Path) -> anyhow::Result<Option<SetupMachine>> {
148    let Some(extension) = discovery::read_pack_extension(pack_path, SETUP_MACHINE_EXTENSION)?
149    else {
150        return Ok(None);
151    };
152    let machine_value = resolve_machine_value(pack_path, extension)?;
153    Ok(Some(serde_json::from_value(machine_value)?))
154}
155
156pub fn validate_setup_machine(machine: &SetupMachine) -> Vec<Diagnostic> {
157    let mut diagnostics = Vec::new();
158
159    if machine.version == 0 {
160        diagnostics.push(error(
161            "setup.machine.version",
162            "setup machine version must be greater than zero",
163        ));
164    }
165    if machine.id.trim().is_empty() {
166        diagnostics.push(error("setup.machine.id", "setup machine id is required"));
167    }
168    if machine.entry_step.trim().is_empty() {
169        diagnostics.push(error(
170            "setup.machine.entry_step",
171            "setup machine entry_step is required",
172        ));
173    }
174    if machine.steps.is_empty() {
175        diagnostics.push(error(
176            "setup.machine.steps",
177            "setup machine must declare at least one step",
178        ));
179        return diagnostics;
180    }
181
182    let mut seen = BTreeSet::new();
183    let mut step_ids = BTreeSet::new();
184    for step in &machine.steps {
185        if step.id.trim().is_empty() {
186            diagnostics.push(error("setup.machine.step.id", "setup step id is required"));
187            continue;
188        }
189        if !seen.insert(step.id.clone()) {
190            diagnostics.push(error_with_actual(
191                "setup.machine.step.unique_id",
192                "setup step ids must be unique",
193                &step.id,
194            ));
195        }
196        step_ids.insert(step.id.clone());
197        if step.kind.trim().is_empty() {
198            diagnostics.push(error_with_actual(
199                "setup.machine.step.kind",
200                "setup step kind is required",
201                &step.id,
202            ));
203        } else if !SUPPORTED_STEP_KINDS.contains(&step.kind.as_str()) {
204            diagnostics.push(error_with_actual(
205                "setup.machine.step.supported_kind",
206                "setup step kind is not supported by this greentic-setup version",
207                &step.kind,
208            ));
209        }
210        for required in &step.requires {
211            if required.trim().is_empty() {
212                diagnostics.push(error_with_actual(
213                    "setup.machine.step.requires",
214                    "setup step requires entries must be non-empty",
215                    &step.id,
216                ));
217            }
218        }
219    }
220
221    if !step_ids.contains(&machine.entry_step) {
222        diagnostics.push(error_with_actual(
223            "setup.machine.entry_step.exists",
224            "setup machine entry_step must reference an existing step",
225            &machine.entry_step,
226        ));
227    }
228
229    for step in &machine.steps {
230        validate_transition(
231            &mut diagnostics,
232            &step_ids,
233            &step.id,
234            "setup.machine.step.on_success",
235            step.on_success.as_deref(),
236        );
237        for rule in step.on_failure.iter().chain(step.recover.iter()) {
238            for target in rule_targets(rule) {
239                validate_transition(
240                    &mut diagnostics,
241                    &step_ids,
242                    &step.id,
243                    "setup.machine.step.rule_target",
244                    Some(&target),
245                );
246            }
247        }
248    }
249
250    diagnostics.extend(validate_reachability(machine, &step_ids));
251    diagnostics.extend(validate_unbounded_cycles(machine, &step_ids));
252    diagnostics.extend(validate_artifact_paths(machine));
253    diagnostics.extend(validate_http_steps(machine));
254    diagnostics.extend(validate_tunnel_steps(machine));
255    diagnostics.extend(validate_oauth_steps(machine));
256    diagnostics.extend(validate_data_mapping_steps(machine));
257    diagnostics
258}
259
260pub fn run_provider_setup_machine_doctor(pack_path: &Path) -> DoctorReport {
261    let mut diagnostics = Vec::new();
262    if !pack_path.is_file() {
263        diagnostics.push(Diagnostic {
264            check_id: "setup.provider_pack.present".to_string(),
265            severity: DiagnosticSeverity::Error,
266            component: "provider_setup".to_string(),
267            message: "provider pack does not exist or is not a file".to_string(),
268            evidence: None,
269            expected: Some(".gtpack file".to_string()),
270            actual: Some(pack_path.display().to_string()),
271            fix_hint: Some("pass a built provider .gtpack file".to_string()),
272            related_file: Some(pack_path.display().to_string()),
273            related_pack: None,
274            related_component: None,
275        });
276        return report(pack_path, diagnostics);
277    }
278
279    let pack_id = discovery::read_pack_meta(pack_path)
280        .ok()
281        .flatten()
282        .map(|meta| meta.pack_id)
283        .or_else(|| {
284            pack_path
285                .file_stem()
286                .and_then(|stem| stem.to_str())
287                .map(str::to_string)
288        });
289
290    diagnostics.extend(validate_legacy_setup_actions_absent(
291        pack_path,
292        pack_id.as_deref(),
293    ));
294
295    let mut found_setup_contract = false;
296
297    match load_setup_backend_contract_from_pack(pack_path, pack_id.as_deref()) {
298        Ok(Some(contract)) => {
299            found_setup_contract = true;
300            diagnostics.push(info(
301                "setup.backend_contract.extension.present",
302                "provider pack declares a setup backend contract",
303                SETUP_BACKEND_CONTRACT_EXTENSION,
304                pack_path,
305                contract.get("provider_id").and_then(Value::as_str),
306            ));
307            diagnostics.extend(validate_json_schema_diagnostics(
308                "setup.backend_contract.schema",
309                &contract,
310                SETUP_BACKEND_CONTRACT_SCHEMA,
311                pack_path,
312            ));
313            diagnostics.extend(validate_setup_backend_contract(&contract, pack_path));
314            diagnostics.extend(validate_setup_web_component(pack_path));
315            diagnostics.extend(validate_provider_http_routes(pack_path, &contract));
316        }
317        Ok(None) => {}
318        Err(err) => diagnostics.push(load_error(
319            "setup.backend_contract.load",
320            "provider setup backend contract could not be loaded",
321            "valid greentic.setup.backend-contract.v1 JSON",
322            err,
323            pack_path,
324        )),
325    }
326
327    match load_setup_machine_from_pack(pack_path) {
328        Ok(Some(machine)) => {
329            found_setup_contract = true;
330            diagnostics.push(info(
331                "setup.machine.extension.present",
332                "provider pack declares a setup state machine",
333                SETUP_MACHINE_EXTENSION,
334                pack_path,
335                Some(&machine.id),
336            ));
337            if let Ok(machine_value) = serde_json::to_value(&machine) {
338                diagnostics.extend(validate_json_schema_diagnostics(
339                    "setup.machine.schema",
340                    &machine_value,
341                    SETUP_MACHINE_SCHEMA,
342                    pack_path,
343                ));
344            }
345            diagnostics.extend(validate_setup_machine(&machine));
346            diagnostics.extend(validate_setup_machine_template_inputs(
347                pack_path,
348                pack_id.as_deref(),
349                &machine,
350            ));
351            diagnostics.extend(validate_setup_i18n_message_keys(pack_path, &machine));
352            diagnostics.extend(validate_setup_machine_fixtures(pack_path, &machine));
353        }
354        Ok(None) => {}
355        Err(err) => diagnostics.push(load_error(
356            "setup.machine.load",
357            "provider setup state machine could not be loaded",
358            "valid setup-machine.v1 JSON",
359            err,
360            pack_path,
361        )),
362    }
363
364    if !found_setup_contract {
365        diagnostics.push(Diagnostic {
366            check_id: "setup.provider_contract.present".to_string(),
367            severity: DiagnosticSeverity::Error,
368            component: "provider_setup".to_string(),
369            message: "provider pack does not declare a supported generic setup contract"
370                .to_string(),
371            evidence: None,
372            expected: Some(format!(
373                "{SETUP_BACKEND_CONTRACT_EXTENSION} or {SETUP_MACHINE_EXTENSION}"
374            )),
375            actual: Some("missing".to_string()),
376            fix_hint: Some(format!(
377                "add {SETUP_BACKEND_CONTRACT_EXTENSION} for the current generic setup contract or {SETUP_MACHINE_EXTENSION} for the future setup-machine contract"
378            )),
379            related_file: Some(pack_path.display().to_string()),
380            related_pack: pack_id,
381            related_component: None,
382        });
383    }
384
385    report(pack_path, diagnostics)
386}
387
388fn validate_legacy_setup_actions_absent(
389    pack_path: &Path,
390    pack_id: Option<&str>,
391) -> Vec<Diagnostic> {
392    let Some(spec) = crate::setup_input::load_setup_spec(pack_path)
393        .ok()
394        .flatten()
395    else {
396        return Vec::new();
397    };
398    if spec.setup_actions.is_empty() {
399        return Vec::new();
400    }
401    vec![Diagnostic {
402        check_id: "setup.legacy_setup_actions.absent".to_string(),
403        severity: DiagnosticSeverity::Error,
404        component: "provider_setup".to_string(),
405        message: "provider setup.yaml still declares legacy setup_actions".to_string(),
406        evidence: Some(format!("{} setup action(s)", spec.setup_actions.len())),
407        expected: Some(format!(
408            "{SETUP_BACKEND_CONTRACT_EXTENSION} or {SETUP_MACHINE_EXTENSION} only"
409        )),
410        actual: Some("setup.yaml setup_actions".to_string()),
411        fix_hint: Some(
412            "move provider setup into the generic setup contract and remove setup_actions from setup.yaml"
413                .to_string(),
414        ),
415        related_file: Some(pack_path.display().to_string()),
416        related_pack: pack_id.map(str::to_string),
417        related_component: None,
418    }]
419}
420
421fn validate_json_schema_diagnostics(
422    check_id: &str,
423    value: &Value,
424    schema_text: &str,
425    pack_path: &Path,
426) -> Vec<Diagnostic> {
427    let schema = match serde_json::from_str::<Value>(schema_text) {
428        Ok(schema) => schema,
429        Err(err) => {
430            return vec![load_error(
431                check_id,
432                "setup schema could not be parsed",
433                "valid JSON Schema",
434                err.into(),
435                pack_path,
436            )];
437        }
438    };
439    crate::schema_validation::validate(value, &schema)
440        .into_iter()
441        .map(|error| Diagnostic {
442            check_id: check_id.to_string(),
443            severity: DiagnosticSeverity::Error,
444            component: "provider_setup".to_string(),
445            message: "setup contract does not match its JSON schema".to_string(),
446            evidence: Some(error),
447            expected: schema
448                .get("$id")
449                .and_then(Value::as_str)
450                .map(str::to_string),
451            actual: None,
452            fix_hint: Some(
453                "update provider setup metadata to match the published schema".to_string(),
454            ),
455            related_file: Some(pack_path.display().to_string()),
456            related_pack: value
457                .get("provider_id")
458                .or_else(|| value.get("id"))
459                .and_then(Value::as_str)
460                .map(str::to_string),
461            related_component: None,
462        })
463        .collect()
464}
465
466pub fn load_setup_backend_contract_from_pack(
467    pack_path: &Path,
468    expected_provider_id: Option<&str>,
469) -> anyhow::Result<Option<Value>> {
470    let Some(extension) =
471        discovery::read_pack_extension(pack_path, SETUP_BACKEND_CONTRACT_EXTENSION)?
472    else {
473        return Ok(None);
474    };
475    let descriptor = extension_inline(&extension).cloned().unwrap_or(extension);
476    validate_schema_id(
477        &descriptor,
478        SETUP_BACKEND_CONTRACT_EXTENSION,
479        "setup backend contract descriptor",
480    )?;
481
482    let Some(asset) = descriptor.get("asset").and_then(Value::as_str) else {
483        let provider_id = descriptor.get("provider_id").and_then(Value::as_str);
484        validate_provider_id(provider_id, expected_provider_id)?;
485        return Ok(Some(descriptor));
486    };
487    let mut contract = discovery::read_pack_json_asset(pack_path, asset)?;
488    validate_schema_id(
489        &contract,
490        SETUP_BACKEND_CONTRACT_EXTENSION,
491        "setup backend contract asset",
492    )?;
493    let provider_id = contract.get("provider_id").and_then(Value::as_str);
494    validate_provider_id(provider_id, expected_provider_id)?;
495    if let Some(map) = contract.as_object_mut() {
496        map.insert(
497            "descriptor".to_string(),
498            serde_json::json!({
499                "schema_id": descriptor.get("schema_id").cloned().unwrap_or(Value::Null),
500                "provider_id": descriptor.get("provider_id").cloned().unwrap_or(Value::Null),
501                "asset": descriptor.get("asset").cloned().unwrap_or(Value::Null),
502            }),
503        );
504    }
505    Ok(Some(contract))
506}
507
508fn validate_setup_backend_contract(contract: &Value, pack_path: &Path) -> Vec<Diagnostic> {
509    let mut diagnostics = Vec::new();
510    let provider_id = contract.get("provider_id").and_then(Value::as_str);
511
512    if provider_id.is_none_or(|value| value.trim().is_empty()) {
513        diagnostics.push(error(
514            "setup.backend_contract.provider_id",
515            "setup backend contract provider_id is required",
516        ));
517    }
518
519    let required_order = string_array(contract.get("required_order"));
520    if required_order.is_empty() {
521        diagnostics.push(error(
522            "setup.backend_contract.required_order",
523            "setup backend contract must declare required_order",
524        ));
525    }
526    diagnostics.extend(validate_unique_strings(
527        "setup.backend_contract.required_order.unique",
528        "required_order entries must be unique",
529        &required_order,
530    ));
531
532    let actions = contract
533        .get("actions")
534        .and_then(Value::as_array)
535        .cloned()
536        .unwrap_or_default();
537    if actions.is_empty() {
538        diagnostics.push(error(
539            "setup.backend_contract.actions",
540            "setup backend contract must declare actions",
541        ));
542    }
543
544    let server_owned = string_array(contract.get("server_owned_config_keys"));
545    let mut action_ids = BTreeSet::new();
546    for action in &actions {
547        let Some(id) = action.get("id").and_then(Value::as_str) else {
548            diagnostics.push(error(
549                "setup.backend_contract.action.id",
550                "setup backend action id is required",
551            ));
552            continue;
553        };
554        if id.trim().is_empty() {
555            diagnostics.push(error(
556                "setup.backend_contract.action.id",
557                "setup backend action id is required",
558            ));
559            continue;
560        }
561        if !action_ids.insert(id.to_string()) {
562            diagnostics.push(error_with_actual(
563                "setup.backend_contract.action.unique_id",
564                "setup backend action ids must be unique",
565                id,
566            ));
567        }
568
569        for required in string_array(action.get("requires")) {
570            if !required_order.contains(&required) && !action_ids.contains(&required) {
571                diagnostics.push(error_with_actual(
572                    "setup.backend_contract.action.requires",
573                    "setup backend action requires entry must reference another declared action",
574                    &format!("{id} requires {required}"),
575                ));
576            }
577        }
578
579        validate_backend_executor(&mut diagnostics, id, action, pack_path, &server_owned);
580
581        if action.get("completion").is_none() {
582            diagnostics.push(error_with_actual(
583                "setup.backend_contract.action.completion",
584                "setup backend action should declare completion criteria",
585                id,
586            ));
587        }
588    }
589
590    for step in &required_order {
591        if !action_ids.contains(step) {
592            diagnostics.push(error_with_actual(
593                "setup.backend_contract.required_order.action",
594                "required_order entry must have a matching action",
595                step,
596            ));
597        }
598    }
599
600    diagnostics.extend(validate_unique_strings(
601        "setup.backend_contract.server_owned_config_keys.unique",
602        "server_owned_config_keys entries must be unique",
603        &server_owned,
604    ));
605
606    diagnostics
607}
608
609fn validate_backend_executor(
610    diagnostics: &mut Vec<Diagnostic>,
611    action_id: &str,
612    action: &Value,
613    _pack_path: &Path,
614    server_owned: &[String],
615) {
616    let Some(executor) = action.get("executor").and_then(Value::as_object) else {
617        diagnostics.push(error_with_actual(
618            "setup.backend_contract.action.executor",
619            "setup backend action must declare executor",
620            action_id,
621        ));
622        return;
623    };
624    let kind = executor
625        .get("kind")
626        .and_then(Value::as_str)
627        .unwrap_or_default();
628    if kind.is_empty() {
629        diagnostics.push(error_with_actual(
630            "setup.backend_contract.executor.kind",
631            "setup backend executor kind is required",
632            action_id,
633        ));
634        return;
635    }
636    if !SUPPORTED_BACKEND_EXECUTOR_KINDS.contains(&kind) {
637        diagnostics.push(error_with_actual(
638            "setup.backend_contract.executor.supported_kind",
639            "setup backend executor kind is not supported by this greentic-setup version",
640            kind,
641        ));
642        return;
643    }
644
645    match kind {
646        "oauth_device_code" => {
647            for key in [
648                "authority_url_template",
649                "client_id_config_key",
650                "oauth_kind",
651                "token_store_key",
652            ] {
653                require_executor_str(diagnostics, action_id, executor, key);
654            }
655            if string_array(executor.get("scopes")).is_empty() {
656                diagnostics.push(error_with_actual(
657                    "setup.backend_contract.executor.scopes",
658                    "oauth_device_code executor requires at least one scope",
659                    action_id,
660                ));
661            }
662            validate_oauth_server_owned_keys(diagnostics, action_id, executor, server_owned);
663        }
664        "microsoft_graph_application" => {
665            for key in [
666                "graph_token_store_key",
667                "display_name_config_key",
668                "app_id_config_key",
669                "client_secret_config_key",
670            ] {
671                require_executor_str(diagnostics, action_id, executor, key);
672            }
673        }
674        "provider_http" => {
675            let path_template = executor
676                .get("path_template")
677                .or_else(|| executor.get("target_path_template"))
678                .and_then(Value::as_str);
679            let url_template = executor
680                .get("url_template")
681                .or_else(|| executor.get("target_url_template"))
682                .and_then(Value::as_str);
683            if path_template.is_none() && url_template.is_none() {
684                diagnostics.push(error_with_actual(
685                    "setup.backend_contract.executor.provider_http.target",
686                    "provider_http executor requires path_template or url_template",
687                    action_id,
688                ));
689            }
690            if let Some(path) = path_template
691                && !path.starts_with('/')
692            {
693                diagnostics.push(error_with_actual(
694                    "setup.backend_contract.executor.provider_http.path",
695                    "provider_http path_template must be an absolute path",
696                    path,
697                ));
698            }
699        }
700        "runtime_observation" => {
701            for key in ["source", "event", "state_store_key"] {
702                require_executor_str(diagnostics, action_id, executor, key);
703            }
704        }
705        _ => {}
706    }
707}
708
709fn validate_oauth_server_owned_keys(
710    diagnostics: &mut Vec<Diagnostic>,
711    action_id: &str,
712    executor: &serde_json::Map<String, Value>,
713    server_owned: &[String],
714) {
715    let oauth_kind_key = "oauth_kind";
716    validate_server_owned_key(diagnostics, action_id, server_owned, oauth_kind_key);
717
718    for key in [
719        "token_store_key",
720        "device_code_store_key",
721        "user_code_store_key",
722    ] {
723        if let Some(value) = executor.get(key).and_then(Value::as_str) {
724            validate_server_owned_key(diagnostics, action_id, server_owned, value);
725        }
726    }
727
728    if !executor.contains_key("device_code_store_key") {
729        validate_server_owned_key(diagnostics, action_id, server_owned, "oauth_device_code");
730    }
731    if !executor.contains_key("user_code_store_key") {
732        validate_server_owned_key(diagnostics, action_id, server_owned, "oauth_user_code");
733    }
734}
735
736fn validate_server_owned_key(
737    diagnostics: &mut Vec<Diagnostic>,
738    action_id: &str,
739    server_owned: &[String],
740    key: &str,
741) {
742    if server_owned.iter().any(|value| value == key) {
743        return;
744    }
745    diagnostics.push(error_with_actual(
746        "setup.backend_contract.server_owned_config_keys.oauth",
747        "oauth_device_code executor output keys must be listed in server_owned_config_keys",
748        &format!("{action_id}.{key}"),
749    ));
750}
751
752fn validate_setup_web_component(pack_path: &Path) -> Vec<Diagnostic> {
753    let mut diagnostics = Vec::new();
754    match discovery::read_pack_extension(pack_path, SETUP_WEB_COMPONENT_EXTENSION) {
755        Ok(Some(extension)) => {
756            let descriptor = extension_inline(&extension).unwrap_or(&extension);
757            if let Err(err) = validate_schema_id(
758                descriptor,
759                SETUP_WEB_COMPONENT_EXTENSION,
760                "setup web component descriptor",
761            ) {
762                diagnostics.push(load_error(
763                    "setup.web_component.schema_id",
764                    "setup web component descriptor has invalid schema_id",
765                    SETUP_WEB_COMPONENT_EXTENSION,
766                    err,
767                    pack_path,
768                ));
769            }
770            for key in ["provider_id", "tag_name", "module_asset"] {
771                if descriptor
772                    .get(key)
773                    .and_then(Value::as_str)
774                    .is_none_or(|value| value.trim().is_empty())
775                {
776                    diagnostics.push(error_with_actual(
777                        "setup.web_component.required_field",
778                        "setup web component descriptor is missing a required field",
779                        key,
780                    ));
781                }
782            }
783            if let Some(module_asset) = descriptor.get("module_asset").and_then(Value::as_str) {
784                match discovery::pack_asset_exists(pack_path, module_asset) {
785                    Ok(true) => {}
786                    Ok(false) => diagnostics.push(error_with_actual(
787                        "setup.web_component.module_asset.exists",
788                        "setup web component module_asset must exist in the pack",
789                        module_asset,
790                    )),
791                    Err(err) => diagnostics.push(load_error(
792                        "setup.web_component.module_asset.read",
793                        "setup web component module_asset could not be checked",
794                        "readable pack asset",
795                        err,
796                        pack_path,
797                    )),
798                }
799            }
800        }
801        Ok(None) => diagnostics.push(Diagnostic {
802            check_id: "setup.web_component.extension.present".to_string(),
803            severity: DiagnosticSeverity::Error,
804            component: "provider_setup".to_string(),
805            message: "provider pack does not declare a setup web component".to_string(),
806            evidence: None,
807            expected: Some(SETUP_WEB_COMPONENT_EXTENSION.to_string()),
808            actual: Some("missing".to_string()),
809            fix_hint: Some(format!(
810                "add {SETUP_WEB_COMPONENT_EXTENSION} to the pack manifest"
811            )),
812            related_file: Some(pack_path.display().to_string()),
813            related_pack: None,
814            related_component: None,
815        }),
816        Err(err) => diagnostics.push(load_error(
817            "setup.web_component.load",
818            "setup web component descriptor could not be loaded",
819            SETUP_WEB_COMPONENT_EXTENSION,
820            err,
821            pack_path,
822        )),
823    }
824    diagnostics
825}
826
827fn validate_provider_http_routes(pack_path: &Path, contract: &Value) -> Vec<Diagnostic> {
828    let mut diagnostics = Vec::new();
829    let provider_http_routes = provider_http_requirements(contract);
830    if provider_http_routes.is_empty() {
831        return diagnostics;
832    }
833
834    let routes_extension = match discovery::read_pack_extension(pack_path, HTTP_ROUTES_EXTENSION) {
835        Ok(Some(extension)) => extension,
836        Ok(None) => {
837            diagnostics.push(Diagnostic {
838                check_id: "setup.backend_contract.provider_http.routes_extension".to_string(),
839                severity: DiagnosticSeverity::Error,
840                component: "provider_setup".to_string(),
841                message: "provider_http setup actions require greentic.http-routes.v1".to_string(),
842                evidence: Some(
843                    provider_http_routes
844                        .iter()
845                        .map(|route| route.path.as_str())
846                        .collect::<Vec<_>>()
847                        .join(", "),
848                ),
849                expected: Some(HTTP_ROUTES_EXTENSION.to_string()),
850                actual: Some("missing".to_string()),
851                fix_hint: Some(format!(
852                    "declare {HTTP_ROUTES_EXTENSION} routes for provider_http setup actions"
853                )),
854                related_file: Some(pack_path.display().to_string()),
855                related_pack: contract
856                    .get("provider_id")
857                    .and_then(Value::as_str)
858                    .map(str::to_string),
859                related_component: None,
860            });
861            return diagnostics;
862        }
863        Err(err) => {
864            diagnostics.push(load_error(
865                "setup.backend_contract.provider_http.routes_load",
866                "greentic.http-routes.v1 could not be loaded",
867                HTTP_ROUTES_EXTENSION,
868                err,
869                pack_path,
870            ));
871            return diagnostics;
872        }
873    };
874
875    let routes = extension_inline(&routes_extension).unwrap_or(&routes_extension);
876    let declared_routes: Vec<DeclaredHttpRoute> = routes
877        .get("routes")
878        .and_then(Value::as_array)
879        .into_iter()
880        .flatten()
881        .filter_map(declared_http_route)
882        .collect();
883    if declared_routes.is_empty() {
884        diagnostics.push(error(
885            "setup.backend_contract.provider_http.routes",
886            "greentic.http-routes.v1 must declare routes for provider_http setup actions",
887        ));
888        return diagnostics;
889    }
890
891    for required in provider_http_routes {
892        let Some(route) = declared_routes
893            .iter()
894            .find(|route| route_pattern_covers_path(&route.pattern, &required.path))
895        else {
896            diagnostics.push(error_with_actual(
897                "setup.backend_contract.provider_http.route_covered",
898                "provider_http path_template is not covered by greentic.http-routes.v1",
899                &required.path,
900            ));
901            continue;
902        };
903        if !route.methods.is_empty()
904            && !route
905                .methods
906                .iter()
907                .any(|method| method.eq_ignore_ascii_case(&required.method))
908        {
909            diagnostics.push(error_with_actual(
910                "setup.backend_contract.provider_http.route_method",
911                "provider_http method is not allowed by greentic.http-routes.v1",
912                &format!("{} {}", required.method, required.path),
913            ));
914        }
915    }
916
917    diagnostics
918}
919
920#[derive(Clone, Debug)]
921struct ProviderHttpRequirement {
922    method: String,
923    path: String,
924}
925
926#[derive(Clone, Debug)]
927struct DeclaredHttpRoute {
928    pattern: String,
929    methods: Vec<String>,
930}
931
932fn provider_http_requirements(contract: &Value) -> Vec<ProviderHttpRequirement> {
933    contract
934        .get("actions")
935        .and_then(Value::as_array)
936        .into_iter()
937        .flatten()
938        .filter_map(|action| action.get("executor"))
939        .filter(|executor| executor.get("kind").and_then(Value::as_str) == Some("provider_http"))
940        .filter_map(|executor| {
941            let path = executor
942                .get("path_template")
943                .or_else(|| executor.get("target_path_template"))
944                .and_then(Value::as_str)?;
945            let method = executor
946                .get("method")
947                .or_else(|| executor.get("http_method"))
948                .and_then(Value::as_str)
949                .unwrap_or("POST");
950            Some(ProviderHttpRequirement {
951                method: method.trim().to_ascii_uppercase(),
952                path: path.trim().to_string(),
953            })
954        })
955        .filter(|route| !route.path.is_empty())
956        .collect()
957}
958
959fn declared_http_route(record: &Value) -> Option<DeclaredHttpRoute> {
960    let pattern = record.get("pattern").and_then(Value::as_str)?.trim();
961    if pattern.is_empty() {
962        return None;
963    }
964    let methods = record
965        .get("methods")
966        .and_then(Value::as_array)
967        .into_iter()
968        .flatten()
969        .filter_map(Value::as_str)
970        .map(str::trim)
971        .filter(|value| !value.is_empty())
972        .map(|value| value.to_ascii_uppercase())
973        .collect();
974    Some(DeclaredHttpRoute {
975        pattern: pattern.to_string(),
976        methods,
977    })
978}
979
980fn route_pattern_covers_path(pattern: &str, path: &str) -> bool {
981    let pattern_segments: Vec<&str> = normalized_path_segments(pattern).collect();
982    let path_segments: Vec<&str> = normalized_path_segments(path).collect();
983    route_segments_cover(&pattern_segments, &path_segments)
984}
985
986fn normalized_path_segments(path: &str) -> impl Iterator<Item = &str> {
987    path.trim_start_matches('/')
988        .split('/')
989        .filter(|segment| !segment.is_empty())
990}
991
992fn route_segments_cover(pattern: &[&str], path: &[&str]) -> bool {
993    let mut p = 0;
994    let mut s = 0;
995    while p < pattern.len() {
996        let pattern_segment = pattern[p];
997        if is_route_splat(pattern_segment) {
998            return true;
999        }
1000        let Some(path_segment) = path.get(s) else {
1001            return false;
1002        };
1003        if !route_segment_covers(pattern_segment, path_segment) {
1004            return false;
1005        }
1006        p += 1;
1007        s += 1;
1008    }
1009    s == path.len()
1010}
1011
1012fn route_segment_covers(pattern_segment: &str, path_segment: &str) -> bool {
1013    pattern_segment == path_segment
1014        || is_route_variable(pattern_segment) && is_route_variable(path_segment)
1015}
1016
1017fn is_route_variable(segment: &str) -> bool {
1018    segment.starts_with('{') && segment.ends_with('}') && !segment.ends_with("*}")
1019}
1020
1021fn is_route_splat(segment: &str) -> bool {
1022    segment.starts_with('{') && segment.ends_with("*}")
1023}
1024
1025pub fn setup_machine_state_dir(
1026    bundle_root: &Path,
1027    tenant: &str,
1028    team: &str,
1029    provider_id: &str,
1030) -> PathBuf {
1031    bundle_root
1032        .join("state")
1033        .join("setup")
1034        .join(tenant)
1035        .join(team)
1036        .join(provider_id)
1037}
1038
1039pub fn setup_machine_state_path(
1040    bundle_root: &Path,
1041    tenant: &str,
1042    team: &str,
1043    provider_id: &str,
1044) -> PathBuf {
1045    setup_machine_state_dir(bundle_root, tenant, team, provider_id).join("machine.json")
1046}
1047
1048pub fn setup_machine_events_path(
1049    bundle_root: &Path,
1050    tenant: &str,
1051    team: &str,
1052    provider_id: &str,
1053) -> PathBuf {
1054    setup_machine_state_dir(bundle_root, tenant, team, provider_id).join("events.jsonl")
1055}
1056
1057pub fn write_setup_machine_state(
1058    bundle_root: &Path,
1059    state: &SetupMachineState,
1060) -> anyhow::Result<PathBuf> {
1061    let path =
1062        setup_machine_state_path(bundle_root, &state.tenant, &state.team, &state.provider_id);
1063    if let Some(parent) = path.parent() {
1064        std::fs::create_dir_all(parent)?;
1065    }
1066    std::fs::write(&path, serde_json::to_vec_pretty(state)?)?;
1067    Ok(path)
1068}
1069
1070pub fn load_setup_machine_state(path: &Path) -> anyhow::Result<SetupMachineState> {
1071    let raw = std::fs::read_to_string(path)?;
1072    Ok(serde_json::from_str(&raw)?)
1073}
1074
1075pub fn append_setup_machine_event(
1076    bundle_root: &Path,
1077    event: &SetupMachineEvent,
1078) -> anyhow::Result<PathBuf> {
1079    let path =
1080        setup_machine_events_path(bundle_root, &event.tenant, &event.team, &event.provider_id);
1081    if let Some(parent) = path.parent() {
1082        std::fs::create_dir_all(parent)?;
1083    }
1084    let mut line = serde_json::to_string(event)?;
1085    line.push('\n');
1086    use std::io::Write;
1087    let mut file = std::fs::OpenOptions::new()
1088        .create(true)
1089        .append(true)
1090        .open(&path)?;
1091    file.write_all(line.as_bytes())?;
1092    Ok(path)
1093}
1094
1095pub fn load_or_init_setup_machine_state(
1096    bundle_root: &Path,
1097    provider_id: &str,
1098    tenant: &str,
1099    team: &str,
1100    machine: &SetupMachine,
1101) -> anyhow::Result<SetupMachineState> {
1102    load_or_init_setup_machine_state_with_context(
1103        bundle_root,
1104        None,
1105        provider_id,
1106        tenant,
1107        team,
1108        machine,
1109    )
1110}
1111
1112pub fn load_or_init_setup_machine_state_with_context(
1113    bundle_root: &Path,
1114    pack_path: Option<&Path>,
1115    provider_id: &str,
1116    tenant: &str,
1117    team: &str,
1118    machine: &SetupMachine,
1119) -> anyhow::Result<SetupMachineState> {
1120    let current_pack_fingerprint = pack_path
1121        .map(setup_machine_hash_file)
1122        .transpose()
1123        .with_context(|| format!("hash setup provider pack for {provider_id}"))?;
1124    let current_answers_hash = setup_machine_answers_hash(bundle_root, provider_id)?;
1125    let path = setup_machine_state_path(bundle_root, tenant, team, provider_id);
1126    if path.is_file() {
1127        let mut state = load_setup_machine_state(&path)?;
1128        if state.schema_version != SUPPORTED_SETUP_STATE_SCHEMA_VERSION {
1129            state.status = SetupMachineStatus::Failed;
1130            state.last_error = Some(serde_json::json!({
1131                "error": "setup_state_schema_changed",
1132                "recoverable": false,
1133                "expected": SUPPORTED_SETUP_STATE_SCHEMA_VERSION,
1134                "actual": state.schema_version,
1135            }));
1136        }
1137        if state.provider_id != provider_id || state.tenant != tenant || state.team != team {
1138            state.status = SetupMachineStatus::Failed;
1139            state.last_error = Some(serde_json::json!({
1140                "error": "setup_state_scope_changed",
1141                "recoverable": false,
1142                "expected": {
1143                    "provider_id": provider_id,
1144                    "tenant": tenant,
1145                    "team": team,
1146                },
1147                "actual": {
1148                    "provider_id": state.provider_id,
1149                    "tenant": state.tenant,
1150                    "team": state.team,
1151                }
1152            }));
1153        }
1154        if state.machine_id != machine.id || state.machine_version != machine.version {
1155            state.status = SetupMachineStatus::Failed;
1156            state.last_error = Some(serde_json::json!({
1157                "error": "machine_identity_changed",
1158                "recoverable": false,
1159                "expected": {
1160                    "machine_id": machine.id,
1161                    "machine_version": machine.version,
1162                },
1163                "actual": {
1164                    "machine_id": state.machine_id,
1165                    "machine_version": state.machine_version,
1166                }
1167            }));
1168        }
1169        if let (Some(stored), Some(current)) = (
1170            state.pack_fingerprint.as_deref(),
1171            current_pack_fingerprint.as_deref(),
1172        ) && stored != current
1173        {
1174            state.status = SetupMachineStatus::Failed;
1175            state.last_error = Some(serde_json::json!({
1176                "error": "pack_fingerprint_changed",
1177                "recoverable": false,
1178                "expected": stored,
1179                "actual": current,
1180            }));
1181        } else if state.pack_fingerprint.is_none() {
1182            state.pack_fingerprint = current_pack_fingerprint;
1183        }
1184        if state.answers_hash.as_deref() != current_answers_hash.as_deref() {
1185            if let Some(stored) = state.answers_hash.as_deref() {
1186                state.status = SetupMachineStatus::Failed;
1187                state.last_error = Some(serde_json::json!({
1188                    "error": "setup_answers_changed",
1189                    "recoverable": false,
1190                    "expected": stored,
1191                    "actual": current_answers_hash.as_deref().unwrap_or("missing"),
1192                }));
1193            } else {
1194                state.answers_hash = current_answers_hash;
1195            }
1196        }
1197        return Ok(state);
1198    }
1199
1200    Ok(SetupMachineState {
1201        schema_version: 1,
1202        provider_id: provider_id.to_string(),
1203        tenant: tenant.to_string(),
1204        team: team.to_string(),
1205        machine_id: machine.id.clone(),
1206        machine_version: machine.version,
1207        status: SetupMachineStatus::NotStarted,
1208        current_step: Some(machine.entry_step.clone()),
1209        completed_steps: Vec::new(),
1210        failed_step: None,
1211        answers_hash: current_answers_hash,
1212        pack_fingerprint: current_pack_fingerprint,
1213        outputs: serde_json::json!({}),
1214        created_resources: Vec::new(),
1215        last_error: None,
1216        updated_at: Some(now_setup_ts()),
1217    })
1218}
1219
1220fn setup_machine_state_compatibility_error(state: &SetupMachineState) -> Option<String> {
1221    state
1222        .last_error
1223        .as_ref()
1224        .and_then(|error| error.get("error"))
1225        .and_then(Value::as_str)
1226        .filter(|error| {
1227            matches!(
1228                *error,
1229                "setup_state_schema_changed"
1230                    | "setup_state_scope_changed"
1231                    | "machine_identity_changed"
1232                    | "pack_fingerprint_changed"
1233                    | "setup_answers_changed"
1234            )
1235        })
1236        .map(str::to_string)
1237}
1238
1239fn setup_machine_answers_hash(
1240    bundle_root: &Path,
1241    provider_id: &str,
1242) -> anyhow::Result<Option<String>> {
1243    let path = bundle_root
1244        .join("state/config")
1245        .join(provider_id)
1246        .join("setup-answers.json");
1247    if !path.is_file() {
1248        return Ok(None);
1249    }
1250    Ok(Some(setup_machine_hash_file(&path)?))
1251}
1252
1253fn setup_machine_hash_file(path: &Path) -> anyhow::Result<String> {
1254    let mut file = std::fs::File::open(path).with_context(|| format!("open {}", path.display()))?;
1255    let mut hasher = Sha256::new();
1256    let mut buf = [0_u8; 8192];
1257    loop {
1258        let read = file
1259            .read(&mut buf)
1260            .with_context(|| format!("read {}", path.display()))?;
1261        if read == 0 {
1262            break;
1263        }
1264        hasher.update(&buf[..read]);
1265    }
1266    Ok(format!(
1267        "sha256:{}",
1268        setup_machine_hex_encode(&hasher.finalize())
1269    ))
1270}
1271
1272fn setup_machine_hex_encode(bytes: &[u8]) -> String {
1273    const HEX: &[u8; 16] = b"0123456789abcdef";
1274    let mut out = String::with_capacity(bytes.len() * 2);
1275    for byte in bytes {
1276        out.push(HEX[(byte >> 4) as usize] as char);
1277        out.push(HEX[(byte & 0x0f) as usize] as char);
1278    }
1279    out
1280}
1281
1282pub fn render_setup_machine_status(machine: &SetupMachine, state: &SetupMachineState) -> Value {
1283    let current = state
1284        .current_step
1285        .clone()
1286        .unwrap_or_else(|| machine.entry_step.clone());
1287    let completed: BTreeSet<&str> = state.completed_steps.iter().map(String::as_str).collect();
1288    let steps: Vec<Value> = machine
1289        .steps
1290        .iter()
1291        .map(|step| {
1292            let step_state = if completed.contains(step.id.as_str()) {
1293                "done"
1294            } else if Some(step.id.as_str()) == state.failed_step.as_deref() {
1295                "failed"
1296            } else if step.id == current {
1297                "current"
1298            } else {
1299                "pending"
1300            };
1301            serde_json::json!({
1302                "id": step.id,
1303                "kind": step.kind,
1304                "title": step.title,
1305                "state": step_state,
1306            })
1307        })
1308        .collect();
1309    serde_json::json!({
1310        "ok": state.status == SetupMachineStatus::Complete,
1311        "provider_id": state.provider_id,
1312        "tenant": state.tenant,
1313        "team": state.team,
1314        "machine_id": state.machine_id,
1315        "machine_version": state.machine_version,
1316        "status": state.status,
1317        "current_step": current,
1318        "completed_steps": state.completed_steps,
1319        "failed_step": state.failed_step,
1320        "last_error": state.last_error,
1321        "steps": steps,
1322    })
1323}
1324
1325pub async fn complete_setup_machine_oauth_authorization_code_with_token_response(
1326    bundle_root: &Path,
1327    env: &str,
1328    oauth_state: &crate::setup_actions::OAuthStatePayload,
1329    token_response: &Value,
1330) -> anyhow::Result<crate::oauth_callback::OAuthCallbackReport> {
1331    let discovered = discovery::discover(bundle_root)
1332        .context("failed to discover providers for setup-machine OAuth callback")?;
1333    let provider = discovered
1334        .find_setup_target(&oauth_state.provider_id)
1335        .ok_or_else(|| {
1336            anyhow::anyhow!(
1337                "provider not found for setup-machine OAuth callback: {}",
1338                oauth_state.provider_id
1339            )
1340        })?;
1341    let machine = load_setup_machine_from_pack(&provider.pack_path)?.ok_or_else(|| {
1342        anyhow::anyhow!(
1343            "provider does not declare {SETUP_MACHINE_EXTENSION}: {}",
1344            oauth_state.provider_id
1345        )
1346    })?;
1347    complete_setup_machine_oauth_authorization_code_for_machine(
1348        bundle_root,
1349        env,
1350        Some(&provider.pack_path),
1351        &machine,
1352        oauth_state,
1353        token_response,
1354    )
1355    .await
1356}
1357
1358pub async fn complete_setup_machine_oauth_authorization_code(
1359    bundle_root: &Path,
1360    env: &str,
1361    oauth_state: &crate::setup_actions::OAuthStatePayload,
1362    code: &str,
1363) -> anyhow::Result<crate::oauth_callback::OAuthCallbackReport> {
1364    if code.trim().is_empty() {
1365        anyhow::bail!("OAuth callback missing code");
1366    }
1367    let discovered = discovery::discover(bundle_root)
1368        .context("failed to discover providers for setup-machine OAuth callback")?;
1369    let provider = discovered
1370        .find_setup_target(&oauth_state.provider_id)
1371        .ok_or_else(|| {
1372            anyhow::anyhow!(
1373                "provider not found for setup-machine OAuth callback: {}",
1374                oauth_state.provider_id
1375            )
1376        })?;
1377    let machine = load_setup_machine_from_pack(&provider.pack_path)?.ok_or_else(|| {
1378        anyhow::anyhow!(
1379            "provider does not declare {SETUP_MACHINE_EXTENSION}: {}",
1380            oauth_state.provider_id
1381        )
1382    })?;
1383    let token_response = exchange_setup_machine_oauth_authorization_code(
1384        bundle_root,
1385        &machine,
1386        oauth_state,
1387        code.trim(),
1388    )?;
1389    complete_setup_machine_oauth_authorization_code_for_machine(
1390        bundle_root,
1391        env,
1392        Some(&provider.pack_path),
1393        &machine,
1394        oauth_state,
1395        &token_response,
1396    )
1397    .await
1398}
1399
1400pub async fn complete_setup_machine_oauth_authorization_code_for_machine(
1401    bundle_root: &Path,
1402    env: &str,
1403    pack_path: Option<&Path>,
1404    machine: &SetupMachine,
1405    oauth_state: &crate::setup_actions::OAuthStatePayload,
1406    token_response: &Value,
1407) -> anyhow::Result<crate::oauth_callback::OAuthCallbackReport> {
1408    let step = machine
1409        .steps
1410        .iter()
1411        .find(|candidate| candidate.id == oauth_state.action_id)
1412        .ok_or_else(|| anyhow::anyhow!("setup-machine step not found: {}", oauth_state.action_id))?
1413        .clone();
1414    if step.kind != "oauth_authorization_code" {
1415        anyhow::bail!(
1416            "setup-machine step is not an oauth_authorization_code step: {}",
1417            step.id
1418        );
1419    }
1420
1421    let mut state = load_or_init_setup_machine_state(
1422        bundle_root,
1423        &oauth_state.provider_id,
1424        &oauth_state.tenant,
1425        &oauth_state.team,
1426        machine,
1427    )?;
1428    if state.completed_steps.iter().any(|id| id == &step.id) {
1429        anyhow::bail!("setup-machine OAuth step is already complete");
1430    }
1431    if !matches!(
1432        state.status,
1433        SetupMachineStatus::Running | SetupMachineStatus::Paused | SetupMachineStatus::NotStarted
1434    ) {
1435        anyhow::bail!("setup-machine OAuth step is not pending");
1436    }
1437    if state.current_step.as_deref() != Some(step.id.as_str()) {
1438        anyhow::bail!(
1439            "setup-machine OAuth callback step mismatch: current={:?} callback={}",
1440            state.current_step,
1441            step.id
1442        );
1443    }
1444
1445    let config = map_oauth_authorization_token_response(&step, token_response)?;
1446    let persisted_keys = crate::qa::persist::persist_all_config_as_secrets(
1447        bundle_root,
1448        env,
1449        &oauth_state.tenant,
1450        Some(&oauth_state.team),
1451        &oauth_state.provider_id,
1452        &Value::Object(config.clone()),
1453        pack_path,
1454    )
1455    .await?;
1456
1457    ensure_outputs_object(&mut state).insert(
1458        step.extra
1459            .get("output_key")
1460            .and_then(Value::as_str)
1461            .unwrap_or("oauth_authorization")
1462            .to_string(),
1463        serde_json::json!({
1464            "ok": true,
1465            "persisted_keys": persisted_keys,
1466        }),
1467    );
1468    mark_machine_step_complete(machine, &step, &mut state);
1469    state.updated_at = Some(now_setup_ts());
1470    write_setup_machine_state(bundle_root, &state)?;
1471    append_machine_step_event(
1472        bundle_root,
1473        &state,
1474        "setup.step.oauth_callback_complete",
1475        Some(&step.id),
1476        Some(serde_json::json!({
1477            "persisted_keys": persisted_keys,
1478        })),
1479    )?;
1480
1481    Ok(crate::oauth_callback::OAuthCallbackReport {
1482        provider_id: oauth_state.provider_id.clone(),
1483        tenant: oauth_state.tenant.clone(),
1484        team: oauth_state.team.clone(),
1485        action_id: oauth_state.action_id.clone(),
1486        persisted_secret_keys: persisted_keys,
1487    })
1488}
1489
1490pub fn advance_setup_machine(
1491    bundle_root: &Path,
1492    provider_id: &str,
1493    tenant: &str,
1494    team: &str,
1495    machine: &SetupMachine,
1496    dry_run: bool,
1497) -> anyhow::Result<Value> {
1498    advance_setup_machine_with_pack(
1499        bundle_root,
1500        None,
1501        provider_id,
1502        tenant,
1503        team,
1504        machine,
1505        dry_run,
1506    )
1507}
1508
1509pub fn advance_setup_machine_with_pack(
1510    bundle_root: &Path,
1511    pack_path: Option<&Path>,
1512    provider_id: &str,
1513    tenant: &str,
1514    team: &str,
1515    machine: &SetupMachine,
1516    dry_run: bool,
1517) -> anyhow::Result<Value> {
1518    let mut state = load_or_init_setup_machine_state_with_context(
1519        bundle_root,
1520        pack_path,
1521        provider_id,
1522        tenant,
1523        team,
1524        machine,
1525    )?;
1526    let before = render_setup_machine_status(machine, &state);
1527    if state.status == SetupMachineStatus::Complete {
1528        return Ok(serde_json::json!({
1529            "provider_id": provider_id,
1530            "tenant": tenant,
1531            "team": team,
1532            "machine_id": machine.id,
1533            "ok": true,
1534            "step": "complete",
1535            "dry_run": dry_run,
1536            "result": {
1537                "ok": true,
1538                "complete": true,
1539            },
1540            "status": before,
1541        }));
1542    }
1543    if let Some(error) = setup_machine_state_compatibility_error(&state) {
1544        state.updated_at = Some(now_setup_ts());
1545        let event_path = if dry_run {
1546            None
1547        } else {
1548            write_setup_machine_state(bundle_root, &state)?;
1549            Some(append_machine_step_event(
1550                bundle_root,
1551                &state,
1552                "setup.machine.incompatible_state",
1553                state.current_step.as_deref(),
1554                state.last_error.clone(),
1555            )?)
1556        };
1557        let step_id = state
1558            .current_step
1559            .clone()
1560            .unwrap_or_else(|| machine.entry_step.clone());
1561        return Ok(machine_step_output(
1562            machine,
1563            &state,
1564            &step_id,
1565            dry_run,
1566            false,
1567            &format!("{error}; run setup-migrate or setup-reset before continuing"),
1568            event_path,
1569        ));
1570    }
1571
1572    let step_id = state
1573        .current_step
1574        .clone()
1575        .unwrap_or_else(|| machine.entry_step.clone());
1576    let Some(step) = machine
1577        .steps
1578        .iter()
1579        .find(|candidate| candidate.id == step_id)
1580    else {
1581        state.status = SetupMachineStatus::Failed;
1582        state.failed_step = Some(step_id.clone());
1583        state.last_error = Some(serde_json::json!({
1584            "error": "missing_current_step",
1585            "step": step_id,
1586            "recoverable": false,
1587        }));
1588        state.updated_at = Some(now_setup_ts());
1589        if !dry_run {
1590            write_setup_machine_state(bundle_root, &state)?;
1591            append_machine_step_event(
1592                bundle_root,
1593                &state,
1594                "setup.step.failed",
1595                Some(&step_id),
1596                None,
1597            )?;
1598        }
1599        return Ok(machine_step_output(
1600            machine,
1601            &state,
1602            &step_id,
1603            dry_run,
1604            false,
1605            "missing_current_step",
1606            None,
1607        ));
1608    };
1609
1610    let mut state_after = state.clone();
1611    state_after.status = SetupMachineStatus::Running;
1612    let result = execute_machine_step(bundle_root, pack_path, machine, step, &mut state_after);
1613    let recovery_step = apply_declared_failure_transition(machine, step, &mut state_after, &result);
1614    state_after.updated_at = Some(now_setup_ts());
1615    let event_name = if recovery_step.is_some() {
1616        "setup.step.recovery_scheduled"
1617    } else if result.ok {
1618        "setup.step.completed"
1619    } else if state_after.status == SetupMachineStatus::Paused {
1620        "setup.step.paused"
1621    } else {
1622        "setup.step.failed"
1623    };
1624    let mut result_detail = result.detail;
1625    if let Some(recovery_step) = recovery_step.as_deref()
1626        && let Some(obj) = result_detail.as_object_mut()
1627    {
1628        obj.insert(
1629            "recovery_step".to_string(),
1630            Value::String(recovery_step.to_string()),
1631        );
1632    }
1633    let event_path = if dry_run {
1634        None
1635    } else {
1636        write_setup_machine_state(bundle_root, &state_after)?;
1637        Some(append_machine_step_event(
1638            bundle_root,
1639            &state_after,
1640            event_name,
1641            Some(&step.id),
1642            Some(result_detail.clone()),
1643        )?)
1644    };
1645    let next_text = recovery_step
1646        .as_deref()
1647        .map(|step| format!("recovery scheduled: {step}"))
1648        .unwrap_or_else(|| result.next.clone());
1649    let mut output = machine_step_output(
1650        machine,
1651        &state_after,
1652        &step.id,
1653        dry_run,
1654        result.ok,
1655        &next_text,
1656        event_path,
1657    );
1658    if let Some(result_obj) = output.get_mut("result").and_then(Value::as_object_mut) {
1659        result_obj.insert("detail".to_string(), result_detail);
1660    }
1661    Ok(output)
1662}
1663
1664pub fn reset_setup_machine_state(
1665    bundle_root: &Path,
1666    tenant: &str,
1667    team: &str,
1668    provider_id: &str,
1669    reason: &str,
1670) -> anyhow::Result<Option<PathBuf>> {
1671    let state_path = setup_machine_state_path(bundle_root, tenant, team, provider_id);
1672    let archive_path = if state_path.is_file() {
1673        let archive_dir =
1674            setup_machine_state_dir(bundle_root, tenant, team, provider_id).join("archive");
1675        std::fs::create_dir_all(&archive_dir)?;
1676        let archive_path = archive_dir.join(format!(
1677            "machine-{}-{}.json",
1678            safe_archive_segment(reason),
1679            now_setup_ms()
1680        ));
1681        std::fs::copy(&state_path, &archive_path)?;
1682        std::fs::remove_file(&state_path)?;
1683        Some(archive_path)
1684    } else {
1685        None
1686    };
1687    append_setup_machine_event(
1688        bundle_root,
1689        &SetupMachineEvent {
1690            ts: now_setup_ts(),
1691            level: "info".to_string(),
1692            event: "setup.machine.reset".to_string(),
1693            provider_id: provider_id.to_string(),
1694            tenant: tenant.to_string(),
1695            team: team.to_string(),
1696            machine_id: provider_id.to_string(),
1697            step_id: None,
1698            correlation_id: Some(format!("setup-{}", now_setup_ms())),
1699            fields: serde_json::json!({
1700                "reason": reason,
1701                "archive_path": archive_path,
1702            })
1703            .as_object()
1704            .cloned()
1705            .unwrap_or_default(),
1706        },
1707    )?;
1708    Ok(archive_path)
1709}
1710
1711#[derive(Clone, Debug)]
1712pub struct SetupMachineMigration {
1713    pub legacy_backend_path: PathBuf,
1714    pub legacy_setup_actions_path: PathBuf,
1715    pub state_path: PathBuf,
1716    pub backend_archive_path: Option<PathBuf>,
1717    pub setup_actions_archive_path: Option<PathBuf>,
1718    pub event_path: PathBuf,
1719    pub source: String,
1720    pub legacy_backend_removed: bool,
1721    pub setup_actions_removed: bool,
1722    pub initialized: bool,
1723    pub empty: bool,
1724}
1725
1726pub fn migrate_setup_machine_state(
1727    bundle_root: &Path,
1728    env: &str,
1729    tenant: &str,
1730    team: &str,
1731    provider_id: &str,
1732    machine: &SetupMachine,
1733) -> anyhow::Result<SetupMachineMigration> {
1734    let state_path = setup_machine_state_path(bundle_root, tenant, team, provider_id);
1735    let legacy_backend_path = crate::setup_backend_contract::legacy_backend_state_path(
1736        bundle_root,
1737        env,
1738        tenant,
1739        team,
1740        provider_id,
1741    )?;
1742    let legacy_setup_actions_path =
1743        crate::setup_actions::setup_actions_state_path(bundle_root, tenant, team, provider_id);
1744    let had_machine_state = state_path.is_file();
1745    let had_legacy_backend = legacy_backend_path.is_file();
1746    let had_setup_actions = legacy_setup_actions_path.is_file();
1747
1748    let mut state =
1749        load_or_init_setup_machine_state(bundle_root, provider_id, tenant, team, machine)?;
1750    if !had_machine_state {
1751        state.status = SetupMachineStatus::NotStarted;
1752        state.current_step = Some(machine.entry_step.clone());
1753        state.updated_at = Some(now_setup_ts());
1754        write_setup_machine_state(bundle_root, &state)?;
1755    }
1756
1757    let backend_archive_path = if legacy_backend_path.is_file() {
1758        archive_setup_machine_file(
1759            bundle_root,
1760            tenant,
1761            team,
1762            provider_id,
1763            &legacy_backend_path,
1764            "legacy-backend",
1765        )?
1766    } else {
1767        None
1768    };
1769    let legacy_backend_removed = if legacy_backend_path.is_file() {
1770        std::fs::remove_file(&legacy_backend_path).with_context(|| {
1771            format!(
1772                "remove legacy setup backend {}",
1773                legacy_backend_path.display()
1774            )
1775        })?;
1776        true
1777    } else {
1778        false
1779    };
1780    let setup_actions_archive_path = if legacy_setup_actions_path.is_file() {
1781        archive_setup_machine_file(
1782            bundle_root,
1783            tenant,
1784            team,
1785            provider_id,
1786            &legacy_setup_actions_path,
1787            "legacy-setup-actions",
1788        )?
1789    } else {
1790        None
1791    };
1792    let setup_actions_removed = if legacy_setup_actions_path.is_file() {
1793        std::fs::remove_file(&legacy_setup_actions_path).with_context(|| {
1794            format!(
1795                "remove legacy setup actions {}",
1796                legacy_setup_actions_path.display()
1797            )
1798        })?;
1799        true
1800    } else {
1801        false
1802    };
1803    let source = if had_machine_state {
1804        "machine"
1805    } else if had_legacy_backend || had_setup_actions {
1806        "legacy-archived"
1807    } else {
1808        "empty"
1809    };
1810    let event_path = append_setup_machine_event(
1811        bundle_root,
1812        &SetupMachineEvent {
1813            ts: now_setup_ts(),
1814            level: "info".to_string(),
1815            event: "setup.machine.legacy_migrated".to_string(),
1816            provider_id: provider_id.to_string(),
1817            tenant: tenant.to_string(),
1818            team: team.to_string(),
1819            machine_id: machine.id.clone(),
1820            step_id: None,
1821            correlation_id: Some(format!("setup-{}", now_setup_ms())),
1822            fields: serde_json::json!({
1823                "legacy_backend_path": legacy_backend_path,
1824                "legacy_setup_actions_path": legacy_setup_actions_path,
1825                "state_path": state_path,
1826                "backend_archive_path": backend_archive_path,
1827                "setup_actions_archive_path": setup_actions_archive_path,
1828                "source": source,
1829                "legacy_backend_removed": legacy_backend_removed,
1830                "setup_actions_removed": setup_actions_removed,
1831                "initialized": !had_machine_state,
1832                "empty": !had_machine_state && !had_legacy_backend && !had_setup_actions,
1833            })
1834            .as_object()
1835            .cloned()
1836            .unwrap_or_default(),
1837        },
1838    )?;
1839
1840    Ok(SetupMachineMigration {
1841        legacy_backend_path,
1842        legacy_setup_actions_path,
1843        state_path,
1844        backend_archive_path,
1845        setup_actions_archive_path,
1846        event_path,
1847        source: source.to_string(),
1848        legacy_backend_removed,
1849        setup_actions_removed,
1850        initialized: !had_machine_state,
1851        empty: !had_machine_state && !had_legacy_backend && !had_setup_actions,
1852    })
1853}
1854
1855fn archive_setup_machine_file(
1856    bundle_root: &Path,
1857    tenant: &str,
1858    team: &str,
1859    provider_id: &str,
1860    source_path: &Path,
1861    label: &str,
1862) -> anyhow::Result<Option<PathBuf>> {
1863    if !source_path.is_file() {
1864        return Ok(None);
1865    }
1866    let archive_dir =
1867        setup_machine_state_dir(bundle_root, tenant, team, provider_id).join("archive");
1868    std::fs::create_dir_all(&archive_dir)?;
1869    let archive_path = archive_dir.join(format!(
1870        "{}-{}.json",
1871        safe_archive_segment(label),
1872        now_setup_ms()
1873    ));
1874    std::fs::copy(source_path, &archive_path)?;
1875    Ok(Some(archive_path))
1876}
1877
1878pub fn retry_setup_machine_step(
1879    bundle_root: &Path,
1880    provider_id: &str,
1881    tenant: &str,
1882    team: &str,
1883    machine: &SetupMachine,
1884    step: Option<&str>,
1885) -> anyhow::Result<Value> {
1886    let mut state =
1887        load_or_init_setup_machine_state(bundle_root, provider_id, tenant, team, machine)?;
1888    let selected_step = step
1889        .map(str::to_string)
1890        .or_else(|| state.failed_step.clone())
1891        .or_else(|| state.current_step.clone())
1892        .unwrap_or_else(|| machine.entry_step.clone());
1893    if !machine
1894        .steps
1895        .iter()
1896        .any(|candidate| candidate.id == selected_step)
1897    {
1898        anyhow::bail!("setup-machine step not found: {selected_step}");
1899    }
1900    let changed = state.last_error.is_some()
1901        || state.failed_step.as_deref() == Some(selected_step.as_str())
1902        || state.status == SetupMachineStatus::Paused
1903        || state.status == SetupMachineStatus::Failed;
1904    state.current_step = Some(selected_step.clone());
1905    state.failed_step = None;
1906    state.last_error = None;
1907    if state.status != SetupMachineStatus::Complete {
1908        state.status = SetupMachineStatus::Running;
1909    }
1910    state.updated_at = Some(now_setup_ts());
1911    write_setup_machine_state(bundle_root, &state)?;
1912    let event_path = append_machine_step_event(
1913        bundle_root,
1914        &state,
1915        "setup.step.retry_requested",
1916        Some(&selected_step),
1917        Some(serde_json::json!({
1918            "changed": changed,
1919        })),
1920    )?;
1921    Ok(serde_json::json!({
1922        "provider_id": provider_id,
1923        "tenant": tenant,
1924        "team": team,
1925        "machine_id": machine.id,
1926        "step": selected_step,
1927        "changed": changed,
1928        "event_path": event_path,
1929        "status": render_setup_machine_status(machine, &state),
1930    }))
1931}
1932
1933#[derive(Clone, Debug)]
1934struct MachineStepResult {
1935    ok: bool,
1936    next: String,
1937    detail: Value,
1938}
1939
1940fn execute_machine_step(
1941    bundle_root: &Path,
1942    pack_path: Option<&Path>,
1943    machine: &SetupMachine,
1944    step: &SetupMachineStep,
1945    state: &mut SetupMachineState,
1946) -> MachineStepResult {
1947    if step.extra.get("auto_complete").and_then(Value::as_bool) == Some(true) {
1948        mark_machine_step_complete(machine, step, state);
1949        return MachineStepResult {
1950            ok: true,
1951            next: "step completed".to_string(),
1952            detail: serde_json::json!({
1953                "kind": step.kind,
1954                "auto_complete": true,
1955            }),
1956        };
1957    }
1958
1959    match step.kind.as_str() {
1960        "platform_public_url" => {
1961            execute_platform_public_url_step(bundle_root, machine, step, state)
1962        }
1963        "cloudflare_tunnel" => execute_cloudflare_tunnel_step(bundle_root, machine, step, state),
1964        "oauth_device_code" => execute_oauth_device_code_step(bundle_root, machine, step, state),
1965        "oauth_authorization_code" => {
1966            execute_oauth_authorization_code_step(bundle_root, machine, step, state)
1967        }
1968        "http_probe" | "http_json" => execute_http_step(machine, step, state),
1969        "select_from_json" => execute_select_from_json_step(machine, step, state),
1970        "generate_file" => execute_generate_file_step(bundle_root, machine, step, state),
1971        "persist_runtime_config" => {
1972            execute_persist_runtime_config_step(bundle_root, pack_path, machine, step, state)
1973        }
1974        "provider_component_call" => {
1975            execute_provider_component_call_step(bundle_root, pack_path, machine, step, state)
1976        }
1977        "manual_action" | "download_file" | "qa_form" => {
1978            state.status = SetupMachineStatus::Paused;
1979            state.current_step = Some(step.id.clone());
1980            state.last_error = Some(serde_json::json!({
1981                "error": "manual_action_required",
1982                "step": step.id,
1983                "recoverable": true,
1984            }));
1985            MachineStepResult {
1986                ok: false,
1987                next: "operator action required".to_string(),
1988                detail: serde_json::json!({
1989                    "blocked": true,
1990                    "retryable": true,
1991                    "error": "manual_action_required",
1992                    "kind": step.kind,
1993                    "title": step.title,
1994                }),
1995            }
1996        }
1997        _ => {
1998            state.status = SetupMachineStatus::Paused;
1999            state.current_step = Some(step.id.clone());
2000            state.last_error = Some(serde_json::json!({
2001                "error": "unsupported_step_kind",
2002                "step": step.id,
2003                "kind": step.kind,
2004                "recoverable": true,
2005            }));
2006            MachineStepResult {
2007                ok: false,
2008                next: "setup-machine step kind is not supported by this greentic-setup build"
2009                    .to_string(),
2010                detail: serde_json::json!({
2011                    "blocked": true,
2012                    "retryable": true,
2013                    "error": "unsupported_step_kind",
2014                    "kind": step.kind,
2015                }),
2016            }
2017        }
2018    }
2019}
2020
2021fn apply_declared_failure_transition(
2022    machine: &SetupMachine,
2023    step: &SetupMachineStep,
2024    state: &mut SetupMachineState,
2025    result: &MachineStepResult,
2026) -> Option<String> {
2027    if result.ok {
2028        return None;
2029    }
2030    let error = result_error_code(state, result)?;
2031    let target = step
2032        .recover
2033        .iter()
2034        .chain(step.on_failure.iter())
2035        .find(|rule| failure_rule_matches(rule, &error))
2036        .and_then(|rule| rule_targets(rule).into_iter().next())?;
2037
2038    if target == TERMINAL_FAILED {
2039        state.status = SetupMachineStatus::Failed;
2040        state.current_step = Some(step.id.clone());
2041        state.failed_step = Some(step.id.clone());
2042        state.last_error = Some(merge_failure_recovery_detail(
2043            state.last_error.clone(),
2044            &error,
2045            &target,
2046        ));
2047        return Some(target);
2048    }
2049    if target == TERMINAL_SUCCESS {
2050        state.status = SetupMachineStatus::Complete;
2051        state.current_step = None;
2052        state.failed_step = None;
2053        state.last_error = None;
2054        return Some(target);
2055    }
2056    if machine.steps.iter().any(|candidate| candidate.id == target) {
2057        state.status = SetupMachineStatus::Running;
2058        state.current_step = Some(target.clone());
2059        state.failed_step = None;
2060        state.last_error = Some(merge_failure_recovery_detail(
2061            state.last_error.clone(),
2062            &error,
2063            &target,
2064        ));
2065        return Some(target);
2066    }
2067
2068    state.status = SetupMachineStatus::Failed;
2069    state.current_step = Some(step.id.clone());
2070    state.failed_step = Some(step.id.clone());
2071    state.last_error = Some(serde_json::json!({
2072        "error": "invalid_recovery_target",
2073        "step": step.id,
2074        "target": target,
2075        "recoverable": false,
2076    }));
2077    None
2078}
2079
2080fn result_error_code(state: &SetupMachineState, result: &MachineStepResult) -> Option<String> {
2081    result
2082        .detail
2083        .get("error")
2084        .and_then(Value::as_str)
2085        .or_else(|| {
2086            state
2087                .last_error
2088                .as_ref()
2089                .and_then(|error| error.get("error"))
2090                .and_then(Value::as_str)
2091        })
2092        .map(str::to_string)
2093}
2094
2095fn failure_rule_matches(rule: &Value, error: &str) -> bool {
2096    let Some(obj) = rule.as_object() else {
2097        return false;
2098    };
2099    for key in ["when", "error", "code"] {
2100        match obj.get(key) {
2101            Some(Value::String(value)) if value == error || value == "*" => return true,
2102            Some(Value::Array(values))
2103                if values
2104                    .iter()
2105                    .any(|value| value.as_str() == Some(error) || value.as_str() == Some("*")) =>
2106            {
2107                return true;
2108            }
2109            _ => {}
2110        }
2111    }
2112    !obj.contains_key("when") && !obj.contains_key("error") && !obj.contains_key("code")
2113}
2114
2115fn merge_failure_recovery_detail(base: Option<Value>, error: &str, recovery_step: &str) -> Value {
2116    let mut object = base
2117        .and_then(|value| value.as_object().cloned())
2118        .unwrap_or_default();
2119    object.insert("error".to_string(), Value::String(error.to_string()));
2120    object.insert(
2121        "recovery_step".to_string(),
2122        Value::String(recovery_step.to_string()),
2123    );
2124    object.insert("recoverable".to_string(), Value::Bool(true));
2125    Value::Object(object)
2126}
2127
2128fn execute_provider_component_call_step(
2129    bundle_root: &Path,
2130    pack_path: Option<&Path>,
2131    machine: &SetupMachine,
2132    step: &SetupMachineStep,
2133    state: &mut SetupMachineState,
2134) -> MachineStepResult {
2135    let Some(pack_path) = pack_path else {
2136        state.status = SetupMachineStatus::Paused;
2137        state.current_step = Some(step.id.clone());
2138        state.last_error = Some(serde_json::json!({
2139            "error": "missing_prerequisite",
2140            "step": step.id,
2141            "missing": "pack_path",
2142            "recoverable": true,
2143        }));
2144        return MachineStepResult {
2145            ok: false,
2146            next: "run setup-machine with provider pack provenance, then retry".to_string(),
2147            detail: serde_json::json!({
2148                "blocked": true,
2149                "retryable": true,
2150                "error": "missing_prerequisite",
2151                "missing_config_key": "pack_path",
2152            }),
2153        };
2154    };
2155    let Some(component_ref) = step
2156        .extra
2157        .get("component_ref")
2158        .or_else(|| step.extra.get("component"))
2159        .and_then(Value::as_str)
2160        .map(str::trim)
2161        .filter(|value| !value.is_empty())
2162    else {
2163        return pause_provider_component_contract_error(
2164            step,
2165            state,
2166            "provider_component_call requires component_ref",
2167        );
2168    };
2169    let Some(op) = step
2170        .extra
2171        .get("op")
2172        .or_else(|| step.extra.get("operation"))
2173        .and_then(Value::as_str)
2174        .map(str::trim)
2175        .filter(|value| !value.is_empty())
2176    else {
2177        return pause_provider_component_contract_error(
2178            step,
2179            state,
2180            "provider_component_call requires op",
2181        );
2182    };
2183    let request = step
2184        .extra
2185        .get("request")
2186        .or_else(|| step.extra.get("body"))
2187        .cloned()
2188        .unwrap_or_else(|| {
2189            serde_json::json!({
2190                "tenant": state.tenant,
2191                "team": state.team,
2192                "provider_id": state.provider_id,
2193                "outputs": state.outputs,
2194            })
2195        });
2196    let request = expand_machine_value_templates(request, state);
2197    let env = step
2198        .extra
2199        .get("env")
2200        .and_then(Value::as_str)
2201        .unwrap_or("dev");
2202    let config = crate::engine::SetupConfig {
2203        tenant: state.tenant.clone(),
2204        team: Some(state.team.clone()),
2205        env: env.to_string(),
2206        offline: false,
2207        verbose: false,
2208    };
2209    let result = match crate::engine::invoke_setup_component_operation(
2210        bundle_root,
2211        pack_path,
2212        component_ref,
2213        op,
2214        &request,
2215        &config,
2216    ) {
2217        Ok(result) => result,
2218        Err(err) => {
2219            return pause_provider_component_contract_error(step, state, &err.to_string());
2220        }
2221    };
2222    if result.get("ok").and_then(Value::as_bool) == Some(false) {
2223        state.status = SetupMachineStatus::Paused;
2224        state.current_step = Some(step.id.clone());
2225        state.last_error = Some(serde_json::json!({
2226            "error": "provider_contract_error",
2227            "step": step.id,
2228            "detail": result,
2229            "recoverable": true,
2230        }));
2231        return MachineStepResult {
2232            ok: false,
2233            next: "provider setup component returned ok:false".to_string(),
2234            detail: serde_json::json!({
2235                "blocked": true,
2236                "retryable": true,
2237                "error": "provider_contract_error",
2238                "result": result,
2239            }),
2240        };
2241    }
2242    let output_key = step
2243        .extra
2244        .get("output_key")
2245        .and_then(Value::as_str)
2246        .unwrap_or(step.id.as_str());
2247    ensure_outputs_object(state).insert(output_key.to_string(), result.clone());
2248    mark_machine_step_complete(machine, step, state);
2249    MachineStepResult {
2250        ok: true,
2251        next: "provider component setup operation completed".to_string(),
2252        detail: serde_json::json!({
2253            "kind": step.kind,
2254            "component_ref": component_ref,
2255            "op": op,
2256            "output_key": output_key,
2257            "result": redact_large_json_detail(&result),
2258        }),
2259    }
2260}
2261
2262fn pause_provider_component_contract_error(
2263    step: &SetupMachineStep,
2264    state: &mut SetupMachineState,
2265    detail: &str,
2266) -> MachineStepResult {
2267    state.status = SetupMachineStatus::Paused;
2268    state.current_step = Some(step.id.clone());
2269    state.last_error = Some(serde_json::json!({
2270        "error": "provider_contract_error",
2271        "step": step.id,
2272        "detail": detail,
2273        "recoverable": true,
2274    }));
2275    MachineStepResult {
2276        ok: false,
2277        next: "fix provider component setup declaration, then retry".to_string(),
2278        detail: serde_json::json!({
2279            "blocked": true,
2280            "retryable": true,
2281            "error": "provider_contract_error",
2282            "detail": detail,
2283        }),
2284    }
2285}
2286
2287#[derive(Clone, Debug, Serialize, Deserialize)]
2288struct SetupMachineOAuthDeviceSession {
2289    step_id: String,
2290    provider_id: String,
2291    tenant: String,
2292    team: String,
2293    device_code: String,
2294    client_id: String,
2295    token_url: String,
2296    token_store_key: String,
2297    #[serde(default, skip_serializing_if = "Option::is_none")]
2298    refresh_token_store_key: Option<String>,
2299    interval: u64,
2300    expires_at: u64,
2301    created_at: u64,
2302}
2303
2304fn execute_oauth_device_code_step(
2305    bundle_root: &Path,
2306    machine: &SetupMachine,
2307    step: &SetupMachineStep,
2308    state: &mut SetupMachineState,
2309) -> MachineStepResult {
2310    match load_setup_machine_oauth_device_session(bundle_root, state, &step.id) {
2311        Ok(Some(session)) => {
2312            execute_oauth_device_code_poll(bundle_root, machine, step, state, session)
2313        }
2314        Ok(None) => execute_oauth_device_code_start(bundle_root, step, state),
2315        Err(err) => pause_oauth_device_error(
2316            step,
2317            state,
2318            "provider_contract_error",
2319            "OAuth device-code session could not be loaded",
2320            serde_json::json!({"detail": err.to_string()}),
2321        ),
2322    }
2323}
2324
2325fn execute_oauth_device_code_start(
2326    bundle_root: &Path,
2327    step: &SetupMachineStep,
2328    state: &mut SetupMachineState,
2329) -> MachineStepResult {
2330    let client_id = match oauth_step_value(step, state, "client_id", "client_id_output") {
2331        Ok(Some(value)) if !value.trim().is_empty() => value,
2332        Ok(_) => {
2333            return pause_oauth_device_error(
2334                step,
2335                state,
2336                "missing_prerequisite",
2337                "set OAuth client_id, then retry",
2338                serde_json::json!({"missing_config_key": "client_id"}),
2339            );
2340        }
2341        Err(err) => {
2342            return pause_oauth_device_error(
2343                step,
2344                state,
2345                "invalid_answer",
2346                "fix OAuth client_id template, then retry",
2347                serde_json::json!({"detail": err.to_string()}),
2348            );
2349        }
2350    };
2351    let scopes = match oauth_scope(step) {
2352        Ok(Some(scopes)) if !scopes.trim().is_empty() => scopes,
2353        Ok(_) => {
2354            return pause_oauth_device_error(
2355                step,
2356                state,
2357                "missing_prerequisite",
2358                "declare OAuth device-code scopes, then retry",
2359                serde_json::json!({"missing_config_key": "scopes"}),
2360            );
2361        }
2362        Err(err) => {
2363            return pause_oauth_device_error(
2364                step,
2365                state,
2366                "invalid_answer",
2367                "fix OAuth scopes, then retry",
2368                serde_json::json!({"detail": err.to_string()}),
2369            );
2370        }
2371    };
2372    let device_code_url = match oauth_device_endpoint(step, "device_code_url", "devicecode") {
2373        Ok(url) => url,
2374        Err(err) => {
2375            return pause_oauth_device_error(
2376                step,
2377                state,
2378                "missing_prerequisite",
2379                "declare device_code_url or authority_url_template, then retry",
2380                serde_json::json!({"detail": err.to_string()}),
2381            );
2382        }
2383    };
2384    let token_url = match oauth_device_endpoint(step, "token_url", "token") {
2385        Ok(url) => url,
2386        Err(err) => {
2387            return pause_oauth_device_error(
2388                step,
2389                state,
2390                "missing_prerequisite",
2391                "declare token_url or authority_url_template, then retry",
2392                serde_json::json!({"detail": err.to_string()}),
2393            );
2394        }
2395    };
2396    if let Err(err) =
2397        validate_http_step_url(&device_code_url).and_then(|_| validate_http_step_url(&token_url))
2398    {
2399        return pause_oauth_device_error(
2400            step,
2401            state,
2402            "invalid_answer",
2403            "fix OAuth device-code URLs, then retry",
2404            serde_json::json!({"detail": err.to_string()}),
2405        );
2406    }
2407
2408    let mut response = match ureq::post(&device_code_url).send_form([
2409        ("client_id", client_id.as_str()),
2410        ("scope", scopes.as_str()),
2411    ]) {
2412        Ok(response) => response,
2413        Err(err) => {
2414            return pause_oauth_device_error(
2415                step,
2416                state,
2417                "external_timeout",
2418                "OAuth device-code endpoint is not reachable",
2419                serde_json::json!({"detail": err.to_string(), "target": device_code_url}),
2420            );
2421        }
2422    };
2423    let body = match response.body_mut().read_json::<Value>() {
2424        Ok(body) => body,
2425        Err(err) => {
2426            return pause_oauth_device_error(
2427                step,
2428                state,
2429                "provider_contract_error",
2430                "OAuth device-code response was not valid JSON",
2431                serde_json::json!({"detail": err.to_string()}),
2432            );
2433        }
2434    };
2435    let device_code = match body
2436        .get("device_code")
2437        .and_then(Value::as_str)
2438        .map(str::trim)
2439    {
2440        Some(value) if !value.is_empty() => value.to_string(),
2441        _ => {
2442            return pause_oauth_device_error(
2443                step,
2444                state,
2445                "provider_contract_error",
2446                "OAuth device-code response did not include device_code",
2447                serde_json::json!({"body": public_oauth_device_response(&body)}),
2448            );
2449        }
2450    };
2451    let user_code = body
2452        .get("user_code")
2453        .and_then(Value::as_str)
2454        .map(str::trim)
2455        .filter(|value| !value.is_empty())
2456        .unwrap_or_default()
2457        .to_string();
2458    let verification_uri = body
2459        .get("verification_uri")
2460        .or_else(|| body.get("verification_url"))
2461        .and_then(Value::as_str)
2462        .map(str::trim)
2463        .filter(|value| !value.is_empty())
2464        .or_else(|| step.extra.get("verification_uri").and_then(Value::as_str))
2465        .unwrap_or_default()
2466        .to_string();
2467    if user_code.is_empty() || verification_uri.is_empty() {
2468        return pause_oauth_device_error(
2469            step,
2470            state,
2471            "provider_contract_error",
2472            "OAuth device-code response did not include user_code or verification_uri",
2473            serde_json::json!({"body": public_oauth_device_response(&body)}),
2474        );
2475    }
2476    let now = crate::setup_actions::current_epoch_secs();
2477    let expires_in = body
2478        .get("expires_in")
2479        .and_then(Value::as_u64)
2480        .unwrap_or(900);
2481    let interval = body
2482        .get("interval")
2483        .and_then(Value::as_u64)
2484        .unwrap_or(5)
2485        .max(1);
2486    let token_store_key = step
2487        .extra
2488        .get("token_store_key")
2489        .and_then(Value::as_str)
2490        .map(str::trim)
2491        .filter(|value| !value.is_empty())
2492        .unwrap_or("oauth_access_token")
2493        .to_string();
2494    let refresh_token_store_key = step
2495        .extra
2496        .get("refresh_token_store_key")
2497        .and_then(Value::as_str)
2498        .map(str::trim)
2499        .filter(|value| !value.is_empty())
2500        .map(str::to_string);
2501    let session = SetupMachineOAuthDeviceSession {
2502        step_id: step.id.clone(),
2503        provider_id: state.provider_id.clone(),
2504        tenant: state.tenant.clone(),
2505        team: state.team.clone(),
2506        device_code,
2507        client_id,
2508        token_url,
2509        token_store_key,
2510        refresh_token_store_key,
2511        interval,
2512        expires_at: now + expires_in,
2513        created_at: now,
2514    };
2515    if let Err(err) = write_setup_machine_oauth_device_session(bundle_root, state, &session) {
2516        return pause_oauth_device_error(
2517            step,
2518            state,
2519            "provider_contract_error",
2520            "OAuth device-code session could not be saved",
2521            serde_json::json!({"detail": err.to_string()}),
2522        );
2523    }
2524
2525    let output_key = step
2526        .extra
2527        .get("output_key")
2528        .and_then(Value::as_str)
2529        .unwrap_or("oauth_device_login");
2530    ensure_outputs_object(state).insert(
2531        output_key.to_string(),
2532        serde_json::json!({
2533            "verification_uri": verification_uri,
2534            "verification_uri_complete": body.get("verification_uri_complete").cloned().unwrap_or(Value::Null),
2535            "user_code": user_code,
2536            "expires_at": session.expires_at,
2537            "interval": session.interval,
2538        }),
2539    );
2540    state.status = SetupMachineStatus::Paused;
2541    state.current_step = Some(step.id.clone());
2542    state.last_error = Some(serde_json::json!({
2543        "error": "manual_action_required",
2544        "reason": "oauth_device_code_pending",
2545        "step": step.id,
2546        "recoverable": true,
2547    }));
2548    MachineStepResult {
2549        ok: false,
2550        next: "open device-code URL, enter the code, then run setup-next again".to_string(),
2551        detail: serde_json::json!({
2552            "blocked": true,
2553            "retryable": true,
2554            "error": "manual_action_required",
2555            "reason": "oauth_device_code_pending",
2556            "login": state.outputs.get(output_key).cloned().unwrap_or(Value::Null),
2557            "output_key": output_key,
2558        }),
2559    }
2560}
2561
2562fn execute_oauth_device_code_poll(
2563    bundle_root: &Path,
2564    machine: &SetupMachine,
2565    step: &SetupMachineStep,
2566    state: &mut SetupMachineState,
2567    session: SetupMachineOAuthDeviceSession,
2568) -> MachineStepResult {
2569    if crate::setup_actions::current_epoch_secs() >= session.expires_at {
2570        let _ = remove_setup_machine_oauth_device_session(bundle_root, state, &step.id);
2571        return pause_oauth_device_error(
2572            step,
2573            state,
2574            "oauth_token_expired",
2575            "OAuth device code expired; retry to start a new login",
2576            serde_json::json!({"expired": true}),
2577        );
2578    }
2579    let agent = ureq::Agent::config_builder()
2580        .http_status_as_error(false)
2581        .build()
2582        .new_agent();
2583    let mut response = match agent.post(&session.token_url).send_form([
2584        ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
2585        ("client_id", session.client_id.as_str()),
2586        ("device_code", session.device_code.as_str()),
2587    ]) {
2588        Ok(response) => response,
2589        Err(err) => {
2590            return pause_oauth_device_error(
2591                step,
2592                state,
2593                "external_timeout",
2594                "OAuth token endpoint is not reachable",
2595                serde_json::json!({"detail": err.to_string(), "target": session.token_url}),
2596            );
2597        }
2598    };
2599    let status = response.status().as_u16();
2600    let body = match response.body_mut().read_json::<Value>() {
2601        Ok(body) => body,
2602        Err(err) => {
2603            return pause_oauth_device_error(
2604                step,
2605                state,
2606                "provider_contract_error",
2607                "OAuth token response was not valid JSON",
2608                serde_json::json!({"detail": err.to_string(), "status": status}),
2609            );
2610        }
2611    };
2612    if let Some(error) = body.get("error").and_then(Value::as_str)
2613        && matches!(error, "authorization_pending" | "slow_down")
2614    {
2615        state.status = SetupMachineStatus::Paused;
2616        state.current_step = Some(step.id.clone());
2617        state.last_error = Some(serde_json::json!({
2618            "error": "oauth_pending",
2619            "step": step.id,
2620            "recoverable": true,
2621        }));
2622        return MachineStepResult {
2623            ok: false,
2624            next: "OAuth authorization is still pending".to_string(),
2625            detail: serde_json::json!({
2626                "blocked": true,
2627                "retryable": true,
2628                "error": "oauth_pending",
2629                "interval": session.interval,
2630            }),
2631        };
2632    }
2633    if status >= 400 || body.get("access_token").and_then(Value::as_str).is_none() {
2634        return pause_oauth_device_error(
2635            step,
2636            state,
2637            "oauth_refresh_failed",
2638            "OAuth token polling failed",
2639            serde_json::json!({"status": status, "body": public_oauth_device_response(&body)}),
2640        );
2641    }
2642    let mut config = serde_json::Map::new();
2643    if let Some(access_token) = body.get("access_token").and_then(Value::as_str) {
2644        config.insert(
2645            session.token_store_key.clone(),
2646            Value::String(access_token.to_string()),
2647        );
2648    }
2649    if let Some(refresh_key) = session.refresh_token_store_key.as_deref()
2650        && let Some(refresh_token) = body.get("refresh_token").and_then(Value::as_str)
2651    {
2652        config.insert(
2653            refresh_key.to_string(),
2654            Value::String(refresh_token.to_string()),
2655        );
2656    }
2657    if config.is_empty() {
2658        return pause_oauth_device_error(
2659            step,
2660            state,
2661            "provider_contract_error",
2662            "OAuth token response had no persistable tokens",
2663            serde_json::json!({"status": status}),
2664        );
2665    }
2666    let config = Value::Object(config);
2667    let env = step
2668        .extra
2669        .get("env")
2670        .and_then(Value::as_str)
2671        .unwrap_or("dev");
2672    let persist_result = tokio::runtime::Runtime::new()
2673        .map_err(|err| err.to_string())
2674        .and_then(|runtime| {
2675            runtime
2676                .block_on(crate::qa::persist::persist_all_config_as_secrets(
2677                    bundle_root,
2678                    env,
2679                    &state.tenant,
2680                    Some(&state.team),
2681                    &state.provider_id,
2682                    &config,
2683                    None,
2684                ))
2685                .map_err(|err| err.to_string())
2686        });
2687    let persisted_keys = match persist_result {
2688        Ok(keys) => keys,
2689        Err(err) => {
2690            return pause_oauth_device_error(
2691                step,
2692                state,
2693                "provider_contract_error",
2694                "OAuth token secrets could not be persisted",
2695                serde_json::json!({"detail": err}),
2696            );
2697        }
2698    };
2699    let _ = remove_setup_machine_oauth_device_session(bundle_root, state, &step.id);
2700    ensure_outputs_object(state).insert(
2701        step.extra
2702            .get("output_key")
2703            .and_then(Value::as_str)
2704            .unwrap_or("oauth_device_login")
2705            .to_string(),
2706        serde_json::json!({
2707            "ok": true,
2708            "token_store_key": session.token_store_key,
2709            "persisted_keys": persisted_keys,
2710        }),
2711    );
2712    mark_machine_step_complete(machine, step, state);
2713    MachineStepResult {
2714        ok: true,
2715        next: "OAuth device-code step completed".to_string(),
2716        detail: serde_json::json!({
2717            "kind": step.kind,
2718            "persisted_keys": persisted_keys,
2719        }),
2720    }
2721}
2722
2723fn pause_oauth_device_error(
2724    step: &SetupMachineStep,
2725    state: &mut SetupMachineState,
2726    error: &str,
2727    next: &str,
2728    detail: Value,
2729) -> MachineStepResult {
2730    state.status = SetupMachineStatus::Paused;
2731    state.current_step = Some(step.id.clone());
2732    state.last_error = Some(serde_json::json!({
2733        "error": error,
2734        "step": step.id,
2735        "detail": detail,
2736        "recoverable": true,
2737    }));
2738    let mut detail_obj = serde_json::json!({
2739        "blocked": true,
2740        "retryable": true,
2741        "error": error,
2742    });
2743    if let (Some(obj), Some(extra)) = (detail_obj.as_object_mut(), detail.as_object()) {
2744        for (key, value) in extra {
2745            obj.insert(key.clone(), value.clone());
2746        }
2747    }
2748    MachineStepResult {
2749        ok: false,
2750        next: next.to_string(),
2751        detail: detail_obj,
2752    }
2753}
2754
2755fn oauth_device_endpoint(
2756    step: &SetupMachineStep,
2757    direct_key: &str,
2758    authority_suffix: &str,
2759) -> anyhow::Result<String> {
2760    if let Some(value) = step
2761        .extra
2762        .get(direct_key)
2763        .or_else(|| step.extra.get(&format!("{direct_key}_template")))
2764        .and_then(Value::as_str)
2765        .map(str::trim)
2766        .filter(|value| !value.is_empty())
2767    {
2768        return Ok(value.to_string());
2769    }
2770    let authority = step
2771        .extra
2772        .get("authority_url_template")
2773        .or_else(|| step.extra.get("authority_url"))
2774        .and_then(Value::as_str)
2775        .map(str::trim)
2776        .filter(|value| !value.is_empty())
2777        .ok_or_else(|| anyhow::anyhow!("missing {direct_key}"))?;
2778    Ok(format!(
2779        "{}/oauth2/v2.0/{}",
2780        authority.trim_end_matches('/'),
2781        authority_suffix
2782    ))
2783}
2784
2785fn public_oauth_device_response(body: &Value) -> Value {
2786    let mut public = serde_json::Map::new();
2787    for key in [
2788        "user_code",
2789        "verification_uri",
2790        "verification_url",
2791        "verification_uri_complete",
2792        "expires_in",
2793        "interval",
2794        "message",
2795        "error",
2796        "error_description",
2797    ] {
2798        if let Some(value) = body.get(key) {
2799            public.insert(key.to_string(), value.clone());
2800        }
2801    }
2802    Value::Object(public)
2803}
2804
2805fn setup_machine_oauth_device_session_path(
2806    bundle_root: &Path,
2807    state: &SetupMachineState,
2808    step_id: &str,
2809) -> PathBuf {
2810    setup_machine_state_dir(bundle_root, &state.tenant, &state.team, &state.provider_id)
2811        .join("oauth-device-sessions")
2812        .join(format!("{step_id}.json"))
2813}
2814
2815fn load_setup_machine_oauth_device_session(
2816    bundle_root: &Path,
2817    state: &SetupMachineState,
2818    step_id: &str,
2819) -> anyhow::Result<Option<SetupMachineOAuthDeviceSession>> {
2820    let path = setup_machine_oauth_device_session_path(bundle_root, state, step_id);
2821    if !path.is_file() {
2822        return Ok(None);
2823    }
2824    let raw = std::fs::read_to_string(&path)?;
2825    Ok(Some(serde_json::from_str(&raw)?))
2826}
2827
2828fn write_setup_machine_oauth_device_session(
2829    bundle_root: &Path,
2830    state: &SetupMachineState,
2831    session: &SetupMachineOAuthDeviceSession,
2832) -> anyhow::Result<PathBuf> {
2833    let path = setup_machine_oauth_device_session_path(bundle_root, state, &session.step_id);
2834    if let Some(parent) = path.parent() {
2835        std::fs::create_dir_all(parent)?;
2836    }
2837    std::fs::write(&path, serde_json::to_vec_pretty(session)?)?;
2838    #[cfg(unix)]
2839    {
2840        use std::os::unix::fs::PermissionsExt;
2841        let mut permissions = std::fs::metadata(&path)?.permissions();
2842        permissions.set_mode(0o600);
2843        std::fs::set_permissions(&path, permissions)?;
2844    }
2845    Ok(path)
2846}
2847
2848fn remove_setup_machine_oauth_device_session(
2849    bundle_root: &Path,
2850    state: &SetupMachineState,
2851    step_id: &str,
2852) -> anyhow::Result<()> {
2853    let path = setup_machine_oauth_device_session_path(bundle_root, state, step_id);
2854    if path.exists() {
2855        std::fs::remove_file(path)?;
2856    }
2857    Ok(())
2858}
2859
2860fn execute_oauth_authorization_code_step(
2861    bundle_root: &Path,
2862    _machine: &SetupMachine,
2863    step: &SetupMachineStep,
2864    state: &mut SetupMachineState,
2865) -> MachineStepResult {
2866    let authorization_url = match build_oauth_authorization_url(bundle_root, step, state) {
2867        Ok(url) => url,
2868        Err(OauthAuthorizationError::Missing(key)) => {
2869            state.status = SetupMachineStatus::Paused;
2870            state.current_step = Some(step.id.clone());
2871            state.last_error = Some(serde_json::json!({
2872                "error": "missing_prerequisite",
2873                "step": step.id,
2874                "missing": key,
2875                "recoverable": true,
2876            }));
2877            return MachineStepResult {
2878                ok: false,
2879                next: "provide OAuth authorization prerequisites, then retry".to_string(),
2880                detail: serde_json::json!({
2881                    "blocked": true,
2882                    "retryable": true,
2883                    "error": "missing_prerequisite",
2884                    "missing_config_key": key,
2885                }),
2886            };
2887        }
2888        Err(OauthAuthorizationError::Invalid(detail)) => {
2889            state.status = SetupMachineStatus::Paused;
2890            state.current_step = Some(step.id.clone());
2891            state.last_error = Some(serde_json::json!({
2892                "error": "invalid_answer",
2893                "step": step.id,
2894                "detail": detail,
2895                "recoverable": true,
2896            }));
2897            return MachineStepResult {
2898                ok: false,
2899                next: "fix OAuth authorization URL inputs, then retry".to_string(),
2900                detail: serde_json::json!({
2901                    "blocked": true,
2902                    "retryable": true,
2903                    "error": "invalid_answer",
2904                    "detail": detail,
2905                }),
2906            };
2907        }
2908    };
2909
2910    let output_key = step
2911        .extra
2912        .get("output_key")
2913        .and_then(Value::as_str)
2914        .unwrap_or("authorization_url");
2915    ensure_outputs_object(state).insert(
2916        output_key.to_string(),
2917        Value::String(authorization_url.clone()),
2918    );
2919    state.status = SetupMachineStatus::Paused;
2920    state.current_step = Some(step.id.clone());
2921    state.last_error = Some(serde_json::json!({
2922        "error": "manual_action_required",
2923        "reason": "oauth_authorization_required",
2924        "step": step.id,
2925        "recoverable": true,
2926    }));
2927    MachineStepResult {
2928        ok: false,
2929        next: "open authorization URL and complete OAuth callback".to_string(),
2930        detail: serde_json::json!({
2931            "blocked": true,
2932            "retryable": true,
2933            "error": "manual_action_required",
2934            "reason": "oauth_authorization_required",
2935            "authorization_url": authorization_url,
2936            "output_key": output_key,
2937            "kind": step.kind,
2938        }),
2939    }
2940}
2941
2942#[derive(Debug)]
2943enum OauthAuthorizationError {
2944    Missing(&'static str),
2945    Invalid(String),
2946}
2947
2948impl std::fmt::Display for OauthAuthorizationError {
2949    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2950        match self {
2951            Self::Missing(key) => write!(f, "missing {key}"),
2952            Self::Invalid(detail) => f.write_str(detail),
2953        }
2954    }
2955}
2956
2957impl std::error::Error for OauthAuthorizationError {}
2958
2959fn build_oauth_authorization_url(
2960    bundle_root: &Path,
2961    step: &SetupMachineStep,
2962    state: &SetupMachineState,
2963) -> Result<String, OauthAuthorizationError> {
2964    let template = step
2965        .extra
2966        .get("authorize_url")
2967        .or_else(|| step.extra.get("authorization_url"))
2968        .or_else(|| step.extra.get("auth_url"))
2969        .and_then(Value::as_str)
2970        .map(str::trim)
2971        .filter(|value| !value.is_empty())
2972        .ok_or(OauthAuthorizationError::Missing("authorize_url"))?;
2973    let expanded = expand_machine_template(template, state)
2974        .map_err(|err| OauthAuthorizationError::Invalid(err.to_string()))?;
2975    let mut parsed = url::Url::parse(&expanded)
2976        .map_err(|err| OauthAuthorizationError::Invalid(err.to_string()))?;
2977    if parsed.scheme() != "https" || parsed.host_str().is_none() {
2978        return Err(OauthAuthorizationError::Invalid(
2979            "authorize_url must be an https URL with a host".to_string(),
2980        ));
2981    }
2982
2983    let key = crate::setup_actions::load_or_create_signing_key(bundle_root)
2984        .map_err(|err| OauthAuthorizationError::Invalid(err.to_string()))?;
2985    let ttl = step
2986        .extra
2987        .get("state_ttl_secs")
2988        .and_then(Value::as_u64)
2989        .unwrap_or(15 * 60);
2990    let payload = crate::setup_actions::OAuthStatePayload {
2991        provider_id: state.provider_id.clone(),
2992        tenant: state.tenant.clone(),
2993        team: state.team.clone(),
2994        action_id: step.id.clone(),
2995        nonce: oauth_nonce(),
2996        expires_at: crate::setup_actions::current_epoch_secs() + ttl.max(1),
2997    };
2998    let signed_state = crate::setup_actions::sign_oauth_state(&payload, &key)
2999        .map_err(|err| OauthAuthorizationError::Invalid(err.to_string()))?;
3000
3001    append_oauth_query_param(&mut parsed, "response_type", oauth_response_type(step));
3002    if let Some(client_id) = oauth_step_value(step, state, "client_id", "client_id_output")? {
3003        append_oauth_query_param(&mut parsed, "client_id", &client_id);
3004    }
3005    if let Some(redirect_uri) = oauth_redirect_uri(bundle_root, step, state)? {
3006        append_oauth_query_param(&mut parsed, "redirect_uri", &redirect_uri);
3007    }
3008    if let Some(scope) = oauth_scope(step)? {
3009        append_oauth_query_param(&mut parsed, "scope", &scope);
3010    }
3011    append_oauth_query_param(&mut parsed, "state", &signed_state);
3012    if let Some(extra_params) = step.extra.get("params").and_then(Value::as_object) {
3013        let expanded = expand_machine_value_templates(Value::Object(extra_params.clone()), state);
3014        if let Some(params) = expanded.as_object() {
3015            for (key, value) in params {
3016                if let Some(value) = value.as_str() {
3017                    append_oauth_query_param(&mut parsed, key, value);
3018                }
3019            }
3020        }
3021    }
3022    Ok(parsed.to_string())
3023}
3024
3025fn exchange_setup_machine_oauth_authorization_code(
3026    bundle_root: &Path,
3027    machine: &SetupMachine,
3028    oauth_state: &crate::setup_actions::OAuthStatePayload,
3029    code: &str,
3030) -> anyhow::Result<Value> {
3031    let state = load_or_init_setup_machine_state(
3032        bundle_root,
3033        &oauth_state.provider_id,
3034        &oauth_state.tenant,
3035        &oauth_state.team,
3036        machine,
3037    )?;
3038    let step = machine
3039        .steps
3040        .iter()
3041        .find(|candidate| candidate.id == oauth_state.action_id)
3042        .ok_or_else(|| {
3043            anyhow::anyhow!("setup-machine step not found: {}", oauth_state.action_id)
3044        })?;
3045    if step.kind != "oauth_authorization_code" {
3046        anyhow::bail!(
3047            "setup-machine step is not an oauth_authorization_code step: {}",
3048            step.id
3049        );
3050    }
3051    let token_url = oauth_authorization_token_url(step, &state)?;
3052    let redirect_uri = oauth_redirect_uri(bundle_root, step, &state)?
3053        .ok_or(OauthAuthorizationError::Missing("redirect_uri"))?;
3054    let client_id = oauth_step_value(step, &state, "client_id", "client_id_output")?
3055        .ok_or(OauthAuthorizationError::Missing("client_id"))?;
3056    let client_secret = oauth_step_value(step, &state, "client_secret", "client_secret_output")?;
3057
3058    let mut form = vec![
3059        ("grant_type".to_string(), "authorization_code".to_string()),
3060        ("code".to_string(), code.to_string()),
3061        ("redirect_uri".to_string(), redirect_uri),
3062        ("client_id".to_string(), client_id),
3063    ];
3064    if let Some(client_secret) = client_secret {
3065        form.push(("client_secret".to_string(), client_secret));
3066    }
3067    let agent = ureq::Agent::config_builder()
3068        .http_status_as_error(false)
3069        .build()
3070        .new_agent();
3071    let mut response = agent
3072        .post(&token_url)
3073        .send_form(form)
3074        .context("OAuth token exchange failed")?;
3075    let status = response.status().as_u16();
3076    let body = response
3077        .body_mut()
3078        .read_json::<Value>()
3079        .context("failed to parse OAuth token response")?;
3080    if status >= 400 {
3081        anyhow::bail!("OAuth token exchange failed with status {status}: {body}");
3082    }
3083    Ok(body)
3084}
3085
3086fn oauth_nonce() -> String {
3087    use base64::Engine;
3088    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(rand::random::<[u8; 16]>())
3089}
3090
3091fn oauth_response_type(step: &SetupMachineStep) -> &str {
3092    step.extra
3093        .get("response_type")
3094        .and_then(Value::as_str)
3095        .map(str::trim)
3096        .filter(|value| !value.is_empty())
3097        .unwrap_or("code")
3098}
3099
3100fn oauth_step_value(
3101    step: &SetupMachineStep,
3102    state: &SetupMachineState,
3103    literal_key: &'static str,
3104    output_key_key: &'static str,
3105) -> Result<Option<String>, OauthAuthorizationError> {
3106    if let Some(value) = step
3107        .extra
3108        .get(literal_key)
3109        .and_then(Value::as_str)
3110        .map(str::trim)
3111        .filter(|value| !value.is_empty())
3112    {
3113        return expand_machine_template(value, state)
3114            .map(Some)
3115            .map_err(|err| OauthAuthorizationError::Invalid(err.to_string()));
3116    }
3117    let output_key = step
3118        .extra
3119        .get(output_key_key)
3120        .and_then(Value::as_str)
3121        .unwrap_or(literal_key);
3122    Ok(output_string(&state.outputs, output_key))
3123}
3124
3125fn oauth_redirect_uri(
3126    bundle_root: &Path,
3127    step: &SetupMachineStep,
3128    state: &SetupMachineState,
3129) -> Result<Option<String>, OauthAuthorizationError> {
3130    if let Some(value) = step
3131        .extra
3132        .get("redirect_uri")
3133        .or_else(|| step.extra.get("redirect_uri_template"))
3134        .and_then(Value::as_str)
3135        .map(str::trim)
3136        .filter(|value| !value.is_empty())
3137    {
3138        return expand_machine_template(value, state)
3139            .map(Some)
3140            .map_err(|err| OauthAuthorizationError::Invalid(err.to_string()));
3141    }
3142    let Some(callback_path) = step
3143        .extra
3144        .get("callback_path")
3145        .and_then(Value::as_str)
3146        .map(str::trim)
3147        .filter(|value| !value.is_empty())
3148    else {
3149        return Ok(None);
3150    };
3151    let public_base_url = output_string(&state.outputs, "public_base_url")
3152        .or_else(|| {
3153            crate::platform_setup::load_effective_static_routes_defaults(
3154                bundle_root,
3155                &state.tenant,
3156                Some(&state.team),
3157            )
3158            .ok()
3159            .flatten()
3160            .and_then(|policy| policy.public_base_url)
3161        })
3162        .ok_or(OauthAuthorizationError::Missing("public_base_url"))?;
3163    Ok(Some(format!(
3164        "{}{}",
3165        public_base_url.trim_end_matches('/'),
3166        ensure_leading_slash(callback_path)
3167    )))
3168}
3169
3170fn oauth_authorization_token_url(
3171    step: &SetupMachineStep,
3172    state: &SetupMachineState,
3173) -> Result<String, OauthAuthorizationError> {
3174    let template = step
3175        .extra
3176        .get("token_url")
3177        .or_else(|| step.extra.get("token_url_template"))
3178        .and_then(Value::as_str)
3179        .map(str::trim)
3180        .filter(|value| !value.is_empty())
3181        .ok_or(OauthAuthorizationError::Missing("token_url"))?;
3182    let token_url = expand_machine_template(template, state)
3183        .map_err(|err| OauthAuthorizationError::Invalid(err.to_string()))?;
3184    let parsed = url::Url::parse(&token_url)
3185        .map_err(|err| OauthAuthorizationError::Invalid(err.to_string()))?;
3186    if !(parsed.scheme() == "https" && parsed.host_str().is_some()
3187        || parsed.scheme() == "http"
3188            && matches!(parsed.host_str(), Some("127.0.0.1" | "localhost" | "::1")))
3189    {
3190        return Err(OauthAuthorizationError::Invalid(
3191            "token_url must be https or loopback http".to_string(),
3192        ));
3193    }
3194    Ok(token_url)
3195}
3196
3197fn oauth_scope(step: &SetupMachineStep) -> Result<Option<String>, OauthAuthorizationError> {
3198    if let Some(scope) = step.extra.get("scope").and_then(Value::as_str) {
3199        let scope = scope.trim();
3200        return Ok((!scope.is_empty()).then(|| scope.to_string()));
3201    }
3202    if let Some(scopes) = step.extra.get("scopes").and_then(Value::as_array) {
3203        let scope = scopes
3204            .iter()
3205            .filter_map(Value::as_str)
3206            .map(str::trim)
3207            .filter(|value| !value.is_empty())
3208            .collect::<Vec<_>>()
3209            .join(" ");
3210        return Ok((!scope.is_empty()).then_some(scope));
3211    }
3212    Ok(None)
3213}
3214
3215fn append_oauth_query_param(parsed: &mut url::Url, key: &str, value: &str) {
3216    if value.trim().is_empty() || parsed.query_pairs().any(|(existing, _)| existing == key) {
3217        return;
3218    }
3219    parsed.query_pairs_mut().append_pair(key, value);
3220}
3221
3222fn map_oauth_authorization_token_response(
3223    step: &SetupMachineStep,
3224    token_response: &Value,
3225) -> anyhow::Result<serde_json::Map<String, Value>> {
3226    let mut config = serde_json::Map::new();
3227    if let Some(map) = step
3228        .extra
3229        .get("response_secret_map")
3230        .and_then(Value::as_object)
3231    {
3232        for (response_key, secret_key) in map {
3233            let Some(secret_key) = secret_key
3234                .as_str()
3235                .map(str::trim)
3236                .filter(|value| !value.is_empty())
3237            else {
3238                anyhow::bail!(
3239                    "setup-machine OAuth response_secret_map value must be a non-empty string: {response_key}"
3240                );
3241            };
3242            if let Some(value) = token_response.get(response_key).and_then(Value::as_str)
3243                && !value.trim().is_empty()
3244            {
3245                config.insert(secret_key.to_string(), Value::String(value.to_string()));
3246            }
3247        }
3248    } else {
3249        insert_oauth_token_field(
3250            &mut config,
3251            token_response,
3252            "access_token",
3253            step.extra
3254                .get("token_store_key")
3255                .and_then(Value::as_str)
3256                .unwrap_or("oauth_access_token"),
3257        );
3258        if let Some(secret_key) = step
3259            .extra
3260            .get("refresh_token_store_key")
3261            .and_then(Value::as_str)
3262        {
3263            insert_oauth_token_field(&mut config, token_response, "refresh_token", secret_key);
3264        }
3265        if let Some(secret_key) = step.extra.get("id_token_store_key").and_then(Value::as_str) {
3266            insert_oauth_token_field(&mut config, token_response, "id_token", secret_key);
3267        }
3268    }
3269
3270    if config.is_empty() {
3271        anyhow::bail!("OAuth token response had no persistable tokens");
3272    }
3273    Ok(config)
3274}
3275
3276fn insert_oauth_token_field(
3277    config: &mut serde_json::Map<String, Value>,
3278    token_response: &Value,
3279    response_key: &str,
3280    secret_key: &str,
3281) {
3282    let secret_key = secret_key.trim();
3283    if secret_key.is_empty() {
3284        return;
3285    }
3286    if let Some(value) = token_response.get(response_key).and_then(Value::as_str)
3287        && !value.trim().is_empty()
3288    {
3289        config.insert(secret_key.to_string(), Value::String(value.to_string()));
3290    }
3291}
3292
3293fn ensure_leading_slash(value: &str) -> String {
3294    if value.starts_with('/') {
3295        value.to_string()
3296    } else {
3297        format!("/{value}")
3298    }
3299}
3300
3301fn execute_select_from_json_step(
3302    machine: &SetupMachine,
3303    step: &SetupMachineStep,
3304    state: &mut SetupMachineState,
3305) -> MachineStepResult {
3306    let Some(source_output) = step
3307        .extra
3308        .get("source_output")
3309        .or_else(|| step.extra.get("source"))
3310        .and_then(Value::as_str)
3311        .map(str::trim)
3312        .filter(|value| !value.is_empty())
3313    else {
3314        return pause_select_from_json(
3315            step,
3316            state,
3317            "missing_prerequisite",
3318            "source_output",
3319            "declare source_output, then retry",
3320        );
3321    };
3322    let Some(source) = state.outputs.get(source_output) else {
3323        return pause_select_from_json(
3324            step,
3325            state,
3326            "missing_prerequisite",
3327            source_output,
3328            "run prerequisite JSON step, then retry",
3329        );
3330    };
3331    let selected_scope = match select_json_scope(source, step) {
3332        Some(value) => value,
3333        None => {
3334            return pause_select_from_json(
3335                step,
3336                state,
3337                "missing_prerequisite",
3338                "json_path",
3339                "fix source JSON path, then retry",
3340            );
3341        }
3342    };
3343
3344    let selected = match choose_json_selection(selected_scope, step, state) {
3345        Ok(selected) => selected,
3346        Err(SelectFromJsonError::SelectionRequired(count)) => {
3347            state.status = SetupMachineStatus::Paused;
3348            state.current_step = Some(step.id.clone());
3349            state.last_error = Some(serde_json::json!({
3350                "error": "manual_action_required",
3351                "reason": "selection_required",
3352                "step": step.id,
3353                "options": count,
3354                "recoverable": true,
3355            }));
3356            return MachineStepResult {
3357                ok: false,
3358                next: "operator selection required".to_string(),
3359                detail: serde_json::json!({
3360                    "blocked": true,
3361                    "retryable": true,
3362                    "error": "manual_action_required",
3363                    "reason": "selection_required",
3364                    "options": count,
3365                    "source_output": source_output,
3366                }),
3367            };
3368        }
3369        Err(SelectFromJsonError::InvalidSelection(detail)) => {
3370            state.status = SetupMachineStatus::Paused;
3371            state.current_step = Some(step.id.clone());
3372            state.last_error = Some(serde_json::json!({
3373                "error": "invalid_answer",
3374                "step": step.id,
3375                "detail": detail,
3376                "recoverable": true,
3377            }));
3378            return MachineStepResult {
3379                ok: false,
3380                next: "fix selected JSON value, then retry".to_string(),
3381                detail: serde_json::json!({
3382                    "blocked": true,
3383                    "retryable": true,
3384                    "error": "invalid_answer",
3385                    "detail": detail,
3386                }),
3387            };
3388        }
3389    };
3390
3391    let output_key = step
3392        .extra
3393        .get("output_key")
3394        .and_then(Value::as_str)
3395        .unwrap_or(step.id.as_str());
3396    let value_key = step.extra.get("value_key").and_then(Value::as_str);
3397    let stored = value_key
3398        .and_then(|key| selected.get(key))
3399        .cloned()
3400        .unwrap_or_else(|| selected.clone());
3401    let outputs = ensure_outputs_object(state);
3402    outputs.insert(output_key.to_string(), stored.clone());
3403    if selected.is_object() {
3404        outputs.insert(format!("{output_key}_item"), selected.clone());
3405    }
3406    mark_machine_step_complete(machine, step, state);
3407    MachineStepResult {
3408        ok: true,
3409        next: "JSON selection completed".to_string(),
3410        detail: serde_json::json!({
3411            "kind": step.kind,
3412            "source_output": source_output,
3413            "output_key": output_key,
3414            "selected": redact_large_json_detail(&stored),
3415        }),
3416    }
3417}
3418
3419#[derive(Debug)]
3420enum SelectFromJsonError {
3421    SelectionRequired(usize),
3422    InvalidSelection(String),
3423}
3424
3425fn pause_select_from_json(
3426    step: &SetupMachineStep,
3427    state: &mut SetupMachineState,
3428    error: &str,
3429    missing: &str,
3430    next: &str,
3431) -> MachineStepResult {
3432    state.status = SetupMachineStatus::Paused;
3433    state.current_step = Some(step.id.clone());
3434    state.last_error = Some(serde_json::json!({
3435        "error": error,
3436        "step": step.id,
3437        "missing": missing,
3438        "recoverable": true,
3439    }));
3440    MachineStepResult {
3441        ok: false,
3442        next: next.to_string(),
3443        detail: serde_json::json!({
3444            "blocked": true,
3445            "retryable": true,
3446            "error": error,
3447            "missing_config_key": missing,
3448        }),
3449    }
3450}
3451
3452fn select_json_scope<'a>(source: &'a Value, step: &SetupMachineStep) -> Option<&'a Value> {
3453    let Some(path) = step
3454        .extra
3455        .get("json_pointer")
3456        .or_else(|| step.extra.get("path"))
3457        .or_else(|| step.extra.get("json_path"))
3458        .and_then(Value::as_str)
3459        .map(str::trim)
3460        .filter(|value| !value.is_empty())
3461    else {
3462        return Some(source);
3463    };
3464    if path.starts_with('/') {
3465        source.pointer(path)
3466    } else {
3467        let mut current = source;
3468        for segment in path.split('.').filter(|segment| !segment.is_empty()) {
3469            current = current.get(segment)?;
3470        }
3471        Some(current)
3472    }
3473}
3474
3475fn choose_json_selection(
3476    value: &Value,
3477    step: &SetupMachineStep,
3478    state: &SetupMachineState,
3479) -> Result<Value, SelectFromJsonError> {
3480    let Some(items) = value.as_array() else {
3481        return Ok(value.clone());
3482    };
3483    if items.is_empty() {
3484        return Err(SelectFromJsonError::InvalidSelection(
3485            "source JSON selection array is empty".to_string(),
3486        ));
3487    }
3488    if let Some(index) = step.extra.get("selected_index").and_then(Value::as_u64) {
3489        return items.get(index as usize).cloned().ok_or_else(|| {
3490            SelectFromJsonError::InvalidSelection(format!("selected_index {index} is out of range"))
3491        });
3492    }
3493    if let Some(value) = step
3494        .extra
3495        .get("selected_value")
3496        .or_else(|| step.extra.get("default_value"))
3497    {
3498        return find_json_selection_by_value(items, step, value).ok_or_else(|| {
3499            SelectFromJsonError::InvalidSelection(
3500                "selected_value did not match any option".to_string(),
3501            )
3502        });
3503    }
3504    let choice_key = step
3505        .extra
3506        .get("choice_output_key")
3507        .and_then(Value::as_str)
3508        .unwrap_or(step.id.as_str());
3509    if let Some(value) = state.outputs.get(choice_key) {
3510        return find_json_selection_by_value(items, step, value).ok_or_else(|| {
3511            SelectFromJsonError::InvalidSelection(format!(
3512                "choice output `{choice_key}` did not match any option"
3513            ))
3514        });
3515    }
3516    if items.len() == 1 {
3517        return Ok(items[0].clone());
3518    }
3519    Err(SelectFromJsonError::SelectionRequired(items.len()))
3520}
3521
3522fn find_json_selection_by_value(
3523    items: &[Value],
3524    step: &SetupMachineStep,
3525    selected_value: &Value,
3526) -> Option<Value> {
3527    let value_key = step.extra.get("value_key").and_then(Value::as_str);
3528    items.iter().find_map(|item| {
3529        let candidate = value_key.and_then(|key| item.get(key)).unwrap_or(item);
3530        values_match_for_selection(candidate, selected_value).then(|| item.clone())
3531    })
3532}
3533
3534fn values_match_for_selection(candidate: &Value, selected_value: &Value) -> bool {
3535    if candidate == selected_value {
3536        return true;
3537    }
3538    match (candidate, selected_value) {
3539        (Value::String(left), Value::String(right)) => left == right,
3540        (Value::String(left), other) => other.as_str() == Some(left.as_str()),
3541        (other, Value::String(right)) => other.as_str() == Some(right.as_str()),
3542        _ => false,
3543    }
3544}
3545
3546fn redact_large_json_detail(value: &Value) -> Value {
3547    match value {
3548        Value::Array(items) if items.len() > 10 => serde_json::json!({
3549            "kind": "array",
3550            "len": items.len(),
3551        }),
3552        Value::Object(map) if map.len() > 20 => serde_json::json!({
3553            "kind": "object",
3554            "keys": map.len(),
3555        }),
3556        other => other.clone(),
3557    }
3558}
3559
3560fn execute_generate_file_step(
3561    bundle_root: &Path,
3562    machine: &SetupMachine,
3563    step: &SetupMachineStep,
3564    state: &mut SetupMachineState,
3565) -> MachineStepResult {
3566    let Some(artifact_path) = setup_artifact_relative_path(step) else {
3567        state.status = SetupMachineStatus::Paused;
3568        state.current_step = Some(step.id.clone());
3569        state.last_error = Some(serde_json::json!({
3570            "error": "missing_prerequisite",
3571            "step": step.id,
3572            "missing": "artifact_path",
3573            "recoverable": true,
3574        }));
3575        return MachineStepResult {
3576            ok: false,
3577            next: "declare artifact_path, then retry".to_string(),
3578            detail: serde_json::json!({
3579                "blocked": true,
3580                "retryable": true,
3581                "error": "missing_prerequisite",
3582                "missing_config_key": "artifact_path",
3583            }),
3584        };
3585    };
3586    if !is_safe_setup_artifact_path(artifact_path) {
3587        state.status = SetupMachineStatus::Paused;
3588        state.current_step = Some(step.id.clone());
3589        state.last_error = Some(serde_json::json!({
3590            "error": "provider_contract_error",
3591            "step": step.id,
3592            "artifact_path": artifact_path,
3593            "recoverable": true,
3594        }));
3595        return MachineStepResult {
3596            ok: false,
3597            next: "fix setup artifact path, then retry".to_string(),
3598            detail: serde_json::json!({
3599                "blocked": true,
3600                "retryable": true,
3601                "error": "provider_contract_error",
3602                "artifact_path": artifact_path,
3603            }),
3604        };
3605    }
3606
3607    let rendered = match render_generate_file_content(step, state) {
3608        Ok(rendered) => rendered,
3609        Err(err) => {
3610            state.status = SetupMachineStatus::Paused;
3611            state.current_step = Some(step.id.clone());
3612            state.last_error = Some(serde_json::json!({
3613                "error": "missing_prerequisite",
3614                "step": step.id,
3615                "detail": err.to_string(),
3616                "recoverable": true,
3617            }));
3618            return MachineStepResult {
3619                ok: false,
3620                next: "provide file content inputs, then retry".to_string(),
3621                detail: serde_json::json!({
3622                    "blocked": true,
3623                    "retryable": true,
3624                    "error": "missing_prerequisite",
3625                    "detail": err.to_string(),
3626                }),
3627            };
3628        }
3629    };
3630
3631    let artifact_file = resolve_setup_artifact_path(bundle_root, state, artifact_path);
3632    if let Some(parent) = artifact_file.parent()
3633        && let Err(err) = std::fs::create_dir_all(parent)
3634    {
3635        return pause_generate_file_write_error(step, state, artifact_path, err);
3636    }
3637    if let Err(err) = std::fs::write(&artifact_file, rendered.as_bytes()) {
3638        return pause_generate_file_write_error(step, state, artifact_path, err);
3639    }
3640
3641    let output_key = step
3642        .extra
3643        .get("output_key")
3644        .and_then(Value::as_str)
3645        .unwrap_or(step.id.as_str());
3646    ensure_outputs_object(state).insert(
3647        output_key.to_string(),
3648        Value::String(artifact_path.to_string()),
3649    );
3650    state.created_resources.push(serde_json::json!({
3651        "kind": "setup_artifact",
3652        "step": step.id,
3653        "path": artifact_path,
3654    }));
3655    mark_machine_step_complete(machine, step, state);
3656    MachineStepResult {
3657        ok: true,
3658        next: "setup artifact generated".to_string(),
3659        detail: serde_json::json!({
3660            "kind": step.kind,
3661            "artifact_path": artifact_path,
3662            "bytes": rendered.len(),
3663            "output_key": output_key,
3664        }),
3665    }
3666}
3667
3668fn execute_persist_runtime_config_step(
3669    bundle_root: &Path,
3670    pack_path: Option<&Path>,
3671    machine: &SetupMachine,
3672    step: &SetupMachineStep,
3673    state: &mut SetupMachineState,
3674) -> MachineStepResult {
3675    let config = match render_runtime_config(machine, step, state) {
3676        Ok(config) => config,
3677        Err(err) => {
3678            state.status = SetupMachineStatus::Paused;
3679            state.current_step = Some(step.id.clone());
3680            state.last_error = Some(serde_json::json!({
3681                "error": "missing_prerequisite",
3682                "step": step.id,
3683                "detail": err.to_string(),
3684                "recoverable": true,
3685            }));
3686            return MachineStepResult {
3687                ok: false,
3688                next: "provide runtime config inputs, then retry".to_string(),
3689                detail: serde_json::json!({
3690                    "blocked": true,
3691                    "retryable": true,
3692                    "error": "missing_prerequisite",
3693                    "detail": err.to_string(),
3694                }),
3695            };
3696        }
3697    };
3698
3699    let config_dir = bundle_root
3700        .join("state")
3701        .join("config")
3702        .join(&state.provider_id);
3703    let config_path = config_dir.join("setup-answers.json");
3704    if let Some(parent) = config_path.parent()
3705        && let Err(err) = std::fs::create_dir_all(parent)
3706    {
3707        return pause_runtime_config_write_error(step, state, "setup_answers", err);
3708    }
3709    let bytes = match serde_json::to_vec_pretty(&config) {
3710        Ok(bytes) => bytes,
3711        Err(err) => {
3712            state.status = SetupMachineStatus::Paused;
3713            state.current_step = Some(step.id.clone());
3714            state.last_error = Some(serde_json::json!({
3715                "error": "provider_contract_error",
3716                "step": step.id,
3717                "detail": err.to_string(),
3718                "recoverable": true,
3719            }));
3720            return MachineStepResult {
3721                ok: false,
3722                next: "runtime config could not be serialized".to_string(),
3723                detail: serde_json::json!({
3724                    "blocked": true,
3725                    "retryable": true,
3726                    "error": "provider_contract_error",
3727                    "detail": err.to_string(),
3728                }),
3729            };
3730        }
3731    };
3732    if let Err(err) = std::fs::write(&config_path, bytes) {
3733        return pause_runtime_config_write_error(step, state, "setup_answers", err);
3734    }
3735
3736    let mut envelope_path = Value::Null;
3737    let mut envelope_skipped = false;
3738    if let Some(pack_path) = pack_path {
3739        match crate::config_envelope::write_provider_config_envelope(
3740            &bundle_root.join(".providers"),
3741            &state.provider_id,
3742            &step.id,
3743            &config,
3744            pack_path,
3745            true,
3746        ) {
3747            Ok(path) => envelope_path = Value::String(path.display().to_string()),
3748            Err(err) => {
3749                state.status = SetupMachineStatus::Paused;
3750                state.current_step = Some(step.id.clone());
3751                state.last_error = Some(serde_json::json!({
3752                    "error": "provider_contract_error",
3753                    "step": step.id,
3754                    "detail": err.to_string(),
3755                    "recoverable": true,
3756                }));
3757                return MachineStepResult {
3758                    ok: false,
3759                    next: "provider config envelope could not be written".to_string(),
3760                    detail: serde_json::json!({
3761                        "blocked": true,
3762                        "retryable": true,
3763                        "error": "provider_contract_error",
3764                        "detail": err.to_string(),
3765                    }),
3766                };
3767            }
3768        }
3769    } else {
3770        envelope_skipped = true;
3771    }
3772
3773    state.created_resources.push(serde_json::json!({
3774        "kind": "runtime_config",
3775        "step": step.id,
3776        "path": config_path.display().to_string(),
3777        "envelope_path": envelope_path,
3778    }));
3779    let output_key = step
3780        .extra
3781        .get("output_key")
3782        .and_then(Value::as_str)
3783        .unwrap_or("runtime_config_path");
3784    ensure_outputs_object(state).insert(
3785        output_key.to_string(),
3786        Value::String(config_path.display().to_string()),
3787    );
3788    mark_machine_step_complete(machine, step, state);
3789    MachineStepResult {
3790        ok: true,
3791        next: "runtime config persisted".to_string(),
3792        detail: serde_json::json!({
3793            "kind": step.kind,
3794            "config_path": config_path,
3795            "envelope_path": envelope_path,
3796            "envelope_skipped": envelope_skipped,
3797        }),
3798    }
3799}
3800
3801fn render_runtime_config(
3802    machine: &SetupMachine,
3803    step: &SetupMachineStep,
3804    state: &SetupMachineState,
3805) -> anyhow::Result<Value> {
3806    let mut config = if let Some(config) = step.extra.get("config") {
3807        expand_machine_value_templates(config.clone(), state)
3808    } else if let Some(mappings) = step.extra.get("mappings") {
3809        expand_machine_value_templates(mappings.clone(), state)
3810    } else {
3811        anyhow::bail!("persist_runtime_config requires config or mappings")
3812    };
3813    let server_owned = setup_machine_server_owned_keys(machine, step);
3814    if let Some(config) = config.as_object_mut() {
3815        for key in server_owned {
3816            config.remove(&key);
3817        }
3818    } else {
3819        anyhow::bail!("persist_runtime_config config must render to an object");
3820    }
3821    Ok(config)
3822}
3823
3824fn setup_machine_server_owned_keys(
3825    machine: &SetupMachine,
3826    step: &SetupMachineStep,
3827) -> BTreeSet<String> {
3828    machine
3829        .extra
3830        .get("server_owned_config_keys")
3831        .into_iter()
3832        .chain(step.extra.get("server_owned_config_keys"))
3833        .flat_map(|value| value.as_array().into_iter().flatten())
3834        .filter_map(Value::as_str)
3835        .map(str::trim)
3836        .filter(|value| !value.is_empty())
3837        .map(str::to_string)
3838        .collect()
3839}
3840
3841fn pause_runtime_config_write_error(
3842    step: &SetupMachineStep,
3843    state: &mut SetupMachineState,
3844    target: &str,
3845    err: std::io::Error,
3846) -> MachineStepResult {
3847    state.status = SetupMachineStatus::Paused;
3848    state.current_step = Some(step.id.clone());
3849    state.last_error = Some(serde_json::json!({
3850        "error": "provider_contract_error",
3851        "step": step.id,
3852        "target": target,
3853        "detail": err.to_string(),
3854        "recoverable": true,
3855    }));
3856    MachineStepResult {
3857        ok: false,
3858        next: "runtime config could not be written".to_string(),
3859        detail: serde_json::json!({
3860            "blocked": true,
3861            "retryable": true,
3862            "error": "provider_contract_error",
3863            "target": target,
3864            "detail": err.to_string(),
3865        }),
3866    }
3867}
3868
3869fn pause_generate_file_write_error(
3870    step: &SetupMachineStep,
3871    state: &mut SetupMachineState,
3872    artifact_path: &str,
3873    err: std::io::Error,
3874) -> MachineStepResult {
3875    state.status = SetupMachineStatus::Paused;
3876    state.current_step = Some(step.id.clone());
3877    state.last_error = Some(serde_json::json!({
3878        "error": "artifact_write_failed",
3879        "step": step.id,
3880        "artifact_path": artifact_path,
3881        "detail": err.to_string(),
3882        "recoverable": true,
3883    }));
3884    MachineStepResult {
3885        ok: false,
3886        next: "setup artifact could not be written".to_string(),
3887        detail: serde_json::json!({
3888            "blocked": true,
3889            "retryable": true,
3890            "error": "artifact_write_failed",
3891            "artifact_path": artifact_path,
3892            "detail": err.to_string(),
3893        }),
3894    }
3895}
3896
3897fn setup_artifact_relative_path(step: &SetupMachineStep) -> Option<&str> {
3898    step.extra
3899        .get("artifact_path")
3900        .or_else(|| step.extra.get("path"))
3901        .or_else(|| step.extra.get("output_path"))
3902        .and_then(Value::as_str)
3903        .map(str::trim)
3904        .filter(|value| !value.is_empty())
3905}
3906
3907fn is_safe_setup_artifact_path(path: &str) -> bool {
3908    !path.starts_with('/')
3909        && !path.contains("..")
3910        && (path.starts_with("artifacts/") || path.starts_with("state/setup/"))
3911}
3912
3913fn resolve_setup_artifact_path(
3914    bundle_root: &Path,
3915    state: &SetupMachineState,
3916    artifact_path: &str,
3917) -> PathBuf {
3918    if artifact_path.starts_with("state/setup/") {
3919        bundle_root.join(artifact_path)
3920    } else {
3921        setup_machine_state_dir(bundle_root, &state.tenant, &state.team, &state.provider_id)
3922            .join(artifact_path)
3923    }
3924}
3925
3926fn render_generate_file_content(
3927    step: &SetupMachineStep,
3928    state: &SetupMachineState,
3929) -> anyhow::Result<String> {
3930    if let Some(template) = step.extra.get("template").and_then(Value::as_str) {
3931        return expand_machine_template(template, state);
3932    }
3933    if let Some(content) = step.extra.get("content") {
3934        return render_generated_value(content.clone(), state);
3935    }
3936    if let Some(template_json) = step.extra.get("template_json") {
3937        return render_generated_value(template_json.clone(), state);
3938    }
3939    anyhow::bail!("generate_file requires template, content, or template_json")
3940}
3941
3942fn render_generated_value(value: Value, state: &SetupMachineState) -> anyhow::Result<String> {
3943    match value {
3944        Value::String(text) => expand_machine_template(&text, state),
3945        other => Ok(serde_json::to_string_pretty(
3946            &expand_machine_value_templates(other, state),
3947        )?),
3948    }
3949}
3950
3951fn execute_http_step(
3952    machine: &SetupMachine,
3953    step: &SetupMachineStep,
3954    state: &mut SetupMachineState,
3955) -> MachineStepResult {
3956    let Some(template) = step
3957        .extra
3958        .get("url_template")
3959        .or_else(|| step.extra.get("url"))
3960        .and_then(Value::as_str)
3961    else {
3962        state.status = SetupMachineStatus::Paused;
3963        state.current_step = Some(step.id.clone());
3964        state.last_error = Some(serde_json::json!({
3965            "error": "missing_prerequisite",
3966            "step": step.id,
3967            "missing": "url_template",
3968            "recoverable": true,
3969        }));
3970        return MachineStepResult {
3971            ok: false,
3972            next: "declare url_template, then retry".to_string(),
3973            detail: serde_json::json!({
3974                "blocked": true,
3975                "retryable": true,
3976                "error": "missing_prerequisite",
3977                "missing_config_key": "url_template",
3978            }),
3979        };
3980    };
3981    let target = match expand_machine_template(template, state) {
3982        Ok(target) => target,
3983        Err(err) => {
3984            state.status = SetupMachineStatus::Paused;
3985            state.current_step = Some(step.id.clone());
3986            state.last_error = Some(serde_json::json!({
3987                "error": "missing_prerequisite",
3988                "step": step.id,
3989                "detail": err.to_string(),
3990                "recoverable": true,
3991            }));
3992            return MachineStepResult {
3993                ok: false,
3994                next: "provide missing template values, then retry".to_string(),
3995                detail: serde_json::json!({
3996                    "blocked": true,
3997                    "retryable": true,
3998                    "error": "missing_prerequisite",
3999                    "detail": err.to_string(),
4000                }),
4001            };
4002        }
4003    };
4004    if let Err(err) = validate_http_step_url(&target) {
4005        state.status = SetupMachineStatus::Paused;
4006        state.current_step = Some(step.id.clone());
4007        state.last_error = Some(serde_json::json!({
4008            "error": "invalid_answer",
4009            "step": step.id,
4010            "detail": err.to_string(),
4011            "recoverable": true,
4012        }));
4013        return MachineStepResult {
4014            ok: false,
4015            next: "fix setup HTTP URL, then retry".to_string(),
4016            detail: serde_json::json!({
4017                "blocked": true,
4018                "retryable": true,
4019                "error": "invalid_answer",
4020                "detail": err.to_string(),
4021            }),
4022        };
4023    }
4024
4025    let method = step
4026        .extra
4027        .get("method")
4028        .and_then(Value::as_str)
4029        .unwrap_or("GET")
4030        .to_ascii_uppercase();
4031    let expected_status = step
4032        .extra
4033        .get("expected_status")
4034        .or_else(|| step.extra.get("status"))
4035        .and_then(Value::as_u64)
4036        .unwrap_or(200) as u16;
4037    let response = match method.as_str() {
4038        "GET" => ureq::get(&target).call(),
4039        "POST" => {
4040            let payload = step
4041                .extra
4042                .get("body")
4043                .cloned()
4044                .unwrap_or_else(|| serde_json::json!({}));
4045            let payload = expand_machine_value_templates(payload, state);
4046            ureq::post(&target).send_json(payload)
4047        }
4048        _ => {
4049            state.status = SetupMachineStatus::Paused;
4050            state.current_step = Some(step.id.clone());
4051            state.last_error = Some(serde_json::json!({
4052                "error": "unsupported_http_method",
4053                "step": step.id,
4054                "method": method,
4055                "recoverable": true,
4056            }));
4057            return MachineStepResult {
4058                ok: false,
4059                next: "fix setup HTTP method, then retry".to_string(),
4060                detail: serde_json::json!({
4061                    "blocked": true,
4062                    "retryable": true,
4063                    "error": "unsupported_http_method",
4064                    "method": method,
4065                }),
4066            };
4067        }
4068    };
4069    let mut response = match response {
4070        Ok(response) => response,
4071        Err(ureq::Error::StatusCode(status)) => {
4072            state.status = SetupMachineStatus::Paused;
4073            state.current_step = Some(step.id.clone());
4074            state.last_error = Some(serde_json::json!({
4075                "error": "provider_contract_error",
4076                "step": step.id,
4077                "status": status,
4078                "expected_status": expected_status,
4079                "recoverable": true,
4080            }));
4081            return MachineStepResult {
4082                ok: false,
4083                next: "setup HTTP endpoint returned an unexpected status".to_string(),
4084                detail: serde_json::json!({
4085                    "blocked": true,
4086                    "retryable": true,
4087                    "error": "provider_contract_error",
4088                    "status": status,
4089                    "expected_status": expected_status,
4090                    "target": target,
4091                }),
4092            };
4093        }
4094        Err(err) => {
4095            state.status = SetupMachineStatus::Paused;
4096            state.current_step = Some(step.id.clone());
4097            state.last_error = Some(serde_json::json!({
4098                "error": "external_timeout",
4099                "step": step.id,
4100                "detail": err.to_string(),
4101                "recoverable": true,
4102            }));
4103            return MachineStepResult {
4104                ok: false,
4105                next: "setup HTTP endpoint is not reachable".to_string(),
4106                detail: serde_json::json!({
4107                    "blocked": true,
4108                    "retryable": true,
4109                    "error": "external_timeout",
4110                    "detail": err.to_string(),
4111                    "target": target,
4112                }),
4113            };
4114        }
4115    };
4116    let status = response.status().as_u16();
4117    if status != expected_status {
4118        state.status = SetupMachineStatus::Paused;
4119        state.current_step = Some(step.id.clone());
4120        state.last_error = Some(serde_json::json!({
4121            "error": "provider_contract_error",
4122            "step": step.id,
4123            "status": status,
4124            "expected_status": expected_status,
4125            "recoverable": true,
4126        }));
4127        return MachineStepResult {
4128            ok: false,
4129            next: "setup HTTP endpoint returned an unexpected status".to_string(),
4130            detail: serde_json::json!({
4131                "blocked": true,
4132                "retryable": true,
4133                "error": "provider_contract_error",
4134                "status": status,
4135                "expected_status": expected_status,
4136                "target": target,
4137            }),
4138        };
4139    }
4140
4141    let body = response
4142        .body_mut()
4143        .read_json::<Value>()
4144        .unwrap_or(Value::Null);
4145    if step.kind == "http_json" {
4146        let output_key = step
4147            .extra
4148            .get("output_key")
4149            .and_then(Value::as_str)
4150            .unwrap_or(step.id.as_str());
4151        ensure_outputs_object(state).insert(output_key.to_string(), body.clone());
4152    }
4153    mark_machine_step_complete(machine, step, state);
4154    MachineStepResult {
4155        ok: true,
4156        next: "HTTP setup step completed".to_string(),
4157        detail: serde_json::json!({
4158            "kind": step.kind,
4159            "method": method,
4160            "status": status,
4161            "target": target,
4162            "body": if step.kind == "http_json" { body } else { Value::Null },
4163        }),
4164    }
4165}
4166
4167fn execute_platform_public_url_step(
4168    bundle_root: &Path,
4169    machine: &SetupMachine,
4170    step: &SetupMachineStep,
4171    state: &mut SetupMachineState,
4172) -> MachineStepResult {
4173    let key = step
4174        .extra
4175        .get("output_key")
4176        .and_then(Value::as_str)
4177        .unwrap_or("public_base_url");
4178    let candidate = output_string(&state.outputs, key)
4179        .or_else(|| {
4180            step.extra
4181                .get(key)
4182                .and_then(Value::as_str)
4183                .map(str::to_string)
4184        })
4185        .or_else(|| {
4186            step.extra
4187                .get("default")
4188                .or_else(|| step.extra.get("default_url"))
4189                .and_then(Value::as_str)
4190                .map(str::to_string)
4191        })
4192        .or_else(|| {
4193            crate::platform_setup::load_effective_static_routes_defaults(
4194                bundle_root,
4195                &state.tenant,
4196                Some(&state.team),
4197            )
4198            .ok()
4199            .flatten()
4200            .and_then(|policy| policy.public_base_url)
4201        })
4202        .map(|value| value.trim_end_matches('/').to_string())
4203        .filter(|value| !value.trim().is_empty());
4204
4205    let Some(public_base_url) = candidate else {
4206        state.status = SetupMachineStatus::Paused;
4207        state.current_step = Some(step.id.clone());
4208        state.last_error = Some(serde_json::json!({
4209            "error": "missing_prerequisite",
4210            "step": step.id,
4211            "missing": key,
4212            "recoverable": true,
4213        }));
4214        return MachineStepResult {
4215            ok: false,
4216            next: "configure public_base_url, then retry".to_string(),
4217            detail: serde_json::json!({
4218                "blocked": true,
4219                "retryable": true,
4220                "error": "missing_prerequisite",
4221                "missing_config_key": key,
4222            }),
4223        };
4224    };
4225
4226    if url::Url::parse(&public_base_url).is_err() {
4227        state.status = SetupMachineStatus::Paused;
4228        state.current_step = Some(step.id.clone());
4229        state.last_error = Some(serde_json::json!({
4230            "error": "invalid_answer",
4231            "step": step.id,
4232            "field": key,
4233            "recoverable": true,
4234        }));
4235        return MachineStepResult {
4236            ok: false,
4237            next: "fix public_base_url, then retry".to_string(),
4238            detail: serde_json::json!({
4239                "blocked": true,
4240                "retryable": true,
4241                "error": "invalid_answer",
4242                "field": key,
4243            }),
4244        };
4245    }
4246
4247    ensure_outputs_object(state).insert(key.to_string(), Value::String(public_base_url.clone()));
4248    mark_machine_step_complete(machine, step, state);
4249    MachineStepResult {
4250        ok: true,
4251        next: "public URL resolved".to_string(),
4252        detail: serde_json::json!({
4253            "kind": step.kind,
4254            "output_key": key,
4255            "public_base_url": public_base_url,
4256        }),
4257    }
4258}
4259
4260fn execute_cloudflare_tunnel_step(
4261    bundle_root: &Path,
4262    machine: &SetupMachine,
4263    step: &SetupMachineStep,
4264    state: &mut SetupMachineState,
4265) -> MachineStepResult {
4266    let key = step
4267        .extra
4268        .get("output_key")
4269        .and_then(Value::as_str)
4270        .unwrap_or("public_base_url");
4271    let candidate = output_string(&state.outputs, key)
4272        .or_else(|| {
4273            step.extra
4274                .get(key)
4275                .and_then(Value::as_str)
4276                .map(str::to_string)
4277        })
4278        .or_else(|| {
4279            step.extra
4280                .get("public_base_url")
4281                .or_else(|| step.extra.get("default"))
4282                .or_else(|| step.extra.get("default_url"))
4283                .and_then(Value::as_str)
4284                .map(str::to_string)
4285        })
4286        .or_else(|| {
4287            crate::platform_setup::load_effective_static_routes_defaults(
4288                bundle_root,
4289                &state.tenant,
4290                Some(&state.team),
4291            )
4292            .ok()
4293            .flatten()
4294            .and_then(|policy| policy.public_base_url)
4295        })
4296        .map(|value| value.trim_end_matches('/').to_string())
4297        .filter(|value| !value.trim().is_empty());
4298
4299    let Some(public_base_url) = candidate else {
4300        state.status = SetupMachineStatus::Paused;
4301        state.current_step = Some(step.id.clone());
4302        state.last_error = Some(serde_json::json!({
4303            "error": "tunnel_unreachable",
4304            "step": step.id,
4305            "missing": key,
4306            "recoverable": true,
4307        }));
4308        return MachineStepResult {
4309            ok: false,
4310            next: "start or configure a setup tunnel, then retry".to_string(),
4311            detail: serde_json::json!({
4312                "blocked": true,
4313                "retryable": true,
4314                "error": "tunnel_unreachable",
4315                "missing_config_key": key,
4316            }),
4317        };
4318    };
4319
4320    let parsed = match url::Url::parse(&public_base_url) {
4321        Ok(parsed) => parsed,
4322        Err(err) => {
4323            state.status = SetupMachineStatus::Paused;
4324            state.current_step = Some(step.id.clone());
4325            state.last_error = Some(serde_json::json!({
4326                "error": "invalid_answer",
4327                "step": step.id,
4328                "field": key,
4329                "detail": err.to_string(),
4330                "recoverable": true,
4331            }));
4332            return MachineStepResult {
4333                ok: false,
4334                next: "fix setup tunnel URL, then retry".to_string(),
4335                detail: serde_json::json!({
4336                    "blocked": true,
4337                    "retryable": true,
4338                    "error": "invalid_answer",
4339                    "field": key,
4340                    "detail": err.to_string(),
4341                }),
4342            };
4343        }
4344    };
4345    if parsed.scheme() != "https" || parsed.host_str().is_none() {
4346        state.status = SetupMachineStatus::Paused;
4347        state.current_step = Some(step.id.clone());
4348        state.last_error = Some(serde_json::json!({
4349            "error": "invalid_answer",
4350            "step": step.id,
4351            "field": key,
4352            "detail": "setup tunnel URL must be an https URL with a host",
4353            "recoverable": true,
4354        }));
4355        return MachineStepResult {
4356            ok: false,
4357            next: "fix setup tunnel URL, then retry".to_string(),
4358            detail: serde_json::json!({
4359                "blocked": true,
4360                "retryable": true,
4361                "error": "invalid_answer",
4362                "field": key,
4363                "detail": "setup tunnel URL must be an https URL with a host",
4364            }),
4365        };
4366    }
4367
4368    ensure_outputs_object(state).insert(key.to_string(), Value::String(public_base_url.clone()));
4369    mark_machine_step_complete(machine, step, state);
4370    MachineStepResult {
4371        ok: true,
4372        next: "setup tunnel URL resolved".to_string(),
4373        detail: serde_json::json!({
4374            "kind": step.kind,
4375            "output_key": key,
4376            "public_base_url": public_base_url,
4377            "ephemeral": crate::setup_tunnel::is_ephemeral_tunnel_url(&public_base_url),
4378        }),
4379    }
4380}
4381
4382fn ensure_outputs_object(state: &mut SetupMachineState) -> &mut serde_json::Map<String, Value> {
4383    if !state.outputs.is_object() {
4384        state.outputs = serde_json::json!({});
4385    }
4386    state.outputs.as_object_mut().expect("outputs object")
4387}
4388
4389fn output_string(outputs: &Value, key: &str) -> Option<String> {
4390    outputs
4391        .get(key)
4392        .and_then(Value::as_str)
4393        .map(str::trim)
4394        .filter(|value| !value.is_empty())
4395        .map(str::to_string)
4396}
4397
4398fn validate_http_step_url(value: &str) -> anyhow::Result<()> {
4399    let url = url::Url::parse(value)?;
4400    match url.scheme() {
4401        "http" if url.host_str().is_some_and(is_loopback_host) => Ok(()),
4402        "https" => Ok(()),
4403        _ => anyhow::bail!("setup HTTP URL must use https or loopback http"),
4404    }
4405}
4406
4407fn is_loopback_host(host: &str) -> bool {
4408    matches!(host, "localhost" | "127.0.0.1" | "::1")
4409}
4410
4411fn expand_machine_template(template: &str, state: &SetupMachineState) -> anyhow::Result<String> {
4412    let mut expanded = template
4413        .replace("{tenant}", &state.tenant)
4414        .replace("{team}", &state.team)
4415        .replace("{provider_id}", &state.provider_id);
4416    let mut start = 0;
4417    while let Some(open) = expanded[start..].find('{') {
4418        let open = start + open;
4419        let Some(close_rel) = expanded[open + 1..].find('}') else {
4420            break;
4421        };
4422        let close = open + 1 + close_rel;
4423        let key = &expanded[open + 1..close];
4424        let Some(value) = output_string(&state.outputs, key) else {
4425            anyhow::bail!("missing setup-machine output `{key}`");
4426        };
4427        expanded.replace_range(open..=close, &value);
4428        start = open + value.len();
4429    }
4430    Ok(expanded)
4431}
4432
4433fn expand_machine_value_templates(value: Value, state: &SetupMachineState) -> Value {
4434    match value {
4435        Value::String(text) => expand_machine_template(&text, state)
4436            .map(Value::String)
4437            .unwrap_or(Value::String(text)),
4438        Value::Array(values) => Value::Array(
4439            values
4440                .into_iter()
4441                .map(|value| expand_machine_value_templates(value, state))
4442                .collect(),
4443        ),
4444        Value::Object(map) => Value::Object(
4445            map.into_iter()
4446                .map(|(key, value)| (key, expand_machine_value_templates(value, state)))
4447                .collect(),
4448        ),
4449        other => other,
4450    }
4451}
4452
4453fn mark_machine_step_complete(
4454    machine: &SetupMachine,
4455    step: &SetupMachineStep,
4456    state: &mut SetupMachineState,
4457) {
4458    if !state.completed_steps.iter().any(|id| id == &step.id) {
4459        state.completed_steps.push(step.id.clone());
4460    }
4461    state.last_error = None;
4462    state.failed_step = None;
4463    match step.on_success.as_deref() {
4464        Some(TERMINAL_SUCCESS) | None => {
4465            state.status = SetupMachineStatus::Complete;
4466            state.current_step = None;
4467        }
4468        Some(TERMINAL_FAILED) => {
4469            state.status = SetupMachineStatus::Failed;
4470            state.current_step = None;
4471            state.failed_step = Some(step.id.clone());
4472            state.last_error = Some(serde_json::json!({
4473                "error": "transitioned_to_failed",
4474                "step": step.id,
4475            }));
4476        }
4477        Some(next) if machine.steps.iter().any(|candidate| candidate.id == next) => {
4478            state.status = SetupMachineStatus::Running;
4479            state.current_step = Some(next.to_string());
4480        }
4481        Some(next) => {
4482            state.status = SetupMachineStatus::Failed;
4483            state.current_step = Some(step.id.clone());
4484            state.failed_step = Some(step.id.clone());
4485            state.last_error = Some(serde_json::json!({
4486                "error": "invalid_transition_target",
4487                "step": step.id,
4488                "target": next,
4489            }));
4490        }
4491    }
4492}
4493
4494fn machine_step_output(
4495    machine: &SetupMachine,
4496    state: &SetupMachineState,
4497    step_id: &str,
4498    dry_run: bool,
4499    ok: bool,
4500    next: &str,
4501    event_path: Option<PathBuf>,
4502) -> Value {
4503    serde_json::json!({
4504        "provider_id": state.provider_id,
4505        "tenant": state.tenant,
4506        "team": state.team,
4507        "machine_id": machine.id,
4508        "dry_run": dry_run,
4509        "step": step_id,
4510        "ok": ok,
4511        "next": next,
4512        "result": {
4513            "ok": ok,
4514            "status": state.status,
4515        },
4516        "event_path": event_path,
4517        "status": render_setup_machine_status(machine, state),
4518    })
4519}
4520
4521fn append_machine_step_event(
4522    bundle_root: &Path,
4523    state: &SetupMachineState,
4524    event_name: &str,
4525    step_id: Option<&str>,
4526    detail: Option<Value>,
4527) -> anyhow::Result<PathBuf> {
4528    let mut fields = serde_json::Map::new();
4529    if let Some(detail) = detail {
4530        fields.insert("detail".to_string(), detail);
4531    }
4532    append_setup_machine_event(
4533        bundle_root,
4534        &SetupMachineEvent {
4535            ts: now_setup_ts(),
4536            level: "info".to_string(),
4537            event: event_name.to_string(),
4538            provider_id: state.provider_id.clone(),
4539            tenant: state.tenant.clone(),
4540            team: state.team.clone(),
4541            machine_id: state.machine_id.clone(),
4542            step_id: step_id.map(str::to_string),
4543            correlation_id: Some(format!("setup-{}", now_setup_ms())),
4544            fields,
4545        },
4546    )
4547}
4548
4549fn now_setup_ts() -> String {
4550    now_setup_ms().to_string()
4551}
4552
4553fn now_setup_ms() -> u128 {
4554    SystemTime::now()
4555        .duration_since(UNIX_EPOCH)
4556        .unwrap_or_default()
4557        .as_millis()
4558}
4559
4560fn safe_archive_segment(value: &str) -> String {
4561    let mut out = value
4562        .chars()
4563        .map(|ch| {
4564            if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
4565                ch
4566            } else {
4567                '-'
4568            }
4569        })
4570        .collect::<String>();
4571    while out.contains("--") {
4572        out = out.replace("--", "-");
4573    }
4574    let out = out.trim_matches('-');
4575    if out.is_empty() {
4576        "archive".to_string()
4577    } else {
4578        out.to_string()
4579    }
4580}
4581
4582fn resolve_machine_value(pack_path: &Path, extension: Value) -> anyhow::Result<Value> {
4583    if let Some(inline) = extension.get("inline") {
4584        return Ok(inline.clone());
4585    }
4586    if let Some(path) = extension.get("path").and_then(Value::as_str) {
4587        return discovery::read_pack_json_asset(pack_path, path);
4588    }
4589    Ok(extension)
4590}
4591
4592fn extension_inline(extension: &Value) -> Option<&Value> {
4593    extension.get("inline").or(Some(extension))
4594}
4595
4596fn validate_schema_id(value: &Value, expected: &str, label: &str) -> anyhow::Result<()> {
4597    let schema_id = value
4598        .get("schema_id")
4599        .and_then(Value::as_str)
4600        .unwrap_or(expected);
4601    if schema_id != expected {
4602        anyhow::bail!("{label} has schema_id {schema_id}, expected {expected}");
4603    }
4604    Ok(())
4605}
4606
4607fn validate_provider_id(
4608    provider_id: Option<&str>,
4609    expected_provider_id: Option<&str>,
4610) -> anyhow::Result<()> {
4611    let Some(expected) = expected_provider_id else {
4612        return Ok(());
4613    };
4614    let Some(actual) = provider_id else {
4615        anyhow::bail!("setup backend contract provider_id is missing");
4616    };
4617    if actual != expected {
4618        anyhow::bail!(
4619            "setup backend contract provider_id {actual} does not match pack id {expected}"
4620        );
4621    }
4622    Ok(())
4623}
4624
4625fn string_array(value: Option<&Value>) -> Vec<String> {
4626    value
4627        .and_then(Value::as_array)
4628        .into_iter()
4629        .flatten()
4630        .filter_map(Value::as_str)
4631        .map(str::trim)
4632        .filter(|value| !value.is_empty())
4633        .map(str::to_string)
4634        .collect()
4635}
4636
4637fn validate_unique_strings(check_id: &str, message: &str, values: &[String]) -> Vec<Diagnostic> {
4638    let mut diagnostics = Vec::new();
4639    let mut seen = BTreeSet::new();
4640    for value in values {
4641        if !seen.insert(value) {
4642            diagnostics.push(error_with_actual(check_id, message, value));
4643        }
4644    }
4645    diagnostics
4646}
4647
4648fn require_executor_str(
4649    diagnostics: &mut Vec<Diagnostic>,
4650    action_id: &str,
4651    executor: &serde_json::Map<String, Value>,
4652    key: &str,
4653) {
4654    if executor
4655        .get(key)
4656        .and_then(Value::as_str)
4657        .is_none_or(|value| value.trim().is_empty())
4658    {
4659        diagnostics.push(error_with_actual(
4660            "setup.backend_contract.executor.required_field",
4661            "setup backend executor is missing a required field",
4662            &format!("{action_id}.{key}"),
4663        ));
4664    }
4665}
4666
4667fn validate_transition(
4668    diagnostics: &mut Vec<Diagnostic>,
4669    step_ids: &BTreeSet<String>,
4670    source_step: &str,
4671    check_id: &str,
4672    target: Option<&str>,
4673) {
4674    let Some(target) = target.map(str::trim).filter(|value| !value.is_empty()) else {
4675        return;
4676    };
4677    if matches!(target, TERMINAL_SUCCESS | TERMINAL_FAILED) || step_ids.contains(target) {
4678        return;
4679    }
4680    diagnostics.push(Diagnostic {
4681        check_id: check_id.to_string(),
4682        severity: DiagnosticSeverity::Error,
4683        component: "provider_setup".to_string(),
4684        message: "setup transition target must reference an existing step or terminal state"
4685            .to_string(),
4686        evidence: Some(format!("{source_step} -> {target}")),
4687        expected: Some("existing step id, complete, or failed".to_string()),
4688        actual: Some(target.to_string()),
4689        fix_hint: Some("fix the transition target or add the missing setup step".to_string()),
4690        related_file: None,
4691        related_pack: None,
4692        related_component: None,
4693    });
4694}
4695
4696fn rule_targets(rule: &Value) -> Vec<String> {
4697    let Some(obj) = rule.as_object() else {
4698        return Vec::new();
4699    };
4700    ["step", "next", "target", "on_success"]
4701        .iter()
4702        .filter_map(|key| obj.get(*key).and_then(Value::as_str).map(str::to_string))
4703        .collect()
4704}
4705
4706fn validate_reachability(machine: &SetupMachine, step_ids: &BTreeSet<String>) -> Vec<Diagnostic> {
4707    if !step_ids.contains(&machine.entry_step) {
4708        return Vec::new();
4709    }
4710
4711    let mut seen = BTreeSet::new();
4712    let mut queue = VecDeque::from([machine.entry_step.clone()]);
4713    while let Some(step_id) = queue.pop_front() {
4714        if !seen.insert(step_id.clone()) {
4715            continue;
4716        }
4717        let Some(step) = machine.steps.iter().find(|step| step.id == step_id) else {
4718            continue;
4719        };
4720        if let Some(target) = step.on_success.as_deref()
4721            && step_ids.contains(target)
4722        {
4723            queue.push_back(target.to_string());
4724        }
4725        for rule in step.on_failure.iter().chain(step.recover.iter()) {
4726            for target in rule_targets(rule) {
4727                if step_ids.contains(&target) {
4728                    queue.push_back(target);
4729                }
4730            }
4731        }
4732    }
4733
4734    step_ids
4735        .difference(&seen)
4736        .map(|step_id| {
4737            error_with_actual(
4738                "setup.machine.step.reachable",
4739                "setup step is not reachable from entry_step",
4740                step_id,
4741            )
4742        })
4743        .collect()
4744}
4745
4746fn validate_unbounded_cycles(
4747    machine: &SetupMachine,
4748    step_ids: &BTreeSet<String>,
4749) -> Vec<Diagnostic> {
4750    let steps: BTreeMap<&str, &SetupMachineStep> = machine
4751        .steps
4752        .iter()
4753        .map(|step| (step.id.as_str(), step))
4754        .collect();
4755    let mut edges: BTreeMap<String, Vec<String>> = BTreeMap::new();
4756    for step in &machine.steps {
4757        let mut targets = Vec::new();
4758        if let Some(target) = step.on_success.as_deref()
4759            && step_ids.contains(target)
4760        {
4761            targets.push(target.to_string());
4762        }
4763        for rule in step.on_failure.iter().chain(step.recover.iter()) {
4764            for target in rule_targets(rule) {
4765                if step_ids.contains(&target) {
4766                    targets.push(target);
4767                }
4768            }
4769        }
4770        edges.insert(step.id.clone(), targets);
4771    }
4772
4773    let mut diagnostics = Vec::new();
4774    let mut visited = BTreeSet::new();
4775    let mut stack = Vec::<String>::new();
4776    let mut reported = BTreeSet::new();
4777    for step_id in step_ids {
4778        detect_cycle(
4779            step_id,
4780            &edges,
4781            &steps,
4782            &mut visited,
4783            &mut stack,
4784            &mut reported,
4785            &mut diagnostics,
4786        );
4787    }
4788    diagnostics
4789}
4790
4791fn validate_artifact_paths(machine: &SetupMachine) -> Vec<Diagnostic> {
4792    let mut diagnostics = Vec::new();
4793    for step in &machine.steps {
4794        if !matches!(step.kind.as_str(), "generate_file" | "download_file") {
4795            continue;
4796        }
4797        let path = step
4798            .extra
4799            .get("artifact_path")
4800            .or_else(|| step.extra.get("path"))
4801            .or_else(|| step.extra.get("output_path"))
4802            .and_then(Value::as_str)
4803            .map(str::trim)
4804            .filter(|value| !value.is_empty());
4805        let Some(path) = path else {
4806            diagnostics.push(error_with_actual(
4807                "setup.machine.artifact.path",
4808                "setup artifact steps must declare artifact_path",
4809                &step.id,
4810            ));
4811            continue;
4812        };
4813        if path.starts_with('/')
4814            || path.contains("..")
4815            || !(path.starts_with("artifacts/") || path.starts_with("state/setup/"))
4816        {
4817            diagnostics.push(error_with_actual(
4818                "setup.machine.artifact.path.safe",
4819                "setup artifact path must be relative and stay under setup artifacts",
4820                &format!("{}: {}", step.id, path),
4821            ));
4822        }
4823    }
4824    diagnostics
4825}
4826
4827fn validate_http_steps(machine: &SetupMachine) -> Vec<Diagnostic> {
4828    let mut diagnostics = Vec::new();
4829    for step in &machine.steps {
4830        if !matches!(step.kind.as_str(), "http_probe" | "http_json") {
4831            continue;
4832        }
4833        let url = step
4834            .extra
4835            .get("url_template")
4836            .or_else(|| step.extra.get("url"))
4837            .and_then(Value::as_str)
4838            .map(str::trim)
4839            .filter(|value| !value.is_empty());
4840        let Some(url) = url else {
4841            diagnostics.push(error_with_actual(
4842                "setup.machine.http.url",
4843                "setup HTTP steps must declare url_template",
4844                &step.id,
4845            ));
4846            continue;
4847        };
4848        if !url.contains('{')
4849            && let Err(err) = validate_http_step_url(url)
4850        {
4851            diagnostics.push(error_with_actual(
4852                "setup.machine.http.url.safe",
4853                "setup HTTP URL must use https or loopback http",
4854                &format!("{}: {url} ({err})", step.id),
4855            ));
4856        }
4857        let method = step
4858            .extra
4859            .get("method")
4860            .and_then(Value::as_str)
4861            .unwrap_or("GET")
4862            .to_ascii_uppercase();
4863        if !matches!(method.as_str(), "GET" | "POST") {
4864            diagnostics.push(error_with_actual(
4865                "setup.machine.http.method",
4866                "setup HTTP method must be GET or POST",
4867                &format!("{}: {method}", step.id),
4868            ));
4869        }
4870        if step.kind == "http_json" && method != "GET" && !has_idempotency_key(step) {
4871            diagnostics.push(error_with_actual(
4872                "setup.machine.step.idempotency_key",
4873                "side-effecting setup steps must declare idempotency_key",
4874                &step.id,
4875            ));
4876        }
4877        if let Some(status) = step
4878            .extra
4879            .get("expected_status")
4880            .or_else(|| step.extra.get("status"))
4881            .and_then(Value::as_u64)
4882            && !(100..=599).contains(&status)
4883        {
4884            diagnostics.push(error_with_actual(
4885                "setup.machine.http.expected_status",
4886                "setup HTTP expected_status must be a valid HTTP status code",
4887                &format!("{}: {status}", step.id),
4888            ));
4889        }
4890    }
4891    diagnostics
4892}
4893
4894fn validate_tunnel_steps(machine: &SetupMachine) -> Vec<Diagnostic> {
4895    let mut diagnostics = Vec::new();
4896    for step in &machine.steps {
4897        if step.kind != "cloudflare_tunnel" {
4898            continue;
4899        }
4900        if let Some(mode) = step.extra.get("mode").and_then(Value::as_str)
4901            && !matches!(mode, "cloudflared" | "cloudflare" | "external")
4902        {
4903            diagnostics.push(error_with_actual(
4904                "setup.machine.cloudflare_tunnel.mode",
4905                "cloudflare_tunnel mode must be cloudflared, cloudflare, or external",
4906                &format!("{}: {}", step.id, mode),
4907            ));
4908        }
4909        if let Some(url) = step
4910            .extra
4911            .get("public_base_url")
4912            .or_else(|| step.extra.get("default"))
4913            .or_else(|| step.extra.get("default_url"))
4914            .and_then(Value::as_str)
4915            .map(str::trim)
4916            .filter(|value| !value.is_empty())
4917            && !url.contains('{')
4918        {
4919            match url::Url::parse(url) {
4920                Ok(parsed) if parsed.scheme() == "https" && parsed.host_str().is_some() => {}
4921                Ok(_) | Err(_) => diagnostics.push(error_with_actual(
4922                    "setup.machine.cloudflare_tunnel.public_base_url",
4923                    "cloudflare_tunnel declared public URL must be https with a host",
4924                    &format!("{}: {}", step.id, url),
4925                )),
4926            }
4927        }
4928    }
4929    diagnostics
4930}
4931
4932fn validate_oauth_steps(machine: &SetupMachine) -> Vec<Diagnostic> {
4933    let mut diagnostics = Vec::new();
4934    for step in &machine.steps {
4935        match step.kind.as_str() {
4936            "oauth_device_code" => validate_oauth_device_step(&mut diagnostics, step),
4937            "oauth_authorization_code" => validate_oauth_authorization_step(&mut diagnostics, step),
4938            _ => {}
4939        }
4940    }
4941    diagnostics
4942}
4943
4944fn validate_oauth_device_step(diagnostics: &mut Vec<Diagnostic>, step: &SetupMachineStep) {
4945    let has_device_url = step
4946        .extra
4947        .get("device_code_url")
4948        .or_else(|| step.extra.get("device_code_url_template"))
4949        .and_then(Value::as_str)
4950        .map(str::trim)
4951        .filter(|value| !value.is_empty())
4952        .is_some();
4953    let has_token_url = step
4954        .extra
4955        .get("token_url")
4956        .or_else(|| step.extra.get("token_url_template"))
4957        .and_then(Value::as_str)
4958        .map(str::trim)
4959        .filter(|value| !value.is_empty())
4960        .is_some();
4961    let has_authority = step
4962        .extra
4963        .get("authority_url_template")
4964        .or_else(|| step.extra.get("authority_url"))
4965        .and_then(Value::as_str)
4966        .map(str::trim)
4967        .filter(|value| !value.is_empty())
4968        .is_some();
4969    if !has_authority && (!has_device_url || !has_token_url) {
4970        diagnostics.push(error_with_actual(
4971            "setup.machine.oauth_device_code.endpoints",
4972            "oauth_device_code steps must declare authority_url_template or both device_code_url and token_url",
4973            &step.id,
4974        ));
4975    }
4976    for key in ["device_code_url", "token_url", "authority_url"] {
4977        if let Some(url) = step
4978            .extra
4979            .get(key)
4980            .or_else(|| step.extra.get(&format!("{key}_template")))
4981            .and_then(Value::as_str)
4982            .map(str::trim)
4983            .filter(|value| !value.is_empty())
4984            && !url.contains('{')
4985            && let Err(err) = validate_http_step_url(url)
4986        {
4987            diagnostics.push(error_with_actual(
4988                "setup.machine.oauth_device_code.url.safe",
4989                "oauth_device_code URLs must use https or loopback http",
4990                &format!("{}: {url} ({err})", step.id),
4991            ));
4992        }
4993    }
4994    let has_client_id = step
4995        .extra
4996        .get("client_id")
4997        .or_else(|| step.extra.get("client_id_output"))
4998        .and_then(Value::as_str)
4999        .map(str::trim)
5000        .filter(|value| !value.is_empty())
5001        .is_some();
5002    if !has_client_id {
5003        diagnostics.push(error_with_actual(
5004            "setup.machine.oauth_device_code.client_id",
5005            "oauth_device_code steps must declare client_id or client_id_output",
5006            &step.id,
5007        ));
5008    }
5009    if step.extra.get("scope").is_none() && step.extra.get("scopes").is_none() {
5010        diagnostics.push(error_with_actual(
5011            "setup.machine.oauth_device_code.scopes",
5012            "oauth_device_code steps must declare scope or scopes",
5013            &step.id,
5014        ));
5015    }
5016    if let Some(scopes) = step.extra.get("scopes")
5017        && !scopes.is_array()
5018    {
5019        diagnostics.push(error_with_actual(
5020            "setup.machine.oauth_device_code.scopes",
5021            "oauth_device_code scopes must be an array",
5022            &step.id,
5023        ));
5024    }
5025}
5026
5027fn validate_oauth_authorization_step(diagnostics: &mut Vec<Diagnostic>, step: &SetupMachineStep) {
5028    let authorize_url = step
5029        .extra
5030        .get("authorize_url")
5031        .or_else(|| step.extra.get("authorization_url"))
5032        .or_else(|| step.extra.get("auth_url"))
5033        .and_then(Value::as_str)
5034        .map(str::trim)
5035        .filter(|value| !value.is_empty());
5036    let Some(authorize_url) = authorize_url else {
5037        diagnostics.push(error_with_actual(
5038            "setup.machine.oauth_authorization_code.authorize_url",
5039            "oauth_authorization_code steps must declare authorize_url",
5040            &step.id,
5041        ));
5042        return;
5043    };
5044    if !authorize_url.contains('{') {
5045        match url::Url::parse(authorize_url) {
5046            Ok(parsed) if parsed.scheme() == "https" && parsed.host_str().is_some() => {}
5047            Ok(_) | Err(_) => diagnostics.push(error_with_actual(
5048                "setup.machine.oauth_authorization_code.authorize_url.safe",
5049                "oauth_authorization_code authorize_url must be https with a host",
5050                &format!("{}: {}", step.id, authorize_url),
5051            )),
5052        }
5053    }
5054    let token_url = step
5055        .extra
5056        .get("token_url")
5057        .or_else(|| step.extra.get("token_url_template"))
5058        .and_then(Value::as_str)
5059        .map(str::trim)
5060        .filter(|value| !value.is_empty());
5061    match token_url {
5062        Some(token_url) if !token_url.contains('{') => match url::Url::parse(token_url) {
5063            Ok(parsed)
5064                if (parsed.host_str().is_some() && parsed.scheme() == "https")
5065                    || (parsed.scheme() == "http"
5066                        && matches!(
5067                            parsed.host_str(),
5068                            Some("127.0.0.1" | "localhost" | "::1")
5069                        )) => {}
5070            Ok(_) | Err(_) => diagnostics.push(error_with_actual(
5071                "setup.machine.oauth_authorization_code.token_url.safe",
5072                "oauth_authorization_code token_url must be https or loopback http",
5073                &format!("{}: {}", step.id, token_url),
5074            )),
5075        },
5076        Some(_) => {}
5077        None => diagnostics.push(error_with_actual(
5078            "setup.machine.oauth_authorization_code.token_url",
5079            "oauth_authorization_code steps must declare token_url",
5080            &step.id,
5081        )),
5082    }
5083    if let Some(scopes) = step.extra.get("scopes")
5084        && !scopes.is_array()
5085    {
5086        diagnostics.push(error_with_actual(
5087            "setup.machine.oauth_authorization_code.scopes",
5088            "oauth_authorization_code scopes must be an array",
5089            &step.id,
5090        ));
5091    }
5092    if let Some(params) = step.extra.get("params")
5093        && !params.is_object()
5094    {
5095        diagnostics.push(error_with_actual(
5096            "setup.machine.oauth_authorization_code.params",
5097            "oauth_authorization_code params must be a JSON object",
5098            &step.id,
5099        ));
5100    }
5101    if let Some(ttl) = step.extra.get("state_ttl_secs").and_then(Value::as_u64)
5102        && ttl == 0
5103    {
5104        diagnostics.push(error_with_actual(
5105            "setup.machine.oauth_authorization_code.state_ttl_secs",
5106            "oauth_authorization_code state_ttl_secs must be greater than zero",
5107            &step.id,
5108        ));
5109    }
5110}
5111
5112fn validate_data_mapping_steps(machine: &SetupMachine) -> Vec<Diagnostic> {
5113    let mut diagnostics = Vec::new();
5114    for step in &machine.steps {
5115        match step.kind.as_str() {
5116            "select_from_json" => {
5117                if step
5118                    .extra
5119                    .get("source_output")
5120                    .or_else(|| step.extra.get("source"))
5121                    .and_then(Value::as_str)
5122                    .map(str::trim)
5123                    .filter(|value| !value.is_empty())
5124                    .is_none()
5125                {
5126                    diagnostics.push(error_with_actual(
5127                        "setup.machine.select_from_json.source_output",
5128                        "select_from_json steps must declare source_output",
5129                        &step.id,
5130                    ));
5131                }
5132                if let Some(pointer) = step.extra.get("json_pointer").and_then(Value::as_str)
5133                    && !pointer.starts_with('/')
5134                {
5135                    diagnostics.push(error_with_actual(
5136                        "setup.machine.select_from_json.json_pointer",
5137                        "select_from_json json_pointer must use JSON Pointer syntax",
5138                        &format!("{}: {}", step.id, pointer),
5139                    ));
5140                }
5141            }
5142            "persist_runtime_config" => {
5143                let config = step.extra.get("config");
5144                let mappings = step.extra.get("mappings");
5145                if config.is_none() && mappings.is_none() {
5146                    diagnostics.push(error_with_actual(
5147                        "setup.machine.persist_runtime_config.input",
5148                        "persist_runtime_config steps must declare config or mappings",
5149                        &step.id,
5150                    ));
5151                }
5152                for (name, value) in [("config", config), ("mappings", mappings)] {
5153                    if let Some(value) = value
5154                        && !value.is_object()
5155                    {
5156                        diagnostics.push(error_with_actual(
5157                            "setup.machine.persist_runtime_config.object",
5158                            "persist_runtime_config config and mappings must be JSON objects",
5159                            &format!("{}: {}", step.id, name),
5160                        ));
5161                    }
5162                }
5163                if let Some(server_owned) = step.extra.get("server_owned_config_keys")
5164                    && !server_owned.is_array()
5165                {
5166                    diagnostics.push(error_with_actual(
5167                        "setup.machine.persist_runtime_config.server_owned_config_keys",
5168                        "persist_runtime_config server_owned_config_keys must be an array",
5169                        &step.id,
5170                    ));
5171                }
5172                let server_owned = setup_machine_server_owned_keys(machine, step);
5173                for (name, value) in [("config", config), ("mappings", mappings)] {
5174                    if let Some(object) = value.and_then(Value::as_object) {
5175                        for key in object.keys() {
5176                            if server_owned.contains(key) {
5177                                diagnostics.push(error_with_actual(
5178                                    "setup.machine.persist_runtime_config.server_owned_output",
5179                                    "persist_runtime_config config and mappings must not write server-owned keys",
5180                                    &format!("{}: {}.{}", step.id, name, key),
5181                                ));
5182                            }
5183                        }
5184                    }
5185                }
5186            }
5187            "provider_component_call" => {
5188                if !has_idempotency_key(step) {
5189                    diagnostics.push(error_with_actual(
5190                        "setup.machine.step.idempotency_key",
5191                        "side-effecting setup steps must declare idempotency_key",
5192                        &step.id,
5193                    ));
5194                }
5195                if step
5196                    .extra
5197                    .get("component_ref")
5198                    .or_else(|| step.extra.get("component"))
5199                    .and_then(Value::as_str)
5200                    .map(str::trim)
5201                    .filter(|value| !value.is_empty())
5202                    .is_none()
5203                {
5204                    diagnostics.push(error_with_actual(
5205                        "setup.machine.provider_component_call.component_ref",
5206                        "provider_component_call steps must declare component_ref",
5207                        &step.id,
5208                    ));
5209                }
5210                if step
5211                    .extra
5212                    .get("op")
5213                    .or_else(|| step.extra.get("operation"))
5214                    .and_then(Value::as_str)
5215                    .map(str::trim)
5216                    .filter(|value| !value.is_empty())
5217                    .is_none()
5218                {
5219                    diagnostics.push(error_with_actual(
5220                        "setup.machine.provider_component_call.op",
5221                        "provider_component_call steps must declare op",
5222                        &step.id,
5223                    ));
5224                }
5225                if let Some(request) = step.extra.get("request").or_else(|| step.extra.get("body"))
5226                    && !request.is_object()
5227                {
5228                    diagnostics.push(error_with_actual(
5229                        "setup.machine.provider_component_call.request",
5230                        "provider_component_call request/body must be a JSON object",
5231                        &step.id,
5232                    ));
5233                }
5234            }
5235            _ => {}
5236        }
5237    }
5238    diagnostics
5239}
5240
5241fn validate_setup_machine_template_inputs(
5242    pack_path: &Path,
5243    provider_id: Option<&str>,
5244    machine: &SetupMachine,
5245) -> Vec<Diagnostic> {
5246    let mut available = BTreeSet::from([
5247        "tenant".to_string(),
5248        "team".to_string(),
5249        "provider_id".to_string(),
5250    ]);
5251
5252    if let Some(provider_id) = provider_id
5253        && let Some(form_spec) = crate::setup_to_formspec::pack_to_form_spec(pack_path, provider_id)
5254    {
5255        for question in form_spec.questions {
5256            if !question.id.trim().is_empty() {
5257                available.insert(question.id);
5258            }
5259        }
5260    }
5261
5262    for step in &machine.steps {
5263        available.extend(setup_machine_step_output_keys(step));
5264    }
5265
5266    let mut diagnostics = Vec::new();
5267    for step in &machine.steps {
5268        for (path, key) in setup_machine_step_template_references(step) {
5269            if !available.contains(&key) {
5270                diagnostics.push(error_with_actual(
5271                    "setup.machine.template.input",
5272                    "setup-machine template placeholder must reference a built-in value, setup answer, or declared setup output",
5273                    &format!("{}: {} -> {}", step.id, path, key),
5274                ));
5275            }
5276        }
5277    }
5278    diagnostics
5279}
5280
5281fn setup_machine_step_template_references(step: &SetupMachineStep) -> Vec<(String, String)> {
5282    let mut references = Vec::new();
5283    for (key, value) in &step.extra {
5284        collect_template_references(value, key, &mut references);
5285    }
5286    references
5287}
5288
5289fn collect_template_references(value: &Value, path: &str, references: &mut Vec<(String, String)>) {
5290    match value {
5291        Value::String(text) => {
5292            for key in template_placeholders(text) {
5293                references.push((path.to_string(), key));
5294            }
5295        }
5296        Value::Array(values) => {
5297            for (idx, value) in values.iter().enumerate() {
5298                collect_template_references(value, &format!("{path}[{idx}]"), references);
5299            }
5300        }
5301        Value::Object(map) => {
5302            for (key, value) in map {
5303                collect_template_references(value, &format!("{path}.{key}"), references);
5304            }
5305        }
5306        _ => {}
5307    }
5308}
5309
5310fn template_placeholders(text: &str) -> Vec<String> {
5311    let mut keys = Vec::new();
5312    let mut start = 0;
5313    while let Some(open_rel) = text[start..].find('{') {
5314        let open = start + open_rel;
5315        let Some(close_rel) = text[open + 1..].find('}') else {
5316            break;
5317        };
5318        let close = open + 1 + close_rel;
5319        let key = text[open + 1..close].trim();
5320        if !key.is_empty() {
5321            keys.push(key.to_string());
5322        }
5323        start = close + 1;
5324    }
5325    keys
5326}
5327
5328fn setup_machine_step_output_keys(step: &SetupMachineStep) -> BTreeSet<String> {
5329    let mut keys = BTreeSet::new();
5330    for output in &step.outputs {
5331        match output {
5332            Value::String(key) if !key.trim().is_empty() => {
5333                keys.insert(key.trim().to_string());
5334            }
5335            Value::Object(object) => {
5336                for field in ["key", "name", "id"] {
5337                    if let Some(key) = object
5338                        .get(field)
5339                        .and_then(Value::as_str)
5340                        .map(str::trim)
5341                        .filter(|value| !value.is_empty())
5342                    {
5343                        keys.insert(key.to_string());
5344                        break;
5345                    }
5346                }
5347            }
5348            _ => {}
5349        }
5350    }
5351
5352    let output_key = step
5353        .extra
5354        .get("output_key")
5355        .and_then(Value::as_str)
5356        .map(str::trim)
5357        .filter(|value| !value.is_empty());
5358    match step.kind.as_str() {
5359        "platform_public_url" | "cloudflare_tunnel" => {
5360            keys.insert(output_key.unwrap_or("public_base_url").to_string());
5361        }
5362        "oauth_device_code" => {
5363            keys.insert(output_key.unwrap_or("oauth_device_login").to_string());
5364        }
5365        "oauth_authorization_code" => {
5366            keys.insert(output_key.unwrap_or("authorization_url").to_string());
5367        }
5368        "http_json" | "select_from_json" | "generate_file" | "provider_component_call" => {
5369            let key = output_key.unwrap_or(step.id.as_str()).to_string();
5370            if step.kind == "select_from_json" {
5371                keys.insert(format!("{key}_item"));
5372            }
5373            keys.insert(key);
5374        }
5375        "persist_runtime_config" => {
5376            keys.insert(output_key.unwrap_or("runtime_config_path").to_string());
5377        }
5378        _ => {}
5379    }
5380    keys
5381}
5382
5383fn has_idempotency_key(step: &SetupMachineStep) -> bool {
5384    step.extra
5385        .get("idempotency_key")
5386        .and_then(Value::as_str)
5387        .map(str::trim)
5388        .is_some_and(|value| !value.is_empty())
5389}
5390
5391fn validate_setup_i18n_message_keys(pack_path: &Path, machine: &SetupMachine) -> Vec<Diagnostic> {
5392    let extension = match discovery::read_pack_extension(pack_path, SETUP_I18N_EXTENSION) {
5393        Ok(Some(extension)) => extension,
5394        Ok(None) => return Vec::new(),
5395        Err(err) => {
5396            return vec![load_error(
5397                "setup.i18n.load",
5398                "setup i18n descriptor could not be loaded",
5399                SETUP_I18N_EXTENSION,
5400                err,
5401                pack_path,
5402            )];
5403        }
5404    };
5405    let mut diagnostics = Vec::new();
5406    let declared_keys = match collect_setup_i18n_keys(pack_path, &extension) {
5407        Ok(keys) => keys,
5408        Err(err) => {
5409            diagnostics.push(load_error(
5410                "setup.i18n.messages.load",
5411                "setup i18n messages could not be loaded",
5412                "valid setup i18n JSON",
5413                err,
5414                pack_path,
5415            ));
5416            return diagnostics;
5417        }
5418    };
5419    if declared_keys.is_empty() {
5420        diagnostics.push(Diagnostic {
5421            check_id: "setup.i18n.messages.present".to_string(),
5422            severity: DiagnosticSeverity::Error,
5423            component: "provider_setup".to_string(),
5424            message: "setup i18n extension does not declare any message keys".to_string(),
5425            evidence: Some(SETUP_I18N_EXTENSION.to_string()),
5426            expected: Some("inline messages or message asset with at least one key".to_string()),
5427            actual: Some("empty".to_string()),
5428            fix_hint: Some(
5429                "add localized setup messages or remove the setup i18n extension".to_string(),
5430            ),
5431            related_file: Some(pack_path.display().to_string()),
5432            related_pack: Some(machine.id.clone()),
5433            related_component: None,
5434        });
5435        return diagnostics;
5436    }
5437
5438    for key in collect_setup_message_key_references(machine) {
5439        if !declared_keys.contains(&key) {
5440            diagnostics.push(error_with_actual(
5441                "setup.i18n.message_key.exists",
5442                "setup message_key must exist in greentic.setup.i18n.v1 messages",
5443                &key,
5444            ));
5445        }
5446    }
5447    diagnostics
5448}
5449
5450fn validate_setup_machine_fixtures(pack_path: &Path, machine: &SetupMachine) -> Vec<Diagnostic> {
5451    let mut diagnostics = Vec::new();
5452    let fixture_paths = setup_machine_fixture_paths(machine);
5453    if fixture_paths.is_empty() {
5454        return diagnostics;
5455    }
5456
5457    let step_ids = machine
5458        .steps
5459        .iter()
5460        .map(|step| step.id.as_str())
5461        .collect::<BTreeSet<_>>();
5462    for path in fixture_paths {
5463        let value = match discovery::read_pack_json_asset(pack_path, &path) {
5464            Ok(value) => value,
5465            Err(err) => {
5466                diagnostics.push(load_error(
5467                    "setup.machine.fixture.load",
5468                    "setup-machine fixture could not be loaded",
5469                    "JSON fixture asset inside the provider pack",
5470                    err,
5471                    pack_path,
5472                ));
5473                continue;
5474            }
5475        };
5476        let state_value = value.get("state").cloned().unwrap_or(value);
5477        let state: SetupMachineState = match serde_json::from_value(state_value) {
5478            Ok(state) => state,
5479            Err(err) => {
5480                diagnostics.push(error_with_actual(
5481                    "setup.machine.fixture.state",
5482                    "setup-machine fixture must contain a valid setup-machine state",
5483                    &format!("{path}: {err}"),
5484                ));
5485                continue;
5486            }
5487        };
5488        if state.machine_id != machine.id {
5489            diagnostics.push(error_with_actual(
5490                "setup.machine.fixture.machine_id",
5491                "setup-machine fixture machine_id must match the current machine",
5492                &format!("{}: {}", path, state.machine_id),
5493            ));
5494        }
5495        if state.machine_version != machine.version {
5496            diagnostics.push(error_with_actual(
5497                "setup.machine.fixture.machine_version",
5498                "setup-machine fixture machine_version must match the current machine version",
5499                &format!("{}: {}", path, state.machine_version),
5500            ));
5501        }
5502        if let Some(current_step) = state.current_step.as_deref()
5503            && !step_ids.contains(current_step)
5504        {
5505            diagnostics.push(error_with_actual(
5506                "setup.machine.fixture.current_step",
5507                "setup-machine fixture current_step must reference a declared step",
5508                &format!("{path}: {current_step}"),
5509            ));
5510        }
5511        if let Some(failed_step) = state.failed_step.as_deref()
5512            && !step_ids.contains(failed_step)
5513        {
5514            diagnostics.push(error_with_actual(
5515                "setup.machine.fixture.failed_step",
5516                "setup-machine fixture failed_step must reference a declared step",
5517                &format!("{path}: {failed_step}"),
5518            ));
5519        }
5520        for completed_step in &state.completed_steps {
5521            if !step_ids.contains(completed_step.as_str()) {
5522                diagnostics.push(error_with_actual(
5523                    "setup.machine.fixture.completed_step",
5524                    "setup-machine fixture completed_steps must reference declared steps",
5525                    &format!("{path}: {completed_step}"),
5526                ));
5527            }
5528        }
5529    }
5530
5531    diagnostics
5532}
5533
5534fn setup_machine_fixture_paths(machine: &SetupMachine) -> Vec<String> {
5535    let mut paths = Vec::new();
5536    if let Some(fixtures) = machine.extra.get("fixtures").and_then(Value::as_array) {
5537        for fixture in fixtures {
5538            if let Some(path) = fixture.as_str() {
5539                paths.push(path.to_string());
5540            } else if let Some(path) = fixture.get("path").and_then(Value::as_str) {
5541                paths.push(path.to_string());
5542            }
5543        }
5544    }
5545    if let Some(fixture_paths) = machine.extra.get("fixture_paths").and_then(Value::as_array) {
5546        for path in fixture_paths.iter().filter_map(Value::as_str) {
5547            paths.push(path.to_string());
5548        }
5549    }
5550    paths
5551        .into_iter()
5552        .map(|path| path.trim().to_string())
5553        .filter(|path| !path.is_empty())
5554        .collect()
5555}
5556
5557fn collect_setup_i18n_keys(
5558    pack_path: &Path,
5559    extension: &Value,
5560) -> anyhow::Result<BTreeSet<String>> {
5561    let descriptor = extension_inline(extension).unwrap_or(extension);
5562    let mut keys = BTreeSet::new();
5563    collect_message_keys_from_value(&mut keys, descriptor);
5564
5565    for key in ["asset", "path", "messages_asset", "messages_path"] {
5566        if let Some(path) = descriptor.get(key).and_then(Value::as_str) {
5567            let value = discovery::read_pack_json_asset(pack_path, path)?;
5568            collect_message_keys_from_value(&mut keys, &value);
5569        }
5570    }
5571
5572    for key in ["assets", "paths", "locales"] {
5573        if let Some(map) = descriptor.get(key).and_then(Value::as_object) {
5574            for value in map.values() {
5575                if let Some(path) = value.as_str() {
5576                    let messages = discovery::read_pack_json_asset(pack_path, path)?;
5577                    collect_message_keys_from_value(&mut keys, &messages);
5578                } else {
5579                    collect_message_keys_from_value(&mut keys, value);
5580                }
5581            }
5582        }
5583    }
5584
5585    Ok(keys)
5586}
5587
5588fn collect_message_keys_from_value(keys: &mut BTreeSet<String>, value: &Value) {
5589    if let Some(messages) = value.get("messages").and_then(Value::as_object) {
5590        collect_message_keys_from_object(keys, messages);
5591    }
5592    if let Some(translations) = value.get("translations").and_then(Value::as_object) {
5593        collect_message_keys_from_object(keys, translations);
5594    }
5595    if let Some(obj) = value.as_object() {
5596        collect_message_keys_from_object(keys, obj);
5597    }
5598}
5599
5600fn collect_message_keys_from_object(
5601    keys: &mut BTreeSet<String>,
5602    obj: &serde_json::Map<String, Value>,
5603) {
5604    for (key, value) in obj {
5605        if [
5606            "schema_id",
5607            "provider_id",
5608            "default_locale",
5609            "asset",
5610            "path",
5611            "messages",
5612            "translations",
5613            "assets",
5614            "paths",
5615            "locales",
5616        ]
5617        .contains(&key.as_str())
5618        {
5619            continue;
5620        }
5621        match value {
5622            Value::String(_) => {
5623                keys.insert(key.clone());
5624            }
5625            Value::Object(map) if map.values().all(|candidate| candidate.as_str().is_some()) => {
5626                keys.insert(key.clone());
5627            }
5628            Value::Object(_) => collect_message_keys_from_value(keys, value),
5629            _ => {}
5630        }
5631    }
5632}
5633
5634fn collect_setup_message_key_references(machine: &SetupMachine) -> BTreeSet<String> {
5635    let mut keys = BTreeSet::new();
5636    for step in &machine.steps {
5637        collect_message_key_from_value(&mut keys, &Value::Object(step.extra.clone()));
5638        for rule in step.on_failure.iter().chain(step.recover.iter()) {
5639            collect_message_key_from_value(&mut keys, rule);
5640        }
5641    }
5642    keys
5643}
5644
5645fn collect_message_key_from_value(keys: &mut BTreeSet<String>, value: &Value) {
5646    match value {
5647        Value::Object(map) => {
5648            for (key, nested) in map {
5649                if (key == "message_key" || key == "title_key" || key == "description_key")
5650                    && let Some(message_key) = nested.as_str()
5651                    && !message_key.trim().is_empty()
5652                {
5653                    keys.insert(message_key.to_string());
5654                }
5655                collect_message_key_from_value(keys, nested);
5656            }
5657        }
5658        Value::Array(values) => {
5659            for nested in values {
5660                collect_message_key_from_value(keys, nested);
5661            }
5662        }
5663        _ => {}
5664    }
5665}
5666
5667fn detect_cycle(
5668    step_id: &str,
5669    edges: &BTreeMap<String, Vec<String>>,
5670    steps: &BTreeMap<&str, &SetupMachineStep>,
5671    visited: &mut BTreeSet<String>,
5672    stack: &mut Vec<String>,
5673    reported: &mut BTreeSet<String>,
5674    diagnostics: &mut Vec<Diagnostic>,
5675) {
5676    if let Some(position) = stack.iter().position(|candidate| candidate == step_id) {
5677        let cycle = stack[position..].to_vec();
5678        let key = cycle.join(" -> ");
5679        if reported.insert(key.clone())
5680            && !cycle.iter().any(|id| {
5681                steps.get(id.as_str()).is_some_and(|step| {
5682                    step.extra.get("retry").is_some() || step.extra.get("timeout").is_some()
5683                })
5684            })
5685        {
5686            diagnostics.push(error_with_actual(
5687                "setup.machine.step.cycle",
5688                "setup step cycle must declare bounded retry or timeout policy",
5689                &key,
5690            ));
5691        }
5692        return;
5693    }
5694    if !visited.insert(step_id.to_string()) {
5695        return;
5696    }
5697    stack.push(step_id.to_string());
5698    for target in edges.get(step_id).into_iter().flatten() {
5699        detect_cycle(target, edges, steps, visited, stack, reported, diagnostics);
5700    }
5701    stack.pop();
5702}
5703
5704fn error(check_id: &str, message: &str) -> Diagnostic {
5705    Diagnostic {
5706        check_id: check_id.to_string(),
5707        severity: DiagnosticSeverity::Error,
5708        component: "provider_setup".to_string(),
5709        message: message.to_string(),
5710        evidence: None,
5711        expected: None,
5712        actual: None,
5713        fix_hint: None,
5714        related_file: None,
5715        related_pack: None,
5716        related_component: None,
5717    }
5718}
5719
5720fn error_with_actual(check_id: &str, message: &str, actual: &str) -> Diagnostic {
5721    Diagnostic {
5722        check_id: check_id.to_string(),
5723        severity: DiagnosticSeverity::Error,
5724        component: "provider_setup".to_string(),
5725        message: message.to_string(),
5726        evidence: None,
5727        expected: None,
5728        actual: Some(actual.to_string()),
5729        fix_hint: None,
5730        related_file: None,
5731        related_pack: None,
5732        related_component: None,
5733    }
5734}
5735
5736fn info(
5737    check_id: &str,
5738    message: &str,
5739    evidence: &str,
5740    pack_path: &Path,
5741    related_pack: Option<&str>,
5742) -> Diagnostic {
5743    Diagnostic {
5744        check_id: check_id.to_string(),
5745        severity: DiagnosticSeverity::Info,
5746        component: "provider_setup".to_string(),
5747        message: message.to_string(),
5748        evidence: Some(evidence.to_string()),
5749        expected: None,
5750        actual: None,
5751        fix_hint: None,
5752        related_file: Some(pack_path.display().to_string()),
5753        related_pack: related_pack.map(str::to_string),
5754        related_component: None,
5755    }
5756}
5757
5758fn load_error(
5759    check_id: &str,
5760    message: &str,
5761    expected: &str,
5762    err: anyhow::Error,
5763    pack_path: &Path,
5764) -> Diagnostic {
5765    Diagnostic {
5766        check_id: check_id.to_string(),
5767        severity: DiagnosticSeverity::Error,
5768        component: "provider_setup".to_string(),
5769        message: message.to_string(),
5770        evidence: Some(err.to_string()),
5771        expected: Some(expected.to_string()),
5772        actual: None,
5773        fix_hint: None,
5774        related_file: Some(pack_path.display().to_string()),
5775        related_pack: None,
5776        related_component: None,
5777    }
5778}
5779
5780fn report(pack_path: &Path, diagnostics: Vec<Diagnostic>) -> DoctorReport {
5781    let error_count = diagnostics
5782        .iter()
5783        .filter(|d| d.severity == DiagnosticSeverity::Error)
5784        .count();
5785    let warn_count = diagnostics
5786        .iter()
5787        .filter(|d| d.severity == DiagnosticSeverity::Warn)
5788        .count();
5789    let info_count = diagnostics
5790        .iter()
5791        .filter(|d| d.severity == DiagnosticSeverity::Info)
5792        .count();
5793    DoctorReport {
5794        bundle: pack_path.display().to_string(),
5795        status: if error_count > 0 {
5796            "error".to_string()
5797        } else if warn_count > 0 {
5798            "warn".to_string()
5799        } else {
5800            "ok".to_string()
5801        },
5802        error_count,
5803        warn_count,
5804        info_count,
5805        diagnostics,
5806    }
5807}
5808
5809#[cfg(test)]
5810mod tests {
5811    use super::*;
5812    use greentic_secrets_lib::SecretsStore;
5813    use std::io::Read;
5814    use std::io::Write;
5815    use zip::write::{FileOptions, ZipWriter};
5816
5817    fn valid_machine() -> SetupMachine {
5818        serde_json::from_value(serde_json::json!({
5819            "version": 1,
5820            "id": "messaging-teams-default",
5821            "entry_step": "collect",
5822            "steps": [
5823                {
5824                    "id": "collect",
5825                    "kind": "qa_form",
5826                    "on_success": "login"
5827                },
5828                {
5829                    "id": "login",
5830                    "kind": "oauth_device_code",
5831                    "device_code_url": "https://login.example.com/device",
5832                    "token_url": "https://login.example.com/token",
5833                    "client_id": "client-123",
5834                    "scopes": ["User.Read"],
5835                    "recover": [
5836                        {"when": "refresh_token_invalid", "action": "reauthorize"}
5837                    ],
5838                    "on_success": "complete"
5839                }
5840            ]
5841        }))
5842        .unwrap()
5843    }
5844
5845    fn serve_one_json(status: u16, body: &'static str) -> String {
5846        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
5847        let addr = listener.local_addr().unwrap();
5848        std::thread::spawn(move || {
5849            let (mut stream, _) = listener.accept().unwrap();
5850            let mut buffer = [0_u8; 2048];
5851            let _ = stream.read(&mut buffer);
5852            let wire = format!(
5853                "HTTP/1.1 {status} OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
5854                body.len(),
5855                body
5856            );
5857            stream.write_all(wire.as_bytes()).unwrap();
5858        });
5859        format!("http://{addr}")
5860    }
5861
5862    fn serve_json_sequence(responses: Vec<(u16, &'static str)>) -> String {
5863        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
5864        let addr = listener.local_addr().unwrap();
5865        std::thread::spawn(move || {
5866            for (status, body) in responses {
5867                let (mut stream, _) = listener.accept().unwrap();
5868                let mut buffer = [0_u8; 4096];
5869                let _ = stream.read(&mut buffer);
5870                let wire = format!(
5871                    "HTTP/1.1 {status} OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
5872                    body.len(),
5873                    body
5874                );
5875                stream.write_all(wire.as_bytes()).unwrap();
5876            }
5877        });
5878        format!("http://{addr}")
5879    }
5880
5881    #[test]
5882    fn validates_valid_machine() {
5883        let diagnostics = validate_setup_machine(&valid_machine());
5884        assert!(diagnostics.is_empty(), "{diagnostics:?}");
5885    }
5886
5887    #[test]
5888    fn rejects_missing_transition_target_and_unreachable_step() {
5889        let mut machine = valid_machine();
5890        machine.steps[0].on_success = Some("missing".to_string());
5891        machine.steps.push(SetupMachineStep {
5892            id: "never".to_string(),
5893            title: None,
5894            kind: "qa_form".to_string(),
5895            requires: vec![],
5896            outputs: vec![],
5897            on_success: None,
5898            on_failure: vec![],
5899            recover: vec![],
5900            extra: serde_json::Map::new(),
5901        });
5902
5903        let ids: BTreeSet<_> = validate_setup_machine(&machine)
5904            .into_iter()
5905            .map(|d| d.check_id)
5906            .collect();
5907        assert!(ids.contains("setup.machine.step.on_success"));
5908        assert!(ids.contains("setup.machine.step.reachable"));
5909    }
5910
5911    #[test]
5912    fn rejects_unbounded_setup_machine_cycles() {
5913        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
5914            "version": 1,
5915            "id": "cycle-machine",
5916            "entry_step": "a",
5917            "steps": [
5918                {"id": "a", "kind": "qa_form", "on_success": "b"},
5919                {"id": "b", "kind": "manual_action", "on_success": "a"}
5920            ]
5921        }))
5922        .unwrap();
5923
5924        let ids: BTreeSet<_> = validate_setup_machine(&machine)
5925            .into_iter()
5926            .map(|d| d.check_id)
5927            .collect();
5928        assert!(ids.contains("setup.machine.step.cycle"));
5929    }
5930
5931    #[test]
5932    fn allows_bounded_setup_machine_cycles() {
5933        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
5934            "version": 1,
5935            "id": "cycle-machine",
5936            "entry_step": "a",
5937            "steps": [
5938                {"id": "a", "kind": "qa_form", "on_success": "b"},
5939                {
5940                    "id": "b",
5941                    "kind": "manual_action",
5942                    "retry": {"max_attempts": 3},
5943                    "on_success": "a"
5944                }
5945            ]
5946        }))
5947        .unwrap();
5948
5949        let ids: BTreeSet<_> = validate_setup_machine(&machine)
5950            .into_iter()
5951            .map(|d| d.check_id)
5952            .collect();
5953        assert!(!ids.contains("setup.machine.step.cycle"));
5954    }
5955
5956    #[test]
5957    fn rejects_unsafe_setup_machine_artifact_paths() {
5958        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
5959            "version": 1,
5960            "id": "artifact-machine",
5961            "entry_step": "generate",
5962            "steps": [
5963                {"id": "generate", "kind": "generate_file", "artifact_path": "../secret.json"},
5964                {"id": "download", "kind": "download_file", "on_success": "complete"}
5965            ]
5966        }))
5967        .unwrap();
5968
5969        let ids: BTreeSet<_> = validate_setup_machine(&machine)
5970            .into_iter()
5971            .map(|d| d.check_id)
5972            .collect();
5973        assert!(ids.contains("setup.machine.artifact.path.safe"));
5974        assert!(ids.contains("setup.machine.artifact.path"));
5975    }
5976
5977    #[test]
5978    fn accepts_setup_machine_artifact_paths_under_artifacts() {
5979        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
5980            "version": 1,
5981            "id": "artifact-machine",
5982            "entry_step": "generate",
5983            "steps": [
5984                {
5985                    "id": "generate",
5986                    "kind": "generate_file",
5987                    "artifact_path": "artifacts/teams/app.zip",
5988                    "on_success": "download"
5989                },
5990                {
5991                    "id": "download",
5992                    "kind": "download_file",
5993                    "artifact_path": "artifacts/teams/app.zip",
5994                    "on_success": "complete"
5995                }
5996            ]
5997        }))
5998        .unwrap();
5999
6000        let ids: BTreeSet<_> = validate_setup_machine(&machine)
6001            .into_iter()
6002            .map(|d| d.check_id)
6003            .collect();
6004        assert!(!ids.contains("setup.machine.artifact.path"));
6005        assert!(!ids.contains("setup.machine.artifact.path.safe"));
6006    }
6007
6008    #[test]
6009    fn rejects_invalid_setup_machine_http_steps() {
6010        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
6011            "version": 1,
6012            "id": "http-machine",
6013            "entry_step": "missing_url",
6014            "steps": [
6015                {"id": "missing_url", "kind": "http_probe", "on_success": "bad_url"},
6016                {
6017                    "id": "bad_url",
6018                    "kind": "http_json",
6019                    "url_template": "http://example.com/setup",
6020                    "method": "PUT",
6021                    "expected_status": 999
6022                }
6023            ]
6024        }))
6025        .unwrap();
6026
6027        let ids: BTreeSet<_> = validate_setup_machine(&machine)
6028            .into_iter()
6029            .map(|d| d.check_id)
6030            .collect();
6031        assert!(ids.contains("setup.machine.http.url"));
6032        assert!(ids.contains("setup.machine.http.url.safe"));
6033        assert!(ids.contains("setup.machine.http.method"));
6034        assert!(ids.contains("setup.machine.http.expected_status"));
6035        assert!(ids.contains("setup.machine.step.idempotency_key"));
6036    }
6037
6038    #[test]
6039    fn rejects_invalid_setup_machine_tunnel_steps() {
6040        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
6041            "version": 1,
6042            "id": "tunnel-machine",
6043            "entry_step": "tunnel",
6044            "steps": [
6045                {
6046                    "id": "tunnel",
6047                    "kind": "cloudflare_tunnel",
6048                    "mode": "unsupported",
6049                    "public_base_url": "http://example.com"
6050                }
6051            ]
6052        }))
6053        .unwrap();
6054
6055        let ids: BTreeSet<_> = validate_setup_machine(&machine)
6056            .into_iter()
6057            .map(|d| d.check_id)
6058            .collect();
6059        assert!(ids.contains("setup.machine.cloudflare_tunnel.mode"));
6060        assert!(ids.contains("setup.machine.cloudflare_tunnel.public_base_url"));
6061    }
6062
6063    #[test]
6064    fn rejects_invalid_setup_machine_oauth_authorization_steps() {
6065        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
6066            "version": 1,
6067            "id": "oauth-machine",
6068            "entry_step": "oauth",
6069            "steps": [
6070                {
6071                    "id": "oauth",
6072                    "kind": "oauth_authorization_code",
6073                    "authorize_url": "http://login.example.com/auth",
6074                    "token_url": "http://login.example.com/token",
6075                    "scopes": "User.Read",
6076                    "params": ["bad"],
6077                    "state_ttl_secs": 0
6078                }
6079            ]
6080        }))
6081        .unwrap();
6082
6083        let ids: BTreeSet<_> = validate_setup_machine(&machine)
6084            .into_iter()
6085            .map(|d| d.check_id)
6086            .collect();
6087        assert!(ids.contains("setup.machine.oauth_authorization_code.authorize_url.safe"));
6088        assert!(ids.contains("setup.machine.oauth_authorization_code.token_url.safe"));
6089        assert!(ids.contains("setup.machine.oauth_authorization_code.scopes"));
6090        assert!(ids.contains("setup.machine.oauth_authorization_code.params"));
6091        assert!(ids.contains("setup.machine.oauth_authorization_code.state_ttl_secs"));
6092    }
6093
6094    #[test]
6095    fn rejects_invalid_setup_machine_oauth_device_steps() {
6096        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
6097            "version": 1,
6098            "id": "oauth-machine",
6099            "entry_step": "device",
6100            "steps": [
6101                {
6102                    "id": "device",
6103                    "kind": "oauth_device_code",
6104                    "device_code_url": "http://example.com/device",
6105                    "scopes": "User.Read"
6106                }
6107            ]
6108        }))
6109        .unwrap();
6110
6111        let ids: BTreeSet<_> = validate_setup_machine(&machine)
6112            .into_iter()
6113            .map(|d| d.check_id)
6114            .collect();
6115        assert!(ids.contains("setup.machine.oauth_device_code.endpoints"));
6116        assert!(ids.contains("setup.machine.oauth_device_code.url.safe"));
6117        assert!(ids.contains("setup.machine.oauth_device_code.client_id"));
6118        assert!(ids.contains("setup.machine.oauth_device_code.scopes"));
6119    }
6120
6121    #[test]
6122    fn rejects_invalid_setup_machine_data_mapping_steps() {
6123        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
6124            "version": 1,
6125            "id": "mapping-machine",
6126            "entry_step": "select",
6127            "steps": [
6128                {
6129                    "id": "select",
6130                    "kind": "select_from_json",
6131                    "json_pointer": "value",
6132                    "on_success": "persist"
6133                },
6134                {
6135                    "id": "persist",
6136                    "kind": "persist_runtime_config",
6137                    "config": ["not", "object"],
6138                    "server_owned_config_keys": "graph_access_token"
6139                },
6140                {
6141                    "id": "component",
6142                    "kind": "provider_component_call",
6143                    "request": ["not", "object"]
6144                }
6145            ]
6146        }))
6147        .unwrap();
6148
6149        let ids: BTreeSet<_> = validate_setup_machine(&machine)
6150            .into_iter()
6151            .map(|d| d.check_id)
6152            .collect();
6153        assert!(ids.contains("setup.machine.select_from_json.source_output"));
6154        assert!(ids.contains("setup.machine.select_from_json.json_pointer"));
6155        assert!(ids.contains("setup.machine.persist_runtime_config.object"));
6156        assert!(ids.contains("setup.machine.persist_runtime_config.server_owned_config_keys"));
6157        assert!(ids.contains("setup.machine.provider_component_call.component_ref"));
6158        assert!(ids.contains("setup.machine.provider_component_call.op"));
6159        assert!(ids.contains("setup.machine.provider_component_call.request"));
6160        assert!(ids.contains("setup.machine.step.idempotency_key"));
6161    }
6162
6163    #[test]
6164    fn accepts_valid_setup_machine_data_mapping_steps() {
6165        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
6166            "version": 1,
6167            "id": "mapping-machine",
6168            "entry_step": "select",
6169            "steps": [
6170                {
6171                    "id": "select",
6172                    "kind": "select_from_json",
6173                    "source_output": "teams",
6174                    "json_pointer": "/value",
6175                    "value_key": "id",
6176                    "on_success": "persist"
6177                },
6178                {
6179                    "id": "persist",
6180                    "kind": "persist_runtime_config",
6181                    "config": {"team_id": "{select}"},
6182                    "server_owned_config_keys": ["graph_access_token"],
6183                    "on_success": "component"
6184                },
6185                {
6186                    "id": "component",
6187                    "kind": "provider_component_call",
6188                    "component_ref": "messaging-example",
6189                    "op": "register",
6190                    "idempotency_key": "register:{select}",
6191                    "request": {"team_id": "{select}"}
6192                }
6193            ]
6194        }))
6195        .unwrap();
6196
6197        let ids: BTreeSet<_> = validate_setup_machine(&machine)
6198            .into_iter()
6199            .map(|d| d.check_id)
6200            .collect();
6201        assert!(!ids.contains("setup.machine.select_from_json.source_output"));
6202        assert!(!ids.contains("setup.machine.select_from_json.json_pointer"));
6203        assert!(!ids.contains("setup.machine.persist_runtime_config.input"));
6204        assert!(!ids.contains("setup.machine.persist_runtime_config.object"));
6205        assert!(!ids.contains("setup.machine.provider_component_call.component_ref"));
6206        assert!(!ids.contains("setup.machine.provider_component_call.op"));
6207        assert!(!ids.contains("setup.machine.provider_component_call.request"));
6208        assert!(!ids.contains("setup.machine.step.idempotency_key"));
6209    }
6210
6211    #[test]
6212    fn rejects_persist_runtime_config_writing_server_owned_keys() {
6213        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
6214            "version": 1,
6215            "id": "mapping-machine",
6216            "entry_step": "persist",
6217            "server_owned_config_keys": ["tenant_secret"],
6218            "steps": [
6219                {
6220                    "id": "persist",
6221                    "kind": "persist_runtime_config",
6222                    "config": {
6223                        "graph_access_token": "{token}",
6224                        "team_id": "{team}"
6225                    },
6226                    "mappings": {
6227                        "tenant_secret": "{secret}"
6228                    },
6229                    "server_owned_config_keys": ["graph_access_token"]
6230                }
6231            ]
6232        }))
6233        .unwrap();
6234
6235        let diagnostics = validate_setup_machine(&machine);
6236        let actual: BTreeSet<_> = diagnostics
6237            .iter()
6238            .filter(|diagnostic| {
6239                diagnostic.check_id == "setup.machine.persist_runtime_config.server_owned_output"
6240            })
6241            .filter_map(|diagnostic| diagnostic.actual.as_deref())
6242            .collect();
6243        assert!(actual.contains("persist: config.graph_access_token"));
6244        assert!(actual.contains("persist: mappings.tenant_secret"));
6245    }
6246
6247    #[test]
6248    fn accepts_templated_setup_machine_http_steps() {
6249        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
6250            "version": 1,
6251            "id": "http-machine",
6252            "entry_step": "probe",
6253            "steps": [
6254                {
6255                    "id": "probe",
6256                    "kind": "http_probe",
6257                    "url_template": "{public_base_url}/health",
6258                    "method": "GET",
6259                    "expected_status": 204,
6260                    "on_success": "register"
6261                },
6262                {
6263                    "id": "register",
6264                    "kind": "http_json",
6265                    "url_template": "{public_base_url}/register",
6266                    "method": "POST",
6267                    "expected_status": 200,
6268                    "idempotency_key": "register:{tenant}",
6269                    "on_success": "complete"
6270                }
6271            ]
6272        }))
6273        .unwrap();
6274
6275        let ids: BTreeSet<_> = validate_setup_machine(&machine)
6276            .into_iter()
6277            .map(|d| d.check_id)
6278            .collect();
6279        assert!(!ids.contains("setup.machine.http.url"));
6280        assert!(!ids.contains("setup.machine.http.url.safe"));
6281        assert!(!ids.contains("setup.machine.http.method"));
6282        assert!(!ids.contains("setup.machine.http.expected_status"));
6283        assert!(!ids.contains("setup.machine.step.idempotency_key"));
6284    }
6285
6286    fn write_setup_machine_pack(
6287        pack: &Path,
6288        provider_id: &str,
6289        machine: &Value,
6290        setup_yaml: Option<&str>,
6291    ) -> anyhow::Result<()> {
6292        let file = std::fs::File::create(pack)?;
6293        let mut writer = ZipWriter::new(file);
6294        let options: FileOptions<'_, ()> =
6295            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6296        writer.start_file("pack.manifest.json", options)?;
6297        writer.write_all(
6298            serde_json::json!({
6299                "pack_id": provider_id,
6300                "extensions": {
6301                    SETUP_MACHINE_EXTENSION: {
6302                        "path": "assets/setup/setup-machine.v1.json",
6303                        "schema": SETUP_MACHINE_SCHEMA_URL
6304                    }
6305                }
6306            })
6307            .to_string()
6308            .as_bytes(),
6309        )?;
6310        if let Some(setup_yaml) = setup_yaml {
6311            writer.start_file("assets/setup.yaml", options)?;
6312            writer.write_all(setup_yaml.as_bytes())?;
6313        }
6314        writer.start_file("assets/setup/setup-machine.v1.json", options)?;
6315        writer.write_all(serde_json::to_string(machine)?.as_bytes())?;
6316        writer.finish()?;
6317        Ok(())
6318    }
6319
6320    #[test]
6321    fn doctor_accepts_setup_machine_template_inputs_from_answers_and_outputs() -> anyhow::Result<()>
6322    {
6323        let temp = tempfile::tempdir()?;
6324        let pack = temp.path().join("messaging-example.gtpack");
6325        let machine = serde_json::json!({
6326            "version": 1,
6327            "id": "messaging-example-machine",
6328            "entry_step": "public_url",
6329            "steps": [
6330                {
6331                    "id": "public_url",
6332                    "kind": "platform_public_url",
6333                    "default": "https://example.test",
6334                    "on_success": "persist"
6335                },
6336                {
6337                    "id": "persist",
6338                    "kind": "persist_runtime_config",
6339                    "config": {
6340                        "bot_app_id": "{bot_app_id}",
6341                        "public_base_url": "{public_base_url}",
6342                        "tenant_id": "{tenant}",
6343                        "provider": "{provider_id}"
6344                    }
6345                }
6346            ]
6347        });
6348        write_setup_machine_pack(
6349            &pack,
6350            "messaging-example",
6351            &machine,
6352            Some("title: Example\nquestions:\n  - name: bot_app_id\n    required: true\n"),
6353        )?;
6354
6355        let report = run_provider_setup_machine_doctor(&pack);
6356        assert_eq!(report.status, "ok");
6357        assert!(
6358            !report
6359                .diagnostics
6360                .iter()
6361                .any(|diagnostic| diagnostic.check_id == "setup.machine.template.input")
6362        );
6363        Ok(())
6364    }
6365
6366    #[test]
6367    fn doctor_rejects_setup_machine_unknown_template_input() -> anyhow::Result<()> {
6368        let temp = tempfile::tempdir()?;
6369        let pack = temp.path().join("messaging-example.gtpack");
6370        let machine = serde_json::json!({
6371            "version": 1,
6372            "id": "messaging-example-machine",
6373            "entry_step": "persist",
6374            "steps": [
6375                {
6376                    "id": "persist",
6377                    "kind": "persist_runtime_config",
6378                    "config": {
6379                        "bot_app_id": "{bot_ap_id}"
6380                    }
6381                }
6382            ]
6383        });
6384        write_setup_machine_pack(
6385            &pack,
6386            "messaging-example",
6387            &machine,
6388            Some("title: Example\nquestions:\n  - name: bot_app_id\n    required: true\n"),
6389        )?;
6390
6391        let report = run_provider_setup_machine_doctor(&pack);
6392        assert_eq!(report.status, "error");
6393        assert!(report.diagnostics.iter().any(|diagnostic| {
6394            diagnostic.check_id == "setup.machine.template.input"
6395                && diagnostic
6396                    .actual
6397                    .as_deref()
6398                    .is_some_and(|actual| actual.contains("bot_ap_id"))
6399        }));
6400        Ok(())
6401    }
6402
6403    #[test]
6404    fn loads_machine_from_pack_asset_extension() -> anyhow::Result<()> {
6405        let temp = tempfile::tempdir()?;
6406        let pack = temp.path().join("messaging-teams.gtpack");
6407        let file = std::fs::File::create(&pack)?;
6408        let mut writer = ZipWriter::new(file);
6409        let options: FileOptions<'_, ()> =
6410            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6411        writer.start_file("pack.manifest.json", options)?;
6412        writer.write_all(
6413            serde_json::json!({
6414                "pack_id": "messaging-teams",
6415                "extensions": {
6416                    SETUP_MACHINE_EXTENSION: {
6417                        "path": "assets/setup/setup-machine.v1.json",
6418                        "schema": SETUP_MACHINE_SCHEMA_URL
6419                    }
6420                }
6421            })
6422            .to_string()
6423            .as_bytes(),
6424        )?;
6425        writer.start_file("assets/setup/setup-machine.v1.json", options)?;
6426        writer.write_all(serde_json::to_string(&valid_machine())?.as_bytes())?;
6427        writer.finish()?;
6428
6429        let machine = load_setup_machine_from_pack(&pack)?.expect("machine");
6430        assert_eq!(machine.id, "messaging-teams-default");
6431        let report = run_provider_setup_machine_doctor(&pack);
6432        assert_eq!(report.status, "ok");
6433        Ok(())
6434    }
6435
6436    #[test]
6437    fn doctor_rejects_legacy_setup_actions_in_migrated_pack() -> anyhow::Result<()> {
6438        let temp = tempfile::tempdir()?;
6439        let pack = temp.path().join("messaging-teams.gtpack");
6440        let file = std::fs::File::create(&pack)?;
6441        let mut writer = ZipWriter::new(file);
6442        let options: FileOptions<'_, ()> =
6443            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6444        writer.start_file("pack.manifest.json", options)?;
6445        writer.write_all(
6446            serde_json::json!({
6447                "pack_id": "messaging-teams",
6448                "extensions": {
6449                    SETUP_MACHINE_EXTENSION: {
6450                        "path": "assets/setup/setup-machine.v1.json",
6451                        "schema": SETUP_MACHINE_SCHEMA_URL
6452                    }
6453                }
6454            })
6455            .to_string()
6456            .as_bytes(),
6457        )?;
6458        writer.start_file("assets/setup/setup-machine.v1.json", options)?;
6459        writer.write_all(serde_json::to_string(&valid_machine())?.as_bytes())?;
6460        writer.start_file("assets/setup.yaml", options)?;
6461        writer.write_all(
6462            br#"
6463title: Teams
6464questions: []
6465setup_actions:
6466  - id: legacy_install
6467    label: Legacy install
6468    kind: oauth_install_button
6469    authorize_url: https://login.example/install
6470"#,
6471        )?;
6472        writer.finish()?;
6473
6474        let report = run_provider_setup_machine_doctor(&pack);
6475
6476        assert_eq!(report.status, "error");
6477        assert!(report.diagnostics.iter().any(|d| {
6478            d.check_id == "setup.legacy_setup_actions.absent"
6479                && d.severity == DiagnosticSeverity::Error
6480        }));
6481        Ok(())
6482    }
6483
6484    #[test]
6485    fn doctor_rejects_missing_setup_i18n_message_key() -> anyhow::Result<()> {
6486        let temp = tempfile::tempdir()?;
6487        let pack = temp.path().join("messaging-teams.gtpack");
6488        let file = std::fs::File::create(&pack)?;
6489        let mut writer = ZipWriter::new(file);
6490        let options: FileOptions<'_, ()> =
6491            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6492        writer.start_file("pack.manifest.json", options)?;
6493        writer.write_all(
6494            serde_json::json!({
6495                "pack_id": "messaging-teams",
6496                "extensions": {
6497                    SETUP_MACHINE_EXTENSION: {
6498                        "path": "assets/setup/setup-machine.v1.json",
6499                        "schema": SETUP_MACHINE_SCHEMA_URL
6500                    },
6501                    SETUP_I18N_EXTENSION: {
6502                        "inline": {
6503                            "schema_id": SETUP_I18N_EXTENSION,
6504                            "provider_id": "messaging-teams",
6505                            "messages": {
6506                                "setup.present": "Present"
6507                            }
6508                        }
6509                    }
6510                }
6511            })
6512            .to_string()
6513            .as_bytes(),
6514        )?;
6515        writer.start_file("assets/setup/setup-machine.v1.json", options)?;
6516        writer.write_all(
6517            serde_json::json!({
6518                "version": 1,
6519                "id": "messaging-teams-default",
6520                "entry_step": "approve",
6521                "steps": [
6522                    {
6523                        "id": "approve",
6524                        "kind": "manual_action",
6525                        "message_key": "setup.missing"
6526                    }
6527                ]
6528            })
6529            .to_string()
6530            .as_bytes(),
6531        )?;
6532        writer.finish()?;
6533
6534        let report = run_provider_setup_machine_doctor(&pack);
6535
6536        assert_eq!(report.status, "error");
6537        assert!(report.diagnostics.iter().any(|d| {
6538            d.check_id == "setup.i18n.message_key.exists"
6539                && d.actual.as_deref() == Some("setup.missing")
6540        }));
6541        Ok(())
6542    }
6543
6544    #[test]
6545    fn doctor_accepts_setup_i18n_message_key_from_asset() -> anyhow::Result<()> {
6546        let temp = tempfile::tempdir()?;
6547        let pack = temp.path().join("messaging-teams.gtpack");
6548        let file = std::fs::File::create(&pack)?;
6549        let mut writer = ZipWriter::new(file);
6550        let options: FileOptions<'_, ()> =
6551            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6552        writer.start_file("pack.manifest.json", options)?;
6553        writer.write_all(
6554            serde_json::json!({
6555                "pack_id": "messaging-teams",
6556                "extensions": {
6557                    SETUP_MACHINE_EXTENSION: {
6558                        "path": "assets/setup/setup-machine.v1.json",
6559                        "schema": SETUP_MACHINE_SCHEMA_URL
6560                    },
6561                    SETUP_I18N_EXTENSION: {
6562                        "path": "assets/setup/i18n/en.json"
6563                    }
6564                }
6565            })
6566            .to_string()
6567            .as_bytes(),
6568        )?;
6569        writer.start_file("assets/setup/setup-machine.v1.json", options)?;
6570        writer.write_all(
6571            serde_json::json!({
6572                "version": 1,
6573                "id": "messaging-teams-default",
6574                "entry_step": "approve",
6575                "steps": [
6576                    {
6577                        "id": "approve",
6578                        "kind": "manual_action",
6579                        "on_failure": [
6580                            {
6581                                "when": "manual_action_required",
6582                                "action": "prompt",
6583                                "message_key": "setup.approve"
6584                            }
6585                        ]
6586                    }
6587                ]
6588            })
6589            .to_string()
6590            .as_bytes(),
6591        )?;
6592        writer.start_file("assets/setup/i18n/en.json", options)?;
6593        writer.write_all(
6594            serde_json::json!({
6595                "messages": {
6596                    "setup.approve": "Approve setup"
6597                }
6598            })
6599            .to_string()
6600            .as_bytes(),
6601        )?;
6602        writer.finish()?;
6603
6604        let report = run_provider_setup_machine_doctor(&pack);
6605
6606        assert_eq!(report.status, "ok", "{report:#?}");
6607        Ok(())
6608    }
6609
6610    #[test]
6611    fn doctor_accepts_setup_machine_fixture_state() -> anyhow::Result<()> {
6612        let temp = tempfile::tempdir()?;
6613        let pack = temp.path().join("messaging-teams.gtpack");
6614        let file = std::fs::File::create(&pack)?;
6615        let mut writer = ZipWriter::new(file);
6616        let options: FileOptions<'_, ()> =
6617            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6618        writer.start_file("pack.manifest.json", options)?;
6619        writer.write_all(
6620            serde_json::json!({
6621                "pack_id": "messaging-teams",
6622                "extensions": {
6623                    SETUP_MACHINE_EXTENSION: {
6624                        "path": "assets/setup/setup-machine.v1.json",
6625                        "schema": SETUP_MACHINE_SCHEMA_URL
6626                    }
6627                }
6628            })
6629            .to_string()
6630            .as_bytes(),
6631        )?;
6632        writer.start_file("assets/setup/setup-machine.v1.json", options)?;
6633        writer.write_all(
6634            serde_json::json!({
6635                "version": 1,
6636                "id": "messaging-teams-default",
6637                "entry_step": "start",
6638                "fixture_paths": ["assets/setup/fixtures/paused.json"],
6639                "steps": [
6640                    {
6641                        "id": "start",
6642                        "kind": "manual_action",
6643                        "on_success": "complete"
6644                    }
6645                ]
6646            })
6647            .to_string()
6648            .as_bytes(),
6649        )?;
6650        writer.start_file("assets/setup/fixtures/paused.json", options)?;
6651        writer.write_all(
6652            serde_json::json!({
6653                "state": {
6654                    "schema_version": 1,
6655                    "provider_id": "messaging-teams",
6656                    "tenant": "demo",
6657                    "team": "default",
6658                    "machine_id": "messaging-teams-default",
6659                    "machine_version": 1,
6660                    "status": "paused",
6661                    "current_step": "start",
6662                    "completed_steps": [],
6663                    "outputs": {},
6664                    "created_resources": []
6665                }
6666            })
6667            .to_string()
6668            .as_bytes(),
6669        )?;
6670        writer.finish()?;
6671
6672        let report = run_provider_setup_machine_doctor(&pack);
6673
6674        assert_eq!(report.status, "ok", "{report:#?}");
6675        Ok(())
6676    }
6677
6678    #[test]
6679    fn doctor_rejects_setup_machine_fixture_with_missing_step() -> anyhow::Result<()> {
6680        let temp = tempfile::tempdir()?;
6681        let pack = temp.path().join("messaging-teams.gtpack");
6682        let file = std::fs::File::create(&pack)?;
6683        let mut writer = ZipWriter::new(file);
6684        let options: FileOptions<'_, ()> =
6685            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6686        writer.start_file("pack.manifest.json", options)?;
6687        writer.write_all(
6688            serde_json::json!({
6689                "pack_id": "messaging-teams",
6690                "extensions": {
6691                    SETUP_MACHINE_EXTENSION: {
6692                        "path": "assets/setup/setup-machine.v1.json",
6693                        "schema": SETUP_MACHINE_SCHEMA_URL
6694                    }
6695                }
6696            })
6697            .to_string()
6698            .as_bytes(),
6699        )?;
6700        writer.start_file("assets/setup/setup-machine.v1.json", options)?;
6701        writer.write_all(
6702            serde_json::json!({
6703                "version": 1,
6704                "id": "messaging-teams-default",
6705                "entry_step": "start",
6706                "fixtures": [{"path": "assets/setup/fixtures/bad.json"}],
6707                "steps": [
6708                    {
6709                        "id": "start",
6710                        "kind": "manual_action",
6711                        "on_success": "complete"
6712                    }
6713                ]
6714            })
6715            .to_string()
6716            .as_bytes(),
6717        )?;
6718        writer.start_file("assets/setup/fixtures/bad.json", options)?;
6719        writer.write_all(
6720            serde_json::json!({
6721                "schema_version": 1,
6722                "provider_id": "messaging-teams",
6723                "tenant": "demo",
6724                "team": "default",
6725                "machine_id": "messaging-teams-default",
6726                "machine_version": 1,
6727                "status": "paused",
6728                "current_step": "missing",
6729                "completed_steps": ["start"],
6730                "outputs": {},
6731                "created_resources": []
6732            })
6733            .to_string()
6734            .as_bytes(),
6735        )?;
6736        writer.finish()?;
6737
6738        let report = run_provider_setup_machine_doctor(&pack);
6739
6740        assert_eq!(report.status, "error");
6741        assert!(report.diagnostics.iter().any(|diagnostic| {
6742            diagnostic.check_id == "setup.machine.fixture.current_step"
6743                && diagnostic.actual.as_deref() == Some("assets/setup/fixtures/bad.json: missing")
6744        }));
6745        Ok(())
6746    }
6747
6748    #[test]
6749    fn doctor_accepts_backend_contract_without_setup_machine() -> anyhow::Result<()> {
6750        let temp = tempfile::tempdir()?;
6751        let pack = temp.path().join("messaging-teams.gtpack");
6752        let file = std::fs::File::create(&pack)?;
6753        let mut writer = ZipWriter::new(file);
6754        let options: FileOptions<'_, ()> =
6755            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6756        writer.start_file("pack.manifest.json", options)?;
6757        writer.write_all(
6758            serde_json::json!({
6759                "pack_id": "messaging-teams",
6760                "extensions": {
6761                    SETUP_WEB_COMPONENT_EXTENSION: {
6762                        "inline": {
6763                            "schema_id": SETUP_WEB_COMPONENT_EXTENSION,
6764                            "provider_id": "messaging-teams",
6765                            "tag_name": "greentic-teams-setup-v4",
6766                            "module_asset": "assets/setup/greentic-teams-setup.js"
6767                        }
6768                    },
6769                    SETUP_BACKEND_CONTRACT_EXTENSION: {
6770                        "inline": {
6771                            "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
6772                            "provider_id": "messaging-teams",
6773                            "asset": "assets/setup/backend-contract.json"
6774                        }
6775                    },
6776                    HTTP_ROUTES_EXTENSION: {
6777                        "inline": {
6778                            "routes": [
6779                                {
6780                                    "pattern": "/v1/setup/messaging-teams/{tenant}/{team}/register",
6781                                    "methods": ["POST"],
6782                                    "setup_component_ref": "messaging-teams",
6783                                    "setup_op": "register"
6784                                }
6785                            ]
6786                        }
6787                    }
6788                }
6789            })
6790            .to_string()
6791            .as_bytes(),
6792        )?;
6793        writer.start_file("assets/setup/greentic-teams-setup.js", options)?;
6794        writer.write_all(b"export default class GreenticTeamsSetup extends HTMLElement {}\n")?;
6795        writer.start_file("assets/setup/backend-contract.json", options)?;
6796        writer.write_all(
6797            serde_json::json!({
6798                "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
6799                "provider_id": "messaging-teams",
6800                "server_owned_config_keys": [
6801                    "oauth_kind",
6802                    "oauth_device_code",
6803                    "oauth_user_code",
6804                    "graph_access_token"
6805                ],
6806                "required_order": ["graph_admin_consent", "register_endpoint", "first_message"],
6807                "actions": [
6808                    {
6809                        "id": "graph_admin_consent",
6810                        "executor": {
6811                            "kind": "oauth_device_code",
6812                            "authority_url_template": "https://login.microsoftonline.com/{authority_tenant}",
6813                            "client_id_config_key": "graph_setup_client_id",
6814                            "oauth_kind": "graph",
6815                            "token_store_key": "graph_access_token",
6816                            "scopes": ["https://graph.microsoft.com/User.Read"]
6817                        },
6818                        "completion": {"state_path": "oauth.graph.ok", "exists": true}
6819                    },
6820                    {
6821                        "id": "register_endpoint",
6822                        "requires": ["graph_admin_consent"],
6823                        "executor": {
6824                            "kind": "provider_http",
6825                            "path_template": "/v1/setup/messaging-teams/{tenant}/{team}/register"
6826                        },
6827                        "completion": {"state_path": "last_reconcile.ok", "equals": true}
6828                    },
6829                    {
6830                        "id": "first_message",
6831                        "requires": ["register_endpoint"],
6832                        "executor": {
6833                            "kind": "runtime_observation",
6834                            "source": "greentic-start",
6835                            "event": "bot_framework_activity_received",
6836                            "state_store_key": "last_activity"
6837                        },
6838                        "completion": {"state_path": "last_activity", "exists": true}
6839                    }
6840                ]
6841            })
6842            .to_string()
6843            .as_bytes(),
6844        )?;
6845        writer.finish()?;
6846
6847        let report = run_provider_setup_machine_doctor(&pack);
6848        assert_eq!(report.status, "ok", "{report:#?}");
6849        assert!(
6850            report
6851                .diagnostics
6852                .iter()
6853                .any(|d| d.check_id == "setup.backend_contract.extension.present")
6854        );
6855        Ok(())
6856    }
6857
6858    #[test]
6859    fn doctor_rejects_invalid_setup_machine_even_with_backend_contract() -> anyhow::Result<()> {
6860        let temp = tempfile::tempdir()?;
6861        let pack = temp.path().join("messaging-teams.gtpack");
6862        let file = std::fs::File::create(&pack)?;
6863        let mut writer = ZipWriter::new(file);
6864        let options: FileOptions<'_, ()> =
6865            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6866        writer.start_file("pack.manifest.json", options)?;
6867        writer.write_all(
6868            serde_json::json!({
6869                "pack_id": "messaging-teams",
6870                "extensions": {
6871                    SETUP_WEB_COMPONENT_EXTENSION: {
6872                        "inline": {
6873                            "schema_id": SETUP_WEB_COMPONENT_EXTENSION,
6874                            "provider_id": "messaging-teams",
6875                            "tag_name": "greentic-teams-setup-v4",
6876                            "module_asset": "assets/setup/greentic-teams-setup.js"
6877                        }
6878                    },
6879                    SETUP_BACKEND_CONTRACT_EXTENSION: {
6880                        "inline": {
6881                            "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
6882                            "provider_id": "messaging-teams",
6883                            "asset": "assets/setup/backend-contract.json"
6884                        }
6885                    },
6886                    SETUP_MACHINE_EXTENSION: {
6887                        "path": "assets/setup/setup-machine.v1.json",
6888                        "schema": SETUP_MACHINE_SCHEMA_URL
6889                    }
6890                }
6891            })
6892            .to_string()
6893            .as_bytes(),
6894        )?;
6895        writer.start_file("assets/setup/greentic-teams-setup.js", options)?;
6896        writer.write_all(b"export default class GreenticTeamsSetup extends HTMLElement {}\n")?;
6897        writer.start_file("assets/setup/backend-contract.json", options)?;
6898        writer.write_all(
6899            serde_json::json!({
6900                "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
6901                "provider_id": "messaging-teams",
6902                "server_owned_config_keys": [
6903                    "oauth_kind",
6904                    "oauth_device_code",
6905                    "oauth_user_code",
6906                    "graph_access_token"
6907                ],
6908                "required_order": ["graph_admin_consent"],
6909                "actions": [
6910                    {
6911                        "id": "graph_admin_consent",
6912                        "executor": {
6913                            "kind": "oauth_device_code",
6914                            "authority_url_template": "https://login.microsoftonline.com/{authority_tenant}",
6915                            "client_id_config_key": "graph_setup_client_id",
6916                            "oauth_kind": "graph",
6917                            "token_store_key": "graph_access_token",
6918                            "scopes": ["https://graph.microsoft.com/User.Read"]
6919                        },
6920                        "completion": {"state_path": "oauth.graph.ok", "exists": true}
6921                    }
6922                ]
6923            })
6924            .to_string()
6925            .as_bytes(),
6926        )?;
6927        writer.start_file("assets/setup/setup-machine.v1.json", options)?;
6928        writer.write_all(
6929            serde_json::json!({
6930                "version": 1,
6931                "id": "messaging-teams-broken",
6932                "entry_step": "missing",
6933                "steps": [
6934                    {
6935                        "id": "only",
6936                        "kind": "auto_complete",
6937                        "on_success": "complete"
6938                    }
6939                ]
6940            })
6941            .to_string()
6942            .as_bytes(),
6943        )?;
6944        writer.finish()?;
6945
6946        let report = run_provider_setup_machine_doctor(&pack);
6947
6948        assert_eq!(report.status, "error");
6949        assert!(
6950            report
6951                .diagnostics
6952                .iter()
6953                .any(|d| d.check_id == "setup.backend_contract.extension.present")
6954        );
6955        assert!(
6956            report
6957                .diagnostics
6958                .iter()
6959                .any(|d| d.check_id == "setup.machine.extension.present")
6960        );
6961        assert!(report.diagnostics.iter().any(|d| {
6962            d.severity == DiagnosticSeverity::Error && d.check_id.starts_with("setup.machine.")
6963        }));
6964        Ok(())
6965    }
6966
6967    #[test]
6968    fn route_pattern_matches_provider_http_templates() {
6969        assert!(route_pattern_covers_path(
6970            "/v1/setup/messaging-teams/{tenant}/{team}/register",
6971            "/v1/setup/messaging-teams/{tenant}/{team}/register",
6972        ));
6973        assert!(route_pattern_covers_path(
6974            "/v1/messaging/setup/messaging-teams/{tenant}/{action*}",
6975            "/v1/messaging/setup/messaging-teams/{tenant}/oauth/{kind}/start",
6976        ));
6977        assert!(!route_pattern_covers_path(
6978            "/v1/setup/messaging-teams/{tenant}/{team}/publish",
6979            "/v1/setup/messaging-teams/{tenant}/{team}/register",
6980        ));
6981    }
6982
6983    #[test]
6984    fn backend_contract_doctor_rejects_uncovered_provider_http_route() -> anyhow::Result<()> {
6985        let temp = tempfile::tempdir()?;
6986        let pack = temp.path().join("messaging-teams.gtpack");
6987        let file = std::fs::File::create(&pack)?;
6988        let mut writer = ZipWriter::new(file);
6989        let options: FileOptions<'_, ()> =
6990            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6991        writer.start_file("pack.manifest.json", options)?;
6992        writer.write_all(
6993            serde_json::json!({
6994                "pack_id": "messaging-teams",
6995                "extensions": {
6996                    SETUP_WEB_COMPONENT_EXTENSION: {
6997                        "inline": {
6998                            "schema_id": SETUP_WEB_COMPONENT_EXTENSION,
6999                            "provider_id": "messaging-teams",
7000                            "tag_name": "greentic-teams-setup-v4",
7001                            "module_asset": "assets/setup/greentic-teams-setup.js"
7002                        }
7003                    },
7004                    SETUP_BACKEND_CONTRACT_EXTENSION: {
7005                        "inline": {
7006                            "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
7007                            "provider_id": "messaging-teams",
7008                            "asset": "assets/setup/backend-contract.json"
7009                        }
7010                    },
7011                    HTTP_ROUTES_EXTENSION: {
7012                        "inline": {
7013                            "routes": [
7014                                {
7015                                    "pattern": "/v1/setup/messaging-teams/{tenant}/{team}/other",
7016                                    "methods": ["POST"],
7017                                    "setup_component_ref": "messaging-teams",
7018                                    "setup_op": "other"
7019                                }
7020                            ]
7021                        }
7022                    }
7023                }
7024            })
7025            .to_string()
7026            .as_bytes(),
7027        )?;
7028        writer.start_file("assets/setup/greentic-teams-setup.js", options)?;
7029        writer.write_all(b"export default class GreenticTeamsSetup extends HTMLElement {}\n")?;
7030        writer.start_file("assets/setup/backend-contract.json", options)?;
7031        writer.write_all(
7032            serde_json::json!({
7033                "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
7034                "provider_id": "messaging-teams",
7035                "required_order": ["register_endpoint"],
7036                "actions": [
7037                    {
7038                        "id": "register_endpoint",
7039                        "executor": {
7040                            "kind": "provider_http",
7041                            "path_template": "/v1/setup/messaging-teams/{tenant}/{team}/register"
7042                        },
7043                        "completion": {"state_path": "last_reconcile.ok", "equals": true}
7044                    }
7045                ]
7046            })
7047            .to_string()
7048            .as_bytes(),
7049        )?;
7050        writer.finish()?;
7051
7052        let report = run_provider_setup_machine_doctor(&pack);
7053        assert_eq!(report.status, "error");
7054        assert!(
7055            report
7056                .diagnostics
7057                .iter()
7058                .any(|d| { d.check_id == "setup.backend_contract.provider_http.route_covered" })
7059        );
7060        Ok(())
7061    }
7062
7063    #[test]
7064    fn backend_contract_doctor_rejects_provider_http_method_mismatch() -> anyhow::Result<()> {
7065        let temp = tempfile::tempdir()?;
7066        let pack = temp.path().join("messaging-teams.gtpack");
7067        let file = std::fs::File::create(&pack)?;
7068        let mut writer = ZipWriter::new(file);
7069        let options: FileOptions<'_, ()> =
7070            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
7071        writer.start_file("pack.manifest.json", options)?;
7072        writer.write_all(
7073            serde_json::json!({
7074                "pack_id": "messaging-teams",
7075                "extensions": {
7076                    SETUP_WEB_COMPONENT_EXTENSION: {
7077                        "inline": {
7078                            "schema_id": SETUP_WEB_COMPONENT_EXTENSION,
7079                            "provider_id": "messaging-teams",
7080                            "tag_name": "greentic-teams-setup-v4",
7081                            "module_asset": "assets/setup/greentic-teams-setup.js"
7082                        }
7083                    },
7084                    SETUP_BACKEND_CONTRACT_EXTENSION: {
7085                        "inline": {
7086                            "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
7087                            "provider_id": "messaging-teams",
7088                            "asset": "assets/setup/backend-contract.json"
7089                        }
7090                    },
7091                    HTTP_ROUTES_EXTENSION: {
7092                        "inline": {
7093                            "routes": [
7094                                {
7095                                    "pattern": "/v1/setup/messaging-teams/{tenant}/{team}/register",
7096                                    "methods": ["GET"],
7097                                    "setup_component_ref": "messaging-teams",
7098                                    "setup_op": "register"
7099                                }
7100                            ]
7101                        }
7102                    }
7103                }
7104            })
7105            .to_string()
7106            .as_bytes(),
7107        )?;
7108        writer.start_file("assets/setup/greentic-teams-setup.js", options)?;
7109        writer.write_all(b"export default class GreenticTeamsSetup extends HTMLElement {}\n")?;
7110        writer.start_file("assets/setup/backend-contract.json", options)?;
7111        writer.write_all(
7112            serde_json::json!({
7113                "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
7114                "provider_id": "messaging-teams",
7115                "required_order": ["register_endpoint"],
7116                "actions": [
7117                    {
7118                        "id": "register_endpoint",
7119                        "executor": {
7120                            "kind": "provider_http",
7121                            "method": "POST",
7122                            "path_template": "/v1/setup/messaging-teams/{tenant}/{team}/register"
7123                        },
7124                        "completion": {"state_path": "last_reconcile.ok", "equals": true}
7125                    }
7126                ]
7127            })
7128            .to_string()
7129            .as_bytes(),
7130        )?;
7131        writer.finish()?;
7132
7133        let report = run_provider_setup_machine_doctor(&pack);
7134        assert_eq!(report.status, "error");
7135        assert!(
7136            report
7137                .diagnostics
7138                .iter()
7139                .any(|d| d.check_id == "setup.backend_contract.provider_http.route_method")
7140        );
7141        Ok(())
7142    }
7143
7144    #[test]
7145    fn backend_contract_doctor_rejects_oauth_token_not_server_owned() -> anyhow::Result<()> {
7146        let temp = tempfile::tempdir()?;
7147        let pack = temp.path().join("messaging-teams.gtpack");
7148        let file = std::fs::File::create(&pack)?;
7149        let mut writer = ZipWriter::new(file);
7150        let options: FileOptions<'_, ()> =
7151            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
7152        writer.start_file("pack.manifest.json", options)?;
7153        writer.write_all(
7154            serde_json::json!({
7155                "pack_id": "messaging-teams",
7156                "extensions": {
7157                    SETUP_WEB_COMPONENT_EXTENSION: {
7158                        "inline": {
7159                            "schema_id": SETUP_WEB_COMPONENT_EXTENSION,
7160                            "provider_id": "messaging-teams",
7161                            "tag_name": "greentic-teams-setup-v4",
7162                            "module_asset": "assets/setup/greentic-teams-setup.js"
7163                        }
7164                    },
7165                    SETUP_BACKEND_CONTRACT_EXTENSION: {
7166                        "inline": {
7167                            "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
7168                            "provider_id": "messaging-teams",
7169                            "asset": "assets/setup/backend-contract.json"
7170                        }
7171                    }
7172                }
7173            })
7174            .to_string()
7175            .as_bytes(),
7176        )?;
7177        writer.start_file("assets/setup/greentic-teams-setup.js", options)?;
7178        writer.write_all(b"export default class GreenticTeamsSetup extends HTMLElement {}\n")?;
7179        writer.start_file("assets/setup/backend-contract.json", options)?;
7180        writer.write_all(
7181            serde_json::json!({
7182                "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
7183                "provider_id": "messaging-teams",
7184                "server_owned_config_keys": ["oauth_kind", "oauth_device_code", "oauth_user_code"],
7185                "required_order": ["graph_admin_consent"],
7186                "actions": [
7187                    {
7188                        "id": "graph_admin_consent",
7189                        "executor": {
7190                            "kind": "oauth_device_code",
7191                            "authority_url_template": "https://login.microsoftonline.com/{authority_tenant}",
7192                            "client_id_config_key": "graph_setup_client_id",
7193                            "oauth_kind": "graph",
7194                            "token_store_key": "graph_access_token",
7195                            "scopes": ["https://graph.microsoft.com/User.Read"]
7196                        },
7197                        "completion": {"state_path": "oauth.graph.ok", "exists": true}
7198                    }
7199                ]
7200            })
7201            .to_string()
7202            .as_bytes(),
7203        )?;
7204        writer.finish()?;
7205
7206        let report = run_provider_setup_machine_doctor(&pack);
7207        assert_eq!(report.status, "error");
7208        assert!(
7209            report
7210                .diagnostics
7211                .iter()
7212                .any(|d| { d.check_id == "setup.backend_contract.server_owned_config_keys.oauth" })
7213        );
7214        Ok(())
7215    }
7216
7217    #[test]
7218    fn persists_state_and_appends_events() -> anyhow::Result<()> {
7219        let temp = tempfile::tempdir()?;
7220        let state = SetupMachineState {
7221            schema_version: 1,
7222            provider_id: "messaging-teams".to_string(),
7223            tenant: "demo".to_string(),
7224            team: "default".to_string(),
7225            machine_id: "messaging-teams-default".to_string(),
7226            machine_version: 1,
7227            status: SetupMachineStatus::Running,
7228            current_step: Some("login".to_string()),
7229            completed_steps: vec!["collect".to_string()],
7230            failed_step: None,
7231            answers_hash: None,
7232            pack_fingerprint: None,
7233            outputs: serde_json::json!({}),
7234            created_resources: vec![],
7235            last_error: None,
7236            updated_at: None,
7237        };
7238        let path = write_setup_machine_state(temp.path(), &state)?;
7239        let loaded = load_setup_machine_state(&path)?;
7240        assert_eq!(loaded.current_step.as_deref(), Some("login"));
7241
7242        let event = SetupMachineEvent {
7243            ts: "2026-06-17T00:00:00Z".to_string(),
7244            level: "info".to_string(),
7245            event: "setup.step.started".to_string(),
7246            provider_id: "messaging-teams".to_string(),
7247            tenant: "demo".to_string(),
7248            team: "default".to_string(),
7249            machine_id: "messaging-teams-default".to_string(),
7250            step_id: Some("login".to_string()),
7251            correlation_id: Some("corr".to_string()),
7252            fields: serde_json::Map::new(),
7253        };
7254        let event_path = append_setup_machine_event(temp.path(), &event)?;
7255        let events = std::fs::read_to_string(event_path)?;
7256        assert!(events.contains("setup.step.started"));
7257        Ok(())
7258    }
7259
7260    #[test]
7261    fn advance_setup_machine_blocks_incompatible_persisted_machine_state() -> anyhow::Result<()> {
7262        let temp = tempfile::tempdir()?;
7263        let machine = valid_machine();
7264        let state = SetupMachineState {
7265            schema_version: 1,
7266            provider_id: "messaging-teams".to_string(),
7267            tenant: "demo".to_string(),
7268            team: "default".to_string(),
7269            machine_id: machine.id.clone(),
7270            machine_version: 0,
7271            status: SetupMachineStatus::Running,
7272            current_step: Some(machine.entry_step.clone()),
7273            completed_steps: Vec::new(),
7274            failed_step: None,
7275            answers_hash: None,
7276            pack_fingerprint: None,
7277            outputs: serde_json::json!({}),
7278            created_resources: Vec::new(),
7279            last_error: None,
7280            updated_at: None,
7281        };
7282        write_setup_machine_state(temp.path(), &state)?;
7283
7284        let output = advance_setup_machine(
7285            temp.path(),
7286            "messaging-teams",
7287            "demo",
7288            "default",
7289            &machine,
7290            false,
7291        )?;
7292
7293        assert_eq!(output["ok"], false);
7294        assert!(
7295            output["next"]
7296                .as_str()
7297                .unwrap()
7298                .contains("machine_identity_changed")
7299        );
7300        let loaded = load_setup_machine_state(&setup_machine_state_path(
7301            temp.path(),
7302            "demo",
7303            "default",
7304            "messaging-teams",
7305        ))?;
7306        assert_eq!(loaded.status, SetupMachineStatus::Failed);
7307        assert_eq!(
7308            loaded
7309                .last_error
7310                .as_ref()
7311                .and_then(|error| error.get("error"))
7312                .and_then(Value::as_str),
7313            Some("machine_identity_changed")
7314        );
7315        let events = std::fs::read_to_string(setup_machine_events_path(
7316            temp.path(),
7317            "demo",
7318            "default",
7319            "messaging-teams",
7320        ))?;
7321        assert!(events.contains("setup.machine.incompatible_state"));
7322        Ok(())
7323    }
7324
7325    #[test]
7326    fn setup_machine_state_records_pack_and_answers_hashes() -> anyhow::Result<()> {
7327        let temp = tempfile::tempdir()?;
7328        let machine = valid_machine();
7329        let pack_path = temp.path().join("provider.gtpack");
7330        std::fs::write(&pack_path, b"pack-v1")?;
7331        let answers_path = temp
7332            .path()
7333            .join("state/config/messaging-teams/setup-answers.json");
7334        std::fs::create_dir_all(answers_path.parent().unwrap())?;
7335        std::fs::write(&answers_path, br#"{"client_id":"demo"}"#)?;
7336
7337        let state = load_or_init_setup_machine_state_with_context(
7338            temp.path(),
7339            Some(&pack_path),
7340            "messaging-teams",
7341            "demo",
7342            "default",
7343            &machine,
7344        )?;
7345
7346        assert!(state.pack_fingerprint.as_deref().is_some_and(|hash| {
7347            hash.starts_with("sha256:") && hash == setup_machine_hash_file(&pack_path).unwrap()
7348        }));
7349        assert!(state.answers_hash.as_deref().is_some_and(|hash| {
7350            hash.starts_with("sha256:") && hash == setup_machine_hash_file(&answers_path).unwrap()
7351        }));
7352        Ok(())
7353    }
7354
7355    #[test]
7356    fn advance_setup_machine_blocks_changed_provider_pack_fingerprint() -> anyhow::Result<()> {
7357        let temp = tempfile::tempdir()?;
7358        let machine = valid_machine();
7359        let pack_path = temp.path().join("provider.gtpack");
7360        std::fs::write(&pack_path, b"pack-v1")?;
7361        let mut state = load_or_init_setup_machine_state_with_context(
7362            temp.path(),
7363            Some(&pack_path),
7364            "messaging-teams",
7365            "demo",
7366            "default",
7367            &machine,
7368        )?;
7369        state.status = SetupMachineStatus::Running;
7370        write_setup_machine_state(temp.path(), &state)?;
7371        std::fs::write(&pack_path, b"pack-v2")?;
7372
7373        let output = advance_setup_machine_with_pack(
7374            temp.path(),
7375            Some(&pack_path),
7376            "messaging-teams",
7377            "demo",
7378            "default",
7379            &machine,
7380            false,
7381        )?;
7382
7383        assert_eq!(output["ok"], false);
7384        assert!(
7385            output["next"]
7386                .as_str()
7387                .unwrap()
7388                .contains("pack_fingerprint_changed")
7389        );
7390        let loaded = load_setup_machine_state(&setup_machine_state_path(
7391            temp.path(),
7392            "demo",
7393            "default",
7394            "messaging-teams",
7395        ))?;
7396        assert_eq!(
7397            loaded
7398                .last_error
7399                .as_ref()
7400                .and_then(|error| error.get("error"))
7401                .and_then(Value::as_str),
7402            Some("pack_fingerprint_changed")
7403        );
7404        Ok(())
7405    }
7406
7407    #[test]
7408    fn advance_setup_machine_blocks_changed_setup_answers_hash() -> anyhow::Result<()> {
7409        let temp = tempfile::tempdir()?;
7410        let machine = valid_machine();
7411        let pack_path = temp.path().join("provider.gtpack");
7412        std::fs::write(&pack_path, b"pack-v1")?;
7413        let answers_path = temp
7414            .path()
7415            .join("state/config/messaging-teams/setup-answers.json");
7416        std::fs::create_dir_all(answers_path.parent().unwrap())?;
7417        std::fs::write(&answers_path, br#"{"client_id":"demo"}"#)?;
7418        let mut state = load_or_init_setup_machine_state_with_context(
7419            temp.path(),
7420            Some(&pack_path),
7421            "messaging-teams",
7422            "demo",
7423            "default",
7424            &machine,
7425        )?;
7426        state.status = SetupMachineStatus::Running;
7427        write_setup_machine_state(temp.path(), &state)?;
7428        std::fs::write(&answers_path, br#"{"client_id":"changed"}"#)?;
7429
7430        let output = advance_setup_machine_with_pack(
7431            temp.path(),
7432            Some(&pack_path),
7433            "messaging-teams",
7434            "demo",
7435            "default",
7436            &machine,
7437            false,
7438        )?;
7439
7440        assert_eq!(output["ok"], false);
7441        assert!(
7442            output["next"]
7443                .as_str()
7444                .unwrap()
7445                .contains("setup_answers_changed")
7446        );
7447        let loaded = load_setup_machine_state(&setup_machine_state_path(
7448            temp.path(),
7449            "demo",
7450            "default",
7451            "messaging-teams",
7452        ))?;
7453        assert_eq!(
7454            loaded
7455                .last_error
7456                .as_ref()
7457                .and_then(|error| error.get("error"))
7458                .and_then(Value::as_str),
7459            Some("setup_answers_changed")
7460        );
7461        Ok(())
7462    }
7463
7464    #[test]
7465    fn advance_setup_machine_completes_auto_step_and_persists_state() -> anyhow::Result<()> {
7466        let temp = tempfile::tempdir()?;
7467        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
7468            "version": 1,
7469            "id": "example-machine",
7470            "entry_step": "collect",
7471            "steps": [
7472                {
7473                    "id": "collect",
7474                    "kind": "qa_form",
7475                    "auto_complete": true,
7476                    "on_success": "manual"
7477                },
7478                {
7479                    "id": "manual",
7480                    "kind": "manual_action",
7481                    "title": "Approve"
7482                }
7483            ]
7484        }))?;
7485
7486        let output = advance_setup_machine(
7487            temp.path(),
7488            "messaging-example",
7489            "demo",
7490            "default",
7491            &machine,
7492            false,
7493        )?;
7494
7495        assert_eq!(output["ok"], true);
7496        assert_eq!(output["step"], "collect");
7497        assert_eq!(output["status"]["current_step"], "manual");
7498        let state = load_setup_machine_state(&setup_machine_state_path(
7499            temp.path(),
7500            "demo",
7501            "default",
7502            "messaging-example",
7503        ))?;
7504        assert_eq!(state.status, SetupMachineStatus::Running);
7505        assert_eq!(state.completed_steps, vec!["collect"]);
7506        assert_eq!(state.current_step.as_deref(), Some("manual"));
7507        let events = std::fs::read_to_string(setup_machine_events_path(
7508            temp.path(),
7509            "demo",
7510            "default",
7511            "messaging-example",
7512        ))?;
7513        assert!(events.contains("setup.step.completed"));
7514        Ok(())
7515    }
7516
7517    #[test]
7518    fn advance_setup_machine_resumes_current_step_and_pauses_for_manual_action()
7519    -> anyhow::Result<()> {
7520        let temp = tempfile::tempdir()?;
7521        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
7522            "version": 1,
7523            "id": "example-machine",
7524            "entry_step": "manual",
7525            "steps": [
7526                {
7527                    "id": "manual",
7528                    "kind": "manual_action",
7529                    "title": "Approve"
7530                }
7531            ]
7532        }))?;
7533
7534        let output = advance_setup_machine(
7535            temp.path(),
7536            "messaging-example",
7537            "demo",
7538            "default",
7539            &machine,
7540            false,
7541        )?;
7542
7543        assert_eq!(output["ok"], false);
7544        assert_eq!(output["step"], "manual");
7545        assert_eq!(output["result"]["status"], "paused");
7546        assert_eq!(
7547            output["result"]["detail"]["error"],
7548            "manual_action_required"
7549        );
7550        let state = load_setup_machine_state(&setup_machine_state_path(
7551            temp.path(),
7552            "demo",
7553            "default",
7554            "messaging-example",
7555        ))?;
7556        assert_eq!(state.status, SetupMachineStatus::Paused);
7557        assert_eq!(state.current_step.as_deref(), Some("manual"));
7558
7559        let repeated = advance_setup_machine(
7560            temp.path(),
7561            "messaging-example",
7562            "demo",
7563            "default",
7564            &machine,
7565            true,
7566        )?;
7567        assert_eq!(repeated["step"], "manual");
7568        assert_eq!(repeated["dry_run"], true);
7569        Ok(())
7570    }
7571
7572    #[test]
7573    fn advance_setup_machine_follows_declared_failure_recovery_step() -> anyhow::Result<()> {
7574        let temp = tempfile::tempdir()?;
7575        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
7576            "version": 1,
7577            "id": "example-machine",
7578            "entry_step": "public_url",
7579            "steps": [
7580                {
7581                    "id": "public_url",
7582                    "kind": "platform_public_url",
7583                    "on_failure": [
7584                        {
7585                            "when": "missing_prerequisite",
7586                            "target": "manual_public_url"
7587                        }
7588                    ],
7589                    "on_success": "complete"
7590                },
7591                {
7592                    "id": "manual_public_url",
7593                    "kind": "manual_action",
7594                    "on_success": "public_url"
7595                }
7596            ]
7597        }))?;
7598
7599        let output = advance_setup_machine(
7600            temp.path(),
7601            "messaging-example",
7602            "demo",
7603            "default",
7604            &machine,
7605            false,
7606        )?;
7607
7608        assert_eq!(output["ok"], false);
7609        assert_eq!(output["next"], "recovery scheduled: manual_public_url");
7610        assert_eq!(
7611            output["result"]["detail"]["recovery_step"],
7612            "manual_public_url"
7613        );
7614        let state = load_setup_machine_state(&setup_machine_state_path(
7615            temp.path(),
7616            "demo",
7617            "default",
7618            "messaging-example",
7619        ))?;
7620        assert_eq!(state.status, SetupMachineStatus::Running);
7621        assert_eq!(state.current_step.as_deref(), Some("manual_public_url"));
7622        assert_eq!(
7623            state
7624                .last_error
7625                .as_ref()
7626                .and_then(|error| error.get("recovery_step"))
7627                .and_then(Value::as_str),
7628            Some("manual_public_url")
7629        );
7630        let events = std::fs::read_to_string(setup_machine_events_path(
7631            temp.path(),
7632            "demo",
7633            "default",
7634            "messaging-example",
7635        ))?;
7636        assert!(events.contains("setup.step.recovery_scheduled"));
7637        Ok(())
7638    }
7639
7640    #[test]
7641    fn advance_setup_machine_resolves_platform_public_url_from_static_routes() -> anyhow::Result<()>
7642    {
7643        let temp = tempfile::tempdir()?;
7644        crate::platform_setup::persist_static_routes_artifact(
7645            temp.path(),
7646            &crate::platform_setup::StaticRoutesPolicy {
7647                public_web_enabled: true,
7648                public_base_url: Some("https://runtime.example.com/base/".to_string()),
7649                public_surface_policy: "enabled".to_string(),
7650                ..Default::default()
7651            },
7652        )?;
7653        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
7654            "version": 1,
7655            "id": "example-machine",
7656            "entry_step": "public_url",
7657            "steps": [
7658                {
7659                    "id": "public_url",
7660                    "kind": "platform_public_url",
7661                    "on_success": "complete"
7662                }
7663            ]
7664        }))?;
7665
7666        let output = advance_setup_machine(
7667            temp.path(),
7668            "messaging-example",
7669            "demo",
7670            "default",
7671            &machine,
7672            false,
7673        )?;
7674
7675        assert_eq!(output["ok"], true);
7676        let state = load_setup_machine_state(&setup_machine_state_path(
7677            temp.path(),
7678            "demo",
7679            "default",
7680            "messaging-example",
7681        ))?;
7682        assert_eq!(state.status, SetupMachineStatus::Complete);
7683        assert_eq!(
7684            state.outputs["public_base_url"],
7685            "https://runtime.example.com/base"
7686        );
7687        Ok(())
7688    }
7689
7690    #[test]
7691    fn advance_setup_machine_blocks_platform_public_url_when_missing() -> anyhow::Result<()> {
7692        let temp = tempfile::tempdir()?;
7693        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
7694            "version": 1,
7695            "id": "example-machine",
7696            "entry_step": "public_url",
7697            "steps": [
7698                {
7699                    "id": "public_url",
7700                    "kind": "platform_public_url"
7701                }
7702            ]
7703        }))?;
7704
7705        let output = advance_setup_machine(
7706            temp.path(),
7707            "messaging-example",
7708            "demo",
7709            "default",
7710            &machine,
7711            false,
7712        )?;
7713
7714        assert_eq!(output["ok"], false);
7715        assert_eq!(output["result"]["detail"]["error"], "missing_prerequisite");
7716        let state = load_setup_machine_state(&setup_machine_state_path(
7717            temp.path(),
7718            "demo",
7719            "default",
7720            "messaging-example",
7721        ))?;
7722        assert_eq!(state.status, SetupMachineStatus::Paused);
7723        assert_eq!(state.current_step.as_deref(), Some("public_url"));
7724        Ok(())
7725    }
7726
7727    #[test]
7728    fn advance_setup_machine_cloudflare_tunnel_resolves_existing_https_url() -> anyhow::Result<()> {
7729        let temp = tempfile::tempdir()?;
7730        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
7731            "version": 1,
7732            "id": "example-machine",
7733            "entry_step": "tunnel",
7734            "steps": [
7735                {
7736                    "id": "tunnel",
7737                    "kind": "cloudflare_tunnel",
7738                    "public_base_url": "https://setup.trycloudflare.com",
7739                    "on_success": "complete"
7740                }
7741            ]
7742        }))?;
7743
7744        let output = advance_setup_machine(
7745            temp.path(),
7746            "messaging-example",
7747            "demo",
7748            "default",
7749            &machine,
7750            false,
7751        )?;
7752
7753        assert_eq!(output["ok"], true);
7754        assert_eq!(output["result"]["detail"]["ephemeral"], true);
7755        let state = load_setup_machine_state(&setup_machine_state_path(
7756            temp.path(),
7757            "demo",
7758            "default",
7759            "messaging-example",
7760        ))?;
7761        assert_eq!(state.status, SetupMachineStatus::Complete);
7762        assert_eq!(
7763            state.outputs["public_base_url"],
7764            "https://setup.trycloudflare.com"
7765        );
7766        Ok(())
7767    }
7768
7769    #[test]
7770    fn advance_setup_machine_cloudflare_tunnel_blocks_when_missing() -> anyhow::Result<()> {
7771        let temp = tempfile::tempdir()?;
7772        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
7773            "version": 1,
7774            "id": "example-machine",
7775            "entry_step": "tunnel",
7776            "steps": [
7777                {
7778                    "id": "tunnel",
7779                    "kind": "cloudflare_tunnel"
7780                }
7781            ]
7782        }))?;
7783
7784        let output = advance_setup_machine(
7785            temp.path(),
7786            "messaging-example",
7787            "demo",
7788            "default",
7789            &machine,
7790            false,
7791        )?;
7792
7793        assert_eq!(output["ok"], false);
7794        assert_eq!(output["result"]["detail"]["error"], "tunnel_unreachable");
7795        let state = load_setup_machine_state(&setup_machine_state_path(
7796            temp.path(),
7797            "demo",
7798            "default",
7799            "messaging-example",
7800        ))?;
7801        assert_eq!(state.status, SetupMachineStatus::Paused);
7802        assert_eq!(state.current_step.as_deref(), Some("tunnel"));
7803        Ok(())
7804    }
7805
7806    #[test]
7807    fn advance_setup_machine_oauth_authorization_code_renders_signed_url() -> anyhow::Result<()> {
7808        let temp = tempfile::tempdir()?;
7809        let state = SetupMachineState {
7810            schema_version: 1,
7811            provider_id: "messaging-example".to_string(),
7812            tenant: "demo".to_string(),
7813            team: "default".to_string(),
7814            machine_id: "example-machine".to_string(),
7815            machine_version: 1,
7816            status: SetupMachineStatus::Running,
7817            current_step: Some("oauth".to_string()),
7818            completed_steps: Vec::new(),
7819            failed_step: None,
7820            answers_hash: None,
7821            pack_fingerprint: None,
7822            outputs: serde_json::json!({
7823                "public_base_url": "https://runtime.example.com",
7824                "client_id": "client-123"
7825            }),
7826            created_resources: Vec::new(),
7827            last_error: None,
7828            updated_at: None,
7829        };
7830        write_setup_machine_state(temp.path(), &state)?;
7831        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
7832            "version": 1,
7833            "id": "example-machine",
7834            "entry_step": "oauth",
7835            "steps": [
7836                {
7837                    "id": "oauth",
7838                    "kind": "oauth_authorization_code",
7839                    "authorize_url": "https://login.example.com/oauth2/v2.0/authorize",
7840                    "token_url": "https://login.example.com/oauth2/v2.0/token",
7841                    "callback_path": "/oauth/callback",
7842                    "scopes": ["User.Read", "offline_access"],
7843                    "params": {"prompt": "consent"}
7844                }
7845            ]
7846        }))?;
7847
7848        let output = advance_setup_machine(
7849            temp.path(),
7850            "messaging-example",
7851            "demo",
7852            "default",
7853            &machine,
7854            false,
7855        )?;
7856
7857        assert_eq!(output["ok"], false);
7858        assert_eq!(
7859            output["result"]["detail"]["reason"],
7860            "oauth_authorization_required"
7861        );
7862        let state = load_setup_machine_state(&setup_machine_state_path(
7863            temp.path(),
7864            "demo",
7865            "default",
7866            "messaging-example",
7867        ))?;
7868        assert_eq!(state.status, SetupMachineStatus::Paused);
7869        let authorization_url = state.outputs["authorization_url"]
7870            .as_str()
7871            .expect("authorization_url");
7872        let parsed = url::Url::parse(authorization_url)?;
7873        let params: std::collections::BTreeMap<_, _> = parsed.query_pairs().collect();
7874        assert_eq!(
7875            params.get("client_id").map(|v| v.as_ref()),
7876            Some("client-123")
7877        );
7878        assert_eq!(
7879            params.get("redirect_uri").map(|v| v.as_ref()),
7880            Some("https://runtime.example.com/oauth/callback")
7881        );
7882        assert_eq!(
7883            params.get("scope").map(|v| v.as_ref()),
7884            Some("User.Read offline_access")
7885        );
7886        assert_eq!(params.get("prompt").map(|v| v.as_ref()), Some("consent"));
7887        let signed_state = params.get("state").expect("state");
7888        let key = crate::setup_actions::load_or_create_signing_key(temp.path())?;
7889        let payload = crate::setup_actions::validate_oauth_state(
7890            signed_state,
7891            &key,
7892            Some("messaging-example"),
7893            Some("demo"),
7894            Some("default"),
7895            crate::setup_actions::current_epoch_secs(),
7896        )?;
7897        assert_eq!(payload.action_id, "oauth");
7898        Ok(())
7899    }
7900
7901    #[test]
7902    fn advance_setup_machine_oauth_authorization_code_blocks_without_public_url()
7903    -> anyhow::Result<()> {
7904        let temp = tempfile::tempdir()?;
7905        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
7906            "version": 1,
7907            "id": "example-machine",
7908            "entry_step": "oauth",
7909            "steps": [
7910                {
7911                    "id": "oauth",
7912                    "kind": "oauth_authorization_code",
7913                    "authorize_url": "https://login.example.com/oauth2/v2.0/authorize",
7914                    "token_url": "https://login.example.com/oauth2/v2.0/token",
7915                    "callback_path": "/oauth/callback"
7916                }
7917            ]
7918        }))?;
7919
7920        let output = advance_setup_machine(
7921            temp.path(),
7922            "messaging-example",
7923            "demo",
7924            "default",
7925            &machine,
7926            false,
7927        )?;
7928
7929        assert_eq!(output["ok"], false);
7930        assert_eq!(output["result"]["detail"]["error"], "missing_prerequisite");
7931        assert_eq!(
7932            output["result"]["detail"]["missing_config_key"],
7933            "public_base_url"
7934        );
7935        let state = load_setup_machine_state(&setup_machine_state_path(
7936            temp.path(),
7937            "demo",
7938            "default",
7939            "messaging-example",
7940        ))?;
7941        assert_eq!(state.status, SetupMachineStatus::Paused);
7942        assert_eq!(state.current_step.as_deref(), Some("oauth"));
7943        Ok(())
7944    }
7945
7946    #[tokio::test]
7947    async fn complete_setup_machine_oauth_authorization_code_persists_tokens_as_secrets()
7948    -> anyhow::Result<()> {
7949        let temp = tempfile::tempdir()?;
7950        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
7951            "version": 1,
7952            "id": "example-machine",
7953            "entry_step": "oauth",
7954            "steps": [
7955                {
7956                    "id": "oauth",
7957                    "kind": "oauth_authorization_code",
7958                    "authorize_url": "https://login.example.com/oauth2/v2.0/authorize",
7959                    "token_url": "https://login.example.com/oauth2/v2.0/token",
7960                    "token_store_key": "EXAMPLE_ACCESS_TOKEN",
7961                    "refresh_token_store_key": "EXAMPLE_REFRESH_TOKEN",
7962                    "id_token_store_key": "EXAMPLE_ID_TOKEN",
7963                    "output_key": "oauth_result",
7964                    "on_success": "complete"
7965                }
7966            ]
7967        }))?;
7968        let state = SetupMachineState {
7969            schema_version: 1,
7970            provider_id: "messaging-example".to_string(),
7971            tenant: "demo".to_string(),
7972            team: "default".to_string(),
7973            machine_id: "example-machine".to_string(),
7974            machine_version: 1,
7975            status: SetupMachineStatus::Paused,
7976            current_step: Some("oauth".to_string()),
7977            completed_steps: Vec::new(),
7978            failed_step: None,
7979            answers_hash: None,
7980            pack_fingerprint: None,
7981            outputs: serde_json::json!({}),
7982            created_resources: Vec::new(),
7983            last_error: Some(serde_json::json!({
7984                "error": "manual_action_required",
7985                "reason": "oauth_authorization_required",
7986            })),
7987            updated_at: None,
7988        };
7989        write_setup_machine_state(temp.path(), &state)?;
7990        let oauth_state = crate::setup_actions::OAuthStatePayload {
7991            provider_id: "messaging-example".into(),
7992            tenant: "demo".into(),
7993            team: "default".into(),
7994            action_id: "oauth".into(),
7995            nonce: "nonce".into(),
7996            expires_at: crate::setup_actions::current_epoch_secs() + 60,
7997        };
7998
7999        let mut report = complete_setup_machine_oauth_authorization_code_for_machine(
8000            temp.path(),
8001            "dev",
8002            None,
8003            &machine,
8004            &oauth_state,
8005            &serde_json::json!({
8006                "access_token": "access-secret",
8007                "refresh_token": "refresh-secret",
8008                "id_token": "id-secret",
8009            }),
8010        )
8011        .await?;
8012
8013        report.persisted_secret_keys.sort();
8014        assert_eq!(
8015            report.persisted_secret_keys,
8016            vec![
8017                "EXAMPLE_ACCESS_TOKEN",
8018                "EXAMPLE_ID_TOKEN",
8019                "EXAMPLE_REFRESH_TOKEN"
8020            ]
8021        );
8022        let state_path =
8023            setup_machine_state_path(temp.path(), "demo", "default", "messaging-example");
8024        let stored_state = std::fs::read_to_string(&state_path)?;
8025        assert!(!stored_state.contains("access-secret"));
8026        assert!(!stored_state.contains("refresh-secret"));
8027        assert!(!stored_state.contains("id-secret"));
8028
8029        let state = load_setup_machine_state(&state_path)?;
8030        assert_eq!(state.status, SetupMachineStatus::Complete);
8031        assert_eq!(state.outputs["oauth_result"]["ok"], true);
8032
8033        let store = crate::secrets::open_dev_store(temp.path())?;
8034        for (key, expected) in [
8035            ("EXAMPLE_ACCESS_TOKEN", "access-secret"),
8036            ("EXAMPLE_REFRESH_TOKEN", "refresh-secret"),
8037            ("EXAMPLE_ID_TOKEN", "id-secret"),
8038        ] {
8039            let uri = crate::canonical_secret_uri(
8040                "dev",
8041                "demo",
8042                Some("default"),
8043                "messaging-example",
8044                key,
8045            );
8046            let bytes = store.get(&uri).await?;
8047            assert_eq!(String::from_utf8(bytes)?, expected);
8048        }
8049        Ok(())
8050    }
8051
8052    #[test]
8053    fn advance_setup_machine_oauth_device_code_starts_then_polls_complete() -> anyhow::Result<()> {
8054        let temp = tempfile::tempdir()?;
8055        let base = serve_json_sequence(vec![
8056            (
8057                200,
8058                r#"{"device_code":"raw-device-code","user_code":"ABCD-EFGH","verification_uri":"https://login.example.com/device","expires_in":900,"interval":1}"#,
8059            ),
8060            (
8061                200,
8062                r#"{"access_token":"access-token-secret","refresh_token":"refresh-token-secret"}"#,
8063            ),
8064        ]);
8065        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8066            "version": 1,
8067            "id": "example-machine",
8068            "entry_step": "device",
8069            "steps": [
8070                {
8071                    "id": "device",
8072                    "kind": "oauth_device_code",
8073                    "device_code_url": format!("{base}/device"),
8074                    "token_url": format!("{base}/token"),
8075                    "client_id": "client-123",
8076                    "scopes": ["User.Read"],
8077                    "token_store_key": "graph_access_token",
8078                    "refresh_token_store_key": "graph_refresh_token",
8079                    "on_success": "complete"
8080                }
8081            ]
8082        }))?;
8083
8084        let started = advance_setup_machine(
8085            temp.path(),
8086            "messaging-example",
8087            "demo",
8088            "default",
8089            &machine,
8090            false,
8091        )?;
8092
8093        assert_eq!(started["ok"], false);
8094        assert_eq!(
8095            started["result"]["detail"]["reason"],
8096            "oauth_device_code_pending"
8097        );
8098        let state_path =
8099            setup_machine_state_path(temp.path(), "demo", "default", "messaging-example");
8100        let raw_state = std::fs::read_to_string(&state_path)?;
8101        assert!(!raw_state.contains("raw-device-code"));
8102        assert!(!raw_state.contains("access-token-secret"));
8103        let state = load_setup_machine_state(&state_path)?;
8104        assert_eq!(state.status, SetupMachineStatus::Paused);
8105        assert_eq!(
8106            state.outputs["oauth_device_login"]["user_code"],
8107            "ABCD-EFGH"
8108        );
8109        assert!(setup_machine_oauth_device_session_path(temp.path(), &state, "device").is_file());
8110
8111        let completed = advance_setup_machine(
8112            temp.path(),
8113            "messaging-example",
8114            "demo",
8115            "default",
8116            &machine,
8117            false,
8118        )?;
8119
8120        assert_eq!(completed["ok"], true);
8121        let state = load_setup_machine_state(&state_path)?;
8122        assert_eq!(state.status, SetupMachineStatus::Complete);
8123        assert_eq!(
8124            state.outputs["oauth_device_login"]["token_store_key"],
8125            "graph_access_token"
8126        );
8127        assert!(!setup_machine_oauth_device_session_path(temp.path(), &state, "device").exists());
8128        let raw_state = std::fs::read_to_string(&state_path)?;
8129        assert!(!raw_state.contains("access-token-secret"));
8130        assert!(!raw_state.contains("refresh-token-secret"));
8131        Ok(())
8132    }
8133
8134    #[test]
8135    fn advance_setup_machine_oauth_device_code_keeps_pending_resumable() -> anyhow::Result<()> {
8136        let temp = tempfile::tempdir()?;
8137        let base = serve_json_sequence(vec![
8138            (
8139                200,
8140                r#"{"device_code":"raw-device-code","user_code":"ABCD-EFGH","verification_uri":"https://login.example.com/device","expires_in":900,"interval":1}"#,
8141            ),
8142            (200, r#"{"error":"authorization_pending"}"#),
8143        ]);
8144        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8145            "version": 1,
8146            "id": "example-machine",
8147            "entry_step": "device",
8148            "steps": [
8149                {
8150                    "id": "device",
8151                    "kind": "oauth_device_code",
8152                    "device_code_url": format!("{base}/device"),
8153                    "token_url": format!("{base}/token"),
8154                    "client_id": "client-123",
8155                    "scope": "User.Read",
8156                    "token_store_key": "graph_access_token",
8157                    "on_success": "complete"
8158                }
8159            ]
8160        }))?;
8161
8162        let _ = advance_setup_machine(
8163            temp.path(),
8164            "messaging-example",
8165            "demo",
8166            "default",
8167            &machine,
8168            false,
8169        )?;
8170        let pending = advance_setup_machine(
8171            temp.path(),
8172            "messaging-example",
8173            "demo",
8174            "default",
8175            &machine,
8176            false,
8177        )?;
8178
8179        assert_eq!(pending["ok"], false);
8180        assert_eq!(pending["result"]["detail"]["error"], "oauth_pending");
8181        let state = load_setup_machine_state(&setup_machine_state_path(
8182            temp.path(),
8183            "demo",
8184            "default",
8185            "messaging-example",
8186        ))?;
8187        assert_eq!(state.status, SetupMachineStatus::Paused);
8188        assert_eq!(state.current_step.as_deref(), Some("device"));
8189        assert!(setup_machine_oauth_device_session_path(temp.path(), &state, "device").is_file());
8190        Ok(())
8191    }
8192
8193    #[test]
8194    fn advance_setup_machine_http_json_stores_response_and_advances() -> anyhow::Result<()> {
8195        let temp = tempfile::tempdir()?;
8196        let url = serve_one_json(200, r#"{"registered":true,"tenant":"demo"}"#);
8197        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8198            "version": 1,
8199            "id": "example-machine",
8200            "entry_step": "register",
8201            "steps": [
8202                {
8203                    "id": "register",
8204                    "kind": "http_json",
8205                    "url_template": url,
8206                    "output_key": "registration",
8207                    "on_success": "complete"
8208                }
8209            ]
8210        }))?;
8211
8212        let output = advance_setup_machine(
8213            temp.path(),
8214            "messaging-example",
8215            "demo",
8216            "default",
8217            &machine,
8218            false,
8219        )?;
8220
8221        assert_eq!(output["ok"], true);
8222        let state = load_setup_machine_state(&setup_machine_state_path(
8223            temp.path(),
8224            "demo",
8225            "default",
8226            "messaging-example",
8227        ))?;
8228        assert_eq!(state.status, SetupMachineStatus::Complete);
8229        assert_eq!(state.outputs["registration"]["registered"], true);
8230        assert_eq!(state.outputs["registration"]["tenant"], "demo");
8231        Ok(())
8232    }
8233
8234    #[test]
8235    fn advance_setup_machine_http_probe_blocks_on_unexpected_status() -> anyhow::Result<()> {
8236        let temp = tempfile::tempdir()?;
8237        let url = serve_one_json(503, r#"{"ok":false}"#);
8238        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8239            "version": 1,
8240            "id": "example-machine",
8241            "entry_step": "probe",
8242            "steps": [
8243                {
8244                    "id": "probe",
8245                    "kind": "http_probe",
8246                    "url_template": url,
8247                    "expected_status": 200
8248                }
8249            ]
8250        }))?;
8251
8252        let output = advance_setup_machine(
8253            temp.path(),
8254            "messaging-example",
8255            "demo",
8256            "default",
8257            &machine,
8258            false,
8259        )?;
8260
8261        assert_eq!(output["ok"], false);
8262        assert_eq!(
8263            output["result"]["detail"]["error"],
8264            "provider_contract_error"
8265        );
8266        assert_eq!(output["result"]["detail"]["status"], 503);
8267        let state = load_setup_machine_state(&setup_machine_state_path(
8268            temp.path(),
8269            "demo",
8270            "default",
8271            "messaging-example",
8272        ))?;
8273        assert_eq!(state.status, SetupMachineStatus::Paused);
8274        assert_eq!(state.current_step.as_deref(), Some("probe"));
8275        Ok(())
8276    }
8277
8278    #[test]
8279    fn advance_setup_machine_select_from_json_auto_selects_single_option() -> anyhow::Result<()> {
8280        let temp = tempfile::tempdir()?;
8281        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8282            "version": 1,
8283            "id": "example-machine",
8284            "entry_step": "select_team",
8285            "steps": [
8286                {
8287                    "id": "select_team",
8288                    "kind": "select_from_json",
8289                    "source_output": "teams_response",
8290                    "json_pointer": "/value",
8291                    "value_key": "id",
8292                    "output_key": "team_id",
8293                    "on_success": "complete"
8294                }
8295            ]
8296        }))?;
8297        let state = SetupMachineState {
8298            schema_version: 1,
8299            provider_id: "messaging-example".to_string(),
8300            tenant: "demo".to_string(),
8301            team: "default".to_string(),
8302            machine_id: "example-machine".to_string(),
8303            machine_version: 1,
8304            status: SetupMachineStatus::Running,
8305            current_step: Some("select_team".to_string()),
8306            completed_steps: Vec::new(),
8307            failed_step: None,
8308            answers_hash: None,
8309            pack_fingerprint: None,
8310            outputs: serde_json::json!({
8311                "teams_response": {
8312                    "value": [
8313                        {"id": "team-123", "displayName": "Engineering"}
8314                    ]
8315                }
8316            }),
8317            created_resources: Vec::new(),
8318            last_error: None,
8319            updated_at: None,
8320        };
8321        write_setup_machine_state(temp.path(), &state)?;
8322
8323        let output = advance_setup_machine(
8324            temp.path(),
8325            "messaging-example",
8326            "demo",
8327            "default",
8328            &machine,
8329            false,
8330        )?;
8331
8332        assert_eq!(output["ok"], true);
8333        let state = load_setup_machine_state(&setup_machine_state_path(
8334            temp.path(),
8335            "demo",
8336            "default",
8337            "messaging-example",
8338        ))?;
8339        assert_eq!(state.status, SetupMachineStatus::Complete);
8340        assert_eq!(state.outputs["team_id"], "team-123");
8341        assert_eq!(state.outputs["team_id_item"]["displayName"], "Engineering");
8342        Ok(())
8343    }
8344
8345    #[test]
8346    fn advance_setup_machine_select_from_json_pauses_for_multiple_options() -> anyhow::Result<()> {
8347        let temp = tempfile::tempdir()?;
8348        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8349            "version": 1,
8350            "id": "example-machine",
8351            "entry_step": "select_team",
8352            "steps": [
8353                {
8354                    "id": "select_team",
8355                    "kind": "select_from_json",
8356                    "source_output": "teams_response",
8357                    "json_pointer": "/value",
8358                    "value_key": "id",
8359                    "output_key": "team_id"
8360                }
8361            ]
8362        }))?;
8363        let state = SetupMachineState {
8364            schema_version: 1,
8365            provider_id: "messaging-example".to_string(),
8366            tenant: "demo".to_string(),
8367            team: "default".to_string(),
8368            machine_id: "example-machine".to_string(),
8369            machine_version: 1,
8370            status: SetupMachineStatus::Running,
8371            current_step: Some("select_team".to_string()),
8372            completed_steps: Vec::new(),
8373            failed_step: None,
8374            answers_hash: None,
8375            pack_fingerprint: None,
8376            outputs: serde_json::json!({
8377                "teams_response": {
8378                    "value": [
8379                        {"id": "team-123", "displayName": "Engineering"},
8380                        {"id": "team-456", "displayName": "Support"}
8381                    ]
8382                }
8383            }),
8384            created_resources: Vec::new(),
8385            last_error: None,
8386            updated_at: None,
8387        };
8388        write_setup_machine_state(temp.path(), &state)?;
8389
8390        let output = advance_setup_machine(
8391            temp.path(),
8392            "messaging-example",
8393            "demo",
8394            "default",
8395            &machine,
8396            false,
8397        )?;
8398
8399        assert_eq!(output["ok"], false);
8400        assert_eq!(output["result"]["detail"]["reason"], "selection_required");
8401        assert_eq!(output["result"]["detail"]["options"], 2);
8402        let state = load_setup_machine_state(&setup_machine_state_path(
8403            temp.path(),
8404            "demo",
8405            "default",
8406            "messaging-example",
8407        ))?;
8408        assert_eq!(state.status, SetupMachineStatus::Paused);
8409        assert_eq!(state.current_step.as_deref(), Some("select_team"));
8410        Ok(())
8411    }
8412
8413    #[test]
8414    fn advance_setup_machine_generate_file_writes_artifact_and_records_resource()
8415    -> anyhow::Result<()> {
8416        let temp = tempfile::tempdir()?;
8417        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8418            "version": 1,
8419            "id": "example-machine",
8420            "entry_step": "generate",
8421            "steps": [
8422                {
8423                    "id": "generate",
8424                    "kind": "generate_file",
8425                    "artifact_path": "artifacts/app/config.json",
8426                    "output_key": "config_artifact",
8427                    "content": {
8428                        "tenant": "{tenant}",
8429                        "team": "{team}",
8430                        "provider": "{provider_id}",
8431                        "public_base_url": "{public_base_url}"
8432                    },
8433                    "on_success": "complete"
8434                }
8435            ]
8436        }))?;
8437        let state = SetupMachineState {
8438            schema_version: 1,
8439            provider_id: "messaging-example".to_string(),
8440            tenant: "demo".to_string(),
8441            team: "default".to_string(),
8442            machine_id: "example-machine".to_string(),
8443            machine_version: 1,
8444            status: SetupMachineStatus::Running,
8445            current_step: Some("generate".to_string()),
8446            completed_steps: Vec::new(),
8447            failed_step: None,
8448            answers_hash: None,
8449            pack_fingerprint: None,
8450            outputs: serde_json::json!({
8451                "public_base_url": "https://runtime.example.com"
8452            }),
8453            created_resources: Vec::new(),
8454            last_error: None,
8455            updated_at: None,
8456        };
8457        write_setup_machine_state(temp.path(), &state)?;
8458
8459        let output = advance_setup_machine(
8460            temp.path(),
8461            "messaging-example",
8462            "demo",
8463            "default",
8464            &machine,
8465            false,
8466        )?;
8467
8468        assert_eq!(output["ok"], true);
8469        let artifact_path =
8470            setup_machine_state_dir(temp.path(), "demo", "default", "messaging-example")
8471                .join("artifacts/app/config.json");
8472        let artifact = std::fs::read_to_string(artifact_path)?;
8473        let artifact: Value = serde_json::from_str(&artifact)?;
8474        assert_eq!(artifact["tenant"], "demo");
8475        assert_eq!(artifact["team"], "default");
8476        assert_eq!(artifact["provider"], "messaging-example");
8477        assert_eq!(artifact["public_base_url"], "https://runtime.example.com");
8478
8479        let state = load_setup_machine_state(&setup_machine_state_path(
8480            temp.path(),
8481            "demo",
8482            "default",
8483            "messaging-example",
8484        ))?;
8485        assert_eq!(state.status, SetupMachineStatus::Complete);
8486        assert_eq!(
8487            state.outputs["config_artifact"],
8488            "artifacts/app/config.json"
8489        );
8490        assert_eq!(
8491            state
8492                .created_resources
8493                .first()
8494                .and_then(|value| value.get("path")),
8495            Some(&Value::String("artifacts/app/config.json".to_string()))
8496        );
8497        Ok(())
8498    }
8499
8500    #[test]
8501    fn advance_setup_machine_generate_file_blocks_without_content() -> anyhow::Result<()> {
8502        let temp = tempfile::tempdir()?;
8503        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8504            "version": 1,
8505            "id": "example-machine",
8506            "entry_step": "generate",
8507            "steps": [
8508                {
8509                    "id": "generate",
8510                    "kind": "generate_file",
8511                    "artifact_path": "artifacts/app/config.json"
8512                }
8513            ]
8514        }))?;
8515
8516        let output = advance_setup_machine(
8517            temp.path(),
8518            "messaging-example",
8519            "demo",
8520            "default",
8521            &machine,
8522            false,
8523        )?;
8524
8525        assert_eq!(output["ok"], false);
8526        assert_eq!(output["result"]["detail"]["error"], "missing_prerequisite");
8527        let state = load_setup_machine_state(&setup_machine_state_path(
8528            temp.path(),
8529            "demo",
8530            "default",
8531            "messaging-example",
8532        ))?;
8533        assert_eq!(state.status, SetupMachineStatus::Paused);
8534        assert_eq!(state.current_step.as_deref(), Some("generate"));
8535        assert_eq!(
8536            state
8537                .last_error
8538                .as_ref()
8539                .and_then(|value| value.get("error")),
8540            Some(&Value::String("missing_prerequisite".to_string()))
8541        );
8542        Ok(())
8543    }
8544
8545    #[test]
8546    fn advance_setup_machine_persist_runtime_config_writes_setup_answers_and_strips_server_owned()
8547    -> anyhow::Result<()> {
8548        let temp = tempfile::tempdir()?;
8549        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8550            "version": 1,
8551            "id": "example-machine",
8552            "entry_step": "persist",
8553            "server_owned_config_keys": ["graph_access_token"],
8554            "steps": [
8555                {
8556                    "id": "persist",
8557                    "kind": "persist_runtime_config",
8558                    "config": {
8559                        "bot_app_id": "{bot_app_id}",
8560                        "team_id": "{team_id}",
8561                        "graph_access_token": "{graph_access_token}"
8562                    },
8563                    "on_success": "complete"
8564                }
8565            ]
8566        }))?;
8567        let state = SetupMachineState {
8568            schema_version: 1,
8569            provider_id: "messaging-example".to_string(),
8570            tenant: "demo".to_string(),
8571            team: "default".to_string(),
8572            machine_id: "example-machine".to_string(),
8573            machine_version: 1,
8574            status: SetupMachineStatus::Running,
8575            current_step: Some("persist".to_string()),
8576            completed_steps: Vec::new(),
8577            failed_step: None,
8578            answers_hash: None,
8579            pack_fingerprint: None,
8580            outputs: serde_json::json!({
8581                "bot_app_id": "app-123",
8582                "team_id": "team-123",
8583                "graph_access_token": "secret-token"
8584            }),
8585            created_resources: Vec::new(),
8586            last_error: None,
8587            updated_at: None,
8588        };
8589        write_setup_machine_state(temp.path(), &state)?;
8590
8591        let output = advance_setup_machine(
8592            temp.path(),
8593            "messaging-example",
8594            "demo",
8595            "default",
8596            &machine,
8597            false,
8598        )?;
8599
8600        assert_eq!(output["ok"], true);
8601        assert_eq!(output["result"]["detail"]["envelope_skipped"], true);
8602        let config_path = temp
8603            .path()
8604            .join("state/config/messaging-example/setup-answers.json");
8605        let config: Value = serde_json::from_str(&std::fs::read_to_string(config_path)?)?;
8606        assert_eq!(config["bot_app_id"], "app-123");
8607        assert_eq!(config["team_id"], "team-123");
8608        assert!(config.get("graph_access_token").is_none());
8609        let state = load_setup_machine_state(&setup_machine_state_path(
8610            temp.path(),
8611            "demo",
8612            "default",
8613            "messaging-example",
8614        ))?;
8615        assert_eq!(state.status, SetupMachineStatus::Complete);
8616        assert_eq!(
8617            state
8618                .created_resources
8619                .first()
8620                .and_then(|value| value.get("kind")),
8621            Some(&Value::String("runtime_config".to_string()))
8622        );
8623        Ok(())
8624    }
8625
8626    #[test]
8627    fn advance_setup_machine_persist_runtime_config_blocks_without_config() -> anyhow::Result<()> {
8628        let temp = tempfile::tempdir()?;
8629        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8630            "version": 1,
8631            "id": "example-machine",
8632            "entry_step": "persist",
8633            "steps": [
8634                {
8635                    "id": "persist",
8636                    "kind": "persist_runtime_config"
8637                }
8638            ]
8639        }))?;
8640
8641        let output = advance_setup_machine(
8642            temp.path(),
8643            "messaging-example",
8644            "demo",
8645            "default",
8646            &machine,
8647            false,
8648        )?;
8649
8650        assert_eq!(output["ok"], false);
8651        assert_eq!(output["result"]["detail"]["error"], "missing_prerequisite");
8652        let state = load_setup_machine_state(&setup_machine_state_path(
8653            temp.path(),
8654            "demo",
8655            "default",
8656            "messaging-example",
8657        ))?;
8658        assert_eq!(state.status, SetupMachineStatus::Paused);
8659        assert_eq!(state.current_step.as_deref(), Some("persist"));
8660        Ok(())
8661    }
8662
8663    #[test]
8664    fn advance_setup_machine_provider_component_call_blocks_without_pack_path() -> anyhow::Result<()>
8665    {
8666        let temp = tempfile::tempdir()?;
8667        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8668            "version": 1,
8669            "id": "example-machine",
8670            "entry_step": "register",
8671            "steps": [
8672                {
8673                    "id": "register",
8674                    "kind": "provider_component_call",
8675                    "component_ref": "messaging-example",
8676                    "op": "register",
8677                    "request": {"tenant": "{tenant}"}
8678                }
8679            ]
8680        }))?;
8681
8682        let output = advance_setup_machine(
8683            temp.path(),
8684            "messaging-example",
8685            "demo",
8686            "default",
8687            &machine,
8688            false,
8689        )?;
8690
8691        assert_eq!(output["ok"], false);
8692        assert_eq!(output["result"]["detail"]["error"], "missing_prerequisite");
8693        assert_eq!(
8694            output["result"]["detail"]["missing_config_key"],
8695            "pack_path"
8696        );
8697        let state = load_setup_machine_state(&setup_machine_state_path(
8698            temp.path(),
8699            "demo",
8700            "default",
8701            "messaging-example",
8702        ))?;
8703        assert_eq!(state.status, SetupMachineStatus::Paused);
8704        assert_eq!(state.current_step.as_deref(), Some("register"));
8705        Ok(())
8706    }
8707
8708    #[test]
8709    fn retry_setup_machine_step_clears_paused_error_without_losing_completed_steps()
8710    -> anyhow::Result<()> {
8711        let temp = tempfile::tempdir()?;
8712        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8713            "version": 1,
8714            "id": "example-machine",
8715            "entry_step": "collect",
8716            "steps": [
8717                {"id": "collect", "kind": "qa_form", "on_success": "manual"},
8718                {"id": "manual", "kind": "manual_action"}
8719            ]
8720        }))?;
8721        let state = SetupMachineState {
8722            schema_version: 1,
8723            provider_id: "messaging-example".to_string(),
8724            tenant: "demo".to_string(),
8725            team: "default".to_string(),
8726            machine_id: "example-machine".to_string(),
8727            machine_version: 1,
8728            status: SetupMachineStatus::Paused,
8729            current_step: Some("manual".to_string()),
8730            completed_steps: vec!["collect".to_string()],
8731            failed_step: Some("manual".to_string()),
8732            answers_hash: None,
8733            pack_fingerprint: None,
8734            outputs: serde_json::json!({}),
8735            created_resources: Vec::new(),
8736            last_error: Some(serde_json::json!({"error": "manual_action_required"})),
8737            updated_at: None,
8738        };
8739        write_setup_machine_state(temp.path(), &state)?;
8740
8741        let output = retry_setup_machine_step(
8742            temp.path(),
8743            "messaging-example",
8744            "demo",
8745            "default",
8746            &machine,
8747            None,
8748        )?;
8749
8750        assert_eq!(output["changed"], true);
8751        assert_eq!(output["step"], "manual");
8752        let loaded = load_setup_machine_state(&setup_machine_state_path(
8753            temp.path(),
8754            "demo",
8755            "default",
8756            "messaging-example",
8757        ))?;
8758        assert_eq!(loaded.status, SetupMachineStatus::Running);
8759        assert_eq!(loaded.current_step.as_deref(), Some("manual"));
8760        assert_eq!(loaded.completed_steps, vec!["collect"]);
8761        assert!(loaded.last_error.is_none());
8762        assert!(loaded.failed_step.is_none());
8763        Ok(())
8764    }
8765
8766    #[test]
8767    fn reset_setup_machine_state_archives_and_removes_machine_json() -> anyhow::Result<()> {
8768        let temp = tempfile::tempdir()?;
8769        let state = SetupMachineState {
8770            schema_version: 1,
8771            provider_id: "messaging-example".to_string(),
8772            tenant: "demo".to_string(),
8773            team: "default".to_string(),
8774            machine_id: "example-machine".to_string(),
8775            machine_version: 1,
8776            status: SetupMachineStatus::Paused,
8777            current_step: Some("manual".to_string()),
8778            completed_steps: Vec::new(),
8779            failed_step: None,
8780            answers_hash: None,
8781            pack_fingerprint: None,
8782            outputs: serde_json::json!({}),
8783            created_resources: Vec::new(),
8784            last_error: None,
8785            updated_at: None,
8786        };
8787        let state_path = write_setup_machine_state(temp.path(), &state)?;
8788
8789        let archive = reset_setup_machine_state(
8790            temp.path(),
8791            "demo",
8792            "default",
8793            "messaging-example",
8794            "manual reset",
8795        )?
8796        .expect("archive");
8797
8798        assert!(!state_path.exists());
8799        assert!(archive.is_file());
8800        let events = std::fs::read_to_string(setup_machine_events_path(
8801            temp.path(),
8802            "demo",
8803            "default",
8804            "messaging-example",
8805        ))?;
8806        assert!(events.contains("setup.machine.reset"));
8807        Ok(())
8808    }
8809
8810    #[test]
8811    fn migrate_setup_machine_state_archives_legacy_artifacts_and_initializes_machine()
8812    -> anyhow::Result<()> {
8813        let temp = tempfile::tempdir()?;
8814        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8815            "version": 1,
8816            "id": "example-machine",
8817            "entry_step": "start",
8818            "steps": [
8819                {
8820                    "id": "start",
8821                    "kind": "manual_action",
8822                    "on_success": "complete"
8823                }
8824            ]
8825        }))?;
8826        let legacy_backend = crate::setup_backend_contract::legacy_backend_state_path(
8827            temp.path(),
8828            "dev",
8829            "demo",
8830            "default",
8831            "messaging-example",
8832        )?;
8833        std::fs::create_dir_all(legacy_backend.parent().unwrap())?;
8834        std::fs::write(&legacy_backend, r#"{"config":{"bot_app_id":"old"}}"#)?;
8835        let legacy_actions = crate::setup_actions::setup_actions_state_path(
8836            temp.path(),
8837            "demo",
8838            "default",
8839            "messaging-example",
8840        );
8841        std::fs::create_dir_all(legacy_actions.parent().unwrap())?;
8842        std::fs::write(&legacy_actions, r#"{"actions":[]}"#)?;
8843
8844        let migration = migrate_setup_machine_state(
8845            temp.path(),
8846            "dev",
8847            "demo",
8848            "default",
8849            "messaging-example",
8850            &machine,
8851        )?;
8852
8853        assert_eq!(migration.source, "legacy-archived");
8854        assert!(migration.initialized);
8855        assert!(migration.legacy_backend_removed);
8856        assert!(migration.setup_actions_removed);
8857        assert!(!legacy_backend.exists());
8858        assert!(!legacy_actions.exists());
8859        assert!(migration.backend_archive_path.unwrap().is_file());
8860        assert!(migration.setup_actions_archive_path.unwrap().is_file());
8861        let state = load_setup_machine_state(&migration.state_path)?;
8862        assert_eq!(state.status, SetupMachineStatus::NotStarted);
8863        assert_eq!(state.current_step.as_deref(), Some("start"));
8864        assert!(migration.event_path.is_file());
8865
8866        let repeated = migrate_setup_machine_state(
8867            temp.path(),
8868            "dev",
8869            "demo",
8870            "default",
8871            "messaging-example",
8872            &machine,
8873        )?;
8874        assert_eq!(repeated.source, "machine");
8875        assert!(!repeated.initialized);
8876        assert!(!repeated.legacy_backend_removed);
8877        assert!(!repeated.setup_actions_removed);
8878        assert!(!repeated.empty);
8879        Ok(())
8880    }
8881}