fresh-editor 0.3.9

A lightweight, fast terminal-based text editor with LSP support and TypeScript plugins
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
//! Docker-exec process spawner.
//!
//! Used by container authorities. Plugins build this via the
//! `editor.setAuthority({ spawner: { kind: "docker-exec", … } })` op
//! after they have brought a container up. Core never names "docker"
//! anywhere outside this file — the spawner is just one more
//! `dyn ProcessSpawner` / `dyn LongRunningSpawner` implementation as
//! far as the rest of the editor is concerned.

use std::path::Path;
use std::process::Stdio;
use std::sync::Arc;

use async_trait::async_trait;
use tokio::process::Command;

use crate::services::process_hidden::HideWindow;
use crate::services::remote::{
    LongRunningSpawner, ProcessSpawner, SpawnError, SpawnResult, StdioChild,
};
use crate::services::workspace_trust::{gate, WorkspaceTrust};

/// Spawn processes inside a long-lived Docker container via `docker exec`.
///
/// `base_env` carries env vars that get injected via `docker exec -e
/// KEY=VAL` on every spawn — the plugin populates this with the
/// container's `userEnvProbe` capture (notably `PATH`, which differs
/// between an interactive login shell and a bare `docker exec`). LSP
/// servers and `command_exists` probes both go through this so
/// `pylsp` installed by a `postCreateCommand` into `~/.local/bin`
/// is actually discoverable when the editor goes to spawn it.
pub(crate) struct DockerExecSpawner {
    container_id: String,
    user: Option<String>,
    workspace: Option<String>,
    base_env: Vec<(String, String)>,
    trust: Arc<WorkspaceTrust>,
}

impl DockerExecSpawner {
    pub(crate) fn with_env(
        container_id: String,
        user: Option<String>,
        workspace: Option<String>,
        base_env: Vec<(String, String)>,
        trust: Arc<WorkspaceTrust>,
    ) -> Self {
        Self {
            container_id,
            user,
            workspace,
            base_env,
            trust,
        }
    }

    /// Test helper — `with_env` with an empty base env and a permissive
    /// trust. Production always knows whether it has a `userEnvProbe`
    /// capture or not, so the explicit form is what `from_plugin_payload`
    /// calls.
    #[cfg(test)]
    pub(crate) fn new(
        container_id: String,
        user: Option<String>,
        workspace: Option<String>,
    ) -> Self {
        Self::with_env(
            container_id,
            user,
            workspace,
            Vec::new(),
            Arc::new(WorkspaceTrust::permissive()),
        )
    }
}

impl DockerExecSpawner {
    /// Compose the `docker` CLI arguments for invoking `command` with
    /// `args` inside the container. Shared between the one-shot
    /// `ProcessSpawner` impl and the long-running variant so both
    /// paths honour `-u <user>` / `-w <cwd-or-workspace>` consistently.
    ///
    /// `extra_env` is merged after the spawner's own `base_env` (so
    /// per-call entries override the captured probe). Both flatten to
    /// `-e KEY=VALUE` flags placed before the container id, matching
    /// `docker`'s flag-parsing rules.
    fn build_exec_args(
        &self,
        command: &str,
        args: &[String],
        cwd: Option<&Path>,
        interactive: bool,
        extra_env: &[(String, String)],
    ) -> Vec<String> {
        let env_capacity = (self.base_env.len() + extra_env.len()) * 2;
        let mut docker_args: Vec<String> = Vec::with_capacity(args.len() + env_capacity + 8);
        docker_args.push("exec".into());
        if interactive {
            // `-i` keeps stdin open so JSON-RPC clients can write to
            // the in-container process; LSP needs this.
            docker_args.push("-i".into());
        }
        if let Some(user) = self.user.as_ref() {
            docker_args.push("-u".into());
            docker_args.push(user.clone());
        }
        let chosen_cwd: Option<String> = cwd
            .map(|p| p.to_string_lossy().into_owned())
            .or_else(|| self.workspace.clone());
        if let Some(dir) = chosen_cwd {
            docker_args.push("-w".into());
            docker_args.push(dir);
        }
        for (k, v) in &self.base_env {
            docker_args.push("-e".into());
            docker_args.push(format!("{}={}", k, v));
        }
        for (k, v) in extra_env {
            docker_args.push("-e".into());
            docker_args.push(format!("{}={}", k, v));
        }
        docker_args.push(self.container_id.clone());
        docker_args.push(command.to_string());
        docker_args.extend(args.iter().cloned());
        docker_args
    }
}

