Skip to main content

agent_first_http/cli/
args.rs

1//! Turn one resolved `cli-spec-v1` invocation into the typed command the async
2//! dispatcher runs.
3//!
4//! The registry in [`crate::cli::spec`] has already decided that the argv is
5//! legal, which shape it matched, and what every value's type is. What is left
6//! here is projection: reading those values, applying the two environment
7//! fallbacks the registry cannot see (`AFHTTP_ENDPOINT_URL` and
8//! `AFHTTP_TOKEN_SECRET`), and narrowing registry integers to the unsigned
9//! ranges the domain types use.
10//!
11//! Projection stays pure. A `--token-secret` source is classified here but read
12//! later, and a missing endpoint is carried here but discovered later, so
13//! nothing in this file opens a file, spawns a container runtime, or reaches a
14//! host — argv is either legal or it is not, before any of that can happen.
15
16use std::path::PathBuf;
17
18use agent_first_data::{CliValue, ResolvedInvocation, ValueSource};
19
20use crate::cli::cmd;
21use crate::cli::connect::Connection;
22use crate::cli::token_source;
23use crate::host::bootstrap::{
24    BrowserChoice, DisplayMode, HealthPublic, Takeover, TakeoverProviderKind,
25};
26use crate::sdk::fetch::{NetworkBodies, RenderMode};
27use crate::shared::error::{Error, ErrorCode};
28
29const ENDPOINT_ENV: &str = "AFHTTP_ENDPOINT_URL";
30const TOKEN_ENV: &str = "AFHTTP_TOKEN_SECRET";
31
32/// One dispatchable command, already projected off its invocation.
33pub enum Command {
34    Fetch(Box<cmd::fetch::Args>),
35    Host(cmd::host::Args),
36    Upload(cmd::upload::Args),
37    Cdp(cmd::cdp::Args),
38    Panel(cmd::panel::Args),
39    Health(cmd::health::Args),
40    Capabilities(cmd::capabilities::Args),
41    Profile(cmd::profile::Args),
42    Tabs(cmd::tabs::Args),
43    Ui(cmd::ui::Args),
44    Skill(cmd::skill::Args),
45    Container(cmd::container::Args),
46}
47
48/// What every action handler produces. Projection can still fail — an endpoint
49/// that is neither on argv nor in the environment, an integer outside its
50/// domain range — and those are argument errors, not run failures.
51pub type Projected = Result<Command, Error>;
52
53type Handler = fn(&ResolvedInvocation) -> Projected;
54
55/// Exactly one handler per action id in the registry; `bind_actions` proves the
56/// two sets match at startup.
57pub fn handlers() -> [(&'static str, Handler); 24] {
58    [
59        ("fetch", fetch as Handler),
60        ("host", host),
61        ("upload", upload),
62        ("cdp", cdp),
63        ("panel", panel),
64        ("health", health),
65        ("capabilities", capabilities),
66        ("profile_list", profile_list),
67        ("profile_info", profile_info),
68        ("profile_lock_status", profile_lock_status),
69        ("profile_downloads", profile_downloads),
70        ("profile_cookies", profile_cookies),
71        ("profile_delete", profile_delete),
72        ("profile_prune", profile_prune),
73        ("tabs_list", tabs_list),
74        ("tabs_close", tabs_close),
75        ("ui_takeover", ui_takeover),
76        ("skill_status", skill_status),
77        ("skill_install", skill_install),
78        ("skill_uninstall", skill_uninstall),
79        ("container_install", container_install),
80        ("container_uninstall", container_uninstall),
81        ("container_status", container_status),
82        ("container_logs", container_logs),
83    ]
84}
85
86// ── value projection ────────────────────────────────────────────────────────
87
88/// A value the matched shape declares as required or fixed.
89///
90/// Reading it cannot fail: the shape that matched supplies every id it
91/// declares. Asking for an id it does not declare is a defect in this file, and
92/// `call_every_combination` is where that surfaces, from a test.
93fn string(invocation: &ResolvedInvocation, id: &str) -> String {
94    invocation
95        .required(id)
96        .as_str()
97        .unwrap_or_default()
98        .to_string()
99}
100
101fn opt_string(invocation: &ResolvedInvocation, id: &str) -> Option<String> {
102    invocation
103        .optional(id)
104        .and_then(CliValue::as_str)
105        .map(str::to_string)
106}
107
108fn strings(invocation: &ResolvedInvocation, id: &str) -> Vec<String> {
109    invocation
110        .repeated(id)
111        .iter()
112        .filter_map(CliValue::as_str)
113        .map(str::to_string)
114        .collect()
115}
116
117fn flag(invocation: &ResolvedInvocation, id: &str) -> bool {
118    invocation
119        .optional(id)
120        .and_then(CliValue::as_bool)
121        .unwrap_or(false)
122}
123
124fn opt_path(invocation: &ResolvedInvocation, id: &str) -> Option<PathBuf> {
125    opt_string(invocation, id).map(PathBuf::from)
126}
127
128/// Registry integers are `i64`. Every afhttp count is unsigned and bounded, and
129/// that range is the one thing `cli-spec-v1` cannot type, so it is checked here
130/// and reported as the argument error it is.
131fn unsigned(invocation: &ResolvedInvocation, id: &str, name: &str, max: u64) -> Result<u64, Error> {
132    let value = invocation
133        .optional(id)
134        .and_then(CliValue::as_i64)
135        .unwrap_or(0);
136    u64::try_from(value)
137        .ok()
138        .filter(|value| *value <= max)
139        .ok_or_else(|| {
140            Error::new(
141                ErrorCode::InvalidArgument,
142                format!("{name}: expected an integer from 0 to {max}, got {value}"),
143            )
144        })
145}
146
147/// An environment fallback the registry cannot express: a value is legal on
148/// argv, in the environment, or — where the command can still run without one —
149/// nowhere at all.
150fn from_env(value: Option<String>, variable: &str) -> Option<String> {
151    value.or_else(|| {
152        std::env::var(variable)
153            .ok()
154            .filter(|value| !value.is_empty())
155    })
156}
157
158fn endpoint(invocation: &ResolvedInvocation) -> Option<String> {
159    from_env(opt_string(invocation, "endpoint_url"), ENDPOINT_ENV)
160}
161
162/// `--token-secret` names where the token is, so an environment variable is one
163/// source among several rather than the only alternative to typing the secret.
164/// `AFHTTP_TOKEN_SECRET` stays a fallback and holds the value itself.
165fn token(invocation: &ResolvedInvocation) -> Result<Option<ValueSource>, Error> {
166    match opt_string(invocation, "token_secret") {
167        Some(raw) => token_source::parse(&raw).map(Some),
168        None => Ok(std::env::var(TOKEN_ENV)
169            .ok()
170            .filter(|value| !value.is_empty())
171            .map(ValueSource::Literal)),
172    }
173}
174
175/// The host a client command talks to. Neither half is required on argv: an
176/// absent endpoint means the standard local container, discovered at connect
177/// time, and an absent token means whatever that host's own token is.
178fn connection(invocation: &ResolvedInvocation) -> Result<Connection, Error> {
179    Ok(Connection::new(endpoint(invocation), token(invocation)?))
180}
181
182fn browser(invocation: &ResolvedInvocation) -> BrowserChoice {
183    opt_string(invocation, "browser")
184        .and_then(|value| value.parse().ok())
185        .unwrap_or_default()
186}
187
188fn render(invocation: &ResolvedInvocation) -> RenderMode {
189    match opt_string(invocation, "render").as_deref() {
190        Some("none") => RenderMode::None,
191        Some("always") => RenderMode::Always,
192        _ => RenderMode::Auto,
193    }
194}
195
196fn network_bodies(invocation: &ResolvedInvocation) -> NetworkBodies {
197    match opt_string(invocation, "network_bodies").as_deref() {
198        Some("xhr") => NetworkBodies::Xhr,
199        Some("all") => NetworkBodies::All,
200        _ => NetworkBodies::Off,
201    }
202}
203
204fn takeover_provider(invocation: &ResolvedInvocation) -> Takeover {
205    match opt_string(invocation, "takeover_provider").as_deref() {
206        Some("kasmvnc") => Takeover::On {
207            provider: TakeoverProviderKind::KasmVnc,
208        },
209        _ => Takeover::Off,
210    }
211}
212
213fn skill_target(invocation: &ResolvedInvocation) -> cmd::skill::TargetArgs {
214    cmd::skill::TargetArgs {
215        agent: opt_string(invocation, "agent").unwrap_or_else(|| "all".to_string()),
216        scope: opt_string(invocation, "scope").unwrap_or_else(|| "personal".to_string()),
217        skills_dir: opt_string(invocation, "skills_dir"),
218    }
219}
220
221fn profile_info_args(invocation: &ResolvedInvocation) -> cmd::profile::InfoArgs {
222    cmd::profile::InfoArgs {
223        name: string(invocation, "name"),
224        backend: opt_string(invocation, "backend"),
225        profile_root: opt_path(invocation, "profile_root"),
226    }
227}
228
229fn container_common(invocation: &ResolvedInvocation) -> Result<cmd::container::CommonArgs, Error> {
230    Ok(cmd::container::CommonArgs {
231        runtime: opt_string(invocation, "runtime")
232            .map(|value| cmd::container::runtime_from_str(&value))
233            .transpose()?,
234        name: string(invocation, "name"),
235    })
236}
237
238fn container_port(invocation: &ResolvedInvocation) -> Result<u16, Error> {
239    let port = unsigned(invocation, "port", "--port", u64::from(u16::MAX))?;
240    Ok(port as u16)
241}
242
243// ── actions ─────────────────────────────────────────────────────────────────
244
245fn fetch(invocation: &ResolvedInvocation) -> Projected {
246    Ok(Command::Fetch(Box::new(cmd::fetch::Args {
247        url: string(invocation, "url"),
248        connection: connection(invocation)?,
249        browser: browser(invocation),
250        browser_bin: opt_path(invocation, "browser_bin"),
251        render: render(invocation),
252        tab: opt_string(invocation, "tab").unwrap_or_else(|| "new".to_string()),
253        takeover: flag(invocation, "takeover"),
254        profile: opt_string(invocation, "profile"),
255        wait: opt_string(invocation, "wait").unwrap_or_else(|| "auto".to_string()),
256        headers: strings(invocation, "header"),
257        cookies: strings(invocation, "cookie"),
258        user_agent: opt_string(invocation, "user_agent"),
259        evaluate_after_wait: strings(invocation, "evaluate_after_wait"),
260        want: strings(invocation, "want"),
261        method: opt_string(invocation, "method").unwrap_or_else(|| "GET".to_string()),
262        data: opt_string(invocation, "data"),
263        form: strings(invocation, "form"),
264        network_bodies: network_bodies(invocation),
265        network_body_max_bytes: unsigned(
266            invocation,
267            "network_body_max_bytes",
268            "--network-body-max-bytes",
269            u64::MAX,
270        )?,
271        readiness_idle_ms: unsigned(
272            invocation,
273            "readiness_idle_ms",
274            "--readiness-idle-ms",
275            u64::MAX,
276        )?,
277        readiness_stable_ms: unsigned(
278            invocation,
279            "readiness_stable_ms",
280            "--readiness-stable-ms",
281            u64::MAX,
282        )?,
283        readiness_min_text_bytes: unsigned(
284            invocation,
285            "readiness_min_text_bytes",
286            "--readiness-min-text-bytes",
287            u64::MAX,
288        )?,
289        no_network_redact: flag(invocation, "no_network_redact"),
290        out: opt_path(invocation, "out"),
291        cookie_jar: opt_path(invocation, "cookie_jar"),
292        no_cookie_jar: flag(invocation, "no_cookie_jar"),
293        observe_main_wait_ms: unsigned(
294            invocation,
295            "observe_main_wait_ms",
296            "--observe-main-wait-ms",
297            u64::MAX,
298        )?,
299        max_response_bytes: unsigned(
300            invocation,
301            "max_response_bytes",
302            "--max-response-bytes",
303            u64::MAX,
304        )?,
305        retry: unsigned(invocation, "retry", "--retry", u64::from(u32::MAX))? as u32,
306        backoff_ms: unsigned(invocation, "backoff_ms", "--backoff-ms", u64::MAX)?,
307        proxy: opt_string(invocation, "proxy_url"),
308        ca_cert: opt_path(invocation, "ca_cert"),
309        tls_insecure: flag(invocation, "tls_insecure"),
310        timeout_ms: unsigned(invocation, "timeout_ms", "--timeout-ms", u64::MAX)?,
311        capture_ws: flag(invocation, "capture_ws"),
312        capture_sse: flag(invocation, "capture_sse"),
313    })))
314}
315
316fn host(invocation: &ResolvedInvocation) -> Projected {
317    Ok(Command::Host(cmd::host::Args {
318        listen: string(invocation, "listen"),
319        profile: opt_string(invocation, "profile").unwrap_or_else(|| "-".to_string()),
320        display: match opt_string(invocation, "display").as_deref() {
321            Some("headful") => Some(DisplayMode::Headful),
322            Some("headless") => Some(DisplayMode::Headless),
323            _ => None,
324        },
325        takeover: takeover_provider(invocation),
326        takeover_quality_percent: unsigned(
327            invocation,
328            "takeover_quality_percent",
329            "--takeover-quality-percent",
330            100,
331        )? as u8,
332        browser: browser(invocation),
333        browser_bin: opt_path(invocation, "browser_bin"),
334        // The token this host will require of its callers, not one to go find:
335        // no environment fallback, but the same sources, so the secret can come
336        // from a config file instead of the process environment.
337        token: opt_string(invocation, "token_secret")
338            .map(|raw| token_source::parse(&raw))
339            .transpose()?,
340        no_health: flag(invocation, "no_health"),
341        health_public: match opt_string(invocation, "health_public").as_deref() {
342            Some("minimal") => HealthPublic::Minimal,
343            _ => HealthPublic::Off,
344        },
345        engine_envs: strings(invocation, "engine_env"),
346        browser_args: strings(invocation, "browser_arg"),
347        proxy: opt_string(invocation, "proxy_url"),
348        recent_requests_cap: unsigned(
349            invocation,
350            "recent_requests_cap",
351            "--recent-requests-cap",
352            u64::try_from(usize::MAX).unwrap_or(u64::MAX),
353        )? as usize,
354    }))
355}
356
357fn upload(invocation: &ResolvedInvocation) -> Projected {
358    Ok(Command::Upload(cmd::upload::Args {
359        connection: connection(invocation)?,
360        tab: string(invocation, "tab"),
361        selector: string(invocation, "selector"),
362        file: PathBuf::from(string(invocation, "file")),
363    }))
364}
365
366fn cdp(invocation: &ResolvedInvocation) -> Projected {
367    Ok(Command::Cdp(cmd::cdp::Args {
368        method: string(invocation, "method"),
369        connection: connection(invocation)?,
370        tab: string(invocation, "tab"),
371        params: opt_string(invocation, "params"),
372        wait: opt_string(invocation, "wait_event"),
373    }))
374}
375
376fn panel(invocation: &ResolvedInvocation) -> Projected {
377    Ok(Command::Panel(cmd::panel::Args {
378        connection: connection(invocation)?,
379    }))
380}
381
382fn health(invocation: &ResolvedInvocation) -> Projected {
383    Ok(Command::Health(cmd::health::Args {
384        connection: connection(invocation)?,
385    }))
386}
387
388fn capabilities(invocation: &ResolvedInvocation) -> Projected {
389    Ok(Command::Capabilities(cmd::capabilities::Args {
390        connection: connection(invocation)?,
391    }))
392}
393
394fn profile_command(sub: cmd::profile::ProfileSub) -> Projected {
395    Ok(Command::Profile(cmd::profile::Args { sub }))
396}
397
398fn profile_list(invocation: &ResolvedInvocation) -> Projected {
399    profile_command(cmd::profile::ProfileSub::List(cmd::profile::ListArgs {
400        profile_root: opt_path(invocation, "profile_root"),
401    }))
402}
403
404fn profile_info(invocation: &ResolvedInvocation) -> Projected {
405    profile_command(cmd::profile::ProfileSub::Info(profile_info_args(
406        invocation,
407    )))
408}
409
410fn profile_lock_status(invocation: &ResolvedInvocation) -> Projected {
411    profile_command(cmd::profile::ProfileSub::LockStatus(profile_info_args(
412        invocation,
413    )))
414}
415
416fn profile_downloads(invocation: &ResolvedInvocation) -> Projected {
417    profile_command(cmd::profile::ProfileSub::Downloads(profile_info_args(
418        invocation,
419    )))
420}
421
422fn profile_cookies(invocation: &ResolvedInvocation) -> Projected {
423    profile_command(cmd::profile::ProfileSub::Cookies(profile_info_args(
424        invocation,
425    )))
426}
427
428fn profile_delete(invocation: &ResolvedInvocation) -> Projected {
429    profile_command(cmd::profile::ProfileSub::Delete(cmd::profile::DeleteArgs {
430        name: string(invocation, "name"),
431        backend: opt_string(invocation, "backend"),
432        confirm: string(invocation, "confirm"),
433        profile_root: opt_path(invocation, "profile_root"),
434    }))
435}
436
437fn profile_prune(invocation: &ResolvedInvocation) -> Projected {
438    profile_command(cmd::profile::ProfileSub::Prune(cmd::profile::PruneArgs {
439        older_than: string(invocation, "older_than"),
440        dry_run: flag(invocation, "dry_run"),
441        profile_root: opt_path(invocation, "profile_root"),
442    }))
443}
444
445fn tabs_list(invocation: &ResolvedInvocation) -> Projected {
446    Ok(Command::Tabs(cmd::tabs::Args {
447        sub: cmd::tabs::TabsSub::List(cmd::tabs::EndpointArgs {
448            connection: connection(invocation)?,
449        }),
450    }))
451}
452
453fn tabs_close(invocation: &ResolvedInvocation) -> Projected {
454    Ok(Command::Tabs(cmd::tabs::Args {
455        sub: cmd::tabs::TabsSub::Close(cmd::tabs::CloseArgs {
456            tab: string(invocation, "tab"),
457            connection: connection(invocation)?,
458        }),
459    }))
460}
461
462/// Both `ui takeover` shapes, told apart by the one argument only the second
463/// declares. An already-minted URL carries its own credential, so that shape
464/// never reaches for a host — no environment fallback, and no discovery, which
465/// would otherwise make a stray variable or a stray container look load-bearing.
466fn ui_takeover(invocation: &ResolvedInvocation) -> Projected {
467    let takeover = match opt_string(invocation, "takeover_url_secret") {
468        Some(takeover_url_secret) => cmd::ui::TakeoverArgs::Open {
469            takeover_url_secret,
470        },
471        None => cmd::ui::TakeoverArgs::Mint {
472            connection: connection(invocation)?,
473        },
474    };
475    Ok(Command::Ui(cmd::ui::Args {
476        sub: cmd::ui::UiSub::Takeover(takeover),
477        delivery: if flag(invocation, "takeover_no_window") {
478            cmd::ui::Delivery::Listed
479        } else {
480            cmd::ui::Delivery::Window
481        },
482    }))
483}
484
485fn skill_status(invocation: &ResolvedInvocation) -> Projected {
486    Ok(Command::Skill(cmd::skill::Args {
487        sub: cmd::skill::SkillSub::Status(skill_target(invocation)),
488    }))
489}
490
491fn skill_install(invocation: &ResolvedInvocation) -> Projected {
492    Ok(Command::Skill(cmd::skill::Args {
493        sub: cmd::skill::SkillSub::Install(cmd::skill::WriteArgs {
494            target: skill_target(invocation),
495            force: flag(invocation, "force"),
496        }),
497    }))
498}
499
500fn skill_uninstall(invocation: &ResolvedInvocation) -> Projected {
501    Ok(Command::Skill(cmd::skill::Args {
502        sub: cmd::skill::SkillSub::Uninstall(cmd::skill::WriteArgs {
503            target: skill_target(invocation),
504            force: flag(invocation, "force"),
505        }),
506    }))
507}
508
509fn container_install(invocation: &ResolvedInvocation) -> Projected {
510    Ok(Command::Container(cmd::container::Args {
511        sub: cmd::container::ContainerSub::Install(cmd::container::InstallArgs {
512            common: container_common(invocation)?,
513            port: container_port(invocation)?,
514            profile: opt_string(invocation, "profile"),
515            shm_size: opt_string(invocation, "shm_size"),
516            takeover_provider: takeover_provider(invocation),
517            with: strings(invocation, "with"),
518            rebuild: flag(invocation, "rebuild"),
519            from_source: flag(invocation, "from_source"),
520            context: opt_string(invocation, "context"),
521            host_args: strings(invocation, "host_args"),
522            reveal_token_secret: flag(invocation, "reveal_token_secret"),
523        }),
524    }))
525}
526
527fn container_uninstall(invocation: &ResolvedInvocation) -> Projected {
528    Ok(Command::Container(cmd::container::Args {
529        sub: cmd::container::ContainerSub::Uninstall(cmd::container::UninstallArgs {
530            common: container_common(invocation)?,
531            purge: flag(invocation, "purge"),
532        }),
533    }))
534}
535
536fn container_status(invocation: &ResolvedInvocation) -> Projected {
537    Ok(Command::Container(cmd::container::Args {
538        sub: cmd::container::ContainerSub::Status(cmd::container::StatusArgs {
539            common: container_common(invocation)?,
540            port: container_port(invocation)?,
541            reveal_token_secret: flag(invocation, "reveal_token_secret"),
542        }),
543    }))
544}
545
546fn container_logs(invocation: &ResolvedInvocation) -> Projected {
547    Ok(Command::Container(cmd::container::Args {
548        sub: cmd::container::ContainerSub::Logs(cmd::container::LogsArgs {
549            common: container_common(invocation)?,
550            follow: flag(invocation, "follow"),
551            raw: flag(invocation, "raw"),
552        }),
553    }))
554}
555
556#[cfg(test)]
557mod tests {
558    use agent_first_data::BoundOutcome;
559
560    use super::*;
561    use crate::cli::spec::cli_spec;
562
563    fn project(argv: &[&str]) -> Projected {
564        let cli = match cli_spec() {
565            Ok(cli) => cli,
566            Err(error) => panic!("registry must build: {error}"),
567        };
568        let app = match cli.bind_actions(handlers()) {
569            Ok(app) => app,
570            Err(error) => panic!("handlers must cover every action: {error}"),
571        };
572        match app.resolve_from(argv.to_vec()) {
573            Ok(BoundOutcome::Run(invocation)) => invocation.run(),
574            Ok(_) => panic!("{argv:?} did not resolve to a run"),
575            Err(error) => panic!("{argv:?} failed to resolve: {}", error.message),
576        }
577    }
578
579    #[test]
580    fn every_action_has_exactly_one_handler() {
581        let cli = cli_spec().expect("registry must build");
582        cli.bind_actions(handlers())
583            .expect("handlers must cover every action exactly once");
584    }
585
586    /// Every declared shape, run through its own handler with strict reads: a
587    /// handler that asks for an argument id its shape cannot supply names
588    /// itself here rather than degrading to an empty string in production.
589    ///
590    /// Safe to run because these handlers only project — nothing here opens a
591    /// socket, launches a host, or touches a profile directory.
592    #[test]
593    fn every_combination_reads_only_ids_its_shape_declares() {
594        let cli = cli_spec().expect("registry must build");
595        let app = cli
596            .bind_actions(handlers())
597            .expect("handlers must cover every action exactly once");
598        app.call_every_combination();
599    }
600
601    #[test]
602    fn an_environment_fallback_never_overrides_argv() {
603        const ABSENT: &str = "AFHTTP_TEST_ABSENT_ENDPOINT_URL";
604        assert_eq!(from_env(None, ABSENT), None);
605        assert_eq!(
606            from_env(Some("ws://argv".to_string()), ABSENT),
607            Some("ws://argv".to_string())
608        );
609    }
610
611    #[test]
612    fn an_explicit_endpoint_reaches_the_command() {
613        let projected = project(&["afhttp", "health", "--endpoint-url", "ws://127.0.0.1:9222"]);
614        let Ok(Command::Health(args)) = projected else {
615            panic!("health must project to a health command");
616        };
617        assert_eq!(
618            args.connection.endpoint.as_deref(),
619            Some("ws://127.0.0.1:9222")
620        );
621        assert!(args.connection.is_explicit());
622    }
623
624    /// A command that needs a host no longer refuses argv that omits one: the
625    /// endpoint is carried absent and answered by discovery at connect time,
626    /// which is what makes the local case a bare command.
627    #[test]
628    fn a_missing_endpoint_is_left_for_discovery_rather_than_rejected() {
629        let Ok(Command::Health(args)) = project(&["afhttp", "health"]) else {
630            panic!("health must project without an endpoint");
631        };
632        assert!(!args.connection.is_explicit());
633    }
634
635    /// The registry now owns the source grammar, so a malformed one is refused
636    /// while argv is resolved — beside the other usage errors, at exit 2 — and
637    /// a legal one reaches projection classified but unread. Nothing on this
638    /// path opens a file or spawns a container runtime.
639    #[test]
640    fn a_token_source_is_classified_but_not_read() {
641        let Ok(Command::Cdp(args)) = project(&[
642            "afhttp",
643            "cdp",
644            "Page.enable",
645            "--tab",
646            "T1",
647            "--token-secret",
648            "container:afhttp-host",
649        ]) else {
650            panic!("cdp must project with a container token source");
651        };
652        assert_eq!(
653            args.connection.token,
654            Some(ValueSource::Host {
655                scheme: "container".to_string(),
656                value: "afhttp-host".to_string(),
657            })
658        );
659
660        let cli = cli_spec().expect("registry must build");
661        let app = cli
662            .bind_actions(handlers())
663            .expect("handlers must cover every action");
664        for raw in [
665            // A file source with no dot path…
666            "file:/etc/afhttp/hosts.json",
667            // …and a scheme this argument does not accept.
668            "prompt",
669        ] {
670            let error = app
671                .resolve_from(vec![
672                    "afhttp",
673                    "cdp",
674                    "Page.enable",
675                    "--tab",
676                    "T1",
677                    "--token-secret",
678                    raw,
679                ])
680                .err()
681                .unwrap_or_else(|| panic!("{raw} must be refused"));
682            assert_eq!(
683                error.rule,
684                agent_first_data::CliErrorRule::InvalidArgumentValue,
685                "{raw}"
686            );
687            assert_eq!(error.exit_code(), 2, "{raw}");
688        }
689    }
690
691    #[test]
692    fn quality_percent_is_bounded_where_the_registry_cannot_type_it() {
693        let error = project(&[
694            "afhttp",
695            "host",
696            "--listen",
697            "tcp:127.0.0.1:9222",
698            "--takeover-provider",
699            "kasmvnc",
700            "--takeover-quality-percent",
701            "101",
702        ])
703        .err()
704        .expect("out-of-range quality");
705        assert_eq!(error.error_code, ErrorCode::InvalidArgument);
706    }
707}