docs/internal/remote-env-manager-design.md
Environment managers — Python venv, direnv, mise, poetry/pipenv,
pyenv — are one of the trickiest parts of a remote development workflow. If
the editor ignores them, three things break in ways that are confusing to the
user:
ruff, gopls behind a mise-managed
toolchain) are spawned with a PATH that never saw the environment, so they
either fail to launch or run against the wrong interpreter.VIRTUAL_ENV or an un-allowed .envrc.This document specifies a UX for Fresh that makes the common cases seamless, keeps full user control available at every step, and reuses the abstractions Fresh already has rather than bolting on a parallel system.
This is a TUI editor, not a GUI IDE. The VS Code / JetBrains patterns (status-bar interpreter pill, "environment source script" field, login-shell discovery) are adapted to Fresh's command palette, prompt-based pickers, status-bar segments, and layered project config.
The design leans entirely on existing machinery. The relevant pieces, with anchors:
Authority — crates/fresh-editor/src/services/authority/mod.rs:237.
The single backend slot per Editor. Every primitive routes through it:
file I/O, integrated terminal, plugin spawnProcess, formatter, LSP
spawn (long_running_spawner), file watcher. Transitions are atomic: a new
authority triggers a clean editor restart (install_authority →
request_restart).SpawnerSpec::DockerExec { env: Vec<(String, String)> }
(authority/mod.rs:197). The devcontainer plugin captures a login-shell env
snapshot inside the container (bash -lic env) and the docker spawner
applies it (docker exec -e KEY=VAL …) to every spawn — one-shot
processes, the LSP command_exists probe, and the LSP server itself. This is
exactly the mechanism an environment manager needs; venv/direnv/mise simply
produce a different env snapshot.lsp/async_handler.rs:~2630 spawns servers via
long_running_spawner.spawn_stdio(command, args, env_pairs, …). Injecting an
environment is a matter of populating env_pairs, not new plumbing.config.rs StatusBarElement::RemoteIndicator; view/ui/status_bar.rs:56
RemoteIndicatorState) plus the showActionPopup dropdown the devcontainer
plugin drives. Natural home for an interpreter pill.StatusBarElement::CustomToken("plugin:token"),
registered via editor.registerStatusBarElement(token, title) and updated
via editor.setStatusBarValue(bufferId, token, value) (see
plugins/git_statusbar.ts).getGlobalState/setGlobalState, keyed by the
canonicalized workspace path. Stored in the user's config dir, never in the
repo. This is where the trust decision and any per-user override live (the
devcontainer plugin uses the same store for its per-workspace attach
decisions). Fresh also has a committed project-config layer
($PROJECT_ROOT/.fresh/config.json, config_io.rs:283), but this feature
deliberately does not use it — see §Tier 3.editor.startPrompt + editor.setPromptSuggestions
(the buffer switcher and every devcontainer picker), and showActionPopup
for modal allow/deny choices.setAuthority, getAuthorityLabel,
setRemoteIndicatorState, registerStatusBarElement/setStatusBarValue,
getEnv, setSetting, registerLspServer, spawnProcess/spawnHostProcess,
getGlobalState/setGlobalState (plugins/lib/fresh.d.ts).The devcontainer plugin (crates/fresh-editor/plugins/devcontainer.ts) is a
complete, shipping precedent for nearly all of this: detect → prompt → capture
login env → setAuthority → status-bar indicator → info panel → persist
decisions per workspace across the restart.
Do not model venv, direnv, and mise as three independent features, and
do not bake a captured env into a stored snapshot. Two converged decisions:
1. One universal capture mechanism. The active environment is produced by a single function — run a shell snippet in the project directory on the active backend's host, and capture the resulting environment delta:
capture(host, snippet, dir) -> Vec<(String, String)>
// runs `$SHELL -lc 'cd <dir>; <snippet>; env -0'` through the active
// backend (local tokio / SSH agent / docker exec), diffs against a
// baseline, and returns the delta. The snippet's own stdout is parsed
// and discarded, so the captured value is clean.
There are no bespoke per-manager code paths. The only manager-specific knowledge is a tiny, overridable default-snippet table chosen by passive detection:
| Detected in project root | Default snippet |
|---|---|
.envrc (direnv) | eval "$(direnv export bash)" |
mise.toml / .mise.toml / .tool-versions | eval "$(mise env -s bash)" |
.venv/ / venv/ | source <dir>/bin/activate |
| nothing, or user override | `` (empty ⇒ pure login shell) or a user snippet |
Why the table can't be dropped entirely: direnv and mise hook the shell
prompt (PROMPT_COMMAND / precmd), which never fires in a non-interactive
capture — so a blank login-shell capture silently misses them. That's why they
ship dedicated exporters (direnv export, mise env); we just use those as the
default snippets. Everything else — pyenv, conda, asdf, custom rc edits —
is caught by the empty (login-shell) snippet, and anything exotic is a
user-supplied snippet. So LoginShell (empty) and Command(snippet) are
first-class universal catch-alls; the named managers are merely auto-filled
defaults, not special cases.
One active provider, no N-way merging. Setups that compose (a .envrc that
does use flake / eval "$(mise activate)") are handled by the outermost
manager: direnv export already returns the fully-composed result. We run one
provider; we never merge several.
2. The provider is live, not a stored snapshot — and never goes stale. The
active environment is a recipe ({ snippet, project_dir }) held as a shared,
interior-mutable handle on the Authority — exactly parallel to
Arc<WorkspaceTrust> — and re-evaluated fresh against the active backend:
spawnProcess, formatters, find-in-files) re-evaluate
the recipe at spawn time. A cache keyed on a content-hash of the env inputs
(.envrc/mise.toml/pyvenv.cfg) skips re-running when nothing changed, but
correctness never depends on it — there is no stored vars blob to drift.TerminalWrapper), so direnv/mise hooks fire natively on cd — fresh by
construction, and stdout noise is expected there.Mechanism: a plugin op, not setAuthority. Activation sets the live
provider in place via editor.setEnv(...) / clearEnv() — not by rebuilding
the authority. This is the only thing that works over SSH: a setAuthority
payload is serializable JSON and cannot carry the live SSH AgentChannel,
so it could never reconstruct a remote backend with env. setAuthority /
SpawnerSpec remain for backend selection (devcontainer attach, SSH startup);
env stops riding on them. Because every spawner already holds the active
backend's handle, and the capture runs through that backend, the whole thing is
remote-correct by construction — evaluation and execution always happen on
the same host.
The entire UX is a ladder from zero-effort to total control. A user never has to configure anything, but can always override the behavior — and every override lives in the user's own config, never in the repository.
Fresh is a pure consumer of the standard, editor-agnostic files a project already commits — it adds none of its own. On opening a workspace, scan the root for, in priority order:
.venv/bin/activate, venv/bin/activate.envrc (direnv)mise.toml, .mise.toml, .tool-versionspoetry.lock, PipfileIf exactly one obvious environment is found, detection is passive: Fresh
reads files only and reflects the discovery in the status bar. It does not
activate (which executes code — see §Threat model) until the workspace is
trusted. In an untrusted workspace the pill shows env: .venv ⚠ (activate?)
and activation is one explicit user action away. This mirrors the devcontainer
plugin's auto-detection of .devcontainer/, which also asks before doing
anything.
A CustomToken status-bar segment sits next to the Remote Indicator:
SSH: deploy@prod env: .venv (py3.11) ⏷ main utf-8 12:4
States:
| Display | Meaning |
|---|---|
env: .venv (py3.11) | venv/poetry active, interpreter resolved |
env: direnv ✓ | .envrc loaded |
env: mise | mise env applied |
env: system | no environment / explicitly system |
env: direnv ⚠ blocked | .envrc present but not allowed |
env: … | resolving |
env: ⚠ | active env's tooling failed to resolve (points at diagnostics) |
Activating the pill (or the command Env: Select Environment) opens a
startPrompt quick-pick — the same primitive as the buffer switcher:
┌─ Select environment ──────────────────────────────────┐
│ > .venv ./.venv/bin/python (3.11) │
│ poetry: myproj-AbC ~/.cache/.../bin/python │
│ direnv (.envrc) reload to apply │
│ System Python /usr/bin/python3 │
│ ─────────────────────────────────────────────────── │
│ Reload directory env (direnv/mise) │
│ Edit activation command… │
│ Use system / none │
└────────────────────────────────────────────────────────┘
Selecting an entry sets the live env provider via editor.setEnv(...) — no
authority rebuild, no restart for one-shot tools; the LSP is restarted so it
re-spawns inside the environment. This is the TUI equivalent of VS Code's
"click the interpreter in the status bar" quick-pick.
A user can override, enable, or disable the auto-detect behavior — but the
override lives in user-global state, never in the repository. Fresh is not a
widely used editor; committing a Fresh-specific config file (.vscode-style)
would land an obscure artifact in the tree that teammates on other editors read
as garbage. More importantly, the what environment to use answer is already
committed in editor-agnostic form (§Tier 1) — Fresh has nothing project-specific
worth adding to git.
So overrides are stored via setGlobalState, keyed by the canonicalized
workspace path (the same store the devcontainer plugin uses for its
per-workspace decisions, and the same store that holds the trust decision):
// conceptual shape of the per-workspace record in user-global state
{
"enabled": true, // master switch for this workspace
"enabled": true, // master switch for this workspace
"snippet": "source .venv/bin/activate", // the capture recipe; "" = pure login
// shell; overrides the auto-detected default
"applyToTerminal": true
}
The override is just the capture snippet (plus on/off) — the same recipe the
universal capture runs. Leaving it unset uses the auto-detected default;
setting it to "" forces a pure login-shell capture; setting it to any shell
snippet is the universal escape hatch for exotic/custom setups.
Plus a global default (in the user's config, not per-workspace) to turn the
whole feature on or off, or set it to "ask, never auto." Reached through the
picker's "Edit activation command…" / "Use system / none" entries and the
Env: … command-palette commands — the user never hand-edits a file unless they
want to.
What this trades away: a team cannot share a Fresh-specific override
through git. In practice that override is almost always expressible in the
standard files anyway — pin the version in .tool-versions, point .envrc at
the venv, set [tool.poetry] in pyproject.toml — so the loss is small and the
repo stays clean. If a real demand for committed Fresh overrides ever appears,
it can be added later as an explicit, opt-in "save to project" action; it is
deliberately not a default, and the threat-model rules below would then
apply to it.
For direnv/mise/pyenv to work over SSH, the capture and the spawn both
have to happen on the remote host. The universal capture handles this by
construction — it runs the snippet through the active backend, so on an SSH
authority capture() issues $SHELL -lc 'cd <dir>; <snippet>; env -0' over the
agent, and the captured env is injected into the (remote) spawns. No special
"login shell mode" toggle on the agent is needed: the snippet is the login
shell when that's what we want, and the snippet's stdout is parsed and
discarded so the agent's exec stays clean.
Two SSH gaps had to close for this, and both are now done:
LSP must run on the remote host. Previously Authority::ssh routed the
long-running spawner to the host-local LocalLongRunningSpawner (the old
"Phase L" gap). Fixed: RemoteLongRunningSpawner runs each server as its
own ssh user@host <remote-cmd> subprocess — a real local
tokio::process::Child whose piped stdio is the remote process's stdio
(the same trick Docker uses with the local docker CLI), so the LSP I/O
layer is untouched. It honours an injected PATH in command_exists, so a
remote venv's pyright/ruff is discoverable.
Remote spawns must carry the env. Fixed: every spawner (local / SSH /
docker, one-shot + long-running) applies the active env via its transport's
native mechanism (Command::envs / env K=V … over ssh / docker exec -e).
The env itself comes from the live provider (above), captured on the remote
host — not from a setAuthority payload, which can't carry the live SSH
connection.
The integrated terminal is the easy case: its TerminalWrapper
(authority/mod.rs:99) opens a real login/interactive shell, so cd-triggered
direnv/mise hooks fire natively, matching the manual experience — fresh by
construction, no capture needed there.
The trust levels below describe the original three-state gate. A proposed evolution replaces the syntactic "Restricted" middle with a sandboxed execution model (run untrusted projects in an ad-hoc container instead of trying to classify which spawns are safe) — see
workspace-trust-sandbox-design.md. This section still describes the enforcement contract env-manager relies on.
Precedent that motivates this section. A reported issue: "simply opening a
.cs file can cause Fresh to execute commands from an attacker-controlled C#
project." The root cause of that class of bug is treating opening a folder as
implicit consent to run project-controlled content (build/restore commands, an
LSP launched with project-specified arguments, a tool resolved from a
repo-local path). This feature is in exactly the same blast radius and must not
repeat the mistake — arguably it is worse, because activating an environment is
designed to run repo-controlled code.
Every one of these executes attacker-controllable code or runs an attacker-controllable binary, and therefore must sit behind the trust boundary — never on the open-folder path:
.envrc (direnv) — arbitrary shell, by design.mise.toml / .mise.toml — env, tasks, and hooks can run commands.activate wrapper (the hand-typed escape hatch in user state) — arbitrary
shell. Note this one is user-authored, not repo-supplied, so the risk is the
user's own typo rather than a hostile project — but it still executes.venv/poetry activation — bin/activate is shell; and more subtly,
./.venv/bin/python (or ruff, pyright) is a repo-controlled binary.
Prepending ./.venv/bin to PATH means every subsequent spawn — LSP,
formatter, spawnProcess — may run a binary the repo author placed there.$SHELL -lic env) — sources rc files; a repo can
influence these via BASH_ENV, a project-local .zshenv/ZDOTDIR, or
direnv/mise shell hooks already installed in the user's rc. Running it is
execution.python --version to render
(py3.11) in the pill executes the repo-controlled interpreter. Use a
non-executing source instead (parse pyvenv.cfg / .python-version) when the
workspace is untrusted.Opening a workspace is read-only. Detecting an environment, reading
pyvenv.cfg, and rendering a status pill never execute project content. Activation — anything in the list above — requires the workspace to be trusted.
Trust is a single per-workspace setting, not a per-feature one, persisted via
getGlobalState/setGlobalState keyed by the canonicalized workspace path
(resolve symlinks; a path-spoof must not inherit another path's trust). Env
managers, the C# project loader, build/restore commands, tasks, and any LSP
launched with project-specified arguments all consult the same setting. It is
a small core service — e.g. editor.workspaceTrustLevel() and
editor.requestWorkspaceTrust() in the plugin API — that any feature which
would spawn or run repo-controlled content checks first. The env-manager plugin
is just one consumer; it must not invent a parallel notion.
There are three levels:
Restricted — the default. No repo-controlled code runs. Env managers do
not activate; C#-style project execution (restore/build, MSBuild targets,
analyzers/source generators, project-configured LSP, tasks) is suppressed;
spawns whose resolved executable lives inside the workspace are refused. But
ordinary spawns still proceed — with the plain, non-activated
environment. git, rg, and normal plugins keep working; only repo-controlled
execution is held back. This is the safe-but-usable default a freshly opened,
never-seen project gets (including if the user just dismisses the prompt).
Trusted — full execution. Env managers activate and inject; the C# loader and project tooling run; repo-relative executables are allowed. The user has vouched for the project.
Blocked — nothing runs without per-spawn say-so. A hard lockdown over
every spawn — spawnProcess, spawnHostProcess, and every core spawn
path (LSP, formatter, exec'd file watchers) alike. It has two behaviors,
chosen by the user:
Allow once / Deny
(deny behaves like fail). The strictest interactive mode: an audit lens
on exactly what a project tries to run, nothing executing without explicit
per-execution consent.Both are strictly more restrictive than Restricted, which lets system tools run unprompted.
The level (and the Blocked sub-behavior) is settable any time from the
status-bar picker / a Workspace: Set Trust Level command and remembered per
canonical path.
When a never-decided workspace is opened and it actually contains something
that would execute (a detected env file, a .sln/.csproj, repo-defined
tasks, analyzers), a single one-shot showActionPopup asks once. Don't prompt
for a plain folder with nothing to gate — a popup on every open trains users
to dismiss it. The prompt names what would run, so consent is informed, and
the safe choice is the default:
This project can run code on your machine through its tooling
(found: .envrc, MyApp.csproj). How should Fresh treat it?
[ Restricted (default) ] [ Trust this folder ] [ Block all execution ]
remember? (·) remember? (·) remember? (·)
The decision is global to the project, so choosing Trusted once lets env activation, the C# loader, etc. all proceed without their own separate popups.
Why not always prompt / never prompt? Prompting on every open is friction and breeds reflexive "yes"; never prompting is the C# bug. Prompting only when there is genuinely executable content present, once, and remembering the answer, keeps the gate meaningful and rare.
Because every primitive routes through the one Authority spawner, the trust
level is enforced at that single choke-point — so it covers plugin
spawnProcess and core Rust callers (LSP, formatter, watchers) identically,
with no per-caller cooperation required:
| Level | Repo-controlled exec (.envrc, analyzers, repo-relative binary) | Ordinary spawn (git, rg, system tool) | Env injection |
|---|---|---|---|
| Restricted (default) | Refused / suppressed | Proceeds | None (env never activated) |
| Trusted | Runs | Proceeds | Active env injected |
| Blocked / fail | Fails with error | Fails with error | N/A — nothing spawns |
| Blocked / prompt | Allow once / Deny per spawn | Allow once / Deny per spawn | None unless an allowed env step ran |
Under Blocked / prompt, the call (plugin or core) blocks awaiting the
decision; Deny returns the same error as Blocked / fail. Because LSP,
formatters, and watchers spawn frequently, per-spawn prompting can flood — so
this mode coalesces identical (executable, args) requests and offers
Allow for this session on the prompt to suppress repeats of the same command.
It is an audit/power-user mode, not a daily default.
Two layers implement the Restricted row, since the spawner can't always tell a repo-controlled spawn from a benign one:
./.venv/bin/python, ./node_modules/.bin/eslint, ./scripts/build). This
catches repo-placed binaries regardless of caller. Note that not activating
already keeps repo bin/ off PATH, so a bare python can't silently
resolve into the repo — the backstop only has to catch explicit in-workspace
paths.dotnet
for a .csproj, a task runner, a project-configured LSP command) check
workspaceTrustLevel() themselves and degrade. The spawner can't infer this;
the feature declares it.Honest caveat. The executable-path backstop is not a proof: even a system tool can run repo-controlled content via config it auto-reads (
gitwith a repo-setcore.fsmonitor/core.pager/hooks is the classic case). Restricted reduces accidental exposure; the real protection is that repo-configurable features degrade when not Trusted. Blocked is the only level that guarantees zero spawning, for when that residual risk is unacceptable.
enabled switch —
lives in user-global state keyed by canonical path (§Tier 3), so the repo
cannot vouch for itself or pre-set an override. Dropping the committed
project-config layer for this feature removes the self-authorization vector
entirely (a malicious repo has no Fresh file to plant a trust level in). The
only thing read from the repo is the standard ecosystem files, and reading
them is passive; running them needs Trusted.PATH risk. When activation prepends a repo-local bin/,
the info panel states plainly that subsequent tools resolve from inside the
project, so a user auditing an untrusted repo understands the consequence..envrc /
mise.toml / activate value changes on disk, drop back to Restricted and
re-ask rather than silently running the new code (content-hash the trusted
value, not just the path).The reported C# issue — opening a .cs file runs commands from the project —
is the same bug with a different trigger, and it gets the same fix. Loading a
C# project is not passive: the language server (OmniSharp/Roslyn) runs
dotnet restore/build, evaluates project-specified MSBuild targets, and — most
dangerously — loads the project's analyzers and source generators, which are
arbitrary code executed at design time. So:
The general rule for every feature that would run repo-controlled content:
check workspaceTrustLevel(), degrade gracefully when not Trusted, and surface
why. A dedicated core design doc for the Workspace Trust service is the right
home for the full enumeration of gated behaviors; this section establishes the
contract env managers rely on.
Env: Select EnvironmentEnv: Reload (direnv/mise)Env: Use SystemEnv: Edit Activation CommandEnv: Show DiagnosticsPATH, and which key binaries (python, ruff,
pyright) actually resolved. When Pyright would have errored, the user sees
why in one place instead of a cryptic LSP failure.env: ⚠ and routes to the diagnostics panel — the opposite of the
default "linter throws errors with no explanation."The split is: core owns the live env handle and applies it; the
env-manager.ts plugin owns detection, the snippet defaults, and the UX.
Core (EnvProvider, parallel to WorkspaceTrust):
Authority, set via the plugin op
editor.setEnv(snippet) / clearEnv() — never a setAuthority rebuildcapture(host, snippet, dir) runs through the active backend (local / SSH
agent / docker), with the inputs-hashed cache and clean-stdio parsingPlugin (env-manager.ts, composing primitives the devcontainer plugin proves
out):
fileExists/readFile) → the default snippet tableregisterStatusBarElement/setStatusBarValue),
pickers (startPrompt), trust prompt (showActionPopup)editor.workspaceTrustLevel() === "trusted", then calls
editor.setEnv(snippet)setGlobalStateDone:
Arc<WorkspaceTrust> field (no decorator, no Option), per-project
persistence, default-deny + prompt-on-open, three levels, host-spawn gating,
and editor.workspaceTrustLevel().RemoteLongRunningSpawner (closes the old
Phase-L fallback), with PATH-aware command_exists.EnvProvider — the live recipe + on-demand capture() with the
inputs-hashed cache (services::env_provider, unit-tested), held as a
mandatory Arc<EnvProvider> on the Authority, born in main.rs.editor.setEnv(snippet, dir?) / clearEnv() plugin ops — set/clear the
live recipe in place (Trusted-gated), restarting tooling; no setAuthority.
SpawnerSpec::LocalWithEnv retired.$SHELL -lc; the SSH spawners capture on the remote host (one-shot via the
agent's exec, long-running via a one-shot ssh), then apply: local via
Command::envs, SSH one-shot via an env K=V … wrapper, SSH long-running via
the cd; … ; exec env … remote script. command_exists honours the captured
PATH on both. So env-manager activation works over SSH by construction.env-manager.ts migrated to detect → snippet → setEnv; capture lives in
core, so the plugin holds no env vars.Remaining:
base_env (the
devcontainer's userEnvProbe) — env-manager activation isn't routed into a
container, because the devcontainer flow owns container env. Wiring the
provider into docker exec (capture via docker exec sh -lc) is a small,
optional follow-up if "activate a venv inside a devcontainer" is wanted..envrc/mise.toml is user-triggered via Env: Reload
(re-capture + restart) for now; auto-watching the input files and restarting
on save is a possible later refinement, intentionally not wired.env: ⚠ diagnostics path, multi-root..envrc/mise.toml means restarting the server. Resolved for now as
a user-triggered Env: Reload (a "reload" affordance) rather than
auto-restarting under the user mid-task; if auto-watching is added later it
should debounce so a burst of saves doesn't thrash.registerLspServer env are a later
extension once LspServerPackConfig grows an env field.