Skip to main content

mj_controller/controller/worker_binary/
launch.rs

1use super::*;
2
3fn disable_mbx_cache_shims(
4    session_id: &str,
5    backend: &targets::TargetLocator,
6    worker_root: &str,
7    executor: &impl CommandExecutor,
8    reason: &str,
9) {
10    let cleanup = remove_generated_mbx_shims(executor, backend, worker_root);
11    if let Err(error) = &cleanup {
12        tracing::warn!(
13            session_id,
14            "could not remove marked shared-mbx launchers: {error:#}"
15        );
16    }
17    let cleanup_note = cleanup
18        .err()
19        .map(|error| format!(" Mjolnir could not remove its marked launchers: {error:#}."))
20        .unwrap_or_default();
21    tracing::warn!(
22        session_id,
23        "shared mbx is unavailable in this container: {reason}"
24    );
25    executor.notify_notice(&format!(
26        "The Rust build cache is unavailable in this container: {reason}.{cleanup_note} The session will start without it."
27    ));
28}
29
30fn build_cache_config_roots(launch: &WorkerLaunchConfig) -> Vec<PathBuf> {
31    [&launch.target_environment, &launch.environment]
32        .into_iter()
33        .filter_map(|environment| environment.get("XDG_CONFIG_HOME").map(PathBuf::from))
34        .collect::<std::collections::BTreeSet<_>>()
35        .into_iter()
36        .collect()
37}
38
39impl Controller {
40    /// Where this session's worker lives. This is decided from the session
41    /// record and configuration alone, so a caller can name the worker root
42    /// before anything is installed into it.
43    pub(in crate::controller) fn worker_placement(
44        &self,
45        session_id: &str,
46    ) -> Result<(targets::TargetLocator, String)> {
47        let session = self
48            .state
49            .sessions
50            .get(session_id)
51            .with_context(|| format!("unknown session {session_id}"))?;
52        let locator = session
53            .target
54            .as_ref()
55            .context("session target is missing")?;
56        if let Some(owner) = crate::worker_lifecycle::current(session_id) {
57            owner.verify_target(locator)?;
58        }
59        let backend = backend_locator(locator, session, &self.config)?;
60        let worker_root = targets::worker_root(&backend, session_id)?;
61        Ok((backend, worker_root))
62    }
63
64    pub(in crate::controller) fn prepare_worker_files(
65        &self,
66        session_id: &str,
67        backend: &targets::TargetLocator,
68        worker_root: &str,
69        executor: &impl CommandExecutor,
70    ) -> Result<()> {
71        let owner = crate::worker_lifecycle::require(session_id)?;
72        let session = self
73            .state
74            .sessions
75            .get(session_id)
76            .with_context(|| format!("unknown session {session_id}"))?;
77        if let Some(target) = session.target.as_ref() {
78            owner.verify_target(target)?;
79        }
80        let profile = self
81            .config
82            .profiles
83            .get(&session.last_profile)
84            .context("session profile is missing")?;
85        profile.ensure_ready(&session.last_profile)?;
86        let (mut launch, project_memory, target_profile_home) =
87            self.session_launch_config(session_id, backend)?;
88
89        if session.native_session_id.is_some()
90            && profile.kind == mj_core::config::HarnessKind::Codex
91        {
92            launch.goal_resume_request = Some(mj_core::state::new_session_id()?);
93        }
94        let staging = tempfile::tempdir().context("create worker staging directory")?;
95        let launch_path = staging.path().join("launch.json");
96        launch.write(&launch_path)?;
97        let ownership_path = staging.path().join("ownership.json");
98        WorkerOwnership {
99            version: WorkerOwnership::VERSION,
100            workspace_id: session.workspace_id.clone(),
101            session_id: session_id.to_string(),
102            profile_id: session.last_profile.clone(),
103            bundle_id: session.bundle_id.clone(),
104            target_template_id: session.target_template_id.clone(),
105            instance_id: Some(mj_core::config::instance_identity()),
106        }
107        .write(&ownership_path)?;
108        // Every session runs from a staged copy of its profile, on every target,
109        // so `target_profile_home` is always a home the session owns and the
110        // stage is always installed there.
111        let profile_stage = staging.path().join("profile");
112        let started = Instant::now();
113        let result = stage_profile(profile, &profile_stage);
114        tracing::debug!(
115            session_id,
116            elapsed_ms = started.elapsed().as_millis(),
117            "profile staging completed"
118        );
119        result?;
120        stage_managed_skills(
121            profile.kind,
122            &profile_stage,
123            session
124                .target
125                .as_ref()
126                .context("session target is missing")?
127                .skills_scope(),
128        )?;
129        stage_codex_catalog(
130            &session.last_profile,
131            profile,
132            &profile_stage,
133            &fetch_catalog_over_https,
134            &SharedCatalogCache,
135        )?;
136        append_hel_target_environment(profile.kind, &profile_stage, backend)?;
137        append_container_storage_guidance(
138            profile.kind,
139            &profile_stage,
140            backend,
141            session.container_workspace.as_deref(),
142            targets::has_managed_temporary_volume(backend, executor)?,
143        )?;
144        append_subagent_policy(
145            profile.kind,
146            &profile_stage,
147            &launch.subagents,
148            self.config.subagents.max_concurrent,
149        )?;
150        apply_staged_execution_setting(profile.kind, launch.execution_policy, &profile_stage)?;
151        if profile.kind == mj_core::config::HarnessKind::Claude {
152            if let Some(role) = launch.subagents.parent_role() {
153                configure_claude_subagent_mcp(&profile_stage, worker_root, role)?;
154            } else if launch.handback_tool {
155                configure_claude_subagent_mcp(
156                    &profile_stage,
157                    worker_root,
158                    mj_core::subagent::SubagentMcpRole::Child,
159                )?;
160            }
161        }
162        stage_memory_replica(
163            &project_memory,
164            Path::new(&target_profile_home),
165            &profile_stage,
166        )?;
167        if project_memory.mcp_delivery == ProjectMemoryMcpDelivery::HarnessProfile {
168            configure_kimi_history_mcp(
169                &profile_stage,
170                worker_root,
171                project_memory.history_socket.as_deref(),
172            )?;
173        }
174        let worker_binary = worker_binary_for(backend, executor)?;
175
176        install_worker_files(
177            executor,
178            backend,
179            session_id,
180            worker_root,
181            &target_profile_home,
182            &worker_binary,
183            &launch_path,
184            &ownership_path,
185            &profile_stage,
186        )?;
187        if session.build_cache.is_some() {
188            self.install_build_cache_shim(session, backend, &launch, executor)
189                .context("install shared machine build cache configuration")?;
190        }
191        prepare_installed_managed_harness(executor, backend, worker_root, &launch)
192    }
193
194    /// Refresh and verify the shared-cache copy, then install marked launchers
195    /// in the session's `bin` directory on the worker PATH.
196    pub(in crate::controller) fn install_build_cache_shim(
197        &self,
198        session: &mj_core::state::SessionRecord,
199        backend: &targets::TargetLocator,
200        launch: &WorkerLaunchConfig,
201        executor: &impl CommandExecutor,
202    ) -> Result<()> {
203        let Some(cache) = session.build_cache.as_ref() else {
204            return Ok(());
205        };
206        let worker_root = targets::worker_root(backend, &session.id)?;
207        let config_roots = build_cache_config_roots(launch);
208        let configuration = crate::controller::mbx::shared_configuration_file(&cache.directory);
209        if link_legacy_mbx_configuration(
210            executor,
211            backend,
212            &worker_root,
213            &configuration,
214            &config_roots,
215        )? {
216            return Ok(());
217        }
218        let binary = match crate::controller::mbx::sync_mbx_binary_for_container(
219            backend,
220            &cache.directory,
221            executor,
222        ) {
223            Ok(crate::controller::mbx::CachedMbxSync::Ready(binary)) => binary,
224            Ok(crate::controller::mbx::CachedMbxSync::Unavailable(reason)) => {
225                disable_mbx_cache_shims(&session.id, backend, &worker_root, executor, &reason);
226                return Ok(());
227            }
228            Err(error) => {
229                disable_mbx_cache_shims(
230                    &session.id,
231                    backend,
232                    &worker_root,
233                    executor,
234                    &format!("{error:#}"),
235                );
236                return Ok(());
237            }
238        };
239        if let Err(error) = verify_mbx_binary(executor, backend, &binary.path, &binary.version) {
240            disable_mbx_cache_shims(
241                &session.id,
242                backend,
243                &worker_root,
244                executor,
245                &format!("{error:#}"),
246            );
247            return Ok(());
248        }
249        let (configuration, config_roots) =
250            self.build_cache_configuration(session, backend, launch, executor)?;
251        install_mbx_shims(
252            executor,
253            backend,
254            &worker_root,
255            &binary.path,
256            &configuration,
257            &config_roots,
258        )
259    }
260
261    pub(in crate::controller) fn prepare_build_cache_links(
262        &self,
263        session: &mj_core::state::SessionRecord,
264        backend: &targets::TargetLocator,
265        launch: &WorkerLaunchConfig,
266        executor: &impl CommandExecutor,
267    ) -> Result<()> {
268        self.install_build_cache_shim(session, backend, launch, executor)
269    }
270
271    fn build_cache_configuration(
272        &self,
273        session: &mj_core::state::SessionRecord,
274        backend: &targets::TargetLocator,
275        launch: &WorkerLaunchConfig,
276        executor: &impl CommandExecutor,
277    ) -> Result<(PathBuf, Vec<PathBuf>)> {
278        let configuration = crate::controller::mbx::prepare_session_configuration(
279            &self.config,
280            backend,
281            session
282                .build_cache
283                .as_ref()
284                .context("session has no build cache")?,
285            executor,
286        )?;
287        let config_roots = build_cache_config_roots(launch);
288        Ok((configuration, config_roots))
289    }
290
291    /// Probe the installed binary and the worker's recorded state after a
292    /// session becomes unreachable. Returns `None` only when the session has
293    /// no target to probe; a probe that fails says why.
294    pub fn diagnose_worker(&self, session_id: &str) -> Option<String> {
295        self.diagnose_worker_controlled(session_id, &crate::targets::ProcessExecutor)
296    }
297
298    pub fn diagnose_worker_controlled(
299        &self,
300        session_id: &str,
301        executor: &impl CommandExecutor,
302    ) -> Option<String> {
303        let session = self.state.sessions.get(session_id)?;
304        let locator = session.target.as_ref()?;
305        let backend = match backend_locator(locator, session, &self.config) {
306            Ok(backend) => backend,
307            Err(error) => {
308                tracing::debug!(
309                    session_id,
310                    error = format!("{error:#}"),
311                    "could not construct a worker diagnostic probe"
312                );
313                return None;
314            }
315        };
316        let worker_root = match targets::worker_root(&backend, session_id) {
317            Ok(root) => root,
318            Err(error) => {
319                tracing::debug!(
320                    session_id,
321                    error = format!("{error:#}"),
322                    "could not derive the worker diagnostic root"
323                );
324                return None;
325            }
326        };
327        let binary_failure = worker_binary_probe_failure(executor, &backend, &worker_root);
328        let probe = match probe_worker(executor, &backend, &worker_root) {
329            Ok(probe) => probe.to_string(),
330            Err(error) => format!("the worker could not be probed: {error:#}"),
331        };
332        Some(match binary_failure {
333            Some(binary_failure) => format!("{binary_failure}; {probe}"),
334            None => probe,
335        })
336    }
337
338    /// A non-destructive liveness probe plus commands that replace a confirmed
339    /// dead session worker without touching its durable relay files. The
340    /// session manager runs both off its async actor.
341    pub fn worker_recovery_plan(
342        &self,
343        session_id: &str,
344        operation: Option<&mj_core::state::MoveOperation>,
345    ) -> Result<WorkerRecoveryPlan> {
346        let (backend, worker_root) = self.worker_placement(session_id)?;
347        let launch = self.worker_launch_config_for_move(session_id, &backend, operation)?;
348        let workspace = worker_workspace_for_recovery(&backend, &launch.cwd);
349        Ok(WorkerRecoveryPlan {
350            source_target: self.state.sessions[session_id]
351                .target
352                .clone()
353                .context("session target is missing")?,
354            target: targets::target_recovery_plan(&backend, session_id)?,
355            workspace,
356            liveness_probe: worker_liveness_command(&backend, &worker_root),
357            exit_record: Some(worker_exit_record_command(&backend, &worker_root)),
358            binary_refresh: worker_binary_refresh_plan(&backend, session_id)?,
359            launch_refresh: Some(worker_launch_refresh_plan(&backend, session_id, &launch)?),
360            restart: CommandPlan {
361                description: format!("restart Mjolnir worker for session {session_id}"),
362                commands: vec![
363                    stop_worker_command(&backend, &worker_root),
364                    start_worker_command(&backend, &worker_root),
365                ],
366            },
367        })
368    }
369
370    /// The launch config for a session, including what depends on its
371    /// sub-agent role. The first launch and every relaunch use this, so a
372    /// relaunched worker keeps its delegation tools and a relaunched child
373    /// keeps its parent's workspace.
374    fn session_launch_config(
375        &self,
376        session_id: &str,
377        backend: &targets::TargetLocator,
378    ) -> Result<(WorkerLaunchConfig, ProjectMemoryLaunchConfig, String)> {
379        let session = self
380            .state
381            .sessions
382            .get(session_id)
383            .with_context(|| format!("unknown session {session_id}"))?;
384        session.validate_configuration(&self.config)?;
385        let profile = self
386            .config
387            .profiles
388            .get(&session.last_profile)
389            .context("session profile is missing")?;
390        let checkout = self.state.checkout(session_id)?;
391        let bundle = checkout
392            .project_directory()
393            .is_none()
394            .then(|| session.project_bundle(&self.config))
395            .flatten();
396        let target = session.target_runtime_settings(&self.config)?;
397        let subagent = self.state.subagents.get(session_id);
398        // A sub-agent child shares its parent's container, so it works in the
399        // parent's workspace. The parent record is authoritative for that path.
400        let (workspace_session_id, workspace_container) = match subagent.as_ref() {
401            Some(child) => {
402                let parent = self
403                    .state
404                    .sessions
405                    .get(&child.parent_session_id)
406                    .context("sub-agent parent session is missing")?;
407                (parent.id.clone(), parent.container_workspace.clone())
408            }
409            None => (session_id.to_owned(), session.container_workspace.clone()),
410        };
411        let (mut launch, project_memory, target_profile_home) = worker_launch_config_with_checkout(
412            session,
413            &checkout,
414            profile,
415            bundle,
416            backend,
417            LaunchWorkspace {
418                session_id: &workspace_session_id,
419                container: workspace_container.as_deref(),
420                parent_worktree: self.subagent_parent_worktree(session_id),
421            },
422            &target,
423        )?;
424        apply_jev_switch(&mut launch, self.config.jev.enabled);
425        apply_continuation_switch(&mut launch, self.config.automatic_continuation_enabled());
426        launch.subagents = session
427            .subagents
428            .clone()
429            .unwrap_or_default()
430            .for_launch(profile.kind, subagent.is_some());
431        // Registration decided whether this child can be given the tool.
432        launch.handback_tool = subagent.as_ref().is_some_and(|child| child.handback_tool);
433        // The child's harness opens on its spawn model instead of switching
434        // to it from the profile default before the first turn (issue 1217).
435        launch.initial_model = subagent.as_ref().and_then(|child| child.model.clone());
436        // Capturing the working tree is only ever useful to a turn review, so
437        // it is spent only on a session a review can run for: one whose
438        // configuration has an eligible reviewer, and that is not a child. A child
439        // works in its parent's tree, and reviewing it would report the
440        // parent's work as the child's.
441        launch.review_capture =
442            mj_core::review::settings::can_review(&self.config) && subagent.is_none();
443        if let Some(subagent) = &subagent {
444            let parent = self
445                .state
446                .sessions
447                .get(&subagent.parent_session_id)
448                .context("sub-agent parent session is missing")?;
449            let parent_profile = self
450                .config
451                .profiles
452                .get(&parent.last_profile)
453                .context("sub-agent parent profile is missing")?;
454            let parent_target = parent.target_runtime_settings(&self.config)?;
455            let parent_locator = parent
456                .target
457                .as_ref()
458                .context("sub-agent parent has no live target")?;
459            let parent_backend = backend_locator(parent_locator, parent, &self.config)?;
460            let parent_checkout = self.state.checkout(&parent.id)?;
461            let parent_bundle = parent_checkout
462                .project_directory()
463                .is_none()
464                .then(|| parent.project_bundle(&self.config))
465                .flatten();
466            let (parent_launch, _, _) = worker_launch_config_with_checkout(
467                parent,
468                &parent_checkout,
469                parent_profile,
470                parent_bundle,
471                &parent_backend,
472                LaunchWorkspace {
473                    session_id: &parent.id,
474                    container: parent.container_workspace.as_deref(),
475                    parent_worktree: None,
476                },
477                &parent_target,
478            )?;
479            launch.cwd = if subagent.working_directory.as_os_str().is_empty() {
480                parent_launch.cwd
481            } else {
482                parent_launch.cwd.join(&subagent.working_directory)
483            };
484            launch.additional_directories = parent_launch.additional_directories;
485        }
486        Ok((launch, project_memory, target_profile_home))
487    }
488
489    pub(in crate::controller) fn current_worker_launch_config(
490        &self,
491        session_id: &str,
492        backend: &targets::TargetLocator,
493    ) -> Result<WorkerLaunchConfig> {
494        let operation = crate::database::load_move_operation(session_id)?;
495        self.worker_launch_config_for_move(session_id, backend, operation.as_ref())
496    }
497
498    fn worker_launch_config_for_move(
499        &self,
500        session_id: &str,
501        backend: &targets::TargetLocator,
502        operation: Option<&mj_core::state::MoveOperation>,
503    ) -> Result<WorkerLaunchConfig> {
504        let session = self
505            .state
506            .sessions
507            .get(session_id)
508            .with_context(|| format!("unknown session {session_id}"))?;
509        let (mut launch, _, _) = self.session_launch_config(session_id, backend)?;
510        if operation.is_some_and(|operation| {
511            operation.source_checkpoint_only
512                && operation.destination_target.is_none()
513                && matches!(
514                    operation.phase,
515                    mj_core::state::MovePhase::Preparing
516                        | mj_core::state::MovePhase::ClosingSource
517                        | mj_core::state::MovePhase::Failed
518                        | mj_core::state::MovePhase::Cancelled
519                )
520                && session.last_profile == operation.source_profile_id
521                && session.target == operation.source_target
522                && matches!(
523                    session.state,
524                    mj_core::state::SessionState::Running
525                        | mj_core::state::SessionState::Disconnected
526                        | mj_core::state::SessionState::Closing
527                )
528        }) {
529            launch.run_mode = mj_core::worker_launch::WorkerRunMode::CheckpointOnly;
530        }
531        Ok(launch)
532    }
533
534    pub fn project_memory_sync_target(&self, session_id: &str) -> Result<ProjectMemorySyncTarget> {
535        let session = self
536            .state
537            .sessions
538            .get(session_id)
539            .with_context(|| format!("unknown session {session_id}"))?;
540        session.validate_configuration(&self.config)?;
541        let locator = session
542            .target
543            .as_ref()
544            .context("session target is missing")?;
545        let backend = backend_locator(locator, session, &self.config)?;
546        let profile = self
547            .config
548            .profiles
549            .get(&session.last_profile)
550            .context("session profile is missing")?;
551        let checkout = self.state.checkout(session_id)?;
552        let bundle = checkout
553            .project_directory()
554            .is_none()
555            .then(|| session.project_bundle(&self.config))
556            .flatten();
557        let workspace = if let Some(project_directory) = checkout.project_directory() {
558            (project_directory.to_string_lossy().into_owned(), Vec::new())
559        } else {
560            workspace_paths(
561                &backend,
562                bundle.context("session bundle is missing")?,
563                session_id,
564                session.container_workspace.as_deref(),
565            )?
566        };
567        let target_home = target_profile_home(&backend, session_id, profile);
568        let launch = project_memory_launch(
569            session,
570            bundle,
571            &workspace,
572            &target_home,
573            self.subagent_parent_worktree(session_id),
574        )?;
575        Ok(ProjectMemorySyncTarget {
576            canonical_root: canonical_memory_root(&launch.project_key),
577        })
578    }
579}
580
581/// Carries `[jev] enabled = false` to the worker, which reads it from its
582/// launch environment, and keeps the Jev key out of the worker and the
583/// harness so nothing can reach the service.
584pub(super) fn apply_jev_switch(launch: &mut WorkerLaunchConfig, enabled: bool) {
585    if enabled {
586        return;
587    }
588    for environment in [&mut launch.target_environment, &mut launch.environment] {
589        environment.remove("TYPESAFE_API_KEY");
590        environment.insert(
591            mj_core::jev::DISABLED_ENVIRONMENT.to_owned(),
592            "1".to_owned(),
593        );
594    }
595}
596
597/// Tell the worker when nobody will act on a `Continue` verdict, so it does
598/// not park the assessment as deferred. Travels like the Jev switch.
599pub(super) fn apply_continuation_switch(launch: &mut WorkerLaunchConfig, enabled: bool) {
600    if enabled {
601        return;
602    }
603    for environment in [&mut launch.target_environment, &mut launch.environment] {
604        environment.insert(
605            mj_core::jev::CONTINUATION_DISABLED_ENVIRONMENT.to_owned(),
606            "1".to_owned(),
607        );
608    }
609}
610
611/// Whether this session gets Mjolnir's delegation tools in place of its
612/// harness's own. The session's stored choice governs; `None` means native
613/// sub-agents, so a session created before the per-session choice existed (or
614/// through `mj new` with neither flag given) gets its harness's own
615/// sub-agents. A child never gets them, and only Claude and Codex can receive
616/// them at all.
617#[cfg(test)]
618pub(super) fn subagent_tools_enabled(
619    session: &mj_core::state::SessionRecord,
620    is_child: bool,
621) -> bool {
622    session
623        .subagents
624        .clone()
625        .unwrap_or_default()
626        .for_launch(session.harness_kind, is_child)
627        .uses_mjolnir()
628}
629
630pub(super) fn worker_workspace_for_recovery(
631    backend: &targets::TargetLocator,
632    directory: &Path,
633) -> Option<WorkerWorkspace> {
634    let target = match backend {
635        targets::TargetLocator::LocalBare { .. } => mj_core::state::ManagedWorktreeTarget::Local,
636        targets::TargetLocator::SshBare { ssh, .. } => mj_core::state::ManagedWorktreeTarget::Ssh {
637            destination: ssh.destination.clone(),
638            ssh_args: ssh.ssh_args.clone(),
639        },
640        targets::TargetLocator::LocalPodman { .. }
641        | targets::TargetLocator::LocalDocker { .. }
642        | targets::TargetLocator::AppleContainer { .. }
643        | targets::TargetLocator::AwsEc2 { .. }
644        | targets::TargetLocator::SshPodman { .. }
645        | targets::TargetLocator::SshDocker { .. } => return None,
646    };
647    Some(WorkerWorkspace {
648        target,
649        directory: directory.to_path_buf(),
650    })
651}
652
653pub(super) struct LaunchWorkspace<'a> {
654    pub session_id: &'a str,
655    pub container: Option<&'a Path>,
656    pub parent_worktree: Option<&'a mj_core::state::ManagedWorktree>,
657}
658
659#[cfg(test)]
660pub(super) fn worker_launch_config(
661    session: &mj_core::state::SessionRecord,
662    profile: &mj_core::config::HarnessProfile,
663    bundle: Option<&ProjectBundle>,
664    backend: &targets::TargetLocator,
665    worker_workspace: LaunchWorkspace<'_>,
666    target: &mj_core::state::TargetRuntimeSettings,
667) -> Result<(WorkerLaunchConfig, ProjectMemoryLaunchConfig, String)> {
668    worker_launch_config_with_checkout(
669        session,
670        &session.checkout(),
671        profile,
672        bundle,
673        backend,
674        worker_workspace,
675        target,
676    )
677}
678
679pub(super) fn worker_launch_config_with_checkout(
680    session: &mj_core::state::SessionRecord,
681    checkout: &mj_core::state::Checkout<'_>,
682    profile: &mj_core::config::HarnessProfile,
683    bundle: Option<&ProjectBundle>,
684    backend: &targets::TargetLocator,
685    worker_workspace: LaunchWorkspace<'_>,
686    target: &mj_core::state::TargetRuntimeSettings,
687) -> Result<(WorkerLaunchConfig, ProjectMemoryLaunchConfig, String)> {
688    let session_id = session.id.as_str();
689    let execution_policy = target.execution_policy;
690    let target_profile_home = target_profile_home(backend, session_id, profile);
691    let workspace = if let Some(project_directory) = checkout.project_directory() {
692        (project_directory.to_string_lossy().into_owned(), Vec::new())
693    } else {
694        workspace_paths(
695            backend,
696            bundle.context("session bundle is missing")?,
697            worker_workspace.session_id,
698            worker_workspace.container,
699        )?
700    };
701    let mut additional_directories = workspace.1.iter().map(PathBuf::from).collect::<Vec<_>>();
702    additional_directories.extend(
703        session
704            .additional_mounts
705            .iter()
706            .map(|resource| resource.destination.clone()),
707    );
708    if profile.kind == mj_core::config::HarnessKind::Muse && !additional_directories.is_empty() {
709        bail!(
710            "{} ACP does not support multiple workspace roots; use a single-repository bundle",
711            profile.kind.display_name()
712        );
713    }
714    let (bridge_command, bridge_args) = bridge_launch(profile.kind, execution_policy);
715    let mut target_environment = target.environment.clone();
716    let podman_container = backend.container_engine() == Some("podman");
717    if podman_container {
718        // Provisioning writes inherited settings to this file under the
719        // container's configured home. A root Podman worker can have
720        // HOME=/root, so the session Git config includes the file by path.
721        target_environment.insert(
722            mj_core::worker_launch::SESSION_GIT_CONFIG_INCLUDE_PATH.into(),
723            "/home/hel/.gitconfig".into(),
724        );
725    }
726    // The turn bounds are read by the worker process, which re-execs with a
727    // cleared environment, so a value set for the daemon cannot reach it by
728    // inheritance. Carry the two knobs explicitly when the daemon was started
729    // with them, so shortening a timeout for a test works on every target and
730    // not only on the container targets that can set it in configuration.
731    // `RUST_LOG` travels the same way and for the same reason: a worker that
732    // has gone quiet is diagnosed from its own log, and the log level cannot
733    // be raised after the fact on a worker that re-execs with a cleared
734    // environment.
735    for name in [
736        "MJ_TURN_STALL_TIMEOUT_MS",
737        "MJ_TURN_TOOL_STALL_TIMEOUT_MS",
738        "RUST_LOG",
739    ] {
740        if let Ok(value) = std::env::var(name) {
741            target_environment.insert(name.to_owned(), value);
742        }
743    }
744    // Resolve the daemon's local key before launching remote or container
745    // workers, whose home directories do not contain its secrets file.
746    if let Some(key) = mj_core::activity::verdict::api_key() {
747        target_environment.insert("TYPESAFE_API_KEY".to_owned(), key);
748    }
749    // The build cache reaches the harness, its terminals, and the reviewer
750    // sidecar, all of which run Cargo through the mbx shim.
751    if let Some(build_cache) = &session.build_cache {
752        target_environment.insert(
753            "MBX_CACHE_DIR".into(),
754            build_cache.directory.to_string_lossy().into_owned(),
755        );
756        target_environment.insert(
757            "MJ_MBX_CONFIG_DIR".into(),
758            mj_core::config::build_cache_configuration_directory(&build_cache.directory)
759                .to_string_lossy()
760                .into_owned(),
761        );
762        // Compiler symlinks name this worker's private executable. Sharing
763        // them lets another container replace them with an unreachable path.
764        target_environment.insert(
765            "MBX_SHIMS_DIR".into(),
766            Path::new(&targets::worker_root(backend, session_id)?)
767                .join("mbx-shims")
768                .to_string_lossy()
769                .into_owned(),
770        );
771        // The per-build summary and savings lines are for a human at a
772        // terminal; in a harness session they only add noise to Cargo output.
773        target_environment.insert("MBX_SUMMARY".into(), "off".into());
774        target_environment.insert("MBX_SAVINGS".into(), "off".into());
775    }
776    let mut environment = target_environment.clone();
777    environment.extend(profile.environment.resolved().clone());
778    if session
779        .target
780        .as_ref()
781        .is_some_and(|target| target.skills_scope() == mj_core::skills::SkillsScope::Localhost)
782    {
783        // Harnesses clear their environment. Host CLI commands must still
784        // address the daemon that created this session, including named instances.
785        environment.insert(
786            "MJ_CONFIG_DIR".into(),
787            std::path::absolute(mj_core::config::config_dir())
788                .context("resolve host Mjolnir configuration directory")?
789                .to_string_lossy()
790                .into_owned(),
791        );
792        environment.insert(
793            "MJ_DATA_DIR".into(),
794            std::path::absolute(data_dir())
795                .context("resolve host Mjolnir data directory")?
796                .to_string_lossy()
797                .into_owned(),
798        );
799        if let Some(instance) = mj_core::config::instance_name() {
800            environment.insert("MJ_INSTANCE".into(), instance);
801        } else {
802            environment.remove("MJ_INSTANCE");
803        }
804    }
805    profile
806        .kind
807        .configure_home_environment(Path::new(&target_profile_home), &mut environment);
808    profile
809        .kind
810        .configure_execution_environment(execution_policy, &mut environment)?;
811    let mut project_memory = project_memory_launch(
812        session,
813        bundle,
814        &workspace,
815        &target_profile_home,
816        worker_workspace.parent_worktree,
817    )?;
818    project_memory.mcp_delivery = project_memory_mcp_delivery(profile.kind, backend);
819    project_memory.history_socket =
820        Some(Path::new(&targets::worker_root(backend, &session.id)?).join("control.sock"));
821    if profile.kind == mj_core::config::HarnessKind::Claude {
822        environment.insert(
823            "CLAUDE_CODE_PROJECT_DIR_NAME".into(),
824            project_memory_replica_slug(&project_memory.project_key, session_id),
825        );
826    }
827    apply_claude_setup_token(
828        &mut environment,
829        profile.kind,
830        &mj_core::credentials::claude_oauth_token_path(&session.last_profile),
831    );
832    let excluded_environment =
833        exclude_harness_environment(&session.last_profile, profile, &mut environment);
834    if podman_container && profile.kind == mj_core::config::HarnessKind::Claude {
835        // Claude Code rejects bypassPermissions under uid 0 unless it is
836        // told the process is already running in an isolated sandbox.
837        // Podman container exec inherits HOME and the configured user from
838        // the container, preserving both for stopped legacy sessions.
839        environment.insert("IS_SANDBOX".into(), "1".into());
840    }
841    Ok((
842        WorkerLaunchConfig {
843            goal_resume_request: None,
844            target_environment,
845            seed_image_environment: backend.container_engine().is_some(),
846            run_mode: Default::default(),
847            session_id: session_id.to_string(),
848            subagents: mj_core::subagent::SubagentPolicy::Native,
849            handback_tool: false,
850            initial_model: None,
851            review_capture: false,
852            harness: profile.kind,
853            harness_home: PathBuf::from(&target_profile_home),
854            // The staged home mirrors the profile home, so the marker's path
855            // within the profile home is the one the worker must check and
856            // the credential sync must write. Kimi's is nested
857            // (`credentials/kimi-code.json`); its file name alone named a file
858            // at the top of the staged home that Kimi never reads.
859            authentication_marker: profile
860                .authentication_marker()
861                .strip_prefix(&profile.home)
862                .ok()
863                .map(|name| name.to_string_lossy().into_owned()),
864            bridge_command: PathBuf::from(bridge_command),
865            bridge_args,
866            harness_runtime: harness_runtime_policy(backend),
867            environment,
868            excluded_environment,
869            cwd: PathBuf::from(&workspace.0),
870            additional_directories,
871            native_session_id: session.native_session_id.clone(),
872            project_memory: Some(project_memory.clone()),
873            execution_policy,
874        },
875        project_memory,
876        target_profile_home,
877    ))
878}
879
880/// Leave out of a launch every variable the profile's harness must never see,
881/// and name them for the worker, which removes them again once it has added
882/// the target's own login environment.
883///
884/// Saying so once per profile is enough: the launch config is rebuilt for
885/// every recovery check, and the variables do not change between them.
886pub(super) fn exclude_harness_environment(
887    profile_id: &str,
888    profile: &mj_core::config::HarnessProfile,
889    environment: &mut std::collections::BTreeMap<String, String>,
890) -> Vec<String> {
891    static REPORTED: std::sync::Mutex<std::collections::BTreeSet<String>> =
892        std::sync::Mutex::new(std::collections::BTreeSet::new());
893    let before = environment.clone();
894    let excluded = profile.exclude_harness_environment(environment);
895    let removed = excluded
896        .iter()
897        .filter(|name| before.contains_key(*name))
898        .cloned()
899        .collect::<Vec<_>>();
900    if !removed.is_empty()
901        && REPORTED
902            .lock()
903            .map(|mut reported| reported.insert(profile_id.to_owned()))
904            .unwrap_or(true)
905    {
906        tracing::info!(
907            profile_id,
908            removed = removed.join(", "),
909            "left API key settings out of the harness environment: this Codex profile must not use an OpenAI API key"
910        );
911    }
912    excluded
913}
914
915pub(super) fn harness_runtime_policy(backend: &targets::TargetLocator) -> HarnessRuntimePolicy {
916    match backend {
917        targets::TargetLocator::LocalBare { .. }
918        | targets::TargetLocator::AwsEc2 { .. }
919        | targets::TargetLocator::SshBare { .. } => HarnessRuntimePolicy::Managed,
920        _ => HarnessRuntimePolicy::Ambient,
921    }
922}