Skip to content

Offer every spawned command a lazy controlling TTY for gpg/ssh/git credential prompts #128

Description

@ernestrc

Any command Rune spawns that needs to interactively ask for a secret — a GPG passphrase for commit signing, an SSH key passphrase, a git HTTPS credential — has nowhere to prompt. Rune has zero handling for this today, and the failure mode differs by launch mode:

  • GUI mode: Rune has no controlling terminal at all, so the child's open("/dev/tty") fails outright (Inappropriate ioctl for device) or blocks indefinitely.
  • TUI mode: the child inherits Rune's own terminal and writes pinentry-curses escape sequences straight onto the editor's display, corrupting it.

Mechanism: lazy controlling TTY. Every spawned command gets a private PTY as its controlling terminal (/dev/tty), while stdin/stdout/stderr stay exactly as the caller provided them. If — and only if — the child writes to that PTY, Rune surfaces a floating terminal rendering it, so the user answers their real pinentry-curses/ssh/git prompt. If the child never touches it, the PTY is discarded silently and nothing is shown. Both launch modes get identical behavior, and neither can be corrupted by the child.

One mechanism covers all three channels: gpg via GPG_TTY, ssh and git because they open("/dev/tty") directly.

Observed at v1.2.1-85-g31ecfa12.

Implementation

  1. Spike first (open question): validate master.SyscallConn() + rawConn.Read(f) readiness polling on a pty master on darwin and linux, where f returns false on its first call and true thereafter. The first call happens before any wait, the second only once the fd is readable, so not a byte of the prompt is consumed. If this misbehaves, fall back to reading into a small buffer and passing the prefix through Offer.Prefix []byte, which then seeds the vte parser in step 5.

  2. Create package internal/term/ttyoffer (alongside internal/term/vte and internal/term/gui) defining the seam between the executor (no UI deps) and the IDE, mirroring workspacessh.UI / internal/ide/sshui_adapter.go:

    • type Offer struct { Pty workspaceapi.Pty; Title string }
    • type Presenter interface { Present(ctx context.Context, o Offer) error }, documented as: called from a goroutine that is not the IDE event loop, the first time the child writes to the offered terminal; takes ownership of Pty.
    • func SetPresenter(Presenter) (called once at IDE startup) and func Get() Presenter returning nil when unset.

    Use process-wide registration rather than constructor injection: workspace.NewFileScheme has ~120 call sites (internal/ide/ide.go:823 plus ~115 tests, cmd/walkbench, cmd/xsandbox, runesvc, runenetsvc), and a nil default keeps every one of them at today's behavior. Add presenter_other.go under //go:build !unix whose Get() returns nil so Windows compiles and no-ops (Setctty/Ctty do not exist there; ConPTY is out of scope).

  3. Add internal/workspace/file_scheme_tty.go (//go:build unix) with a helper called from StartCommand between the env setup at file_scheme.go:425 and stdcmd.Start() at :448. Skip entirely when ttyoffer.Get() == nil, or when cmd.SysProcAttr already sets Setctty or Setsid (the vte/component.go:954-1001 caller already owns a terminal). Otherwise:

    • pty, err := p.NewPty(ctx); on error log and spawn normally — an offer must never fail a command.
    • stdcmd.ExtraFiles = append(stdcmd.ExtraFiles, rawSlave); the child's fd is 3 + index.
    • Copy (never mutate) cmd.SysProcAttr, setting Setsid: true, Setctty: true, Ctty: 3 + idx on the copy.
    • Append GPG_TTY=<slave name> and, only if absent, TERM=xterm-256color, after cmd.Env so a stale inherited GPG_TTY (pointing at the terminal Rune was launched from, which in GUI mode may not exist) loses; skip when cmd.Env sets GPG_TTY explicitly.
    • After Start(), close the parent's copy of the slave. This is what makes the master return EIO when the child exits, and is the teardown trigger.
    • Start one goroutine, wrapped in debug.CapturePanicReport per AGENTS.md, that waits for the master to become readable and then calls Presenter.Present. The goroutine parks in netpoll (no OS thread, ~2KB stack), so one per in-flight command is acceptable even under agent bash spam.

    Teardown matrix to honor:

    Event Result
    Child exits, never wrote stdcmd.Wait() goroutine (:456) closes master → waiter errors out → no widget, no trace
    Child exits with widget open Master EOF → vte shows exit → presenter closes the window
    Context cancelled Master closed → same as above
    User dismisses widget Master closed → child's tty I/O gets EIO → gpg fails cleanly instead of hanging. Declining an offer is always allowed
  4. Add a SysProcAttr/env construction unit seam in the same file (pure functions taking cmd, returning the copied attr and env slice) so the hermetic tests in step 8 can assert on it without spawning.

  5. In internal/term/vte, add Config.AttachPty *workspaceapi.Pty. When set, Component.createPty (component.go:868) adopts it instead of calling t.terminal.NewPty and returns before startCommand; Init (component.go:160) skips the CommandExpander goroutine. Add an ownership flag consumed by Close/closeSlave (component.go:805,828) so the attached master is closed but the already-closed slave is not double-closed.

  6. Add internal/ide/ttyoffer_adapter.go implementing ttyoffer.Presenter following sshui_adapter.go:182-222: hop onto the event loop with ide.scheduleFn, build a vte.Handler with AttachPty set, and open it as a centered browserapi.Floating titled e.g. gpg — passphrase. As a browser floating window it renders identically in GUI and TUI mode. Present must schedule the window and return immediately — it is called from the detection goroutine and must never block the spawner, because the event loop can itself be blocked on the spawning call (async_plugin.go:34, async_vte.go:122, ex.go:356,1655, executePluginWait at ex.go:1684-1740). The spawner is already past Start() by then, so no call path can be waiting on it. Call fileScheme.SetPtySize (file_scheme.go:556) once the floating window has real dimensions, otherwise pinentry-curses renders at 0x0 — likely a resize on first Present.

  7. Add a command.offer_tty bool (default true) to the Starlark config and read it where the IDE registers the presenter near internal/ide/ide.go:823. Setting it false registers nothing and restores today's behavior exactly.

  8. Tests, hermetic (untagged, in make test) — no process spawning, per AGENTS.md:

    • Table-driven skip predicate: nil presenter, Setctty set, Setsid set, Setpgid only, nil SysProcAttr.
    • Env construction: GPG_TTY wins over inherited, caller-set GPG_TTY preserved, TERM added only when absent, gitenv.Sanitize ordering intact.
    • SysProcAttr copy-not-mutate; correct Ctty index with and without pre-existing ExtraFiles.
    • Detection/teardown state machine driven by os.Pipe against a fake presenter: no write → no Present; write → exactly one Present; close-before-write → no Present; cancel mid-wait → clean exit. Assert no fd leaks via the p.files registry.
    • Presenter adapter with a fake scheduler, asserting Present never blocks.
    • vte attach: AttachPty renders bytes written to the slave and does not spawn; close semantics with no double close of the slave.
    • A regression test on the agent exec_command cancel path, since Setsid detaches children from Rune's session.
  9. Tests, //go:build e2e (make test-e2e):

    • A shell writing to /dev/tty triggers exactly one offer with the right bytes.
    • The real proof: a temp GNUPGHOME with a passphrase-protected key, git commit -S spawned through the executor, a test presenter that answers by writing the passphrase into the pty master, asserting git verify-commit passes and that nothing leaks onto the test's own stdio.

Regression risk from Setsid

Children currently inherit Rune's session; Setsid detaches them.

  • Terminal-driven SIGINT no longer reaches these children. Rune routes input itself in both launch modes and signals children explicitly (file_scheme.go:515 Signal, :529 killProcessGroup), and in GUI mode there is no controlling terminal to deliver a signal from — so this is near-theoretical. It still gets the dedicated test in step 8.
  • killProcessGroup gets better: the child is now a session and group leader, so kill(-pid) reaches its whole tree even when the caller did not request Setpgid.
  • isatty checks on stdio are unchanged — stdio stays pipes.
  • Programs that today corrupt the TUI display or fail outright under the GUI are fixed.

Remote workspaces

  • internal/workspacessh/proc_remote.go:79-84 re-dispatches through a local fileScheme, so the local ssh binary gets the offer for free and its passphrase prompt surfaces. No work needed.
  • In-process ssh (std_remote.go) already has native prompts via workspacessh.UI.
  • runesvc/workspacerpc remote spawns are deferred: the remote side would need its own presenter plus a way to tunnel the pty back over the command stream. Follow-up, not attempted here.

Out of scope

cmd/rune-agent/memory/dream is untouched, including its existing -c commit.gpgsign=false flags.

Verification

  • make generate && make lint && make test — hermetic suite must stay green with the nil-presenter default, proving the ~120 NewFileScheme call sites are unaffected.
  • go test -race ./internal/workspace/... — skip predicate, env construction, SysProcAttr copy, and the detection/teardown state machine including the fd leak assertion against p.files.
  • go test -race ./internal/term/vte/... ./internal/ide/... — AttachPty rendering and close semantics; Present never blocks the scheduler.
  • make test-e2e — /dev/tty write triggers exactly one offer; git commit -S through the executor with a temp GNUPGHOME produces a commit that git verify-commit accepts.
  • Manual, GUI mode: :!git commit -S -m test in a repo with a passphrase-protected signing key surfaces a floating pinentry-curses, accepts the passphrase, and the commit is signed.
  • Manual, TUI mode: the same command does not corrupt the editor display.
  • Manual: dismissing the floating window makes gpg fail cleanly instead of hanging.
  • Manual: a command that never writes to /dev/tty (e.g. :!ls) shows no window at all.
  • command.offer_tty = False restores today's behavior with no window and no GPG_TTY injection.

No activity

Activity on this issue will appear here.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions