sensorium-mcp 2.17.27 → 3.0.0

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.
Files changed (224) hide show
  1. package/Install-Sensorium.ps1 +327 -0
  2. package/README.md +14 -0
  3. package/dist/config.d.ts +16 -1
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +39 -2
  6. package/dist/config.js.map +1 -1
  7. package/dist/daily-session.d.ts +2 -1
  8. package/dist/daily-session.d.ts.map +1 -1
  9. package/dist/daily-session.js +23 -26
  10. package/dist/daily-session.js.map +1 -1
  11. package/dist/dashboard/routes/settings.d.ts +4 -0
  12. package/dist/dashboard/routes/settings.d.ts.map +1 -1
  13. package/dist/dashboard/routes/settings.js +57 -1
  14. package/dist/dashboard/routes/settings.js.map +1 -1
  15. package/dist/dashboard/routes/threads.d.ts +1 -0
  16. package/dist/dashboard/routes/threads.d.ts.map +1 -1
  17. package/dist/dashboard/routes/threads.js +23 -27
  18. package/dist/dashboard/routes/threads.js.map +1 -1
  19. package/dist/dashboard/routes.d.ts.map +1 -1
  20. package/dist/dashboard/routes.js +7 -2
  21. package/dist/dashboard/routes.js.map +1 -1
  22. package/dist/dashboard/spa.html +11 -11
  23. package/dist/data/interfaces.d.ts +36 -0
  24. package/dist/data/interfaces.d.ts.map +1 -0
  25. package/dist/data/interfaces.js +2 -0
  26. package/dist/data/interfaces.js.map +1 -0
  27. package/dist/data/memory/bootstrap.d.ts +36 -16
  28. package/dist/data/memory/bootstrap.d.ts.map +1 -1
  29. package/dist/data/memory/bootstrap.js +71 -217
  30. package/dist/data/memory/bootstrap.js.map +1 -1
  31. package/dist/data/memory/consolidation.d.ts +35 -34
  32. package/dist/data/memory/consolidation.d.ts.map +1 -1
  33. package/dist/data/memory/consolidation.js +43 -555
  34. package/dist/data/memory/consolidation.js.map +1 -1
  35. package/dist/data/memory/index.d.ts +0 -1
  36. package/dist/data/memory/index.d.ts.map +1 -1
  37. package/dist/data/memory/index.js +0 -1
  38. package/dist/data/memory/index.js.map +1 -1
  39. package/dist/data/memory/migration-runner.d.ts +5 -0
  40. package/dist/data/memory/migration-runner.d.ts.map +1 -0
  41. package/dist/data/memory/migration-runner.js +403 -0
  42. package/dist/data/memory/migration-runner.js.map +1 -0
  43. package/dist/data/memory/reflection.js +1 -1
  44. package/dist/data/memory/schema-ddl.d.ts +4 -0
  45. package/dist/data/memory/schema-ddl.d.ts.map +1 -0
  46. package/dist/data/memory/schema-ddl.js +194 -0
  47. package/dist/data/memory/schema-ddl.js.map +1 -0
  48. package/dist/data/memory/schema-guard.d.ts +3 -0
  49. package/dist/data/memory/schema-guard.d.ts.map +1 -0
  50. package/dist/data/memory/schema-guard.js +184 -0
  51. package/dist/data/memory/schema-guard.js.map +1 -0
  52. package/dist/data/memory/schema.d.ts +2 -5
  53. package/dist/data/memory/schema.d.ts.map +1 -1
  54. package/dist/data/memory/schema.js +6 -834
  55. package/dist/data/memory/schema.js.map +1 -1
  56. package/dist/data/memory/semantic.d.ts +0 -1
  57. package/dist/data/memory/semantic.d.ts.map +1 -1
  58. package/dist/data/memory/semantic.js +2 -8
  59. package/dist/data/memory/semantic.js.map +1 -1
  60. package/dist/data/memory/synthesis.js +2 -2
  61. package/dist/data/memory/synthesis.js.map +1 -1
  62. package/dist/data/memory/thread-registry.d.ts +18 -4
  63. package/dist/data/memory/thread-registry.d.ts.map +1 -1
  64. package/dist/data/memory/thread-registry.js +25 -0
  65. package/dist/data/memory/thread-registry.js.map +1 -1
  66. package/dist/data/sent-message.repository.d.ts +12 -0
  67. package/dist/data/sent-message.repository.d.ts.map +1 -0
  68. package/dist/data/sent-message.repository.js +31 -0
  69. package/dist/data/sent-message.repository.js.map +1 -0
  70. package/dist/http-server.d.ts.map +1 -1
  71. package/dist/http-server.js +23 -2
  72. package/dist/http-server.js.map +1 -1
  73. package/dist/index.js +27 -48
  74. package/dist/index.js.map +1 -1
  75. package/dist/logger.d.ts +7 -2
  76. package/dist/logger.d.ts.map +1 -1
  77. package/dist/logger.js +89 -12
  78. package/dist/logger.js.map +1 -1
  79. package/dist/scheduler.d.ts +8 -0
  80. package/dist/scheduler.d.ts.map +1 -1
  81. package/dist/scheduler.js +15 -0
  82. package/dist/scheduler.js.map +1 -1
  83. package/dist/server/factory.d.ts +2 -1
  84. package/dist/server/factory.d.ts.map +1 -1
  85. package/dist/server/factory.js +11 -4
  86. package/dist/server/factory.js.map +1 -1
  87. package/dist/services/agent-spawn.service.d.ts +39 -0
  88. package/dist/services/agent-spawn.service.d.ts.map +1 -0
  89. package/dist/services/agent-spawn.service.js +348 -0
  90. package/dist/services/agent-spawn.service.js.map +1 -0
  91. package/dist/services/background-runner.d.ts +26 -0
  92. package/dist/services/background-runner.d.ts.map +1 -0
  93. package/dist/services/background-runner.js +71 -0
  94. package/dist/services/background-runner.js.map +1 -0
  95. package/dist/services/consolidation.service.d.ts +16 -0
  96. package/dist/services/consolidation.service.d.ts.map +1 -0
  97. package/dist/services/consolidation.service.js +508 -0
  98. package/dist/services/consolidation.service.js.map +1 -0
  99. package/dist/services/dispatcher/broker.d.ts +2 -0
  100. package/dist/services/dispatcher/broker.d.ts.map +1 -1
  101. package/dist/services/dispatcher/broker.js +5 -10
  102. package/dist/services/dispatcher/broker.js.map +1 -1
  103. package/dist/services/dispatcher/index.d.ts +1 -1
  104. package/dist/services/dispatcher/index.d.ts.map +1 -1
  105. package/dist/services/dispatcher/index.js +1 -1
  106. package/dist/services/dispatcher/index.js.map +1 -1
  107. package/dist/services/dispatcher/lock.d.ts.map +1 -1
  108. package/dist/services/dispatcher/lock.js +7 -11
  109. package/dist/services/dispatcher/lock.js.map +1 -1
  110. package/dist/services/maintenance-signal.d.ts +18 -0
  111. package/dist/services/maintenance-signal.d.ts.map +1 -0
  112. package/dist/services/maintenance-signal.js +48 -0
  113. package/dist/services/maintenance-signal.js.map +1 -0
  114. package/dist/services/memory-briefing.service.d.ts +4 -0
  115. package/dist/services/memory-briefing.service.d.ts.map +1 -0
  116. package/dist/services/memory-briefing.service.js +143 -0
  117. package/dist/services/memory-briefing.service.js.map +1 -0
  118. package/dist/services/process.service.d.ts +31 -0
  119. package/dist/services/process.service.d.ts.map +1 -0
  120. package/dist/services/process.service.js +100 -0
  121. package/dist/services/process.service.js.map +1 -0
  122. package/dist/services/thread-health.service.d.ts +18 -0
  123. package/dist/services/thread-health.service.d.ts.map +1 -0
  124. package/dist/services/thread-health.service.js +118 -0
  125. package/dist/services/thread-health.service.js.map +1 -0
  126. package/dist/services/thread-lifecycle.service.d.ts +52 -0
  127. package/dist/services/thread-lifecycle.service.d.ts.map +1 -0
  128. package/dist/services/thread-lifecycle.service.js +174 -0
  129. package/dist/services/thread-lifecycle.service.js.map +1 -0
  130. package/dist/services/topic.service.d.ts +25 -0
  131. package/dist/services/topic.service.d.ts.map +1 -0
  132. package/dist/services/topic.service.js +65 -0
  133. package/dist/services/topic.service.js.map +1 -0
  134. package/dist/services/worker-cleanup.service.d.ts +8 -0
  135. package/dist/services/worker-cleanup.service.d.ts.map +1 -0
  136. package/dist/services/worker-cleanup.service.js +82 -0
  137. package/dist/services/worker-cleanup.service.js.map +1 -0
  138. package/dist/sessions.d.ts +14 -0
  139. package/dist/sessions.d.ts.map +1 -1
  140. package/dist/sessions.js +55 -0
  141. package/dist/sessions.js.map +1 -1
  142. package/dist/telegram.d.ts +13 -6
  143. package/dist/telegram.d.ts.map +1 -1
  144. package/dist/telegram.js +43 -14
  145. package/dist/telegram.js.map +1 -1
  146. package/dist/tools/defs/memory-defs.d.ts.map +1 -1
  147. package/dist/tools/defs/memory-defs.js +0 -19
  148. package/dist/tools/defs/memory-defs.js.map +1 -1
  149. package/dist/tools/delegate-tool.d.ts +4 -0
  150. package/dist/tools/delegate-tool.d.ts.map +1 -1
  151. package/dist/tools/delegate-tool.js +48 -109
  152. package/dist/tools/delegate-tool.js.map +1 -1
  153. package/dist/tools/memory-tools.d.ts.map +1 -1
  154. package/dist/tools/memory-tools.js +1 -16
  155. package/dist/tools/memory-tools.js.map +1 -1
  156. package/dist/tools/shared-agent-utils.d.ts +9 -1
  157. package/dist/tools/shared-agent-utils.d.ts.map +1 -1
  158. package/dist/tools/shared-agent-utils.js +24 -42
  159. package/dist/tools/shared-agent-utils.js.map +1 -1
  160. package/dist/tools/start-session-tool.d.ts +2 -0
  161. package/dist/tools/start-session-tool.d.ts.map +1 -1
  162. package/dist/tools/start-session-tool.js +66 -106
  163. package/dist/tools/start-session-tool.js.map +1 -1
  164. package/dist/tools/thread-lifecycle.d.ts +5 -127
  165. package/dist/tools/thread-lifecycle.d.ts.map +1 -1
  166. package/dist/tools/thread-lifecycle.js +5 -1163
  167. package/dist/tools/thread-lifecycle.js.map +1 -1
  168. package/dist/tools/utility-tools.js +5 -2
  169. package/dist/tools/utility-tools.js.map +1 -1
  170. package/dist/tools/wait/drive-handler.d.ts +0 -1
  171. package/dist/tools/wait/drive-handler.d.ts.map +1 -1
  172. package/dist/tools/wait/drive-handler.js +5 -22
  173. package/dist/tools/wait/drive-handler.js.map +1 -1
  174. package/dist/tools/wait/message-delivery.js +1 -1
  175. package/dist/tools/wait/message-delivery.js.map +1 -1
  176. package/dist/tools/wait/message-processing.d.ts.map +1 -1
  177. package/dist/tools/wait/message-processing.js +9 -8
  178. package/dist/tools/wait/message-processing.js.map +1 -1
  179. package/dist/tools/wait/poll-loop.d.ts +2 -0
  180. package/dist/tools/wait/poll-loop.d.ts.map +1 -1
  181. package/dist/tools/wait/poll-loop.js +27 -29
  182. package/dist/tools/wait/poll-loop.js.map +1 -1
  183. package/dist/tools/wait/task-handler.d.ts +0 -3
  184. package/dist/tools/wait/task-handler.d.ts.map +1 -1
  185. package/dist/tools/wait/task-handler.js +3 -2
  186. package/dist/tools/wait/task-handler.js.map +1 -1
  187. package/dist/types.d.ts +0 -1
  188. package/dist/types.d.ts.map +1 -1
  189. package/package.json +4 -8
  190. package/supervisor/config.go +182 -69
  191. package/supervisor/config_test.go +78 -0
  192. package/supervisor/go.mod +12 -0
  193. package/supervisor/go.sum +20 -0
  194. package/supervisor/health.go +60 -11
  195. package/supervisor/health_test.go +29 -0
  196. package/supervisor/keeper.go +15 -10
  197. package/supervisor/log.go +109 -28
  198. package/supervisor/log_test.go +86 -6
  199. package/supervisor/main.go +150 -19
  200. package/supervisor/main_test.go +130 -0
  201. package/supervisor/process.go +47 -4
  202. package/supervisor/process_test.go +14 -0
  203. package/supervisor/secrets.go +95 -0
  204. package/supervisor/secrets_securevault_test.go +98 -0
  205. package/supervisor/secrets_test.go +119 -0
  206. package/supervisor/self_update.go +282 -0
  207. package/supervisor/self_update_test.go +177 -0
  208. package/supervisor/service_restart_stub.go +9 -0
  209. package/supervisor/service_restart_windows.go +63 -0
  210. package/supervisor/service_stub.go +15 -0
  211. package/supervisor/service_windows.go +216 -0
  212. package/supervisor/update_state.go +264 -0
  213. package/supervisor/update_state_test.go +306 -0
  214. package/supervisor/updater.go +311 -10
  215. package/supervisor/updater_test.go +64 -0
  216. package/dist/data/memory/quality-scoring.d.ts +0 -32
  217. package/dist/data/memory/quality-scoring.d.ts.map +0 -1
  218. package/dist/data/memory/quality-scoring.js +0 -182
  219. package/dist/data/memory/quality-scoring.js.map +0 -1
  220. package/scripts/install-supervisor.ps1 +0 -67
  221. package/scripts/install-supervisor.sh +0 -43
  222. package/scripts/start-supervisor.ps1 +0 -46
  223. package/scripts/start-supervisor.sh +0 -20
  224. package/templates/coding-task.default.md +0 -12
