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