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