talon-agent 5.24.0 → 5.25.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/src/app.ts +30 -0
- package/src/backend/agy/process/orphans.ts +5 -1
- package/src/backend/claude-sdk/one-shot.ts +8 -1
- package/src/backend/codex/factory.ts +5 -1
- package/src/backend/codex/plan-usage.ts +12 -0
- package/src/backend/runtime/turn/turn-phases.ts +7 -1
- package/src/bootstrap.ts +1 -0
- package/src/cli.ts +18 -12
- package/src/core/agent-runtime/capabilities.ts +7 -0
- package/src/core/agents/index.ts +1 -1
- package/src/core/agents/prompt.ts +29 -2
- package/src/core/agents/runner.ts +7 -1
- package/src/core/agents/types.ts +6 -0
- package/src/core/background/cron/job-oneshot.ts +7 -1
- package/src/core/background/cron/scheduler.ts +28 -14
- package/src/core/background/cron/spec.ts +23 -3
- package/src/core/background/heartbeat/agent.ts +6 -0
- package/src/core/background/triggers/resume.ts +19 -4
- package/src/core/background/triggers/spawn.ts +1 -1
- package/src/core/config/index.ts +82 -0
- package/src/core/daemon/discovery.ts +160 -0
- package/src/core/daemon/pidfile.ts +107 -0
- package/src/core/daemon/respawn.ts +4 -1
- package/src/core/engine/backend-router/breaker.ts +158 -0
- package/src/core/engine/backend-router/headroom.ts +90 -22
- package/src/core/engine/backend-router/index.ts +11 -0
- package/src/core/engine/gateway-actions/agents/control.ts +9 -0
- package/src/core/engine/gateway-actions/agents/index.ts +4 -0
- package/src/core/engine/gateway-actions/agents/preflight.ts +215 -0
- package/src/core/engine/gateway-actions/cron.ts +5 -0
- package/src/core/engine/gateway-actions/fetch-url/guard.ts +13 -44
- package/src/core/engine/gateway-actions/fetch-url/index.ts +23 -43
- package/src/core/engine/gateway-actions/mesh.ts +4 -0
- package/src/core/engine/gateway-actions/models.ts +19 -4
- package/src/core/engine/gateway-routes.ts +31 -2
- package/src/core/engine/gateway.ts +11 -1
- package/src/core/fetch/classify.ts +73 -0
- package/src/core/fetch/curl-impersonate.ts +447 -0
- package/src/core/fetch/errors.ts +14 -0
- package/src/core/fetch/index.ts +133 -0
- package/src/core/fetch/ladder.ts +401 -0
- package/src/core/fetch/rungs.ts +319 -0
- package/src/core/fetch/types.ts +105 -0
- package/src/core/frontend-runtime/admin-notify.ts +100 -12
- package/src/core/mcp-hub/child-guard.ts +215 -0
- package/src/core/mcp-hub/child-transport.ts +89 -39
- package/src/core/mcp-hub/children.ts +65 -9
- package/src/core/mcp-hub/guest-scope.ts +3 -2
- package/src/core/mcp-hub/index.ts +36 -16
- package/src/core/mcp-hub/launcher.ts +81 -37
- package/src/core/mcp-hub/proxy-server.ts +12 -8
- package/src/core/mcp-hub/reaper.ts +120 -0
- package/src/core/mesh/credentials/index.ts +1 -1
- package/src/core/mesh/credentials/store.ts +1 -1
- package/src/core/mesh/devices/service.ts +8 -0
- package/src/core/mesh/links/bridge-links.ts +37 -0
- package/src/core/mesh/links/companion-pairing.ts +2 -2
- package/src/core/plugin/mcp.ts +8 -8
- package/src/core/tools/bridge.ts +5 -0
- package/src/core/tools/content/web.ts +1 -1
- package/src/core/tools/index.ts +2 -1
- package/src/core/tools/ops/agents.ts +28 -0
- package/src/core/tools/ops/mesh.ts +21 -0
- package/src/core/tools/ops/scheduling.ts +15 -0
- package/src/frontend/native/bridge/credentials/claims.ts +11 -1
- package/src/frontend/telegram/actions/media.ts +164 -12
- package/src/index.ts +22 -19
- package/src/plugins/playwright/version-coupling.ts +172 -62
- package/src/storage/cron.ts +34 -2
- package/src/storage/db.ts +1 -0
- package/src/storage/repositories/cron-repo.ts +9 -0
- package/src/storage/sql/cron.sql +5 -5
- package/src/storage/sql/db.sql +5 -0
- package/src/storage/sql/schema.sql +2 -1
- package/src/storage/sql/statements.generated.ts +10 -6
- package/src/util/log.ts +2 -1
- package/src/util/paths.ts +2 -0
- package/src/core/background/triggers/pid.ts +0 -28
|
@@ -20,7 +20,10 @@
|
|
|
20
20
|
* - tool-surface trimming (disabledTools / disabledToolTags)
|
|
21
21
|
* - plugin reload retires children; the next request respawns from
|
|
22
22
|
* the current registry (see reloadHubChildren)
|
|
23
|
-
* - children
|
|
23
|
+
* - children are orphan-guarded (one reaper per daemon, falling back
|
|
24
|
+
* to the per-child supervisor wrap — see child-guard.ts)
|
|
25
|
+
* - a session's tools/list does not spawn its chat's child when any
|
|
26
|
+
* chat already listed that server (see listChildTools)
|
|
24
27
|
*
|
|
25
28
|
* Endpoints live on the gateway HTTP server (127.0.0.1-bound, behind the
|
|
26
29
|
* same token and Host/Origin guard as /action — see engine/gateway-auth.ts;
|
|
@@ -33,7 +36,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|
|
33
36
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
34
37
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
35
38
|
import { getPluginMcpServers } from "../plugin/index.js";
|
|
36
|
-
import {
|
|
39
|
+
import { enableChildGuard, stopChildGuard } from "./child-guard.js";
|
|
37
40
|
import { log, logError } from "../../util/log.js";
|
|
38
41
|
import { buildTalonToolServer, VALID_TOOL_FRONTENDS } from "./talon-server.js";
|
|
39
42
|
import { buildProxyServer } from "./proxy-server.js";
|
|
@@ -42,6 +45,7 @@ import {
|
|
|
42
45
|
closeAllChildren,
|
|
43
46
|
formatChildExit,
|
|
44
47
|
getLastChildExit,
|
|
48
|
+
listChildTools,
|
|
45
49
|
retireAllChildren,
|
|
46
50
|
startChildReaper,
|
|
47
51
|
stopChildReaper,
|
|
@@ -71,6 +75,12 @@ export type HubConfig = {
|
|
|
71
75
|
adminUserId?: number;
|
|
72
76
|
/** Further operator sender keys (see guest-scope.ts). */
|
|
73
77
|
operatorIds?: readonly string[];
|
|
78
|
+
/**
|
|
79
|
+
* Orphan-guard hub children with the reaper (child-guard.ts). Set by the
|
|
80
|
+
* daemon bootstrap, whose entrypoint dispatches `_mcp-reaper`; left off
|
|
81
|
+
* by embedders/tests that drive the hub from an entry that doesn't.
|
|
82
|
+
*/
|
|
83
|
+
guardChildren?: boolean;
|
|
74
84
|
};
|
|
75
85
|
|
|
76
86
|
let hubConfig: HubConfig = {};
|
|
@@ -79,6 +89,7 @@ let hubConfig: HubConfig = {};
|
|
|
79
89
|
export function initHub(config: HubConfig): void {
|
|
80
90
|
hubConfig = config;
|
|
81
91
|
initGuestDmScope(config.guestDmScope, config.adminUserId, config.operatorIds);
|
|
92
|
+
if (config.guardChildren) enableChildGuard();
|
|
82
93
|
startChildReaper();
|
|
83
94
|
}
|
|
84
95
|
|
|
@@ -124,10 +135,10 @@ export async function listHubPluginToolNames(
|
|
|
124
135
|
chatId: string,
|
|
125
136
|
bridgeUrl: string,
|
|
126
137
|
): Promise<string[]> {
|
|
127
|
-
const
|
|
138
|
+
const tools = await listChildTools(childKey(serverName, chatId), () =>
|
|
128
139
|
pluginSpec(serverName, chatId, bridgeUrl),
|
|
129
140
|
);
|
|
130
|
-
return
|
|
141
|
+
return tools.map((tool) => tool.name);
|
|
131
142
|
}
|
|
132
143
|
|
|
133
144
|
/** Stderr lines quoted in a registration-failure warning (full tail is in the exit log line). */
|
|
@@ -181,15 +192,16 @@ function parseHubPath(rawUrl: string): HubTarget | null {
|
|
|
181
192
|
return null;
|
|
182
193
|
}
|
|
183
194
|
|
|
184
|
-
function braveSpec(): ChildSpec {
|
|
185
|
-
return
|
|
195
|
+
function braveSpec(bridgeUrl: string): ChildSpec {
|
|
196
|
+
return {
|
|
186
197
|
command: resolve(
|
|
187
198
|
import.meta.dirname ?? ".",
|
|
188
199
|
"../../../node_modules/.bin/brave-search-mcp-server",
|
|
189
200
|
),
|
|
190
201
|
args: [],
|
|
191
202
|
env: { BRAVE_API_KEY: hubConfig.braveApiKey ?? "" },
|
|
192
|
-
|
|
203
|
+
bridgeUrl,
|
|
204
|
+
};
|
|
193
205
|
}
|
|
194
206
|
|
|
195
207
|
/**
|
|
@@ -202,11 +214,16 @@ function pluginSpec(
|
|
|
202
214
|
chatId: string,
|
|
203
215
|
bridgeUrl: string,
|
|
204
216
|
): ChildSpec {
|
|
205
|
-
if (serverName === "brave-search") return braveSpec();
|
|
217
|
+
if (serverName === "brave-search") return braveSpec(bridgeUrl);
|
|
206
218
|
const specs = getPluginMcpServers(bridgeUrl, chatId);
|
|
207
219
|
const spec = specs[serverName];
|
|
208
220
|
if (!spec) throw new Error(`Unknown hub plugin server: ${serverName}`);
|
|
209
|
-
return {
|
|
221
|
+
return {
|
|
222
|
+
command: spec.command,
|
|
223
|
+
args: [...spec.args],
|
|
224
|
+
env: spec.env,
|
|
225
|
+
bridgeUrl,
|
|
226
|
+
};
|
|
210
227
|
}
|
|
211
228
|
|
|
212
229
|
/** Test-only: expose the spec resolution for the pass-through contract. */
|
|
@@ -226,13 +243,15 @@ function buildServerFor(target: HubTarget, bridgeUrl: string) {
|
|
|
226
243
|
}
|
|
227
244
|
// Re-checked per request: a session opened during an operator turn must
|
|
228
245
|
// not serve a later guest turn in the same chat.
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
246
|
+
const key = childKey(target.serverName, target.chatId);
|
|
247
|
+
const spec = () => pluginSpec(target.serverName, target.chatId, bridgeUrl);
|
|
248
|
+
const denied = () =>
|
|
249
|
+
guestPluginDenied(target)
|
|
250
|
+
? Promise.reject(new Error("Not available in this chat"))
|
|
251
|
+
: null;
|
|
252
|
+
return buildProxyServer(target.serverName, {
|
|
253
|
+
listTools: () => denied() ?? listChildTools(key, spec),
|
|
254
|
+
getChild: () => denied() ?? acquireChild(key, spec),
|
|
236
255
|
});
|
|
237
256
|
}
|
|
238
257
|
|
|
@@ -412,6 +431,7 @@ export async function shutdownHub(): Promise<void> {
|
|
|
412
431
|
sessions.clear();
|
|
413
432
|
await Promise.allSettled(entries.map((entry) => entry.transport.close()));
|
|
414
433
|
await closeAllChildren();
|
|
434
|
+
stopChildGuard();
|
|
415
435
|
}
|
|
416
436
|
|
|
417
437
|
/** Diagnostic: live hub session count. */
|
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MCP supervisor — one universal launch method for every install type.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* through a supervisor process that proxies stdio, filters non-JSON
|
|
4
|
+
* The per-child supervisor process proxies stdio, filters non-JSON
|
|
6
5
|
* stdout lines, and kills the child when Talon dies — no orphans, no
|
|
7
6
|
* /proc scan, no per-plugin signature list.
|
|
8
7
|
*
|
|
8
|
+
* The hub no longer runs one per MCP child by default: it spawns
|
|
9
|
+
* children itself, filters stdout in-process (child-transport.ts, same
|
|
10
|
+
* parseJsonRpcLine rule) and guards them with ONE reaper process per
|
|
11
|
+
* daemon (child-guard.ts / reaper.ts, same stdin-EOF + BridgeWatchdog +
|
|
12
|
+
* SIGTERM→SIGKILL semantics as below). Wrapping a child in this
|
|
13
|
+
* supervisor is the fallback when the reaper can't run, or when
|
|
14
|
+
* `TALON_MCP_SUPERVISOR=per-child` asks for it. `selfInvocation` below
|
|
15
|
+
* is how both helpers are launched.
|
|
16
|
+
*
|
|
9
17
|
* The supervisor is Talon itself: `wrapMcpServer()` re-invokes the
|
|
10
18
|
* current process (`process.execPath` + `process.execArgv` + entry
|
|
11
19
|
* script) with the hidden `_mcp-launch` subcommand, the same
|
|
@@ -28,8 +36,8 @@
|
|
|
28
36
|
* }
|
|
29
37
|
*
|
|
30
38
|
* src/index.ts and src/cli.ts are the two real entrypoints; both
|
|
31
|
-
* dispatch
|
|
32
|
-
* dynamic import so
|
|
39
|
+
* dispatch (the reaper's `_mcp-reaper` too), each helper behind its own
|
|
40
|
+
* dynamic import so helper processes load only their own module.
|
|
33
41
|
*
|
|
34
42
|
* Child-shutdown signals (both terminate the child with SIGTERM, then
|
|
35
43
|
* SIGKILL after a grace period):
|
|
@@ -107,7 +115,8 @@ function isEmbeddedEntry(entry: string): boolean {
|
|
|
107
115
|
* The command prefix that re-invokes the current Talon process with a
|
|
108
116
|
* hidden subcommand, in every install shape (tsx source run, bun source
|
|
109
117
|
* run, bun-compiled binary, npm install). Used for MCP supervision
|
|
110
|
-
* (`_mcp-launch`) and the WASM Lua trigger runner
|
|
118
|
+
* (`_mcp-launch`, `_mcp-reaper`) and the WASM Lua trigger runner
|
|
119
|
+
* (`_lua-run`) — any
|
|
111
120
|
* subcommand passed here must be dispatched by src/index.ts and
|
|
112
121
|
* src/cli.ts before the app graph loads.
|
|
113
122
|
*/
|
|
@@ -275,6 +284,63 @@ export class BridgeWatchdog {
|
|
|
275
284
|
}
|
|
276
285
|
}
|
|
277
286
|
|
|
287
|
+
/** Tag prefixed to child stdout lines that were re-routed to stderr. */
|
|
288
|
+
export const STDOUT_REROUTE_TAG = "[mcp-launcher: stdout→stderr] ";
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Parse one line of MCP child stdout under the stdio filter rule: a
|
|
292
|
+
* JSON-RPC line must be a JSON OBJECT (starts with `{`, parses). Returns
|
|
293
|
+
* the parsed value, or null for anything else — banners, log lines,
|
|
294
|
+
* `[timestamp] [INFO] …`, a stray `{ tip: "…" }` that isn't JSON. Both
|
|
295
|
+
* the per-child supervisor and the hub's in-process transport use this,
|
|
296
|
+
* so the two filtering paths cannot drift apart.
|
|
297
|
+
*/
|
|
298
|
+
export function parseJsonRpcLine(line: string): object | null {
|
|
299
|
+
const s = line.trimStart();
|
|
300
|
+
if (s.length === 0 || s[0] !== "{") return null;
|
|
301
|
+
try {
|
|
302
|
+
const parsed: unknown = JSON.parse(s);
|
|
303
|
+
return typeof parsed === "object" && parsed !== null ? parsed : null;
|
|
304
|
+
} catch {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Run the bridge-health watchdog: ping `${bridgeUrl}/health` every
|
|
311
|
+
* BRIDGE_PING_INTERVAL_MS (first tick staggered randomly), and call
|
|
312
|
+
* `onTrip` once BridgeWatchdog says the daemon is gone. `isStopping`
|
|
313
|
+
* short-circuits ticks after shutdown began. Shared by the per-child
|
|
314
|
+
* supervisor and the hub's reaper so both keep identical semantics.
|
|
315
|
+
*/
|
|
316
|
+
export function startBridgeWatchdog(
|
|
317
|
+
bridgeUrl: string,
|
|
318
|
+
isStopping: () => boolean,
|
|
319
|
+
onTrip: (outcome: BridgePingOutcome, downForSec: number) => void,
|
|
320
|
+
): void {
|
|
321
|
+
const watchdog = new BridgeWatchdog();
|
|
322
|
+
const tick = async (): Promise<void> => {
|
|
323
|
+
if (isStopping()) return;
|
|
324
|
+
const outcome = await pingBridge(bridgeUrl);
|
|
325
|
+
if (isStopping()) return;
|
|
326
|
+
if (watchdog.record(outcome)) {
|
|
327
|
+
onTrip(
|
|
328
|
+
outcome,
|
|
329
|
+
watchdog.consecutiveFailures * (BRIDGE_PING_INTERVAL_MS / 1000),
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
// Stagger first tick so a process-wide restart doesn't have every
|
|
334
|
+
// watchdog pinging the bridge in lockstep.
|
|
335
|
+
const initialDelay = Math.floor(Math.random() * BRIDGE_PING_INTERVAL_MS);
|
|
336
|
+
const startTimer = setTimeout(() => {
|
|
337
|
+
void tick();
|
|
338
|
+
const interval = setInterval(() => void tick(), BRIDGE_PING_INTERVAL_MS);
|
|
339
|
+
interval.unref?.();
|
|
340
|
+
}, initialDelay);
|
|
341
|
+
startTimer.unref?.();
|
|
342
|
+
}
|
|
343
|
+
|
|
278
344
|
/**
|
|
279
345
|
* Run the supervisor over `argvTail` = [cmd, ...args].
|
|
280
346
|
*
|
|
@@ -331,16 +397,7 @@ export function runSupervisor(argvTail: string[]): Promise<never> {
|
|
|
331
397
|
// `[ISO-timestamp] [INFO] …`). Restrict to `{` and verify it's
|
|
332
398
|
// parseable JSON before forwarding, so a stray `{ tip: "..." }`
|
|
333
399
|
// shell-style line that isn't valid JSON still goes to stderr.
|
|
334
|
-
const looksJson = (line: string): boolean =>
|
|
335
|
-
const s = line.trimStart();
|
|
336
|
-
if (s.length === 0 || s[0] !== "{") return false;
|
|
337
|
-
try {
|
|
338
|
-
JSON.parse(s);
|
|
339
|
-
return true;
|
|
340
|
-
} catch {
|
|
341
|
-
return false;
|
|
342
|
-
}
|
|
343
|
-
};
|
|
400
|
+
const looksJson = (line: string): boolean => parseJsonRpcLine(line) !== null;
|
|
344
401
|
let buf = "";
|
|
345
402
|
child.stdout.setEncoding("utf-8");
|
|
346
403
|
child.stdout.on("data", (chunk: string) => {
|
|
@@ -352,7 +409,7 @@ export function runSupervisor(argvTail: string[]): Promise<never> {
|
|
|
352
409
|
if (looksJson(line)) {
|
|
353
410
|
process.stdout.write(line);
|
|
354
411
|
} else if (line.trim().length > 0) {
|
|
355
|
-
process.stderr.write(
|
|
412
|
+
process.stderr.write(`${STDOUT_REROUTE_TAG}${line}`);
|
|
356
413
|
}
|
|
357
414
|
}
|
|
358
415
|
});
|
|
@@ -361,7 +418,7 @@ export function runSupervisor(argvTail: string[]): Promise<never> {
|
|
|
361
418
|
if (looksJson(buf)) {
|
|
362
419
|
process.stdout.write(buf);
|
|
363
420
|
} else {
|
|
364
|
-
process.stderr.write(
|
|
421
|
+
process.stderr.write(`${STDOUT_REROUTE_TAG}${buf}`);
|
|
365
422
|
}
|
|
366
423
|
buf = "";
|
|
367
424
|
});
|
|
@@ -416,33 +473,20 @@ export function runSupervisor(argvTail: string[]): Promise<never> {
|
|
|
416
473
|
// (every Talon-spawned MCP server has it; ad-hoc supervisor uses
|
|
417
474
|
// without the env var keep the stdin-EOF-only behavior).
|
|
418
475
|
if (BRIDGE_URL) {
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
if (terminating) return;
|
|
424
|
-
if (watchdog.record(outcome)) {
|
|
476
|
+
startBridgeWatchdog(
|
|
477
|
+
BRIDGE_URL,
|
|
478
|
+
() => terminating,
|
|
479
|
+
(outcome, downForSec) => {
|
|
425
480
|
// Talon's gateway is gone (or wedged for minutes). The MCP child
|
|
426
481
|
// has nothing useful to serve — bridge calls would 404 against a
|
|
427
482
|
// dead port — so shut down. Kilo/OpenCode notice the stdio close
|
|
428
483
|
// on the next interaction and drop the registration on their side.
|
|
429
484
|
process.stderr.write(
|
|
430
|
-
`mcp-launcher: bridge ${BRIDGE_URL} ${outcome} for ${
|
|
431
|
-
watchdog.consecutiveFailures * (BRIDGE_PING_INTERVAL_MS / 1000)
|
|
432
|
-
}s; shutting down child\n`,
|
|
485
|
+
`mcp-launcher: bridge ${BRIDGE_URL} ${outcome} for ${downForSec}s; shutting down child\n`,
|
|
433
486
|
);
|
|
434
487
|
terminate(0);
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
// Stagger first tick so a process-wide restart doesn't have every
|
|
438
|
-
// supervisor pinging the bridge in lockstep.
|
|
439
|
-
const initialDelay = Math.floor(Math.random() * BRIDGE_PING_INTERVAL_MS);
|
|
440
|
-
const startTimer = setTimeout(() => {
|
|
441
|
-
void tick();
|
|
442
|
-
const interval = setInterval(() => void tick(), BRIDGE_PING_INTERVAL_MS);
|
|
443
|
-
interval.unref?.();
|
|
444
|
-
}, initialDelay);
|
|
445
|
-
startTimer.unref?.();
|
|
488
|
+
},
|
|
489
|
+
);
|
|
446
490
|
}
|
|
447
491
|
|
|
448
492
|
// The child + stdin pipe keep the event loop alive; exit happens via
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
* and tools/call to a hub-managed child (see children.ts).
|
|
4
4
|
*
|
|
5
5
|
* One proxy per hub session; many sessions share one child. The child
|
|
6
|
-
* is re-acquired through `getChild` on every
|
|
6
|
+
* is re-acquired through `getChild` on every call, so a child that
|
|
7
7
|
* was idle-reaped or crashed respawns transparently mid-session —
|
|
8
8
|
* clients never see the lifecycle, only (at worst) a slow first call.
|
|
9
|
+
* tools/list goes through `listTools`, which the hub answers from its
|
|
10
|
+
* per-server cache when it can, so listing alone need not spawn.
|
|
9
11
|
*
|
|
10
12
|
* Tools-only by design: every MCP server Talon consumes (plugins,
|
|
11
13
|
* brave) exposes tools, and the backends only wire tools. Resource /
|
|
@@ -18,26 +20,28 @@ import {
|
|
|
18
20
|
ListToolsRequestSchema,
|
|
19
21
|
CallToolRequestSchema,
|
|
20
22
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
23
|
+
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
21
24
|
import type { ChildHandle } from "./children.js";
|
|
22
25
|
|
|
23
|
-
export
|
|
24
|
-
|
|
25
|
-
getChild: () => Promise<ChildHandle
|
|
26
|
-
|
|
26
|
+
export type ProxySource = {
|
|
27
|
+
listTools: () => Promise<Tool[]>;
|
|
28
|
+
getChild: () => Promise<ChildHandle>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function buildProxyServer(name: string, source: ProxySource): Server {
|
|
27
32
|
const server = new Server(
|
|
28
33
|
{ name, version: "1.0.0" },
|
|
29
34
|
{ capabilities: { tools: {} } },
|
|
30
35
|
);
|
|
31
36
|
|
|
32
37
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
33
|
-
|
|
34
|
-
return { tools: await child.listTools() };
|
|
38
|
+
return { tools: await source.listTools() };
|
|
35
39
|
});
|
|
36
40
|
|
|
37
41
|
// `extra.signal` fires when the upstream client cancels or its session
|
|
38
42
|
// closes; passing it on stops the child's work instead of orphaning it.
|
|
39
43
|
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
40
|
-
const child = await getChild();
|
|
44
|
+
const child = await source.getChild();
|
|
41
45
|
return child.callTool(
|
|
42
46
|
request.params.name,
|
|
43
47
|
request.params.arguments ?? {},
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP reaper process — the `_mcp-reaper` side of the hub's child guard
|
|
3
|
+
* (see child-guard.ts for the design). Kept in its own module with no
|
|
4
|
+
* imports beyond launcher.ts so the reaper process stays small: the
|
|
5
|
+
* entry shims dispatch it before the app graph loads.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { startBridgeWatchdog } from "./launcher.js";
|
|
9
|
+
|
|
10
|
+
/** Hidden CLI subcommand that turns a Talon process into the reaper. */
|
|
11
|
+
export const MCP_REAPER_SUBCOMMAND = "_mcp-reaper";
|
|
12
|
+
|
|
13
|
+
/** SIGTERM → SIGKILL grace, matching the per-child supervisor. */
|
|
14
|
+
const REAPER_KILL_GRACE_MS = 1_000;
|
|
15
|
+
|
|
16
|
+
/** One parsed control line: register (`+`) or release (`-`) a pid. */
|
|
17
|
+
export function parseGuardLine(
|
|
18
|
+
line: string,
|
|
19
|
+
): { op: "+" | "-"; pid: number } | null {
|
|
20
|
+
const m = /^([+-])(\d+)$/.exec(line.trim());
|
|
21
|
+
if (!m) return null;
|
|
22
|
+
const pid = Number(m[2]);
|
|
23
|
+
return Number.isSafeInteger(pid) && pid > 0
|
|
24
|
+
? { op: m[1] as "+" | "-", pid }
|
|
25
|
+
: null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function pidAlive(pid: number): boolean {
|
|
29
|
+
try {
|
|
30
|
+
process.kill(pid, 0);
|
|
31
|
+
return true;
|
|
32
|
+
} catch (err) {
|
|
33
|
+
// EPERM: the pid exists but is not ours to signal — still alive.
|
|
34
|
+
return (err as NodeJS.ErrnoException).code === "EPERM";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function signalPid(pid: number, signal: NodeJS.Signals): void {
|
|
39
|
+
try {
|
|
40
|
+
process.kill(pid, signal);
|
|
41
|
+
} catch {
|
|
42
|
+
/* already gone */
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* SIGTERM every pid, wait up to `graceMs` for them to exit, then SIGKILL
|
|
48
|
+
* whatever is left. Resolves once the SIGKILLs are sent.
|
|
49
|
+
*/
|
|
50
|
+
export async function killPids(
|
|
51
|
+
pids: readonly number[],
|
|
52
|
+
graceMs: number = REAPER_KILL_GRACE_MS,
|
|
53
|
+
): Promise<void> {
|
|
54
|
+
for (const pid of pids) signalPid(pid, "SIGTERM");
|
|
55
|
+
const deadline = Date.now() + graceMs;
|
|
56
|
+
let alive = pids.filter(pidAlive);
|
|
57
|
+
while (alive.length > 0 && Date.now() < deadline) {
|
|
58
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
59
|
+
alive = alive.filter(pidAlive);
|
|
60
|
+
}
|
|
61
|
+
for (const pid of alive) signalPid(pid, "SIGKILL");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Reaper main. Never resolves: the process exits once it has reaped, which
|
|
66
|
+
* happens on stdin EOF/close, a termination signal, or a bridge-watchdog
|
|
67
|
+
* trip (only when TALON_BRIDGE_URL is set).
|
|
68
|
+
*/
|
|
69
|
+
export function runReaper(): Promise<never> {
|
|
70
|
+
const pids = new Set<number>();
|
|
71
|
+
let reaping = false;
|
|
72
|
+
let buf = "";
|
|
73
|
+
|
|
74
|
+
const reap = (): void => {
|
|
75
|
+
if (reaping) return;
|
|
76
|
+
reaping = true;
|
|
77
|
+
void killPids([...pids]).finally(() => process.exit(0));
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// The daemon's end of stdin/stderr may vanish at any moment (that is
|
|
81
|
+
// the point); an EPIPE must not take the reaper down before it reaps.
|
|
82
|
+
process.stdin.on("error", () => {});
|
|
83
|
+
process.stderr.on("error", () => {});
|
|
84
|
+
process.stdin.setEncoding("utf-8");
|
|
85
|
+
process.stdin.on("data", (chunk: string) => {
|
|
86
|
+
buf += chunk;
|
|
87
|
+
let nl;
|
|
88
|
+
while ((nl = buf.indexOf("\n")) !== -1) {
|
|
89
|
+
const parsed = parseGuardLine(buf.slice(0, nl));
|
|
90
|
+
buf = buf.slice(nl + 1);
|
|
91
|
+
if (!parsed) continue;
|
|
92
|
+
if (parsed.op === "+") pids.add(parsed.pid);
|
|
93
|
+
else pids.delete(parsed.pid);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
process.stdin.on("end", reap);
|
|
97
|
+
process.stdin.on("close", reap);
|
|
98
|
+
|
|
99
|
+
const signals: NodeJS.Signals[] =
|
|
100
|
+
process.platform === "win32"
|
|
101
|
+
? ["SIGTERM", "SIGINT"]
|
|
102
|
+
: ["SIGTERM", "SIGINT", "SIGHUP"];
|
|
103
|
+
for (const sig of signals) process.on(sig, reap);
|
|
104
|
+
|
|
105
|
+
const bridgeUrl = process.env.TALON_BRIDGE_URL;
|
|
106
|
+
if (bridgeUrl) {
|
|
107
|
+
startBridgeWatchdog(
|
|
108
|
+
bridgeUrl,
|
|
109
|
+
() => reaping,
|
|
110
|
+
(outcome, downForSec) => {
|
|
111
|
+
process.stderr.write(
|
|
112
|
+
`mcp-reaper: bridge ${bridgeUrl} ${outcome} for ${downForSec}s; reaping ${pids.size} MCP child(ren)\n`,
|
|
113
|
+
);
|
|
114
|
+
reap();
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return new Promise<never>(() => {});
|
|
120
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* the token format, the scope vocabulary, and the operator surface.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
export { DeviceCredentialStore } from "./store.js";
|
|
6
|
+
export { DeviceCredentialStore, UNBOUND_TTL_MS } from "./store.js";
|
|
7
7
|
export { credentialIdOf, isDeviceCredentialToken } from "./token.js";
|
|
8
8
|
export {
|
|
9
9
|
DEFAULT_COMPANION_SCOPES,
|
|
@@ -43,7 +43,7 @@ import {
|
|
|
43
43
|
const DEFAULT_FILE = resolve(dirs.root, "mesh-credentials.json");
|
|
44
44
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
45
45
|
/** A pairing link or installer that is never used stops being a credential. */
|
|
46
|
-
const UNBOUND_TTL_MS = 7 * DAY_MS;
|
|
46
|
+
export const UNBOUND_TTL_MS = 7 * DAY_MS;
|
|
47
47
|
/** How long a device has to pick up a requested rotation before it expires. */
|
|
48
48
|
const ROTATION_GRACE_MS = 7 * DAY_MS;
|
|
49
49
|
/** Revoked rows are kept this long for `talon mesh` / audit, then pruned. */
|
|
@@ -863,6 +863,14 @@ export class MeshService {
|
|
|
863
863
|
return this.links.makeCompanionPairLink(label, bridgeUrl);
|
|
864
864
|
}
|
|
865
865
|
|
|
866
|
+
/** `make_companion_pair_link`: the pairing link as a tool result. */
|
|
867
|
+
makeCompanionPairLinkText(
|
|
868
|
+
label?: unknown,
|
|
869
|
+
bridgeUrl?: unknown,
|
|
870
|
+
): MeshToolResult {
|
|
871
|
+
return this.links.makeCompanionPairLinkText(label, bridgeUrl);
|
|
872
|
+
}
|
|
873
|
+
|
|
866
874
|
/** GET /pair — serve a pairing grant (single-use). */
|
|
867
875
|
openCompanionPair(
|
|
868
876
|
token: string,
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { networkInterfaces } from "node:os";
|
|
13
13
|
import {
|
|
14
14
|
CompanionPairStore,
|
|
15
|
+
PAIR_GRANT_TTL_MS,
|
|
15
16
|
pairLink,
|
|
16
17
|
pairPage,
|
|
17
18
|
type CompanionPairPayload,
|
|
@@ -32,7 +33,9 @@ import {
|
|
|
32
33
|
} from "./node-provision.js";
|
|
33
34
|
import {
|
|
34
35
|
DEFAULT_COMPANION_SCOPES,
|
|
36
|
+
isDeviceCredentialToken,
|
|
35
37
|
NODE_SCOPES,
|
|
38
|
+
UNBOUND_TTL_MS,
|
|
36
39
|
type CredentialOrigin,
|
|
37
40
|
type MeshScope,
|
|
38
41
|
} from "../credentials/index.js";
|
|
@@ -281,6 +284,40 @@ export class BridgeLinks {
|
|
|
281
284
|
};
|
|
282
285
|
}
|
|
283
286
|
|
|
287
|
+
/**
|
|
288
|
+
* `make_companion_pair_link`: {@link makeCompanionPairLink} as a tool
|
|
289
|
+
* result — the one-tap link plus the values for typing in by hand, the
|
|
290
|
+
* same things the Telegram `/mesh pair` reply shows.
|
|
291
|
+
*/
|
|
292
|
+
makeCompanionPairLinkText(
|
|
293
|
+
label?: unknown,
|
|
294
|
+
bridgeUrl?: unknown,
|
|
295
|
+
): MeshToolResult {
|
|
296
|
+
const minted = this.makeCompanionPairLink(label, bridgeUrl);
|
|
297
|
+
if (!minted.ok) return minted;
|
|
298
|
+
const minutes = Math.round(PAIR_GRANT_TTL_MS / 60_000);
|
|
299
|
+
const perDevice = isDeviceCredentialToken(minted.token);
|
|
300
|
+
return {
|
|
301
|
+
ok: true,
|
|
302
|
+
text: [
|
|
303
|
+
"Open this link on the phone or computer running the Talon companion:",
|
|
304
|
+
"",
|
|
305
|
+
` ${minted.link}`,
|
|
306
|
+
"",
|
|
307
|
+
`Single-use, expires in ${minutes} minutes. If the link doesn't open the app, enter these by hand:`,
|
|
308
|
+
` Bridge URL: ${minted.url}`,
|
|
309
|
+
` Token: ${minted.token}`,
|
|
310
|
+
...(minted.fingerprint
|
|
311
|
+
? [` Certificate SHA-256: ${minted.fingerprint}`]
|
|
312
|
+
: []),
|
|
313
|
+
"",
|
|
314
|
+
perDevice
|
|
315
|
+
? `The token is this device's own credential: it binds to the first device that registers with it and lapses after ${Math.round(UNBOUND_TTL_MS / 86_400_000)} days unused. It can't take over a device that already holds a live credential — revoke that one first (talon mesh revoke <device>).`
|
|
316
|
+
: "The token is the bridge's shared token (this mesh has no per-device credential store).",
|
|
317
|
+
].join("\n"),
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
284
321
|
/**
|
|
285
322
|
* GET /pair — serve a pairing grant (single-use), as the landing page or
|
|
286
323
|
* as the JSON the companion reads.
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
import { randomBytes } from "node:crypto";
|
|
33
33
|
|
|
34
34
|
/** Unclaimed grants die after this long. A pairing link is used at once. */
|
|
35
|
-
const
|
|
35
|
+
export const PAIR_GRANT_TTL_MS = 10 * 60 * 1000;
|
|
36
36
|
|
|
37
37
|
export type CompanionPairGrant = {
|
|
38
38
|
token: string;
|
|
@@ -59,7 +59,7 @@ export type CompanionPairPayload = {
|
|
|
59
59
|
export class CompanionPairStore {
|
|
60
60
|
private readonly grants = new Map<string, CompanionPairGrant>();
|
|
61
61
|
|
|
62
|
-
constructor(private readonly ttlMs =
|
|
62
|
+
constructor(private readonly ttlMs = PAIR_GRANT_TTL_MS) {}
|
|
63
63
|
|
|
64
64
|
create(
|
|
65
65
|
grant: Omit<CompanionPairGrant, "token" | "createdAt" | "used">,
|
package/src/core/plugin/mcp.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MCP server specs — the stdio command for every plugin that exposes an MCP
|
|
3
3
|
* server (via `mcpServer` command/args or `mcpServerPath`), plus the
|
|
4
|
-
* standalone MCP entries from config. The hub spawns its children from these
|
|
4
|
+
* standalone MCP entries from config. The hub spawns its children from these
|
|
5
|
+
* raw specs and applies orphan protection itself (mcp-hub/child-guard.ts).
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import { resolve } from "node:path";
|
|
8
9
|
import { logWarn } from "../../util/log.js";
|
|
9
|
-
import { wrapMcpServer } from "../mcp-hub/launcher.js";
|
|
10
10
|
import { isBunRuntime } from "../../util/runtime.js";
|
|
11
11
|
import { registry, reloadState } from "./registry.js";
|
|
12
12
|
import type { McpServerConfig } from "./types.js";
|
|
@@ -71,14 +71,14 @@ export function getPluginMcpServers(
|
|
|
71
71
|
|
|
72
72
|
if (plugin.mcpServer) {
|
|
73
73
|
// Custom command/args (Python, Go, etc.) — no tsx wrapper
|
|
74
|
-
servers[`${plugin.name}-tools`] =
|
|
74
|
+
servers[`${plugin.name}-tools`] = {
|
|
75
75
|
command: plugin.mcpServer.command,
|
|
76
76
|
args: [...plugin.mcpServer.args],
|
|
77
77
|
env: baseEnv,
|
|
78
|
-
}
|
|
78
|
+
};
|
|
79
79
|
} else if (plugin.mcpServerPath) {
|
|
80
80
|
// TS server entry: bun runs it directly; node needs the tsx loader.
|
|
81
|
-
servers[`${plugin.name}-tools`] =
|
|
81
|
+
servers[`${plugin.name}-tools`] = {
|
|
82
82
|
command: isBunRuntime()
|
|
83
83
|
? process.execPath
|
|
84
84
|
: process.platform === "win32"
|
|
@@ -90,17 +90,17 @@ export function getPluginMcpServers(
|
|
|
90
90
|
? ["tsx", plugin.mcpServerPath]
|
|
91
91
|
: ["--import", tsxPath, plugin.mcpServerPath],
|
|
92
92
|
env: baseEnv,
|
|
93
|
-
}
|
|
93
|
+
};
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
for (const entry of registry.mcpEntries) {
|
|
98
98
|
if (only !== undefined && !only.includes(entry.name)) continue;
|
|
99
|
-
servers[`${entry.name}-tools`] =
|
|
99
|
+
servers[`${entry.name}-tools`] = {
|
|
100
100
|
command: entry.command,
|
|
101
101
|
args: [...(entry.args ?? [])],
|
|
102
102
|
env: buildBridgeEnv(bridgeUrl, chatId, entry.env),
|
|
103
|
-
}
|
|
103
|
+
};
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
return servers;
|
package/src/core/tools/bridge.ts
CHANGED
|
@@ -28,6 +28,8 @@ const LONG_ACTION_TIMEOUTS_MS: Record<string, number> = {
|
|
|
28
28
|
// must sit above it so the layer that gives up is the tool (which reports
|
|
29
29
|
// the agent's live state), never the transport.
|
|
30
30
|
wait_for_agent: 180_000,
|
|
31
|
+
// The pre-flight lane caps itself at 600s; same margin rule as above.
|
|
32
|
+
run_preflight: 660_000,
|
|
31
33
|
device_pull_file: 3_600_000,
|
|
32
34
|
device_push_file: 3_600_000,
|
|
33
35
|
device_read_file: 3_600_000,
|
|
@@ -36,6 +38,9 @@ const LONG_ACTION_TIMEOUTS_MS: Record<string, number> = {
|
|
|
36
38
|
native_write: 3_600_000,
|
|
37
39
|
native_edit: 3_600_000,
|
|
38
40
|
device_exec: 330_000,
|
|
41
|
+
// The fetch ladder may climb several rungs (and install curl-impersonate
|
|
42
|
+
// on first use) inside its own 150s budget.
|
|
43
|
+
fetch_url: 180_000,
|
|
39
44
|
native_bash: 330_000,
|
|
40
45
|
native_glob: 330_000,
|
|
41
46
|
native_search: 330_000,
|
|
@@ -14,7 +14,7 @@ export const webTools: ToolDefinition[] = [
|
|
|
14
14
|
{
|
|
15
15
|
name: "fetch_url",
|
|
16
16
|
description:
|
|
17
|
-
"Fetch a URL — web pages return text content, image URLs are downloaded to workspace. Use to read articles, download images, or fetch any URL.",
|
|
17
|
+
"Fetch a URL — web pages return text content, image URLs are downloaded to workspace. Use to read articles, download images, or fetch any URL. Bot walls are retried automatically (browser-TLS impersonation, then any configured exits/browser); the result footer names the route that worked. A 404 means the URL is wrong, not that you were blocked.",
|
|
18
18
|
schema: {
|
|
19
19
|
url: z.string().describe("The URL to fetch"),
|
|
20
20
|
},
|