talon-agent 5.5.1 → 5.7.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.
package/README.md CHANGED
@@ -249,6 +249,7 @@ The `native` frontend turns the daemon into a **client bridge** — a versioned
249
249
  - **Local (desktop):** the app connects to a Talon on the same machine and launches one if needed (`TALON_FRONTEND_OVERRIDE=desktop`).
250
250
  - **Remote (mobile/LAN):** point the app at `host:port` + token; the bridge requires `Authorization: Bearer …` (or `?token=` on the SSE stream) whenever a token is set.
251
251
  - **Encryption:** off-loopback binds serve **HTTPS by default** with a persistent self-signed certificate (`~/.talon/keys/`); the companion pins its SHA-256 fingerprint on first connect and refuses any change afterwards. The daemon logs the fingerprint at startup and `/health` advertises it. Opt out (or in, on loopback) with `"tls": false` / `true` in the `native` section.
252
+ - **From anywhere, certificate-only (Immich-style):** put your reverse proxy (Caddy, nginx, Traefik, Cloudflare Tunnel) in front with client-certificate auth; import the `.p12` in the companion, which also switches to your home-network address whenever it answers. Talon still authenticates with its token — see [docs/mtls.md](docs/mtls.md).
252
253
 
253
254
  The app **keeps itself up to date** — it watches this repo's releases and installs the next one itself (silently on a rooted/Shizuku phone, swap-and-relaunch on desktop; see [docs/companion-updates.md](docs/companion-updates.md)). It provides multi-chat history, live streaming with reasoning + tool-call visibility, per-chat model/effort/reset, and **settings sync** — read and change the daemon's own config (default model, display name, timezone, pulse/heartbeat/dream) and restart it. See [apps/companion/README.md](apps/companion/README.md).
254
255
 
@@ -529,7 +530,7 @@ Commands: `/model`, `/effort`, `/context`, `/status`, `/reset`, `/rename`, `/res
529
530
 
530
531
  ## Production
531
532
 
532
- **Docker:** the image runs the daemon on Bun (`bun src/index.ts`); `~/.talon` and `~/.claude` are bind-mounted from the host into the container's `HOME=/home/bun`.
533
+ **Docker:** the image runs the daemon on Bun (`bun src/index.ts`); `~/.talon` and `~/.claude` are bind-mounted from the host into the container's `HOME=/home/bun`. Prebuilt images are on GHCR (`ghcr.io/dylanneve1/talon:latest`), and a first boot can be configured entirely from `TALON_*` environment variables — see **[docs/docker.md](docs/docker.md)** for the quick install, and **[docs/truenas.md](docs/truenas.md)** for a step-by-step TrueNAS SCALE install.
533
534
 
534
535
  ```bash
535
536
  docker compose up -d
@@ -537,6 +538,8 @@ docker compose up -d
537
538
 
