From 0b77c9334b218eb1d7249983ceae424904ebe071 Mon Sep 17 00:00:00 2001 From: shemishtamesh <62944862+shemishtamesh@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:05:47 +0300 Subject: [PATCH] fix(spec): isolate __complete probe from the controlling terminal (#111) run the `__complete` probe subprocess in its own session so that a cobra-incompatible binary that ignores `__complete` can't mess with the user's controlling terminal. tested on both macos and nixos. encountered the problem while trying to use [visidata](https://www.visidata.org/). --------- Co-authored-by: shemishtamesh --- spec/cobra_complete.go | 14 +++++++++++++- spec/cobra_complete_test.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/spec/cobra_complete.go b/spec/cobra_complete.go index 8aeb19b..86afdc6 100644 --- a/spec/cobra_complete.go +++ b/spec/cobra_complete.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" "sync" + "syscall" "time" ) @@ -90,6 +91,16 @@ func buildCobraCacheKey(binKey string, args []string, partial string) string { return sb.String() } +// newProbeCmd builds and isolates the `__complete` probe command. +// starts the child in its own session so it has no controlling terminal +// and therefore won't affect the user's tty in the case of programs that +// don't respect `__complete`. +func newProbeCmd(ctx context.Context, binName string, args []string) *exec.Cmd { + cmd := exec.CommandContext(ctx, binName, args...) + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + return cmd +} + // QueryCobraComplete calls `binName __complete ` and returns // structured suggestions cached per binary mtime, args, and partial. // returns nil if the binary is not Cobra-based or times out. @@ -113,7 +124,8 @@ func QueryCobraComplete(binName string, args []string, partial string) []Suggest cmdArgs := append([]string{"__complete"}, args...) cmdArgs = append(cmdArgs, partial) - out, err := exec.CommandContext(ctx, binName, cmdArgs...).Output() + probe := newProbeCmd(ctx, binName, cmdArgs) + out, err := probe.Output() if err != nil { return nil } diff --git a/spec/cobra_complete_test.go b/spec/cobra_complete_test.go index 0100e66..1f21602 100644 --- a/spec/cobra_complete_test.go +++ b/spec/cobra_complete_test.go @@ -1,7 +1,13 @@ package spec import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" "testing" + "time" ) func TestParseCobraOutput_ValidCobra(t *testing.T) { @@ -131,6 +137,35 @@ func TestLookup_CobraKubectl(t *testing.T) { } } +func TestNewProbeCmd_Setsid(t *testing.T) { + cmd := newProbeCmd(context.Background(), "true", nil) + if cmd.SysProcAttr == nil || !cmd.SysProcAttr.Setsid { + t.Fatalf("expected probe command to set Setsid: true, got %+v", cmd.SysProcAttr) + } +} + +// TestNewProbeCmd_NoControllingTerminal writes a small script +// and runs it to check +// if newProbeCmd actually denies it access to tty +func TestNewProbeCmd_NoControllingTerminal(t *testing.T) { + dir := t.TempDir() + script := "#!/bin/sh\nexec 3<>/dev/tty" + + binPath := filepath.Join(dir, "ttyprobe") + if err := os.WriteFile(binPath, []byte(script), 0o755); err != nil { + t.Fatalf("failed to write probe script: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + runErr := newProbeCmd(ctx, binPath, nil).Run() + + var exitErr *exec.ExitError + if !errors.As(runErr, &exitErr) { + t.Fatalf("expected probe script to exit nonzero after failing to open /dev/tty, got %v", runErr) + } +} + func TestLookup_CobraGolangciLint(t *testing.T) { results := Lookup("golangci-lint ") if len(results) == 0 {