talon-agent 5.29.0 → 5.30.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 (48) hide show
  1. package/package.json +2 -1
  2. package/prompts/system/agent-brief.md +9 -6
  3. package/src/backend/claude-sdk/constants.ts +22 -0
  4. package/src/backend/claude-sdk/models/discovery.ts +3 -0
  5. package/src/backend/claude-sdk/one-shot.ts +3 -1
  6. package/src/backend/claude-sdk/options.ts +7 -1
  7. package/src/core/agents/index.ts +2 -0
  8. package/src/core/agents/prompt.ts +70 -2
  9. package/src/core/agents/registry.ts +47 -5
  10. package/src/core/agents/runner.ts +169 -31
  11. package/src/core/agents/trail.ts +141 -0
  12. package/src/core/agents/types.ts +29 -3
  13. package/src/core/agents/watchdog.ts +70 -0
  14. package/src/core/background/isolated-agent.ts +6 -2
  15. package/src/core/backup/plan.ts +4 -0
  16. package/src/core/config/index.ts +13 -4
  17. package/src/core/engine/gateway-actions/agents/control.ts +9 -2
  18. package/src/core/engine/gateway-actions/agents/preflight.ts +17 -1
  19. package/src/core/engine/gateway-actions/agents/report.ts +81 -24
  20. package/src/core/engine/gateway-actions/index.ts +3 -0
  21. package/src/core/mcp-hub/guest-scope.ts +3 -1
  22. package/src/core/mesh/devices/service.ts +7 -0
  23. package/src/core/mesh/links/bridge-links.ts +20 -0
  24. package/src/core/secrets/actions.ts +18 -0
  25. package/src/core/secrets/drop.ts +176 -0
  26. package/src/core/secrets/index.ts +11 -0
  27. package/src/core/secrets/service.ts +248 -0
  28. package/src/core/secrets/store.ts +100 -0
  29. package/src/core/tools/index.ts +2 -0
  30. package/src/core/tools/ops/agents.ts +26 -8
  31. package/src/core/tools/ops/secrets.ts +36 -0
  32. package/src/core/tools/types.ts +2 -1
  33. package/src/frontend/discord/commands/definitions.ts +21 -0
  34. package/src/frontend/discord/commands/router.ts +3 -0
  35. package/src/frontend/discord/commands/secret.ts +26 -0
  36. package/src/frontend/native/bridge/routes/host.ts +10 -0
  37. package/src/frontend/native/bridge/routes/pre-auth.ts +82 -1
  38. package/src/frontend/native/bridge/routes/table.ts +5 -0
  39. package/src/frontend/native/bridge/server.ts +32 -4
  40. package/src/frontend/native/commands/definitions.ts +6 -0
  41. package/src/frontend/native/commands/index.ts +12 -0
  42. package/src/frontend/native/surface/handlers.ts +9 -0
  43. package/src/frontend/telegram/commands/definitions.ts +4 -0
  44. package/src/frontend/telegram/commands/index.ts +3 -0
  45. package/src/frontend/telegram/commands/secret.ts +24 -0
  46. package/src/frontend/whatsapp/commands.ts +16 -1
  47. package/src/util/log.ts +1 -0
  48. package/src/util/paths.ts +6 -0
@@ -1,3 +1,4 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
1
2
  import type { RouteHost } from "./host.js";
2
3
  import type { BridgeRoutes } from "./table.js";
3
4
  import { BRIDGE_PROTOCOL_VERSION } from "../../protocol.js";
@@ -6,7 +7,12 @@ export function preAuthRoutes(
6
7
  host: RouteHost,
7
8
  ): Pick<
8
9
  BridgeRoutes,
9
- "GET /health" | "GET /pair" | "GET /node/install" | "GET /node/binary"
10
+ | "GET /health"
11
+ | "GET /pair"
12
+ | "GET /node/install"
13
+ | "GET /node/binary"
14
+ | "GET /secret"
15
+ | "POST /secret"
10
16
  > {
11
17
  const { json, handlers: h } = host;
12
18
  return {
@@ -90,6 +96,37 @@ export function preAuthRoutes(
90
96
  });
91
97
  res.end(install.script);
92
98
  },
