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 crate::http_client::api_agent()
2409        .post(&device_code_url)
2410        .send_form([
2411            ("client_id", client_id.as_str()),
2412            ("scope", scopes.as_str()),
2413        ]) {
2414        Ok(response) => response,
2415        Err(err) => {
2416            return pause_oauth_device_error(
2417                step,
2418                state,
2419                "external_timeout",
2420                "OAuth device-code endpoint is not reachable",
2421                serde_json::json!({"detail": err.to_string(), "target": device_code_url}),
2422            );
2423        }
2424    };
2425    let body = match response.body_mut().read_json::<Value>() {
2426        Ok(body) => body,
2427        Err(err) => {
2428            return pause_oauth_device_error(
2429                step,
2430                state,
2431                "provider_contract_error",
2432                "OAuth device-code response was not valid JSON",
2433                serde_json::json!({"detail": err.to_string()}),
2434            );
2435        }
2436    };
2437    let device_code = match body
2438        .get("device_code")
2439        .and_then(Value::as_str)
2440        .map(str::trim)
2441    {
2442        Some(value) if !value.is_empty() => value.to_string(),
2443        _ => {
2444            return pause_oauth_device_error(
2445                step,
2446                state,
2447                "provider_contract_error",
2448                "OAuth device-code response did not include device_code",
2449                serde_json::json!({"body": public_oauth_device_response(&body)}),
2450            );
2451        }
2452    };
2453    let user_code = body
2454        .get("user_code")
2455        .and_then(Value::as_str)
2456        .map(str::trim)
2457        .filter(|value| !value.is_empty())
2458        .unwrap_or_default()
2459        .to_string();
2460    let verification_uri = body
2461        .get("verification_uri")
2462        .or_else(|| body.get("verification_url"))
2463        .and_then(Value::as_str)
2464        .map(str::trim)
2465        .filter(|value| !value.is_empty())
2466        .or_else(|| step.extra.get("verification_uri").and_then(Value::as_str))
2467        .unwrap_or_default()
2468        .to_string();
2469    if user_code.is_empty() || verification_uri.is_empty() {
2470        return pause_oauth_device_error(
2471            step,
2472            state,
2473            "provider_contract_error",
2474            "OAuth device-code response did not include user_code or verification_uri",
2475            serde_json::json!({"body": public_oauth_device_response(&body)}),
2476        );
2477    }
2478    let now = crate::setup_actions::current_epoch_secs();
2479    let expires_in = body
2480        .get("expires_in")
2481        .and_then(Value::as_u64)
2482        .unwrap_or(900);
2483    let interval = body
2484        .get("interval")
2485        .and_then(Value::as_u64)
2486        .unwrap_or(5)
2487        .max(1);
2488    let token_store_key = step
2489        .extra
2490        .get("token_store_key")
2491        .and_then(Value::as_str)
2492        .map(str::trim)
2493        .filter(|value| !value.is_empty())
2494        .unwrap_or("oauth_access_token")
2495        .to_string();
2496    let refresh_token_store_key = step
2497        .extra
2498        .get("refresh_token_store_key")
2499        .and_then(Value::as_str)
2500        .map(str::trim)
2501        .filter(|value| !value.is_empty())
2502        .map(str::to_string);
2503    let session = SetupMachineOAuthDeviceSession {
2504        step_id: step.id.clone(),
2505        provider_id: state.provider_id.clone(),
2506        tenant: state.tenant.clone(),
2507        team: state.team.clone(),
2508        device_code,
2509        client_id,
2510        token_url,
2511        token_store_key,
2512        refresh_token_store_key,
2513        interval,
2514        expires_at: now + expires_in,
2515        created_at: now,
2516    };
2517    if let Err(err) = write_setup_machine_oauth_device_session(bundle_root, state, &session) {
2518        return pause_oauth_device_error(
2519            step,
2520            state,
2521            "provider_contract_error",
2522            "OAuth device-code session could not be saved",
2523            serde_json::json!({"detail": err.to_string()}),
2524        );
2525    }
2526
2527    let output_key = step
2528        .extra
2529        .get("output_key")
2530        .and_then(Value::as_str)
2531        .unwrap_or("oauth_device_login");
2532    ensure_outputs_object(state).insert(
2533        output_key.to_string(),
2534        serde_json::json!({
2535            "verification_uri": verification_uri,
2536            "verification_uri_complete": body.get("verification_uri_complete").cloned().unwrap_or(Value::Null),
2537            "user_code": user_code,
2538            "expires_at": session.expires_at,
2539            "interval": session.interval,
2540        }),
2541    );
2542    state.status = SetupMachineStatus::Paused;
2543    state.current_step = Some(step.id.clone());
2544    state.last_error = Some(serde_json::json!({
2545        "error": "manual_action_required",
2546        "reason": "oauth_device_code_pending",
2547        "step": step.id,
2548        "recoverable": true,
2549    }));
2550    MachineStepResult {
2551        ok: false,
2552        next: "open device-code URL, enter the code, then run setup-next again".to_string(),
2553        detail: serde_json::json!({
2554            "blocked": true,
2555            "retryable": true,
2556            "error": "manual_action_required",
2557            "reason": "oauth_device_code_pending",
2558            "login": state.outputs.get(output_key).cloned().unwrap_or(Value::Null),
2559            "output_key": output_key,
2560        }),
2561    }
2562}
2563
2564fn execute_oauth_device_code_poll(
2565    bundle_root: &Path,
2566    machine: &SetupMachine,
2567    step: &SetupMachineStep,
2568    state: &mut SetupMachineState,
2569    session: SetupMachineOAuthDeviceSession,
2570) -> MachineStepResult {
2571    if crate::setup_actions::current_epoch_secs() >= session.expires_at {
2572        let _ = remove_setup_machine_oauth_device_session(bundle_root, state, &step.id);
2573        return pause_oauth_device_error(
2574            step,
2575            state,
2576            "oauth_token_expired",
2577            "OAuth device code expired; retry to start a new login",
2578            serde_json::json!({"expired": true}),
2579        );
2580    }
2581    let agent = crate::http_client::api_agent_any_status();
2582    let mut response = match agent.post(&session.token_url).send_form([
2583        ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
2584        ("client_id", session.client_id.as_str()),
2585        ("device_code", session.device_code.as_str()),
2586    ]) {
2587        Ok(response) => response,
2588        Err(err) => {
2589            return pause_oauth_device_error(
2590                step,
2591                state,
2592                "external_timeout",
2593                "OAuth token endpoint is not reachable",
2594                serde_json::json!({"detail": err.to_string(), "target": session.token_url}),
2595            );
2596        }
2597    };
2598    let status = response.status().as_u16();
2599    let body = match response.body_mut().read_json::<Value>() {
2600        Ok(body) => body,
2601        Err(err) => {
2602            return pause_oauth_device_error(
2603                step,
2604                state,
2605                "provider_contract_error",
2606                "OAuth token response was not valid JSON",
2607                serde_json::json!({"detail": err.to_string(), "status": status}),
2608            );
2609        }
2610    };
2611    if let Some(error) = body.get("error").and_then(Value::as_str)
2612        && matches!(error, "authorization_pending" | "slow_down")
2613    {
2614        state.status = SetupMachineStatus::Paused;
2615        state.current_step = Some(step.id.clone());
2616        state.last_error = Some(serde_json::json!({
2617            "error": "oauth_pending",
2618            "step": step.id,
2619            "recoverable": true,
2620        }));
2621        return MachineStepResult {
2622            ok: false,
2623            next: "OAuth authorization is still pending".to_string(),
2624            detail: serde_json::json!({
2625                "blocked": true,
2626                "retryable": true,
2627                "error": "oauth_pending",
2628                "interval": session.interval,
2629            }),
2630        };
2631    }
2632    if status >= 400 || body.get("access_token").and_then(Value::as_str).is_none() {
2633        return pause_oauth_device_error(
2634            step,
2635            state,
2636            "oauth_refresh_failed",
2637            "OAuth token polling failed",
2638            serde_json::json!({"status": status, "body": public_oauth_device_response(&body)}),
2639        );
2640    }
2641    let mut config = serde_json::Map::new();
2642    if let Some(access_token) = body.get("access_token").and_then(Value::as_str) {
2643        config.insert(
2644            session.token_store_key.clone(),
2645            Value::String(access_token.to_string()),
2646        );
2647    }
2648    if let Some(refresh_key) = session.refresh_token_store_key.as_deref()
2649        && let Some(refresh_token) = body.get("refresh_token").and_then(Value::as_str)
2650    {
2651        config.insert(
2652            refresh_key.to_string(),
2653            Value::String(refresh_token.to_string()),
2654        );
2655    }
2656    if config.is_empty() {
2657        return pause_oauth_device_error(
2658            step,
2659            state,
2660            "provider_contract_error",
2661            "OAuth token response had no persistable tokens",
2662            serde_json::json!({"status": status}),
2663        );
2664    }
2665    let config = Value::Object(config);
2666    let env = step
2667        .extra
2668        .get("env")
2669        .and_then(Value::as_str)
2670        .unwrap_or("dev");
2671    let persist_result = tokio::runtime::Runtime::new()
2672        .map_err(|err| err.to_string())
2673        .and_then(|runtime| {
2674            runtime
2675                .block_on(crate::qa::persist::persist_all_config_as_secrets(
2676                    bundle_root,
2677                    env,
2678                    &state.tenant,
2679                    Some(&state.team),
2680                    &state.provider_id,
2681                    &config,
2682                    None,
2683                ))
2684                .map_err(|err| err.to_string())
2685        });
2686    let persisted_keys = match persist_result {
2687        Ok(keys) => keys,
2688        Err(err) => {
2689            return pause_oauth_device_error(
2690                step,
2691                state,
2692                "provider_contract_error",
2693                "OAuth token secrets could not be persisted",
2694                serde_json::json!({"detail": err}),
2695            );
2696        }
2697    };
2698    let _ = remove_setup_machine_oauth_device_session(bundle_root, state, &step.id);
2699    ensure_outputs_object(state).insert(
2700        step.extra
2701            .get("output_key")
2702            .and_then(Value::as_str)
2703            .unwrap_or("oauth_device_login")
2704            .to_string(),
2705        serde_json::json!({
2706            "ok": true,
2707            "token_store_key": session.token_store_key,
2708            "persisted_keys": persisted_keys,
2709        }),
2710    );
2711    mark_machine_step_complete(machine, step, state);
2712    MachineStepResult {
2713        ok: true,
2714        next: "OAuth device-code step completed".to_string(),
2715        detail: serde_json::json!({
2716            "kind": step.kind,
2717            "persisted_keys": persisted_keys,
2718        }),
2719    }
2720}
2721
2722fn pause_oauth_device_error(
2723    step: &SetupMachineStep,
2724    state: &mut SetupMachineState,
2725    error: &str,
2726    next: &str,
2727    detail: Value,
2728) -> MachineStepResult {
2729    state.status = SetupMachineStatus::Paused;
2730    state.current_step = Some(step.id.clone());
2731    state.last_error = Some(serde_json::json!({
2732        "error": error,
2733        "step": step.id,
2734        "detail": detail,
2735        "recoverable": true,
2736    }));
2737    let mut detail_obj = serde_json::json!({
2738        "blocked": true,
2739        "retryable": true,
2740        "error": error,
2741    });
2742    if let (Some(obj), Some(extra)) = (detail_obj.as_object_mut(), detail.as_object()) {
2743        for (key, value) in extra {
2744            obj.insert(key.clone(), value.clone());
2745        }
2746    }
2747    MachineStepResult {
2748        ok: false,
2749        next: next.to_string(),
2750        detail: detail_obj,
2751    }
2752}
2753
2754fn oauth_device_endpoint(
2755    step: &SetupMachineStep,
2756    direct_key: &str,
2757    authority_suffix: &str,
2758) -> anyhow::Result<String> {
2759    if let Some(value) = step
2760        .extra
2761        .get(direct_key)
2762        .or_else(|| step.extra.get(&format!("{direct_key}_template")))
2763        .and_then(Value::as_str)
2764        .map(str::trim)
2765        .filter(|value| !value.is_empty())
2766    {
2767        return Ok(value.to_string());
2768    }
2769    let authority = step
2770        .extra
2771        .get("authority_url_template")
2772        .or_else(|| step.extra.get("authority_url"))
2773        .and_then(Value::as_str)
2774        .map(str::trim)
2775        .filter(|value| !value.is_empty())
2776        .ok_or_else(|| anyhow::anyhow!("missing {direct_key}"))?;
2777    Ok(format!(
2778        "{}/oauth2/v2.0/{}",
2779        authority.trim_end_matches('/'),
2780        authority_suffix
2781    ))
2782}
2783
2784fn public_oauth_device_response(body: &Value) -> Value {
2785    let mut public = serde_json::Map::new();
2786    for key in [
2787        "user_code",
2788        "verification_uri",
2789        "verification_url",
2790        "verification_uri_complete",
2791        "expires_in",
2792        "interval",
2793        "message",
2794        "error",
2795        "error_description",
2796    ] {
2797        if let Some(value) = body.get(key) {
2798            public.insert(key.to_string(), value.clone());
2799        }
2800    }
2801    Value::Object(public)
2802}
2803
2804fn setup_machine_oauth_device_session_path(
2805    bundle_root: &Path,
2806    state: &SetupMachineState,
2807    step_id: &str,
2808) -> PathBuf {
2809    setup_machine_state_dir(bundle_root, &state.tenant, &state.team, &state.provider_id)
2810        .join("oauth-device-sessions")
2811        .join(format!("{step_id}.json"))
2812}
2813
2814fn load_setup_machine_oauth_device_session(
2815    bundle_root: &Path,
2816    state: &SetupMachineState,
2817    step_id: &str,
2818) -> anyhow::Result<Option<SetupMachineOAuthDeviceSession>> {
2819    let path = setup_machine_oauth_device_session_path(bundle_root, state, step_id);
2820    if !path.is_file() {
2821        return Ok(None);
2822    }
2823    let raw = std::fs::read_to_string(&path)?;
2824    Ok(Some(serde_json::from_str(&raw)?))
2825}
2826
2827fn write_setup_machine_oauth_device_session(
2828    bundle_root: &Path,
2829    state: &SetupMachineState,
2830    session: &SetupMachineOAuthDeviceSession,
2831) -> anyhow::Result<PathBuf> {
2832    let path = setup_machine_oauth_device_session_path(bundle_root, state, &session.step_id);
2833    if let Some(parent) = path.parent() {
2834        std::fs::create_dir_all(parent)?;
2835    }
2836    std::fs::write(&path, serde_json::to_vec_pretty(session)?)?;
2837    #[cfg(unix)]
2838    {
2839        use std::os::unix::fs::PermissionsExt;
2840        let mut permissions = std::fs::metadata(&path)?.permissions();
2841        permissions.set_mode(0o600);
2842        std::fs::set_permissions(&path, permissions)?;
2843    }
2844    Ok(path)
2845}
2846
2847fn remove_setup_machine_oauth_device_session(
2848    bundle_root: &Path,
2849    state: &SetupMachineState,
2850    step_id: &str,
2851) -> anyhow::Result<()> {
2852    let path = setup_machine_oauth_device_session_path(bundle_root, state, step_id);
2853    if path.exists() {
2854        std::fs::remove_file(path)?;
2855    }
2856    Ok(())
2857}
2858
2859fn execute_oauth_authorization_code_step(
2860    bundle_root: &Path,
2861    _machine: &SetupMachine,
2862    step: &SetupMachineStep,
2863    state: &mut SetupMachineState,
2864) -> MachineStepResult {
2865    let authorization_url = match build_oauth_authorization_url(bundle_root, step, state) {
2866        Ok(url) => url,
2867        Err(OauthAuthorizationError::Missing(key)) => {
2868            state.status = SetupMachineStatus::Paused;
2869            state.current_step = Some(step.id.clone());
2870            state.last_error = Some(serde_json::json!({
2871                "error": "missing_prerequisite",
2872                "step": step.id,
2873                "missing": key,
2874                "recoverable": true,
2875            }));
2876            return MachineStepResult {
2877                ok: false,
2878                next: "provide OAuth authorization prerequisites, then retry".to_string(),
2879                detail: serde_json::json!({
2880                    "blocked": true,
2881                    "retryable": true,
2882                    "error": "missing_prerequisite",
2883                    "missing_config_key": key,
2884                }),
2885            };
2886        }
2887        Err(OauthAuthorizationError::Invalid(detail)) => {
2888            state.status = SetupMachineStatus::Paused;
2889            state.current_step = Some(step.id.clone());
2890            state.last_error = Some(serde_json::json!({
2891                "error": "invalid_answer",
2892                "step": step.id,
2893                "detail": detail,
2894                "recoverable": true,
2895            }));
2896            return MachineStepResult {
2897                ok: false,
2898                next: "fix OAuth authorization URL inputs, then retry".to_string(),
2899                detail: serde_json::json!({
2900                    "blocked": true,
2901                    "retryable": true,
2902                    "error": "invalid_answer",
2903                    "detail": detail,
2904                }),
2905            };
2906        }
2907    };
2908
2909    let output_key = step
2910        .extra
2911        .get("output_key")
2912        .and_then(Value::as_str)
2913        .unwrap_or("authorization_url");
2914    ensure_outputs_object(state).insert(
2915        output_key.to_string(),
2916        Value::String(authorization_url.clone()),
2917    );
2918    state.status = SetupMachineStatus::Paused;
2919    state.current_step = Some(step.id.clone());
2920    state.last_error = Some(serde_json::json!({
2921        "error": "manual_action_required",
2922        "reason": "oauth_authorization_required",
2923        "step": step.id,
2924        "recoverable": true,
2925    }));
2926    MachineStepResult {
2927        ok: false,
2928        next: "open authorization URL and complete OAuth callback".to_string(),
2929        detail: serde_json::json!({
2930            "blocked": true,
2931            "retryable": true,
2932            "error": "manual_action_required",
2933            "reason": "oauth_authorization_required",
2934            "authorization_url": authorization_url,
2935            "output_key": output_key,
2936            "kind": step.kind,
2937        }),
2938    }
2939}
2940
2941#[derive(Debug)]
2942enum OauthAuthorizationError {
2943    Missing(&'static str),
2944    Invalid(String),
2945}
2946
2947impl std::fmt::Display for OauthAuthorizationError {
2948    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2949        match self {
2950            Self::Missing(key) => write!(f, "missing {key}"),
2951            Self::Invalid(detail) => f.write_str(detail),
2952        }
2953    }
2954}
2955
2956impl std::error::Error for OauthAuthorizationError {}
2957
2958fn build_oauth_authorization_url(
2959    bundle_root: &Path,
2960    step: &SetupMachineStep,
2961    state: &SetupMachineState,
2962) -> Result<String, OauthAuthorizationError> {
2963    let template = step
2964        .extra
2965        .get("authorize_url")
2966        .or_else(|| step.extra.get("authorization_url"))
2967        .or_else(|| step.extra.get("auth_url"))
2968        .and_then(Value::as_str)
2969        .map(str::trim)
2970        .filter(|value| !value.is_empty())
2971        .ok_or(OauthAuthorizationError::Missing("authorize_url"))?;
2972    let expanded = expand_machine_template(template, state)
2973        .map_err(|err| OauthAuthorizationError::Invalid(err.to_string()))?;
2974    let mut parsed = url::Url::parse(&expanded)
2975        .map_err(|err| OauthAuthorizationError::Invalid(err.to_string()))?;
2976    if parsed.scheme() != "https" || parsed.host_str().is_none() {
2977        return Err(OauthAuthorizationError::Invalid(
2978            "authorize_url must be an https URL with a host".to_string(),
2979        ));
2980    }
2981
2982    let key = crate::setup_actions::load_or_create_signing_key(bundle_root)
2983        .map_err(|err| OauthAuthorizationError::Invalid(err.to_string()))?;
2984    let ttl = step
2985        .extra
2986        .get("state_ttl_secs")
2987        .and_then(Value::as_u64)
2988        .unwrap_or(15 * 60);
2989    let payload = crate::setup_actions::OAuthStatePayload {
2990        provider_id: state.provider_id.clone(),
2991        tenant: state.tenant.clone(),
2992        team: state.team.clone(),
2993        action_id: step.id.clone(),
2994        nonce: oauth_nonce(),
2995        expires_at: crate::setup_actions::current_epoch_secs() + ttl.max(1),
2996    };
2997    let signed_state = crate::setup_actions::sign_oauth_state(&payload, &key)
2998        .map_err(|err| OauthAuthorizationError::Invalid(err.to_string()))?;
2999
3000    append_oauth_query_param(&mut parsed, "response_type", oauth_response_type(step));
3001    if let Some(client_id) = oauth_step_value(step, state, "client_id", "client_id_output")? {
3002        append_oauth_query_param(&mut parsed, "client_id", &client_id);
3003    }
3004    if let Some(redirect_uri) = oauth_redirect_uri(bundle_root, step, state)? {
3005        append_oauth_query_param(&mut parsed, "redirect_uri", &redirect_uri);
3006    }
3007    if let Some(scope) = oauth_scope(step)? {
3008        append_oauth_query_param(&mut parsed, "scope", &scope);
3009    }
3010    append_oauth_query_param(&mut parsed, "state", &signed_state);
3011    if let Some(extra_params) = step.extra.get("params").and_then(Value::as_object) {
3012        let expanded = expand_machine_value_templates(Value::Object(extra_params.clone()), state);
3013        if let Some(params) = expanded.as_object() {
3014            for (key, value) in params {
3015                if let Some(value) = value.as_str() {
3016                    append_oauth_query_param(&mut parsed, key, value);
3017                }
3018            }
3019        }
3020    }
3021    Ok(parsed.to_string())
3022}
3023
3024fn exchange_setup_machine_oauth_authorization_code(
3025    bundle_root: &Path,
3026    machine: &SetupMachine,
3027    oauth_state: &crate::setup_actions::OAuthStatePayload,
3028    code: &str,
3029) -> anyhow::Result<Value> {
3030    let state = load_or_init_setup_machine_state(
3031        bundle_root,
3032        &oauth_state.provider_id,
3033        &oauth_state.tenant,
3034        &oauth_state.team,
3035        machine,
3036    )?;
3037    let step = machine
3038        .steps
3039        .iter()
3040        .find(|candidate| candidate.id == oauth_state.action_id)
3041        .ok_or_else(|| {
3042            anyhow::anyhow!("setup-machine step not found: {}", oauth_state.action_id)
3043        })?;
3044    if step.kind != "oauth_authorization_code" {
3045        anyhow::bail!(
3046            "setup-machine step is not an oauth_authorization_code step: {}",
3047            step.id
3048        );
3049    }
3050    let token_url = oauth_authorization_token_url(step, &state)?;
3051    let redirect_uri = oauth_redirect_uri(bundle_root, step, &state)?
3052        .ok_or(OauthAuthorizationError::Missing("redirect_uri"))?;
3053    let client_id = oauth_step_value(step, &state, "client_id", "client_id_output")?
3054        .ok_or(OauthAuthorizationError::Missing("client_id"))?;
3055    let client_secret = oauth_step_value(step, &state, "client_secret", "client_secret_output")?;
3056
3057    let mut form = vec![
3058        ("grant_type".to_string(), "authorization_code".to_string()),
3059        ("code".to_string(), code.to_string()),
3060        ("redirect_uri".to_string(), redirect_uri),
3061        ("client_id".to_string(), client_id),
3062    ];
3063    if let Some(client_secret) = client_secret {
3064        form.push(("client_secret".to_string(), client_secret));
3065    }
3066    let agent = crate::http_client::api_agent_any_status();
3067    let mut response = agent
3068        .post(&token_url)
3069        .send_form(form)
3070        .context("OAuth token exchange failed")?;
3071    let status = response.status().as_u16();
3072    let body = response
3073        .body_mut()
3074        .read_json::<Value>()
3075        .context("failed to parse OAuth token response")?;
3076    if status >= 400 {
3077        anyhow::bail!("OAuth token exchange failed with status {status}: {body}");
3078    }
3079    Ok(body)
3080}
3081
3082fn oauth_nonce() -> String {
3083    use base64::Engine;
3084    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(rand::random::<[u8; 16]>())
3085}
3086
3087fn oauth_response_type(step: &SetupMachineStep) -> &str {
3088    step.extra
3089        .get("response_type")
3090        .and_then(Value::as_str)
3091        .map(str::trim)
3092        .filter(|value| !value.is_empty())
3093        .unwrap_or("code")
3094}
3095
3096fn oauth_step_value(
3097    step: &SetupMachineStep,
3098    state: &SetupMachineState,
3099    literal_key: &'static str,
3100    output_key_key: &'static str,
3101) -> Result<Option<String>, OauthAuthorizationError> {
3102    if let Some(value) = step
3103        .extra
3104        .get(literal_key)
3105        .and_then(Value::as_str)
3106        .map(str::trim)
3107        .filter(|value| !value.is_empty())
3108    {
3109        return expand_machine_template(value, state)
3110            .map(Some)
3111            .map_err(|err| OauthAuthorizationError::Invalid(err.to_string()));
3112    }
3113    let output_key = step
3114        .extra
3115        .get(output_key_key)
3116        .and_then(Value::as_str)
3117        .unwrap_or(literal_key);
3118    Ok(output_string(&state.outputs, output_key))
3119}
3120
3121fn oauth_redirect_uri(
3122    bundle_root: &Path,
3123    step: &SetupMachineStep,
3124    state: &SetupMachineState,
3125) -> Result<Option<String>, OauthAuthorizationError> {
3126    if let Some(value) = step
3127        .extra
3128        .get("redirect_uri")
3129        .or_else(|| step.extra.get("redirect_uri_template"))
3130        .and_then(Value::as_str)
3131        .map(str::trim)
3132        .filter(|value| !value.is_empty())
3133    {
3134        return expand_machine_template(value, state)
3135            .map(Some)
3136            .map_err(|err| OauthAuthorizationError::Invalid(err.to_string()));
3137    }
3138    let Some(callback_path) = step
3139        .extra
3140        .get("callback_path")
3141        .and_then(Value::as_str)
3142        .map(str::trim)
3143        .filter(|value| !value.is_empty())
3144    else {
3145        return Ok(None);
3146    };
3147    let public_base_url = output_string(&state.outputs, "public_base_url")
3148        .or_else(|| {
3149            crate::platform_setup::load_effective_static_routes_defaults(
3150                bundle_root,
3151                &state.tenant,
3152                Some(&state.team),
3153            )
3154            .ok()
3155            .flatten()
3156            .and_then(|policy| policy.public_base_url)
3157        })
3158        .ok_or(OauthAuthorizationError::Missing("public_base_url"))?;
3159    Ok(Some(format!(
3160        "{}{}",
3161        public_base_url.trim_end_matches('/'),
3162        ensure_leading_slash(callback_path)
3163    )))
3164}
3165
3166fn oauth_authorization_token_url(
3167    step: &SetupMachineStep,
3168    state: &SetupMachineState,
3169) -> Result<String, OauthAuthorizationError> {
3170    let template = step
3171        .extra
3172        .get("token_url")
3173        .or_else(|| step.extra.get("token_url_template"))
3174        .and_then(Value::as_str)
3175        .map(str::trim)
3176        .filter(|value| !value.is_empty())
3177        .ok_or(OauthAuthorizationError::Missing("token_url"))?;
3178    let token_url = expand_machine_template(template, state)
3179        .map_err(|err| OauthAuthorizationError::Invalid(err.to_string()))?;
3180    let parsed = url::Url::parse(&token_url)
3181        .map_err(|err| OauthAuthorizationError::Invalid(err.to_string()))?;
3182    if !(parsed.scheme() == "https" && parsed.host_str().is_some()
3183        || parsed.scheme() == "http"
3184            && matches!(parsed.host_str(), Some("127.0.0.1" | "localhost" | "::1")))
3185    {
3186        return Err(OauthAuthorizationError::Invalid(
3187            "token_url must be https or loopback http".to_string(),
3188        ));
3189    }
3190    Ok(token_url)
3191}
3192
3193fn oauth_scope(step: &SetupMachineStep) -> Result<Option<String>, OauthAuthorizationError> {
3194    if let Some(scope) = step.extra.get("scope").and_then(Value::as_str) {
3195        let scope = scope.trim();
3196        return Ok((!scope.is_empty()).then(|| scope.to_string()));
3197    }
3198    if let Some(scopes) = step.extra.get("scopes").and_then(Value::as_array) {
3199        let scope = scopes
3200            .iter()
3201            .filter_map(Value::as_str)
3202            .map(str::trim)
3203            .filter(|value| !value.is_empty())
3204            .collect::<Vec<_>>()
3205            .join(" ");
3206        return Ok((!scope.is_empty()).then_some(scope));
3207    }
3208    Ok(None)
3209}
3210
3211fn append_oauth_query_param(parsed: &mut url::Url, key: &str, value: &str) {
3212    if value.trim().is_empty() || parsed.query_pairs().any(|(existing, _)| existing == key) {
3213        return;
3214    }
3215    parsed.query_pairs_mut().append_pair(key, value);
3216}
3217
3218fn map_oauth_authorization_token_response(
3219    step: &SetupMachineStep,
3220    token_response: &Value,
3221) -> anyhow::Result<serde_json::Map<String, Value>> {
3222    let mut config = serde_json::Map::new();
3223    if let Some(map) = step
3224        .extra
3225        .get("response_secret_map")
3226        .and_then(Value::as_object)
3227    {
3228        for (response_key, secret_key) in map {
3229            let Some(secret_key) = secret_key
3230                .as_str()
3231                .map(str::trim)
3232                .filter(|value| !value.is_empty())
3233            else {
3234                anyhow::bail!(
3235                    "setup-machine OAuth response_secret_map value must be a non-empty string: {response_key}"
3236                );
3237            };
3238            if let Some(value) = token_response.get(response_key).and_then(Value::as_str)
3239                && !value.trim().is_empty()
3240            {
3241                config.insert(secret_key.to_string(), Value::String(value.to_string()));
3242            }
3243        }
3244    } else {
3245        insert_oauth_token_field(
3246            &mut config,
3247            token_response,
3248            "access_token",
3249            step.extra
3250                .get("token_store_key")
3251                .and_then(Value::as_str)
3252                .unwrap_or("oauth_access_token"),
3253        );
3254        if let Some(secret_key) = step
3255            .extra
3256            .get("refresh_token_store_key")
3257            .and_then(Value::as_str)
3258        {
3259            insert_oauth_token_field(&mut config, token_response, "refresh_token", secret_key);
3260        }
3261        if let Some(secret_key) = step.extra.get("id_token_store_key").and_then(Value::as_str) {
3262            insert_oauth_token_field(&mut config, token_response, "id_token", secret_key);
3263        }
3264    }
3265
3266    if config.is_empty() {
3267        anyhow::bail!("OAuth token response had no persistable tokens");
3268    }
3269    Ok(config)
3270}
3271
3272fn insert_oauth_token_field(
3273    config: &mut serde_json::Map<String, Value>,
3274    token_response: &Value,
3275    response_key: &str,
3276    secret_key: &str,
3277) {
3278    let secret_key = secret_key.trim();
3279    if secret_key.is_empty() {
3280        return;
3281    }
3282    if let Some(value) = token_response.get(response_key).and_then(Value::as_str)
3283        && !value.trim().is_empty()
3284    {
3285        config.insert(secret_key.to_string(), Value::String(value.to_string()));
3286    }
3287}
3288
3289fn ensure_leading_slash(value: &str) -> String {
3290    if value.starts_with('/') {
3291        value.to_string()
3292    } else {
3293        format!("/{value}")
3294    }
3295}
3296
3297fn execute_select_from_json_step(
3298    machine: &SetupMachine,
3299    step: &SetupMachineStep,
3300    state: &mut SetupMachineState,
3301) -> MachineStepResult {
3302    let Some(source_output) = step
3303        .extra
3304        .get("source_output")
3305        .or_else(|| step.extra.get("source"))
3306        .and_then(Value::as_str)
3307        .map(str::trim)
3308        .filter(|value| !value.is_empty())
3309    else {
3310        return pause_select_from_json(
3311            step,
3312            state,
3313            "missing_prerequisite",
3314            "source_output",
3315            "declare source_output, then retry",
3316        );
3317    };
3318    let Some(source) = state.outputs.get(source_output) else {
3319        return pause_select_from_json(
3320            step,
3321            state,
3322            "missing_prerequisite",
3323            source_output,
3324            "run prerequisite JSON step, then retry",
3325        );
3326    };
3327    let selected_scope = match select_json_scope(source, step) {
3328        Some(value) => value,
3329        None => {
3330            return pause_select_from_json(
3331                step,
3332                state,
3333                "missing_prerequisite",
3334                "json_path",
3335                "fix source JSON path, then retry",
3336            );
3337        }
3338    };
3339
3340    let selected = match choose_json_selection(selected_scope, step, state) {
3341        Ok(selected) => selected,
3342        Err(SelectFromJsonError::SelectionRequired(count)) => {
3343            state.status = SetupMachineStatus::Paused;
3344            state.current_step = Some(step.id.clone());
3345            state.last_error = Some(serde_json::json!({
3346                "error": "manual_action_required",
3347                "reason": "selection_required",
3348                "step": step.id,
3349                "options": count,
3350                "recoverable": true,
3351            }));
3352            return MachineStepResult {
3353                ok: false,
3354                next: "operator selection required".to_string(),
3355                detail: serde_json::json!({
3356                    "blocked": true,
3357                    "retryable": true,
3358                    "error": "manual_action_required",
3359                    "reason": "selection_required",
3360                    "options": count,
3361                    "source_output": source_output,
3362                }),
3363            };
3364        }
3365        Err(SelectFromJsonError::InvalidSelection(detail)) => {
3366            state.status = SetupMachineStatus::Paused;
3367            state.current_step = Some(step.id.clone());
3368            state.last_error = Some(serde_json::json!({
3369                "error": "invalid_answer",
3370                "step": step.id,
3371                "detail": detail,
3372                "recoverable": true,
3373            }));
3374            return MachineStepResult {
3375                ok: false,
3376                next: "fix selected JSON value, then retry".to_string(),
3377                detail: serde_json::json!({
3378                    "blocked": true,
3379                    "retryable": true,
3380                    "error": "invalid_answer",
3381                    "detail": detail,
3382                }),
3383            };
3384        }
3385    };
3386
3387    let output_key = step
3388        .extra
3389        .get("output_key")
3390        .and_then(Value::as_str)
3391        .unwrap_or(step.id.as_str());
3392    let value_key = step.extra.get("value_key").and_then(Value::as_str);
3393    let stored = value_key
3394        .and_then(|key| selected.get(key))
3395        .cloned()
3396        .unwrap_or_else(|| selected.clone());
3397    let outputs = ensure_outputs_object(state);
3398    outputs.insert(output_key.to_string(), stored.clone());
3399    if selected.is_object() {
3400        outputs.insert(format!("{output_key}_item"), selected.clone());
3401    }
3402    mark_machine_step_complete(machine, step, state);
3403    MachineStepResult {
3404        ok: true,
3405        next: "JSON selection completed".to_string(),
3406        detail: serde_json::json!({
3407            "kind": step.kind,
3408            "source_output": source_output,
3409            "output_key": output_key,
3410            "selected": redact_large_json_detail(&stored),
3411        }),
3412    }
3413}
3414
3415#[derive(Debug)]
3416enum SelectFromJsonError {
3417    SelectionRequired(usize),
3418    InvalidSelection(String),
3419}
3420
3421fn pause_select_from_json(
3422    step: &SetupMachineStep,
3423    state: &mut SetupMachineState,
3424    error: &str,
3425    missing: &str,
3426    next: &str,
3427) -> MachineStepResult {
3428    state.status = SetupMachineStatus::Paused;
3429    state.current_step = Some(step.id.clone());
3430    state.last_error = Some(serde_json::json!({
3431        "error": error,
3432        "step": step.id,
3433        "missing": missing,
3434        "recoverable": true,
3435    }));
3436    MachineStepResult {
3437        ok: false,
3438        next: next.to_string(),
3439        detail: serde_json::json!({
3440            "blocked": true,
3441            "retryable": true,
3442            "error": error,
3443            "missing_config_key": missing,
3444        }),
3445    }
3446}
3447
3448fn select_json_scope<'a>(source: &'a Value, step: &SetupMachineStep) -> Option<&'a Value> {
3449    let Some(path) = step
3450        .extra
3451        .get("json_pointer")
3452        .or_else(|| step.extra.get("path"))
3453        .or_else(|| step.extra.get("json_path"))
3454        .and_then(Value::as_str)
3455        .map(str::trim)
3456        .filter(|value| !value.is_empty())
3457    else {
3458        return Some(source);
3459    };
3460    if path.starts_with('/') {
3461        source.pointer(path)
3462    } else {
3463        let mut current = source;
3464        for segment in path.split('.').filter(|segment| !segment.is_empty()) {
3465            current = current.get(segment)?;
3466        }
3467        Some(current)
3468    }
3469}
3470
3471fn choose_json_selection(
3472    value: &Value,
3473    step: &SetupMachineStep,
3474    state: &SetupMachineState,
3475) -> Result<Value, SelectFromJsonError> {
3476    let Some(items) = value.as_array() else {
3477        return Ok(value.clone());
3478    };
3479    if items.is_empty() {
3480        return Err(SelectFromJsonError::InvalidSelection(
3481            "source JSON selection array is empty".to_string(),
3482        ));
3483    }
3484    if let Some(index) = step.extra.get("selected_index").and_then(Value::as_u64) {
3485        return items.get(index as usize).cloned().ok_or_else(|| {
3486            SelectFromJsonError::InvalidSelection(format!("selected_index {index} is out of range"))
3487        });
3488    }
3489    if let Some(value) = step
3490        .extra
3491        .get("selected_value")
3492        .or_else(|| step.extra.get("default_value"))
3493    {
3494        return find_json_selection_by_value(items, step, value).ok_or_else(|| {
3495            SelectFromJsonError::InvalidSelection(
3496                "selected_value did not match any option".to_string(),
3497            )
3498        });
3499    }
3500    let choice_key = step
3501        .extra
3502        .get("choice_output_key")
3503        .and_then(Value::as_str)
3504        .unwrap_or(step.id.as_str());
3505    if let Some(value) = state.outputs.get(choice_key) {
3506        return find_json_selection_by_value(items, step, value).ok_or_else(|| {
3507            SelectFromJsonError::InvalidSelection(format!(
3508                "choice output `{choice_key}` did not match any option"
3509            ))
3510        });
3511    }
3512    if items.len() == 1 {
3513        return Ok(items[0].clone());
3514    }
3515    Err(SelectFromJsonError::SelectionRequired(items.len()))
3516}
3517
3518fn find_json_selection_by_value(
3519    items: &[Value],
3520    step: &SetupMachineStep,
3521    selected_value: &Value,
3522) -> Option<Value> {
3523    let value_key = step.extra.get("value_key").and_then(Value::as_str);
3524    items.iter().find_map(|item| {
3525        let candidate = value_key.and_then(|key| item.get(key)).unwrap_or(item);
3526        values_match_for_selection(candidate, selected_value).then(|| item.clone())
3527    })
3528}
3529
3530fn values_match_for_selection(candidate: &Value, selected_value: &Value) -> bool {
3531    if candidate == selected_value {
3532        return true;
3533    }
3534    match (candidate, selected_value) {
3535        (Value::String(left), Value::String(right)) => left == right,
3536        (Value::String(left), other) => other.as_str() == Some(left.as_str()),
3537        (other, Value::String(right)) => other.as_str() == Some(right.as_str()),
3538        _ => false,
3539    }
3540}
3541
3542fn redact_large_json_detail(value: &Value) -> Value {
3543    match value {
3544        Value::Array(items) if items.len() > 10 => serde_json::json!({
3545            "kind": "array",
3546            "len": items.len(),
3547        }),
3548        Value::Object(map) if map.len() > 20 => serde_json::json!({
3549            "kind": "object",
3550            "keys": map.len(),
3551        }),
3552        other => other.clone(),
3553    }
3554}
3555
3556fn execute_generate_file_step(
3557    bundle_root: &Path,
3558    machine: &SetupMachine,
3559    step: &SetupMachineStep,
3560    state: &mut SetupMachineState,
3561) -> MachineStepResult {
3562    let Some(artifact_path) = setup_artifact_relative_path(step) else {
3563        state.status = SetupMachineStatus::Paused;
3564        state.current_step = Some(step.id.clone());
3565        state.last_error = Some(serde_json::json!({
3566            "error": "missing_prerequisite",
3567            "step": step.id,
3568            "missing": "artifact_path",
3569            "recoverable": true,
3570        }));
3571        return MachineStepResult {
3572            ok: false,
3573            next: "declare artifact_path, then retry".to_string(),
3574            detail: serde_json::json!({
3575                "blocked": true,
3576                "retryable": true,
3577                "error": "missing_prerequisite",
3578                "missing_config_key": "artifact_path",
3579            }),
3580        };
3581    };
3582    if !is_safe_setup_artifact_path(artifact_path) {
3583        state.status = SetupMachineStatus::Paused;
3584        state.current_step = Some(step.id.clone());
3585        state.last_error = Some(serde_json::json!({
3586            "error": "provider_contract_error",
3587            "step": step.id,
3588            "artifact_path": artifact_path,
3589            "recoverable": true,
3590        }));
3591        return MachineStepResult {
3592            ok: false,
3593            next: "fix setup artifact path, then retry".to_string(),
3594            detail: serde_json::json!({
3595                "blocked": true,
3596                "retryable": true,
3597                "error": "provider_contract_error",
3598                "artifact_path": artifact_path,
3599            }),
3600        };
3601    }
3602
3603    let rendered = match render_generate_file_content(step, state) {
3604        Ok(rendered) => rendered,
3605        Err(err) => {
3606            state.status = SetupMachineStatus::Paused;
3607            state.current_step = Some(step.id.clone());
3608            state.last_error = Some(serde_json::json!({
3609                "error": "missing_prerequisite",
3610                "step": step.id,
3611                "detail": err.to_string(),
3612                "recoverable": true,
3613            }));
3614            return MachineStepResult {
3615                ok: false,
3616                next: "provide file content inputs, then retry".to_string(),
3617                detail: serde_json::json!({
3618                    "blocked": true,
3619                    "retryable": true,
3620                    "error": "missing_prerequisite",
3621                    "detail": err.to_string(),
3622                }),
3623            };
3624        }
3625    };
3626
3627    let artifact_file = resolve_setup_artifact_path(bundle_root, state, artifact_path);
3628    if let Some(parent) = artifact_file.parent()
3629        && let Err(err) = std::fs::create_dir_all(parent)
3630    {
3631        return pause_generate_file_write_error(step, state, artifact_path, err);
3632    }
3633    if let Err(err) = std::fs::write(&artifact_file, rendered.as_bytes()) {
3634        return pause_generate_file_write_error(step, state, artifact_path, err);
3635    }
3636
3637    let output_key = step
3638        .extra
3639        .get("output_key")
3640        .and_then(Value::as_str)
3641        .unwrap_or(step.id.as_str());
3642    ensure_outputs_object(state).insert(
3643        output_key.to_string(),
3644        Value::String(artifact_path.to_string()),
3645    );
3646    state.created_resources.push(serde_json::json!({
3647        "kind": "setup_artifact",
3648        "step": step.id,
3649        "path": artifact_path,
3650    }));
3651    mark_machine_step_complete(machine, step, state);
3652    MachineStepResult {
3653        ok: true,
3654        next: "setup artifact generated".to_string(),
3655        detail: serde_json::json!({
3656            "kind": step.kind,
3657            "artifact_path": artifact_path,
3658            "bytes": rendered.len(),
3659            "output_key": output_key,
3660        }),
3661    }
3662}
3663
3664fn execute_persist_runtime_config_step(
3665    bundle_root: &Path,
3666    pack_path: Option<&Path>,
3667    machine: &SetupMachine,
3668    step: &SetupMachineStep,
3669    state: &mut SetupMachineState,
3670) -> MachineStepResult {
3671    let config = match render_runtime_config(machine, step, state) {
3672        Ok(config) => config,
3673        Err(err) => {
3674            state.status = SetupMachineStatus::Paused;
3675            state.current_step = Some(step.id.clone());
3676            state.last_error = Some(serde_json::json!({
3677                "error": "missing_prerequisite",
3678                "step": step.id,
3679                "detail": err.to_string(),
3680                "recoverable": true,
3681            }));
3682            return MachineStepResult {
3683                ok: false,
3684                next: "provide runtime config inputs, then retry".to_string(),
3685                detail: serde_json::json!({
3686                    "blocked": true,
3687                    "retryable": true,
3688                    "error": "missing_prerequisite",
3689                    "detail": err.to_string(),
3690                }),
3691            };
3692        }
3693    };
3694
3695    let config_dir = bundle_root
3696        .join("state")
3697        .join("config")
3698        .join(&state.provider_id);
3699    let config_path = config_dir.join("setup-answers.json");
3700    if let Some(parent) = config_path.parent()
3701        && let Err(err) = std::fs::create_dir_all(parent)
3702    {
3703        return pause_runtime_config_write_error(step, state, "setup_answers", err);
3704    }
3705    let bytes = match serde_json::to_vec_pretty(&config) {
3706        Ok(bytes) => bytes,
3707        Err(err) => {
3708            state.status = SetupMachineStatus::Paused;
3709            state.current_step = Some(step.id.clone());
3710            state.last_error = Some(serde_json::json!({
3711                "error": "provider_contract_error",
3712                "step": step.id,
3713                "detail": err.to_string(),
3714                "recoverable": true,
3715            }));
3716            return MachineStepResult {
3717                ok: false,
3718                next: "runtime config could not be serialized".to_string(),
3719                detail: serde_json::json!({
3720                    "blocked": true,
3721                    "retryable": true,
3722                    "error": "provider_contract_error",
3723                    "detail": err.to_string(),
3724                }),
3725            };
3726        }
3727    };
3728    if let Err(err) = std::fs::write(&config_path, bytes) {
3729        return pause_runtime_config_write_error(step, state, "setup_answers", err);
3730    }
3731
3732    let mut envelope_path = Value::Null;
3733    let mut envelope_skipped = false;
3734    if let Some(pack_path) = pack_path {
3735        match crate::config_envelope::write_provider_config_envelope(
3736            &bundle_root.join(".providers"),
3737            &state.provider_id,
3738            &step.id,
3739            &config,
3740            pack_path,
3741            true,
3742        ) {
3743            Ok(path) => envelope_path = Value::String(path.display().to_string()),
3744            Err(err) => {
3745                state.status = SetupMachineStatus::Paused;
3746                state.current_step = Some(step.id.clone());
3747                state.last_error = Some(serde_json::json!({
3748                    "error": "provider_contract_error",
3749                    "step": step.id,
3750                    "detail": err.to_string(),
3751                    "recoverable": true,
3752                }));
3753                return MachineStepResult {
3754                    ok: false,
3755                    next: "provider config envelope could not be written".to_string(),
3756                    detail: serde_json::json!({
3757                        "blocked": true,
3758                        "retryable": true,
3759                        "error": "provider_contract_error",
3760                        "detail": err.to_string(),
3761                    }),
3762                };
3763            }
3764        }
3765    } else {
3766        envelope_skipped = true;
3767    }
3768
3769    state.created_resources.push(serde_json::json!({
3770        "kind": "runtime_config",
3771        "step": step.id,
3772        "path": config_path.display().to_string(),
3773        "envelope_path": envelope_path,
3774    }));
3775    let output_key = step
3776        .extra
3777        .get("output_key")
3778        .and_then(Value::as_str)
3779        .unwrap_or("runtime_config_path");
3780    ensure_outputs_object(state).insert(
3781        output_key.to_string(),
3782        Value::String(config_path.display().to_string()),
3783    );
3784    mark_machine_step_complete(machine, step, state);
3785    MachineStepResult {
3786        ok: true,
3787        next: "runtime config persisted".to_string(),
3788        detail: serde_json::json!({
3789            "kind": step.kind,
3790            "config_path": config_path,
3791            "envelope_path": envelope_path,
3792            "envelope_skipped": envelope_skipped,
3793        }),
3794    }
3795}
3796
3797fn render_runtime_config(
3798    machine: &SetupMachine,
3799    step: &SetupMachineStep,
3800    state: &SetupMachineState,
3801) -> anyhow::Result<Value> {
3802    let mut config = if let Some(config) = step.extra.get("config") {
3803        expand_machine_value_templates(config.clone(), state)
3804    } else if let Some(mappings) = step.extra.get("mappings") {
3805        expand_machine_value_templates(mappings.clone(), state)
3806    } else {
3807        anyhow::bail!("persist_runtime_config requires config or mappings")
3808    };
3809    let server_owned = setup_machine_server_owned_keys(machine, step);
3810    if let Some(config) = config.as_object_mut() {
3811        for key in server_owned {
3812            config.remove(&key);
3813        }
3814    } else {
3815        anyhow::bail!("persist_runtime_config config must render to an object");
3816    }
3817    Ok(config)
3818}
3819
3820fn setup_machine_server_owned_keys(
3821    machine: &SetupMachine,
3822    step: &SetupMachineStep,
3823) -> BTreeSet<String> {
3824    machine
3825        .extra
3826        .get("server_owned_config_keys")
3827        .into_iter()
3828        .chain(step.extra.get("server_owned_config_keys"))
3829        .flat_map(|value| value.as_array().into_iter().flatten())
3830        .filter_map(Value::as_str)
3831        .map(str::trim)
3832        .filter(|value| !value.is_empty())
3833        .map(str::to_string)
3834        .collect()
3835}
3836
3837fn pause_runtime_config_write_error(
3838    step: &SetupMachineStep,
3839    state: &mut SetupMachineState,
3840    target: &str,
3841    err: std::io::Error,
3842) -> MachineStepResult {
3843    state.status = SetupMachineStatus::Paused;
3844    state.current_step = Some(step.id.clone());
3845    state.last_error = Some(serde_json::json!({
3846        "error": "provider_contract_error",
3847        "step": step.id,
3848        "target": target,
3849        "detail": err.to_string(),
3850        "recoverable": true,
3851    }));
3852    MachineStepResult {
3853        ok: false,
3854        next: "runtime config could not be written".to_string(),
3855        detail: serde_json::json!({
3856            "blocked": true,
3857            "retryable": true,
3858            "error": "provider_contract_error",
3859            "target": target,
3860            "detail": err.to_string(),
3861        }),
3862    }
3863}
3864
3865fn pause_generate_file_write_error(
3866    step: &SetupMachineStep,
3867    state: &mut SetupMachineState,
3868    artifact_path: &str,
3869    err: std::io::Error,
3870) -> MachineStepResult {
3871    state.status = SetupMachineStatus::Paused;
3872    state.current_step = Some(step.id.clone());
3873    state.last_error = Some(serde_json::json!({
3874        "error": "artifact_write_failed",
3875        "step": step.id,
3876        "artifact_path": artifact_path,
3877        "detail": err.to_string(),
3878        "recoverable": true,
3879    }));
3880    MachineStepResult {
3881        ok: false,
3882        next: "setup artifact could not be written".to_string(),
3883        detail: serde_json::json!({
3884            "blocked": true,
3885            "retryable": true,
3886            "error": "artifact_write_failed",
3887            "artifact_path": artifact_path,
3888            "detail": err.to_string(),
3889        }),
3890    }
3891}
3892
3893fn setup_artifact_relative_path(step: &SetupMachineStep) -> Option<&str> {
3894    step.extra
3895        .get("artifact_path")
3896        .or_else(|| step.extra.get("path"))
3897        .or_else(|| step.extra.get("output_path"))
3898        .and_then(Value::as_str)
3899        .map(str::trim)
3900        .filter(|value| !value.is_empty())
3901}
3902
3903fn is_safe_setup_artifact_path(path: &str) -> bool {
3904    !path.starts_with('/')
3905        && !path.contains("..")
3906        && (path.starts_with("artifacts/") || path.starts_with("state/setup/"))
3907}
3908
3909fn resolve_setup_artifact_path(
3910    bundle_root: &Path,
3911    state: &SetupMachineState,
3912    artifact_path: &str,
3913) -> PathBuf {
3914    if artifact_path.starts_with("state/setup/") {
3915        bundle_root.join(artifact_path)
3916    } else {
3917        setup_machine_state_dir(bundle_root, &state.tenant, &state.team, &state.provider_id)
3918            .join(artifact_path)
3919    }
3920}
3921
3922fn render_generate_file_content(
3923    step: &SetupMachineStep,
3924    state: &SetupMachineState,
3925) -> anyhow::Result<String> {
3926    if let Some(template) = step.extra.get("template").and_then(Value::as_str) {
3927        return expand_machine_template(template, state);
3928    }
3929    if let Some(content) = step.extra.get("content") {
3930        return render_generated_value(content.clone(), state);
3931    }
3932    if let Some(template_json) = step.extra.get("template_json") {
3933        return render_generated_value(template_json.clone(), state);
3934    }
3935    anyhow::bail!("generate_file requires template, content, or template_json")
3936}
3937
3938fn render_generated_value(value: Value, state: &SetupMachineState) -> anyhow::Result<String> {
3939    match value {
3940        Value::String(text) => expand_machine_template(&text, state),
3941        other => Ok(serde_json::to_string_pretty(
3942            &expand_machine_value_templates(other, state),
3943        )?),
3944    }
3945}
3946
3947fn execute_http_step(
3948    machine: &SetupMachine,
3949    step: &SetupMachineStep,
3950    state: &mut SetupMachineState,
3951) -> MachineStepResult {
3952    let Some(template) = step
3953        .extra
3954        .get("url_template")
3955        .or_else(|| step.extra.get("url"))
3956        .and_then(Value::as_str)
3957    else {
3958        state.status = SetupMachineStatus::Paused;
3959        state.current_step = Some(step.id.clone());
3960        state.last_error = Some(serde_json::json!({
3961            "error": "missing_prerequisite",
3962            "step": step.id,
3963            "missing": "url_template",
3964            "recoverable": true,
3965        }));
3966        return MachineStepResult {
3967            ok: false,
3968            next: "declare url_template, then retry".to_string(),
3969            detail: serde_json::json!({
3970                "blocked": true,
3971                "retryable": true,
3972                "error": "missing_prerequisite",
3973                "missing_config_key": "url_template",
3974            }),
3975        };
3976    };
3977    let target = match expand_machine_template(template, state) {
3978        Ok(target) => target,
3979        Err(err) => {
3980            state.status = SetupMachineStatus::Paused;
3981            state.current_step = Some(step.id.clone());
3982            state.last_error = Some(serde_json::json!({
3983                "error": "missing_prerequisite",
3984                "step": step.id,
3985                "detail": err.to_string(),
3986                "recoverable": true,
3987            }));
3988            return MachineStepResult {
3989                ok: false,
3990                next: "provide missing template values, then retry".to_string(),
3991                detail: serde_json::json!({
3992                    "blocked": true,
3993                    "retryable": true,
3994                    "error": "missing_prerequisite",
3995                    "detail": err.to_string(),
3996                }),
3997            };
3998        }
3999    };
4000    if let Err(err) = validate_http_step_url(&target) {
4001        state.status = SetupMachineStatus::Paused;
4002        state.current_step = Some(step.id.clone());
4003        state.last_error = Some(serde_json::json!({
4004            "error": "invalid_answer",
4005            "step": step.id,
4006            "detail": err.to_string(),
4007            "recoverable": true,
4008        }));
4009        return MachineStepResult {
4010            ok: false,
4011            next: "fix setup HTTP URL, then retry".to_string(),
4012            detail: serde_json::json!({
4013                "blocked": true,
4014                "retryable": true,
4015                "error": "invalid_answer",
4016                "detail": err.to_string(),
4017            }),
4018        };
4019    }
4020
4021    let method = step
4022        .extra
4023        .get("method")
4024        .and_then(Value::as_str)
4025        .unwrap_or("GET")
4026        .to_ascii_uppercase();
4027    let expected_status = step
4028        .extra
4029        .get("expected_status")
4030        .or_else(|| step.extra.get("status"))
4031        .and_then(Value::as_u64)
4032        .unwrap_or(200) as u16;
4033    let response = match method.as_str() {
4034        "GET" => crate::http_client::api_agent().get(&target).call(),
4035        "POST" => {
4036            let payload = step
4037                .extra
4038                .get("body")
4039                .cloned()
4040                .unwrap_or_else(|| serde_json::json!({}));
4041            let payload = expand_machine_value_templates(payload, state);
4042            crate::http_client::api_agent()
4043                .post(&target)
4044                .send_json(payload)
4045        }
4046        _ => {
4047            state.status = SetupMachineStatus::Paused;
4048            state.current_step = Some(step.id.clone());
4049            state.last_error = Some(serde_json::json!({
4050                "error": "unsupported_http_method",
4051                "step": step.id,
4052                "method": method,
4053                "recoverable": true,
4054            }));
4055            return MachineStepResult {
4056                ok: false,
4057                next: "fix setup HTTP method, then retry".to_string(),
4058                detail: serde_json::json!({
4059                    "blocked": true,
4060                    "retryable": true,
4061                    "error": "unsupported_http_method",
4062                    "method": method,
4063                }),
4064            };
4065        }
4066    };
4067    let mut response = match response {
4068        Ok(response) => response,
4069        Err(ureq::Error::StatusCode(status)) => {
4070            state.status = SetupMachineStatus::Paused;
4071            state.current_step = Some(step.id.clone());
4072            state.last_error = Some(serde_json::json!({
4073                "error": "provider_contract_error",
4074                "step": step.id,
4075                "status": status,
4076                "expected_status": expected_status,
4077                "recoverable": true,
4078            }));
4079            return MachineStepResult {
4080                ok: false,
4081                next: "setup HTTP endpoint returned an unexpected status".to_string(),
4082                detail: serde_json::json!({
4083                    "blocked": true,
4084                    "retryable": true,
4085                    "error": "provider_contract_error",
4086                    "status": status,
4087                    "expected_status": expected_status,
4088                    "target": target,
4089                }),
4090            };
4091        }
4092        Err(err) => {
4093            state.status = SetupMachineStatus::Paused;
4094            state.current_step = Some(step.id.clone());
4095            state.last_error = Some(serde_json::json!({
4096                "error": "external_timeout",
4097                "step": step.id,
4098                "detail": err.to_string(),
4099                "recoverable": true,
4100            }));
4101            return MachineStepResult {
4102                ok: false,
4103                next: "setup HTTP endpoint is not reachable".to_string(),
4104                detail: serde_json::json!({
4105                    "blocked": true,
4106                    "retryable": true,
4107                    "error": "external_timeout",
4108                    "detail": err.to_string(),
4109                    "target": target,
4110                }),
4111            };
4112        }
4113    };
4114    let status = response.status().as_u16();
4115    if status != expected_status {
4116        state.status = SetupMachineStatus::Paused;
4117        state.current_step = Some(step.id.clone());
4118        state.last_error = Some(serde_json::json!({
4119            "error": "provider_contract_error",
4120            "step": step.id,
4121            "status": status,
4122            "expected_status": expected_status,
4123            "recoverable": true,
4124        }));
4125        return MachineStepResult {
4126            ok: false,
4127            next: "setup HTTP endpoint returned an unexpected status".to_string(),
4128            detail: serde_json::json!({
4129                "blocked": true,
4130                "retryable": true,
4131                "error": "provider_contract_error",
4132                "status": status,
4133                "expected_status": expected_status,
4134                "target": target,
4135            }),
4136        };
4137    }
4138
4139    let body = response
4140        .body_mut()
4141        .read_json::<Value>()
4142        .unwrap_or(Value::Null);
4143    if step.kind == "http_json" {
4144        let output_key = step
4145            .extra
4146            .get("output_key")
4147            .and_then(Value::as_str)
4148            .unwrap_or(step.id.as_str());
4149        ensure_outputs_object(state).insert(output_key.to_string(), body.clone());
4150    }
4151    mark_machine_step_complete(machine, step, state);
4152    MachineStepResult {
4153        ok: true,
4154        next: "HTTP setup step completed".to_string(),
4155        detail: serde_json::json!({
4156            "kind": step.kind,
4157            "method": method,
4158            "status": status,
4159            "target": target,
4160            "body": if step.kind == "http_json" { body } else { Value::Null },
4161        }),
4162    }
4163}
4164
4165fn execute_platform_public_url_step(
4166    bundle_root: &Path,
4167    machine: &SetupMachine,
4168    step: &SetupMachineStep,
4169    state: &mut SetupMachineState,
4170) -> MachineStepResult {
4171    let key = step
4172        .extra
4173        .get("output_key")
4174        .and_then(Value::as_str)
4175        .unwrap_or("public_base_url");
4176    let candidate = output_string(&state.outputs, key)
4177        .or_else(|| {
4178            step.extra
4179                .get(key)
4180                .and_then(Value::as_str)
4181                .map(str::to_string)
4182        })
4183        .or_else(|| {
4184            step.extra
4185                .get("default")
4186                .or_else(|| step.extra.get("default_url"))
4187                .and_then(Value::as_str)
4188                .map(str::to_string)
4189        })
4190        .or_else(|| {
4191            crate::platform_setup::load_effective_static_routes_defaults(
4192                bundle_root,
4193                &state.tenant,
4194                Some(&state.team),
4195            )
4196            .ok()
4197            .flatten()
4198            .and_then(|policy| policy.public_base_url)
4199        })
4200        .map(|value| value.trim_end_matches('/').to_string())
4201        .filter(|value| !value.trim().is_empty());
4202
4203    let Some(public_base_url) = candidate else {
4204        state.status = SetupMachineStatus::Paused;
4205        state.current_step = Some(step.id.clone());
4206        state.last_error = Some(serde_json::json!({
4207            "error": "missing_prerequisite",
4208            "step": step.id,
4209            "missing": key,
4210            "recoverable": true,
4211        }));
4212        return MachineStepResult {
4213            ok: false,
4214            next: "configure public_base_url, then retry".to_string(),
4215            detail: serde_json::json!({
4216                "blocked": true,
4217                "retryable": true,
4218                "error": "missing_prerequisite",
4219                "missing_config_key": key,
4220            }),
4221        };
4222    };
4223
4224    if url::Url::parse(&public_base_url).is_err() {
4225        state.status = SetupMachineStatus::Paused;
4226        state.current_step = Some(step.id.clone());
4227        state.last_error = Some(serde_json::json!({
4228            "error": "invalid_answer",
4229            "step": step.id,
4230            "field": key,
4231            "recoverable": true,
4232        }));
4233        return MachineStepResult {
4234            ok: false,
4235            next: "fix public_base_url, then retry".to_string(),
4236            detail: serde_json::json!({
4237                "blocked": true,
4238                "retryable": true,
4239                "error": "invalid_answer",
4240                "field": key,
4241            }),
4242        };
4243    }
4244
4245    ensure_outputs_object(state).insert(key.to_string(), Value::String(public_base_url.clone()));
4246    mark_machine_step_complete(machine, step, state);
4247    MachineStepResult {
4248        ok: true,
4249        next: "public URL resolved".to_string(),
4250        detail: serde_json::json!({
4251            "kind": step.kind,
4252            "output_key": key,
4253            "public_base_url": public_base_url,
4254        }),
4255    }
4256}
4257
4258fn execute_cloudflare_tunnel_step(
4259    bundle_root: &Path,
4260    machine: &SetupMachine,
4261    step: &SetupMachineStep,
4262    state: &mut SetupMachineState,
4263) -> MachineStepResult {
4264    let key = step
4265        .extra
4266        .get("output_key")
4267        .and_then(Value::as_str)
4268        .unwrap_or("public_base_url");
4269    let candidate = output_string(&state.outputs, key)
4270        .or_else(|| {
4271            step.extra
4272                .get(key)
4273                .and_then(Value::as_str)
4274                .map(str::to_string)
4275        })
4276        .or_else(|| {
4277            step.extra
4278                .get("public_base_url")
4279                .or_else(|| step.extra.get("default"))
4280                .or_else(|| step.extra.get("default_url"))
4281                .and_then(Value::as_str)
4282                .map(str::to_string)
4283        })
4284        .or_else(|| {
4285            crate::platform_setup::load_effective_static_routes_defaults(
4286                bundle_root,
4287                &state.tenant,
4288                Some(&state.team),
4289            )
4290            .ok()
4291            .flatten()
4292            .and_then(|policy| policy.public_base_url)
4293        })
4294        .map(|value| value.trim_end_matches('/').to_string())
4295        .filter(|value| !value.trim().is_empty());
4296
4297    let Some(public_base_url) = candidate else {
4298        state.status = SetupMachineStatus::Paused;
4299        state.current_step = Some(step.id.clone());
4300        state.last_error = Some(serde_json::json!({
4301            "error": "tunnel_unreachable",
4302            "step": step.id,
4303            "missing": key,
4304            "recoverable": true,
4305        }));
4306        return MachineStepResult {
4307            ok: false,
4308            next: "start or configure a setup tunnel, then retry".to_string(),
4309            detail: serde_json::json!({
4310                "blocked": true,
4311                "retryable": true,
4312                "error": "tunnel_unreachable",
4313                "missing_config_key": key,
4314            }),
4315        };
4316    };
4317
4318    let parsed = match url::Url::parse(&public_base_url) {
4319        Ok(parsed) => parsed,
4320        Err(err) => {
4321            state.status = SetupMachineStatus::Paused;
4322            state.current_step = Some(step.id.clone());
4323            state.last_error = Some(serde_json::json!({
4324                "error": "invalid_answer",
4325                "step": step.id,
4326                "field": key,
4327                "detail": err.to_string(),
4328                "recoverable": true,
4329            }));
4330            return MachineStepResult {
4331                ok: false,
4332                next: "fix setup tunnel URL, then retry".to_string(),
4333                detail: serde_json::json!({
4334                    "blocked": true,
4335                    "retryable": true,
4336                    "error": "invalid_answer",
4337                    "field": key,
4338                    "detail": err.to_string(),
4339                }),
4340            };
4341        }
4342    };
4343    if parsed.scheme() != "https" || parsed.host_str().is_none() {
4344        state.status = SetupMachineStatus::Paused;
4345        state.current_step = Some(step.id.clone());
4346        state.last_error = Some(serde_json::json!({
4347            "error": "invalid_answer",
4348            "step": step.id,
4349            "field": key,
4350            "detail": "setup tunnel URL must be an https URL with a host",
4351            "recoverable": true,
4352        }));
4353        return MachineStepResult {
4354            ok: false,
4355            next: "fix setup tunnel URL, then retry".to_string(),
4356            detail: serde_json::json!({
4357                "blocked": true,
4358                "retryable": true,
4359                "error": "invalid_answer",
4360                "field": key,
4361                "detail": "setup tunnel URL must be an https URL with a host",
4362            }),
4363        };
4364    }
4365
4366    ensure_outputs_object(state).insert(key.to_string(), Value::String(public_base_url.clone()));
4367    mark_machine_step_complete(machine, step, state);
4368    MachineStepResult {
4369        ok: true,
4370        next: "setup tunnel URL resolved".to_string(),
4371        detail: serde_json::json!({
4372            "kind": step.kind,
4373            "output_key": key,
4374            "public_base_url": public_base_url,
4375            "ephemeral": crate::setup_tunnel::is_ephemeral_tunnel_url(&public_base_url),
4376        }),
4377    }
4378}
4379
4380fn ensure_outputs_object(state: &mut SetupMachineState) -> &mut serde_json::Map<String, Value> {
4381    if !state.outputs.is_object() {
4382        state.outputs = serde_json::json!({});
4383    }
4384    state.outputs.as_object_mut().expect("outputs object")
4385}
4386
4387fn output_string(outputs: &Value, key: &str) -> Option<String> {
4388    outputs
4389        .get(key)
4390        .and_then(Value::as_str)
4391        .map(str::trim)
4392        .filter(|value| !value.is_empty())
4393        .map(str::to_string)
4394}
4395
4396fn validate_http_step_url(value: &str) -> anyhow::Result<()> {
4397    let url = url::Url::parse(value)?;
4398    match url.scheme() {
4399        "http" if url.host_str().is_some_and(is_loopback_host) => Ok(()),
4400        "https" => Ok(()),
4401        _ => anyhow::bail!("setup HTTP URL must use https or loopback http"),
4402    }
4403}
4404
4405fn is_loopback_host(host: &str) -> bool {
4406    matches!(host, "localhost" | "127.0.0.1" | "::1")
4407}
4408
4409fn expand_machine_template(template: &str, state: &SetupMachineState) -> anyhow::Result<String> {
4410    let mut expanded = template
4411        .replace("{tenant}", &state.tenant)
4412        .replace("{team}", &state.team)
4413        .replace("{provider_id}", &state.provider_id);
4414    let mut start = 0;
4415    while let Some(open) = expanded[start..].find('{') {
4416        let open = start + open;
4417        let Some(close_rel) = expanded[open + 1..].find('}') else {
4418            break;
4419        };
4420        let close = open + 1 + close_rel;
4421        let key = &expanded[open + 1..close];
4422        let Some(value) = output_string(&state.outputs, key) else {
4423            anyhow::bail!("missing setup-machine output `{key}`");
4424        };
4425        expanded.replace_range(open..=close, &value);
4426        start = open + value.len();
4427    }
4428    Ok(expanded)
4429}
4430
4431fn expand_machine_value_templates(value: Value, state: &SetupMachineState) -> Value {
4432    match value {
4433        Value::String(text) => expand_machine_template(&text, state)
4434            .map(Value::String)
4435            .unwrap_or(Value::String(text)),
4436        Value::Array(values) => Value::Array(
4437            values
4438                .into_iter()
4439                .map(|value| expand_machine_value_templates(value, state))
4440                .collect(),
4441        ),
4442        Value::Object(map) => Value::Object(
4443            map.into_iter()
4444                .map(|(key, value)| (key, expand_machine_value_templates(value, state)))
4445                .collect(),
4446        ),
4447        other => other,
4448    }
4449}
4450
4451fn mark_machine_step_complete(
4452    machine: &SetupMachine,
4453    step: &SetupMachineStep,
4454    state: &mut SetupMachineState,
4455) {
4456    if !state.completed_steps.iter().any(|id| id == &step.id) {
4457        state.completed_steps.push(step.id.clone());
4458    }
4459    state.last_error = None;
4460    state.failed_step = None;
4461    match step.on_success.as_deref() {
4462        Some(TERMINAL_SUCCESS) | None => {
4463            state.status = SetupMachineStatus::Complete;
4464            state.current_step = None;
4465        }
4466        Some(TERMINAL_FAILED) => {
4467            state.status = SetupMachineStatus::Failed;
4468            state.current_step = None;
4469            state.failed_step = Some(step.id.clone());
4470            state.last_error = Some(serde_json::json!({
4471                "error": "transitioned_to_failed",
4472                "step": step.id,
4473            }));
4474        }
4475        Some(next) if machine.steps.iter().any(|candidate| candidate.id == next) => {
4476            state.status = SetupMachineStatus::Running;
4477            state.current_step = Some(next.to_string());
4478        }
4479        Some(next) => {
4480            state.status = SetupMachineStatus::Failed;
4481            state.current_step = Some(step.id.clone());
4482            state.failed_step = Some(step.id.clone());
4483            state.last_error = Some(serde_json::json!({
4484                "error": "invalid_transition_target",
4485                "step": step.id,
4486                "target": next,
4487            }));
4488        }
4489    }
4490}
4491
4492fn machine_step_output(
4493    machine: &SetupMachine,
4494    state: &SetupMachineState,
4495    step_id: &str,
4496    dry_run: bool,
4497    ok: bool,
4498    next: &str,
4499    event_path: Option<PathBuf>,
4500) -> Value {
4501    serde_json::json!({
4502        "provider_id": state.provider_id,
4503        "tenant": state.tenant,
4504        "team": state.team,
4505        "machine_id": machine.id,
4506        "dry_run": dry_run,
4507        "step": step_id,
4508        "ok": ok,
4509        "next": next,
4510        "result": {
4511            "ok": ok,
4512            "status": state.status,
4513        },
4514        "event_path": event_path,
4515        "status": render_setup_machine_status(machine, state),
4516    })
4517}
4518
4519fn append_machine_step_event(
4520    bundle_root: &Path,
4521    state: &SetupMachineState,
4522    event_name: &str,
4523    step_id: Option<&str>,
4524    detail: Option<Value>,
4525) -> anyhow::Result<PathBuf> {
4526    let mut fields = serde_json::Map::new();
4527    if let Some(detail) = detail {
4528        fields.insert("detail".to_string(), detail);
4529    }
4530    append_setup_machine_event(
4531        bundle_root,
4532        &SetupMachineEvent {
4533            ts: now_setup_ts(),
4534            level: "info".to_string(),
4535            event: event_name.to_string(),
4536            provider_id: state.provider_id.clone(),
4537            tenant: state.tenant.clone(),
4538            team: state.team.clone(),
4539            machine_id: state.machine_id.clone(),
4540            step_id: step_id.map(str::to_string),
4541            correlation_id: Some(format!("setup-{}", now_setup_ms())),
4542            fields,
4543        },
4544    )
4545}
4546
4547fn now_setup_ts() -> String {
4548    now_setup_ms().to_string()
4549}
4550
4551fn now_setup_ms() -> u128 {
4552    SystemTime::now()
4553        .duration_since(UNIX_EPOCH)
4554        .unwrap_or_default()
4555        .as_millis()
4556}
4557
4558fn safe_archive_segment(value: &str) -> String {
4559    let mut out = value
4560        .chars()
4561        .map(|ch| {
4562            if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
4563                ch
4564            } else {
4565                '-'
4566            }
4567        })
4568        .collect::<String>();
4569    while out.contains("--") {
4570        out = out.replace("--", "-");
4571    }
4572    let out = out.trim_matches('-');
4573    if out.is_empty() {
4574        "archive".to_string()
4575    } else {
4576        out.to_string()
4577    }
4578}
4579
4580fn resolve_machine_value(pack_path: &Path, extension: Value) -> anyhow::Result<Value> {
4581    if let Some(inline) = extension.get("inline") {
4582        return Ok(inline.clone());
4583    }
4584    if let Some(path) = extension.get("path").and_then(Value::as_str) {
4585        return discovery::read_pack_json_asset(pack_path, path);
4586    }
4587    Ok(extension)
4588}
4589
4590fn extension_inline(extension: &Value) -> Option<&Value> {
4591    extension.get("inline").or(Some(extension))
4592}
4593
4594fn validate_schema_id(value: &Value, expected: &str, label: &str) -> anyhow::Result<()> {
4595    let schema_id = value
4596        .get("schema_id")
4597        .and_then(Value::as_str)
4598        .unwrap_or(expected);
4599    if schema_id != expected {
4600        anyhow::bail!("{label} has schema_id {schema_id}, expected {expected}");
4601    }
4602    Ok(())
4603}
4604
4605fn validate_provider_id(
4606    provider_id: Option<&str>,
4607    expected_provider_id: Option<&str>,
4608) -> anyhow::Result<()> {
4609    let Some(expected) = expected_provider_id else {
4610        return Ok(());
4611    };
4612    let Some(actual) = provider_id else {
4613        anyhow::bail!("setup backend contract provider_id is missing");
4614    };
4615    if actual != expected {
4616        anyhow::bail!(
4617            "setup backend contract provider_id {actual} does not match pack id {expected}"
4618        );
4619    }
4620    Ok(())
4621}
4622
4623fn string_array(value: Option<&Value>) -> Vec<String> {
4624    value
4625        .and_then(Value::as_array)
4626        .into_iter()
4627        .flatten()
4628        .filter_map(Value::as_str)
4629        .map(str::trim)
4630        .filter(|value| !value.is_empty())
4631        .map(str::to_string)
4632        .collect()
4633}
4634
4635fn validate_unique_strings(check_id: &str, message: &str, values: &[String]) -> Vec<Diagnostic> {
4636    let mut diagnostics = Vec::new();
4637    let mut seen = BTreeSet::new();
4638    for value in values {
4639        if !seen.insert(value) {
4640            diagnostics.push(error_with_actual(check_id, message, value));
4641        }
4642    }
4643    diagnostics
4644}
4645
4646fn require_executor_str(
4647    diagnostics: &mut Vec<Diagnostic>,
4648    action_id: &str,
4649    executor: &serde_json::Map<String, Value>,
4650    key: &str,
4651) {
4652    if executor
4653        .get(key)
4654        .and_then(Value::as_str)
4655        .is_none_or(|value| value.trim().is_empty())
4656    {
4657        diagnostics.push(error_with_actual(
4658            "setup.backend_contract.executor.required_field",
4659            "setup backend executor is missing a required field",
4660            &format!("{action_id}.{key}"),
4661        ));
4662    }
4663}
4664
4665fn validate_transition(
4666    diagnostics: &mut Vec<Diagnostic>,
4667    step_ids: &BTreeSet<String>,
4668    source_step: &str,
4669    check_id: &str,
4670    target: Option<&str>,
4671) {
4672    let Some(target) = target.map(str::trim).filter(|value| !value.is_empty()) else {
4673        return;
4674    };
4675    if matches!(target, TERMINAL_SUCCESS | TERMINAL_FAILED) || step_ids.contains(target) {
4676        return;
4677    }
4678    diagnostics.push(Diagnostic {
4679        check_id: check_id.to_string(),
4680        severity: DiagnosticSeverity::Error,
4681        component: "provider_setup".to_string(),
4682        message: "setup transition target must reference an existing step or terminal state"
4683            .to_string(),
4684        evidence: Some(format!("{source_step} -> {target}")),
4685        expected: Some("existing step id, complete, or failed".to_string()),
4686        actual: Some(target.to_string()),
4687        fix_hint: Some("fix the transition target or add the missing setup step".to_string()),
4688        related_file: None,
4689        related_pack: None,
4690        related_component: None,
4691    });
4692}
4693
4694fn rule_targets(rule: &Value) -> Vec<String> {
4695    let Some(obj) = rule.as_object() else {
4696        return Vec::new();
4697    };
4698    ["step", "next", "target", "on_success"]
4699        .iter()
4700        .filter_map(|key| obj.get(*key).and_then(Value::as_str).map(str::to_string))
4701        .collect()
4702}
4703
4704fn validate_reachability(machine: &SetupMachine, step_ids: &BTreeSet<String>) -> Vec<Diagnostic> {
4705    if !step_ids.contains(&machine.entry_step) {
4706        return Vec::new();
4707    }
4708
4709    let mut seen = BTreeSet::new();
4710    let mut queue = VecDeque::from([machine.entry_step.clone()]);
4711    while let Some(step_id) = queue.pop_front() {
4712        if !seen.insert(step_id.clone()) {
4713            continue;
4714        }
4715        let Some(step) = machine.steps.iter().find(|step| step.id == step_id) else {
4716            continue;
4717        };
4718        if let Some(target) = step.on_success.as_deref()
4719            && step_ids.contains(target)
4720        {
4721            queue.push_back(target.to_string());
4722        }
4723        for rule in step.on_failure.iter().chain(step.recover.iter()) {
4724            for target in rule_targets(rule) {
4725                if step_ids.contains(&target) {
4726                    queue.push_back(target);
4727                }
4728            }
4729        }
4730    }
4731
4732    step_ids
4733        .difference(&seen)
4734        .map(|step_id| {
4735            error_with_actual(
4736                "setup.machine.step.reachable",
4737                "setup step is not reachable from entry_step",
4738                step_id,
4739            )
4740        })
4741        .collect()
4742}
4743
4744fn validate_unbounded_cycles(
4745    machine: &SetupMachine,
4746    step_ids: &BTreeSet<String>,
4747) -> Vec<Diagnostic> {
4748    let steps: BTreeMap<&str, &SetupMachineStep> = machine
4749        .steps
4750        .iter()
4751        .map(|step| (step.id.as_str(), step))
4752        .collect();
4753    let mut edges: BTreeMap<String, Vec<String>> = BTreeMap::new();
4754    for step in &machine.steps {
4755        let mut targets = Vec::new();
4756        if let Some(target) = step.on_success.as_deref()
4757            && step_ids.contains(target)
4758        {
4759            targets.push(target.to_string());
4760        }
4761        for rule in step.on_failure.iter().chain(step.recover.iter()) {
4762            for target in rule_targets(rule) {
4763                if step_ids.contains(&target) {
4764                    targets.push(target);
4765                }
4766            }
4767        }
4768        edges.insert(step.id.clone(), targets);
4769    }
4770
4771    let mut diagnostics = Vec::new();
4772    let mut visited = BTreeSet::new();
4773    let mut stack = Vec::<String>::new();
4774    let mut reported = BTreeSet::new();
4775    for step_id in step_ids {
4776        detect_cycle(
4777            step_id,
4778            &edges,
4779            &steps,
4780            &mut visited,
4781            &mut stack,
4782            &mut reported,
4783            &mut diagnostics,
4784        );
4785    }
4786    diagnostics
4787}
4788
4789fn validate_artifact_paths(machine: &SetupMachine) -> Vec<Diagnostic> {
4790    let mut diagnostics = Vec::new();
4791    for step in &machine.steps {
4792        if !matches!(step.kind.as_str(), "generate_file" | "download_file") {
4793            continue;
4794        }
4795        let path = step
4796            .extra
4797            .get("artifact_path")
4798            .or_else(|| step.extra.get("path"))
4799            .or_else(|| step.extra.get("output_path"))
4800            .and_then(Value::as_str)
4801            .map(str::trim)
4802            .filter(|value| !value.is_empty());
4803        let Some(path) = path else {
4804            diagnostics.push(error_with_actual(
4805                "setup.machine.artifact.path",
4806                "setup artifact steps must declare artifact_path",
4807                &step.id,
4808            ));
4809            continue;
4810        };
4811        if path.starts_with('/')
4812            || path.contains("..")
4813            || !(path.starts_with("artifacts/") || path.starts_with("state/setup/"))
4814        {
4815            diagnostics.push(error_with_actual(
4816                "setup.machine.artifact.path.safe",
4817                "setup artifact path must be relative and stay under setup artifacts",
4818                &format!("{}: {}", step.id, path),
4819            ));
4820        }
4821    }
4822    diagnostics
4823}
4824
4825fn validate_http_steps(machine: &SetupMachine) -> Vec<Diagnostic> {
4826    let mut diagnostics = Vec::new();
4827    for step in &machine.steps {
4828        if !matches!(step.kind.as_str(), "http_probe" | "http_json") {
4829            continue;
4830        }
4831        let url = step
4832            .extra
4833            .get("url_template")
4834            .or_else(|| step.extra.get("url"))
4835            .and_then(Value::as_str)
4836            .map(str::trim)
4837            .filter(|value| !value.is_empty());
4838        let Some(url) = url else {
4839            diagnostics.push(error_with_actual(
4840                "setup.machine.http.url",
4841                "setup HTTP steps must declare url_template",
4842                &step.id,
4843            ));
4844            continue;
4845        };
4846        if !url.contains('{')
4847            && let Err(err) = validate_http_step_url(url)
4848        {
4849            diagnostics.push(error_with_actual(
4850                "setup.machine.http.url.safe",
4851                "setup HTTP URL must use https or loopback http",
4852                &format!("{}: {url} ({err})", step.id),
4853            ));
4854        }
4855        let method = step
4856            .extra
4857            .get("method")
4858            .and_then(Value::as_str)
4859            .unwrap_or("GET")
4860            .to_ascii_uppercase();
4861        if !matches!(method.as_str(), "GET" | "POST") {
4862            diagnostics.push(error_with_actual(
4863                "setup.machine.http.method",
4864                "setup HTTP method must be GET or POST",
4865                &format!("{}: {method}", step.id),
4866            ));
4867        }
4868        if step.kind == "http_json" && method != "GET" && !has_idempotency_key(step) {
4869            diagnostics.push(error_with_actual(
4870                "setup.machine.step.idempotency_key",
4871                "side-effecting setup steps must declare idempotency_key",
4872                &step.id,
4873            ));
4874        }
4875        if let Some(status) = step
4876            .extra
4877            .get("expected_status")
4878            .or_else(|| step.extra.get("status"))
4879            .and_then(Value::as_u64)
4880            && !(100..=599).contains(&status)
4881        {
4882            diagnostics.push(error_with_actual(
4883                "setup.machine.http.expected_status",
4884                "setup HTTP expected_status must be a valid HTTP status code",
4885                &format!("{}: {status}", step.id),
4886            ));
4887        }
4888    }
4889    diagnostics
4890}
4891
4892fn validate_tunnel_steps(machine: &SetupMachine) -> Vec<Diagnostic> {
4893    let mut diagnostics = Vec::new();
4894    for step in &machine.steps {
4895        if step.kind != "cloudflare_tunnel" {
4896            continue;
4897        }
4898        if let Some(mode) = step.extra.get("mode").and_then(Value::as_str)
4899            && !matches!(mode, "cloudflared" | "cloudflare" | "external")
4900        {
4901            diagnostics.push(error_with_actual(
4902                "setup.machine.cloudflare_tunnel.mode",
4903                "cloudflare_tunnel mode must be cloudflared, cloudflare, or external",
4904                &format!("{}: {}", step.id, mode),
4905            ));
4906        }
4907        if let Some(url) = step
4908            .extra
4909            .get("public_base_url")
4910            .or_else(|| step.extra.get("default"))
4911            .or_else(|| step.extra.get("default_url"))
4912            .and_then(Value::as_str)
4913            .map(str::trim)
4914            .filter(|value| !value.is_empty())
4915            && !url.contains('{')
4916        {
4917            match url::Url::parse(url) {
4918                Ok(parsed) if parsed.scheme() == "https" && parsed.host_str().is_some() => {}
4919                Ok(_) | Err(_) => diagnostics.push(error_with_actual(
4920                    "setup.machine.cloudflare_tunnel.public_base_url",
4921                    "cloudflare_tunnel declared public URL must be https with a host",
4922                    &format!("{}: {}", step.id, url),
4923                )),
4924            }
4925        }
4926    }
4927    diagnostics
4928}
4929
4930fn validate_oauth_steps(machine: &SetupMachine) -> Vec<Diagnostic> {
4931    let mut diagnostics = Vec::new();
4932    for step in &machine.steps {
4933        match step.kind.as_str() {
4934            "oauth_device_code" => validate_oauth_device_step(&mut diagnostics, step),
4935            "oauth_authorization_code" => validate_oauth_authorization_step(&mut diagnostics, step),
4936            _ => {}
4937        }
4938    }
4939    diagnostics
4940}
4941
4942fn validate_oauth_device_step(diagnostics: &mut Vec<Diagnostic>, step: &SetupMachineStep) {
4943    let has_device_url = step
4944        .extra
4945        .get("device_code_url")
4946        .or_else(|| step.extra.get("device_code_url_template"))
4947        .and_then(Value::as_str)
4948        .map(str::trim)
4949        .filter(|value| !value.is_empty())
4950        .is_some();
4951    let has_token_url = step
4952        .extra
4953        .get("token_url")
4954        .or_else(|| step.extra.get("token_url_template"))
4955        .and_then(Value::as_str)
4956        .map(str::trim)
4957        .filter(|value| !value.is_empty())
4958        .is_some();
4959    let has_authority = step
4960        .extra
4961        .get("authority_url_template")
4962        .or_else(|| step.extra.get("authority_url"))
4963        .and_then(Value::as_str)
4964        .map(str::trim)
4965        .filter(|value| !value.is_empty())
4966        .is_some();
4967    if !has_authority && (!has_device_url || !has_token_url) {
4968        diagnostics.push(error_with_actual(
4969            "setup.machine.oauth_device_code.endpoints",
4970            "oauth_device_code steps must declare authority_url_template or both device_code_url and token_url",
4971            &step.id,
4972        ));
4973    }
4974    for key in ["device_code_url", "token_url", "authority_url"] {
4975        if let Some(url) = step
4976            .extra
4977            .get(key)
4978            .or_else(|| step.extra.get(&format!("{key}_template")))
4979            .and_then(Value::as_str)
4980            .map(str::trim)
4981            .filter(|value| !value.is_empty())
4982            && !url.contains('{')
4983            && let Err(err) = validate_http_step_url(url)
4984        {
4985            diagnostics.push(error_with_actual(
4986                "setup.machine.oauth_device_code.url.safe",
4987                "oauth_device_code URLs must use https or loopback http",
4988                &format!("{}: {url} ({err})", step.id),
4989            ));
4990        }
4991    }
4992    let has_client_id = step
4993        .extra
4994        .get("client_id")
4995        .or_else(|| step.extra.get("client_id_output"))
4996        .and_then(Value::as_str)
4997        .map(str::trim)
4998        .filter(|value| !value.is_empty())
4999        .is_some();
5000    if !has_client_id {
5001        diagnostics.push(error_with_actual(
5002            "setup.machine.oauth_device_code.client_id",
5003            "oauth_device_code steps must declare client_id or client_id_output",
5004            &step.id,
5005        ));
5006    }
5007    if step.extra.get("scope").is_none() && step.extra.get("scopes").is_none() {
5008        diagnostics.push(error_with_actual(
5009            "setup.machine.oauth_device_code.scopes",
5010            "oauth_device_code steps must declare scope or scopes",
5011            &step.id,
5012        ));
5013    }
5014    if let Some(scopes) = step.extra.get("scopes")
5015        && !scopes.is_array()
5016    {
5017        diagnostics.push(error_with_actual(
5018            "setup.machine.oauth_device_code.scopes",
5019            "oauth_device_code scopes must be an array",
5020            &step.id,
5021        ));
5022    }
5023}
5024
5025fn validate_oauth_authorization_step(diagnostics: &mut Vec<Diagnostic>, step: &SetupMachineStep) {
5026    let authorize_url = step
5027        .extra
5028        .get("authorize_url")
5029        .or_else(|| step.extra.get("authorization_url"))
5030        .or_else(|| step.extra.get("auth_url"))
5031        .and_then(Value::as_str)
5032        .map(str::trim)
5033        .filter(|value| !value.is_empty());
5034    let Some(authorize_url) = authorize_url else {
5035        diagnostics.push(error_with_actual(
5036            "setup.machine.oauth_authorization_code.authorize_url",
5037            "oauth_authorization_code steps must declare authorize_url",
5038            &step.id,
5039        ));
5040        return;
5041    };
5042    if !authorize_url.contains('{') {
5043        match url::Url::parse(authorize_url) {
5044            Ok(parsed) if parsed.scheme() == "https" && parsed.host_str().is_some() => {}
5045            Ok(_) | Err(_) => diagnostics.push(error_with_actual(
5046                "setup.machine.oauth_authorization_code.authorize_url.safe",
5047                "oauth_authorization_code authorize_url must be https with a host",
5048                &format!("{}: {}", step.id, authorize_url),
5049            )),
5050        }
5051    }
5052    let token_url = step
5053        .extra
5054        .get("token_url")
5055        .or_else(|| step.extra.get("token_url_template"))
5056        .and_then(Value::as_str)
5057        .map(str::trim)
5058        .filter(|value| !value.is_empty());
5059    match token_url {
5060        Some(token_url) if !token_url.contains('{') => match url::Url::parse(token_url) {
5061            Ok(parsed)
5062                if (parsed.host_str().is_some() && parsed.scheme() == "https")
5063                    || (parsed.scheme() == "http"
5064                        && matches!(
5065                            parsed.host_str(),
5066                            Some("127.0.0.1" | "localhost" | "::1")
5067                        )) => {}
5068            Ok(_) | Err(_) => diagnostics.push(error_with_actual(
5069                "setup.machine.oauth_authorization_code.token_url.safe",
5070                "oauth_authorization_code token_url must be https or loopback http",
5071                &format!("{}: {}", step.id, token_url),
5072            )),
5073        },
5074        Some(_) => {}
5075        None => diagnostics.push(error_with_actual(
5076            "setup.machine.oauth_authorization_code.token_url",
5077            "oauth_authorization_code steps must declare token_url",
5078            &step.id,
5079        )),
5080    }
5081    if let Some(scopes) = step.extra.get("scopes")
5082        && !scopes.is_array()
5083    {
5084        diagnostics.push(error_with_actual(
5085            "setup.machine.oauth_authorization_code.scopes",
5086            "oauth_authorization_code scopes must be an array",
5087            &step.id,
5088        ));
5089    }
5090    if let Some(params) = step.extra.get("params")
5091        && !params.is_object()
5092    {
5093        diagnostics.push(error_with_actual(
5094            "setup.machine.oauth_authorization_code.params",
5095            "oauth_authorization_code params must be a JSON object",
5096            &step.id,
5097        ));
5098    }
5099    if let Some(ttl) = step.extra.get("state_ttl_secs").and_then(Value::as_u64)
5100        && ttl == 0
5101    {
5102        diagnostics.push(error_with_actual(
5103            "setup.machine.oauth_authorization_code.state_ttl_secs",
5104            "oauth_authorization_code state_ttl_secs must be greater than zero",
5105            &step.id,
5106        ));
5107    }
5108}
5109
5110fn validate_data_mapping_steps(machine: &SetupMachine) -> Vec<Diagnostic> {
5111    let mut diagnostics = Vec::new();
5112    for step in &machine.steps {
5113        match step.kind.as_str() {
5114            "select_from_json" => {
5115                if step
5116                    .extra
5117                    .get("source_output")
5118                    .or_else(|| step.extra.get("source"))
5119                    .and_then(Value::as_str)
5120                    .map(str::trim)
5121                    .filter(|value| !value.is_empty())
5122                    .is_none()
5123                {
5124                    diagnostics.push(error_with_actual(
5125                        "setup.machine.select_from_json.source_output",
5126                        "select_from_json steps must declare source_output",
5127                        &step.id,
5128                    ));
5129                }
5130                if let Some(pointer) = step.extra.get("json_pointer").and_then(Value::as_str)
5131                    && !pointer.starts_with('/')
5132                {
5133                    diagnostics.push(error_with_actual(
5134                        "setup.machine.select_from_json.json_pointer",
5135                        "select_from_json json_pointer must use JSON Pointer syntax",
5136                        &format!("{}: {}", step.id, pointer),
5137                    ));
5138                }
5139            }
5140            "persist_runtime_config" => {
5141                let config = step.extra.get("config");
5142                let mappings = step.extra.get("mappings");
5143                if config.is_none() && mappings.is_none() {
5144                    diagnostics.push(error_with_actual(
5145                        "setup.machine.persist_runtime_config.input",
5146                        "persist_runtime_config steps must declare config or mappings",
5147                        &step.id,
5148                    ));
5149                }
5150                for (name, value) in [("config", config), ("mappings", mappings)] {
5151                    if let Some(value) = value
5152                        && !value.is_object()
5153                    {
5154                        diagnostics.push(error_with_actual(
5155                            "setup.machine.persist_runtime_config.object",
5156                            "persist_runtime_config config and mappings must be JSON objects",
5157                            &format!("{}: {}", step.id, name),
5158                        ));
5159                    }
5160                }
5161                if let Some(server_owned) = step.extra.get("server_owned_config_keys")
5162                    && !server_owned.is_array()
5163                {
5164                    diagnostics.push(error_with_actual(
5165                        "setup.machine.persist_runtime_config.server_owned_config_keys",
5166                        "persist_runtime_config server_owned_config_keys must be an array",
5167                        &step.id,
5168                    ));
5169                }
5170                let server_owned = setup_machine_server_owned_keys(machine, step);
5171                for (name, value) in [("config", config), ("mappings", mappings)] {
5172                    if let Some(object) = value.and_then(Value::as_object) {
5173                        for key in object.keys() {
5174                            if server_owned.contains(key) {
5175                                diagnostics.push(error_with_actual(
5176                                    "setup.machine.persist_runtime_config.server_owned_output",
5177                                    "persist_runtime_config config and mappings must not write server-owned keys",
5178                                    &format!("{}: {}.{}", step.id, name, key),
5179                                ));
5180                            }
5181                        }
5182                    }
5183                }
5184            }
5185            "provider_component_call" => {
5186                if !has_idempotency_key(step) {
5187                    diagnostics.push(error_with_actual(
5188                        "setup.machine.step.idempotency_key",
5189                        "side-effecting setup steps must declare idempotency_key",
5190                        &step.id,
5191                    ));
5192                }
5193                if step
5194                    .extra
5195                    .get("component_ref")
5196                    .or_else(|| step.extra.get("component"))
5197                    .and_then(Value::as_str)
5198                    .map(str::trim)
5199                    .filter(|value| !value.is_empty())
5200                    .is_none()
5201                {
5202                    diagnostics.push(error_with_actual(
5203                        "setup.machine.provider_component_call.component_ref",
5204                        "provider_component_call steps must declare component_ref",
5205                        &step.id,
5206                    ));
5207                }
5208                if step
5209                    .extra
5210                    .get("op")
5211                    .or_else(|| step.extra.get("operation"))
5212                    .and_then(Value::as_str)
5213                    .map(str::trim)
5214                    .filter(|value| !value.is_empty())
5215                    .is_none()
5216                {
5217                    diagnostics.push(error_with_actual(
5218                        "setup.machine.provider_component_call.op",
5219                        "provider_component_call steps must declare op",
5220                        &step.id,
5221                    ));
5222                }
5223                if let Some(request) = step.extra.get("request").or_else(|| step.extra.get("body"))
5224                    && !request.is_object()
5225                {
5226                    diagnostics.push(error_with_actual(
5227                        "setup.machine.provider_component_call.request",
5228                        "provider_component_call request/body must be a JSON object",
5229                        &step.id,
5230                    ));
5231                }
5232            }
5233            _ => {}
5234        }
5235    }
5236    diagnostics
5237}
5238
5239fn validate_setup_machine_template_inputs(
5240    pack_path: &Path,
5241    provider_id: Option<&str>,
5242    machine: &SetupMachine,
5243) -> Vec<Diagnostic> {
5244    let mut available = BTreeSet::from([
5245        "tenant".to_string(),
5246        "team".to_string(),
5247        "provider_id".to_string(),
5248    ]);
5249
5250    if let Some(provider_id) = provider_id
5251        && let Some(form_spec) = crate::setup_to_formspec::pack_to_form_spec(pack_path, provider_id)
5252    {
5253        for question in form_spec.questions {
5254            if !question.id.trim().is_empty() {
5255                available.insert(question.id);
5256            }
5257        }
5258    }
5259
5260    for step in &machine.steps {
5261        available.extend(setup_machine_step_output_keys(step));
5262    }
5263
5264    let mut diagnostics = Vec::new();
5265    for step in &machine.steps {
5266        for (path, key) in setup_machine_step_template_references(step) {
5267            if !available.contains(&key) {
5268                diagnostics.push(error_with_actual(
5269                    "setup.machine.template.input",
5270                    "setup-machine template placeholder must reference a built-in value, setup answer, or declared setup output",
5271                    &format!("{}: {} -> {}", step.id, path, key),
5272                ));
5273            }
5274        }
5275    }
5276    diagnostics
5277}
5278
5279fn setup_machine_step_template_references(step: &SetupMachineStep) -> Vec<(String, String)> {
5280    let mut references = Vec::new();
5281    for (key, value) in &step.extra {
5282        collect_template_references(value, key, &mut references);
5283    }
5284    references
5285}
5286
5287fn collect_template_references(value: &Value, path: &str, references: &mut Vec<(String, String)>) {
5288    match value {
5289        Value::String(text) => {
5290            for key in template_placeholders(text) {
5291                references.push((path.to_string(), key));
5292            }
5293        }
5294        Value::Array(values) => {
5295            for (idx, value) in values.iter().enumerate() {
5296                collect_template_references(value, &format!("{path}[{idx}]"), references);
5297            }
5298        }
5299        Value::Object(map) => {
5300            for (key, value) in map {
5301                collect_template_references(value, &format!("{path}.{key}"), references);
5302            }
5303        }
5304        _ => {}
5305    }
5306}
5307
5308fn template_placeholders(text: &str) -> Vec<String> {
5309    let mut keys = Vec::new();
5310    let mut start = 0;
5311    while let Some(open_rel) = text[start..].find('{') {
5312        let open = start + open_rel;
5313        let Some(close_rel) = text[open + 1..].find('}') else {
5314            break;
5315        };
5316        let close = open + 1 + close_rel;
5317        let key = text[open + 1..close].trim();
5318        if !key.is_empty() {
5319            keys.push(key.to_string());
5320        }
5321        start = close + 1;
5322    }
5323    keys
5324}
5325
5326fn setup_machine_step_output_keys(step: &SetupMachineStep) -> BTreeSet<String> {
5327    let mut keys = BTreeSet::new();
5328    for output in &step.outputs {
5329        match output {
5330            Value::String(key) if !key.trim().is_empty() => {
5331                keys.insert(key.trim().to_string());
5332            }
5333            Value::Object(object) => {
5334                for field in ["key", "name", "id"] {
5335                    if let Some(key) = object
5336                        .get(field)
5337                        .and_then(Value::as_str)
5338                        .map(str::trim)
5339                        .filter(|value| !value.is_empty())
5340                    {
5341                        keys.insert(key.to_string());
5342                        break;
5343                    }
5344                }
5345            }
5346            _ => {}
5347        }
5348    }
5349
5350    let output_key = step
5351        .extra
5352        .get("output_key")
5353        .and_then(Value::as_str)
5354        .map(str::trim)
5355        .filter(|value| !value.is_empty());
5356    match step.kind.as_str() {
5357        "platform_public_url" | "cloudflare_tunnel" => {
5358            keys.insert(output_key.unwrap_or("public_base_url").to_string());
5359        }
5360        "oauth_device_code" => {
5361            keys.insert(output_key.unwrap_or("oauth_device_login").to_string());
5362        }
5363        "oauth_authorization_code" => {
5364            keys.insert(output_key.unwrap_or("authorization_url").to_string());
5365        }
5366        "http_json" | "select_from_json" | "generate_file" | "provider_component_call" => {
5367            let key = output_key.unwrap_or(step.id.as_str()).to_string();
5368            if step.kind == "select_from_json" {
5369                keys.insert(format!("{key}_item"));
5370            }
5371            keys.insert(key);
5372        }
5373        "persist_runtime_config" => {
5374            keys.insert(output_key.unwrap_or("runtime_config_path").to_string());
5375        }
5376        _ => {}
5377    }
5378    keys
5379}
5380
5381fn has_idempotency_key(step: &SetupMachineStep) -> bool {
5382    step.extra
5383        .get("idempotency_key")
5384        .and_then(Value::as_str)
5385        .map(str::trim)
5386        .is_some_and(|value| !value.is_empty())
5387}
5388
5389fn validate_setup_i18n_message_keys(pack_path: &Path, machine: &SetupMachine) -> Vec<Diagnostic> {
5390    let extension = match discovery::read_pack_extension(pack_path, SETUP_I18N_EXTENSION) {
5391        Ok(Some(extension)) => extension,
5392        Ok(None) => return Vec::new(),
5393        Err(err) => {
5394            return vec![load_error(
5395                "setup.i18n.load",
5396                "setup i18n descriptor could not be loaded",
5397                SETUP_I18N_EXTENSION,
5398                err,
5399                pack_path,
5400            )];
5401        }
5402    };
5403    let mut diagnostics = Vec::new();
5404    let declared_keys = match collect_setup_i18n_keys(pack_path, &extension) {
5405        Ok(keys) => keys,
5406        Err(err) => {
5407            diagnostics.push(load_error(
5408                "setup.i18n.messages.load",
5409                "setup i18n messages could not be loaded",
5410                "valid setup i18n JSON",
5411                err,
5412                pack_path,
5413            ));
5414            return diagnostics;
5415        }
5416    };
5417    if declared_keys.is_empty() {
5418        diagnostics.push(Diagnostic {
5419            check_id: "setup.i18n.messages.present".to_string(),
5420            severity: DiagnosticSeverity::Error,
5421            component: "provider_setup".to_string(),
5422            message: "setup i18n extension does not declare any message keys".to_string(),
5423            evidence: Some(SETUP_I18N_EXTENSION.to_string()),
5424            expected: Some("inline messages or message asset with at least one key".to_string()),
5425            actual: Some("empty".to_string()),
5426            fix_hint: Some(
5427                "add localized setup messages or remove the setup i18n extension".to_string(),
5428            ),
5429            related_file: Some(pack_path.display().to_string()),
5430            related_pack: Some(machine.id.clone()),
5431            related_component: None,
5432        });
5433        return diagnostics;
5434    }
5435
5436    for key in collect_setup_message_key_references(machine) {
5437        if !declared_keys.contains(&key) {
5438            diagnostics.push(error_with_actual(
5439                "setup.i18n.message_key.exists",
5440                "setup message_key must exist in greentic.setup.i18n.v1 messages",
5441                &key,
5442            ));
5443        }
5444    }
5445    diagnostics
5446}
5447
5448fn validate_setup_machine_fixtures(pack_path: &Path, machine: &SetupMachine) -> Vec<Diagnostic> {
5449    let mut diagnostics = Vec::new();
5450    let fixture_paths = setup_machine_fixture_paths(machine);
5451    if fixture_paths.is_empty() {
5452        return diagnostics;
5453    }
5454
5455    let step_ids = machine
5456        .steps
5457        .iter()
5458        .map(|step| step.id.as_str())
5459        .collect::<BTreeSet<_>>();
5460    for path in fixture_paths {
5461        let value = match discovery::read_pack_json_asset(pack_path, &path) {
5462            Ok(value) => value,
5463            Err(err) => {
5464                diagnostics.push(load_error(
5465                    "setup.machine.fixture.load",
5466                    "setup-machine fixture could not be loaded",
5467                    "JSON fixture asset inside the provider pack",
5468                    err,
5469                    pack_path,
5470                ));
5471                continue;
5472            }
5473        };
5474        let state_value = value.get("state").cloned().unwrap_or(value);
5475        let state: SetupMachineState = match serde_json::from_value(state_value) {
5476            Ok(state) => state,
5477            Err(err) => {
5478                diagnostics.push(error_with_actual(
5479                    "setup.machine.fixture.state",
5480                    "setup-machine fixture must contain a valid setup-machine state",
5481                    &format!("{path}: {err}"),
5482                ));
5483                continue;
5484            }
5485        };
5486        if state.machine_id != machine.id {
5487            diagnostics.push(error_with_actual(
5488                "setup.machine.fixture.machine_id",
5489                "setup-machine fixture machine_id must match the current machine",
5490                &format!("{}: {}", path, state.machine_id),
5491            ));
5492        }
5493        if state.machine_version != machine.version {
5494            diagnostics.push(error_with_actual(
5495                "setup.machine.fixture.machine_version",
5496                "setup-machine fixture machine_version must match the current machine version",
5497                &format!("{}: {}", path, state.machine_version),
5498            ));
5499        }
5500        if let Some(current_step) = state.current_step.as_deref()
5501            && !step_ids.contains(current_step)
5502        {
5503            diagnostics.push(error_with_actual(
5504                "setup.machine.fixture.current_step",
5505                "setup-machine fixture current_step must reference a declared step",
5506                &format!("{path}: {current_step}"),
5507            ));
5508        }
5509        if let Some(failed_step) = state.failed_step.as_deref()
5510            && !step_ids.contains(failed_step)
5511        {
5512            diagnostics.push(error_with_actual(
5513                "setup.machine.fixture.failed_step",
5514                "setup-machine fixture failed_step must reference a declared step",
5515                &format!("{path}: {failed_step}"),
5516            ));
5517        }
5518        for completed_step in &state.completed_steps {
5519            if !step_ids.contains(completed_step.as_str()) {
5520                diagnostics.push(error_with_actual(
5521                    "setup.machine.fixture.completed_step",
5522                    "setup-machine fixture completed_steps must reference declared steps",
5523                    &format!("{path}: {completed_step}"),
5524                ));
5525            }
5526        }
5527    }
5528
5529    diagnostics
5530}
5531
5532fn setup_machine_fixture_paths(machine: &SetupMachine) -> Vec<String> {
5533    let mut paths = Vec::new();
5534    if let Some(fixtures) = machine.extra.get("fixtures").and_then(Value::as_array) {
5535        for fixture in fixtures {
5536            if let Some(path) = fixture.as_str() {
5537                paths.push(path.to_string());
5538            } else if let Some(path) = fixture.get("path").and_then(Value::as_str) {
5539                paths.push(path.to_string());
5540            }
5541        }
5542    }
5543    if let Some(fixture_paths) = machine.extra.get("fixture_paths").and_then(Value::as_array) {
5544        for path in fixture_paths.iter().filter_map(Value::as_str) {
5545            paths.push(path.to_string());
5546        }
5547    }
5548    paths
5549        .into_iter()
5550        .map(|path| path.trim().to_string())
5551        .filter(|path| !path.is_empty())
5552        .collect()
5553}
5554
5555fn collect_setup_i18n_keys(
5556    pack_path: &Path,
5557    extension: &Value,
5558) -> anyhow::Result<BTreeSet<String>> {
5559    let descriptor = extension_inline(extension).unwrap_or(extension);
5560    let mut keys = BTreeSet::new();
5561    collect_message_keys_from_value(&mut keys, descriptor);
5562
5563    for key in ["asset", "path", "messages_asset", "messages_path"] {
5564        if let Some(path) = descriptor.get(key).and_then(Value::as_str) {
5565            let value = discovery::read_pack_json_asset(pack_path, path)?;
5566            collect_message_keys_from_value(&mut keys, &value);
5567        }
5568    }
5569
5570    for key in ["assets", "paths", "locales"] {
5571        if let Some(map) = descriptor.get(key).and_then(Value::as_object) {
5572            for value in map.values() {
5573                if let Some(path) = value.as_str() {
5574                    let messages = discovery::read_pack_json_asset(pack_path, path)?;
5575                    collect_message_keys_from_value(&mut keys, &messages);
5576                } else {
5577                    collect_message_keys_from_value(&mut keys, value);
5578                }
5579            }
5580        }
5581    }
5582
5583    Ok(keys)
5584}
5585
5586fn collect_message_keys_from_value(keys: &mut BTreeSet<String>, value: &Value) {
5587    if let Some(messages) = value.get("messages").and_then(Value::as_object) {
5588        collect_message_keys_from_object(keys, messages);
5589    }
5590    if let Some(translations) = value.get("translations").and_then(Value::as_object) {
5591        collect_message_keys_from_object(keys, translations);
5592    }
5593    if let Some(obj) = value.as_object() {
5594        collect_message_keys_from_object(keys, obj);
5595    }
5596}
5597
5598fn collect_message_keys_from_object(
5599    keys: &mut BTreeSet<String>,
5600    obj: &serde_json::Map<String, Value>,
5601) {
5602    for (key, value) in obj {
5603        if [
5604            "schema_id",
5605            "provider_id",
5606            "default_locale",
5607            "asset",
5608            "path",
5609            "messages",
5610            "translations",
5611            "assets",
5612            "paths",
5613            "locales",
5614        ]
5615        .contains(&key.as_str())
5616        {
5617            continue;
5618        }
5619        match value {
5620            Value::String(_) => {
5621                keys.insert(key.clone());
5622            }
5623            Value::Object(map) if map.values().all(|candidate| candidate.as_str().is_some()) => {
5624                keys.insert(key.clone());
5625            }
5626            Value::Object(_) => collect_message_keys_from_value(keys, value),
5627            _ => {}
5628        }
5629    }
5630}
5631
5632fn collect_setup_message_key_references(machine: &SetupMachine) -> BTreeSet<String> {
5633    let mut keys = BTreeSet::new();
5634    for step in &machine.steps {
5635        collect_message_key_from_value(&mut keys, &Value::Object(step.extra.clone()));
5636        for rule in step.on_failure.iter().chain(step.recover.iter()) {
5637            collect_message_key_from_value(&mut keys, rule);
5638        }
5639    }
5640    keys
5641}
5642
5643fn collect_message_key_from_value(keys: &mut BTreeSet<String>, value: &Value) {
5644    match value {
5645        Value::Object(map) => {
5646            for (key, nested) in map {
5647                if (key == "message_key" || key == "title_key" || key == "description_key")
5648                    && let Some(message_key) = nested.as_str()
5649                    && !message_key.trim().is_empty()
5650                {
5651                    keys.insert(message_key.to_string());
5652                }
5653                collect_message_key_from_value(keys, nested);
5654            }
5655        }
5656        Value::Array(values) => {
5657            for nested in values {
5658                collect_message_key_from_value(keys, nested);
5659            }
5660        }
5661        _ => {}
5662    }
5663}
5664
5665fn detect_cycle(
5666    step_id: &str,
5667    edges: &BTreeMap<String, Vec<String>>,
5668    steps: &BTreeMap<&str, &SetupMachineStep>,
5669    visited: &mut BTreeSet<String>,
5670    stack: &mut Vec<String>,
5671    reported: &mut BTreeSet<String>,
5672    diagnostics: &mut Vec<Diagnostic>,
5673) {
5674    if let Some(position) = stack.iter().position(|candidate| candidate == step_id) {
5675        let cycle = stack[position..].to_vec();
5676        let key = cycle.join(" -> ");
5677        if reported.insert(key.clone())
5678            && !cycle.iter().any(|id| {
5679                steps.get(id.as_str()).is_some_and(|step| {
5680                    step.extra.get("retry").is_some() || step.extra.get("timeout").is_some()
5681                })
5682            })
5683        {
5684            diagnostics.push(error_with_actual(
5685                "setup.machine.step.cycle",
5686                "setup step cycle must declare bounded retry or timeout policy",
5687                &key,
5688            ));
5689        }
5690        return;
5691    }
5692    if !visited.insert(step_id.to_string()) {
5693        return;
5694    }
5695    stack.push(step_id.to_string());
5696    for target in edges.get(step_id).into_iter().flatten() {
5697        detect_cycle(target, edges, steps, visited, stack, reported, diagnostics);
5698    }
5699    stack.pop();
5700}
5701
5702fn error(check_id: &str, message: &str) -> Diagnostic {
5703    Diagnostic {
5704        check_id: check_id.to_string(),
5705        severity: DiagnosticSeverity::Error,
5706        component: "provider_setup".to_string(),
5707        message: message.to_string(),
5708        evidence: None,
5709        expected: None,
5710        actual: None,
5711        fix_hint: None,
5712        related_file: None,
5713        related_pack: None,
5714        related_component: None,
5715    }
5716}
5717
5718fn error_with_actual(check_id: &str, message: &str, actual: &str) -> Diagnostic {
5719    Diagnostic {
5720        check_id: check_id.to_string(),
5721        severity: DiagnosticSeverity::Error,
5722        component: "provider_setup".to_string(),
5723        message: message.to_string(),
5724        evidence: None,
5725        expected: None,
5726        actual: Some(actual.to_string()),
5727        fix_hint: None,
5728        related_file: None,
5729        related_pack: None,
5730        related_component: None,
5731    }
5732}
5733
5734fn info(
5735    check_id: &str,
5736    message: &str,
5737    evidence: &str,
5738    pack_path: &Path,
5739    related_pack: Option<&str>,
5740) -> Diagnostic {
5741    Diagnostic {
5742        check_id: check_id.to_string(),
5743        severity: DiagnosticSeverity::Info,
5744        component: "provider_setup".to_string(),
5745        message: message.to_string(),
5746        evidence: Some(evidence.to_string()),
5747        expected: None,
5748        actual: None,
5749        fix_hint: None,
5750        related_file: Some(pack_path.display().to_string()),
5751        related_pack: related_pack.map(str::to_string),
5752        related_component: None,
5753    }
5754}
5755
5756fn load_error(
5757    check_id: &str,
5758    message: &str,
5759    expected: &str,
5760    err: anyhow::Error,
5761    pack_path: &Path,
5762) -> Diagnostic {
5763    Diagnostic {
5764        check_id: check_id.to_string(),
5765        severity: DiagnosticSeverity::Error,
5766        component: "provider_setup".to_string(),
5767        message: message.to_string(),
5768        evidence: Some(err.to_string()),
5769        expected: Some(expected.to_string()),
5770        actual: None,
5771        fix_hint: None,
5772        related_file: Some(pack_path.display().to_string()),
5773        related_pack: None,
5774        related_component: None,
5775    }
5776}
5777
5778fn report(pack_path: &Path, diagnostics: Vec<Diagnostic>) -> DoctorReport {
5779    let error_count = diagnostics
5780        .iter()
5781        .filter(|d| d.severity == DiagnosticSeverity::Error)
5782        .count();
5783    let warn_count = diagnostics
5784        .iter()
5785        .filter(|d| d.severity == DiagnosticSeverity::Warn)
5786        .count();
5787    let info_count = diagnostics
5788        .iter()
5789        .filter(|d| d.severity == DiagnosticSeverity::Info)
5790        .count();
5791    DoctorReport {
5792        bundle: pack_path.display().to_string(),
5793        status: if error_count > 0 {
5794            "error".to_string()
5795        } else if warn_count > 0 {
5796            "warn".to_string()
5797        } else {
5798            "ok".to_string()
5799        },
5800        error_count,
5801        warn_count,
5802        info_count,
5803        diagnostics,
5804    }
5805}
5806
5807#[cfg(test)]
5808mod tests {
5809    use super::*;
5810    use greentic_secrets_lib::SecretsStore;
5811    use std::io::Read;
5812    use std::io::Write;
5813    use zip::write::{FileOptions, ZipWriter};
5814
5815    fn valid_machine() -> SetupMachine {
5816        serde_json::from_value(serde_json::json!({
5817            "version": 1,
5818            "id": "messaging-teams-default",
5819            "entry_step": "collect",
5820            "steps": [
5821                {
5822                    "id": "collect",
5823                    "kind": "qa_form",
5824                    "on_success": "login"
5825                },
5826                {
5827                    "id": "login",
5828                    "kind": "oauth_device_code",
5829                    "device_code_url": "https://login.example.com/device",
5830                    "token_url": "https://login.example.com/token",
5831                    "client_id": "client-123",
5832                    "scopes": ["User.Read"],
5833                    "recover": [
5834                        {"when": "refresh_token_invalid", "action": "reauthorize"}
5835                    ],
5836                    "on_success": "complete"
5837                }
5838            ]
5839        }))
5840        .unwrap()
5841    }
5842
5843    fn serve_one_json(status: u16, body: &'static str) -> String {
5844        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
5845        let addr = listener.local_addr().unwrap();
5846        std::thread::spawn(move || {
5847            let (mut stream, _) = listener.accept().unwrap();
5848            let mut buffer = [0_u8; 2048];
5849            let _ = stream.read(&mut buffer);
5850            let wire = format!(
5851                "HTTP/1.1 {status} OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
5852                body.len(),
5853                body
5854            );
5855            stream.write_all(wire.as_bytes()).unwrap();
5856        });
5857        format!("http://{addr}")
5858    }
5859
5860    fn serve_json_sequence(responses: Vec<(u16, &'static str)>) -> String {
5861        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
5862        let addr = listener.local_addr().unwrap();
5863        std::thread::spawn(move || {
5864            for (status, body) in responses {
5865                let (mut stream, _) = listener.accept().unwrap();
5866                let mut buffer = [0_u8; 4096];
5867                let _ = stream.read(&mut buffer);
5868                let wire = format!(
5869                    "HTTP/1.1 {status} OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
5870                    body.len(),
5871                    body
5872                );
5873                stream.write_all(wire.as_bytes()).unwrap();
5874            }
5875        });
5876        format!("http://{addr}")
5877    }
5878
5879    #[test]
5880    fn validates_valid_machine() {
5881        let diagnostics = validate_setup_machine(&valid_machine());
5882        assert!(diagnostics.is_empty(), "{diagnostics:?}");
5883    }
5884
5885    #[test]
5886    fn rejects_missing_transition_target_and_unreachable_step() {
5887        let mut machine = valid_machine();
5888        machine.steps[0].on_success = Some("missing".to_string());
5889        machine.steps.push(SetupMachineStep {
5890            id: "never".to_string(),
5891            title: None,
5892            kind: "qa_form".to_string(),
5893            requires: vec![],
5894            outputs: vec![],
5895            on_success: None,
5896            on_failure: vec![],
5897            recover: vec![],
5898            extra: serde_json::Map::new(),
5899        });
5900
5901        let ids: BTreeSet<_> = validate_setup_machine(&machine)
5902            .into_iter()
5903            .map(|d| d.check_id)
5904            .collect();
5905        assert!(ids.contains("setup.machine.step.on_success"));
5906        assert!(ids.contains("setup.machine.step.reachable"));
5907    }
5908
5909    #[test]
5910    fn rejects_unbounded_setup_machine_cycles() {
5911        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
5912            "version": 1,
5913            "id": "cycle-machine",
5914            "entry_step": "a",
5915            "steps": [
5916                {"id": "a", "kind": "qa_form", "on_success": "b"},
5917                {"id": "b", "kind": "manual_action", "on_success": "a"}
5918            ]
5919        }))
5920        .unwrap();
5921
5922        let ids: BTreeSet<_> = validate_setup_machine(&machine)
5923            .into_iter()
5924            .map(|d| d.check_id)
5925            .collect();
5926        assert!(ids.contains("setup.machine.step.cycle"));
5927    }
5928
5929    #[test]
5930    fn allows_bounded_setup_machine_cycles() {
5931        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
5932            "version": 1,
5933            "id": "cycle-machine",
5934            "entry_step": "a",
5935            "steps": [
5936                {"id": "a", "kind": "qa_form", "on_success": "b"},
5937                {
5938                    "id": "b",
5939                    "kind": "manual_action",
5940                    "retry": {"max_attempts": 3},
5941                    "on_success": "a"
5942                }
5943            ]
5944        }))
5945        .unwrap();
5946
5947        let ids: BTreeSet<_> = validate_setup_machine(&machine)
5948            .into_iter()
5949            .map(|d| d.check_id)
5950            .collect();
5951        assert!(!ids.contains("setup.machine.step.cycle"));
5952    }
5953
5954    #[test]
5955    fn rejects_unsafe_setup_machine_artifact_paths() {
5956        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
5957            "version": 1,
5958            "id": "artifact-machine",
5959            "entry_step": "generate",
5960            "steps": [
5961                {"id": "generate", "kind": "generate_file", "artifact_path": "../secret.json"},
5962                {"id": "download", "kind": "download_file", "on_success": "complete"}
5963            ]
5964        }))
5965        .unwrap();
5966
5967        let ids: BTreeSet<_> = validate_setup_machine(&machine)
5968            .into_iter()
5969            .map(|d| d.check_id)
5970            .collect();
5971        assert!(ids.contains("setup.machine.artifact.path.safe"));
5972        assert!(ids.contains("setup.machine.artifact.path"));
5973    }
5974
5975    #[test]
5976    fn accepts_setup_machine_artifact_paths_under_artifacts() {
5977        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
5978            "version": 1,
5979            "id": "artifact-machine",
5980            "entry_step": "generate",
5981            "steps": [
5982                {
5983                    "id": "generate",
5984                    "kind": "generate_file",
5985                    "artifact_path": "artifacts/teams/app.zip",
5986                    "on_success": "download"
5987                },
5988                {
5989                    "id": "download",
5990                    "kind": "download_file",
5991                    "artifact_path": "artifacts/teams/app.zip",
5992                    "on_success": "complete"
5993                }
5994            ]
5995        }))
5996        .unwrap();
5997
5998        let ids: BTreeSet<_> = validate_setup_machine(&machine)
5999            .into_iter()
6000            .map(|d| d.check_id)
6001            .collect();
6002        assert!(!ids.contains("setup.machine.artifact.path"));
6003        assert!(!ids.contains("setup.machine.artifact.path.safe"));
6004    }
6005
6006    #[test]
6007    fn rejects_invalid_setup_machine_http_steps() {
6008        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
6009            "version": 1,
6010            "id": "http-machine",
6011            "entry_step": "missing_url",
6012            "steps": [
6013                {"id": "missing_url", "kind": "http_probe", "on_success": "bad_url"},
6014                {
6015                    "id": "bad_url",
6016                    "kind": "http_json",
6017                    "url_template": "http://example.com/setup",
6018                    "method": "PUT",
6019                    "expected_status": 999
6020                }
6021            ]
6022        }))
6023        .unwrap();
6024
6025        let ids: BTreeSet<_> = validate_setup_machine(&machine)
6026            .into_iter()
6027            .map(|d| d.check_id)
6028            .collect();
6029        assert!(ids.contains("setup.machine.http.url"));
6030        assert!(ids.contains("setup.machine.http.url.safe"));
6031        assert!(ids.contains("setup.machine.http.method"));
6032        assert!(ids.contains("setup.machine.http.expected_status"));
6033        assert!(ids.contains("setup.machine.step.idempotency_key"));
6034    }
6035
6036    #[test]
6037    fn rejects_invalid_setup_machine_tunnel_steps() {
6038        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
6039            "version": 1,
6040            "id": "tunnel-machine",
6041            "entry_step": "tunnel",
6042            "steps": [
6043                {
6044                    "id": "tunnel",
6045                    "kind": "cloudflare_tunnel",
6046                    "mode": "unsupported",
6047                    "public_base_url": "http://example.com"
6048                }
6049            ]
6050        }))
6051        .unwrap();
6052
6053        let ids: BTreeSet<_> = validate_setup_machine(&machine)
6054            .into_iter()
6055            .map(|d| d.check_id)
6056            .collect();
6057        assert!(ids.contains("setup.machine.cloudflare_tunnel.mode"));
6058        assert!(ids.contains("setup.machine.cloudflare_tunnel.public_base_url"));
6059    }
6060
6061    #[test]
6062    fn rejects_invalid_setup_machine_oauth_authorization_steps() {
6063        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
6064            "version": 1,
6065            "id": "oauth-machine",
6066            "entry_step": "oauth",
6067            "steps": [
6068                {
6069                    "id": "oauth",
6070                    "kind": "oauth_authorization_code",
6071                    "authorize_url": "http://login.example.com/auth",
6072                    "token_url": "http://login.example.com/token",
6073                    "scopes": "User.Read",
6074                    "params": ["bad"],
6075                    "state_ttl_secs": 0
6076                }
6077            ]
6078        }))
6079        .unwrap();
6080
6081        let ids: BTreeSet<_> = validate_setup_machine(&machine)
6082            .into_iter()
6083            .map(|d| d.check_id)
6084            .collect();
6085        assert!(ids.contains("setup.machine.oauth_authorization_code.authorize_url.safe"));
6086        assert!(ids.contains("setup.machine.oauth_authorization_code.token_url.safe"));
6087        assert!(ids.contains("setup.machine.oauth_authorization_code.scopes"));
6088        assert!(ids.contains("setup.machine.oauth_authorization_code.params"));
6089        assert!(ids.contains("setup.machine.oauth_authorization_code.state_ttl_secs"));
6090    }
6091
6092    #[test]
6093    fn rejects_invalid_setup_machine_oauth_device_steps() {
6094        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
6095            "version": 1,
6096            "id": "oauth-machine",
6097            "entry_step": "device",
6098            "steps": [
6099                {
6100                    "id": "device",
6101                    "kind": "oauth_device_code",
6102                    "device_code_url": "http://example.com/device",
6103                    "scopes": "User.Read"
6104                }
6105            ]
6106        }))
6107        .unwrap();
6108
6109        let ids: BTreeSet<_> = validate_setup_machine(&machine)
6110            .into_iter()
6111            .map(|d| d.check_id)
6112            .collect();
6113        assert!(ids.contains("setup.machine.oauth_device_code.endpoints"));
6114        assert!(ids.contains("setup.machine.oauth_device_code.url.safe"));
6115        assert!(ids.contains("setup.machine.oauth_device_code.client_id"));
6116        assert!(ids.contains("setup.machine.oauth_device_code.scopes"));
6117    }
6118
6119    #[test]
6120    fn rejects_invalid_setup_machine_data_mapping_steps() {
6121        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
6122            "version": 1,
6123            "id": "mapping-machine",
6124            "entry_step": "select",
6125            "steps": [
6126                {
6127                    "id": "select",
6128                    "kind": "select_from_json",
6129                    "json_pointer": "value",
6130                    "on_success": "persist"
6131                },
6132                {
6133                    "id": "persist",
6134                    "kind": "persist_runtime_config",
6135                    "config": ["not", "object"],
6136                    "server_owned_config_keys": "graph_access_token"
6137                },
6138                {
6139                    "id": "component",
6140                    "kind": "provider_component_call",
6141                    "request": ["not", "object"]
6142                }
6143            ]
6144        }))
6145        .unwrap();
6146
6147        let ids: BTreeSet<_> = validate_setup_machine(&machine)
6148            .into_iter()
6149            .map(|d| d.check_id)
6150            .collect();
6151        assert!(ids.contains("setup.machine.select_from_json.source_output"));
6152        assert!(ids.contains("setup.machine.select_from_json.json_pointer"));
6153        assert!(ids.contains("setup.machine.persist_runtime_config.object"));
6154        assert!(ids.contains("setup.machine.persist_runtime_config.server_owned_config_keys"));
6155        assert!(ids.contains("setup.machine.provider_component_call.component_ref"));
6156        assert!(ids.contains("setup.machine.provider_component_call.op"));
6157        assert!(ids.contains("setup.machine.provider_component_call.request"));
6158        assert!(ids.contains("setup.machine.step.idempotency_key"));
6159    }
6160
6161    #[test]
6162    fn accepts_valid_setup_machine_data_mapping_steps() {
6163        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
6164            "version": 1,
6165            "id": "mapping-machine",
6166            "entry_step": "select",
6167            "steps": [
6168                {
6169                    "id": "select",
6170                    "kind": "select_from_json",
6171                    "source_output": "teams",
6172                    "json_pointer": "/value",
6173                    "value_key": "id",
6174                    "on_success": "persist"
6175                },
6176                {
6177                    "id": "persist",
6178                    "kind": "persist_runtime_config",
6179                    "config": {"team_id": "{select}"},
6180                    "server_owned_config_keys": ["graph_access_token"],
6181                    "on_success": "component"
6182                },
6183                {
6184                    "id": "component",
6185                    "kind": "provider_component_call",
6186                    "component_ref": "messaging-example",
6187                    "op": "register",
6188                    "idempotency_key": "register:{select}",
6189                    "request": {"team_id": "{select}"}
6190                }
6191            ]
6192        }))
6193        .unwrap();
6194
6195        let ids: BTreeSet<_> = validate_setup_machine(&machine)
6196            .into_iter()
6197            .map(|d| d.check_id)
6198            .collect();
6199        assert!(!ids.contains("setup.machine.select_from_json.source_output"));
6200        assert!(!ids.contains("setup.machine.select_from_json.json_pointer"));
6201        assert!(!ids.contains("setup.machine.persist_runtime_config.input"));
6202        assert!(!ids.contains("setup.machine.persist_runtime_config.object"));
6203        assert!(!ids.contains("setup.machine.provider_component_call.component_ref"));
6204        assert!(!ids.contains("setup.machine.provider_component_call.op"));
6205        assert!(!ids.contains("setup.machine.provider_component_call.request"));
6206        assert!(!ids.contains("setup.machine.step.idempotency_key"));
6207    }
6208
6209    #[test]
6210    fn rejects_persist_runtime_config_writing_server_owned_keys() {
6211        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
6212            "version": 1,
6213            "id": "mapping-machine",
6214            "entry_step": "persist",
6215            "server_owned_config_keys": ["tenant_secret"],
6216            "steps": [
6217                {
6218                    "id": "persist",
6219                    "kind": "persist_runtime_config",
6220                    "config": {
6221                        "graph_access_token": "{token}",
6222                        "team_id": "{team}"
6223                    },
6224                    "mappings": {
6225                        "tenant_secret": "{secret}"
6226                    },
6227                    "server_owned_config_keys": ["graph_access_token"]
6228                }
6229            ]
6230        }))
6231        .unwrap();
6232
6233        let diagnostics = validate_setup_machine(&machine);
6234        let actual: BTreeSet<_> = diagnostics
6235            .iter()
6236            .filter(|diagnostic| {
6237                diagnostic.check_id == "setup.machine.persist_runtime_config.server_owned_output"
6238            })
6239            .filter_map(|diagnostic| diagnostic.actual.as_deref())
6240            .collect();
6241        assert!(actual.contains("persist: config.graph_access_token"));
6242        assert!(actual.contains("persist: mappings.tenant_secret"));
6243    }
6244
6245    #[test]
6246    fn accepts_templated_setup_machine_http_steps() {
6247        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
6248            "version": 1,
6249            "id": "http-machine",
6250            "entry_step": "probe",
6251            "steps": [
6252                {
6253                    "id": "probe",
6254                    "kind": "http_probe",
6255                    "url_template": "{public_base_url}/health",
6256                    "method": "GET",
6257                    "expected_status": 204,
6258                    "on_success": "register"
6259                },
6260                {
6261                    "id": "register",
6262                    "kind": "http_json",
6263                    "url_template": "{public_base_url}/register",
6264                    "method": "POST",
6265                    "expected_status": 200,
6266                    "idempotency_key": "register:{tenant}",
6267                    "on_success": "complete"
6268                }
6269            ]
6270        }))
6271        .unwrap();
6272
6273        let ids: BTreeSet<_> = validate_setup_machine(&machine)
6274            .into_iter()
6275            .map(|d| d.check_id)
6276            .collect();
6277        assert!(!ids.contains("setup.machine.http.url"));
6278        assert!(!ids.contains("setup.machine.http.url.safe"));
6279        assert!(!ids.contains("setup.machine.http.method"));
6280        assert!(!ids.contains("setup.machine.http.expected_status"));
6281        assert!(!ids.contains("setup.machine.step.idempotency_key"));
6282    }
6283
6284    fn write_setup_machine_pack(
6285        pack: &Path,
6286        provider_id: &str,
6287        machine: &Value,
6288        setup_yaml: Option<&str>,
6289    ) -> anyhow::Result<()> {
6290        let file = std::fs::File::create(pack)?;
6291        let mut writer = ZipWriter::new(file);
6292        let options: FileOptions<'_, ()> =
6293            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6294        writer.start_file("pack.manifest.json", options)?;
6295        writer.write_all(
6296            serde_json::json!({
6297                "pack_id": provider_id,
6298                "extensions": {
6299                    SETUP_MACHINE_EXTENSION: {
6300                        "path": "assets/setup/setup-machine.v1.json",
6301                        "schema": SETUP_MACHINE_SCHEMA_URL
6302                    }
6303                }
6304            })
6305            .to_string()
6306            .as_bytes(),
6307        )?;
6308        if let Some(setup_yaml) = setup_yaml {
6309            writer.start_file("assets/setup.yaml", options)?;
6310            writer.write_all(setup_yaml.as_bytes())?;
6311        }
6312        writer.start_file("assets/setup/setup-machine.v1.json", options)?;
6313        writer.write_all(serde_json::to_string(machine)?.as_bytes())?;
6314        writer.finish()?;
6315        Ok(())
6316    }
6317
6318    #[test]
6319    fn doctor_accepts_setup_machine_template_inputs_from_answers_and_outputs() -> anyhow::Result<()>
6320    {
6321        let temp = tempfile::tempdir()?;
6322        let pack = temp.path().join("messaging-example.gtpack");
6323        let machine = serde_json::json!({
6324            "version": 1,
6325            "id": "messaging-example-machine",
6326            "entry_step": "public_url",
6327            "steps": [
6328                {
6329                    "id": "public_url",
6330                    "kind": "platform_public_url",
6331                    "default": "https://example.test",
6332                    "on_success": "persist"
6333                },
6334                {
6335                    "id": "persist",
6336                    "kind": "persist_runtime_config",
6337                    "config": {
6338                        "bot_app_id": "{bot_app_id}",
6339                        "public_base_url": "{public_base_url}",
6340                        "tenant_id": "{tenant}",
6341                        "provider": "{provider_id}"
6342                    }
6343                }
6344            ]
6345        });
6346        write_setup_machine_pack(
6347            &pack,
6348            "messaging-example",
6349            &machine,
6350            Some("title: Example\nquestions:\n  - name: bot_app_id\n    required: true\n"),
6351        )?;
6352
6353        let report = run_provider_setup_machine_doctor(&pack);
6354        assert_eq!(report.status, "ok");
6355        assert!(
6356            !report
6357                .diagnostics
6358                .iter()
6359                .any(|diagnostic| diagnostic.check_id == "setup.machine.template.input")
6360        );
6361        Ok(())
6362    }
6363
6364    #[test]
6365    fn doctor_rejects_setup_machine_unknown_template_input() -> anyhow::Result<()> {
6366        let temp = tempfile::tempdir()?;
6367        let pack = temp.path().join("messaging-example.gtpack");
6368        let machine = serde_json::json!({
6369            "version": 1,
6370            "id": "messaging-example-machine",
6371            "entry_step": "persist",
6372            "steps": [
6373                {
6374                    "id": "persist",
6375                    "kind": "persist_runtime_config",
6376                    "config": {
6377                        "bot_app_id": "{bot_ap_id}"
6378                    }
6379                }
6380            ]
6381        });
6382        write_setup_machine_pack(
6383            &pack,
6384            "messaging-example",
6385            &machine,
6386            Some("title: Example\nquestions:\n  - name: bot_app_id\n    required: true\n"),
6387        )?;
6388
6389        let report = run_provider_setup_machine_doctor(&pack);
6390        assert_eq!(report.status, "error");
6391        assert!(report.diagnostics.iter().any(|diagnostic| {
6392            diagnostic.check_id == "setup.machine.template.input"
6393                && diagnostic
6394                    .actual
6395                    .as_deref()
6396                    .is_some_and(|actual| actual.contains("bot_ap_id"))
6397        }));
6398        Ok(())
6399    }
6400
6401    #[test]
6402    fn loads_machine_from_pack_asset_extension() -> anyhow::Result<()> {
6403        let temp = tempfile::tempdir()?;
6404        let pack = temp.path().join("messaging-teams.gtpack");
6405        let file = std::fs::File::create(&pack)?;
6406        let mut writer = ZipWriter::new(file);
6407        let options: FileOptions<'_, ()> =
6408            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6409        writer.start_file("pack.manifest.json", options)?;
6410        writer.write_all(
6411            serde_json::json!({
6412                "pack_id": "messaging-teams",
6413                "extensions": {
6414                    SETUP_MACHINE_EXTENSION: {
6415                        "path": "assets/setup/setup-machine.v1.json",
6416                        "schema": SETUP_MACHINE_SCHEMA_URL
6417                    }
6418                }
6419            })
6420            .to_string()
6421            .as_bytes(),
6422        )?;
6423        writer.start_file("assets/setup/setup-machine.v1.json", options)?;
6424        writer.write_all(serde_json::to_string(&valid_machine())?.as_bytes())?;
6425        writer.finish()?;
6426
6427        let machine = load_setup_machine_from_pack(&pack)?.expect("machine");
6428        assert_eq!(machine.id, "messaging-teams-default");
6429        let report = run_provider_setup_machine_doctor(&pack);
6430        assert_eq!(report.status, "ok");
6431        Ok(())
6432    }
6433
6434    #[test]
6435    fn doctor_rejects_legacy_setup_actions_in_migrated_pack() -> anyhow::Result<()> {
6436        let temp = tempfile::tempdir()?;
6437        let pack = temp.path().join("messaging-teams.gtpack");
6438        let file = std::fs::File::create(&pack)?;
6439        let mut writer = ZipWriter::new(file);
6440        let options: FileOptions<'_, ()> =
6441            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6442        writer.start_file("pack.manifest.json", options)?;
6443        writer.write_all(
6444            serde_json::json!({
6445                "pack_id": "messaging-teams",
6446                "extensions": {
6447                    SETUP_MACHINE_EXTENSION: {
6448                        "path": "assets/setup/setup-machine.v1.json",
6449                        "schema": SETUP_MACHINE_SCHEMA_URL
6450                    }
6451                }
6452            })
6453            .to_string()
6454            .as_bytes(),
6455        )?;
6456        writer.start_file("assets/setup/setup-machine.v1.json", options)?;
6457        writer.write_all(serde_json::to_string(&valid_machine())?.as_bytes())?;
6458        writer.start_file("assets/setup.yaml", options)?;
6459        writer.write_all(
6460            br#"
6461title: Teams
6462questions: []
6463setup_actions:
6464  - id: legacy_install
6465    label: Legacy install
6466    kind: oauth_install_button
6467    authorize_url: https://login.example/install
6468"#,
6469        )?;
6470        writer.finish()?;
6471
6472        let report = run_provider_setup_machine_doctor(&pack);
6473
6474        assert_eq!(report.status, "error");
6475        assert!(report.diagnostics.iter().any(|d| {
6476            d.check_id == "setup.legacy_setup_actions.absent"
6477                && d.severity == DiagnosticSeverity::Error
6478        }));
6479        Ok(())
6480    }
6481
6482    #[test]
6483    fn doctor_rejects_missing_setup_i18n_message_key() -> anyhow::Result<()> {
6484        let temp = tempfile::tempdir()?;
6485        let pack = temp.path().join("messaging-teams.gtpack");
6486        let file = std::fs::File::create(&pack)?;
6487        let mut writer = ZipWriter::new(file);
6488        let options: FileOptions<'_, ()> =
6489            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6490        writer.start_file("pack.manifest.json", options)?;
6491        writer.write_all(
6492            serde_json::json!({
6493                "pack_id": "messaging-teams",
6494                "extensions": {
6495                    SETUP_MACHINE_EXTENSION: {
6496                        "path": "assets/setup/setup-machine.v1.json",
6497                        "schema": SETUP_MACHINE_SCHEMA_URL
6498                    },
6499                    SETUP_I18N_EXTENSION: {
6500                        "inline": {
6501                            "schema_id": SETUP_I18N_EXTENSION,
6502                            "provider_id": "messaging-teams",
6503                            "messages": {
6504                                "setup.present": "Present"
6505                            }
6506                        }
6507                    }
6508                }
6509            })
6510            .to_string()
6511            .as_bytes(),
6512        )?;
6513        writer.start_file("assets/setup/setup-machine.v1.json", options)?;
6514        writer.write_all(
6515            serde_json::json!({
6516                "version": 1,
6517                "id": "messaging-teams-default",
6518                "entry_step": "approve",
6519                "steps": [
6520                    {
6521                        "id": "approve",
6522                        "kind": "manual_action",
6523                        "message_key": "setup.missing"
6524                    }
6525                ]
6526            })
6527            .to_string()
6528            .as_bytes(),
6529        )?;
6530        writer.finish()?;
6531
6532        let report = run_provider_setup_machine_doctor(&pack);
6533
6534        assert_eq!(report.status, "error");
6535        assert!(report.diagnostics.iter().any(|d| {
6536            d.check_id == "setup.i18n.message_key.exists"
6537                && d.actual.as_deref() == Some("setup.missing")
6538        }));
6539        Ok(())
6540    }
6541
6542    #[test]
6543    fn doctor_accepts_setup_i18n_message_key_from_asset() -> anyhow::Result<()> {
6544        let temp = tempfile::tempdir()?;
6545        let pack = temp.path().join("messaging-teams.gtpack");
6546        let file = std::fs::File::create(&pack)?;
6547        let mut writer = ZipWriter::new(file);
6548        let options: FileOptions<'_, ()> =
6549            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6550        writer.start_file("pack.manifest.json", options)?;
6551        writer.write_all(
6552            serde_json::json!({
6553                "pack_id": "messaging-teams",
6554                "extensions": {
6555                    SETUP_MACHINE_EXTENSION: {
6556                        "path": "assets/setup/setup-machine.v1.json",
6557                        "schema": SETUP_MACHINE_SCHEMA_URL
6558                    },
6559                    SETUP_I18N_EXTENSION: {
6560                        "path": "assets/setup/i18n/en.json"
6561                    }
6562                }
6563            })
6564            .to_string()
6565            .as_bytes(),
6566        )?;
6567        writer.start_file("assets/setup/setup-machine.v1.json", options)?;
6568        writer.write_all(
6569            serde_json::json!({
6570                "version": 1,
6571                "id": "messaging-teams-default",
6572                "entry_step": "approve",
6573                "steps": [
6574                    {
6575                        "id": "approve",
6576                        "kind": "manual_action",
6577                        "on_failure": [
6578                            {
6579                                "when": "manual_action_required",
6580                                "action": "prompt",
6581                                "message_key": "setup.approve"
6582                            }
6583                        ]
6584                    }
6585                ]
6586            })
6587            .to_string()
6588            .as_bytes(),
6589        )?;
6590        writer.start_file("assets/setup/i18n/en.json", options)?;
6591        writer.write_all(
6592            serde_json::json!({
6593                "messages": {
6594                    "setup.approve": "Approve setup"
6595                }
6596            })
6597            .to_string()
6598            .as_bytes(),
6599        )?;
6600        writer.finish()?;
6601
6602        let report = run_provider_setup_machine_doctor(&pack);
6603
6604        assert_eq!(report.status, "ok", "{report:#?}");
6605        Ok(())
6606    }
6607
6608    #[test]
6609    fn doctor_accepts_setup_machine_fixture_state() -> anyhow::Result<()> {
6610        let temp = tempfile::tempdir()?;
6611        let pack = temp.path().join("messaging-teams.gtpack");
6612        let file = std::fs::File::create(&pack)?;
6613        let mut writer = ZipWriter::new(file);
6614        let options: FileOptions<'_, ()> =
6615            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6616        writer.start_file("pack.manifest.json", options)?;
6617        writer.write_all(
6618            serde_json::json!({
6619                "pack_id": "messaging-teams",
6620                "extensions": {
6621                    SETUP_MACHINE_EXTENSION: {
6622                        "path": "assets/setup/setup-machine.v1.json",
6623                        "schema": SETUP_MACHINE_SCHEMA_URL
6624                    }
6625                }
6626            })
6627            .to_string()
6628            .as_bytes(),
6629        )?;
6630        writer.start_file("assets/setup/setup-machine.v1.json", options)?;
6631        writer.write_all(
6632            serde_json::json!({
6633                "version": 1,
6634                "id": "messaging-teams-default",
6635                "entry_step": "start",
6636                "fixture_paths": ["assets/setup/fixtures/paused.json"],
6637                "steps": [
6638                    {
6639                        "id": "start",
6640                        "kind": "manual_action",
6641                        "on_success": "complete"
6642                    }
6643                ]
6644            })
6645            .to_string()
6646            .as_bytes(),
6647        )?;
6648        writer.start_file("assets/setup/fixtures/paused.json", options)?;
6649        writer.write_all(
6650            serde_json::json!({
6651                "state": {
6652                    "schema_version": 1,
6653                    "provider_id": "messaging-teams",
6654                    "tenant": "demo",
6655                    "team": "default",
6656                    "machine_id": "messaging-teams-default",
6657                    "machine_version": 1,
6658                    "status": "paused",
6659                    "current_step": "start",
6660                    "completed_steps": [],
6661                    "outputs": {},
6662                    "created_resources": []
6663                }
6664            })
6665            .to_string()
6666            .as_bytes(),
6667        )?;
6668        writer.finish()?;
6669
6670        let report = run_provider_setup_machine_doctor(&pack);
6671
6672        assert_eq!(report.status, "ok", "{report:#?}");
6673        Ok(())
6674    }
6675
6676    #[test]
6677    fn doctor_rejects_setup_machine_fixture_with_missing_step() -> anyhow::Result<()> {
6678        let temp = tempfile::tempdir()?;
6679        let pack = temp.path().join("messaging-teams.gtpack");
6680        let file = std::fs::File::create(&pack)?;
6681        let mut writer = ZipWriter::new(file);
6682        let options: FileOptions<'_, ()> =
6683            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6684        writer.start_file("pack.manifest.json", options)?;
6685        writer.write_all(
6686            serde_json::json!({
6687                "pack_id": "messaging-teams",
6688                "extensions": {
6689                    SETUP_MACHINE_EXTENSION: {
6690                        "path": "assets/setup/setup-machine.v1.json",
6691                        "schema": SETUP_MACHINE_SCHEMA_URL
6692                    }
6693                }
6694            })
6695            .to_string()
6696            .as_bytes(),
6697        )?;
6698        writer.start_file("assets/setup/setup-machine.v1.json", options)?;
6699        writer.write_all(
6700            serde_json::json!({
6701                "version": 1,
6702                "id": "messaging-teams-default",
6703                "entry_step": "start",
6704                "fixtures": [{"path": "assets/setup/fixtures/bad.json"}],
6705                "steps": [
6706                    {
6707                        "id": "start",
6708                        "kind": "manual_action",
6709                        "on_success": "complete"
6710                    }
6711                ]
6712            })
6713            .to_string()
6714            .as_bytes(),
6715        )?;
6716        writer.start_file("assets/setup/fixtures/bad.json", options)?;
6717        writer.write_all(
6718            serde_json::json!({
6719                "schema_version": 1,
6720                "provider_id": "messaging-teams",
6721                "tenant": "demo",
6722                "team": "default",
6723                "machine_id": "messaging-teams-default",
6724                "machine_version": 1,
6725                "status": "paused",
6726                "current_step": "missing",
6727                "completed_steps": ["start"],
6728                "outputs": {},
6729                "created_resources": []
6730            })
6731            .to_string()
6732            .as_bytes(),
6733        )?;
6734        writer.finish()?;
6735
6736        let report = run_provider_setup_machine_doctor(&pack);
6737
6738        assert_eq!(report.status, "error");
6739        assert!(report.diagnostics.iter().any(|diagnostic| {
6740            diagnostic.check_id == "setup.machine.fixture.current_step"
6741                && diagnostic.actual.as_deref() == Some("assets/setup/fixtures/bad.json: missing")
6742        }));
6743        Ok(())
6744    }
6745
6746    #[test]
6747    fn doctor_accepts_backend_contract_without_setup_machine() -> anyhow::Result<()> {
6748        let temp = tempfile::tempdir()?;
6749        let pack = temp.path().join("messaging-teams.gtpack");
6750        let file = std::fs::File::create(&pack)?;
6751        let mut writer = ZipWriter::new(file);
6752        let options: FileOptions<'_, ()> =
6753            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6754        writer.start_file("pack.manifest.json", options)?;
6755        writer.write_all(
6756            serde_json::json!({
6757                "pack_id": "messaging-teams",
6758                "extensions": {
6759                    SETUP_WEB_COMPONENT_EXTENSION: {
6760                        "inline": {
6761                            "schema_id": SETUP_WEB_COMPONENT_EXTENSION,
6762                            "provider_id": "messaging-teams",
6763                            "tag_name": "greentic-teams-setup-v4",
6764                            "module_asset": "assets/setup/greentic-teams-setup.js"
6765                        }
6766                    },
6767                    SETUP_BACKEND_CONTRACT_EXTENSION: {
6768                        "inline": {
6769                            "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
6770                            "provider_id": "messaging-teams",
6771                            "asset": "assets/setup/backend-contract.json"
6772                        }
6773                    },
6774                    HTTP_ROUTES_EXTENSION: {
6775                        "inline": {
6776                            "routes": [
6777                                {
6778                                    "pattern": "/v1/setup/messaging-teams/{tenant}/{team}/register",
6779                                    "methods": ["POST"],
6780                                    "setup_component_ref": "messaging-teams",
6781                                    "setup_op": "register"
6782                                }
6783                            ]
6784                        }
6785                    }
6786                }
6787            })
6788            .to_string()
6789            .as_bytes(),
6790        )?;
6791        writer.start_file("assets/setup/greentic-teams-setup.js", options)?;
6792        writer.write_all(b"export default class GreenticTeamsSetup extends HTMLElement {}\n")?;
6793        writer.start_file("assets/setup/backend-contract.json", options)?;
6794        writer.write_all(
6795            serde_json::json!({
6796                "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
6797                "provider_id": "messaging-teams",
6798                "server_owned_config_keys": [
6799                    "oauth_kind",
6800                    "oauth_device_code",
6801                    "oauth_user_code",
6802                    "graph_access_token"
6803                ],
6804                "required_order": ["graph_admin_consent", "register_endpoint", "first_message"],
6805                "actions": [
6806                    {
6807                        "id": "graph_admin_consent",
6808                        "executor": {
6809                            "kind": "oauth_device_code",
6810                            "authority_url_template": "https://login.microsoftonline.com/{authority_tenant}",
6811                            "client_id_config_key": "graph_setup_client_id",
6812                            "oauth_kind": "graph",
6813                            "token_store_key": "graph_access_token",
6814                            "scopes": ["https://graph.microsoft.com/User.Read"]
6815                        },
6816                        "completion": {"state_path": "oauth.graph.ok", "exists": true}
6817                    },
6818                    {
6819                        "id": "register_endpoint",
6820                        "requires": ["graph_admin_consent"],
6821                        "executor": {
6822                            "kind": "provider_http",
6823                            "path_template": "/v1/setup/messaging-teams/{tenant}/{team}/register"
6824                        },
6825                        "completion": {"state_path": "last_reconcile.ok", "equals": true}
6826                    },
6827                    {
6828                        "id": "first_message",
6829                        "requires": ["register_endpoint"],
6830                        "executor": {
6831                            "kind": "runtime_observation",
6832                            "source": "greentic-start",
6833                            "event": "bot_framework_activity_received",
6834                            "state_store_key": "last_activity"
6835                        },
6836                        "completion": {"state_path": "last_activity", "exists": true}
6837                    }
6838                ]
6839            })
6840            .to_string()
6841            .as_bytes(),
6842        )?;
6843        writer.finish()?;
6844
6845        let report = run_provider_setup_machine_doctor(&pack);
6846        assert_eq!(report.status, "ok", "{report:#?}");
6847        assert!(
6848            report
6849                .diagnostics
6850                .iter()
6851                .any(|d| d.check_id == "setup.backend_contract.extension.present")
6852        );
6853        Ok(())
6854    }
6855
6856    #[test]
6857    fn doctor_rejects_invalid_setup_machine_even_with_backend_contract() -> anyhow::Result<()> {
6858        let temp = tempfile::tempdir()?;
6859        let pack = temp.path().join("messaging-teams.gtpack");
6860        let file = std::fs::File::create(&pack)?;
6861        let mut writer = ZipWriter::new(file);
6862        let options: FileOptions<'_, ()> =
6863            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6864        writer.start_file("pack.manifest.json", options)?;
6865        writer.write_all(
6866            serde_json::json!({
6867                "pack_id": "messaging-teams",
6868                "extensions": {
6869                    SETUP_WEB_COMPONENT_EXTENSION: {
6870                        "inline": {
6871                            "schema_id": SETUP_WEB_COMPONENT_EXTENSION,
6872                            "provider_id": "messaging-teams",
6873                            "tag_name": "greentic-teams-setup-v4",
6874                            "module_asset": "assets/setup/greentic-teams-setup.js"
6875                        }
6876                    },
6877                    SETUP_BACKEND_CONTRACT_EXTENSION: {
6878                        "inline": {
6879                            "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
6880                            "provider_id": "messaging-teams",
6881                            "asset": "assets/setup/backend-contract.json"
6882                        }
6883                    },
6884                    SETUP_MACHINE_EXTENSION: {
6885                        "path": "assets/setup/setup-machine.v1.json",
6886                        "schema": SETUP_MACHINE_SCHEMA_URL
6887                    }
6888                }
6889            })
6890            .to_string()
6891            .as_bytes(),
6892        )?;
6893        writer.start_file("assets/setup/greentic-teams-setup.js", options)?;
6894        writer.write_all(b"export default class GreenticTeamsSetup extends HTMLElement {}\n")?;
6895        writer.start_file("assets/setup/backend-contract.json", options)?;
6896        writer.write_all(
6897            serde_json::json!({
6898                "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
6899                "provider_id": "messaging-teams",
6900                "server_owned_config_keys": [
6901                    "oauth_kind",
6902                    "oauth_device_code",
6903                    "oauth_user_code",
6904                    "graph_access_token"
6905                ],
6906                "required_order": ["graph_admin_consent"],
6907                "actions": [
6908                    {
6909                        "id": "graph_admin_consent",
6910                        "executor": {
6911                            "kind": "oauth_device_code",
6912                            "authority_url_template": "https://login.microsoftonline.com/{authority_tenant}",
6913                            "client_id_config_key": "graph_setup_client_id",
6914                            "oauth_kind": "graph",
6915                            "token_store_key": "graph_access_token",
6916                            "scopes": ["https://graph.microsoft.com/User.Read"]
6917                        },
6918                        "completion": {"state_path": "oauth.graph.ok", "exists": true}
6919                    }
6920                ]
6921            })
6922            .to_string()
6923            .as_bytes(),
6924        )?;
6925        writer.start_file("assets/setup/setup-machine.v1.json", options)?;
6926        writer.write_all(
6927            serde_json::json!({
6928                "version": 1,
6929                "id": "messaging-teams-broken",
6930                "entry_step": "missing",
6931                "steps": [
6932                    {
6933                        "id": "only",
6934                        "kind": "auto_complete",
6935                        "on_success": "complete"
6936                    }
6937                ]
6938            })
6939            .to_string()
6940            .as_bytes(),
6941        )?;
6942        writer.finish()?;
6943
6944        let report = run_provider_setup_machine_doctor(&pack);
6945
6946        assert_eq!(report.status, "error");
6947        assert!(
6948            report
6949                .diagnostics
6950                .iter()
6951                .any(|d| d.check_id == "setup.backend_contract.extension.present")
6952        );
6953        assert!(
6954            report
6955                .diagnostics
6956                .iter()
6957                .any(|d| d.check_id == "setup.machine.extension.present")
6958        );
6959        assert!(report.diagnostics.iter().any(|d| {
6960            d.severity == DiagnosticSeverity::Error && d.check_id.starts_with("setup.machine.")
6961        }));
6962        Ok(())
6963    }
6964
6965    #[test]
6966    fn route_pattern_matches_provider_http_templates() {
6967        assert!(route_pattern_covers_path(
6968            "/v1/setup/messaging-teams/{tenant}/{team}/register",
6969            "/v1/setup/messaging-teams/{tenant}/{team}/register",
6970        ));
6971        assert!(route_pattern_covers_path(
6972            "/v1/messaging/setup/messaging-teams/{tenant}/{action*}",
6973            "/v1/messaging/setup/messaging-teams/{tenant}/oauth/{kind}/start",
6974        ));
6975        assert!(!route_pattern_covers_path(
6976            "/v1/setup/messaging-teams/{tenant}/{team}/publish",
6977            "/v1/setup/messaging-teams/{tenant}/{team}/register",
6978        ));
6979    }
6980
6981    #[test]
6982    fn backend_contract_doctor_rejects_uncovered_provider_http_route() -> anyhow::Result<()> {
6983        let temp = tempfile::tempdir()?;
6984        let pack = temp.path().join("messaging-teams.gtpack");
6985        let file = std::fs::File::create(&pack)?;
6986        let mut writer = ZipWriter::new(file);
6987        let options: FileOptions<'_, ()> =
6988            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
6989        writer.start_file("pack.manifest.json", options)?;
6990        writer.write_all(
6991            serde_json::json!({
6992                "pack_id": "messaging-teams",
6993                "extensions": {
6994                    SETUP_WEB_COMPONENT_EXTENSION: {
6995                        "inline": {
6996                            "schema_id": SETUP_WEB_COMPONENT_EXTENSION,
6997                            "provider_id": "messaging-teams",
6998                            "tag_name": "greentic-teams-setup-v4",
6999                            "module_asset": "assets/setup/greentic-teams-setup.js"
7000                        }
7001                    },
7002                    SETUP_BACKEND_CONTRACT_EXTENSION: {
7003                        "inline": {
7004                            "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
7005                            "provider_id": "messaging-teams",
7006                            "asset": "assets/setup/backend-contract.json"
7007                        }
7008                    },
7009                    HTTP_ROUTES_EXTENSION: {
7010                        "inline": {
7011                            "routes": [
7012                                {
7013                                    "pattern": "/v1/setup/messaging-teams/{tenant}/{team}/other",
7014                                    "methods": ["POST"],
7015                                    "setup_component_ref": "messaging-teams",
7016                                    "setup_op": "other"
7017                                }
7018                            ]
7019                        }
7020                    }
7021                }
7022            })
7023            .to_string()
7024            .as_bytes(),
7025        )?;
7026        writer.start_file("assets/setup/greentic-teams-setup.js", options)?;
7027        writer.write_all(b"export default class GreenticTeamsSetup extends HTMLElement {}\n")?;
7028        writer.start_file("assets/setup/backend-contract.json", options)?;
7029        writer.write_all(
7030            serde_json::json!({
7031                "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
7032                "provider_id": "messaging-teams",
7033                "required_order": ["register_endpoint"],
7034                "actions": [
7035                    {
7036                        "id": "register_endpoint",
7037                        "executor": {
7038                            "kind": "provider_http",
7039                            "path_template": "/v1/setup/messaging-teams/{tenant}/{team}/register"
7040                        },
7041                        "completion": {"state_path": "last_reconcile.ok", "equals": true}
7042                    }
7043                ]
7044            })
7045            .to_string()
7046            .as_bytes(),
7047        )?;
7048        writer.finish()?;
7049
7050        let report = run_provider_setup_machine_doctor(&pack);
7051        assert_eq!(report.status, "error");
7052        assert!(
7053            report
7054                .diagnostics
7055                .iter()
7056                .any(|d| { d.check_id == "setup.backend_contract.provider_http.route_covered" })
7057        );
7058        Ok(())
7059    }
7060
7061    #[test]
7062    fn backend_contract_doctor_rejects_provider_http_method_mismatch() -> anyhow::Result<()> {
7063        let temp = tempfile::tempdir()?;
7064        let pack = temp.path().join("messaging-teams.gtpack");
7065        let file = std::fs::File::create(&pack)?;
7066        let mut writer = ZipWriter::new(file);
7067        let options: FileOptions<'_, ()> =
7068            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
7069        writer.start_file("pack.manifest.json", options)?;
7070        writer.write_all(
7071            serde_json::json!({
7072                "pack_id": "messaging-teams",
7073                "extensions": {
7074                    SETUP_WEB_COMPONENT_EXTENSION: {
7075                        "inline": {
7076                            "schema_id": SETUP_WEB_COMPONENT_EXTENSION,
7077                            "provider_id": "messaging-teams",
7078                            "tag_name": "greentic-teams-setup-v4",
7079                            "module_asset": "assets/setup/greentic-teams-setup.js"
7080                        }
7081                    },
7082                    SETUP_BACKEND_CONTRACT_EXTENSION: {
7083                        "inline": {
7084                            "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
7085                            "provider_id": "messaging-teams",
7086                            "asset": "assets/setup/backend-contract.json"
7087                        }
7088                    },
7089                    HTTP_ROUTES_EXTENSION: {
7090                        "inline": {
7091                            "routes": [
7092                                {
7093                                    "pattern": "/v1/setup/messaging-teams/{tenant}/{team}/register",
7094                                    "methods": ["GET"],
7095                                    "setup_component_ref": "messaging-teams",
7096                                    "setup_op": "register"
7097                                }
7098                            ]
7099                        }
7100                    }
7101                }
7102            })
7103            .to_string()
7104            .as_bytes(),
7105        )?;
7106        writer.start_file("assets/setup/greentic-teams-setup.js", options)?;
7107        writer.write_all(b"export default class GreenticTeamsSetup extends HTMLElement {}\n")?;
7108        writer.start_file("assets/setup/backend-contract.json", options)?;
7109        writer.write_all(
7110            serde_json::json!({
7111                "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
7112                "provider_id": "messaging-teams",
7113                "required_order": ["register_endpoint"],
7114                "actions": [
7115                    {
7116                        "id": "register_endpoint",
7117                        "executor": {
7118                            "kind": "provider_http",
7119                            "method": "POST",
7120                            "path_template": "/v1/setup/messaging-teams/{tenant}/{team}/register"
7121                        },
7122                        "completion": {"state_path": "last_reconcile.ok", "equals": true}
7123                    }
7124                ]
7125            })
7126            .to_string()
7127            .as_bytes(),
7128        )?;
7129        writer.finish()?;
7130
7131        let report = run_provider_setup_machine_doctor(&pack);
7132        assert_eq!(report.status, "error");
7133        assert!(
7134            report
7135                .diagnostics
7136                .iter()
7137                .any(|d| d.check_id == "setup.backend_contract.provider_http.route_method")
7138        );
7139        Ok(())
7140    }
7141
7142    #[test]
7143    fn backend_contract_doctor_rejects_oauth_token_not_server_owned() -> anyhow::Result<()> {
7144        let temp = tempfile::tempdir()?;
7145        let pack = temp.path().join("messaging-teams.gtpack");
7146        let file = std::fs::File::create(&pack)?;
7147        let mut writer = ZipWriter::new(file);
7148        let options: FileOptions<'_, ()> =
7149            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
7150        writer.start_file("pack.manifest.json", options)?;
7151        writer.write_all(
7152            serde_json::json!({
7153                "pack_id": "messaging-teams",
7154                "extensions": {
7155                    SETUP_WEB_COMPONENT_EXTENSION: {
7156                        "inline": {
7157                            "schema_id": SETUP_WEB_COMPONENT_EXTENSION,
7158                            "provider_id": "messaging-teams",
7159                            "tag_name": "greentic-teams-setup-v4",
7160                            "module_asset": "assets/setup/greentic-teams-setup.js"
7161                        }
7162                    },
7163                    SETUP_BACKEND_CONTRACT_EXTENSION: {
7164                        "inline": {
7165                            "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
7166                            "provider_id": "messaging-teams",
7167                            "asset": "assets/setup/backend-contract.json"
7168                        }
7169                    }
7170                }
7171            })
7172            .to_string()
7173            .as_bytes(),
7174        )?;
7175        writer.start_file("assets/setup/greentic-teams-setup.js", options)?;
7176        writer.write_all(b"export default class GreenticTeamsSetup extends HTMLElement {}\n")?;
7177        writer.start_file("assets/setup/backend-contract.json", options)?;
7178        writer.write_all(
7179            serde_json::json!({
7180                "schema_id": SETUP_BACKEND_CONTRACT_EXTENSION,
7181                "provider_id": "messaging-teams",
7182                "server_owned_config_keys": ["oauth_kind", "oauth_device_code", "oauth_user_code"],
7183                "required_order": ["graph_admin_consent"],
7184                "actions": [
7185                    {
7186                        "id": "graph_admin_consent",
7187                        "executor": {
7188                            "kind": "oauth_device_code",
7189                            "authority_url_template": "https://login.microsoftonline.com/{authority_tenant}",
7190                            "client_id_config_key": "graph_setup_client_id",
7191                            "oauth_kind": "graph",
7192                            "token_store_key": "graph_access_token",
7193                            "scopes": ["https://graph.microsoft.com/User.Read"]
7194                        },
7195                        "completion": {"state_path": "oauth.graph.ok", "exists": true}
7196                    }
7197                ]
7198            })
7199            .to_string()
7200            .as_bytes(),
7201        )?;
7202        writer.finish()?;
7203
7204        let report = run_provider_setup_machine_doctor(&pack);
7205        assert_eq!(report.status, "error");
7206        assert!(
7207            report
7208                .diagnostics
7209                .iter()
7210                .any(|d| { d.check_id == "setup.backend_contract.server_owned_config_keys.oauth" })
7211        );
7212        Ok(())
7213    }
7214
7215    #[test]
7216    fn persists_state_and_appends_events() -> anyhow::Result<()> {
7217        let temp = tempfile::tempdir()?;
7218        let state = SetupMachineState {
7219            schema_version: 1,
7220            provider_id: "messaging-teams".to_string(),
7221            tenant: "demo".to_string(),
7222            team: "default".to_string(),
7223            machine_id: "messaging-teams-default".to_string(),
7224            machine_version: 1,
7225            status: SetupMachineStatus::Running,
7226            current_step: Some("login".to_string()),
7227            completed_steps: vec!["collect".to_string()],
7228            failed_step: None,
7229            answers_hash: None,
7230            pack_fingerprint: None,
7231            outputs: serde_json::json!({}),
7232            created_resources: vec![],
7233            last_error: None,
7234            updated_at: None,
7235        };
7236        let path = write_setup_machine_state(temp.path(), &state)?;
7237        let loaded = load_setup_machine_state(&path)?;
7238        assert_eq!(loaded.current_step.as_deref(), Some("login"));
7239
7240        let event = SetupMachineEvent {
7241            ts: "2026-06-17T00:00:00Z".to_string(),
7242            level: "info".to_string(),
7243            event: "setup.step.started".to_string(),
7244            provider_id: "messaging-teams".to_string(),
7245            tenant: "demo".to_string(),
7246            team: "default".to_string(),
7247            machine_id: "messaging-teams-default".to_string(),
7248            step_id: Some("login".to_string()),
7249            correlation_id: Some("corr".to_string()),
7250            fields: serde_json::Map::new(),
7251        };
7252        let event_path = append_setup_machine_event(temp.path(), &event)?;
7253        let events = std::fs::read_to_string(event_path)?;
7254        assert!(events.contains("setup.step.started"));
7255        Ok(())
7256    }
7257
7258    #[test]
7259    fn advance_setup_machine_blocks_incompatible_persisted_machine_state() -> anyhow::Result<()> {
7260        let temp = tempfile::tempdir()?;
7261        let machine = valid_machine();
7262        let state = SetupMachineState {
7263            schema_version: 1,
7264            provider_id: "messaging-teams".to_string(),
7265            tenant: "demo".to_string(),
7266            team: "default".to_string(),
7267            machine_id: machine.id.clone(),
7268            machine_version: 0,
7269            status: SetupMachineStatus::Running,
7270            current_step: Some(machine.entry_step.clone()),
7271            completed_steps: Vec::new(),
7272            failed_step: None,
7273            answers_hash: None,
7274            pack_fingerprint: None,
7275            outputs: serde_json::json!({}),
7276            created_resources: Vec::new(),
7277            last_error: None,
7278            updated_at: None,
7279        };
7280        write_setup_machine_state(temp.path(), &state)?;
7281
7282        let output = advance_setup_machine(
7283            temp.path(),
7284            "messaging-teams",
7285            "demo",
7286            "default",
7287            &machine,
7288            false,
7289        )?;
7290
7291        assert_eq!(output["ok"], false);
7292        assert!(
7293            output["next"]
7294                .as_str()
7295                .unwrap()
7296                .contains("machine_identity_changed")
7297        );
7298        let loaded = load_setup_machine_state(&setup_machine_state_path(
7299            temp.path(),
7300            "demo",
7301            "default",
7302            "messaging-teams",
7303        ))?;
7304        assert_eq!(loaded.status, SetupMachineStatus::Failed);
7305        assert_eq!(
7306            loaded
7307                .last_error
7308                .as_ref()
7309                .and_then(|error| error.get("error"))
7310                .and_then(Value::as_str),
7311            Some("machine_identity_changed")
7312        );
7313        let events = std::fs::read_to_string(setup_machine_events_path(
7314            temp.path(),
7315            "demo",
7316            "default",
7317            "messaging-teams",
7318        ))?;
7319        assert!(events.contains("setup.machine.incompatible_state"));
7320        Ok(())
7321    }
7322
7323    #[test]
7324    fn setup_machine_state_records_pack_and_answers_hashes() -> anyhow::Result<()> {
7325        let temp = tempfile::tempdir()?;
7326        let machine = valid_machine();
7327        let pack_path = temp.path().join("provider.gtpack");
7328        std::fs::write(&pack_path, b"pack-v1")?;
7329        let answers_path = temp
7330            .path()
7331            .join("state/config/messaging-teams/setup-answers.json");
7332        std::fs::create_dir_all(answers_path.parent().unwrap())?;
7333        std::fs::write(&answers_path, br#"{"client_id":"demo"}"#)?;
7334
7335        let state = load_or_init_setup_machine_state_with_context(
7336            temp.path(),
7337            Some(&pack_path),
7338            "messaging-teams",
7339            "demo",
7340            "default",
7341            &machine,
7342        )?;
7343
7344        assert!(state.pack_fingerprint.as_deref().is_some_and(|hash| {
7345            hash.starts_with("sha256:") && hash == setup_machine_hash_file(&pack_path).unwrap()
7346        }));
7347        assert!(state.answers_hash.as_deref().is_some_and(|hash| {
7348            hash.starts_with("sha256:") && hash == setup_machine_hash_file(&answers_path).unwrap()
7349        }));
7350        Ok(())
7351    }
7352
7353    #[test]
7354    fn advance_setup_machine_blocks_changed_provider_pack_fingerprint() -> anyhow::Result<()> {
7355        let temp = tempfile::tempdir()?;
7356        let machine = valid_machine();
7357        let pack_path = temp.path().join("provider.gtpack");
7358        std::fs::write(&pack_path, b"pack-v1")?;
7359        let mut state = load_or_init_setup_machine_state_with_context(
7360            temp.path(),
7361            Some(&pack_path),
7362            "messaging-teams",
7363            "demo",
7364            "default",
7365            &machine,
7366        )?;
7367        state.status = SetupMachineStatus::Running;
7368        write_setup_machine_state(temp.path(), &state)?;
7369        std::fs::write(&pack_path, b"pack-v2")?;
7370
7371        let output = advance_setup_machine_with_pack(
7372            temp.path(),
7373            Some(&pack_path),
7374            "messaging-teams",
7375            "demo",
7376            "default",
7377            &machine,
7378            false,
7379        )?;
7380
7381        assert_eq!(output["ok"], false);
7382        assert!(
7383            output["next"]
7384                .as_str()
7385                .unwrap()
7386                .contains("pack_fingerprint_changed")
7387        );
7388        let loaded = load_setup_machine_state(&setup_machine_state_path(
7389            temp.path(),
7390            "demo",
7391            "default",
7392            "messaging-teams",
7393        ))?;
7394        assert_eq!(
7395            loaded
7396                .last_error
7397                .as_ref()
7398                .and_then(|error| error.get("error"))
7399                .and_then(Value::as_str),
7400            Some("pack_fingerprint_changed")
7401        );
7402        Ok(())
7403    }
7404
7405    #[test]
7406    fn advance_setup_machine_blocks_changed_setup_answers_hash() -> anyhow::Result<()> {
7407        let temp = tempfile::tempdir()?;
7408        let machine = valid_machine();
7409        let pack_path = temp.path().join("provider.gtpack");
7410        std::fs::write(&pack_path, b"pack-v1")?;
7411        let answers_path = temp
7412            .path()
7413            .join("state/config/messaging-teams/setup-answers.json");
7414        std::fs::create_dir_all(answers_path.parent().unwrap())?;
7415        std::fs::write(&answers_path, br#"{"client_id":"demo"}"#)?;
7416        let mut state = load_or_init_setup_machine_state_with_context(
7417            temp.path(),
7418            Some(&pack_path),
7419            "messaging-teams",
7420            "demo",
7421            "default",
7422            &machine,
7423        )?;
7424        state.status = SetupMachineStatus::Running;
7425        write_setup_machine_state(temp.path(), &state)?;
7426        std::fs::write(&answers_path, br#"{"client_id":"changed"}"#)?;
7427
7428        let output = advance_setup_machine_with_pack(
7429            temp.path(),
7430            Some(&pack_path),
7431            "messaging-teams",
7432            "demo",
7433            "default",
7434            &machine,
7435            false,
7436        )?;
7437
7438        assert_eq!(output["ok"], false);
7439        assert!(
7440            output["next"]
7441                .as_str()
7442                .unwrap()
7443                .contains("setup_answers_changed")
7444        );
7445        let loaded = load_setup_machine_state(&setup_machine_state_path(
7446            temp.path(),
7447            "demo",
7448            "default",
7449            "messaging-teams",
7450        ))?;
7451        assert_eq!(
7452            loaded
7453                .last_error
7454                .as_ref()
7455                .and_then(|error| error.get("error"))
7456                .and_then(Value::as_str),
7457            Some("setup_answers_changed")
7458        );
7459        Ok(())
7460    }
7461
7462    #[test]
7463    fn advance_setup_machine_completes_auto_step_and_persists_state() -> anyhow::Result<()> {
7464        let temp = tempfile::tempdir()?;
7465        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
7466            "version": 1,
7467            "id": "example-machine",
7468            "entry_step": "collect",
7469            "steps": [
7470                {
7471                    "id": "collect",
7472                    "kind": "qa_form",
7473                    "auto_complete": true,
7474                    "on_success": "manual"
7475                },
7476                {
7477                    "id": "manual",
7478                    "kind": "manual_action",
7479                    "title": "Approve"
7480                }
7481            ]
7482        }))?;
7483
7484        let output = advance_setup_machine(
7485            temp.path(),
7486            "messaging-example",
7487            "demo",
7488            "default",
7489            &machine,
7490            false,
7491        )?;
7492
7493        assert_eq!(output["ok"], true);
7494        assert_eq!(output["step"], "collect");
7495        assert_eq!(output["status"]["current_step"], "manual");
7496        let state = load_setup_machine_state(&setup_machine_state_path(
7497            temp.path(),
7498            "demo",
7499            "default",
7500            "messaging-example",
7501        ))?;
7502        assert_eq!(state.status, SetupMachineStatus::Running);
7503        assert_eq!(state.completed_steps, vec!["collect"]);
7504        assert_eq!(state.current_step.as_deref(), Some("manual"));
7505        let events = std::fs::read_to_string(setup_machine_events_path(
7506            temp.path(),
7507            "demo",
7508            "default",
7509            "messaging-example",
7510        ))?;
7511        assert!(events.contains("setup.step.completed"));
7512        Ok(())
7513    }
7514
7515    #[test]
7516    fn advance_setup_machine_resumes_current_step_and_pauses_for_manual_action()
7517    -> anyhow::Result<()> {
7518        let temp = tempfile::tempdir()?;
7519        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
7520            "version": 1,
7521            "id": "example-machine",
7522            "entry_step": "manual",
7523            "steps": [
7524                {
7525                    "id": "manual",
7526                    "kind": "manual_action",
7527                    "title": "Approve"
7528                }
7529            ]
7530        }))?;
7531
7532        let output = advance_setup_machine(
7533            temp.path(),
7534            "messaging-example",
7535            "demo",
7536            "default",
7537            &machine,
7538            false,
7539        )?;
7540
7541        assert_eq!(output["ok"], false);
7542        assert_eq!(output["step"], "manual");
7543        assert_eq!(output["result"]["status"], "paused");
7544        assert_eq!(
7545            output["result"]["detail"]["error"],
7546            "manual_action_required"
7547        );
7548        let state = load_setup_machine_state(&setup_machine_state_path(
7549            temp.path(),
7550            "demo",
7551            "default",
7552            "messaging-example",
7553        ))?;
7554        assert_eq!(state.status, SetupMachineStatus::Paused);
7555        assert_eq!(state.current_step.as_deref(), Some("manual"));
7556
7557        let repeated = advance_setup_machine(
7558            temp.path(),
7559            "messaging-example",
7560            "demo",
7561            "default",
7562            &machine,
7563            true,
7564        )?;
7565        assert_eq!(repeated["step"], "manual");
7566        assert_eq!(repeated["dry_run"], true);
7567        Ok(())
7568    }
7569
7570    #[test]
7571    fn advance_setup_machine_follows_declared_failure_recovery_step() -> anyhow::Result<()> {
7572        let temp = tempfile::tempdir()?;
7573        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
7574            "version": 1,
7575            "id": "example-machine",
7576            "entry_step": "public_url",
7577            "steps": [
7578                {
7579                    "id": "public_url",
7580                    "kind": "platform_public_url",
7581                    "on_failure": [
7582                        {
7583                            "when": "missing_prerequisite",
7584                            "target": "manual_public_url"
7585                        }
7586                    ],
7587                    "on_success": "complete"
7588                },
7589                {
7590                    "id": "manual_public_url",
7591                    "kind": "manual_action",
7592                    "on_success": "public_url"
7593                }
7594            ]
7595        }))?;
7596
7597        let output = advance_setup_machine(
7598            temp.path(),
7599            "messaging-example",
7600            "demo",
7601            "default",
7602            &machine,
7603            false,
7604        )?;
7605
7606        assert_eq!(output["ok"], false);
7607        assert_eq!(output["next"], "recovery scheduled: manual_public_url");
7608        assert_eq!(
7609            output["result"]["detail"]["recovery_step"],
7610            "manual_public_url"
7611        );
7612        let state = load_setup_machine_state(&setup_machine_state_path(
7613            temp.path(),
7614            "demo",
7615            "default",
7616            "messaging-example",
7617        ))?;
7618        assert_eq!(state.status, SetupMachineStatus::Running);
7619        assert_eq!(state.current_step.as_deref(), Some("manual_public_url"));
7620        assert_eq!(
7621            state
7622                .last_error
7623                .as_ref()
7624                .and_then(|error| error.get("recovery_step"))
7625                .and_then(Value::as_str),
7626            Some("manual_public_url")
7627        );
7628        let events = std::fs::read_to_string(setup_machine_events_path(
7629            temp.path(),
7630            "demo",
7631            "default",
7632            "messaging-example",
7633        ))?;
7634        assert!(events.contains("setup.step.recovery_scheduled"));
7635        Ok(())
7636    }
7637
7638    #[test]
7639    fn advance_setup_machine_resolves_platform_public_url_from_static_routes() -> anyhow::Result<()>
7640    {
7641        let temp = tempfile::tempdir()?;
7642        crate::platform_setup::persist_static_routes_artifact(
7643            temp.path(),
7644            &crate::platform_setup::StaticRoutesPolicy {
7645                public_web_enabled: true,
7646                public_base_url: Some("https://runtime.example.com/base/".to_string()),
7647                public_surface_policy: "enabled".to_string(),
7648                ..Default::default()
7649            },
7650        )?;
7651        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
7652            "version": 1,
7653            "id": "example-machine",
7654            "entry_step": "public_url",
7655            "steps": [
7656                {
7657                    "id": "public_url",
7658                    "kind": "platform_public_url",
7659                    "on_success": "complete"
7660                }
7661            ]
7662        }))?;
7663
7664        let output = advance_setup_machine(
7665            temp.path(),
7666            "messaging-example",
7667            "demo",
7668            "default",
7669            &machine,
7670            false,
7671        )?;
7672
7673        assert_eq!(output["ok"], true);
7674        let state = load_setup_machine_state(&setup_machine_state_path(
7675            temp.path(),
7676            "demo",
7677            "default",
7678            "messaging-example",
7679        ))?;
7680        assert_eq!(state.status, SetupMachineStatus::Complete);
7681        assert_eq!(
7682            state.outputs["public_base_url"],
7683            "https://runtime.example.com/base"
7684        );
7685        Ok(())
7686    }
7687
7688    #[test]
7689    fn advance_setup_machine_blocks_platform_public_url_when_missing() -> anyhow::Result<()> {
7690        let temp = tempfile::tempdir()?;
7691        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
7692            "version": 1,
7693            "id": "example-machine",
7694            "entry_step": "public_url",
7695            "steps": [
7696                {
7697                    "id": "public_url",
7698                    "kind": "platform_public_url"
7699                }
7700            ]
7701        }))?;
7702
7703        let output = advance_setup_machine(
7704            temp.path(),
7705            "messaging-example",
7706            "demo",
7707            "default",
7708            &machine,
7709            false,
7710        )?;
7711
7712        assert_eq!(output["ok"], false);
7713        assert_eq!(output["result"]["detail"]["error"], "missing_prerequisite");
7714        let state = load_setup_machine_state(&setup_machine_state_path(
7715            temp.path(),
7716            "demo",
7717            "default",
7718            "messaging-example",
7719        ))?;
7720        assert_eq!(state.status, SetupMachineStatus::Paused);
7721        assert_eq!(state.current_step.as_deref(), Some("public_url"));
7722        Ok(())
7723    }
7724
7725    #[test]
7726    fn advance_setup_machine_cloudflare_tunnel_resolves_existing_https_url() -> anyhow::Result<()> {
7727        let temp = tempfile::tempdir()?;
7728        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
7729            "version": 1,
7730            "id": "example-machine",
7731            "entry_step": "tunnel",
7732            "steps": [
7733                {
7734                    "id": "tunnel",
7735                    "kind": "cloudflare_tunnel",
7736                    "public_base_url": "https://setup.trycloudflare.com",
7737                    "on_success": "complete"
7738                }
7739            ]
7740        }))?;
7741
7742        let output = advance_setup_machine(
7743            temp.path(),
7744            "messaging-example",
7745            "demo",
7746            "default",
7747            &machine,
7748            false,
7749        )?;
7750
7751        assert_eq!(output["ok"], true);
7752        assert_eq!(output["result"]["detail"]["ephemeral"], true);
7753        let state = load_setup_machine_state(&setup_machine_state_path(
7754            temp.path(),
7755            "demo",
7756            "default",
7757            "messaging-example",
7758        ))?;
7759        assert_eq!(state.status, SetupMachineStatus::Complete);
7760        assert_eq!(
7761            state.outputs["public_base_url"],
7762            "https://setup.trycloudflare.com"
7763        );
7764        Ok(())
7765    }
7766
7767    #[test]
7768    fn advance_setup_machine_cloudflare_tunnel_blocks_when_missing() -> anyhow::Result<()> {
7769        let temp = tempfile::tempdir()?;
7770        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
7771            "version": 1,
7772            "id": "example-machine",
7773            "entry_step": "tunnel",
7774            "steps": [
7775                {
7776                    "id": "tunnel",
7777                    "kind": "cloudflare_tunnel"
7778                }
7779            ]
7780        }))?;
7781
7782        let output = advance_setup_machine(
7783            temp.path(),
7784            "messaging-example",
7785            "demo",
7786            "default",
7787            &machine,
7788            false,
7789        )?;
7790
7791        assert_eq!(output["ok"], false);
7792        assert_eq!(output["result"]["detail"]["error"], "tunnel_unreachable");
7793        let state = load_setup_machine_state(&setup_machine_state_path(
7794            temp.path(),
7795            "demo",
7796            "default",
7797            "messaging-example",
7798        ))?;
7799        assert_eq!(state.status, SetupMachineStatus::Paused);
7800        assert_eq!(state.current_step.as_deref(), Some("tunnel"));
7801        Ok(())
7802    }
7803
7804    #[test]
7805    fn advance_setup_machine_oauth_authorization_code_renders_signed_url() -> anyhow::Result<()> {
7806        let temp = tempfile::tempdir()?;
7807        let state = SetupMachineState {
7808            schema_version: 1,
7809            provider_id: "messaging-example".to_string(),
7810            tenant: "demo".to_string(),
7811            team: "default".to_string(),
7812            machine_id: "example-machine".to_string(),
7813            machine_version: 1,
7814            status: SetupMachineStatus::Running,
7815            current_step: Some("oauth".to_string()),
7816            completed_steps: Vec::new(),
7817            failed_step: None,
7818            answers_hash: None,
7819            pack_fingerprint: None,
7820            outputs: serde_json::json!({
7821                "public_base_url": "https://runtime.example.com",
7822                "client_id": "client-123"
7823            }),
7824            created_resources: Vec::new(),
7825            last_error: None,
7826            updated_at: None,
7827        };
7828        write_setup_machine_state(temp.path(), &state)?;
7829        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
7830            "version": 1,
7831            "id": "example-machine",
7832            "entry_step": "oauth",
7833            "steps": [
7834                {
7835                    "id": "oauth",
7836                    "kind": "oauth_authorization_code",
7837                    "authorize_url": "https://login.example.com/oauth2/v2.0/authorize",
7838                    "token_url": "https://login.example.com/oauth2/v2.0/token",
7839                    "callback_path": "/oauth/callback",
7840                    "scopes": ["User.Read", "offline_access"],
7841                    "params": {"prompt": "consent"}
7842                }
7843            ]
7844        }))?;
7845
7846        let output = advance_setup_machine(
7847            temp.path(),
7848            "messaging-example",
7849            "demo",
7850            "default",
7851            &machine,
7852            false,
7853        )?;
7854
7855        assert_eq!(output["ok"], false);
7856        assert_eq!(
7857            output["result"]["detail"]["reason"],
7858            "oauth_authorization_required"
7859        );
7860        let state = load_setup_machine_state(&setup_machine_state_path(
7861            temp.path(),
7862            "demo",
7863            "default",
7864            "messaging-example",
7865        ))?;
7866        assert_eq!(state.status, SetupMachineStatus::Paused);
7867        let authorization_url = state.outputs["authorization_url"]
7868            .as_str()
7869            .expect("authorization_url");
7870        let parsed = url::Url::parse(authorization_url)?;
7871        let params: std::collections::BTreeMap<_, _> = parsed.query_pairs().collect();
7872        assert_eq!(
7873            params.get("client_id").map(|v| v.as_ref()),
7874            Some("client-123")
7875        );
7876        assert_eq!(
7877            params.get("redirect_uri").map(|v| v.as_ref()),
7878            Some("https://runtime.example.com/oauth/callback")
7879        );
7880        assert_eq!(
7881            params.get("scope").map(|v| v.as_ref()),
7882            Some("User.Read offline_access")
7883        );
7884        assert_eq!(params.get("prompt").map(|v| v.as_ref()), Some("consent"));
7885        let signed_state = params.get("state").expect("state");
7886        let key = crate::setup_actions::load_or_create_signing_key(temp.path())?;
7887        let payload = crate::setup_actions::validate_oauth_state(
7888            signed_state,
7889            &key,
7890            Some("messaging-example"),
7891            Some("demo"),
7892            Some("default"),
7893            crate::setup_actions::current_epoch_secs(),
7894        )?;
7895        assert_eq!(payload.action_id, "oauth");
7896        Ok(())
7897    }
7898
7899    #[test]
7900    fn advance_setup_machine_oauth_authorization_code_blocks_without_public_url()
7901    -> anyhow::Result<()> {
7902        let temp = tempfile::tempdir()?;
7903        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
7904            "version": 1,
7905            "id": "example-machine",
7906            "entry_step": "oauth",
7907            "steps": [
7908                {
7909                    "id": "oauth",
7910                    "kind": "oauth_authorization_code",
7911                    "authorize_url": "https://login.example.com/oauth2/v2.0/authorize",
7912                    "token_url": "https://login.example.com/oauth2/v2.0/token",
7913                    "callback_path": "/oauth/callback"
7914                }
7915            ]
7916        }))?;
7917
7918        let output = advance_setup_machine(
7919            temp.path(),
7920            "messaging-example",
7921            "demo",
7922            "default",
7923            &machine,
7924            false,
7925        )?;
7926
7927        assert_eq!(output["ok"], false);
7928        assert_eq!(output["result"]["detail"]["error"], "missing_prerequisite");
7929        assert_eq!(
7930            output["result"]["detail"]["missing_config_key"],
7931            "public_base_url"
7932        );
7933        let state = load_setup_machine_state(&setup_machine_state_path(
7934            temp.path(),
7935            "demo",
7936            "default",
7937            "messaging-example",
7938        ))?;
7939        assert_eq!(state.status, SetupMachineStatus::Paused);
7940        assert_eq!(state.current_step.as_deref(), Some("oauth"));
7941        Ok(())
7942    }
7943
7944    #[tokio::test]
7945    async fn complete_setup_machine_oauth_authorization_code_persists_tokens_as_secrets()
7946    -> anyhow::Result<()> {
7947        let _store_iso_dir = tempfile::tempdir().expect("store isolation dir");
7948        let _store_iso = crate::secrets::test_support::StoreOverride::in_dir(_store_iso_dir.path());
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_for_env(temp.path(), "dev")?;
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 _store = crate::secrets::test_support::isolated_store();
8055        let temp = tempfile::tempdir()?;
8056        let base = serve_json_sequence(vec![
8057            (
8058                200,
8059                r#"{"device_code":"raw-device-code","user_code":"ABCD-EFGH","verification_uri":"https://login.example.com/device","expires_in":900,"interval":1}"#,
8060            ),
8061            (
8062                200,
8063                r#"{"access_token":"access-token-secret","refresh_token":"refresh-token-secret"}"#,
8064            ),
8065        ]);
8066        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8067            "version": 1,
8068            "id": "example-machine",
8069            "entry_step": "device",
8070            "steps": [
8071                {
8072                    "id": "device",
8073                    "kind": "oauth_device_code",
8074                    "device_code_url": format!("{base}/device"),
8075                    "token_url": format!("{base}/token"),
8076                    "client_id": "client-123",
8077                    "scopes": ["User.Read"],
8078                    "token_store_key": "graph_access_token",
8079                    "refresh_token_store_key": "graph_refresh_token",
8080                    "on_success": "complete"
8081                }
8082            ]
8083        }))?;
8084
8085        let started = advance_setup_machine(
8086            temp.path(),
8087            "messaging-example",
8088            "demo",
8089            "default",
8090            &machine,
8091            false,
8092        )?;
8093
8094        assert_eq!(started["ok"], false);
8095        assert_eq!(
8096            started["result"]["detail"]["reason"],
8097            "oauth_device_code_pending"
8098        );
8099        let state_path =
8100            setup_machine_state_path(temp.path(), "demo", "default", "messaging-example");
8101        let raw_state = std::fs::read_to_string(&state_path)?;
8102        assert!(!raw_state.contains("raw-device-code"));
8103        assert!(!raw_state.contains("access-token-secret"));
8104        let state = load_setup_machine_state(&state_path)?;
8105        assert_eq!(state.status, SetupMachineStatus::Paused);
8106        assert_eq!(
8107            state.outputs["oauth_device_login"]["user_code"],
8108            "ABCD-EFGH"
8109        );
8110        assert!(setup_machine_oauth_device_session_path(temp.path(), &state, "device").is_file());
8111
8112        let completed = advance_setup_machine(
8113            temp.path(),
8114            "messaging-example",
8115            "demo",
8116            "default",
8117            &machine,
8118            false,
8119        )?;
8120
8121        assert_eq!(completed["ok"], true);
8122        let state = load_setup_machine_state(&state_path)?;
8123        assert_eq!(state.status, SetupMachineStatus::Complete);
8124        assert_eq!(
8125            state.outputs["oauth_device_login"]["token_store_key"],
8126            "graph_access_token"
8127        );
8128        assert!(!setup_machine_oauth_device_session_path(temp.path(), &state, "device").exists());
8129        let raw_state = std::fs::read_to_string(&state_path)?;
8130        assert!(!raw_state.contains("access-token-secret"));
8131        assert!(!raw_state.contains("refresh-token-secret"));
8132        Ok(())
8133    }
8134
8135    #[test]
8136    fn advance_setup_machine_oauth_device_code_keeps_pending_resumable() -> anyhow::Result<()> {
8137        let temp = tempfile::tempdir()?;
8138        let base = serve_json_sequence(vec![
8139            (
8140                200,
8141                r#"{"device_code":"raw-device-code","user_code":"ABCD-EFGH","verification_uri":"https://login.example.com/device","expires_in":900,"interval":1}"#,
8142            ),
8143            (200, r#"{"error":"authorization_pending"}"#),
8144        ]);
8145        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8146            "version": 1,
8147            "id": "example-machine",
8148            "entry_step": "device",
8149            "steps": [
8150                {
8151                    "id": "device",
8152                    "kind": "oauth_device_code",
8153                    "device_code_url": format!("{base}/device"),
8154                    "token_url": format!("{base}/token"),
8155                    "client_id": "client-123",
8156                    "scope": "User.Read",
8157                    "token_store_key": "graph_access_token",
8158                    "on_success": "complete"
8159                }
8160            ]
8161        }))?;
8162
8163        let _ = advance_setup_machine(
8164            temp.path(),
8165            "messaging-example",
8166            "demo",
8167            "default",
8168            &machine,
8169            false,
8170        )?;
8171        let pending = advance_setup_machine(
8172            temp.path(),
8173            "messaging-example",
8174            "demo",
8175            "default",
8176            &machine,
8177            false,
8178        )?;
8179
8180        assert_eq!(pending["ok"], false);
8181        assert_eq!(pending["result"]["detail"]["error"], "oauth_pending");
8182        let state = load_setup_machine_state(&setup_machine_state_path(
8183            temp.path(),
8184            "demo",
8185            "default",
8186            "messaging-example",
8187        ))?;
8188        assert_eq!(state.status, SetupMachineStatus::Paused);
8189        assert_eq!(state.current_step.as_deref(), Some("device"));
8190        assert!(setup_machine_oauth_device_session_path(temp.path(), &state, "device").is_file());
8191        Ok(())
8192    }
8193
8194    #[test]
8195    fn advance_setup_machine_http_json_stores_response_and_advances() -> anyhow::Result<()> {
8196        let temp = tempfile::tempdir()?;
8197        let url = serve_one_json(200, r#"{"registered":true,"tenant":"demo"}"#);
8198        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8199            "version": 1,
8200            "id": "example-machine",
8201            "entry_step": "register",
8202            "steps": [
8203                {
8204                    "id": "register",
8205                    "kind": "http_json",
8206                    "url_template": url,
8207                    "output_key": "registration",
8208                    "on_success": "complete"
8209                }
8210            ]
8211        }))?;
8212
8213        let output = advance_setup_machine(
8214            temp.path(),
8215            "messaging-example",
8216            "demo",
8217            "default",
8218            &machine,
8219            false,
8220        )?;
8221
8222        assert_eq!(output["ok"], true);
8223        let state = load_setup_machine_state(&setup_machine_state_path(
8224            temp.path(),
8225            "demo",
8226            "default",
8227            "messaging-example",
8228        ))?;
8229        assert_eq!(state.status, SetupMachineStatus::Complete);
8230        assert_eq!(state.outputs["registration"]["registered"], true);
8231        assert_eq!(state.outputs["registration"]["tenant"], "demo");
8232        Ok(())
8233    }
8234
8235    #[test]
8236    fn advance_setup_machine_http_probe_blocks_on_unexpected_status() -> anyhow::Result<()> {
8237        let temp = tempfile::tempdir()?;
8238        let url = serve_one_json(503, r#"{"ok":false}"#);
8239        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8240            "version": 1,
8241            "id": "example-machine",
8242            "entry_step": "probe",
8243            "steps": [
8244                {
8245                    "id": "probe",
8246                    "kind": "http_probe",
8247                    "url_template": url,
8248                    "expected_status": 200
8249                }
8250            ]
8251        }))?;
8252
8253        let output = advance_setup_machine(
8254            temp.path(),
8255            "messaging-example",
8256            "demo",
8257            "default",
8258            &machine,
8259            false,
8260        )?;
8261
8262        assert_eq!(output["ok"], false);
8263        assert_eq!(
8264            output["result"]["detail"]["error"],
8265            "provider_contract_error"
8266        );
8267        assert_eq!(output["result"]["detail"]["status"], 503);
8268        let state = load_setup_machine_state(&setup_machine_state_path(
8269            temp.path(),
8270            "demo",
8271            "default",
8272            "messaging-example",
8273        ))?;
8274        assert_eq!(state.status, SetupMachineStatus::Paused);
8275        assert_eq!(state.current_step.as_deref(), Some("probe"));
8276        Ok(())
8277    }
8278
8279    #[test]
8280    fn advance_setup_machine_select_from_json_auto_selects_single_option() -> anyhow::Result<()> {
8281        let temp = tempfile::tempdir()?;
8282        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8283            "version": 1,
8284            "id": "example-machine",
8285            "entry_step": "select_team",
8286            "steps": [
8287                {
8288                    "id": "select_team",
8289                    "kind": "select_from_json",
8290                    "source_output": "teams_response",
8291                    "json_pointer": "/value",
8292                    "value_key": "id",
8293                    "output_key": "team_id",
8294                    "on_success": "complete"
8295                }
8296            ]
8297        }))?;
8298        let state = SetupMachineState {
8299            schema_version: 1,
8300            provider_id: "messaging-example".to_string(),
8301            tenant: "demo".to_string(),
8302            team: "default".to_string(),
8303            machine_id: "example-machine".to_string(),
8304            machine_version: 1,
8305            status: SetupMachineStatus::Running,
8306            current_step: Some("select_team".to_string()),
8307            completed_steps: Vec::new(),
8308            failed_step: None,
8309            answers_hash: None,
8310            pack_fingerprint: None,
8311            outputs: serde_json::json!({
8312                "teams_response": {
8313                    "value": [
8314                        {"id": "team-123", "displayName": "Engineering"}
8315                    ]
8316                }
8317            }),
8318            created_resources: Vec::new(),
8319            last_error: None,
8320            updated_at: None,
8321        };
8322        write_setup_machine_state(temp.path(), &state)?;
8323
8324        let output = advance_setup_machine(
8325            temp.path(),
8326            "messaging-example",
8327            "demo",
8328            "default",
8329            &machine,
8330            false,
8331        )?;
8332
8333        assert_eq!(output["ok"], true);
8334        let state = load_setup_machine_state(&setup_machine_state_path(
8335            temp.path(),
8336            "demo",
8337            "default",
8338            "messaging-example",
8339        ))?;
8340        assert_eq!(state.status, SetupMachineStatus::Complete);
8341        assert_eq!(state.outputs["team_id"], "team-123");
8342        assert_eq!(state.outputs["team_id_item"]["displayName"], "Engineering");
8343        Ok(())
8344    }
8345
8346    #[test]
8347    fn advance_setup_machine_select_from_json_pauses_for_multiple_options() -> anyhow::Result<()> {
8348        let temp = tempfile::tempdir()?;
8349        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8350            "version": 1,
8351            "id": "example-machine",
8352            "entry_step": "select_team",
8353            "steps": [
8354                {
8355                    "id": "select_team",
8356                    "kind": "select_from_json",
8357                    "source_output": "teams_response",
8358                    "json_pointer": "/value",
8359                    "value_key": "id",
8360                    "output_key": "team_id"
8361                }
8362            ]
8363        }))?;
8364        let state = SetupMachineState {
8365            schema_version: 1,
8366            provider_id: "messaging-example".to_string(),
8367            tenant: "demo".to_string(),
8368            team: "default".to_string(),
8369            machine_id: "example-machine".to_string(),
8370            machine_version: 1,
8371            status: SetupMachineStatus::Running,
8372            current_step: Some("select_team".to_string()),
8373            completed_steps: Vec::new(),
8374            failed_step: None,
8375            answers_hash: None,
8376            pack_fingerprint: None,
8377            outputs: serde_json::json!({
8378                "teams_response": {
8379                    "value": [
8380                        {"id": "team-123", "displayName": "Engineering"},
8381                        {"id": "team-456", "displayName": "Support"}
8382                    ]
8383                }
8384            }),
8385            created_resources: Vec::new(),
8386            last_error: None,
8387            updated_at: None,
8388        };
8389        write_setup_machine_state(temp.path(), &state)?;
8390
8391        let output = advance_setup_machine(
8392            temp.path(),
8393            "messaging-example",
8394            "demo",
8395            "default",
8396            &machine,
8397            false,
8398        )?;
8399
8400        assert_eq!(output["ok"], false);
8401        assert_eq!(output["result"]["detail"]["reason"], "selection_required");
8402        assert_eq!(output["result"]["detail"]["options"], 2);
8403        let state = load_setup_machine_state(&setup_machine_state_path(
8404            temp.path(),
8405            "demo",
8406            "default",
8407            "messaging-example",
8408        ))?;
8409        assert_eq!(state.status, SetupMachineStatus::Paused);
8410        assert_eq!(state.current_step.as_deref(), Some("select_team"));
8411        Ok(())
8412    }
8413
8414    #[test]
8415    fn advance_setup_machine_generate_file_writes_artifact_and_records_resource()
8416    -> anyhow::Result<()> {
8417        let temp = tempfile::tempdir()?;
8418        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8419            "version": 1,
8420            "id": "example-machine",
8421            "entry_step": "generate",
8422            "steps": [
8423                {
8424                    "id": "generate",
8425                    "kind": "generate_file",
8426                    "artifact_path": "artifacts/app/config.json",
8427                    "output_key": "config_artifact",
8428                    "content": {
8429                        "tenant": "{tenant}",
8430                        "team": "{team}",
8431                        "provider": "{provider_id}",
8432                        "public_base_url": "{public_base_url}"
8433                    },
8434                    "on_success": "complete"
8435                }
8436            ]
8437        }))?;
8438        let state = SetupMachineState {
8439            schema_version: 1,
8440            provider_id: "messaging-example".to_string(),
8441            tenant: "demo".to_string(),
8442            team: "default".to_string(),
8443            machine_id: "example-machine".to_string(),
8444            machine_version: 1,
8445            status: SetupMachineStatus::Running,
8446            current_step: Some("generate".to_string()),
8447            completed_steps: Vec::new(),
8448            failed_step: None,
8449            answers_hash: None,
8450            pack_fingerprint: None,
8451            outputs: serde_json::json!({
8452                "public_base_url": "https://runtime.example.com"
8453            }),
8454            created_resources: Vec::new(),
8455            last_error: None,
8456            updated_at: None,
8457        };
8458        write_setup_machine_state(temp.path(), &state)?;
8459
8460        let output = advance_setup_machine(
8461            temp.path(),
8462            "messaging-example",
8463            "demo",
8464            "default",
8465            &machine,
8466            false,
8467        )?;
8468
8469        assert_eq!(output["ok"], true);
8470        let artifact_path =
8471            setup_machine_state_dir(temp.path(), "demo", "default", "messaging-example")
8472                .join("artifacts/app/config.json");
8473        let artifact = std::fs::read_to_string(artifact_path)?;
8474        let artifact: Value = serde_json::from_str(&artifact)?;
8475        assert_eq!(artifact["tenant"], "demo");
8476        assert_eq!(artifact["team"], "default");
8477        assert_eq!(artifact["provider"], "messaging-example");
8478        assert_eq!(artifact["public_base_url"], "https://runtime.example.com");
8479
8480        let state = load_setup_machine_state(&setup_machine_state_path(
8481            temp.path(),
8482            "demo",
8483            "default",
8484            "messaging-example",
8485        ))?;
8486        assert_eq!(state.status, SetupMachineStatus::Complete);
8487        assert_eq!(
8488            state.outputs["config_artifact"],
8489            "artifacts/app/config.json"
8490        );
8491        assert_eq!(
8492            state
8493                .created_resources
8494                .first()
8495                .and_then(|value| value.get("path")),
8496            Some(&Value::String("artifacts/app/config.json".to_string()))
8497        );
8498        Ok(())
8499    }
8500
8501    #[test]
8502    fn advance_setup_machine_generate_file_blocks_without_content() -> anyhow::Result<()> {
8503        let temp = tempfile::tempdir()?;
8504        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8505            "version": 1,
8506            "id": "example-machine",
8507            "entry_step": "generate",
8508            "steps": [
8509                {
8510                    "id": "generate",
8511                    "kind": "generate_file",
8512                    "artifact_path": "artifacts/app/config.json"
8513                }
8514            ]
8515        }))?;
8516
8517        let output = advance_setup_machine(
8518            temp.path(),
8519            "messaging-example",
8520            "demo",
8521            "default",
8522            &machine,
8523            false,
8524        )?;
8525
8526        assert_eq!(output["ok"], false);
8527        assert_eq!(output["result"]["detail"]["error"], "missing_prerequisite");
8528        let state = load_setup_machine_state(&setup_machine_state_path(
8529            temp.path(),
8530            "demo",
8531            "default",
8532            "messaging-example",
8533        ))?;
8534        assert_eq!(state.status, SetupMachineStatus::Paused);
8535        assert_eq!(state.current_step.as_deref(), Some("generate"));
8536        assert_eq!(
8537            state
8538                .last_error
8539                .as_ref()
8540                .and_then(|value| value.get("error")),
8541            Some(&Value::String("missing_prerequisite".to_string()))
8542        );
8543        Ok(())
8544    }
8545
8546    #[test]
8547    fn advance_setup_machine_persist_runtime_config_writes_setup_answers_and_strips_server_owned()
8548    -> anyhow::Result<()> {
8549        let temp = tempfile::tempdir()?;
8550        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8551            "version": 1,
8552            "id": "example-machine",
8553            "entry_step": "persist",
8554            "server_owned_config_keys": ["graph_access_token"],
8555            "steps": [
8556                {
8557                    "id": "persist",
8558                    "kind": "persist_runtime_config",
8559                    "config": {
8560                        "bot_app_id": "{bot_app_id}",
8561                        "team_id": "{team_id}",
8562                        "graph_access_token": "{graph_access_token}"
8563                    },
8564                    "on_success": "complete"
8565                }
8566            ]
8567        }))?;
8568        let state = SetupMachineState {
8569            schema_version: 1,
8570            provider_id: "messaging-example".to_string(),
8571            tenant: "demo".to_string(),
8572            team: "default".to_string(),
8573            machine_id: "example-machine".to_string(),
8574            machine_version: 1,
8575            status: SetupMachineStatus::Running,
8576            current_step: Some("persist".to_string()),
8577            completed_steps: Vec::new(),
8578            failed_step: None,
8579            answers_hash: None,
8580            pack_fingerprint: None,
8581            outputs: serde_json::json!({
8582                "bot_app_id": "app-123",
8583                "team_id": "team-123",
8584                "graph_access_token": "secret-token"
8585            }),
8586            created_resources: Vec::new(),
8587            last_error: None,
8588            updated_at: None,
8589        };
8590        write_setup_machine_state(temp.path(), &state)?;
8591
8592        let output = advance_setup_machine(
8593            temp.path(),
8594            "messaging-example",
8595            "demo",
8596            "default",
8597            &machine,
8598            false,
8599        )?;
8600
8601        assert_eq!(output["ok"], true);
8602        assert_eq!(output["result"]["detail"]["envelope_skipped"], true);
8603        let config_path = temp
8604            .path()
8605            .join("state/config/messaging-example/setup-answers.json");
8606        let config: Value = serde_json::from_str(&std::fs::read_to_string(config_path)?)?;
8607        assert_eq!(config["bot_app_id"], "app-123");
8608        assert_eq!(config["team_id"], "team-123");
8609        assert!(config.get("graph_access_token").is_none());
8610        let state = load_setup_machine_state(&setup_machine_state_path(
8611            temp.path(),
8612            "demo",
8613            "default",
8614            "messaging-example",
8615        ))?;
8616        assert_eq!(state.status, SetupMachineStatus::Complete);
8617        assert_eq!(
8618            state
8619                .created_resources
8620                .first()
8621                .and_then(|value| value.get("kind")),
8622            Some(&Value::String("runtime_config".to_string()))
8623        );
8624        Ok(())
8625    }
8626
8627    #[test]
8628    fn advance_setup_machine_persist_runtime_config_blocks_without_config() -> anyhow::Result<()> {
8629        let temp = tempfile::tempdir()?;
8630        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8631            "version": 1,
8632            "id": "example-machine",
8633            "entry_step": "persist",
8634            "steps": [
8635                {
8636                    "id": "persist",
8637                    "kind": "persist_runtime_config"
8638                }
8639            ]
8640        }))?;
8641
8642        let output = advance_setup_machine(
8643            temp.path(),
8644            "messaging-example",
8645            "demo",
8646            "default",
8647            &machine,
8648            false,
8649        )?;
8650
8651        assert_eq!(output["ok"], false);
8652        assert_eq!(output["result"]["detail"]["error"], "missing_prerequisite");
8653        let state = load_setup_machine_state(&setup_machine_state_path(
8654            temp.path(),
8655            "demo",
8656            "default",
8657            "messaging-example",
8658        ))?;
8659        assert_eq!(state.status, SetupMachineStatus::Paused);
8660        assert_eq!(state.current_step.as_deref(), Some("persist"));
8661        Ok(())
8662    }
8663
8664    #[test]
8665    fn advance_setup_machine_provider_component_call_blocks_without_pack_path() -> anyhow::Result<()>
8666    {
8667        let temp = tempfile::tempdir()?;
8668        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8669            "version": 1,
8670            "id": "example-machine",
8671            "entry_step": "register",
8672            "steps": [
8673                {
8674                    "id": "register",
8675                    "kind": "provider_component_call",
8676                    "component_ref": "messaging-example",
8677                    "op": "register",
8678                    "request": {"tenant": "{tenant}"}
8679                }
8680            ]
8681        }))?;
8682
8683        let output = advance_setup_machine(
8684            temp.path(),
8685            "messaging-example",
8686            "demo",
8687            "default",
8688            &machine,
8689            false,
8690        )?;
8691
8692        assert_eq!(output["ok"], false);
8693        assert_eq!(output["result"]["detail"]["error"], "missing_prerequisite");
8694        assert_eq!(
8695            output["result"]["detail"]["missing_config_key"],
8696            "pack_path"
8697        );
8698        let state = load_setup_machine_state(&setup_machine_state_path(
8699            temp.path(),
8700            "demo",
8701            "default",
8702            "messaging-example",
8703        ))?;
8704        assert_eq!(state.status, SetupMachineStatus::Paused);
8705        assert_eq!(state.current_step.as_deref(), Some("register"));
8706        Ok(())
8707    }
8708
8709    #[test]
8710    fn retry_setup_machine_step_clears_paused_error_without_losing_completed_steps()
8711    -> anyhow::Result<()> {
8712        let temp = tempfile::tempdir()?;
8713        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8714            "version": 1,
8715            "id": "example-machine",
8716            "entry_step": "collect",
8717            "steps": [
8718                {"id": "collect", "kind": "qa_form", "on_success": "manual"},
8719                {"id": "manual", "kind": "manual_action"}
8720            ]
8721        }))?;
8722        let state = SetupMachineState {
8723            schema_version: 1,
8724            provider_id: "messaging-example".to_string(),
8725            tenant: "demo".to_string(),
8726            team: "default".to_string(),
8727            machine_id: "example-machine".to_string(),
8728            machine_version: 1,
8729            status: SetupMachineStatus::Paused,
8730            current_step: Some("manual".to_string()),
8731            completed_steps: vec!["collect".to_string()],
8732            failed_step: Some("manual".to_string()),
8733            answers_hash: None,
8734            pack_fingerprint: None,
8735            outputs: serde_json::json!({}),
8736            created_resources: Vec::new(),
8737            last_error: Some(serde_json::json!({"error": "manual_action_required"})),
8738            updated_at: None,
8739        };
8740        write_setup_machine_state(temp.path(), &state)?;
8741
8742        let output = retry_setup_machine_step(
8743            temp.path(),
8744            "messaging-example",
8745            "demo",
8746            "default",
8747            &machine,
8748            None,
8749        )?;
8750
8751        assert_eq!(output["changed"], true);
8752        assert_eq!(output["step"], "manual");
8753        let loaded = load_setup_machine_state(&setup_machine_state_path(
8754            temp.path(),
8755            "demo",
8756            "default",
8757            "messaging-example",
8758        ))?;
8759        assert_eq!(loaded.status, SetupMachineStatus::Running);
8760        assert_eq!(loaded.current_step.as_deref(), Some("manual"));
8761        assert_eq!(loaded.completed_steps, vec!["collect"]);
8762        assert!(loaded.last_error.is_none());
8763        assert!(loaded.failed_step.is_none());
8764        Ok(())
8765    }
8766
8767    #[test]
8768    fn reset_setup_machine_state_archives_and_removes_machine_json() -> anyhow::Result<()> {
8769        let temp = tempfile::tempdir()?;
8770        let state = SetupMachineState {
8771            schema_version: 1,
8772            provider_id: "messaging-example".to_string(),
8773            tenant: "demo".to_string(),
8774            team: "default".to_string(),
8775            machine_id: "example-machine".to_string(),
8776            machine_version: 1,
8777            status: SetupMachineStatus::Paused,
8778            current_step: Some("manual".to_string()),
8779            completed_steps: Vec::new(),
8780            failed_step: None,
8781            answers_hash: None,
8782            pack_fingerprint: None,
8783            outputs: serde_json::json!({}),
8784            created_resources: Vec::new(),
8785            last_error: None,
8786            updated_at: None,
8787        };
8788        let state_path = write_setup_machine_state(temp.path(), &state)?;
8789
8790        let archive = reset_setup_machine_state(
8791            temp.path(),
8792            "demo",
8793            "default",
8794            "messaging-example",
8795            "manual reset",
8796        )?
8797        .expect("archive");
8798
8799        assert!(!state_path.exists());
8800        assert!(archive.is_file());
8801        let events = std::fs::read_to_string(setup_machine_events_path(
8802            temp.path(),
8803            "demo",
8804            "default",
8805            "messaging-example",
8806        ))?;
8807        assert!(events.contains("setup.machine.reset"));
8808        Ok(())
8809    }
8810
8811    #[test]
8812    fn migrate_setup_machine_state_archives_legacy_artifacts_and_initializes_machine()
8813    -> anyhow::Result<()> {
8814        let _store_iso_dir = tempfile::tempdir().expect("store isolation dir");
8815        let _store_iso = crate::secrets::test_support::StoreOverride::in_dir(_store_iso_dir.path());
8816        let temp = tempfile::tempdir()?;
8817        let machine: SetupMachine = serde_json::from_value(serde_json::json!({
8818            "version": 1,
8819            "id": "example-machine",
8820            "entry_step": "start",
8821            "steps": [
8822                {
8823                    "id": "start",
8824                    "kind": "manual_action",
8825                    "on_success": "complete"
8826                }
8827            ]
8828        }))?;
8829        let legacy_backend = crate::setup_backend_contract::legacy_backend_state_path(
8830            temp.path(),
8831            "dev",
8832            "demo",
8833            "default",
8834            "messaging-example",
8835        )?;
8836        std::fs::create_dir_all(legacy_backend.parent().unwrap())?;
8837        std::fs::write(&legacy_backend, r#"{"config":{"bot_app_id":"old"}}"#)?;
8838        let legacy_actions = crate::setup_actions::setup_actions_state_path(
8839            temp.path(),
8840            "demo",
8841            "default",
8842            "messaging-example",
8843        );
8844        std::fs::create_dir_all(legacy_actions.parent().unwrap())?;
8845        std::fs::write(&legacy_actions, r#"{"actions":[]}"#)?;
8846
8847        let migration = migrate_setup_machine_state(
8848            temp.path(),
8849            "dev",
8850            "demo",
8851            "default",
8852            "messaging-example",
8853            &machine,
8854        )?;
8855
8856        assert_eq!(migration.source, "legacy-archived");
8857        assert!(migration.initialized);
8858        assert!(migration.legacy_backend_removed);
8859        assert!(migration.setup_actions_removed);
8860        assert!(!legacy_backend.exists());
8861        assert!(!legacy_actions.exists());
8862        assert!(migration.backend_archive_path.unwrap().is_file());
8863        assert!(migration.setup_actions_archive_path.unwrap().is_file());
8864        let state = load_setup_machine_state(&migration.state_path)?;
8865        assert_eq!(state.status, SetupMachineStatus::NotStarted);
8866        assert_eq!(state.current_step.as_deref(), Some("start"));
8867        assert!(migration.event_path.is_file());
8868
8869        let repeated = migrate_setup_machine_state(
8870            temp.path(),
8871            "dev",
8872            "demo",
8873            "default",
8874            "messaging-example",
8875            &machine,
8876        )?;
8877        assert_eq!(repeated.source, "machine");
8878        assert!(!repeated.initialized);
8879        assert!(!repeated.legacy_backend_removed);
8880        assert!(!repeated.setup_actions_removed);
8881        assert!(!repeated.empty);
8882        Ok(())
8883    }
8884}