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.
Files changed (116) hide show
  1. package/LICENSE +202 -21
  2. package/LICENSE-MIT +21 -0
  3. package/NOTICE +16 -0
  4. package/README.md +14 -7
  5. package/package.json +6 -4
  6. package/prompts/system/heartbeat-agent.md +1 -1
  7. package/src/backend/agy/factory.ts +3 -0
  8. package/src/backend/agy/mcp/config.ts +14 -2
  9. package/src/backend/claude-sdk/factory.ts +3 -0
  10. package/src/backend/claude-sdk/options.ts +24 -3
  11. package/src/backend/codex/factory.ts +3 -0
  12. package/src/backend/codex/init.ts +4 -0
  13. package/src/backend/codex/mcp-config.ts +10 -0
  14. package/src/backend/codex/oauth-incompat.ts +1 -1
  15. package/src/backend/codex/token-usage.ts +2 -2
  16. package/src/backend/openai-agents/factory.ts +3 -0
  17. package/src/backend/openai-agents/mcp-pool.ts +4 -0
  18. package/src/backend/remote-server/factory.ts +3 -0
  19. package/src/backend/remote-server/mcp.ts +3 -0
  20. package/src/backend/runtime/prompt/prompt-format.ts +3 -3
  21. package/src/bootstrap.ts +8 -0
  22. package/src/cli/commands/backup.ts +61 -5
  23. package/src/cli/commands/mesh.ts +133 -0
  24. package/src/cli/config.ts +3 -1
  25. package/src/cli/daemon-api.ts +22 -0
  26. package/src/cli/index.ts +6 -0
  27. package/src/cli/install-sources.ts +40 -5
  28. package/src/cli/plugin.ts +10 -0
  29. package/src/cli/setup.ts +45 -4
  30. package/src/cli/skill.ts +3 -0
  31. package/src/core/agent-runtime/backend-registry.ts +16 -0
  32. package/src/core/backup/archive/crypt.ts +429 -0
  33. package/src/core/backup/archive/manifest-auth.ts +98 -0
  34. package/src/core/backup/passphrase.ts +130 -0
  35. package/src/core/backup/plan.ts +66 -13
  36. package/src/core/backup/restore-guard.ts +101 -0
  37. package/src/core/backup/restore.ts +249 -32
  38. package/src/core/backup/snapshot.ts +418 -60
  39. package/src/core/backup/sources/plugins.ts +223 -0
  40. package/src/core/backup/sources/relocate.ts +136 -0
  41. package/src/core/backup/sources/sessions.ts +198 -0
  42. package/src/core/backup/store.ts +3 -1
  43. package/src/core/backup/types.ts +66 -0
  44. package/src/core/backup/upload.ts +66 -6
  45. package/src/core/config/index.ts +140 -8
  46. package/src/core/daemon/control.ts +9 -0
  47. package/src/core/daemon/discovery.ts +7 -0
  48. package/src/core/engine/backend-router/headroom.ts +29 -5
  49. package/src/core/engine/backend-router/usage.ts +6 -2
  50. package/src/core/engine/gateway-actions/fetch-url/guard.ts +201 -0
  51. package/src/core/engine/gateway-actions/{fetch-url.ts → fetch-url/index.ts} +60 -32
  52. package/src/core/engine/gateway-actions/index.ts +4 -2
  53. package/src/core/engine/gateway-actions/native/index.ts +24 -0
  54. package/src/core/engine/gateway-actions/whatsapp-account.ts +1 -1
  55. package/src/core/engine/gateway-auth.ts +164 -0
  56. package/src/core/engine/gateway-routes.ts +100 -5
  57. package/src/core/engine/gateway.ts +12 -4
  58. package/src/core/mcp-hub/guest-scope.ts +170 -29
  59. package/src/core/mcp-hub/index.ts +33 -16
  60. package/src/core/mcp-hub/talon-server.ts +71 -14
  61. package/src/core/mesh/credentials/admin.ts +146 -0
  62. package/src/core/mesh/credentials/index.ts +19 -0
  63. package/src/core/mesh/credentials/store.ts +443 -0
  64. package/src/core/mesh/credentials/token.ts +45 -0
  65. package/src/core/mesh/credentials/types.ts +83 -0
  66. package/src/core/mesh/devices/service.ts +58 -6
  67. package/src/core/mesh/links/bridge-links.ts +46 -4
  68. package/src/core/mesh/links/node-binaries.ts +1 -1
  69. package/src/core/mesh/links/node-provision.ts +8 -1
  70. package/src/core/models/active-model.ts +1 -1
  71. package/src/core/plugin/loader.ts +4 -0
  72. package/src/core/plugin/mcp.ts +4 -0
  73. package/src/core/tools/bridge.ts +2 -1
  74. package/src/core/types.ts +13 -0
  75. package/src/core/weaver/weaver.ts +47 -15
  76. package/src/frontend/discord/callbacks/components/agent-buttons.ts +1 -0
  77. package/src/frontend/discord/handlers/delivery.ts +3 -0
  78. package/src/frontend/discord/handlers/queue.ts +1 -0
  79. package/src/frontend/native/bridge/auth-guard.ts +277 -0
  80. package/src/frontend/native/bridge/auth.ts +84 -1
  81. package/src/frontend/native/bridge/credentials/claims.ts +51 -0
  82. package/src/frontend/native/bridge/credentials/principal.ts +200 -0
  83. package/src/frontend/native/bridge/credentials/upgrade.ts +129 -0
  84. package/src/frontend/native/bridge/routes/auth.ts +37 -0
  85. package/src/frontend/native/bridge/routes/chats.ts +20 -2
  86. package/src/frontend/native/bridge/routes/host.ts +11 -1
  87. package/src/frontend/native/bridge/routes/index.ts +2 -0
  88. package/src/frontend/native/bridge/routes/mesh.ts +72 -15
  89. package/src/frontend/native/bridge/routes/table.ts +73 -51
  90. package/src/frontend/native/bridge/server.ts +266 -83
  91. package/src/frontend/native/index.ts +54 -3
  92. package/src/frontend/native/turn/turn.ts +2 -0
  93. package/src/frontend/teams/turn.ts +1 -0
  94. package/src/frontend/telegram/actions/outgoing-log.ts +70 -0
  95. package/src/frontend/telegram/actions/send.ts +4 -0
  96. package/src/frontend/telegram/admin.ts +20 -0
  97. package/src/frontend/telegram/commands/admin.ts +1 -1
  98. package/src/frontend/telegram/commands/state.ts +7 -9
  99. package/src/frontend/telegram/handlers/access.ts +31 -10
  100. package/src/frontend/telegram/handlers/delivery.ts +14 -1
  101. package/src/frontend/telegram/handlers/group-access.ts +50 -0
  102. package/src/frontend/telegram/handlers/messages.ts +1 -0
  103. package/src/frontend/telegram/handlers/queue.ts +14 -0
  104. package/src/frontend/telegram/handlers/state.ts +2 -2
  105. package/src/frontend/telegram/index.ts +32 -9
  106. package/src/frontend/telegram/middleware.ts +2 -2
  107. package/src/frontend/telegram/polling/poll-deadline.ts +52 -0
  108. package/src/frontend/telegram/{stale-command.ts → polling/stale-command.ts} +1 -1
  109. package/src/frontend/telegram/{update-offset.ts → polling/update-offset.ts} +1 -1
  110. package/src/frontend/telegram/userbot.ts +103 -10
  111. package/src/frontend/terminal/index.ts +8 -1
  112. package/src/frontend/whatsapp/commands.ts +3 -3
  113. package/src/frontend/whatsapp/messages/inbound.ts +1 -0
  114. package/src/plugins/playwright/index.ts +1 -1
  115. package/src/storage/backup/index.ts +1 -1
  116. package/src/storage/db.ts +23 -0
