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, params, remote_dir,
26//!   trust, env)` — used by the `fresh user@host:path` startup flow. `params`
27//!   and `remote_dir` build the `ssh -t …` terminal wrapper so the integrated
28//!   terminal opens on the remote host.
29//! - `Authority::from_plugin_payload(payload, trust, env)` — built from the
30//!   `editor.setAuthority(...)` plugin op. The payload is a tagged shape
31//!   (filesystem kind + spawner kind + terminal wrapper + label); it stays
32//!   small and additive so we can grow new kinds without breaking the
33//!   plugin contract.
34
35use std::path::{Path, PathBuf};
36use std::sync::Arc;
37
38use serde::{Deserialize, Serialize};
39
40use crate::model::filesystem::{FileSystem, StdFileSystem};
41use crate::services::remote::{
42    build_kube_agent_terminal_args, build_kube_terminal_args, build_ssh_agent_terminal_args,
43    build_ssh_terminal_args, spawn_kube_reconnect_task, spawn_reconnect_task, ConnectionParams,
44    KubeConnection, KubeTarget, LocalLongRunningSpawner, LocalProcessSpawner, LongRunningSpawner,
45    ProcessSpawner, RemoteFileSystem, RemoteLongRunningSpawner, RemoteProcessSpawner,
46    SshConnection, SshError, TransportError,
47};
48use crate::services::workspace_trust::WorkspaceTrust;
49
50/// Plugin-supplied form of the host↔remote workspace mapping. Plugins
51/// build this from their own knowledge (e.g. the devcontainer plugin
52/// already has `editor.getCwd()` for the host root and
53/// `result.remoteWorkspaceFolder` for the in-container root). Strings
54/// because the wire format is JSON; paths get parsed in
55/// `Authority::from_plugin_payload`.
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
57pub struct PathTranslationSpec {
58    pub host_root: String,
59    pub remote_root: String,
60}
61
62/// Symmetric path translation between host and remote workspace
63/// roots. Owned by the active [`Authority`] when the backend lives in
64/// a container (or any other place where the workspace is mounted at a
65/// different path than its on-host location). Local authorities and
66/// SSH leave this field unset.
67///
68/// LSP URIs are the primary consumer: the editor's buffer file paths
69/// are host-side, but the LSP server is on the other side of the
70/// mount and only knows the remote-side path. We translate at the
71/// boundary so the editor can keep using host paths internally and
72/// the LSP keeps seeing the paths it expects.
73#[derive(Debug, Clone)]
74pub struct PathTranslation {
75    pub host_root: PathBuf,
76    pub remote_root: PathBuf,
77}
78
79impl PathTranslation {
80    /// Map a host-side path under `host_root` to its remote-side
81    /// counterpart. Returns `None` for paths outside the workspace
82    /// (e.g. system headers, library sources) — those are passed
83    /// through unchanged so the caller can decide whether to forward
84    /// them as-is or drop them.
85    pub fn host_to_remote(&self, host: &Path) -> Option<PathBuf> {
86        let rel = host.strip_prefix(&self.host_root).ok()?;
87        Some(self.remote_root.join(rel))
88    }
89
90    /// Map a remote-side path under `remote_root` back to its
91    /// host-side counterpart. Same outside-the-workspace caveat as
92    /// [`Self::host_to_remote`].
93    pub fn remote_to_host(&self, remote: &Path) -> Option<PathBuf> {
94        let rel = remote.strip_prefix(&self.remote_root).ok()?;
95        Some(self.host_root.join(rel))
96    }
97}
98
99/// How the integrated terminal is launched under this authority.
100///
101/// The terminal manager unconditionally honours this — there is no
102/// "no wrapper" branch.  For local authority, the wrapper command is the
103/// detected host shell with no extra args; `manages_cwd` is false so the
104/// terminal manager calls `CommandBuilder::cwd()` itself.  Authorities
105/// that re-parent the shell (e.g. `docker exec -w <workspace>`) set
106/// `manages_cwd = true` so cwd is left to the wrapper's args.
107#[derive(Debug, Clone)]
108pub struct TerminalWrapper {
109    /// Command to execute (e.g. the host shell, `"docker"`, `"ssh"`).
110    pub command: String,
111    /// Arguments passed before any user input — usually the flags that
112    /// drop the user into an interactive shell at the right place.
113    pub args: Vec<String>,
114    /// If true, `args` already establishes the working directory and the
115    /// terminal manager must skip `CommandBuilder::cwd()`. For local
116    /// authorities this is false so the host shell honours the per-
117    /// terminal cwd the editor passes in.
118    pub manages_cwd: bool,
119}
120
121impl TerminalWrapper {
122    /// Wrap the detected host shell with no extra args. Cwd is set by
123    /// the terminal manager from the spawn call.
124    pub fn host_shell() -> Self {
125        Self {
126            command: crate::services::terminal::manager::detect_shell(),
127            args: Vec::new(),
128            manages_cwd: false,
129        }
130    }
131
132    /// Re-parent the integrated terminal onto the remote host over SSH:
133    /// `ssh -t … user@host 'cd <dir>; exec $SHELL -l'`. Like the
134    /// `docker exec -w …` wrapper, this pins cwd through its own args
135    /// (`cd <dir>`), so `manages_cwd` is true and the terminal manager
136    /// must not call `CommandBuilder::cwd()` with a remote path the local
137    /// PTY can't honour. Without this, SSH authorities would fall back to
138    /// the local host shell and the embedded terminal would run on the
139    /// *local* machine instead of the remote host.
140    pub fn ssh(params: &ConnectionParams, remote_dir: Option<&str>) -> Self {
141        Self {
142            command: "ssh".to_string(),
143            args: build_ssh_terminal_args(params, remote_dir),
144            manages_cwd: true,
145        }
146    }
147
148    /// Re-parent the integrated terminal into a K8s pod via
149    /// `kubectl exec -it … -- sh -lc 'cd <ws>; exec $SHELL -l'`. Same
150    /// re-parenting contract as [`Self::ssh`] / the `docker exec -w …`
151    /// wrapper: cwd is pinned through the wrapper's own args, so
152    /// `manages_cwd` is true and the terminal manager must not hand the
153    /// local PTY a pod-side cwd it can't honour.
154    pub fn kube(target: &KubeTarget, base_env: &[(String, String)]) -> Self {
155        Self {
156            command: "kubectl".to_string(),
157            args: build_kube_terminal_args(target, base_env),
158            manages_cwd: true,
159        }
160    }
161
162    /// Apply the user's `terminal.shell` config override on top of this
163    /// wrapper. The override replaces `command` and `args` only when the
164    /// wrapper leaves cwd management to the terminal manager
165    /// (`manages_cwd == false`) — that is, for the host-shell wrapper.
166    /// Authorities that re-parent the shell (e.g. `docker exec -w …`,
167    /// `ssh …`) pin cwd through their own args and are left untouched so
168    /// the re-parenting stays intact.
169    pub fn with_user_shell_override(
170        mut self,
171        shell: Option<&crate::config::TerminalShellConfig>,
172    ) -> Self {
173        if let Some(shell) = shell {
174            if !self.manages_cwd {
175                self.command = shell.command.clone();
176                self.args = shell.args.clone();
177            }
178        }
179        self
180    }
181}
182
183/// Tagged payload describing how to build an authority from a plugin.
184///
185/// Kept intentionally small and explicit. Adding a new spawner or
186/// filesystem kind means adding a new variant here and a constructor in
187/// `Authority::from_plugin_payload`. Plugins consuming the API see only
188/// the `kind` discriminator and the kind-specific params, so old payloads
189/// keep working as new kinds are added.
190#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
191pub struct AuthorityPayload {
192    pub filesystem: FilesystemSpec,
193    pub spawner: SpawnerSpec,
194    pub terminal_wrapper: TerminalWrapperSpec,
195    /// Status-bar / explorer label. Empty = no label rendered.
196    #[serde(default)]
197    pub display_label: String,
198    /// Optional host↔remote workspace path mapping. Devcontainer-style
199    /// authorities supply both the host workspace path (the editor's
200    /// `cwd` at attach time) and the in-container `remoteWorkspaceFolder`
201    /// so URIs traveling to/from the LSP get translated symmetrically.
202    /// SSH and local authorities leave this unset.
203    #[serde(default)]
204    pub path_translation: Option<PathTranslationSpec>,
205}
206
207/// Filesystem kind chosen by a plugin payload.
208#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
209#[serde(tag = "kind", rename_all = "kebab-case")]
210pub enum FilesystemSpec {
211    /// Use the host filesystem. Devcontainers fall here because the
212    /// workspace is mounted into the container, so file paths translate
213    /// 1:1 between host and container.
214    Local,
215}
216
217/// Process-spawner kind chosen by a plugin payload.
218#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
219#[serde(tag = "kind", rename_all = "kebab-case")]
220pub enum SpawnerSpec {
221    /// Spawn on the host. Equivalent to `LocalProcessSpawner`.
222    ///
223    /// Environment-manager activation is *not* expressed here — env is a live
224    /// provider set via `editor.setEnv` (see `services::env_provider`), not a
225    /// backend rebuilt from a payload. `SpawnerSpec` is only for choosing the
226    /// backend (local vs container).
227    Local,
228    /// Run via `docker exec` against a long-lived container. The plugin
229    /// manages the container lifecycle (e.g. via `editor.spawnHostProcess`
230    /// to invoke `devcontainer up`) and hands us the container id once it
231    /// is ready.
232    ///
233    /// `env` is the captured `userEnvProbe` snapshot from inside the
234    /// container — typically `PATH`, `HOME`, `LANG`, and any vars the
235    /// user's profile exports. It's applied to every `docker exec`
236    /// (one-shot spawns, LSP/long-running, `command_exists` probes)
237    /// so plugins-installed binaries on `~/.local/bin` (or any
238    /// shell-only PATH) actually resolve. Empty when `userEnvProbe`
239    /// is `none` or the probe fails.
240    DockerExec {
241        container_id: String,
242        #[serde(default)]
243        user: Option<String>,
244        #[serde(default)]
245        workspace: Option<String>,
246        /// Captured `userEnvProbe` env. Order is preserved so
247        /// per-call `env` can layer over it deterministically; the
248        /// list of pairs (rather than a HashMap) keeps `docker exec
249        /// -e` ordering explicit.
250        #[serde(default)]
251        env: Vec<(String, String)>,
252    },
253}
254
255/// Terminal-wrapper kind chosen by a plugin payload.
256#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
257#[serde(tag = "kind", rename_all = "kebab-case")]
258pub enum TerminalWrapperSpec {
259    /// Use the detected host shell.
260    HostShell,
261    /// Use an explicit command + args (e.g. `docker exec -it -u <user>
262    /// -w <workspace> <id> bash -l`). `manages_cwd` defaults to true
263    /// because that is the only sensible choice for re-parented shells.
264    Explicit {
265        command: String,
266        args: Vec<String>,
267        #[serde(default = "default_true")]
268        manages_cwd: bool,
269    },
270}
271
272fn default_true() -> bool {
273    true
274}
275
276/// The single backend slot. Replaces the old quartet of `filesystem`,
277/// `process_spawner`, `terminal_wrapper`, and `authority_display_string`
278/// fields on `Editor`. **Not `Clone`**: an `Authority` is owned by exactly
279/// one `Window`, so a session's backend/trust/env cannot be shared into
280/// another window — that isolation is enforced by the type system, not a
281/// runtime check (issue #2280). It is *moved* between slots
282/// (`set_session_authority`, `set_boot_authority`, restore), never copied.
283pub struct Authority {
284    pub filesystem: Arc<dyn FileSystem + Send + Sync>,
285    pub process_spawner: Arc<dyn ProcessSpawner>,
286    /// Spawner for long-lived stdio processes — LSP servers today, tool
287    /// agents tomorrow. Container authorities wire this to a
288    /// `docker exec -i` variant so servers run inside the container
289    /// rather than on the host. Without it, LSP bypasses the authority
290    /// entirely (see `AUTHORITY_DESIGN.md` principle 2).
291    pub long_running_spawner: Arc<dyn LongRunningSpawner>,
292    pub terminal_wrapper: TerminalWrapper,
293    /// Status-bar / file-explorer label. Empty means render nothing.
294    /// SSH leaves this empty and lets the status bar fall back to the
295    /// filesystem's `remote_connection_info()` so disconnect annotations
296    /// stay in one place.
297    pub display_label: String,
298    /// Host↔remote workspace path mapping for backends where the
299    /// workspace is mounted at a different path than its on-host
300    /// location. The dev-container authority populates this so LSP
301    /// URIs translate at the host/container boundary; local and SSH
302    /// authorities leave it `None` and URIs flow through unchanged.
303    pub path_translation: Option<PathTranslation>,
304    /// Workspace Trust state gating execution under this authority — mandatory,
305    /// passed into every constructor and held by each spawner (no optional, no
306    /// post-hoc wrapping). It's the same `Arc` the server owns, so the command
307    /// palette / prompt mutate the level through it and every spawner sees it
308    /// live. A spawner literally cannot be built without it.
309    pub workspace_trust: Arc<WorkspaceTrust>,
310    /// Live environment provider (the activated venv/direnv/mise recipe) gating
311    /// what env every spawn carries — shared, mutated in place via the
312    /// `setEnv`/`clearEnv` plugin ops, never a stored snapshot. Same `Arc` the
313    /// server owns; born in `main.rs` alongside trust.
314    pub env_provider: Arc<crate::services::env_provider::EnvProvider>,
315    /// How an arbitrary *interactive* argv (an agent like `claude --resume
316    /// <id>`, or a plain command) is turned into a PTY command that runs
317    /// **inside** this backend, rooted at the session's workspace dir. Used by
318    /// [`Self::terminal_command`]. This is the seam where the per-session
319    /// backend and the agent-resume terminal command compose — see
320    /// `docs/internal/PER_SESSION_BACKENDS_DESIGN.md`.
321    pub command_wrap: CommandWrap,
322}
323
324/// How [`Authority::terminal_command`] composes an interactive argv with the
325/// authority's backend. Each backend pins the argv's working directory in the
326/// way that backend supports: a `-w` flag for `docker exec`, or a `cd <dir>`
327/// shell hop for `ssh` / `kubectl` (which have no cwd flag).
328#[derive(Debug, Clone)]
329pub enum CommandWrap {
330    /// Local: the argv is the PTY child directly; the terminal sets cwd.
331    Direct,
332    /// Argv-pure exec prefix (`docker exec -it [-u][-w][-e] <id>`): the agent
333    /// argv is appended verbatim — never a shell string — and cwd is pinned by
334    /// the prefix's own `-w` flag. The prefix is non-empty (`prefix[0]` is the
335    /// program, e.g. `docker`).
336    Prefix(Vec<String>),
337    /// SSH: re-exec the argv on the remote host rooted at `remote_dir`. `ssh`
338    /// has no cwd flag, so cwd is pinned via a `cd <dir>` shell hop and the
339    /// argv is shell-quoted (not appended raw). Without this an agent argv ran
340    /// on the **local** host instead of the remote.
341    Ssh {
342        params: ConnectionParams,
343        remote_dir: Option<String>,
344    },
345    /// Kubernetes: re-exec the argv inside the pod rooted at the target's
346    /// workspace, same shell-hop contract as SSH. `base_env` is the captured
347    /// in-pod env probe, exported before the agent so its `PATH` resolves.
348    Kube {
349        target: KubeTarget,
350        base_env: Vec<(String, String)>,
351    },
352}
353
354/// A session's **execution scope**: the trust gate (*may it run?*) and env
355/// overlay (*with what env?*) for one session, paired so they are always
356/// minted and handed around together.
357///
358/// This is the one blessed way to obtain per-session `trust` + `env`:
359/// [`SessionScope::for_root`] mints **fresh** handles owned by exactly one
360/// session, so trusting/activating in one window can never leak into another
361/// (issue #2280). It is move-only (not `Clone`) and consumed by
362/// `Authority::local_scoped`, and the [`Authority`] it builds is itself
363/// non-`Clone` and owned by a single `Window` — so the isolation is enforced
364/// by the type system at construction, not a runtime check.
365pub struct SessionScope {
366    pub trust: Arc<WorkspaceTrust>,
367    pub env: Arc<crate::services::env_provider::EnvProvider>,
368}
369
370impl SessionScope {
371    /// Mint a fresh scope for a session rooted at `root`: a per-root trust
372    /// (backed by that project's store, adopting its recorded level) and a
373    /// per-session env backed by that project's recipe store. When the session
374    /// is trusted, a previously-activated recipe is restored so the session
375    /// boots already in its env (no auto-activation restart); otherwise it
376    /// starts inactive. Each call yields handles owned by exactly one session.
377    pub fn for_root(root: &Path, project_state_dir: &Path) -> Self {
378        let trust = WorkspaceTrust::for_session(root, project_state_dir);
379        let trusted = trust.level() == crate::services::workspace_trust::TrustLevel::Trusted;
380        Self {
381            trust,
382            env: Arc::new(crate::services::env_provider::EnvProvider::for_session(
383                project_state_dir,
384                trusted,
385            )),
386        }
387    }
388}
389
390impl Authority {
391    /// Build a local authority from a per-session [`SessionScope`] — the
392    /// canonical per-session local constructor. Equivalent to
393    /// [`Self::local`] but takes the scope as a unit so callers can't pass a
394    /// trust from one window and an env from another.
395    pub fn local_scoped(scope: SessionScope) -> Self {
396        Self::local(scope.trust, scope.env)
397    }
398
399    /// The persistable backend descriptor for this *live* authority, derived
400    /// from its [`CommandWrap`]. This is the single source of truth a window's
401    /// `authority_spec` should reflect: it makes a plain `fresh ssh://…` launch
402    /// carry a real `RemoteAgent` spec (so persistence, the dormancy model, and
403    /// the manual-reconnect rebuild in `start_remote_reconnect` all work)
404    /// instead of the historical `Local` default that left those paths inert.
405    ///
406    /// `Direct` (local) and `Prefix` (container — its spec is owned by the
407    /// plugin that built it) map to `Local`; the SSH and Kube wraps reconstruct
408    /// their transport spec from the connection params they already hold.
409    pub fn session_spec(&self) -> SessionAuthoritySpec {
410        match &self.command_wrap {
411            CommandWrap::Ssh { params, remote_dir } => {
412                SessionAuthoritySpec::RemoteAgent(RemoteAgentSpec {
413                    transport: RemoteTransportSpec::Ssh {
414                        user: params.user.clone(),
415                        host: params.host.clone(),
416                        port: params.port,
417                        identity_file: params
418                            .identity_file
419                            .as_ref()
420                            .map(|p| p.to_string_lossy().into_owned()),
421                        remote_path: remote_dir.clone(),
422                        extra_args: params.extra_args.clone(),
423                    },
424                    base_env: Vec::new(),
425                    window: false,
426                    label: None,
427                    command: None,
428                })
429            }
430            CommandWrap::Kube { target, base_env } => {
431                SessionAuthoritySpec::RemoteAgent(RemoteAgentSpec {
432                    transport: RemoteTransportSpec::KubectlExec {
433                        context: target.context.clone(),
434                        namespace: target.namespace.clone(),
435                        pod: target.pod.clone(),
436                        container: target.container.clone(),
437                        workspace: target.workspace.clone(),
438                    },
439                    base_env: base_env.clone(),
440                    window: false,
441                    label: None,
442                    command: None,
443                })
444            }
445            CommandWrap::Direct | CommandWrap::Prefix(_) => SessionAuthoritySpec::Local,
446        }
447    }
448
449    /// Build a [`TerminalWrapper`] that runs `argv` as an interactive PTY
450    /// child **inside this authority's backend**, rooted at the session's
451    /// workspace dir. Local runs it directly; container/remote authorities wrap
452    /// it per [`CommandWrap`] so it runs in the backend (a container exec, an
453    /// `ssh`/`kubectl` shell hop) rather than on the host. An empty `argv`
454    /// falls back to the authority's interactive shell wrapper.
455    ///
456    /// This is what the Orchestrator agent-resume path spawns through, so a
457    /// `claude --resume <id>` restored in a devcontainer session runs as
458    /// `docker exec -it … <id> claude --resume <id>`, and an SSH session runs
459    /// it as `ssh -t … 'cd <dir>; exec <argv>'` on the remote host — never on
460    /// the local machine.
461    pub fn terminal_command(&self, argv: &[String]) -> TerminalWrapper {
462        let Some((cmd, rest)) = argv.split_first() else {
463            return self.terminal_wrapper.clone();
464        };
465        match &self.command_wrap {
466            CommandWrap::Direct => {
467                // Local: the command is the PTY child; cwd is the terminal's.
468                TerminalWrapper {
469                    command: cmd.clone(),
470                    args: rest.to_vec(),
471                    manages_cwd: false,
472                }
473            }
474            CommandWrap::Prefix(prefix) => {
475                // Container: `<exec-prefix> <argv>`, argv-pure. The prefix pins
476                // the backend (and its cwd via `-w`), so cwd is managed by the
477                // wrapper's own args.
478                let mut args = prefix[1..].to_vec();
479                args.extend(argv.iter().cloned());
480                TerminalWrapper {
481                    command: prefix[0].clone(),
482                    args,
483                    manages_cwd: true,
484                }
485            }
486            CommandWrap::Ssh { params, remote_dir } => {
487                // SSH: run the argv on the remote host rooted at `remote_dir`
488                // via a `cd <dir>; exec <argv>` shell hop. cwd is pinned by the
489                // wrapper's own args.
490                TerminalWrapper {
491                    command: "ssh".to_string(),
492                    args: build_ssh_agent_terminal_args(params, remote_dir.as_deref(), argv),
493                    manages_cwd: true,
494                }
495            }
496            CommandWrap::Kube { target, base_env } => {
497                // Kubernetes: run the argv inside the pod rooted at the target's
498                // workspace via the same shell hop. cwd is pinned by the
499                // wrapper's own args.
500                TerminalWrapper {
501                    command: "kubectl".to_string(),
502                    args: build_kube_agent_terminal_args(target, base_env, argv),
503                    manages_cwd: true,
504                }
505            }
506        }
507    }
508
509    /// Default boot-time authority: host filesystem, host process
510    /// spawner, host shell wrapper, gated by `trust`. The editor starts
511    /// here on every startup; SSH or plugin-installed authorities replace
512    /// it later (carrying the same `trust`).
513    pub fn local(
514        trust: Arc<WorkspaceTrust>,
515        env: Arc<crate::services::env_provider::EnvProvider>,
516    ) -> Self {
517        Self {
518            filesystem: Arc::new(StdFileSystem),
519            process_spawner: Arc::new(LocalProcessSpawner::new(
520                Arc::clone(&env),
521                Arc::clone(&trust),
522            )),
523            long_running_spawner: Arc::new(LocalLongRunningSpawner::new(
524                Arc::clone(&env),
525                Arc::clone(&trust),
526            )),
527            terminal_wrapper: TerminalWrapper::host_shell(),
528            display_label: String::new(),
529            path_translation: None,
530            workspace_trust: trust,
531            env_provider: env,
532            // Local: commands run directly as the PTY child, no backend wrap.
533            command_wrap: CommandWrap::Direct,
534        }
535    }
536
537    /// Build an SSH authority. The caller already holds the connection
538    /// (and its keepalive resources) so we just wire the parts in. Label
539    /// is left empty — the status bar falls back to the filesystem's own
540    /// `remote_connection_info()` which knows how to annotate disconnect.
541    ///
542    /// `long_running_spawner` is an SSH-routed spawner
543    /// ([`RemoteLongRunningSpawner`](crate::services::remote::RemoteLongRunningSpawner)),
544    /// so LSP servers run on the remote host rather than the host-local
545    /// fallback that earlier versions used.
546    ///
547    /// `params` / `remote_dir` build the integrated-terminal wrapper so the
548    /// embedded terminal opens a shell *on the remote host* (`ssh -t …`)
549    /// rooted at the workspace, matching where the filesystem and spawners
550    /// already act. Earlier versions hardcoded the local host shell here,
551    /// so terminals silently ran on the local machine.
552    pub fn ssh(
553        filesystem: Arc<dyn FileSystem + Send + Sync>,
554        process_spawner: Arc<dyn ProcessSpawner>,
555        long_running_spawner: Arc<dyn LongRunningSpawner>,
556        params: &ConnectionParams,
557        remote_dir: Option<&str>,
558        trust: Arc<WorkspaceTrust>,
559        env: Arc<crate::services::env_provider::EnvProvider>,
560    ) -> Self {
561        Self {
562            filesystem,
563            process_spawner,
564            long_running_spawner,
565            terminal_wrapper: TerminalWrapper::ssh(params, remote_dir),
566            display_label: String::new(),
567            path_translation: None,
568            workspace_trust: trust,
569            env_provider: env,
570            // An agent argv (`claude --resume <id>`) runs on the *remote* host
571            // rooted at `remote_dir` via a `cd <dir>; exec <argv>` shell hop —
572            // not on the local machine. See [`CommandWrap::Ssh`].
573            command_wrap: CommandWrap::Ssh {
574                params: params.clone(),
575                remote_dir: remote_dir.map(str::to_string),
576            },
577        }
578    }
579
580    /// Build a K8s authority: the remote-agent stack (filesystem + spawners,
581    /// already wired to the pod's `kubectl exec` agent channel) plus a
582    /// terminal wrapper that opens a shell *inside the pod*.
583    ///
584    /// Mirrors [`Self::ssh`] — the caller owns the connection and its
585    /// keepalive resources and threads the parts in. Unlike SSH, the label
586    /// is set from the target (there is no filesystem-side
587    /// `remote_connection_info()` that knows about a K8s pod, so identity
588    /// lives in the label per `AUTHORITY_DESIGN.md` principle 9). Path
589    /// translation is unset: the editor operates directly in the pod's path
590    /// space, exactly as SSH does.
591    pub fn kube(
592        filesystem: Arc<dyn FileSystem + Send + Sync>,
593        process_spawner: Arc<dyn ProcessSpawner>,
594        long_running_spawner: Arc<dyn LongRunningSpawner>,
595        target: &KubeTarget,
596        base_env: &[(String, String)],
597        trust: Arc<WorkspaceTrust>,
598        env: Arc<crate::services::env_provider::EnvProvider>,
599    ) -> Self {
600        Self {
601            filesystem,
602            process_spawner,
603            long_running_spawner,
604            terminal_wrapper: TerminalWrapper::kube(target, base_env),
605            display_label: target.display(),
606            path_translation: None,
607            workspace_trust: trust,
608            env_provider: env,
609            // An agent argv runs *inside the pod* rooted at the target's
610            // workspace via a `cd <ws>; exec <argv>` shell hop — not on the
611            // local machine. See [`CommandWrap::Kube`].
612            command_wrap: CommandWrap::Kube {
613                target: target.clone(),
614                base_env: base_env.to_vec(),
615            },
616        }
617    }
618
619    /// Assemble a full K8s authority from a live [`KubeConnection`].
620    ///
621    /// The high-level counterpart to [`Self::k8s`]: wires the filesystem and
622    /// one-shot spawner onto the connection's agent channel
623    /// ([`RemoteFileSystem`] / [`RemoteProcessSpawner`], reused verbatim from
624    /// the SSH stack) and the long-running (LSP) spawner onto a per-server
625    /// `kubectl exec` ([`KubectlLongRunningSpawner`]). `base_env` is the
626    /// captured in-pod env probe applied to LSP spawns and `command_exists`.
627    ///
628    /// The caller must keep the `KubeConnection` alive (in the session
629    /// keepalive bundle) — dropping it kills the carrier and tears down the
630    /// channel the returned authority rides on, exactly as SSH holds its
631    /// `SshConnection`.
632    pub fn kube_from_connection(
633        connection: &KubeConnection,
634        target: KubeTarget,
635        base_env: Vec<(String, String)>,
636        trust: Arc<WorkspaceTrust>,
637        env: Arc<crate::services::env_provider::EnvProvider>,
638    ) -> Self {
639        let channel = connection.channel();
640        let filesystem: Arc<dyn FileSystem + Send + Sync> = Arc::new(RemoteFileSystem::new(
641            channel.clone(),
642            connection.connection_string().to_string(),
643        ));
644        let process_spawner: Arc<dyn ProcessSpawner> = Arc::new(RemoteProcessSpawner::new(
645            channel,
646            Arc::clone(&env),
647            Arc::clone(&trust),
648        ));
649        let long_running_spawner: Arc<dyn LongRunningSpawner> =
650            Arc::new(KubectlLongRunningSpawner::with_env(
651                target.clone(),
652                base_env.clone(),
653                Arc::clone(&trust),
654            ));
655        Self::kube(
656            filesystem,
657            process_spawner,
658            long_running_spawner,
659            &target,
660            &base_env,
661            trust,
662            env,
663        )
664    }
665
666    /// Build an authority from a plugin payload (the data carried by the
667    /// `editor.setAuthority(...)` op), gated by `trust` (the editor passes its
668    /// live trust handle so the new authority shares it). All translation from
669    /// "kind + params" to concrete `Arc<dyn …>` lives here and nowhere else.
670    pub fn from_plugin_payload(
671        payload: AuthorityPayload,
672        trust: Arc<WorkspaceTrust>,
673        env: Arc<crate::services::env_provider::EnvProvider>,
674    ) -> Result<Self, AuthorityPayloadError> {
675        let filesystem: Arc<dyn FileSystem + Send + Sync> = match payload.filesystem {
676            FilesystemSpec::Local => Arc::new(StdFileSystem),
677        };
678
679        // Both spawner traits need the docker-exec params when the
680        // payload is a container, so destructure once and reuse.
681        let (process_spawner, long_running_spawner, command_wrap): (
682            Arc<dyn ProcessSpawner>,
683            Arc<dyn LongRunningSpawner>,
684            CommandWrap,
685        ) = match payload.spawner {
686            SpawnerSpec::Local => (
687                Arc::new(LocalProcessSpawner::new(
688                    Arc::clone(&env),
689                    Arc::clone(&trust),
690                )),
691                Arc::new(LocalLongRunningSpawner::new(
692                    Arc::clone(&env),
693                    Arc::clone(&trust),
694                )),
695                // Host-local spawner: commands run directly, no backend wrap.
696                CommandWrap::Direct,
697            ),
698            SpawnerSpec::DockerExec {
699                container_id,
700                user,
701                workspace,
702                env: docker_env,
703            } => {
704                // The interactive exec prefix so an agent terminal runs
705                // *inside* the container (`docker exec -it … <id> <argv>`),
706                // mirroring the spawner's one-shot `docker exec` invocation
707                // (and the captured `userEnvProbe` env so the agent's PATH
708                // resolves the same binaries).
709                let command_prefix = build_docker_exec_prefix(
710                    &container_id,
711                    user.as_deref(),
712                    workspace.as_deref(),
713                    &docker_env,
714                );
715                (
716                    Arc::new(
717                        crate::services::authority::docker_spawner::DockerExecSpawner::with_env(
718                            container_id.clone(),
719                            user.clone(),
720                            workspace.clone(),
721                            docker_env.clone(),
722                            Arc::clone(&trust),
723                        ),
724                    ),
725                    Arc::new(
726                        crate::services::authority::docker_spawner::DockerLongRunningSpawner::with_env(
727                            container_id,
728                            user,
729                            workspace,
730                            docker_env,
731                            Arc::clone(&trust),
732                        ),
733                    ),
734                    CommandWrap::Prefix(command_prefix),
735                )
736            }
737        };
738
739        let terminal_wrapper = match payload.terminal_wrapper {
740            TerminalWrapperSpec::HostShell => TerminalWrapper::host_shell(),
741            TerminalWrapperSpec::Explicit {
742                command,
743                args,
744                manages_cwd,
745            } => TerminalWrapper {
746                command,
747                args,
748                manages_cwd,
749            },
750        };
751
752        let path_translation = payload.path_translation.map(|spec| PathTranslation {
753            host_root: PathBuf::from(spec.host_root),
754            remote_root: PathBuf::from(spec.remote_root),
755        });
756
757        Ok(Self {
758            filesystem,
759            process_spawner,
760            long_running_spawner,
761            terminal_wrapper,
762            display_label: payload.display_label,
763            path_translation,
764            workspace_trust: trust,
765            env_provider: env,
766            command_wrap,
767        })
768    }
769}
770
771/// Build the `docker exec -it …` argv prefix that runs an interactive command
772/// inside a container, mirroring [`DockerExecSpawner`]'s one-shot exec args.
773/// A following agent argv is appended verbatim (argv-pure — no shell string).
774/// `-t` (added here, alongside the spawner's `-i`) allocates the PTY the
775/// integrated terminal needs.
776fn build_docker_exec_prefix(
777    container_id: &str,
778    user: Option<&str>,
779    workspace: Option<&str>,
780    env: &[(String, String)],
781) -> Vec<String> {
782    let mut prefix: Vec<String> = vec!["docker".into(), "exec".into(), "-it".into()];
783    if let Some(user) = user {
784        prefix.push("-u".into());
785        prefix.push(user.to_string());
786    }
787    if let Some(ws) = workspace {
788        prefix.push("-w".into());
789        prefix.push(ws.to_string());
790    }
791    for (k, v) in env {
792        prefix.push("-e".into());
793        prefix.push(format!("{k}={v}"));
794    }
795    prefix.push(container_id.to_string());
796    prefix
797}
798
799/// Declarative description of *how to rebuild* a session's backend — the
800/// persisted, source-of-truth counterpart to the live [`Authority`]. A
801/// session stores this (in its per-dir workspace file) so that after an
802/// editor restart or a cold relaunch its backend can be reconstructed
803/// (`Local`) or reconnected (`Plugin` / `RemoteAgent`) instead of silently
804/// degrading to local. See `docs/internal/PER_SESSION_BACKENDS_DESIGN.md`.
805///
806/// Reuses the existing creation payloads ([`AuthorityPayload`],
807/// [`RemoteAgentSpec`]) verbatim so there is no new backend vocabulary and
808/// `fresh-core` stays backend-opaque. Externally tagged so it round-trips
809/// through JSON robustly and additively.
810#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
811pub enum SessionAuthoritySpec {
812    /// Host-local backend. The default for a brand-new session and for any
813    /// session with no persisted spec (back-compat).
814    Local,
815    /// A backend installed via `editor.setAuthority(...)` — devcontainer /
816    /// docker. Reconnecting it is the owning plugin's job (only it can run
817    /// `devcontainer up`).
818    Plugin(AuthorityPayload),
819    /// A born-attached remote agent (SSH / Kubernetes). Reconnectable from
820    /// core via `connect_ssh_authority` / `connect_kube_authority`.
821    RemoteAgent(RemoteAgentSpec),
822}
823
824impl Default for SessionAuthoritySpec {
825    fn default() -> Self {
826        Self::Local
827    }
828}
829
830impl SessionAuthoritySpec {
831    /// Whether this session's backend is anything other than plain local —
832    /// i.e. one that must be reconnected (not just rebuilt) on restore.
833    pub fn is_remote(&self) -> bool {
834        !matches!(self, Self::Local)
835    }
836}
837
838/// Plugin payload for `editor.attachRemoteAgent(...)`. Names a transport
839/// that needs a live connection plus the captured in-pod env probe.
840/// Opaque JSON at the fresh-core boundary; parsed here.
841#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
842pub struct RemoteAgentSpec {
843    pub transport: RemoteTransportSpec,
844    /// Captured in-pod env (PATH/HOME/LANG/…) applied to LSP spawns and
845    /// `command_exists`. Empty when no probe ran.
846    #[serde(default)]
847    pub base_env: Vec<(String, String)>,
848    /// When true, attach as a **new window** (born-attached, coexisting with
849    /// existing windows) instead of the default global restart. The
850    /// Orchestrator sets this so a cloud session is a real session row rather
851    /// than retargeting the whole editor.
852    #[serde(default)]
853    pub window: bool,
854    /// Window label (used only when `window` is true). Empty falls back to the
855    /// transport's display.
856    #[serde(default)]
857    pub label: Option<String>,
858    /// Optional agent argv for the new window's seed terminal (window mode).
859    #[serde(default)]
860    pub command: Option<Vec<String>>,
861}
862
863/// Transport kind for [`RemoteAgentSpec`]. Tagged + additive so new
864/// carriers slot in without breaking the plugin contract.
865#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
866#[serde(tag = "kind", rename_all = "kebab-case")]
867pub enum RemoteTransportSpec {
868    /// Exec into a pod on a K8s (or any kube) cluster.
869    KubectlExec {
870        #[serde(default)]
871        context: Option<String>,
872        namespace: String,
873        pod: String,
874        #[serde(default)]
875        container: Option<String>,
876        #[serde(default)]
877        workspace: Option<String>,
878    },
879    /// SSH into a remote host: the same remote-agent stack as the boot-time
880    /// `fresh user@host:path` flow, exposed at runtime so the Orchestrator can
881    /// open an SSH session as a born-attached window.
882    Ssh {
883        /// Login user. Optional — omit for `host` / `ssh://host`, letting ssh
884        /// resolve the user from its own config or the current local user.
885        #[serde(default)]
886        user: Option<String>,
887        host: String,
888        #[serde(default)]
889        port: Option<u16>,
890        #[serde(default)]
891        identity_file: Option<String>,
892        /// Remote directory to root the session at (terminal `cd` target).
893        #[serde(default)]
894        remote_path: Option<String>,
895        /// Extra `ssh` arguments (e.g. `-J jump`, `-o ProxyCommand=…`) applied
896        /// to every ssh invocation for this session.
897        #[serde(default)]
898        extra_args: Vec<String>,
899    },
900}
901
902impl RemoteAgentSpec {
903    /// Resolve a kubectl-exec spec into the pod target and the captured env.
904    /// Only valid for the `KubectlExec` transport (the caller dispatches on
905    /// `transport` first); panics otherwise.
906    pub fn into_kube_target(self) -> (KubeTarget, Vec<(String, String)>) {
907        match self.transport {
908            RemoteTransportSpec::KubectlExec {
909                context,
910                namespace,
911                pod,
912                container,
913                workspace,
914            } => (
915                KubeTarget {
916                    context,
917                    namespace,
918                    pod,
919                    container,
920                    workspace,
921                },
922                self.base_env,
923            ),
924            RemoteTransportSpec::Ssh { .. } => {
925                unreachable!("into_kube_target called on a non-kube transport")
926            }
927        }
928    }
929}
930
931/// Resources that must outlive a K8s [`Authority`]: the carrier
932/// connection (its `kubectl exec` child + heartbeat task) and the
933/// reconnect task. The editor parks this in its session-keepalive slot —
934/// the same one SSH uses for its `SshConnection` — so the agent channel
935/// survives the editor rebuild on attach. Dropping it tears the session
936/// down (reconnect aborted, then the connection's carrier killed).
937pub struct KubeKeepalive {
938    // Drop runs the explicit `Drop` below first (aborting reconnect), then
939    // fields drop in declaration order: the connection (kills the carrier),
940    // then the runtime (shuts down its now-idle workers).
941    reconnect: tokio::task::JoinHandle<()>,
942    _connection: KubeConnection,
943    // The load-bearing field: the dedicated runtime the agent channel +
944    // heartbeat + reconnect tasks run on. Owned here so they survive the editor
945    // restart the attach triggers — the editor's per-instance runtime is
946    // dropped during that rebuild, and if the channel rode *that* runtime its
947    // I/O tasks would die the instant the attach completed ("Channel closed"
948    // on every file op). SSH's `RemoteSession._runtime` does exactly this.
949    _runtime: tokio::runtime::Runtime,
950}
951
952impl Drop for KubeKeepalive {
953    fn drop(&mut self) {
954        self.reconnect.abort();
955    }
956}
957
958/// Connect to a K8s pod and assemble its [`Authority`] plus the
959/// [`KubeKeepalive`] that must be parked to keep it alive.
960///
961/// The K8s counterpart to SSH's `connect_remote`: bootstraps the agent
962/// ([`KubeConnection::connect`]) and the reconnect/heartbeat tasks **on a
963/// dedicated runtime owned by the returned keepalive** — *not* the caller's
964/// runtime. Installing the authority restarts the editor, dropping the
965/// editor's per-instance runtime; binding the channel there would kill it the
966/// moment the attach completes (regression: `agent_channel_survives_dropping_
967/// the_attach_runtime`). `base_env` is the captured in-pod env probe applied to
968/// LSP spawns and `command_exists`.
969///
970/// Stays `async` for callers, but the bootstrap needs `block_on` (which can't
971/// run inside the caller's async context), so it happens on a short-lived
972/// helper thread; the live runtime — with its channel/heartbeat/reconnect
973/// tasks — is handed back and parked in the keepalive.
974pub async fn connect_kube_authority(
975    target: KubeTarget,
976    base_env: Vec<(String, String)>,
977    trust: Arc<WorkspaceTrust>,
978    env: Arc<crate::services::env_provider::EnvProvider>,
979    cancel: Option<tokio::sync::oneshot::Receiver<()>>,
980) -> Result<(Authority, KubeKeepalive), TransportError> {
981    type Built = Result<
982        (
983            KubeConnection,
984            tokio::task::JoinHandle<()>,
985            tokio::runtime::Runtime,
986        ),
987        TransportError,
988    >;
989
990    let (tx, rx) = tokio::sync::oneshot::channel::<Built>();
991    let bootstrap_target = target.clone();
992    std::thread::Builder::new()
993        .name("kube-connect".to_string())
994        .spawn(move || {
995            let built: Built = (|| {
996                let runtime = tokio::runtime::Builder::new_multi_thread()
997                    .worker_threads(2)
998                    .thread_name("kube-agent")
999                    .enable_all()
1000                    .build()
1001                    .map_err(|e| TransportError::AgentStartFailed(format!("runtime: {e}")))?;
1002                // `block_on` drives the bootstrap; the channel/heartbeat/reconnect
1003                // tasks it spawns live on `runtime`'s worker threads, which keep
1004                // running after `block_on` returns and after this helper thread
1005                // exits — until the `runtime` (moved into the keepalive) drops.
1006                let (connection, reconnect) = runtime.block_on(async {
1007                    // Race the connect against the cancel signal so a slow/hung
1008                    // kubectl bootstrap can be aborted; dropping the connect
1009                    // future drops the in-flight child. No signal → await it.
1010                    let connection = match cancel {
1011                        Some(cancel) => tokio::select! {
1012                            biased;
1013                            _ = cancel => {
1014                                return Err(TransportError::AgentStartFailed(
1015                                    "cancelled".to_string(),
1016                                ));
1017                            }
1018                            res = KubeConnection::connect(bootstrap_target.clone()) => res?,
1019                        },
1020                        None => KubeConnection::connect(bootstrap_target.clone()).await?,
1021                    };
1022                    let reconnect =
1023                        spawn_kube_reconnect_task(&connection.channel(), bootstrap_target.clone());
1024                    Ok::<_, TransportError>((connection, reconnect))
1025                })?;
1026                Ok((connection, reconnect, runtime))
1027            })();
1028            #[allow(clippy::let_underscore_must_use)]
1029            let _ = tx.send(built);
1030        })
1031        .map_err(|e| TransportError::AgentStartFailed(format!("connect thread: {e}")))?;
1032
1033    let (connection, reconnect, runtime) = rx
1034        .await
1035        .map_err(|_| TransportError::AgentStartFailed("connect thread vanished".to_string()))??;
1036
1037    let authority = Authority::kube_from_connection(&connection, target, base_env, trust, env);
1038    Ok((
1039        authority,
1040        KubeKeepalive {
1041            reconnect,
1042            _connection: connection,
1043            _runtime: runtime,
1044        },
1045    ))
1046}
1047
1048/// Resources that must outlive an SSH [`Authority`]: the `SshConnection`
1049/// (its `ssh …` child), the reconnect task, and the dedicated runtime the
1050/// agent channel rides on. The runtime-owned analogue of `main.rs`'s
1051/// boot-time `RemoteSession`; parked per-window by the Orchestrator's
1052/// born-attached SSH sessions (and droppable on window close).
1053pub struct SshKeepalive {
1054    reconnect: tokio::task::JoinHandle<()>,
1055    _connection: SshConnection,
1056    _runtime: tokio::runtime::Runtime,
1057}
1058
1059impl Drop for SshKeepalive {
1060    fn drop(&mut self) {
1061        self.reconnect.abort();
1062    }
1063}
1064
1065/// Connect to a remote host over SSH and assemble its [`Authority`] plus the
1066/// [`SshKeepalive`] that must be parked to keep it alive — the runtime-owned,
1067/// reusable counterpart to `main.rs`'s boot-time `connect_remote`, mirroring
1068/// [`connect_kube_authority`]. The agent channel + reconnect run on a dedicated
1069/// runtime returned in the keepalive so they survive editor rebuilds.
1070///
1071/// `remote_dir` is the directory the integrated terminal roots at (the
1072/// `ssh -t … 'cd <dir>; …'` wrapper); filesystem/process ops carry absolute
1073/// paths and don't need it.
1074pub async fn connect_ssh_authority(
1075    params: ConnectionParams,
1076    remote_dir: Option<String>,
1077    trust: Arc<WorkspaceTrust>,
1078    env: Arc<crate::services::env_provider::EnvProvider>,
1079    cancel: Option<tokio::sync::oneshot::Receiver<()>>,
1080) -> Result<(Authority, SshKeepalive), SshError> {
1081    type Built = Result<
1082        (
1083            SshConnection,
1084            tokio::task::JoinHandle<()>,
1085            tokio::runtime::Runtime,
1086        ),
1087        SshError,
1088    >;
1089
1090    let (tx, rx) = tokio::sync::oneshot::channel::<Built>();
1091    let bootstrap_params = params.clone();
1092    std::thread::Builder::new()
1093        .name("ssh-connect".to_string())
1094        .spawn(move || {
1095            let built: Built = (|| {
1096                let runtime = tokio::runtime::Builder::new_multi_thread()
1097                    .worker_threads(2)
1098                    .thread_name("ssh-agent")
1099                    .enable_all()
1100                    .build()
1101                    .map_err(|e| SshError::AgentStartFailed(format!("runtime: {e}")))?;
1102                // The channel/reconnect tasks spawned here live on `runtime`'s
1103                // workers, surviving after this helper thread exits — until the
1104                // `runtime` (moved into the keepalive) drops.
1105                let (connection, reconnect) = runtime.block_on(async {
1106                    // Race the connect against the cancel signal. On cancel the
1107                    // connect future is dropped, which drops the in-flight ssh
1108                    // child (spawned kill-on-drop) so a hung handshake leaves no
1109                    // orphaned process. No cancel signal → just await connect.
1110                    let connection = match cancel {
1111                        Some(cancel) => tokio::select! {
1112                            biased;
1113                            _ = cancel => {
1114                                return Err(SshError::AgentStartFailed("cancelled".to_string()));
1115                            }
1116                            res = SshConnection::connect(bootstrap_params.clone()) => res?,
1117                        },
1118                        None => SshConnection::connect(bootstrap_params.clone()).await?,
1119                    };
1120                    let reconnect =
1121                        spawn_reconnect_task(connection.channel(), connection.params().clone());
1122                    Ok::<_, SshError>((connection, reconnect))
1123                })?;
1124                Ok((connection, reconnect, runtime))
1125            })();
1126            #[allow(clippy::let_underscore_must_use)]
1127            let _ = tx.send(built);
1128        })
1129        .map_err(|e| SshError::AgentStartFailed(format!("connect thread: {e}")))?;
1130
1131    let (connection, reconnect, runtime) = rx
1132        .await
1133        .map_err(|_| SshError::AgentStartFailed("connect thread vanished".to_string()))??;
1134
1135    let channel = connection.channel();
1136    let connection_string = connection.connection_string().to_string();
1137    let reconnect_params = connection.params().clone();
1138    let filesystem: Arc<dyn FileSystem + Send + Sync> =
1139        Arc::new(RemoteFileSystem::new(channel.clone(), connection_string));
1140    let process_spawner: Arc<dyn ProcessSpawner> = Arc::new(RemoteProcessSpawner::new(
1141        channel.clone(),
1142        Arc::clone(&env),
1143        Arc::clone(&trust),
1144    ));
1145    let long_running_spawner: Arc<dyn LongRunningSpawner> =
1146        Arc::new(RemoteLongRunningSpawner::new(
1147            reconnect_params.clone(),
1148            Arc::clone(&env),
1149            Arc::clone(&trust),
1150        ));
1151    let authority = Authority::ssh(
1152        filesystem,
1153        process_spawner,
1154        long_running_spawner,
1155        &reconnect_params,
1156        remote_dir.as_deref(),
1157        trust,
1158        env,
1159    );
1160    Ok((
1161        authority,
1162        SshKeepalive {
1163            reconnect,
1164            _connection: connection,
1165            _runtime: runtime,
1166        },
1167    ))
1168}
1169
1170/// Error from translating a plugin payload into a live authority.
1171/// Reserved for future kinds that might fail to construct (e.g. invalid
1172/// connection parameters); local-only payloads currently never fail.
1173#[derive(Debug, thiserror::Error)]
1174pub enum AuthorityPayloadError {
1175    #[error("invalid authority payload: {0}")]
1176    Invalid(String),
1177}
1178
1179mod docker_spawner;
1180mod kube_spawner;
1181
1182pub(crate) use kube_spawner::KubectlLongRunningSpawner;
1183
1184#[cfg(test)]
1185mod tests {
1186    use super::*;
1187
1188    #[test]
1189    fn local_authority_uses_host_shell_with_no_args() {
1190        let auth = Authority::local(
1191            Arc::new(WorkspaceTrust::permissive()),
1192            Arc::new(crate::services::env_provider::EnvProvider::inactive()),
1193        );
1194        assert!(!auth.terminal_wrapper.command.is_empty());
1195        assert!(auth.terminal_wrapper.args.is_empty());
1196        assert!(!auth.terminal_wrapper.manages_cwd);
1197        assert_eq!(auth.display_label, "");
1198    }
1199
1200    #[test]
1201    fn kube_terminal_wrapper_reparents_into_pod() {
1202        let target = KubeTarget {
1203            context: Some("prod".into()),
1204            namespace: "dev".into(),
1205            pod: "pod-1".into(),
1206            container: None,
1207            workspace: Some("/workspace".into()),
1208        };
1209        let wrapper = TerminalWrapper::kube(&target, &[]);
1210        assert_eq!(wrapper.command, "kubectl");
1211        // Re-parented shell must pin cwd through its own args.
1212        assert!(wrapper.manages_cwd);
1213        assert_eq!(wrapper.args[0], "--context");
1214        assert!(wrapper.args.iter().any(|a| a == "-it"));
1215        assert!(wrapper.args.iter().any(|a| a == "pod-1"));
1216        // User shell override is a no-op for a cwd-managing wrapper, so the
1217        // re-parenting into the pod stays intact.
1218        let override_shell = crate::config::TerminalShellConfig {
1219            command: "/usr/local/bin/fish".into(),
1220            args: vec![],
1221        };
1222        let after = wrapper
1223            .clone()
1224            .with_user_shell_override(Some(&override_shell));
1225        assert_eq!(after.command, "kubectl");
1226    }
1227
1228    #[test]
1229    fn remote_agent_spec_parses_plugin_payload() {
1230        // The exact JSON shape `editor.attachRemoteAgent(...)` carries and
1231        // `handle_attach_remote_agent` parses (opaque at the fresh-core
1232        // boundary). Pins the plugin↔core wire contract.
1233        let json = serde_json::json!({
1234            "transport": {
1235                "kind": "kubectl-exec",
1236                "context": "k3d-dev",
1237                "namespace": "dev",
1238                "pod": "fresh-7c9f",
1239                "container": "app",
1240                "workspace": "/workspace"
1241            },
1242            "base_env": [
1243                ["PATH", "/home/dev/.local/bin:/usr/bin"],
1244                ["LANG", "C.UTF-8"]
1245            ]
1246        });
1247        let spec: RemoteAgentSpec = serde_json::from_value(json).expect("spec parses");
1248        let (target, base_env) = spec.into_kube_target();
1249        assert_eq!(target.context.as_deref(), Some("k3d-dev"));
1250        assert_eq!(target.namespace, "dev");
1251        assert_eq!(target.pod, "fresh-7c9f");
1252        assert_eq!(target.container.as_deref(), Some("app"));
1253        assert_eq!(target.workspace.as_deref(), Some("/workspace"));
1254        assert_eq!(base_env.len(), 2);
1255        assert_eq!(
1256            base_env[0],
1257            (
1258                "PATH".to_string(),
1259                "/home/dev/.local/bin:/usr/bin".to_string()
1260            )
1261        );
1262
1263        // Minimal payload (only namespace + pod) parses too: context,
1264        // container, workspace, and base_env are all optional.
1265        let minimal = serde_json::json!({
1266            "transport": { "kind": "kubectl-exec", "namespace": "dev", "pod": "p" }
1267        });
1268        let spec2: RemoteAgentSpec = serde_json::from_value(minimal).expect("minimal parses");
1269        let (t2, env2) = spec2.into_kube_target();
1270        assert!(t2.context.is_none() && t2.container.is_none() && t2.workspace.is_none());
1271        assert!(env2.is_empty());
1272    }
1273
1274    #[test]
1275    fn from_plugin_payload_local_yields_host_shell() {
1276        let payload = AuthorityPayload {
1277            filesystem: FilesystemSpec::Local,
1278            spawner: SpawnerSpec::Local,
1279            terminal_wrapper: TerminalWrapperSpec::HostShell,
1280            display_label: String::new(),
1281            path_translation: None,
1282        };
1283        let auth = Authority::from_plugin_payload(
1284            payload,
1285            Arc::new(WorkspaceTrust::permissive()),
1286            Arc::new(crate::services::env_provider::EnvProvider::inactive()),
1287        )
1288        .expect("local payload is valid");
1289        assert!(!auth.terminal_wrapper.command.is_empty());
1290        assert!(auth.terminal_wrapper.args.is_empty());
1291    }
1292
1293    #[test]
1294    fn payload_roundtrips_through_serde_json() {
1295        // The plugin op carries the payload as opaque JSON through
1296        // `fresh-core`; this test nails down the wire shape so we
1297        // don't silently break plugins when the struct evolves.
1298        let json = serde_json::json!({
1299            "filesystem": { "kind": "local" },
1300            "spawner": {
1301                "kind": "docker-exec",
1302                "container_id": "abc123",
1303                "user": "vscode",
1304                "workspace": "/workspaces/proj"
1305            },
1306            "terminal_wrapper": {
1307                "kind": "explicit",
1308                "command": "docker",
1309                "args": ["exec", "-it", "abc123", "bash", "-l"],
1310                "manages_cwd": true
1311            },
1312            "display_label": "Container:abc123"
1313        });
1314        let payload: AuthorityPayload =
1315            serde_json::from_value(json).expect("json matches payload schema");
1316        let auth = Authority::from_plugin_payload(
1317            payload,
1318            Arc::new(WorkspaceTrust::permissive()),
1319            Arc::new(crate::services::env_provider::EnvProvider::inactive()),
1320        )
1321        .expect("docker payload is valid");
1322        assert_eq!(auth.terminal_wrapper.command, "docker");
1323        assert!(auth.terminal_wrapper.manages_cwd);
1324        assert_eq!(auth.display_label, "Container:abc123");
1325    }
1326
1327    #[test]
1328    fn payload_accepts_docker_exec_env_pairs() {
1329        // The captured `userEnvProbe` env carries the in-container
1330        // `PATH`/`HOME`/etc. so LSP `command_exists` can find binaries
1331        // that live in shell-only PATHs (e.g. `~/.local/bin`).
1332        let json = serde_json::json!({
1333            "filesystem": { "kind": "local" },
1334            "spawner": {
1335                "kind": "docker-exec",
1336                "container_id": "abc123",
1337                "user": "vscode",
1338                "workspace": "/workspaces/proj",
1339                "env": [
1340                    ["PATH", "/home/vscode/.local/bin:/usr/bin"],
1341                    ["LANG", "C.UTF-8"]
1342                ]
1343            },
1344            "terminal_wrapper": { "kind": "host-shell" }
1345        });
1346        let payload: AuthorityPayload =
1347            serde_json::from_value(json).expect("env field is accepted");
1348        if let SpawnerSpec::DockerExec { env, .. } = &payload.spawner {
1349            assert_eq!(env.len(), 2);
1350            assert_eq!(
1351                env[0],
1352                ("PATH".into(), "/home/vscode/.local/bin:/usr/bin".into())
1353            );
1354            assert_eq!(env[1], ("LANG".into(), "C.UTF-8".into()));
1355        } else {
1356            panic!("expected docker-exec spawner");
1357        }
1358        // And the omitted-field form still parses (the field defaults
1359        // to empty), so older plugins that don't populate it stay
1360        // wire-compatible.
1361        let json_no_env = serde_json::json!({
1362            "filesystem": { "kind": "local" },
1363            "spawner": {
1364                "kind": "docker-exec",
1365                "container_id": "abc123"
1366            },
1367            "terminal_wrapper": { "kind": "host-shell" }
1368        });
1369        let payload2: AuthorityPayload =
1370            serde_json::from_value(json_no_env).expect("env is optional");
1371        if let SpawnerSpec::DockerExec { env, .. } = payload2.spawner {
1372            assert!(env.is_empty());
1373        } else {
1374            panic!("expected docker-exec spawner");
1375        }
1376    }
1377
1378    #[test]
1379    fn payload_defaults_manages_cwd_to_true_for_explicit_wrapper() {
1380        // Per the schema, `manages_cwd` is optional in the JSON and
1381        // defaults to true because re-parented shells almost always
1382        // want it that way.
1383        let json = serde_json::json!({
1384            "filesystem": { "kind": "local" },
1385            "spawner": { "kind": "local" },
1386            "terminal_wrapper": {
1387                "kind": "explicit",
1388                "command": "bash",
1389                "args": []
1390            }
1391        });
1392        let payload: AuthorityPayload =
1393            serde_json::from_value(json).expect("manages_cwd is optional");
1394        let auth = Authority::from_plugin_payload(
1395            payload,
1396            Arc::new(WorkspaceTrust::permissive()),
1397            Arc::new(crate::services::env_provider::EnvProvider::inactive()),
1398        )
1399        .expect("payload is valid");
1400        assert!(auth.terminal_wrapper.manages_cwd);
1401        assert_eq!(auth.display_label, "");
1402    }
1403
1404    #[test]
1405    fn user_shell_override_replaces_host_shell_wrapper() {
1406        let override_shell = crate::config::TerminalShellConfig {
1407            command: "/usr/local/bin/fish".into(),
1408            args: vec!["-l".into(), "-i".into()],
1409        };
1410        let wrapper = TerminalWrapper::host_shell().with_user_shell_override(Some(&override_shell));
1411        assert_eq!(wrapper.command, "/usr/local/bin/fish");
1412        assert_eq!(wrapper.args, vec!["-l".to_string(), "-i".to_string()]);
1413        assert!(!wrapper.manages_cwd);
1414    }
1415
1416    #[test]
1417    fn user_shell_override_is_noop_when_wrapper_manages_cwd() {
1418        // Docker/SSH-style wrappers set `manages_cwd = true`; replacing
1419        // their command would drop the re-parenting args and spawn the
1420        // user's shell on the host, defeating the authority.
1421        let docker = TerminalWrapper {
1422            command: "docker".into(),
1423            args: vec![
1424                "exec".into(),
1425                "-w".into(),
1426                "/workspaces/proj".into(),
1427                "abc123".into(),
1428                "bash".into(),
1429            ],
1430            manages_cwd: true,
1431        };
1432        let override_shell = crate::config::TerminalShellConfig {
1433            command: "/usr/local/bin/fish".into(),
1434            args: vec![],
1435        };
1436        let wrapper = docker
1437            .clone()
1438            .with_user_shell_override(Some(&override_shell));
1439        assert_eq!(wrapper.command, docker.command);
1440        assert_eq!(wrapper.args, docker.args);
1441        assert!(wrapper.manages_cwd);
1442    }
1443
1444    #[test]
1445    fn user_shell_override_none_leaves_wrapper_unchanged() {
1446        let original = TerminalWrapper::host_shell();
1447        let wrapper = original.clone().with_user_shell_override(None);
1448        assert_eq!(wrapper.command, original.command);
1449        assert_eq!(wrapper.args, original.args);
1450        assert_eq!(wrapper.manages_cwd, original.manages_cwd);
1451    }
1452
1453    #[test]
1454    fn from_plugin_payload_docker_exec_carries_label() {
1455        let payload = AuthorityPayload {
1456            filesystem: FilesystemSpec::Local,
1457            spawner: SpawnerSpec::DockerExec {
1458                container_id: "abc123".into(),
1459                user: Some("vscode".into()),
1460                workspace: Some("/workspaces/proj".into()),
1461                env: Vec::new(),
1462            },
1463            terminal_wrapper: TerminalWrapperSpec::Explicit {
1464                command: "docker".into(),
1465                args: vec![
1466                    "exec".into(),
1467                    "-it".into(),
1468                    "-u".into(),
1469                    "vscode".into(),
1470                    "-w".into(),
1471                    "/workspaces/proj".into(),
1472                    "abc123".into(),
1473                    "bash".into(),
1474                    "-l".into(),
1475                ],
1476                manages_cwd: true,
1477            },
1478            display_label: "Container:abc123".into(),
1479            path_translation: None,
1480        };
1481        let auth = Authority::from_plugin_payload(
1482            payload,
1483            Arc::new(WorkspaceTrust::permissive()),
1484            Arc::new(crate::services::env_provider::EnvProvider::inactive()),
1485        )
1486        .expect("docker payload is valid");
1487        assert_eq!(auth.terminal_wrapper.command, "docker");
1488        assert!(auth.terminal_wrapper.manages_cwd);
1489        assert_eq!(auth.display_label, "Container:abc123");
1490    }
1491
1492    #[test]
1493    fn terminal_command_runs_local_argv_directly() {
1494        let auth = Authority::local(
1495            Arc::new(WorkspaceTrust::permissive()),
1496            Arc::new(crate::services::env_provider::EnvProvider::inactive()),
1497        );
1498        // Local backend: the command is the PTY child, no exec prefix, cwd
1499        // managed by the terminal (not the wrapper).
1500        let w = auth.terminal_command(&["claude".into(), "--resume".into(), "u-1".into()]);
1501        assert_eq!(w.command, "claude");
1502        assert_eq!(w.args, vec!["--resume".to_string(), "u-1".to_string()]);
1503        assert!(!w.manages_cwd);
1504        // Empty argv falls back to the interactive shell wrapper.
1505        let shell = auth.terminal_command(&[]);
1506        assert_eq!(shell.command, auth.terminal_wrapper.command);
1507    }
1508
1509    #[test]
1510    fn terminal_command_wraps_argv_into_container_exec() {
1511        // A container authority must run an agent argv *inside* the container
1512        // (`docker exec -it … <id> <argv>`) with the argv intact — never on
1513        // the host, never as a shell string. This is the seam where the
1514        // per-session backend and the agent-resume command compose.
1515        let payload = AuthorityPayload {
1516            filesystem: FilesystemSpec::Local,
1517            spawner: SpawnerSpec::DockerExec {
1518                container_id: "abc123".into(),
1519                user: Some("vscode".into()),
1520                workspace: Some("/workspaces/proj".into()),
1521                env: vec![("PATH".into(), "/home/vscode/.local/bin:/usr/bin".into())],
1522            },
1523            terminal_wrapper: TerminalWrapperSpec::HostShell,
1524            display_label: "Container:abc123".into(),
1525            path_translation: None,
1526        };
1527        let auth = Authority::from_plugin_payload(
1528            payload,
1529            Arc::new(WorkspaceTrust::permissive()),
1530            Arc::new(crate::services::env_provider::EnvProvider::inactive()),
1531        )
1532        .expect("docker payload is valid");
1533
1534        let w = auth.terminal_command(&["claude".into(), "--resume".into(), "u-1".into()]);
1535        assert_eq!(w.command, "docker");
1536        // Re-parented into the container, so cwd is pinned by the wrapper.
1537        assert!(w.manages_cwd);
1538        // The exec prefix (with -u/-w/-e env and the container id) precedes
1539        // the agent argv, which appears verbatim as the trailing elements.
1540        assert_eq!(
1541            w.args,
1542            vec![
1543                "exec",
1544                "-it",
1545                "-u",
1546                "vscode",
1547                "-w",
1548                "/workspaces/proj",
1549                "-e",
1550                "PATH=/home/vscode/.local/bin:/usr/bin",
1551                "abc123",
1552                "claude",
1553                "--resume",
1554                "u-1",
1555            ]
1556        );
1557    }
1558
1559    /// Build a minimal SSH authority for the terminal_command tests. The
1560    /// spawners are local stubs — `terminal_command` only consults
1561    /// `command_wrap` (built from `params` + `remote_dir`), never the spawners.
1562    fn ssh_authority(params: &ConnectionParams, remote_dir: Option<&str>) -> Authority {
1563        let trust = Arc::new(WorkspaceTrust::permissive());
1564        let env = Arc::new(crate::services::env_provider::EnvProvider::inactive());
1565        Authority::ssh(
1566            Arc::new(StdFileSystem),
1567            Arc::new(LocalProcessSpawner::new(
1568                Arc::clone(&env),
1569                Arc::clone(&trust),
1570            )),
1571            Arc::new(LocalLongRunningSpawner::new(
1572                Arc::clone(&env),
1573                Arc::clone(&trust),
1574            )),
1575            params,
1576            remote_dir,
1577            trust,
1578            env,
1579        )
1580    }
1581
1582    #[test]
1583    fn ssh_terminal_command_runs_agent_on_remote_in_workspace() {
1584        // Regression: launching an agent (e.g. `claude`) in an SSH session ran
1585        // it on the *local* host (command == "claude", manages_cwd == false),
1586        // ignoring the session's remote path. It must run through `ssh` on the
1587        // remote host, rooted at the provided remote workspace dir.
1588        let params = ConnectionParams {
1589            user: Some("u".into()),
1590            host: "h".into(),
1591            port: Some(2222),
1592            identity_file: None,
1593            extra_args: Vec::new(),
1594        };
1595        let auth = ssh_authority(&params, Some("/srv/proj"));
1596
1597        let w = auth.terminal_command(&["claude".into(), "--resume".into(), "u-1".into()]);
1598        assert_eq!(
1599            w.command, "ssh",
1600            "agent must launch through ssh, not locally"
1601        );
1602        assert!(
1603            w.manages_cwd,
1604            "ssh re-parents the agent, so it manages its own cwd"
1605        );
1606        // Target precedes the single remote-command argument.
1607        assert!(w.args.contains(&"u@h".to_string()));
1608        let remote_cmd = w.args.last().expect("ssh passes a remote command");
1609        assert!(
1610            remote_cmd.contains("/srv/proj") && remote_cmd.contains("cd "),
1611            "agent must be rooted at the remote workspace dir: {remote_cmd}"
1612        );
1613        assert!(
1614            remote_cmd.contains("claude"),
1615            "agent argv must be present in the remote command: {remote_cmd}"
1616        );
1617    }
1618
1619    #[test]
1620    fn ssh_terminal_command_empty_argv_falls_back_to_shell_wrapper() {
1621        // Empty argv = "spawn a bare remote shell": still the ssh login-shell
1622        // wrapper, not an empty/garbage command.
1623        let params = ConnectionParams {
1624            user: None,
1625            host: "h".into(),
1626            port: None,
1627            identity_file: None,
1628            extra_args: Vec::new(),
1629        };
1630        let auth = ssh_authority(&params, Some("/srv/proj"));
1631        let w = auth.terminal_command(&[]);
1632        assert_eq!(w.command, auth.terminal_wrapper.command);
1633        assert_eq!(w.args, auth.terminal_wrapper.args);
1634    }
1635
1636    #[test]
1637    fn kube_terminal_command_runs_agent_in_pod_workspace() {
1638        // Same regression as SSH, but for a Kubernetes-backed session: the
1639        // agent must run inside the pod (`kubectl exec … -- sh -lc 'cd <ws>;
1640        // exec <argv>'`), rooted at the pod-side workspace, not on the host.
1641        let target = KubeTarget {
1642            context: None,
1643            namespace: "dev".into(),
1644            pod: "pod-1".into(),
1645            container: None,
1646            workspace: Some("/workspace".into()),
1647        };
1648        let trust = Arc::new(WorkspaceTrust::permissive());
1649        let env = Arc::new(crate::services::env_provider::EnvProvider::inactive());
1650        let auth = Authority::kube(
1651            Arc::new(StdFileSystem),
1652            Arc::new(LocalProcessSpawner::new(
1653                Arc::clone(&env),
1654                Arc::clone(&trust),
1655            )),
1656            Arc::new(LocalLongRunningSpawner::new(
1657                Arc::clone(&env),
1658                Arc::clone(&trust),
1659            )),
1660            &target,
1661            &[],
1662            trust,
1663            env,
1664        );
1665
1666        let w = auth.terminal_command(&["claude".into(), "--resume".into(), "u-1".into()]);
1667        assert_eq!(w.command, "kubectl", "agent must launch through kubectl");
1668        assert!(w.manages_cwd);
1669        let remote_cmd = w.args.last().expect("kubectl passes a remote command");
1670        assert!(
1671            remote_cmd.contains("/workspace") && remote_cmd.contains("cd "),
1672            "agent must be rooted at the pod workspace dir: {remote_cmd}"
1673        );
1674        assert!(
1675            remote_cmd.contains("claude"),
1676            "agent argv must be present in the pod command: {remote_cmd}"
1677        );
1678    }
1679
1680    #[test]
1681    fn path_translation_round_trips_under_workspace() {
1682        let pt = PathTranslation {
1683            host_root: PathBuf::from("/tmp/.tmpA1B2"),
1684            remote_root: PathBuf::from("/workspaces/proj"),
1685        };
1686        let host = Path::new("/tmp/.tmpA1B2/src/util.py");
1687        let remote = pt.host_to_remote(host).expect("host under host_root");
1688        assert_eq!(remote, PathBuf::from("/workspaces/proj/src/util.py"));
1689        assert_eq!(
1690            pt.remote_to_host(&remote)
1691                .expect("remote under remote_root"),
1692            host.to_path_buf(),
1693        );
1694    }
1695
1696    #[test]
1697    fn path_translation_returns_none_outside_root() {
1698        // Library / system paths sit outside the workspace mapping —
1699        // callers decide what to do with them. The translator just
1700        // says "not mine".
1701        let pt = PathTranslation {
1702            host_root: PathBuf::from("/host/proj"),
1703            remote_root: PathBuf::from("/workspaces/proj"),
1704        };
1705        assert!(pt
1706            .host_to_remote(Path::new("/usr/include/stdio.h"))
1707            .is_none());
1708        assert!(pt
1709            .remote_to_host(Path::new("/usr/include/stdio.h"))
1710            .is_none());
1711    }
1712
1713    #[test]
1714    fn from_plugin_payload_with_path_translation_round_trips() {
1715        // Plugins (the devcontainer one in particular) supply both
1716        // workspace roots so LSP URIs translate at the boundary. The
1717        // wire shape uses strings so it survives JSON; the constructed
1718        // authority parses them into `PathBuf`.
1719        let json = serde_json::json!({
1720            "filesystem": { "kind": "local" },
1721            "spawner": {
1722                "kind": "docker-exec",
1723                "container_id": "abc123",
1724                "workspace": "/workspaces/proj"
1725            },
1726            "terminal_wrapper": { "kind": "host-shell" },
1727            "path_translation": {
1728                "host_root": "/tmp/.tmpA1B2",
1729                "remote_root": "/workspaces/proj"
1730            }
1731        });
1732        let payload: AuthorityPayload =
1733            serde_json::from_value(json).expect("path_translation is accepted");
1734        let auth = Authority::from_plugin_payload(
1735            payload,
1736            Arc::new(WorkspaceTrust::permissive()),
1737            Arc::new(crate::services::env_provider::EnvProvider::inactive()),
1738        )
1739        .expect("payload with translation is valid");
1740        let pt = auth
1741            .path_translation
1742            .expect("authority carries the translation");
1743        assert_eq!(pt.host_root, PathBuf::from("/tmp/.tmpA1B2"));
1744        assert_eq!(pt.remote_root, PathBuf::from("/workspaces/proj"));
1745    }
1746}