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
@@ -0,0 +1,248 @@
1
+ /**
2
+ * The secret-drop service: mint a link (`/secret <name>`, `request_secret`),
3
+ * serve its form, take the POST, write the file, and tell the chat.
4
+ *
5
+ * Frontends and the gateway call {@link requestSecretDrop}; the native
6
+ * bridge calls {@link openSecretDropForm} and {@link submitSecretDrop}.
7
+ * Core never imports a frontend, so the receipt goes out through the same
8
+ * cross-send broker `send_via` uses.
9
+ *
10
+ * The value path is: request body → {@link parseSubmittedValue} →
11
+ * {@link writeSecret}. It is never logged, returned, persisted elsewhere
12
+ * or put into the receipt.
13
+ */
14
+
15
+ import { log, logWarn } from "../../util/log.js";
16
+ import {
17
+ isDiscordChatId,
18
+ isNativeChatId,
19
+ isTeamsChatId,
20
+ isTelegramChatId,
21
+ isTerminalChatId,
22
+ isWhatsAppChatId,
23
+ numericChatIdFor,
24
+ } from "../frontend-runtime/chat-id.js";
25
+ import { crossSendTarget } from "../engine/gateway-actions/cross-send.js";
26
+ import { getMeshService } from "../mesh/index.js";
27
+ import {
28
+ SECRET_GRANT_TTL_MS,
29
+ SecretDropStore,
30
+ secretDropForm,
31
+ secretDropResult,
32
+ } from "./drop.js";
33
+ import { secretNameProblem, writeSecret } from "./store.js";
34
+
35
+ export type SecretDropRequest = {
36
+ name: unknown;
37
+ purpose?: unknown;
38
+ /** Canonical key of the chat that asked (receives the receipt). */
39
+ chatKey: string;
40
+ /** Frontend that chat lives on; inferred from the key when absent. */
41
+ frontend?: string;
42
+ };
43
+
44
+ export type SecretDropDeps = {
45
+ store: SecretDropStore;
46
+ /** The bridge's public base URL, or why there isn't one. */
47
+ baseUrl: () => { ok: true; url: string } | { ok: false; text: string };
48
+ write: typeof writeSecret;
49
+ notify: (frontend: string, chatKey: string, text: string) => Promise<void>;
50
+ };
51
+
52
+ /** The frontend a chat key belongs to, from its shape. */
53
+ function frontendForChatKey(chatKey: string): string | undefined {
54
+ if (isNativeChatId(chatKey)) return "native";
55
+ if (isDiscordChatId(chatKey)) return "discord";
56
+ if (isWhatsAppChatId(chatKey)) return "whatsapp";
57
+ if (isTeamsChatId(chatKey)) return "teams";
58
+ if (isTerminalChatId(chatKey)) return "terminal";
59
+ if (isTelegramChatId(chatKey)) return "telegram";
60
+ return undefined;
61
+ }
62
+
63
+ async function notifyChat(
64
+ frontend: string,
65
+ chatKey: string,
66
+ text: string,
67
+ ): Promise<void> {
68
+ const handler = crossSendTarget(frontend);
69
+ if (!handler) return;
70
+ await handler(
71
+ { action: "send_message", text, target: chatKey },
72
+ numericChatIdFor(chatKey),
73
+ );
74
+ }
75
+
76
+ function meshBaseUrl(): ReturnType<SecretDropDeps["baseUrl"]> {
77
+ return getMeshService().bridgeBaseUrl();
78
+ }
79
+
80
+ let deps: SecretDropDeps = {
81
+ store: new SecretDropStore(),
82
+ baseUrl: meshBaseUrl,
83
+ write: writeSecret,
84
+ notify: notifyChat,
85
+ };
86
+
87
+ /** Swap collaborators (tests). Returns the previous set. */
88
+ export function setSecretDropDeps(
89
+ next: Partial<SecretDropDeps>,
90
+ ): SecretDropDeps {
91
+ const prev = deps;
92
+ deps = { ...deps, ...next };
93
+ return prev;
94
+ }
95
+
96
+ /**
97
+ * Mint a link for one secret. The text is what the chat (or the model)
98
+ * gets: the link, the name, the expiry — never a value.
99
+ */
100
+ export function requestSecretDrop(
101
+ req: SecretDropRequest,
102
+ ): { ok: true; text: string; link: string } | { ok: false; text: string } {
103
+ const problem = secretNameProblem(req.name);
104
+ if (problem) return { ok: false, text: `Invalid secret name: ${problem}.` };
105
+ const name = req.name as string;
106
+ const frontend = req.frontend ?? frontendForChatKey(req.chatKey);
107
+ if (!frontend) {
108
+ return { ok: false, text: "Can't tell which chat to confirm the drop in." };
109
+ }
110
+ const base = deps.baseUrl();
111
+ if (!base.ok) return { ok: false, text: base.text };
112
+ if (!base.url.startsWith("https://")) {
113
+ return {
114
+ ok: false,
115
+ text: "The bridge is plain HTTP, and a password must not cross the network unencrypted. Enable native TLS or set native.publicUrl to an https:// address.",
116
+ };
117
+ }
118
+ const purpose =
119
+ typeof req.purpose === "string" && req.purpose.trim()
120
+ ? req.purpose.trim()
121
+ : undefined;
122
+ const grant = deps.store.create({
123
+ name,
124
+ chatKey: req.chatKey,
125
+ frontend,
126
+ ...(purpose ? { purpose } : {}),
127
+ });
128
+ const link = `${base.url}/secret?grant=${grant.token}`;
129
+ const minutes = Math.round(SECRET_GRANT_TTL_MS / 60_000);
130
+ log("secrets", `Secret drop link minted for "${name}" (chat ${req.chatKey})`);
131
+ return {
132
+ ok: true,
133
+ link,
134
+ text: [
135
+ `Open this link to store the secret "${name}":`,
136
+ "",
137
+ ` ${link}`,
138
+ "",
139
+ `Single-use, expires in ${minutes} minutes. The value is written to ~/.talon/secrets/${name} (mode 600); it never goes through the chat or the model. This chat gets a confirmation when it's stored.`,
140
+ ].join("\n"),
141
+ };
142
+ }
143
+
144
+ /** GET /secret — the form for a live grant (not spent), or null. */
145
+ export function openSecretDropForm(token: string): string | null {
146
+ const grant = token ? deps.store.peek(token) : null;
147
+ return grant ? secretDropForm(grant) : null;
148
+ }
149
+
150
+ /** Whether a POST for `token` is worth reading the body of. */
151
+ export function isLiveSecretDrop(token: string): boolean {
152
+ return Boolean(token && deps.store.peek(token));
153
+ }
154
+
155
+ /**
156
+ * The value out of a form POST (`application/x-www-form-urlencoded`) or a
157
+ * plain-text body. Null when there is none.
158
+ */
159
+ function parseSubmittedValue(
160
+ body: string,
161
+ contentType: string | undefined,
162
+ ): string | null {
163
+ if ((contentType ?? "").includes("application/x-www-form-urlencoded")) {
164
+ return new URLSearchParams(body).get("value");
165
+ }
166
+ return body || null;
167
+ }
168
+
169
+ /**
170
+ * POST /secret — spend the grant, write the value, send the receipt.
171
+ * Returns the page the browser sees. The grant is spent even when the
172
+ * write fails, so a failure means "ask again", never "retry this link".
173
+ */
174
+ export async function submitSecretDrop(
175
+ token: string,
176
+ body: string,
177
+ contentType: string | undefined,
178
+ ): Promise<{ status: number; html: string }> {
179
+ const grant = token ? deps.store.consume(token) : null;
180
+ if (!grant) return { status: 404, html: secretDropResult(false, null) };
181
+ const value = parseSubmittedValue(body, contentType);
182
+ if (!value) {
183
+ return {
184
+ status: 400,
185
+ html: secretDropResult(false, null, "No value was submitted."),
186
+ };
187
+ }
188
+ const written = await deps.write(grant.name, value);
189
+ if (!written.ok) {
190
+ logWarn(
191
+ "secrets",
192
+ `Secret drop for "${grant.name}" failed: ${written.error}`,
193
+ );
194
+ return { status: 500, html: secretDropResult(false, null, written.error) };
195
+ }
196
+ log("secrets", `Secret "${grant.name}" stored via drop link`);
197
+ try {
198
+ await deps.notify(
199
+ grant.frontend,
200
+ grant.chatKey,
201
+ `stored ✓ as ${grant.name}`,
202
+ );
203
+ } catch (err) {
204
+ logWarn(
205
+ "secrets",
206
+ `Secret drop receipt to ${grant.frontend} chat ${grant.chatKey} failed: ${err instanceof Error ? err.message : String(err)}`,
207
+ );
208
+ }
209
+ return { status: 200, html: secretDropResult(true, grant.name) };
210
+ }
211
+
212
+ export type SecretCommandContext = {
213
+ /** Everything after `/secret`. */
214
+ arg: string;
215
+ chatKey: string;
216
+ frontend: string;
217
+ /** Whether the sender may write into the operator's secrets folder. */
218
+ isOperator: boolean;
219
+ /** Whether this chat has more than one human in it. */
220
+ isGroup: boolean;
221
+ };
222
+
223
+ /**
224
+ * `/secret <name> [purpose]` — the reply every frontend sends. One
225
+ * implementation, so the rules (operator only, DMs only, the usage line)
226
+ * are the same on Telegram, Discord, WhatsApp and the native app.
227
+ */
228
+ export function secretCommandReply(ctx: SecretCommandContext): string {
229
+ if (!ctx.isOperator) return "Only the operator can store secrets.";
230
+ if (ctx.isGroup) {
231
+ return "Run /secret in a private chat with me: a drop link writes to the secrets folder for whoever opens it, so it doesn't belong in a group.";
232
+ }
233
+ const [name = "", ...rest] = ctx.arg.trim().split(/\s+/);
234
+ if (!name) {
235
+ return [
236
+ "Usage: /secret <name> [what it's for]",
237
+ "",
238
+ "Sends a single-use link to a paste form. The value is saved to ~/.talon/secrets/<name> and never goes through the chat. Don't paste passwords here.",
239
+ ].join("\n");
240
+ }
241
+ const minted = requestSecretDrop({
242
+ name,
243
+ purpose: rest.join(" "),
244
+ chatKey: ctx.chatKey,
245
+ frontend: ctx.frontend,
246
+ });
247
+ return minted.text;
248
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * The secrets folder — one file per value under ~/.talon/secrets/, mode 600.
3
+ *
4
+ * Writes are atomic (temp file in the same directory, fsync, rename), so a
5
+ * crash mid-write leaves the old value or the new one, never half of
6
+ * either. Names are a closed alphabet, checked before any path is built,
7
+ * and the resolved path must sit directly inside the folder — a name can
8
+ * never climb out of it or into a subdirectory.
9
+ *
10
+ * Nothing here logs a value, and no error message carries one.
11
+ */
12
+
13
+ import { randomBytes } from "node:crypto";
14
+ import { chmod, mkdir, open, rename, rm } from "node:fs/promises";
15
+ import { dirname, join, resolve } from "node:path";
16
+ import { dirs } from "../../util/paths.js";
17
+
18
+ /** Largest value the drop accepts. A password, a key file, not a dump. */
19
+ const MAX_SECRET_BYTES = 64 * 1024;
20
+
21
+ /**
22
+ * Letters, digits, `.`, `_`, `-`; starts with a letter or digit; at most
23
+ * 64 characters. No separators, so no traversal; no leading dot, so no
24
+ * hidden files and no `..`.
25
+ */
26
+ const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
27
+
28
+ /** Why `name` can't be a secret name, or undefined when it can. */
29
+ export function secretNameProblem(name: unknown): string | undefined {
30
+ if (typeof name !== "string" || !name) return "a name is required";
31
+ if (!NAME_RE.test(name)) {
32
+ return "use 1-64 letters, digits, '.', '_' or '-', starting with a letter or digit";
33
+ }
34
+ if (name.includes("..")) return "'..' is not allowed in a name";
35
+ return undefined;
36
+ }
37
+
38
+ /** The folder secrets live in. Overridable for tests. */
39
+ function secretsDir(): string {
40
+ return dirs.secrets;
41
+ }
42
+
43
+ /**
44
+ * The file a name maps to, or an error. Checks the name and then the
45
+ * resolved path, so the second check still holds if the first ever widens.
46
+ */
47
+ export function secretPath(
48
+ name: string,
49
+ dir: string = secretsDir(),
50
+ ): { ok: true; path: string } | { ok: false; error: string } {
51
+ const problem = secretNameProblem(name);
52
+ if (problem) return { ok: false, error: `Invalid secret name: ${problem}` };
53
+ const root = resolve(dir);
54
+ const path = resolve(root, name);
55
+ if (dirname(path) !== root) {
56
+ return { ok: false, error: "Invalid secret name: it leaves the folder" };
57
+ }
58
+ return { ok: true, path };
59
+ }
60
+
61
+ /**
62
+ * Write one secret atomically with mode 600, creating the folder (700)
63
+ * when it's missing. A trailing newline from a paste is dropped; nothing
64
+ * else about the value is touched.
65
+ */
66
+ export async function writeSecret(
67
+ name: string,
68
+ value: string,
69
+ dir: string = secretsDir(),
70
+ ): Promise<{ ok: true; path: string } | { ok: false; error: string }> {
71
+ const target = secretPath(name, dir);
72
+ if (!target.ok) return target;
73
+ const clean = value.replace(/\r?\n$/, "");
74
+ if (!clean) return { ok: false, error: "The value is empty" };
75
+ if (Buffer.byteLength(clean, "utf8") > MAX_SECRET_BYTES) {
76
+ return { ok: false, error: "The value is too large" };
77
+ }
78
+ const root = resolve(dir);
79
+ await mkdir(root, { recursive: true, mode: 0o700 });
80
+ // mkdir's mode is masked by umask and ignored for a folder that exists.
81
+ await chmod(root, 0o700).catch(() => {});
82
+ const temp = join(root, `.${name}.${randomBytes(6).toString("hex")}.tmp`);
83
+ try {
84
+ const fh = await open(temp, "wx", 0o600);
85
+ try {
86
+ await fh.writeFile(clean, "utf8");
87
+ await fh.sync();
88
+ } finally {
89
+ await fh.close();
90
+ }
91
+ await chmod(temp, 0o600);
92
+ // rename replaces a symlink at the target, never what it points to.
93
+ await rename(temp, target.path);
94
+ } catch (err) {
95
+ await rm(temp, { force: true }).catch(() => {});
96
+ const code = (err as NodeJS.ErrnoException).code ?? "error";
97
+ return { ok: false, error: `Could not write the secret (${code})` };
98
+ }
99
+ return { ok: true, path: target.path };
100
+ }
@@ -29,6 +29,7 @@ import { whatsappTools } from "./chat/whatsapp.js";
29
29
  import { moderationTools } from "./chat/moderation.js";
30
30
  import { nativeTools } from "./ops/native.js";
31
31
  import { backupTools } from "./ops/backup.js";
32
+ import { secretTools } from "./ops/secrets.js";
32
33
 
33
34
  /** All built-in tool definitions. */
34
35
  export const ALL_TOOLS: readonly ToolDefinition[] = [
@@ -57,6 +58,7 @@ export const ALL_TOOLS: readonly ToolDefinition[] = [
57
58
  ...agentTools,
58
59
  ...backupTools,
59
60
  ...preflightTools,
61
+ ...secretTools,
60
62
  ];
61
63
 
62
64
  /**
@@ -37,7 +37,14 @@ could do in one tool call, or work whose result you need in the next second
37
37
 
38
38
  Backend and model default to the caller's (a sub-agent's own children
39
39
  inherit its backend and model); pass them to put the agent somewhere else (e.g. a cheap model for a mechanical sweep, or a backend with
40
- a bigger context window). Returns the agent id immediately.`;
40
+ a bigger context window). Returns the agent id immediately.
41
+
42
+ There is no hard timeout unless you pass timeout_s (or the deployment sets
43
+ one): an agent runs until it reports, is killed, or goes quiet. A no-progress
44
+ watchdog pings an agent that has made no tool call or output for a while,
45
+ then tells you, then kills it. A killed, timed-out or stalled agent's report
46
+ still carries its last interim messages, progress notes and the files it
47
+ changed, so its work is not lost.`;
41
48
 
42
49
  export const agentTools: ToolDefinition[] = [
43
50
  {
@@ -81,7 +88,7 @@ export const agentTools: ToolDefinition[] = [
81
88
  .positive()
82
89
  .optional()
83
90
  .describe(
84
- "Hard wall-clock cap in seconds (default 900, min 30, max 3600). On timeout the agent is aborted and you are told.",
91
+ "Optional hard wall-clock cap in seconds (min 30). Unset = no cap unless the deployment sets agents.defaultTimeoutMs; agents.maxTimeoutMs, when set, caps every run. Stalled agents are ended by the no-progress watchdog regardless. On timeout the agent is aborted and you are told, with what it had done so far.",
85
92
  ),
86
93
  preflight: z
87
94
  .boolean()
@@ -193,8 +200,15 @@ export const agentTools: ToolDefinition[] = [
193
200
  {
194
201
  name: "list_peers",
195
202
  description:
196
- "Sub-agents only. List the other agents your parent spawned alongside you — id, label and what each is working on. These are the only agents you may message directly with message_peer. Call it before assuming you are working alone.",
197
- schema: {},
203
+ 'Sub-agents only. List the other live agents you can message with message_peer — id, label, how they relate to you and what each is working on. Default scope "siblings": the agents your parent spawned alongside you. Scope "tree": every live agent working for the same chat — your parent, children, siblings and cousins. Call it before assuming you are working alone.',
204
+ schema: {
205
+ scope: z
206
+ .enum(["siblings", "tree"])
207
+ .optional()
208
+ .describe(
209
+ 'Which agents to list: "siblings" (default) or the whole "tree" under your chat.',
210
+ ),
211
+ },
198
212
  execute: (params, bridge) => bridge("list_peers", params),
199
213
  tag: "agents",
200
214
  },
@@ -202,10 +216,14 @@ export const agentTools: ToolDefinition[] = [
202
216
  {
203
217
  name: "message_peer",
204
218
  description:
205
- "Sub-agents only. Send a note straight to a peer — an agent spawned by the same parent as you — without routing it through your parent. Use it when you find something that changes another agent's work: a shared fact, a dead end worth not repeating, a correction to something you sent earlier. The peer sees it at its next check_inbox, so it is not an interrupt. You can only address peers (see list_peers); any other id is refused. This does not end your run and does not replace report_result.",
219
+ "Sub-agents only. Send a note straight to any live agent in your tree — a sibling, your parent agent, a child, or a cousin working for the same chat — by id or label, without routing it through anyone. Use it when you find something that changes another agent's work: a shared fact, a dead end worth not repeating, a correction to something you sent earlier. It sees the note at its next check_inbox, so it is not an interrupt. Agents working for another chat are never reachable. This does not end your run and does not replace report_result.",
206
220
  schema: {
207
- agent_id: z.string().describe("Peer agent id, from list_peers"),
208
- text: z.string().min(1).describe("What the peer needs to know"),
221
+ agent_id: z
222
+ .string()
223
+ .describe(
224
+ "Target agent id (or its exact label, when no other live agent in your tree shares it), from list_peers",
225
+ ),
226
+ text: z.string().min(1).describe("What the agent needs to know"),
209
227
  },
210
228
  execute: (params, bridge) => bridge("message_peer", params),
211
229
  tag: "agents",
@@ -229,7 +247,7 @@ export const preflightTools: ToolDefinition[] = [
229
247
  {
230
248
  name: "run_preflight",
231
249
  description:
232
- "Run the pre-flight lane (`npm run preflight`: typecheck, lint, format, architecture/knip/ratchet gates, the unit tests touched by your diff, gitleaks) in a talon checkout on the daemon host and get its verdict. Run it before every `git push` on a PR branch and push only when it is GREEN; a RED result lists each failing step with the tail of its log. Takes a few minutes (hard cap 10). Needs `npm ci` done in the checkout.",
250
+ "Run the pre-flight lane (`npm run preflight`: typecheck, lint, format, architecture/knip/ratchet gates, the unit tests touched by your diff, gitleaks) in a talon checkout on the daemon host and get its verdict. Run it before every `git push` on a PR branch and push only when it is GREEN; a RED result lists each failing step with the tail of its log. Takes a few minutes (hard cap 10). Needs node_modules in the checkout: make worktrees with `node scripts/worktree.mjs add <path> <branch>` (hardlinked shared install, ~0 extra disk) rather than `npm ci`.",
233
251
  schema: {
234
252
  cwd: z
235
253
  .string()
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Secret tools — ask the operator for a password without it crossing the
3
+ * chat. The tool mints a single-use link to a paste form on the native
4
+ * bridge; the value lands in ~/.talon/secrets/<name> and the chat gets a
5
+ * receipt. The model sees the link and the name, never the value.
6
+ */
7
+
8
+ import { z } from "zod";
9
+ import type { ToolDefinition } from "../types.js";
10
+
11
+ export const secretTools: ToolDefinition[] = [
12
+ {
13
+ name: "request_secret",
14
+ description: `Ask the operator for a secret (password, API key, token) without it passing through the chat. Mints a single-use link, valid about 15 minutes, to a paste form served by Talon's native bridge (the agent-tool twin of /secret <name>). The value is written to ~/.talon/secrets/<name> (mode 600) and this chat gets "stored ✓ as <name>" when it lands. You never see the value: read it from the file when a command needs it, e.g. "$(cat ~/.talon/secrets/<name>)", and never echo it.
15
+
16
+ Use this whenever you need a credential, and point the operator to it if they start pasting one into the chat. Requires the native bridge on an https:// address.`,
17
+ schema: {
18
+ name: z
19
+ .string()
20
+ .min(1)
21
+ .max(64)
22
+ .describe(
23
+ "File name under ~/.talon/secrets: letters, digits, '.', '_' or '-', starting with a letter or digit (e.g. 'gmail-app-password'). An existing secret of that name is replaced.",
24
+ ),
25
+ purpose: z
26
+ .string()
27
+ .max(200)
28
+ .optional()
29
+ .describe(
30
+ "One line shown on the form so the operator knows what it's for (e.g. 'Gmail app password for the email plugin').",
31
+ ),
32
+ },
33
+ execute: (params, bridge) => bridge("request_secret", params),
34
+ tag: "secrets",
35
+ },
36
+ ];
@@ -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
+ }
@@ -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
  /**