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