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}