@@ -1,44 +1,69 @@
1
1
  /**
2
- * Guest DM tool scope.
2
+ * Tool scope: who gets the full agent, and who gets the conversation-only
3
+ * ("guest") surface.
3
4
  *
4
- * Anyone the operator lets DM the bot gets the same agent the operator
5
- * gets: shell, files, mail, mesh devices, cron, memory, cross-chat sends.
6
- * With `guestDmScope.enabled`, a DM from anyone who is not an operator gets a
7
- * conversation-only surface instead, enforced here in the hub, so it holds
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 session may only target its own chat and may
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
- * Groups are untouched: they already require the operator's membership,
21
- * and their tool surface is a separate decision.
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. The Claude SDK
25
- * backend drops its built-ins for guest chats (see claude-sdk/options.ts).
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
- /** Chat ids that keep the full surface in addition to the admin's DM. */
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 DM may use. */
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 DM may see and call. Everything else is hidden. */
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
- enabled: boolean;
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
- enabled: false,
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
- /** Set at bootstrap (and on config reload). */
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>(cfg?.operatorChats ?? []);
124
+ const operators = new Set<string>([
125
+ ...(cfg?.operatorChats ?? []),
126
+ ...operatorIds,
127
+ ]);
80
128
  if (adminUserId) operators.add(String(adminUserId));
81
129
  state = {
82
- enabled: cfg?.enabled === true,
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
- * Is this chat id a one-to-one DM? Telegram DMs are the peer's positive
90
- * user id; WhatsApp DMs are `wa_dm_<number>`. Anything else (groups,
91
- * Discord, native, heartbeat) is not treated as a DM here.
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 function isDmChatId(chatId: string): boolean {
94
- return /^\d+$/.test(chatId) || chatId.startsWith("wa_dm_");
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
- /** Should this chat get the guest surface? */
98
- export function isGuestChat(chatId: string): boolean {
99
- if (!state.enabled) return false;
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, same
32
- * trust boundary as /action).
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 DMs. */
74
+ /** Conversation-only tool surface for non-operator senders. */
74
75
  guestDmScope?: GuestDmScopeConfig;
75
- /** Operator's Telegram id — their DM always keeps the full surface. */
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: isGuestChat(target.chatId),
232
+ guest: isGuestTurn(target.chatId),
230
233
  });
231
234
  }
232
- return buildProxyServer(target.serverName, () =>
233
- acquireChild(childKey(target.serverName, target.chatId), () =>
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 { ToolFrontend, ToolTag } from "../tools/types.js";
21
- import { guestParamViolation, isGuestToolAllowed } from "./guest-scope.js";
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
- * Guest DM: expose only the conversation allowlist and refuse calls that
43
- * name another chat or a local file. See guest-scope.ts.
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
- if (options.guest) {
81
- const why = guestParamViolation(
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";