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}