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