99
+ // Secret drop. GET shows the paste form and leaves the grant live, so
100
+ // a chat app fetching the link for a preview can't burn it. POST
101
+ // checks the grant BEFORE reading a byte of body — an anonymous caller
102
+ // without one gets nothing read and a closed socket — then reads at
103
+ // most SECRET_BODY_MAX bytes. The value goes to the core service and
104
+ // nowhere else: not a log line, not an error message.
105
+ "GET /secret": ({ res, url }) => {
106
+ const form = h.openSecretDrop(url.searchParams.get("grant") ?? "");
107
+ if (!form) return sendSecretPage(res, 404, notFoundPage());
108
+ sendSecretPage(res, 200, form);
109
+ },
110
+ "POST /secret": async ({ req, res, url }) => {
111
+ const token = url.searchParams.get("grant") ?? "";
112
+ if (!h.isLiveSecretDrop(token)) {
113
+ res.setHeader("Connection", "close");
114
+ return sendSecretPage(res, 404, notFoundPage());
115
+ }
116
+ let body: string;
117
+ try {
118
+ body = await readCappedBody(req, SECRET_BODY_MAX);
119
+ } catch {
120
+ res.setHeader("Connection", "close");
121
+ return sendSecretPage(res, 413, notFoundPage("Too large."));
122
+ }
123
+ const result = await h.submitSecretDrop(
124
+ token,
125
+ body,
126
+ req.headers["content-type"],
127
+ );
128
+ sendSecretPage(res, result.status, result.html);
129
+ },
93
130
  "GET /node/binary": ({ res, url }) => {
94
131
  const token = url.searchParams.get("provision") ?? "";
95
132
  const binary = token ? h.openNodeBinary(token) : null;
@@ -98,3 +135,47 @@ export function preAuthRoutes(
98
135
  },
99
136
  };
100
137
  }
