apteva 0.4.18 → 0.4.19

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 (76) hide show
  1. package/dist/ActivityPage.9a1qg4bp.js +3 -0
  2. package/dist/ApiDocsPage.rfpf7ws1.js +4 -0
  3. package/dist/App.1nmg2h01.js +4 -0
  4. package/dist/App.5qw2dtxs.js +4 -0
  5. package/dist/App.6nc5acvk.js +4 -0
  6. package/dist/{App.nps62kvt.js → App.7vzbaz56.js} +3 -3
  7. package/dist/App.8rfz30p1.js +4 -0
  8. package/dist/App.amwp54wf.js +4 -0
  9. package/dist/App.e4202qb4.js +267 -0
  10. package/dist/App.errxz2q4.js +4 -0
  11. package/dist/App.f8qsyhpr.js +4 -0
  12. package/dist/App.g8vq68n0.js +20 -0
  13. package/dist/App.kfyrnznw.js +13 -0
  14. package/dist/{App.mq6jqare.js → App.p02f4ret.js} +1 -1
  15. package/dist/{App.np463xvy.js → App.p93mmyqw.js} +3 -3
  16. package/dist/App.qmg33p02.js +4 -0
  17. package/dist/{App.nft7h9jt.js → App.sdsc0258.js} +3 -3
  18. package/dist/ConnectionsPage.7zqba1r0.js +3 -0
  19. package/dist/McpPage.kf2g327t.js +3 -0
  20. package/dist/SettingsPage.472c15ep.js +3 -0
  21. package/dist/SkillsPage.xdxnh68a.js +3 -0
  22. package/dist/TasksPage.7g0b8xwc.js +3 -0
  23. package/dist/TelemetryPage.pr7rbz4r.js +3 -0
  24. package/dist/TestsPage.zhc6rqjm.js +3 -0
  25. package/dist/apteva-kit.css +1 -1
  26. package/dist/index.html +1 -1
  27. package/dist/styles.css +1 -1
  28. package/package.json +9 -4
  29. package/src/channels/index.ts +40 -0
  30. package/src/channels/telegram.ts +306 -0
  31. package/src/db.ts +180 -0
  32. package/src/integrations/agentdojo.ts +1 -1
  33. package/src/mcp-handler.ts +31 -24
  34. package/src/providers.ts +22 -9
  35. package/src/routes/api/channels.ts +182 -0
  36. package/src/routes/api/integrations.ts +13 -5
  37. package/src/routes/api/mcp.ts +27 -9
  38. package/src/routes/api/telemetry.ts +30 -0
  39. package/src/routes/api/triggers.ts +22 -2
  40. package/src/routes/api.ts +3 -1
  41. package/src/server.ts +39 -4
  42. package/src/triggers/agentdojo.ts +23 -18
  43. package/src/tui/AgentList.tsx +145 -0
  44. package/src/tui/App.tsx +102 -0
  45. package/src/tui/Login.tsx +104 -0
  46. package/src/tui/api.ts +72 -0
  47. package/src/tui/index.tsx +7 -0
  48. package/src/web/App.tsx +1 -1
  49. package/src/web/components/agents/AgentPanel.tsx +4 -37
  50. package/src/web/components/common/Icons.tsx +8 -0
  51. package/src/web/components/connections/OverviewTab.tsx +22 -68
  52. package/src/web/components/connections/TriggersTab.tsx +549 -70
  53. package/src/web/components/layout/Header.tsx +196 -4
  54. package/src/web/components/settings/SettingsPage.tsx +269 -1
  55. package/src/web/context/TelemetryContext.tsx +14 -1
  56. package/src/web/context/index.ts +1 -1
  57. package/dist/ActivityPage.yv28a2vj.js +0 -3
  58. package/dist/ApiDocsPage.4ccwjjbk.js +0 -4
  59. package/dist/App.155wke5v.js +0 -4
  60. package/dist/App.2e19nvn4.js +0 -13
  61. package/dist/App.2ye1b5n0.js +0 -4
  62. package/dist/App.4da4ycbe.js +0 -4
  63. package/dist/App.b6wtzd1j.js +0 -4
  64. package/dist/App.fjrh28tf.js +0 -4
  65. package/dist/App.htc36cy8.js +0 -4
  66. package/dist/App.me6reaa6.js +0 -4
  67. package/dist/App.n5q6p960.js +0 -4
  68. package/dist/App.q8ws33cc.js +0 -181
  69. package/dist/App.tb0y0jmt.js +0 -40
  70. package/dist/ConnectionsPage.52evzrp7.js +0 -3
  71. package/dist/McpPage.bjqrp0n2.js +0 -3
  72. package/dist/SettingsPage.es76hnj2.js +0 -3
  73. package/dist/SkillsPage.06h8yf0h.js +0 -3
  74. package/dist/TasksPage.99df66mk.js +0 -3
  75. package/dist/TelemetryPage.bmdnxhq7.js +0 -3
  76. package/dist/TestsPage.denxrg8c.js +0 -3
