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; text is 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

outcome()

@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()}}

Functions

run(draft, opts)

@spec run(
  String.t(),
  keyword()
) :: outcome()