diff --git a/.claude/worktrees/plan-review-edits b/.claude/worktrees/plan-review-edits deleted file mode 160000 index 98a3861..0000000 --- a/.claude/worktrees/plan-review-edits +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 98a3861bdde9e53022ba1b1808b4d20ec084ab96 diff --git a/.gitignore b/.gitignore index 5882b61..8d40a06 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,6 @@ coverage.* .DS_Store .idea/ .vscode/ +/.claude/worktrees/ /CLAUDE.md /PLAN.md diff --git a/internal/app/app.go b/internal/app/app.go index e78ac26..8bdea79 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -175,12 +175,6 @@ func (m Model) validateDefaultAgent(candidate string) string { } return candidate } -func NewWizard(st *store.Store, reg *adapter.Registry) Model { - m := NewWithDeps(st, reg) - m.wizard = true - return m -} - func (m Model) Init() tea.Cmd { return tea.Batch(m.loadSessionsCmd(), refreshTick(), peekTick(), prRefreshTick(100*time.Millisecond)) } @@ -1063,9 +1057,6 @@ func (m Model) resumeSelectedCmd() tea.Cmd { return m.loadSessionsCmd()() } } -func (m Model) stopSelectedCmd(remove bool) tea.Cmd { - return m.stopTargetCmd("", remove) -} // persistDefaultAgent persists the default-agent choice. On failure it surfaces // the error in the status line instead of swallowing it; on success it returns a @@ -1594,21 +1585,6 @@ func sessionGlyph(s adapter.Session) (string, lipgloss.Style) { } } -func stateGlyph(s adapter.State) (string, lipgloss.Style) { - switch s { - case adapter.Active: - return "⟳", liveGlyphStyle - case adapter.Failed: - return "✕", failGlyphStyle - default: - return "•", hintStyle - } -} - -func stateLabel(s adapter.State) string { - return strings.ToLower(string(s)) -} - // prStatusDot returns a distinct glyph per PR status (not color-only) so the PR // state survives a monochrome terminal or a screen scrape: open=hollow circle, // merged=filled circle, draft=half circle, closed=cross (F26). diff --git a/internal/app/app_cmd_test.go b/internal/app/app_cmd_test.go index 6a8f080..2aa67fb 100644 --- a/internal/app/app_cmd_test.go +++ b/internal/app/app_cmd_test.go @@ -80,9 +80,6 @@ func TestModelCommandFactories(t *testing.T) { if New().defaultAgent == "" { t.Fatal("New default empty") } - if !NewWizard(st, m.service.Registry).wizard { - t.Fatal("NewWizard not wizard") - } if m.Init() == nil { t.Fatal("Init returned nil") } @@ -98,7 +95,7 @@ func TestModelCommandFactories(t *testing.T) { if msg := m.pinSelectedCmd()(); msg.(sessionsLoadedMsg).err != nil { t.Fatalf("pin msg=%+v", msg) } - if msg := m.stopSelectedCmd(false)(); msg.(sessionsLoadedMsg).err != nil { + if msg := m.stopTargetCmd("", false)(); msg.(sessionsLoadedMsg).err != nil { t.Fatalf("stop msg=%+v", msg) } if msg := m.persistOrderCmd()(); msg.(sessionsLoadedMsg).err != nil { diff --git a/internal/app/app_more_test.go b/internal/app/app_more_test.go index ac630f7..f71dd74 100644 --- a/internal/app/app_more_test.go +++ b/internal/app/app_more_test.go @@ -114,7 +114,7 @@ func assertDispatchParsing(t *testing.T, m Model) { func assertViewHelpers(t *testing.T) { t.Helper() - if stateLabel(adapter.Active) != "active" || prStatusDot(adapter.PRMerged) == " " { + if prStatusDot(adapter.PRMerged) == " " { t.Fatal("status helpers bad") } if truncate("abcdef", 4) != "abc…" || trimLines("a\nb\nc", 2) != "b\nc" { @@ -431,9 +431,6 @@ func TestInputWindowAndStateBranches(t *testing.T) { if start < 0 || end < start || end > len(m.sessions) { t.Fatalf("bad window %d:%d", start, end) } - if stateLabel(adapter.Active) != "active" || stateLabel(adapter.Failed) != "failed" { - t.Fatal("state labels not covered") - } } func TestRenameEditingBranches(t *testing.T) { diff --git a/internal/app/loadsessions_test.go b/internal/app/loadsessions_test.go index 6754bda..bcd35e0 100644 --- a/internal/app/loadsessions_test.go +++ b/internal/app/loadsessions_test.go @@ -63,7 +63,9 @@ func TestLoadSessionsRefreshDoesNotClobberConcurrentPin(t *testing.T) { live := map[string]adapter.Session{store.Key("fake", liveA.ID): liveA} svc.mergeStoredSessions(live, cfgStale, time.Now()) updates := svc.refreshSessionRecords(context.Background(), live, &cfgStale) - svc.persistRefresh(updates) + if err := svc.persistRefresh(updates); err != nil { + t.Fatal(err) + } cfg, err := st.Load() if err != nil { diff --git a/internal/app/render_cluster_test.go b/internal/app/render_cluster_test.go index ef24bd0..75dbcac 100644 --- a/internal/app/render_cluster_test.go +++ b/internal/app/render_cluster_test.go @@ -178,9 +178,8 @@ func TestStateGlyphDistinguishesResumableFromFailed(t *testing.T) { if live == resumable { t.Fatalf("a live session and a resumable dead session must use distinct glyphs (both %q)", live) } - failGlyph, _ := stateGlyph(adapter.Failed) - if resumable == failGlyph { - t.Fatalf("a reboot-survivor resumable session must not use the red Failed glyph %q", failGlyph) + if resumable == "✕" { + t.Fatal("a reboot-survivor resumable session must not use the failure glyph") } _ = closed } @@ -192,9 +191,8 @@ func TestRenderRowResumableSessionNotMarkedFailed(t *testing.T) { if strings.Contains(strings.ToLower(row), "failed") { t.Fatalf("resumable row should not show the literal \"failed\": %q", row) } - failGlyph, _ := stateGlyph(adapter.Failed) - if strings.Contains(row, failGlyph) { - t.Fatalf("resumable row should not show the red Failed glyph %q: %q", failGlyph, row) + if strings.Contains(row, "✕") { + t.Fatalf("resumable row should not show the failure glyph: %q", row) } } diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 07012a2..1df5d85 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -26,16 +26,29 @@ import ( func Main() { flag.Usage = Usage flag.Parse() + args := flag.Args() + + // Help and version are deliberately independent from both the cache logger + // and the persistent store. They must remain usable when either location is + // unavailable (for example during installation diagnostics). + if handled, err := runBeforeLogger(args); handled { + if err != nil && !errors.Is(err, context.Canceled) { + fmt.Fprintf(os.Stderr, "uam: %v\n", err) + os.Exit(1) + } + return + } closer, err := log.Init() if err != nil { + log.UseStderr(os.Stderr) fmt.Fprintf(os.Stderr, "uam: failed to initialize logger: %v\n", err) - os.Exit(1) + } else { + defer func() { _ = closer.Close() }() } - defer func() { _ = closer.Close() }() ctx := context.Background() - if err := Run(ctx, flag.Args()); err != nil && !errors.Is(err, context.Canceled) { + if err := Run(ctx, args); err != nil && !errors.Is(err, context.Canceled) { log.Error("run exited with error", "err", err) fmt.Fprintf(os.Stderr, "uam: %v\n", err) os.Exit(1) @@ -69,6 +82,9 @@ func Run(ctx context.Context, args []string) error { // RunWithTUI executes the CLI with an injectable TUI runner for tests. func RunWithTUI(ctx context.Context, args []string, runTUI func(context.Context, tea.Model) error) error { + if handled, err := runWithoutStore(ctx, args); handled { + return err + } st, err := store.Open(store.DefaultPath()) if err != nil { return err @@ -86,6 +102,47 @@ func RunWithTUI(ctx context.Context, args []string, runTUI func(context.Context, return runCommand(ctx, svc, args, runTUI) } +// runBeforeLogger handles commands that have no cache, store, or session +// dependency. Main invokes it before log.Init; RunWithTUI reaches the same +// behavior through runWithoutStore when called directly by tests/embedders. +func runBeforeLogger(args []string) (bool, error) { + if len(args) == 0 { + return false, nil + } + switch args[0] { + case "-h", "--help", "help": + Usage() + return true, nil + case "version", "--version": + fmt.Println(version.String()) + return true, nil + default: + return false, nil + } +} + +// runWithoutStore handles commands whose behavior does not depend on +// sessions.json. Keeping these ahead of store.Open makes recovery commands +// available even when the config directory is damaged or unwritable. +func runWithoutStore(ctx context.Context, args []string) (bool, error) { + if handled, err := runBeforeLogger(args); handled { + return true, err + } + if len(args) == 0 { + return false, nil + } + switch args[0] { + case "kill-all": + return true, runKillAll(ctx, session.NewClient().KillAll) + case "__host": + return true, session.RunHost(args[1:]) + case "__attach": + return true, session.RunAttach(args[1:]) + default: + return false, nil + } +} + func runCommand(ctx context.Context, svc *app.Service, args []string, runTUI func(context.Context, tea.Model) error) error { switch args[0] { case "-h", "--help", "help": diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index c12addb..8f712cf 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -4,8 +4,10 @@ import ( "bytes" "context" "errors" + "flag" "io" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -147,6 +149,86 @@ func TestRunWithTUIHelpVersionAndDefault(t *testing.T) { } } +func TestRunWithTUIStateFreeCommandsDoNotOpenStore(t *testing.T) { + blocked := filepath.Join(t.TempDir(), "not-a-directory") + if err := os.WriteFile(blocked, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("UAM_CONFIG_DIR", blocked) + t.Setenv("UAM_SESSION_DIR", secureSessionDir(t)) + + for _, args := range [][]string{{"help"}, {"version"}, {"kill-all"}} { + if err := RunWithTUI(context.Background(), args, noopRunTUI); err != nil { + t.Fatalf("RunWithTUI(%q) opened the store: %v", args, err) + } + } + if err := RunWithTUI(context.Background(), []string{"__host", "--name", "bad name", "--", "/bin/true"}, noopRunTUI); err == nil || !strings.Contains(err.Error(), "invalid session name") { + t.Fatalf("__host must be routed before the store and return its own validation error, got %v", err) + } + if err := RunWithTUI(context.Background(), []string{"__attach"}, noopRunTUI); err == nil || !strings.Contains(err.Error(), "attach requires a session name") { + t.Fatalf("__attach must be routed before the store and return its own validation error, got %v", err) + } +} + +func TestMainStatelessCommandsSkipLoggerAndStore(t *testing.T) { + blocked := filepath.Join(t.TempDir(), "not-a-directory") + if err := os.WriteFile(blocked, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + for _, command := range []string{"help", "version"} { + cmd := cliMainSubprocess(t, command, blocked, blocked) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("uam %s: %v\n%s", command, err, output) + } + if strings.Contains(string(output), "failed to initialize logger") { + t.Fatalf("uam %s initialized the logger: %s", command, output) + } + } +} + +func TestMainFallsBackToStderrWhenFileLoggerFails(t *testing.T) { + blocked := filepath.Join(t.TempDir(), "not-a-directory") + if err := os.WriteFile(blocked, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + cmd := cliMainSubprocess(t, "kill-all", blocked, filepath.Join(t.TempDir(), "config")) + cmd.Env = append(cmd.Env, "UAM_SESSION_DIR="+secureSessionDir(t)) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("uam kill-all must continue with stderr logging: %v\n%s", err, output) + } + text := string(output) + if strings.Count(text, "failed to initialize logger") != 1 { + t.Fatalf("logger fallback warning count = %d, want 1: %s", strings.Count(text, "failed to initialize logger"), text) + } + if !strings.Contains(text, "all uam sessions stopped") { + t.Fatalf("kill-all did not run after logger fallback: %s", text) + } +} + +func TestCLIMainHelperProcess(t *testing.T) { + command := os.Getenv("UAM_TEST_MAIN_COMMAND") + if command == "" { + return + } + flag.CommandLine = flag.NewFlagSet("uam", flag.ContinueOnError) + os.Args = []string{"uam", command} + Main() + os.Exit(0) +} + +func cliMainSubprocess(t *testing.T, command, cacheDir, configDir string) *exec.Cmd { + t.Helper() + cmd := exec.Command(os.Args[0], "-test.run=^TestCLIMainHelperProcess$") + cmd.Env = append(os.Environ(), + "UAM_TEST_MAIN_COMMAND="+command, + "UAM_CACHE_DIR="+cacheDir, + "UAM_CONFIG_DIR="+configDir, + ) + return cmd +} + func TestRunCommandAttachLastAndNew(t *testing.T) { svc, fake := newCLITestService(t) id := dispatchAndCaptureID(t, svc, []string{"fake", "attachable"}) @@ -296,6 +378,15 @@ func writeCLIExecutable(t *testing.T, path string) { } } +func secureSessionDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + if err := os.Chmod(dir, 0o700); err != nil { + t.Fatal(err) + } + return dir +} + func must(t *testing.T, err error) { t.Helper() if err != nil { @@ -315,7 +406,7 @@ func firstNonEmpty(values ...string) string { // The internal __host/__attach subcommands are routed through runCommand; // invalid input must surface as errors rather than silently doing nothing. func TestRunCommandInternalSubcommands(t *testing.T) { - t.Setenv("UAM_SESSION_DIR", t.TempDir()) + t.Setenv("UAM_SESSION_DIR", secureSessionDir(t)) svc, _ := newCLITestService(t) noTUI := func(context.Context, tea.Model) error { return nil } if err := runCommand(context.Background(), svc, []string{"__host", "--name", "bad name", "--", "/bin/true"}, noTUI); err == nil { diff --git a/internal/cli/killall_test.go b/internal/cli/killall_test.go index b5bb76a..0ea7025 100644 --- a/internal/cli/killall_test.go +++ b/internal/cli/killall_test.go @@ -37,7 +37,7 @@ func TestRunKillAllPropagatesError(t *testing.T) { // real sessions: KillAll over zero sessions is an idempotent success. func TestRunCommandKillAllDispatches(t *testing.T) { svc, _ := newCLITestService(t) - t.Setenv("UAM_SESSION_DIR", t.TempDir()) + t.Setenv("UAM_SESSION_DIR", secureSessionDir(t)) out := captureCLIStdout(t, func() { if err := runCommand(context.Background(), svc, []string{"kill-all"}, func(context.Context, tea.Model) error { return nil }); err != nil { diff --git a/internal/log/log.go b/internal/log/log.go index fce104e..f9571d6 100644 --- a/internal/log/log.go +++ b/internal/log/log.go @@ -1,31 +1,161 @@ package log import ( - "fmt" "io" "log/slog" "os" "path/filepath" + "sync" +) + +const ( + maxLogSize = int64(5 << 20) + maxBackups = 3 ) var current *slog.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) +type rotatingFile struct { + mu sync.Mutex + path string + file *os.File + size int64 +} + func Init() (io.Closer, error) { dir := cacheDir() if err := os.MkdirAll(dir, 0o700); err != nil { return nil, err } - f, err := os.OpenFile(filepath.Join(dir, "uam.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) // #nosec G304 -- UAM intentionally writes its own cache log path. + path := filepath.Join(dir, "uam.log") + if err := rotateIfNeeded(path); err != nil { + return nil, err + } + f, err := openLogFile(path) + if err != nil { + return nil, err + } + if err := enforcePrivateLogModes(path); err != nil { + _ = f.Close() + return nil, err + } + info, err := f.Stat() if err != nil { + _ = f.Close() return nil, err } + w := &rotatingFile{path: path, file: f, size: info.Size()} + current = newLogger(w) + current.Info("logger initialized", "path", f.Name()) + return w, nil +} + +func openLogFile(path string) (*os.File, error) { + return os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) // #nosec G304 -- UAM intentionally writes its own cache log path. +} + +func (w *rotatingFile) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + if w.file == nil { + return 0, os.ErrClosed + } + if w.size > 0 && w.size+int64(len(p)) > maxLogSize { + if err := w.file.Close(); err != nil { + return 0, err + } + w.file = nil + if err := rotateLogs(w.path); err != nil { + return 0, err + } + f, err := openLogFile(w.path) + if err != nil { + return 0, err + } + w.file = f + w.size = 0 + if err := enforcePrivateLogModes(w.path); err != nil { + _ = w.file.Close() + w.file = nil + return 0, err + } + } + n, err := w.file.Write(p) + w.size += int64(n) + return n, err +} + +func (w *rotatingFile) Close() error { + w.mu.Lock() + defer w.mu.Unlock() + if w.file == nil { + return nil + } + err := w.file.Close() + w.file = nil + return err +} + +// UseStderr installs the non-file fallback used when Init cannot create or +// open the cache log. The caller remains responsible for printing the single +// initialization warning that explains why the fallback was selected. +func UseStderr(w io.Writer) { + if w == nil { + w = os.Stderr + } + current = newLogger(w) +} + +func newLogger(w io.Writer) *slog.Logger { level := slog.LevelInfo if os.Getenv("UAM_DEBUG") != "" { level = slog.LevelDebug } - current = slog.New(slog.NewTextHandler(f, &slog.HandlerOptions{Level: level})) - current.Info("logger initialized", "path", f.Name()) - return f, nil + return slog.New(slog.NewTextHandler(w, &slog.HandlerOptions{Level: level})) +} + +func rotateIfNeeded(path string) error { + info, err := os.Stat(path) + if os.IsNotExist(err) { + return enforcePrivateLogModes(path) + } + if err != nil { + return err + } + if info.Size() < maxLogSize { + return enforcePrivateLogModes(path) + } + return rotateLogs(path) +} + +func rotateLogs(path string) error { + if err := os.Remove(path + ".3"); err != nil && !os.IsNotExist(err) { + return err + } + for i := maxBackups - 1; i >= 1; i-- { + oldPath := path + "." + string(rune('0'+i)) + newPath := path + "." + string(rune('1'+i)) + if err := os.Rename(oldPath, newPath); err != nil && !os.IsNotExist(err) { + return err + } + } + if err := os.Rename(path, path+".1"); err != nil { + return err + } + return enforcePrivateLogModes(path) +} + +func enforcePrivateLogModes(path string) error { + for i := 0; i <= maxBackups; i++ { + candidate := path + if i > 0 { + candidate += "." + string(rune('0'+i)) + } + if err := os.Chmod(candidate, 0o600); err != nil && !os.IsNotExist(err) { + return err + } + } + return nil } func L() *slog.Logger { return current } @@ -57,9 +187,3 @@ func cacheDir() string { } return filepath.Join(".uam", "cache") } - -// Fatal prints to stderr and exits with code 1. -func Fatal(format string, args ...any) { - fmt.Fprintf(os.Stderr, "uam: "+format+"\n", args...) - os.Exit(1) -} diff --git a/internal/log/log_test.go b/internal/log/log_test.go index 7daaaaa..9af8b5f 100644 --- a/internal/log/log_test.go +++ b/internal/log/log_test.go @@ -3,6 +3,7 @@ package log import ( "os" "path/filepath" + "strings" "testing" ) @@ -39,6 +40,117 @@ func TestInitError(t *testing.T) { } } +func TestInitEnforcesPrivateLogPermissions(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "uam.log") + if err := os.WriteFile(path, []byte("old log\n"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("UAM_CACHE_DIR", dir) + c, err := Init() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := c.Close(); err != nil { + t.Errorf("close logger: %v", err) + } + }) + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("uam.log mode = %o, want 600", got) + } +} + +func TestInitRotatesOversizedLogAndRetainsThreePrivateBackups(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "uam.log") + if err := os.WriteFile(path, nil, 0o644); err != nil { + t.Fatal(err) + } + if err := os.Truncate(path, maxLogSize); err != nil { + t.Fatal(err) + } + for i, body := range []string{"backup-one", "backup-two", "backup-three"} { + if err := os.WriteFile(path+"."+string(rune('1'+i)), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + t.Setenv("UAM_CACHE_DIR", dir) + c, err := Init() + if err != nil { + t.Fatal(err) + } + if err := c.Close(); err != nil { + t.Fatal(err) + } + + wantBodies := map[string]string{ + path + ".2": "backup-one", + path + ".3": "backup-two", + } + for _, candidate := range []string{path, path + ".1", path + ".2", path + ".3"} { + info, err := os.Stat(candidate) + if err != nil { + t.Fatalf("stat %s: %v", candidate, err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("%s mode = %o, want 600", candidate, got) + } + if want, ok := wantBodies[candidate]; ok { + got, err := os.ReadFile(candidate) + if err != nil { + t.Fatal(err) + } + if string(got) != want { + t.Fatalf("%s = %q, want %q", candidate, got, want) + } + } + } + rotated, err := os.Stat(path + ".1") + if err != nil { + t.Fatal(err) + } + if rotated.Size() != maxLogSize { + t.Fatalf("rotated size = %d, want %d", rotated.Size(), maxLogSize) + } +} + +func TestLoggerRotatesWhenActiveLogCrossesSizeLimit(t *testing.T) { + dir := t.TempDir() + t.Setenv("UAM_CACHE_DIR", dir) + c, err := Init() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := c.Close(); err != nil { + t.Errorf("close logger: %v", err) + } + }) + + Info("fill", "data", strings.Repeat("x", int(maxLogSize-1024))) + Info("after rotation", "marker", "new-log", "padding", strings.Repeat("y", 2048)) + + rotated, err := os.ReadFile(filepath.Join(dir, "uam.log.1")) + if err != nil { + t.Fatalf("read rotated log: %v", err) + } + if !strings.Contains(string(rotated), "fill") { + t.Fatal("rotated log does not contain the pre-limit entry") + } + current, err := os.ReadFile(filepath.Join(dir, "uam.log")) + if err != nil { + t.Fatalf("read current log: %v", err) + } + if !strings.Contains(string(current), "new-log") { + t.Fatal("current log does not contain the post-rotation entry") + } +} + func TestCacheDirFallbacks(t *testing.T) { dir := t.TempDir() t.Setenv("UAM_CACHE_DIR", "") diff --git a/internal/session/client.go b/internal/session/client.go index d96b4c9..8b381cb 100644 --- a/internal/session/client.go +++ b/internal/session/client.go @@ -254,11 +254,18 @@ func (c *Client) Kill(ctx context.Context, name string) error { } // State exists but the socket path failed: the host is wedged, crashed, // or mid-shutdown. Escalate directly and wait for both processes to go. - // The start-time-verified probes matter most here: a recycled PID must - // never be signalled as if it were the session. - if st.hostAlive() { + // A live PID is not sufficient authority to signal: fallback control paths + // require a nonzero matching start identity so a recycled PID can never be + // signalled as if it were the session. + if ProcAlive(st.HostPID) { + if !procIdentityMatches(st.HostPID, st.HostStart) { + return fmt.Errorf("kill session %s: cannot verify process identity for host pid %d", name, st.HostPID) + } _ = syscall.Kill(st.HostPID, syscall.SIGTERM) - } else if st.childAlive() { + } else if ProcAlive(st.ChildPID) { + if !procIdentityMatches(st.ChildPID, st.ChildStart) { + return fmt.Errorf("kill session %s: cannot verify process identity for child pid %d", name, st.ChildPID) + } // Orphaned agent (host crashed): signal its process group directly. if err := syscall.Kill(-st.ChildPID, syscall.SIGTERM); err != nil { _ = syscall.Kill(st.ChildPID, syscall.SIGTERM) diff --git a/internal/session/host_inprocess_test.go b/internal/session/host_inprocess_test.go index 1a39ecb..3d824d4 100644 --- a/internal/session/host_inprocess_test.go +++ b/internal/session/host_inprocess_test.go @@ -3,6 +3,7 @@ package session import ( "bufio" "context" + "errors" "net" "os" "strings" @@ -20,14 +21,37 @@ func startInProcessHost(t *testing.T, c *Client, name, command string) chan erro if err != nil { t.Fatal(err) } + cwd := t.TempDir() done := make(chan error, 1) go func() { - done <- runHost(c.Dir, name, t.TempDir(), "label0", []string{"UAM_T=1"}, []string{"/bin/sh", "-c", command}, w) + defer func() { _ = w.Close() }() + done <- runHost(c.Dir, name, cwd, "label0", []string{"UAM_T=1"}, []string{"/bin/sh", "-c", command}, w) }() - line, err := bufio.NewReader(r).ReadString('\n') + type readyResult struct { + line string + err error + } + ready := make(chan readyResult, 1) + go func() { + line, err := bufio.NewReader(r).ReadString('\n') + ready <- readyResult{line: line, err: err} + }() + var result readyResult + select { + case result = <-ready: + case <-time.After(10 * time.Second): + _ = r.Close() + t.Fatal("timed out waiting for host readiness") + } _ = r.Close() - if err != nil || strings.TrimSpace(line) != "ok" { - t.Fatalf("host not ready: %q %v", line, err) + if result.err != nil || strings.TrimSpace(result.line) != "ok" { + var hostErr error + select { + case hostErr = <-done: + case <-time.After(time.Second): + hostErr = errors.New("host did not return after closing readiness pipe") + } + t.Fatalf("host not ready: %q (read: %v, host: %v)", result.line, result.err, hostErr) } return done } diff --git a/internal/session/liveness_test.go b/internal/session/liveness_test.go index 353ffd1..b311480 100644 --- a/internal/session/liveness_test.go +++ b/internal/session/liveness_test.go @@ -237,6 +237,41 @@ func TestProcAliveWithStartDetectsPIDReuse(t *testing.T) { } } +func TestProcIdentityMatchesRequiresRecordedAndCurrentIdentity(t *testing.T) { + pid := os.Getpid() + real := procStartTime(pid) + if real == 0 { + t.Skip("process start identity unavailable on this platform") + } + if !procIdentityMatches(pid, real) { + t.Fatal("matching nonzero process identity must be accepted") + } + if procIdentityMatches(pid, 0) { + t.Fatal("zero recorded identity must fail closed for signaling") + } + if procIdentityMatches(pid, real+1) { + t.Fatal("mismatched process identity must fail closed for signaling") + } +} + +func TestKillRefusesPIDFallbackWithoutVerifiedIdentity(t *testing.T) { + c := newTestClient(t) + if err := EnsureDir(c.Dir); err != nil { + t.Fatal(err) + } + name := "uam-fake-1d1d1d1d" + if err := writeState(c.Dir, State{Name: name, HostPID: os.Getpid(), HostStart: 0}); err != nil { + t.Fatal(err) + } + err := c.Kill(t.Context(), name) + if err == nil || !strings.Contains(err.Error(), "cannot verify process identity") { + t.Fatalf("Kill error = %v, want explicit process-identity safety error", err) + } + if !ProcAlive(os.Getpid()) { + t.Fatal("Kill signaled the test process despite missing identity") + } +} + // A stale state file whose PIDs were recycled by other processes (alive, but // with different start times) must be swept by List, not reported live. func TestListSweepsRecycledPIDState(t *testing.T) { diff --git a/internal/session/proc_start_darwin.go b/internal/session/proc_start_darwin.go new file mode 100644 index 0000000..2f2f1cd --- /dev/null +++ b/internal/session/proc_start_darwin.go @@ -0,0 +1,22 @@ +//go:build darwin + +package session + +import "golang.org/x/sys/unix" + +// procStartTime returns the process start timestamp in microseconds since the +// Unix epoch, or 0 when Darwin cannot provide a stable identity. +func procStartTime(pid int) int64 { + if pid <= 0 { + return 0 + } + info, err := unix.SysctlKinfoProc("kern.proc.pid", pid) + if err != nil || info == nil { + return 0 + } + started := info.Proc.P_starttime + if started.Sec <= 0 { + return 0 + } + return started.Sec*1_000_000 + int64(started.Usec) +} diff --git a/internal/session/proc_start_linux.go b/internal/session/proc_start_linux.go new file mode 100644 index 0000000..d23568b --- /dev/null +++ b/internal/session/proc_start_linux.go @@ -0,0 +1,38 @@ +//go:build linux + +package session + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +// procStartTime returns /proc//stat field 22 (clock ticks since boot), +// or 0 when the process identity cannot be read. +func procStartTime(pid int) int64 { + if pid <= 0 { + return 0 + } + data, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) + if err != nil { + return 0 + } + // comm (field 2) is parenthesized and may itself contain spaces or ')', + // so split after the LAST ')'. starttime is overall field 22, i.e. index + // 19 of the fields that follow comm. + rest := string(data) + if i := strings.LastIndexByte(rest, ')'); i >= 0 { + rest = rest[i+1:] + } + fields := strings.Fields(rest) + if len(fields) < 20 { + return 0 + } + v, err := strconv.ParseInt(fields[19], 10, 64) + if err != nil { + return 0 + } + return v +} diff --git a/internal/session/proc_start_other.go b/internal/session/proc_start_other.go new file mode 100644 index 0000000..9907f7c --- /dev/null +++ b/internal/session/proc_start_other.go @@ -0,0 +1,7 @@ +//go:build !linux && !darwin + +package session + +// procStartTime reports that stable process identity is unavailable. Display +// liveness remains permissive, while PID-based fallback signaling fails closed. +func procStartTime(int) int64 { return 0 } diff --git a/internal/session/session.go b/internal/session/session.go index c4f08c4..7cd53b8 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -15,7 +15,6 @@ import ( "path/filepath" "regexp" "strconv" - "strings" "syscall" ) @@ -115,9 +114,9 @@ func verifyDirIdentity(dir string) error { type State struct { Name string `json:"name"` HostPID int `json:"host_pid"` - // HostStart / ChildStart are the processes' kernel start times - // (/proc//stat field 22, in clock ticks since boot; 0 where - // unavailable). They disambiguate a recycled PID from the original + // HostStart / ChildStart are platform-specific stable process identities + // derived from kernel start times (0 where unavailable). They disambiguate + // a recycled PID from the original // process, so a stale state file can never make uam treat — or worse, // signal — an unrelated process as a session. HostStart int64 `json:"host_start,omitempty"` @@ -226,9 +225,10 @@ func ProcAlive(pid int) bool { } // procAliveWithStart is ProcAlive hardened against PID reuse: when a start -// time was recorded AND the live process's start time is readable, they must -// match. Where /proc is unavailable (e.g. macOS) either side reads as 0 and -// the check degrades to the plain signal-0 probe. +// identity was recorded AND the live process's identity is readable, they +// must match. This intentionally remains permissive for display compatibility: +// missing identity degrades to the plain signal-0 probe. Signaling paths use +// procIdentityMatches instead. func procAliveWithStart(pid int, start int64) bool { if !ProcAlive(pid) { return false @@ -240,32 +240,15 @@ func procAliveWithStart(pid int, start int64) bool { return current == 0 || current == start } -// procStartTime returns the kernel start time of pid (clock ticks since boot, -// /proc//stat field 22), or 0 when unavailable. -func procStartTime(pid int) int64 { - if pid <= 0 { - return 0 - } - data, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) - if err != nil { - return 0 - } - // comm (field 2) is parenthesized and may itself contain spaces or ')', - // so split after the LAST ')'. starttime is overall field 22, i.e. index - // 19 of the fields that follow comm. - rest := string(data) - if i := strings.LastIndexByte(rest, ')'); i >= 0 { - rest = rest[i+1:] - } - fields := strings.Fields(rest) - if len(fields) < 20 { - return 0 - } - v, err := strconv.ParseInt(fields[19], 10, 64) - if err != nil { - return 0 +// procIdentityMatches is the fail-closed process identity check used before +// PID-based fallback signaling. Both recorded and live identities must be +// available and equal; liveness alone is never authorization to signal. +func procIdentityMatches(pid int, recorded int64) bool { + if pid <= 0 || recorded == 0 || !ProcAlive(pid) { + return false } - return v + current := procStartTime(pid) + return current != 0 && current == recorded } // procCwd returns the live working directory of pid via /proc (Linux). On diff --git a/internal/session/session_test.go b/internal/session/session_test.go index e7251bb..5201d90 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -30,7 +30,15 @@ func TestMain(m *testing.M) { func newTestClient(t *testing.T) *Client { t.Helper() - dir := filepath.Join(t.TempDir(), "run") + dir, err := os.MkdirTemp("", "uam-test-") + if err != nil { + t.Fatal(err) + } + if err := os.Chmod(dir, 0o700); err != nil { + _ = os.RemoveAll(dir) + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) t.Setenv("UAM_CONFIG_DIR", filepath.Join(t.TempDir(), "cfg")) exe, err := os.Executable() if err != nil { diff --git a/internal/store/store.go b/internal/store/store.go index 2b143d0..732d825 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -147,9 +147,11 @@ func (c *Config) UnmarshalJSON(data []byte) error { } type UISettings struct { - GroupByDir bool `json:"group_by_dir"` - Sort string `json:"sort"` - PeekWidth int `json:"peek_width"` + GroupByDir bool `json:"group_by_dir"` + // Sort and PeekWidth are retained schema-v3 compatibility fields. They are + // normalized and round-tripped even when the TUI exposes no direct control. + Sort string `json:"sort"` + PeekWidth int `json:"peek_width"` } type SessionRecord struct {