talon-agent 5.28.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 (56) hide show
  1. package/package.json +3 -2
  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 +227 -39
  11. package/src/core/agents/trail.ts +141 -0
  12. package/src/core/agents/types.ts +43 -5
  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 +17 -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 +30 -12
  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/discord/handlers/messages.ts +6 -4
  37. package/src/frontend/native/bridge/routes/host.ts +10 -0
  38. package/src/frontend/native/bridge/routes/pre-auth.ts +82 -1
  39. package/src/frontend/native/bridge/routes/table.ts +5 -0
  40. package/src/frontend/native/bridge/server.ts +32 -4
  41. package/src/frontend/native/commands/definitions.ts +6 -0
  42. package/src/frontend/native/commands/index.ts +12 -0
  43. package/src/frontend/native/surface/handlers.ts +9 -0
  44. package/src/frontend/telegram/actions/chat-info.ts +22 -5
  45. package/src/frontend/telegram/commands/definitions.ts +4 -0
  46. package/src/frontend/telegram/commands/index.ts +3 -0
  47. package/src/frontend/telegram/commands/secret.ts +24 -0
  48. package/src/frontend/telegram/handlers/messages.ts +7 -5
  49. package/src/frontend/whatsapp/commands.ts +16 -1
  50. package/src/frontend/whatsapp/messages/media-store.ts +4 -2
  51. package/src/storage/media-index.ts +43 -11
  52. package/src/storage/repositories/media-index-repo.ts +12 -1
  53. package/src/storage/sql/media-index.sql +4 -0
  54. package/src/storage/sql/statements.generated.ts +2 -0
  55. package/src/util/log.ts +1 -0
  56. package/src/util/paths.ts +6 -0
@@ -32,7 +32,8 @@ export type ToolTag =
32
32
  | "mesh"
33
33
  | "moderation"
34
34
  | "native"
35
- | "backup";
35
+ | "backup"
36
+ | "secrets";
36
37
 
37
38
  /** The bridge caller signature — injected into execute(). */
