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