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::sync::Arc;
33
34use serde::{Deserialize, Serialize};
35
36use crate::model::filesystem::{FileSystem, StdFileSystem};
37use crate::services::remote::{
38    LocalLongRunningSpawner, LocalProcessSpawner, LongRunningSpawner, ProcessSpawner,
39};
40
41/// How the integrated terminal is launched under this authority.
42///
43/// The terminal manager unconditionally honours this — there is no
44/// "no wrapper" branch.  For local authority, the wrapper command is the
45/// detected host shell with no extra args; `manages_cwd` is false so the
46/// terminal manager calls `CommandBuilder::cwd()` itself.  Authorities
47/// that re-parent the shell (e.g. `docker exec -w <workspace>`) set
48/// `manages_cwd = true` so cwd is left to the wrapper's args.
49#[derive(Debug, Clone)]
50pub struct TerminalWrapper {
51    /// Command to execute (e.g. the host shell, `"docker"`, `"ssh"`).
52    pub command: String,
53    /// Arguments passed before any user input — usually the flags that
54    /// drop the user into an interactive shell at the right place.
55    pub args: Vec<String>,
56    /// If true, `args` already establishes the working directory and the
57    /// terminal manager must skip `CommandBuilder::cwd()`. For local
58    /// authorities this is false so the host shell honours the per-
59    /// terminal cwd the editor passes in.
60    pub manages_cwd: bool,
61}
62
63impl TerminalWrapper {
64    /// Wrap the detected host shell with no extra args. Cwd is set by
65    /// the terminal manager from the spawn call.
66    pub fn host_shell() -> Self {
67        Self {
68            command: crate::services::terminal::manager::detect_shell(),
69            args: Vec::new(),
70            manages_cwd: false,
71        }
72    }
73
74    /// Apply the user's `terminal.shell` config override on top of this
75    /// wrapper. The override replaces `command` and `args` only when the
76    /// wrapper leaves cwd management to the terminal manager
77    /// (`manages_cwd == false`) — that is, for the host-shell wrapper.
78    /// Authorities that re-parent the shell (e.g. `docker exec -w …`,
79    /// `ssh …`) pin cwd through their own args and are left untouched so
80    /// the re-parenting stays intact.
81    pub fn with_user_shell_override(
82        mut self,
83        shell: Option<&crate::config::TerminalShellConfig>,
84    ) -> Self {
85        if let Some(shell) = shell {
86            if !self.manages_cwd {
87                self.command = shell.command.clone();
88                self.args = shell.args.clone();
89            }
90        }
91        self
92    }
93}
94
95/// Tagged payload describing how to build an authority from a plugin.
96///
97/// Kept intentionally small and explicit. Adding a new spawner or
98/// filesystem kind means adding a new variant here and a constructor in
99/// `Authority::from_plugin_payload`. Plugins consuming the API see only
100/// the `kind` discriminator and the kind-specific params, so old payloads
101/// keep working as new kinds are added.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct AuthorityPayload {
104    pub filesystem: FilesystemSpec,
105    pub spawner: SpawnerSpec,
106    pub terminal_wrapper: TerminalWrapperSpec,
107    /// Status-bar / explorer label. Empty = no label rendered.
108    #[serde(default)]
109    pub display_label: String,
110}
111
112/// Filesystem kind chosen by a plugin payload.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114#[serde(tag = "kind", rename_all = "kebab-case")]
115pub enum FilesystemSpec {
116    /// Use the host filesystem. Devcontainers fall here because the
117    /// workspace is mounted into the container, so file paths translate
118    /// 1:1 between host and container.
119    Local,
120}
121
122/// Process-spawner kind chosen by a plugin payload.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124#[serde(tag = "kind", rename_all = "kebab-case")]
125pub enum SpawnerSpec {
126    /// Spawn on the host. Equivalent to `LocalProcessSpawner`.
127    Local,
128    /// Run via `docker exec` against a long-lived container. The plugin
129    /// manages the container lifecycle (e.g. via `editor.spawnHostProcess`
130    /// to invoke `devcontainer up`) and hands us the container id once it
131    /// is ready.
132    DockerExec {
133        container_id: String,
134        #[serde(default)]
135        user: Option<String>,
136        #[serde(default)]
137        workspace: Option<String>,
138    },
139}
140
141/// Terminal-wrapper kind chosen by a plugin payload.
142#[derive(Debug, Clone, Serialize, Deserialize)]
143#[serde(tag = "kind", rename_all = "kebab-case")]
144pub enum TerminalWrapperSpec {
145    /// Use the detected host shell.
146    HostShell,
147    /// Use an explicit command + args (e.g. `docker exec -it -u <user>
148    /// -w <workspace> <id> bash -l`). `manages_cwd` defaults to true
149    /// because that is the only sensible choice for re-parented shells.
150    Explicit {
151        command: String,
152        args: Vec<String>,
153        #[serde(default = "default_true")]
154        manages_cwd: bool,
155    },
156}
157
158fn default_true() -> bool {
159    true
160}
161
162/// The single backend slot. Replaces the old quartet of `filesystem`,
163/// `process_spawner`, `terminal_wrapper`, and `authority_display_string`
164/// fields on `Editor`. Cloned cheaply via `Arc`s.
165#[derive(Clone)]
166pub struct Authority {
167    pub filesystem: Arc<dyn FileSystem + Send + Sync>,
168    pub process_spawner: Arc<dyn ProcessSpawner>,
169    /// Spawner for long-lived stdio processes — LSP servers today, tool
170    /// agents tomorrow. Container authorities wire this to a
171    /// `docker exec -i` variant so servers run inside the container
172    /// rather than on the host. Without it, LSP bypasses the authority
173    /// entirely (see `AUTHORITY_DESIGN.md` principle 2).
174    pub long_running_spawner: Arc<dyn LongRunningSpawner>,
175    pub terminal_wrapper: TerminalWrapper,
176    /// Status-bar / file-explorer label. Empty means render nothing.
177    /// SSH leaves this empty and lets the status bar fall back to the
178    /// filesystem's `remote_connection_info()` so disconnect annotations
179    /// stay in one place.
180    pub display_label: String,
181}
182
183impl Authority {
184    /// Default boot-time authority: host filesystem, host process
185    /// spawner, host shell wrapper. The editor starts here on every
186    /// startup; SSH or plugin-installed authorities replace it later.
187    pub fn local() -> Self {
188        Self {
189            filesystem: Arc::new(StdFileSystem),
190            process_spawner: Arc::new(LocalProcessSpawner),
191            long_running_spawner: Arc::new(LocalLongRunningSpawner),
192            terminal_wrapper: TerminalWrapper::host_shell(),
193            display_label: String::new(),
194        }
195    }
196
197    /// Build an SSH authority. The caller already holds the connection
198    /// (and its keepalive resources) so we just wire the parts in. Label
199    /// is left empty — the status bar falls back to the filesystem's own
200    /// `remote_connection_info()` which knows how to annotate disconnect.
201    ///
202    /// `long_running_spawner` defaults to the local implementation for
203    /// now; Phase L of the dev-container gap plan adds an SSH-routed
204    /// variant so LSP runs on the remote host. Until then, LSP over SSH
205    /// still spawns on the host — a pre-existing limitation the plan
206    /// documents but defers.
207    pub fn ssh(
208        filesystem: Arc<dyn FileSystem + Send + Sync>,
209        process_spawner: Arc<dyn ProcessSpawner>,
210    ) -> Self {
211        Self {
212            filesystem,
213            process_spawner,
214            long_running_spawner: Arc::new(LocalLongRunningSpawner),
215            terminal_wrapper: TerminalWrapper::host_shell(),
216            display_label: String::new(),
217        }
218    }
219
220    /// Build an authority from a plugin payload (the data carried by the
221    /// `editor.setAuthority(...)` op). All translation from "kind +
222    /// params" to concrete `Arc<dyn …>` lives here and nowhere else.
223    pub fn from_plugin_payload(payload: AuthorityPayload) -> Result<Self, AuthorityPayloadError> {
224        let filesystem: Arc<dyn FileSystem + Send + Sync> = match payload.filesystem {
225            FilesystemSpec::Local => Arc::new(StdFileSystem),
226        };
227
228        // Both spawner traits need the docker-exec params when the
229        // payload is a container, so destructure once and reuse.
230        let (process_spawner, long_running_spawner): (
231            Arc<dyn ProcessSpawner>,
232            Arc<dyn LongRunningSpawner>,
233        ) = match payload.spawner {
234            SpawnerSpec::Local => (
235                Arc::new(LocalProcessSpawner),
236                Arc::new(LocalLongRunningSpawner),
237            ),
238            SpawnerSpec::DockerExec {
239                container_id,
240                user,
241                workspace,
242            } => (
243                Arc::new(
244                    crate::services::authority::docker_spawner::DockerExecSpawner::new(
245                        container_id.clone(),
246                        user.clone(),
247                        workspace.clone(),
248                    ),
249                ),
250                Arc::new(
251                    crate::services::authority::docker_spawner::DockerLongRunningSpawner::new(
252                        container_id,
253                        user,
254                        workspace,
255                    ),
256                ),
257            ),
258        };
259
260        let terminal_wrapper = match payload.terminal_wrapper {
261            TerminalWrapperSpec::HostShell => TerminalWrapper::host_shell(),
262            TerminalWrapperSpec::Explicit {
263                command,
264                args,
265                manages_cwd,
266            } => TerminalWrapper {
267                command,
268                args,
269                manages_cwd,
270            },
271        };
272
273        Ok(Self {
274            filesystem,
275            process_spawner,
276            long_running_spawner,
277            terminal_wrapper,
278            display_label: payload.display_label,
279        })
280    }
281}
282
283/// Error from translating a plugin payload into a live authority.
284/// Reserved for future kinds that might fail to construct (e.g. invalid
285/// connection parameters); local-only payloads currently never fail.
286#[derive(Debug, thiserror::Error)]
287pub enum AuthorityPayloadError {
288    #[error("invalid authority payload: {0}")]
289    Invalid(String),
290}
291
292mod docker_spawner;
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn local_authority_uses_host_shell_with_no_args() {
300        let auth = Authority::local();
301        assert!(!auth.terminal_wrapper.command.is_empty());
302        assert!(auth.terminal_wrapper.args.is_empty());
303        assert!(!auth.terminal_wrapper.manages_cwd);
304        assert_eq!(auth.display_label, "");
305    }
306
307    #[test]
308    fn from_plugin_payload_local_yields_host_shell() {
309        let payload = AuthorityPayload {
310            filesystem: FilesystemSpec::Local,
311            spawner: SpawnerSpec::Local,
312            terminal_wrapper: TerminalWrapperSpec::HostShell,
313            display_label: String::new(),
314        };
315        let auth = Authority::from_plugin_payload(payload).expect("local payload is valid");
316        assert!(!auth.terminal_wrapper.command.is_empty());
317        assert!(auth.terminal_wrapper.args.is_empty());
318    }
319
320    #[test]
321    fn payload_roundtrips_through_serde_json() {
322        // The plugin op carries the payload as opaque JSON through
323        // `fresh-core`; this test nails down the wire shape so we
324        // don't silently break plugins when the struct evolves.
325        let json = serde_json::json!({
326            "filesystem": { "kind": "local" },
327            "spawner": {
328                "kind": "docker-exec",
329                "container_id": "abc123",
330                "user": "vscode",
331                "workspace": "/workspaces/proj"
332            },
333            "terminal_wrapper": {
334                "kind": "explicit",
335                "command": "docker",
336                "args": ["exec", "-it", "abc123", "bash", "-l"],
337                "manages_cwd": true
338            },
339            "display_label": "Container:abc123"
340        });
341        let payload: AuthorityPayload =
342            serde_json::from_value(json).expect("json matches payload schema");
343        let auth = Authority::from_plugin_payload(payload).expect("docker payload is valid");
344        assert_eq!(auth.terminal_wrapper.command, "docker");
345        assert!(auth.terminal_wrapper.manages_cwd);
346        assert_eq!(auth.display_label, "Container:abc123");
347    }
348
349    #[test]
350    fn payload_defaults_manages_cwd_to_true_for_explicit_wrapper() {
351        // Per the schema, `manages_cwd` is optional in the JSON and
352        // defaults to true because re-parented shells almost always
353        // want it that way.
354        let json = serde_json::json!({
355            "filesystem": { "kind": "local" },
356            "spawner": { "kind": "local" },
357            "terminal_wrapper": {
358                "kind": "explicit",
359                "command": "bash",
360                "args": []
361            }
362        });
363        let payload: AuthorityPayload =
364            serde_json::from_value(json).expect("manages_cwd is optional");
365        let auth = Authority::from_plugin_payload(payload).expect("payload is valid");
366        assert!(auth.terminal_wrapper.manages_cwd);
367        assert_eq!(auth.display_label, "");
368    }
369
370    #[test]
371    fn user_shell_override_replaces_host_shell_wrapper() {
372        let override_shell = crate::config::TerminalShellConfig {
373            command: "/usr/local/bin/fish".into(),
374            args: vec!["-l".into(), "-i".into()],
375        };
376        let wrapper = TerminalWrapper::host_shell().with_user_shell_override(Some(&override_shell));
377        assert_eq!(wrapper.command, "/usr/local/bin/fish");
378        assert_eq!(wrapper.args, vec!["-l".to_string(), "-i".to_string()]);
379        assert!(!wrapper.manages_cwd);
380    }
381
382    #[test]
383    fn user_shell_override_is_noop_when_wrapper_manages_cwd() {
384        // Docker/SSH-style wrappers set `manages_cwd = true`; replacing
385        // their command would drop the re-parenting args and spawn the
386        // user's shell on the host, defeating the authority.
387        let docker = TerminalWrapper {
388            command: "docker".into(),
389            args: vec![
390                "exec".into(),
391                "-w".into(),
392                "/workspaces/proj".into(),
393                "abc123".into(),
394                "bash".into(),
395            ],
396            manages_cwd: true,
397        };
398        let override_shell = crate::config::TerminalShellConfig {
399            command: "/usr/local/bin/fish".into(),
400            args: vec![],
401        };
402        let wrapper = docker
403            .clone()
404            .with_user_shell_override(Some(&override_shell));
405        assert_eq!(wrapper.command, docker.command);
406        assert_eq!(wrapper.args, docker.args);
407        assert!(wrapper.manages_cwd);
408    }
409
410    #[test]
411    fn user_shell_override_none_leaves_wrapper_unchanged() {
412        let original = TerminalWrapper::host_shell();
413        let wrapper = original.clone().with_user_shell_override(None);
414        assert_eq!(wrapper.command, original.command);
415        assert_eq!(wrapper.args, original.args);
416        assert_eq!(wrapper.manages_cwd, original.manages_cwd);
417    }
418
419    #[test]
420    fn from_plugin_payload_docker_exec_carries_label() {
421        let payload = AuthorityPayload {
422            filesystem: FilesystemSpec::Local,
423            spawner: SpawnerSpec::DockerExec {
424                container_id: "abc123".into(),
425                user: Some("vscode".into()),
426                workspace: Some("/workspaces/proj".into()),
427            },
428            terminal_wrapper: TerminalWrapperSpec::Explicit {
429                command: "docker".into(),
430                args: vec![
431                    "exec".into(),
432                    "-it".into(),
433                    "-u".into(),
434                    "vscode".into(),
435                    "-w".into(),
436                    "/workspaces/proj".into(),
437                    "abc123".into(),
438                    "bash".into(),
439                    "-l".into(),
440                ],
441                manages_cwd: true,
442            },
443            display_label: "Container:abc123".into(),
444        };
445        let auth = Authority::from_plugin_payload(payload).expect("docker payload is valid");
446        assert_eq!(auth.terminal_wrapper.command, "docker");
447        assert!(auth.terminal_wrapper.manages_cwd);
448        assert_eq!(auth.display_label, "Container:abc123");
449    }
450}