sensorium-mcp 3.0.0 → 3.0.2
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/Install-Sensorium.ps1 +30 -6
- 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 +9 -1
- package/dist/http-server.js.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/services/agent-spawn.service.d.ts.map +1 -1
- package/dist/services/agent-spawn.service.js +12 -5
- package/dist/services/agent-spawn.service.js.map +1 -1
- package/dist/services/reconnect-snapshot.service.d.ts +30 -0
- package/dist/services/reconnect-snapshot.service.d.ts.map +1 -0
- package/dist/services/reconnect-snapshot.service.js +83 -0
- package/dist/services/reconnect-snapshot.service.js.map +1 -0
- package/dist/services/thread-lifecycle.service.js +1 -1
- package/dist/services/thread-lifecycle.service.js.map +1 -1
- package/dist/services/worker-cleanup.service.d.ts.map +1 -1
- package/dist/services/worker-cleanup.service.js +30 -7
- package/dist/services/worker-cleanup.service.js.map +1 -1
- package/dist/sessions.d.ts +5 -0
- package/dist/sessions.d.ts.map +1 -1
- package/dist/sessions.js +7 -0
- package/dist/sessions.js.map +1 -1
- package/dist/stdio-server.d.ts.map +1 -1
- package/dist/stdio-server.js +7 -1
- package/dist/stdio-server.js.map +1 -1
- package/dist/tools/delegate-tool.d.ts.map +1 -1
- package/dist/tools/delegate-tool.js +1 -0
- package/dist/tools/delegate-tool.js.map +1 -1
- package/dist/tools/start-session-tool.d.ts.map +1 -1
- package/dist/tools/start-session-tool.js +10 -19
- package/dist/tools/start-session-tool.js.map +1 -1
- package/package.json +1 -1
- package/supervisor/config.go +1 -1
- package/supervisor/health.go +19 -45
- package/supervisor/health_test.go +0 -29
- package/supervisor/keeper.go +27 -26
- package/supervisor/lock.go +1 -0
- package/supervisor/main.go +0 -4
- package/supervisor/service_windows.go +98 -120
- package/supervisor/updater.go +36 -11
package/supervisor/keeper.go
CHANGED
|
@@ -114,6 +114,7 @@ func (k *Keeper) run(ctx context.Context) {
|
|
|
114
114
|
k.log.Warn("Thread %d (worker %d) is stuck (no heartbeat for %v) — restarting", k.cfg.ThreadID, activeThreadID, k.global.StuckThreshold)
|
|
115
115
|
// Kill via MCP API, then fall through to restart
|
|
116
116
|
k.killThread(ctx, activeThreadID)
|
|
117
|
+
retryCount = 0
|
|
117
118
|
activeThreadID = k.cfg.ThreadID // reset — will get new worker ID on restart
|
|
118
119
|
} else {
|
|
119
120
|
// Healthy — reset counters
|
|
@@ -129,7 +130,31 @@ func (k *Keeper) run(ctx context.Context) {
|
|
|
129
130
|
}
|
|
130
131
|
}
|
|
131
132
|
|
|
132
|
-
// Thread is not running (or was stuck and killed)
|
|
133
|
+
// Thread is not running (or was stuck and killed).
|
|
134
|
+
// Detect fast-exit: if the previous successful start was recent, the thread exited too quickly.
|
|
135
|
+
if !lastStartTime.IsZero() && time.Since(lastStartTime) < k.global.FastExitThreshold {
|
|
136
|
+
fastExitCount++
|
|
137
|
+
if fastExitCount >= k.global.FastExitMaxCount {
|
|
138
|
+
cooldown := time.Duration(float64(k.global.FastExitBaseCooldown) * math.Pow(2, float64(fastExitEscalation)))
|
|
139
|
+
if cooldown > k.global.FastExitMaxCooldown {
|
|
140
|
+
cooldown = k.global.FastExitMaxCooldown
|
|
141
|
+
}
|
|
142
|
+
k.log.Warn("Thread %d: %d consecutive fast exits — backing off %v", k.cfg.ThreadID, fastExitCount, cooldown)
|
|
143
|
+
if k.onDeath != nil {
|
|
144
|
+
k.onDeath(k.cfg.ThreadID, k.cfg.SessionName+" (repeated fast exits — check credits/API key)")
|
|
145
|
+
}
|
|
146
|
+
fastExitEscalation++
|
|
147
|
+
k.sleep(ctx, cooldown)
|
|
148
|
+
fastExitCount = 0
|
|
149
|
+
retryCount = 0
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
} else if !lastStartTime.IsZero() {
|
|
153
|
+
// Previous start was long ago — not a fast exit pattern, reset counters
|
|
154
|
+
fastExitCount = 0
|
|
155
|
+
fastExitEscalation = 0
|
|
156
|
+
}
|
|
157
|
+
|
|
133
158
|
if retryCount >= k.cfg.MaxRetries {
|
|
134
159
|
cooldown := k.global.KeeperCooldown
|
|
135
160
|
if k.cfg.CooldownMs > 0 {
|
|
@@ -157,10 +182,10 @@ func (k *Keeper) run(ctx context.Context) {
|
|
|
157
182
|
return
|
|
158
183
|
}
|
|
159
184
|
|
|
160
|
-
lastStartTime = time.Now()
|
|
161
185
|
ok, workerID := k.callStartThread(ctx)
|
|
162
186
|
|
|
163
187
|
if ok {
|
|
188
|
+
lastStartTime = time.Now()
|
|
164
189
|
if workerID > 0 {
|
|
165
190
|
activeThreadID = workerID
|
|
166
191
|
k.log.Info("Thread %d start_thread succeeded (worker %d)", k.cfg.ThreadID, workerID)
|
|
@@ -168,31 +193,7 @@ func (k *Keeper) run(ctx context.Context) {
|
|
|
168
193
|
k.log.Info("Thread %d start_thread succeeded", k.cfg.ThreadID)
|
|
169
194
|
}
|
|
170
195
|
retryCount = 0
|
|
171
|
-
// Check for fast exit on next check
|
|
172
196
|
} else {
|
|
173
|
-
// Check for fast exit pattern
|
|
174
|
-
if !lastStartTime.IsZero() && time.Since(lastStartTime) < k.global.FastExitThreshold {
|
|
175
|
-
fastExitCount++
|
|
176
|
-
if fastExitCount >= k.global.FastExitMaxCount {
|
|
177
|
-
cooldown := time.Duration(float64(k.global.FastExitBaseCooldown) * math.Pow(2, float64(fastExitEscalation)))
|
|
178
|
-
if cooldown > k.global.FastExitMaxCooldown {
|
|
179
|
-
cooldown = k.global.FastExitMaxCooldown
|
|
180
|
-
}
|
|
181
|
-
k.log.Warn("Thread %d: %d consecutive fast exits — backing off %v", k.cfg.ThreadID, fastExitCount, cooldown)
|
|
182
|
-
if k.onDeath != nil {
|
|
183
|
-
k.onDeath(k.cfg.ThreadID, k.cfg.SessionName+" (repeated fast exits — check credits/API key)")
|
|
184
|
-
}
|
|
185
|
-
fastExitEscalation++
|
|
186
|
-
k.sleep(ctx, cooldown)
|
|
187
|
-
fastExitCount = 0
|
|
188
|
-
retryCount = 0
|
|
189
|
-
return
|
|
190
|
-
}
|
|
191
|
-
} else {
|
|
192
|
-
fastExitCount = 0
|
|
193
|
-
fastExitEscalation = 0
|
|
194
|
-
}
|
|
195
|
-
|
|
196
197
|
retryCount++
|
|
197
198
|
delay := k.backoff(retryCount)
|
|
198
199
|
k.log.Info("Backing off %v before next attempt", delay)
|
package/supervisor/lock.go
CHANGED
package/supervisor/main.go
CHANGED
|
@@ -395,10 +395,6 @@ func fetchKeeperSettings(ctx context.Context, mcp *MCPClient, log *Logger) ([]Ke
|
|
|
395
395
|
continue
|
|
396
396
|
}
|
|
397
397
|
|
|
398
|
-
if typ, _ := r["type"].(string); typ == "worker" {
|
|
399
|
-
continue
|
|
400
|
-
}
|
|
401
|
-
|
|
402
398
|
// Skip non-active roots (archived, expired, exited)
|
|
403
399
|
if status, _ := r["status"].(string); status != "" && status != "active" {
|
|
404
400
|
continue
|
|
@@ -5,6 +5,7 @@ package main
|
|
|
5
5
|
import (
|
|
6
6
|
"fmt"
|
|
7
7
|
"os"
|
|
8
|
+
"path/filepath"
|
|
8
9
|
"time"
|
|
9
10
|
|
|
10
11
|
"golang.org/x/sys/windows/svc"
|
|
@@ -58,159 +59,136 @@ func runAsService() error {
|
|
|
58
59
|
return svc.Run(serviceName, &supervisorService{})
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
func
|
|
62
|
+
func withServiceManager(fn func(*mgr.Mgr) error) error {
|
|
62
63
|
m, err := mgr.Connect()
|
|
63
64
|
if err != nil {
|
|
64
|
-
return
|
|
65
|
+
return err
|
|
65
66
|
}
|
|
66
67
|
defer m.Disconnect()
|
|
68
|
+
return fn(m)
|
|
69
|
+
}
|
|
67
70
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
s.
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
func installService(exePath, serviceUser, servicePassword string) error {
|
|
72
|
+
return withServiceManager(func(m *mgr.Mgr) error {
|
|
73
|
+
s, err := m.OpenService(serviceName)
|
|
74
|
+
if err == nil {
|
|
75
|
+
s.Close()
|
|
76
|
+
return fmt.Errorf("install failed: service %q already exists", serviceName)
|
|
77
|
+
}
|
|
73
78
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
79
|
+
cfg := mgr.Config{
|
|
80
|
+
DisplayName: serviceDisplay,
|
|
81
|
+
Description: serviceDesc,
|
|
82
|
+
StartType: mgr.StartAutomatic,
|
|
83
|
+
DelayedAutoStart: true,
|
|
84
|
+
}
|
|
85
|
+
if serviceUser != "" {
|
|
86
|
+
cfg.ServiceStartName = serviceUser
|
|
87
|
+
cfg.Password = servicePassword
|
|
88
|
+
if servicePassword == "" {
|
|
89
|
+
fmt.Printf("Installing service as passwordless identity %q\n", serviceUser)
|
|
90
|
+
} else {
|
|
91
|
+
fmt.Printf("Installing service as user %q\n", serviceUser)
|
|
92
|
+
}
|
|
85
93
|
} else {
|
|
86
|
-
fmt.
|
|
94
|
+
fmt.Println("Installing service as LocalSystem (default). Use -service-user to run as a specific user account.")
|
|
87
95
|
}
|
|
88
|
-
} else {
|
|
89
|
-
fmt.Println("Installing service as LocalSystem (default). Use -service-user to run as a specific user account.")
|
|
90
|
-
}
|
|
91
96
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
+
s, err = m.CreateService(serviceName, exePath, cfg)
|
|
98
|
+
if err != nil {
|
|
99
|
+
return fmt.Errorf("install failed: create service: %w", err)
|
|
100
|
+
}
|
|
101
|
+
defer s.Close()
|
|
97
102
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
103
|
+
fmt.Printf("Service %q installed successfully.\n", serviceName)
|
|
104
|
+
fmt.Printf("Start it with: %s start\n", filepath.Base(exePath))
|
|
105
|
+
return nil
|
|
106
|
+
})
|
|
101
107
|
}
|
|
102
108
|
|
|
103
109
|
func uninstallService() error {
|
|
104
|
-
m
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
s, err := m.OpenService(serviceName)
|
|
111
|
-
if err != nil {
|
|
112
|
-
return fmt.Errorf("uninstall failed: service %q not found: %w", serviceName, err)
|
|
113
|
-
}
|
|
114
|
-
defer s.Close()
|
|
110
|
+
return withServiceManager(func(m *mgr.Mgr) error {
|
|
111
|
+
s, err := m.OpenService(serviceName)
|
|
112
|
+
if err != nil {
|
|
113
|
+
return fmt.Errorf("uninstall failed: service %q not found: %w", serviceName, err)
|
|
114
|
+
}
|
|
115
|
+
defer s.Close()
|
|
115
116
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
if err := s.Delete(); err != nil {
|
|
118
|
+
return fmt.Errorf("uninstall failed: delete service: %w", err)
|
|
119
|
+
}
|
|
119
120
|
|
|
120
|
-
|
|
121
|
-
|
|
121
|
+
fmt.Printf("Service %q uninstalled.\n", serviceName)
|
|
122
|
+
return nil
|
|
123
|
+
})
|
|
122
124
|
}
|
|
123
125
|
|
|
124
126
|
func startService() error {
|
|
125
|
-
m
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
s, err := m.OpenService(serviceName)
|
|
132
|
-
if err != nil {
|
|
133
|
-
return fmt.Errorf("start failed: service %q not found: %w", serviceName, err)
|
|
134
|
-
}
|
|
135
|
-
defer s.Close()
|
|
127
|
+
return withServiceManager(func(m *mgr.Mgr) error {
|
|
128
|
+
s, err := m.OpenService(serviceName)
|
|
129
|
+
if err != nil {
|
|
130
|
+
return fmt.Errorf("start failed: service %q not found: %w", serviceName, err)
|
|
131
|
+
}
|
|
132
|
+
defer s.Close()
|
|
136
133
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
134
|
+
if err := s.Start(); err != nil {
|
|
135
|
+
return fmt.Errorf("start failed: %w", err)
|
|
136
|
+
}
|
|
140
137
|
|
|
141
|
-
|
|
142
|
-
|
|
138
|
+
fmt.Printf("Service %q started.\n", serviceName)
|
|
139
|
+
return nil
|
|
140
|
+
})
|
|
143
141
|
}
|
|
144
142
|
|
|
145
143
|
func stopService() error {
|
|
146
|
-
m
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
s, err := m.OpenService(serviceName)
|
|
153
|
-
if err != nil {
|
|
154
|
-
return fmt.Errorf("stop failed: service %q not found: %w", serviceName, err)
|
|
155
|
-
}
|
|
156
|
-
defer s.Close()
|
|
144
|
+
return withServiceManager(func(m *mgr.Mgr) error {
|
|
145
|
+
s, err := m.OpenService(serviceName)
|
|
146
|
+
if err != nil {
|
|
147
|
+
return fmt.Errorf("stop failed: service %q not found: %w", serviceName, err)
|
|
148
|
+
}
|
|
149
|
+
defer s.Close()
|
|
157
150
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
151
|
+
if _, err := s.Control(svc.Stop); err != nil {
|
|
152
|
+
return fmt.Errorf("stop failed: %w", err)
|
|
153
|
+
}
|
|
161
154
|
|
|
162
|
-
|
|
163
|
-
|
|
155
|
+
fmt.Printf("Service %q stopping.\n", serviceName)
|
|
156
|
+
return nil
|
|
157
|
+
})
|
|
164
158
|
}
|
|
165
159
|
|
|
166
160
|
func serviceStatus() error {
|
|
167
|
-
m
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
s, err := m.OpenService(serviceName)
|
|
174
|
-
if err != nil {
|
|
175
|
-
return fmt.Errorf("status failed: service %q not found: %w", serviceName, err)
|
|
176
|
-
}
|
|
177
|
-
defer s.Close()
|
|
161
|
+
return withServiceManager(func(m *mgr.Mgr) error {
|
|
162
|
+
s, err := m.OpenService(serviceName)
|
|
163
|
+
if err != nil {
|
|
164
|
+
return fmt.Errorf("status failed: service %q not found: %w", serviceName, err)
|
|
165
|
+
}
|
|
166
|
+
defer s.Close()
|
|
178
167
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
168
|
+
st, err := s.Query()
|
|
169
|
+
if err != nil {
|
|
170
|
+
return fmt.Errorf("status failed: query service: %w", err)
|
|
171
|
+
}
|
|
183
172
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
173
|
+
states := map[svc.State]string{
|
|
174
|
+
svc.Stopped: "Stopped",
|
|
175
|
+
svc.StartPending: "StartPending",
|
|
176
|
+
svc.StopPending: "StopPending",
|
|
177
|
+
svc.Running: "Running",
|
|
178
|
+
svc.ContinuePending: "ContinuePending",
|
|
179
|
+
svc.PausePending: "PausePending",
|
|
180
|
+
svc.Paused: "Paused",
|
|
181
|
+
}
|
|
182
|
+
state, ok := states[st.State]
|
|
183
|
+
if !ok {
|
|
184
|
+
state = fmt.Sprintf("Unknown(%d)", st.State)
|
|
185
|
+
}
|
|
197
186
|
|
|
198
|
-
|
|
199
|
-
|
|
187
|
+
fmt.Printf("Service %q: %s\n", serviceName, state)
|
|
188
|
+
return nil
|
|
189
|
+
})
|
|
200
190
|
}
|
|
201
191
|
|
|
202
192
|
func isWindowsService() (bool, error) {
|
|
203
193
|
return svc.IsWindowsService()
|
|
204
194
|
}
|
|
205
|
-
|
|
206
|
-
func filepathBase(path string) string {
|
|
207
|
-
if path == "" {
|
|
208
|
-
return serviceName
|
|
209
|
-
}
|
|
210
|
-
for i := len(path) - 1; i >= 0; i-- {
|
|
211
|
-
if path[i] == '\\' || path[i] == '/' {
|
|
212
|
-
return path[i+1:]
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
return path
|
|
216
|
-
}
|
package/supervisor/updater.go
CHANGED
|
@@ -7,6 +7,7 @@ import (
|
|
|
7
7
|
"io"
|
|
8
8
|
"net/http"
|
|
9
9
|
"os"
|
|
10
|
+
"os/exec"
|
|
10
11
|
"path/filepath"
|
|
11
12
|
"runtime"
|
|
12
13
|
"strings"
|
|
@@ -432,6 +433,9 @@ func (u *Updater) checkSupervisorUpdate(ctx context.Context) {
|
|
|
432
433
|
u.state.Transition(updateScopeSupervisor, updatePhaseStaged, remote, local, "")
|
|
433
434
|
notifyUpdaterOperator(u.cfg, u.log, fmt.Sprintf("⚙️ Supervisor: downloaded %s. Restarting supervisor to apply update...", remote), 0)
|
|
434
435
|
|
|
436
|
+
// Reset start time so minimum uptime is re-enforced after restart
|
|
437
|
+
u.startAt = time.Now()
|
|
438
|
+
|
|
435
439
|
isService, err := isWindowsService()
|
|
436
440
|
if err != nil {
|
|
437
441
|
markFailed(err)
|
|
@@ -450,7 +454,7 @@ func (u *Updater) checkSupervisorUpdate(ctx context.Context) {
|
|
|
450
454
|
return
|
|
451
455
|
}
|
|
452
456
|
|
|
453
|
-
if err :=
|
|
457
|
+
if err := requestSupervisorRestart(u.log); err != nil {
|
|
454
458
|
markFailed(err)
|
|
455
459
|
u.log.Error("Failed to signal supervisor for restart: %v", err)
|
|
456
460
|
notifyUpdaterOperator(u.cfg, u.log, "🔴 Supervisor: update downloaded but restart signal failed.", 0)
|
|
@@ -498,14 +502,6 @@ func (u *Updater) downloadSupervisorBinary(ctx context.Context, downloadURL stri
|
|
|
498
502
|
return fmt.Errorf("downloaded empty binary")
|
|
499
503
|
}
|
|
500
504
|
|
|
501
|
-
info, err := os.Stat(tmpPath)
|
|
502
|
-
if err != nil {
|
|
503
|
-
return err
|
|
504
|
-
}
|
|
505
|
-
if info.Size() <= 0 {
|
|
506
|
-
return fmt.Errorf("downloaded binary has invalid size %d", info.Size())
|
|
507
|
-
}
|
|
508
|
-
|
|
509
505
|
if err := os.Remove(u.cfg.Paths.PendingBinary); err != nil && !os.IsNotExist(err) {
|
|
510
506
|
return err
|
|
511
507
|
}
|
|
@@ -513,7 +509,7 @@ func (u *Updater) downloadSupervisorBinary(ctx context.Context, downloadURL stri
|
|
|
513
509
|
return err
|
|
514
510
|
}
|
|
515
511
|
|
|
516
|
-
u.log.Info("Supervisor binary downloaded to %s (%d bytes)", u.cfg.Paths.PendingBinary,
|
|
512
|
+
u.log.Info("Supervisor binary downloaded to %s (%d bytes)", u.cfg.Paths.PendingBinary, written)
|
|
517
513
|
return nil
|
|
518
514
|
}
|
|
519
515
|
|
|
@@ -525,6 +521,35 @@ func signalSelf(sig os.Signal) error {
|
|
|
525
521
|
return proc.Signal(sig)
|
|
526
522
|
}
|
|
527
523
|
|
|
524
|
+
func requestSupervisorRestart(log *Logger) error {
|
|
525
|
+
if runtime.GOOS != "windows" {
|
|
526
|
+
return signalSelf(syscall.SIGTERM)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
exePath, err := os.Executable()
|
|
530
|
+
if err != nil {
|
|
531
|
+
return fmt.Errorf("resolve executable path: %w", err)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
cmd := exec.Command(exePath)
|
|
535
|
+
cmd.Env = os.Environ()
|
|
536
|
+
setSysProcAttr(cmd)
|
|
537
|
+
if err := cmd.Start(); err != nil {
|
|
538
|
+
return fmt.Errorf("start replacement supervisor: %w", err)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if log != nil {
|
|
542
|
+
log.Info("Spawned replacement supervisor process PID %d", cmd.Process.Pid)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
go func() {
|
|
546
|
+
time.Sleep(2 * time.Second)
|
|
547
|
+
os.Exit(0)
|
|
548
|
+
}()
|
|
549
|
+
|
|
550
|
+
return nil
|
|
551
|
+
}
|
|
552
|
+
|
|
528
553
|
func (u *Updater) killServer() {
|
|
529
554
|
u.log.Info("Updater: stopping current MCP server for update")
|
|
530
555
|
pid, err := ReadPIDFile(u.cfg.Paths.ServerPID)
|
|
@@ -570,7 +595,7 @@ func (u *Updater) clearNpxCache() {
|
|
|
570
595
|
}
|
|
571
596
|
pkgDir := filepath.Join(base, e.Name(), "node_modules", "sensorium-mcp")
|
|
572
597
|
// Validate path doesn't escape base directory
|
|
573
|
-
if !strings.HasPrefix(pkgDir, base) {
|
|
598
|
+
if !strings.HasPrefix(pkgDir, base+string(os.PathSeparator)) {
|
|
574
599
|
continue
|
|
575
600
|
}
|
|
576
601
|
if _, err := os.Stat(pkgDir); err == nil {
|