Skip to main content

mj_controller/controller/worker_binary/
launch.rs

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