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#[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
134pub 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#[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#[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
206pub 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
219pub struct NativeMcpServerLaunchOverlay {
238 agent_id: AgentId,
239 environment: Vec<EnvMutation>,
240 mcp_server: McpServerSpec,
241}
242
243impl NativeMcpServerLaunchOverlay {
244 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 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
312pub 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 Ok(Self {
339 agent_id,
340 transport,
341 environment,
342 profile_selection_required: true,
343 })
344 }
345
346 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 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 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 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
506pub 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#[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 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 pub fn clear_native_launch_profile_selection(&self, instance_id: AgentInstanceId) -> bool {
863 self.lock().clear_selection(instance_id)
864 }
865
866 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 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 pub fn clear_native_instance_launch_overlay(&self, instance_id: AgentInstanceId) -> bool {
889 self.lock().clear_instance_overlay(instance_id)
890 }
891
892 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 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
1112fn 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 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}