talon-agent 1.10.1 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/package.json +16 -3
  2. package/src/backend/claude-sdk/options.ts +19 -9
  3. package/src/core/gateway.ts +10 -3
  4. package/src/frontend/telegram/handlers.ts +108 -5
  5. package/src/__tests__/chat-id.test.ts +0 -91
  6. package/src/__tests__/chat-settings.test.ts +0 -471
  7. package/src/__tests__/claude-sdk-models.test.ts +0 -146
  8. package/src/__tests__/claude-sdk-options.test.ts +0 -205
  9. package/src/__tests__/cleanup-registry.test.ts +0 -58
  10. package/src/__tests__/compose-tools.test.ts +0 -216
  11. package/src/__tests__/config.test.ts +0 -716
  12. package/src/__tests__/cron-store-extended.test.ts +0 -661
  13. package/src/__tests__/cron-store.test.ts +0 -574
  14. package/src/__tests__/daily-log.test.ts +0 -357
  15. package/src/__tests__/disallowed-tools.test.ts +0 -64
  16. package/src/__tests__/dispatcher.test.ts +0 -784
  17. package/src/__tests__/dream.test.ts +0 -1145
  18. package/src/__tests__/end-turn.test.ts +0 -307
  19. package/src/__tests__/errors-extended.test.ts +0 -428
  20. package/src/__tests__/errors.test.ts +0 -332
  21. package/src/__tests__/fixtures/test-mcp-server.ts +0 -37
  22. package/src/__tests__/fuzz.test.ts +0 -375
  23. package/src/__tests__/gateway-actions.test.ts +0 -1772
  24. package/src/__tests__/gateway-context.test.ts +0 -102
  25. package/src/__tests__/gateway-http.test.ts +0 -436
  26. package/src/__tests__/gateway-retry.test.ts +0 -355
  27. package/src/__tests__/gateway-withRetry-extended.test.ts +0 -343
  28. package/src/__tests__/graph.test.ts +0 -830
  29. package/src/__tests__/handlers-stream.test.ts +0 -203
  30. package/src/__tests__/handlers.test.ts +0 -2972
  31. package/src/__tests__/heartbeat.test.ts +0 -388
  32. package/src/__tests__/history-extended.test.ts +0 -775
  33. package/src/__tests__/history-persistence.test.ts +0 -227
  34. package/src/__tests__/history.test.ts +0 -693
  35. package/src/__tests__/integration/sdk-stub.test.ts +0 -208
  36. package/src/__tests__/integration/stub-claude/build-sea.mjs +0 -114
  37. package/src/__tests__/integration/stub-claude/fake-claude.mjs +0 -352
  38. package/src/__tests__/integration/stub-claude/helpers.ts +0 -263
  39. package/src/__tests__/integration/stub-claude/protocol.ts +0 -108
  40. package/src/__tests__/integration/stub-claude/sea-config.json +0 -7
  41. package/src/__tests__/integration/talon-bootstrap.ts +0 -206
  42. package/src/__tests__/integration/talon-functional.test.ts +0 -190
  43. package/src/__tests__/integration.test.ts +0 -224
  44. package/src/__tests__/log-init.test.ts +0 -129
  45. package/src/__tests__/log.test.ts +0 -129
  46. package/src/__tests__/mcp-launcher-functional.test.ts +0 -334
  47. package/src/__tests__/mcp-launcher.test.ts +0 -139
  48. package/src/__tests__/mcp-lifecycle.test.ts +0 -165
  49. package/src/__tests__/media-index.test.ts +0 -559
  50. package/src/__tests__/mempalace-plugin.test.ts +0 -350
  51. package/src/__tests__/metrics.test.ts +0 -76
  52. package/src/__tests__/opencode-models.test.ts +0 -117
  53. package/src/__tests__/opencode-summary.test.ts +0 -105
  54. package/src/__tests__/opencode-ui.test.ts +0 -94
  55. package/src/__tests__/package.functional.test.ts +0 -178
  56. package/src/__tests__/plugin.test.ts +0 -962
  57. package/src/__tests__/reload-plugins.test.ts +0 -342
  58. package/src/__tests__/sessions.test.ts +0 -877
  59. package/src/__tests__/storage-save-errors.test.ts +0 -342
  60. package/src/__tests__/teams-frontend.test.ts +0 -762
  61. package/src/__tests__/telegram-formatting.test.ts +0 -86
  62. package/src/__tests__/telegram-helpers.test.ts +0 -151
  63. package/src/__tests__/telegram.test.ts +0 -176
  64. package/src/__tests__/terminal-commands.test.ts +0 -666
  65. package/src/__tests__/terminal-frontend.test.ts +0 -141
  66. package/src/__tests__/terminal-renderer.test.ts +0 -501
  67. package/src/__tests__/time.test.ts +0 -107
  68. package/src/__tests__/tool-functional.test.ts +0 -615
  69. package/src/__tests__/tool-id-coercion.test.ts +0 -136
  70. package/src/__tests__/watchdog.test.ts +0 -285
  71. package/src/__tests__/workspace-migrate.test.ts +0 -256
  72. package/src/__tests__/workspace.test.ts +0 -284
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "1.10.1",
3
+ "version": "1.11.0",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
@@ -30,7 +30,16 @@
30
30
  },
