sensorium-mcp 3.0.4 → 3.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/dashboard/routes/data.d.ts.map +1 -1
- package/dist/dashboard/routes/data.js +2 -1
- package/dist/dashboard/routes/data.js.map +1 -1
- package/dist/dashboard/routes/threads.js +1 -1
- package/dist/dashboard/routes/threads.js.map +1 -1
- package/dist/dashboard/routes.d.ts.map +1 -1
- package/dist/dashboard/routes.js +1 -3
- package/dist/dashboard/routes.js.map +1 -1
- package/dist/data/memory/migration-runner.d.ts +1 -1
- package/dist/data/memory/migration-runner.d.ts.map +1 -1
- package/dist/data/memory/migration-runner.js +59 -3
- package/dist/data/memory/migration-runner.js.map +1 -1
- package/dist/data/memory/schema-ddl.d.ts +1 -1
- package/dist/data/memory/schema-ddl.d.ts.map +1 -1
- package/dist/data/memory/schema-ddl.js +2 -1
- package/dist/data/memory/schema-ddl.js.map +1 -1
- package/dist/data/memory/thread-registry.js +1 -1
- package/dist/data/memory/thread-registry.js.map +1 -1
- package/dist/http-server.d.ts.map +1 -1
- package/dist/http-server.js +1 -9
- package/dist/http-server.js.map +1 -1
- package/dist/index.js +3 -6
- package/dist/index.js.map +1 -1
- package/dist/server/factory.js +1 -1
- package/dist/server/factory.js.map +1 -1
- package/dist/services/agent-spawn.service.d.ts +7 -1
- package/dist/services/agent-spawn.service.d.ts.map +1 -1
- package/dist/services/agent-spawn.service.js +69 -45
- package/dist/services/agent-spawn.service.js.map +1 -1
- package/dist/services/consolidation.service.d.ts.map +1 -1
- package/dist/services/consolidation.service.js +49 -35
- package/dist/services/consolidation.service.js.map +1 -1
- package/dist/services/keeper.service.d.ts +21 -0
- package/dist/services/keeper.service.d.ts.map +1 -0
- package/dist/services/keeper.service.js +195 -0
- package/dist/services/keeper.service.js.map +1 -0
- package/dist/services/maintenance-signal.d.ts +2 -0
- package/dist/services/maintenance-signal.d.ts.map +1 -1
- package/dist/services/maintenance-signal.js +7 -1
- package/dist/services/maintenance-signal.js.map +1 -1
- package/dist/services/process.service.d.ts +19 -2
- package/dist/services/process.service.d.ts.map +1 -1
- package/dist/services/process.service.js +104 -10
- package/dist/services/process.service.js.map +1 -1
- package/dist/services/thread-lifecycle.service.d.ts +5 -0
- package/dist/services/thread-lifecycle.service.d.ts.map +1 -1
- package/dist/services/thread-lifecycle.service.js +33 -8
- package/dist/services/thread-lifecycle.service.js.map +1 -1
- package/dist/services/worker-cleanup.service.d.ts +14 -1
- package/dist/services/worker-cleanup.service.d.ts.map +1 -1
- package/dist/services/worker-cleanup.service.js +36 -38
- package/dist/services/worker-cleanup.service.js.map +1 -1
- package/dist/sessions.d.ts +0 -5
- package/dist/sessions.d.ts.map +1 -1
- package/dist/sessions.js +0 -7
- package/dist/sessions.js.map +1 -1
- package/dist/stdio-server.d.ts.map +1 -1
- package/dist/stdio-server.js +1 -7
- package/dist/stdio-server.js.map +1 -1
- package/dist/tools/delegate-tool.d.ts.map +1 -1
- package/dist/tools/delegate-tool.js +2 -2
- package/dist/tools/delegate-tool.js.map +1 -1
- package/dist/tools/session-tools.js +1 -1
- package/dist/tools/session-tools.js.map +1 -1
- package/dist/tools/start-session-tool.d.ts.map +1 -1
- package/dist/tools/start-session-tool.js +8 -9
- package/dist/tools/start-session-tool.js.map +1 -1
- package/dist/tools/wait/message-processing.d.ts.map +1 -1
- package/dist/tools/wait/message-processing.js +28 -0
- package/dist/tools/wait/message-processing.js.map +1 -1
- package/dist/tools/wait/poll-loop.js +1 -1
- package/dist/tools/wait/poll-loop.js.map +1 -1
- package/package.json +1 -1
- package/dist/tools/thread-lifecycle.d.ts +0 -6
- package/dist/tools/thread-lifecycle.d.ts.map +0 -1
- package/dist/tools/thread-lifecycle.js +0 -6
- package/dist/tools/thread-lifecycle.js.map +0 -1
- package/supervisor/config.go +0 -253
- package/supervisor/config_test.go +0 -78
- package/supervisor/go.mod +0 -15
- package/supervisor/go.sum +0 -20
- package/supervisor/health.go +0 -433
- package/supervisor/health_test.go +0 -93
- package/supervisor/keeper.go +0 -309
- package/supervisor/keeper_test.go +0 -27
- package/supervisor/lock.go +0 -57
- package/supervisor/lock_test.go +0 -54
- package/supervisor/log.go +0 -195
- package/supervisor/log_test.go +0 -125
- package/supervisor/main.go +0 -475
- package/supervisor/main_test.go +0 -130
- package/supervisor/notify.go +0 -53
- package/supervisor/process.go +0 -294
- package/supervisor/process_test.go +0 -108
- package/supervisor/process_unix.go +0 -14
- package/supervisor/process_windows.go +0 -15
- package/supervisor/secrets.go +0 -95
- package/supervisor/secrets_securevault_test.go +0 -98
- package/supervisor/secrets_test.go +0 -119
- package/supervisor/self_update.go +0 -282
- package/supervisor/self_update_test.go +0 -177
- package/supervisor/service_restart_stub.go +0 -9
- package/supervisor/service_restart_windows.go +0 -63
- package/supervisor/service_stub.go +0 -15
- package/supervisor/service_windows.go +0 -194
- package/supervisor/update_state.go +0 -264
- package/supervisor/update_state_test.go +0 -306
- package/supervisor/updater.go +0 -613
- package/supervisor/updater_test.go +0 -64
package/supervisor/main_test.go
DELETED
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
package main
|
|
2
|
-
|
|
3
|
-
import (
|
|
4
|
-
"os"
|
|
5
|
-
"path/filepath"
|
|
6
|
-
"strings"
|
|
7
|
-
"testing"
|
|
8
|
-
)
|
|
9
|
-
|
|
10
|
-
func TestResolveRunSupervisorMode(t *testing.T) {
|
|
11
|
-
tests := []struct {
|
|
12
|
-
name string
|
|
13
|
-
processIsService bool
|
|
14
|
-
hostMode string
|
|
15
|
-
want bool
|
|
16
|
-
}{
|
|
17
|
-
{
|
|
18
|
-
name: "service process always forces service mode",
|
|
19
|
-
processIsService: true,
|
|
20
|
-
hostMode: "task",
|
|
21
|
-
want: true,
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
name: "non-service defaults to task mode",
|
|
25
|
-
processIsService: false,
|
|
26
|
-
hostMode: "",
|
|
27
|
-
want: false,
|
|
28
|
-
},
|
|
29
|
-
{
|
|
30
|
-
name: "non-service task remains task mode",
|
|
31
|
-
processIsService: false,
|
|
32
|
-
hostMode: "task",
|
|
33
|
-
want: false,
|
|
34
|
-
},
|
|
35
|
-
{
|
|
36
|
-
name: "non-service service mode is honored",
|
|
37
|
-
processIsService: false,
|
|
38
|
-
hostMode: "service",
|
|
39
|
-
want: true,
|
|
40
|
-
},
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
for _, tc := range tests {
|
|
44
|
-
t.Run(tc.name, func(t *testing.T) {
|
|
45
|
-
got := resolveRunSupervisorMode(tc.processIsService, tc.hostMode)
|
|
46
|
-
if got != tc.want {
|
|
47
|
-
t.Fatalf("resolveRunSupervisorMode(processIsService=%v, hostMode=%q) = %v, want %v", tc.processIsService, tc.hostMode, got, tc.want)
|
|
48
|
-
}
|
|
49
|
-
})
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
func TestRunSupervisor_DoesNotRecoverPersistedStateWhenWatcherLockNotAcquired(t *testing.T) {
|
|
54
|
-
tempHome := t.TempDir()
|
|
55
|
-
t.Setenv("USERPROFILE", tempHome)
|
|
56
|
-
t.Setenv("HOME", tempHome)
|
|
57
|
-
t.Setenv("MCP_HTTP_PORT", "7777")
|
|
58
|
-
t.Setenv("MCP_HTTP_SECRET", "test-secret")
|
|
59
|
-
t.Setenv("TELEGRAM_TOKEN", "test-token")
|
|
60
|
-
t.Setenv("TELEGRAM_CHAT_ID", "test-chat")
|
|
61
|
-
|
|
62
|
-
dataDir := filepath.Join(tempHome, ".remote-copilot-mcp")
|
|
63
|
-
log := NewLogger(filepath.Join(dataDir, "test.log"))
|
|
64
|
-
defer log.Close()
|
|
65
|
-
|
|
66
|
-
store := NewUpdateStateStore(filepath.Join(dataDir, "update-state.json"), log)
|
|
67
|
-
store.Transition(updateScopeMCP, updatePhaseApplying, "2.0.0", "1.0.0", "")
|
|
68
|
-
|
|
69
|
-
watcherLock := filepath.Join(dataDir, "watcher.lock")
|
|
70
|
-
if !AcquireLock(watcherLock, log) {
|
|
71
|
-
t.Fatal("failed to pre-acquire watcher lock")
|
|
72
|
-
}
|
|
73
|
-
defer ReleaseLock(watcherLock)
|
|
74
|
-
|
|
75
|
-
err := runSupervisor(false)
|
|
76
|
-
if err == nil {
|
|
77
|
-
t.Fatal("expected runSupervisor to fail when watcher lock is already held")
|
|
78
|
-
}
|
|
79
|
-
if !strings.Contains(err.Error(), "another supervisor instance") {
|
|
80
|
-
t.Fatalf("unexpected error: %v", err)
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
state, loadErr := store.Load()
|
|
84
|
-
if loadErr != nil {
|
|
85
|
-
t.Fatalf("failed to load update state: %v", loadErr)
|
|
86
|
-
}
|
|
87
|
-
if state.Phase != updatePhaseApplying {
|
|
88
|
-
t.Fatalf("phase = %q, want %q", state.Phase, updatePhaseApplying)
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
func TestRunSupervisor_DoesNotApplyPendingSupervisorUpdateWhenWatcherLockNotAcquired(t *testing.T) {
|
|
93
|
-
tempHome := t.TempDir()
|
|
94
|
-
t.Setenv("USERPROFILE", tempHome)
|
|
95
|
-
t.Setenv("HOME", tempHome)
|
|
96
|
-
t.Setenv("MCP_HTTP_PORT", "7777")
|
|
97
|
-
t.Setenv("MCP_HTTP_SECRET", "test-secret")
|
|
98
|
-
t.Setenv("TELEGRAM_TOKEN", "test-token")
|
|
99
|
-
t.Setenv("TELEGRAM_CHAT_ID", "test-chat")
|
|
100
|
-
|
|
101
|
-
dataDir := filepath.Join(tempHome, ".remote-copilot-mcp")
|
|
102
|
-
log := NewLogger(filepath.Join(dataDir, "test.log"))
|
|
103
|
-
defer log.Close()
|
|
104
|
-
|
|
105
|
-
pendingVersion := filepath.Join(dataDir, "bin", "sensorium-supervisor.new.exe.version")
|
|
106
|
-
if err := os.MkdirAll(filepath.Dir(pendingVersion), 0755); err != nil {
|
|
107
|
-
t.Fatalf("failed to create pending version directory: %v", err)
|
|
108
|
-
}
|
|
109
|
-
if err := os.WriteFile(pendingVersion, []byte("2.0.0"), 0644); err != nil {
|
|
110
|
-
t.Fatalf("failed to create stale pending version file: %v", err)
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
watcherLock := filepath.Join(dataDir, "watcher.lock")
|
|
114
|
-
if !AcquireLock(watcherLock, log) {
|
|
115
|
-
t.Fatal("failed to pre-acquire watcher lock")
|
|
116
|
-
}
|
|
117
|
-
defer ReleaseLock(watcherLock)
|
|
118
|
-
|
|
119
|
-
err := runSupervisor(false)
|
|
120
|
-
if err == nil {
|
|
121
|
-
t.Fatal("expected runSupervisor to fail when watcher lock is already held")
|
|
122
|
-
}
|
|
123
|
-
if !strings.Contains(err.Error(), "another supervisor instance") {
|
|
124
|
-
t.Fatalf("unexpected error: %v", err)
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
if _, statErr := os.Stat(pendingVersion); statErr != nil {
|
|
128
|
-
t.Fatalf("pending supervisor version file was unexpectedly modified: %v", statErr)
|
|
129
|
-
}
|
|
130
|
-
}
|
package/supervisor/notify.go
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
package main
|
|
2
|
-
|
|
3
|
-
import (
|
|
4
|
-
"context"
|
|
5
|
-
"fmt"
|
|
6
|
-
"net/http"
|
|
7
|
-
"net/url"
|
|
8
|
-
"strings"
|
|
9
|
-
"time"
|
|
10
|
-
)
|
|
11
|
-
|
|
12
|
-
// NotifyOperator sends a message via Telegram to the operator.
|
|
13
|
-
// Silently fails if credentials are not configured.
|
|
14
|
-
func NotifyOperator(cfg Config, log *Logger, text string, threadID int) {
|
|
15
|
-
if cfg.TelegramToken == "" || cfg.TelegramChatID == "" {
|
|
16
|
-
log.Debug("NotifyOperator: skipped (no Telegram credentials)")
|
|
17
|
-
return
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
log.Debug("NotifyOperator: sending to chat %s (threadID=%d)", cfg.TelegramChatID, threadID)
|
|
21
|
-
|
|
22
|
-
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
23
|
-
defer cancel()
|
|
24
|
-
|
|
25
|
-
apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", cfg.TelegramToken)
|
|
26
|
-
|
|
27
|
-
form := url.Values{}
|
|
28
|
-
form.Set("chat_id", cfg.TelegramChatID)
|
|
29
|
-
form.Set("text", text)
|
|
30
|
-
form.Set("parse_mode", "HTML")
|
|
31
|
-
if threadID > 0 {
|
|
32
|
-
form.Set("message_thread_id", fmt.Sprintf("%d", threadID))
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, strings.NewReader(form.Encode()))
|
|
36
|
-
if err != nil {
|
|
37
|
-
log.Warn("Telegram notify: %v", err)
|
|
38
|
-
return
|
|
39
|
-
}
|
|
40
|
-
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
41
|
-
|
|
42
|
-
resp, err := http.DefaultClient.Do(req)
|
|
43
|
-
if err != nil {
|
|
44
|
-
log.Warn("Telegram notify: %v", err)
|
|
45
|
-
return
|
|
46
|
-
}
|
|
47
|
-
defer resp.Body.Close()
|
|
48
|
-
if resp.StatusCode >= 400 {
|
|
49
|
-
log.Warn("Telegram notify: HTTP %d", resp.StatusCode)
|
|
50
|
-
} else {
|
|
51
|
-
log.Debug("Telegram notify: sent OK (HTTP %d)", resp.StatusCode)
|
|
52
|
-
}
|
|
53
|
-
}
|
package/supervisor/process.go
DELETED
|
@@ -1,294 +0,0 @@
|
|
|
1
|
-
package main
|
|
2
|
-
|
|
3
|
-
import (
|
|
4
|
-
"encoding/json"
|
|
5
|
-
"errors"
|
|
6
|
-
"fmt"
|
|
7
|
-
"os"
|
|
8
|
-
"os/exec"
|
|
9
|
-
"path/filepath"
|
|
10
|
-
"runtime"
|
|
11
|
-
"strconv"
|
|
12
|
-
"strings"
|
|
13
|
-
"syscall"
|
|
14
|
-
"time"
|
|
15
|
-
)
|
|
16
|
-
|
|
17
|
-
// SpawnMCPServer starts the MCP server as a detached child process.
|
|
18
|
-
// Returns the PID of the spawned process.
|
|
19
|
-
func SpawnMCPServer(cfg Config, log *Logger) (int, error) {
|
|
20
|
-
parts := strings.Fields(cfg.MCPStartCommand)
|
|
21
|
-
if len(parts) == 0 {
|
|
22
|
-
return 0, errors.New("empty MCP_START_COMMAND")
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
if err := os.MkdirAll(filepath.Dir(cfg.Paths.MCPStderrLog), 0755); err != nil {
|
|
26
|
-
return 0, fmt.Errorf("create MCP stderr log directory: %w", err)
|
|
27
|
-
}
|
|
28
|
-
stderrFile, err := os.OpenFile(cfg.Paths.MCPStderrLog, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
|
29
|
-
if err != nil {
|
|
30
|
-
return 0, fmt.Errorf("open MCP stderr log: %w", err)
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
cmd := exec.Command(parts[0], parts[1:]...)
|
|
34
|
-
cmd.Env = os.Environ()
|
|
35
|
-
for k, v := range cfg.ResolvedProfileEnv {
|
|
36
|
-
if v == "" {
|
|
37
|
-
continue
|
|
38
|
-
}
|
|
39
|
-
cmd.Env = upsertEnv(cmd.Env, k, v)
|
|
40
|
-
}
|
|
41
|
-
if cfg.MCPHttpPort > 0 {
|
|
42
|
-
cmd.Env = upsertEnv(cmd.Env, "MCP_HTTP_PORT", strconv.Itoa(cfg.MCPHttpPort))
|
|
43
|
-
}
|
|
44
|
-
if cfg.MCPHttpSecret != "" {
|
|
45
|
-
cmd.Env = upsertEnv(cmd.Env, "MCP_HTTP_SECRET", cfg.MCPHttpSecret)
|
|
46
|
-
}
|
|
47
|
-
if cfg.TelegramToken != "" {
|
|
48
|
-
cmd.Env = upsertEnv(cmd.Env, "TELEGRAM_TOKEN", cfg.TelegramToken)
|
|
49
|
-
}
|
|
50
|
-
if cfg.TelegramChatID != "" {
|
|
51
|
-
cmd.Env = upsertEnv(cmd.Env, "TELEGRAM_CHAT_ID", cfg.TelegramChatID)
|
|
52
|
-
}
|
|
53
|
-
cmd.Stdin = nil
|
|
54
|
-
cmd.Stdout = nil
|
|
55
|
-
cmd.Stderr = stderrFile
|
|
56
|
-
setSysProcAttr(cmd)
|
|
57
|
-
|
|
58
|
-
log.Info("Starting MCP server: %s", cfg.MCPStartCommand)
|
|
59
|
-
log.Info("Capturing MCP server stderr to %s", cfg.Paths.MCPStderrLog)
|
|
60
|
-
|
|
61
|
-
if err := cmd.Start(); err != nil {
|
|
62
|
-
_ = stderrFile.Close()
|
|
63
|
-
return 0, fmt.Errorf("spawn MCP server: %w", err)
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
pid := cmd.Process.Pid
|
|
67
|
-
log.Info("MCP server started with PID %d", pid)
|
|
68
|
-
|
|
69
|
-
// Don't wait — detached process
|
|
70
|
-
go func() {
|
|
71
|
-
_ = cmd.Wait()
|
|
72
|
-
_ = stderrFile.Close()
|
|
73
|
-
}()
|
|
74
|
-
|
|
75
|
-
if err := writePIDFile(cfg.Paths.ServerPID, pid); err != nil {
|
|
76
|
-
log.Warn("Failed to write server PID file: %v", err)
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
return pid, nil
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
func upsertEnv(env []string, key, value string) []string {
|
|
83
|
-
prefix := key + "="
|
|
84
|
-
for i, kv := range env {
|
|
85
|
-
if strings.HasPrefix(kv, prefix) {
|
|
86
|
-
env[i] = prefix + value
|
|
87
|
-
return env
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
return append(env, prefix+value)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// KillProcess kills a process by PID. On Windows, uses taskkill /F /T for tree kill.
|
|
94
|
-
func KillProcess(pid int, log *Logger) error {
|
|
95
|
-
if !IsProcessAlive(pid) {
|
|
96
|
-
log.Debug("KillProcess: PID %d already dead", pid)
|
|
97
|
-
return nil
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
log.Debug("KillProcess: killing PID %d", pid)
|
|
101
|
-
|
|
102
|
-
if runtime.GOOS == "windows" {
|
|
103
|
-
// taskkill /F /T kills the tree
|
|
104
|
-
out, err := exec.Command("taskkill", "/F", "/T", "/PID", strconv.Itoa(pid)).CombinedOutput()
|
|
105
|
-
if err != nil {
|
|
106
|
-
return fmt.Errorf("taskkill PID %d: %w (%s)", pid, err, strings.TrimSpace(string(out)))
|
|
107
|
-
}
|
|
108
|
-
log.Info("Killed process tree PID %d", pid)
|
|
109
|
-
return nil
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// Unix: SIGTERM, wait 2s, then SIGKILL
|
|
113
|
-
proc, err := os.FindProcess(pid)
|
|
114
|
-
if err != nil {
|
|
115
|
-
log.Debug("KillProcess: FindProcess(%d) failed: %v", pid, err)
|
|
116
|
-
return err
|
|
117
|
-
}
|
|
118
|
-
if err := proc.Signal(syscall.SIGTERM); err != nil {
|
|
119
|
-
// Already dead
|
|
120
|
-
return nil
|
|
121
|
-
}
|
|
122
|
-
time.Sleep(2 * time.Second)
|
|
123
|
-
if IsProcessAlive(pid) {
|
|
124
|
-
_ = proc.Kill()
|
|
125
|
-
log.Info("Force-killed PID %d", pid)
|
|
126
|
-
} else {
|
|
127
|
-
log.Info("Process PID %d terminated gracefully", pid)
|
|
128
|
-
}
|
|
129
|
-
return nil
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// KillByPort finds a process listening on the given port and kills it (Windows-only orphan cleanup).
|
|
133
|
-
func KillByPort(port int, log *Logger) {
|
|
134
|
-
if runtime.GOOS != "windows" || port <= 0 || port > 65535 {
|
|
135
|
-
return
|
|
136
|
-
}
|
|
137
|
-
log.Debug("KillByPort: checking for processes on port %d", port)
|
|
138
|
-
out, err := exec.Command("cmd", "/c", fmt.Sprintf("netstat -aon | findstr \":%d.*LISTENING\"", port)).CombinedOutput()
|
|
139
|
-
if err != nil {
|
|
140
|
-
log.Debug("KillByPort: no listeners on port %d", port)
|
|
141
|
-
return
|
|
142
|
-
}
|
|
143
|
-
for _, line := range strings.Split(string(out), "\n") {
|
|
144
|
-
fields := strings.Fields(strings.TrimSpace(line))
|
|
145
|
-
if len(fields) >= 5 {
|
|
146
|
-
pid, err := strconv.Atoi(fields[len(fields)-1])
|
|
147
|
-
if err == nil && pid > 0 {
|
|
148
|
-
log.Info("Found orphan PID %d on port %d — killing", pid, port)
|
|
149
|
-
_ = KillProcess(pid, log)
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// IsProcessAlive checks whether a process with the given PID exists.
|
|
156
|
-
func IsProcessAlive(pid int) bool {
|
|
157
|
-
if pid <= 0 {
|
|
158
|
-
return false
|
|
159
|
-
}
|
|
160
|
-
if runtime.GOOS == "windows" {
|
|
161
|
-
out, err := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid), "/NH").CombinedOutput()
|
|
162
|
-
if err != nil {
|
|
163
|
-
return false
|
|
164
|
-
}
|
|
165
|
-
return strings.Contains(string(out), strconv.Itoa(pid))
|
|
166
|
-
}
|
|
167
|
-
proc, err := os.FindProcess(pid)
|
|
168
|
-
if err != nil {
|
|
169
|
-
return false
|
|
170
|
-
}
|
|
171
|
-
return proc.Signal(syscall.Signal(0)) == nil // signal 0 = existence check on Unix
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// --- PID File Helpers ---
|
|
175
|
-
|
|
176
|
-
type pidJSON struct {
|
|
177
|
-
PID int `json:"pid"`
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// ReadPIDFile reads a PID from a file. Supports both JSON {"pid":123} and raw integer formats.
|
|
181
|
-
func ReadPIDFile(path string) (int, error) {
|
|
182
|
-
data, err := os.ReadFile(path)
|
|
183
|
-
if err != nil {
|
|
184
|
-
return 0, err
|
|
185
|
-
}
|
|
186
|
-
raw := strings.TrimSpace(string(data))
|
|
187
|
-
|
|
188
|
-
// Try JSON first
|
|
189
|
-
var pj pidJSON
|
|
190
|
-
if json.Unmarshal([]byte(raw), &pj) == nil && pj.PID > 0 {
|
|
191
|
-
return pj.PID, nil
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// Fallback: raw integer
|
|
195
|
-
pid, err := strconv.Atoi(raw)
|
|
196
|
-
if err != nil {
|
|
197
|
-
return 0, fmt.Errorf("invalid PID file content: %q", raw)
|
|
198
|
-
}
|
|
199
|
-
if pid <= 0 {
|
|
200
|
-
return 0, fmt.Errorf("invalid PID: %d", pid)
|
|
201
|
-
}
|
|
202
|
-
return pid, nil
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
func writePIDFile(path string, pid int) error {
|
|
206
|
-
dir := filepath.Dir(path)
|
|
207
|
-
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
208
|
-
return err
|
|
209
|
-
}
|
|
210
|
-
data, _ := json.Marshal(pidJSON{PID: pid})
|
|
211
|
-
return atomicWrite(path, data)
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// atomicWrite writes data to a temp file then renames — prevents partial reads.
|
|
215
|
-
func atomicWrite(path string, data []byte) error {
|
|
216
|
-
tmp := fmt.Sprintf("%s.tmp.%d", path, os.Getpid())
|
|
217
|
-
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
|
218
|
-
return err
|
|
219
|
-
}
|
|
220
|
-
return os.Rename(tmp, path)
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// ListThreadPIDs returns a map of threadId → PID from the pids directory.
|
|
224
|
-
func ListThreadPIDs(pidsDir string) map[string]int {
|
|
225
|
-
result := make(map[string]int)
|
|
226
|
-
entries, err := os.ReadDir(pidsDir)
|
|
227
|
-
if err != nil {
|
|
228
|
-
// directory may not exist yet
|
|
229
|
-
return result
|
|
230
|
-
}
|
|
231
|
-
for _, e := range entries {
|
|
232
|
-
if e.IsDir() || !strings.HasSuffix(e.Name(), ".pid") {
|
|
233
|
-
continue
|
|
234
|
-
}
|
|
235
|
-
threadID := strings.TrimSuffix(e.Name(), ".pid")
|
|
236
|
-
pid, err := ReadPIDFile(filepath.Join(pidsDir, e.Name()))
|
|
237
|
-
if err != nil {
|
|
238
|
-
continue
|
|
239
|
-
}
|
|
240
|
-
result[threadID] = pid
|
|
241
|
-
}
|
|
242
|
-
return result
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// KillOrphanThreads kills any alive processes listed in PID files (leftovers from
|
|
246
|
-
// a previous supervisor run) and then removes the PID files. Called on startup
|
|
247
|
-
// before the MCP server is launched, so all thread PIDs are guaranteed to be stale.
|
|
248
|
-
func KillOrphanThreads(pidsDir string, log *Logger) {
|
|
249
|
-
pids := ListThreadPIDs(pidsDir)
|
|
250
|
-
if len(pids) == 0 {
|
|
251
|
-
log.Debug("KillOrphanThreads: no PID files found")
|
|
252
|
-
return
|
|
253
|
-
}
|
|
254
|
-
log.Info("KillOrphanThreads: checking %d PID files for orphan processes", len(pids))
|
|
255
|
-
killed := 0
|
|
256
|
-
for threadID, pid := range pids {
|
|
257
|
-
path := filepath.Join(pidsDir, threadID+".pid")
|
|
258
|
-
if IsProcessAlive(pid) {
|
|
259
|
-
log.Info("Killing orphan thread %s (PID %d)", threadID, pid)
|
|
260
|
-
if err := KillProcess(pid, log); err != nil {
|
|
261
|
-
log.Warn("Failed to kill orphan PID %d: %v", pid, err)
|
|
262
|
-
}
|
|
263
|
-
killed++
|
|
264
|
-
}
|
|
265
|
-
_ = os.Remove(path)
|
|
266
|
-
}
|
|
267
|
-
if killed > 0 {
|
|
268
|
-
log.Info("KillOrphanThreads: killed %d orphan processes, cleaned %d PID files", killed, len(pids))
|
|
269
|
-
} else {
|
|
270
|
-
log.Info("KillOrphanThreads: no orphans found, cleaned %d stale PID files", len(pids))
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// CleanStalePIDs removes PID files for processes that are no longer running.
|
|
275
|
-
func CleanStalePIDs(pidsDir string, log *Logger) {
|
|
276
|
-
pids := ListThreadPIDs(pidsDir)
|
|
277
|
-
if len(pids) == 0 {
|
|
278
|
-
log.Debug("CleanStalePIDs: no PID files found in %s", pidsDir)
|
|
279
|
-
return
|
|
280
|
-
}
|
|
281
|
-
log.Debug("CleanStalePIDs: checking %d PID files", len(pids))
|
|
282
|
-
cleaned := 0
|
|
283
|
-
for threadID, pid := range pids {
|
|
284
|
-
if !IsProcessAlive(pid) {
|
|
285
|
-
path := filepath.Join(pidsDir, threadID+".pid")
|
|
286
|
-
log.Info("Removing stale PID file for thread %s (PID %d)", threadID, pid)
|
|
287
|
-
_ = os.Remove(path)
|
|
288
|
-
cleaned++
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
if cleaned > 0 {
|
|
292
|
-
log.Info("CleanStalePIDs: removed %d stale PID files", cleaned)
|
|
293
|
-
}
|
|
294
|
-
}
|
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
package main
|
|
2
|
-
|
|
3
|
-
import (
|
|
4
|
-
"os"
|
|
5
|
-
"path/filepath"
|
|
6
|
-
"testing"
|
|
7
|
-
)
|
|
8
|
-
|
|
9
|
-
func TestReadPIDFile_JSON(t *testing.T) {
|
|
10
|
-
dir := t.TempDir()
|
|
11
|
-
path := filepath.Join(dir, "test.pid")
|
|
12
|
-
os.WriteFile(path, []byte(`{"pid":12345}`), 0644)
|
|
13
|
-
|
|
14
|
-
pid, err := ReadPIDFile(path)
|
|
15
|
-
if err != nil {
|
|
16
|
-
t.Fatalf("unexpected error: %v", err)
|
|
17
|
-
}
|
|
18
|
-
if pid != 12345 {
|
|
19
|
-
t.Errorf("got %d, want 12345", pid)
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
func TestReadPIDFile_RawInt(t *testing.T) {
|
|
24
|
-
dir := t.TempDir()
|
|
25
|
-
path := filepath.Join(dir, "test.pid")
|
|
26
|
-
os.WriteFile(path, []byte("54321\n"), 0644)
|
|
27
|
-
|
|
28
|
-
pid, err := ReadPIDFile(path)
|
|
29
|
-
if err != nil {
|
|
30
|
-
t.Fatalf("unexpected error: %v", err)
|
|
31
|
-
}
|
|
32
|
-
if pid != 54321 {
|
|
33
|
-
t.Errorf("got %d, want 54321", pid)
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
func TestReadPIDFile_Invalid(t *testing.T) {
|
|
38
|
-
dir := t.TempDir()
|
|
39
|
-
path := filepath.Join(dir, "test.pid")
|
|
40
|
-
os.WriteFile(path, []byte("not-a-pid"), 0644)
|
|
41
|
-
|
|
42
|
-
_, err := ReadPIDFile(path)
|
|
43
|
-
if err == nil {
|
|
44
|
-
t.Fatal("expected error for invalid PID content")
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
func TestReadPIDFile_Missing(t *testing.T) {
|
|
49
|
-
_, err := ReadPIDFile(filepath.Join(t.TempDir(), "missing.pid"))
|
|
50
|
-
if err == nil {
|
|
51
|
-
t.Fatal("expected error for missing file")
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
func TestAtomicWrite(t *testing.T) {
|
|
56
|
-
dir := t.TempDir()
|
|
57
|
-
path := filepath.Join(dir, "data.txt")
|
|
58
|
-
|
|
59
|
-
if err := atomicWrite(path, []byte("hello")); err != nil {
|
|
60
|
-
t.Fatalf("atomicWrite failed: %v", err)
|
|
61
|
-
}
|
|
62
|
-
data, err := os.ReadFile(path)
|
|
63
|
-
if err != nil {
|
|
64
|
-
t.Fatalf("ReadFile failed: %v", err)
|
|
65
|
-
}
|
|
66
|
-
if string(data) != "hello" {
|
|
67
|
-
t.Errorf("got %q, want %q", string(data), "hello")
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
func TestListThreadPIDs(t *testing.T) {
|
|
72
|
-
dir := t.TempDir()
|
|
73
|
-
os.WriteFile(filepath.Join(dir, "1234.pid"), []byte(`{"pid":100}`), 0644)
|
|
74
|
-
os.WriteFile(filepath.Join(dir, "5678.pid"), []byte("200"), 0644)
|
|
75
|
-
os.WriteFile(filepath.Join(dir, "not-a-pid.txt"), []byte("300"), 0644)
|
|
76
|
-
|
|
77
|
-
result := ListThreadPIDs(dir)
|
|
78
|
-
if len(result) != 2 {
|
|
79
|
-
t.Fatalf("got %d entries, want 2", len(result))
|
|
80
|
-
}
|
|
81
|
-
if result["1234"] != 100 {
|
|
82
|
-
t.Errorf("result[1234] = %d, want 100", result["1234"])
|
|
83
|
-
}
|
|
84
|
-
if result["5678"] != 200 {
|
|
85
|
-
t.Errorf("result[5678] = %d, want 200", result["5678"])
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
func TestListThreadPIDs_MissingDir(t *testing.T) {
|
|
90
|
-
result := ListThreadPIDs(filepath.Join(t.TempDir(), "no-such-dir"))
|
|
91
|
-
if len(result) != 0 {
|
|
92
|
-
t.Errorf("expected empty map for missing directory, got %d entries", len(result))
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
func TestUpsertEnv_ReplacesExistingAndAppendsMissing(t *testing.T) {
|
|
97
|
-
env := []string{"A=1", "B=2"}
|
|
98
|
-
|
|
99
|
-
env = upsertEnv(env, "B", "updated")
|
|
100
|
-
if env[1] != "B=updated" {
|
|
101
|
-
t.Fatalf("expected existing key to be replaced, got %q", env[1])
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
env = upsertEnv(env, "C", "3")
|
|
105
|
-
if env[len(env)-1] != "C=3" {
|
|
106
|
-
t.Fatalf("expected missing key to be appended, got %q", env[len(env)-1])
|
|
107
|
-
}
|
|
108
|
-
}
|
package/supervisor/secrets.go
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
package main
|
|
2
|
-
|
|
3
|
-
import (
|
|
4
|
-
"errors"
|
|
5
|
-
"fmt"
|
|
6
|
-
"os"
|
|
7
|
-
"strconv"
|
|
8
|
-
|
|
9
|
-
"github.com/zalando/go-keyring"
|
|
10
|
-
|
|
11
|
-
sv "github.com/andriyshevchenko/SecureVault/securevault-go"
|
|
12
|
-
)
|
|
13
|
-
|
|
14
|
-
const defaultKeyringService = "sensorium-supervisor"
|
|
15
|
-
|
|
16
|
-
var keyringGet = keyring.Get
|
|
17
|
-
|
|
18
|
-
// resolveSecretWithKeyring returns environment value first, then keyring fallback.
|
|
19
|
-
// If both sources are unavailable, it returns an empty string.
|
|
20
|
-
func resolveSecretWithKeyring(envKey, keyringService string) string {
|
|
21
|
-
if v := os.Getenv(envKey); v != "" {
|
|
22
|
-
return v
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
if keyringService == "" {
|
|
26
|
-
return ""
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
secret, err := keyringGet(keyringService, envKey)
|
|
30
|
-
if err != nil {
|
|
31
|
-
if errors.Is(err, keyring.ErrNotFound) {
|
|
32
|
-
return ""
|
|
33
|
-
}
|
|
34
|
-
fmt.Fprintf(os.Stderr, "WARN: keyring lookup failed for %s (service=%s): %v\n", envKey, keyringService, err)
|
|
35
|
-
return ""
|
|
36
|
-
}
|
|
37
|
-
return secret
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// resolveIntWithKeyring parses an integer value from env first, then keyring fallback.
|
|
41
|
-
// If parsing fails or no value exists, it returns fallback.
|
|
42
|
-
func resolveIntWithKeyring(envKey, keyringService string, fallback int) int {
|
|
43
|
-
v := resolveSecretWithKeyring(envKey, keyringService)
|
|
44
|
-
if v == "" {
|
|
45
|
-
return fallback
|
|
46
|
-
}
|
|
47
|
-
parsed, err := strconv.Atoi(v)
|
|
48
|
-
if err != nil {
|
|
49
|
-
fmt.Fprintf(os.Stderr, "WARN: invalid integer for %s: %q\n", envKey, v)
|
|
50
|
-
return fallback
|
|
51
|
-
}
|
|
52
|
-
return parsed
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// resolveFromSecureVault looks up envKey in the named SecureVault profile.
|
|
56
|
-
// baseDir may be empty (defaults to %LOCALAPPDATA%\SecureVault).
|
|
57
|
-
// Returns empty string on any error, with a warning for unexpected failures.
|
|
58
|
-
func resolveFromSecureVault(profileName, envKey, baseDir string) string {
|
|
59
|
-
store := sv.NewStore(baseDir)
|
|
60
|
-
val, err := store.ResolveKey(profileName, envKey)
|
|
61
|
-
if err != nil {
|
|
62
|
-
if errors.Is(err, sv.ErrNotFound) || errors.Is(err, sv.ErrUnsupportedPlatform) {
|
|
63
|
-
return ""
|
|
64
|
-
}
|
|
65
|
-
fmt.Fprintf(os.Stderr, "WARN: securevault lookup failed for %s (profile=%s): %v\n", envKey, profileName, err)
|
|
66
|
-
return ""
|
|
67
|
-
}
|
|
68
|
-
return val
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// resolveStringChain resolves a string config key using the chain:
|
|
72
|
-
// environment variable → SecureVault profile → OS keyring → empty string.
|
|
73
|
-
func resolveStringChain(envKey, svProfile, svBaseDir, keyringService string) string {
|
|
74
|
-
if v := os.Getenv(envKey); v != "" {
|
|
75
|
-
return v
|
|
76
|
-
}
|
|
77
|
-
if v := resolveFromSecureVault(svProfile, envKey, svBaseDir); v != "" {
|
|
78
|
-
return v
|
|
79
|
-
}
|
|
80
|
-
return resolveSecretWithKeyring(envKey, keyringService)
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// resolveIntChain resolves an integer config key using the same chain as resolveStringChain.
|
|
84
|
-
func resolveIntChain(envKey, svProfile, svBaseDir, keyringService string, fallback int) int {
|
|
85
|
-
v := resolveStringChain(envKey, svProfile, svBaseDir, keyringService)
|
|
86
|
-
if v == "" {
|
|
87
|
-
return fallback
|
|
88
|
-
}
|
|
89
|
-
parsed, err := strconv.Atoi(v)
|
|
90
|
-
if err != nil {
|
|
91
|
-
fmt.Fprintf(os.Stderr, "WARN: invalid integer for %s: %q\n", envKey, v)
|
|
92
|
-
return fallback
|
|
93
|
-
}
|
|
94
|
-
return parsed
|
|
95
|
-
}
|