Skip to main content

gate4agent_runtime_native/
vendor_contract.rs

1use std::collections::{BTreeSet, VecDeque};
2use std::fs::OpenOptions;
3use std::io::Read;
4use std::path::{Path, PathBuf};
5use std::process::{Child, Command, Stdio};
6use std::thread;
7use std::time::{Duration, Instant, UNIX_EPOCH};
8
9const MAX_VERSION_OUTPUT_BYTES: usize = 16 * 1024;
10const MAX_LAUNCHER_BYTES: u64 = 512 * 1024 * 1024;
11const VERSION_PROBE_CACHE_CAPACITY: usize = 16;
12const VERSION_PROBE_POLL_INTERVAL: Duration = Duration::from_millis(5);
13const VERSION_PROBE_MAX_CLEANUP_RESERVE: Duration = Duration::from_millis(100);
14const CLAUDE_VERSION_ARGV: &[&str] = &["--version"];
15const CODEX_VERSION_ARGV: &[&str] = &["--version"];
16const KIMI_VERSION_ARGV: &[&str] = &["--version"];
17
18pub const CLAUDE_WINDOWS_X86_64_2_1_223_CONTRACT_ID: &str =
19    "claude.windows-x86_64.2.1.223";
20pub const CLAUDE_WINDOWS_X86_64_2_1_224_CONTRACT_ID: &str =
21    "claude.windows-x86_64.2.1.224";
22
23const RAW_PASSTHROUGH: VendorCapabilityVerdict = VendorCapabilityVerdict::RawPassthrough;
24
25#[derive(Clone, Copy, Debug, Eq, PartialEq)]
26pub enum VendorCliFamily {
27    Claude,
28    Codex,
29    Kimi,
30    Unknown,
31}
32
33impl VendorCliFamily {
34    pub fn from_agent_id(agent_id: &str) -> Self {
35        if agent_id.eq_ignore_ascii_case("claude") {
36            Self::Claude
37        } else if agent_id.eq_ignore_ascii_case("codex") {
38            Self::Codex
39        } else if agent_id.eq_ignore_ascii_case("kimi") {
40            Self::Kimi
41        } else {
42            Self::Unknown
43        }
44    }
45
46    pub const fn label(self) -> &'static str {
47        match self {
48            Self::Claude => "claude",
49            Self::Codex => "codex",
50            Self::Kimi => "kimi",
51            Self::Unknown => "unknown",
52        }
53    }
54}
55
56#[derive(Clone, Copy, Debug, Eq, PartialEq)]
57pub enum VendorPlatform {
58    WindowsX86_64,
59    WindowsAarch64,
60    LinuxX86_64,
61    LinuxAarch64,
62    MacosX86_64,
63    MacosAarch64,
64    Other,
65}
66
67impl VendorPlatform {
68    pub fn current() -> Self {
69        match (std::env::consts::OS, std::env::consts::ARCH) {
70            ("windows", "x86_64") => Self::WindowsX86_64,
71            ("windows", "aarch64") => Self::WindowsAarch64,
72            ("linux", "x86_64") => Self::LinuxX86_64,
73            ("linux", "aarch64") => Self::LinuxAarch64,
74            ("macos", "x86_64") => Self::MacosX86_64,
75            ("macos", "aarch64") => Self::MacosAarch64,
76            _ => Self::Other,
77        }
78    }
79
80    pub const fn label(self) -> &'static str {
81        match self {
82            Self::WindowsX86_64 => "windows-x86_64",
83            Self::WindowsAarch64 => "windows-aarch64",
84            Self::LinuxX86_64 => "linux-x86_64",
85            Self::LinuxAarch64 => "linux-aarch64",
86            Self::MacosX86_64 => "macos-x86_64",
87            Self::MacosAarch64 => "macos-aarch64",
88            Self::Other => "other",
89        }
90    }
91}
92
93#[derive(Clone, Copy, Debug, Eq, PartialEq)]
94pub enum VendorRuntimeMode {
95    RawPassthrough,
96    VerifiedSemantic,
97}
98
99impl VendorRuntimeMode {
100    pub const fn label(self) -> &'static str {
101        match self {
102            Self::RawPassthrough => "raw_passthrough",
103            Self::VerifiedSemantic => "verified_semantic",
104        }
105    }
106}
107
108#[derive(Clone, Copy, Debug, Eq, PartialEq)]
109pub enum VendorVersionStatus {
110    Normalized,
111    Missing,
112    Ambiguous,
113    Unparseable,
114    Unavailable,
115}
116
117#[derive(Clone, Copy, Debug, Eq, PartialEq)]
118pub enum VendorFallbackReason {
119    UnsupportedVendor,
120    NoVerifiedProfile,
121    UnsupportedPlatform,
122    LegacyVersion,
123    FutureVersion,
124    UnlistedVersion,
125    MissingVersion,
126    AmbiguousVersion,
127    UnparseableVersion,
128    InvalidLauncher,
129    LauncherUnavailable,
130    LauncherChanged,
131    ProbeDeadlineExceeded,
132    ProbeSpawnFailed,
133    ProbeNonzero,
134    ProbeOutputOverflow,
135}
136
137#[derive(Clone, Copy, Debug, Eq, PartialEq)]
138pub enum VendorCapabilityUnavailableReason {
139    NoVerifiedContract(VendorFallbackReason),
140    NotVerifiedByContract,
141}
142
143#[derive(Clone, Copy, Debug, Eq, PartialEq)]
144pub enum VendorCapabilityVerdict {
145    /// The operation is admitted through the version-independent PTY path.
146    RawPassthrough,
147    /// The version-specific semantic behavior has an exact verified contract.
148    Verified { capability_contract: &'static str },
149    /// The operation must not use semantic parsing or structured control.
150    Unavailable {
151        reason: VendorCapabilityUnavailableReason,
152    },
153}
154
155impl VendorCapabilityVerdict {
156    pub const fn is_admitted(self) -> bool {
157        !matches!(self, Self::Unavailable { .. })
158    }
159
160    pub const fn is_verified(self) -> bool {
161        matches!(self, Self::Verified { .. })
162    }
163
164    pub const fn unavailable_reason(self) -> Option<VendorCapabilityUnavailableReason> {
165        match self {
166            Self::Unavailable { reason } => Some(reason),
167            Self::RawPassthrough | Self::Verified { .. } => None,
168        }
169    }
170}
171
172#[derive(Clone, Copy, Debug, Eq, PartialEq)]
173pub struct VendorCapabilitySet {
174    pub raw_pty_spawn: VendorCapabilityVerdict,
175    pub terminal_screen: VendorCapabilityVerdict,
176    pub terminal_resize: VendorCapabilityVerdict,
177    pub terminal_interrupt: VendorCapabilityVerdict,
178    pub terminal_stop: VendorCapabilityVerdict,
179    pub semantic_readiness: VendorCapabilityVerdict,
180    pub provider_session_identity: VendorCapabilityVerdict,
181    pub structured_prompt: VendorCapabilityVerdict,
182    pub semantic_resume: VendorCapabilityVerdict,
183}
184
185impl VendorCapabilitySet {
186    pub const fn admits_raw_pty_lifecycle(self) -> bool {
187        self.raw_pty_spawn.is_admitted()
188            && self.terminal_screen.is_admitted()
189            && self.terminal_resize.is_admitted()
190            && self.terminal_interrupt.is_admitted()
191            && self.terminal_stop.is_admitted()
192    }
193}
194
195#[derive(Clone, Debug, Eq, PartialEq)]
196pub struct VendorContractResolution {
197    vendor: VendorCliFamily,
198    platform: VendorPlatform,
199    mode: VendorRuntimeMode,
200    version_status: VendorVersionStatus,
201    normalized_version: Option<String>,
202    contract_id: Option<&'static str>,
203    fallback_reason: Option<VendorFallbackReason>,
204    capabilities: VendorCapabilitySet,
205}
206
207impl VendorContractResolution {
208    pub const fn vendor(&self) -> VendorCliFamily {
209        self.vendor
210    }
211
212    pub const fn platform(&self) -> VendorPlatform {
213        self.platform
214    }
215
216    pub const fn mode(&self) -> VendorRuntimeMode {
217        self.mode
218    }
219
220    pub const fn version_status(&self) -> VendorVersionStatus {
221        self.version_status
222    }
223
224    pub fn normalized_version(&self) -> Option<&str> {
225        self.normalized_version.as_deref()
226    }
227
228    pub const fn contract_id(&self) -> Option<&'static str> {
229        self.contract_id
230    }
231
232    pub const fn fallback_reason(&self) -> Option<VendorFallbackReason> {
233        self.fallback_reason
234    }
235
236    pub const fn capabilities(&self) -> VendorCapabilitySet {
237        self.capabilities
238    }
239
240    pub const fn admits_raw_pty_lifecycle(&self) -> bool {
241        self.capabilities.admits_raw_pty_lifecycle()
242    }
243}
244
245#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
246pub struct VendorLauncherIdentity {
247    path_fingerprint: u64,
248    bytes: u64,
249    modified_unix_nanos: Option<u128>,
250    content_fingerprint: u64,
251}
252
253impl VendorLauncherIdentity {
254    pub const fn path_fingerprint(self) -> u64 {
255        self.path_fingerprint
256    }
257
258    pub const fn bytes(self) -> u64 {
259        self.bytes
260    }
261
262    pub const fn modified_unix_nanos(self) -> Option<u128> {
263        self.modified_unix_nanos
264    }
265
266    pub const fn content_fingerprint(self) -> u64 {
267        self.content_fingerprint
268    }
269}
270
271#[derive(Clone, Copy, Debug, Eq, PartialEq)]
272pub enum VendorVersionProbeStatus {
273    Resolved,
274    UnsupportedVendor,
275    InvalidLauncher,
276    LauncherUnavailable,
277    LauncherChanged,
278    DeadlineExceeded,
279    SpawnFailed,
280    NonzeroExit,
281    OutputOverflow,
282    MissingVersion,
283    AmbiguousVersion,
284    UnparseableVersion,
285}
286
287impl VendorVersionProbeStatus {
288    pub const fn is_resolved(self) -> bool {
289        matches!(self, Self::Resolved)
290    }
291}
292
293#[derive(Clone, Debug, Eq, PartialEq)]
294pub struct VendorVersionProbeResult {
295    status: VendorVersionProbeStatus,
296    resolution: VendorContractResolution,
297    launcher_identity: Option<VendorLauncherIdentity>,
298    cache_hit: bool,
299}
300
301impl VendorVersionProbeResult {
302    pub const fn status(&self) -> VendorVersionProbeStatus {
303        self.status
304    }
305
306    pub const fn resolution(&self) -> &VendorContractResolution {
307        &self.resolution
308    }
309
310    pub const fn launcher_identity(&self) -> Option<VendorLauncherIdentity> {
311        self.launcher_identity
312    }
313
314    pub const fn was_cached(&self) -> bool {
315        self.cache_hit
316    }
317}
318
319struct CachedVersionProbe {
320    vendor: VendorCliFamily,
321    platform: VendorPlatform,
322    launcher_identity: VendorLauncherIdentity,
323    status: VendorVersionProbeStatus,
324    resolution: VendorContractResolution,
325}
326
327/// Bounded host-local cache keyed by provider, platform, and exact launcher
328/// identity. A metadata or content identity change evicts the prior entry for
329/// the same canonical path fingerprint before the new result is inserted.
330pub struct VendorVersionProbeCache {
331    entries: VecDeque<CachedVersionProbe>,
332}
333
334impl Default for VendorVersionProbeCache {
335    fn default() -> Self {
336        Self {
337            entries: VecDeque::with_capacity(VERSION_PROBE_CACHE_CAPACITY),
338        }
339    }
340}
341
342impl VendorVersionProbeCache {
343    pub fn clear(&mut self) {
344        self.entries.clear();
345    }
346
347    pub fn len(&self) -> usize {
348        self.entries.len()
349    }
350
351    pub fn is_empty(&self) -> bool {
352        self.entries.is_empty()
353    }
354
355    pub fn probe(
356        &mut self,
357        agent_id: &str,
358        exact_launcher: &Path,
359        deadline: Instant,
360    ) -> VendorVersionProbeResult {
361        probe_installed_vendor_version(self, agent_id, exact_launcher, deadline)
362    }
363
364    fn lookup(
365        &mut self,
366        vendor: VendorCliFamily,
367        platform: VendorPlatform,
368        launcher_identity: VendorLauncherIdentity,
369    ) -> Option<VendorVersionProbeResult> {
370        let position = self.entries.iter().position(|entry| {
371            entry.vendor == vendor
372                && entry.platform == platform
373                && entry.launcher_identity == launcher_identity
374        })?;
375        let entry = self
376            .entries
377            .remove(position)
378            .expect("located vendor version cache entry must remain present");
379        let result = VendorVersionProbeResult {
380            status: entry.status,
381            resolution: entry.resolution.clone(),
382            launcher_identity: Some(entry.launcher_identity),
383            cache_hit: true,
384        };
385        self.entries.push_back(entry);
386        Some(result)
387    }
388
389    fn insert(
390        &mut self,
391        vendor: VendorCliFamily,
392        platform: VendorPlatform,
393        launcher_identity: VendorLauncherIdentity,
394        status: VendorVersionProbeStatus,
395        resolution: VendorContractResolution,
396    ) {
397        self.entries.retain(|entry| {
398            entry.vendor != vendor
399                || entry.platform != platform
400                || entry.launcher_identity.path_fingerprint
401                    != launcher_identity.path_fingerprint
402        });
403        if self.entries.len() == VERSION_PROBE_CACHE_CAPACITY {
404            self.entries.pop_front();
405        }
406        self.entries.push_back(CachedVersionProbe {
407            vendor,
408            platform,
409            launcher_identity,
410            status,
411            resolution,
412        });
413    }
414}
415
416#[derive(Clone, Copy)]
417struct VerifiedProfile {
418    contract_id: &'static str,
419    vendor: VendorCliFamily,
420    platform: VendorPlatform,
421    version: VersionTriple,
422    semantic_readiness_contract: Option<&'static str>,
423    provider_session_identity_contract: Option<&'static str>,
424    structured_prompt_contract: Option<&'static str>,
425    semantic_resume_contract: Option<&'static str>,
426}
427
428const VERIFIED_PROFILES: &[VerifiedProfile] = &[
429    VerifiedProfile {
430        contract_id: CLAUDE_WINDOWS_X86_64_2_1_223_CONTRACT_ID,
431        vendor: VendorCliFamily::Claude,
432        platform: VendorPlatform::WindowsX86_64,
433        version: VersionTriple::new(2, 1, 223),
434        semantic_readiness_contract: Some("readiness.bracketed_paste"),
435        provider_session_identity_contract: None,
436        structured_prompt_contract: Some("pty.prompt_round_trip"),
437        semantic_resume_contract: None,
438    },
439    VerifiedProfile {
440        contract_id: CLAUDE_WINDOWS_X86_64_2_1_224_CONTRACT_ID,
441        vendor: VendorCliFamily::Claude,
442        platform: VendorPlatform::WindowsX86_64,
443        version: VersionTriple::new(2, 1, 224),
444        semantic_readiness_contract: Some("readiness.win32_input_focus_cursor"),
445        provider_session_identity_contract: None,
446        structured_prompt_contract: Some("pty.prompt_round_trip"),
447        semantic_resume_contract: None,
448    },
449];
450
451struct ResolvedLauncher {
452    path: PathBuf,
453    identity: VendorLauncherIdentity,
454}
455
456#[derive(Clone, Copy)]
457enum LauncherResolveError {
458    Invalid,
459    Unavailable,
460    DeadlineExceeded,
461}
462
463struct CapturedProbeOutput {
464    bytes: Vec<u8>,
465    overflowed: bool,
466}
467
468enum VersionChildOutcome {
469    Completed {
470        success: bool,
471        stdout: CapturedProbeOutput,
472        stderr: CapturedProbeOutput,
473    },
474    DeadlineExceeded,
475    SpawnFailed,
476}
477
478/// Probes one exact, absolute launcher without inheriting the parent process
479/// environment. The returned value contains no child output, environment
480/// values, launcher path, or command line. Windows accepts an exact `.exe`
481/// only; command-script wrappers degrade to raw passthrough instead of adding
482/// a shell interpreter to this probe path. The deadline covers launcher
483/// fingerprinting, child execution, capture, and the post-execution identity
484/// check.
485pub fn probe_installed_vendor_version(
486    cache: &mut VendorVersionProbeCache,
487    agent_id: &str,
488    exact_launcher: &Path,
489    deadline: Instant,
490) -> VendorVersionProbeResult {
491    let vendor = VendorCliFamily::from_agent_id(agent_id);
492    let platform = VendorPlatform::current();
493    if vendor == VendorCliFamily::Unknown {
494        return unavailable_probe_result(
495            vendor,
496            platform,
497            VendorVersionProbeStatus::UnsupportedVendor,
498            VendorFallbackReason::UnsupportedVendor,
499            None,
500        );
501    }
502    if Instant::now() >= deadline {
503        return unavailable_probe_result(
504            vendor,
505            platform,
506            VendorVersionProbeStatus::DeadlineExceeded,
507            VendorFallbackReason::ProbeDeadlineExceeded,
508            None,
509        );
510    }
511    let launcher = match resolve_exact_launcher(exact_launcher, deadline) {
512        Ok(launcher) => launcher,
513        Err(LauncherResolveError::Invalid) => {
514            return unavailable_probe_result(
515                vendor,
516                platform,
517                VendorVersionProbeStatus::InvalidLauncher,
518                VendorFallbackReason::InvalidLauncher,
519                None,
520            );
521        }
522        Err(LauncherResolveError::Unavailable) => {
523            return unavailable_probe_result(
524                vendor,
525                platform,
526                VendorVersionProbeStatus::LauncherUnavailable,
527                VendorFallbackReason::LauncherUnavailable,
528                None,
529            );
530        }
531        Err(LauncherResolveError::DeadlineExceeded) => {
532            return unavailable_probe_result(
533                vendor,
534                platform,
535                VendorVersionProbeStatus::DeadlineExceeded,
536                VendorFallbackReason::ProbeDeadlineExceeded,
537                None,
538            );
539        }
540    };
541    if let Some(cached) = cache.lookup(vendor, platform, launcher.identity) {
542        return cached;
543    }
544    let Some(command) = provider_version_command(vendor, &launcher.path) else {
545        return unavailable_probe_result(
546            vendor,
547            platform,
548            VendorVersionProbeStatus::InvalidLauncher,
549            VendorFallbackReason::InvalidLauncher,
550            Some(launcher.identity),
551        );
552    };
553    let (stdout, stderr) = match run_version_child(command, deadline) {
554        VersionChildOutcome::Completed {
555            success: false, ..
556        } => {
557            return unavailable_probe_result(
558                vendor,
559                platform,
560                VendorVersionProbeStatus::NonzeroExit,
561                VendorFallbackReason::ProbeNonzero,
562                Some(launcher.identity),
563            );
564        }
565        VersionChildOutcome::Completed {
566            success: true,
567            stdout,
568            stderr,
569        } => (stdout, stderr),
570        VersionChildOutcome::DeadlineExceeded => {
571            return unavailable_probe_result(
572                vendor,
573                platform,
574                VendorVersionProbeStatus::DeadlineExceeded,
575                VendorFallbackReason::ProbeDeadlineExceeded,
576                Some(launcher.identity),
577            );
578        }
579        VersionChildOutcome::SpawnFailed => {
580            return unavailable_probe_result(
581                vendor,
582                platform,
583                VendorVersionProbeStatus::SpawnFailed,
584                VendorFallbackReason::ProbeSpawnFailed,
585                Some(launcher.identity),
586            );
587        }
588    };
589    let combined_output_bytes = stdout
590        .bytes
591        .len()
592        .checked_add(stderr.bytes.len())
593        .and_then(|bytes| bytes.checked_add(1));
594    if stdout.overflowed
595        || stderr.overflowed
596        || combined_output_bytes.is_none()
597        || combined_output_bytes.is_some_and(|bytes| bytes > MAX_VERSION_OUTPUT_BYTES)
598    {
599        return unavailable_probe_result(
600            vendor,
601            platform,
602            VendorVersionProbeStatus::OutputOverflow,
603            VendorFallbackReason::ProbeOutputOverflow,
604            Some(launcher.identity),
605        );
606    }
607    let after = match resolve_exact_launcher(exact_launcher, deadline) {
608        Ok(after) => after,
609        Err(LauncherResolveError::DeadlineExceeded) => {
610            return unavailable_probe_result(
611                vendor,
612                platform,
613                VendorVersionProbeStatus::DeadlineExceeded,
614                VendorFallbackReason::ProbeDeadlineExceeded,
615                Some(launcher.identity),
616            );
617        }
618        Err(LauncherResolveError::Invalid | LauncherResolveError::Unavailable) => {
619            return unavailable_probe_result(
620                vendor,
621                platform,
622                VendorVersionProbeStatus::LauncherChanged,
623                VendorFallbackReason::LauncherChanged,
624                Some(launcher.identity),
625            );
626        }
627    };
628    if after.identity != launcher.identity {
629        return unavailable_probe_result(
630            vendor,
631            platform,
632            VendorVersionProbeStatus::LauncherChanged,
633            VendorFallbackReason::LauncherChanged,
634            Some(after.identity),
635        );
636    }
637    let resolution = resolve_vendor_contract(agent_id, platform, &stdout.bytes, &stderr.bytes);
638    let status = probe_status_for_resolution(&resolution);
639    cache.insert(
640        vendor,
641        platform,
642        launcher.identity,
643        status,
644        resolution.clone(),
645    );
646    VendorVersionProbeResult {
647        status,
648        resolution,
649        launcher_identity: Some(launcher.identity),
650        cache_hit: false,
651    }
652}
653
654fn unavailable_probe_result(
655    vendor: VendorCliFamily,
656    platform: VendorPlatform,
657    status: VendorVersionProbeStatus,
658    fallback_reason: VendorFallbackReason,
659    launcher_identity: Option<VendorLauncherIdentity>,
660) -> VendorVersionProbeResult {
661    VendorVersionProbeResult {
662        status,
663        resolution: raw_resolution(
664            vendor,
665            platform,
666            VendorVersionStatus::Unavailable,
667            None,
668            fallback_reason,
669        ),
670        launcher_identity,
671        cache_hit: false,
672    }
673}
674
675fn probe_status_for_resolution(
676    resolution: &VendorContractResolution,
677) -> VendorVersionProbeStatus {
678    match resolution.version_status() {
679        VendorVersionStatus::Normalized => VendorVersionProbeStatus::Resolved,
680        VendorVersionStatus::Missing => VendorVersionProbeStatus::MissingVersion,
681        VendorVersionStatus::Ambiguous => VendorVersionProbeStatus::AmbiguousVersion,
682        VendorVersionStatus::Unparseable => VendorVersionProbeStatus::UnparseableVersion,
683        VendorVersionStatus::Unavailable => VendorVersionProbeStatus::LauncherUnavailable,
684    }
685}
686
687fn resolve_exact_launcher(
688    exact_launcher: &Path,
689    deadline: Instant,
690) -> Result<ResolvedLauncher, LauncherResolveError> {
691    if !exact_launcher.is_absolute() || Instant::now() >= deadline {
692        return if Instant::now() >= deadline {
693            Err(LauncherResolveError::DeadlineExceeded)
694        } else {
695            Err(LauncherResolveError::Invalid)
696        };
697    }
698    let canonical_path = exact_launcher
699        .canonicalize()
700        .map_err(|_| LauncherResolveError::Unavailable)?;
701    let mut launcher = OpenOptions::new()
702        .read(true)
703        .open(&canonical_path)
704        .map_err(|_| LauncherResolveError::Unavailable)?;
705    let before = launcher
706        .metadata()
707        .map_err(|_| LauncherResolveError::Unavailable)?;
708    if !before.is_file() || before.len() > MAX_LAUNCHER_BYTES {
709        return Err(LauncherResolveError::Invalid);
710    }
711    #[cfg(unix)]
712    {
713        use std::os::unix::fs::PermissionsExt;
714        if before.permissions().mode() & 0o111 == 0 {
715            return Err(LauncherResolveError::Invalid);
716        }
717    }
718    let modified = before.modified().ok();
719    let mut content_fingerprint = fnv_offset();
720    let mut bytes = 0_u64;
721    let mut buffer = [0_u8; 16 * 1024];
722    loop {
723        if Instant::now() >= deadline {
724            return Err(LauncherResolveError::DeadlineExceeded);
725        }
726        let read = launcher
727            .read(&mut buffer)
728            .map_err(|_| LauncherResolveError::Unavailable)?;
729        if read == 0 {
730            break;
731        }
732        bytes = bytes
733            .checked_add(read as u64)
734            .ok_or(LauncherResolveError::Invalid)?;
735        if bytes > MAX_LAUNCHER_BYTES {
736            return Err(LauncherResolveError::Invalid);
737        }
738        content_fingerprint = fnv_bytes(content_fingerprint, &buffer[..read]);
739    }
740    let after = launcher
741        .metadata()
742        .map_err(|_| LauncherResolveError::Unavailable)?;
743    if before.len() != after.len()
744        || bytes != before.len()
745        || modified != after.modified().ok()
746    {
747        return Err(LauncherResolveError::Unavailable);
748    }
749    let modified_unix_nanos = modified
750        .and_then(|value| value.duration_since(UNIX_EPOCH).ok())
751        .map(|value| value.as_nanos());
752    Ok(ResolvedLauncher {
753        identity: VendorLauncherIdentity {
754            path_fingerprint: fingerprint_path(&canonical_path),
755            bytes,
756            modified_unix_nanos,
757            content_fingerprint,
758        },
759        path: canonical_path,
760    })
761}
762
763fn provider_version_command(vendor: VendorCliFamily, launcher: &Path) -> Option<Command> {
764    let argv = match vendor {
765        VendorCliFamily::Claude => CLAUDE_VERSION_ARGV,
766        VendorCliFamily::Codex => CODEX_VERSION_ARGV,
767        VendorCliFamily::Kimi => KIMI_VERSION_ARGV,
768        VendorCliFamily::Unknown => return None,
769    };
770    #[cfg(windows)]
771    let mut command = {
772        let extension = launcher
773            .extension()
774            .and_then(|value| value.to_str())
775            .map(str::to_ascii_lowercase)?;
776        if extension != "exe" {
777            return None;
778        }
779        let mut command = Command::new(launcher);
780        command.args(argv);
781        command.env_clear();
782        command
783    };
784    #[cfg(not(windows))]
785    let mut command = {
786        let mut command = Command::new(launcher);
787        command.args(argv);
788        command.env_clear();
789        command
790    };
791    command
792        .stdin(Stdio::null())
793        .stdout(Stdio::piped())
794        .stderr(Stdio::piped());
795    #[cfg(windows)]
796    {
797        use std::os::windows::process::CommandExt;
798        const CREATE_NO_WINDOW: u32 = 0x0800_0000;
799        command.creation_flags(CREATE_NO_WINDOW);
800    }
801    Some(command)
802}
803
804fn run_version_child(mut command: Command, deadline: Instant) -> VersionChildOutcome {
805    if Instant::now() >= deadline {
806        return VersionChildOutcome::DeadlineExceeded;
807    }
808    let Ok(mut child) = command.spawn() else {
809        return VersionChildOutcome::SpawnFailed;
810    };
811    let stdout = child.stdout.take().map(spawn_output_reader);
812    let stderr = child.stderr.take().map(spawn_output_reader);
813    let execution_deadline = execution_deadline(deadline);
814    let status = loop {
815        match child.try_wait() {
816            Ok(Some(status)) => break status,
817            Ok(None) if Instant::now() < execution_deadline => {
818                sleep_until_poll(execution_deadline);
819            }
820            Ok(None) => {
821                terminate_child_before(&mut child, deadline);
822                return VersionChildOutcome::DeadlineExceeded;
823            }
824            Err(_) => {
825                terminate_child_before(&mut child, deadline);
826                return VersionChildOutcome::SpawnFailed;
827            }
828        }
829    };
830    let Some(stdout) = finish_output_reader(stdout, deadline) else {
831        return VersionChildOutcome::DeadlineExceeded;
832    };
833    let Some(stderr) = finish_output_reader(stderr, deadline) else {
834        return VersionChildOutcome::DeadlineExceeded;
835    };
836    VersionChildOutcome::Completed {
837        success: status.success(),
838        stdout,
839        stderr,
840    }
841}
842
843fn execution_deadline(deadline: Instant) -> Instant {
844    let now = Instant::now();
845    let remaining = deadline.saturating_duration_since(now);
846    let reserve = (remaining / 4).min(VERSION_PROBE_MAX_CLEANUP_RESERVE);
847    deadline.checked_sub(reserve).unwrap_or(now)
848}
849
850fn sleep_until_poll(deadline: Instant) {
851    let remaining = deadline.saturating_duration_since(Instant::now());
852    if !remaining.is_zero() {
853        thread::sleep(remaining.min(VERSION_PROBE_POLL_INTERVAL));
854    }
855}
856
857fn terminate_child_before(child: &mut Child, deadline: Instant) {
858    let _ = child.kill();
859    loop {
860        match child.try_wait() {
861            Ok(Some(_)) | Err(_) => return,
862            Ok(None) if Instant::now() < deadline => sleep_until_poll(deadline),
863            Ok(None) => return,
864        }
865    }
866}
867
868fn spawn_output_reader(reader: impl Read + Send + 'static) -> thread::JoinHandle<CapturedProbeOutput> {
869    thread::spawn(move || read_capped_output(reader))
870}
871
872fn read_capped_output(mut reader: impl Read) -> CapturedProbeOutput {
873    let mut bytes = Vec::with_capacity(MAX_VERSION_OUTPUT_BYTES.min(4 * 1024));
874    let mut overflowed = false;
875    let mut buffer = [0_u8; 4 * 1024];
876    loop {
877        let Ok(read) = reader.read(&mut buffer) else {
878            overflowed = true;
879            break;
880        };
881        if read == 0 {
882            break;
883        }
884        let remaining = MAX_VERSION_OUTPUT_BYTES.saturating_sub(bytes.len());
885        let retained = remaining.min(read);
886        bytes.extend_from_slice(&buffer[..retained]);
887        overflowed |= retained != read;
888    }
889    CapturedProbeOutput { bytes, overflowed }
890}
891
892fn finish_output_reader(
893    reader: Option<thread::JoinHandle<CapturedProbeOutput>>,
894    deadline: Instant,
895) -> Option<CapturedProbeOutput> {
896    let Some(reader) = reader else {
897        return Some(CapturedProbeOutput {
898            bytes: Vec::new(),
899            overflowed: false,
900        });
901    };
902    while !reader.is_finished() && Instant::now() < deadline {
903        sleep_until_poll(deadline);
904    }
905    if !reader.is_finished() {
906        return None;
907    }
908    reader.join().ok()
909}
910
911const fn fnv_offset() -> u64 {
912    0xcbf2_9ce4_8422_2325
913}
914
915fn fnv_bytes(mut value: u64, bytes: &[u8]) -> u64 {
916    for byte in bytes {
917        value ^= u64::from(*byte);
918        value = value.wrapping_mul(0x0000_0100_0000_01b3);
919    }
920    value
921}
922
923#[cfg(unix)]
924fn fingerprint_path(path: &Path) -> u64 {
925    use std::os::unix::ffi::OsStrExt;
926    fnv_bytes(fnv_offset(), path.as_os_str().as_bytes())
927}
928
929#[cfg(windows)]
930fn fingerprint_path(path: &Path) -> u64 {
931    use std::os::windows::ffi::OsStrExt;
932    path.as_os_str()
933        .encode_wide()
934        .fold(fnv_offset(), |value, word| {
935            fnv_bytes(value, &word.to_le_bytes())
936        })
937}
938
939#[cfg(not(any(unix, windows)))]
940fn fingerprint_path(path: &Path) -> u64 {
941    fnv_bytes(fnv_offset(), path.to_string_lossy().as_bytes())
942}
943
944/// Resolves a pure runtime contract from already captured vendor `--version`
945/// output. It never launches a process and never rejects the raw PTY lifecycle.
946pub fn resolve_vendor_contract(
947    agent_id: &str,
948    platform: VendorPlatform,
949    stdout: &[u8],
950    stderr: &[u8],
951) -> VendorContractResolution {
952    let vendor = VendorCliFamily::from_agent_id(agent_id);
953    let version = normalize_version(stdout, stderr);
954    let version_status = version.status();
955    let normalized_version = version.normalized();
956
957    if let ParsedVersion::Normalized { triple, .. } = &version {
958        if let Some(profile) = exact_profile(vendor, platform, *triple) {
959            return verified_resolution(
960                vendor,
961                platform,
962                version_status,
963                normalized_version,
964                profile,
965            );
966        }
967    }
968
969    let fallback_reason = match version {
970        ParsedVersion::Missing => VendorFallbackReason::MissingVersion,
971        ParsedVersion::Ambiguous => VendorFallbackReason::AmbiguousVersion,
972        ParsedVersion::Unparseable => VendorFallbackReason::UnparseableVersion,
973        ParsedVersion::Normalized { triple, .. } => {
974            unverified_version_reason(vendor, platform, triple)
975        }
976    };
977    raw_resolution(
978        vendor,
979        platform,
980        version_status,
981        normalized_version,
982        fallback_reason,
983    )
984}
985
986fn verified_resolution(
987    vendor: VendorCliFamily,
988    platform: VendorPlatform,
989    version_status: VendorVersionStatus,
990    normalized_version: Option<String>,
991    profile: &VerifiedProfile,
992) -> VendorContractResolution {
993    VendorContractResolution {
994        vendor,
995        platform,
996        mode: VendorRuntimeMode::VerifiedSemantic,
997        version_status,
998        normalized_version,
999        contract_id: Some(profile.contract_id),
1000        fallback_reason: None,
1001        capabilities: VendorCapabilitySet {
1002            raw_pty_spawn: RAW_PASSTHROUGH,
1003            terminal_screen: RAW_PASSTHROUGH,
1004            terminal_resize: RAW_PASSTHROUGH,
1005            terminal_interrupt: RAW_PASSTHROUGH,
1006            terminal_stop: RAW_PASSTHROUGH,
1007            semantic_readiness: verified_or_unavailable(
1008                profile.semantic_readiness_contract,
1009            ),
1010            provider_session_identity: verified_or_unavailable(
1011                profile.provider_session_identity_contract,
1012            ),
1013            structured_prompt: verified_or_unavailable(profile.structured_prompt_contract),
1014            semantic_resume: verified_or_unavailable(profile.semantic_resume_contract),
1015        },
1016    }
1017}
1018
1019fn raw_resolution(
1020    vendor: VendorCliFamily,
1021    platform: VendorPlatform,
1022    version_status: VendorVersionStatus,
1023    normalized_version: Option<String>,
1024    fallback_reason: VendorFallbackReason,
1025) -> VendorContractResolution {
1026    let semantic_unavailable = VendorCapabilityVerdict::Unavailable {
1027        reason: VendorCapabilityUnavailableReason::NoVerifiedContract(fallback_reason),
1028    };
1029    VendorContractResolution {
1030        vendor,
1031        platform,
1032        mode: VendorRuntimeMode::RawPassthrough,
1033        version_status,
1034        normalized_version,
1035        contract_id: None,
1036        fallback_reason: Some(fallback_reason),
1037        capabilities: VendorCapabilitySet {
1038            raw_pty_spawn: RAW_PASSTHROUGH,
1039            terminal_screen: RAW_PASSTHROUGH,
1040            terminal_resize: RAW_PASSTHROUGH,
1041            terminal_interrupt: RAW_PASSTHROUGH,
1042            terminal_stop: RAW_PASSTHROUGH,
1043            semantic_readiness: semantic_unavailable,
1044            provider_session_identity: semantic_unavailable,
1045            structured_prompt: semantic_unavailable,
1046            semantic_resume: semantic_unavailable,
1047        },
1048    }
1049}
1050
1051const fn verified_or_unavailable(
1052    capability_contract: Option<&'static str>,
1053) -> VendorCapabilityVerdict {
1054    match capability_contract {
1055        Some(capability_contract) => VendorCapabilityVerdict::Verified {
1056            capability_contract,
1057        },
1058        None => VendorCapabilityVerdict::Unavailable {
1059            reason: VendorCapabilityUnavailableReason::NotVerifiedByContract,
1060        },
1061    }
1062}
1063
1064fn exact_profile(
1065    vendor: VendorCliFamily,
1066    platform: VendorPlatform,
1067    version: VersionTriple,
1068) -> Option<&'static VerifiedProfile> {
1069    VERIFIED_PROFILES.iter().find(|profile| {
1070        profile.vendor == vendor && profile.platform == platform && profile.version == version
1071    })
1072}
1073
1074fn unverified_version_reason(
1075    vendor: VendorCliFamily,
1076    platform: VendorPlatform,
1077    version: VersionTriple,
1078) -> VendorFallbackReason {
1079    if vendor == VendorCliFamily::Unknown {
1080        return VendorFallbackReason::UnsupportedVendor;
1081    }
1082    if !VERIFIED_PROFILES.iter().any(|profile| profile.vendor == vendor) {
1083        return VendorFallbackReason::NoVerifiedProfile;
1084    }
1085    let platform_versions = VERIFIED_PROFILES
1086        .iter()
1087        .filter(|profile| profile.vendor == vendor && profile.platform == platform)
1088        .map(|profile| profile.version)
1089        .collect::<Vec<_>>();
1090    let Some(oldest) = platform_versions.iter().min() else {
1091        return VendorFallbackReason::UnsupportedPlatform;
1092    };
1093    let newest = platform_versions
1094        .iter()
1095        .max()
1096        .expect("a verified platform version must have a newest member");
1097    if version < *oldest {
1098        VendorFallbackReason::LegacyVersion
1099    } else if version > *newest {
1100        VendorFallbackReason::FutureVersion
1101    } else {
1102        VendorFallbackReason::UnlistedVersion
1103    }
1104}
1105
1106#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
1107struct VersionTriple {
1108    major: u64,
1109    minor: u64,
1110    patch: u64,
1111}
1112
1113impl VersionTriple {
1114    const fn new(major: u64, minor: u64, patch: u64) -> Self {
1115        Self {
1116            major,
1117            minor,
1118            patch,
1119        }
1120    }
1121
1122    fn normalized(self) -> String {
1123        format!("{}.{}.{}", self.major, self.minor, self.patch)
1124    }
1125}
1126
1127enum ParsedVersion {
1128    Normalized {
1129        triple: VersionTriple,
1130        normalized: String,
1131    },
1132    Missing,
1133    Ambiguous,
1134    Unparseable,
1135}
1136
1137impl ParsedVersion {
1138    const fn status(&self) -> VendorVersionStatus {
1139        match self {
1140            Self::Normalized { .. } => VendorVersionStatus::Normalized,
1141            Self::Missing => VendorVersionStatus::Missing,
1142            Self::Ambiguous => VendorVersionStatus::Ambiguous,
1143            Self::Unparseable => VendorVersionStatus::Unparseable,
1144        }
1145    }
1146
1147    fn normalized(&self) -> Option<String> {
1148        match self {
1149            Self::Normalized { normalized, .. } => Some(normalized.clone()),
1150            Self::Missing | Self::Ambiguous | Self::Unparseable => None,
1151        }
1152    }
1153}
1154
1155fn normalize_version(stdout: &[u8], stderr: &[u8]) -> ParsedVersion {
1156    let Some(total_bytes) = stdout
1157        .len()
1158        .checked_add(stderr.len())
1159        .and_then(|bytes| bytes.checked_add(1))
1160    else {
1161        return ParsedVersion::Unparseable;
1162    };
1163    if total_bytes > MAX_VERSION_OUTPUT_BYTES {
1164        return ParsedVersion::Unparseable;
1165    }
1166    if stdout.iter().chain(stderr).all(u8::is_ascii_whitespace) {
1167        return ParsedVersion::Missing;
1168    }
1169    if !stdout.is_ascii() || !stderr.is_ascii() {
1170        return ParsedVersion::Unparseable;
1171    }
1172    let mut output = Vec::with_capacity(total_bytes);
1173    output.extend_from_slice(stdout);
1174    output.push(b'\n');
1175    output.extend_from_slice(stderr);
1176    let Some(candidates) = version_candidates(&output) else {
1177        return ParsedVersion::Unparseable;
1178    };
1179    match candidates.len() {
1180        0 => ParsedVersion::Unparseable,
1181        1 => {
1182            let triple = *candidates
1183                .first()
1184                .expect("single version candidate must be present");
1185            ParsedVersion::Normalized {
1186                normalized: triple.normalized(),
1187                triple,
1188            }
1189        }
1190        _ => ParsedVersion::Ambiguous,
1191    }
1192}
1193
1194fn version_candidates(bytes: &[u8]) -> Option<BTreeSet<VersionTriple>> {
1195    let mut candidates = BTreeSet::new();
1196    let mut index = 0;
1197    while index < bytes.len() {
1198        if !bytes[index].is_ascii_digit() || !valid_start_boundary(bytes, index) {
1199            index += 1;
1200            continue;
1201        }
1202        let start = index;
1203        let Some(major_end) = decimal_end(bytes, start) else {
1204            index += 1;
1205            continue;
1206        };
1207        if major_end >= bytes.len() || bytes[major_end] != b'.' {
1208            index = major_end;
1209            continue;
1210        }
1211        let minor_start = major_end + 1;
1212        let Some(minor_end) = decimal_end(bytes, minor_start) else {
1213            index = minor_start;
1214            continue;
1215        };
1216        if minor_end >= bytes.len() || bytes[minor_end] != b'.' {
1217            index = minor_end;
1218            continue;
1219        }
1220        let patch_start = minor_end + 1;
1221        let Some(end) = decimal_end(bytes, patch_start) else {
1222            index = patch_start;
1223            continue;
1224        };
1225        if !valid_end_boundary(bytes, end)
1226            || !valid_decimal(&bytes[start..major_end])
1227            || !valid_decimal(&bytes[minor_start..minor_end])
1228            || !valid_decimal(&bytes[patch_start..end])
1229        {
1230            return None;
1231        }
1232        let major = parse_decimal(&bytes[start..major_end])?;
1233        let minor = parse_decimal(&bytes[minor_start..minor_end])?;
1234        let patch = parse_decimal(&bytes[patch_start..end])?;
1235        candidates.insert(VersionTriple::new(major, minor, patch));
1236        index = end.max(index + 1);
1237    }
1238    Some(candidates)
1239}
1240
1241fn valid_start_boundary(bytes: &[u8], index: usize) -> bool {
1242    if index == 0 {
1243        return true;
1244    }
1245    let previous = bytes[index - 1];
1246    if matches!(previous, b'v' | b'V') {
1247        return index == 1 || is_version_boundary(bytes[index - 2]);
1248    }
1249    is_version_boundary(previous)
1250}
1251
1252fn valid_end_boundary(bytes: &[u8], end: usize) -> bool {
1253    end == bytes.len() || is_version_boundary(bytes[end])
1254}
1255
1256fn is_version_boundary(byte: u8) -> bool {
1257    !byte.is_ascii_alphanumeric() && !matches!(byte, b'.' | b'-' | b'+' | b'_')
1258}
1259
1260fn decimal_end(bytes: &[u8], start: usize) -> Option<usize> {
1261    if start >= bytes.len() || !bytes[start].is_ascii_digit() {
1262        return None;
1263    }
1264    let mut end = start + 1;
1265    while end < bytes.len() && bytes[end].is_ascii_digit() {
1266        end += 1;
1267    }
1268    Some(end)
1269}
1270
1271fn valid_decimal(bytes: &[u8]) -> bool {
1272    bytes.len() == 1 || bytes.first() != Some(&b'0')
1273}
1274
1275fn parse_decimal(bytes: &[u8]) -> Option<u64> {
1276    bytes.iter().try_fold(0_u64, |value, byte| {
1277        value
1278            .checked_mul(10)?
1279            .checked_add(u64::from(byte.checked_sub(b'0')?))
1280    })
1281}