31
31
  "files": [
32
32
  "bin/",
33
- "src/",
33
+ "src/backend/",
34
+ "src/core/",
35
+ "src/frontend/",
36
+ "src/plugins/",
37
+ "src/storage/",
38
+ "src/util/",
39
+ "src/bootstrap.ts",
40
+ "src/cli.ts",
41
+ "src/index.ts",
42
+ "src/login.ts",
34
43
  "prompts/",
35
44
  "README.md",
36
45
  "tsconfig.json"
@@ -43,6 +52,9 @@
43
52
  "test": "vitest run",
44
53
  "test:ci": "vitest run --reporter=verbose --reporter=json --outputFile=test-results.json",
45
54
  "test:functional": "vitest run --reporter=verbose --reporter=json --outputFile=functional-results.json src/__tests__/package.functional.test.ts src/__tests__/tool-functional.test.ts src/__tests__/mcp-launcher.test.ts src/__tests__/mcp-launcher-functional.test.ts src/__tests__/integration/sdk-stub.test.ts src/__tests__/integration/talon-functional.test.ts",
55
+ "test:integration": "vitest run --reporter=verbose --reporter=json --outputFile=integration-results.json src/__tests__/integration/talon-mcp-functional.test.ts",
56
+ "test:integration:all": "vitest run --reporter=verbose src/__tests__/integration/",
57
+ "tarball:check": "node .github/scripts/tarball-check.mjs",
46
58
  "build:stub-sea": "node src/__tests__/integration/stub-claude/build-sea.mjs",
47
59
  "test:watch": "vitest",
48
60
  "test:coverage": "vitest run --coverage",
@@ -91,6 +103,7 @@
91
103
  },
92
104
  "overrides": {
93
105
  "@anthropic-ai/sdk": "^0.95.0",
94
- "ip-address": "^10.1.1"
106
+ "ip-address": "^10.1.1",
107
+ "fast-uri": "^3.1.2"
95
108
  }
