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