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