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 thread_dump(
205            &self,
206        ) -> Result<crate::client::threads::FormattedThreadDump, CoreError> {
207            unreachable!("not part of this action")
208        }
209        async fn thread_deadlocks(
210            &self,
211        ) -> Result<crate::client::threads::DeadlocksWire, CoreError> {
212            unreachable!("not part of this action")
213        }
214        async fn uninstall_module(
215            &self,
216            _gateway_module_id: &str,
217            _module_id: &str,
218        ) -> Result<(), CoreError> {
219            unreachable!("not part of this action")
220        }
221        async fn bundle_generate(
222            &self,
223        ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
224            unreachable!("not part of this action")
225        }
226        async fn bundle_status(
227            &self,
228        ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
229            unreachable!("not part of this action")
230        }
231        async fn bundle_download(
232            &self,
233            _out: &std::path::Path,
234        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
235            unreachable!("not part of this action")
236        }
237        async fn tag_provider_list(
238            &self,
239            _query: &crate::client::query::ListQuery,
240        ) -> Result<
241            crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
242            CoreError,
243        > {
244            unreachable!("not part of this action")
245        }
246        async fn tag_provider_find(
247            &self,
248            _name: &str,
249        ) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
250            unreachable!("not part of this action")
251        }
252        async fn tag_provider_create(
253            &self,
254            _body: &[crate::client::tags::TagProviderCreate],
255        ) -> Result<(), CoreError> {
256            unreachable!("not part of this action")
257        }
258        async fn tag_provider_delete(
259            &self,
260            _name: &str,
261            _signature: &str,
262        ) -> Result<(), CoreError> {
263            unreachable!("not part of this action")
264        }
265        async fn webdev_route_call(
266            &self,
267            _project: &str,
268            _route: &str,
269            body: &serde_json::Value,
270            _extra_headers: &[(&str, &str)],
271        ) -> Result<serde_json::Value, CoreError> {
272            let action = body["action"].as_str().unwrap_or_default().to_string();
273            self.calls
274                .lock()
275                .expect("calls lock")
276                .push((action.clone(), body.clone()));
277            (self.answers)(&action)
278        }
279        async fn gateway_info(&self) -> Result<crate::client::version::GatewayInfo, CoreError> {
280            unreachable!("not part of this action")
281        }
282        async fn overview(&self) -> Result<crate::client::status::Overview, CoreError> {
283            unreachable!("not part of this action")
284        }
285        async fn status_ping(&self) -> Result<crate::client::status::StatusPing, CoreError> {
286            unreachable!("not part of this action")
287        }
288        async fn modules(
289            &self,
290            _quarantined: bool,
291            _query: &crate::client::query::ListQuery,
292        ) -> Result<crate::client::query::ListEnvelope<crate::client::status::ModuleInfo>, CoreError>
293        {
294            unreachable!("not part of this action")
295        }
296        async fn metrics_current(
297            &self,
298        ) -> Result<crate::client::metrics::CurrentGauges, CoreError> {
299            unreachable!("not part of this action")
300        }
301        async fn metrics_historic(
302            &self,
303        ) -> Result<crate::client::metrics::PerformanceCharts, CoreError> {
304            unreachable!("not part of this action")
305        }
306        async fn metrics_threads(&self) -> Result<crate::client::metrics::ThreadCounts, CoreError> {
307            unreachable!("not part of this action")
308        }
309        async fn designers(
310            &self,
311            _query: &crate::client::query::ListQuery,
312        ) -> Result<
313            crate::client::query::ListEnvelope<crate::client::sessions::DesignerInfo>,
314            CoreError,
315        > {
316            unreachable!("not part of this action")
317        }
318        async fn perspective_sessions(
319            &self,
320            _query: &crate::client::query::ListQuery,
321        ) -> Result<
322            crate::client::query::ListEnvelope<crate::client::sessions::PerspectiveSession>,
323            CoreError,
324        > {
325            unreachable!("not part of this action")
326        }
327        async fn vision_clients(
328            &self,
329            _query: &crate::client::query::ListQuery,
330        ) -> Result<
331            crate::client::query::ListEnvelope<crate::client::sessions::VisionClient>,
332            CoreError,
333        > {
334            unreachable!("not part of this action")
335        }
336        async fn terminate_perspective_session(
337            &self,
338            _id: &str,
339            _message: Option<&str>,
340        ) -> Result<(), CoreError> {
341            unreachable!("not part of this action")
342        }
343        async fn terminate_vision_client(&self, _id: &str) -> Result<(), CoreError> {
344            unreachable!("not part of this action")
345        }
346        async fn prune_designer(&self, _id: &str) -> Result<(), CoreError> {
347            unreachable!("not part of this action")
348        }
349        async fn database_connections(
350            &self,
351        ) -> Result<
352            crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
353            CoreError,
354        > {
355            unreachable!("not part of this action")
356        }
357        async fn opc_connections(
358            &self,
359        ) -> Result<
360            crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
361            CoreError,
362        > {
363            unreachable!("not part of this action")
364        }
365        async fn logs(
366            &self,
367            _filter: &crate::client::logs::LogQuery,
368        ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LogEntry>, CoreError>
369        {
370            unreachable!("not part of this action")
371        }
372        async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
373            unreachable!("not part of this action")
374        }
375        async fn loggers(
376            &self,
377            _query: &crate::client::query::ListQuery,
378        ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LoggerInfo>, CoreError>
379        {
380            unreachable!("not part of this action")
381        }
382        async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
383            unreachable!("not part of this action")
384        }
385        async fn reset_logger_levels(&self) -> Result<(), CoreError> {
386            unreachable!("not part of this action")
387        }
388        async fn restart(&self) -> Result<(), CoreError> {
389            unreachable!("not part of this action")
390        }
391        async fn scan_projects(&self) -> Result<(), CoreError> {
392            unreachable!("not part of this action")
393        }
394        async fn security_properties(
395            &self,
396        ) -> Result<crate::client::restart::SecurityProperties, CoreError> {
397            unreachable!("not part of this action")
398        }
399        async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
400            unreachable!("not part of this action")
401        }
402        async fn webdev_route_probe(
403            &self,
404            _project: &str,
405            _route: &str,
406            _extra_headers: &[(&str, &str)],
407        ) -> Result<crate::client::webdev::RouteProbe, CoreError> {
408            unreachable!("not part of this action")
409        }
410        async fn projects(
411            &self,
412            _query: &crate::client::query::ListQuery,
413        ) -> Result<
414            crate::client::query::ListEnvelope<crate::client::projects::ProjectRecord>,
415            CoreError,
416        > {
417            unreachable!("not part of this action")
418        }
419        async fn project_find(
420            &self,
421            _name: &str,
422        ) -> Result<crate::client::projects::ProjectRecord, CoreError> {
423            unreachable!("not part of this action")
424        }
425        async fn project_create(
426            &self,
427            _body: &crate::client::projects::ProjectCreate,
428        ) -> Result<(), CoreError> {
429            unreachable!("not part of this action")
430        }
431        async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
432            unreachable!("not part of this action")
433        }
434        async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
435            unreachable!("not part of this action")
436        }
437        async fn project_modify(
438            &self,
439            _name: &str,
440            _body: &crate::client::projects::ProjectModify,
441        ) -> Result<(), CoreError> {
442            unreachable!("not part of this action")
443        }
444        async fn project_delete(&self, _name: &str) -> Result<(), CoreError> {
445            unreachable!("not part of this action")
446        }
447        async fn project_export_to_file(
448            &self,
449            _name: &str,
450            _out: &std::path::Path,
451        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
452            unreachable!("not part of this action")
453        }
454        async fn project_import(
455            &self,
456            _name: &str,
457            _zip: Vec<u8>,
458            _overwrite: bool,
459        ) -> Result<crate::client::projects::ImportOutcome, CoreError> {
460            unreachable!("not part of this action")
461        }
462        async fn trial_status_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
463            unreachable!("not part of this action")
464        }
465        async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
466            unreachable!("not part of this action")
467        }
468        async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
469            unreachable!("not part of this action")
470        }
471        async fn backup_download(
472            &self,
473            _out: &std::path::Path,
474            _backup_type: crate::client::backup::BackupType,
475        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
476            unreachable!("not part of this action")
477        }
478        async fn backup_restore(&self, _gwbk: &std::path::Path) -> Result<(), CoreError> {
479            unreachable!("not part of this action")
480        }
481        async fn eam_task_history(
482            &self,
483            _limit: Option<u32>,
484            _search: Option<&str>,
485        ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>, CoreError>
486        {
487            unreachable!("not part of this action")
488        }
489        async fn eam_task_definitions(
490            &self,
491        ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>, CoreError>
492        {
493            unreachable!("not part of this action")
494        }
495        async fn eam_task_find(
496            &self,
497            _name: &str,
498        ) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
499            unreachable!("not part of this action")
500        }
501        async fn eam_task_create(&self, _definition: &serde_json::Value) -> Result<(), CoreError> {
502            unreachable!("not part of this action")
503        }
504        async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
505            unreachable!("not part of this action")
506        }
507        async fn eam_task_suspend(&self, _name: &str) -> Result<(), CoreError> {
508            unreachable!("not part of this action")
509        }
510        async fn eam_task_resume(&self, _name: &str) -> Result<(), CoreError> {
511            unreachable!("not part of this action")
512        }
513        async fn eam_task_cancel(&self, _name: &str) -> Result<(), CoreError> {
514            unreachable!("not part of this action")
515        }
516        async fn eam_tasks_scheduled(
517            &self,
518            _running: bool,
519        ) -> Result<Vec<crate::client::eam::EamScheduledTask>, CoreError> {
520            unreachable!("not part of this action")
521        }
522        async fn eam_task_modify(
523            &self,
524            _definition: &serde_json::Value,
525        ) -> Result<Option<crate::client::eam::ModifyOutcome>, CoreError> {
526            unreachable!("not part of this action")
527        }
528        async fn eam_task_delete(
529            &self,
530            _name: &str,
531            _signature: &str,
532            _confirm: bool,
533        ) -> Result<crate::client::eam::DeleteOutcome, CoreError> {
534            unreachable!("not part of this action")
535        }
536        async fn api_call(
537            &self,
538            _call: &crate::client::apicall::ApiCallRequest,
539        ) -> Result<crate::client::apicall::ApiCallData, CoreError> {
540            unreachable!("not part of this action")
541        }
542        async fn license_status(
543            &self,
544        ) -> Result<crate::client::license::LicenseStatusWire, CoreError> {
545            unreachable!("not part of this action")
546        }
547        async fn redundancy_status(
548            &self,
549        ) -> Result<crate::client::redundancy::RedundancyStatusWire, CoreError> {
550            unreachable!("not part of this action")
551        }
552        async fn gan_status(&self) -> Result<crate::client::gan::GanStatusWire, CoreError> {
553            unreachable!("not part of this action")
554        }
555    }
556
557    /// Temp config with one `dev` profile; the optional secret seeds
558    /// `webdev_secret` (the persisted-secret gate's two states).
559    fn temp_config(secret: Option<&str>) -> (tempfile::TempDir, config::Config, PathBuf) {
560        let dir = tempfile::tempdir().expect("tempdir");
561        let path = dir.path().join("config.toml");
562        let secret_line = secret
563            .map(|secret| format!("webdev_secret = \"{secret}\"\n"))
564            .unwrap_or_default();
565        std::fs::write(
566            &path,
567            format!("active = \"dev\"\n\n[profiles.dev]\nurl = \"http://localhost:9088/\"\n{secret_line}"),
568        )
569        .expect("write config");
570        let config = config::load(&path).expect("config loads");
571        (dir, config, path)
572    }
573
574    /// A rig whose every call answers from a table keyed on the
575    /// action token, recording the bodies it saw.
576    fn rig(answers: fn(&str) -> Result<serde_json::Value, CoreError>) -> (ScriptRig, CallLog) {
577        let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
578        (
579            ScriptRig {
580                calls: std::sync::Arc::clone(&calls),
581                answers,
582            },
583            calls,
584        )
585    }
586
587    /// THE structural gate: no stored secret → the additive slug,
588    /// exit 6, hint naming the deploy flag — and ZERO route calls.
589    #[tokio::test]
590    async fn missing_secret_refuses_before_any_call() {
591        let (_dir, config, _path) = temp_config(None);
592        let (double, calls) = rig(|_| unreachable!("the gate refuses before any call"));
593        let err = script_run(&double, &config, "dev", "ign-cli", "2+2")
594            .await
595            .expect_err("no secret refuses");
596        assert_eq!(err.code(), "script_exec_not_configured");
597        assert_eq!(err.exit_code(), 6);
598        assert!(
599            err.hint()
600                .unwrap()
601                .contains("ign webdev deploy --with-script-exec"),
602            "hint names the deploy flag: {:?}",
603            err.hint()
604        );
605        assert!(
606            calls.lock().expect("calls lock").is_empty(),
607            "zero route calls"
608        );
609    }
610
611    /// The success round: version probe then exec, both seen, the
612    /// exec body carrying the code verbatim, and the answer mapped
613    /// under {stdout, result, elapsedMs} with ALL keys always.
614    #[tokio::test]
615    async fn success_round_probes_then_execs_and_maps_the_envelope() {
616        let (_dir, config, _path) = temp_config(Some("aabbcc"));
617        let (double, calls) = rig(|action| {
618            Ok(match action {
619                "version" => serde_json::json!({"routeVersion": "1.0.0", "minCli": "1.0"}),
620                _ => serde_json::json!({
621                    "stdout": "hello\n",
622                    "result": 4,
623                    "elapsedMs": 12,
624                }),
625            })
626        });
627        let result = script_run(&double, &config, "dev", "ign-cli", "print 'hello'\n2+2")
628            .await
629            .expect("exec succeeds");
630        assert_eq!(result.stdout, "hello\n");
631        assert_eq!(result.result, serde_json::json!(4));
632        assert_eq!(result.elapsed_ms, 12);
633
634        let calls = calls.lock().expect("calls lock");
635        assert_eq!(calls.len(), 2, "exactly probe + exec");
636        assert_eq!(calls[0].0, "version");
637        assert_eq!(calls[1].0, "exec");
638        assert_eq!(
639            calls[1].1["code"], "print 'hello'\n2+2",
640            "code rides verbatim"
641        );
642
643        // Serialized shape: unit-explicit keys, ALL always.
644        let serialized = serde_json::to_value(&result).expect("serializes");
645        assert_eq!(serialized["stdout"], "hello\n");
646        assert_eq!(serialized["result"], 4);
647        assert_eq!(serialized["elapsedMs"], 12);
648        let mut keys: Vec<&str> = serialized
649            .as_object()
650            .expect("object")
651            .keys()
652            .map(String::as_str)
653            .collect();
654        keys.sort_unstable();
655        assert_eq!(keys, vec!["elapsedMs", "result", "stdout"]);
656    }
657
658    /// The missing-answer degrade: absent fields default (empty
659    /// stdout, null result, 0 ms) instead of erroring — ALL keys
660    /// still ride (the family convention).
661    #[tokio::test]
662    async fn absent_answer_fields_default_but_keys_ride() {
663        let (_dir, config, _path) = temp_config(Some("aabbcc"));
664        let (double, _calls) = rig(|_| Ok(serde_json::json!({})));
665        let result = script_run(&double, &config, "dev", "ign-cli", "pass")
666            .await
667            .expect("an empty object still answers");
668        assert_eq!(result.stdout, "");
669        assert_eq!(result.result, serde_json::Value::Null);
670        assert_eq!(result.elapsed_ms, 0);
671    }
672
673    /// A probe denial surfaces HONESTLY through the existing family
674    /// (the rig hands back the error webdev_route_call would have
675    /// mapped) — and exec NEVER fires.
676    #[tokio::test]
677    async fn probe_denial_surfaces_honestly_without_exec() {
678        let (_dir, config, _path) = temp_config(Some("stale"));
679        let (double, calls) = rig(|action| match action {
680            "version" => Err(CoreError::WebdevRouteError {
681                code: "secret_mismatch".to_string(),
682                message: "scriptExec secret mismatch".to_string(),
683                endpoint: Some("/system/webdev/ign-cli/cli/scriptExec".to_string()),
684            }),
685            _ => unreachable!("exec must not fire after a probe denial"),
686        });
687        let err = script_run(&double, &config, "dev", "ign-cli", "2+2")
688            .await
689            .expect_err("mismatch refuses");
690        assert_eq!(err.code(), "webdev_route_error");
691        assert_eq!(err.exit_code(), 6);
692        assert!(
693            err.hint().unwrap().contains("--rotate-secret"),
694            "the existing hint carries the redeploy/rotate advice: {:?}",
695            err.hint()
696        );
697        let calls = calls.lock().expect("calls lock");
698        assert_eq!(calls.len(), 1, "only the probe ran");
699        assert_eq!(calls[0].0, "version");
700    }
701
702    /// The pure three-form reader: --code passes through, --file
703    /// reads disk, both/none/unreadable refuse InvalidInput (usage
704    /// errors lead — the 03-03 put convention).
705    #[test]
706    fn read_script_input_resolves_the_three_forms() {
707        // --code verbatim.
708        assert_eq!(read_script_input(Some("2+2"), None).expect("code"), "2+2");
709        // --file PATH reads the file.
710        let dir = tempfile::tempdir().expect("tempdir");
711        let file = dir.path().join("snippet.py");
712        std::fs::write(&file, "print 'hi'\n").expect("write snippet");
713        assert_eq!(
714            read_script_input(None, file.to_str()).expect("file"),
715            "print 'hi'\n"
716        );
717        // Both → InvalidInput naming the exclusivity.
718        let err = read_script_input(Some("2+2"), file.to_str()).expect_err("both refuse");
719        assert_eq!(err.code(), "invalid_input");
720        assert_eq!(err.exit_code(), 2);
721        assert!(
722            err.to_string().contains("--code") && err.to_string().contains("--file"),
723            "reason names both flags: {err}"
724        );
725        // Neither → InvalidInput naming the forms.
726        let err = read_script_input(None, None).expect_err("neither refuses");
727        assert_eq!(err.code(), "invalid_input");
728        assert!(err.to_string().contains("--file -"), "stdin named: {err}");
729        // Unreadable file → InvalidInput naming the path.
730        let err = read_script_input(None, Some("/nonexistent/snippet.py")).expect_err("miss");
731        assert_eq!(err.code(), "invalid_input");
732        assert!(
733            err.to_string().contains("/nonexistent/snippet.py"),
734            "reason names the file: {err}"
735        );
736    }
737}