Skip to main content

ignition_core/actions/
script.rs

1//! The script action (07-03, SCRPT-01) — `ign script run`, the
2//! smallest verb in the CLI: one action over the already-shipped,
3//! already-secured scriptExec route (05-01's template, 05-03's
4//! deploy/secret lifecycle).
5//!
6//! The opt-in is STRUCTURAL, not a flag: scriptExec deploys ONLY via
7//! `ign webdev deploy --with-script-exec`, whose deploy persists a
8//! 32-byte hex secret in the profile config at 0600 BEFORE upload.
9//! `script_run` resolves that secret FIRST — no stored secret means
10//! the route was never deployed, and the verb refuses with the
11//! additive `script_exec_not_configured` (exit 6) naming the deploy
12//! flag verbatim. There is NO `--yes` guard on script run (the
13//! research-adopted decision): the deploy flag IS the opt-in, the
14//! verb is the route's entire purpose, and agents need it
15//! non-interactive.
16//!
17//! Sequence per invocation (two round trips, the 05-04
18//! precondition's correctness-over-latency precedent):
19//!
20//! 1. **Precondition handshake** — the version action WITH the
21//!    secret header. The 200-BODY envelope is the only success
22//!    oracle (WebDev ignores `status`; denials ride 200); a
23//!    `secret_required`/`secret_mismatch` denial surfaces honestly
24//!    through the existing webdev error family — a mismatch means
25//!    the route was deployed elsewhere/stale, and the hint already
26//!    says redeploy or `--rotate-secret`. No new slug, no
27//!    version-compare magic (the tags precondition owns that
28//!    discrimination for its family; scriptExec's gate IS the
29//!    secret).
30//! 2. **Exec** — `{"action": "exec", "code": <code>}` with the
31//!    secret header. A route error body (`{ok:false,
32//!    error{code,message,traceback?}}` at HTTP 200) maps through
33//!    the same denial seam with the traceback surfaced (the 05-08
34//!    pattern) — a route-side Python exception is a black box no
35//!    more.
36//!
37//! Timeout honesty (planner decision): v1.0.0's route has NO
38//! server-side timeout — the client rides the existing per-request
39//! class, and a long-running script simply holds the HTTP connection
40//! (README documents this).
41//!
42//! [`read_script_input`] is the PURE three-form input reader
43//! (`--code STR`, `--file PATH`, `--file -` stdin — the agent pipe
44//! path), unit-tested separately from the async action: usage-class
45//! errors lead (exit 2, the 03-03 put convention).
46
47use std::io::Read;
48
49use serde::Serialize;
50
51use crate::actions::webdev::{SCRIPT_EXEC_ROUTE, SECRET_HEADER};
52use crate::client::GatewayApi;
53use crate::config::Config;
54use crate::error::CoreError;
55
56/// `ign script run` result — the route's exec answer under
57/// unit-explicit keys, ALL keys always (the family convention:
58/// agents never key-hunt). The secret appears in NONE of them
59/// (redaction — the 05-03 canary extended to this surface).
60#[derive(Debug, Serialize)]
61pub struct ScriptRunResult {
62    /// The script's captured stdout, verbatim (empty string when the
63    /// script printed nothing — the key still rides).
64    pub stdout: String,
65    /// The script's value: a single expression's eval result, or the
66    /// `_result` global statements left (null when neither — the key
67    /// still rides). Raw JSON passthrough, never interpreted.
68    pub result: serde_json::Value,
69    /// The route-measured wall time in milliseconds (route-side
70    /// `time.time()` deltas; 0 when the answer carried none — the
71    /// key still rides).
72    #[serde(rename = "elapsedMs")]
73    pub elapsed_ms: u64,
74}
75
76/// `ign script run` — resolve the profile's stored scriptExec
77/// secret, prove the route answers it, execute the code.
78///
79/// Resolution order is fixed: the secret gate FIRST (None →
80/// `script_exec_not_configured`, zero HTTP), then the version
81/// handshake, then exec. The config is the caller's already-loaded
82/// view (main.rs resolves the profile for the client anyway; the
83/// TUI loads it inside the worker — the fire_webdev_status
84/// precedent).
85pub async fn script_run(
86    api: &dyn GatewayApi,
87    config: &Config,
88    profile_name: &str,
89    project: &str,
90    code: &str,
91) -> Result<ScriptRunResult, CoreError> {
92    // THE structural gate: no persisted secret = the route was never
93    // deployed through the opt-in flag. Refuses before ANY network
94    // I/O (must-have truth #3: zero HTTP requests).
95    let secret = config
96        .profiles
97        .get(profile_name)
98        .and_then(|profile| profile.webdev_secret.clone())
99        .ok_or_else(|| CoreError::ScriptExecNotConfigured {
100            profile: profile_name.to_string(),
101        })?;
102
103    // Precondition handshake: the version action WITH the secret.
104    // Success = the 200-BODY ok envelope (the only oracle); a denial
105    // body surfaces honestly through webdev_route_call's existing
106    // mapping (secret_required/secret_mismatch → webdev_route_error
107    // whose hint names redeploy or --rotate-secret).
108    api.webdev_route_call(
109        project,
110        SCRIPT_EXEC_ROUTE,
111        &serde_json::json!({"action": "version"}),
112        &[(SECRET_HEADER, secret.as_str())],
113    )
114    .await?;
115
116    // Exec: the code rides verbatim; the envelope's data carries
117    // {stdout, result, elapsedMs} (the 05-01 route contract). A
118    // denial (route error with traceback) maps at the same seam.
119    let data = api
120        .webdev_route_call(
121            project,
122            SCRIPT_EXEC_ROUTE,
123            &serde_json::json!({"action": "exec", "code": code}),
124            &[(SECRET_HEADER, secret.as_str())],
125        )
126        .await?;
127
128    Ok(ScriptRunResult {
129        stdout: data
130            .get("stdout")
131            .and_then(serde_json::Value::as_str)
132            .unwrap_or_default()
133            .to_string(),
134        result: data
135            .get("result")
136            .cloned()
137            .unwrap_or(serde_json::Value::Null),
138        elapsed_ms: data
139            .get("elapsedMs")
140            .and_then(serde_json::Value::as_u64)
141            .unwrap_or_default(),
142    })
143}
144
145/// The three-form input reader (PURE — no async, no gateway):
146/// `--code STR` passes through, `--file PATH` reads the file,
147/// `--file -` reads stdin (the agent pipe path). Both `--code` AND
148/// `--file` given → InvalidInput (usage errors lead — the caller
149/// runs this BEFORE profile resolution, exit 2, profile null);
150/// neither given → InvalidInput; unreadable file/stdin →
151/// InvalidInput (the 03-03 put convention: the reason names the
152/// source).
153pub fn read_script_input(code: Option<&str>, file: Option<&str>) -> Result<String, CoreError> {
154    match (code, file) {
155        (Some(_), Some(_)) => Err(CoreError::InvalidInput {
156            reason: "provide exactly one of --code or --file (not both)".to_string(),
157        }),
158        (Some(code), None) => Ok(code.to_string()),
159        (None, Some("-")) => {
160            let mut buffer = String::new();
161            std::io::stdin()
162                .read_to_string(&mut buffer)
163                .map_err(|err| CoreError::InvalidInput {
164                    reason: format!("cannot read stdin: {err}"),
165                })?;
166            Ok(buffer)
167        }
168        (None, Some(file)) => {
169            std::fs::read_to_string(file).map_err(|err| CoreError::InvalidInput {
170                reason: format!("cannot read {file}: {err}"),
171            })
172        }
173        (None, None) => Err(CoreError::InvalidInput {
174            reason: "provide the script via --code PY or --file PATH (--file - reads stdin)"
175                .to_string(),
176        }),
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::{read_script_input, script_run};
183    use crate::client::GatewayApi;
184    use crate::config;
185    use crate::error::CoreError;
186    use std::path::PathBuf;
187
188    /// The recorded (action, body) sequence the rig hands back for
189    /// assertion (an alias keeps the helper's signature legible).
190    type CallLog = std::sync::Arc<std::sync::Mutex<Vec<(String, serde_json::Value)>>>;
191
192    /// A scripted double over the ONE call script_run makes —
193    /// `webdev_route_call` answers from a lookup keyed on the body's
194    /// action token, recorded through a Mutex so the closure stays
195    /// `Fn` (the webdev.rs double shape). Everything else is
196    /// unreachable.
197    struct ScriptRig {
198        calls: CallLog,
199        answers: fn(&str) -> Result<serde_json::Value, CoreError>,
200    }
201
202    #[async_trait::async_trait]
203    impl GatewayApi for ScriptRig {
204        async fn uninstall_module(
205            &self,
206            _gateway_module_id: &str,
207            _module_id: &str,
208        ) -> Result<(), CoreError> {
209            unreachable!("not part of this action")
210        }
211        async fn bundle_generate(
212            &self,
213        ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
214            unreachable!("not part of this action")
215        }
216        async fn bundle_status(
217            &self,
218        ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
219            unreachable!("not part of this action")
220        }
221        async fn bundle_download(
222            &self,
223            _out: &std::path::Path,
224        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
225            unreachable!("not part of this action")
226        }
227        async fn tag_provider_list(
228            &self,
229            _query: &crate::client::query::ListQuery,
230        ) -> Result<
231            crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
232            CoreError,
233        > {
234            unreachable!("not part of this action")
235        }
236        async fn tag_provider_find(
237            &self,
238            _name: &str,
239        ) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
240            unreachable!("not part of this action")
241        }
242        async fn tag_provider_create(
243            &self,
244            _body: &[crate::client::tags::TagProviderCreate],
245        ) -> Result<(), CoreError> {
246            unreachable!("not part of this action")
247        }
248        async fn tag_provider_delete(
249            &self,
250            _name: &str,
251            _signature: &str,
252        ) -> Result<(), CoreError> {
253            unreachable!("not part of this action")
254        }
255        async fn webdev_route_call(
256            &self,
257            _project: &str,
258            _route: &str,
259            body: &serde_json::Value,
260            _extra_headers: &[(&str, &str)],
261        ) -> Result<serde_json::Value, CoreError> {
262            let action = body["action"].as_str().unwrap_or_default().to_string();
263            self.calls
264                .lock()
265                .expect("calls lock")
266                .push((action.clone(), body.clone()));
267            (self.answers)(&action)
268        }
269        async fn gateway_info(&self) -> Result<crate::client::version::GatewayInfo, CoreError> {
270            unreachable!("not part of this action")
271        }
272        async fn overview(&self) -> Result<crate::client::status::Overview, CoreError> {
273            unreachable!("not part of this action")
274        }
275        async fn status_ping(&self) -> Result<crate::client::status::StatusPing, CoreError> {
276            unreachable!("not part of this action")
277        }
278        async fn modules(
279            &self,
280            _quarantined: bool,
281            _query: &crate::client::query::ListQuery,
282        ) -> Result<crate::client::query::ListEnvelope<crate::client::status::ModuleInfo>, CoreError>
283        {
284            unreachable!("not part of this action")
285        }
286        async fn metrics_current(
287            &self,
288        ) -> Result<crate::client::metrics::CurrentGauges, CoreError> {
289            unreachable!("not part of this action")
290        }
291        async fn metrics_historic(
292            &self,
293        ) -> Result<crate::client::metrics::PerformanceCharts, CoreError> {
294            unreachable!("not part of this action")
295        }
296        async fn metrics_threads(&self) -> Result<crate::client::metrics::ThreadCounts, CoreError> {
297            unreachable!("not part of this action")
298        }
299        async fn designers(
300            &self,
301            _query: &crate::client::query::ListQuery,
302        ) -> Result<
303            crate::client::query::ListEnvelope<crate::client::sessions::DesignerInfo>,
304            CoreError,
305        > {
306            unreachable!("not part of this action")
307        }
308        async fn perspective_sessions(
309            &self,
310            _query: &crate::client::query::ListQuery,
311        ) -> Result<
312            crate::client::query::ListEnvelope<crate::client::sessions::PerspectiveSession>,
313            CoreError,
314        > {
315            unreachable!("not part of this action")
316        }
317        async fn vision_clients(
318            &self,
319            _query: &crate::client::query::ListQuery,
320        ) -> Result<
321            crate::client::query::ListEnvelope<crate::client::sessions::VisionClient>,
322            CoreError,
323        > {
324            unreachable!("not part of this action")
325        }
326        async fn terminate_perspective_session(
327            &self,
328            _id: &str,
329            _message: Option<&str>,
330        ) -> Result<(), CoreError> {
331            unreachable!("not part of this action")
332        }
333        async fn terminate_vision_client(&self, _id: &str) -> Result<(), CoreError> {
334            unreachable!("not part of this action")
335        }
336        async fn prune_designer(&self, _id: &str) -> Result<(), CoreError> {
337            unreachable!("not part of this action")
338        }
339        async fn database_connections(
340            &self,
341        ) -> Result<
342            crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
343            CoreError,
344        > {
345            unreachable!("not part of this action")
346        }
347        async fn opc_connections(
348            &self,
349        ) -> Result<
350            crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
351            CoreError,
352        > {
353            unreachable!("not part of this action")
354        }
355        async fn logs(
356            &self,
357            _filter: &crate::client::logs::LogQuery,
358        ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LogEntry>, CoreError>
359        {
360            unreachable!("not part of this action")
361        }
362        async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
363            unreachable!("not part of this action")
364        }
365        async fn loggers(
366            &self,
367            _query: &crate::client::query::ListQuery,
368        ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LoggerInfo>, CoreError>
369        {
370            unreachable!("not part of this action")
371        }
372        async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
373            unreachable!("not part of this action")
374        }
375        async fn reset_logger_levels(&self) -> Result<(), CoreError> {
376            unreachable!("not part of this action")
377        }
378        async fn restart(&self) -> Result<(), CoreError> {
379            unreachable!("not part of this action")
380        }
381        async fn scan_projects(&self) -> Result<(), CoreError> {
382            unreachable!("not part of this action")
383        }
384        async fn security_properties(
385            &self,
386        ) -> Result<crate::client::restart::SecurityProperties, CoreError> {
387            unreachable!("not part of this action")
388        }
389        async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
390            unreachable!("not part of this action")
391        }
392        async fn webdev_route_probe(
393            &self,
394            _project: &str,
395            _route: &str,
396            _extra_headers: &[(&str, &str)],
397        ) -> Result<crate::client::webdev::RouteProbe, CoreError> {
398            unreachable!("not part of this action")
399        }
400        async fn projects(
401            &self,
402            _query: &crate::client::query::ListQuery,
403        ) -> Result<
404            crate::client::query::ListEnvelope<crate::client::projects::ProjectRecord>,
405            CoreError,
406        > {
407            unreachable!("not part of this action")
408        }
409        async fn project_find(
410            &self,
411            _name: &str,
412        ) -> Result<crate::client::projects::ProjectRecord, CoreError> {
413            unreachable!("not part of this action")
414        }
415        async fn project_create(
416            &self,
417            _body: &crate::client::projects::ProjectCreate,
418        ) -> Result<(), CoreError> {
419            unreachable!("not part of this action")
420        }
421        async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
422            unreachable!("not part of this action")
423        }
424        async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
425            unreachable!("not part of this action")
426        }
427        async fn project_modify(
428            &self,
429            _name: &str,
430            _body: &crate::client::projects::ProjectModify,
431        ) -> Result<(), CoreError> {
432            unreachable!("not part of this action")
433        }
434        async fn project_delete(&self, _name: &str) -> Result<(), CoreError> {
435            unreachable!("not part of this action")
436        }
437        async fn project_export_to_file(
438            &self,
439            _name: &str,
440            _out: &std::path::Path,
441        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
442            unreachable!("not part of this action")
443        }
444        async fn project_import(
445            &self,
446            _name: &str,
447            _zip: Vec<u8>,
448            _overwrite: bool,
449        ) -> Result<crate::client::projects::ImportOutcome, CoreError> {
450            unreachable!("not part of this action")
451        }
452        async fn trial_status_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
453            unreachable!("not part of this action")
454        }
455        async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
456            unreachable!("not part of this action")
457        }
458        async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
459            unreachable!("not part of this action")
460        }
461        async fn backup_download(
462            &self,
463            _out: &std::path::Path,
464            _backup_type: crate::client::backup::BackupType,
465        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
466            unreachable!("not part of this action")
467        }
468        async fn backup_restore(&self, _gwbk: &std::path::Path) -> Result<(), CoreError> {
469            unreachable!("not part of this action")
470        }
471        async fn eam_task_history(
472            &self,
473            _limit: Option<u32>,
474            _search: Option<&str>,
475        ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>, CoreError>
476        {
477            unreachable!("not part of this action")
478        }
479        async fn eam_task_definitions(
480            &self,
481        ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>, CoreError>
482        {
483            unreachable!("not part of this action")
484        }
485        async fn eam_task_find(
486            &self,
487            _name: &str,
488        ) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
489            unreachable!("not part of this action")
490        }
491        async fn eam_task_create(&self, _definition: &serde_json::Value) -> Result<(), CoreError> {
492            unreachable!("not part of this action")
493        }
494        async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
495            unreachable!("not part of this action")
496        }
497        async fn eam_task_suspend(&self, _name: &str) -> Result<(), CoreError> {
498            unreachable!("not part of this action")
499        }
500        async fn eam_task_resume(&self, _name: &str) -> Result<(), CoreError> {
501            unreachable!("not part of this action")
502        }
503        async fn eam_task_cancel(&self, _name: &str) -> Result<(), CoreError> {
504            unreachable!("not part of this action")
505        }
506        async fn eam_tasks_scheduled(
507            &self,
508            _running: bool,
509        ) -> Result<Vec<crate::client::eam::EamScheduledTask>, CoreError> {
510            unreachable!("not part of this action")
511        }
512        async fn eam_task_modify(
513            &self,
514            _definition: &serde_json::Value,
515        ) -> Result<Option<crate::client::eam::ModifyOutcome>, CoreError> {
516            unreachable!("not part of this action")
517        }
518        async fn eam_task_delete(
519            &self,
520            _name: &str,
521            _signature: &str,
522            _confirm: bool,
523        ) -> Result<crate::client::eam::DeleteOutcome, CoreError> {
524            unreachable!("not part of this action")
525        }
526        async fn api_call(
527            &self,
528            _call: &crate::client::apicall::ApiCallRequest,
529        ) -> Result<crate::client::apicall::ApiCallData, CoreError> {
530            unreachable!("not part of this action")
531        }
532        async fn license_status(
533            &self,
534        ) -> Result<crate::client::license::LicenseStatusWire, CoreError> {
535            unreachable!("not part of this action")
536        }
537        async fn redundancy_status(
538            &self,
539        ) -> Result<crate::client::redundancy::RedundancyStatusWire, CoreError> {
540            unreachable!("not part of this action")
541        }
542        async fn gan_status(&self) -> Result<crate::client::gan::GanStatusWire, CoreError> {
543            unreachable!("not part of this action")
544        }
545    }
546
547    /// Temp config with one `dev` profile; the optional secret seeds
548    /// `webdev_secret` (the persisted-secret gate's two states).
549    fn temp_config(secret: Option<&str>) -> (tempfile::TempDir, config::Config, PathBuf) {
550        let dir = tempfile::tempdir().expect("tempdir");
551        let path = dir.path().join("config.toml");
552        let secret_line = secret
553            .map(|secret| format!("webdev_secret = \"{secret}\"\n"))
554            .unwrap_or_default();
555        std::fs::write(
556            &path,
557            format!("active = \"dev\"\n\n[profiles.dev]\nurl = \"http://localhost:9088/\"\n{secret_line}"),
558        )
559        .expect("write config");
560        let config = config::load(&path).expect("config loads");
561        (dir, config, path)
562    }
563
564    /// A rig whose every call answers from a table keyed on the
565    /// action token, recording the bodies it saw.
566    fn rig(answers: fn(&str) -> Result<serde_json::Value, CoreError>) -> (ScriptRig, CallLog) {
567        let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
568        (
569            ScriptRig {
570                calls: std::sync::Arc::clone(&calls),
571                answers,
572            },
573            calls,
574        )
575    }
576
577    /// THE structural gate: no stored secret → the additive slug,
578    /// exit 6, hint naming the deploy flag — and ZERO route calls.
579    #[tokio::test]
580    async fn missing_secret_refuses_before_any_call() {
581        let (_dir, config, _path) = temp_config(None);
582        let (double, calls) = rig(|_| unreachable!("the gate refuses before any call"));
583        let err = script_run(&double, &config, "dev", "ign-cli", "2+2")
584            .await
585            .expect_err("no secret refuses");
586        assert_eq!(err.code(), "script_exec_not_configured");
587        assert_eq!(err.exit_code(), 6);
588        assert!(
589            err.hint()
590                .unwrap()
591                .contains("ign webdev deploy --with-script-exec"),
592            "hint names the deploy flag: {:?}",
593            err.hint()
594        );
595        assert!(
596            calls.lock().expect("calls lock").is_empty(),
597            "zero route calls"
598        );
599    }
600
601    /// The success round: version probe then exec, both seen, the
602    /// exec body carrying the code verbatim, and the answer mapped
603    /// under {stdout, result, elapsedMs} with ALL keys always.
604    #[tokio::test]
605    async fn success_round_probes_then_execs_and_maps_the_envelope() {
606        let (_dir, config, _path) = temp_config(Some("aabbcc"));
607        let (double, calls) = rig(|action| {
608            Ok(match action {
609                "version" => serde_json::json!({"routeVersion": "1.0.0", "minCli": "1.0"}),
610                _ => serde_json::json!({
611                    "stdout": "hello\n",
612                    "result": 4,
613                    "elapsedMs": 12,
614                }),
615            })
616        });
617        let result = script_run(&double, &config, "dev", "ign-cli", "print 'hello'\n2+2")
618            .await
619            .expect("exec succeeds");
620        assert_eq!(result.stdout, "hello\n");
621        assert_eq!(result.result, serde_json::json!(4));
622        assert_eq!(result.elapsed_ms, 12);
623
624        let calls = calls.lock().expect("calls lock");
625        assert_eq!(calls.len(), 2, "exactly probe + exec");
626        assert_eq!(calls[0].0, "version");
627        assert_eq!(calls[1].0, "exec");
628        assert_eq!(
629            calls[1].1["code"], "print 'hello'\n2+2",
630            "code rides verbatim"
631        );
632
633        // Serialized shape: unit-explicit keys, ALL always.
634        let serialized = serde_json::to_value(&result).expect("serializes");
635        assert_eq!(serialized["stdout"], "hello\n");
636        assert_eq!(serialized["result"], 4);
637        assert_eq!(serialized["elapsedMs"], 12);
638        let mut keys: Vec<&str> = serialized
639            .as_object()
640            .expect("object")
641            .keys()
642            .map(String::as_str)
643            .collect();
644        keys.sort_unstable();
645        assert_eq!(keys, vec!["elapsedMs", "result", "stdout"]);
646    }
647
648    /// The missing-answer degrade: absent fields default (empty
649    /// stdout, null result, 0 ms) instead of erroring — ALL keys
650    /// still ride (the family convention).
651    #[tokio::test]
652    async fn absent_answer_fields_default_but_keys_ride() {
653        let (_dir, config, _path) = temp_config(Some("aabbcc"));
654        let (double, _calls) = rig(|_| Ok(serde_json::json!({})));
655        let result = script_run(&double, &config, "dev", "ign-cli", "pass")
656            .await
657            .expect("an empty object still answers");
658        assert_eq!(result.stdout, "");
659        assert_eq!(result.result, serde_json::Value::Null);
660        assert_eq!(result.elapsed_ms, 0);
661    }
662
663    /// A probe denial surfaces HONESTLY through the existing family
664    /// (the rig hands back the error webdev_route_call would have
665    /// mapped) — and exec NEVER fires.
666    #[tokio::test]
667    async fn probe_denial_surfaces_honestly_without_exec() {
668        let (_dir, config, _path) = temp_config(Some("stale"));
669        let (double, calls) = rig(|action| match action {
670            "version" => Err(CoreError::WebdevRouteError {
671                code: "secret_mismatch".to_string(),
672                message: "scriptExec secret mismatch".to_string(),
673                endpoint: Some("/system/webdev/ign-cli/cli/scriptExec".to_string()),
674            }),
675            _ => unreachable!("exec must not fire after a probe denial"),
676        });
677        let err = script_run(&double, &config, "dev", "ign-cli", "2+2")
678            .await
679            .expect_err("mismatch refuses");
680        assert_eq!(err.code(), "webdev_route_error");
681        assert_eq!(err.exit_code(), 6);
682        assert!(
683            err.hint().unwrap().contains("--rotate-secret"),
684            "the existing hint carries the redeploy/rotate advice: {:?}",
685            err.hint()
686        );
687        let calls = calls.lock().expect("calls lock");
688        assert_eq!(calls.len(), 1, "only the probe ran");
689        assert_eq!(calls[0].0, "version");
690    }
691
692    /// The pure three-form reader: --code passes through, --file
693    /// reads disk, both/none/unreadable refuse InvalidInput (usage
694    /// errors lead — the 03-03 put convention).
695    #[test]
696    fn read_script_input_resolves_the_three_forms() {
697        // --code verbatim.
698        assert_eq!(read_script_input(Some("2+2"), None).expect("code"), "2+2");
699        // --file PATH reads the file.
700        let dir = tempfile::tempdir().expect("tempdir");
701        let file = dir.path().join("snippet.py");
702        std::fs::write(&file, "print 'hi'\n").expect("write snippet");
703        assert_eq!(
704            read_script_input(None, file.to_str()).expect("file"),
705            "print 'hi'\n"
706        );
707        // Both → InvalidInput naming the exclusivity.
708        let err = read_script_input(Some("2+2"), file.to_str()).expect_err("both refuse");
709        assert_eq!(err.code(), "invalid_input");
710        assert_eq!(err.exit_code(), 2);
711        assert!(
712            err.to_string().contains("--code") && err.to_string().contains("--file"),
713            "reason names both flags: {err}"
714        );
715        // Neither → InvalidInput naming the forms.
716        let err = read_script_input(None, None).expect_err("neither refuses");
717        assert_eq!(err.code(), "invalid_input");
718        assert!(err.to_string().contains("--file -"), "stdin named: {err}");
719        // Unreadable file → InvalidInput naming the path.
720        let err = read_script_input(None, Some("/nonexistent/snippet.py")).expect_err("miss");
721        assert_eq!(err.code(), "invalid_input");
722        assert!(
723            err.to_string().contains("/nonexistent/snippet.py"),
724            "reason names the file: {err}"
725        );
726    }
727}