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}