talon-agent 5.14.0 → 5.18.2
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/LICENSE +202 -21
- package/LICENSE-MIT +21 -0
- package/NOTICE +16 -0
- package/README.md +14 -7
- package/package.json +6 -4
- package/prompts/system/heartbeat-agent.md +1 -1
- package/src/backend/agy/factory.ts +3 -0
- package/src/backend/agy/mcp/config.ts +14 -2
- package/src/backend/claude-sdk/factory.ts +3 -0
- package/src/backend/claude-sdk/options.ts +24 -3
- package/src/backend/codex/factory.ts +3 -0
- package/src/backend/codex/init.ts +4 -0
- package/src/backend/codex/mcp-config.ts +10 -0
- package/src/backend/codex/oauth-incompat.ts +1 -1
- package/src/backend/codex/token-usage.ts +2 -2
- package/src/backend/openai-agents/factory.ts +3 -0
- package/src/backend/openai-agents/mcp-pool.ts +4 -0
- package/src/backend/remote-server/factory.ts +3 -0
- package/src/backend/remote-server/mcp.ts +3 -0
- package/src/backend/runtime/prompt/prompt-format.ts +3 -3
- package/src/bootstrap.ts +8 -0
- package/src/cli/commands/backup.ts +61 -5
- package/src/cli/commands/mesh.ts +133 -0
- package/src/cli/config.ts +3 -1
- package/src/cli/daemon-api.ts +22 -0
- package/src/cli/index.ts +6 -0
- package/src/cli/install-sources.ts +40 -5
- package/src/cli/plugin.ts +10 -0
- package/src/cli/setup.ts +45 -4
- package/src/cli/skill.ts +3 -0
- package/src/core/agent-runtime/backend-registry.ts +16 -0
- package/src/core/backup/archive/crypt.ts +429 -0
- package/src/core/backup/archive/manifest-auth.ts +98 -0
- package/src/core/backup/passphrase.ts +130 -0
- package/src/core/backup/plan.ts +66 -13
- package/src/core/backup/restore-guard.ts +101 -0
- package/src/core/backup/restore.ts +249 -32
- package/src/core/backup/snapshot.ts +418 -60
- package/src/core/backup/sources/plugins.ts +223 -0
- package/src/core/backup/sources/relocate.ts +136 -0
- package/src/core/backup/sources/sessions.ts +198 -0
- package/src/core/backup/store.ts +3 -1
- package/src/core/backup/types.ts +66 -0
- package/src/core/backup/upload.ts +66 -6
- package/src/core/config/index.ts +140 -8
- package/src/core/daemon/control.ts +9 -0
- package/src/core/daemon/discovery.ts +7 -0
- package/src/core/engine/backend-router/headroom.ts +29 -5
- package/src/core/engine/backend-router/usage.ts +6 -2
- package/src/core/engine/gateway-actions/fetch-url/guard.ts +201 -0
- package/src/core/engine/gateway-actions/{fetch-url.ts → fetch-url/index.ts} +60 -32
- package/src/core/engine/gateway-actions/index.ts +4 -2
- package/src/core/engine/gateway-actions/native/index.ts +24 -0
- package/src/core/engine/gateway-actions/whatsapp-account.ts +1 -1
- package/src/core/engine/gateway-auth.ts +164 -0
- package/src/core/engine/gateway-routes.ts +100 -5
- package/src/core/engine/gateway.ts +12 -4
- package/src/core/mcp-hub/guest-scope.ts +170 -29
- package/src/core/mcp-hub/index.ts +33 -16
- package/src/core/mcp-hub/talon-server.ts +71 -14
- package/src/core/mesh/credentials/admin.ts +146 -0
- package/src/core/mesh/credentials/index.ts +19 -0
- package/src/core/mesh/credentials/store.ts +443 -0
- package/src/core/mesh/credentials/token.ts +45 -0
- package/src/core/mesh/credentials/types.ts +83 -0
- package/src/core/mesh/devices/service.ts +58 -6
- package/src/core/mesh/links/bridge-links.ts +46 -4
- package/src/core/mesh/links/node-binaries.ts +1 -1
- package/src/core/mesh/links/node-provision.ts +8 -1
- package/src/core/models/active-model.ts +1 -1
- package/src/core/plugin/loader.ts +4 -0
- package/src/core/plugin/mcp.ts +4 -0
- package/src/core/tools/bridge.ts +2 -1
- package/src/core/types.ts +13 -0
- package/src/core/weaver/weaver.ts +47 -15
- package/src/frontend/discord/callbacks/components/agent-buttons.ts +1 -0
- package/src/frontend/discord/handlers/delivery.ts +3 -0
- package/src/frontend/discord/handlers/queue.ts +1 -0
- package/src/frontend/native/bridge/auth-guard.ts +277 -0
- package/src/frontend/native/bridge/auth.ts +84 -1
- package/src/frontend/native/bridge/credentials/claims.ts +51 -0
- package/src/frontend/native/bridge/credentials/principal.ts +200 -0
- package/src/frontend/native/bridge/credentials/upgrade.ts +129 -0
- package/src/frontend/native/bridge/routes/auth.ts +37 -0
- package/src/frontend/native/bridge/routes/chats.ts +20 -2
- package/src/frontend/native/bridge/routes/host.ts +11 -1
- package/src/frontend/native/bridge/routes/index.ts +2 -0
- package/src/frontend/native/bridge/routes/mesh.ts +72 -15
- package/src/frontend/native/bridge/routes/table.ts +73 -51
- package/src/frontend/native/bridge/server.ts +266 -83
- package/src/frontend/native/index.ts +54 -3
- package/src/frontend/native/turn/turn.ts +2 -0
- package/src/frontend/teams/turn.ts +1 -0
- package/src/frontend/telegram/actions/outgoing-log.ts +70 -0
- package/src/frontend/telegram/actions/send.ts +4 -0
- package/src/frontend/telegram/admin.ts +20 -0
- package/src/frontend/telegram/commands/admin.ts +1 -1
- package/src/frontend/telegram/commands/state.ts +7 -9
- package/src/frontend/telegram/handlers/access.ts +31 -10
- package/src/frontend/telegram/handlers/delivery.ts +14 -1
- package/src/frontend/telegram/handlers/group-access.ts +50 -0
- package/src/frontend/telegram/handlers/messages.ts +1 -0
- package/src/frontend/telegram/handlers/queue.ts +14 -0
- package/src/frontend/telegram/handlers/state.ts +2 -2
- package/src/frontend/telegram/index.ts +32 -9
- package/src/frontend/telegram/middleware.ts +2 -2
- package/src/frontend/telegram/polling/poll-deadline.ts +52 -0
- package/src/frontend/telegram/{stale-command.ts → polling/stale-command.ts} +1 -1
- package/src/frontend/telegram/{update-offset.ts → polling/update-offset.ts} +1 -1
- package/src/frontend/telegram/userbot.ts +103 -10
- package/src/frontend/terminal/index.ts +8 -1
- package/src/frontend/whatsapp/commands.ts +3 -3
- package/src/frontend/whatsapp/messages/inbound.ts +1 -0
- package/src/plugins/playwright/index.ts +1 -1
- package/src/storage/backup/index.ts +1 -1
- package/src/storage/db.ts +23 -0
|
@@ -1,44 +1,69 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Tool scope: who gets the full agent, and who gets the conversation-only
|
|
3
|
+
* ("guest") surface.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* conversation-only surface
|
|
8
|
-
* for every backend that reaches tools through the hub:
|
|
5
|
+
* The operator gets everything: shell, files, mail, mesh devices, cron,
|
|
6
|
+
* memory, agents, cross-chat sends. Anyone else who can reach the bot (a
|
|
7
|
+
* member of an allowed group, a DM the operator let in) gets a
|
|
8
|
+
* conversation-only surface, enforced here in the hub:
|
|
9
9
|
*
|
|
10
10
|
* - Talon tools: an explicit allowlist (reply, react, edit/delete own
|
|
11
11
|
* messages, read this chat's history and media, look at stickers).
|
|
12
12
|
* Nothing that runs code, touches files, schedules, remembers, spawns
|
|
13
13
|
* agents, reaches devices or acts on another chat.
|
|
14
|
-
* - Parameters: a guest
|
|
14
|
+
* - Parameters: a guest turn may only target its own chat and may
|
|
15
15
|
* never attach a local file by path (`send(file_path=…)` would
|
|
16
16
|
* otherwise read any file the daemon can read and hand it over).
|
|
17
17
|
* - Plugin servers: only those named in `guestPlugins` (default: web
|
|
18
18
|
* search and the time/weather/currency extras).
|
|
19
19
|
*
|
|
20
|
-
*
|
|
21
|
-
* and
|
|
20
|
+
* The decision is made per TURN, from the SENDER — not per chat. A group
|
|
21
|
+
* holds the operator and other people at once, so "this chat is allowed"
|
|
22
|
+
* says nothing about who is asking. The weaver resolves the scope when a
|
|
23
|
+
* turn starts (`resolveTurnScope`) and brackets the turn with it
|
|
24
|
+
* (`enterTurnScope`); the hub reads it on every session and every call
|
|
25
|
+
* (`isGuestTurn`). A turn that batches messages from several people, or
|
|
26
|
+
* whose sender can't be identified, is a guest turn.
|
|
22
27
|
*
|
|
23
28
|
* Backend built-ins that live outside the hub (the Claude SDK's own
|
|
24
|
-
* Bash/Read/Write, Codex's shell) are the backend's job.
|
|
25
|
-
*
|
|
29
|
+
* Bash/Read/Write, Codex's shell) are the backend's job. A backend that
|
|
30
|
+
* can't drop them declares so, and the weaver refuses guest turns on it
|
|
31
|
+
* (see `guestToolScope` in agent-runtime/backend-registry.ts).
|
|
26
32
|
*/
|
|
27
33
|
|
|
28
34
|
export type GuestDmScopeConfig = {
|
|
35
|
+
/**
|
|
36
|
+
* Guest-scope DMs from non-operators (default true). `false` is a legacy
|
|
37
|
+
* opt-out that keeps the full surface in DMs the operator allowed;
|
|
38
|
+
* groups are always scoped per sender.
|
|
39
|
+
*/
|
|
29
40
|
enabled?: boolean;
|
|
30
|
-
/**
|
|
41
|
+
/** DM chat ids that keep the full surface (e.g. `wa_dm_<number>`). */
|
|
31
42
|
operatorChats?: readonly string[];
|
|
32
|
-
/** Plugin/hub server names a guest
|
|
43
|
+
/** Plugin/hub server names a guest may use. */
|
|
33
44
|
guestPlugins?: readonly string[];
|
|
45
|
+
/**
|
|
46
|
+
* Full surface for everyone in a group the operator is a member of
|
|
47
|
+
* (default false). Membership is attested by the frontend per turn
|
|
48
|
+
* (`TurnScopeInput.operatorInChat`).
|
|
49
|
+
*/
|
|
50
|
+
operatorGroups?: boolean;
|
|
34
51
|
};
|
|
35
52
|
|
|
53
|
+
export type ToolScope = "operator" | "guest";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Sender key for the local operator — terminal and native (bridge-token
|
|
57
|
+
* authenticated) clients, which only the operator can drive.
|
|
58
|
+
*/
|
|
59
|
+
export const LOCAL_OPERATOR_SENDER = "local:operator";
|
|
60
|
+
|
|
36
61
|
const DEFAULT_GUEST_PLUGINS: readonly string[] = [
|
|
37
62
|
"brave-search",
|
|
38
63
|
"extras-tools",
|
|
39
64
|
];
|
|
40
65
|
|
|
41
|
-
/** Talon tools a guest
|
|
66
|
+
/** Talon tools a guest may see and call. Everything else is hidden. */
|
|
42
67
|
export const GUEST_TOOL_ALLOWLIST: ReadonlySet<string> = new Set([
|
|
43
68
|
"end_turn",
|
|
44
69
|
"send",
|
|
@@ -56,49 +81,148 @@ export const GUEST_TOOL_ALLOWLIST: ReadonlySet<string> = new Set([
|
|
|
56
81
|
"download_sticker",
|
|
57
82
|
]);
|
|
58
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Tools whose output is a live credential (an install command embedding
|
|
86
|
+
* the bridge bearer token). Operator-only, and their output is delivered
|
|
87
|
+
* to the operator's private chat, never into a shared one.
|
|
88
|
+
*/
|
|
89
|
+
export const OPERATOR_PRIVATE_OUTPUT_TOOLS: ReadonlySet<string> = new Set([
|
|
90
|
+
"make_node_install_link",
|
|
91
|
+
]);
|
|
92
|
+
|
|
59
93
|
/** Parameters that name another chat. A guest may only name its own. */
|
|
60
94
|
const CHAT_TARGET_PARAMS = ["chat_id", "to_chat_id", "from_chat_id"] as const;
|
|
61
95
|
|
|
62
96
|
type ScopeState = {
|
|
63
|
-
|
|
97
|
+
dmScope: boolean;
|
|
98
|
+
/** Operator identities (sender keys) and operator DM chat ids. */
|
|
64
99
|
operators: Set<string>;
|
|
100
|
+
/** The operator's Telegram DM, where private output is delivered. */
|
|
101
|
+
operatorDm: string | null;
|
|
65
102
|
plugins: Set<string>;
|
|
103
|
+
operatorGroups: boolean;
|
|
66
104
|
};
|
|
67
105
|
|
|
68
106
|
let state: ScopeState = {
|
|
69
|
-
|
|
107
|
+
dmScope: true,
|
|
70
108
|
operators: new Set(),
|
|
109
|
+
operatorDm: null,
|
|
71
110
|
plugins: new Set(DEFAULT_GUEST_PLUGINS),
|
|
111
|
+
operatorGroups: false,
|
|
72
112
|
};
|
|
73
113
|
|
|
74
|
-
/**
|
|
114
|
+
/**
|
|
115
|
+
* Set at bootstrap (and on config reload). `operatorIds` are sender keys
|
|
116
|
+
* in the frontend's own form: a Telegram user id, `wa_dm_<number>`,
|
|
117
|
+
* `discord:<userId>`, `teams:<userId>`.
|
|
118
|
+
*/
|
|
75
119
|
export function initGuestDmScope(
|
|
76
120
|
cfg: GuestDmScopeConfig | undefined,
|
|
77
121
|
adminUserId?: number,
|
|
122
|
+
operatorIds: readonly string[] = [],
|
|
78
123
|
): void {
|
|
79
|
-
const operators = new Set<string>(
|
|
124
|
+
const operators = new Set<string>([
|
|
125
|
+
...(cfg?.operatorChats ?? []),
|
|
126
|
+
...operatorIds,
|
|
127
|
+
]);
|
|
80
128
|
if (adminUserId) operators.add(String(adminUserId));
|
|
81
129
|
state = {
|
|
82
|
-
|
|
130
|
+
dmScope: cfg?.enabled !== false,
|
|
83
131
|
operators,
|
|
132
|
+
operatorDm: adminUserId ? String(adminUserId) : null,
|
|
84
133
|
plugins: new Set(cfg?.guestPlugins ?? DEFAULT_GUEST_PLUGINS),
|
|
134
|
+
operatorGroups: cfg?.operatorGroups === true,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Is any of this sender's keys a configured operator? */
|
|
139
|
+
function isOperatorSender(senderKeys: readonly string[] | undefined): boolean {
|
|
140
|
+
if (!senderKeys || senderKeys.length === 0) return false;
|
|
141
|
+
return senderKeys.some(
|
|
142
|
+
(key) => key === LOCAL_OPERATOR_SENDER || state.operators.has(key),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Whether any operator identity is configured at all. */
|
|
147
|
+
function hasConfiguredOperator(): boolean {
|
|
148
|
+
return state.operators.size > 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export type TurnScopeInput = {
|
|
152
|
+
chatId: string;
|
|
153
|
+
isGroup: boolean;
|
|
154
|
+
source: "message" | "pulse" | "cron" | "trigger" | "agent";
|
|
155
|
+
/** Keys of the ONE person behind this turn; absent when unknown/mixed. */
|
|
156
|
+
senderKeys?: readonly string[];
|
|
157
|
+
/** The frontend checked the operator is a member of this group chat. */
|
|
158
|
+
operatorInChat?: boolean;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Operator or guest, for one turn.
|
|
163
|
+
*
|
|
164
|
+
* - Background turns the operator set up (cron, triggers, agent
|
|
165
|
+
* reports) keep the full surface; guests can't create them.
|
|
166
|
+
* - Pulse in a group reacts to whoever is talking there: guest.
|
|
167
|
+
* - With `operatorGroups` on, any turn in a group the operator is a
|
|
168
|
+
* member of is operator — the operator vouches for its members.
|
|
169
|
+
* - A message turn is operator only when its sender is an operator.
|
|
170
|
+
* Two legacy DM exceptions keep single-user installs working: the
|
|
171
|
+
* explicit `guestDmScope.enabled: false` opt-out, and an install with
|
|
172
|
+
* no operator configured at all (DM access is gated by the frontend's
|
|
173
|
+
* own allowlist there).
|
|
174
|
+
*/
|
|
175
|
+
export function resolveTurnScope(input: TurnScopeInput): ToolScope {
|
|
176
|
+
if (input.isGroup && state.operatorGroups && input.operatorInChat) {
|
|
177
|
+
return "operator";
|
|
178
|
+
}
|
|
179
|
+
if (input.source !== "message") {
|
|
180
|
+
return input.source === "pulse" && input.isGroup ? "guest" : "operator";
|
|
181
|
+
}
|
|
182
|
+
if (isOperatorSender(input.senderKeys)) return "operator";
|
|
183
|
+
if (input.isGroup) return "guest";
|
|
184
|
+
if (!state.dmScope || !hasConfiguredOperator()) return "operator";
|
|
185
|
+
return state.operators.has(input.chatId) ? "operator" : "guest";
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── Active turn scopes ──────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
/** chatId → scope of the turn currently running there (turns are per-chat FIFO). */
|
|
191
|
+
const activeScopes = new Map<string, { scope: ToolScope }>();
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Mark `chatId`'s running turn with `scope` until the returned release is
|
|
195
|
+
* called. The release only clears its own mark.
|
|
196
|
+
*/
|
|
197
|
+
export function enterTurnScope(chatId: string, scope: ToolScope): () => void {
|
|
198
|
+
const mark = { scope };
|
|
199
|
+
activeScopes.set(chatId, mark);
|
|
200
|
+
return () => {
|
|
201
|
+
if (activeScopes.get(chatId) === mark) activeScopes.delete(chatId);
|
|
85
202
|
};
|
|
86
203
|
}
|
|
87
204
|
|
|
88
205
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
206
|
+
* What a guest turn's prompt opens with. The model shares one session per
|
|
207
|
+
* chat across senders, so without it an operator turn followed by a guest
|
|
208
|
+
* turn looks like the MCP servers dropping out from under it — and the
|
|
209
|
+
* model says so in the chat, blaming restarts or a broken supervisor.
|
|
92
210
|
*/
|
|
93
|
-
export
|
|
94
|
-
|
|
211
|
+
export const GUEST_SCOPE_NOTICE =
|
|
212
|
+
"[Tool scope notice: this message is from someone other than the operator, " +
|
|
213
|
+
"so this turn has the conversation-only tool set (replies, reactions, this " +
|
|
214
|
+
"chat's history, web search). Shell, files, plugins and the rest are withheld " +
|
|
215
|
+
"on purpose and come back on the operator's next message. This is access " +
|
|
216
|
+
"control, not a fault — don't report it as tools dropping or try to work around it.]";
|
|
217
|
+
|
|
218
|
+
/** The prompt a backend sees for a turn of this scope. */
|
|
219
|
+
export function scopePrompt(scope: ToolScope, prompt: string): string {
|
|
220
|
+
return scope === "guest" ? `${GUEST_SCOPE_NOTICE}\n\n${prompt}` : prompt;
|
|
95
221
|
}
|
|
96
222
|
|
|
97
|
-
/**
|
|
98
|
-
export function
|
|
99
|
-
|
|
100
|
-
if (!isDmChatId(chatId)) return false;
|
|
101
|
-
return !state.operators.has(chatId);
|
|
223
|
+
/** Is the turn running in this chat right now guest-scoped? */
|
|
224
|
+
export function isGuestTurn(chatId: string): boolean {
|
|
225
|
+
return activeScopes.get(chatId)?.scope === "guest";
|
|
102
226
|
}
|
|
103
227
|
|
|
104
228
|
export function isGuestToolAllowed(name: string): boolean {
|
|
@@ -109,6 +233,23 @@ export function isGuestPluginAllowed(serverName: string): boolean {
|
|
|
109
233
|
return state.plugins.has(serverName);
|
|
110
234
|
}
|
|
111
235
|
|
|
236
|
+
/**
|
|
237
|
+
* Is this chat a private chat with the operator — where credential-bearing
|
|
238
|
+
* output may be shown? The operator's DMs, plus the local frontends.
|
|
239
|
+
*/
|
|
240
|
+
export function isOperatorPrivateChat(
|
|
241
|
+
frontend: string,
|
|
242
|
+
chatId: string,
|
|
243
|
+
): boolean {
|
|
244
|
+
if (frontend === "native" || frontend === "terminal") return true;
|
|
245
|
+
return state.operators.has(chatId) && !chatId.startsWith("-");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Where private operator output is delivered, if anywhere. */
|
|
249
|
+
export function operatorDmChatId(): string | null {
|
|
250
|
+
return state.operatorDm;
|
|
251
|
+
}
|
|
252
|
+
|
|
112
253
|
/**
|
|
113
254
|
* Why a guest call with these params must be refused, or null if it is
|
|
114
255
|
* fine. Checked on every guest tool call, after the allowlist.
|
|
@@ -28,8 +28,9 @@
|
|
|
28
28
|
* from the current registry — see reloadHubChildren)
|
|
29
29
|
* - orphan cleanup (children still run under the supervisor wrap)
|
|
30
30
|
*
|
|
31
|
-
* Endpoints live on the gateway HTTP server (127.0.0.1-bound,
|
|
32
|
-
*
|
|
31
|
+
* Endpoints live on the gateway HTTP server (127.0.0.1-bound, behind the
|
|
32
|
+
* same token and Host/Origin guard as /action — see engine/gateway-auth.ts;
|
|
33
|
+
* every backend's hub entry carries the token as a bearer header).
|
|
33
34
|
*/
|
|
34
35
|
|
|
35
36
|
import { randomUUID } from "node:crypto";
|
|
@@ -55,8 +56,8 @@ import {
|
|
|
55
56
|
import type { ToolFrontend } from "../tools/types.js";
|
|
56
57
|
import {
|
|
57
58
|
initGuestDmScope,
|
|
58
|
-
isGuestChat,
|
|
59
59
|
isGuestPluginAllowed,
|
|
60
|
+
isGuestTurn,
|
|
60
61
|
type GuestDmScopeConfig,
|
|
61
62
|
} from "./guest-scope.js";
|
|
62
63
|
|
|
@@ -70,10 +71,12 @@ export type HubConfig = {
|
|
|
70
71
|
braveApiKey?: string;
|
|
71
72
|
/** Surface the native tool set (bash/read/write/… + teleport). */
|
|
72
73
|
nativeTools?: boolean;
|
|
73
|
-
/** Conversation-only tool surface for non-operator
|
|
74
|
+
/** Conversation-only tool surface for non-operator senders. */
|
|
74
75
|
guestDmScope?: GuestDmScopeConfig;
|
|
75
|
-
/** Operator's Telegram id — their
|
|
76
|
+
/** Operator's Telegram id — their messages keep the full surface. */
|
|
76
77
|
adminUserId?: number;
|
|
78
|
+
/** Further operator sender keys (see guest-scope.ts). */
|
|
79
|
+
operatorIds?: readonly string[];
|
|
77
80
|
};
|
|
78
81
|
|
|
79
82
|
let hubConfig: HubConfig = {};
|
|
@@ -81,7 +84,7 @@ let hubConfig: HubConfig = {};
|
|
|
81
84
|
/** Set at bootstrap; safe to call again on config reload. */
|
|
82
85
|
export function initHub(config: HubConfig): void {
|
|
83
86
|
hubConfig = config;
|
|
84
|
-
initGuestDmScope(config.guestDmScope, config.adminUserId);
|
|
87
|
+
initGuestDmScope(config.guestDmScope, config.adminUserId, config.operatorIds);
|
|
85
88
|
startChildReaper();
|
|
86
89
|
}
|
|
87
90
|
|
|
@@ -226,14 +229,23 @@ function buildServerFor(target: HubTarget, bridgeUrl: string) {
|
|
|
226
229
|
disabledTools: hubConfig.disabledTools,
|
|
227
230
|
disabledToolTags: hubConfig.disabledToolTags,
|
|
228
231
|
includeNativeTools: hubConfig.nativeTools,
|
|
229
|
-
guest:
|
|
232
|
+
guest: isGuestTurn(target.chatId),
|
|
230
233
|
});
|
|
231
234
|
}
|
|
232
|
-
|
|
233
|
-
|
|
235
|
+
// Re-checked per request: a session opened during an operator turn must
|
|
236
|
+
// not serve a later guest turn in the same chat.
|
|
237
|
+
return buildProxyServer(target.serverName, () => {
|
|
238
|
+
if (guestPluginDenied(target)) {
|
|
239
|
+
return Promise.reject(new Error("Not available in this chat"));
|
|
240
|
+
}
|
|
241
|
+
return acquireChild(childKey(target.serverName, target.chatId), () =>
|
|
234
242
|
pluginSpec(target.serverName, target.chatId, bridgeUrl),
|
|
235
|
-
)
|
|
236
|
-
);
|
|
243
|
+
);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function guestPluginDenied(target: { serverName: string; chatId: string }) {
|
|
248
|
+
return isGuestTurn(target.chatId) && !isGuestPluginAllowed(target.serverName);
|
|
237
249
|
}
|
|
238
250
|
|
|
239
251
|
// ── Session registry ────────────────────────────────────────────────────────
|
|
@@ -293,6 +305,12 @@ function jsonRpcError(
|
|
|
293
305
|
);
|
|
294
306
|
}
|
|
295
307
|
|
|
308
|
+
/** `Host` values a loopback client of the gateway at `bridgeUrl` sends. */
|
|
309
|
+
function loopbackHosts(bridgeUrl: string): string[] {
|
|
310
|
+
const { port } = new URL(bridgeUrl);
|
|
311
|
+
return ["127.0.0.1", "localhost", "[::1]"].map((name) => `${name}:${port}`);
|
|
312
|
+
}
|
|
313
|
+
|
|
296
314
|
/**
|
|
297
315
|
* Handle one request under /mcp/. `bridgeUrl` is the gateway's own base
|
|
298
316
|
* URL (the gateway knows its bound port; the hub does not).
|
|
@@ -339,11 +357,7 @@ export async function handleHubRequest(
|
|
|
339
357
|
return;
|
|
340
358
|
}
|
|
341
359
|
|
|
342
|
-
if (
|
|
343
|
-
target.kind === "plugin" &&
|
|
344
|
-
isGuestChat(target.chatId) &&
|
|
345
|
-
!isGuestPluginAllowed(target.serverName)
|
|
346
|
-
) {
|
|
360
|
+
if (target.kind === "plugin" && guestPluginDenied(target)) {
|
|
347
361
|
jsonRpcError(res, 403, "Not available in this chat");
|
|
348
362
|
return;
|
|
349
363
|
}
|
|
@@ -351,6 +365,9 @@ export async function handleHubRequest(
|
|
|
351
365
|
const server = buildServerFor(target, bridgeUrl);
|
|
352
366
|
const transport = new StreamableHTTPServerTransport({
|
|
353
367
|
sessionIdGenerator: () => randomUUID(),
|
|
368
|
+
// Defence in depth behind the gateway's own Host/Origin guard.
|
|
369
|
+
enableDnsRebindingProtection: true,
|
|
370
|
+
allowedHosts: loopbackHosts(bridgeUrl),
|
|
354
371
|
onsessioninitialized: (id) => {
|
|
355
372
|
sessions.set(id, { transport, lastSeen: Date.now() });
|
|
356
373
|
},
|
|
@@ -17,8 +17,20 @@
|
|
|
17
17
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
18
18
|
import { composeTools } from "../tools/index.js";
|
|
19
19
|
import { createBridge, textResult } from "../tools/bridge.js";
|
|
20
|
-
import type {
|
|
21
|
-
|
|
20
|
+
import type {
|
|
21
|
+
BridgeFunction,
|
|
22
|
+
ToolDefinition,
|
|
23
|
+
ToolFrontend,
|
|
24
|
+
ToolTag,
|
|
25
|
+
} from "../tools/types.js";
|
|
26
|
+
import {
|
|
27
|
+
guestParamViolation,
|
|
28
|
+
isGuestToolAllowed,
|
|
29
|
+
isGuestTurn,
|
|
30
|
+
isOperatorPrivateChat,
|
|
31
|
+
operatorDmChatId,
|
|
32
|
+
OPERATOR_PRIVATE_OUTPUT_TOOLS,
|
|
33
|
+
} from "./guest-scope.js";
|
|
22
34
|
|
|
23
35
|
export const VALID_TOOL_FRONTENDS: ReadonlySet<string> = new Set([
|
|
24
36
|
"telegram",
|
|
@@ -39,8 +51,10 @@ export type TalonServerOptions = {
|
|
|
39
51
|
/** Expose the native tool set (replaces the SDK built-ins). */
|
|
40
52
|
includeNativeTools?: boolean;
|
|
41
53
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
54
|
+
* Build the guest surface: expose only the conversation allowlist. Calls
|
|
55
|
+
* are re-checked against the chat's live turn scope either way, so a
|
|
56
|
+
* session opened by an operator turn can't serve a later guest turn.
|
|
57
|
+
* See guest-scope.ts.
|
|
44
58
|
*/
|
|
45
59
|
guest?: boolean;
|
|
46
60
|
};
|
|
@@ -76,17 +90,60 @@ export function buildTalonToolServer(options: TalonServerOptions): McpServer {
|
|
|
76
90
|
: tools;
|
|
77
91
|
|
|
78
92
|
for (const tool of surface) {
|
|
79
|
-
server.tool(tool.name, tool.description, tool.schema, async (params) =>
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
options.chatId,
|
|
83
|
-
params as Record<string, unknown>,
|
|
84
|
-
);
|
|
85
|
-
if (why) return textResult(`Not available in this chat: ${why}.`);
|
|
86
|
-
}
|
|
87
|
-
return textResult(await tool.execute(params, bridge));
|
|
88
|
-
});
|
|
93
|
+
server.tool(tool.name, tool.description, tool.schema, async (params) =>
|
|
94
|
+
textResult(await callScoped(options, tool, bridge, params)),
|
|
95
|
+
);
|
|
89
96
|
}
|
|
90
97
|
|
|
91
98
|
return server;
|
|
92
99
|
}
|
|
100
|
+
|
|
101
|
+
/** A refused call: an error result the model reads as a failure. */
|
|
102
|
+
function refuse(message: string): { ok: false; text: string } {
|
|
103
|
+
return { ok: false, text: message };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const PRIVATE_OUTPUT_RECEIPT =
|
|
107
|
+
"Done. The result carries a live bridge credential, so it was sent to the " +
|
|
108
|
+
"operator's private chat instead of this one. Do not repeat or summarise it here.";
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Run one tool call under the chat's live scope: guest turns get the
|
|
112
|
+
* allowlist and the parameter guard; credential-bearing tools only ever
|
|
113
|
+
* show their output in the operator's private chat.
|
|
114
|
+
*/
|
|
115
|
+
async function callScoped(
|
|
116
|
+
options: TalonServerOptions,
|
|
117
|
+
tool: ToolDefinition,
|
|
118
|
+
bridge: BridgeFunction,
|
|
119
|
+
params: Record<string, unknown>,
|
|
120
|
+
): Promise<unknown> {
|
|
121
|
+
const guest = options.guest || isGuestTurn(options.chatId);
|
|
122
|
+
if (guest) {
|
|
123
|
+
if (!isGuestToolAllowed(tool.name)) {
|
|
124
|
+
return refuse("Not available in this chat.");
|
|
125
|
+
}
|
|
126
|
+
const why = guestParamViolation(options.chatId, params);
|
|
127
|
+
if (why) return refuse(`Not available in this chat: ${why}.`);
|
|
128
|
+
}
|
|
129
|
+
if (
|
|
130
|
+
!OPERATOR_PRIVATE_OUTPUT_TOOLS.has(tool.name) ||
|
|
131
|
+
isOperatorPrivateChat(options.frontend, options.chatId)
|
|
132
|
+
) {
|
|
133
|
+
return tool.execute(params, bridge);
|
|
134
|
+
}
|
|
135
|
+
const dm = operatorDmChatId();
|
|
136
|
+
if (!dm) {
|
|
137
|
+
return refuse(
|
|
138
|
+
`${tool.name} returns a credential and can only run in the operator's private chat.`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
const result = (await tool.execute(params, bridge)) as {
|
|
142
|
+
ok?: boolean;
|
|
143
|
+
text?: string;
|
|
144
|
+
};
|
|
145
|
+
if (result?.ok === false) return result;
|
|
146
|
+
const text = result?.text ?? JSON.stringify(result);
|
|
147
|
+
await bridge("send_message", { text, chat_id: dm });
|
|
148
|
+
return { ok: true, text: PRIVATE_OUTPUT_RECEIPT };
|
|
149
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator surface for per-device credentials — what `talon mesh` drives
|
|
3
|
+
* through the daemon's loopback gateway (core/engine/gateway-routes.ts).
|
|
4
|
+
*
|
|
5
|
+
* Pure policy over the store: resolve "which device?" from an id, a
|
|
6
|
+
* credential id, or a registry name, then list / revoke / rotate / re-scope.
|
|
7
|
+
* Every reply is plain data plus a human line; the CLI only renders.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { DeviceCredentialStore } from "./store.js";
|
|
11
|
+
import { normalizeScopes, type DeviceCredential } from "./types.js";
|
|
12
|
+
|
|
13
|
+
type CredentialAdminOp = "revoke" | "rotate" | "scopes";
|
|
14
|
+
|
|
15
|
+
/** What the admin surface needs from the mesh around it. */
|
|
16
|
+
export type CredentialAdminContext = {
|
|
17
|
+
store: DeviceCredentialStore;
|
|
18
|
+
/** Registry lookup by id or name (MeshService.resolveDevice). */
|
|
19
|
+
resolveDeviceId: (query: string) => string | undefined;
|
|
20
|
+
/** Whether the shared token is still accepted from remote clients. */
|
|
21
|
+
legacySharedToken: () => boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type CredentialOverview = {
|
|
25
|
+
ok: true;
|
|
26
|
+
credentials: DeviceCredential[];
|
|
27
|
+
legacyDevices: { deviceId: string; lastSeen: number }[];
|
|
28
|
+
legacySharedToken: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type CredentialAdminResult =
|
|
32
|
+
| { ok: true; text: string; credentials: DeviceCredential[] }
|
|
33
|
+
| { ok: false; error: string };
|
|
34
|
+
|
|
35
|
+
export async function credentialOverview(
|
|
36
|
+
ctx: CredentialAdminContext,
|
|
37
|
+
): Promise<CredentialOverview> {
|
|
38
|
+
await ctx.store.load();
|
|
39
|
+
return {
|
|
40
|
+
ok: true,
|
|
41
|
+
credentials: ctx.store.list(),
|
|
42
|
+
legacyDevices: ctx.store.legacyDevices(),
|
|
43
|
+
legacySharedToken: ctx.legacySharedToken(),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolve a CLI argument to the device id credentials are bound to. A
|
|
49
|
+
* credential id wins (it is unambiguous), then an exact device id with
|
|
50
|
+
* credentials (a device removed from the registry can still hold one), then
|
|
51
|
+
* whatever the registry resolves the text to.
|
|
52
|
+
*/
|
|
53
|
+
function resolveTarget(
|
|
54
|
+
ctx: CredentialAdminContext,
|
|
55
|
+
query: string,
|
|
56
|
+
): { deviceId: string } | { credentialId: string } | undefined {
|
|
57
|
+
const all = ctx.store.list();
|
|
58
|
+
if (all.some((c) => c.id === query)) return { credentialId: query };
|
|
59
|
+
if (all.some((c) => c.deviceId === query)) return { deviceId: query };
|
|
60
|
+
const fromRegistry = ctx.resolveDeviceId(query);
|
|
61
|
+
return fromRegistry ? { deviceId: fromRegistry } : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function credentialAdmin(
|
|
65
|
+
ctx: CredentialAdminContext,
|
|
66
|
+
body: Record<string, unknown>,
|
|
67
|
+
): Promise<CredentialAdminResult> {
|
|
68
|
+
await ctx.store.load();
|
|
69
|
+
const op = body.op;
|
|
70
|
+
const query = typeof body.device === "string" ? body.device.trim() : "";
|
|
71
|
+
if (op !== "revoke" && op !== "rotate" && op !== "scopes") {
|
|
72
|
+
return { ok: false, error: "op must be revoke, rotate or scopes" };
|
|
73
|
+
}
|
|
74
|
+
if (!query) return { ok: false, error: "device is required" };
|
|
75
|
+
const target = resolveTarget(ctx, query);
|
|
76
|
+
if (!target) {
|
|
77
|
+
return { ok: false, error: `No device or credential matches "${query}"` };
|
|
78
|
+
}
|
|
79
|
+
if ("credentialId" in target) {
|
|
80
|
+
if (op !== "revoke") {
|
|
81
|
+
return { ok: false, error: `${op} takes a device, not a credential id` };
|
|
82
|
+
}
|
|
83
|
+
const revoked = await ctx.store.revokeCredential(
|
|
84
|
+
target.credentialId,
|
|
85
|
+
"revoked by operator",
|
|
86
|
+
);
|
|
87
|
+
return done(revoked, `Revoked credential ${target.credentialId}.`, ctx);
|
|
88
|
+
}
|
|
89
|
+
return deviceOp(ctx, op, target.deviceId, body.scopes);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function deviceOp(
|
|
93
|
+
ctx: CredentialAdminContext,
|
|
94
|
+
op: CredentialAdminOp,
|
|
95
|
+
deviceId: string,
|
|
96
|
+
scopes: unknown,
|
|
97
|
+
): Promise<CredentialAdminResult> {
|
|
98
|
+
if (op === "revoke") {
|
|
99
|
+
const revoked = await ctx.store.revokeDevice(
|
|
100
|
+
deviceId,
|
|
101
|
+
"revoked by operator",
|
|
102
|
+
);
|
|
103
|
+
return done(
|
|
104
|
+
revoked,
|
|
105
|
+
`Revoked ${revoked.length} credential(s) for ${deviceId}; its live sessions were dropped.`,
|
|
106
|
+
ctx,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
if (op === "rotate") {
|
|
110
|
+
const pending = await ctx.store.requestRotation(deviceId);
|
|
111
|
+
return done(
|
|
112
|
+
pending,
|
|
113
|
+
`Rotation requested for ${deviceId}: it swaps credentials on its next heartbeat (companions: next connect). The old credential expires in 7 days either way — revoke it to cut it off now.`,
|
|
114
|
+
ctx,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
const next = normalizeScopes(
|
|
118
|
+
typeof scopes === "string" ? scopes.split(",").map((s) => s.trim()) : [],
|
|
119
|
+
);
|
|
120
|
+
if (next.length === 0) {
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
error: "scopes must list one or more of: device, client, operator",
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const changed = await ctx.store.setScopes(deviceId, next);
|
|
127
|
+
return done(
|
|
128
|
+
changed,
|
|
129
|
+
`${deviceId} now holds: ${next.join(", ")}. Its live sessions were dropped so the new scopes apply on reconnect.`,
|
|
130
|
+
ctx,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function done(
|
|
135
|
+
credentials: DeviceCredential[],
|
|
136
|
+
text: string,
|
|
137
|
+
ctx: CredentialAdminContext,
|
|
138
|
+
): CredentialAdminResult {
|
|
139
|
+
if (credentials.length === 0) {
|
|
140
|
+
return { ok: false, error: "That device holds no live credential." };
|
|
141
|
+
}
|
|
142
|
+
const legacyNote = ctx.legacySharedToken()
|
|
143
|
+
? " Note: native.legacySharedToken is on, so a device that still has the shared token can reconnect with it — rotate native.token and set legacySharedToken: false to close that door."
|
|
144
|
+
: "";
|
|
145
|
+
return { ok: true, text: text + legacyNote, credentials };
|
|
146
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-device mesh credentials — barrel. The store (persistence + lifecycle),
|
|
3
|
+
* the token format, the scope vocabulary, and the operator surface.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export { DeviceCredentialStore } from "./store.js";
|
|
7
|
+
export { isDeviceCredentialToken } from "./token.js";
|
|
8
|
+
export {
|
|
9
|
+
DEFAULT_COMPANION_SCOPES,
|
|
10
|
+
NODE_SCOPES,
|
|
11
|
+
type CredentialOrigin,
|
|
12
|
+
type DeviceCredential,
|
|
13
|
+
type MeshScope,
|
|
14
|
+
} from "./types.js";
|
|
15
|
+
export {
|
|
16
|
+
credentialAdmin,
|
|
17
|
+
credentialOverview,
|
|
18
|
+
type CredentialAdminContext,
|
|
19
|
+
} from "./admin.js";
|