Raxol. Harness. EditorSession
(Raxol v2.6.1)
View Source
The THIN impure runner for the composer's external-editor handoff:
drives Raxol.Harness.EditorSuspend's pure state machine and
interprets each step against the real device / stty / reader gate /
editor process. All sequencing, ordering, and failure-recovery policy
lives in the pure machine; this module only performs effects.
The spawn mechanism (why a Port with :nouse_stdio)
The editor must own the REAL tty. System.cmd/3 can never provide
that -- its child's stdio is a pipe pair back to the BEAM. A
Port.open({:spawn, cmd}, [:nouse_stdio, :exit_status]) moves the
port's own pipe protocol to fds 3/4, so the child INHERITS the BEAM's
fds 0/1/2 -- the terminal itself. This is byte-for-byte the mechanism
OTP's own shell uses for its open-in-editor feature (user_drv's
Ctrl+O path: disable_reader -> open_port(..., [nouse_stdio]) ->
reload on port exit -> enable_reader), so the approach is
OTP-sanctioned, not novel. {:spawn, string} goes through /bin/sh,
which buys two things: $EDITOR values carrying arguments
("code -w") work unmodified, and a missing editor binary surfaces
as the shell's exit status 127 -- mapped here to a keep-the-draft
{:editor_not_found, cmd} outcome instead of a crash. The temp-file
path is single-quote shell-escaped before it joins the command line.
The :spawn_fun seam is (shell_command :: String.t()) -> non_neg_integer() | :crashed | :timeout -- synchronous, returning the
editor's exit status (:crashed when the port died without ever
delivering one; :timeout when :editor_timeout_ms elapsed first).
The default implementation is the Port described above.
The default :editor_timeout_ms is :infinity -- an interactive
human legitimately edits for arbitrary time on their own tty and can
always quit the editor themselves. This deliberately leaves
NON-interactive embedders (automation, agents, CI harnesses driving a
pty) unprotected against a wedged editor by default: such embedders
MUST set a bound (via this option, or Raxol.Harness.Surface's
:editor_opts), or a never-exiting editor blocks the calling loop
forever.
Outcomes
{:ok, %{text: edited, width: w, rows: h, degraded: [...]}}-- editor exited 0 and the temp file read back;textis the decoded draft (EditorSuspend.decode_draft/1).{:kept, reason, %{width: w, rows: h, degraded: [...]}}-- the terminal was suspended and RESUMED, but the draft is kept unchanged::editor_nonzero|{:editor_not_found, cmd}|:editor_crashed|:editor_timeout|:reload_failed.{:error, {:reader_disable, reason}}/{:error, {step, reason}}-- a step failed before the handoff completed; the machine's compensation ran, so the terminal is back in a recoverable state. In particular a reader-disable failure aborts BEFORE any byte touches the device: never hand the tty to an editor while the BEAM reader still competes for its keystrokes.
degraded is the machine's EditorSuspend.degradations/1 list --
[] on a clean run. A non-empty list (today: {:enable_reader, reason} when the stdin reader failed to re-enable after the editor)
means the run COMPLETED but keyboard input may be dead; the failure
also emits [:raxol, :harness, :editor, :reader_enable_failed]
telemetry with %{reason: reason} metadata. Callers MUST surface a
non-empty degraded to the operator (Surface renders a footer
warning) -- it is never safe to show the edited draft as if nothing
happened while the tty cannot type.
A step that RAISES (rather than returning an error) still runs the
machine's recovery/1 compensation first, then the exception
propagates unchanged -- callers that must not crash (the harness UI
loop) wrap their call; the terminal is already restored by the time
the exception reaches them either way. The temp file is additionally
removed in a try/after, so no path -- including the raise path --
leaks it.
Draft confidentiality (the temp file is a secret)
A composer draft can contain anything the operator types -- API keys,
private text. It is therefore never written bare into the shared
tmp dir: each run creates a fresh per-run subdirectory chmod'd 0700
(unreadable to other local users regardless of file modes or umask),
with an unpredictable :crypto.strong_rand_bytes-suffixed name, and
the draft file inside it is created by write_draft_file/2 with
[:exclusive] (O_CREAT | O_EXCL -- creation FAILS on any
pre-existing path, and per POSIX the final path component is never
followed as a symlink under O_EXCL, so a pre-planted symlink cannot
redirect the write) and chmod'd 0600. Cleanup removes the whole
per-run directory on every path.
Trust boundary: $VISUAL/$EDITOR is the operator's own shell config
The editor command is interpolated into a /bin/sh command line
UNVALIDATED, by design -- the same contract git, crontab, and OTP's
own shell honor (all of them sh-execute $EDITOR, precisely so
values like "code -w" or emacsclient -a "" work; a metacharacter
allowlist would break real configurations while defending a boundary
that does not exist here). It is safe under exactly one assumption:
the process environment is the SAME trust domain as the operator's
shell -- whoever set $EDITOR could already run commands as this
user. The draft content itself never reaches the shell (only the
quoted generated path does). Embedders that expose the harness across
a privilege boundary where the environment is attacker-influenceable
(SSH AcceptEnv/ForceCommand setups, sudo env_keep, a service
manager injecting env) MUST NOT pass the ambient environment through
-- inject a vetted :env explicitly instead.
What this module deliberately does NOT do
It never writes DECSTBM region bytes. The machine's
:reassert_region step is interpreted as a no-op HERE because region
emission is owned by the paint authority
(Raxol.UI.Rendering.PaintAuthority.InlineAuthority.reassert/1 over
its ScrollRegionManager state -- the single-DECSTBM-owner rule): the
caller that owns the authority (Raxol.Harness.Surface's edit-draft
dispatch) composes resize |> reassert on EVERY return from this
function -- ok, kept, or error -- so the pin is guaranteed
belt-and-braces regardless of where a failure landed. Geometry is
re-queried here (while still cooked, before re-entering raw mode) and
returned so that caller re-pins at the terminal's CURRENT size.
Every injectable seam has a real default: :stty (module),
:reader_gate (module), :reader (pid), :env, :tmp_dir,
:spawn_fun, :size_fun, :device. Tests drive the full sequence
with fakes and a StringIO device; the real tty is never touched by
the suite.
Summary
Types
@type outcome() :: {:ok, %{ text: String.t(), width: pos_integer(), rows: pos_integer(), degraded: [{Raxol.Harness.EditorSuspend.step(), term()}] }} | {:kept, term(), %{ width: pos_integer(), rows: pos_integer(), degraded: [{Raxol.Harness.EditorSuspend.step(), term()}] }} | {:error, {atom(), term()}}