Skip to main content

fresh/services/authority/
mod.rs

1//! Authority — the single backend slot for "where does the editor act?"
2//!
3//! Every primitive the editor exposes — file I/O, integrated terminal,
4//! plugin `spawnProcess`, formatter, LSP server spawn, file watcher,
5//! find-in-files, save, recovery — routes through the active `Authority`.
6//! There is exactly one authority per `Editor` at any moment.
7//!
8//! Transitions are atomic and destructive: `Editor::install_authority`
9//! queues the replacement, then piggy-backs on the existing
10//! `request_restart` flow so the whole `Editor` is dropped and rebuilt
11//! around the new authority. Every cached `Arc<dyn FileSystem>`, LSP
12//! handle, terminal PTY, plugin state, and in-flight task goes away
13//! with the old `Editor`; there is no in-place swap and no half-
14//! transitioned window. See `docs/internal/AUTHORITY_DESIGN.md`.
15//!
16//! Authority is opaque to core code. The four fields below are the
17//! entire contract; nothing else inspects whether the backend is local,
18//! SSH, a container, or something a plugin invented.
19//!
20//! ## Construction
21//!
22//! - `Authority::local(trust, env)` — host filesystem + host spawner + host
23//!   shell. Always available; the editor boots with this. Trust + env are
24//!   mandatory shared handles.
25//! - `Authority::ssh(filesystem, spawner, long_running, params, remote_dir,
26//!   trust, env)` — used by the `fresh user@host:path` startup flow. `params`
27//!   and `remote_dir` build the `ssh -t …` terminal wrapper so the integrated
28//!   terminal opens on the remote host.
29//! - `Authority::from_plugin_payload(payload, trust, env)` — built from the
30//!   `editor.setAuthority(...)` plugin op. The payload is a tagged shape
31//!   (filesystem kind + spawner kind + terminal wrapper + label); it stays
32//!   small and additive so we can grow new kinds without breaking the
33//!   plugin contract.
34
35use std::path::{Path, PathBuf};
36use std::sync::Arc;
37
38use serde::{Deserialize, Serialize};
39
40use crate::model::filesystem::{FileSystem, StdFileSystem};
41use crate::services::remote::{
42    build_kube_terminal_args, build_ssh_terminal_args, spawn_kube_reconnect_task,
43    spawn_reconnect_task, ConnectionParams, KubeConnection, KubeTarget, LocalLongRunningSpawner,
44    LocalProcessSpawner, LongRunningSpawner, ProcessSpawner, RemoteFileSystem,
45    RemoteLongRunningSpawner, RemoteProcessSpawner, SshConnection, SshError, TransportError,
46};
47use crate::services::workspace_trust::WorkspaceTrust;
48
49/// Plugin-supplied form of the host↔remote workspace mapping. Plugins
50/// build this from their own knowledge (e.g. the devcontainer plugin
51/// already has `editor.getCwd()` for the host root and
52/// `result.remoteWorkspaceFolder` for the in-container root). Strings
53/// because the wire format is JSON; paths get parsed in
54/// `Authority::from_plugin_payload`.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct PathTranslationSpec {
57    pub host_root: String,
58    pub remote_root: String,
59}
60
61/// Symmetric path translation between host and remote workspace
62/// roots. Owned by the active [`Authority`] when the backend lives in
63/// a container (or any other place where the workspace is mounted at a
64/// different path than its on-host location). Local authorities and
65/// SSH leave this field unset.
66///
67/// LSP URIs are the primary consumer: the editor's buffer file paths
68/// are host-side, but the LSP server is on the other side of the
69/// mount and only knows the remote-side path. We translate at the
70/// boundary so the editor can keep using host paths internally and
71/// the LSP keeps seeing the paths it expects.
72#[derive(Debug, Clone)]
73pub struct PathTranslation {
74    pub host_root: PathBuf,
75    pub remote_root: PathBuf,
76}
77
78impl PathTranslation {
79    /// Map a host-side path under `host_root` to its remote-side
80    /// counterpart. Returns `None` for paths outside the workspace
81    /// (e.g. system headers, library sources) — those are passed
82    /// through unchanged so the caller can decide whether to forward
83    /// them as-is or drop them.
84    pub fn host_to_remote(&self, host: &Path) -> Option<PathBuf> {
85        let rel = host.strip_prefix(&self.host_root).ok()?;
86        Some(self.remote_root.join(rel))
87    }
88
89    /// Map a remote-side path under `remote_root` back to its
90    /// host-side counterpart. Same outside-the-workspace caveat as
91    /// [`Self::host_to_remote`].
92    pub fn remote_to_host(&self, remote: &Path) -> Option<PathBuf> {
93        let rel = remote.strip_prefix(&self.remote_root).ok()?;
94        Some(self.host_root.join(rel))
95    }
96}
97
98/// How the integrated terminal is launched under this authority.
99///
100/// The terminal manager unconditionally honours this — there is no
101/// "no wrapper" branch.  For local authority, the wrapper command is the
102/// detected host shell with no extra args; `manages_cwd` is false so the
103/// terminal manager calls `CommandBuilder::cwd()` itself.  Authorities
104/// that re-parent the shell (e.g. `docker exec -w <workspace>`) set
105/// `manages_cwd = true` so cwd is left to the wrapper's args.
106#[derive(Debug, Clone)]
107pub struct TerminalWrapper {
108    /// Command to execute (e.g. the host shell, `"docker"`, `"ssh"`).
109    pub command: String,
110    /// Arguments passed before any user input — usually the flags that
111    /// drop the user into an interactive shell at the right place.
112    pub args: Vec<String>,
113    /// If true, `args` already establishes the working directory and the
114    /// terminal manager must skip `CommandBuilder::cwd()`. For local
115    /// authorities this is false so the host shell honours the per-
116    /// terminal cwd the editor passes in.
117    pub manages_cwd: bool,
118}
119
120impl TerminalWrapper {
121    /// Wrap the detected host shell with no extra args. Cwd is set by
122    /// the terminal manager from the spawn call.
123    pub fn host_shell() -> Self {
124        Self {
125            command: crate::services::terminal::manager::detect_shell(),
126            args: Vec::new(),
127            manages_cwd: false,
128        }
129    }
130
131    /// Re-parent the integrated terminal onto the remote host over SSH:
132    /// `ssh -t … user@host 'cd <dir>; exec $SHELL -l'`. Like the
133    /// `docker exec -w …` wrapper, this pins cwd through its own args
134    /// (`cd <dir>`), so `manages_cwd` is true and the terminal manager
135    /// must not call `CommandBuilder::cwd()` with a remote path the local
136    /// PTY can't honour. Without this, SSH authorities would fall back to
137    /// the local host shell and the embedded terminal would run on the
138    /// *local* machine instead of the remote host.
139    pub fn ssh(params: &ConnectionParams, remote_dir: Option<&str>) -> Self {
140        Self {
141            command: "ssh".to_string(),
142            args: build_ssh_terminal_args(params, remote_dir),
143            manages_cwd: true,
144        }
145    }
146
147    /// Re-parent the integrated terminal into a K8s pod via
148    /// `kubectl exec -it … -- sh -lc 'cd <ws>; exec $SHELL -l'`. Same
149    /// re-parenting contract as [`Self::ssh`] / the `docker exec -w …`
150    /// wrapper: cwd is pinned through the wrapper's own args, so
151    /// `manages_cwd` is true and the terminal manager must not hand the
152    /// local PTY a pod-side cwd it can't honour.
153    pub fn kube(target: &KubeTarget) -> Self {
154        Self {
155            command: "kubectl".to_string(),
156            args: build_kube_terminal_args(target),
157            manages_cwd: true,
158        }
159    }
160
161    /// Apply the user's `terminal.shell` config override on top of this
162    /// wrapper. The override replaces `command` and `args` only when the
163    /// wrapper leaves cwd management to the terminal manager
164    /// (`manages_cwd == false`) — that is, for the host-shell wrapper.
165    /// Authorities that re-parent the shell (e.g. `docker exec -w …`,
166    /// `ssh …`) pin cwd through their own args and are left untouched so
167    /// the re-parenting stays intact.
168    pub fn with_user_shell_override(
169        mut self,
170        shell: Option<&crate::config::TerminalShellConfig>,
171    ) -> Self {
172        if let Some(shell) = shell {
173            if !self.manages_cwd {
174                self.command = shell.command.clone();
175                self.args = shell.args.clone();
176            }
177        }
178        self
179    }
180}
181
182/// Tagged payload describing how to build an authority from a plugin.
183///
184/// Kept intentionally small and explicit. Adding a new spawner or
185/// filesystem kind means adding a new variant here and a constructor in
186/// `Authority::from_plugin_payload`. Plugins consuming the API see only
187/// the `kind` discriminator and the kind-specific params, so old payloads
188/// keep working as new kinds are added.
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct AuthorityPayload {
191    pub filesystem: FilesystemSpec,
192    pub spawner: SpawnerSpec,
193    pub terminal_wrapper: TerminalWrapperSpec,
194    /// Status-bar / explorer label. Empty = no label rendered.
195    #[serde(default)]
196    pub display_label: String,
197    /// Optional host↔remote workspace path mapping. Devcontainer-style
198    /// authorities supply both the host workspace path (the editor's
199    /// `cwd` at attach time) and the in-container `remoteWorkspaceFolder`
200    /// so URIs traveling to/from the LSP get translated symmetrically.
201    /// SSH and local authorities leave this unset.
202    #[serde(default)]
203    pub path_translation: Option<PathTranslationSpec>,
204}
205
206/// Filesystem kind chosen by a plugin payload.
207#[derive(Debug, Clone, Serialize, Deserialize)]
208#[serde(tag = "kind", rename_all = "kebab-case")]
209pub enum FilesystemSpec {
210    /// Use the host filesystem. Devcontainers fall here because the
211    /// workspace is mounted into the container, so file paths translate
212    /// 1:1 between host and container.
213    Local,
214}
215
216/// Process-spawner kind chosen by a plugin payload.
217#[derive(Debug, Clone, Serialize, Deserialize)]
218#[serde(tag = "kind", rename_all = "kebab-case")]
219pub enum SpawnerSpec {
220    /// Spawn on the host. Equivalent to `LocalProcessSpawner`.
221    ///
222    /// Environment-manager activation is *not* expressed here — env is a live
223    /// provider set via `editor.setEnv` (see `services::env_provider`), not a
224    /// backend rebuilt from a payload. `SpawnerSpec` is only for choosing the
225    /// backend (local vs container).
226    Local,
227    /// Run via `docker exec` against a long-lived container. The plugin
228    /// manages the container lifecycle (e.g. via `editor.spawnHostProcess`
229    /// to invoke `devcontainer up`) and hands us the container id once it
230    /// is ready.
231    ///
232    /// `env` is the captured `userEnvProbe` snapshot from inside the
233    /// container — typically `PATH`, `HOME`, `LANG`, and any vars the
234    /// user's profile exports. It's applied to every `docker exec`
235    /// (one-shot spawns, LSP/long-running, `command_exists` probes)
236    /// so plugins-installed binaries on `~/.local/bin` (or any
237    /// shell-only PATH) actually resolve. Empty when `userEnvProbe`
238    /// is `none` or the probe fails.
239    DockerExec {
240        container_id: String,
241        #[serde(default)]
242        user: Option<String>,
243        #[serde(default)]
244        workspace: Option<String>,
245        /// Captured `userEnvProbe` env. Order is preserved so
246        /// per-call `env` can layer over it deterministically; the
247        /// list of pairs (rather than a HashMap) keeps `docker exec
248        /// -e` ordering explicit.
249        #[serde(default)]
250        env: Vec<(String, String)>,
251    },
252}
253
254/// Terminal-wrapper kind chosen by a plugin payload.
255#[derive(Debug, Clone, Serialize, Deserialize)]
256#[serde(tag = "kind", rename_all = "kebab-case")]
257pub enum TerminalWrapperSpec {
258    /// Use the detected host shell.
259    HostShell,
260    /// Use an explicit command + args (e.g. `docker exec -it -u <user>
261    /// -w <workspace> <id> bash -l`). `manages_cwd` defaults to true
262    /// because that is the only sensible choice for re-parented shells.
263    Explicit {
264        command: String,
265        args: Vec<String>,
266        #[serde(default = "default_true")]
267        manages_cwd: bool,
268    },
269}
270
271fn default_true() -> bool {
272    true
273}
274
275/// The single backend slot. Replaces the old quartet of `filesystem`,
276/// `process_spawner`, `terminal_wrapper`, and `authority_display_string`
277/// fields on `Editor`. Cloned cheaply via `Arc`s.
278#[derive(Clone)]
279pub struct Authority {
280    pub filesystem: Arc<dyn FileSystem + Send + Sync>,
281    pub process_spawner: Arc<dyn ProcessSpawner>,
282    /// Spawner for long-lived stdio processes — LSP servers today, tool
283    /// agents tomorrow. Container authorities wire this to a
284    /// `docker exec -i` variant so servers run inside the container
285    /// rather than on the host. Without it, LSP bypasses the authority
286    /// entirely (see `AUTHORITY_DESIGN.md` principle 2).
287    pub long_running_spawner: Arc<dyn LongRunningSpawner>,
288    pub terminal_wrapper: TerminalWrapper,
289    /// Status-bar / file-explorer label. Empty means render nothing.
290    /// SSH leaves this empty and lets the status bar fall back to the
291    /// filesystem's `remote_connection_info()` so disconnect annotations
292    /// stay in one place.
293    pub display_label: String,
294    /// Host↔remote workspace path mapping for backends where the
295    /// workspace is mounted at a different path than its on-host
296    /// location. The dev-container authority populates this so LSP
297    /// URIs translate at the host/container boundary; local and SSH
298    /// authorities leave it `None` and URIs flow through unchanged.
299    pub path_translation: Option<PathTranslation>,
300    /// Workspace Trust state gating execution under this authority — mandatory,
301    /// passed into every constructor and held by each spawner (no optional, no
302    /// post-hoc wrapping). It's the same `Arc` the server owns, so the command
303    /// palette / prompt mutate the level through it and every spawner sees it
304    /// live. A spawner literally cannot be built without it.
305    pub workspace_trust: Arc<WorkspaceTrust>,
306    /// Live environment provider (the activated venv/direnv/mise recipe) gating
307    /// what env every spawn carries — shared, mutated in place via the
308    /// `setEnv`/`clearEnv` plugin ops, never a stored snapshot. Same `Arc` the
309    /// server owns; born in `main.rs` alongside trust.
310    pub env_provider: Arc<crate::services::env_provider::EnvProvider>,
311}
312
313impl Authority {
314    /// Default boot-time authority: host filesystem, host process
315    /// spawner, host shell wrapper, gated by `trust`. The editor starts
316    /// here on every startup; SSH or plugin-installed authorities replace
317    /// it later (carrying the same `trust`).
318    pub fn local(
319        trust: Arc<WorkspaceTrust>,
320        env: Arc<crate::services::env_provider::EnvProvider>,
321    ) -> Self {
322        Self {
323            filesystem: Arc::new(StdFileSystem),
324            process_spawner: Arc::new(LocalProcessSpawner::new(
325                Arc::clone(&env),
326                Arc::clone(&trust),
327            )),
328            long_running_spawner: Arc::new(LocalLongRunningSpawner::new(
329                Arc::clone(&env),
330                Arc::clone(&trust),
331            )),
332            terminal_wrapper: TerminalWrapper::host_shell(),
333            display_label: String::new(),
334            path_translation: None,
335            workspace_trust: trust,
336            env_provider: env,
337        }
338    }
339
340    /// Build an SSH authority. The caller already holds the connection
341    /// (and its keepalive resources) so we just wire the parts in. Label
342    /// is left empty — the status bar falls back to the filesystem's own
343    /// `remote_connection_info()` which knows how to annotate disconnect.
344    ///
345    /// `long_running_spawner` is an SSH-routed spawner
346    /// ([`RemoteLongRunningSpawner`](crate::services::remote::RemoteLongRunningSpawner)),
347    /// so LSP servers run on the remote host rather than the host-local
348    /// fallback that earlier versions used.
349    ///
350    /// `params` / `remote_dir` build the integrated-terminal wrapper so the
351    /// embedded terminal opens a shell *on the remote host* (`ssh -t …`)
352    /// rooted at the workspace, matching where the filesystem and spawners
353    /// already act. Earlier versions hardcoded the local host shell here,
354    /// so terminals silently ran on the local machine.
355    pub fn ssh(
356        filesystem: Arc<dyn FileSystem + Send + Sync>,
357        process_spawner: Arc<dyn ProcessSpawner>,
358        long_running_spawner: Arc<dyn LongRunningSpawner>,
359        params: &ConnectionParams,
360        remote_dir: Option<&str>,
361        trust: Arc<WorkspaceTrust>,
362        env: Arc<crate::services::env_provider::EnvProvider>,
363    ) -> Self {
364        Self {
365            filesystem,
366            process_spawner,
367            long_running_spawner,
368            terminal_wrapper: TerminalWrapper::ssh(params, remote_dir),
369            display_label: String::new(),
370            path_translation: None,
371            workspace_trust: trust,
372            env_provider: env,
373        }
374    }
375
376    /// Build a K8s authority: the remote-agent stack (filesystem + spawners,
377    /// already wired to the pod's `kubectl exec` agent channel) plus a
378    /// terminal wrapper that opens a shell *inside the pod*.
379    ///
380    /// Mirrors [`Self::ssh`] — the caller owns the connection and its
381    /// keepalive resources and threads the parts in. Unlike SSH, the label
382    /// is set from the target (there is no filesystem-side
383    /// `remote_connection_info()` that knows about a K8s pod, so identity
384    /// lives in the label per `AUTHORITY_DESIGN.md` principle 9). Path
385    /// translation is unset: the editor operates directly in the pod's path
386    /// space, exactly as SSH does.
387    pub fn kube(
388        filesystem: Arc<dyn FileSystem + Send + Sync>,
389        process_spawner: Arc<dyn ProcessSpawner>,
390        long_running_spawner: Arc<dyn LongRunningSpawner>,
391        target: &KubeTarget,
392        trust: Arc<WorkspaceTrust>,
393        env: Arc<crate::services::env_provider::EnvProvider>,
394    ) -> Self {
395        Self {
396            filesystem,
397            process_spawner,
398            long_running_spawner,
399            terminal_wrapper: TerminalWrapper::kube(target),
400            display_label: target.display(),
401            path_translation: None,
402            workspace_trust: trust,
403            env_provider: env,
404        }
405    }
406
407    /// Assemble a full K8s authority from a live [`KubeConnection`].
408    ///
409    /// The high-level counterpart to [`Self::k8s`]: wires the filesystem and
410    /// one-shot spawner onto the connection's agent channel
411    /// ([`RemoteFileSystem`] / [`RemoteProcessSpawner`], reused verbatim from
412    /// the SSH stack) and the long-running (LSP) spawner onto a per-server
413    /// `kubectl exec` ([`KubectlLongRunningSpawner`]). `base_env` is the
414    /// captured in-pod env probe applied to LSP spawns and `command_exists`.
415    ///
416    /// The caller must keep the `KubeConnection` alive (in the session
417    /// keepalive bundle) — dropping it kills the carrier and tears down the
418    /// channel the returned authority rides on, exactly as SSH holds its
419    /// `SshConnection`.
420    pub fn kube_from_connection(
421        connection: &KubeConnection,
422        target: KubeTarget,
423        base_env: Vec<(String, String)>,
424        trust: Arc<WorkspaceTrust>,
425        env: Arc<crate::services::env_provider::EnvProvider>,
426    ) -> Self {
427        let channel = connection.channel();
428        let filesystem: Arc<dyn FileSystem + Send + Sync> = Arc::new(RemoteFileSystem::new(
429            channel.clone(),
430            connection.connection_string().to_string(),
431        ));
432        let process_spawner: Arc<dyn ProcessSpawner> = Arc::new(RemoteProcessSpawner::new(
433            channel,
434            Arc::clone(&env),
435            Arc::clone(&trust),
436        ));
437        let long_running_spawner: Arc<dyn LongRunningSpawner> = Arc::new(
438            KubectlLongRunningSpawner::with_env(target.clone(), base_env, Arc::clone(&trust)),
439        );
440        Self::kube(
441            filesystem,
442            process_spawner,
443            long_running_spawner,
444            &target,
445            trust,
446            env,
447        )
448    }
449
450    /// Build an authority from a plugin payload (the data carried by the
451    /// `editor.setAuthority(...)` op), gated by `trust` (the editor passes its
452    /// live trust handle so the new authority shares it). All translation from
453    /// "kind + params" to concrete `Arc<dyn …>` lives here and nowhere else.
454    pub fn from_plugin_payload(
455        payload: AuthorityPayload,
456        trust: Arc<WorkspaceTrust>,
457        env: Arc<crate::services::env_provider::EnvProvider>,
458    ) -> Result<Self, AuthorityPayloadError> {
459        let filesystem: Arc<dyn FileSystem + Send + Sync> = match payload.filesystem {
460            FilesystemSpec::Local => Arc::new(StdFileSystem),
461        };
462
463        // Both spawner traits need the docker-exec params when the
464        // payload is a container, so destructure once and reuse.
465        let (process_spawner, long_running_spawner): (
466            Arc<dyn ProcessSpawner>,
467            Arc<dyn LongRunningSpawner>,
468        ) = match payload.spawner {
469            SpawnerSpec::Local => (
470                Arc::new(LocalProcessSpawner::new(
471                    Arc::clone(&env),
472                    Arc::clone(&trust),
473                )),
474                Arc::new(LocalLongRunningSpawner::new(
475                    Arc::clone(&env),
476                    Arc::clone(&trust),
477                )),
478            ),
479            SpawnerSpec::DockerExec {
480                container_id,
481                user,
482                workspace,
483                env,
484            } => (
485                Arc::new(
486                    crate::services::authority::docker_spawner::DockerExecSpawner::with_env(
487                        container_id.clone(),
488                        user.clone(),
489                        workspace.clone(),
490                        env.clone(),
491                        Arc::clone(&trust),
492                    ),
493                ),
494                Arc::new(
495                    crate::services::authority::docker_spawner::DockerLongRunningSpawner::with_env(
496                        container_id,
497                        user,
498                        workspace,
499                        env,
500                        Arc::clone(&trust),
501                    ),
502                ),
503            ),
504        };
505
506        let terminal_wrapper = match payload.terminal_wrapper {
507            TerminalWrapperSpec::HostShell => TerminalWrapper::host_shell(),
508            TerminalWrapperSpec::Explicit {
509                command,
510                args,
511                manages_cwd,
512            } => TerminalWrapper {
513                command,
514                args,
515                manages_cwd,
516            },
517        };
518
519        let path_translation = payload.path_translation.map(|spec| PathTranslation {
520            host_root: PathBuf::from(spec.host_root),
521            remote_root: PathBuf::from(spec.remote_root),
522        });
523
524        Ok(Self {
525            filesystem,
526            process_spawner,
527            long_running_spawner,
528            terminal_wrapper,
529            display_label: payload.display_label,
530            path_translation,
531            workspace_trust: trust,
532            env_provider: env,
533        })
534    }
535}
536
537/// Plugin payload for `editor.attachRemoteAgent(...)`. Names a transport
538/// that needs a live connection plus the captured in-pod env probe.
539/// Opaque JSON at the fresh-core boundary; parsed here.
540#[derive(Debug, Clone, Serialize, Deserialize)]
541pub struct RemoteAgentSpec {
542    pub transport: RemoteTransportSpec,
543    /// Captured in-pod env (PATH/HOME/LANG/…) applied to LSP spawns and
544    /// `command_exists`. Empty when no probe ran.
545    #[serde(default)]
546    pub base_env: Vec<(String, String)>,
547    /// When true, attach as a **new window** (born-attached, coexisting with
548    /// existing windows) instead of the default global restart. The
549    /// Orchestrator sets this so a cloud session is a real session row rather
550    /// than retargeting the whole editor.
551    #[serde(default)]
552    pub window: bool,
553    /// Window label (used only when `window` is true). Empty falls back to the
554    /// transport's display.
555    #[serde(default)]
556    pub label: Option<String>,
557    /// Optional agent argv for the new window's seed terminal (window mode).
558    #[serde(default)]
559    pub command: Option<Vec<String>>,
560}
561
562/// Transport kind for [`RemoteAgentSpec`]. Tagged + additive so new
563/// carriers slot in without breaking the plugin contract.
564#[derive(Debug, Clone, Serialize, Deserialize)]
565#[serde(tag = "kind", rename_all = "kebab-case")]
566pub enum RemoteTransportSpec {
567    /// Exec into a pod on a K8s (or any kube) cluster.
568    KubectlExec {
569        #[serde(default)]
570        context: Option<String>,
571        namespace: String,
572        pod: String,
573        #[serde(default)]
574        container: Option<String>,
575        #[serde(default)]
576        workspace: Option<String>,
577    },
578    /// SSH into a remote host: the same remote-agent stack as the boot-time
579    /// `fresh user@host:path` flow, exposed at runtime so the Orchestrator can
580    /// open an SSH session as a born-attached window.
581    Ssh {
582        /// Login user. Optional — omit for `host` / `ssh://host`, letting ssh
583        /// resolve the user from its own config or the current local user.
584        #[serde(default)]
585        user: Option<String>,
586        host: String,
587        #[serde(default)]
588        port: Option<u16>,
589        #[serde(default)]
590        identity_file: Option<String>,
591        /// Remote directory to root the session at (terminal `cd` target).
592        #[serde(default)]
593        remote_path: Option<String>,
594        /// Extra `ssh` arguments (e.g. `-J jump`, `-o ProxyCommand=…`) applied
595        /// to every ssh invocation for this session.
596        #[serde(default)]
597        extra_args: Vec<String>,
598    },
599}
600
601impl RemoteAgentSpec {
602    /// Resolve a kubectl-exec spec into the pod target and the captured env.
603    /// Only valid for the `KubectlExec` transport (the caller dispatches on
604    /// `transport` first); panics otherwise.
605    pub fn into_kube_target(self) -> (KubeTarget, Vec<(String, String)>) {
606        match self.transport {
607            RemoteTransportSpec::KubectlExec {
608                context,
609                namespace,
610                pod,
611                container,
612                workspace,
613            } => (
614                KubeTarget {
615                    context,
616                    namespace,
617                    pod,
618                    container,
619                    workspace,
620                },
621                self.base_env,
622            ),
623            RemoteTransportSpec::Ssh { .. } => {
624                unreachable!("into_kube_target called on a non-kube transport")
625            }
626        }
627    }
628}
629
630/// Resources that must outlive a K8s [`Authority`]: the carrier
631/// connection (its `kubectl exec` child + heartbeat task) and the
632/// reconnect task. The editor parks this in its session-keepalive slot —
633/// the same one SSH uses for its `SshConnection` — so the agent channel
634/// survives the editor rebuild on attach. Dropping it tears the session
635/// down (reconnect aborted, then the connection's carrier killed).
636pub struct KubeKeepalive {
637    // Drop runs the explicit `Drop` below first (aborting reconnect), then
638    // fields drop in declaration order: the connection (kills the carrier),
639    // then the runtime (shuts down its now-idle workers).
640    reconnect: tokio::task::JoinHandle<()>,
641    _connection: KubeConnection,
642    // The load-bearing field: the dedicated runtime the agent channel +
643    // heartbeat + reconnect tasks run on. Owned here so they survive the editor
644    // restart the attach triggers — the editor's per-instance runtime is
645    // dropped during that rebuild, and if the channel rode *that* runtime its
646    // I/O tasks would die the instant the attach completed ("Channel closed"
647    // on every file op). SSH's `RemoteSession._runtime` does exactly this.
648    _runtime: tokio::runtime::Runtime,
649}
650
651impl Drop for KubeKeepalive {
652    fn drop(&mut self) {
653        self.reconnect.abort();
654    }
655}
656
657/// Connect to a K8s pod and assemble its [`Authority`] plus the
658/// [`KubeKeepalive`] that must be parked to keep it alive.
659///
660/// The K8s counterpart to SSH's `connect_remote`: bootstraps the agent
661/// ([`KubeConnection::connect`]) and the reconnect/heartbeat tasks **on a
662/// dedicated runtime owned by the returned keepalive** — *not* the caller's
663/// runtime. Installing the authority restarts the editor, dropping the
664/// editor's per-instance runtime; binding the channel there would kill it the
665/// moment the attach completes (regression: `agent_channel_survives_dropping_
666/// the_attach_runtime`). `base_env` is the captured in-pod env probe applied to
667/// LSP spawns and `command_exists`.
668///
669/// Stays `async` for callers, but the bootstrap needs `block_on` (which can't
670/// run inside the caller's async context), so it happens on a short-lived
671/// helper thread; the live runtime — with its channel/heartbeat/reconnect
672/// tasks — is handed back and parked in the keepalive.
673pub async fn connect_kube_authority(
674    target: KubeTarget,
675    base_env: Vec<(String, String)>,
676    trust: Arc<WorkspaceTrust>,
677    env: Arc<crate::services::env_provider::EnvProvider>,
678    cancel: Option<tokio::sync::oneshot::Receiver<()>>,
679) -> Result<(Authority, KubeKeepalive), TransportError> {
680    type Built = Result<
681        (
682            KubeConnection,
683            tokio::task::JoinHandle<()>,
684            tokio::runtime::Runtime,
685        ),
686        TransportError,
687    >;
688
689    let (tx, rx) = tokio::sync::oneshot::channel::<Built>();
690    let bootstrap_target = target.clone();
691    std::thread::Builder::new()
692        .name("kube-connect".to_string())
693        .spawn(move || {
694            let built: Built = (|| {
695                let runtime = tokio::runtime::Builder::new_multi_thread()
696                    .worker_threads(2)
697                    .thread_name("kube-agent")
698                    .enable_all()
699                    .build()
700                    .map_err(|e| TransportError::AgentStartFailed(format!("runtime: {e}")))?;
701                // `block_on` drives the bootstrap; the channel/heartbeat/reconnect
702                // tasks it spawns live on `runtime`'s worker threads, which keep
703                // running after `block_on` returns and after this helper thread
704                // exits — until the `runtime` (moved into the keepalive) drops.
705                let (connection, reconnect) = runtime.block_on(async {
706                    // Race the connect against the cancel signal so a slow/hung
707                    // kubectl bootstrap can be aborted; dropping the connect
708                    // future drops the in-flight child. No signal → await it.
709                    let connection = match cancel {
710                        Some(cancel) => tokio::select! {
711                            biased;
712                            _ = cancel => {
713                                return Err(TransportError::AgentStartFailed(
714                                    "cancelled".to_string(),
715                                ));
716                            }
717                            res = KubeConnection::connect(bootstrap_target.clone()) => res?,
718                        },
719                        None => KubeConnection::connect(bootstrap_target.clone()).await?,
720                    };
721                    let reconnect =
722                        spawn_kube_reconnect_task(&connection.channel(), bootstrap_target.clone());
723                    Ok::<_, TransportError>((connection, reconnect))
724                })?;
725                Ok((connection, reconnect, runtime))
726            })();
727            #[allow(clippy::let_underscore_must_use)]
728            let _ = tx.send(built);
729        })
730        .map_err(|e| TransportError::AgentStartFailed(format!("connect thread: {e}")))?;
731
732    let (connection, reconnect, runtime) = rx
733        .await
734        .map_err(|_| TransportError::AgentStartFailed("connect thread vanished".to_string()))??;
735
736    let authority = Authority::kube_from_connection(&connection, target, base_env, trust, env);
737    Ok((
738        authority,
739        KubeKeepalive {
740            reconnect,
741            _connection: connection,
742            _runtime: runtime,
743        },
744    ))
745}
746
747/// Resources that must outlive an SSH [`Authority`]: the `SshConnection`
748/// (its `ssh …` child), the reconnect task, and the dedicated runtime the
749/// agent channel rides on. The runtime-owned analogue of `main.rs`'s
750/// boot-time `RemoteSession`; parked per-window by the Orchestrator's
751/// born-attached SSH sessions (and droppable on window close).
752pub struct SshKeepalive {
753    reconnect: tokio::task::JoinHandle<()>,
754    _connection: SshConnection,
755    _runtime: tokio::runtime::Runtime,
756}
757
758impl Drop for SshKeepalive {
759    fn drop(&mut self) {
760        self.reconnect.abort();
761    }
762}
763
764/// Connect to a remote host over SSH and assemble its [`Authority`] plus the
765/// [`SshKeepalive`] that must be parked to keep it alive — the runtime-owned,
766/// reusable counterpart to `main.rs`'s boot-time `connect_remote`, mirroring
767/// [`connect_kube_authority`]. The agent channel + reconnect run on a dedicated
768/// runtime returned in the keepalive so they survive editor rebuilds.
769///
770/// `remote_dir` is the directory the integrated terminal roots at (the
771/// `ssh -t … 'cd <dir>; …'` wrapper); filesystem/process ops carry absolute
772/// paths and don't need it.
773pub async fn connect_ssh_authority(
774    params: ConnectionParams,
775    remote_dir: Option<String>,
776    trust: Arc<WorkspaceTrust>,
777    env: Arc<crate::services::env_provider::EnvProvider>,
778    cancel: Option<tokio::sync::oneshot::Receiver<()>>,
779) -> Result<(Authority, SshKeepalive), SshError> {
780    type Built = Result<
781        (
782            SshConnection,
783            tokio::task::JoinHandle<()>,
784            tokio::runtime::Runtime,
785        ),
786        SshError,
787    >;
788
789    let (tx, rx) = tokio::sync::oneshot::channel::<Built>();
790    let bootstrap_params = params.clone();
791    std::thread::Builder::new()
792        .name("ssh-connect".to_string())
793        .spawn(move || {
794            let built: Built = (|| {
795                let runtime = tokio::runtime::Builder::new_multi_thread()
796                    .worker_threads(2)
797                    .thread_name("ssh-agent")
798                    .enable_all()
799                    .build()
800                    .map_err(|e| SshError::AgentStartFailed(format!("runtime: {e}")))?;
801                // The channel/reconnect tasks spawned here live on `runtime`'s
802                // workers, surviving after this helper thread exits — until the
803                // `runtime` (moved into the keepalive) drops.
804                let (connection, reconnect) = runtime.block_on(async {
805                    // Race the connect against the cancel signal. On cancel the
806                    // connect future is dropped, which drops the in-flight ssh
807                    // child (spawned kill-on-drop) so a hung handshake leaves no
808                    // orphaned process. No cancel signal → just await connect.
809                    let connection = match cancel {
810                        Some(cancel) => tokio::select! {
811                            biased;
812                            _ = cancel => {
813                                return Err(SshError::AgentStartFailed("cancelled".to_string()));
814                            }
815                            res = SshConnection::connect(bootstrap_params.clone()) => res?,
816                        },
817                        None => SshConnection::connect(bootstrap_params.clone()).await?,
818                    };
819                    let reconnect =
820                        spawn_reconnect_task(connection.channel(), connection.params().clone());
821                    Ok::<_, SshError>((connection, reconnect))
822                })?;
823                Ok((connection, reconnect, runtime))
824            })();
825            #[allow(clippy::let_underscore_must_use)]
826            let _ = tx.send(built);
827        })
828        .map_err(|e| SshError::AgentStartFailed(format!("connect thread: {e}")))?;
829
830    let (connection, reconnect, runtime) = rx
831        .await
832        .map_err(|_| SshError::AgentStartFailed("connect thread vanished".to_string()))??;
833
834    let channel = connection.channel();
835    let connection_string = connection.connection_string().to_string();
836    let reconnect_params = connection.params().clone();
837    let filesystem: Arc<dyn FileSystem + Send + Sync> =
838        Arc::new(RemoteFileSystem::new(channel.clone(), connection_string));
839    let process_spawner: Arc<dyn ProcessSpawner> = Arc::new(RemoteProcessSpawner::new(
840        channel.clone(),
841        Arc::clone(&env),
842        Arc::clone(&trust),
843    ));
844    let long_running_spawner: Arc<dyn LongRunningSpawner> =
845        Arc::new(RemoteLongRunningSpawner::new(
846            reconnect_params.clone(),
847            Arc::clone(&env),
848            Arc::clone(&trust),
849        ));
850    let authority = Authority::ssh(
851        filesystem,
852        process_spawner,
853        long_running_spawner,
854        &reconnect_params,
855        remote_dir.as_deref(),
856        trust,
857        env,
858    );
859    Ok((
860        authority,
861        SshKeepalive {
862            reconnect,
863            _connection: connection,
864            _runtime: runtime,
865        },
866    ))
867}
868
869/// Error from translating a plugin payload into a live authority.
870/// Reserved for future kinds that might fail to construct (e.g. invalid
871/// connection parameters); local-only payloads currently never fail.
872#[derive(Debug, thiserror::Error)]
873pub enum AuthorityPayloadError {
874    #[error("invalid authority payload: {0}")]
875    Invalid(String),
876}
877
878mod docker_spawner;
879mod kube_spawner;
880
881pub(crate) use kube_spawner::KubectlLongRunningSpawner;
882
883#[cfg(test)]
884mod tests {
885    use super::*;
886
887    #[test]
888    fn local_authority_uses_host_shell_with_no_args() {
889        let auth = Authority::local(
890            Arc::new(WorkspaceTrust::permissive()),
891            Arc::new(crate::services::env_provider::EnvProvider::inactive()),
892        );
893        assert!(!auth.terminal_wrapper.command.is_empty());
894        assert!(auth.terminal_wrapper.args.is_empty());
895        assert!(!auth.terminal_wrapper.manages_cwd);
896        assert_eq!(auth.display_label, "");
897    }
898
899    #[test]
900    fn kube_terminal_wrapper_reparents_into_pod() {
901        let target = KubeTarget {
902            context: Some("prod".into()),
903            namespace: "dev".into(),
904            pod: "pod-1".into(),
905            container: None,
906            workspace: Some("/workspace".into()),
907        };
908        let wrapper = TerminalWrapper::kube(&target);
909        assert_eq!(wrapper.command, "kubectl");
910        // Re-parented shell must pin cwd through its own args.
911        assert!(wrapper.manages_cwd);
912        assert_eq!(wrapper.args[0], "--context");
913        assert!(wrapper.args.iter().any(|a| a == "-it"));
914        assert!(wrapper.args.iter().any(|a| a == "pod-1"));
915        // User shell override is a no-op for a cwd-managing wrapper, so the
916        // re-parenting into the pod stays intact.
917        let override_shell = crate::config::TerminalShellConfig {
918            command: "/usr/local/bin/fish".into(),
919            args: vec![],
920        };
921        let after = wrapper
922            .clone()
923            .with_user_shell_override(Some(&override_shell));
924        assert_eq!(after.command, "kubectl");
925    }
926
927    #[test]
928    fn remote_agent_spec_parses_plugin_payload() {
929        // The exact JSON shape `editor.attachRemoteAgent(...)` carries and
930        // `handle_attach_remote_agent` parses (opaque at the fresh-core
931        // boundary). Pins the plugin↔core wire contract.
932        let json = serde_json::json!({
933            "transport": {
934                "kind": "kubectl-exec",
935                "context": "k3d-dev",
936                "namespace": "dev",
937                "pod": "fresh-7c9f",
938                "container": "app",
939                "workspace": "/workspace"
940            },
941            "base_env": [
942                ["PATH", "/home/dev/.local/bin:/usr/bin"],
943                ["LANG", "C.UTF-8"]
944            ]
945        });
946        let spec: RemoteAgentSpec = serde_json::from_value(json).expect("spec parses");
947        let (target, base_env) = spec.into_kube_target();
948        assert_eq!(target.context.as_deref(), Some("k3d-dev"));
949        assert_eq!(target.namespace, "dev");
950        assert_eq!(target.pod, "fresh-7c9f");
951        assert_eq!(target.container.as_deref(), Some("app"));
952        assert_eq!(target.workspace.as_deref(), Some("/workspace"));
953        assert_eq!(base_env.len(), 2);
954        assert_eq!(
955            base_env[0],
956            (
957                "PATH".to_string(),
958                "/home/dev/.local/bin:/usr/bin".to_string()
959            )
960        );
961
962        // Minimal payload (only namespace + pod) parses too: context,
963        // container, workspace, and base_env are all optional.
964        let minimal = serde_json::json!({
965            "transport": { "kind": "kubectl-exec", "namespace": "dev", "pod": "p" }
966        });
967        let spec2: RemoteAgentSpec = serde_json::from_value(minimal).expect("minimal parses");
968        let (t2, env2) = spec2.into_kube_target();
969        assert!(t2.context.is_none() && t2.container.is_none() && t2.workspace.is_none());
970        assert!(env2.is_empty());
971    }
972
973    #[test]
974    fn from_plugin_payload_local_yields_host_shell() {
975        let payload = AuthorityPayload {
976            filesystem: FilesystemSpec::Local,
977            spawner: SpawnerSpec::Local,
978            terminal_wrapper: TerminalWrapperSpec::HostShell,
979            display_label: String::new(),
980            path_translation: None,
981        };
982        let auth = Authority::from_plugin_payload(
983            payload,
984            Arc::new(WorkspaceTrust::permissive()),
985            Arc::new(crate::services::env_provider::EnvProvider::inactive()),
986        )
987        .expect("local payload is valid");
988        assert!(!auth.terminal_wrapper.command.is_empty());
989        assert!(auth.terminal_wrapper.args.is_empty());
990    }
991
992    #[test]
993    fn payload_roundtrips_through_serde_json() {
994        // The plugin op carries the payload as opaque JSON through
995        // `fresh-core`; this test nails down the wire shape so we
996        // don't silently break plugins when the struct evolves.
997        let json = serde_json::json!({
998            "filesystem": { "kind": "local" },
999            "spawner": {
1000                "kind": "docker-exec",
1001                "container_id": "abc123",
1002                "user": "vscode",
1003                "workspace": "/workspaces/proj"
1004            },
1005            "terminal_wrapper": {
1006                "kind": "explicit",
1007                "command": "docker",
1008                "args": ["exec", "-it", "abc123", "bash", "-l"],
1009                "manages_cwd": true
1010            },
1011            "display_label": "Container:abc123"
1012        });
1013        let payload: AuthorityPayload =
1014            serde_json::from_value(json).expect("json matches payload schema");
1015        let auth = Authority::from_plugin_payload(
1016            payload,
1017            Arc::new(WorkspaceTrust::permissive()),
1018            Arc::new(crate::services::env_provider::EnvProvider::inactive()),
1019        )
1020        .expect("docker payload is valid");
1021        assert_eq!(auth.terminal_wrapper.command, "docker");
1022        assert!(auth.terminal_wrapper.manages_cwd);
1023        assert_eq!(auth.display_label, "Container:abc123");
1024    }
1025
1026    #[test]
1027    fn payload_accepts_docker_exec_env_pairs() {
1028        // The captured `userEnvProbe` env carries the in-container
1029        // `PATH`/`HOME`/etc. so LSP `command_exists` can find binaries
1030        // that live in shell-only PATHs (e.g. `~/.local/bin`).
1031        let json = serde_json::json!({
1032            "filesystem": { "kind": "local" },
1033            "spawner": {
1034                "kind": "docker-exec",
1035                "container_id": "abc123",
1036                "user": "vscode",
1037                "workspace": "/workspaces/proj",
1038                "env": [
1039                    ["PATH", "/home/vscode/.local/bin:/usr/bin"],
1040                    ["LANG", "C.UTF-8"]
1041                ]
1042            },
1043            "terminal_wrapper": { "kind": "host-shell" }
1044        });
1045        let payload: AuthorityPayload =
1046            serde_json::from_value(json).expect("env field is accepted");
1047        if let SpawnerSpec::DockerExec { env, .. } = &payload.spawner {
1048            assert_eq!(env.len(), 2);
1049            assert_eq!(
1050                env[0],
1051                ("PATH".into(), "/home/vscode/.local/bin:/usr/bin".into())
1052            );
1053            assert_eq!(env[1], ("LANG".into(), "C.UTF-8".into()));
1054        } else {
1055            panic!("expected docker-exec spawner");
1056        }
1057        // And the omitted-field form still parses (the field defaults
1058        // to empty), so older plugins that don't populate it stay
1059        // wire-compatible.
1060        let json_no_env = serde_json::json!({
1061            "filesystem": { "kind": "local" },
1062            "spawner": {
1063                "kind": "docker-exec",
1064                "container_id": "abc123"
1065            },
1066            "terminal_wrapper": { "kind": "host-shell" }
1067        });
1068        let payload2: AuthorityPayload =
1069            serde_json::from_value(json_no_env).expect("env is optional");
1070        if let SpawnerSpec::DockerExec { env, .. } = payload2.spawner {
1071            assert!(env.is_empty());
1072        } else {
1073            panic!("expected docker-exec spawner");
1074        }
1075    }
1076
1077    #[test]
1078    fn payload_defaults_manages_cwd_to_true_for_explicit_wrapper() {
1079        // Per the schema, `manages_cwd` is optional in the JSON and
1080        // defaults to true because re-parented shells almost always
1081        // want it that way.
1082        let json = serde_json::json!({
1083            "filesystem": { "kind": "local" },
1084            "spawner": { "kind": "local" },
1085            "terminal_wrapper": {
1086                "kind": "explicit",
1087                "command": "bash",
1088                "args": []
1089            }
1090        });
1091        let payload: AuthorityPayload =
1092            serde_json::from_value(json).expect("manages_cwd is optional");
1093        let auth = Authority::from_plugin_payload(
1094            payload,
1095            Arc::new(WorkspaceTrust::permissive()),
1096            Arc::new(crate::services::env_provider::EnvProvider::inactive()),
1097        )
1098        .expect("payload is valid");
1099        assert!(auth.terminal_wrapper.manages_cwd);
1100        assert_eq!(auth.display_label, "");
1101    }
1102
1103    #[test]
1104    fn user_shell_override_replaces_host_shell_wrapper() {
1105        let override_shell = crate::config::TerminalShellConfig {
1106            command: "/usr/local/bin/fish".into(),
1107            args: vec!["-l".into(), "-i".into()],
1108        };
1109        let wrapper = TerminalWrapper::host_shell().with_user_shell_override(Some(&override_shell));
1110        assert_eq!(wrapper.command, "/usr/local/bin/fish");
1111        assert_eq!(wrapper.args, vec!["-l".to_string(), "-i".to_string()]);
1112        assert!(!wrapper.manages_cwd);
1113    }
1114
1115    #[test]
1116    fn user_shell_override_is_noop_when_wrapper_manages_cwd() {
1117        // Docker/SSH-style wrappers set `manages_cwd = true`; replacing
1118        // their command would drop the re-parenting args and spawn the
1119        // user's shell on the host, defeating the authority.
1120        let docker = TerminalWrapper {
1121            command: "docker".into(),
1122            args: vec![
1123                "exec".into(),
1124                "-w".into(),
1125                "/workspaces/proj".into(),
1126                "abc123".into(),
1127                "bash".into(),
1128            ],
1129            manages_cwd: true,
1130        };
1131        let override_shell = crate::config::TerminalShellConfig {
1132            command: "/usr/local/bin/fish".into(),
1133            args: vec![],
1134        };
1135        let wrapper = docker
1136            .clone()
1137            .with_user_shell_override(Some(&override_shell));
1138        assert_eq!(wrapper.command, docker.command);
1139        assert_eq!(wrapper.args, docker.args);
1140        assert!(wrapper.manages_cwd);
1141    }
1142
1143    #[test]
1144    fn user_shell_override_none_leaves_wrapper_unchanged() {
1145        let original = TerminalWrapper::host_shell();
1146        let wrapper = original.clone().with_user_shell_override(None);
1147        assert_eq!(wrapper.command, original.command);
1148        assert_eq!(wrapper.args, original.args);
1149        assert_eq!(wrapper.manages_cwd, original.manages_cwd);
1150    }
1151
1152    #[test]
1153    fn from_plugin_payload_docker_exec_carries_label() {
1154        let payload = AuthorityPayload {
1155            filesystem: FilesystemSpec::Local,
1156            spawner: SpawnerSpec::DockerExec {
1157                container_id: "abc123".into(),
1158                user: Some("vscode".into()),
1159                workspace: Some("/workspaces/proj".into()),
1160                env: Vec::new(),
1161            },
1162            terminal_wrapper: TerminalWrapperSpec::Explicit {
1163                command: "docker".into(),
1164                args: vec![
1165                    "exec".into(),
1166                    "-it".into(),
1167                    "-u".into(),
1168                    "vscode".into(),
1169                    "-w".into(),
1170                    "/workspaces/proj".into(),
1171                    "abc123".into(),
1172                    "bash".into(),
1173                    "-l".into(),
1174                ],
1175                manages_cwd: true,
1176            },
1177            display_label: "Container:abc123".into(),
1178            path_translation: None,
1179        };
1180        let auth = Authority::from_plugin_payload(
1181            payload,
1182            Arc::new(WorkspaceTrust::permissive()),
1183            Arc::new(crate::services::env_provider::EnvProvider::inactive()),
1184        )
1185        .expect("docker payload is valid");
1186        assert_eq!(auth.terminal_wrapper.command, "docker");
1187        assert!(auth.terminal_wrapper.manages_cwd);
1188        assert_eq!(auth.display_label, "Container:abc123");
1189    }
1190
1191    #[test]
1192    fn path_translation_round_trips_under_workspace() {
1193        let pt = PathTranslation {
1194            host_root: PathBuf::from("/tmp/.tmpA1B2"),
1195            remote_root: PathBuf::from("/workspaces/proj"),
1196        };
1197        let host = Path::new("/tmp/.tmpA1B2/src/util.py");
1198        let remote = pt.host_to_remote(host).expect("host under host_root");
1199        assert_eq!(remote, PathBuf::from("/workspaces/proj/src/util.py"));
1200        assert_eq!(
1201            pt.remote_to_host(&remote)
1202                .expect("remote under remote_root"),
1203            host.to_path_buf(),
1204        );
1205    }
1206
1207    #[test]
1208    fn path_translation_returns_none_outside_root() {
1209        // Library / system paths sit outside the workspace mapping —
1210        // callers decide what to do with them. The translator just
1211        // says "not mine".
1212        let pt = PathTranslation {
1213            host_root: PathBuf::from("/host/proj"),
1214            remote_root: PathBuf::from("/workspaces/proj"),
1215        };
1216        assert!(pt
1217            .host_to_remote(Path::new("/usr/include/stdio.h"))
1218            .is_none());
1219        assert!(pt
1220            .remote_to_host(Path::new("/usr/include/stdio.h"))
1221            .is_none());
1222    }
1223
1224    #[test]
1225    fn from_plugin_payload_with_path_translation_round_trips() {
1226        // Plugins (the devcontainer one in particular) supply both
1227        // workspace roots so LSP URIs translate at the boundary. The
1228        // wire shape uses strings so it survives JSON; the constructed
1229        // authority parses them into `PathBuf`.
1230        let json = serde_json::json!({
1231            "filesystem": { "kind": "local" },
1232            "spawner": {
1233                "kind": "docker-exec",
1234                "container_id": "abc123",
1235                "workspace": "/workspaces/proj"
1236            },
1237            "terminal_wrapper": { "kind": "host-shell" },
1238            "path_translation": {
1239                "host_root": "/tmp/.tmpA1B2",
1240                "remote_root": "/workspaces/proj"
1241            }
1242        });
1243        let payload: AuthorityPayload =
1244            serde_json::from_value(json).expect("path_translation is accepted");
1245        let auth = Authority::from_plugin_payload(
1246            payload,
1247            Arc::new(WorkspaceTrust::permissive()),
1248            Arc::new(crate::services::env_provider::EnvProvider::inactive()),
1249        )
1250        .expect("payload with translation is valid");
1251        let pt = auth
1252            .path_translation
1253            .expect("authority carries the translation");
1254        assert_eq!(pt.host_root, PathBuf::from("/tmp/.tmpA1B2"));
1255        assert_eq!(pt.remote_root, PathBuf::from("/workspaces/proj"));
1256    }
1257}