diff --git a/internal/control/coexistence_linux.go b/internal/control/coexistence_linux.go index c22b3d7d..755ca9f9 100644 --- a/internal/control/coexistence_linux.go +++ b/internal/control/coexistence_linux.go @@ -94,7 +94,13 @@ ancestry: } } readers := append(slices.Clone(terminals), consoles...) - if reader, handled, err := attached_screen_reader("/proc", ancestors, uid, readers); handled { + lookup := s.screenReader + if lookup == nil { + lookup = func(ancestors []int, uid int, readers []instance) (instance, bool, error) { + return attached_screen_reader("/proc", ancestors, uid, readers) + } + } + if reader, handled, err := lookup(ancestors, uid, readers); handled { if err != nil { return "", err } diff --git a/internal/control/coexistence_test.go b/internal/control/coexistence_test.go index 203c8996..d3351155 100644 --- a/internal/control/coexistence_test.go +++ b/internal/control/coexistence_test.go @@ -14,6 +14,7 @@ import ( "os/exec" "path/filepath" "strings" + "syscall" "testing" "time" ) @@ -66,13 +67,54 @@ func TestControlProcess(t *testing.T) { func child_command(mode string) *exec.Cmd { command := exec.Command(os.Args[0], "-test.run=^TestControlProcess$") command.Env = append(os.Environ(), "FENRIR_CONTROL_PROCESS="+mode) + // A fixture must not inherit the developer's real VT as its controlling TTY. + command.SysProcAttr = &syscall.SysProcAttr{Setsid: true} return command } +// Screen daemons launched by a fixture may be reparented to PID 1 and never +// reach the coordinator; their full ancestry still belongs to that fixture. +func fixture_ancestors(ancestors []int, coordinator int) []int { + for i, pid := range ancestors { + if pid == coordinator { + return ancestors[:i] + } + } + return ancestors +} + +func TestFixtureScreenInspectionExcludesHostAndKeepsPrivateSessions(t *testing.T) { + for _, trial := range []struct { + name string + ancestors []int + handled bool + }{ + {"host Screen above coordinator", []int{120, 300, 100}, false}, + {"private Screen below coordinator", []int{120, 100, 300}, true}, + {"private Screen reparented to init", []int{120, 100}, true}, + {"coordinator caller", []int{300, 100}, false}, + } { + t.Run(trial.name, func(t *testing.T) { + f := new_screen_fixture(t) + f.attach(200, 300, "/dev/null", true) + reader, handled, err := attached_screen_reader(f.root, fixture_ancestors(trial.ancestors, 300), os.Geteuid(), []instance{{PID: 300}}) + if err != nil || handled != trial.handled || handled && reader.PID != 300 { + t.Fatalf("reader=%+v handled=%v err=%v; want handled=%v", reader, handled, err, trial.handled) + } + }) + } +} + func run_reader_process(mode string) { encoder := json.NewEncoder(os.Stdout) directory := os.Getenv("FENRIR_CONTROL_DIRECTORY") - server, err := Open(context.Background(), Options{Directory: directory, RegistryDirectory: filepath.Join(directory, "registry"), TerminalMode: mode == "terminal", WindowID: os.Getenv("WINDOWID")}) + // Processes above the test coordinator belong to the host, not this fixture. + // Screen sessions created by the native test remain inside the inspected tree. + coordinator := os.Getppid() + lookup := func(ancestors []int, uid int, readers []instance) (instance, bool, error) { + return attached_screen_reader("/proc", fixture_ancestors(ancestors, coordinator), uid, readers) + } + server, err := open_server(context.Background(), Options{Directory: directory, RegistryDirectory: filepath.Join(directory, "registry"), TerminalMode: mode == "terminal", WindowID: os.Getenv("WINDOWID")}, lookup) if err != nil { _ = encoder.Encode(processReply{Error: err.Error()}) return diff --git a/internal/control/server_linux.go b/internal/control/server_linux.go index 3cbed3ac..0c552c57 100644 --- a/internal/control/server_linux.go +++ b/internal/control/server_linux.go @@ -59,6 +59,8 @@ type endpoint struct { info os.FileInfo } +type screenLookup func(ancestors []int, uid int, readers []instance) (instance, bool, error) + type Server struct { ctx context.Context cancel context.CancelFunc @@ -69,6 +71,7 @@ type Server struct { workers sync.WaitGroup clients chan struct{} registryPath string + screenReader screenLookup stopOnce sync.Once done chan struct{} } @@ -81,8 +84,14 @@ func (s *Server) main_path() string { } func Open(ctx context.Context, options Options) (*Server, error) { + return open_server(ctx, options, nil) +} + +// Keep process inspection injectable so fixture readers can exclude the host's +// terminal ancestry while retaining real sockets, credentials and handoffs. +func open_server(ctx context.Context, options Options, lookup screenLookup) (*Server, error) { ctx, cancel := context.WithCancel(ctx) - s := &Server{ctx: ctx, cancel: cancel, options: options, requests: make(chan Request, 32), clients: make(chan struct{}, 8), done: make(chan struct{})} + s := &Server{ctx: ctx, cancel: cancel, options: options, screenReader: lookup, requests: make(chan Request, 32), clients: make(chan struct{}, 8), done: make(chan struct{})} paths := []string{filepath.Join(filepath.Dir(s.main_path()), fmt.Sprintf("fenrirscreenreader-%d.sock", os.Getpid())), s.main_path()} if options.SocketFile != "" { paths = []string{options.SocketFile}