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.
- package/package.json +3 -2
- package/prompts/system/agent-brief.md +9 -6
- package/src/backend/claude-sdk/constants.ts +22 -0
- package/src/backend/claude-sdk/models/discovery.ts +3 -0
- package/src/backend/claude-sdk/one-shot.ts +3 -1
- package/src/backend/claude-sdk/options.ts +7 -1
- package/src/core/agents/index.ts +2 -0
- package/src/core/agents/prompt.ts +70 -2
- package/src/core/agents/registry.ts +47 -5
- package/src/core/agents/runner.ts +227 -39
- package/src/core/agents/trail.ts +141 -0
- package/src/core/agents/types.ts +43 -5
- package/src/core/agents/watchdog.ts +70 -0
- package/src/core/background/isolated-agent.ts +6 -2
- package/src/core/backup/plan.ts +4 -0
- package/src/core/config/index.ts +17 -4
- package/src/core/engine/gateway-actions/agents/control.ts +9 -2
- package/src/core/engine/gateway-actions/agents/preflight.ts +17 -1
- package/src/core/engine/gateway-actions/agents/report.ts +81 -24
- package/src/core/engine/gateway-actions/index.ts +3 -0
- package/src/core/mcp-hub/guest-scope.ts +3 -1
- package/src/core/mesh/devices/service.ts +7 -0
- package/src/core/mesh/links/bridge-links.ts +20 -0
- package/src/core/secrets/actions.ts +18 -0
- package/src/core/secrets/drop.ts +176 -0
- package/src/core/secrets/index.ts +11 -0
- package/src/core/secrets/service.ts +248 -0
- package/src/core/secrets/store.ts +100 -0
- package/src/core/tools/index.ts +2 -0
- package/src/core/tools/ops/agents.ts +30 -12
- package/src/core/tools/ops/secrets.ts +36 -0
- package/src/core/tools/types.ts +2 -1
- package/src/frontend/discord/commands/definitions.ts +21 -0
- package/src/frontend/discord/commands/router.ts +3 -0
- package/src/frontend/discord/commands/secret.ts +26 -0
- package/src/frontend/discord/handlers/messages.ts +6 -4
- package/src/frontend/native/bridge/routes/host.ts +10 -0
- package/src/frontend/native/bridge/routes/pre-auth.ts +82 -1
- package/src/frontend/native/bridge/routes/table.ts +5 -0
- package/src/frontend/native/bridge/server.ts +32 -4
- package/src/frontend/native/commands/definitions.ts +6 -0
- package/src/frontend/native/commands/index.ts +12 -0
- package/src/frontend/native/surface/handlers.ts +9 -0
- package/src/frontend/telegram/actions/chat-info.ts +22 -5
- package/src/frontend/telegram/commands/definitions.ts +4 -0
- package/src/frontend/telegram/commands/index.ts +3 -0
- package/src/frontend/telegram/commands/secret.ts +24 -0
- package/src/frontend/telegram/handlers/messages.ts +7 -5
- package/src/frontend/whatsapp/commands.ts +16 -1
- package/src/frontend/whatsapp/messages/media-store.ts +4 -2
- package/src/storage/media-index.ts +43 -11
- package/src/storage/repositories/media-index-repo.ts +12 -1
- package/src/storage/sql/media-index.sql +4 -0
- package/src/storage/sql/statements.generated.ts +2 -0
- package/src/util/log.ts +1 -0
- package/src/util/paths.ts +6 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret drop: single-use, expiring grants that let the operator paste a
|
|
3
|
+
* password into a form served by the native bridge instead of into a chat.
|
|
4
|
+
*
|
|
5
|
+
* Same trust model as companion pairing (mesh/links/companion-pairing.ts):
|
|
6
|
+
* the bridge serves the grant pre-auth, and the random 192-bit grant in the
|
|
7
|
+
* query is the whole authorization. Differences:
|
|
8
|
+
*
|
|
9
|
+
* - GET only shows the form and does NOT spend the grant. Chat apps
|
|
10
|
+
* fetch links to build previews, and a preview must not burn the link
|
|
11
|
+
* before the human opens it.
|
|
12
|
+
* - POST spends it, once, whether or not the write then succeeds — a
|
|
13
|
+
* failed write asks for a fresh link rather than leaving a live one
|
|
14
|
+
* open to retries.
|
|
15
|
+
*
|
|
16
|
+
* The grant records which chat asked, so the receipt ("stored ✓ as <name>")
|
|
17
|
+
* goes back there. The value itself never leaves `consume` except into the
|
|
18
|
+
* file.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { randomBytes } from "node:crypto";
|
|
22
|
+
|
|
23
|
+
/** Unused grants die after this long. */
|
|
24
|
+
export const SECRET_GRANT_TTL_MS = 15 * 60 * 1000;
|
|
25
|
+
|
|
26
|
+
export type SecretDropGrant = {
|
|
27
|
+
token: string;
|
|
28
|
+
/** Validated secret name — the file under ~/.talon/secrets. */
|
|
29
|
+
name: string;
|
|
30
|
+
/** Shown on the form so the operator knows what is being asked for. */
|
|
31
|
+
purpose?: string;
|
|
32
|
+
/** Chat that asked, for the receipt. */
|
|
33
|
+
chatKey: string;
|
|
34
|
+
/** Frontend that chat lives on. */
|
|
35
|
+
frontend: string;
|
|
36
|
+
createdAt: number;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export class SecretDropStore {
|
|
40
|
+
private readonly grants = new Map<string, SecretDropGrant>();
|
|
41
|
+
|
|
42
|
+
constructor(
|
|
43
|
+
private readonly ttlMs = SECRET_GRANT_TTL_MS,
|
|
44
|
+
private readonly now: () => number = Date.now,
|
|
45
|
+
) {}
|
|
46
|
+
|
|
47
|
+
create(grant: Omit<SecretDropGrant, "token" | "createdAt">): SecretDropGrant {
|
|
48
|
+
this.sweep();
|
|
49
|
+
const full: SecretDropGrant = {
|
|
50
|
+
...grant,
|
|
51
|
+
...(grant.purpose ? { purpose: cleanPurpose(grant.purpose) } : {}),
|
|
52
|
+
token: randomBytes(24).toString("base64url"),
|
|
53
|
+
createdAt: this.now(),
|
|
54
|
+
};
|
|
55
|
+
this.grants.set(full.token, full);
|
|
56
|
+
return full;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A live grant, without spending it (the GET form). */
|
|
60
|
+
peek(token: string): SecretDropGrant | null {
|
|
61
|
+
this.sweep();
|
|
62
|
+
return this.grants.get(token) ?? null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Spend a live grant (the POST). Null when unknown, expired or used. */
|
|
66
|
+
consume(token: string): SecretDropGrant | null {
|
|
67
|
+
this.sweep();
|
|
68
|
+
const grant = this.grants.get(token);
|
|
69
|
+
if (!grant) return null;
|
|
70
|
+
this.grants.delete(token);
|
|
71
|
+
return grant;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private sweep(): void {
|
|
75
|
+
const cutoff = this.now() - this.ttlMs;
|
|
76
|
+
for (const [token, grant] of this.grants) {
|
|
77
|
+
if (grant.createdAt <= cutoff) this.grants.delete(token);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Purposes come from the model: one line, no markup, bounded. */
|
|
83
|
+
function cleanPurpose(purpose: string): string {
|
|
84
|
+
return purpose
|
|
85
|
+
.replace(/[\r\n\t]+/g, " ")
|
|
86
|
+
.trim()
|
|
87
|
+
.slice(0, 200);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Minimal HTML escape for values interpolated into the pages. */
|
|
91
|
+
function esc(s: string): string {
|
|
92
|
+
return s
|
|
93
|
+
.replace(/&/g, "&")
|
|
94
|
+
.replace(/</g, "<")
|
|
95
|
+
.replace(/>/g, ">")
|
|
96
|
+
.replace(/"/g, """)
|
|
97
|
+
.replace(/'/g, "'");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const STYLE = `:root { color-scheme: light dark; }
|
|
101
|
+
body { margin: 0; padding: 24px; font: 15px/1.5 system-ui, sans-serif; }
|
|
102
|
+
main { max-width: 32rem; margin: 0 auto; }
|
|
103
|
+
h1 { font-size: 1.25rem; margin: 0 0 .25rem; }
|
|
104
|
+
p.sub { margin: 0 0 1.25rem; opacity: .7; }
|
|
105
|
+
textarea { box-sizing: border-box; width: 100%; min-height: 6rem; padding: 10px;
|
|
106
|
+
font: 14px ui-monospace, monospace; border-radius: 8px; }
|
|
107
|
+
button { margin-top: 12px; width: 100%; padding: 14px; border: 0; border-radius: 10px;
|
|
108
|
+
background: #2f6fed; color: #fff; font-weight: 600; font-size: 1rem; }
|
|
109
|
+
footer { margin-top: 1.5rem; font-size: .8rem; opacity: .6; }`;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* One self-contained page, no script and no external assets. `no-referrer`
|
|
113
|
+
* keeps the grant out of any Referer and makes the browser send
|
|
114
|
+
* `Origin: null` on the POST; the bridge also accepts its own origin there.
|
|
115
|
+
*/
|
|
116
|
+
function page(title: string, body: string): string {
|
|
117
|
+
return `<!doctype html>
|
|
118
|
+
<html lang="en">
|
|
119
|
+
<head>
|
|
120
|
+
<meta charset="utf-8">
|
|
121
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
122
|
+
<meta name="referrer" content="no-referrer">
|
|
123
|
+
<meta name="robots" content="noindex">
|
|
124
|
+
<title>${esc(title)}</title>
|
|
125
|
+
<style>
|
|
126
|
+
${STYLE}
|
|
127
|
+
</style>
|
|
128
|
+
</head>
|
|
129
|
+
<body>
|
|
130
|
+
<main>
|
|
131
|
+
${body}
|
|
132
|
+
</main>
|
|
133
|
+
</body>
|
|
134
|
+
</html>
|
|
135
|
+
`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The paste form for a live grant. */
|
|
139
|
+
export function secretDropForm(grant: SecretDropGrant): string {
|
|
140
|
+
const minutes = Math.round(SECRET_GRANT_TTL_MS / 60_000);
|
|
141
|
+
return page(
|
|
142
|
+
"Talon secret drop",
|
|
143
|
+
` <h1>Store a secret</h1>
|
|
144
|
+
<p class="sub">Saved as <b>${esc(grant.name)}</b> on the Talon host.${
|
|
145
|
+
grant.purpose ? `<br>For: ${esc(grant.purpose)}` : ""
|
|
146
|
+
}</p>
|
|
147
|
+
<form method="post" action="?grant=${esc(grant.token)}" autocomplete="off">
|
|
148
|
+
<textarea name="value" required autofocus spellcheck="false"
|
|
149
|
+
autocapitalize="off" autocorrect="off" aria-label="Secret value"></textarea>
|
|
150
|
+
<button type="submit">Store</button>
|
|
151
|
+
</form>
|
|
152
|
+
<footer>The value goes straight to a file readable only by Talon's user. It is
|
|
153
|
+
not posted to the chat or shown to the model. This link works once and expires
|
|
154
|
+
${minutes} minutes after it was made.</footer>`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** What the browser sees after a POST. */
|
|
159
|
+
export function secretDropResult(
|
|
160
|
+
ok: boolean,
|
|
161
|
+
name: string | null,
|
|
162
|
+
error?: string,
|
|
163
|
+
): string {
|
|
164
|
+
return ok && name
|
|
165
|
+
? page(
|
|
166
|
+
"Stored",
|
|
167
|
+
` <h1>Stored ✓</h1>
|
|
168
|
+
<p class="sub">Saved as <b>${esc(name)}</b>. You can close this page.</p>`,
|
|
169
|
+
)
|
|
170
|
+
: page(
|
|
171
|
+
"Not stored",
|
|
172
|
+
` <h1>Not stored</h1>
|
|
173
|
+
<p class="sub">${esc(error ?? "This link is unknown, expired, or already used.")}</p>
|
|
174
|
+
<footer>Ask for a fresh link with /secret <name>.</footer>`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
@@ -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
|
+
}
|
package/src/core/tools/index.ts
CHANGED
|
@@ -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
|
/**
|
|
@@ -35,9 +35,16 @@ Bad uses: anything needing a back-and-forth with the user, trivial work you
|
|
|
35
35
|
could do in one tool call, or work whose result you need in the next second
|
|
36
36
|
(spawning costs a model cold-start).
|
|
37
37
|
|
|
38
|
-
Backend and model default to
|
|
39
|
-
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
|
|
38
|
+
Backend and model default to the caller's (a sub-agent's own children
|
|
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.
|
|
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
|
{
|
|
@@ -61,13 +68,13 @@ export const agentTools: ToolDefinition[] = [
|
|
|
61
68
|
.string()
|
|
62
69
|
.optional()
|
|
63
70
|
.describe(
|
|
64
|
-
"Backend id to run on. Unset =
|
|
71
|
+
"Backend id to run on. Unset = inherit: an agent's children run on its own backend and model; a chat's spawns start from the chat's backend (and may be routed to one with more headroom). Must have a background capability and, when the deployment sets agents.allowedBackends, be on that list.",
|
|
65
72
|
),
|
|
66
73
|
model: z
|
|
67
74
|
.string()
|
|
68
75
|
.optional()
|
|
69
76
|
.describe(
|
|
70
|
-
"Model id on the chosen backend. Unset = that backend's default model. Call list_models for valid ids.",
|
|
77
|
+
"Model id on the chosen backend. Unset = the parent agent's model when the backend is inherited from one, else that backend's default model. Call list_models for valid ids.",
|
|
71
78
|
),
|
|
72
79
|
effort: z
|
|
73
80
|
.enum(["minimal", "low", "medium", "high", "xhigh"])
|
|
@@ -81,7 +88,7 @@ export const agentTools: ToolDefinition[] = [
|
|
|
81
88
|
.positive()
|
|
82
89
|
.optional()
|
|
83
90
|
.describe(
|
|
84
|
-
"
|
|
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
|
-
|
|
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
|
|
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
|
|
208
|
-
|
|
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
|
|
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
|
+
];
|