sensorium-mcp 2.17.28 → 3.0.1

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 (207) hide show
  1. package/Install-Sensorium.ps1 +351 -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 -25
  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 -554
  34. package/dist/data/memory/consolidation.js.map +1 -1
  35. package/dist/data/memory/migration-runner.d.ts +5 -0
  36. package/dist/data/memory/migration-runner.d.ts.map +1 -0
  37. package/dist/data/memory/migration-runner.js +403 -0
  38. package/dist/data/memory/migration-runner.js.map +1 -0
  39. package/dist/data/memory/reflection.js +1 -1
  40. package/dist/data/memory/schema-ddl.d.ts +4 -0
  41. package/dist/data/memory/schema-ddl.d.ts.map +1 -0
  42. package/dist/data/memory/schema-ddl.js +194 -0
  43. package/dist/data/memory/schema-ddl.js.map +1 -0
  44. package/dist/data/memory/schema-guard.d.ts +3 -0
  45. package/dist/data/memory/schema-guard.d.ts.map +1 -0
  46. package/dist/data/memory/schema-guard.js +184 -0
  47. package/dist/data/memory/schema-guard.js.map +1 -0
  48. package/dist/data/memory/schema.d.ts +2 -5
  49. package/dist/data/memory/schema.d.ts.map +1 -1
  50. package/dist/data/memory/schema.js +6 -834
  51. package/dist/data/memory/schema.js.map +1 -1
  52. package/dist/data/memory/synthesis.js +2 -2
  53. package/dist/data/memory/synthesis.js.map +1 -1
  54. package/dist/data/memory/thread-registry.d.ts +18 -4
  55. package/dist/data/memory/thread-registry.d.ts.map +1 -1
  56. package/dist/data/memory/thread-registry.js +25 -0
  57. package/dist/data/memory/thread-registry.js.map +1 -1
  58. package/dist/data/sent-message.repository.d.ts +12 -0
  59. package/dist/data/sent-message.repository.d.ts.map +1 -0
  60. package/dist/data/sent-message.repository.js +31 -0
  61. package/dist/data/sent-message.repository.js.map +1 -0
  62. package/dist/http-server.d.ts.map +1 -1
  63. package/dist/http-server.js +23 -2
  64. package/dist/http-server.js.map +1 -1
  65. package/dist/index.js +27 -48
  66. package/dist/index.js.map +1 -1
  67. package/dist/logger.d.ts +7 -2
  68. package/dist/logger.d.ts.map +1 -1
  69. package/dist/logger.js +89 -12
  70. package/dist/logger.js.map +1 -1
  71. package/dist/scheduler.d.ts +8 -0
  72. package/dist/scheduler.d.ts.map +1 -1
  73. package/dist/scheduler.js +15 -0
  74. package/dist/scheduler.js.map +1 -1
  75. package/dist/server/factory.d.ts +2 -1
  76. package/dist/server/factory.d.ts.map +1 -1
  77. package/dist/server/factory.js +11 -4
  78. package/dist/server/factory.js.map +1 -1
  79. package/dist/services/agent-spawn.service.d.ts +39 -0
  80. package/dist/services/agent-spawn.service.d.ts.map +1 -0
  81. package/dist/services/agent-spawn.service.js +348 -0
  82. package/dist/services/agent-spawn.service.js.map +1 -0
  83. package/dist/services/background-runner.d.ts +26 -0
  84. package/dist/services/background-runner.d.ts.map +1 -0
  85. package/dist/services/background-runner.js +71 -0
  86. package/dist/services/background-runner.js.map +1 -0
  87. package/dist/services/consolidation.service.d.ts +16 -0
  88. package/dist/services/consolidation.service.d.ts.map +1 -0
  89. package/dist/services/consolidation.service.js +508 -0
  90. package/dist/services/consolidation.service.js.map +1 -0
  91. package/dist/services/dispatcher/broker.d.ts +2 -0
  92. package/dist/services/dispatcher/broker.d.ts.map +1 -1
  93. package/dist/services/dispatcher/broker.js +5 -10
  94. package/dist/services/dispatcher/broker.js.map +1 -1
  95. package/dist/services/dispatcher/index.d.ts +1 -1
  96. package/dist/services/dispatcher/index.d.ts.map +1 -1
  97. package/dist/services/dispatcher/index.js +1 -1
  98. package/dist/services/dispatcher/index.js.map +1 -1
  99. package/dist/services/dispatcher/lock.d.ts.map +1 -1
  100. package/dist/services/dispatcher/lock.js +7 -11
  101. package/dist/services/dispatcher/lock.js.map +1 -1
  102. package/dist/services/maintenance-signal.d.ts +18 -0
  103. package/dist/services/maintenance-signal.d.ts.map +1 -0
  104. package/dist/services/maintenance-signal.js +48 -0
  105. package/dist/services/maintenance-signal.js.map +1 -0
  106. package/dist/services/memory-briefing.service.d.ts +4 -0
  107. package/dist/services/memory-briefing.service.d.ts.map +1 -0
  108. package/dist/services/memory-briefing.service.js +143 -0
  109. package/dist/services/memory-briefing.service.js.map +1 -0
  110. package/dist/services/process.service.d.ts +31 -0
  111. package/dist/services/process.service.d.ts.map +1 -0
  112. package/dist/services/process.service.js +100 -0
  113. package/dist/services/process.service.js.map +1 -0
  114. package/dist/services/thread-health.service.d.ts +18 -0
  115. package/dist/services/thread-health.service.d.ts.map +1 -0
  116. package/dist/services/thread-health.service.js +118 -0
  117. package/dist/services/thread-health.service.js.map +1 -0
  118. package/dist/services/thread-lifecycle.service.d.ts +52 -0
  119. package/dist/services/thread-lifecycle.service.d.ts.map +1 -0
  120. package/dist/services/thread-lifecycle.service.js +174 -0
  121. package/dist/services/thread-lifecycle.service.js.map +1 -0
  122. package/dist/services/topic.service.d.ts +25 -0
  123. package/dist/services/topic.service.d.ts.map +1 -0
  124. package/dist/services/topic.service.js +65 -0
  125. package/dist/services/topic.service.js.map +1 -0
  126. package/dist/services/worker-cleanup.service.d.ts +8 -0
  127. package/dist/services/worker-cleanup.service.d.ts.map +1 -0
  128. package/dist/services/worker-cleanup.service.js +82 -0
  129. package/dist/services/worker-cleanup.service.js.map +1 -0
  130. package/dist/sessions.d.ts +14 -0
  131. package/dist/sessions.d.ts.map +1 -1
  132. package/dist/sessions.js +55 -0
  133. package/dist/sessions.js.map +1 -1
  134. package/dist/telegram.d.ts +13 -6
  135. package/dist/telegram.d.ts.map +1 -1
  136. package/dist/telegram.js +43 -14
  137. package/dist/telegram.js.map +1 -1
  138. package/dist/tools/delegate-tool.d.ts +4 -0
  139. package/dist/tools/delegate-tool.d.ts.map +1 -1
  140. package/dist/tools/delegate-tool.js +48 -109
  141. package/dist/tools/delegate-tool.js.map +1 -1
  142. package/dist/tools/memory-tools.d.ts.map +1 -1
  143. package/dist/tools/memory-tools.js +1 -1
  144. package/dist/tools/memory-tools.js.map +1 -1
  145. package/dist/tools/shared-agent-utils.d.ts +9 -1
  146. package/dist/tools/shared-agent-utils.d.ts.map +1 -1
  147. package/dist/tools/shared-agent-utils.js +21 -38
  148. package/dist/tools/shared-agent-utils.js.map +1 -1
  149. package/dist/tools/start-session-tool.d.ts +2 -0
  150. package/dist/tools/start-session-tool.d.ts.map +1 -1
  151. package/dist/tools/start-session-tool.js +68 -118
  152. package/dist/tools/start-session-tool.js.map +1 -1
  153. package/dist/tools/thread-lifecycle.d.ts +5 -127
  154. package/dist/tools/thread-lifecycle.d.ts.map +1 -1
  155. package/dist/tools/thread-lifecycle.js +5 -1167
  156. package/dist/tools/thread-lifecycle.js.map +1 -1
  157. package/dist/tools/utility-tools.js +5 -2
  158. package/dist/tools/utility-tools.js.map +1 -1
  159. package/dist/tools/wait/drive-handler.d.ts +0 -1
  160. package/dist/tools/wait/drive-handler.d.ts.map +1 -1
  161. package/dist/tools/wait/drive-handler.js +5 -22
  162. package/dist/tools/wait/drive-handler.js.map +1 -1
  163. package/dist/tools/wait/message-delivery.js +1 -1
  164. package/dist/tools/wait/message-delivery.js.map +1 -1
  165. package/dist/tools/wait/message-processing.d.ts.map +1 -1
  166. package/dist/tools/wait/message-processing.js +9 -8
  167. package/dist/tools/wait/message-processing.js.map +1 -1
  168. package/dist/tools/wait/poll-loop.d.ts +2 -0
  169. package/dist/tools/wait/poll-loop.d.ts.map +1 -1
  170. package/dist/tools/wait/poll-loop.js +27 -29
  171. package/dist/tools/wait/poll-loop.js.map +1 -1
  172. package/dist/tools/wait/task-handler.d.ts +0 -3
  173. package/dist/tools/wait/task-handler.d.ts.map +1 -1
  174. package/dist/tools/wait/task-handler.js +3 -2
  175. package/dist/tools/wait/task-handler.js.map +1 -1
  176. package/dist/types.d.ts +0 -1
  177. package/dist/types.d.ts.map +1 -1
  178. package/package.json +4 -8
  179. package/supervisor/config.go +182 -69
  180. package/supervisor/config_test.go +78 -0
  181. package/supervisor/go.mod +12 -0
  182. package/supervisor/go.sum +20 -0
  183. package/supervisor/health.go +24 -6
  184. package/supervisor/keeper.go +15 -10
  185. package/supervisor/log.go +109 -28
  186. package/supervisor/log_test.go +86 -6
  187. package/supervisor/main.go +146 -19
  188. package/supervisor/main_test.go +130 -0
  189. package/supervisor/process.go +47 -4
  190. package/supervisor/process_test.go +14 -0
  191. package/supervisor/secrets.go +95 -0
  192. package/supervisor/secrets_securevault_test.go +98 -0
  193. package/supervisor/secrets_test.go +119 -0
  194. package/supervisor/self_update.go +282 -0
  195. package/supervisor/self_update_test.go +177 -0
  196. package/supervisor/service_restart_stub.go +9 -0
  197. package/supervisor/service_restart_windows.go +63 -0
  198. package/supervisor/service_stub.go +15 -0
  199. package/supervisor/service_windows.go +216 -0
  200. package/supervisor/update_state.go +264 -0
  201. package/supervisor/update_state_test.go +306 -0
  202. package/supervisor/updater.go +341 -10
  203. package/supervisor/updater_test.go +64 -0
  204. package/scripts/install-supervisor.ps1 +0 -67
  205. package/scripts/install-supervisor.sh +0 -43
  206. package/scripts/start-supervisor.ps1 +0 -46
  207. package/scripts/start-supervisor.sh +0 -20
