docs/internal/AUTHORITY_DESIGN.md
Supersedes DEVCONTAINER_INTEGRATION_PLAN.md. That plan predicted the
Authority Provider pattern; this document describes the architecture
as actually shipped.
The editor has exactly one Authority at any moment. It is the single
answer to "where does this primitive run?":
:term, plugin createTerminal)spawnProcessThe struct carries four fields and nothing else:
pub struct Authority {
pub filesystem: Arc<dyn FileSystem + Send + Sync>,
pub process_spawner: Arc<dyn ProcessSpawner>,
pub terminal_wrapper: TerminalWrapper,
pub display_label: String,
}
Empty display_label means the status bar renders nothing — the SSH
constructor leaves it empty so the existing
filesystem.remote_connection_info() path (which knows about
disconnect) stays the source of truth for user@host labels.
One authority on Editor. No Option. Local is an authority;
SSH is an authority; devcontainer is an authority; anything a plugin
invents is an authority. Local's terminal wrapper is
detect_shell() with no args and manages_cwd: false.
Authority is the sole router for "where". Every primitive routes
through editor.authority(). Nothing reads a backend-specific
field. Nothing branches on "is this SSH / a container".
Authority is opaque to core. No string "docker" / "ssh" /
"container" in core logic that consumes the authority. The only
code that names a backend is the constructor for that backend's
authority.
Plugins own backend lifecycle; core owns the slot. Plugins
attach a container, parse a devcontainer.json, drive a rebuild.
Core just holds the authority and re-routes through it.
Modal per window, no composition. One authority, one workspace. Opening a non-project file while attached still routes through the active authority — this is the contract, not a bug.
Startup is local; plugins upgrade. The editor always boots
Authority::local() and renders immediately. The SSH CLI form
(fresh user@host:path) substitutes Authority::ssh(...) at
startup. Devcontainer attach is a plugin op and happens post-boot.
Authority transitions are destructive. See next section.
The core shrinks. This refactor net-deleted ~400 lines: the
services/devcontainer/ module, DevcontainerConfig, the
connect_devcontainer block in main.rs, and every per-backend
branch in the terminal manager, render code, and plugin dispatch.
Identity lives in the authority. Whoever constructs the
authority fills in display_label. SSH intentionally leaves it
empty so disconnect annotations flow through one place.
Every authority is constructible in isolation. Authority::local(),
Authority::ssh(...), Authority::from_plugin_payload(...) — all
available for unit testing without a running editor.
Principle 7 says "atomic and destructive". The spec's original
phrasing — "installing a new authority closes all terminals spawned
under the previous one, restarts LSP servers, invalidates cached
spawner handles; pointer-equality against Editor.authority is the
'still attached to the same thing?' check" — describes an in-place
swap.
We chose the more conservative option: transitions drop and rebuild
the whole Editor, piggy-backing on the existing
change_working_dir / request_restart flow.
In-place swap means enumerating everything that holds an
Arc<dyn FileSystem> or an Arc<dyn ProcessSpawner> at the moment of
swap and invalidating each one. As of this refactor that set
includes, at minimum:
EditorState (captured filesystem at load time)FsManager (file explorer)FileProvider in Quick OpenLspManager (server handles spawned through the old spawner)TerminalManager (every PTY)Arc to the old spawnerAny cache holding a closure over the old authority's filesystem would silently keep using the old backend after a "successful" transition. Enumerating them is doable, but easy to miss, and the miss manifests as "files save to the wrong place" — a trust-destroying class of bug.
The request_restart path already drops the entire Editor, calls
Drop on every resource, rebuilds from scratch, and reloads plugins.
Session restore brings buffers back. LSPs cold-start, but they were
going to restart on authority change anyway. The visible cost is one
frame of "Restarting editor…" status and a ~1-second pause — a price
we pay once per attach/detach, not per keystroke.
Editor::install_authority(new) stashes the replacement in
pending_authority and calls request_restart(self.working_dir).Editor::clear_authority() is sugar for install_authority(Authority::local()).main.rs drains take_pending_authority()
from the old editor before dropping it, threads the result into
current_authority, then builds the next Editor with
set_boot_authority(current_authority) immediately after
construction so plugins load with the new backend from the first
tick.The in-place swap remains a future optimization. The single-line
escape hatch is at install_authority — replace the pending + restart
with a direct swap once every cache-holder is audited.
Session mode (fresh --session / fresh server) runs the editor in a
long-lived daemon with thin clients attaching over IPC. The daemon
must not exit on every authority transition or working-dir change —
that would disconnect every attached client.
EditorServer (crates/fresh-editor/src/server/editor_server.rs)
mirrors the standalone restart loop: when the editor sets
should_quit via request_restart (either from
change_working_dir or from install_authority), the server takes
the pending fields off the old editor, calls rebuild_editor(...),
and clients stay attached. rebuild_editor:
self.config.working_dir and/or self.current_authority.build_editor_instance with the new
authority already installed (set_boot_authority).If neither a pending authority nor a restart dir is present,
should_quit is treated as a real shutdown request and the daemon
exits as before. Tests cover both the authority-swap and the
working-dir-swap paths (test_session_rebuild_swaps_editor_and_authority
and test_session_rebuild_switches_working_dir).
EditorServerConfig has two optional slots for callers that want
the daemon to boot into something other than local:
startup_authority: Option<Authority> — installed as
current_authority before the first editor is built. Defaults to
Authority::local().session_keepalive: Option<Box<dyn Any + Send>> — an opaque
bundle held for the server's lifetime alongside startup_authority.
SSH authorities back this with the Tokio runtime, the
SshConnection, and the reconnect task; dropping any one of those
would tear the remote session down, so the server just holds the
bundle until shutdown. Local authorities leave it None.When a client command (fresh -a <files> or
fresh --cmd session open-file <name> <files>) sees any remote spec
in the file list, extract_ssh_url_from_files:
parse_location.ssh:// URL via
remote_location_to_ssh_url (line/column are per-file and stay
out of the authority URL).That URL is forwarded to the detached child as
--ssh-url <URL> (a hidden internal flag) by
spawn_server_detached(session_name, ssh_url). The file list sent
to the daemon over the OpenFiles control message is stripped to
bare paths — the daemon's active authority already knows the host.
On the daemon side, run_server_command uses parse_ssh_url_arg
(URL-form only, hard error on anything else) to build a
RemoteLocation, calls the same create_startup_authority /
connect_remote used by standalone mode, and wraps the resulting
RemoteSession in the session_keepalive slot. The remote path
becomes the daemon's working_dir; local cwd keeps its role as the
config-layering key.
Existing servers are not re-attached through a remote URL: a URL
passed to fresh -a is only consumed when the client starts a new
daemon. If a local-authority session is already running under the
target key, the URL is ignored. Callers wanting isolation should
pass --session-name (or equivalent) so the new SSH daemon gets a
distinct socket.
change_working_dirchange_working_dir uses the same restart machinery to switch project
roots. Authority transitions and project-root changes are the same
primitive at the main-loop level — drop the Editor, rebuild — with
different "what changes" semantics (working directory vs. the
current_authority slot). Keeping them separate entry points means
callers don't have to care about each other; each can evolve
independently.
Three ops, intentionally small:
editor.setAuthority(payload) — payload is a tagged
AuthorityPayload (filesystem kind + spawner kind + terminal
wrapper + display label). The concrete schema lives in
crates/fresh-editor/src/services/authority/mod.rs; TS types are
mirrored in plugins/lib/fresh.d.ts. Fire-and-forget — the editor
restarts before any follow-up code on this call's return could run.editor.clearAuthority() — restore Authority::local() with the
same restart semantics.editor.spawnHostProcess(command, args, cwd?) — run on the host
regardless of the current authority. Reserved for plugin internals
that must do host-side work (e.g. devcontainer up) before the
authority they want even exists. Same calling shape as
spawnProcess, a thenable returning a SpawnResult.type AuthorityPayload = {
filesystem: { kind: "local" };
spawner:
| { kind: "local" }
| {
kind: "docker-exec";
container_id: string;
user?: string | null;
workspace?: string | null;
};
terminal_wrapper:
| { kind: "host-shell" }
| { kind: "explicit"; command: string; args: string[]; manages_cwd?: boolean };
display_label?: string;
};
New kinds go here and in Authority::from_plugin_payload.
serde's tagged-enum representation means old payloads keep parsing
as new variants are added.
Example of the plugin-owned backend lifecycle, in full:
Authority::local().plugins/devcontainer.ts loads, calls findConfig(), sees
.devcontainer/devcontainer.json.getCwd() — reopening the project doesn't
re-prompt.editor.spawnHostProcess("devcontainer", ["up", "--workspace-folder", cwd]).
This always runs on the host, even if the call originates from
inside a container (important for rebuild).AuthorityPayload, calls editor.setAuthority(payload).status.detected in the status bar.Detach / rebuild follow the same path with different args:
clearAuthority() for detach, up --remove-existing-container for
rebuild.
Vec<Authority>, no path-prefix
routing). Principle 5.devcontainer.auto_detect / devcontainer.cli_path
in user config are ignored on load; the plugin now owns both.