@@ -2,14 +2,21 @@ package main
2
2
 
3
3
  import (
4
4
  "context"
5
+ "flag"
5
6
  "fmt"
6
7
  "os"
7
8
  "os/signal"
9
+ "strings"
8
10
  "sync"
9
11
  "syscall"
10
12
  "time"
11
13
  )
12
14
 
15
+ var (
16
+ globalCancelMu sync.Mutex
17
+ globalCancel context.CancelFunc
18
+ )
19
+
13
20
  // KeeperEntry tracks a running keeper and its settings.
14
21
  type KeeperEntry struct {
15
22
  keeper *Keeper
@@ -17,17 +24,130 @@ type KeeperEntry struct {
17
24
  }
18
25
 
19
26
  func main() {
20
- cfg := LoadConfig()
27
+ isService, err := isWindowsService()
28
+ if err != nil {
29
+ fmt.Fprintf(os.Stderr, "Failed to detect service mode: %v\n", err)
30
+ os.Exit(1)
31
+ }
32
+ if isService {
33
+ if err := runAsService(); err != nil {
34
+ fmt.Fprintf(os.Stderr, "Service run failed: %v\n", err)
35
+ os.Exit(1)
36
+ }
37
+ return
38
+ }
21
39
 
22
- if err := os.MkdirAll(cfg.DataDir, 0755); err != nil {
23
- fmt.Fprintf(os.Stderr, "Cannot create data dir %s: %v\n", cfg.DataDir, err)
40
+ if handled, err := handleServiceCommand(os.Args[1:]); err != nil {
41
+ fmt.Fprintf(os.Stderr, "%v\n", err)
24
42
  os.Exit(1)
43
+ } else if handled {
44
+ return
45
+ }
46
+
47
+ runningAsService := resolveRunSupervisorMode(isService, os.Getenv("HOST_MODE"))
48
+ if err := runSupervisor(runningAsService); err != nil {
49
+ fmt.Fprintf(os.Stderr, "Supervisor failed: %v\n", err)
50
+ os.Exit(1)
51
+ }
52
+ }
53
+
54
+ func resolveRunSupervisorMode(processIsService bool, hostModeValue string) bool {
55
+ if processIsService {
56
+ return true
57
+ }
58
+
59
+ return parseHostMode(hostModeValue, false) == "service"
60
+ }
61
+
62
+ func handleServiceCommand(args []string) (bool, error) {
63
+ if len(args) == 0 {
64
+ return false, nil
65
+ }
66
+
67
+ switch args[0] {
68
+ case "install":
69
+ fs := flag.NewFlagSet("install", flag.ContinueOnError)
70
+ serviceUser := fs.String("service-user", "", "Windows account to run service as (e.g. .\\YourUser). Defaults to LocalSystem if empty.")
71
+ servicePassword := fs.String("service-password", "", "Password for the service account (required for regular user accounts; not needed for LocalSystem/LocalService/NetworkService, NT SERVICE\\*, or gMSA names ending with '$').")
72
+ if err := fs.Parse(args[1:]); err != nil {
73
+ return true, err
74
+ }
75
+ if *serviceUser != "" && *servicePassword == "" && !isPasswordlessServiceIdentity(*serviceUser) {
76
+ return true, fmt.Errorf("install failed: -service-password is required for regular -service-user accounts\nAllowed passwordless identities: LocalSystem/LocalService/NetworkService, NT SERVICE\\*, and gMSA names ending with '$'\nNote: prefer using Install-Sensorium.ps1, which prompts securely for passwords")
77
+ }
78
+ exePath, err := os.Executable()
79
+ if err != nil {
80
+ return true, fmt.Errorf("install failed: resolve executable: %w", err)
81
+ }
82
+ return true, installService(exePath, *serviceUser, *servicePassword)
83
+ case "uninstall":
84
+ return true, uninstallService()
85
+ case "start":
86
+ return true, startService()
87
+ case "stop":
88
+ return true, stopService()
89
+ case "status":
90
+ return true, serviceStatus()
91
+ default:
92
+ return false, nil
93
+ }
94
+ }
95
+
96
+ func isPasswordlessServiceIdentity(user string) bool {
97
+ trimmed := strings.TrimSpace(user)
98
+ if trimmed == "" {
99
+ return false
100
+ }
101
+
102
+ lower := strings.ToLower(trimmed)
103
+ switch lower {
104
+ case "localsystem", "nt authority\\system", "localservice", "nt authority\\localservice", "networkservice", "nt authority\\networkservice":
105
+ return true
106
+ }
107
+
108
+ if strings.HasPrefix(lower, "nt service\\") {
109
+ return true
110
+ }
111
+
112
+ return strings.HasSuffix(trimmed, "$")
113
+ }
114
+
115
+ func stopSupervisor() {
116
+ globalCancelMu.Lock()
117
+ fn := globalCancel
118
+ globalCancelMu.Unlock()
119
+ if fn != nil {
120
+ fn()
121
+ }
122
+ }
123
+
124
+ func runSupervisor(runningAsService bool) error {
125
+ cfg := LoadConfig(runningAsService)
126
+
127
+ if err := os.MkdirAll(cfg.DataDir, 0755); err != nil {
128
+ return fmt.Errorf("cannot create data dir %s: %w", cfg.DataDir, err)
25
129
  }
26
130
 
27
131
  log := NewLogger(cfg.Paths.WatcherLog)
28
132
  defer log.Close()
29
133
 
30
- log.Info("sensorium-supervisor starting (mode=%s, port=%d, dataDir=%s)", cfg.Mode, cfg.MCPHttpPort, cfg.DataDir)
134
+ // Acquire lock prevent multiple instances
135
+ if !AcquireLock(cfg.Paths.WatcherLock, log) {
136
+ return fmt.Errorf("another supervisor instance is already running")
137
+ }
138
+ defer ReleaseLock(cfg.Paths.WatcherLock)
139
+
140
+ shouldRestart, err := applyPendingSupervisorUpdate(cfg, log)
141
+ if err != nil {
142
+ log.Warn("Pending supervisor update could not be applied: %v", err)
143
+ }
144
+ if shouldRestart {
145
+ return nil
146
+ }
147
+
148
+ recoverPersistedUpdateStateOnStartup(cfg, log)
149
+
150
+ log.Info("sensorium-supervisor starting (mode=%s, hostMode=%s, port=%d, dataDir=%s)", cfg.Mode, cfg.HostMode, cfg.MCPHttpPort, cfg.DataDir)
31
151
  log.Debug("Config: MCPStartCommand=%q, PollInterval=%v, MinUptime=%v, KeeperMaxRetries=%d", cfg.MCPStartCommand, cfg.PollInterval, cfg.MinUptime, cfg.KeeperMaxRetries)
32
152
  log.Debug("Config: TelegramToken=%v, HealthFailThresh=%d, StuckThreshold=%v", cfg.TelegramToken != "", cfg.HealthFailThresh, cfg.StuckThreshold)
33
153
 
@@ -38,15 +158,9 @@ func main() {
38
158
  log.Warn("Cannot create heartbeats dir %s: %v", cfg.Paths.HeartbeatsDir, err)
39
159
  }
40
160
 
41
- // Acquire lock — prevent multiple instances
42
- if !AcquireLock(cfg.Paths.WatcherLock, log) {
43
- os.Exit(1)
44
- }
45
- defer ReleaseLock(cfg.Paths.WatcherLock)
46
-
47
161
  if cfg.MCPHttpPort <= 0 {
48
162
  log.Error("MCP_HTTP_PORT must be set (got %d)", cfg.MCPHttpPort)
49
- os.Exit(1)
163
+ return fmt.Errorf("MCP_HTTP_PORT must be set (got %d)", cfg.MCPHttpPort)
50
164
  }
51
165
 
52
166
  mcp := NewMCPClient(cfg.MCPHttpPort, cfg.MCPHttpSecret)
@@ -59,15 +173,23 @@ func main() {
59
173
  KillByPort(cfg.MCPHttpPort, log)
60
174
 
61
175
  // Spawn MCP server
62
- _, err := SpawnMCPServer(cfg, log)
176
+ _, err = SpawnMCPServer(cfg, log)
63
177
  if err != nil {
64
178
  log.Error("Failed to start MCP server: %v", err)
65
- os.Exit(1)
179
+ return fmt.Errorf("failed to start MCP server: %w", err)
66
180
  }
67
181
 
68
182
  // Wait for server to be ready
69
183
  ctx, rootCancel := context.WithCancel(context.Background())
70
184
  defer rootCancel()
185
+ globalCancelMu.Lock()
186
+ globalCancel = rootCancel
187
+ globalCancelMu.Unlock()
188
+ defer func() {
189
+ globalCancelMu.Lock()
190
+ globalCancel = nil
191
+ globalCancelMu.Unlock()
192
+ }()
71
193
 
72
194
  if mcp.WaitForReady(ctx, 3*time.Second, cfg.KeeperReadyTimeout) {
73
195
  log.Info("MCP server is ready")
@@ -208,9 +330,13 @@ func main() {
208
330
 
209
331
  log.Info("All subsystems started — supervisor is running (PID %d)", os.Getpid())
210
332
 
211
- sig := <-sigCh
212
- log.Info("Received %s shutting down", sig)
213
- rootCancel()
333
+ select {
334
+ case sig := <-sigCh:
335
+ log.Info("Received %s — shutting down", sig)
336
+ rootCancel()
337
+ case <-ctx.Done():
338
+ log.Info("Shutdown requested")
339
+ }
214
340
 
215
341
  // Stop keepers (with 10s timeout)
216
342
  shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -251,12 +377,13 @@ func main() {
251
377
  }
252
378
 
253
379
  log.Info("Supervisor stopped cleanly")
380
+ return nil
254
381
  }
255
382
 
256
- // fetchKeeperSettings reads the root threads from the MCP server,
257
- // filtering for those with keepAlive=true.
383
+ // fetchKeeperSettings reads all keepAlive threads from the MCP server
384
+ // (root, branch, and daily — excludes worker threads).
258
385
  func fetchKeeperSettings(ctx context.Context, mcp *MCPClient, log *Logger) ([]KeeperConfig, error) {
259
- roots, err := mcp.GetRootThreads(ctx)
386
+ roots, err := mcp.GetKeepAliveThreads(ctx)
260
387
  if err != nil {
261
388
  return nil, err
262
389
  }
@@ -268,6 +395,10 @@ func fetchKeeperSettings(ctx context.Context, mcp *MCPClient, log *Logger) ([]Ke
268
395
  continue
269
396
  }
270
397
 
398
+ if typ, _ := r["type"].(string); typ == "worker" {
399
+ continue
400
+ }
401
+
271
402
  // Skip non-active roots (archived, expired, exited)
272
403
  if status, _ := r["status"].(string); status != "" && status != "active" {
273
404
  continue
@@ -0,0 +1,130 @@
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
+ }
@@ -10,6 +10,7 @@ import (
10
10
  "runtime"
11
11
  "strconv"
12
12
  "strings"
13
+ "syscall"
13
14
  "time"
14
15
  )
15
16
 
@@ -21,16 +22,44 @@ func SpawnMCPServer(cfg Config, log *Logger) (int, error) {
21
22
  return 0, errors.New("empty MCP_START_COMMAND")
22
23
  }
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
+
24
33
  cmd := exec.Command(parts[0], parts[1:]...)
25
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
+ }
26
53
  cmd.Stdin = nil
27
54
  cmd.Stdout = nil
28
- cmd.Stderr = nil
55
+ cmd.Stderr = stderrFile
29
56
  setSysProcAttr(cmd)
30
57
 
31
58
  log.Info("Starting MCP server: %s", cfg.MCPStartCommand)
59
+ log.Info("Capturing MCP server stderr to %s", cfg.Paths.MCPStderrLog)
32
60
 
33
61
  if err := cmd.Start(); err != nil {
62
+ _ = stderrFile.Close()
34
63
  return 0, fmt.Errorf("spawn MCP server: %w", err)
35
64
  }
36
65
 
@@ -38,7 +67,10 @@ func SpawnMCPServer(cfg Config, log *Logger) (int, error) {
38
67
  log.Info("MCP server started with PID %d", pid)
39
68
 
40
69
  // Don't wait — detached process
41
- go func() { _ = cmd.Wait() }()
70
+ go func() {
71
+ _ = cmd.Wait()
72
+ _ = stderrFile.Close()
73
+ }()
42
74
 
43
75
  if err := writePIDFile(cfg.Paths.ServerPID, pid); err != nil {
44
76
  log.Warn("Failed to write server PID file: %v", err)
@@ -47,6 +79,17 @@ func SpawnMCPServer(cfg Config, log *Logger) (int, error) {
47
79
  return pid, nil
48
80
  }
49
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
+
50
93
  // KillProcess kills a process by PID. On Windows, uses taskkill /F /T for tree kill.
51
94
  func KillProcess(pid int, log *Logger) error {
52
95
  if !IsProcessAlive(pid) {
@@ -72,7 +115,7 @@ func KillProcess(pid int, log *Logger) error {
72
115
  log.Debug("KillProcess: FindProcess(%d) failed: %v", pid, err)
73
116
  return err
74
117
  }
75
- if err := proc.Signal(os.Interrupt); err != nil {
118
+ if err := proc.Signal(syscall.SIGTERM); err != nil {
76
119
  // Already dead
77
120
  return nil
78
121
  }
@@ -125,7 +168,7 @@ func IsProcessAlive(pid int) bool {
125
168
  if err != nil {
126
169
  return false
127
170
  }
128
- return proc.Signal(nil) == nil // signal 0 = existence check on Unix
171
+ return proc.Signal(syscall.Signal(0)) == nil // signal 0 = existence check on Unix
129
172
  }
130
173
 
131
174
  // --- PID File Helpers ---
@@ -92,3 +92,17 @@ func TestListThreadPIDs_MissingDir(t *testing.T) {
92
92
  t.Errorf("expected empty map for missing directory, got %d entries", len(result))
93
93
  }
94
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
+ }
@@ -0,0 +1,95 @@
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
+ }
@@ -0,0 +1,98 @@
1
+ package main
2
+
3
+ import (
4
+ "encoding/json"
5
+ "os"
6
+ "path/filepath"
7
+ "testing"
8
+
9
+ sv "github.com/andriyshevchenko/SecureVault/securevault-go"
10
+ "github.com/zalando/go-keyring"
11
+ )
12
+
13
+ // writeProfileFixture writes a minimal profiles.json to dir and returns the dir.
14
+ func writeProfileFixture(t *testing.T, dir string, profiles []sv.Profile) {
15
+ t.Helper()
16
+ data, err := json.Marshal(profiles)
17
+ if err != nil {
18
+ t.Fatalf("marshal profiles: %v", err)
19
+ }
20
+ if err := os.WriteFile(filepath.Join(dir, "profiles.json"), data, 0600); err != nil {
21
+ t.Fatalf("write profiles.json: %v", err)
22
+ }
23
+ }
24
+
25
+ func TestResolveStringChain_EnvWins(t *testing.T) {
26
+ t.Setenv("TELEGRAM_TOKEN", "env-value")
27
+ dir := t.TempDir()
28
+ writeProfileFixture(t, dir, []sv.Profile{
29
+ {ID: "p1", Name: "TEST", Mappings: []sv.ProfileMapping{
30
+ {EnvVar: "TELEGRAM_TOKEN", SecretID: "no-cred"},
31
+ }},
32
+ })
33
+
34
+ got := resolveStringChain("TELEGRAM_TOKEN", "TEST", dir, "")
35
+ if got != "env-value" {
36
+ t.Errorf("resolveStringChain = %q, want env-value", got)
37
+ }
38
+ }
39
+
40
+ func TestResolveStringChain_SecureVaultFallback_MissingCred_UsesKeyring(t *testing.T) {
41
+ t.Setenv("TELEGRAM_TOKEN", "")
42
+ dir := t.TempDir()
43
+ writeProfileFixture(t, dir, []sv.Profile{
44
+ {ID: "p1", Name: "TEST", Mappings: []sv.ProfileMapping{
45
+ {EnvVar: "TELEGRAM_TOKEN", SecretID: "non-existent-cred"},
46
+ }},
47
+ })
48
+
49
+ orig := keyringGet
50
+ keyringGet = func(service, user string) (string, error) {
51
+ if service != "sensorium-supervisor" {
52
+ t.Fatalf("service = %q, want %q", service, "sensorium-supervisor")
53
+ }
54
+ if user != "TELEGRAM_TOKEN" {
55
+ t.Fatalf("user = %q, want %q", user, "TELEGRAM_TOKEN")
56
+ }
57
+ return "from-keyring", nil
58
+ }
59
+ t.Cleanup(func() { keyringGet = orig })
60
+
61
+ // Credential not in store → falls through to keyring.
62
+ got := resolveStringChain("TELEGRAM_TOKEN", "TEST", dir, "sensorium-supervisor")
63
+ if got != "from-keyring" {
64
+ t.Errorf("resolveStringChain = %q, want from-keyring", got)
65
+ }
66
+ }
67
+
68
+ func TestResolveStringChain_NoProfile_FallsToKeyring(t *testing.T) {
69
+ t.Setenv("TELEGRAM_TOKEN", "")
70
+
71
+ orig := keyringGet
72
+ keyringGet = func(service, user string) (string, error) {
73
+ return "", keyring.ErrNotFound
74
+ }
75
+ t.Cleanup(func() { keyringGet = orig })
76
+
77
+ // No profiles file in dir → securevault returns empty → keyring path taken.
78
+ got := resolveStringChain("TELEGRAM_TOKEN", "NONEXISTENT_PROFILE", t.TempDir(), "sensorium-supervisor")
79
+ if got != "" {
80
+ t.Errorf("resolveStringChain = %q, want empty", got)
81
+ }
82
+ }
83
+
84
+ func TestResolveIntChain_EnvWins(t *testing.T) {
85
+ t.Setenv("MCP_HTTP_PORT", "4567")
86
+ got := resolveIntChain("MCP_HTTP_PORT", "TEST", t.TempDir(), "", 0)
87
+ if got != 4567 {
88
+ t.Errorf("resolveIntChain = %d, want 4567", got)
89
+ }
90
+ }
91
+
92
+ func TestResolveIntChain_InvalidFallback(t *testing.T) {
93
+ t.Setenv("MCP_HTTP_PORT", "not-a-number")
94
+ got := resolveIntChain("MCP_HTTP_PORT", "TEST", t.TempDir(), "", 9999)
95
+ if got != 9999 {
96
+ t.Errorf("resolveIntChain invalid = %d, want fallback 9999", got)
97
+ }
98
+ }