@lobu/gateway 3.0.5 → 3.0.7

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 (175) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/agent-config-routes.test.ts +254 -0
  3. package/src/__tests__/agent-history-routes.test.ts +72 -0
  4. package/src/__tests__/agent-routes.test.ts +68 -0
  5. package/src/__tests__/agent-schedules-routes.test.ts +59 -0
  6. package/src/__tests__/agent-settings-store.test.ts +323 -0
  7. package/src/__tests__/chat-instance-manager-slack.test.ts +204 -0
  8. package/src/__tests__/chat-response-bridge.test.ts +131 -0
  9. package/src/__tests__/config-memory-plugins.test.ts +92 -0
  10. package/src/__tests__/config-request-store.test.ts +127 -0
  11. package/src/__tests__/connection-routes.test.ts +144 -0
  12. package/src/__tests__/core-services-store-selection.test.ts +92 -0
  13. package/src/__tests__/docker-deployment.test.ts +1211 -0
  14. package/src/__tests__/embedded-deployment.test.ts +342 -0
  15. package/src/__tests__/grant-store.test.ts +148 -0
  16. package/src/__tests__/http-proxy.test.ts +281 -0
  17. package/src/__tests__/instruction-service.test.ts +37 -0
  18. package/src/__tests__/link-buttons.test.ts +112 -0
  19. package/src/__tests__/lobu.test.ts +32 -0
  20. package/src/__tests__/mcp-config-service.test.ts +347 -0
  21. package/src/__tests__/mcp-proxy.test.ts +696 -0
  22. package/src/__tests__/message-handler-bridge.test.ts +17 -0
  23. package/src/__tests__/model-selection.test.ts +172 -0
  24. package/src/__tests__/oauth-templates.test.ts +39 -0
  25. package/src/__tests__/platform-adapter-slack-send.test.ts +114 -0
  26. package/src/__tests__/platform-helpers-model-resolution.test.ts +253 -0
  27. package/src/__tests__/provider-inheritance.test.ts +212 -0
  28. package/src/__tests__/routes/cli-auth.test.ts +337 -0
  29. package/src/__tests__/routes/interactions.test.ts +121 -0
  30. package/src/__tests__/secret-proxy.test.ts +85 -0
  31. package/src/__tests__/session-manager.test.ts +572 -0
  32. package/src/__tests__/setup.ts +133 -0
  33. package/src/__tests__/skill-and-mcp-registry.test.ts +203 -0
  34. package/src/__tests__/slack-routes.test.ts +161 -0
  35. package/src/__tests__/system-config-resolver.test.ts +75 -0
  36. package/src/__tests__/system-message-limiter.test.ts +89 -0
  37. package/src/__tests__/system-skills-service.test.ts +362 -0
  38. package/src/__tests__/transcription-service.test.ts +222 -0
  39. package/src/__tests__/utils/rate-limiter.test.ts +102 -0
  40. package/src/__tests__/worker-connection-manager.test.ts +497 -0
  41. package/src/__tests__/worker-job-router.test.ts +722 -0
  42. package/src/api/index.ts +1 -0
  43. package/src/api/platform.ts +292 -0
  44. package/src/api/response-renderer.ts +157 -0
  45. package/src/auth/agent-metadata-store.ts +168 -0
  46. package/src/auth/api-auth-middleware.ts +69 -0
  47. package/src/auth/api-key-provider-module.ts +213 -0
  48. package/src/auth/base-provider-module.ts +201 -0
  49. package/src/auth/chatgpt/chatgpt-oauth-module.ts +185 -0
  50. package/src/auth/chatgpt/device-code-client.ts +218 -0
  51. package/src/auth/chatgpt/index.ts +1 -0
  52. package/src/auth/claude/oauth-module.ts +280 -0
  53. package/src/auth/cli/token-service.ts +249 -0
  54. package/src/auth/external/client.ts +560 -0
  55. package/src/auth/external/device-code-client.ts +225 -0
  56. package/src/auth/mcp/config-service.ts +392 -0
  57. package/src/auth/mcp/proxy.ts +1088 -0
  58. package/src/auth/mcp/string-substitution.ts +17 -0
  59. package/src/auth/mcp/tool-cache.ts +90 -0
  60. package/src/auth/oauth/base-client.ts +267 -0
  61. package/src/auth/oauth/client.ts +153 -0
  62. package/src/auth/oauth/credentials.ts +7 -0
  63. package/src/auth/oauth/providers.ts +69 -0
  64. package/src/auth/oauth/state-store.ts +150 -0
  65. package/src/auth/oauth-templates.ts +179 -0
  66. package/src/auth/provider-catalog.ts +220 -0
  67. package/src/auth/provider-model-options.ts +41 -0
  68. package/src/auth/settings/agent-settings-store.ts +565 -0
  69. package/src/auth/settings/auth-profiles-manager.ts +216 -0
  70. package/src/auth/settings/index.ts +12 -0
  71. package/src/auth/settings/model-preference-store.ts +52 -0
  72. package/src/auth/settings/model-selection.ts +135 -0
  73. package/src/auth/settings/resolved-settings-view.ts +298 -0
  74. package/src/auth/settings/template-utils.ts +44 -0
  75. package/src/auth/settings/token-service.ts +88 -0
  76. package/src/auth/system-env-store.ts +98 -0
  77. package/src/auth/user-agents-store.ts +68 -0
  78. package/src/channels/binding-service.ts +214 -0
  79. package/src/channels/index.ts +4 -0
  80. package/src/cli/gateway.ts +1304 -0
  81. package/src/cli/index.ts +74 -0
  82. package/src/commands/built-in-commands.ts +80 -0
  83. package/src/commands/command-dispatcher.ts +94 -0
  84. package/src/commands/command-reply-adapters.ts +27 -0
  85. package/src/config/file-loader.ts +618 -0
  86. package/src/config/index.ts +588 -0
  87. package/src/config/network-allowlist.ts +71 -0
  88. package/src/connections/chat-instance-manager.ts +1284 -0
  89. package/src/connections/chat-response-bridge.ts +618 -0
  90. package/src/connections/index.ts +7 -0
  91. package/src/connections/interaction-bridge.ts +831 -0
  92. package/src/connections/message-handler-bridge.ts +415 -0
  93. package/src/connections/platform-auth-methods.ts +15 -0
  94. package/src/connections/types.ts +84 -0
  95. package/src/gateway/connection-manager.ts +291 -0
  96. package/src/gateway/index.ts +700 -0
  97. package/src/gateway/job-router.ts +201 -0
  98. package/src/gateway-main.ts +200 -0
  99. package/src/index.ts +41 -0
  100. package/src/infrastructure/queue/index.ts +12 -0
  101. package/src/infrastructure/queue/queue-producer.ts +148 -0
  102. package/src/infrastructure/queue/redis-queue.ts +361 -0
  103. package/src/infrastructure/queue/types.ts +133 -0
  104. package/src/infrastructure/redis/system-message-limiter.ts +94 -0
  105. package/src/interactions/config-request-store.ts +198 -0
  106. package/src/interactions.ts +363 -0
  107. package/src/lobu.ts +311 -0
  108. package/src/metrics/prometheus.ts +159 -0
  109. package/src/modules/module-system.ts +179 -0
  110. package/src/orchestration/base-deployment-manager.ts +900 -0
  111. package/src/orchestration/deployment-utils.ts +98 -0
  112. package/src/orchestration/impl/docker-deployment.ts +620 -0
  113. package/src/orchestration/impl/embedded-deployment.ts +268 -0
  114. package/src/orchestration/impl/index.ts +8 -0
  115. package/src/orchestration/impl/k8s/deployment.ts +1061 -0
  116. package/src/orchestration/impl/k8s/helpers.ts +610 -0
  117. package/src/orchestration/impl/k8s/index.ts +1 -0
  118. package/src/orchestration/index.ts +333 -0
  119. package/src/orchestration/message-consumer.ts +584 -0
  120. package/src/orchestration/scheduled-wakeup.ts +704 -0
  121. package/src/permissions/approval-policy.ts +36 -0
  122. package/src/permissions/grant-store.ts +219 -0
  123. package/src/platform/file-handler.ts +66 -0
  124. package/src/platform/link-buttons.ts +57 -0
  125. package/src/platform/renderer-utils.ts +44 -0
  126. package/src/platform/response-renderer.ts +84 -0
  127. package/src/platform/unified-thread-consumer.ts +187 -0
  128. package/src/platform.ts +318 -0
  129. package/src/proxy/http-proxy.ts +752 -0
  130. package/src/proxy/proxy-manager.ts +81 -0
  131. package/src/proxy/secret-proxy.ts +402 -0
  132. package/src/proxy/token-refresh-job.ts +143 -0
  133. package/src/routes/internal/audio.ts +141 -0
  134. package/src/routes/internal/device-auth.ts +566 -0
  135. package/src/routes/internal/files.ts +226 -0
  136. package/src/routes/internal/history.ts +69 -0
  137. package/src/routes/internal/images.ts +127 -0
  138. package/src/routes/internal/interactions.ts +84 -0
  139. package/src/routes/internal/middleware.ts +23 -0
  140. package/src/routes/internal/schedule.ts +226 -0
  141. package/src/routes/internal/types.ts +22 -0
  142. package/src/routes/openapi-auto.ts +239 -0
  143. package/src/routes/public/agent-access.ts +23 -0
  144. package/src/routes/public/agent-config.ts +675 -0
  145. package/src/routes/public/agent-history.ts +422 -0
  146. package/src/routes/public/agent-schedules.ts +296 -0
  147. package/src/routes/public/agent.ts +1086 -0
  148. package/src/routes/public/agents.ts +373 -0
  149. package/src/routes/public/channels.ts +191 -0
  150. package/src/routes/public/cli-auth.ts +883 -0
  151. package/src/routes/public/connections.ts +574 -0
  152. package/src/routes/public/landing.ts +16 -0
  153. package/src/routes/public/oauth.ts +147 -0
  154. package/src/routes/public/settings-auth.ts +104 -0
  155. package/src/routes/public/slack.ts +173 -0
  156. package/src/routes/shared/agent-ownership.ts +101 -0
  157. package/src/routes/shared/token-verifier.ts +34 -0
  158. package/src/services/core-services.ts +1053 -0
  159. package/src/services/image-generation-service.ts +257 -0
  160. package/src/services/instruction-service.ts +318 -0
  161. package/src/services/mcp-registry.ts +94 -0
  162. package/src/services/platform-helpers.ts +287 -0
  163. package/src/services/session-manager.ts +262 -0
  164. package/src/services/settings-resolver.ts +74 -0
  165. package/src/services/system-config-resolver.ts +90 -0
  166. package/src/services/system-skills-service.ts +229 -0
  167. package/src/services/transcription-service.ts +684 -0
  168. package/src/session.ts +110 -0
  169. package/src/spaces/index.ts +1 -0
  170. package/src/spaces/space-resolver.ts +17 -0
  171. package/src/stores/in-memory-agent-store.ts +403 -0
  172. package/src/stores/redis-agent-store.ts +279 -0
  173. package/src/utils/public-url.ts +44 -0
  174. package/src/utils/rate-limiter.ts +94 -0
  175. package/tsconfig.json +33 -0
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Shared platform helpers.
3
+ * Extracts common logic duplicated across Slack, Telegram, and WhatsApp message handlers.
4
+ */
5
+
6
+ import {
7
+ createLogger,
8
+ type PluginConfig,
9
+ type PluginsConfig,
10
+ } from "@lobu/core";
11
+ import type { AgentSettingsStore } from "../auth/settings";
12
+ import { resolveEffectiveModelRef } from "../auth/settings/model-selection";
13
+ import type { ChannelBindingService } from "../channels";
14
+ import { buildMemoryPlugins, getInternalGatewayUrl } from "../config";
15
+ import type { MessagePayload } from "../infrastructure/queue/queue-producer";
16
+ import { getModelProviderModules } from "../modules/module-system";
17
+ import { platformAgentId } from "../spaces";
18
+
19
+ const logger = createLogger("platform-helpers");
20
+ const OWLETTO_PLUGIN_SOURCE = "@lobu/owletto-openclaw";
21
+
22
+ function readOwlettoRuntimeDefaults(): PluginConfig | null {
23
+ const configuredPlugin = buildMemoryPlugins().find(
24
+ (plugin) =>
25
+ plugin.source === OWLETTO_PLUGIN_SOURCE && plugin.slot === "memory"
26
+ );
27
+ if (configuredPlugin) {
28
+ return configuredPlugin;
29
+ }
30
+
31
+ const gatewayUrl = getInternalGatewayUrl();
32
+ return {
33
+ source: OWLETTO_PLUGIN_SOURCE,
34
+ slot: "memory",
35
+ enabled: true,
36
+ config: {
37
+ mcpUrl: `${gatewayUrl}/mcp/owletto`,
38
+ gatewayAuthUrl: gatewayUrl,
39
+ },
40
+ };
41
+ }
42
+
43
+ function normalizeOwlettoPluginConfig(
44
+ plugin: PluginConfig,
45
+ runtimeDefault: PluginConfig | null
46
+ ): PluginConfig {
47
+ if (
48
+ plugin.source !== OWLETTO_PLUGIN_SOURCE ||
49
+ plugin.slot !== "memory" ||
50
+ !runtimeDefault?.config ||
51
+ !plugin.config
52
+ ) {
53
+ return plugin;
54
+ }
55
+
56
+ const storedMcpUrl = plugin.config.mcpUrl;
57
+ const storedGatewayAuthUrl = plugin.config.gatewayAuthUrl;
58
+ const runtimeMcpUrl = runtimeDefault.config.mcpUrl;
59
+ const runtimeGatewayAuthUrl = runtimeDefault.config.gatewayAuthUrl;
60
+
61
+ const shouldReplaceMcpUrl =
62
+ typeof storedMcpUrl === "string" &&
63
+ typeof runtimeMcpUrl === "string" &&
64
+ runtimeMcpUrl !== storedMcpUrl &&
65
+ /^https?:\/\/gateway(?::\d+)?\/mcp\/owletto\/?$/.test(storedMcpUrl);
66
+ const shouldReplaceGatewayAuthUrl =
67
+ typeof storedGatewayAuthUrl === "string" &&
68
+ typeof runtimeGatewayAuthUrl === "string" &&
69
+ runtimeGatewayAuthUrl !== storedGatewayAuthUrl &&
70
+ /^https?:\/\/gateway(?::\d+)?\/?$/.test(storedGatewayAuthUrl);
71
+
72
+ if (!shouldReplaceMcpUrl && !shouldReplaceGatewayAuthUrl) {
73
+ return plugin;
74
+ }
75
+
76
+ return {
77
+ ...plugin,
78
+ config: {
79
+ ...plugin.config,
80
+ ...(shouldReplaceMcpUrl ? { mcpUrl: runtimeMcpUrl } : {}),
81
+ ...(shouldReplaceGatewayAuthUrl
82
+ ? { gatewayAuthUrl: runtimeGatewayAuthUrl }
83
+ : {}),
84
+ },
85
+ };
86
+ }
87
+
88
+ function normalizePluginsConfig(
89
+ pluginsConfig: PluginsConfig | undefined
90
+ ): PluginsConfig | undefined {
91
+ if (!pluginsConfig?.plugins?.length) {
92
+ return pluginsConfig;
93
+ }
94
+
95
+ const runtimeDefault = readOwlettoRuntimeDefaults();
96
+ let changed = false;
97
+ const plugins = pluginsConfig.plugins.map((plugin) => {
98
+ const normalized = normalizeOwlettoPluginConfig(plugin, runtimeDefault);
99
+ if (normalized !== plugin) {
100
+ changed = true;
101
+ }
102
+ return normalized;
103
+ });
104
+
105
+ return changed ? { ...pluginsConfig, plugins } : pluginsConfig;
106
+ }
107
+
108
+ /**
109
+ * Resolve agent options by merging base options with per-agent settings.
110
+ * Priority: agent settings > config defaults.
111
+ */
112
+ export async function resolveAgentOptions(
113
+ agentId: string,
114
+ baseOptions: Record<string, any>,
115
+ agentSettingsStore?: AgentSettingsStore
116
+ ): Promise<Record<string, any>> {
117
+ if (!agentSettingsStore) {
118
+ return { ...baseOptions };
119
+ }
120
+
121
+ const settings = await agentSettingsStore.getEffectiveSettings(agentId);
122
+ if (!settings) {
123
+ return { ...baseOptions };
124
+ }
125
+
126
+ const effectiveProviders = settings.installedProviders || [];
127
+
128
+ const mergedOptions: Record<string, any> = { ...baseOptions };
129
+ const effectiveModelRef = resolveEffectiveModelRef(settings);
130
+ logger.info(
131
+ {
132
+ agentId,
133
+ configuredModel: settings.model,
134
+ effectiveModel: effectiveModelRef,
135
+ },
136
+ "Applying agent settings"
137
+ );
138
+
139
+ if (effectiveModelRef) {
140
+ mergedOptions.model = effectiveModelRef;
141
+ } else if (effectiveProviders.length > 0) {
142
+ // Auto mode with installed providers: let worker resolve default model.
143
+ delete mergedOptions.model;
144
+ }
145
+
146
+ if (settings.networkConfig) {
147
+ mergedOptions.networkConfig = settings.networkConfig;
148
+ }
149
+ if (settings.nixConfig) {
150
+ mergedOptions.nixConfig = settings.nixConfig;
151
+ }
152
+ if (settings.toolsConfig) {
153
+ mergedOptions.toolsConfig = settings.toolsConfig;
154
+ }
155
+ if (settings.mcpServers) {
156
+ mergedOptions.mcpServers = settings.mcpServers;
157
+ }
158
+ if (settings.pluginsConfig) {
159
+ mergedOptions.pluginsConfig = normalizePluginsConfig(
160
+ settings.pluginsConfig
161
+ );
162
+ }
163
+ // Apply default memory plugins if no pluginsConfig from settings or baseOptions
164
+ if (!mergedOptions.pluginsConfig) {
165
+ mergedOptions.pluginsConfig = { plugins: buildMemoryPlugins() };
166
+ }
167
+ if (settings.verboseLogging !== undefined) {
168
+ mergedOptions.verboseLogging = settings.verboseLogging;
169
+ }
170
+
171
+ return mergedOptions;
172
+ }
173
+
174
+ export async function hasConfiguredProvider(
175
+ agentId: string,
176
+ agentSettingsStore?: AgentSettingsStore
177
+ ): Promise<boolean> {
178
+ if (!agentSettingsStore) {
179
+ return true;
180
+ }
181
+
182
+ const settings = await agentSettingsStore.getEffectiveSettings(agentId);
183
+ const installedProviderIds = new Set(
184
+ (settings?.installedProviders || []).map((provider) => provider.providerId)
185
+ );
186
+
187
+ if ((settings?.authProfiles?.length || 0) > 0) {
188
+ return true;
189
+ }
190
+
191
+ const modules = getModelProviderModules();
192
+ if (installedProviderIds.size > 0) {
193
+ return modules.some(
194
+ (module) =>
195
+ installedProviderIds.has(module.providerId) && module.hasSystemKey()
196
+ );
197
+ }
198
+
199
+ return modules.some((module) => module.hasSystemKey());
200
+ }
201
+
202
+ /**
203
+ * Build a MessagePayload from common fields.
204
+ * Extracts networkConfig, nixConfig, mcpServers from agentOptions before constructing the payload.
205
+ */
206
+ export function buildMessagePayload(params: {
207
+ platform: string;
208
+ userId: string;
209
+ botId: string;
210
+ conversationId: string;
211
+ teamId: string;
212
+ agentId: string;
213
+ messageId: string;
214
+ messageText: string;
215
+ channelId: string;
216
+ platformMetadata: Record<string, any>;
217
+ agentOptions: Record<string, any>;
218
+ }): MessagePayload {
219
+ const { networkConfig, nixConfig, mcpServers, ...remainingOptions } =
220
+ params.agentOptions;
221
+
222
+ return {
223
+ platform: params.platform,
224
+ userId: params.userId,
225
+ botId: params.botId,
226
+ conversationId: params.conversationId,
227
+ teamId: params.teamId,
228
+ agentId: params.agentId,
229
+ messageId: params.messageId,
230
+ messageText: params.messageText,
231
+ channelId: params.channelId,
232
+ platformMetadata: params.platformMetadata,
233
+ agentOptions: remainingOptions,
234
+ networkConfig,
235
+ nixConfig,
236
+ mcpConfig: mcpServers ? { mcpServers } : undefined,
237
+ };
238
+ }
239
+
240
+ /**
241
+ * Resolve agent ID. Deterministic for all platforms.
242
+ * Channel binding is checked first for Slack (multi-tenant), then falls back to platformAgentId.
243
+ */
244
+ export async function resolveAgentId(params: {
245
+ platform: string;
246
+ userId: string;
247
+ channelId: string;
248
+ isGroup: boolean;
249
+ teamId?: string;
250
+ channelBindingService?: ChannelBindingService;
251
+ sendConfigPrompt?: () => Promise<boolean>;
252
+ }): Promise<{ agentId: string; promptSent: boolean }> {
253
+ const {
254
+ platform,
255
+ userId,
256
+ channelId,
257
+ isGroup,
258
+ teamId,
259
+ channelBindingService,
260
+ sendConfigPrompt,
261
+ } = params;
262
+
263
+ // Check channel binding first (Slack multi-tenant)
264
+ if (channelBindingService) {
265
+ const binding = await channelBindingService.getBinding(
266
+ platform,
267
+ channelId,
268
+ teamId
269
+ );
270
+ if (binding) {
271
+ logger.info({ agentId: binding.agentId, channelId }, "Using bound agent");
272
+ return { agentId: binding.agentId, promptSent: false };
273
+ }
274
+
275
+ if (sendConfigPrompt) {
276
+ const sent = await sendConfigPrompt();
277
+ if (sent) return { agentId: "", promptSent: true };
278
+ }
279
+ }
280
+
281
+ const agentId = platformAgentId(platform, userId, channelId, isGroup);
282
+ logger.info(
283
+ { agentId, platform, channelId },
284
+ "Deterministic agent ID resolved"
285
+ );
286
+ return { agentId, promptSent: false };
287
+ }
@@ -0,0 +1,262 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { createLogger, DEFAULTS, REDIS_KEYS } from "@lobu/core";
4
+ import type Redis from "ioredis";
5
+ import type { IMessageQueue } from "../infrastructure/queue";
6
+ import {
7
+ computeSessionKey,
8
+ type ISessionManager,
9
+ type SessionStore,
10
+ type ThreadSession,
11
+ } from "../session";
12
+
13
+ const logger = createLogger("session-manager");
14
+
15
+ /**
16
+ * Redis-based session storage
17
+ * Sessions are stored with automatic TTL expiration
18
+ */
19
+ export class RedisSessionStore implements SessionStore {
20
+ private readonly SESSION_PREFIX = REDIS_KEYS.SESSION;
21
+ private readonly THREAD_INDEX_PREFIX = "conversation_index:";
22
+ private readonly DEFAULT_TTL_SECONDS = DEFAULTS.SESSION_TTL_SECONDS;
23
+ private redis: Redis;
24
+
25
+ constructor(queue: IMessageQueue) {
26
+ // Get Redis client from queue connection pool
27
+ this.redis = queue.getRedisClient();
28
+ }
29
+
30
+ private getSessionKey(sessionKey: string): string {
31
+ return `${this.SESSION_PREFIX}${sessionKey}`;
32
+ }
33
+
34
+ private getThreadIndexKey(channelId: string, threadTs: string): string {
35
+ return `${this.THREAD_INDEX_PREFIX}${channelId}:${threadTs}`;
36
+ }
37
+
38
+ async get(sessionKey: string): Promise<ThreadSession | null> {
39
+ try {
40
+ const key = this.getSessionKey(sessionKey);
41
+ const data = await this.redis.get(key);
42
+
43
+ if (!data) {
44
+ return null;
45
+ }
46
+
47
+ // Parse JSON
48
+ return JSON.parse(data) as ThreadSession;
49
+ } catch (error) {
50
+ logger.error(`Failed to get session ${sessionKey}:`, error);
51
+ return null;
52
+ }
53
+ }
54
+
55
+ async set(sessionKey: string, session: ThreadSession): Promise<void> {
56
+ try {
57
+ const key = this.getSessionKey(sessionKey);
58
+ const indexKey = this.getThreadIndexKey(
59
+ session.channelId,
60
+ session.conversationId
61
+ );
62
+
63
+ // Atomically set both session and thread index keys
64
+ const pipeline = this.redis.pipeline();
65
+ pipeline.setex(key, this.DEFAULT_TTL_SECONDS, JSON.stringify(session));
66
+ pipeline.setex(
67
+ indexKey,
68
+ this.DEFAULT_TTL_SECONDS,
69
+ JSON.stringify({ sessionKey })
70
+ );
71
+ await pipeline.exec();
72
+
73
+ logger.debug(`Stored session ${sessionKey}`);
74
+ } catch (error) {
75
+ logger.error(`Failed to set session ${sessionKey}:`, error);
76
+ throw error;
77
+ }
78
+ }
79
+
80
+ async delete(sessionKey: string): Promise<void> {
81
+ try {
82
+ // Get session first to clean up thread index
83
+ const session = await this.get(sessionKey);
84
+
85
+ const key = this.getSessionKey(sessionKey);
86
+
87
+ // Atomically delete both session and thread index keys
88
+ if (session?.conversationId) {
89
+ const indexKey = this.getThreadIndexKey(
90
+ session.channelId,
91
+ session.conversationId
92
+ );
93
+ const pipeline = this.redis.pipeline();
94
+ pipeline.del(key);
95
+ pipeline.del(indexKey);
96
+ await pipeline.exec();
97
+ } else {
98
+ await this.redis.del(key);
99
+ }
100
+
101
+ logger.debug(`Deleted session ${sessionKey}`);
102
+ } catch (error) {
103
+ logger.error(`Failed to delete session ${sessionKey}:`, error);
104
+ throw error;
105
+ }
106
+ }
107
+
108
+ async getByThread(
109
+ channelId: string,
110
+ threadTs: string
111
+ ): Promise<ThreadSession | null> {
112
+ try {
113
+ const indexKey = this.getThreadIndexKey(channelId, threadTs);
114
+ const indexData = await this.redis.get(indexKey);
115
+
116
+ if (!indexData) {
117
+ return null;
118
+ }
119
+
120
+ const index = JSON.parse(indexData) as { sessionKey: string };
121
+ return await this.get(index.sessionKey);
122
+ } catch (error) {
123
+ logger.error(
124
+ `Failed to get session by thread ${channelId}:${threadTs}:`,
125
+ error
126
+ );
127
+ return null;
128
+ }
129
+ }
130
+
131
+ /** Optional cleanup - Redis handles this via TTL */
132
+ async cleanup?(): Promise<number> {
133
+ logger.debug("Redis TTL handles automatic cleanup");
134
+ return 0;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Session manager that abstracts session storage
140
+ * Provides thread ownership validation and session lifecycle management
141
+ */
142
+ export class SessionManager implements ISessionManager {
143
+ private store: SessionStore;
144
+
145
+ constructor(store: SessionStore) {
146
+ this.store = store;
147
+ }
148
+
149
+ /**
150
+ * Create a new session
151
+ */
152
+ async createSession(
153
+ channelId: string,
154
+ userId: string,
155
+ conversationId?: string,
156
+ threadCreator?: string
157
+ ): Promise<ThreadSession> {
158
+ const effectiveConversationId = conversationId || userId;
159
+ const session: ThreadSession = {
160
+ conversationId: effectiveConversationId,
161
+ channelId,
162
+ userId,
163
+ threadCreator: threadCreator || userId,
164
+ lastActivity: Date.now(),
165
+ createdAt: Date.now(),
166
+ };
167
+ const sessionKey = computeSessionKey(session);
168
+ await this.store.set(sessionKey, session);
169
+ return session;
170
+ }
171
+
172
+ /**
173
+ * Update session
174
+ */
175
+ async updateSession(
176
+ sessionKey: string,
177
+ updates: Partial<ThreadSession>
178
+ ): Promise<void> {
179
+ const session = await this.getSession(sessionKey);
180
+ if (session) {
181
+ const updated = { ...session, ...updates };
182
+ await this.store.set(sessionKey, updated);
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Get session by session key
188
+ */
189
+ async getSession(sessionKey: string): Promise<ThreadSession | null> {
190
+ return await this.store.get(sessionKey);
191
+ }
192
+
193
+ /**
194
+ * Create or update a session
195
+ */
196
+ async setSession(session: ThreadSession): Promise<void> {
197
+ const sessionKey = computeSessionKey(session);
198
+ await this.store.set(sessionKey, session);
199
+ }
200
+
201
+ /**
202
+ * Delete a session
203
+ */
204
+ async deleteSession(sessionKey: string): Promise<void> {
205
+ await this.store.delete(sessionKey);
206
+ }
207
+
208
+ /**
209
+ * Find session by thread
210
+ */
211
+ async findSessionByThread(
212
+ channelId: string,
213
+ threadTs: string
214
+ ): Promise<ThreadSession | null> {
215
+ return await this.store.getByThread(channelId, threadTs);
216
+ }
217
+
218
+ /**
219
+ * Validate thread ownership
220
+ * Returns true if the user is the thread creator or no session exists
221
+ */
222
+ async validateThreadOwnership(
223
+ channelId: string,
224
+ threadTs: string,
225
+ userId: string
226
+ ): Promise<{ allowed: boolean; owner?: string }> {
227
+ const session = await this.findSessionByThread(channelId, threadTs);
228
+
229
+ if (!session) {
230
+ return { allowed: true }; // No session, allow creation
231
+ }
232
+
233
+ if (!session.threadCreator) {
234
+ return { allowed: true }; // No owner set, allow
235
+ }
236
+
237
+ if (session.threadCreator === userId) {
238
+ return { allowed: true, owner: session.threadCreator };
239
+ }
240
+
241
+ return { allowed: false, owner: session.threadCreator };
242
+ }
243
+
244
+ /**
245
+ * Update session activity timestamp
246
+ */
247
+ async touchSession(sessionKey: string): Promise<void> {
248
+ const session = await this.getSession(sessionKey);
249
+ if (session) {
250
+ session.lastActivity = Date.now();
251
+ await this.setSession(session);
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Cleanup expired sessions (for in-memory stores)
257
+ * Note: Redis-based stores handle this automatically via TTL
258
+ */
259
+ async cleanupExpired(ttl: number): Promise<number> {
260
+ return (await this.store.cleanup?.(ttl)) || 0;
261
+ }
262
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * SettingsResolver — resolves effective agent settings with template fallback.
3
+ *
4
+ * Extracted from the store layer so sub-stores stay single-domain.
5
+ * Orchestrates across AgentConfigStore (settings + metadata) and
6
+ * AgentConnectionStore (connections) for template resolution.
7
+ */
8
+
9
+ import type {
10
+ AgentConfigStore,
11
+ AgentConnectionStore,
12
+ AgentSettings,
13
+ } from "@lobu/core";
14
+
15
+ export class SettingsResolver {
16
+ constructor(
17
+ private readonly config: AgentConfigStore,
18
+ private readonly connections: AgentConnectionStore
19
+ ) {}
20
+
21
+ /**
22
+ * Get effective settings for an agent, with template agent fallback.
23
+ * For sandbox agents, inherits from the template agent when own settings
24
+ * are missing or have no providers configured.
25
+ */
26
+ async getEffectiveSettings(agentId: string): Promise<AgentSettings | null> {
27
+ const settings = await this.config.getSettings(agentId);
28
+
29
+ // If settings exist and have providers, use them directly
30
+ if (settings?.installedProviders?.length) return settings;
31
+
32
+ // Resolve template agent ID
33
+ const templateAgentId = await this.resolveTemplateAgentId(
34
+ agentId,
35
+ settings
36
+ );
37
+ if (!templateAgentId) return settings;
38
+
39
+ const templateSettings = await this.config.getSettings(templateAgentId);
40
+ if (!templateSettings) return settings;
41
+
42
+ // Merge: own settings override template, but inherit missing fields
43
+ if (!settings) {
44
+ return { ...templateSettings, templateAgentId };
45
+ }
46
+
47
+ return {
48
+ ...templateSettings,
49
+ ...Object.fromEntries(
50
+ Object.entries(settings).filter(([, v]) => v !== undefined)
51
+ ),
52
+ templateAgentId,
53
+ } as AgentSettings;
54
+ }
55
+
56
+ /**
57
+ * Resolve the template agent ID for a sandbox agent.
58
+ * Chain: settings.templateAgentId → metadata.parentConnectionId → connection.templateAgentId
59
+ */
60
+ private async resolveTemplateAgentId(
61
+ agentId: string,
62
+ settings: AgentSettings | null
63
+ ): Promise<string | undefined> {
64
+ if (settings?.templateAgentId) return settings.templateAgentId;
65
+
66
+ const metadata = await this.config.getMetadata(agentId);
67
+ if (!metadata?.parentConnectionId) return undefined;
68
+
69
+ const conn = await this.connections.getConnection(
70
+ metadata.parentConnectionId
71
+ );
72
+ return conn?.templateAgentId;
73
+ }
74
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Resolves system config from system-skills.json.
3
+ * Handles provider configs and MCP server resolution.
4
+ *
5
+ * NOTE: Integration OAuth config resolution (getIntegrationConfig, isOAuthConfigured,
6
+ * overlayAgentOAuthCredentials, getSkillScopesForIntegration) has been removed.
7
+ * OAuth for third-party APIs is now handled by Owletto.
8
+ */
9
+ import type { ProviderConfigEntry } from "@lobu/core";
10
+ import type { SystemSkillsService } from "./system-skills-service";
11
+
12
+ export interface ResolvedMcpRegistryServer {
13
+ id: string;
14
+ name: string;
15
+ description: string;
16
+ type: "oauth" | "stdio" | "sse" | "api-key";
17
+ config: Record<string, unknown>;
18
+ }
19
+
20
+ export class SystemConfigResolver {
21
+ constructor(private readonly systemSkillsService: SystemSkillsService) {}
22
+
23
+ async getProviderConfigs(): Promise<Record<string, ProviderConfigEntry>> {
24
+ return this.systemSkillsService.getProviderConfigs();
25
+ }
26
+
27
+ async getGlobalMcpServers(): Promise<
28
+ Record<string, Record<string, unknown>>
29
+ > {
30
+ const systemSkills = await this.systemSkillsService.getSystemSkills();
31
+ const mcpServers: Record<string, Record<string, unknown>> = {};
32
+
33
+ for (const skill of systemSkills) {
34
+ for (const mcp of skill.mcpServers || []) {
35
+ if (!mcp?.id || mcpServers[mcp.id]) continue;
36
+
37
+ const type = mcp.type || (mcp.command ? "stdio" : "sse");
38
+ const config: Record<string, unknown> = { type };
39
+
40
+ if (mcp.url) config.url = mcp.url;
41
+ if (mcp.command) config.command = mcp.command;
42
+ if (Array.isArray(mcp.args) && mcp.args.length > 0) {
43
+ config.args = [...mcp.args];
44
+ }
45
+ if (mcp.oauth) config.oauth = mcp.oauth;
46
+ if (mcp.resource) config.resource = mcp.resource;
47
+ if (mcp.inputs) config.inputs = mcp.inputs;
48
+ if (mcp.headers) config.headers = mcp.headers;
49
+
50
+ mcpServers[mcp.id] = config;
51
+ }
52
+ }
53
+
54
+ return mcpServers;
55
+ }
56
+
57
+ async getMcpRegistryServers(): Promise<ResolvedMcpRegistryServer[]> {
58
+ const systemSkills = await this.systemSkillsService.getSystemSkills();
59
+ const entries: ResolvedMcpRegistryServer[] = [];
60
+ const seenIds = new Set<string>();
61
+
62
+ for (const skill of systemSkills) {
63
+ for (const mcp of skill.mcpServers || []) {
64
+ if (!mcp?.id || seenIds.has(mcp.id)) continue;
65
+ seenIds.add(mcp.id);
66
+
67
+ const type = mcp.type || (mcp.command ? "stdio" : "sse");
68
+ const config: Record<string, unknown> = {
69
+ type,
70
+ };
71
+
72
+ if (mcp.url) config.url = mcp.url;
73
+ if (mcp.command) config.command = mcp.command;
74
+ if (Array.isArray(mcp.args) && mcp.args.length > 0) {
75
+ config.args = [...mcp.args];
76
+ }
77
+
78
+ entries.push({
79
+ id: mcp.id,
80
+ name: mcp.name || mcp.id,
81
+ description: skill.description || `${skill.name} MCP server`,
82
+ type,
83
+ config,
84
+ });
85
+ }
86
+ }
87
+
88
+ return entries;
89
+ }
90
+ }