@@ -0,0 +1,306 @@
1
+ import { Bot } from "grammy";
2
+ import { AgentDB, ChannelDB } from "../db";
3
+ import { decryptObject } from "../crypto";
4
+ import { agentFetch } from "../routes/api/agent-utils";
5
+
6
+ interface TelegramConfig {
7
+ botToken: string;
8
+ allowList?: string[]; // Telegram user IDs allowed to chat
9
+ }
10
+
11
+ // In-memory map of running bot instances
12
+ const activeBots = new Map<string, Bot>();
13
+
14
+ export function isChannelActive(channelId: string): boolean {
15
+ return activeBots.has(channelId);
16
+ }
17
+
18
+ export async function startTelegramChannel(channelId: string): Promise<{ success: boolean; error?: string }> {
19
+ // Stop existing if running
20
+ if (activeBots.has(channelId)) {
21
+ await stopTelegramChannel(channelId);
22
+ }
23
+
24
+ const channel = ChannelDB.findById(channelId);
25
+ if (!channel) return { success: false, error: "Channel not found" };
26
+
27
+ let config: TelegramConfig;
28
+ try {
29
+ config = decryptObject(channel.config) as unknown as TelegramConfig;
30
+ } catch {
31
+ ChannelDB.setStatus(channelId, "error", "Failed to decrypt config");
32
+ return { success: false, error: "Failed to decrypt config" };
33
+ }
34
+
35
+ if (!config.botToken) {
36
+ ChannelDB.setStatus(channelId, "error", "Missing bot token");
37
+ return { success: false, error: "Missing bot token" };
38
+ }
39
+
40
+ const agent = AgentDB.findById(channel.agent_id);
41
+ if (!agent) {
42
+ ChannelDB.setStatus(channelId, "error", "Agent not found");
43
+ return { success: false, error: "Agent not found" };
44
+ }
45
+
46
+ try {
47
+ const bot = new Bot(config.botToken);
48
+
49
+ // /start command
50
+ bot.command("start", async (ctx) => {
51
+ await ctx.reply(`Connected to agent: ${agent.name}`);
52
+ });
53
+
54
+ // Handle text messages
55
+ bot.on("message:text", async (ctx) => {
56
+ // Access control
57
+ if (config.allowList?.length) {
58
+ const senderId = String(ctx.from.id);
59
+ if (!config.allowList.includes(senderId)) return;
60
+ }
61
+
62
+ // Check agent is running
63
+ const currentAgent = AgentDB.findById(channel.agent_id);
64
+ if (!currentAgent || currentAgent.status !== "running" || !currentAgent.port) {
65
+ await ctx.reply("Agent is not running.");
66
+ return;
67
+ }
68
+
69
+ // Send typing indicator
70
+ await ctx.replyWithChatAction("typing");
71
+
72
+ try {
73
+ // Map telegram chat → agent thread
74
+ const threadId = `telegram-${ctx.chat.id}`;
75
+
76
+ // Proxy to agent via agentFetch (same path as web UI chat)
77
+ const res = await agentFetch(currentAgent.id, currentAgent.port, "/chat", {
78
+ method: "POST",
79
+ headers: { "Content-Type": "application/json" },
80
+ body: JSON.stringify({
81
+ message: ctx.message.text,
82
+ thread_id: threadId,
83
+ }),
84
+ });
85
+
86
+ if (!res.ok) {
87
+ await ctx.reply("Error: agent returned an error.");
88
+ return;
89
+ }
90
+
91
+ // Stream response and send messages progressively as segments complete
92
+ await streamAndSend(res, ctx);
93
+ } catch (err) {
94
+ console.error(`[telegram:${channelId}] Message handling error:`, err);
95
+ await ctx.reply("Error processing your message.");
96
+ }
97
+ });
98
+
99
+ // Error handler
100
+ bot.catch((err) => {
101
+ console.error(`[telegram:${channelId}] Bot error:`, err);
102
+ });
103
+
104
+ // Start long-polling (non-blocking)
105
+ bot.start({
106
+ onStart: () => {
107
+ console.log(`[telegram:${channelId}] Bot started for agent ${agent.name}`);
108
+ },
109
+ });
110
+
111
+ activeBots.set(channelId, bot);
112
+ ChannelDB.setStatus(channelId, "running");
113
+ return { success: true };
114
+ } catch (err: any) {
115
+ const errorMsg = err.message || String(err);
116
+ console.error(`[telegram:${channelId}] Failed to start:`, errorMsg);
117
+ ChannelDB.setStatus(channelId, "error", errorMsg);
118
+ return { success: false, error: errorMsg };
119
+ }
120
+ }
121
+
122
+ export async function stopTelegramChannel(channelId: string): Promise<void> {
123
+ const bot = activeBots.get(channelId);
124
+ if (bot) {
125
+ try {
126
+ await bot.stop();
127
+ } catch {
128
+ // Ignore stop errors
129
+ }
130
+ activeBots.delete(channelId);
131
+ }
132
+ ChannelDB.setStatus(channelId, "stopped");
133
+ console.log(`[telegram:${channelId}] Bot stopped`);
134
+ }
135
+
136
+ /**
137
+ * Stream SSE response from agent and send Telegram messages progressively.
138
+ * Mirrors the chunk types from apteva-kit's chat component:
139
+ * content/token → accumulate text, send when a boundary is hit
140
+ * tool_call → flush pending text, send tool indicator immediately
141
+ * tool_use, tool_input_delta, tool_result, tool_stream → skipped
142
+ *
143
+ * Messages are sent as soon as each segment completes (tool boundary or end of stream),
144
+ * so the user sees them appear progressively in real-time.
145
+ */
146
+ async function streamAndSend(
147
+ res: Response,
148
+ ctx: { reply: (text: string, opts?: any) => Promise<any>; replyWithChatAction: (action: string) => Promise<any> },
149
+ onActivity?: () => void,
150
+ ): Promise<void> {
151
+ if (!res.body) {
152
+ await ctx.reply("(No response from agent)");
153
+ return;
154
+ }
155
+
156
+ const reader = res.body.getReader();
157
+ const decoder = new TextDecoder();
158
+ let textBuffer = "";
159
+ let buffer = "";
160
+ let messagesSent = 0;
161
+
162
+ async function flushText() {
163
+ const trimmed = textBuffer.trim();
164
+ if (trimmed) {
165
+ const chunks = splitMessage(trimmed, 4096);
166
+ for (const chunk of chunks) {
167
+ try {
168
+ await ctx.reply(chunk, { parse_mode: "Markdown" });
169
+ } catch {
170
+ await ctx.reply(chunk);
171
+ }
172
+ messagesSent++;
173
+ }
174
+ }
175
+ textBuffer = "";
176
+ }
177
+
178
+ // Periodically send typing indicator while streaming
179
+ const typingInterval = setInterval(() => {
180
+ ctx.replyWithChatAction("typing").catch(() => {});
181
+ }, 4000);
182
+
183
+ try {
184
+ while (true) {
185
+ const { done, value } = await reader.read();
186
+ if (done) break;
187
+
188
+ buffer += decoder.decode(value, { stream: true });
189
+
190
+ const lines = buffer.split("\n");
191
+ buffer = lines.pop() || "";
192
+
193
+ for (const line of lines) {
194
+ if (!line.startsWith("data: ")) continue;
195
+ const data = line.slice(6).trim();
196
+ if (data === "[DONE]") continue;
197
+
198
+ try {
199
+ const chunk = JSON.parse(data);
200
+
201
+ switch (chunk.type) {
202
+ // Text content — accumulate
203
+ case "content":
204
+ case "token":
205
+ if (chunk.content) textBuffer += chunk.content;
206
+ else if (chunk.text) textBuffer += chunk.text;
207
+ break;
208
+
209
+ // Tool starting — flush text immediately, then send tool indicator
210
+ case "tool_call": {
211
+ await flushText();
212
+ const name = chunk.tool_display_name || chunk.tool_name || "tool";
213
+ try {
214
+ await ctx.reply(`🔧 _${escapeMarkdown(name)}_`, { parse_mode: "Markdown" });
215
+ } catch {
216
+ await ctx.reply(`🔧 ${name}`);
217
+ }
218
+ messagesSent++;
219
+ break;
220
+ }
221
+
222
+ // Intermediate tool events — skip, but signal activity
223
+ case "tool_input_delta":
224
+ case "tool_use":
225
+ case "tool_stream":
226
+ case "tool_result":
227
+ onActivity?.();
228
+ break;
229
+
230
+ // Fallback: older SSE formats
231
+ case "message_delta":
232
+ if (chunk.delta?.text) textBuffer += chunk.delta.text;
233
+ break;
234
+ case "content_block_delta":
235
+ if (chunk.delta?.text) textBuffer += chunk.delta.text;
236
+ break;
237
+
238
+ default:
239
+ if (chunk.content && typeof chunk.content === "string") {
240
+ textBuffer += chunk.content;
241
+ } else if (typeof chunk.text === "string") {
242
+ textBuffer += chunk.text;
243
+ }
244
+ break;
245
+ }
246
+ } catch {
247
+ if (data && data !== "[DONE]") {
248
+ textBuffer += data;
249
+ }
250
+ }
251
+ }
252
+ }
253
+ } catch {
254
+ // Stream read error — flush what we have
255
+ } finally {
256
+ clearInterval(typingInterval);
257
+ }
258
+
259
+ // Flush remaining text
260
+ await flushText();
261
+
262
+ if (messagesSent === 0) {
263
+ await ctx.reply("(No response from agent)");
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Escape special Markdown characters for Telegram Markdown parse mode.
269
+ */
270
+ function escapeMarkdown(text: string): string {
271
+ return text.replace(/([_*\[\]()~`>#+\-=|{}.!\\])/g, "\\$1");
272
+ }
273
+
274
+ /**
275
+ * Split a message into chunks respecting the max length.
276
+ * Tries to split at newlines when possible.
277
+ */
278
+ function splitMessage(text: string, maxLength: number): string[] {
279
+ if (text.length <= maxLength) return [text];
280
+
281
+ const chunks: string[] = [];
282
+ let remaining = text;
283
+
284
+ while (remaining.length > 0) {
285
+ if (remaining.length <= maxLength) {
286
+ chunks.push(remaining);
287
+ break;
288
+ }
289
+
290
+ // Try to find a newline near the limit
291
+ let splitAt = remaining.lastIndexOf("\n", maxLength);
292
+ if (splitAt < maxLength * 0.5) {
293
+ // No good newline found — split at space
294
+ splitAt = remaining.lastIndexOf(" ", maxLength);
295
+ }
296
+ if (splitAt < maxLength * 0.3) {
297
+ // No good split point — hard split
298
+ splitAt = maxLength;
299
+ }
300
+
301
+ chunks.push(remaining.slice(0, splitAt));
302
+ remaining = remaining.slice(splitAt).trimStart();
303
+ }
304
+
305
+ return chunks;
306
+ }
package/src/db.ts CHANGED
@@ -217,6 +217,33 @@ export interface SubscriptionRow {
217
217
  updated_at: string;
218
218
  }
219
219
 
220
+ // Channel: external messaging platform bound to an agent
221
+ export interface Channel {
222
+ id: string;
223
+ type: "telegram"; // future: "slack", "discord"
224
+ name: string;
225
+ agent_id: string;
226
+ config: string; // encrypted JSON
227
+ status: "stopped" | "running" | "error";
228
+ error: string | null;
229
+ project_id: string | null;
230
+ created_at: string;
231
+ updated_at: string;
232
+ }
233
+
234
+ export interface ChannelRow {
235
+ id: string;
236
+ type: string;
237
+ name: string;
238
+ agent_id: string;
239
+ config: string;
240
+ status: string;
241
+ error: string | null;
242
+ project_id: string | null;
243
+ created_at: string;
244
+ updated_at: string;
245
+ }
246
+
220
247
  export interface McpServerRow {
221
248
  id: string;
222
249
  name: string;
@@ -737,6 +764,33 @@ function runMigrations() {
737
764
  CREATE INDEX IF NOT EXISTS idx_subscriptions_trigger_instance ON subscriptions(trigger_instance_id);
738
765
  `,
739
766
  },
767
+ {
768
+ name: "032_create_channels",
769
+ sql: `
770
+ CREATE TABLE IF NOT EXISTS channels (
771
+ id TEXT PRIMARY KEY,
772
+ type TEXT NOT NULL,
773
+ name TEXT NOT NULL,
774
+ agent_id TEXT NOT NULL,
775
+ config TEXT NOT NULL,
776
+ status TEXT DEFAULT 'stopped',
777
+ error TEXT,
778
+ project_id TEXT,
779
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
780
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
781
+ FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE
782
+ );
783
+ CREATE INDEX IF NOT EXISTS idx_channels_agent ON channels(agent_id);
784
+ CREATE INDEX IF NOT EXISTS idx_channels_status ON channels(status);
785
+ `,
786
+ },
787
+ {
788
+ name: "033_add_telemetry_seen",
789
+ sql: `
790
+ ALTER TABLE telemetry_events ADD COLUMN seen INTEGER DEFAULT 0;
791
+ CREATE INDEX IF NOT EXISTS idx_telemetry_seen ON telemetry_events(seen);
792
+ `,
793
+ },
740
794
  {
741
795
  name: "029_fix_provider_keys_unique_constraint",
742
796
  sql: `
@@ -1768,6 +1822,7 @@ export interface TelemetryEvent {
1768
1822
  duration_ms: number | null;
1769
1823
  error: string | null;
1770
1824
  received_at: string;
1825
+ seen?: boolean;
1771
1826
  }
1772
1827
 
1773
1828
  interface TelemetryEventRow {
@@ -1785,6 +1840,7 @@ interface TelemetryEventRow {
1785
1840
  duration_ms: number | null;
1786
1841
  error: string | null;
1787
1842
  received_at: string;
1843
+ seen?: number;
1788
1844
  }
1789
1845
 
1790
1846
  // Telemetry operations
@@ -2066,6 +2122,45 @@ export const TelemetryDB = {
2066
2122
  const row = db.query("SELECT COUNT(*) as count FROM telemetry_events").get() as { count: number };
2067
2123
  return row.count;
2068
2124
  },
2125
+
2126
+ // --- Notification helpers (piggyback on telemetry with `seen` flag) ---
2127
+
2128
+ // Notification-worthy filter: errors + agent crashes
2129
+ getNotifications(limit = 50): TelemetryEvent[] {
2130
+ const rows = db.query(`
2131
+ SELECT * FROM telemetry_events
2132
+ WHERE (level = 'error' OR (category = 'system' AND type = 'agent_stopped') OR category = 'ERROR')
2133
+ ORDER BY timestamp DESC
2134
+ LIMIT ?
2135
+ `).all(limit) as TelemetryEventRow[];
2136
+ return rows.map(rowToTelemetryEvent);
2137
+ },
2138
+
2139
+ getUnseenCount(): number {
2140
+ const row = db.query(`
2141
+ SELECT COUNT(*) as count FROM telemetry_events
2142
+ WHERE seen = 0
2143
+ AND (level = 'error' OR (category = 'system' AND type = 'agent_stopped') OR category = 'ERROR')
2144
+ `).get() as { count: number };
2145
+ return row.count;
2146
+ },
2147
+
2148
+ markSeen(ids: string[]): number {
2149
+ if (ids.length === 0) return 0;
2150
+ const placeholders = ids.map(() => "?").join(",");
2151
+ const result = db.run(
2152
+ `UPDATE telemetry_events SET seen = 1 WHERE id IN (${placeholders})`,
2153
+ ids
2154
+ );
2155
+ return result.changes;
2156
+ },
2157
+
2158
+ markAllSeen(): number {
2159
+ const result = db.run(
2160
+ `UPDATE telemetry_events SET seen = 1 WHERE seen = 0 AND (level = 'error' OR (category = 'system' AND type = 'agent_stopped') OR category = 'ERROR')`
2161
+ );
2162
+ return result.changes;
2163
+ },
2069
2164
  };
2070
2165
 
2071
2166
  function rowToTelemetryEvent(row: TelemetryEventRow): TelemetryEvent {
@@ -2084,6 +2179,7 @@ function rowToTelemetryEvent(row: TelemetryEventRow): TelemetryEvent {
2084
2179
  duration_ms: row.duration_ms,
2085
2180
  error: row.error,
2086
2181
  received_at: row.received_at,
2182
+ seen: row.seen === 1,
2087
2183
  };
2088
2184
  }
2089
2185
 
@@ -2726,6 +2822,90 @@ export const SubscriptionDB = {
2726
2822
  },
2727
2823
  };
2728
2824
 
2825
+ // --- Channel DB ---
2826
+
2827
+ function rowToChannel(row: ChannelRow): Channel {
2828
+ return {
2829
+ id: row.id,
2830
+ type: row.type as Channel["type"],
2831
+ name: row.name,
2832
+ agent_id: row.agent_id,
2833
+ config: row.config,
2834
+ status: row.status as Channel["status"],
2835
+ error: row.error,
2836
+ project_id: row.project_id,
2837
+ created_at: row.created_at,
2838
+ updated_at: row.updated_at,
2839
+ };
2840
+ }
2841
+
2842
+ export const ChannelDB = {
2843
+ create(channel: { type: string; name: string; agent_id: string; config: string; project_id?: string | null }): Channel {
2844
+ const id = generateId();
2845
+ const now = new Date().toISOString();
2846
+ db.run(
2847
+ `INSERT INTO channels (id, type, name, agent_id, config, status, project_id, created_at, updated_at)
2848
+ VALUES (?, ?, ?, ?, ?, 'stopped', ?, ?, ?)`,
2849
+ [id, channel.type, channel.name, channel.agent_id, channel.config, channel.project_id || null, now, now]
2850
+ );
2851
+ return this.findById(id)!;
2852
+ },
2853
+
2854
+ findById(id: string): Channel | null {
2855
+ const row = db.query("SELECT * FROM channels WHERE id = ?").get(id) as ChannelRow | null;
2856
+ return row ? rowToChannel(row) : null;
2857
+ },
2858
+
2859
+ findAll(): Channel[] {
2860
+ const rows = db.query("SELECT * FROM channels ORDER BY created_at DESC").all() as ChannelRow[];
2861
+ return rows.map(rowToChannel);
2862
+ },
2863
+
2864
+ findByAgentId(agentId: string): Channel[] {
2865
+ const rows = db.query("SELECT * FROM channels WHERE agent_id = ?").all(agentId) as ChannelRow[];
2866
+ return rows.map(rowToChannel);
2867
+ },
2868
+
2869
+ findRunning(): Channel[] {
2870
+ const rows = db.query("SELECT * FROM channels WHERE status = 'running'").all() as ChannelRow[];
2871
+ return rows.map(rowToChannel);
2872
+ },
2873
+
2874
+ update(id: string, updates: { name?: string; agent_id?: string; config?: string; project_id?: string | null }): Channel | null {
2875
+ const channel = this.findById(id);
2876
+ if (!channel) return null;
2877
+
2878
+ const fields: string[] = [];
2879
+ const values: (string | null)[] = [];
2880
+
2881
+ if (updates.name !== undefined) { fields.push("name = ?"); values.push(updates.name); }
2882
+ if (updates.agent_id !== undefined) { fields.push("agent_id = ?"); values.push(updates.agent_id); }
2883
+ if (updates.config !== undefined) { fields.push("config = ?"); values.push(updates.config); }
2884
+ if (updates.project_id !== undefined) { fields.push("project_id = ?"); values.push(updates.project_id || null); }
2885
+
2886
+ if (fields.length === 0) return channel;
2887
+
2888
+ fields.push("updated_at = ?");
2889
+ values.push(new Date().toISOString());
2890
+ values.push(id);
2891
+
2892
+ db.run(`UPDATE channels SET ${fields.join(", ")} WHERE id = ?`, values);
2893
+ return this.findById(id);
2894
+ },
2895
+
2896
+ setStatus(id: string, status: Channel["status"], error?: string | null): void {
2897
+ db.run(
2898
+ "UPDATE channels SET status = ?, error = ?, updated_at = ? WHERE id = ?",
2899
+ [status, error || null, new Date().toISOString(), id]
2900
+ );
2901
+ },
2902
+
2903
+ delete(id: string): boolean {
2904
+ const result = db.run("DELETE FROM channels WHERE id = ?", [id]);
2905
+ return result.changes > 0;
2906
+ },
2907
+ };
2908
+
2729
2909
  // Generate unique ID
2730
2910
  export function generateId(): string {
2731
2911
  return Math.random().toString(36).substring(2, 15);
@@ -127,7 +127,7 @@ export const AgentDojoProvider: IntegrationProvider = {
127
127
 
128
128
  return credentials.map((cred: any) => ({
129
129
  id: String(cred.id),
130
- appId: String(cred.provider_id || cred.toolkit_id || cred.provider_name),
130
+ appId: cred.provider_name || cred.toolkit_name || String(cred.provider_id || cred.toolkit_id),
131
131
  appName: cred.provider_name || cred.name || cred.toolkit_name || String(cred.provider_id),
132
132
  status: (cred.status === "active" || cred.is_valid !== false) ? "active" as const : "failed" as const,
133
133
  createdAt: cred.created_at || new Date().toISOString(),
@@ -73,34 +73,28 @@ function evaluateExpression(
73
73
  args: Record<string, any>,
74
74
  helpers: ReturnType<typeof templateHelpers>,
75
75
  ): any {
76
- // Handle args.* references
76
+ // Handle args.* references (e.g. args.name, args.query)
77
77
  if (expr.startsWith("args.")) {
78
78
  const key = expr.slice(5);
79
79
  return args[key] ?? null;
80
80
  }
81
81
 
82
- // Handle helper functions and values
83
- try {
84
- const fn = new Function(
85
- "args",
86
- "uuid",
87
- "now",
88
- "timestamp",
89
- "random_int",
90
- "random_float",
91
- `return ${expr}`,
92
- );
93
- return fn(
94
- args,
95
- helpers.uuid,
96
- helpers.now,
97
- helpers.timestamp,
98
- helpers.random_int,
99
- helpers.random_float,
100
- );
101
- } catch {
102
- return expr;
103
- }
82
+ // Handle known helper values
83
+ if (expr === "now") return helpers.now;
84
+ if (expr === "timestamp") return helpers.timestamp;
85
+
86
+ // Handle known helper function calls
87
+ const uuidMatch = expr.match(/^uuid\(\)$/);
88
+ if (uuidMatch) return helpers.uuid();
89
+
90
+ const randIntMatch = expr.match(/^random_int\(\s*(\d+)\s*,\s*(\d+)\s*\)$/);
91
+ if (randIntMatch) return helpers.random_int(Number(randIntMatch[1]), Number(randIntMatch[2]));
92
+
93
+ const randFloatMatch = expr.match(/^random_float\(\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/);
94
+ if (randFloatMatch) return helpers.random_float(Number(randFloatMatch[1]), Number(randFloatMatch[2]));
95
+
96
+ // Return expression as-is if not recognized — never execute arbitrary code
97
+ return expr;
104
98
  }
105
99
 
106
100
  // Execute a mock handler — returns the rendered mock_response
@@ -197,7 +191,11 @@ async function executeHttp(
197
191
  }
198
192
  }
199
193
 
200
- // Execute a JavaScript handler — runs code string with args + credentials
194
+ // Execute a JavaScript handler — runs user-defined code in a restricted scope.
195
+ // SECURITY NOTE: This intentionally allows authenticated admins to define custom tool logic.
196
+ // The code runs in a restricted Function scope with only args, credentials, and helpers exposed.
197
+ // process, require, import, Bun, fetch etc. are NOT passed in — but note that new Function()
198
+ // still has access to globalThis. For full sandboxing, consider using a Worker or subprocess.
201
199
  function executeJavascript(
202
200
  tool: McpServerTool,
203
201
  args: Record<string, any>,
@@ -210,6 +208,15 @@ function executeJavascript(
210
208
  };
211
209
  }
212
210
 
211
+ // Basic static checks — block obvious dangerous patterns
212
+ const dangerous = /\b(process|require|import|Bun|Deno|eval|Function|child_process|exec|spawn)\b/;
213
+ if (dangerous.test(tool.code)) {
214
+ return {
215
+ content: [{ type: "text", text: "Error: Tool code contains disallowed keywords (process, require, import, eval, exec, spawn)" }],
216
+ isError: true,
217
+ };
218
+ }
219
+
213
220
  try {
214
221
  const helpers = templateHelpers();
215
222
  const fn = new Function(
package/src/providers.ts CHANGED
@@ -198,35 +198,48 @@ export const ProviderKeys = {
198
198
  }
199
199
  },
200
200
 
201
- // Get decrypted API key for a provider (global key)
201
+ // Get decrypted API key for a provider (global key, falls back to env var)
202
202
  getDecrypted(providerId: string): string | null {
203
203
  const record = ProviderKeysDB.findByProvider(providerId);
204
- if (!record) return null;
204
+ if (record) {
205
+ try {
206
+ return decrypt(record.encrypted_key);
207
+ } catch (err) {
208
+ console.error(`Failed to decrypt key for ${providerId}:`, err);
209
+ }
210
+ }
205
211
 
206
- try {
207
- return decrypt(record.encrypted_key);
208
- } catch (err) {
209
- console.error(`Failed to decrypt key for ${providerId}:`, err);
210
- return null;
212
+ // Fall back to environment variable
213
+ const provider = PROVIDERS[providerId as ProviderId];
214
+ if (provider?.envVar) {
215
+ const envVal = process.env[provider.envVar];
216
+ if (envVal) return envVal;
211
217
  }
218
+ return null;
212
219
  },
213
220
 
214
221
  // Get decrypted API key for a provider and project
215
222
  // Falls back to global key if no project-specific key exists
216
223
  getDecryptedForProject(providerId: string, projectId: string | null): string | null {
224
+ console.log(`[ProviderKeys.getDecryptedForProject] providerId=${providerId}, projectId=${projectId}`);
217
225
  // Try project-specific key first
218
226
  if (projectId) {
219
227
  const projectRecord = ProviderKeysDB.findByProviderAndProject(providerId, projectId);
228
+ console.log(`[ProviderKeys.getDecryptedForProject] project record found: ${!!projectRecord}`);
220
229
  if (projectRecord) {
221
230
  try {
222
- return decrypt(projectRecord.encrypted_key);
231
+ const key = decrypt(projectRecord.encrypted_key);
232
+ console.log(`[ProviderKeys.getDecryptedForProject] decrypted project key OK, length=${key?.length}`);
233
+ return key;
223
234
  } catch (err) {
224
235
  console.error(`Failed to decrypt project key for ${providerId}/${projectId}:`, err);
225
236
  }
226
237
  }
227
238
  }
228
239
  // Fall back to global key
229
- return this.getDecrypted(providerId);
240
+ const globalKey = this.getDecrypted(providerId);
241
+ console.log(`[ProviderKeys.getDecryptedForProject] global fallback: found=${!!globalKey}, length=${globalKey?.length || 0}`);
242
+ return globalKey;
230
243
  },
231
244
 
232
245
  // Check if a provider has a key configured (global)