96
109
  }
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { resolve } from "node:path";
9
+ import { pathToFileURL } from "node:url";
9
10
  import type {
10
11
  Options,
11
12
  PostToolBatchHookInput,
@@ -45,10 +46,17 @@ export function buildMcpServers(
45
46
  const config = getConfig();
46
47
  const bridgeUrl = `http://127.0.0.1:${getBridgePort()}`;
47
48
 
48
- const tsxImport = resolve(
49
- import.meta.dirname ?? ".",
50
- "../../../node_modules/tsx/dist/esm/index.mjs",
51
- );
49
+ // tsx as a Node loader is passed via `--import <url>`. Node accepts URLs
50
+ // or absolute paths, but on Windows a raw backslash path (`D:\…\tsx`) is
51
+ // ambiguous between path and URL — the loader hook fails to register and
52
+ // every subsequent `import` of a .ts file throws. `pathToFileURL` produces
53
+ // a cross-platform `file://` URL that Node always treats as a loader URL.
54
+ const tsxImport = pathToFileURL(
55
+ resolve(
56
+ import.meta.dirname ?? ".",
57
+ "../../../node_modules/tsx/dist/esm/index.mjs",
58
+ ),
59
+ ).href;
52
60
  const mcpServerPath = resolve(
53
61
  import.meta.dirname ?? ".",
54
62
  "../../core/tools/mcp-server.ts",
@@ -72,12 +80,14 @@ export function buildMcpServers(
72
80
  TALON_CHAT_ID: chatId,
73
81
  TALON_FRONTEND: frontend,
74
82
  };
83
+ // `node --import <tsx-loader>` everywhere — tsx as a Node loader works
84
+ // identically on Windows and POSIX, and avoids spawning `npx.cmd` (which
85
+ // Node 20.19+ refuses to execute via child_process.spawn without
86
+ // shell:true; CVE-2024-27980 mitigation). The wrapping launcher would
87
+ // hit the same .cmd ban when calling its child.
75
88
  servers[serverName] = wrapMcpServer({
76
- command: process.platform === "win32" ? "npx" : "node",
77
- args:
78
- process.platform === "win32"
79
- ? ["tsx", mcpServerPath]
80
- : ["--import", tsxImport, mcpServerPath],
89
+ command: "node",
90
+ args: ["--import", tsxImport, mcpServerPath],
81
91
  env: mcpEnv,
82
92
  });
83
93
  }
@@ -298,9 +298,16 @@ export class Gateway {
298
298
  });
299
299
  httpServer.listen(p, "127.0.0.1", () => {
300
300
  this.server = httpServer;
301
- this.port = p;
302
- log("gateway", `Action gateway on :${p}`);
303
- resolve(p);
301
+ // When the caller asks for port 0 the OS assigns a random free
302
+ // port read the actual port off the listening socket instead
303
+ // of saving the requested 0.
304
+ const addr = httpServer.address();
305
+ this.port =
306
+ typeof addr === "object" && addr !== null
307
+ ? (addr as { port: number }).port
308
+ : p;
309
+ log("gateway", `Action gateway on :${this.port}`);
310
+ resolve(this.port);
304
311
  });
305
312
  };
306
313
  tryPort(port);
@@ -159,11 +159,117 @@ export async function isAccessAllowed(
159
159
  return false;
160
160
  }
161
161
 