@@ -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
+ }
@@ -0,0 +1,119 @@
1
+ package main
2
+
3
+ import (
4
+ "errors"
5
+ "testing"
6
+
7
+ "github.com/zalando/go-keyring"
8
+ )
9
+
10
+ func TestResolveSecretWithKeyring_EnvWins(t *testing.T) {
11
+ t.Setenv("MCP_HTTP_SECRET", "env-secret")
12
+
13
+ orig := keyringGet
14
+ keyringGet = func(service, user string) (string, error) {
15
+ return "keyring-secret", nil
16
+ }
17
+ t.Cleanup(func() { keyringGet = orig })
18
+
19
+ got := resolveSecretWithKeyring("MCP_HTTP_SECRET", "sensorium-supervisor")
20
+ if got != "env-secret" {
21
+ t.Fatalf("resolveSecretWithKeyring() = %q, want %q", got, "env-secret")
22
+ }
23
+ }
24
+
25
+ func TestResolveSecretWithKeyring_FallbackToKeyring(t *testing.T) {
26
+ t.Setenv("MCP_HTTP_SECRET", "")
27
+
28
+ orig := keyringGet
29
+ keyringGet = func(service, user string) (string, error) {
30
+ if service != "sensorium-supervisor" {
31
+ t.Fatalf("service = %q, want %q", service, "sensorium-supervisor")
32
+ }
33
+ if user != "MCP_HTTP_SECRET" {
34
+ t.Fatalf("user = %q, want %q", user, "MCP_HTTP_SECRET")
35
+ }
36
+ return "keyring-secret", nil
37
+ }
38
+ t.Cleanup(func() { keyringGet = orig })
39
+
40
+ got := resolveSecretWithKeyring("MCP_HTTP_SECRET", "sensorium-supervisor")
41
+ if got != "keyring-secret" {
42
+ t.Fatalf("resolveSecretWithKeyring() = %q, want %q", got, "keyring-secret")
43
+ }
44
+ }
45
+
46
+ func TestResolveSecretWithKeyring_NotFound(t *testing.T) {
47
+ t.Setenv("MCP_HTTP_SECRET", "")
48
+
49
+ orig := keyringGet
50
+ keyringGet = func(service, user string) (string, error) {
51
+ return "", keyring.ErrNotFound
52
+ }
53
+ t.Cleanup(func() { keyringGet = orig })
54
+
55
+ got := resolveSecretWithKeyring("MCP_HTTP_SECRET", "sensorium-supervisor")
56
+ if got != "" {
57
+ t.Fatalf("resolveSecretWithKeyring() = %q, want empty", got)
58
+ }
59
+ }
60
+
61
+ func TestResolveSecretWithKeyring_OtherError(t *testing.T) {
62
+ t.Setenv("MCP_HTTP_SECRET", "")
63
+
64
+ orig := keyringGet
65
+ keyringGet = func(service, user string) (string, error) {
66
+ return "", errors.New("keyring backend unavailable")
67
+ }
68
+ t.Cleanup(func() { keyringGet = orig })
69
+
70
+ got := resolveSecretWithKeyring("MCP_HTTP_SECRET", "sensorium-supervisor")
71
+ if got != "" {
72
+ t.Fatalf("resolveSecretWithKeyring() = %q, want empty", got)
73
+ }
74
+ }
75
+
76
+ func TestResolveIntWithKeyring_EnvWins(t *testing.T) {
77
+ t.Setenv("MCP_HTTP_PORT", "3847")
78
+
79
+ orig := keyringGet
80
+ keyringGet = func(service, user string) (string, error) {
81
+ return "9999", nil
82
+ }
83
+ t.Cleanup(func() { keyringGet = orig })
84
+
85
+ got := resolveIntWithKeyring("MCP_HTTP_PORT", "sensorium-supervisor", 0)
86
+ if got != 3847 {
87
+ t.Fatalf("resolveIntWithKeyring() = %d, want %d", got, 3847)
88
+ }
89
+ }
90
+
91
+ func TestResolveIntWithKeyring_FallbackToKeyring(t *testing.T) {
92
+ t.Setenv("MCP_HTTP_PORT", "")
93
+
94
+ orig := keyringGet
95
+ keyringGet = func(service, user string) (string, error) {
96
+ return "5001", nil
97
+ }
98
+ t.Cleanup(func() { keyringGet = orig })
99
+
100
+ got := resolveIntWithKeyring("MCP_HTTP_PORT", "sensorium-supervisor", 0)
101
+ if got != 5001 {
102
+ t.Fatalf("resolveIntWithKeyring() = %d, want %d", got, 5001)
103
+ }
104
+ }
105
+
106
+ func TestResolveIntWithKeyring_InvalidFallback(t *testing.T) {
107
+ t.Setenv("MCP_HTTP_PORT", "")
108
+
109
+ orig := keyringGet
110
+ keyringGet = func(service, user string) (string, error) {
111
+ return "not-a-number", nil
112
+ }
113
+ t.Cleanup(func() { keyringGet = orig })
114
+
115
+ got := resolveIntWithKeyring("MCP_HTTP_PORT", "sensorium-supervisor", 3847)
116
+ if got != 3847 {
117
+ t.Fatalf("resolveIntWithKeyring() = %d, want fallback %d", got, 3847)
118
+ }
119
+ }