#[async_trait]
impl ProcessSpawner for DockerExecSpawner {
    async fn spawn(
        &self,
        command: String,
        args: Vec<String>,
        cwd: Option<String>,
    ) -> Result<SpawnResult, SpawnError> {
        gate(&self.trust, &command, cwd.as_deref())?;
        let cwd_path = cwd.as_deref().map(Path::new);
        let docker_args = self.build_exec_args(&command, &args, cwd_path, false, &[]);

        let output = Command::new("docker")
            .args(&docker_args)
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .hide_window()
            .output()
            .await
            .map_err(|e| SpawnError::Process(e.to_string()))?;

        Ok(SpawnResult {
            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
            exit_code: output.status.code().unwrap_or(-1),
        })
    }

    async fn spawn_to_file(
        &self,
        _command: String,
        _args: Vec<String>,
        _cwd: Option<String>,
        _stdout_to: std::path::PathBuf,
    ) -> Result<SpawnResult, SpawnError> {
        Err(SpawnError::Process(
            "stdoutTo is not supported for docker-exec processes".to_string(),
        ))
    }
}

/// Long-running spawner for container authorities — wraps the `docker
/// exec -i` invocation into a `StdioChild` so LSP servers (and future
/// tool agents) run inside the container with piped JSON-RPC.
///
/// `spawned_locally = false` on the returned child so host-only resource
/// controls (cgroups, rlimits) skip themselves — their PID would be the
/// `docker` CLI wrapper, not the server running inside the container,
/// and applying a cgroup to the wrapper accomplishes nothing useful.
///
/// `command_exists` runs `docker exec <id> sh -c 'command -v <cmd>'` so
/// the LSP popup's binary-presence probe reflects the container's PATH
/// rather than the host's — which is the whole point of routing through
/// the authority.
pub(crate) struct DockerLongRunningSpawner {
    inner: DockerExecSpawner,
}

impl DockerLongRunningSpawner {
    pub(crate) fn with_env(
        container_id: String,
        user: Option<String>,
        workspace: Option<String>,
        base_env: Vec<(String, String)>,
        trust: Arc<WorkspaceTrust>,
    ) -> Self {
        Self {
            inner: DockerExecSpawner::with_env(container_id, user, workspace, base_env, trust),
        }
    }

    /// Test helper — see [`DockerExecSpawner::new`].
    #[cfg(test)]
    pub(crate) fn new(
        container_id: String,
        user: Option<String>,
        workspace: Option<String>,
    ) -> Self {
        Self::with_env(
            container_id,
            user,
            workspace,
            Vec::new(),
            Arc::new(WorkspaceTrust::permissive()),
        )
    }
}