538
539
  A Node 24 + tsx image is kept as a fallback for one release cycle: `docker build --build-arg RUNTIME=node -t talon .` (or set `build.args.RUNTIME` in `docker-compose.yml`). Mount paths are the same for both. See [`packaging/README.md`](packaging/README.md#docker-image) for the build's details.
539
540
 
541
+ The image is also ready for the **Antigravity (`agy`) backend**: add `-f docker-compose.agy.yml` to mount `~/.gemini` and the `agy` binary (or bake the binary in with `AGY_DOWNLOAD_URL` + `AGY_SHA256`). See [docs/docker.md](docs/docker.md#antigravity-agy-backend).
542
+
540
543
  **Systemd:** unit file at `packaging/systemd/talon.service` — copy to `/etc/systemd/system/`, set `User=` and `WorkingDirectory=`, then `systemctl enable --now talon`.
541
544
 
542
545
  **Health endpoint:** `GET http://localhost:19876/health` returns JSON with uptime, memory, queue depth, active sessions, and last activity timestamp.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "5.5.1",
3
+ "version": "5.7.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",
@@ -34,6 +34,16 @@ import { reportToolFingerprint } from "../runtime/cache/cache-metrics.js";
34
34
  import { log, logError } from "../../util/log.js";
35
35
  import { getConfig, getBridgePort } from "./state.js";
36
36
  import { ALLOWED_TOOLS_CHAT, EFFORT_MAP } from "./constants.js";
37
+ import {
38
+ isGuestChat,
39
+ isGuestPluginAllowed,
40
+ } from "../../core/mcp-hub/guest-scope.js";
41
+ import { VALID_TOOL_FRONTENDS } from "../../core/mcp-hub/talon-server.js";
42
+
43
+ /** `telegram-tools`, `whatsapp-tools`, ... — the hub's own frontend servers. */
44
+ function isFrontendToolServerName(name: string): boolean {
45
+ return name.endsWith("-tools") && VALID_TOOL_FRONTENDS.has(name.slice(0, -6));
46
+ }
37
47
 
38
48
  /**
39
49
  * Built-in SDK tools that Talon's native tool set replaces when
@@ -383,14 +393,28 @@ export function buildSdkOptions(
383
393
  const { postToolUseFailureHook, postToolBatchHook } =
384
394
  buildTurnTerminatorHooks();
385
395
 
386
- const builtinTools = config.nativeTools
387
- ? ALLOWED_TOOLS_CHAT.filter((t) => !NATIVE_REPLACED_BUILTINS.has(t))
388
- : [...ALLOWED_TOOLS_CHAT];
389
-
390
- const mcpServers = {
396
+ // Guest DMs (non-operator) get no SDK built-ins at all — Bash/Read/
397
+ // Write/Skill live outside the hub, so the hub's guest scope can't hide
398
+ // them — and only the plugin servers the guest scope allows.
399
+ const guest = isGuestChat(chatId);
400
+ const builtinTools = guest
401
+ ? []
402
+ : config.nativeTools
403
+ ? ALLOWED_TOOLS_CHAT.filter((t) => !NATIVE_REPLACED_BUILTINS.has(t))
404
+ : [...ALLOWED_TOOLS_CHAT];
405
+
406
+ const allServers = {
391
407
  ...buildMcpServers(chatId),
392
408
  ...buildPluginMcpServers(chatId),
393
409
  };
410
+ const mcpServers = guest
411
+ ? Object.fromEntries(
412
+ Object.entries(allServers).filter(
413
+ ([name]) =>
414
+ isFrontendToolServerName(name) || isGuestPluginAllowed(name),
415
+ ),
416
+ )
417
+ : allServers;
394
418
 
395
419
  // Tool definitions render BEFORE the system prompt, so a set that shifts
396
420
  // mid-session invalidates the system prompt and every cached message after
package/src/bootstrap.ts CHANGED
@@ -144,6 +144,8 @@ export async function bootstrap(
144
144
  disabledToolTags: config.disabledToolTags,
145
145
  braveApiKey: config.braveApiKey,
146
146
  nativeTools: config.nativeTools,
147
+ guestDmScope: config.guestDmScope,
148
+ adminUserId: config.adminUserId,
147
149
  });
148
150
 
149
151
  initWorkspace(config.workspace);
@@ -192,6 +192,16 @@ const nativeConfigSchema = z
192
192
  * working zero-config).
193
193
  */
194
194
  tls: z.boolean().optional(),
195
+ /**
196
+ * The URL other devices should dial, when it isn't the bind address —
197
+ * a container (Docker, TrueNAS) or anything behind NAT or a proxy,
198
+ * where the bridge only sees its internal IP. Used for pairing links,
199
+ * node installers and `/mesh`. e.g. "https://truenas.lan:19880".
200
+ */
201
+ publicUrl: z
202
+ .string()
203
+ .regex(/^https?:\/\/\S+$/, "native.publicUrl must be an http(s) URL")
204
+ .optional(),
195
205
  })
196
206
  .strict();
197
207
 
