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.
- package/package.json +2 -1
- 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 +169 -31
- package/src/core/agents/trail.ts +141 -0
- package/src/core/agents/types.ts +29 -3
- 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 +13 -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 +26 -8
- 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/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/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/whatsapp/commands.ts +16 -1
- package/src/util/log.ts +1 -0
- package/src/util/paths.ts +6 -0
package/src/core/config/index.ts
CHANGED
|
@@ -642,7 +642,15 @@ const configSchema = z.object({
|
|
|
642
642
|
* - `maxDepth` — how far delegation may nest. 0 = chats only, 2 (the
|
|
643
643
|
* default) = chat → agent → agent.
|
|
644
644
|
* - `defaultTimeoutMs` — hard wall-clock cap when a spawn doesn't pass
|
|
645
|
-
* its own.
|
|
645
|
+
* its own. Unset (the default) = no hard cap: a run ends when it is
|
|
646
|
+
* done, killed, or stalled (see `stallTimeoutMs`).
|
|
647
|
+
* - `maxTimeoutMs` — optional global ceiling on every run's hard cap,
|
|
648
|
+
* including spawns that pass none. Unset = no ceiling. Per-spawn
|
|
649
|
+
* values are floored at 30s.
|
|
650
|
+
* - `stallTimeoutMs` — the no-progress watchdog step N (default 15
|
|
651
|
+
* min): after N with no tool call or output the agent is pinged,
|
|
652
|
+
* after 2N its parent is warned, after 3N it is killed (`timed_out`).
|
|
653
|
+
* 0 disables it.
|
|
646
654
|
* - `allowedBackends` — optional allowlist of backend ids sub-agents
|
|
647
655
|
* may run on. Unset = any backend with a background capability. A
|
|
648
656
|
* spawn that names (or inherits) a backend outside it is refused.
|
|
@@ -651,11 +659,12 @@ const configSchema = z.object({
|
|
|
651
659
|
.object({
|
|
652
660
|
maxConcurrent: z.number().int().min(1).max(64).default(6),
|
|
653
661
|
maxDepth: z.number().int().min(0).max(5).default(2),
|
|
654
|
-
defaultTimeoutMs: z
|
|
662
|
+
defaultTimeoutMs: z.number().int().min(30_000).optional(),
|
|
663
|
+
maxTimeoutMs: z.number().int().min(30_000).optional(),
|
|
664
|
+
stallTimeoutMs: z
|
|
655
665
|
.number()
|
|
656
666
|
.int()
|
|
657
|
-
.min(
|
|
658
|
-
.max(3_600_000)
|
|
667
|
+
.min(0)
|
|
659
668
|
.default(15 * 60 * 1000),
|
|
660
669
|
allowedBackends: z.array(z.string().min(1)).optional(),
|
|
661
670
|
})
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
agentIdFromContextLabel,
|
|
17
17
|
agentRegistry,
|
|
18
18
|
clampTimeout,
|
|
19
|
+
describeTimeout,
|
|
19
20
|
deliverToAgent,
|
|
20
21
|
getAgentCaps,
|
|
21
22
|
killAgent,
|
|
@@ -210,7 +211,8 @@ export const agentControlHandlers: SharedActionHandlers = {
|
|
|
210
211
|
preflight: parsed.preflight,
|
|
211
212
|
});
|
|
212
213
|
if (!outcome.ok) return { ok: false, error: outcome.error };
|
|
213
|
-
const
|
|
214
|
+
const timeout = describeTimeout(clampTimeout(parsed.timeoutMs));
|
|
215
|
+
const stall = getAgentCaps().stallTimeoutMs;
|
|
214
216
|
log("gateway", `spawn_agent: "${parsed.label}" [${outcome.agentId}]`);
|
|
215
217
|
return {
|
|
216
218
|
ok: true,
|
|
@@ -218,7 +220,12 @@ export const agentControlHandlers: SharedActionHandlers = {
|
|
|
218
220
|
`Spawned agent "${parsed.label}" (id: ${outcome.agentId})\n` +
|
|
219
221
|
`Backend: ${outcome.backendId}/${outcome.model}` +
|
|
220
222
|
`${outcome.routing ? ` (routed: ${outcome.routing})` : ""}\n` +
|
|
221
|
-
`Timeout: ${
|
|
223
|
+
`Timeout: ${timeout}` +
|
|
224
|
+
(stall > 0
|
|
225
|
+
? ` (watchdog: pinged after ${describeTimeout(stall)} quiet, ` +
|
|
226
|
+
`killed after ${describeTimeout(3 * stall)})`
|
|
227
|
+
: "") +
|
|
228
|
+
`\n` +
|
|
222
229
|
(parsed.preflight ? `Pre-flight lane: on\n` : "") +
|
|
223
230
|
`It runs in the background. You will be woken with its report — ` +
|
|
224
231
|
`carry on with what you were doing.`,
|
|
@@ -28,6 +28,10 @@ const PREFLIGHT_TIMEOUT_MS = 600_000;
|
|
|
28
28
|
const KILL_GRACE_MS = 5_000;
|
|
29
29
|
/** Lines of each failing step's log to hand back. */
|
|
30
30
|
const FAIL_TAIL_LINES = 30;
|
|
31
|
+
/** How to give a checkout dependencies without a fresh ~1.2 GB `npm ci`. */
|
|
32
|
+
const LINK_HINT =
|
|
33
|
+
"No node_modules in this checkout: run `node scripts/worktree.mjs link` " +
|
|
34
|
+
"in it (hardlinks a shared install, ~0 extra disk), then retry.";
|
|
31
35
|
|
|
32
36
|
interface PreflightStep {
|
|
33
37
|
readonly name: string;
|
|
@@ -64,6 +68,15 @@ function repoRoot(dir: string): Promise<string> {
|
|
|
64
68
|
});
|
|
65
69
|
}
|
|
66
70
|
|
|
71
|
+
async function exists(path: string): Promise<boolean> {
|
|
72
|
+
try {
|
|
73
|
+
await stat(path);
|
|
74
|
+
return true;
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
67
80
|
async function isFile(path: string): Promise<boolean> {
|
|
68
81
|
try {
|
|
69
82
|
return (await stat(path)).isFile();
|
|
@@ -193,11 +206,14 @@ async function runPreflight(
|
|
|
193
206
|
}
|
|
194
207
|
const summary = await readSummary(root);
|
|
195
208
|
if (!summary) {
|
|
209
|
+
const hint = (await exists(join(root, "node_modules")))
|
|
210
|
+
? ""
|
|
211
|
+
: `\n${LINK_HINT}`;
|
|
196
212
|
return {
|
|
197
213
|
ok: false,
|
|
198
214
|
error:
|
|
199
215
|
`Pre-flight exited ${run.code} without writing .preflight/last.json. ` +
|
|
200
|
-
`Output:\n${run.output.slice(-2_000)}`,
|
|
216
|
+
`Output:\n${run.output.slice(-2_000)}${hint}`,
|
|
201
217
|
};
|
|
202
218
|
}
|
|
203
219
|
// A red lane is a successful tool call with a red verdict — the model is
|
|
@@ -18,12 +18,40 @@ import {
|
|
|
18
18
|
deliverMessage,
|
|
19
19
|
deliverToAgent,
|
|
20
20
|
describeParent,
|
|
21
|
+
recordInterimMessage,
|
|
21
22
|
} from "../../../agents/index.js";
|
|
22
23
|
import { logError } from "../../../../util/log.js";
|
|
23
24
|
import type { ActionResult } from "../../../types.js";
|
|
24
25
|
import type { AgentRecord } from "../../../agents/index.js";
|
|
25
26
|
import type { SharedActionHandlers } from "../types.js";
|
|
26
27
|
|
|
28
|
+
/** How `other` relates to `self` within their shared tree. */
|
|
29
|
+
function relation(self: AgentRecord, other: AgentRecord): string {
|
|
30
|
+
if (self.parent.kind === "agent" && self.parent.agentId === other.id) {
|
|
31
|
+
return "your parent";
|
|
32
|
+
}
|
|
33
|
+
if (other.parent.kind === "agent" && other.parent.agentId === self.id) {
|
|
34
|
+
return "your child";
|
|
35
|
+
}
|
|
36
|
+
const sameParent =
|
|
37
|
+
self.parent.kind === other.parent.kind &&
|
|
38
|
+
(self.parent.kind === "chat"
|
|
39
|
+
? other.parent.kind === "chat" &&
|
|
40
|
+
other.parent.chatId === self.parent.chatId
|
|
41
|
+
: other.parent.kind === "agent" &&
|
|
42
|
+
other.parent.agentId === self.parent.agentId);
|
|
43
|
+
return sameParent ? "sibling" : `in your tree, depth ${other.depth}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** One agent in a `list_peers` listing. */
|
|
47
|
+
function renderPeer(peer: AgentRecord, rel?: string): string {
|
|
48
|
+
return (
|
|
49
|
+
`- ${peer.label} [${peer.state}]${rel ? ` — ${rel}` : ""}\n` +
|
|
50
|
+
` ID: ${peer.id}\n` +
|
|
51
|
+
` Working on: ${peer.brief.slice(0, 160).replace(/\s+/g, " ")}…`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
27
55
|
function notAnAgent(tool: string): ActionResult {
|
|
28
56
|
return {
|
|
29
57
|
ok: false,
|
|
@@ -72,6 +100,8 @@ export const agentReportHandlers: SharedActionHandlers = {
|
|
|
72
100
|
if (!record) return notAnAgent("message_parent");
|
|
73
101
|
const text = String(body.text ?? "").trim();
|
|
74
102
|
if (!text) return { ok: false, error: "Missing text" };
|
|
103
|
+
// Kept for the parent's wake-up should the run be cut short.
|
|
104
|
+
recordInterimMessage(record.id, text);
|
|
75
105
|
// Fire-and-forget: waking a chat runs a whole turn, and this tool call
|
|
76
106
|
// must not block for the length of the parent's reply.
|
|
77
107
|
void deliverMessage(record, text).catch((err: unknown) =>
|
|
@@ -87,64 +117,91 @@ export const agentReportHandlers: SharedActionHandlers = {
|
|
|
87
117
|
};
|
|
88
118
|
},
|
|
89
119
|
|
|
90
|
-
list_peers: (
|
|
120
|
+
list_peers: (body, _chatId, _backend, chatKey) => {
|
|
91
121
|
const record = callingAgent(chatKey);
|
|
92
122
|
if (!record) return notAnAgent("list_peers");
|
|
123
|
+
const scope = body.scope === undefined ? "siblings" : String(body.scope);
|
|
124
|
+
if (scope !== "siblings" && scope !== "tree") {
|
|
125
|
+
return {
|
|
126
|
+
ok: false,
|
|
127
|
+
error: `Unknown scope "${scope}". Use "siblings" or "tree".`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
if (scope === "tree") {
|
|
131
|
+
const tree = agentRegistry.treeOf(record.id);
|
|
132
|
+
if (tree.length === 0) {
|
|
133
|
+
return {
|
|
134
|
+
ok: true,
|
|
135
|
+
text: "No other live agents in your tree. Report to your parent as usual.",
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
ok: true,
|
|
140
|
+
text:
|
|
141
|
+
`${tree.length} other live agent(s) in your tree (everything ` +
|
|
142
|
+
`working for the same chat). Any of them can be reached with ` +
|
|
143
|
+
`message_peer:\n\n` +
|
|
144
|
+
tree
|
|
145
|
+
.map((other) => renderPeer(other, relation(record, other)))
|
|
146
|
+
.join("\n"),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
93
149
|
const peers = agentRegistry.peersOf(record.id);
|
|
94
150
|
if (peers.length === 0) {
|
|
95
151
|
return {
|
|
96
152
|
ok: true,
|
|
97
153
|
text:
|
|
98
154
|
"No peers — you are the only agent your parent has running. " +
|
|
99
|
-
|
|
155
|
+
'Report to your parent as usual (list_peers with scope "tree" ' +
|
|
156
|
+
"shows the rest of your agent tree).",
|
|
100
157
|
};
|
|
101
158
|
}
|
|
102
|
-
const rendered = peers
|
|
103
|
-
.map(
|
|
104
|
-
(peer) =>
|
|
105
|
-
`- ${peer.label} [${peer.state}]\n ID: ${peer.id}\n Working on: ${peer.brief.slice(0, 160).replace(/\s+/g, " ")}…`,
|
|
106
|
-
)
|
|
107
|
-
.join("\n");
|
|
108
159
|
return {
|
|
109
160
|
ok: true,
|
|
110
|
-
text:
|
|
161
|
+
text:
|
|
162
|
+
`${peers.length} peer(s) running alongside you:\n\n` +
|
|
163
|
+
peers.map((peer) => renderPeer(peer)).join("\n"),
|
|
111
164
|
};
|
|
112
165
|
},
|
|
113
166
|
|
|
114
167
|
message_peer: (body, _chatId, _backend, chatKey) => {
|
|
115
168
|
const record = callingAgent(chatKey);
|
|
116
169
|
if (!record) return notAnAgent("message_peer");
|
|
117
|
-
const
|
|
118
|
-
if (!
|
|
170
|
+
const target = String(body.agent_id ?? body.label ?? "").trim();
|
|
171
|
+
if (!target) return { ok: false, error: "Missing agent_id (or label)" };
|
|
119
172
|
const text = String(body.text ?? "").trim();
|
|
120
173
|
if (!text) return { ok: false, error: "Missing text" };
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
.find((candidate) => candidate.id === id);
|
|
127
|
-
if (!peer) {
|
|
174
|
+
// Resolved through the caller's own tree: an agent can reach any live
|
|
175
|
+
// agent working for the same chat, never one working for another — a
|
|
176
|
+
// wrong or foreign id fails closed rather than delivering.
|
|
177
|
+
const found = agentRegistry.findInTree(record.id, target);
|
|
178
|
+
if (!found.ok) {
|
|
128
179
|
return {
|
|
129
180
|
ok: false,
|
|
130
181
|
error:
|
|
131
|
-
|
|
132
|
-
|
|
182
|
+
found.candidates.length > 1
|
|
183
|
+
? `"${target}" is ambiguous — ${found.candidates.length} live ` +
|
|
184
|
+
`agents in your tree have that label ` +
|
|
185
|
+
`(${found.candidates.map((c) => c.id).join(", ")}). Use the id.`
|
|
186
|
+
: `No live agent "${target}" in your tree. You can message any ` +
|
|
187
|
+
`live agent working for the same chat as you — call ` +
|
|
188
|
+
`list_peers with scope "tree" to see them.`,
|
|
133
189
|
};
|
|
134
190
|
}
|
|
135
|
-
|
|
191
|
+
const peer = found.record;
|
|
192
|
+
if (!deliverToAgent(record.id, peer.id, text)) {
|
|
136
193
|
return {
|
|
137
194
|
ok: false,
|
|
138
195
|
error:
|
|
139
|
-
`Could not deliver to "${peer.label}" (${id}): it has already ` +
|
|
196
|
+
`Could not deliver to "${peer.label}" (${peer.id}): it has already ` +
|
|
140
197
|
`settled, or its inbox is full.`,
|
|
141
198
|
};
|
|
142
199
|
}
|
|
143
200
|
return {
|
|
144
201
|
ok: true,
|
|
145
202
|
text:
|
|
146
|
-
`Queued for
|
|
147
|
-
`own milestones, so this is not an interrupt.`,
|
|
203
|
+
`Queued for "${peer.label}" (${peer.id}, ${relation(record, peer)}). ` +
|
|
204
|
+
`It reads its inbox at its own milestones, so this is not an interrupt.`,
|
|
148
205
|
};
|
|
149
206
|
},
|
|
150
207
|
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* - `mesh` — companion device mesh (presence + location)
|
|
23
23
|
* - `cross-send` — explicit-target sends through any enabled frontend
|
|
24
24
|
* - `backup` — snapshots and checkpoints (chat-free; no restore)
|
|
25
|
+
* - `secrets` — secret-drop links (core/secrets; needs the chat)
|
|
25
26
|
*/
|
|
26
27
|
|
|
27
28
|
import type { ActionResult } from "../../types.js";
|
|
@@ -49,6 +50,7 @@ import {
|
|
|
49
50
|
} from "./whatsapp-account.js";
|
|
50
51
|
import { nativeActionRefusal, nativeHandlers } from "./native/index.js";
|
|
51
52
|
import { backupChatFreeActions, backupHandlers } from "./backup/index.js";
|
|
53
|
+
import { secretHandlers } from "../../secrets/actions.js";
|
|
52
54
|
|
|
53
55
|
// Null-prototype so a request `action` of "toString" / "constructor" / etc.
|
|
54
56
|
// can't resolve an inherited Object.prototype method — `handlers[action]` only
|
|
@@ -70,6 +72,7 @@ const handlers: SharedActionHandlers = Object.assign(Object.create(null), {
|
|
|
70
72
|
...whatsappAccountHandlers,
|
|
71
73
|
...nativeHandlers,
|
|
72
74
|
...backupHandlers,
|
|
75
|
+
...secretHandlers,
|
|
73
76
|
});
|
|
74
77
|
|
|
75
78
|
/**
|
|
@@ -83,12 +83,14 @@ export const GUEST_TOOL_ALLOWLIST: ReadonlySet<string> = new Set([
|
|
|
83
83
|
|
|
84
84
|
/**
|
|
85
85
|
* Tools whose output is a live credential (an install command or pairing
|
|
86
|
-
* link embedding a bridge bearer token). Operator-only, and their output is delivered
|
|
86
|
+
* link embedding a bridge bearer token, or a secret-drop link). Operator-only, and their output is delivered
|
|
87
87
|
* to the operator's private chat, never into a shared one.
|
|
88
88
|
*/
|
|
89
89
|
export const OPERATOR_PRIVATE_OUTPUT_TOOLS: ReadonlySet<string> = new Set([
|
|
90
90
|
"make_node_install_link",
|
|
91
91
|
"make_companion_pair_link",
|
|
92
|
+
// A secret-drop link writes into ~/.talon/secrets for whoever opens it.
|
|
93
|
+
"request_secret",
|
|
92
94
|
]);
|
|
93
95
|
|
|
94
96
|
/** Parameters that name another chat. A guest may only name its own. */
|
|
@@ -863,6 +863,13 @@ export class MeshService {
|
|
|
863
863
|
return this.links.makeCompanionPairLink(label, bridgeUrl);
|
|
864
864
|
}
|
|
865
865
|
|
|
866
|
+
/** The bridge base URL a bridge-served link should use (secret drop). */
|
|
867
|
+
bridgeBaseUrl(
|
|
868
|
+
explicit?: unknown,
|
|
869
|
+
): ReturnType<BridgeLinks["bridgeBaseUrlFor"]> {
|
|
870
|
+
return this.links.bridgeBaseUrlFor(explicit);
|
|
871
|
+
}
|
|
872
|
+
|
|
866
873
|
/** `make_companion_pair_link`: the pairing link as a tool result. */
|
|
867
874
|
makeCompanionPairLinkText(
|
|
868
875
|
label?: unknown,
|
|
@@ -371,6 +371,26 @@ export class BridgeLinks {
|
|
|
371
371
|
};
|
|
372
372
|
}
|
|
373
373
|
|
|
374
|
+
/**
|
|
375
|
+
* The base URL a device should open a bridge-served link at (secret
|
|
376
|
+
* drop): the same answer the pairing and install links use.
|
|
377
|
+
*/
|
|
378
|
+
bridgeBaseUrlFor(
|
|
379
|
+
explicit?: unknown,
|
|
380
|
+
): { ok: true; url: string } | { ok: false; text: string } {
|
|
381
|
+
const info = this.bridgeInfo;
|
|
382
|
+
if (!info) {
|
|
383
|
+
return {
|
|
384
|
+
ok: false,
|
|
385
|
+
text: "The native bridge isn't running, so there is nothing to serve the link. Enable the native frontend first.",
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
const base = this.bridgeBaseUrl(info, explicit);
|
|
389
|
+
return typeof base === "string"
|
|
390
|
+
? { ok: true, url: base }
|
|
391
|
+
: { ok: false, text: base.error };
|
|
392
|
+
}
|
|
393
|
+
|
|
374
394
|
/** GET /node/install — serve a grant's installer script (single-use). */
|
|
375
395
|
/**
|
|
376
396
|
* An auto grant is pinned here first, from the os/arch its one-liner
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway action for `request_secret`. Needs the chat (the receipt goes
|
|
3
|
+
* back to it), so it is not chat-free.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { SharedActionHandlers } from "../engine/gateway-actions/types.js";
|
|
7
|
+
import { requestSecretDrop } from "./service.js";
|
|
8
|
+
|
|
9
|
+
export const secretHandlers: SharedActionHandlers = {
|
|
10
|
+
request_secret: (body, _chatId, _backend, chatKey) => {
|
|
11
|
+
const minted = requestSecretDrop({
|
|
12
|
+
name: body.name,
|
|
13
|
+
purpose: body.purpose,
|
|
14
|
+
chatKey,
|
|
15
|
+
});
|
|
16
|
+
return { ok: minted.ok, text: minted.text };
|
|
17
|
+
},
|
|
18
|
+
};
|
|
@@ -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
|
+
}
|