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