@@ -608,6 +618,22 @@ const configSchema = z.object({
608
618
  disabledTools: z.array(z.string()).optional(),
609
619
  disabledToolTags: z.array(z.string()).optional(),
610
620
 
621
+ /**
622
+ * Conversation-only tool surface for DMs from anyone who isn't an
623
+ * operator. The admin's Telegram DM and any `operatorChats` keep
624
+ * everything; other DMs (Telegram user ids, `wa_dm_*`) get reply/react/
625
+ * history/stickers plus the `guestPlugins` servers — no shell, files,
626
+ * mail, devices, cron, memory, agents or cross-chat sends. Groups are
627
+ * unaffected. Off by default. See core/mcp-hub/guest-scope.ts.
628
+ */
629
+ guestDmScope: z
630
+ .object({
631
+ enabled: z.boolean().default(false),
632
+ operatorChats: z.array(z.string()).default([]),
633
+ guestPlugins: z.array(z.string()).optional(),
634
+ })
635
+ .optional(),
636
+
611
637
  /**
612
638
  * Developer build flag. Gates dev-only affordances such as the
613
639
  * `/update` self-update command. Off by default so packaged /
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Guest DM tool scope.
3
+ *
4
+ * Anyone the operator lets DM the bot gets the same agent the operator
5
+ * gets: shell, files, mail, mesh devices, cron, memory, cross-chat sends.
6
+ * With `guestDmScope.enabled`, a DM from anyone who is not an operator gets a
7
+ * conversation-only surface instead, enforced here in the hub, so it holds
8
+ * for every backend that reaches tools through the hub:
9
+ *
10
+ * - Talon tools: an explicit allowlist (reply, react, edit/delete own
11
+ * messages, read this chat's history and media, look at stickers).
12
+ * Nothing that runs code, touches files, schedules, remembers, spawns
13
+ * agents, reaches devices or acts on another chat.
14
+ * - Parameters: a guest session may only target its own chat and may
15
+ * never attach a local file by path (`send(file_path=…)` would
16
+ * otherwise read any file the daemon can read and hand it over).
17
+ * - Plugin servers: only those named in `guestPlugins` (default: web
18
+ * search and the time/weather/currency extras).
19
+ *
20
+ * Groups are untouched: they already require the operator's membership,
21
+ * and their tool surface is a separate decision.
22
+ *
23
+ * Backend built-ins that live outside the hub (the Claude SDK's own
24
+ * Bash/Read/Write, Codex's shell) are the backend's job. The Claude SDK
25
+ * backend drops its built-ins for guest chats (see claude-sdk/options.ts).
26
+ */
27
+
28
+ export type GuestDmScopeConfig = {
29
+ enabled?: boolean;
30
+ /** Chat ids that keep the full surface in addition to the admin's DM. */
31
+ operatorChats?: readonly string[];
32
+ /** Plugin/hub server names a guest DM may use. */
33
+ guestPlugins?: readonly string[];
34
+ };
35
+
36
+ const DEFAULT_GUEST_PLUGINS: readonly string[] = [
37
+ "brave-search",
38
+ "extras-tools",
39
+ ];
40
+
41
+ /** Talon tools a guest DM may see and call. Everything else is hidden. */
42
+ export const GUEST_TOOL_ALLOWLIST: ReadonlySet<string> = new Set([
43
+ "end_turn",
44
+ "send",
45
+ "react",
46
+ "edit_message",
47
+ "delete_message",
48
+ "stop_poll",
49
+ "get_chat_info",
50
+ "read_chat_history",
51
+ "search_chat_history",
52
+ "get_message_by_id",
53
+ "download_media",
54
+ "list_media",
55
+ "get_sticker_pack",
56
+ "download_sticker",
57
+ ]);
58
+
59
+ /** Parameters that name another chat. A guest may only name its own. */
60
+ const CHAT_TARGET_PARAMS = ["chat_id", "to_chat_id", "from_chat_id"] as const;
61
+
62
+ type ScopeState = {
63
+ enabled: boolean;
64
+ operators: Set<string>;
65
+ plugins: Set<string>;
66
+ };
67
+
68
+ let state: ScopeState = {
69
+ enabled: false,
70
+ operators: new Set(),
71
+ plugins: new Set(DEFAULT_GUEST_PLUGINS),
72
+ };
73
+
74
+ /** Set at bootstrap (and on config reload). */
75
+ export function initGuestDmScope(
76
+ cfg: GuestDmScopeConfig | undefined,
77
+ adminUserId?: number,
78
+ ): void {
79
+ const operators = new Set<string>(cfg?.operatorChats ?? []);
80
+ if (adminUserId) operators.add(String(adminUserId));
81
+ state = {
82
+ enabled: cfg?.enabled === true,
83
+ operators,
84
+ plugins: new Set(cfg?.guestPlugins ?? DEFAULT_GUEST_PLUGINS),
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Is this chat id a one-to-one DM? Telegram DMs are the peer's positive
90
+ * user id; WhatsApp DMs are `wa_dm_<number>`. Anything else (groups,
91
+ * Discord, native, heartbeat) is not treated as a DM here.
92
+ */
93
+ export function isDmChatId(chatId: string): boolean {
94
+ return /^\d+$/.test(chatId) || chatId.startsWith("wa_dm_");
95
+ }
96
+
97
+ /** Should this chat get the guest surface? */
98
+ export function isGuestChat(chatId: string): boolean {
99
+ if (!state.enabled) return false;
100
+ if (!isDmChatId(chatId)) return false;
101
+ return !state.operators.has(chatId);
102
+ }
103
+
104
+ export function isGuestToolAllowed(name: string): boolean {
105
+ return GUEST_TOOL_ALLOWLIST.has(name);
106
+ }
107
+
108
+ export function isGuestPluginAllowed(serverName: string): boolean {
109
+ return state.plugins.has(serverName);
110
+ }
111
+
112
+ /**
113
+ * Why a guest call with these params must be refused, or null if it is
114
+ * fine. Checked on every guest tool call, after the allowlist.
115
+ */
116
+ export function guestParamViolation(
117
+ chatId: string,
118
+ params: Record<string, unknown>,
119
+ ): string | null {
120
+ for (const key of CHAT_TARGET_PARAMS) {
121
+ const v = params[key];
122
+ if (v !== undefined && v !== null && String(v) !== chatId) {
123
+ return `${key} must be this chat`;
124
+ }
125
+ }
126
+ if (params.file_path !== undefined) {
127
+ return "file_path is not available here; use a url or file_id";
128
+ }
129
+ const media = params.media;
130
+ if (Array.isArray(media)) {
131
+ for (const item of media) {
132
+ if (
133
+ item &&
134
+ typeof item === "object" &&
135
+ (item as Record<string, unknown>).file_path !== undefined
136
+ ) {
137
+ return "file_path is not available here; use a url or file_id";
138
+ }
139
+ }
140
+ }
141
+ return null;
142
+ }
@@ -53,6 +53,12 @@ import {
53
53
  type ChildSpec,
54
54
  } from "./children.js";
55
55
  import type { ToolFrontend } from "../tools/types.js";
56
+ import {
57
+ initGuestDmScope,
58
+ isGuestChat,
59
+ isGuestPluginAllowed,
60
+ type GuestDmScopeConfig,
61
+ } from "./guest-scope.js";
56
62
 
57
63
  export const HUB_PATH_PREFIX = "/mcp/";
58
64
 
@@ -64,6 +70,10 @@ export type HubConfig = {
64
70
  braveApiKey?: string;
65
71
  /** Surface the native tool set (bash/read/write/… + teleport). */
66
72
  nativeTools?: boolean;
73
+ /** Conversation-only tool surface for non-operator DMs. */
74
+ guestDmScope?: GuestDmScopeConfig;
75
+ /** Operator's Telegram id — their DM always keeps the full surface. */
76
+ adminUserId?: number;
67
77
  };
68
78
 
69
79
  let hubConfig: HubConfig = {};
@@ -71,6 +81,7 @@ let hubConfig: HubConfig = {};
71
81
  /** Set at bootstrap; safe to call again on config reload. */
72
82
  export function initHub(config: HubConfig): void {
73
83
  hubConfig = config;
84
+ initGuestDmScope(config.guestDmScope, config.adminUserId);
74
85
  startChildReaper();
75
86
  }
76
87
 
@@ -215,6 +226,7 @@ function buildServerFor(target: HubTarget, bridgeUrl: string) {
215
226
  disabledTools: hubConfig.disabledTools,
216
227
  disabledToolTags: hubConfig.disabledToolTags,
217
228
  includeNativeTools: hubConfig.nativeTools,
229
+ guest: isGuestChat(target.chatId),
218
230
  });
219
231
  }
220
232
  return buildProxyServer(target.serverName, () =>
@@ -327,6 +339,15 @@ export async function handleHubRequest(
327
339
  return;
328
340
  }
329
341
 
342
+ if (
343
+ target.kind === "plugin" &&
344
+ isGuestChat(target.chatId) &&
345
+ !isGuestPluginAllowed(target.serverName)
346
+ ) {
347
+ jsonRpcError(res, 403, "Not available in this chat");
348
+ return;
349
+ }
350
+
330
351
  const server = buildServerFor(target, bridgeUrl);
331
352
  const transport = new StreamableHTTPServerTransport({
332
353
  sessionIdGenerator: () => randomUUID(),
@@ -18,6 +18,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
18
18
  import { composeTools } from "../tools/index.js";
19
19
  import { createBridge, textResult } from "../tools/bridge.js";
20
20
  import type { ToolFrontend, ToolTag } from "../tools/types.js";
21
+ import { guestParamViolation, isGuestToolAllowed } from "./guest-scope.js";
21
22
 
22
23
  export const VALID_TOOL_FRONTENDS: ReadonlySet<string> = new Set([
23
24
  "telegram",
@@ -37,6 +38,11 @@ export type TalonServerOptions = {
37
38
  disabledToolTags?: readonly string[];
38
39
  /** Expose the native tool set (replaces the SDK built-ins). */
39
40
  includeNativeTools?: boolean;
41
+ /**
42
+ * Guest DM: expose only the conversation allowlist and refuse calls that
43
+ * name another chat or a local file. See guest-scope.ts.
44
+ */
45
+ guest?: boolean;
40
46
  };
41
47
 
42
48
  /** Build a Talon tool MCP server bound to one (frontend, chatId) pair. */
@@ -65,10 +71,21 @@ export function buildTalonToolServer(options: TalonServerOptions): McpServer {
65
71
  if (endTurn) tools.push(endTurn);
66
72
  }
67
73
 
68
- for (const tool of tools) {
69
- server.tool(tool.name, tool.description, tool.schema, async (params) =>
70
- textResult(await tool.execute(params, bridge)),
71
- );
74
+ const surface = options.guest
75
+ ? tools.filter((t) => isGuestToolAllowed(t.name))
76
+ : tools;
77
+
78
+ for (const tool of surface) {
79
+ server.tool(tool.name, tool.description, tool.schema, async (params) => {
80
+ if (options.guest) {
81
+ const why = guestParamViolation(
82
+ options.chatId,
83
+ params as Record<string, unknown>,
84
+ );
85
+ if (why) return textResult(`Not available in this chat: ${why}.`);
86
+ }
87
+ return textResult(await tool.execute(params, bridge));
88
+ });
72
89
  }
73
90
 
74
91
  return server;
@@ -40,6 +40,11 @@ export type MeshBridgeInfo = {
40
40
  token?: string;
41
41
  /** TLS certificate SHA-256 (absent over plain HTTP). */
42
42
  fingerprint?: string;
43
+ /**
44
+ * The operator's `native.publicUrl`: what devices dial when the bind
45
+ * address isn't reachable as-is (containers, NAT, proxies).
46
+ */
47
+ publicUrl?: string;
43
48
  };
44
49
 
45
50
  export class BridgeLinks {
@@ -261,9 +266,10 @@ export class BridgeLinks {
261
266
  }
262
267
 
263
268
  /**
264
- * The bridge base URL a NEW host should dial: an explicit override wins;
265
- * otherwise derive from the bridge's bind. A wildcard bind maps to this
266
- * host's first external IPv4; a loopback bind is unreachable from other
269
+ * The bridge base URL a NEW host should dial: an explicit override wins,
270
+ * then the configured `native.publicUrl`; otherwise derive from the
271
+ * bridge's bind. A wildcard bind maps to this host's first external IPv4;
272
+ * a loopback bind is unreachable from other
267
273
  * machines, so it's an error rather than a link that can't work.
268
274
  */
269
275
  private bridgeBaseUrl(
@@ -277,6 +283,10 @@ export class BridgeLinks {
277
283
  }
278
284
  return url;
279
285
  }
286
+ // Inside a container the "first external IPv4" is the container's own
287
+ // bridge-network address, which no phone can reach — the operator's
288
+ // public URL is the only right answer there.
289
+ if (info.publicUrl) return info.publicUrl.trim().replace(/\/+$/, "");
280
290
  let host = info.host;
281
291
  if (host === "0.0.0.0" || host === "::") {
282
292
  const external = firstExternalIPv4();
@@ -185,6 +185,9 @@ export function createNativeFrontend(
185
185
  port: server.getPort(),
186
186
  ...(listen.token ? { token: listen.token } : {}),
187
187
  ...(fingerprint ? { fingerprint } : {}),
188
+ ...(config.native?.publicUrl
189
+ ? { publicUrl: config.native.publicUrl }
190
+ : {}),
188
191
  });
189
192
  await writeBridgeDiscovery({
190
193
  port: server.getPort(),
@@ -7,9 +7,10 @@
7
7
  * (rollout PR 6), so the operator can always ask what Talon remembers
8
8
  * without the answer being able to change it.
9
9
  *
10
- * Gated exactly like `/status` — not at all. The store holds the
11
- * operator's own memory, and a read of it is the least privileged thing
12
- * a chat can do.
10
+ * Admin only, and only in a private chat. The store holds the operator's
11
+ * private notes — people, places, health, relationships — so a read of it
12
+ * is not a low-privilege action: in a group every member would see the
13
+ * reply, and anyone else who can reach the bot must not see it at all.
13
14
  *
14
15
  * Every line is model- or user-authored text reaching an HTML-parsed
15
16
  * send, so it goes through `escapeHtml` before it is joined; the reply
@@ -20,6 +21,7 @@
20
21
  import type { Bot, Context } from "grammy";
21
22
  import { escapeHtml } from "../formatting.js";
22
23
  import { replyHtmlChunked } from "../admin/chunked-reply.js";
24
+ import { isAuthorizedAdmin } from "./state.js";
23
25
  import {
24
26
  formatMemory,
25
27
  getMemory,
@@ -36,11 +38,31 @@ const LIST_LIMIT = 15;
36
38
 
37
39
  export function registerMemoryCommand(bot: Bot): void {
38
40
  bot.command("memory", async (ctx: Context) => {
41
+ const verdict = memoryAccess(ctx);
42
+ if (verdict !== "ok") {
43
+ await ctx.reply(
44
+ verdict === "not-private"
45
+ ? "Memory is private — ask me in a DM."
46
+ : "Not authorized.",
47
+ );
48
+ return;
49
+ }
39
50
  const arg = (ctx.match ?? "").toString().trim();
40
51
  await replyHtmlChunked(ctx, renderMemory(arg));
41
52
  });
42
53
  }
43
54
 
55
+ /**
56
+ * Who may read memory here: the admin, in a private chat.
57
+ * Order matters — a non-admin in a group gets "not authorized", not
58
+ * a hint that a DM would work.
59
+ */
60
+ function memoryAccess(ctx: Context): "ok" | "not-admin" | "not-private" {
61
+ if (!isAuthorizedAdmin(ctx)) return "not-admin";
62
+ if (ctx.chat?.type !== "private") return "not-private";
63
+ return "ok";
64
+ }
65
+
44
66
  /** Route the argument to one of the four reads. Returns ready HTML. */
45
67
  function renderMemory(arg: string): string {
46
68
  const why = /^why\b\s*(.*)$/is.exec(arg);