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
-
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.
-
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).
-
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 |
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
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:
open("/dev/tty")fails outright (Inappropriate ioctl for device) or blocks indefinitely.pinentry-cursesescape 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 realpinentry-curses/ssh/gitprompt. 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 theyopen("/dev/tty")directly.Observed at
v1.2.1-85-g31ecfa12.Implementation
Spike first (open question): validate
master.SyscallConn()+rawConn.Read(f)readiness polling on a pty master on darwin and linux, wherefreturnsfalseon its first call andtruethereafter. 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 throughOffer.Prefix []byte, which then seeds the vte parser in step 5.Create package
internal/term/ttyoffer(alongsideinternal/term/vteandinternal/term/gui) defining the seam between the executor (no UI deps) and the IDE, mirroringworkspacessh.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 ofPty.func SetPresenter(Presenter)(called once at IDE startup) andfunc Get() Presenterreturning nil when unset.Use process-wide registration rather than constructor injection:
workspace.NewFileSchemehas ~120 call sites (internal/ide/ide.go:823plus ~115 tests,cmd/walkbench,cmd/xsandbox,runesvc,runenetsvc), and a nil default keeps every one of them at today's behavior. Addpresenter_other.gounder//go:build !unixwhoseGet()returns nil so Windows compiles and no-ops (Setctty/Cttydo not exist there; ConPTY is out of scope).Add
internal/workspace/file_scheme_tty.go(//go:build unix) with a helper called fromStartCommandbetween the env setup atfile_scheme.go:425andstdcmd.Start()at:448. Skip entirely whenttyoffer.Get() == nil, or whencmd.SysProcAttralready setsSetcttyorSetsid(thevte/component.go:954-1001caller 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 is3 + index.cmd.SysProcAttr, settingSetsid: true,Setctty: true,Ctty: 3 + idxon the copy.GPG_TTY=<slave name>and, only if absent,TERM=xterm-256color, aftercmd.Envso a stale inheritedGPG_TTY(pointing at the terminal Rune was launched from, which in GUI mode may not exist) loses; skip whencmd.EnvsetsGPG_TTYexplicitly.Start(), close the parent's copy of the slave. This is what makes the master returnEIOwhen the child exits, and is the teardown trigger.debug.CapturePanicReportper AGENTS.md, that waits for the master to become readable and then callsPresenter.Present. The goroutine parks in netpoll (no OS thread, ~2KB stack), so one per in-flight command is acceptable even under agentbashspam.Teardown matrix to honor:
stdcmd.Wait()goroutine (:456) closes master → waiter errors out → no widget, no traceEIO→ gpg fails cleanly instead of hanging. Declining an offer is always allowedAdd a
SysProcAttr/env construction unit seam in the same file (pure functions takingcmd, returning the copied attr and env slice) so the hermetic tests in step 8 can assert on it without spawning.In
internal/term/vte, addConfig.AttachPty *workspaceapi.Pty. When set,Component.createPty(component.go:868) adopts it instead of callingt.terminal.NewPtyand returns beforestartCommand;Init(component.go:160) skips theCommandExpandergoroutine. Add an ownership flag consumed byClose/closeSlave(component.go:805,828) so the attached master is closed but the already-closed slave is not double-closed.Add
internal/ide/ttyoffer_adapter.goimplementingttyoffer.Presenterfollowingsshui_adapter.go:182-222: hop onto the event loop withide.scheduleFn, build avte.HandlerwithAttachPtyset, and open it as a centeredbrowserapi.Floatingtitled e.g.gpg — passphrase. As a browser floating window it renders identically in GUI and TUI mode.Presentmust 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,executePluginWaitatex.go:1684-1740). The spawner is already pastStart()by then, so no call path can be waiting on it. CallfileScheme.SetPtySize(file_scheme.go:556) once the floating window has real dimensions, otherwisepinentry-cursesrenders at 0x0 — likely a resize on firstPresent.Add a
command.offer_ttybool (defaulttrue) to the Starlark config and read it where the IDE registers the presenter nearinternal/ide/ide.go:823. Setting it false registers nothing and restores today's behavior exactly.Tests, hermetic (untagged, in
make test) — no process spawning, per AGENTS.md:Setcttyset,Setsidset,Setpgidonly, nilSysProcAttr.GPG_TTYwins over inherited, caller-setGPG_TTYpreserved,TERMadded only when absent,gitenv.Sanitizeordering intact.SysProcAttrcopy-not-mutate; correctCttyindex with and without pre-existingExtraFiles.os.Pipeagainst a fake presenter: no write → noPresent; write → exactly onePresent; close-before-write → noPresent; cancel mid-wait → clean exit. Assert no fd leaks via thep.filesregistry.Presentnever blocks.vteattach:AttachPtyrenders bytes written to the slave and does not spawn; close semantics with no double close of the slave.exec_commandcancel path, sinceSetsiddetaches children from Rune's session.Tests,
//go:build e2e(make test-e2e):/dev/ttytriggers exactly one offer with the right bytes.GNUPGHOMEwith a passphrase-protected key,git commit -Sspawned through the executor, a test presenter that answers by writing the passphrase into the pty master, assertinggit verify-commitpasses and that nothing leaks onto the test's own stdio.Regression risk from
SetsidChildren currently inherit Rune's session;
Setsiddetaches them.file_scheme.go:515Signal,:529killProcessGroup), 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.killProcessGroupgets better: the child is now a session and group leader, sokill(-pid)reaches its whole tree even when the caller did not requestSetpgid.isattychecks on stdio are unchanged — stdio stays pipes.Remote workspaces
internal/workspacessh/proc_remote.go:79-84re-dispatches through a localfileScheme, so the localsshbinary gets the offer for free and its passphrase prompt surfaces. No work needed.std_remote.go) already has native prompts viaworkspacessh.UI.runesvc/workspacerpcremote 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/dreamis untouched, including its existing-c commit.gpgsign=falseflags.Verification
make generate && make lint && make test— hermetic suite must stay green with the nil-presenter default, proving the ~120NewFileSchemecall sites are unaffected.go test -race ./internal/workspace/...— skip predicate, env construction,SysProcAttrcopy, and the detection/teardown state machine including the fd leak assertion againstp.files.go test -race ./internal/term/vte/... ./internal/ide/...—AttachPtyrendering and close semantics;Presentnever blocks the scheduler.make test-e2e—/dev/ttywrite triggers exactly one offer;git commit -Sthrough the executor with a tempGNUPGHOMEproduces a commit thatgit verify-commitaccepts.:!git commit -S -m testin a repo with a passphrase-protected signing key surfaces a floatingpinentry-curses, accepts the passphrase, and the commit is signed./dev/tty(e.g.:!ls) shows no window at all.command.offer_tty = Falserestores today's behavior with no window and noGPG_TTYinjection.