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