#[async_trait]
impl LongRunningSpawner for DockerLongRunningSpawner {
    async fn spawn_stdio(
        &self,
        command: &str,
        args: &[String],
        env: Vec<(String, String)>,
        cwd: Option<&Path>,
        limits: Option<&crate::types::ProcessLimits>,
    ) -> Result<StdioChild, SpawnError> {
        gate(
            &self.inner.trust,
            command,
            cwd.map(|p| p.to_string_lossy()).as_deref(),
        )?;
        // Docker authorities can't meaningfully enforce host-side
        // resource limits: a cgroup attached to the `docker` CLI PID
        // doesn't govern the container-side server, and `setrlimit`
        // applied via `pre_exec` in the host-side `docker` process
        // propagates nowhere. Log when limits are set so users don't
        // silently wonder why their cap isn't enforced.
        if let Some(lim) = limits {
            if lim.enabled && (lim.max_memory_percent.is_some() || lim.max_cpu_percent.is_some()) {
                tracing::debug!(
                    "DockerLongRunningSpawner: ignoring process_limits — host-side \
                     cgroups/rlimits don't reach into containers (memory={:?}%, cpu={:?}%)",
                    lim.max_memory_percent,
                    lim.max_cpu_percent
                );
            }
        }

        // `-e KEY=VAL` entries (the spawner's captured `userEnvProbe`
        // base env plus this call's per-spawn `env`) are injected
        // *before* the container id so `docker exec` applies them to
        // the server process. `build_exec_args` handles both layers in
        // one pass.
        let docker_args = self.inner.build_exec_args(command, args, cwd, true, &env);

        let child = Command::new("docker")
            .args(&docker_args)
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .hide_window()
            .kill_on_drop(true)
            .spawn()
            .map_err(|e| SpawnError::Process(e.to_string()))?;

        Ok(StdioChild::from_tokio_child(child, false))
    }

    async fn command_exists(&self, command: &str) -> bool {
        // `command -v` is POSIX-standard and honours shell builtins,
        // functions, and `$PATH` lookups — the same semantics
        // `which::which` gives on the host, minus `which`'s non-
        // ubiquity inside minimal container images.
        //
        // The probe has to see the same `$PATH` the LSP child will see
        // when it actually spawns; otherwise the editor declines to
        // launch a server that's perfectly installed (e.g. `pylsp` in
        // `~/.local/bin`, on PATH for an interactive login shell but
        // not for the bare exec env). `build_exec_args` includes
        // `base_env` here for free.
        let probe = format!("command -v {}", shell_quote(command));
        let sh_args = vec!["-c".to_string(), probe];
        let docker_args = self.inner.build_exec_args("sh", &sh_args, None, false, &[]);

        match Command::new("docker")
            .args(&docker_args)
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .hide_window()
            .status()
            .await
        {
            Ok(status) => status.success(),
            Err(_) => false,
        }
    }
}

