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) -> Self {
154 Self {
155 command: "kubectl".to_string(),
156 args: build_kube_terminal_args(target),
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 trust: Arc<WorkspaceTrust>,
489 env: Arc<crate::services::env_provider::EnvProvider>,
490 ) -> Self {
491 Self {
492 filesystem,
493 process_spawner,
494 long_running_spawner,
495 terminal_wrapper: TerminalWrapper::kube(target),
496 display_label: target.display(),
497 path_translation: None,
498 workspace_trust: trust,
499 env_provider: env,
500 // TODO(per-session): build a `kubectl exec -it … -- ` exec prefix
501 // (argv-pure via `kubectl_exec_argv`) so a restored agent runs in
502 // the pod. Empty for now — falls back to host, no regression.
503 command_prefix: Vec::new(),
504 }
505 }
506
507 /// Assemble a full K8s authority from a live [`KubeConnection`].
508 ///
509 /// The high-level counterpart to [`Self::k8s`]: wires the filesystem and
510 /// one-shot spawner onto the connection's agent channel
511 /// ([`RemoteFileSystem`] / [`RemoteProcessSpawner`], reused verbatim from
512 /// the SSH stack) and the long-running (LSP) spawner onto a per-server
513 /// `kubectl exec` ([`KubectlLongRunningSpawner`]). `base_env` is the
514 /// captured in-pod env probe applied to LSP spawns and `command_exists`.
515 ///
516 /// The caller must keep the `KubeConnection` alive (in the session
517 /// keepalive bundle) — dropping it kills the carrier and tears down the
518 /// channel the returned authority rides on, exactly as SSH holds its
519 /// `SshConnection`.
520 pub fn kube_from_connection(
521 connection: &KubeConnection,
522 target: KubeTarget,
523 base_env: Vec<(String, String)>,
524 trust: Arc<WorkspaceTrust>,
525 env: Arc<crate::services::env_provider::EnvProvider>,
526 ) -> Self {
527 let channel = connection.channel();
528 let filesystem: Arc<dyn FileSystem + Send + Sync> = Arc::new(RemoteFileSystem::new(
529 channel.clone(),
530 connection.connection_string().to_string(),
531 ));
532 let process_spawner: Arc<dyn ProcessSpawner> = Arc::new(RemoteProcessSpawner::new(
533 channel,
534 Arc::clone(&env),
535 Arc::clone(&trust),
536 ));
537 let long_running_spawner: Arc<dyn LongRunningSpawner> = Arc::new(
538 KubectlLongRunningSpawner::with_env(target.clone(), base_env, Arc::clone(&trust)),
539 );
540 Self::kube(
541 filesystem,
542 process_spawner,
543 long_running_spawner,
544 &target,
545 trust,
546 env,
547 )
548 }
549
550 /// Build an authority from a plugin payload (the data carried by the
551 /// `editor.setAuthority(...)` op), gated by `trust` (the editor passes its
552 /// live trust handle so the new authority shares it). All translation from
553 /// "kind + params" to concrete `Arc<dyn …>` lives here and nowhere else.
554 pub fn from_plugin_payload(
555 payload: AuthorityPayload,
556 trust: Arc<WorkspaceTrust>,
557 env: Arc<crate::services::env_provider::EnvProvider>,
558 ) -> Result<Self, AuthorityPayloadError> {
559 let filesystem: Arc<dyn FileSystem + Send + Sync> = match payload.filesystem {
560 FilesystemSpec::Local => Arc::new(StdFileSystem),
561 };
562
563 // Both spawner traits need the docker-exec params when the
564 // payload is a container, so destructure once and reuse.
565 let (process_spawner, long_running_spawner, command_prefix): (
566 Arc<dyn ProcessSpawner>,
567 Arc<dyn LongRunningSpawner>,
568 Vec<String>,
569 ) = match payload.spawner {
570 SpawnerSpec::Local => (
571 Arc::new(LocalProcessSpawner::new(
572 Arc::clone(&env),
573 Arc::clone(&trust),
574 )),
575 Arc::new(LocalLongRunningSpawner::new(
576 Arc::clone(&env),
577 Arc::clone(&trust),
578 )),
579 // Host-local spawner: commands run directly, no exec prefix.
580 Vec::new(),
581 ),
582 SpawnerSpec::DockerExec {
583 container_id,
584 user,
585 workspace,
586 env: docker_env,
587 } => {
588 // The interactive exec prefix so an agent terminal runs
589 // *inside* the container (`docker exec -it … <id> <argv>`),
590 // mirroring the spawner's one-shot `docker exec` invocation
591 // (and the captured `userEnvProbe` env so the agent's PATH
592 // resolves the same binaries).
593 let command_prefix = build_docker_exec_prefix(
594 &container_id,
595 user.as_deref(),
596 workspace.as_deref(),
597 &docker_env,
598 );
599 (
600 Arc::new(
601 crate::services::authority::docker_spawner::DockerExecSpawner::with_env(
602 container_id.clone(),
603 user.clone(),
604 workspace.clone(),
605 docker_env.clone(),
606 Arc::clone(&trust),
607 ),
608 ),
609 Arc::new(
610 crate::services::authority::docker_spawner::DockerLongRunningSpawner::with_env(
611 container_id,
612 user,
613 workspace,
614 docker_env,
615 Arc::clone(&trust),
616 ),
617 ),
618 command_prefix,
619 )
620 }
621 };
622
623 let terminal_wrapper = match payload.terminal_wrapper {
624 TerminalWrapperSpec::HostShell => TerminalWrapper::host_shell(),
625 TerminalWrapperSpec::Explicit {
626 command,
627 args,
628 manages_cwd,
629 } => TerminalWrapper {
630 command,
631 args,
632 manages_cwd,
633 },
634 };
635
636 let path_translation = payload.path_translation.map(|spec| PathTranslation {
637 host_root: PathBuf::from(spec.host_root),
638 remote_root: PathBuf::from(spec.remote_root),
639 });
640
641 Ok(Self {
642 filesystem,
643 process_spawner,
644 long_running_spawner,
645 terminal_wrapper,
646 display_label: payload.display_label,
647 path_translation,
648 workspace_trust: trust,
649 env_provider: env,
650 command_prefix,
651 })
652 }
653}
654
655/// Build the `docker exec -it …` argv prefix that runs an interactive command
656/// inside a container, mirroring [`DockerExecSpawner`]'s one-shot exec args.
657/// A following agent argv is appended verbatim (argv-pure — no shell string).
658/// `-t` (added here, alongside the spawner's `-i`) allocates the PTY the
659/// integrated terminal needs.
660fn build_docker_exec_prefix(
661 container_id: &str,
662 user: Option<&str>,
663 workspace: Option<&str>,
664 env: &[(String, String)],
665) -> Vec<String> {
666 let mut prefix: Vec<String> = vec!["docker".into(), "exec".into(), "-it".into()];
667 if let Some(user) = user {
668 prefix.push("-u".into());
669 prefix.push(user.to_string());
670 }
671 if let Some(ws) = workspace {
672 prefix.push("-w".into());
673 prefix.push(ws.to_string());
674 }
675 for (k, v) in env {
676 prefix.push("-e".into());
677 prefix.push(format!("{k}={v}"));
678 }
679 prefix.push(container_id.to_string());
680 prefix
681}
682
683/// Declarative description of *how to rebuild* a session's backend — the
684/// persisted, source-of-truth counterpart to the live [`Authority`]. A
685/// session stores this (in its per-dir workspace file) so that after an
686/// editor restart or a cold relaunch its backend can be reconstructed
687/// (`Local`) or reconnected (`Plugin` / `RemoteAgent`) instead of silently
688/// degrading to local. See `docs/internal/PER_SESSION_BACKENDS_DESIGN.md`.
689///
690/// Reuses the existing creation payloads ([`AuthorityPayload`],
691/// [`RemoteAgentSpec`]) verbatim so there is no new backend vocabulary and
692/// `fresh-core` stays backend-opaque. Externally tagged so it round-trips
693/// through JSON robustly and additively.
694#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
695pub enum SessionAuthoritySpec {
696 /// Host-local backend. The default for a brand-new session and for any
697 /// session with no persisted spec (back-compat).
698 Local,
699 /// A backend installed via `editor.setAuthority(...)` — devcontainer /
700 /// docker. Reconnecting it is the owning plugin's job (only it can run
701 /// `devcontainer up`).
702 Plugin(AuthorityPayload),
703 /// A born-attached remote agent (SSH / Kubernetes). Reconnectable from
704 /// core via `connect_ssh_authority` / `connect_kube_authority`.
705 RemoteAgent(RemoteAgentSpec),
706}
707
708impl Default for SessionAuthoritySpec {
709 fn default() -> Self {
710 Self::Local
711 }
712}
713
714impl SessionAuthoritySpec {
715 /// Whether this session's backend is anything other than plain local —
716 /// i.e. one that must be reconnected (not just rebuilt) on restore.
717 pub fn is_remote(&self) -> bool {
718 !matches!(self, Self::Local)
719 }
720}
721
722/// Plugin payload for `editor.attachRemoteAgent(...)`. Names a transport
723/// that needs a live connection plus the captured in-pod env probe.
724/// Opaque JSON at the fresh-core boundary; parsed here.
725#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
726pub struct RemoteAgentSpec {
727 pub transport: RemoteTransportSpec,
728 /// Captured in-pod env (PATH/HOME/LANG/…) applied to LSP spawns and
729 /// `command_exists`. Empty when no probe ran.
730 #[serde(default)]
731 pub base_env: Vec<(String, String)>,
732 /// When true, attach as a **new window** (born-attached, coexisting with
733 /// existing windows) instead of the default global restart. The
734 /// Orchestrator sets this so a cloud session is a real session row rather
735 /// than retargeting the whole editor.
736 #[serde(default)]
737 pub window: bool,
738 /// Window label (used only when `window` is true). Empty falls back to the
739 /// transport's display.
740 #[serde(default)]
741 pub label: Option<String>,
742 /// Optional agent argv for the new window's seed terminal (window mode).
743 #[serde(default)]
744 pub command: Option<Vec<String>>,
745}
746
747/// Transport kind for [`RemoteAgentSpec`]. Tagged + additive so new
748/// carriers slot in without breaking the plugin contract.
749#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
750#[serde(tag = "kind", rename_all = "kebab-case")]
751pub enum RemoteTransportSpec {
752 /// Exec into a pod on a K8s (or any kube) cluster.
753 KubectlExec {
754 #[serde(default)]
755 context: Option<String>,
756 namespace: String,
757 pod: String,
758 #[serde(default)]
759 container: Option<String>,
760 #[serde(default)]
761 workspace: Option<String>,
762 },
763 /// SSH into a remote host: the same remote-agent stack as the boot-time
764 /// `fresh user@host:path` flow, exposed at runtime so the Orchestrator can
765 /// open an SSH session as a born-attached window.
766 Ssh {
767 /// Login user. Optional — omit for `host` / `ssh://host`, letting ssh
768 /// resolve the user from its own config or the current local user.
769 #[serde(default)]
770 user: Option<String>,
771 host: String,
772 #[serde(default)]
773 port: Option<u16>,
774 #[serde(default)]
775 identity_file: Option<String>,
776 /// Remote directory to root the session at (terminal `cd` target).
777 #[serde(default)]
778 remote_path: Option<String>,
779 /// Extra `ssh` arguments (e.g. `-J jump`, `-o ProxyCommand=…`) applied
780 /// to every ssh invocation for this session.
781 #[serde(default)]
782 extra_args: Vec<String>,
783 },
784}
785
786impl RemoteAgentSpec {
787 /// Resolve a kubectl-exec spec into the pod target and the captured env.
788 /// Only valid for the `KubectlExec` transport (the caller dispatches on
789 /// `transport` first); panics otherwise.
790 pub fn into_kube_target(self) -> (KubeTarget, Vec<(String, String)>) {
791 match self.transport {
792 RemoteTransportSpec::KubectlExec {
793 context,
794 namespace,
795 pod,
796 container,
797 workspace,
798 } => (
799 KubeTarget {
800 context,
801 namespace,
802 pod,
803 container,
804 workspace,
805 },
806 self.base_env,
807 ),
808 RemoteTransportSpec::Ssh { .. } => {
809 unreachable!("into_kube_target called on a non-kube transport")
810 }
811 }
812 }
813}
814
815/// Resources that must outlive a K8s [`Authority`]: the carrier
816/// connection (its `kubectl exec` child + heartbeat task) and the
817/// reconnect task. The editor parks this in its session-keepalive slot —
818/// the same one SSH uses for its `SshConnection` — so the agent channel
819/// survives the editor rebuild on attach. Dropping it tears the session
820/// down (reconnect aborted, then the connection's carrier killed).
821pub struct KubeKeepalive {
822 // Drop runs the explicit `Drop` below first (aborting reconnect), then
823 // fields drop in declaration order: the connection (kills the carrier),
824 // then the runtime (shuts down its now-idle workers).
825 reconnect: tokio::task::JoinHandle<()>,
826 _connection: KubeConnection,
827 // The load-bearing field: the dedicated runtime the agent channel +
828 // heartbeat + reconnect tasks run on. Owned here so they survive the editor
829 // restart the attach triggers — the editor's per-instance runtime is
830 // dropped during that rebuild, and if the channel rode *that* runtime its
831 // I/O tasks would die the instant the attach completed ("Channel closed"
832 // on every file op). SSH's `RemoteSession._runtime` does exactly this.
833 _runtime: tokio::runtime::Runtime,
834}
835
836impl Drop for KubeKeepalive {
837 fn drop(&mut self) {
838 self.reconnect.abort();
839 }
840}
841
842/// Connect to a K8s pod and assemble its [`Authority`] plus the
843/// [`KubeKeepalive`] that must be parked to keep it alive.
844///
845/// The K8s counterpart to SSH's `connect_remote`: bootstraps the agent
846/// ([`KubeConnection::connect`]) and the reconnect/heartbeat tasks **on a
847/// dedicated runtime owned by the returned keepalive** — *not* the caller's
848/// runtime. Installing the authority restarts the editor, dropping the
849/// editor's per-instance runtime; binding the channel there would kill it the
850/// moment the attach completes (regression: `agent_channel_survives_dropping_
851/// the_attach_runtime`). `base_env` is the captured in-pod env probe applied to
852/// LSP spawns and `command_exists`.
853///
854/// Stays `async` for callers, but the bootstrap needs `block_on` (which can't
855/// run inside the caller's async context), so it happens on a short-lived
856/// helper thread; the live runtime — with its channel/heartbeat/reconnect
857/// tasks — is handed back and parked in the keepalive.
858pub async fn connect_kube_authority(
859 target: KubeTarget,
860 base_env: Vec<(String, String)>,
861 trust: Arc<WorkspaceTrust>,
862 env: Arc<crate::services::env_provider::EnvProvider>,
863 cancel: Option<tokio::sync::oneshot::Receiver<()>>,
864) -> Result<(Authority, KubeKeepalive), TransportError> {
865 type Built = Result<
866 (
867 KubeConnection,
868 tokio::task::JoinHandle<()>,
869 tokio::runtime::Runtime,
870 ),
871 TransportError,
872 >;
873
874 let (tx, rx) = tokio::sync::oneshot::channel::<Built>();
875 let bootstrap_target = target.clone();
876 std::thread::Builder::new()
877 .name("kube-connect".to_string())
878 .spawn(move || {
879 let built: Built = (|| {
880 let runtime = tokio::runtime::Builder::new_multi_thread()
881 .worker_threads(2)
882 .thread_name("kube-agent")
883 .enable_all()
884 .build()
885 .map_err(|e| TransportError::AgentStartFailed(format!("runtime: {e}")))?;
886 // `block_on` drives the bootstrap; the channel/heartbeat/reconnect
887 // tasks it spawns live on `runtime`'s worker threads, which keep
888 // running after `block_on` returns and after this helper thread
889 // exits — until the `runtime` (moved into the keepalive) drops.
890 let (connection, reconnect) = runtime.block_on(async {
891 // Race the connect against the cancel signal so a slow/hung
892 // kubectl bootstrap can be aborted; dropping the connect
893 // future drops the in-flight child. No signal → await it.
894 let connection = match cancel {
895 Some(cancel) => tokio::select! {
896 biased;
897 _ = cancel => {
898 return Err(TransportError::AgentStartFailed(
899 "cancelled".to_string(),
900 ));
901 }
902 res = KubeConnection::connect(bootstrap_target.clone()) => res?,
903 },
904 None => KubeConnection::connect(bootstrap_target.clone()).await?,
905 };
906 let reconnect =
907 spawn_kube_reconnect_task(&connection.channel(), bootstrap_target.clone());
908 Ok::<_, TransportError>((connection, reconnect))
909 })?;
910 Ok((connection, reconnect, runtime))
911 })();
912 #[allow(clippy::let_underscore_must_use)]
913 let _ = tx.send(built);
914 })
915 .map_err(|e| TransportError::AgentStartFailed(format!("connect thread: {e}")))?;
916
917 let (connection, reconnect, runtime) = rx
918 .await
919 .map_err(|_| TransportError::AgentStartFailed("connect thread vanished".to_string()))??;
920
921 let authority = Authority::kube_from_connection(&connection, target, base_env, trust, env);
922 Ok((
923 authority,
924 KubeKeepalive {
925 reconnect,
926 _connection: connection,
927 _runtime: runtime,
928 },
929 ))
930}
931
932/// Resources that must outlive an SSH [`Authority`]: the `SshConnection`
933/// (its `ssh …` child), the reconnect task, and the dedicated runtime the
934/// agent channel rides on. The runtime-owned analogue of `main.rs`'s
935/// boot-time `RemoteSession`; parked per-window by the Orchestrator's
936/// born-attached SSH sessions (and droppable on window close).
937pub struct SshKeepalive {
938 reconnect: tokio::task::JoinHandle<()>,
939 _connection: SshConnection,
940 _runtime: tokio::runtime::Runtime,
941}
942
943impl Drop for SshKeepalive {
944 fn drop(&mut self) {
945 self.reconnect.abort();
946 }
947}
948
949/// Connect to a remote host over SSH and assemble its [`Authority`] plus the
950/// [`SshKeepalive`] that must be parked to keep it alive — the runtime-owned,
951/// reusable counterpart to `main.rs`'s boot-time `connect_remote`, mirroring
952/// [`connect_kube_authority`]. The agent channel + reconnect run on a dedicated
953/// runtime returned in the keepalive so they survive editor rebuilds.
954///
955/// `remote_dir` is the directory the integrated terminal roots at (the
956/// `ssh -t … 'cd <dir>; …'` wrapper); filesystem/process ops carry absolute
957/// paths and don't need it.
958pub async fn connect_ssh_authority(
959 params: ConnectionParams,
960 remote_dir: Option<String>,
961 trust: Arc<WorkspaceTrust>,
962 env: Arc<crate::services::env_provider::EnvProvider>,
963 cancel: Option<tokio::sync::oneshot::Receiver<()>>,
964) -> Result<(Authority, SshKeepalive), SshError> {
965 type Built = Result<
966 (
967 SshConnection,
968 tokio::task::JoinHandle<()>,
969 tokio::runtime::Runtime,
970 ),
971 SshError,
972 >;
973
974 let (tx, rx) = tokio::sync::oneshot::channel::<Built>();
975 let bootstrap_params = params.clone();
976 std::thread::Builder::new()
977 .name("ssh-connect".to_string())
978 .spawn(move || {
979 let built: Built = (|| {
980 let runtime = tokio::runtime::Builder::new_multi_thread()
981 .worker_threads(2)
982 .thread_name("ssh-agent")
983 .enable_all()
984 .build()
985 .map_err(|e| SshError::AgentStartFailed(format!("runtime: {e}")))?;
986 // The channel/reconnect tasks spawned here live on `runtime`'s
987 // workers, surviving after this helper thread exits — until the
988 // `runtime` (moved into the keepalive) drops.
989 let (connection, reconnect) = runtime.block_on(async {
990 // Race the connect against the cancel signal. On cancel the
991 // connect future is dropped, which drops the in-flight ssh
992 // child (spawned kill-on-drop) so a hung handshake leaves no
993 // orphaned process. No cancel signal → just await connect.
994 let connection = match cancel {
995 Some(cancel) => tokio::select! {
996 biased;
997 _ = cancel => {
998 return Err(SshError::AgentStartFailed("cancelled".to_string()));
999 }
1000 res = SshConnection::connect(bootstrap_params.clone()) => res?,
1001 },
1002 None => SshConnection::connect(bootstrap_params.clone()).await?,
1003 };
1004 let reconnect =
1005 spawn_reconnect_task(connection.channel(), connection.params().clone());
1006 Ok::<_, SshError>((connection, reconnect))
1007 })?;
1008 Ok((connection, reconnect, runtime))
1009 })();
1010 #[allow(clippy::let_underscore_must_use)]
1011 let _ = tx.send(built);
1012 })
1013 .map_err(|e| SshError::AgentStartFailed(format!("connect thread: {e}")))?;
1014
1015 let (connection, reconnect, runtime) = rx
1016 .await
1017 .map_err(|_| SshError::AgentStartFailed("connect thread vanished".to_string()))??;
1018
1019 let channel = connection.channel();
1020 let connection_string = connection.connection_string().to_string();
1021 let reconnect_params = connection.params().clone();
1022 let filesystem: Arc<dyn FileSystem + Send + Sync> =
1023 Arc::new(RemoteFileSystem::new(channel.clone(), connection_string));
1024 let process_spawner: Arc<dyn ProcessSpawner> = Arc::new(RemoteProcessSpawner::new(
1025 channel.clone(),
1026 Arc::clone(&env),
1027 Arc::clone(&trust),
1028 ));
1029 let long_running_spawner: Arc<dyn LongRunningSpawner> =
1030 Arc::new(RemoteLongRunningSpawner::new(
1031 reconnect_params.clone(),
1032 Arc::clone(&env),
1033 Arc::clone(&trust),
1034 ));
1035 let authority = Authority::ssh(
1036 filesystem,
1037 process_spawner,
1038 long_running_spawner,
1039 &reconnect_params,
1040 remote_dir.as_deref(),
1041 trust,
1042 env,
1043 );
1044 Ok((
1045 authority,
1046 SshKeepalive {
1047 reconnect,
1048 _connection: connection,
1049 _runtime: runtime,
1050 },
1051 ))
1052}
1053
1054/// Error from translating a plugin payload into a live authority.
1055/// Reserved for future kinds that might fail to construct (e.g. invalid
1056/// connection parameters); local-only payloads currently never fail.
1057#[derive(Debug, thiserror::Error)]
1058pub enum AuthorityPayloadError {
1059 #[error("invalid authority payload: {0}")]
1060 Invalid(String),
1061}
1062
1063mod docker_spawner;
1064mod kube_spawner;
1065
1066pub(crate) use kube_spawner::KubectlLongRunningSpawner;
1067
1068#[cfg(test)]
1069mod tests {
1070 use super::*;
1071
1072 #[test]
1073 fn local_authority_uses_host_shell_with_no_args() {
1074 let auth = Authority::local(
1075 Arc::new(WorkspaceTrust::permissive()),
1076 Arc::new(crate::services::env_provider::EnvProvider::inactive()),
1077 );
1078 assert!(!auth.terminal_wrapper.command.is_empty());
1079 assert!(auth.terminal_wrapper.args.is_empty());
1080 assert!(!auth.terminal_wrapper.manages_cwd);
1081 assert_eq!(auth.display_label, "");
1082 }
1083
1084 #[test]
1085 fn kube_terminal_wrapper_reparents_into_pod() {
1086 let target = KubeTarget {
1087 context: Some("prod".into()),
1088 namespace: "dev".into(),
1089 pod: "pod-1".into(),
1090 container: None,
1091 workspace: Some("/workspace".into()),
1092 };
1093 let wrapper = TerminalWrapper::kube(&target);
1094 assert_eq!(wrapper.command, "kubectl");
1095 // Re-parented shell must pin cwd through its own args.
1096 assert!(wrapper.manages_cwd);
1097 assert_eq!(wrapper.args[0], "--context");
1098 assert!(wrapper.args.iter().any(|a| a == "-it"));
1099 assert!(wrapper.args.iter().any(|a| a == "pod-1"));
1100 // User shell override is a no-op for a cwd-managing wrapper, so the
1101 // re-parenting into the pod stays intact.
1102 let override_shell = crate::config::TerminalShellConfig {
1103 command: "/usr/local/bin/fish".into(),
1104 args: vec![],
1105 };
1106 let after = wrapper
1107 .clone()
1108 .with_user_shell_override(Some(&override_shell));
1109 assert_eq!(after.command, "kubectl");
1110 }
1111
1112 #[test]
1113 fn remote_agent_spec_parses_plugin_payload() {
1114 // The exact JSON shape `editor.attachRemoteAgent(...)` carries and
1115 // `handle_attach_remote_agent` parses (opaque at the fresh-core
1116 // boundary). Pins the plugin↔core wire contract.
1117 let json = serde_json::json!({
1118 "transport": {
1119 "kind": "kubectl-exec",
1120 "context": "k3d-dev",
1121 "namespace": "dev",
1122 "pod": "fresh-7c9f",
1123 "container": "app",
1124 "workspace": "/workspace"
1125 },
1126 "base_env": [
1127 ["PATH", "/home/dev/.local/bin:/usr/bin"],
1128 ["LANG", "C.UTF-8"]
1129 ]
1130 });
1131 let spec: RemoteAgentSpec = serde_json::from_value(json).expect("spec parses");
1132 let (target, base_env) = spec.into_kube_target();
1133 assert_eq!(target.context.as_deref(), Some("k3d-dev"));
1134 assert_eq!(target.namespace, "dev");
1135 assert_eq!(target.pod, "fresh-7c9f");
1136 assert_eq!(target.container.as_deref(), Some("app"));
1137 assert_eq!(target.workspace.as_deref(), Some("/workspace"));
1138 assert_eq!(base_env.len(), 2);
1139 assert_eq!(
1140 base_env[0],
1141 (
1142 "PATH".to_string(),
1143 "/home/dev/.local/bin:/usr/bin".to_string()
1144 )
1145 );
1146
1147 // Minimal payload (only namespace + pod) parses too: context,
1148 // container, workspace, and base_env are all optional.
1149 let minimal = serde_json::json!({
1150 "transport": { "kind": "kubectl-exec", "namespace": "dev", "pod": "p" }
1151 });
1152 let spec2: RemoteAgentSpec = serde_json::from_value(minimal).expect("minimal parses");
1153 let (t2, env2) = spec2.into_kube_target();
1154 assert!(t2.context.is_none() && t2.container.is_none() && t2.workspace.is_none());
1155 assert!(env2.is_empty());
1156 }
1157
1158 #[test]
1159 fn from_plugin_payload_local_yields_host_shell() {
1160 let payload = AuthorityPayload {
1161 filesystem: FilesystemSpec::Local,
1162 spawner: SpawnerSpec::Local,
1163 terminal_wrapper: TerminalWrapperSpec::HostShell,
1164 display_label: String::new(),
1165 path_translation: None,
1166 };
1167 let auth = Authority::from_plugin_payload(
1168 payload,
1169 Arc::new(WorkspaceTrust::permissive()),
1170 Arc::new(crate::services::env_provider::EnvProvider::inactive()),
1171 )
1172 .expect("local payload is valid");
1173 assert!(!auth.terminal_wrapper.command.is_empty());
1174 assert!(auth.terminal_wrapper.args.is_empty());
1175 }
1176
1177 #[test]
1178 fn payload_roundtrips_through_serde_json() {
1179 // The plugin op carries the payload as opaque JSON through
1180 // `fresh-core`; this test nails down the wire shape so we
1181 // don't silently break plugins when the struct evolves.
1182 let json = serde_json::json!({
1183 "filesystem": { "kind": "local" },
1184 "spawner": {
1185 "kind": "docker-exec",
1186 "container_id": "abc123",
1187 "user": "vscode",
1188 "workspace": "/workspaces/proj"
1189 },
1190 "terminal_wrapper": {
1191 "kind": "explicit",
1192 "command": "docker",
1193 "args": ["exec", "-it", "abc123", "bash", "-l"],
1194 "manages_cwd": true
1195 },
1196 "display_label": "Container:abc123"
1197 });
1198 let payload: AuthorityPayload =
1199 serde_json::from_value(json).expect("json matches payload schema");
1200 let auth = Authority::from_plugin_payload(
1201 payload,
1202 Arc::new(WorkspaceTrust::permissive()),
1203 Arc::new(crate::services::env_provider::EnvProvider::inactive()),
1204 )
1205 .expect("docker payload is valid");
1206 assert_eq!(auth.terminal_wrapper.command, "docker");
1207 assert!(auth.terminal_wrapper.manages_cwd);
1208 assert_eq!(auth.display_label, "Container:abc123");
1209 }
1210
1211 #[test]
1212 fn payload_accepts_docker_exec_env_pairs() {
1213 // The captured `userEnvProbe` env carries the in-container
1214 // `PATH`/`HOME`/etc. so LSP `command_exists` can find binaries
1215 // that live in shell-only PATHs (e.g. `~/.local/bin`).
1216 let json = serde_json::json!({
1217 "filesystem": { "kind": "local" },
1218 "spawner": {
1219 "kind": "docker-exec",
1220 "container_id": "abc123",
1221 "user": "vscode",
1222 "workspace": "/workspaces/proj",
1223 "env": [
1224 ["PATH", "/home/vscode/.local/bin:/usr/bin"],
1225 ["LANG", "C.UTF-8"]
1226 ]
1227 },
1228 "terminal_wrapper": { "kind": "host-shell" }
1229 });
1230 let payload: AuthorityPayload =
1231 serde_json::from_value(json).expect("env field is accepted");
1232 if let SpawnerSpec::DockerExec { env, .. } = &payload.spawner {
1233 assert_eq!(env.len(), 2);
1234 assert_eq!(
1235 env[0],
1236 ("PATH".into(), "/home/vscode/.local/bin:/usr/bin".into())
1237 );
1238 assert_eq!(env[1], ("LANG".into(), "C.UTF-8".into()));
1239 } else {
1240 panic!("expected docker-exec spawner");
1241 }
1242 // And the omitted-field form still parses (the field defaults
1243 // to empty), so older plugins that don't populate it stay
1244 // wire-compatible.
1245 let json_no_env = serde_json::json!({
1246 "filesystem": { "kind": "local" },
1247 "spawner": {
1248 "kind": "docker-exec",
1249 "container_id": "abc123"
1250 },
1251 "terminal_wrapper": { "kind": "host-shell" }
1252 });
1253 let payload2: AuthorityPayload =
1254 serde_json::from_value(json_no_env).expect("env is optional");
1255 if let SpawnerSpec::DockerExec { env, .. } = payload2.spawner {
1256 assert!(env.is_empty());
1257 } else {
1258 panic!("expected docker-exec spawner");
1259 }
1260 }
1261
1262 #[test]
1263 fn payload_defaults_manages_cwd_to_true_for_explicit_wrapper() {
1264 // Per the schema, `manages_cwd` is optional in the JSON and
1265 // defaults to true because re-parented shells almost always
1266 // want it that way.
1267 let json = serde_json::json!({
1268 "filesystem": { "kind": "local" },
1269 "spawner": { "kind": "local" },
1270 "terminal_wrapper": {
1271 "kind": "explicit",
1272 "command": "bash",
1273 "args": []
1274 }
1275 });
1276 let payload: AuthorityPayload =
1277 serde_json::from_value(json).expect("manages_cwd is optional");
1278 let auth = Authority::from_plugin_payload(
1279 payload,
1280 Arc::new(WorkspaceTrust::permissive()),
1281 Arc::new(crate::services::env_provider::EnvProvider::inactive()),
1282 )
1283 .expect("payload is valid");
1284 assert!(auth.terminal_wrapper.manages_cwd);
1285 assert_eq!(auth.display_label, "");
1286 }
1287
1288 #[test]
1289 fn user_shell_override_replaces_host_shell_wrapper() {
1290 let override_shell = crate::config::TerminalShellConfig {
1291 command: "/usr/local/bin/fish".into(),
1292 args: vec!["-l".into(), "-i".into()],
1293 };
1294 let wrapper = TerminalWrapper::host_shell().with_user_shell_override(Some(&override_shell));
1295 assert_eq!(wrapper.command, "/usr/local/bin/fish");
1296 assert_eq!(wrapper.args, vec!["-l".to_string(), "-i".to_string()]);
1297 assert!(!wrapper.manages_cwd);
1298 }
1299
1300 #[test]
1301 fn user_shell_override_is_noop_when_wrapper_manages_cwd() {
1302 // Docker/SSH-style wrappers set `manages_cwd = true`; replacing
1303 // their command would drop the re-parenting args and spawn the
1304 // user's shell on the host, defeating the authority.
1305 let docker = TerminalWrapper {
1306 command: "docker".into(),
1307 args: vec![
1308 "exec".into(),
1309 "-w".into(),
1310 "/workspaces/proj".into(),
1311 "abc123".into(),
1312 "bash".into(),
1313 ],
1314 manages_cwd: true,
1315 };
1316 let override_shell = crate::config::TerminalShellConfig {
1317 command: "/usr/local/bin/fish".into(),
1318 args: vec![],
1319 };
1320 let wrapper = docker
1321 .clone()
1322 .with_user_shell_override(Some(&override_shell));
1323 assert_eq!(wrapper.command, docker.command);
1324 assert_eq!(wrapper.args, docker.args);
1325 assert!(wrapper.manages_cwd);
1326 }
1327
1328 #[test]
1329 fn user_shell_override_none_leaves_wrapper_unchanged() {
1330 let original = TerminalWrapper::host_shell();
1331 let wrapper = original.clone().with_user_shell_override(None);
1332 assert_eq!(wrapper.command, original.command);
1333 assert_eq!(wrapper.args, original.args);
1334 assert_eq!(wrapper.manages_cwd, original.manages_cwd);
1335 }
1336
1337 #[test]
1338 fn from_plugin_payload_docker_exec_carries_label() {
1339 let payload = AuthorityPayload {
1340 filesystem: FilesystemSpec::Local,
1341 spawner: SpawnerSpec::DockerExec {
1342 container_id: "abc123".into(),
1343 user: Some("vscode".into()),
1344 workspace: Some("/workspaces/proj".into()),
1345 env: Vec::new(),
1346 },
1347 terminal_wrapper: TerminalWrapperSpec::Explicit {
1348 command: "docker".into(),
1349 args: vec![
1350 "exec".into(),
1351 "-it".into(),
1352 "-u".into(),
1353 "vscode".into(),
1354 "-w".into(),
1355 "/workspaces/proj".into(),
1356 "abc123".into(),
1357 "bash".into(),
1358 "-l".into(),
1359 ],
1360 manages_cwd: true,
1361 },
1362 display_label: "Container:abc123".into(),
1363 path_translation: None,
1364 };
1365 let auth = Authority::from_plugin_payload(
1366 payload,
1367 Arc::new(WorkspaceTrust::permissive()),
1368 Arc::new(crate::services::env_provider::EnvProvider::inactive()),
1369 )
1370 .expect("docker payload is valid");
1371 assert_eq!(auth.terminal_wrapper.command, "docker");
1372 assert!(auth.terminal_wrapper.manages_cwd);
1373 assert_eq!(auth.display_label, "Container:abc123");
1374 }
1375
1376 #[test]
1377 fn terminal_command_runs_local_argv_directly() {
1378 let auth = Authority::local(
1379 Arc::new(WorkspaceTrust::permissive()),
1380 Arc::new(crate::services::env_provider::EnvProvider::inactive()),
1381 );
1382 // Local backend: the command is the PTY child, no exec prefix, cwd
1383 // managed by the terminal (not the wrapper).
1384 let w = auth.terminal_command(&["claude".into(), "--resume".into(), "u-1".into()]);
1385 assert_eq!(w.command, "claude");
1386 assert_eq!(w.args, vec!["--resume".to_string(), "u-1".to_string()]);
1387 assert!(!w.manages_cwd);
1388 // Empty argv falls back to the interactive shell wrapper.
1389 let shell = auth.terminal_command(&[]);
1390 assert_eq!(shell.command, auth.terminal_wrapper.command);
1391 }
1392
1393 #[test]
1394 fn terminal_command_wraps_argv_into_container_exec() {
1395 // A container authority must run an agent argv *inside* the container
1396 // (`docker exec -it … <id> <argv>`) with the argv intact — never on
1397 // the host, never as a shell string. This is the seam where the
1398 // per-session backend and the agent-resume command compose.
1399 let payload = AuthorityPayload {
1400 filesystem: FilesystemSpec::Local,
1401 spawner: SpawnerSpec::DockerExec {
1402 container_id: "abc123".into(),
1403 user: Some("vscode".into()),
1404 workspace: Some("/workspaces/proj".into()),
1405 env: vec![("PATH".into(), "/home/vscode/.local/bin:/usr/bin".into())],
1406 },
1407 terminal_wrapper: TerminalWrapperSpec::HostShell,
1408 display_label: "Container:abc123".into(),
1409 path_translation: None,
1410 };
1411 let auth = Authority::from_plugin_payload(
1412 payload,
1413 Arc::new(WorkspaceTrust::permissive()),
1414 Arc::new(crate::services::env_provider::EnvProvider::inactive()),
1415 )
1416 .expect("docker payload is valid");
1417
1418 let w = auth.terminal_command(&["claude".into(), "--resume".into(), "u-1".into()]);
1419 assert_eq!(w.command, "docker");
1420 // Re-parented into the container, so cwd is pinned by the wrapper.
1421 assert!(w.manages_cwd);
1422 // The exec prefix (with -u/-w/-e env and the container id) precedes
1423 // the agent argv, which appears verbatim as the trailing elements.
1424 assert_eq!(
1425 w.args,
1426 vec![
1427 "exec",
1428 "-it",
1429 "-u",
1430 "vscode",
1431 "-w",
1432 "/workspaces/proj",
1433 "-e",
1434 "PATH=/home/vscode/.local/bin:/usr/bin",
1435 "abc123",
1436 "claude",
1437 "--resume",
1438 "u-1",
1439 ]
1440 );
1441 }
1442
1443 #[test]
1444 fn path_translation_round_trips_under_workspace() {
1445 let pt = PathTranslation {
1446 host_root: PathBuf::from("/tmp/.tmpA1B2"),
1447 remote_root: PathBuf::from("/workspaces/proj"),
1448 };
1449 let host = Path::new("/tmp/.tmpA1B2/src/util.py");
1450 let remote = pt.host_to_remote(host).expect("host under host_root");
1451 assert_eq!(remote, PathBuf::from("/workspaces/proj/src/util.py"));
1452 assert_eq!(
1453 pt.remote_to_host(&remote)
1454 .expect("remote under remote_root"),
1455 host.to_path_buf(),
1456 );
1457 }
1458
1459 #[test]
1460 fn path_translation_returns_none_outside_root() {
1461 // Library / system paths sit outside the workspace mapping —
1462 // callers decide what to do with them. The translator just
1463 // says "not mine".
1464 let pt = PathTranslation {
1465 host_root: PathBuf::from("/host/proj"),
1466 remote_root: PathBuf::from("/workspaces/proj"),
1467 };
1468 assert!(pt
1469 .host_to_remote(Path::new("/usr/include/stdio.h"))
1470 .is_none());
1471 assert!(pt
1472 .remote_to_host(Path::new("/usr/include/stdio.h"))
1473 .is_none());
1474 }
1475
1476 #[test]
1477 fn from_plugin_payload_with_path_translation_round_trips() {
1478 // Plugins (the devcontainer one in particular) supply both
1479 // workspace roots so LSP URIs translate at the boundary. The
1480 // wire shape uses strings so it survives JSON; the constructed
1481 // authority parses them into `PathBuf`.
1482 let json = serde_json::json!({
1483 "filesystem": { "kind": "local" },
1484 "spawner": {
1485 "kind": "docker-exec",
1486 "container_id": "abc123",
1487 "workspace": "/workspaces/proj"
1488 },
1489 "terminal_wrapper": { "kind": "host-shell" },
1490 "path_translation": {
1491 "host_root": "/tmp/.tmpA1B2",
1492 "remote_root": "/workspaces/proj"
1493 }
1494 });
1495 let payload: AuthorityPayload =
1496 serde_json::from_value(json).expect("path_translation is accepted");
1497 let auth = Authority::from_plugin_payload(
1498 payload,
1499 Arc::new(WorkspaceTrust::permissive()),
1500 Arc::new(crate::services::env_provider::EnvProvider::inactive()),
1501 )
1502 .expect("payload with translation is valid");
1503 let pt = auth
1504 .path_translation
1505 .expect("authority carries the translation");
1506 assert_eq!(pt.host_root, PathBuf::from("/tmp/.tmpA1B2"));
1507 assert_eq!(pt.remote_root, PathBuf::from("/workspaces/proj"));
1508 }
1509}