mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
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:
@@ -515,7 +515,6 @@ type respondWithMediaHook struct {
|
||||
media []string
|
||||
responseHandled bool
|
||||
forLLM string
|
||||
sendMediaErr error
|
||||
}
|
||||
|
||||
func (h *respondWithMediaHook) BeforeTool(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user