/// Quote a single argument for POSIX `sh -c`. `command -v` only takes a
/// bare command name in practice (no spaces, rare weird chars), but
/// quoting defensively keeps future callers safe.
fn shell_quote(s: &str) -> String {
    if s.chars()
        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | '+' | ':' | '@'))
    {
        s.to_string()
    } else {
        // Wrap in single quotes; escape any embedded single quotes
        // with the usual POSIX `'\''` sequence.
        let escaped = s.replace('\'', "'\\''");
        format!("'{}'", escaped)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn build_exec_args_non_interactive_places_flags_correctly() {
        let sp = DockerExecSpawner::new(
            "abc123".into(),
            Some("vscode".into()),
            Some("/workspaces/proj".into()),
        );
        let args = sp.build_exec_args("rust-analyzer", &[], None, false, &[]);
        // ["exec", "-u", "vscode", "-w", "/workspaces/proj", "abc123", "rust-analyzer"]
        assert_eq!(args[0], "exec");
        assert_eq!(args[1], "-u");
        assert_eq!(args[2], "vscode");
        assert_eq!(args[3], "-w");
        assert_eq!(args[4], "/workspaces/proj");
        assert_eq!(args[5], "abc123");
        assert_eq!(args[6], "rust-analyzer");
        assert_eq!(args.len(), 7);
    }

    #[test]
    fn build_exec_args_interactive_inserts_dash_i() {
        let sp = DockerExecSpawner::new("abc".into(), None, None);
        let args = sp.build_exec_args("bash", &[], None, true, &[]);
        assert_eq!(&args[..3], &["exec", "-i", "abc"]);
    }

    #[test]
    fn build_exec_args_cwd_override_wins_over_workspace() {
        let sp = DockerExecSpawner::new("abc".into(), None, Some("/default".into()));
        let args = sp.build_exec_args("ls", &[], Some(Path::new("/override")), false, &[]);
        // The cwd slot must carry the per-call override, not the default
        let w_pos = args.iter().position(|a| a == "-w").expect("-w present");
        assert_eq!(args[w_pos + 1], "/override");
    }

    #[test]
    fn build_exec_args_base_env_lands_before_container_id() {
        // `userEnvProbe`-captured PATH (and any other env the plugin
        // hands the spawner constructor) must reach the in-container
        // command. `docker exec` parses `-e` only before the container
        // id; anything after is treated as the command/args.
        let sp = DockerExecSpawner::with_env(
            "abc".into(),
            None,
            None,
            vec![
                ("PATH".into(), "/home/vscode/.local/bin:/usr/bin".into()),
                ("LANG".into(), "C.UTF-8".into()),
            ],
            Arc::new(WorkspaceTrust::permissive()),
        );
        let args = sp.build_exec_args("pylsp", &[], None, false, &[]);
        let abc_pos = args
            .iter()
            .position(|a| a == "abc")
            .expect("container id present");
        let path_pos = args
            .iter()
            .position(|a| a == "PATH=/home/vscode/.local/bin:/usr/bin")
            .expect("PATH env injected");
        let lang_pos = args
            .iter()
            .position(|a| a == "LANG=C.UTF-8")
            .expect("LANG env injected");
        let pylsp_pos = args
            .iter()
            .position(|a| a == "pylsp")
            .expect("command present");
        assert!(path_pos < abc_pos, "PATH must precede container id");
        assert!(lang_pos < abc_pos, "LANG must precede container id");
        assert!(abc_pos < pylsp_pos, "container id must precede command");
        // Each env value must be preceded by a `-e` flag.
        assert_eq!(args[path_pos - 1], "-e");
        assert_eq!(args[lang_pos - 1], "-e");
    }

    #[test]
    fn build_exec_args_extra_env_appended_after_base_env() {
        // Per-call `env` should compose with the base env so callers
        // can override individual keys; the call-site env is appended
        // last, which means later `-e KEY=VAL` wins under `docker exec`'s
        // last-flag-wins semantics.
        let sp = DockerExecSpawner::with_env(
            "abc".into(),
            None,
            None,
            vec![("PATH".into(), "/base".into())],
            Arc::new(WorkspaceTrust::permissive()),
        );
        let extra = vec![("PATH".into(), "/override".into())];
        let args = sp.build_exec_args("ls", &[], None, false, &extra);
        let base_idx = args.iter().position(|a| a == "PATH=/base").unwrap();
        let override_idx = args.iter().position(|a| a == "PATH=/override").unwrap();
        assert!(
            base_idx < override_idx,
            "base env precedes per-call env so the call-site value wins"
        );
    }

    #[test]
    fn docker_long_running_spawn_injects_env_before_container_id() {
        // The command composition itself is deterministic — it's what
        // the process will see. Verify that the env flags land between
        // the flag block and the container id, not after the command.
        let sp =
            DockerLongRunningSpawner::new("abc".into(), Some("vscode".into()), Some("/ws".into()));
        let env: Vec<(String, String)> = vec![("RUST_LOG".into(), "debug".into())];
        let out = sp
            .inner
            .build_exec_args("rust-analyzer", &[], None, true, &env);
        let e_pos = out.iter().position(|a| a == "-e").unwrap();
        let abc_pos = out.iter().position(|a| a == "abc").unwrap();
        let ra_pos = out.iter().position(|a| a == "rust-analyzer").unwrap();
        assert!(e_pos < abc_pos);
        assert!(abc_pos < ra_pos);
        assert_eq!(out[e_pos + 1], "RUST_LOG=debug");
    }

    #[test]
    fn shell_quote_passes_simple_names_through() {
        assert_eq!(shell_quote("rust-analyzer"), "rust-analyzer");
        assert_eq!(shell_quote("/usr/bin/env"), "/usr/bin/env");
    }

    #[test]
    fn shell_quote_escapes_weird_characters() {
        assert_eq!(shell_quote("has space"), "'has space'");
        assert_eq!(shell_quote("it's"), "'it'\\''s'");
    }
}