@lobu/gateway 2.8.0 → 3.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/__tests__/agent-config-routes.test.ts +254 -0
- package/src/__tests__/agent-history-routes.test.ts +72 -0
- package/src/__tests__/agent-routes.test.ts +68 -0
- package/src/__tests__/agent-schedules-routes.test.ts +59 -0
- package/src/__tests__/agent-settings-store.test.ts +323 -0
- package/src/__tests__/chat-instance-manager-slack.test.ts +204 -0
- package/src/__tests__/chat-response-bridge.test.ts +131 -0
- package/src/__tests__/config-memory-plugins.test.ts +92 -0
- package/src/__tests__/config-request-store.test.ts +127 -0
- package/src/__tests__/connection-routes.test.ts +144 -0
- package/src/__tests__/core-services-store-selection.test.ts +92 -0
- package/src/__tests__/docker-deployment.test.ts +1211 -0
- package/src/__tests__/embedded-deployment.test.ts +342 -0
- package/src/__tests__/grant-store.test.ts +148 -0
- package/src/__tests__/http-proxy.test.ts +281 -0
- package/src/__tests__/instruction-service.test.ts +37 -0
- package/src/__tests__/link-buttons.test.ts +112 -0
- package/src/__tests__/lobu.test.ts +32 -0
- package/src/__tests__/mcp-config-service.test.ts +347 -0
- package/src/__tests__/mcp-proxy.test.ts +696 -0
- package/src/__tests__/message-handler-bridge.test.ts +17 -0
- package/src/__tests__/model-selection.test.ts +172 -0
- package/src/__tests__/oauth-templates.test.ts +39 -0
- package/src/__tests__/platform-adapter-slack-send.test.ts +114 -0
- package/src/__tests__/platform-helpers-model-resolution.test.ts +253 -0
- package/src/__tests__/provider-inheritance.test.ts +212 -0
- package/src/__tests__/routes/cli-auth.test.ts +337 -0
- package/src/__tests__/routes/interactions.test.ts +121 -0
- package/src/__tests__/secret-proxy.test.ts +85 -0
- package/src/__tests__/session-manager.test.ts +572 -0
- package/src/__tests__/setup.ts +133 -0
- package/src/__tests__/skill-and-mcp-registry.test.ts +203 -0
- package/src/__tests__/slack-routes.test.ts +161 -0
- package/src/__tests__/system-config-resolver.test.ts +75 -0
- package/src/__tests__/system-message-limiter.test.ts +89 -0
- package/src/__tests__/system-skills-service.test.ts +362 -0
- package/src/__tests__/transcription-service.test.ts +222 -0
- package/src/__tests__/utils/rate-limiter.test.ts +102 -0
- package/src/__tests__/worker-connection-manager.test.ts +497 -0
- package/src/__tests__/worker-job-router.test.ts +722 -0
- package/src/api/index.ts +1 -0
- package/src/api/platform.ts +292 -0
- package/src/api/response-renderer.ts +157 -0
- package/src/auth/agent-metadata-store.ts +168 -0
- package/src/auth/api-auth-middleware.ts +69 -0
- package/src/auth/api-key-provider-module.ts +213 -0
- package/src/auth/base-provider-module.ts +201 -0
- package/src/auth/chatgpt/chatgpt-oauth-module.ts +185 -0
- package/src/auth/chatgpt/device-code-client.ts +218 -0
- package/src/auth/chatgpt/index.ts +1 -0
- package/src/auth/claude/oauth-module.ts +280 -0
- package/src/auth/cli/token-service.ts +249 -0
- package/src/auth/external/client.ts +560 -0
- package/src/auth/external/device-code-client.ts +225 -0
- package/src/auth/mcp/config-service.ts +392 -0
- package/src/auth/mcp/proxy.ts +1088 -0
- package/src/auth/mcp/string-substitution.ts +17 -0
- package/src/auth/mcp/tool-cache.ts +90 -0
- package/src/auth/oauth/base-client.ts +267 -0
- package/src/auth/oauth/client.ts +153 -0
- package/src/auth/oauth/credentials.ts +7 -0
- package/src/auth/oauth/providers.ts +69 -0
- package/src/auth/oauth/state-store.ts +150 -0
- package/src/auth/oauth-templates.ts +179 -0
- package/src/auth/provider-catalog.ts +220 -0
- package/src/auth/provider-model-options.ts +41 -0
- package/src/auth/settings/agent-settings-store.ts +565 -0
- package/src/auth/settings/auth-profiles-manager.ts +216 -0
- package/src/auth/settings/index.ts +12 -0
- package/src/auth/settings/model-preference-store.ts +52 -0
- package/src/auth/settings/model-selection.ts +135 -0
- package/src/auth/settings/resolved-settings-view.ts +298 -0
- package/src/auth/settings/template-utils.ts +44 -0
- package/src/auth/settings/token-service.ts +88 -0
- package/src/auth/system-env-store.ts +98 -0
- package/src/auth/user-agents-store.ts +68 -0
- package/src/channels/binding-service.ts +214 -0
- package/src/channels/index.ts +4 -0
- package/src/cli/gateway.ts +1304 -0
- package/src/cli/index.ts +74 -0
- package/src/commands/built-in-commands.ts +80 -0
- package/src/commands/command-dispatcher.ts +94 -0
- package/src/commands/command-reply-adapters.ts +27 -0
- package/src/config/file-loader.ts +618 -0
- package/src/config/index.ts +588 -0
- package/src/config/network-allowlist.ts +71 -0
- package/src/connections/chat-instance-manager.ts +1284 -0
- package/src/connections/chat-response-bridge.ts +618 -0
- package/src/connections/index.ts +7 -0
- package/src/connections/interaction-bridge.ts +831 -0
- package/src/connections/message-handler-bridge.ts +415 -0
- package/src/connections/platform-auth-methods.ts +15 -0
- package/src/connections/types.ts +84 -0
- package/src/gateway/connection-manager.ts +291 -0
- package/src/gateway/index.ts +700 -0
- package/src/gateway/job-router.ts +201 -0
- package/src/gateway-main.ts +200 -0
- package/src/index.ts +41 -0
- package/src/infrastructure/queue/index.ts +12 -0
- package/src/infrastructure/queue/queue-producer.ts +148 -0
- package/src/infrastructure/queue/redis-queue.ts +361 -0
- package/src/infrastructure/queue/types.ts +133 -0
- package/src/infrastructure/redis/system-message-limiter.ts +94 -0
- package/src/interactions/config-request-store.ts +198 -0
- package/src/interactions.ts +363 -0
- package/src/lobu.ts +311 -0
- package/src/metrics/prometheus.ts +159 -0
- package/src/modules/module-system.ts +179 -0
- package/src/orchestration/base-deployment-manager.ts +900 -0
- package/src/orchestration/deployment-utils.ts +98 -0
- package/src/orchestration/impl/docker-deployment.ts +620 -0
- package/src/orchestration/impl/embedded-deployment.ts +268 -0
- package/src/orchestration/impl/index.ts +8 -0
- package/src/orchestration/impl/k8s/deployment.ts +1061 -0
- package/src/orchestration/impl/k8s/helpers.ts +610 -0
- package/src/orchestration/impl/k8s/index.ts +1 -0
- package/src/orchestration/index.ts +333 -0
- package/src/orchestration/message-consumer.ts +584 -0
- package/src/orchestration/scheduled-wakeup.ts +704 -0
- package/src/permissions/approval-policy.ts +36 -0
- package/src/permissions/grant-store.ts +219 -0
- package/src/platform/file-handler.ts +66 -0
- package/src/platform/link-buttons.ts +57 -0
- package/src/platform/renderer-utils.ts +44 -0
- package/src/platform/response-renderer.ts +84 -0
- package/src/platform/unified-thread-consumer.ts +187 -0
- package/src/platform.ts +318 -0
- package/src/proxy/http-proxy.ts +752 -0
- package/src/proxy/proxy-manager.ts +81 -0
- package/src/proxy/secret-proxy.ts +402 -0
- package/src/proxy/token-refresh-job.ts +143 -0
- package/src/routes/internal/audio.ts +141 -0
- package/src/routes/internal/device-auth.ts +566 -0
- package/src/routes/internal/files.ts +226 -0
- package/src/routes/internal/history.ts +69 -0
- package/src/routes/internal/images.ts +127 -0
- package/src/routes/internal/interactions.ts +84 -0
- package/src/routes/internal/middleware.ts +23 -0
- package/src/routes/internal/schedule.ts +226 -0
- package/src/routes/internal/types.ts +22 -0
- package/src/routes/openapi-auto.ts +239 -0
- package/src/routes/public/agent-access.ts +23 -0
- package/src/routes/public/agent-config.ts +675 -0
- package/src/routes/public/agent-history.ts +422 -0
- package/src/routes/public/agent-schedules.ts +296 -0
- package/src/routes/public/agent.ts +1086 -0
- package/src/routes/public/agents.ts +373 -0
- package/src/routes/public/channels.ts +191 -0
- package/src/routes/public/cli-auth.ts +883 -0
- package/src/routes/public/connections.ts +574 -0
- package/src/routes/public/landing.ts +16 -0
- package/src/routes/public/oauth.ts +147 -0
- package/src/routes/public/settings-auth.ts +104 -0
- package/src/routes/public/slack.ts +173 -0
- package/src/routes/shared/agent-ownership.ts +101 -0
- package/src/routes/shared/token-verifier.ts +34 -0
- package/src/services/core-services.ts +1053 -0
- package/src/services/image-generation-service.ts +257 -0
- package/src/services/instruction-service.ts +318 -0
- package/src/services/mcp-registry.ts +94 -0
- package/src/services/platform-helpers.ts +287 -0
- package/src/services/session-manager.ts +262 -0
- package/src/services/settings-resolver.ts +74 -0
- package/src/services/system-config-resolver.ts +90 -0
- package/src/services/system-skills-service.ts +229 -0
- package/src/services/transcription-service.ts +684 -0
- package/src/session.ts +110 -0
- package/src/spaces/index.ts +1 -0
- package/src/spaces/space-resolver.ts +17 -0
- package/src/stores/in-memory-agent-store.ts +403 -0
- package/src/stores/redis-agent-store.ts +279 -0
- package/src/utils/public-url.ts +44 -0
- package/src/utils/rate-limiter.ts +94 -0
- package/tsconfig.json +33 -0
|
@@ -0,0 +1,1086 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createRoute, OpenAPIHono } from "@hono/zod-openapi";
|
|
3
|
+
import {
|
|
4
|
+
type AgentConfigStore,
|
|
5
|
+
createLogger,
|
|
6
|
+
createRootSpan,
|
|
7
|
+
findTemplateAgentId,
|
|
8
|
+
generateWorkerToken,
|
|
9
|
+
type InstalledProvider,
|
|
10
|
+
type McpServerConfig,
|
|
11
|
+
type NetworkConfig,
|
|
12
|
+
verifyWorkerToken,
|
|
13
|
+
} from "@lobu/core";
|
|
14
|
+
import { streamSSE } from "hono/streaming";
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
import {
|
|
17
|
+
createApiAuthMiddleware,
|
|
18
|
+
TOKEN_EXPIRATION_MS,
|
|
19
|
+
} from "../../auth/api-auth-middleware";
|
|
20
|
+
import type { CliTokenService } from "../../auth/cli/token-service";
|
|
21
|
+
import type { ExternalAuthClient } from "../../auth/external/client";
|
|
22
|
+
import type { AgentSettingsStore } from "../../auth/settings/agent-settings-store";
|
|
23
|
+
import type { QueueProducer } from "../../infrastructure/queue/queue-producer";
|
|
24
|
+
import { getModelProviderModules } from "../../modules/module-system";
|
|
25
|
+
import type { PlatformRegistry } from "../../platform";
|
|
26
|
+
import { resolveAgentOptions } from "../../services/platform-helpers";
|
|
27
|
+
import type { ISessionManager, ThreadSession } from "../../session";
|
|
28
|
+
|
|
29
|
+
const logger = createLogger("agent-api");
|
|
30
|
+
|
|
31
|
+
// =============================================================================
|
|
32
|
+
// Constants
|
|
33
|
+
// =============================================================================
|
|
34
|
+
|
|
35
|
+
const MAX_CONNECTIONS_PER_AGENT = 5;
|
|
36
|
+
const MAX_TOTAL_CONNECTIONS = 1000;
|
|
37
|
+
|
|
38
|
+
// SSE connection tracking
|
|
39
|
+
const sseConnections = new Map<string, Set<any>>();
|
|
40
|
+
|
|
41
|
+
// =============================================================================
|
|
42
|
+
// Zod Schemas
|
|
43
|
+
// =============================================================================
|
|
44
|
+
|
|
45
|
+
const NetworkConfigSchema = z.object({
|
|
46
|
+
allowedDomains: z.array(z.string()).optional(),
|
|
47
|
+
deniedDomains: z.array(z.string()).optional(),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const McpServerConfigSchema = z.object({
|
|
51
|
+
url: z.string().optional(),
|
|
52
|
+
type: z.enum(["sse", "stdio"]).optional(),
|
|
53
|
+
command: z.string().optional(),
|
|
54
|
+
args: z.array(z.string()).optional(),
|
|
55
|
+
env: z.record(z.string(), z.string()).optional(),
|
|
56
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
57
|
+
description: z.string().optional(),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const NixConfigSchema = z.object({
|
|
61
|
+
flakeUrl: z.string().optional(),
|
|
62
|
+
packages: z.array(z.string()).optional(),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const CreateAgentRequestSchema = z.object({
|
|
66
|
+
provider: z.string().default("claude").optional(),
|
|
67
|
+
model: z.string().optional(),
|
|
68
|
+
agentId: z.string().min(1).optional(),
|
|
69
|
+
userId: z.string().min(1).optional(),
|
|
70
|
+
thread: z.string().optional(),
|
|
71
|
+
forceNew: z.boolean().optional(),
|
|
72
|
+
dryRun: z.boolean().optional(),
|
|
73
|
+
networkConfig: NetworkConfigSchema.optional(),
|
|
74
|
+
mcpServers: z.record(z.string(), McpServerConfigSchema).optional(),
|
|
75
|
+
nix: NixConfigSchema.optional(),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const CreateAgentResponseSchema = z.object({
|
|
79
|
+
success: z.boolean(),
|
|
80
|
+
agentId: z.string(),
|
|
81
|
+
token: z.string(),
|
|
82
|
+
expiresAt: z.number(),
|
|
83
|
+
sseUrl: z.string(),
|
|
84
|
+
messagesUrl: z.string(),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const SlackRoutingInfoSchema = z.object({
|
|
88
|
+
channel: z.string().describe("Slack channel ID"),
|
|
89
|
+
thread: z.string().optional().describe("Thread timestamp for replies"),
|
|
90
|
+
team: z.string().optional().describe("Slack team ID"),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const SendMessageRequestSchema = z
|
|
94
|
+
.object({
|
|
95
|
+
content: z.string().optional().describe("Message content"),
|
|
96
|
+
message: z
|
|
97
|
+
.string()
|
|
98
|
+
.optional()
|
|
99
|
+
.describe("Message content (alias for content)"),
|
|
100
|
+
messageId: z.string().optional(),
|
|
101
|
+
platform: z
|
|
102
|
+
.string()
|
|
103
|
+
.optional()
|
|
104
|
+
.describe("Target platform (api, slack, telegram)"),
|
|
105
|
+
slack: SlackRoutingInfoSchema.optional().describe(
|
|
106
|
+
"Slack-specific routing info (required when platform=slack)"
|
|
107
|
+
),
|
|
108
|
+
})
|
|
109
|
+
.passthrough();
|
|
110
|
+
|
|
111
|
+
const SendMessageResponseSchema = z.object({
|
|
112
|
+
success: z.boolean(),
|
|
113
|
+
messageId: z.string(),
|
|
114
|
+
agentId: z.string().optional(),
|
|
115
|
+
jobId: z.string().optional(),
|
|
116
|
+
eventsUrl: z.string().optional(),
|
|
117
|
+
queued: z.boolean(),
|
|
118
|
+
traceparent: z.string().optional(),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const AgentStatusResponseSchema = z.object({
|
|
122
|
+
success: z.boolean(),
|
|
123
|
+
agent: z.object({
|
|
124
|
+
agentId: z.string(),
|
|
125
|
+
userId: z.string(),
|
|
126
|
+
status: z.string(),
|
|
127
|
+
createdAt: z.number(),
|
|
128
|
+
lastActivity: z.number(),
|
|
129
|
+
hasActiveConnection: z.boolean(),
|
|
130
|
+
}),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const ErrorResponseSchema = z.object({
|
|
134
|
+
success: z.boolean(),
|
|
135
|
+
error: z.string(),
|
|
136
|
+
details: z.string().optional(),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const SuccessResponseSchema = z.object({
|
|
140
|
+
success: z.boolean(),
|
|
141
|
+
message: z.string().optional(),
|
|
142
|
+
agentId: z.string().optional(),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Path parameters
|
|
146
|
+
const AgentIdParamSchema = z.object({
|
|
147
|
+
agentId: z.string(),
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// =============================================================================
|
|
151
|
+
// Validation Helpers
|
|
152
|
+
// =============================================================================
|
|
153
|
+
|
|
154
|
+
function validateDomainPattern(pattern: string): string | null {
|
|
155
|
+
if (!pattern || typeof pattern !== "string") {
|
|
156
|
+
return "Domain pattern must be a non-empty string";
|
|
157
|
+
}
|
|
158
|
+
const trimmed = pattern.trim().toLowerCase();
|
|
159
|
+
if (trimmed === "*") return "Bare wildcard '*' is not allowed";
|
|
160
|
+
if (trimmed.includes("://"))
|
|
161
|
+
return `Domain pattern cannot contain protocol: ${pattern}`;
|
|
162
|
+
if (trimmed.includes("/"))
|
|
163
|
+
return `Domain pattern cannot contain path: ${pattern}`;
|
|
164
|
+
if (trimmed.includes(":") && !trimmed.includes("[")) {
|
|
165
|
+
return `Domain pattern cannot contain port: ${pattern}`;
|
|
166
|
+
}
|
|
167
|
+
if (trimmed.startsWith("*.") || trimmed.startsWith(".")) {
|
|
168
|
+
const domain = trimmed.startsWith("*.")
|
|
169
|
+
? trimmed.substring(2)
|
|
170
|
+
: trimmed.substring(1);
|
|
171
|
+
if (!domain.includes(".")) {
|
|
172
|
+
return `Wildcard pattern too broad: ${pattern}`;
|
|
173
|
+
}
|
|
174
|
+
} else if (!trimmed.includes(".")) {
|
|
175
|
+
return `Invalid domain pattern: ${pattern}`;
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function validateNetworkConfig(config: NetworkConfig): string | null {
|
|
181
|
+
for (const domains of [config.allowedDomains, config.deniedDomains]) {
|
|
182
|
+
if (domains) {
|
|
183
|
+
for (const domain of domains) {
|
|
184
|
+
const error = validateDomainPattern(domain);
|
|
185
|
+
if (error) return error;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function validateMcpServerConfig(
|
|
193
|
+
id: string,
|
|
194
|
+
config: McpServerConfig
|
|
195
|
+
): string | null {
|
|
196
|
+
if (!config.url && !config.command) {
|
|
197
|
+
return `MCP ${id}: must specify either 'url' or 'command'`;
|
|
198
|
+
}
|
|
199
|
+
if (
|
|
200
|
+
config.url &&
|
|
201
|
+
!config.url.startsWith("http://") &&
|
|
202
|
+
!config.url.startsWith("https://")
|
|
203
|
+
) {
|
|
204
|
+
return `MCP ${id}: url must be http:// or https://`;
|
|
205
|
+
}
|
|
206
|
+
if (config.command) {
|
|
207
|
+
const dangerousCommands = [
|
|
208
|
+
"rm",
|
|
209
|
+
"sudo",
|
|
210
|
+
"curl",
|
|
211
|
+
"wget",
|
|
212
|
+
"sh",
|
|
213
|
+
"bash",
|
|
214
|
+
"zsh",
|
|
215
|
+
"kill",
|
|
216
|
+
];
|
|
217
|
+
const baseCommand = config.command.split("/").pop()?.split(" ")[0] || "";
|
|
218
|
+
if (dangerousCommands.includes(baseCommand)) {
|
|
219
|
+
return `MCP ${id}: command '${baseCommand}' is not allowed`;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function validateMcpConfig(
|
|
226
|
+
mcpServers: Record<string, McpServerConfig>
|
|
227
|
+
): string | null {
|
|
228
|
+
for (const [id, config] of Object.entries(mcpServers)) {
|
|
229
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(id)) {
|
|
230
|
+
return `MCP ID '${id}' is invalid`;
|
|
231
|
+
}
|
|
232
|
+
const error = validateMcpServerConfig(id, config);
|
|
233
|
+
if (error) return error;
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// =============================================================================
|
|
239
|
+
// Broadcast Functions (exported for use by other modules)
|
|
240
|
+
// =============================================================================
|
|
241
|
+
|
|
242
|
+
export function broadcastToAgent(
|
|
243
|
+
agentId: string,
|
|
244
|
+
event: string,
|
|
245
|
+
data: unknown
|
|
246
|
+
): void {
|
|
247
|
+
const connections = sseConnections.get(agentId);
|
|
248
|
+
if (!connections || connections.size === 0) return;
|
|
249
|
+
|
|
250
|
+
const deadConnections = new Set<any>();
|
|
251
|
+
|
|
252
|
+
for (const res of connections) {
|
|
253
|
+
try {
|
|
254
|
+
if (res.closed || res.destroyed || res.writableEnded) {
|
|
255
|
+
deadConnections.add(res);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (typeof res.writeSSE === "function") {
|
|
259
|
+
res.writeSSE({ event, data: JSON.stringify(data) });
|
|
260
|
+
} else if (typeof res.write === "function") {
|
|
261
|
+
const message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
262
|
+
res.write(message);
|
|
263
|
+
}
|
|
264
|
+
} catch {
|
|
265
|
+
deadConnections.add(res);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
for (const deadRes of deadConnections) {
|
|
270
|
+
connections.delete(deadRes);
|
|
271
|
+
}
|
|
272
|
+
if (connections.size === 0) {
|
|
273
|
+
sseConnections.delete(agentId);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// =============================================================================
|
|
278
|
+
// OpenAPI Route Definitions
|
|
279
|
+
// =============================================================================
|
|
280
|
+
|
|
281
|
+
const createAgentRoute = createRoute({
|
|
282
|
+
method: "post",
|
|
283
|
+
path: "/api/v1/agents",
|
|
284
|
+
tags: ["Agents"],
|
|
285
|
+
summary: "Create a new agent",
|
|
286
|
+
security: [{ bearerAuth: [] }],
|
|
287
|
+
description:
|
|
288
|
+
"Creates a new agent session and returns authentication credentials",
|
|
289
|
+
request: {
|
|
290
|
+
body: {
|
|
291
|
+
content: { "application/json": { schema: CreateAgentRequestSchema } },
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
responses: {
|
|
295
|
+
201: {
|
|
296
|
+
description: "Agent created",
|
|
297
|
+
content: { "application/json": { schema: CreateAgentResponseSchema } },
|
|
298
|
+
},
|
|
299
|
+
400: {
|
|
300
|
+
description: "Invalid request",
|
|
301
|
+
content: { "application/json": { schema: ErrorResponseSchema } },
|
|
302
|
+
},
|
|
303
|
+
401: {
|
|
304
|
+
description: "Unauthorized",
|
|
305
|
+
content: { "application/json": { schema: ErrorResponseSchema } },
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const getAgentRoute = createRoute({
|
|
311
|
+
method: "get",
|
|
312
|
+
path: "/api/v1/agents/{agentId}",
|
|
313
|
+
tags: ["Agents"],
|
|
314
|
+
summary: "Get agent status",
|
|
315
|
+
security: [{ bearerAuth: [] }],
|
|
316
|
+
request: { params: AgentIdParamSchema },
|
|
317
|
+
responses: {
|
|
318
|
+
200: {
|
|
319
|
+
description: "Agent status",
|
|
320
|
+
content: { "application/json": { schema: AgentStatusResponseSchema } },
|
|
321
|
+
},
|
|
322
|
+
401: {
|
|
323
|
+
description: "Unauthorized",
|
|
324
|
+
content: { "application/json": { schema: ErrorResponseSchema } },
|
|
325
|
+
},
|
|
326
|
+
404: {
|
|
327
|
+
description: "Not found",
|
|
328
|
+
content: { "application/json": { schema: ErrorResponseSchema } },
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const deleteAgentRoute = createRoute({
|
|
334
|
+
method: "delete",
|
|
335
|
+
path: "/api/v1/agents/{agentId}",
|
|
336
|
+
tags: ["Agents"],
|
|
337
|
+
summary: "Delete an agent",
|
|
338
|
+
security: [{ bearerAuth: [] }],
|
|
339
|
+
request: { params: AgentIdParamSchema },
|
|
340
|
+
responses: {
|
|
341
|
+
200: {
|
|
342
|
+
description: "Agent deleted",
|
|
343
|
+
content: { "application/json": { schema: SuccessResponseSchema } },
|
|
344
|
+
},
|
|
345
|
+
401: {
|
|
346
|
+
description: "Unauthorized",
|
|
347
|
+
content: { "application/json": { schema: ErrorResponseSchema } },
|
|
348
|
+
},
|
|
349
|
+
404: {
|
|
350
|
+
description: "Not found",
|
|
351
|
+
content: { "application/json": { schema: ErrorResponseSchema } },
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
const getAgentEventsRoute = createRoute({
|
|
357
|
+
method: "get",
|
|
358
|
+
path: "/api/v1/agents/{agentId}/events",
|
|
359
|
+
tags: ["Messages"],
|
|
360
|
+
summary: "Subscribe to agent events (SSE)",
|
|
361
|
+
description: "Server-Sent Events stream for real-time agent updates",
|
|
362
|
+
security: [{ bearerAuth: [] }],
|
|
363
|
+
request: { params: AgentIdParamSchema },
|
|
364
|
+
responses: {
|
|
365
|
+
200: {
|
|
366
|
+
description: "SSE stream",
|
|
367
|
+
content: { "text/event-stream": { schema: z.string() } },
|
|
368
|
+
},
|
|
369
|
+
401: {
|
|
370
|
+
description: "Unauthorized",
|
|
371
|
+
content: { "application/json": { schema: ErrorResponseSchema } },
|
|
372
|
+
},
|
|
373
|
+
429: {
|
|
374
|
+
description: "Too many connections",
|
|
375
|
+
content: { "application/json": { schema: ErrorResponseSchema } },
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const sendMessageRoute = createRoute({
|
|
381
|
+
method: "post",
|
|
382
|
+
path: "/api/v1/agents/{agentId}/messages",
|
|
383
|
+
tags: ["Messages"],
|
|
384
|
+
summary: "Send a message to the agent",
|
|
385
|
+
description:
|
|
386
|
+
"Send a message to an agent. Supports JSON body or multipart form data for file uploads. " +
|
|
387
|
+
"When platform is specified, the message is routed through the platform adapter.",
|
|
388
|
+
security: [{ bearerAuth: [] }],
|
|
389
|
+
request: {
|
|
390
|
+
params: AgentIdParamSchema,
|
|
391
|
+
body: {
|
|
392
|
+
content: {
|
|
393
|
+
"application/json": { schema: SendMessageRequestSchema },
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
responses: {
|
|
398
|
+
200: {
|
|
399
|
+
description: "Message queued",
|
|
400
|
+
content: { "application/json": { schema: SendMessageResponseSchema } },
|
|
401
|
+
},
|
|
402
|
+
400: {
|
|
403
|
+
description: "Invalid request",
|
|
404
|
+
content: { "application/json": { schema: ErrorResponseSchema } },
|
|
405
|
+
},
|
|
406
|
+
401: {
|
|
407
|
+
description: "Unauthorized",
|
|
408
|
+
content: { "application/json": { schema: ErrorResponseSchema } },
|
|
409
|
+
},
|
|
410
|
+
403: {
|
|
411
|
+
description: "Forbidden - worker tokens cannot route to platforms",
|
|
412
|
+
content: { "application/json": { schema: ErrorResponseSchema } },
|
|
413
|
+
},
|
|
414
|
+
404: {
|
|
415
|
+
description: "Agent not found",
|
|
416
|
+
content: { "application/json": { schema: ErrorResponseSchema } },
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
// =============================================================================
|
|
422
|
+
// Create OpenAPI Hono App
|
|
423
|
+
// =============================================================================
|
|
424
|
+
|
|
425
|
+
export interface AgentApiConfig {
|
|
426
|
+
queueProducer: QueueProducer;
|
|
427
|
+
sessionManager: ISessionManager;
|
|
428
|
+
publicGatewayUrl: string;
|
|
429
|
+
adminPassword?: string;
|
|
430
|
+
cliTokenService?: CliTokenService;
|
|
431
|
+
externalAuthClient?: ExternalAuthClient;
|
|
432
|
+
agentSettingsStore?: AgentSettingsStore;
|
|
433
|
+
agentConfigStore?: Pick<AgentConfigStore, "getSettings" | "listAgents">;
|
|
434
|
+
platformRegistry?: PlatformRegistry;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export function createAgentApi(config: AgentApiConfig): OpenAPIHono;
|
|
438
|
+
export function createAgentApi(
|
|
439
|
+
queueProducer: QueueProducer,
|
|
440
|
+
sessionManager: ISessionManager,
|
|
441
|
+
publicGatewayUrl: string
|
|
442
|
+
): OpenAPIHono;
|
|
443
|
+
export function createAgentApi(
|
|
444
|
+
configOrQueue: AgentApiConfig | QueueProducer,
|
|
445
|
+
sessionManager?: ISessionManager,
|
|
446
|
+
publicGatewayUrl?: string
|
|
447
|
+
): OpenAPIHono {
|
|
448
|
+
const config: AgentApiConfig =
|
|
449
|
+
configOrQueue instanceof Object && "queueProducer" in configOrQueue
|
|
450
|
+
? configOrQueue
|
|
451
|
+
: {
|
|
452
|
+
queueProducer: configOrQueue as QueueProducer,
|
|
453
|
+
sessionManager: sessionManager!,
|
|
454
|
+
publicGatewayUrl: publicGatewayUrl!,
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
const {
|
|
458
|
+
queueProducer,
|
|
459
|
+
adminPassword,
|
|
460
|
+
cliTokenService,
|
|
461
|
+
agentSettingsStore,
|
|
462
|
+
agentConfigStore,
|
|
463
|
+
platformRegistry,
|
|
464
|
+
} = config;
|
|
465
|
+
const sessMgr = config.sessionManager;
|
|
466
|
+
const pubUrl = config.publicGatewayUrl;
|
|
467
|
+
const app = new OpenAPIHono();
|
|
468
|
+
|
|
469
|
+
// Unified auth middleware for all agent API routes
|
|
470
|
+
app.use(
|
|
471
|
+
"/api/v1/agents/*",
|
|
472
|
+
createApiAuthMiddleware({
|
|
473
|
+
adminPassword,
|
|
474
|
+
cliTokenService,
|
|
475
|
+
externalAuthClient: config.externalAuthClient,
|
|
476
|
+
allowSettingsSession: true,
|
|
477
|
+
})
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
// =============================================================================
|
|
481
|
+
// Route Handlers
|
|
482
|
+
// =============================================================================
|
|
483
|
+
|
|
484
|
+
// POST /api/v1/agents - Create agent
|
|
485
|
+
app.openapi(createAgentRoute, async (c): Promise<any> => {
|
|
486
|
+
const body = c.req.valid("json");
|
|
487
|
+
const {
|
|
488
|
+
provider = "claude",
|
|
489
|
+
model,
|
|
490
|
+
agentId: requestedAgentId,
|
|
491
|
+
userId: requestedUserId,
|
|
492
|
+
thread,
|
|
493
|
+
forceNew,
|
|
494
|
+
dryRun,
|
|
495
|
+
networkConfig,
|
|
496
|
+
mcpServers,
|
|
497
|
+
nix: nixConfig,
|
|
498
|
+
} = body;
|
|
499
|
+
|
|
500
|
+
// Validate provider
|
|
501
|
+
if (provider && !["claude"].includes(provider)) {
|
|
502
|
+
return c.json(
|
|
503
|
+
{ success: false, error: "Invalid provider. Supported: claude" },
|
|
504
|
+
400
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Validate network config
|
|
509
|
+
if (networkConfig) {
|
|
510
|
+
const error = validateNetworkConfig(networkConfig as NetworkConfig);
|
|
511
|
+
if (error) return c.json({ success: false, error }, 400);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Validate MCP config
|
|
515
|
+
if (mcpServers) {
|
|
516
|
+
const error = validateMcpConfig(
|
|
517
|
+
mcpServers as Record<string, McpServerConfig>
|
|
518
|
+
);
|
|
519
|
+
if (error) return c.json({ success: false, error }, 400);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const isEphemeral = !requestedAgentId?.trim();
|
|
523
|
+
const agentId = requestedAgentId?.trim() || randomUUID();
|
|
524
|
+
|
|
525
|
+
// For ephemeral agents, auto-provision settings so the worker gets provider config
|
|
526
|
+
if (isEphemeral && agentSettingsStore) {
|
|
527
|
+
// Try system-key providers first (env var based API keys)
|
|
528
|
+
const providerModules = getModelProviderModules();
|
|
529
|
+
const systemProviders: InstalledProvider[] = providerModules
|
|
530
|
+
.filter((m) => m.hasSystemKey())
|
|
531
|
+
.map((m) => ({
|
|
532
|
+
providerId: m.providerId,
|
|
533
|
+
installedAt: Date.now(),
|
|
534
|
+
}));
|
|
535
|
+
|
|
536
|
+
if (systemProviders.length > 0) {
|
|
537
|
+
// Also inherit pluginsConfig from template agent if available
|
|
538
|
+
const templateId = agentConfigStore
|
|
539
|
+
? await findTemplateAgentId(agentConfigStore)
|
|
540
|
+
: await agentSettingsStore.findTemplateAgentId();
|
|
541
|
+
const templateSettings = templateId
|
|
542
|
+
? await (agentConfigStore?.getSettings(templateId) ??
|
|
543
|
+
agentSettingsStore.getSettings(templateId))
|
|
544
|
+
: null;
|
|
545
|
+
await agentSettingsStore.saveSettings(agentId, {
|
|
546
|
+
installedProviders: systemProviders,
|
|
547
|
+
pluginsConfig: templateSettings?.pluginsConfig,
|
|
548
|
+
});
|
|
549
|
+
logger.info(
|
|
550
|
+
`Ephemeral agent ${agentId}: provisioned system providers [${systemProviders.map((p) => p.providerId).join(", ")}]`
|
|
551
|
+
);
|
|
552
|
+
} else {
|
|
553
|
+
// Fall back to using an existing agent as template (inherits its providers)
|
|
554
|
+
const templateId = agentConfigStore
|
|
555
|
+
? await findTemplateAgentId(agentConfigStore)
|
|
556
|
+
: await agentSettingsStore.findTemplateAgentId();
|
|
557
|
+
if (templateId) {
|
|
558
|
+
const templateSettings = await (agentConfigStore?.getSettings(
|
|
559
|
+
templateId
|
|
560
|
+
) ?? agentSettingsStore.getSettings(templateId));
|
|
561
|
+
await agentSettingsStore.saveSettings(agentId, {
|
|
562
|
+
templateAgentId: templateId,
|
|
563
|
+
pluginsConfig: templateSettings?.pluginsConfig,
|
|
564
|
+
});
|
|
565
|
+
logger.info(
|
|
566
|
+
`Ephemeral agent ${agentId}: using template ${templateId}`
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const userId = requestedUserId || agentId;
|
|
573
|
+
|
|
574
|
+
// Build composite conversationId for user-specific sessions
|
|
575
|
+
// Uses _ separator (colons not allowed in BullMQ custom IDs)
|
|
576
|
+
const conversationId = thread
|
|
577
|
+
? `${agentId}_${userId}_${thread}`
|
|
578
|
+
: `${agentId}_${userId}`;
|
|
579
|
+
const channelId = `api_${userId}`;
|
|
580
|
+
const deploymentName = `api-${agentId.slice(0, 8)}`;
|
|
581
|
+
|
|
582
|
+
// Try to resume existing session (unless forceNew is requested)
|
|
583
|
+
if (!forceNew) {
|
|
584
|
+
const existing = await sessMgr.getSession(conversationId);
|
|
585
|
+
if (existing) {
|
|
586
|
+
// Reuse existing session — touch lastActivity and return existing token
|
|
587
|
+
await sessMgr.touchSession(conversationId);
|
|
588
|
+
|
|
589
|
+
const token = generateWorkerToken(
|
|
590
|
+
agentId,
|
|
591
|
+
conversationId,
|
|
592
|
+
deploymentName,
|
|
593
|
+
{
|
|
594
|
+
channelId,
|
|
595
|
+
agentId,
|
|
596
|
+
platform: "api",
|
|
597
|
+
sessionKey: userId,
|
|
598
|
+
}
|
|
599
|
+
);
|
|
600
|
+
|
|
601
|
+
const expiresAt = Date.now() + TOKEN_EXPIRATION_MS;
|
|
602
|
+
const baseUrl = pubUrl || "http://localhost:8080";
|
|
603
|
+
|
|
604
|
+
logger.info(
|
|
605
|
+
`Resumed API session: ${conversationId} (agent=${agentId})`
|
|
606
|
+
);
|
|
607
|
+
|
|
608
|
+
return c.json(
|
|
609
|
+
{
|
|
610
|
+
success: true,
|
|
611
|
+
agentId: conversationId,
|
|
612
|
+
token,
|
|
613
|
+
expiresAt,
|
|
614
|
+
sseUrl: `${baseUrl}/api/v1/agents/${conversationId}/events`,
|
|
615
|
+
messagesUrl: `${baseUrl}/api/v1/agents/${conversationId}/messages`,
|
|
616
|
+
},
|
|
617
|
+
201
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const token = generateWorkerToken(agentId, conversationId, deploymentName, {
|
|
623
|
+
channelId,
|
|
624
|
+
agentId,
|
|
625
|
+
platform: "api",
|
|
626
|
+
sessionKey: userId,
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
const expiresAt = Date.now() + TOKEN_EXPIRATION_MS;
|
|
630
|
+
|
|
631
|
+
const session: ThreadSession = {
|
|
632
|
+
conversationId,
|
|
633
|
+
channelId,
|
|
634
|
+
userId,
|
|
635
|
+
threadCreator: userId,
|
|
636
|
+
lastActivity: Date.now(),
|
|
637
|
+
createdAt: Date.now(),
|
|
638
|
+
status: "created",
|
|
639
|
+
provider,
|
|
640
|
+
model,
|
|
641
|
+
networkConfig: networkConfig as NetworkConfig | undefined,
|
|
642
|
+
mcpConfig: mcpServers
|
|
643
|
+
? { mcpServers: mcpServers as Record<string, McpServerConfig> }
|
|
644
|
+
: undefined,
|
|
645
|
+
nixConfig,
|
|
646
|
+
agentId,
|
|
647
|
+
dryRun: dryRun || false,
|
|
648
|
+
};
|
|
649
|
+
await sessMgr.setSession(session);
|
|
650
|
+
|
|
651
|
+
logger.info(`Created API agent: ${conversationId} (agent=${agentId})`);
|
|
652
|
+
|
|
653
|
+
const baseUrl = pubUrl || "http://localhost:8080";
|
|
654
|
+
return c.json(
|
|
655
|
+
{
|
|
656
|
+
success: true,
|
|
657
|
+
agentId: conversationId,
|
|
658
|
+
token,
|
|
659
|
+
expiresAt,
|
|
660
|
+
sseUrl: `${baseUrl}/api/v1/agents/${conversationId}/events`,
|
|
661
|
+
messagesUrl: `${baseUrl}/api/v1/agents/${conversationId}/messages`,
|
|
662
|
+
},
|
|
663
|
+
201
|
|
664
|
+
);
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
// GET /api/v1/agents/:agentId - Get status
|
|
668
|
+
app.openapi(getAgentRoute, async (c): Promise<any> => {
|
|
669
|
+
const { agentId: sessionKey } = c.req.valid("param");
|
|
670
|
+
|
|
671
|
+
const session = await sessMgr.getSession(sessionKey);
|
|
672
|
+
if (!session) {
|
|
673
|
+
return c.json({ success: false, error: "Agent not found" }, 404);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const hasActiveConnection =
|
|
677
|
+
sseConnections.has(sessionKey) &&
|
|
678
|
+
(sseConnections.get(sessionKey)?.size ?? 0) > 0;
|
|
679
|
+
|
|
680
|
+
return c.json({
|
|
681
|
+
success: true,
|
|
682
|
+
agent: {
|
|
683
|
+
agentId: session.conversationId,
|
|
684
|
+
userId: session.userId,
|
|
685
|
+
status: session.status || "active",
|
|
686
|
+
createdAt: session.createdAt,
|
|
687
|
+
lastActivity: session.lastActivity,
|
|
688
|
+
hasActiveConnection,
|
|
689
|
+
},
|
|
690
|
+
});
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
// DELETE /api/v1/agents/:agentId
|
|
694
|
+
app.openapi(deleteAgentRoute, async (c): Promise<any> => {
|
|
695
|
+
const { agentId: sessionKey } = c.req.valid("param");
|
|
696
|
+
|
|
697
|
+
const connections = sseConnections.get(sessionKey);
|
|
698
|
+
if (connections) {
|
|
699
|
+
for (const connection of connections) {
|
|
700
|
+
try {
|
|
701
|
+
if (typeof connection.writeSSE === "function") {
|
|
702
|
+
connection.writeSSE({
|
|
703
|
+
event: "closed",
|
|
704
|
+
data: JSON.stringify({ reason: "agent_deleted" }),
|
|
705
|
+
});
|
|
706
|
+
} else if (typeof connection.write === "function") {
|
|
707
|
+
connection.write(
|
|
708
|
+
`event: closed\ndata: ${JSON.stringify({ reason: "agent_deleted" })}\n\n`
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
connection.close?.();
|
|
712
|
+
connection.end?.();
|
|
713
|
+
} catch {
|
|
714
|
+
// Ignore
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
sseConnections.delete(sessionKey);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Get real agentId from session before deleting
|
|
721
|
+
const session = await sessMgr.getSession(sessionKey);
|
|
722
|
+
const realAgentId = session?.agentId || sessionKey;
|
|
723
|
+
|
|
724
|
+
await sessMgr.deleteSession(sessionKey);
|
|
725
|
+
// Clean up ephemeral agent settings
|
|
726
|
+
if (agentSettingsStore) {
|
|
727
|
+
await agentSettingsStore.deleteSettings(realAgentId).catch(() => {
|
|
728
|
+
/* best-effort cleanup */
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
logger.info(`Deleted agent ${sessionKey}`);
|
|
732
|
+
|
|
733
|
+
return c.json({
|
|
734
|
+
success: true,
|
|
735
|
+
message: "Agent deleted",
|
|
736
|
+
agentId: sessionKey,
|
|
737
|
+
});
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
// GET /api/v1/agents/:agentId/events - SSE stream
|
|
741
|
+
app.openapi(getAgentEventsRoute, async (c): Promise<any> => {
|
|
742
|
+
const { agentId: sessionKey } = c.req.valid("param");
|
|
743
|
+
|
|
744
|
+
const session = await sessMgr.getSession(sessionKey);
|
|
745
|
+
if (!session) {
|
|
746
|
+
return c.json({ success: false, error: "Agent not found" }, 404);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// Check connection limits
|
|
750
|
+
const totalConnections = Array.from(sseConnections.values()).reduce(
|
|
751
|
+
(acc, set) => acc + set.size,
|
|
752
|
+
0
|
|
753
|
+
);
|
|
754
|
+
if (totalConnections >= MAX_TOTAL_CONNECTIONS) {
|
|
755
|
+
return c.json(
|
|
756
|
+
{ success: false, error: "Server connection limit reached" },
|
|
757
|
+
429
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// Use conversationId as the SSE connection key (matches broadcastToAgent calls)
|
|
762
|
+
const sseKey = session.conversationId;
|
|
763
|
+
if (!sseConnections.has(sseKey)) {
|
|
764
|
+
sseConnections.set(sseKey, new Set());
|
|
765
|
+
}
|
|
766
|
+
const agentConnections = sseConnections.get(sseKey)!;
|
|
767
|
+
if (agentConnections.size >= MAX_CONNECTIONS_PER_AGENT) {
|
|
768
|
+
return c.json(
|
|
769
|
+
{
|
|
770
|
+
success: false,
|
|
771
|
+
error: `Maximum ${MAX_CONNECTIONS_PER_AGENT} connections`,
|
|
772
|
+
},
|
|
773
|
+
429
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Return SSE stream
|
|
778
|
+
return streamSSE(c, async (stream) => {
|
|
779
|
+
agentConnections.add(stream);
|
|
780
|
+
|
|
781
|
+
await stream.writeSSE({
|
|
782
|
+
event: "connected",
|
|
783
|
+
data: JSON.stringify({
|
|
784
|
+
agentId: session.agentId || sessionKey,
|
|
785
|
+
timestamp: Date.now(),
|
|
786
|
+
}),
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
const heartbeatInterval = setInterval(async () => {
|
|
790
|
+
try {
|
|
791
|
+
await stream.writeSSE({
|
|
792
|
+
event: "ping",
|
|
793
|
+
data: JSON.stringify({ timestamp: Date.now() }),
|
|
794
|
+
});
|
|
795
|
+
} catch {
|
|
796
|
+
clearInterval(heartbeatInterval);
|
|
797
|
+
}
|
|
798
|
+
}, 30000);
|
|
799
|
+
|
|
800
|
+
stream.onAbort(() => {
|
|
801
|
+
clearInterval(heartbeatInterval);
|
|
802
|
+
agentConnections.delete(stream);
|
|
803
|
+
if (agentConnections.size === 0) {
|
|
804
|
+
sseConnections.delete(sseKey);
|
|
805
|
+
}
|
|
806
|
+
logger.info(`SSE connection closed for session ${sseKey}`);
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
while (true) {
|
|
810
|
+
await stream.sleep(1000);
|
|
811
|
+
}
|
|
812
|
+
});
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
// POST /api/v1/agents/:agentId/messages - Send message
|
|
816
|
+
// Supports two paths:
|
|
817
|
+
// 1. Direct API (no platform field): requires pre-created session, enqueues directly
|
|
818
|
+
// 2. Platform-routed (platform field present): delegates to platform adapter
|
|
819
|
+
app.openapi(sendMessageRoute, async (c): Promise<any> => {
|
|
820
|
+
const { agentId } = c.req.valid("param");
|
|
821
|
+
|
|
822
|
+
// Parse body — multipart for file uploads, JSON otherwise
|
|
823
|
+
const contentType = c.req.header("content-type") || "";
|
|
824
|
+
let body: Record<string, any>;
|
|
825
|
+
let files: Array<{ buffer: Buffer; filename: string }> | undefined;
|
|
826
|
+
|
|
827
|
+
if (contentType.includes("multipart/form-data")) {
|
|
828
|
+
const formData = await c.req.formData();
|
|
829
|
+
body = {
|
|
830
|
+
content: formData.get("content") as string | null,
|
|
831
|
+
message: formData.get("message") as string | null,
|
|
832
|
+
messageId: formData.get("messageId") as string | null,
|
|
833
|
+
platform: formData.get("platform") as string | null,
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
// Extract nested platform routing from form fields
|
|
837
|
+
const slackChannel = formData.get("slack.channel") as string;
|
|
838
|
+
if (slackChannel) {
|
|
839
|
+
body.slack = {
|
|
840
|
+
channel: slackChannel,
|
|
841
|
+
thread: formData.get("slack.thread") as string | undefined,
|
|
842
|
+
team: formData.get("slack.team") as string | undefined,
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
const whatsappChat = formData.get("whatsapp.chat") as string;
|
|
846
|
+
if (whatsappChat) {
|
|
847
|
+
body.whatsapp = { chat: whatsappChat };
|
|
848
|
+
}
|
|
849
|
+
const telegramChatId = formData.get("telegram.chatId") as string;
|
|
850
|
+
if (telegramChatId) {
|
|
851
|
+
body.telegram = { chatId: telegramChatId };
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// Extract files with size validation
|
|
855
|
+
const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
|
856
|
+
const MAX_TOTAL_SIZE = 100 * 1024 * 1024;
|
|
857
|
+
const MAX_FILE_COUNT = 10;
|
|
858
|
+
const fileEntries = formData.getAll("files");
|
|
859
|
+
if (fileEntries.length > MAX_FILE_COUNT) {
|
|
860
|
+
return c.json(
|
|
861
|
+
{
|
|
862
|
+
success: false,
|
|
863
|
+
error: `Too many files: ${fileEntries.length} (max ${MAX_FILE_COUNT})`,
|
|
864
|
+
},
|
|
865
|
+
400
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
if (fileEntries.length > 0) {
|
|
869
|
+
const fileResults: Array<{ buffer: Buffer; filename: string }> = [];
|
|
870
|
+
let totalSize = 0;
|
|
871
|
+
for (const entry of fileEntries) {
|
|
872
|
+
if (entry instanceof File) {
|
|
873
|
+
if (entry.size > MAX_FILE_SIZE) {
|
|
874
|
+
return c.json(
|
|
875
|
+
{
|
|
876
|
+
success: false,
|
|
877
|
+
error: `File "${entry.name}" exceeds maximum size of ${MAX_FILE_SIZE / 1024 / 1024}MB`,
|
|
878
|
+
},
|
|
879
|
+
400
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
totalSize += entry.size;
|
|
883
|
+
if (totalSize > MAX_TOTAL_SIZE) {
|
|
884
|
+
return c.json(
|
|
885
|
+
{
|
|
886
|
+
success: false,
|
|
887
|
+
error: `Total upload size exceeds maximum of ${MAX_TOTAL_SIZE / 1024 / 1024}MB`,
|
|
888
|
+
},
|
|
889
|
+
400
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
const arrayBuffer = await entry.arrayBuffer();
|
|
893
|
+
fileResults.push({
|
|
894
|
+
buffer: Buffer.from(arrayBuffer),
|
|
895
|
+
filename: entry.name,
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
if (fileResults.length > 0) files = fileResults;
|
|
900
|
+
}
|
|
901
|
+
} else {
|
|
902
|
+
body = c.req.valid("json");
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
const messageContent = body.content || body.message;
|
|
906
|
+
const messageId = body.messageId || randomUUID();
|
|
907
|
+
|
|
908
|
+
if (!messageContent || typeof messageContent !== "string") {
|
|
909
|
+
return c.json({ success: false, error: "content is required" }, 400);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
const platform = body.platform as string | undefined;
|
|
913
|
+
|
|
914
|
+
// ── Platform-routed path ──────────────────────────────────────────────────
|
|
915
|
+
// When platform is specified, delegate to the platform adapter which handles
|
|
916
|
+
// session creation, routing, and file delivery.
|
|
917
|
+
if (platform) {
|
|
918
|
+
// Worker tokens cannot route to user-facing platform connections
|
|
919
|
+
const authHeader = c.req.header("Authorization");
|
|
920
|
+
const rawToken = authHeader?.startsWith("Bearer ")
|
|
921
|
+
? authHeader.substring(7)
|
|
922
|
+
: "";
|
|
923
|
+
if (verifyWorkerToken(rawToken)) {
|
|
924
|
+
return c.json(
|
|
925
|
+
{ success: false, error: "Worker tokens cannot route to platforms" },
|
|
926
|
+
403
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
if (!platformRegistry) {
|
|
931
|
+
return c.json(
|
|
932
|
+
{ success: false, error: "Platform routing not available" },
|
|
933
|
+
501
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
const adapter = platformRegistry.get(platform);
|
|
938
|
+
if (!adapter) {
|
|
939
|
+
return c.json(
|
|
940
|
+
{
|
|
941
|
+
success: false,
|
|
942
|
+
error: `Platform "${platform}" not found`,
|
|
943
|
+
details: `Available: ${platformRegistry.getAvailablePlatforms().join(", ")}`,
|
|
944
|
+
},
|
|
945
|
+
404
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
if (!adapter.sendMessage) {
|
|
950
|
+
return c.json(
|
|
951
|
+
{
|
|
952
|
+
success: false,
|
|
953
|
+
error: `Platform "${platform}" does not support sendMessage`,
|
|
954
|
+
},
|
|
955
|
+
501
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// Extract platform-specific routing info
|
|
960
|
+
let channelId = agentId;
|
|
961
|
+
let conversationId: string | undefined =
|
|
962
|
+
platform === "api" ? agentId : undefined;
|
|
963
|
+
let teamId = "api";
|
|
964
|
+
|
|
965
|
+
if (adapter.extractRoutingInfo) {
|
|
966
|
+
const routingInfo = adapter.extractRoutingInfo(
|
|
967
|
+
body as Record<string, unknown>
|
|
968
|
+
);
|
|
969
|
+
if (routingInfo) {
|
|
970
|
+
channelId = routingInfo.channelId;
|
|
971
|
+
conversationId = routingInfo.conversationId || conversationId;
|
|
972
|
+
teamId = routingInfo.teamId || "api";
|
|
973
|
+
} else if (platform !== "api") {
|
|
974
|
+
return c.json(
|
|
975
|
+
{
|
|
976
|
+
success: false,
|
|
977
|
+
error: `Platform-specific routing info required for ${platform}`,
|
|
978
|
+
},
|
|
979
|
+
400
|
|
980
|
+
);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
logger.info(
|
|
985
|
+
`Sending message via ${platform}: agentId=${agentId}, channelId=${channelId}${files?.length ? `, files=${files.length}` : ""}`
|
|
986
|
+
);
|
|
987
|
+
|
|
988
|
+
try {
|
|
989
|
+
const result = await adapter.sendMessage(rawToken, messageContent, {
|
|
990
|
+
agentId,
|
|
991
|
+
channelId,
|
|
992
|
+
conversationId,
|
|
993
|
+
teamId,
|
|
994
|
+
files,
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
return c.json({
|
|
998
|
+
success: true,
|
|
999
|
+
agentId,
|
|
1000
|
+
messageId: result.messageId,
|
|
1001
|
+
eventsUrl: result.eventsUrl,
|
|
1002
|
+
queued: result.queued || false,
|
|
1003
|
+
});
|
|
1004
|
+
} catch (error) {
|
|
1005
|
+
logger.error("Failed to send platform message", { error });
|
|
1006
|
+
return c.json({ success: false, error: "Internal server error" }, 500);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// ── Direct API path ───────────────────────────────────────────────────────
|
|
1011
|
+
// No platform field: use existing session-based direct enqueue
|
|
1012
|
+
const session = await sessMgr.getSession(agentId);
|
|
1013
|
+
if (!session) {
|
|
1014
|
+
return c.json({ success: false, error: "Agent not found" }, 404);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
await sessMgr.touchSession(agentId);
|
|
1018
|
+
|
|
1019
|
+
const realAgentId = session.agentId || agentId;
|
|
1020
|
+
|
|
1021
|
+
const { span: rootSpan, traceparent } = createRootSpan("message_received", {
|
|
1022
|
+
"lobu.agent_id": realAgentId,
|
|
1023
|
+
"lobu.message_id": messageId,
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
try {
|
|
1027
|
+
const channelId = session.channelId || `api_${session.userId}`;
|
|
1028
|
+
|
|
1029
|
+
const baseOptions: Record<string, any> = {
|
|
1030
|
+
provider: session.provider || "claude",
|
|
1031
|
+
model: session.model,
|
|
1032
|
+
};
|
|
1033
|
+
const agentOptions = await resolveAgentOptions(
|
|
1034
|
+
realAgentId,
|
|
1035
|
+
baseOptions,
|
|
1036
|
+
agentSettingsStore
|
|
1037
|
+
);
|
|
1038
|
+
|
|
1039
|
+
const {
|
|
1040
|
+
networkConfig: settingsNetwork,
|
|
1041
|
+
mcpServers: settingsMcpServers,
|
|
1042
|
+
...remainingOptions
|
|
1043
|
+
} = agentOptions;
|
|
1044
|
+
|
|
1045
|
+
const jobId = await queueProducer.enqueueMessage({
|
|
1046
|
+
userId: session.userId,
|
|
1047
|
+
conversationId: session.conversationId || agentId,
|
|
1048
|
+
messageId,
|
|
1049
|
+
channelId,
|
|
1050
|
+
teamId: "api",
|
|
1051
|
+
agentId: realAgentId,
|
|
1052
|
+
botId: "lobu-api",
|
|
1053
|
+
platform: "api",
|
|
1054
|
+
messageText: messageContent,
|
|
1055
|
+
platformMetadata: {
|
|
1056
|
+
agentId: realAgentId,
|
|
1057
|
+
source: "direct-api",
|
|
1058
|
+
traceparent: traceparent || undefined,
|
|
1059
|
+
dryRun: session.dryRun || false,
|
|
1060
|
+
},
|
|
1061
|
+
agentOptions: remainingOptions,
|
|
1062
|
+
networkConfig: session.networkConfig || settingsNetwork,
|
|
1063
|
+
mcpConfig:
|
|
1064
|
+
session.mcpConfig ||
|
|
1065
|
+
(settingsMcpServers ? { mcpServers: settingsMcpServers } : undefined),
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
rootSpan?.end();
|
|
1069
|
+
|
|
1070
|
+
return c.json({
|
|
1071
|
+
success: true,
|
|
1072
|
+
messageId,
|
|
1073
|
+
jobId,
|
|
1074
|
+
queued: true,
|
|
1075
|
+
traceparent: traceparent || undefined,
|
|
1076
|
+
});
|
|
1077
|
+
} catch (error) {
|
|
1078
|
+
rootSpan?.end();
|
|
1079
|
+
throw error;
|
|
1080
|
+
}
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
logger.debug("Hono Agent API routes registered");
|
|
1084
|
+
|
|
1085
|
+
return app;
|
|
1086
|
+
}
|