fix(gateway): validate PID ownership and clean stale pid files (#2422)

* fix(gateway): validate PID ownership and clean stale pid files

- include `pid` in health responses for runtime PID verification
- add `RemovePidFileIfPID` to safely delete PID files only on PID match
- sanitize gateway PID data via process-command checks with health fallback
- ignore and remove stale/non-gateway PID files before gateway operations
- refuse stop/restart actions when the attached process is not a gateway
- update gateway and websocket tests to cover PID validation and safety paths

* test(seahorse): use shared in-memory SQLite DB in tests to fix async compaction failures

* test: remove unused sendMediaErr field from hook test mock
This commit is contained in:
wenjie
2026-04-08 14:23:21 +08:00
committed by GitHub
parent ee29aaa871
commit 7d16764674
9 changed files with 528 additions and 28 deletions
-1
View File
@@ -515,7 +515,6 @@ type respondWithMediaHook struct {
media []string
responseHandled bool
forLLM string
sendMediaErr error
}
func (h *respondWithMediaHook) BeforeTool(
+3
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"maps"
"net/http"
"os"
"sync"
"time"
)
@@ -31,6 +32,7 @@ type Check struct {
type StatusResponse struct {
Status string `json:"status"`
Uptime string `json:"uptime"`
PID int `json:"pid,omitempty"`
Checks map[string]Check `json:"checks,omitempty"`
}
@@ -170,6 +172,7 @@ func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
resp := StatusResponse{
Status: "ok",
Uptime: uptime.String(),
PID: os.Getpid(),
}
json.NewEncoder(w).Encode(resp)
+24
View File
@@ -151,6 +151,30 @@ func RemovePidFile(homePath string) {
os.Remove(pidPath)
}
// RemovePidFileIfPID deletes the PID file only when the recorded PID matches
// expectedPID. It returns true when the file is removed successfully.
func RemovePidFileIfPID(homePath string, expectedPID int) bool {
if expectedPID <= 0 {
return false
}
pidMu.Lock()
defer pidMu.Unlock()
pidPath := pidFilePath(homePath)
data, err := readPidFileUnlocked(pidPath)
if err != nil {
return false
}
if data.PID != expectedPID {
return false
}
if err := os.Remove(pidPath); err != nil {
return false
}
return true
}
// readPidFileUnlocked reads the PID file without acquiring the lock.
// Caller must hold pidMu.
func readPidFileUnlocked(pidPath string) (*PidFileData, error) {
+34
View File
@@ -244,6 +244,40 @@ func TestRemovePidFileNonexistent(t *testing.T) {
RemovePidFile(dir)
}
func TestRemovePidFileIfPID(t *testing.T) {
dir := tmpDir(t)
other := PidFileData{PID: 99999999, Token: "deadbeef12345678deadbeef12345678"}
raw, _ := json.MarshalIndent(other, "", " ")
path := filepath.Join(dir, pidFileName)
os.WriteFile(path, raw, 0o600)
removed := RemovePidFileIfPID(dir, 99999999)
if !removed {
t.Fatal("expected RemovePidFileIfPID to remove matching pid file")
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Error("PID file should be removed for matching expected PID")
}
}
func TestRemovePidFileIfPIDMismatch(t *testing.T) {
dir := tmpDir(t)
other := PidFileData{PID: 99999999, Token: "deadbeef12345678deadbeef12345678"}
raw, _ := json.MarshalIndent(other, "", " ")
path := filepath.Join(dir, pidFileName)
os.WriteFile(path, raw, 0o600)
removed := RemovePidFileIfPID(dir, 88888888)
if removed {
t.Fatal("expected RemovePidFileIfPID to keep non-matching pid file")
}
if _, err := os.Stat(path); os.IsNotExist(err) {
t.Error("PID file should NOT be removed for mismatching expected PID")
}
}
// TestReadPidFileUnlockedInvalidJSON returns error for malformed content.
func TestReadPidFileUnlockedInvalidJSON(t *testing.T) {
dir := tmpDir(t)
+13 -1
View File
@@ -2,14 +2,26 @@ package seahorse
import (
"database/sql"
"fmt"
"strings"
"sync/atomic"
"testing"
_ "modernc.org/sqlite"
)
var testDBCounter uint64
func openTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", ":memory:")
n := atomic.AddUint64(&testDBCounter, 1)
testName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
// Use a shared in-memory database so concurrent goroutines/connections in tests
// observe the same schema/data.
dsn := fmt.Sprintf("file:seahorse_test_%s_%d?mode=memory&cache=shared", testName, n)
db, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatalf("open test db: %v", err)
}