38
39
  export type BridgeFunction = (
@@ -17,6 +17,26 @@ import type { TalonConfig } from "../../../core/config/index.js";
17
17
  import { log, logError, logWarn } from "../../../util/log.js";
18
18
  import { getRepoRoot } from "../../../core/update/self-update.js";
19
19
 
20
+ /** /secret — one-time paste link for a password (admin, DMs). */
21
+ function secretCommand(): unknown {
22
+ return new SlashCommandBuilder()
23
+ .setName("secret")
24
+ .setDescription("Store a password via a one-time link (admin, DMs)")
25
+ .addStringOption((o) =>
26
+ o
27
+ .setName("name")
28
+ .setDescription("File name under ~/.talon/secrets")
29
+ .setRequired(true),
30
+ )
31
+ .addStringOption((o) =>
32
+ o
33
+ .setName("purpose")
34
+ .setDescription("What it's for (shown on the form)")
35
+ .setRequired(false),
36
+ )
37
+ .toJSON();
38
+ }
39
+
20
40
  /**
21
41
  * /backup — its own builder: the definitions list is one long literal and
22
42
  * this is the only entry with two options and seven choices.
@@ -167,6 +187,7 @@ function buildCommandDefinitions(devBuild = false): unknown[] {
167
187
  ]
168
188
  : []),
169
189
  backupCommand(),
190
+ secretCommand(),
170
191
  new SlashCommandBuilder()
171
192
  .setName("admin")
172
193
  .setDescription("Admin operations (admin only)")
@@ -38,6 +38,7 @@ import {
38
38
  handleSettings,
39
39
  } from "./settings.js";
40
40
  import { handleBackup } from "./backup.js";
41
+ import { handleSecret } from "./secret.js";
41
42
  import {
42
43
  handleRestart,
43
44
  handleMetrics,
@@ -163,6 +164,8 @@ async function routeSlashCommand(
163
164
  return handleUpdate(interaction, config);
164
165
  case "backup":
165
166
  return handleBackup(interaction);
167
+ case "secret":
168
+ return handleSecret(interaction, chatId);
166
169
  case "admin":
167
170
  return handleAdmin(interaction, config, gateway);
168
171
  default:
@@ -0,0 +1,26 @@
1
+ /**
2
+ * /secret name:<name> [purpose:<text>] — a single-use link to paste a
3
+ * password into instead of the channel. Rules and reply live in
4
+ * core/secrets; the reply is ephemeral either way.
5
+ */
6
+
7
+ import type { ChatInputCommandInteraction } from "discord.js";
8
+ import { secretCommandReply } from "../../../core/secrets/index.js";
9
+ import { isAdmin } from "../handlers/index.js";
10
+ import { reply } from "./interaction.js";
11
+
12
+ export async function handleSecret(
13
+ i: ChatInputCommandInteraction,
14
+ chatId: string,
15
+ ): Promise<void> {
16
+ const name = (i.options.getString("name") ?? "").trim();
17
+ const purpose = (i.options.getString("purpose") ?? "").trim();
18
+ const text = secretCommandReply({
19
+ arg: `${name} ${purpose}`,
20
+ chatKey: chatId,
21
+ frontend: "discord",
22
+ isOperator: isAdmin(i.user.id),
23
+ isGroup: i.inGuild(),
24
+ });
25
+ await reply(i, text, true);
26
+ }
@@ -66,15 +66,17 @@ export async function handleMessage(
66
66
  if (msg.attachments.size > 0) {
67
67
  for (const att of msg.attachments.values()) {
68
68
  try {
69
- const savedPath = await downloadAttachment(att, config.workspace);
69
+ const downloadedPath = await downloadAttachment(att, config.workspace);
70
70
  const cls = classifyAttachment(att);
71
- setMessageFilePath(chatId, numericMessageId, savedPath);
72
- addMedia({
71
+ setMessageFilePath(chatId, numericMessageId, downloadedPath);
72
+ // addMedia may dedupe onto an existing identical file and delete
73
+ // the fresh download — prompt with the path it resolves to.
74
+ const savedPath = await addMedia({
73
75
  chatId,
74
76
  msgId: numericMessageId,
75
77
  senderName: sender,
76
78
  type: cls.type,
77
- filePath: savedPath,
79
+ filePath: downloadedPath,
78
80
  caption: cleanedContent || undefined,
79
81
  timestamp: Date.now(),
80
82
  });
@@ -178,6 +178,16 @@ export type BridgeServerHandlers = {
178
178
  | Promise<{ script: string; filename: string } | null>;
179
179
  /** Resolve a node-provisioning token to the binary to stream, or null. */
180
180
  openNodeBinary(token: string): { path: string; size: number } | null;
181
+ /** The secret-drop form for a live grant (not spent), or null. */
182
+ openSecretDrop(token: string): string | null;
183
+ /** Whether a secret-drop grant is live (checked before reading a body). */
184
+ isLiveSecretDrop(token: string): boolean;
185
+ /** Spend a secret-drop grant and store the value; the page to show. */
186
+ submitSecretDrop(
187
+ token: string,
188
+ body: string,
189
+ contentType: string | undefined,
190
+ ): Promise<{ status: number; html: string }>;
181
191
  };
182
192
 
183
193
  /**
@@ -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
  }
@@ -21,6 +21,7 @@ import {
21
21
  getPinnedMessages as userbotPinnedMessages,
22
22
  getOnlineCount as userbotOnlineCount,
23
23
  } from "../userbot.js";
24
+ import { getMediaForMessage } from "../../../storage/media-index.js";
24
25
  import { savePackToLibrary } from "../sticker-library.js";
25
26
  import { toPositiveId } from "./coerce.js";
26
27
  import type { TelegramActionHandlers } from "./types.js";
@@ -308,16 +309,32 @@ export const chatInfoHandlers: TelegramActionHandlers = {
308
309
  },
309
310
 
310
311
  download_media: async (body, chatId) => {
312
+ const msgId = toPositiveId(body.message_id);
313
+ if (!msgId) return { ok: false, error: "Required: message_id" };
314
+ // Media the bot received is already on disk and indexed. Answer from
315
+ // the index first: the userbot can't see messages in the bot's own
316
+ // DMs (message ids there are per-account), so asking it for an
317
+ // inbound photo by id reports "not found".
318
+ const indexed = getMediaForMessage(String(chatId), msgId);
319
+ if (indexed && existsSync(indexed.filePath)) {
320
+ return {
321
+ ok: true,
322
+ text: `Saved at: ${indexed.filePath} (${indexed.type}). Use the Read tool on this path to view the content.`,
323
+ file_path: indexed.filePath,
324
+ };
325
+ }
311
326
  if (isUserClientReady()) {
312
327
  const { downloadMessageMedia } = await import("../userbot.js");
313
328
  return {
314
329
  ok: true,
315
- text: await downloadMessageMedia({
316
- chatId,
317
- messageId: Number(body.message_id),
318
- }),
330
+ text: await downloadMessageMedia({ chatId, messageId: msgId }),
319
331
  };
320
332
  }
321
- return { ok: false, error: "User client not connected." };
333
+ return {
334
+ ok: false,
335
+ error: indexed
336
+ ? `Media for message ${msgId} is no longer on disk and the user client is not connected to re-download it.`
337
+ : "User client not connected.",
338
+ };
322
339
  },
323
340
  };
@@ -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
+ }
@@ -72,16 +72,18 @@ async function handleMediaMessage(
72
72
  return;
73
73
  }
74
74
 
75
- const savedPath = await downloadTelegramFile(
75
+ const downloadedPath = await downloadTelegramFile(
76
76
  bot,
77
77
  config,
78
78
  media.fileId,
79
79
  media.fileName,
80
80
  );
81
81
 
82
- // Store file path in history + media index
83
- setMessageFilePath(chatId, ctx.message.message_id, savedPath);
84
- addMedia({
82
+ // Store file path in history + media index. addMedia may dedupe the
83
+ // download onto an identical file already on disk (and delete the
84
+ // fresh one), so the prompt must use the path it resolves to.
85
+ setMessageFilePath(chatId, ctx.message.message_id, downloadedPath);
86
+ const savedPath = await addMedia({
85
87
  chatId,
86
88
  msgId: ctx.message.message_id,
87
89
  senderName: sender,
@@ -93,7 +95,7 @@ async function handleMediaMessage(
93
95
  | "animation"
94
96
  | "audio"
95
97
  | "sticker",
96
- filePath: savedPath,
98
+ filePath: downloadedPath,
97
99
  caption: media.caption,
98
100
  timestamp: Date.now(),
99
101
  });
@@ -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
  }
@@ -96,7 +96,9 @@ export async function saveInboundMedia(
96
96
  writeFileSync(filePath, buffer);
97
97
 
98
98
  const caption = content?.caption ?? undefined;
99
- addMedia({
99
+ // addMedia may dedupe onto an existing identical file and delete
100
+ // this download — report the path it resolves to.
101
+ const savedPath = await addMedia({
100
102
  chatId,
101
103
  msgId,
102
104
  senderName,
@@ -105,7 +107,7 @@ export async function saveInboundMedia(
105
107
  timestamp: Date.now(),
106
108
  ...(caption ? { caption } : {}),
107
109
  });
108
- return { filePath, type, ...(caption ? { caption } : {}) };
110
+ return { filePath: savedPath, type, ...(caption ? { caption } : {}) };
109
111
  } catch (err) {
110
112
  logWarn(
111
113
  "whatsapp",