138
+
139
+ /** A form-encoded 64 KB value plus its encoding overhead, and no more. */
140
+ const SECRET_BODY_MAX = 200 * 1024;
141
+
142
+ /** Read a request body as UTF-8, refusing anything over `max` bytes. */
143
+ async function readCappedBody(
144
+ req: IncomingMessage,
145
+ max: number,
146
+ ): Promise<string> {
147
+ const chunks: Buffer[] = [];
148
+ let total = 0;
149
+ for await (const chunk of req) {
150
+ total += (chunk as Buffer).length;
151
+ if (total > max) throw new Error("too large");
152
+ chunks.push(chunk as Buffer);
153
+ }
154
+ return Buffer.concat(chunks).toString("utf-8");
155
+ }
156
+
157
+ /**
158
+ * Secret-drop pages: no caching anywhere, no framing, no referrer, and a
159
+ * CSP that allows inline style and a same-origin form post — nothing else.
160
+ */
161
+ function sendSecretPage(
162
+ res: ServerResponse,
163
+ status: number,
164
+ html: string,
165
+ ): void {
166
+ res.writeHead(status, {
167
+ "Content-Type": "text/html; charset=utf-8",
168
+ "Cache-Control": "no-store",
169
+ "Referrer-Policy": "no-referrer",
170
+ "X-Frame-Options": "DENY",
171
+ "X-Content-Type-Options": "nosniff",
172
+ "Content-Security-Policy":
173
+ "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'",
174
+ });
175
+ res.end(html);
176
+ }
177
+
178
+ function notFoundPage(message?: string): string {
179
+ const text = message ?? "This link is unknown, expired, or already used.";
180
+ return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Not available</title><p style="font:15px system-ui,sans-serif;padding:24px">${text} Ask for a fresh one with /secret &lt;name&gt;.</p>\n`;
181
+ }
@@ -37,6 +37,11 @@ export const BRIDGE_ROUTE_AUTH = {
37
37
  "GET /pair": "public",
38
38
  "GET /node/install": "public",
39
39
  "GET /node/binary": "public",
40
+ // Secret drop: a browser with no credential pastes one value, once. GET
41
+ // shows the form without spending the grant (link previews fetch it);
42
+ // POST spends it, and its body is read only after the grant checks out.
43
+ "GET /secret": "public",
44
+ "POST /secret": "public",
40
45
 
41
46
  // Credential self-service. Any credential may ask who it is, and trade
42
47
  // itself (or the shared legacy token) for a fresh per-device credential
@@ -81,6 +81,25 @@ const SSE_PING_MS = 25_000;
81
81
  */
82
82
  const SSE_MAX_BACKLOG_BYTES = 16 * 1024 * 1024;
83
83
  const MAX_BODY_BYTES = 256 * 1024;
84
+
85
+ /**
86
+ * Routes a page served by the bridge itself posts a plain HTML form to
87
+ * (the secret drop). A browser stamps that POST with the bridge's own
88
+ * origin, which is no cross-site request — so these, and only these,
89
+ * accept it without `native.allowedOrigins`. Every other route still
90
+ * refuses any Origin not configured, same-origin included: a page under
91
+ * /media is the bridge's origin too.
92
+ */
93
+ const SAME_ORIGIN_FORM_ROUTES: ReadonlySet<string> = new Set(["POST /secret"]);
94
+
95
+ /** `host[:port]` of an Origin header, or undefined when it doesn't parse. */
96
+ function originHost(origin: string): string | undefined {
97
+ try {
98
+ return new URL(origin).host;
99
+ } catch {
100
+ return undefined;
101
+ }
102
+ }
84
103
  const PORT_FALLBACKS = 5;
85
104
 
86
105
  /**
@@ -90,7 +109,8 @@ const PORT_FALLBACKS = 5;
90
109
  * bodies) are unaffected. It does bound request bodies, and every
91
110
  * body-reading route sits behind the bearer check (an unauthenticated
92
111
  * request is answered 401/429 with `Connection: close` before its body is
93
- * read), so the budget is sized for the largest authenticated upload
112
+ * read) — the one pre-auth exception, `POST /secret`, reads a body only
113
+ * for a live single-use grant and caps it at 200 KB — so the budget is sized for the largest authenticated upload
94
114
  * (512 MB) on a slow link, not for an attacker.
95
115
  */
96
116
  export type BridgeTimeouts = {
@@ -496,7 +516,10 @@ export class BridgeServer {
496
516
  // slip the browser is asking for.
497
517
  const origin =
498
518
  typeof req.headers.origin === "string" ? req.headers.origin : undefined;
499
- const refusal = this.originGuard(req);
519
+ const refusal = this.originGuard(
520
+ req,
521
+ SAME_ORIGIN_FORM_ROUTES.has(`${method} ${path}`),
522
+ );
500
523
  if (refusal !== undefined) {
501
524
  res.writeHead(403, {
502
525
  ...this.corsHeaders(),
@@ -860,10 +883,15 @@ export class BridgeServer {
860
883
  *
861
884
  * Returns an error string when the request must be refused.
862
885
  */
863
- private originGuard(req: IncomingMessage): string | undefined {
886
+ private originGuard(
887
+ req: IncomingMessage,
888
+ sameOriginAllowed = false,
889
+ ): string | undefined {
864
890
  const origin = req.headers.origin;
865
891
  if (typeof origin === "string" && origin !== "" && origin !== "null") {
866
- if (!this.isAllowedOrigin(origin)) {
892
+ const sameOrigin =
893
+ sameOriginAllowed && originHost(origin) === req.headers.host;
894
+ if (!sameOrigin && !this.isAllowedOrigin(origin)) {
867
895
  return `Origin ${origin} is not allowed. Add it to native.allowedOrigins to permit browser clients.`;
868
896
  }
869
897
  }
@@ -96,6 +96,12 @@ export const NATIVE_COMMANDS = [
96
96
  args: "[status|now|checkpoint <label>|list|show <id>|pin <id>|unpin <id>|restore <id>]",
97
97
  admin: true,
98
98
  },
99
+ {
100
+ name: "secret",
101
+ description: "Store a password via a one-time link, not the chat",
102
+ args: "<name> [what it's for]",
103
+ admin: true,
104
+ },
99
105
  ] as const satisfies readonly ClientCommand[];
100
106
 
101
107
  export type NativeCommandName = (typeof NATIVE_COMMANDS)[number]["name"];
@@ -18,6 +18,7 @@ import type { NativeRuntime } from "../runtime.js";
18
18
  import { emitNotice, emitUser } from "../turn/emit.js";
19
19
  import { adminCommands } from "./admin.js";
20
20
  import { backupCommand } from "./backup.js";
21
+ import { secretCommandReply } from "../../../core/secrets/index.js";
21
22
  import { findNativeCommand, type NativeCommandName } from "./definitions.js";
22
23
  import { infoCommands } from "./info.js";
23
24
  import { sessionCommands } from "./session.js";
@@ -30,6 +31,17 @@ const HANDLERS: Record<NativeCommandName, NativeCommandHandler> = {
30
31
  ...sessionCommands,
31
32
  ...adminCommands,
32
33
  backup: backupCommand,
34
+ // Native chats are one operator's own surface: never a group.
35
+ secret: async (ctx) =>
36
+ ctx.reply(
37
+ secretCommandReply({
38
+ arg: ctx.arg,
39
+ chatKey: ctx.entry.id,
40
+ frontend: "native",
41
+ isOperator: ctx.operator,
42
+ isGroup: false,
43
+ }),
44
+ ),
33
45
  };
34
46
 
35
47
  export type ParsedNativeCommand = { name: NativeCommandName; arg: string };
@@ -4,6 +4,11 @@
4
4
  * anything with logic of its own lives in the module named for it.
5
5
  */
6
6
 
7
+ import {
8
+ isLiveSecretDrop,
9
+ openSecretDropForm,
10
+ submitSecretDrop,
11
+ } from "../../../core/secrets/index.js";
7
12
  import { files } from "../../../util/paths.js";
8
13
  import { setChatPulse } from "../../../storage/chat-settings.js";
9
14
  import { getPooledBackend } from "../../../core/engine/backend-controller/index.js";
@@ -219,5 +224,9 @@ export function buildBridgeHandlers(
219
224
  openCompanionPair: (token, format) => mesh.openCompanionPair(token, format),
220
225
  openNodeInstall: (token, os, arch) => mesh.openNodeInstall(token, os, arch),
221
226
  openNodeBinary: (token) => mesh.openNodeBinary(token),
227
+ openSecretDrop: (token) => openSecretDropForm(token),
228
+ isLiveSecretDrop: (token) => isLiveSecretDrop(token),
229
+ submitSecretDrop: (token, body, contentType) =>
230
+ submitSecretDrop(token, body, contentType),
222
231
  };
223
232
  }
@@ -52,6 +52,10 @@ export const TELEGRAM_COMMANDS: ReadonlyArray<{
52
52
  command: "backup",
53
53
  description: "Snapshots and checkpoints; /backup restore <id> (admin)",
54
54
  },
55
+ {
56
+ command: "secret",
57
+ description: "Store a password via a one-time link, not the chat (admin)",
58
+ },
55
59
  { command: "plugins", description: "List loaded plugins" },
56
60
  { command: "help", description: "All commands and features" },
57
61
  ];
@@ -8,6 +8,7 @@
8
8
  * - `memory` — /memory (read-only view of the typed memory store)
9
9
  * - `session` — /reset /status
10
10
  * - `settings` — /model /effort /pulse /settings
11
+ * - `secret` — /secret (single-use paste link for a password)
11
12
  * - `admin` — /admin /metrics /doctor /dream /restart /update
12
13
  * + the unknown-command suggester
13
14
  *
@@ -27,6 +28,7 @@ import { registerAdminCommands } from "./admin.js";
27
28
  import { registerWhatsAppPairingCommand } from "./whatsapp-pairing.js";
28
29
  import { registerAuthCommand } from "./auth.js";
29
30
  import { registerBackupCommand } from "./backup.js";
31
+ import { registerSecretCommand } from "./secret.js";
30
32
 
31
33
  export { telegramCommandMenu } from "./definitions.js";
32
34
  export { setAdminUserId } from "./state.js";
@@ -45,6 +47,7 @@ export function registerCommands(
45
47
  registerWhatsAppPairingCommand(bot);
46
48
  registerAuthCommand(bot);
47
49
  registerBackupCommand(bot);
50
+ registerSecretCommand(bot);
48
51
  // admin LAST: it owns the unknown-command catch-all, which must only
49
52
  // be reached after every real command has had its chance to match.
50
53
  // Registering anything after it makes that command look unknown
@@ -0,0 +1,24 @@
1
+ /**
2
+ * /secret <name> [purpose] — a single-use link to paste a password into,
3
+ * so it never has to be typed into this chat. The rules and the reply
4
+ * text live in core/secrets; this only adapts Telegram's context.
5
+ */
6
+
7
+ import type { Bot } from "grammy";
8
+ import { secretCommandReply } from "../../../core/secrets/index.js";
9
+ import { isAuthorizedAdmin } from "./state.js";
10
+
11
+ export function registerSecretCommand(bot: Bot): void {
12
+ bot.command("secret", async (ctx) => {
13
+ const text = secretCommandReply({
14
+ arg: typeof ctx.match === "string" ? ctx.match : "",
15
+ chatKey: String(ctx.chat.id),
16
+ frontend: "telegram",
17
+ isOperator: isAuthorizedAdmin(ctx),
18
+ isGroup: ctx.chat.type !== "private",
19
+ });
20
+ // No preview: a preview fetch is harmless (GET doesn't spend the
21
+ // grant) but there is no reason to hand Telegram the URL early.
22
+ await ctx.reply(text, { link_preview_options: { is_disabled: true } });
23
+ });
24
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Slash commands on WhatsApp — `/model`, `/effort`, `/settings`,
3
- * `/reset`, `/status`, `/help`.
3
+ * `/reset`, `/status`, `/secret`, `/help`.
4
4
  *
5
5
  * WhatsApp has no inline buttons, so everything Telegram does with a
6
6
  * picker is done here with typed arguments: `/model` prints a numbered
@@ -26,6 +26,8 @@ import {
26
26
  settingsCommandReply,
27
27
  statusCommandReply,
28
28
  } from "../presentation/text-commands.js";
29
+ import { isOperatorPrivateChat } from "../../core/mcp-hub/guest-scope.js";
30
+ import { secretCommandReply } from "../../core/secrets/index.js";
29
31
  import { sendText } from "./actions/send.js";
30
32
  import { identityAllowed, type Identity } from "./connection/identity.js";
31
33
  import type { WhatsAppChatInfo } from "./registry.js";
@@ -37,6 +39,7 @@ const COMMAND_NAMES = [
37
39
  "settings",
38
40
  "reset",
39
41
  "status",
42
+ "secret",
40
43
  "help",
41
44
  ] as const;
42
45
 
@@ -96,6 +99,7 @@ const HELP_TEXT = [
96
99
  "/settings — this chat's model, backend, effort and pulse",
97
100
  "/status — session info, context usage and stats",
98
101
  "/reset — start a fresh session (chat log kept)",
102
+ "/secret <name> — a one-time link to store a password, so it never goes in a chat (operator, DM)",
99
103
  "/help — this message",
100
104
  ].join("\n");
101
105
 
@@ -143,6 +147,17 @@ export async function executeWhatsAppCommand(
143
147
  return statusCommandReply(chatId, deps);
144
148
  case "reset":
145
149
  return runResetCommand(chatId, inbound.senderName, deps);
150
+ case "secret":
151
+ return secretCommandReply({
152
+ arg: cmd.arg,
153
+ chatKey: chatId,
154
+ frontend: "whatsapp",
155
+ // The operator's own DM (guest scope's operator chats), never a
156
+ // group and never another allowlisted contact.
157
+ isOperator:
158
+ !inbound.isGroup && isOperatorPrivateChat("whatsapp", chatId),
159
+ isGroup: inbound.isGroup,
160
+ });
146
161
  case "help":
147
162
  return HELP_TEXT;
148
163
  }
package/src/util/log.ts CHANGED
@@ -72,6 +72,7 @@ export type LogComponent =
72
72
  | "memory"
73
73
  | "stickers"
74
74
  | "backup"
75
+ | "secrets"
75
76
  | "router"
76
77
  | "backend-controller"
77
78
  | "fetch";
package/src/util/paths.ts CHANGED
@@ -88,6 +88,12 @@ export const dirs = {
88
88
  skills: resolve(TALON_ROOT, "workspace", "skills"),
89
89
  /** Key material (bridge TLS identity, release keys): ~/.talon/keys/ */
90
90
  keys: resolve(TALON_ROOT, "keys"),
91
+ /**
92
+ * Operator secrets, one file per value (mode 600): ~/.talon/secrets/.
93
+ * Written by the secret drop (`/secret <name>`, `request_secret`) so a
94
+ * password never has to travel through a chat message.
95
+ */
96
+ secrets: resolve(TALON_ROOT, "secrets"),
91
97
  /**
92
98
  * The talon:// namespace on disk: ~/.talon/ns/. Holds one symlink per
93
99
  * file-backed mount (home → workspace/, skills/, …); while the daemon