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()` — host filesystem + host spawner + host shell
23//!   wrapped without args. Always available; the editor boots with this.
24//! - `Authority::ssh(filesystem, spawner, display_label)` — used by the
25//!   `fresh user@host:path` startup flow.
26//! - `Authority::from_plugin_payload(payload)` — built from the
27//!   `editor.setAuthority(...)` plugin op. The payload is a tagged shape
28//!   (filesystem kind + spawner kind + terminal wrapper + label); it stays
29//!   small and additive so we can grow new kinds without breaking the
30//!   plugin contract.
31
32use std::path::{Path, PathBuf};
33use std::sync::Arc;
34
35use serde::{Deserialize, Serialize};
36
37use crate::model::filesystem::{FileSystem, StdFileSystem};
38use crate::services::remote::{
39    LocalLongRunningSpawner, LocalProcessSpawner, LongRunningSpawner, ProcessSpawner,
40};
41
42/// Plugin-supplied form of the host↔remote workspace mapping. Plugins
43/// build this from their own knowledge (e.g. the devcontainer plugin
44/// already has `editor.getCwd()` for the host root and
45/// `result.remoteWorkspaceFolder` for the in-container root). Strings
46/// because the wire format is JSON; paths get parsed in
47/// `Authority::from_plugin_payload`.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct PathTranslationSpec {
50    pub host_root: String,
51    pub remote_root: String,
52}
53
54/// Symmetric path translation between host and remote workspace
55/// roots. Owned by the active [`Authority`] when the backend lives in
56/// a container (or any other place where the workspace is mounted at a
57/// different path than its on-host location). Local authorities and
58/// SSH leave this field unset.
59///
60/// LSP URIs are the primary consumer: the editor's buffer file paths
61/// are host-side, but the LSP server is on the other side of the
62/// mount and only knows the remote-side path. We translate at the
63/// boundary so the editor can keep using host paths internally and
64/// the LSP keeps seeing the paths it expects.
65#[derive(Debug, Clone)]
66pub struct PathTranslation {
67    pub host_root: PathBuf,
68    pub remote_root: PathBuf,
69}
70
71impl PathTranslation {
72    /// Map a host-side path under `host_root` to its remote-side
73    /// counterpart. Returns `None` for paths outside the workspace
74    /// (e.g. system headers, library sources) — those are passed
75    /// through unchanged so the caller can decide whether to forward
76    /// them as-is or drop them.
77    pub fn host_to_remote(&self, host: &Path) -> Option<PathBuf> {
78        let rel = host.strip_prefix(&self.host_root).ok()?;
79        Some(self.remote_root.join(rel))
80    }
81
82    /// Map a remote-side path under `remote_root` back to its
83    /// host-side counterpart. Same outside-the-workspace caveat as
84    /// [`Self::host_to_remote`].
85    pub fn remote_to_host(&self, remote: &Path) -> Option<PathBuf> {
86        let rel = remote.strip_prefix(&self.remote_root).ok()?;
87        Some(self.host_root.join(rel))
88    }
89}
90
91/// How the integrated terminal is launched under this authority.
92///
93/// The terminal manager unconditionally honours this — there is no
94/// "no wrapper" branch.  For local authority, the wrapper command is the
95/// detected host shell with no extra args; `manages_cwd` is false so the
96/// terminal manager calls `CommandBuilder::cwd()` itself.  Authorities
97/// that re-parent the shell (e.g. `docker exec -w <workspace>`) set
98/// `manages_cwd = true` so cwd is left to the wrapper's args.
99#[derive(Debug, Clone)]
100pub struct TerminalWrapper {
101    /// Command to execute (e.g. the host shell, `"docker"`, `"ssh"`).
102    pub command: String,
103    /// Arguments passed before any user input — usually the flags that
104    /// drop the user into an interactive shell at the right place.
105    pub args: Vec<String>,
106    /// If true, `args` already establishes the working directory and the
107    /// terminal manager must skip `CommandBuilder::cwd()`. For local
108    /// authorities this is false so the host shell honours the per-
109    /// terminal cwd the editor passes in.
110    pub manages_cwd: bool,
111}
112
113impl TerminalWrapper {
114    /// Wrap the detected host shell with no extra args. Cwd is set by
115    /// the terminal manager from the spawn call.
116    pub fn host_shell() -> Self {
117        Self {
118            command: crate::services::terminal::manager::detect_shell(),
119            args: Vec::new(),
120            manages_cwd: false,
121        }
122    }
123
124    /// Apply the user's `terminal.shell` config override on top of this
125    /// wrapper. The override replaces `command` and `args` only when the
126    /// wrapper leaves cwd management to the terminal manager
127    /// (`manages_cwd == false`) — that is, for the host-shell wrapper.
128    /// Authorities that re-parent the shell (e.g. `docker exec -w …`,
129    /// `ssh …`) pin cwd through their own args and are left untouched so
130    /// the re-parenting stays intact.
131    pub fn with_user_shell_override(
132        mut self,
133        shell: Option<&crate::config::TerminalShellConfig>,
134    ) -> Self {
135        if let Some(shell) = shell {
136            if !self.manages_cwd {
137                self.command = shell.command.clone();
138                self.args = shell.args.clone();
139            }
140        }
141        self
142    }
143}
144
145/// Tagged payload describing how to build an authority from a plugin.
146///
147/// Kept intentionally small and explicit. Adding a new spawner or
148/// filesystem kind means adding a new variant here and a constructor in
149/// `Authority::from_plugin_payload`. Plugins consuming the API see only
150/// the `kind` discriminator and the kind-specific params, so old payloads
151/// keep working as new kinds are added.
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct AuthorityPayload {
154    pub filesystem: FilesystemSpec,
155    pub spawner: SpawnerSpec,
156    pub terminal_wrapper: TerminalWrapperSpec,
157    /// Status-bar / explorer label. Empty = no label rendered.
158    #[serde(default)]
159    pub display_label: String,
160    /// Optional host↔remote workspace path mapping. Devcontainer-style
161    /// authorities supply both the host workspace path (the editor's
162    /// `cwd` at attach time) and the in-container `remoteWorkspaceFolder`
163    /// so URIs traveling to/from the LSP get translated symmetrically.
164    /// SSH and local authorities leave this unset.
165    #[serde(default)]
166    pub path_translation: Option<PathTranslationSpec>,
167}
168
169/// Filesystem kind chosen by a plugin payload.
170#[derive(Debug, Clone, Serialize, Deserialize)]
171#[serde(tag = "kind", rename_all = "kebab-case")]
172pub enum FilesystemSpec {
173    /// Use the host filesystem. Devcontainers fall here because the
174    /// workspace is mounted into the container, so file paths translate
175    /// 1:1 between host and container.
176    Local,
177}
178
179/// Process-spawner kind chosen by a plugin payload.
180#[derive(Debug, Clone, Serialize, Deserialize)]
181#[serde(tag = "kind", rename_all = "kebab-case")]
182pub enum SpawnerSpec {
183    /// Spawn on the host. Equivalent to `LocalProcessSpawner`.
184    Local,
185    /// Run via `docker exec` against a long-lived container. The plugin
186    /// manages the container lifecycle (e.g. via `editor.spawnHostProcess`
187    /// to invoke `devcontainer up`) and hands us the container id once it
188    /// is ready.
189    ///
190    /// `env` is the captured `userEnvProbe` snapshot from inside the
191    /// container — typically `PATH`, `HOME`, `LANG`, and any vars the
192    /// user's profile exports. It's applied to every `docker exec`
193    /// (one-shot spawns, LSP/long-running, `command_exists` probes)
194    /// so plugins-installed binaries on `~/.local/bin` (or any
195    /// shell-only PATH) actually resolve. Empty when `userEnvProbe`
196    /// is `none` or the probe fails.
197    DockerExec {
198        container_id: String,
199        #[serde(default)]
200        user: Option<String>,
201        #[serde(default)]
202        workspace: Option<String>,
203        /// Captured `userEnvProbe` env. Order is preserved so
204        /// per-call `env` can layer over it deterministically; the
205        /// list of pairs (rather than a HashMap) keeps `docker exec
206        /// -e` ordering explicit.
207        #[serde(default)]
208        env: Vec<(String, String)>,
209    },
210}
211
212/// Terminal-wrapper kind chosen by a plugin payload.
213#[derive(Debug, Clone, Serialize, Deserialize)]
214#[serde(tag = "kind", rename_all = "kebab-case")]
215pub enum TerminalWrapperSpec {
216    /// Use the detected host shell.
217    HostShell,
218    /// Use an explicit command + args (e.g. `docker exec -it -u <user>
219    /// -w <workspace> <id> bash -l`). `manages_cwd` defaults to true
220    /// because that is the only sensible choice for re-parented shells.
221    Explicit {
222        command: String,
223        args: Vec<String>,
224        #[serde(default = "default_true")]
225        manages_cwd: bool,
226    },
227}
228
229fn default_true() -> bool {
230    true
231}
232
233/// The single backend slot. Replaces the old quartet of `filesystem`,
234/// `process_spawner`, `terminal_wrapper`, and `authority_display_string`
235/// fields on `Editor`. Cloned cheaply via `Arc`s.
236#[derive(Clone)]
237pub struct Authority {
238    pub filesystem: Arc<dyn FileSystem + Send + Sync>,
239    pub process_spawner: Arc<dyn ProcessSpawner>,
240    /// Spawner for long-lived stdio processes — LSP servers today, tool
241    /// agents tomorrow. Container authorities wire this to a
242    /// `docker exec -i` variant so servers run inside the container
243    /// rather than on the host. Without it, LSP bypasses the authority
244    /// entirely (see `AUTHORITY_DESIGN.md` principle 2).
245    pub long_running_spawner: Arc<dyn LongRunningSpawner>,
246    pub terminal_wrapper: TerminalWrapper,
247    /// Status-bar / file-explorer label. Empty means render nothing.
248    /// SSH leaves this empty and lets the status bar fall back to the
249    /// filesystem's `remote_connection_info()` so disconnect annotations
250    /// stay in one place.
251    pub display_label: String,
252    /// Host↔remote workspace path mapping for backends where the
253    /// workspace is mounted at a different path than its on-host
254    /// location. The dev-container authority populates this so LSP
255    /// URIs translate at the host/container boundary; local and SSH
256    /// authorities leave it `None` and URIs flow through unchanged.
257    pub path_translation: Option<PathTranslation>,
258}
259
260impl Authority {
261    /// Default boot-time authority: host filesystem, host process
262    /// spawner, host shell wrapper. The editor starts here on every
263    /// startup; SSH or plugin-installed authorities replace it later.
264    pub fn local() -> Self {
265        Self {
266            filesystem: Arc::new(StdFileSystem),
267            process_spawner: Arc::new(LocalProcessSpawner),
268            long_running_spawner: Arc::new(LocalLongRunningSpawner),
269            terminal_wrapper: TerminalWrapper::host_shell(),
270            display_label: String::new(),
271            path_translation: None,
272        }
273    }
274
275    /// Build an SSH authority. The caller already holds the connection
276    /// (and its keepalive resources) so we just wire the parts in. Label
277    /// is left empty — the status bar falls back to the filesystem's own
278    /// `remote_connection_info()` which knows how to annotate disconnect.
279    ///
280    /// `long_running_spawner` defaults to the local implementation for
281    /// now; Phase L of the dev-container gap plan adds an SSH-routed
282    /// variant so LSP runs on the remote host. Until then, LSP over SSH
283    /// still spawns on the host — a pre-existing limitation the plan
284    /// documents but defers.
285    pub fn ssh(
286        filesystem: Arc<dyn FileSystem + Send + Sync>,
287        process_spawner: Arc<dyn ProcessSpawner>,
288    ) -> Self {
289        Self {
290            filesystem,
291            process_spawner,
292            long_running_spawner: Arc::new(LocalLongRunningSpawner),
293            terminal_wrapper: TerminalWrapper::host_shell(),
294            display_label: String::new(),
295            path_translation: None,
296        }
297    }
298
299    /// Build an authority from a plugin payload (the data carried by the
300    /// `editor.setAuthority(...)` op). All translation from "kind +
301    /// params" to concrete `Arc<dyn …>` lives here and nowhere else.
302    pub fn from_plugin_payload(payload: AuthorityPayload) -> Result<Self, AuthorityPayloadError> {
303        let filesystem: Arc<dyn FileSystem + Send + Sync> = match payload.filesystem {
304            FilesystemSpec::Local => Arc::new(StdFileSystem),
305        };
306
307        // Both spawner traits need the docker-exec params when the
308        // payload is a container, so destructure once and reuse.
309        let (process_spawner, long_running_spawner): (
310            Arc<dyn ProcessSpawner>,
311            Arc<dyn LongRunningSpawner>,
312        ) = match payload.spawner {
313            SpawnerSpec::Local => (
314                Arc::new(LocalProcessSpawner),
315                Arc::new(LocalLongRunningSpawner),
316            ),
317            SpawnerSpec::DockerExec {
318                container_id,
319                user,
320                workspace,
321                env,
322            } => (
323                Arc::new(
324                    crate::services::authority::docker_spawner::DockerExecSpawner::with_env(
325                        container_id.clone(),
326                        user.clone(),
327                        workspace.clone(),
328                        env.clone(),
329                    ),
330                ),
331                Arc::new(
332                    crate::services::authority::docker_spawner::DockerLongRunningSpawner::with_env(
333                        container_id,
334                        user,
335                        workspace,
336                        env,
337                    ),
338                ),
339            ),
340        };
341
342        let terminal_wrapper = match payload.terminal_wrapper {
343            TerminalWrapperSpec::HostShell => TerminalWrapper::host_shell(),
344            TerminalWrapperSpec::Explicit {
345                command,
346                args,
347                manages_cwd,
348            } => TerminalWrapper {
349                command,
350                args,
351                manages_cwd,
352            },
353        };
354
355        let path_translation = payload.path_translation.map(|spec| PathTranslation {
356            host_root: PathBuf::from(spec.host_root),
357            remote_root: PathBuf::from(spec.remote_root),
358        });
359
360        Ok(Self {
361            filesystem,
362            process_spawner,
363            long_running_spawner,
364            terminal_wrapper,
365            display_label: payload.display_label,
366            path_translation,
367        })
368    }
369}
370
371/// Error from translating a plugin payload into a live authority.
372/// Reserved for future kinds that might fail to construct (e.g. invalid
373/// connection parameters); local-only payloads currently never fail.
374#[derive(Debug, thiserror::Error)]
375pub enum AuthorityPayloadError {
376    #[error("invalid authority payload: {0}")]
377    Invalid(String),
378}
379
380mod docker_spawner;
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    #[test]
387    fn local_authority_uses_host_shell_with_no_args() {
388        let auth = Authority::local();
389        assert!(!auth.terminal_wrapper.command.is_empty());
390        assert!(auth.terminal_wrapper.args.is_empty());
391        assert!(!auth.terminal_wrapper.manages_cwd);
392        assert_eq!(auth.display_label, "");
393    }
394
395    #[test]
396    fn from_plugin_payload_local_yields_host_shell() {
397        let payload = AuthorityPayload {
398            filesystem: FilesystemSpec::Local,
399            spawner: SpawnerSpec::Local,
400            terminal_wrapper: TerminalWrapperSpec::HostShell,
401            display_label: String::new(),
402            path_translation: None,
403        };
404        let auth = Authority::from_plugin_payload(payload).expect("local payload is valid");
405        assert!(!auth.terminal_wrapper.command.is_empty());
406        assert!(auth.terminal_wrapper.args.is_empty());
407    }
408
409    #[test]
410    fn payload_roundtrips_through_serde_json() {
411        // The plugin op carries the payload as opaque JSON through
412        // `fresh-core`; this test nails down the wire shape so we
413        // don't silently break plugins when the struct evolves.
414        let json = serde_json::json!({
415            "filesystem": { "kind": "local" },
416            "spawner": {
417                "kind": "docker-exec",
418                "container_id": "abc123",
419                "user": "vscode",
420                "workspace": "/workspaces/proj"
421            },
422            "terminal_wrapper": {
423                "kind": "explicit",
424                "command": "docker",
425                "args": ["exec", "-it", "abc123", "bash", "-l"],
426                "manages_cwd": true
427            },
428            "display_label": "Container:abc123"
429        });
430        let payload: AuthorityPayload =
431            serde_json::from_value(json).expect("json matches payload schema");
432        let auth = Authority::from_plugin_payload(payload).expect("docker payload is valid");
433        assert_eq!(auth.terminal_wrapper.command, "docker");
434        assert!(auth.terminal_wrapper.manages_cwd);
435        assert_eq!(auth.display_label, "Container:abc123");
436    }
437
438    #[test]
439    fn payload_accepts_docker_exec_env_pairs() {
440        // The captured `userEnvProbe` env carries the in-container
441        // `PATH`/`HOME`/etc. so LSP `command_exists` can find binaries
442        // that live in shell-only PATHs (e.g. `~/.local/bin`).
443        let json = serde_json::json!({
444            "filesystem": { "kind": "local" },
445            "spawner": {
446                "kind": "docker-exec",
447                "container_id": "abc123",
448                "user": "vscode",
449                "workspace": "/workspaces/proj",
450                "env": [
451                    ["PATH", "/home/vscode/.local/bin:/usr/bin"],
452                    ["LANG", "C.UTF-8"]
453                ]
454            },
455            "terminal_wrapper": { "kind": "host-shell" }
456        });
457        let payload: AuthorityPayload =
458            serde_json::from_value(json).expect("env field is accepted");
459        if let SpawnerSpec::DockerExec { env, .. } = &payload.spawner {
460            assert_eq!(env.len(), 2);
461            assert_eq!(
462                env[0],
463                ("PATH".into(), "/home/vscode/.local/bin:/usr/bin".into())
464            );
465            assert_eq!(env[1], ("LANG".into(), "C.UTF-8".into()));
466        } else {
467            panic!("expected docker-exec spawner");
468        }
469        // And the omitted-field form still parses (the field defaults
470        // to empty), so older plugins that don't populate it stay
471        // wire-compatible.
472        let json_no_env = serde_json::json!({
473            "filesystem": { "kind": "local" },
474            "spawner": {
475                "kind": "docker-exec",
476                "container_id": "abc123"
477            },
478            "terminal_wrapper": { "kind": "host-shell" }
479        });
480        let payload2: AuthorityPayload =
481            serde_json::from_value(json_no_env).expect("env is optional");
482        if let SpawnerSpec::DockerExec { env, .. } = payload2.spawner {
483            assert!(env.is_empty());
484        } else {
485            panic!("expected docker-exec spawner");
486        }
487    }
488
489    #[test]
490    fn payload_defaults_manages_cwd_to_true_for_explicit_wrapper() {
491        // Per the schema, `manages_cwd` is optional in the JSON and
492        // defaults to true because re-parented shells almost always
493        // want it that way.
494        let json = serde_json::json!({
495            "filesystem": { "kind": "local" },
496            "spawner": { "kind": "local" },
497            "terminal_wrapper": {
498                "kind": "explicit",
499                "command": "bash",
500                "args": []
501            }
502        });
503        let payload: AuthorityPayload =
504            serde_json::from_value(json).expect("manages_cwd is optional");
505        let auth = Authority::from_plugin_payload(payload).expect("payload is valid");
506        assert!(auth.terminal_wrapper.manages_cwd);
507        assert_eq!(auth.display_label, "");
508    }
509
510    #[test]
511    fn user_shell_override_replaces_host_shell_wrapper() {
512        let override_shell = crate::config::TerminalShellConfig {
513            command: "/usr/local/bin/fish".into(),
514            args: vec!["-l".into(), "-i".into()],
515        };
516        let wrapper = TerminalWrapper::host_shell().with_user_shell_override(Some(&override_shell));
517        assert_eq!(wrapper.command, "/usr/local/bin/fish");
518        assert_eq!(wrapper.args, vec!["-l".to_string(), "-i".to_string()]);
519        assert!(!wrapper.manages_cwd);
520    }
521
522    #[test]
523    fn user_shell_override_is_noop_when_wrapper_manages_cwd() {
524        // Docker/SSH-style wrappers set `manages_cwd = true`; replacing
525        // their command would drop the re-parenting args and spawn the
526        // user's shell on the host, defeating the authority.
527        let docker = TerminalWrapper {
528            command: "docker".into(),
529            args: vec![
530                "exec".into(),
531                "-w".into(),
532                "/workspaces/proj".into(),
533                "abc123".into(),
534                "bash".into(),
535            ],
536            manages_cwd: true,
537        };
538        let override_shell = crate::config::TerminalShellConfig {
539            command: "/usr/local/bin/fish".into(),
540            args: vec![],
541        };
542        let wrapper = docker
543            .clone()
544            .with_user_shell_override(Some(&override_shell));
545        assert_eq!(wrapper.command, docker.command);
546        assert_eq!(wrapper.args, docker.args);
547        assert!(wrapper.manages_cwd);
548    }
549
550    #[test]
551    fn user_shell_override_none_leaves_wrapper_unchanged() {
552        let original = TerminalWrapper::host_shell();
553        let wrapper = original.clone().with_user_shell_override(None);
554        assert_eq!(wrapper.command, original.command);
555        assert_eq!(wrapper.args, original.args);
556        assert_eq!(wrapper.manages_cwd, original.manages_cwd);
557    }
558
559    #[test]
560    fn from_plugin_payload_docker_exec_carries_label() {
561        let payload = AuthorityPayload {
562            filesystem: FilesystemSpec::Local,
563            spawner: SpawnerSpec::DockerExec {
564                container_id: "abc123".into(),
565                user: Some("vscode".into()),
566                workspace: Some("/workspaces/proj".into()),
567                env: Vec::new(),
568            },
569            terminal_wrapper: TerminalWrapperSpec::Explicit {
570                command: "docker".into(),
571                args: vec![
572                    "exec".into(),
573                    "-it".into(),
574                    "-u".into(),
575                    "vscode".into(),
576                    "-w".into(),
577                    "/workspaces/proj".into(),
578                    "abc123".into(),
579                    "bash".into(),
580                    "-l".into(),
581                ],
582                manages_cwd: true,
583            },
584            display_label: "Container:abc123".into(),
585            path_translation: None,
586        };
587        let auth = Authority::from_plugin_payload(payload).expect("docker payload is valid");
588        assert_eq!(auth.terminal_wrapper.command, "docker");
589        assert!(auth.terminal_wrapper.manages_cwd);
590        assert_eq!(auth.display_label, "Container:abc123");
591    }
592
593    #[test]
594    fn path_translation_round_trips_under_workspace() {
595        let pt = PathTranslation {
596            host_root: PathBuf::from("/tmp/.tmpA1B2"),
597            remote_root: PathBuf::from("/workspaces/proj"),
598        };
599        let host = Path::new("/tmp/.tmpA1B2/src/util.py");
600        let remote = pt.host_to_remote(host).expect("host under host_root");
601        assert_eq!(remote, PathBuf::from("/workspaces/proj/src/util.py"));
602        assert_eq!(
603            pt.remote_to_host(&remote)
604                .expect("remote under remote_root"),
605            host.to_path_buf(),
606        );
607    }
608
609    #[test]
610    fn path_translation_returns_none_outside_root() {
611        // Library / system paths sit outside the workspace mapping —
612        // callers decide what to do with them. The translator just
613        // says "not mine".
614        let pt = PathTranslation {
615            host_root: PathBuf::from("/host/proj"),
616            remote_root: PathBuf::from("/workspaces/proj"),
617        };
618        assert!(pt
619            .host_to_remote(Path::new("/usr/include/stdio.h"))
620            .is_none());
621        assert!(pt
622            .remote_to_host(Path::new("/usr/include/stdio.h"))
623            .is_none());
624    }
625
626    #[test]
627    fn from_plugin_payload_with_path_translation_round_trips() {
628        // Plugins (the devcontainer one in particular) supply both
629        // workspace roots so LSP URIs translate at the boundary. The
630        // wire shape uses strings so it survives JSON; the constructed
631        // authority parses them into `PathBuf`.
632        let json = serde_json::json!({
633            "filesystem": { "kind": "local" },
634            "spawner": {
635                "kind": "docker-exec",
636                "container_id": "abc123",
637                "workspace": "/workspaces/proj"
638            },
639            "terminal_wrapper": { "kind": "host-shell" },
640            "path_translation": {
641                "host_root": "/tmp/.tmpA1B2",
642                "remote_root": "/workspaces/proj"
643            }
644        });
645        let payload: AuthorityPayload =
646            serde_json::from_value(json).expect("path_translation is accepted");
647        let auth =
648            Authority::from_plugin_payload(payload).expect("payload with translation is valid");
649        let pt = auth
650            .path_translation
651            .expect("authority carries the translation");
652        assert_eq!(pt.host_root, PathBuf::from("/tmp/.tmpA1B2"));
653        assert_eq!(pt.remote_root, PathBuf::from("/workspaces/proj"));
654    }
655}