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