Skip to main content

gate4agent_runtime_native/
launch_profiles.rs

1use gate4agent_adapters::OneShotSessionPersistence;
2use gate4agent_catalog::{EnvMutation, McpServerSpec, McpServerSpecError};
3use gate4agent_types::{AgentId, AgentInstanceId, TransportKind};
4use std::collections::{BTreeMap, BTreeSet};
5use std::ffi::{OsStr, OsString};
6use std::fmt;
7use std::sync::{Arc, Mutex, MutexGuard};
8use thiserror::Error;
9
10const NATIVE_LAUNCH_PROFILE_ID_MAX_BYTES: usize = 64;
11const NATIVE_LAUNCH_PROFILES_MAX: usize = 512;
12const NATIVE_LAUNCH_PROFILE_SELECTIONS_MAX: usize = 512;
13const NATIVE_LAUNCH_PROFILE_ENV_MUTATIONS_MAX: usize = 128;
14const NATIVE_LAUNCH_PROFILE_ENV_KEY_MAX_BYTES: usize = 1_024;
15const NATIVE_LAUNCH_PROFILE_ENV_VALUE_MAX_BYTES: usize = 65_536;
16const NATIVE_LAUNCH_PROFILE_ENV_TOTAL_MAX_BYTES: usize = 1_048_576;
17const NATIVE_INSTANCE_LAUNCH_ARGS_MAX: usize = 128;
18const NATIVE_INSTANCE_LAUNCH_ARG_MAX_BYTES: usize = 65_536;
19const NATIVE_INSTANCE_LAUNCH_ARGS_TOTAL_MAX_BYTES: usize = 262_144;
20const RESERVED_HOOK_ENV_PREFIX: &str = "GATE4AGENT_HOOK_";
21const CONTEXT_ROOT_ENVIRONMENT_KEY: &str = "GATE4AGENT_CONTEXT_ROOT";
22
23/// The only keys of a host-assigned session identity (the messaging account the host gives
24/// one session: nick = key = role). They are host-owned like the context root, so they need no
25/// native launch profile and can ride on top of any other overlay.
26pub const SESSION_IDENTITY_ENVIRONMENT_KEYS: &[&str] = &["M4A_NICK", "M4A_SESSION_CAP", "M4A_SESSION_ID"];
27
28fn is_session_identity(environment: &[EnvMutation]) -> bool {
29    !environment.is_empty()
30        && environment.iter().all(|m| {
31            SESSION_IDENTITY_ENVIRONMENT_KEYS.iter().any(|k| m.key == OsStr::new(k)) && m.value.as_ref().is_some_and(|v| !v.is_empty())
32        })
33}
34const RESERVED_CLAUDE_LAUNCH_FLAGS: &[&str] = &[
35    "--continue",
36    "--print",
37    "--prompt",
38    "--prompt-interactive",
39    "--resume",
40    "--session-id",
41    "-c",
42    "-p",
43    "-r",
44];
45
46pub const ZAI_GLM_CLAUDE_PROFILE_ID: &str = "zai-glm";
47pub const ZAI_GLM_CLAUDE_PROFILE_REVISION: &str = "zai-claude-env-2026-07-21";
48pub const ZAI_GLM_ANTHROPIC_BASE_URL: &str = "https://api.z.ai/api/anthropic";
49pub const ZAI_GLM_CLAUDE_REQUIRED_ENV_KEYS: &[&str] =
50    &["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL"];
51pub const ZAI_GLM_CLAUDE_OPTIONAL_ENV_KEYS: &[&str] = &[
52    "API_TIMEOUT_MS",
53    "ANTHROPIC_DEFAULT_HAIKU_MODEL",
54    "ANTHROPIC_DEFAULT_SONNET_MODEL",
55    "ANTHROPIC_DEFAULT_OPUS_MODEL",
56    "CLAUDE_CODE_AUTO_COMPACT_WINDOW",
57    "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC",
58];
59pub const ZAI_GLM_CLAUDE_OWNED_ENV_KEYS: &[&str] = &[
60    "ANTHROPIC_AUTH_TOKEN",
61    "ANTHROPIC_BASE_URL",
62    "API_TIMEOUT_MS",
63    "ANTHROPIC_DEFAULT_HAIKU_MODEL",
64    "ANTHROPIC_DEFAULT_SONNET_MODEL",
65    "ANTHROPIC_DEFAULT_OPUS_MODEL",
66    "CLAUDE_CODE_AUTO_COMPACT_WINDOW",
67    "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC",
68];
69
70/// Revisioned non-secret descriptor for a built-in native launch profile.
71#[derive(Clone, Copy, Debug, Eq, PartialEq)]
72pub struct NativeLaunchProfileDescriptor {
73    id: &'static str,
74    revision: &'static str,
75    agent_id: &'static str,
76    transport: TransportKind,
77    required_env_keys: &'static [&'static str],
78    optional_env_keys: &'static [&'static str],
79    owned_env_keys: &'static [&'static str],
80    contract: NativeLaunchProfileContract,
81}
82
83impl NativeLaunchProfileDescriptor {
84    pub const fn id(&self) -> &'static str {
85        self.id
86    }
87
88    pub const fn revision(&self) -> &'static str {
89        self.revision
90    }
91
92    pub const fn agent_id(&self) -> &'static str {
93        self.agent_id
94    }
95
96    pub const fn transport(&self) -> TransportKind {
97        self.transport
98    }
99
100    pub const fn required_env_keys(&self) -> &'static [&'static str] {
101        self.required_env_keys
102    }
103
104    pub const fn optional_env_keys(&self) -> &'static [&'static str] {
105        self.optional_env_keys
106    }
107
108    pub const fn owned_env_keys(&self) -> &'static [&'static str] {
109        self.owned_env_keys
110    }
111
112    pub fn instantiate(
113        &self,
114        resolver: Arc<dyn NativeChildEnvironmentResolver>,
115    ) -> Result<NativeLaunchProfile, NativeLaunchProfileError> {
116        let id = NativeLaunchProfileId::new(self.id)
117            .expect("built-in native launch profile ID must be valid");
118        let agent_id = AgentId::new(self.agent_id)
119            .expect("built-in native launch profile agent ID must be valid");
120        NativeLaunchProfile::new_with_contract(
121            id,
122            agent_id,
123            self.transport,
124            self.owned_env_keys.iter().map(OsString::from).collect(),
125            resolver,
126            self.contract,
127        )
128    }
129}
130
131#[derive(Clone, Copy, Debug, Eq, PartialEq)]
132enum NativeLaunchProfileContract {
133    ExactOwnership,
134    ZaiGlmClaude,
135}
136
137impl NativeLaunchProfileContract {
138    fn validate(self, environment: &[EnvMutation]) -> Result<(), NativeLaunchProfileError> {
139        match self {
140            Self::ExactOwnership => Ok(()),
141            Self::ZaiGlmClaude => validate_zai_glm_claude_environment(environment),
142        }
143    }
144}
145
146/// Z.AI GLM Coding Plan over the installed Claude Code CLI.
147///
148/// The key inventory follows the official Z.AI Claude Code environment example
149/// reviewed on 2026-07-21. Only the token and base URL are required; a resolver
150/// must still return every owned key and use `None` for optional values it does
151/// not set. No model value is embedded because the current upstream example is
152/// internally inconsistent and model selection remains host configuration.
153pub const ZAI_GLM_CLAUDE_PROFILE: NativeLaunchProfileDescriptor =
154    NativeLaunchProfileDescriptor {
155        id: ZAI_GLM_CLAUDE_PROFILE_ID,
156        revision: ZAI_GLM_CLAUDE_PROFILE_REVISION,
157        agent_id: "claude",
158        transport: TransportKind::Pty,
159        required_env_keys: ZAI_GLM_CLAUDE_REQUIRED_ENV_KEYS,
160        optional_env_keys: ZAI_GLM_CLAUDE_OPTIONAL_ENV_KEYS,
161        owned_env_keys: ZAI_GLM_CLAUDE_OWNED_ENV_KEYS,
162        contract: NativeLaunchProfileContract::ZaiGlmClaude,
163    };
164
165/// Host-local identifier for a non-wire native launch profile.
166#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
167pub struct NativeLaunchProfileId(String);
168
169impl NativeLaunchProfileId {
170    pub fn new(value: impl Into<String>) -> Result<Self, NativeLaunchProfileError> {
171        let value = value.into();
172        if value.is_empty() {
173            return Err(NativeLaunchProfileError::EmptyId);
174        }
175        if value.len() > NATIVE_LAUNCH_PROFILE_ID_MAX_BYTES {
176            return Err(NativeLaunchProfileError::IdTooLong {
177                len: value.len(),
178                max: NATIVE_LAUNCH_PROFILE_ID_MAX_BYTES,
179            });
180        }
181        if !value.bytes().all(|byte| {
182            byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_')
183        }) || matches!(value.as_bytes().first(), Some(b'-' | b'_'))
184            || matches!(value.as_bytes().last(), Some(b'-' | b'_'))
185        {
186            return Err(NativeLaunchProfileError::InvalidId);
187        }
188        Ok(Self(value))
189    }
190
191    pub fn as_str(&self) -> &str {
192        &self.0
193    }
194}
195
196impl fmt::Display for NativeLaunchProfileId {
197    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
198        formatter.write_str(self.as_str())
199    }
200}
201
202/// Host-owned child-process environment policy, absent from the wire protocol.
203///
204/// The profile retains an opaque resolver rather than resolved values and does
205/// not implement `Debug`, preventing routine diagnostics from exposing child
206/// environment material.
207#[derive(Clone)]
208pub struct NativeLaunchProfile {
209    id: NativeLaunchProfileId,
210    agent_id: AgentId,
211    transport: TransportKind,
212    owned_env_keys: Vec<OsString>,
213    resolver: Arc<dyn NativeChildEnvironmentResolver>,
214    contract: NativeLaunchProfileContract,
215    one_shot_session_persistence: OneShotSessionPersistence,
216}
217
218/// Host-only launch mutations applied to one exact native PTY instance.
219///
220/// The overlay does not implement `Debug` because environment values and argv
221/// may contain secret material. It is validated and retained without crossing
222/// the wire or durable state boundaries.
223pub struct NativeInstanceLaunchOverlay {
224    agent_id: AgentId,
225    transport: TransportKind,
226    environment: Vec<EnvMutation>,
227    extra_args: Vec<OsString>,
228    profile_selection_required: bool,
229}
230
231/// Host-only MCP-server launch overlay for one exact instance, PTY or ACP.
232///
233/// Built from a caller-supplied [`McpServerSpec`] plus whatever the caller
234/// wants installed into, or scrubbed from, a PTY child's own OS environment.
235/// The two never disagree on the server's own environment entries: `env`
236/// below both becomes the PTY child's environment (as `Some` mutations,
237/// alongside `env_removals` as `None` mutations) and travels unchanged as
238/// [`McpServerSpec::env`] for an ACP-transport agent's own stdio server
239/// entry -- `NativeSpawnOverlay::resolve_environment`, below, does not gate
240/// this overlay by transport, and `gate4agent-shell-native`'s ACP branch
241/// receives the resolved spec as a typed value from
242/// `gate4agent-runtime-native`, in-process
243/// (`NativeSpawnOverlay`/`NativeEffectRequest`), never a wire type or a
244/// well-known environment key it has to re-read.
245///
246/// This crate never interprets `name`, `program`, `args`, or any entry in
247/// `env`/`env_removals` -- they, and any trace opt-in a caller wants, are
248/// entirely the caller's concern.
249pub struct NativeMcpServerLaunchOverlay {
250    agent_id: AgentId,
251    environment: Vec<EnvMutation>,
252    mcp_server: McpServerSpec,
253}
254
255impl NativeMcpServerLaunchOverlay {
256    /// `env_entries` are installed into a PTY child's OS environment and
257    /// carried unchanged as the ACP stdio entry's own `env`. `env_removals`
258    /// applies to the PTY child's OS environment only -- an ACP
259    /// `mcpServers` entry is additive, so there is nothing there to remove.
260    pub fn new(
261        agent_id: AgentId,
262        name: impl Into<String>,
263        program: OsString,
264        args: Vec<OsString>,
265        env_entries: Vec<(OsString, OsString)>,
266        env_removals: Vec<OsString>,
267    ) -> Result<Self, NativeLaunchProfileError> {
268        let mcp_server = McpServerSpec::new(name, program, args, env_entries.clone())?;
269        let mut environment: Vec<EnvMutation> = env_entries
270            .into_iter()
271            .map(|(key, value)| EnvMutation {
272                key,
273                value: Some(value),
274            })
275            .collect();
276        environment.extend(
277            env_removals
278                .into_iter()
279                .map(|key| EnvMutation { key, value: None }),
280        );
281        validate_environment_mutations(&environment)?;
282        Ok(Self {
283            agent_id,
284            environment,
285            mcp_server,
286        })
287    }
288
289    pub(crate) fn mcp_server(&self) -> &McpServerSpec {
290        &self.mcp_server
291    }
292}
293
294impl NativeInstanceLaunchOverlay {
295    pub fn new(
296        agent_id: AgentId,
297        transport: TransportKind,
298        environment: Vec<EnvMutation>,
299        extra_args: Vec<OsString>,
300    ) -> Result<Self, NativeLaunchProfileError> {
301        match transport {
302            TransportKind::Pty => {}
303            // Codex first-slice network_access: argv-only `-c` overlays may
304            // ride ACP the same channel as catalog `windows_wsl_setup_*`.
305            // Environment mutations on ACP stay refused.
306            TransportKind::Acp
307                if environment.is_empty()
308                    && is_codex_config_c_overlay_args(&agent_id, &extra_args) => {}
309            _ => return Err(NativeLaunchProfileError::InstanceOverlayUnsupportedTransport),
310        }
311        validate_environment_mutations(&environment)?;
312        validate_launch_arguments(&agent_id, &extra_args)?;
313        let profile_selection_required = !environment.is_empty();
314        Ok(Self {
315            agent_id,
316            transport,
317            environment,
318            extra_args,
319            profile_selection_required,
320        })
321    }
322}
323
324/// Backward-compatible environment-only overlay used by existing node flows.
325///
326/// This type also intentionally omits `Debug`. New PTY feature bundles should
327/// use [`NativeInstanceLaunchOverlay`] when they need child argv.
328pub struct NativeLaunchEnvironmentOverlay {
329    agent_id: AgentId,
330    transport: TransportKind,
331    environment: Vec<EnvMutation>,
332    profile_selection_required: bool,
333}
334
335impl NativeLaunchEnvironmentOverlay {
336    pub fn new(
337        agent_id: AgentId,
338        transport: TransportKind,
339        environment: Vec<EnvMutation>,
340    ) -> Result<Self, NativeLaunchProfileError> {
341        if !matches!(transport, TransportKind::Pty | TransportKind::Pipe) {
342            return Err(NativeLaunchProfileError::UnsupportedTransport);
343        }
344        validate_environment_mutations(&environment)?;
345        // Every generic environment overlay -- an empty one included -- is bound
346        // to an explicitly selected native launch profile and is refused with
347        // `EnvironmentOverlaySelectionMissing` without one. The requirement used
348        // to follow from the mutation list being non-empty, which let an empty
349        // overlay install unprofiled; only `new_context_root` is exempt.
350        Ok(Self {
351            agent_id,
352            transport,
353            environment,
354            profile_selection_required: true,
355        })
356    }
357
358    /// Creates the one unprofiled environment overlay authorized by Node-owned
359    /// ContextPack materialization. All generic environment overlays remain
360    /// bound to an explicitly selected native launch profile.
361    pub fn new_context_root(
362        agent_id: AgentId,
363        transport: TransportKind,
364        environment: Vec<EnvMutation>,
365    ) -> Result<Self, NativeLaunchProfileError> {
366        let exact_context_root = match environment.as_slice() {
367            [mutation]
368                if mutation.key == OsStr::new(CONTEXT_ROOT_ENVIRONMENT_KEY)
369                    && mutation.value.as_ref().is_some_and(|value| !value.is_empty()) => true,
370            _ => false,
371        };
372        if !exact_context_root {
373            return Err(NativeLaunchProfileError::EnvironmentOverlaySelectionMissing);
374        }
375        let mut overlay = Self::new(agent_id, transport, environment)?;
376        overlay.profile_selection_required = false;
377        Ok(overlay)
378    }
379
380    /// See [`NativeInstanceLaunchOverlay::append_session_identity`].
381    pub fn append_session_identity(
382        &mut self,
383        environment: Vec<EnvMutation>,
384    ) -> Result<(), NativeLaunchProfileError> {
385        if self.transport != TransportKind::Pty {
386            return Err(NativeLaunchProfileError::UnsupportedTransport);
387        }
388        if !is_session_identity(&environment) {
389            return Err(NativeLaunchProfileError::EnvironmentOverlaySelectionMissing);
390        }
391        let mut merged = self.environment.clone();
392        merged.extend(environment);
393        validate_environment_mutations(&merged)?;
394        self.environment = merged;
395        Ok(())
396    }
397
398    fn into_instance_overlay(self) -> NativeInstanceLaunchOverlay {
399        NativeInstanceLaunchOverlay {
400            agent_id: self.agent_id,
401            transport: self.transport,
402            environment: self.environment,
403            extra_args: Vec::new(),
404            profile_selection_required: self.profile_selection_required,
405        }
406    }
407
408    /// Promote an environment overlay into an instance overlay that also
409    /// carries argv (Codex `-c` network_access first slice merge).
410    pub fn into_instance_with_extra_args(
411        self,
412        extra_args: Vec<OsString>,
413    ) -> Result<NativeInstanceLaunchOverlay, NativeLaunchProfileError> {
414        validate_launch_arguments(&self.agent_id, &extra_args)?;
415        if self.transport != TransportKind::Pty && !extra_args.is_empty() {
416            return Err(NativeLaunchProfileError::InstanceOverlayUnsupportedTransport);
417        }
418        Ok(NativeInstanceLaunchOverlay {
419            agent_id: self.agent_id,
420            transport: self.transport,
421            environment: self.environment,
422            extra_args,
423            profile_selection_required: self.profile_selection_required,
424        })
425    }
426}
427
428impl NativeInstanceLaunchOverlay {
429    /// A PTY overlay carrying only a host-assigned session identity
430    /// ([`SESSION_IDENTITY_ENVIRONMENT_KEYS`]); no native launch profile is required.
431    pub fn new_session_identity(
432        agent_id: AgentId,
433        environment: Vec<EnvMutation>,
434    ) -> Result<Self, NativeLaunchProfileError> {
435        if !is_session_identity(&environment) {
436            return Err(NativeLaunchProfileError::EnvironmentOverlaySelectionMissing);
437        }
438        let mut overlay = Self::new(agent_id, TransportKind::Pty, environment, Vec::new())?;
439        overlay.profile_selection_required = false;
440        Ok(overlay)
441    }
442
443    /// Adds a host-assigned session identity to this overlay without changing whether it needs
444    /// a profile. Refused for other keys, for a key the overlay already sets, and off PTY.
445    pub fn append_session_identity(
446        &mut self,
447        environment: Vec<EnvMutation>,
448    ) -> Result<(), NativeLaunchProfileError> {
449        if self.transport != TransportKind::Pty {
450            return Err(NativeLaunchProfileError::InstanceOverlayUnsupportedTransport);
451        }
452        if !is_session_identity(&environment) {
453            return Err(NativeLaunchProfileError::EnvironmentOverlaySelectionMissing);
454        }
455        let mut merged = self.environment.clone();
456        merged.extend(environment);
457        validate_environment_mutations(&merged)?;
458        self.environment = merged;
459        Ok(())
460    }
461
462    /// Append validated argv (Codex `-c` network_access merge onto an
463    /// existing instance overlay).
464    pub fn append_extra_args(
465        &mut self,
466        extra_args: Vec<OsString>,
467    ) -> Result<(), NativeLaunchProfileError> {
468        if extra_args.is_empty() {
469            return Ok(());
470        }
471        if self.transport != TransportKind::Pty
472            && !is_codex_config_c_overlay_args(&self.agent_id, &extra_args)
473        {
474            return Err(NativeLaunchProfileError::InstanceOverlayUnsupportedTransport);
475        }
476        validate_launch_arguments(&self.agent_id, &extra_args)?;
477        self.extra_args.extend(extra_args);
478        validate_launch_arguments(&self.agent_id, &self.extra_args)?;
479        Ok(())
480    }
481}
482
483impl NativeLaunchProfile {
484    pub fn new(
485        id: NativeLaunchProfileId,
486        agent_id: AgentId,
487        transport: TransportKind,
488        owned_env_keys: Vec<OsString>,
489        resolver: Arc<dyn NativeChildEnvironmentResolver>,
490    ) -> Result<Self, NativeLaunchProfileError> {
491        Self::new_with_contract(
492            id,
493            agent_id,
494            transport,
495            owned_env_keys,
496            resolver,
497            NativeLaunchProfileContract::ExactOwnership,
498        )
499    }
500
501    fn new_with_contract(
502        id: NativeLaunchProfileId,
503        agent_id: AgentId,
504        transport: TransportKind,
505        owned_env_keys: Vec<OsString>,
506        resolver: Arc<dyn NativeChildEnvironmentResolver>,
507        contract: NativeLaunchProfileContract,
508    ) -> Result<Self, NativeLaunchProfileError> {
509        if !matches!(transport, TransportKind::Pty | TransportKind::Pipe) {
510            return Err(NativeLaunchProfileError::UnsupportedTransport);
511        }
512        validate_owned_environment_keys(&owned_env_keys)?;
513        Ok(Self {
514            id,
515            agent_id,
516            transport,
517            owned_env_keys,
518            resolver,
519            contract,
520            one_shot_session_persistence: OneShotSessionPersistence::Ephemeral,
521        })
522    }
523
524    /// Selects whether an exact Codex OneShotText Pipe launch may retain its
525    /// vendor session for later resume.
526    pub fn with_one_shot_session_persistence(
527        mut self,
528        policy: OneShotSessionPersistence,
529    ) -> Result<Self, NativeLaunchProfileError> {
530        if policy == OneShotSessionPersistence::Persist
531            && (self.agent_id.as_str() != "codex" || self.transport != TransportKind::Pipe)
532        {
533            return Err(NativeLaunchProfileError::OneShotSessionPersistenceBindingMismatch);
534        }
535        self.one_shot_session_persistence = policy;
536        Ok(self)
537    }
538
539    pub fn id(&self) -> &NativeLaunchProfileId {
540        &self.id
541    }
542
543    pub fn agent_id(&self) -> &AgentId {
544        &self.agent_id
545    }
546
547    pub fn transport(&self) -> TransportKind {
548        self.transport
549    }
550
551    fn resolve_environment(
552        &self,
553        agent_id: &AgentId,
554        transport: TransportKind,
555    ) -> Result<Vec<EnvMutation>, NativeLaunchProfileError> {
556        if &self.agent_id != agent_id || self.transport != transport {
557            return Err(NativeLaunchProfileError::BindingMismatch);
558        }
559        let environment = self
560            .resolver
561            .resolve_child_environment()
562            .map_err(NativeLaunchProfileError::Resolve)?;
563        validate_resolved_environment(&self.owned_env_keys, &environment)?;
564        self.contract.validate(&environment)?;
565        Ok(environment)
566    }
567}
568
569/// Resolves an exact, declared environment overlay at native spawn dispatch.
570///
571/// Implementations should retain references to secret storage and resolve
572/// values only when called; resolver implementations must not expose them via
573/// `Debug` or error text.
574pub trait NativeChildEnvironmentResolver: Send + Sync + 'static {
575    fn resolve_child_environment(
576        &self,
577    ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError>;
578}
579
580#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
581pub enum NativeChildEnvironmentResolveError {
582    #[error("native child environment is temporarily unavailable")]
583    TemporarilyUnavailable,
584    #[error("native child environment resolution was denied")]
585    Denied,
586}
587
588#[derive(Debug, Error, Eq, PartialEq)]
589pub enum NativeLaunchProfileError {
590    #[error("native launch profile ID must not be empty")]
591    EmptyId,
592    #[error("native launch profile ID is {len} bytes; maximum is {max}")]
593    IdTooLong { len: usize, max: usize },
594    #[error("native launch profile ID must be a lowercase ASCII slug")]
595    InvalidId,
596    #[error("native launch profiles support PTY and exact OneShotText Pipe transports only")]
597    UnsupportedTransport,
598    #[error("native launch profile must own at least one environment key")]
599    EmptyEnvironmentOwnership,
600    #[error("native launch profile has {count} environment mutations; maximum is {max}")]
601    TooManyEnvironmentMutations { count: usize, max: usize },
602    #[error("native launch profile environment mutation {index} has an invalid key")]
603    InvalidEnvironmentKey { index: usize },
604    #[error("native launch profile environment mutation {index} uses a reserved hook key")]
605    ReservedHookEnvironmentKey { index: usize },
606    #[error("native launch profile environment mutation {index} duplicates an earlier key")]
607    DuplicateEnvironmentKey { index: usize },
608    #[error("native launch profile environment mutation {index} value exceeds {max} bytes")]
609    EnvironmentValueTooLong { index: usize, max: usize },
610    #[error("native launch profile environment mutation {index} has an invalid value")]
611    InvalidEnvironmentValue { index: usize },
612    #[error("native launch profile environment payload exceeds {max} bytes")]
613    EnvironmentPayloadTooLarge { max: usize },
614    #[error("native launch profile resolver returned a key outside its exact ownership set")]
615    EnvironmentOwnershipMismatch,
616    #[error("native launch profile resolver omitted a required environment value")]
617    RequiredEnvironmentValueMissing,
618    #[error("native launch profile resolver returned an invalid fixed environment value")]
619    FixedEnvironmentValueMismatch,
620    #[error("native launch profile capacity is {max}")]
621    ProfileCapacityExceeded { max: usize },
622    #[error("native launch profile '{profile_id}' is not installed")]
623    UnknownProfile { profile_id: NativeLaunchProfileId },
624    #[error("native launch profile does not match the exact agent and transport binding")]
625    BindingMismatch,
626    #[error("persistent one-shot sessions require exact agent 'codex' and Pipe transport")]
627    OneShotSessionPersistenceBindingMismatch,
628    #[error("native instance launch overlay supports PTY, or argv-only Codex -c overlays on ACP")]
629    InstanceOverlayUnsupportedTransport,
630    #[error("native MCP server launch overlay requires PTY or ACP transport and the exact provider binding; saw transport {transport:?}, overlay provider '{overlay_provider}', spawn provider '{spawn_provider}'")]
631    McpServerOverlayBindingMismatch {
632        transport: TransportKind,
633        overlay_provider: AgentId,
634        spawn_provider: AgentId,
635    },
636    #[error("native launch profile selection capacity is {max}")]
637    SelectionCapacityExceeded { max: usize },
638    #[error("native launch environment overlay requires an existing profile selection")]
639    EnvironmentOverlaySelectionMissing,
640    #[error("native launch environment overlay does not match the selected profile binding")]
641    EnvironmentOverlayBindingMismatch,
642    #[error("native launch environment overlay conflicts with a selected profile-owned key")]
643    EnvironmentOverlayKeyConflict,
644    #[error("native launch environment overlay capacity is {max}")]
645    EnvironmentOverlayCapacityExceeded { max: usize },
646    #[error("native instance launch overlay has {count} arguments; maximum is {max}")]
647    TooManyLaunchArguments { count: usize, max: usize },
648    #[error("native instance launch overlay argument {index} is invalid")]
649    InvalidLaunchArgument { index: usize },
650    #[error("native instance launch overlay argument {index} exceeds {max} bytes")]
651    LaunchArgumentTooLong { index: usize, max: usize },
652    #[error("native instance launch overlay argument payload exceeds {max} bytes")]
653    LaunchArgumentsPayloadTooLarge { max: usize },
654    #[error("native instance launch overlay argument {index} conflicts with Claude session, resume, or prompt authority")]
655    ReservedClaudeLaunchArgument { index: usize },
656    #[error("native launch profile is selected by an instance; clear the selection first")]
657    ProfileInUse,
658    #[error(transparent)]
659    Resolve(#[from] NativeChildEnvironmentResolveError),
660    #[error(transparent)]
661    McpServerSpec(#[from] McpServerSpecError),
662}
663
664pub(crate) struct NativeLaunchProfiles {
665    profiles: BTreeMap<NativeLaunchProfileId, NativeLaunchProfile>,
666    selections: BTreeMap<AgentInstanceId, NativeLaunchProfileId>,
667    instance_overlays: BTreeMap<AgentInstanceId, Arc<NativeInstanceLaunchOverlay>>,
668    mcp_server_overlays: BTreeMap<AgentInstanceId, Arc<NativeMcpServerLaunchOverlay>>,
669}
670
671impl NativeLaunchProfiles {
672    pub(crate) fn new() -> Self {
673        Self {
674            profiles: BTreeMap::new(),
675            selections: BTreeMap::new(),
676            instance_overlays: BTreeMap::new(),
677            mcp_server_overlays: BTreeMap::new(),
678        }
679    }
680
681    pub(crate) fn upsert(
682        &mut self,
683        profile: NativeLaunchProfile,
684    ) -> Result<(), NativeLaunchProfileError> {
685        if !self.profiles.contains_key(profile.id())
686            && self.profiles.len() >= NATIVE_LAUNCH_PROFILES_MAX
687        {
688            return Err(NativeLaunchProfileError::ProfileCapacityExceeded {
689                max: NATIVE_LAUNCH_PROFILES_MAX,
690            });
691        }
692        for (instance_id, selected_profile_id) in &self.selections {
693            if selected_profile_id == profile.id() {
694                if let Some(overlay) = self.instance_overlays.get(instance_id) {
695                    validate_instance_overlay_binding(&profile, overlay)?;
696                }
697            }
698        }
699        self.profiles.insert(profile.id.clone(), profile);
700        Ok(())
701    }
702
703    pub(crate) fn remove(
704        &mut self,
705        profile_id: &NativeLaunchProfileId,
706    ) -> Result<bool, NativeLaunchProfileError> {
707        if self
708            .selections
709            .values()
710            .any(|selected_id| selected_id == profile_id)
711        {
712            return Err(NativeLaunchProfileError::ProfileInUse);
713        }
714        Ok(self.profiles.remove(profile_id).is_some())
715    }
716
717    pub(crate) fn select(
718        &mut self,
719        instance_id: AgentInstanceId,
720        profile_id: NativeLaunchProfileId,
721    ) -> Result<(), NativeLaunchProfileError> {
722        let profile = self.profiles.get(&profile_id).ok_or_else(|| {
723            NativeLaunchProfileError::UnknownProfile {
724                profile_id: profile_id.clone(),
725            }
726        })?;
727        if let Some(overlay) = self.instance_overlays.get(&instance_id) {
728            validate_instance_overlay_binding(profile, overlay)?;
729        }
730        if !self.selections.contains_key(&instance_id)
731            && self.selections.len() >= NATIVE_LAUNCH_PROFILE_SELECTIONS_MAX
732        {
733            return Err(NativeLaunchProfileError::SelectionCapacityExceeded {
734                max: NATIVE_LAUNCH_PROFILE_SELECTIONS_MAX,
735            });
736        }
737        self.selections.insert(instance_id, profile_id);
738        Ok(())
739    }
740
741    pub(crate) fn clear_selection(&mut self, instance_id: AgentInstanceId) -> bool {
742        let selection_removed = self.selections.remove(&instance_id).is_some();
743        let overlay_removed = self.instance_overlays.remove(&instance_id).is_some();
744        selection_removed || overlay_removed
745    }
746
747    pub(crate) fn install_environment_overlay(
748        &mut self,
749        instance_id: AgentInstanceId,
750        overlay: NativeLaunchEnvironmentOverlay,
751    ) -> Result<(), NativeLaunchProfileError> {
752        if overlay.profile_selection_required && !self.selections.contains_key(&instance_id) {
753            return Err(NativeLaunchProfileError::EnvironmentOverlaySelectionMissing);
754        }
755        self.install_instance_overlay(instance_id, overlay.into_instance_overlay())
756    }
757
758    pub(crate) fn install_instance_overlay(
759        &mut self,
760        instance_id: AgentInstanceId,
761        overlay: NativeInstanceLaunchOverlay,
762    ) -> Result<(), NativeLaunchProfileError> {
763        if let Some(profile_id) = self.selections.get(&instance_id) {
764            let profile = self.profiles.get(profile_id).ok_or_else(|| {
765                NativeLaunchProfileError::UnknownProfile {
766                    profile_id: profile_id.clone(),
767                }
768            })?;
769            validate_instance_overlay_binding(profile, &overlay)?;
770        } else if overlay.profile_selection_required {
771            return Err(NativeLaunchProfileError::EnvironmentOverlaySelectionMissing);
772        }
773        if !self.instance_overlays.contains_key(&instance_id)
774            && self.instance_overlays.len() >= NATIVE_LAUNCH_PROFILE_SELECTIONS_MAX
775        {
776            return Err(
777                NativeLaunchProfileError::EnvironmentOverlayCapacityExceeded {
778                    max: NATIVE_LAUNCH_PROFILE_SELECTIONS_MAX,
779                },
780            );
781        }
782        self.instance_overlays
783            .insert(instance_id, Arc::new(overlay));
784        Ok(())
785    }
786
787    pub(crate) fn clear_instance_overlay(&mut self, instance_id: AgentInstanceId) -> bool {
788        self.instance_overlays.remove(&instance_id).is_some()
789    }
790
791    pub(crate) fn install_mcp_server_overlay(
792        &mut self,
793        instance_id: AgentInstanceId,
794        overlay: NativeMcpServerLaunchOverlay,
795    ) -> Result<(), NativeLaunchProfileError> {
796        if !self.mcp_server_overlays.contains_key(&instance_id)
797            && self.mcp_server_overlays.len() >= NATIVE_LAUNCH_PROFILE_SELECTIONS_MAX
798        {
799            return Err(NativeLaunchProfileError::EnvironmentOverlayCapacityExceeded {
800                max: NATIVE_LAUNCH_PROFILE_SELECTIONS_MAX,
801            });
802        }
803        self.mcp_server_overlays
804            .insert(instance_id, Arc::new(overlay));
805        Ok(())
806    }
807
808    pub(crate) fn clear_mcp_server_overlay(&mut self, instance_id: AgentInstanceId) -> bool {
809        self.mcp_server_overlays.remove(&instance_id).is_some()
810    }
811
812    fn profile_for_spawn(
813        &self,
814        instance_id: AgentInstanceId,
815        agent_id: &AgentId,
816        transport: TransportKind,
817        pipe_binding_is_exact_one_shot: bool,
818    ) -> Result<Option<NativeLaunchSpawnEnvironment>, NativeLaunchProfileError> {
819        let overlay = self.instance_overlays.get(&instance_id).cloned();
820        let mcp_server_overlay = self.mcp_server_overlays.get(&instance_id).cloned();
821        let profile = if let Some(profile_id) = self.selections.get(&instance_id) {
822            let profile = self
823                .profiles
824                .get(profile_id)
825                .ok_or_else(|| NativeLaunchProfileError::UnknownProfile {
826                    profile_id: profile_id.clone(),
827                })?;
828            if profile.agent_id() != agent_id || profile.transport() != transport {
829                return Err(NativeLaunchProfileError::BindingMismatch);
830            }
831            if transport == TransportKind::Pipe && !pipe_binding_is_exact_one_shot {
832                return Err(NativeLaunchProfileError::UnsupportedTransport);
833            }
834            if let Some(overlay) = &overlay {
835                validate_instance_overlay_binding(profile, overlay)?;
836            }
837            Some(profile.clone())
838        } else {
839            None
840        };
841        if let Some(overlay) = &overlay {
842            validate_instance_overlay_spawn_binding(agent_id, transport, overlay)?;
843            if profile.is_none() && overlay.profile_selection_required {
844                return Err(NativeLaunchProfileError::EnvironmentOverlaySelectionMissing);
845            }
846        }
847        if let Some(mcp_server_overlay) = &mcp_server_overlay {
848            if !matches!(transport, TransportKind::Pty | TransportKind::Acp)
849                || mcp_server_overlay.agent_id != *agent_id
850            {
851                return Err(NativeLaunchProfileError::McpServerOverlayBindingMismatch {
852                    transport,
853                    overlay_provider: mcp_server_overlay.agent_id.clone(),
854                    spawn_provider: agent_id.clone(),
855                });
856            }
857        }
858        if profile.is_none() && overlay.is_none() && mcp_server_overlay.is_none() {
859            return Ok(None);
860        }
861        Ok(Some(NativeLaunchSpawnEnvironment {
862            profile,
863            overlay,
864            mcp_server_overlay,
865        }))
866    }
867}
868
869struct NativeLaunchSpawnEnvironment {
870    profile: Option<NativeLaunchProfile>,
871    overlay: Option<Arc<NativeInstanceLaunchOverlay>>,
872    mcp_server_overlay: Option<Arc<NativeMcpServerLaunchOverlay>>,
873}
874
875pub(crate) struct ResolvedNativeLaunchOverlay {
876    pub(crate) environment: Vec<EnvMutation>,
877    pub(crate) extra_args: Vec<OsString>,
878    pub(crate) one_shot_session_persistence: OneShotSessionPersistence,
879    pub(crate) mcp_server: Option<McpServerSpec>,
880}
881
882/// Clonable host-local control for selecting profiles without mutable runtime access.
883///
884/// The handle intentionally does not implement `Debug`: its shared registry retains
885/// opaque environment resolvers that may own secret material.
886#[derive(Clone)]
887pub struct NativeLaunchProfileControl {
888    launch_profiles: Arc<Mutex<NativeLaunchProfiles>>,
889}
890
891impl NativeLaunchProfileControl {
892    pub(crate) fn new() -> Self {
893        Self {
894            launch_profiles: Arc::new(Mutex::new(NativeLaunchProfiles::new())),
895        }
896    }
897
898    pub(crate) fn upsert(
899        &self,
900        profile: NativeLaunchProfile,
901    ) -> Result<(), NativeLaunchProfileError> {
902        self.lock().upsert(profile)
903    }
904
905    pub(crate) fn remove(
906        &self,
907        profile_id: &NativeLaunchProfileId,
908    ) -> Result<bool, NativeLaunchProfileError> {
909        self.lock().remove(profile_id)
910    }
911
912    /// Selects a profile for future spawns of one exact instance.
913    ///
914    /// Spawn dispatch snapshots the selection under the same registry lock, so
915    /// a selection change does not alter a child that was already dispatched.
916    pub fn select_native_launch_profile(
917        &self,
918        instance_id: AgentInstanceId,
919        profile_id: NativeLaunchProfileId,
920    ) -> Result<(), NativeLaunchProfileError> {
921        self.lock().select(instance_id, profile_id)
922    }
923
924    /// Clears one instance selection for future spawns only.
925    pub fn clear_native_launch_profile_selection(&self, instance_id: AgentInstanceId) -> bool {
926        self.lock().clear_selection(instance_id)
927    }
928
929    /// Installs or replaces one host-only environment overlay for an exact selection.
930    pub fn install_native_launch_environment_overlay(
931        &self,
932        instance_id: AgentInstanceId,
933        overlay: NativeLaunchEnvironmentOverlay,
934    ) -> Result<(), NativeLaunchProfileError> {
935        self.lock().install_environment_overlay(instance_id, overlay)
936    }
937
938    /// Installs or replaces one host-only PTY launch overlay for an exact instance.
939    ///
940    /// An argv-only overlay does not require an environment profile selection.
941    /// Environment mutations retain the existing exact-selection requirement.
942    pub fn install_native_instance_launch_overlay(
943        &self,
944        instance_id: AgentInstanceId,
945        overlay: NativeInstanceLaunchOverlay,
946    ) -> Result<(), NativeLaunchProfileError> {
947        self.lock().install_instance_overlay(instance_id, overlay)
948    }
949
950    /// Clears one host-only instance launch overlay for future spawns only.
951    pub fn clear_native_instance_launch_overlay(&self, instance_id: AgentInstanceId) -> bool {
952        self.lock().clear_instance_overlay(instance_id)
953    }
954
955    /// Installs the dedicated MCP-server launch overlay for one exact future
956    /// PTY or ACP spawn.
957    pub fn install_native_mcp_server_launch_overlay(
958        &self,
959        instance_id: AgentInstanceId,
960        overlay: NativeMcpServerLaunchOverlay,
961    ) -> Result<(), NativeLaunchProfileError> {
962        self.lock().install_mcp_server_overlay(instance_id, overlay)
963    }
964
965    /// Clears one dedicated MCP-server launch overlay before or after the
966    /// child lifetime.
967    pub fn clear_native_mcp_server_launch_overlay(&self, instance_id: AgentInstanceId) -> bool {
968        self.lock().clear_mcp_server_overlay(instance_id)
969    }
970
971    pub(crate) fn resolve_launch_overlay(
972        &self,
973        instance_id: AgentInstanceId,
974        agent_id: &AgentId,
975        transport: TransportKind,
976        pipe_binding_is_exact_one_shot: bool,
977    ) -> Result<ResolvedNativeLaunchOverlay, NativeLaunchProfileError> {
978        let spawn_environment = {
979            self.lock().profile_for_spawn(
980                instance_id,
981                agent_id,
982                transport,
983                pipe_binding_is_exact_one_shot,
984            )?
985        };
986        let Some(spawn_environment) = spawn_environment else {
987            return Ok(ResolvedNativeLaunchOverlay {
988                environment: Vec::new(),
989                extra_args: Vec::new(),
990                one_shot_session_persistence: OneShotSessionPersistence::Ephemeral,
991                mcp_server: None,
992            });
993        };
994        let one_shot_session_persistence = spawn_environment
995            .profile
996            .as_ref()
997            .map_or(OneShotSessionPersistence::Ephemeral, |profile| {
998                profile.one_shot_session_persistence
999            });
1000        let mcp_server = spawn_environment
1001            .mcp_server_overlay
1002            .as_ref()
1003            .map(|overlay| overlay.mcp_server().clone());
1004        let mut environment = if let Some(profile) = spawn_environment.profile {
1005            profile.resolve_environment(agent_id, transport)?
1006        } else {
1007            Vec::new()
1008        };
1009        let mut extra_args = Vec::new();
1010        if let Some(overlay) = spawn_environment.overlay {
1011            environment.extend(overlay.environment.iter().cloned());
1012            extra_args.extend(overlay.extra_args.iter().cloned());
1013        }
1014        if let Some(overlay) = spawn_environment.mcp_server_overlay {
1015            environment.extend(overlay.environment.iter().cloned());
1016        }
1017        validate_environment_mutations(&environment)?;
1018        validate_launch_arguments(agent_id, &extra_args)?;
1019        Ok(ResolvedNativeLaunchOverlay {
1020            environment,
1021            extra_args,
1022            one_shot_session_persistence,
1023            mcp_server,
1024        })
1025    }
1026
1027    fn lock(&self) -> MutexGuard<'_, NativeLaunchProfiles> {
1028        self.launch_profiles
1029            .lock()
1030            .unwrap_or_else(|poisoned| poisoned.into_inner())
1031    }
1032}
1033
1034fn validate_owned_environment_keys(keys: &[OsString]) -> Result<(), NativeLaunchProfileError> {
1035    if keys.is_empty() {
1036        return Err(NativeLaunchProfileError::EmptyEnvironmentOwnership);
1037    }
1038    if keys.len() > NATIVE_LAUNCH_PROFILE_ENV_MUTATIONS_MAX {
1039        return Err(NativeLaunchProfileError::TooManyEnvironmentMutations {
1040            count: keys.len(),
1041            max: NATIVE_LAUNCH_PROFILE_ENV_MUTATIONS_MAX,
1042        });
1043    }
1044    let mut normalized_keys = BTreeSet::new();
1045    for (index, key) in keys.iter().enumerate() {
1046        let Some(key) = key.to_str() else {
1047            return Err(NativeLaunchProfileError::InvalidEnvironmentKey { index });
1048        };
1049        if key.is_empty()
1050            || key.len() > NATIVE_LAUNCH_PROFILE_ENV_KEY_MAX_BYTES
1051            || key.contains(['\0', '='])
1052        {
1053            return Err(NativeLaunchProfileError::InvalidEnvironmentKey { index });
1054        }
1055        let normalized_key = key.to_ascii_uppercase();
1056        if normalized_key.starts_with(RESERVED_HOOK_ENV_PREFIX) {
1057            return Err(NativeLaunchProfileError::ReservedHookEnvironmentKey { index });
1058        }
1059        if !normalized_keys.insert(normalized_key) {
1060            return Err(NativeLaunchProfileError::DuplicateEnvironmentKey { index });
1061        }
1062    }
1063    Ok(())
1064}
1065
1066fn validate_resolved_environment(
1067    owned_keys: &[OsString],
1068    environment: &[EnvMutation],
1069) -> Result<(), NativeLaunchProfileError> {
1070    if owned_keys.len() != environment.len() {
1071        return Err(NativeLaunchProfileError::EnvironmentOwnershipMismatch);
1072    }
1073    validate_owned_environment_keys(
1074        &environment
1075            .iter()
1076            .map(|mutation| mutation.key.clone())
1077            .collect::<Vec<_>>(),
1078    )?;
1079    let owned_keys = owned_keys.iter().cloned().collect::<BTreeSet<_>>();
1080    let resolved_keys = environment
1081        .iter()
1082        .map(|mutation| mutation.key.clone())
1083        .collect::<BTreeSet<_>>();
1084    if owned_keys != resolved_keys {
1085        return Err(NativeLaunchProfileError::EnvironmentOwnershipMismatch);
1086    }
1087    validate_environment_mutations(environment)
1088}
1089
1090fn validate_environment_mutations(
1091    environment: &[EnvMutation],
1092) -> Result<(), NativeLaunchProfileError> {
1093    if environment.len() > NATIVE_LAUNCH_PROFILE_ENV_MUTATIONS_MAX {
1094        return Err(NativeLaunchProfileError::TooManyEnvironmentMutations {
1095            count: environment.len(),
1096            max: NATIVE_LAUNCH_PROFILE_ENV_MUTATIONS_MAX,
1097        });
1098    }
1099    let mut normalized_keys = BTreeSet::new();
1100    let mut total_bytes = 0usize;
1101    for (index, mutation) in environment.iter().enumerate() {
1102        let Some(key) = mutation.key.to_str() else {
1103            return Err(NativeLaunchProfileError::InvalidEnvironmentKey { index });
1104        };
1105        if key.is_empty()
1106            || key.len() > NATIVE_LAUNCH_PROFILE_ENV_KEY_MAX_BYTES
1107            || key.contains(['\0', '='])
1108        {
1109            return Err(NativeLaunchProfileError::InvalidEnvironmentKey { index });
1110        }
1111        let normalized_key = key.to_ascii_uppercase();
1112        if normalized_key.starts_with(RESERVED_HOOK_ENV_PREFIX) {
1113            return Err(NativeLaunchProfileError::ReservedHookEnvironmentKey { index });
1114        }
1115        if !normalized_keys.insert(normalized_key) {
1116            return Err(NativeLaunchProfileError::DuplicateEnvironmentKey { index });
1117        }
1118        total_bytes = total_bytes.saturating_add(os_string_bytes(&mutation.key));
1119        if let Some(value) = &mutation.value {
1120            let value_bytes = os_string_bytes(value);
1121            if contains_nul(value) {
1122                return Err(NativeLaunchProfileError::InvalidEnvironmentValue { index });
1123            }
1124            if value_bytes > NATIVE_LAUNCH_PROFILE_ENV_VALUE_MAX_BYTES {
1125                return Err(NativeLaunchProfileError::EnvironmentValueTooLong {
1126                    index,
1127                    max: NATIVE_LAUNCH_PROFILE_ENV_VALUE_MAX_BYTES,
1128                });
1129            }
1130            total_bytes = total_bytes.saturating_add(value_bytes);
1131        }
1132        if total_bytes > NATIVE_LAUNCH_PROFILE_ENV_TOTAL_MAX_BYTES {
1133            return Err(NativeLaunchProfileError::EnvironmentPayloadTooLarge {
1134                max: NATIVE_LAUNCH_PROFILE_ENV_TOTAL_MAX_BYTES,
1135            });
1136        }
1137    }
1138    Ok(())
1139}
1140
1141fn validate_instance_overlay_binding(
1142    profile: &NativeLaunchProfile,
1143    overlay: &NativeInstanceLaunchOverlay,
1144) -> Result<(), NativeLaunchProfileError> {
1145    validate_instance_overlay_spawn_binding(profile.agent_id(), profile.transport(), overlay)?;
1146    let profile_keys = profile
1147        .owned_env_keys
1148        .iter()
1149        .filter_map(|key| key.to_str())
1150        .map(str::to_ascii_uppercase)
1151        .collect::<BTreeSet<_>>();
1152    if overlay.environment.iter().any(|mutation| {
1153        mutation
1154            .key
1155            .to_str()
1156            .is_some_and(|key| profile_keys.contains(&key.to_ascii_uppercase()))
1157    }) {
1158        return Err(NativeLaunchProfileError::EnvironmentOverlayKeyConflict);
1159    }
1160    Ok(())
1161}
1162
1163fn validate_instance_overlay_spawn_binding(
1164    agent_id: &AgentId,
1165    transport: TransportKind,
1166    overlay: &NativeInstanceLaunchOverlay,
1167) -> Result<(), NativeLaunchProfileError> {
1168    if agent_id != &overlay.agent_id || transport != overlay.transport {
1169        return Err(NativeLaunchProfileError::EnvironmentOverlayBindingMismatch);
1170    }
1171    Ok(())
1172}
1173
1174
1175/// Codex config overlays are `-c` / `key=value` pairs (same channel as
1176/// catalog `windows_wsl_setup_acknowledged=true` and station
1177/// `sandbox_workspace_write.network_access`).
1178fn is_codex_config_c_overlay_args(agent_id: &AgentId, arguments: &[OsString]) -> bool {
1179    if agent_id.as_str() != "codex" || arguments.is_empty() || arguments.len() % 2 != 0 {
1180        return false;
1181    }
1182    arguments.chunks_exact(2).all(|pair| {
1183        pair[0].as_os_str() == "-c"
1184            && pair[1]
1185                .to_str()
1186                .is_some_and(|value| !value.is_empty() && value.contains('=') && !value.contains('\0'))
1187    })
1188}
1189
1190fn validate_launch_arguments(
1191    agent_id: &AgentId,
1192    arguments: &[OsString],
1193) -> Result<(), NativeLaunchProfileError> {
1194    if arguments.len() > NATIVE_INSTANCE_LAUNCH_ARGS_MAX {
1195        return Err(NativeLaunchProfileError::TooManyLaunchArguments {
1196            count: arguments.len(),
1197            max: NATIVE_INSTANCE_LAUNCH_ARGS_MAX,
1198        });
1199    }
1200    let mut total_bytes = 0usize;
1201    for (index, argument) in arguments.iter().enumerate() {
1202        if contains_nul(argument) {
1203            return Err(NativeLaunchProfileError::InvalidLaunchArgument { index });
1204        }
1205        let argument_bytes = os_string_bytes(argument);
1206        if argument_bytes > NATIVE_INSTANCE_LAUNCH_ARG_MAX_BYTES {
1207            return Err(NativeLaunchProfileError::LaunchArgumentTooLong {
1208                index,
1209                max: NATIVE_INSTANCE_LAUNCH_ARG_MAX_BYTES,
1210            });
1211        }
1212        total_bytes = total_bytes.saturating_add(argument_bytes);
1213        if total_bytes > NATIVE_INSTANCE_LAUNCH_ARGS_TOTAL_MAX_BYTES {
1214            return Err(NativeLaunchProfileError::LaunchArgumentsPayloadTooLarge {
1215                max: NATIVE_INSTANCE_LAUNCH_ARGS_TOTAL_MAX_BYTES,
1216            });
1217        }
1218        if agent_id.as_str() == "claude" && is_reserved_claude_launch_argument(argument) {
1219            return Err(NativeLaunchProfileError::ReservedClaudeLaunchArgument { index });
1220        }
1221    }
1222    Ok(())
1223}
1224
1225fn is_reserved_claude_launch_argument(argument: &OsStr) -> bool {
1226    let Some(argument) = argument.to_str() else {
1227        return false;
1228    };
1229    RESERVED_CLAUDE_LAUNCH_FLAGS.iter().any(|reserved| {
1230        argument == *reserved
1231            || (reserved.starts_with("--")
1232                && argument
1233                    .strip_prefix(reserved)
1234                    .is_some_and(|suffix| suffix.starts_with('=')))
1235            || (reserved.len() == 2
1236                && argument
1237                    .strip_prefix(reserved)
1238                    .is_some_and(|suffix| !suffix.is_empty() && !suffix.starts_with('-')))
1239    })
1240}
1241
1242fn validate_zai_glm_claude_environment(
1243    environment: &[EnvMutation],
1244) -> Result<(), NativeLaunchProfileError> {
1245    let token = environment
1246        .iter()
1247        .find(|mutation| mutation.key == OsStr::new("ANTHROPIC_AUTH_TOKEN"))
1248        .and_then(|mutation| mutation.value.as_deref())
1249        .and_then(OsStr::to_str)
1250        .filter(|value| !value.trim().is_empty());
1251    if token.is_none() {
1252        return Err(NativeLaunchProfileError::RequiredEnvironmentValueMissing);
1253    }
1254
1255    let endpoint = environment
1256        .iter()
1257        .find(|mutation| mutation.key == OsStr::new("ANTHROPIC_BASE_URL"))
1258        .and_then(|mutation| mutation.value.as_deref());
1259    if endpoint != Some(OsStr::new(ZAI_GLM_ANTHROPIC_BASE_URL)) {
1260        return Err(NativeLaunchProfileError::FixedEnvironmentValueMismatch);
1261    }
1262    Ok(())
1263}
1264
1265#[cfg(test)]
1266mod session_identity_tests {
1267    use super::*;
1268
1269    fn set(k: &str, v: &str) -> EnvMutation {
1270        EnvMutation { key: k.into(), value: Some(v.into()) }
1271    }
1272    fn codex() -> AgentId {
1273        AgentId::new("codex").unwrap()
1274    }
1275
1276    #[test]
1277    fn identity_overlay_needs_no_profile_and_only_takes_identity_keys() {
1278        let o = NativeInstanceLaunchOverlay::new_session_identity(codex(), vec![set("M4A_NICK", "codex-1"), set("M4A_SESSION_CAP", "ab")]).unwrap();
1279        assert!(!o.profile_selection_required);
1280        assert!(NativeInstanceLaunchOverlay::new_session_identity(codex(), vec![set("PATH", "/x")]).is_err());
1281        assert!(NativeInstanceLaunchOverlay::new_session_identity(codex(), vec![]).is_err());
1282        assert!(NativeInstanceLaunchOverlay::new_session_identity(codex(), vec![set("M4A_NICK", "")]).is_err());
1283    }
1284
1285    #[test]
1286    fn identity_appends_to_an_existing_overlay_without_relaxing_it() {
1287        let mut o = NativeInstanceLaunchOverlay::new(codex(), TransportKind::Pty, vec![set("FOO", "1")], Vec::new()).unwrap();
1288        assert!(o.profile_selection_required);
1289        o.append_session_identity(vec![set("M4A_NICK", "codex-2"), set("M4A_SESSION_CAP", "cd")]).unwrap();
1290        assert!(o.profile_selection_required);
1291        assert_eq!(o.environment.len(), 3);
1292        assert!(o.append_session_identity(vec![set("M4A_NICK", "again")]).is_err(), "duplicate key refused");
1293        assert!(o.append_session_identity(vec![set("HOME", "/h")]).is_err());
1294        let mut e = NativeLaunchEnvironmentOverlay::new(codex(), TransportKind::Pty, vec![set("BAR", "2")]).unwrap();
1295        e.append_session_identity(vec![set("M4A_SESSION_CAP", "ef")]).unwrap();
1296        assert_eq!(e.environment.len(), 2);
1297    }
1298}
1299
1300#[cfg(test)]
1301mod tests {
1302    use super::*;
1303
1304    struct CodexEnvironment;
1305
1306    impl NativeChildEnvironmentResolver for CodexEnvironment {
1307        fn resolve_child_environment(
1308            &self,
1309        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
1310            Ok(vec![EnvMutation {
1311                key: OsString::from("GATE4AGENT_TEST_CODEX_PROFILE"),
1312                value: Some(OsString::from("selected")),
1313            }])
1314        }
1315    }
1316
1317    fn persistent_codex_profile() -> NativeLaunchProfile {
1318        NativeLaunchProfile::new(
1319            NativeLaunchProfileId::new("persistent-codex").unwrap(),
1320            AgentId::new("codex").unwrap(),
1321            TransportKind::Pipe,
1322            vec![OsString::from("GATE4AGENT_TEST_CODEX_PROFILE")],
1323            Arc::new(CodexEnvironment),
1324        )
1325        .unwrap()
1326        .with_one_shot_session_persistence(OneShotSessionPersistence::Persist)
1327        .unwrap()
1328    }
1329
1330    #[test]
1331    fn selected_persistence_is_snapshotted_and_clear_restores_ephemeral_default() {
1332        let control = NativeLaunchProfileControl::new();
1333        let profile = persistent_codex_profile();
1334        let profile_id = profile.id().clone();
1335        control.upsert(profile).unwrap();
1336        let selected = AgentInstanceId(1);
1337        control
1338            .select_native_launch_profile(selected, profile_id)
1339            .unwrap();
1340
1341        let resolved = control
1342            .resolve_launch_overlay(
1343                selected,
1344                &AgentId::new("codex").unwrap(),
1345                TransportKind::Pipe,
1346                true,
1347            )
1348            .unwrap();
1349        assert_eq!(
1350            resolved.one_shot_session_persistence,
1351            OneShotSessionPersistence::Persist
1352        );
1353
1354        assert!(control.clear_native_launch_profile_selection(selected));
1355        let cleared = control
1356            .resolve_launch_overlay(
1357                selected,
1358                &AgentId::new("codex").unwrap(),
1359                TransportKind::Pipe,
1360                true,
1361            )
1362            .unwrap();
1363        assert_eq!(
1364            cleared.one_shot_session_persistence,
1365            OneShotSessionPersistence::Ephemeral
1366        );
1367
1368        let unselected = control
1369            .resolve_launch_overlay(
1370                AgentInstanceId(2),
1371                &AgentId::new("codex").unwrap(),
1372                TransportKind::Pipe,
1373                true,
1374            )
1375            .unwrap();
1376        assert_eq!(
1377            unselected.one_shot_session_persistence,
1378            OneShotSessionPersistence::Ephemeral
1379        );
1380    }
1381
1382    /// A caller-supplied MCP-server overlay spec, generic on `agent_id` --
1383    /// mirrors what `hatchery-node` builds today (name `"hatchery"`,
1384    /// `--session-proxy`, endpoint/token/trace env entries, and the two
1385    /// legacy `GATE4AGENT_HARNESS_READ_*` names as removals), but under a
1386    /// caller-chosen name and env keys this crate never interprets.
1387    fn sample_mcp_server_overlay(agent_id: &str) -> NativeMcpServerLaunchOverlay {
1388        NativeMcpServerLaunchOverlay::new(
1389            AgentId::new(agent_id).unwrap(),
1390            "test-mcp-server",
1391            OsString::from("reviewed-program"),
1392            vec![OsString::from("--session-proxy")],
1393            vec![
1394                (
1395                    OsString::from("TEST_MCP_SESSION_ENDPOINT"),
1396                    OsString::from("private-endpoint"),
1397                ),
1398                (
1399                    OsString::from("TEST_MCP_SESSION_TOKEN"),
1400                    OsString::from("private-token"),
1401                ),
1402            ],
1403            vec![
1404                OsString::from("TEST_MCP_LEGACY_ENDPOINT"),
1405                OsString::from("TEST_MCP_LEGACY_CREDENTIAL"),
1406            ],
1407        )
1408        .unwrap()
1409    }
1410
1411    #[test]
1412    fn mcp_server_overlay_sets_given_environment_and_scrubs_given_removals() {
1413        let control = NativeLaunchProfileControl::new();
1414        let instance_id = AgentInstanceId(41);
1415        control
1416            .install_native_mcp_server_launch_overlay(instance_id, sample_mcp_server_overlay("codex"))
1417            .unwrap();
1418        let resolved = control
1419            .resolve_launch_overlay(
1420                instance_id,
1421                &AgentId::new("codex").unwrap(),
1422                TransportKind::Pty,
1423                true,
1424            )
1425            .unwrap();
1426        assert_eq!(resolved.environment.len(), 4);
1427        for key in ["TEST_MCP_LEGACY_ENDPOINT", "TEST_MCP_LEGACY_CREDENTIAL"] {
1428            assert!(resolved.environment.iter().any(|mutation| {
1429                mutation.key == OsString::from(key) && mutation.value.is_none()
1430            }));
1431        }
1432        for key in ["TEST_MCP_SESSION_ENDPOINT", "TEST_MCP_SESSION_TOKEN"] {
1433            assert!(resolved.environment.iter().any(|mutation| {
1434                mutation.key == OsString::from(key) && mutation.value.is_some()
1435            }));
1436        }
1437        let mcp_server = resolved
1438            .mcp_server
1439            .expect("mcp server spec resolved for this spawn");
1440        assert_eq!(mcp_server.name(), "test-mcp-server");
1441        assert_eq!(mcp_server.program(), OsStr::new("reviewed-program"));
1442        assert_eq!(mcp_server.args(), &[OsString::from("--session-proxy")]);
1443        assert_eq!(mcp_server.env().len(), 2, "exactly the two entries given, never the removals");
1444    }
1445
1446    #[test]
1447    fn mcp_server_overlay_admits_acp_transport_for_the_same_provider() {
1448        let control = NativeLaunchProfileControl::new();
1449        let instance_id = AgentInstanceId(42);
1450        control
1451            .install_native_mcp_server_launch_overlay(instance_id, sample_mcp_server_overlay("claude"))
1452            .unwrap();
1453        let resolved = control
1454            .resolve_launch_overlay(
1455                instance_id,
1456                &AgentId::new("claude").unwrap(),
1457                TransportKind::Acp,
1458                true,
1459            )
1460            .unwrap();
1461        assert_eq!(resolved.environment.len(), 4);
1462        assert!(resolved.mcp_server.is_some());
1463    }
1464
1465    #[test]
1466    fn mcp_server_overlay_refuses_acp_transport_for_a_different_provider() {
1467        let control = NativeLaunchProfileControl::new();
1468        let instance_id = AgentInstanceId(43);
1469        control
1470            .install_native_mcp_server_launch_overlay(instance_id, sample_mcp_server_overlay("codex"))
1471            .unwrap();
1472        let result = control.resolve_launch_overlay(
1473            instance_id,
1474            &AgentId::new("claude").unwrap(),
1475            TransportKind::Acp,
1476            true,
1477        );
1478        match result {
1479            Ok(_) => panic!("expected the MCP server overlay gate to refuse a mismatched provider"),
1480            Err(error) => assert_eq!(
1481                error,
1482                NativeLaunchProfileError::McpServerOverlayBindingMismatch {
1483                    transport: TransportKind::Acp,
1484                    overlay_provider: AgentId::new("codex").unwrap(),
1485                    spawn_provider: AgentId::new("claude").unwrap(),
1486                }
1487            ),
1488        }
1489    }
1490
1491    #[test]
1492    fn mcp_server_overlay_refuses_pipe_transport_by_name() {
1493        let control = NativeLaunchProfileControl::new();
1494        let instance_id = AgentInstanceId(44);
1495        control
1496            .install_native_mcp_server_launch_overlay(instance_id, sample_mcp_server_overlay("codex"))
1497            .unwrap();
1498        let result = control.resolve_launch_overlay(
1499            instance_id,
1500            &AgentId::new("codex").unwrap(),
1501            TransportKind::Pipe,
1502            true,
1503        );
1504        match result {
1505            Ok(_) => panic!("expected the MCP server overlay gate to refuse Pipe transport"),
1506            Err(error) => assert_eq!(
1507                error,
1508                NativeLaunchProfileError::McpServerOverlayBindingMismatch {
1509                    transport: TransportKind::Pipe,
1510                    overlay_provider: AgentId::new("codex").unwrap(),
1511                    spawn_provider: AgentId::new("codex").unwrap(),
1512                }
1513            ),
1514        }
1515    }
1516}
1517
1518fn os_string_bytes(value: &OsStr) -> usize {
1519    value.to_string_lossy().len()
1520}
1521
1522fn contains_nul(value: &OsStr) -> bool {
1523    value.to_string_lossy().contains('\0')
1524}