162
+ /**
163
+ * Maximum length of an unauthorized message body to retain in logs.
164
+ * Keeps abusive payloads (large pastes, attachment captions etc.) bounded
165
+ * while still preserving enough context to understand what was sent.
166
+ */
167
+ const UNAUTHORIZED_BODY_MAX_LEN = 1024;
168
+
169
+ /**
170
+ * Best-effort preview of an unauthorized message for forensics.
171
+ *
172
+ * Returns the visible text payload (text or caption), a short tag for
173
+ * media-only messages (`[sticker: 🤖]`, `[photo]`, `[voice 14s]`, etc.),
174
+ * or `undefined` if there's nothing meaningful to capture (e.g. a service
175
+ * message or empty content).
176
+ *
177
+ * Truncated to UNAUTHORIZED_BODY_MAX_LEN to keep log lines bounded.
178
+ */
179
+ export function extractUnauthorizedPreview(
180
+ message:
181
+ | {
182
+ text?: string;
183
+ caption?: string;
184
+ sticker?: { emoji?: string; set_name?: string };
185
+ photo?: unknown;
186
+ voice?: { duration?: number };
187
+ video?: unknown;
188
+ video_note?: unknown;
189
+ audio?: unknown;
190
+ animation?: unknown;
191
+ document?: { file_name?: string };
192
+ contact?: unknown;
193
+ location?: unknown;
194
+ poll?: { question?: string };
195
+ dice?: { emoji?: string };
196
+ }
197
+ | undefined,
198
+ ): string | undefined {
199
+ if (!message) return undefined;
200
+
201
+ const text = message.text ?? message.caption;
202
+ if (typeof text === "string" && text.trim().length > 0) {
203
+ return text.length > UNAUTHORIZED_BODY_MAX_LEN
204
+ ? `${text.slice(0, UNAUTHORIZED_BODY_MAX_LEN)}… [truncated]`
205
+ : text;
206
+ }
207
+
208
+ if (message.sticker) {
209
+ const emoji = message.sticker.emoji ?? "?";
210
+ const set = message.sticker.set_name
211
+ ? ` from ${message.sticker.set_name}`
212
+ : "";
213
+ return `[sticker: ${emoji}${set}]`;
214
+ }
215
+ if (message.photo) return "[photo]";
216
+ if (message.voice) {
217
+ const dur = message.voice.duration;
218
+ return dur ? `[voice ${dur}s]` : "[voice]";
219
+ }
220
+ if (message.video_note) return "[video note]";
221
+ if (message.video) return "[video]";
222
+ if (message.audio) return "[audio]";
223
+ if (message.animation) return "[animation]";
224
+ if (message.document) {
225
+ return message.document.file_name
226
+ ? `[document: ${message.document.file_name}]`
227
+ : "[document]";
228
+ }
229
+ if (message.contact) return "[contact]";
230
+ if (message.location) return "[location]";
231
+ if (message.poll) {
232
+ return message.poll.question
233
+ ? `[poll: ${message.poll.question}]`
234
+ : "[poll]";
235
+ }
236
+ if (message.dice) return `[dice: ${message.dice.emoji ?? "🎲"}]`;
237
+
238
+ return undefined;
239
+ }
240
+
162
241
  async function notifyUnauthorized(
163
242
  bot: Bot,
164
243
  ctx: Context,
165
244
  type: "dm" | "group",
166
245
  ): Promise<void> {
246
+ const sender = getSenderName(ctx.from);
247
+ const username = ctx.from?.username ? ` (@${ctx.from.username})` : "";
248
+ const userId = ctx.from?.id ?? "unknown";
249
+
250
+ // Capture message body BEFORE the cooldown check — every unauthorized
251
+ // attempt should be recorded for forensics, even if the user-facing
252
+ // warning + admin notification are suppressed by cooldown. Without
253
+ // this, follow-up DMs from a known social-engineering account vanish
254
+ // entirely from logs.
255
+ const body = extractUnauthorizedPreview(
256
+ ctx.message as Parameters<typeof extractUnauthorizedPreview>[0],
257
+ );
258
+ if (body) {
259
+ try {
260
+ appendDailyLog(
261
+ `⚠️ UNAUTHORIZED ${sender}${username} [id:${userId}]`,
262
+ body,
263
+ );
264
+ } catch {
265
+ /* daily log unavailable — fall through to talon.log */
266
+ }
267
+ logWarn(
268
+ "access",
269
+ `Unauthorized ${type} body from ${sender}${username} [id:${userId}]: ${body.slice(0, 200)}`,
270
+ );
271
+ }
272
+
167
273
  const key = type === "dm" ? `dm:${ctx.from?.id}` : `group:${ctx.chat?.id}`;
168
274
  const now = Date.now();
169
275
  const lastWarned = unauthorizedCooldown.get(key);
@@ -173,10 +279,6 @@ async function notifyUnauthorized(
173
279
  }
174
280
  unauthorizedCooldown.set(key, now);
175
281
 
176
- const sender = getSenderName(ctx.from);
177
- const username = ctx.from?.username ? ` (@${ctx.from.username})` : "";
178
- const userId = ctx.from?.id ?? "unknown";
179
-
180
282
  // Warn the user
181
283
  try {
182
284
  await bot.api.sendMessage(
@@ -193,8 +295,9 @@ async function notifyUnauthorized(
193
295
  type === "dm"
194
296
  ? `🚨 Unauthorized DM from ${sender}${username} [id:${userId}]`
195
297
  : `🚨 Unauthorized group access: "${(ctx.chat as { title?: string })?.title ?? ctx.chat!.id}" [id:${ctx.chat!.id}] by ${sender}${username}`;
298
+ const detailWithBody = body ? `${detail}\n\n${body.slice(0, 400)}` : detail;
196
299
  try {
197
- await bot.api.sendMessage(adminId, detail);
300
+ await bot.api.sendMessage(adminId, detailWithBody);
198
301
  } catch {
199
302
  /* admin unreachable — ignore */
200
303
  }
@@ -1,91 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
-
3
- import {
4
- deriveNumericChatId,
5
- generateTerminalChatId,
6
- isTerminalChatId,
7
- } from "../util/chat-id.js";
8
-
9
- describe("deriveNumericChatId", () => {
10
- it("returns a positive number", () => {
11
- const id = deriveNumericChatId("test-chat");
12
- expect(id).toBeGreaterThan(0);
13
- expect(Number.isInteger(id)).toBe(true);
14
- });
15
-
16
- it("returns the same value for the same input", () => {
17
- const a = deriveNumericChatId("stable-id");
18
- const b = deriveNumericChatId("stable-id");
19
- expect(a).toBe(b);
20
- });
21
-
22
- it("returns different values for different inputs", () => {
23
- const a = deriveNumericChatId("chat-alpha");
24
- const b = deriveNumericChatId("chat-beta");
25
- expect(a).not.toBe(b);
26
- });
27
-
28
- it("handles terminal-style IDs", () => {
29
- const id = deriveNumericChatId("t_1711360000000");
30
- expect(id).toBeGreaterThan(0);
31
- });
32
-
33
- it("handles empty string", () => {
34
- const id = deriveNumericChatId("");
35
- expect(id).toBeGreaterThanOrEqual(0);
36
- expect(Number.isInteger(id)).toBe(true);
37
- });
38
- });
39
-
40
- describe("generateTerminalChatId", () => {
41
- it("returns a string starting with t_", () => {
42
- const id = generateTerminalChatId();
43
- expect(id).toMatch(/^t_\d+$/);
44
- });
45
-
46
- it("returns a string with numeric timestamp portion", () => {
47
- const id = generateTerminalChatId();
48
- const ts = Number(id.slice(2));
49
- expect(Number.isNaN(ts)).toBe(false);
50
- expect(ts).toBeGreaterThan(0);
51
- });
52
-
53
- it("uses a recent timestamp", () => {
54
- const before = Date.now();
55
- const id = generateTerminalChatId();
56
- const after = Date.now();
57
- const ts = Number(id.slice(2));
58
- expect(ts).toBeGreaterThanOrEqual(before);
59
- expect(ts).toBeLessThanOrEqual(after);
60
- });
61
- });
62
-
63
- describe("isTerminalChatId", () => {
64
- it('returns true for "1" (legacy ID)', () => {
65
- expect(isTerminalChatId("1")).toBe(true);
66
- });
67
-
68
- it("returns true for t_ prefixed IDs", () => {
69
- expect(isTerminalChatId("t_1711360000000")).toBe(true);
70
- expect(isTerminalChatId("t_0")).toBe(true);
71
- expect(isTerminalChatId("t_abc")).toBe(true);
72
- });
73
-
74
- it("returns false for Telegram numeric IDs", () => {
75
- expect(isTerminalChatId("123456789")).toBe(false);
76
- expect(isTerminalChatId("-100123456")).toBe(false);
77
- });
78
-
79
- it("returns false for Teams IDs", () => {
80
- expect(isTerminalChatId("teams_chat_abc123")).toBe(false);
81
- });
82
-
83
- it("returns false for empty string", () => {
84
- expect(isTerminalChatId("")).toBe(false);
85
- });
86
-
87
- it('returns false for "10" and other strings starting with 1', () => {
88
- expect(isTerminalChatId("10")).toBe(false);
89
- expect(isTerminalChatId("100")).toBe(false);
90
- });
91
- });