talon-agent 5.20.1 → 5.22.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/README.md +2 -1
- package/package.json +2 -2
- package/src/cli/commands/mesh-audit.ts +89 -0
- package/src/cli/commands/mesh.ts +22 -1
- package/src/cli/index.ts +1 -1
- package/src/cli/status.ts +24 -1
- package/src/core/config/index.ts +12 -11
- package/src/core/engine/gateway-actions/fetch-url/guard.ts +4 -4
- package/src/core/engine/gateway-actions/fetch-url/index.ts +4 -3
- package/src/core/engine/gateway-actions/mesh.ts +2 -0
- package/src/core/engine/gateway-routes.ts +16 -0
- package/src/core/mesh/audit.ts +232 -0
- package/src/core/mesh/credentials/index.ts +1 -0
- package/src/core/mesh/credentials/store.ts +42 -1
- package/src/core/mesh/credentials/types.ts +21 -2
- package/src/core/mesh/devices/service.ts +103 -5
- package/src/core/mesh/links/bridge-links.ts +94 -9
- package/src/core/mesh/links/node-provision.ts +285 -41
- package/src/core/mesh/transfers/device-files.ts +110 -39
- package/src/core/mesh/transfers/transfers.ts +31 -15
- package/src/core/tools/ops/mesh.ts +24 -4
- package/src/core/weaver/weaver.ts +4 -1
- package/src/frontend/native/bridge/credentials/upgrade.ts +16 -3
- package/src/frontend/native/bridge/routes/host.ts +12 -2
- package/src/frontend/native/bridge/routes/pre-auth.ts +8 -2
- package/src/frontend/native/bridge/server.ts +8 -0
- package/src/frontend/native/bridge/tls.ts +15 -0
- package/src/frontend/native/index.ts +26 -0
- package/src/frontend/native/surface/handlers.ts +1 -1
- package/src/util/logging/turn-scope.ts +23 -2
package/README.md
CHANGED
|
@@ -493,6 +493,7 @@ Config file: `~/.talon/config.json`
|
|
|
493
493
|
| `dreamModel` | --- | Model for dream / memory consolidation (falls back to `model`) |
|
|
494
494
|
| `dreamEffort` | --- | Reasoning effort for the dream agent — same levels as `heartbeatEffort` |
|
|
495
495
|
| `braveApiKey` | --- | Brave Search API key |
|
|
496
|
+
| `fetchUrl` | --- | `fetch_url` reaches every address by default, LAN and loopback included. `{ "allowPrivateNetworks": false }` opts into the SSRF guard (refuses private, loopback, link-local and metadata addresses on every redirect hop) — worth it on a cloud VM |
|
|
496
497
|
| `timezone` | --- | IANA timezone (e.g. `"Europe/London"`) |
|
|
497
498
|
| `plugins` | `[]` | External plugin packages |
|
|
498
499
|
| `disabledToolTags` | --- | Hide whole tool groups from the model (e.g. `["stickers", "web"]`) — each registered tool costs context tokens per session |
|
|
@@ -505,7 +506,7 @@ Config file: `~/.talon/config.json`
|
|
|
505
506
|
| `apiId` / `apiHash` | --- | Telegram API credentials for full message history |
|
|
506
507
|
| `whatsapp` | --- | WhatsApp frontend: pairing, allowlists, group policy ([above](#whatsapp)) |
|
|
507
508
|
| `discord` | --- | Discord frontend: bot token, application ID, guild / channel allowlists |
|
|
508
|
-
| `native` | --- | Client bridge: host, port, token, TLS ([above](#desktop--mobile-app))
|
|
509
|
+
| `native` | --- | Client bridge: host, port, token, TLS ([above](#desktop--mobile-app)). `companionScopes` (default all three) narrows what a paired companion's credential may do ([docs/mesh-credentials.md](docs/mesh-credentials.md)) |
|
|
509
510
|
| `nativeTools` | `false` | Swap the SDK's built-in Read/Write/Edit/Bash/Glob/Grep for Talon's own — these also route to a teleported device |
|
|
510
511
|
| `fuse` | `"auto"` | Mount the `talon://` namespace with FUSE live views; falls back to a symlink farm where the host can't |
|
|
511
512
|
| `github` | --- | GitHub plugin config (see above) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talon-agent",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.22.0",
|
|
4
4
|
"description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
|
|
5
5
|
"author": "The Falconry",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -101,7 +101,7 @@
|
|
|
101
101
|
"build:fusefs": "node native/talon-fusefs/build.mjs"
|
|
102
102
|
},
|
|
103
103
|
"dependencies": {
|
|
104
|
-
"@anthropic-ai/claude-agent-sdk": "^0.3.
|
|
104
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.284",
|
|
105
105
|
"@anthropic-ai/sdk": "^0.104.1",
|
|
106
106
|
"@brave/brave-search-mcp-server": "^2.0.75",
|
|
107
107
|
"@clack/prompts": "^1.2.0",
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `talon mesh audit [--limit N] [--device X]` — the daemon's record of
|
|
3
|
+
* every command it sent to a mesh device (core/mesh/audit.ts), read
|
|
4
|
+
* through the loopback gateway. Arguments were never recorded, only their
|
|
5
|
+
* hash; the short form here is its first 12 hex characters.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import pc from "picocolors";
|
|
9
|
+
import { fetchGateway } from "../daemon-api.js";
|
|
10
|
+
import type { MeshAuditEntry } from "../../core/mesh/audit.js";
|
|
11
|
+
|
|
12
|
+
type AuditQuery = { limit?: number; device?: string };
|
|
13
|
+
|
|
14
|
+
/** Parse `--limit N` / `--device X` (either `--flag value` or `--flag=value`). */
|
|
15
|
+
export function parseAuditArgs(args: readonly string[]): AuditQuery | string {
|
|
16
|
+
const query: AuditQuery = {};
|
|
17
|
+
for (let i = 0; i < args.length; i++) {
|
|
18
|
+
const arg = args[i]!;
|
|
19
|
+
const eq = arg.indexOf("=");
|
|
20
|
+
const flag = eq === -1 ? arg : arg.slice(0, eq);
|
|
21
|
+
if (flag !== "--limit" && flag !== "--device") {
|
|
22
|
+
return `Unknown option: ${arg}`;
|
|
23
|
+
}
|
|
24
|
+
const value = eq === -1 ? args[++i] : arg.slice(eq + 1);
|
|
25
|
+
if (value === undefined || value === "") return `${flag} needs a value`;
|
|
26
|
+
if (flag === "--device") {
|
|
27
|
+
query.device = value;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const limit = Number(value);
|
|
31
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
32
|
+
return `--limit must be a positive integer, got "${value}"`;
|
|
33
|
+
}
|
|
34
|
+
query.limit = limit;
|
|
35
|
+
}
|
|
36
|
+
return query;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function issuerText(entry: MeshAuditEntry): string {
|
|
40
|
+
const i = entry.issuer;
|
|
41
|
+
if (!i) return pc.dim("no turn");
|
|
42
|
+
return [
|
|
43
|
+
i.source && i.source !== "message" ? i.source : null,
|
|
44
|
+
i.sender ? `by ${i.sender}` : null,
|
|
45
|
+
`chat ${i.chatId}`,
|
|
46
|
+
i.turnId,
|
|
47
|
+
]
|
|
48
|
+
.filter(Boolean)
|
|
49
|
+
.join(" · ");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function renderEntry(entry: MeshAuditEntry): void {
|
|
53
|
+
const mark = entry.ok ? pc.green("✔") : pc.red("✖");
|
|
54
|
+
console.log(
|
|
55
|
+
` ${pc.dim(entry.time)} ${mark} ${pc.cyan(entry.command)} → ${entry.deviceName} ${pc.dim(`(${entry.deviceId})`)} ${entry.durationMs}ms`,
|
|
56
|
+
);
|
|
57
|
+
console.log(
|
|
58
|
+
pc.dim(
|
|
59
|
+
` ${issuerText(entry)} · args ${entry.argsHash.slice(0, 12)}` +
|
|
60
|
+
(entry.error ? ` · ${entry.error}` : ""),
|
|
61
|
+
),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function runMeshAudit(
|
|
66
|
+
port: number,
|
|
67
|
+
query: AuditQuery,
|
|
68
|
+
): Promise<void> {
|
|
69
|
+
const params = new URLSearchParams();
|
|
70
|
+
if (query.limit !== undefined) params.set("limit", String(query.limit));
|
|
71
|
+
if (query.device) params.set("device", query.device);
|
|
72
|
+
const qs = params.size > 0 ? `?${params.toString()}` : "";
|
|
73
|
+
const reply = (await fetchGateway(port, `/mesh/audit${qs}`)) as {
|
|
74
|
+
ok: boolean;
|
|
75
|
+
entries?: MeshAuditEntry[];
|
|
76
|
+
error?: string;
|
|
77
|
+
};
|
|
78
|
+
if (!reply.ok) {
|
|
79
|
+
console.error(`\n ${pc.red("✖")} ${reply.error}\n`);
|
|
80
|
+
process.exitCode = 1;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const entries = reply.entries ?? [];
|
|
84
|
+
console.log(`\n ${pc.bold("Mesh command audit")}\n`);
|
|
85
|
+
if (entries.length === 0)
|
|
86
|
+
console.log(` ${pc.dim("(no commands recorded)")}`);
|
|
87
|
+
for (const entry of entries) renderEntry(entry);
|
|
88
|
+
console.log("");
|
|
89
|
+
}
|
package/src/cli/commands/mesh.ts
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
* talon mesh rotate <device> the device swaps credentials on its
|
|
8
8
|
* next heartbeat (old one: 7-day grace)
|
|
9
9
|
* talon mesh scopes <device> <list> e.g. device,client,operator
|
|
10
|
+
* talon mesh audit [--limit N] [--device X]
|
|
11
|
+
* commands the daemon sent to devices
|
|
12
|
+
* (core/mesh/audit.ts)
|
|
10
13
|
*
|
|
11
14
|
* Everything goes through the running daemon's loopback gateway
|
|
12
15
|
* (core/engine/gateway-routes.ts → core/mesh/credentials/admin.ts): the
|
|
@@ -17,14 +20,16 @@
|
|
|
17
20
|
import pc from "picocolors";
|
|
18
21
|
import { fetchGateway, requireGatewayPort } from "../daemon-api.js";
|
|
19
22
|
import type { DeviceCredential } from "../../core/mesh/credentials/index.js";
|
|
23
|
+
import { parseAuditArgs, runMeshAudit } from "./mesh-audit.js";
|
|
20
24
|
|
|
21
25
|
const USAGE = `
|
|
22
|
-
${pc.bold("talon mesh")} — per-device mesh credentials
|
|
26
|
+
${pc.bold("talon mesh")} — per-device mesh credentials and command audit
|
|
23
27
|
|
|
24
28
|
${pc.cyan("list")} credentials, scopes, last use (default)
|
|
25
29
|
${pc.cyan("revoke")} <device|credential-id> revoke now; drops its live sessions
|
|
26
30
|
${pc.cyan("rotate")} <device> re-issue on the device's next heartbeat
|
|
27
31
|
${pc.cyan("scopes")} <device> <scope,scope> set scopes: device, client, operator
|
|
32
|
+
${pc.cyan("audit")} [--limit N] [--device X] commands sent to devices (newest last)
|
|
28
33
|
`;
|
|
29
34
|
|
|
30
35
|
type Overview = {
|
|
@@ -101,12 +106,28 @@ async function adminOp(
|
|
|
101
106
|
}
|
|
102
107
|
}
|
|
103
108
|
|
|
109
|
+
async function runAudit(args: readonly string[]): Promise<void> {
|
|
110
|
+
const query = parseAuditArgs(args);
|
|
111
|
+
if (typeof query === "string") {
|
|
112
|
+
console.error(` ${pc.red("✖")} ${query}\n${USAGE}`);
|
|
113
|
+
process.exitCode = 1;
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const port = await requireGatewayPort();
|
|
117
|
+
if (port === null) {
|
|
118
|
+
process.exitCode = 1;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
await runMeshAudit(port, query);
|
|
122
|
+
}
|
|
123
|
+
|
|
104
124
|
export async function runMeshCommand(args: string[]): Promise<void> {
|
|
105
125
|
const [sub = "list", target, scopes] = args;
|
|
106
126
|
if (sub === "help" || sub === "--help" || sub === "-h") {
|
|
107
127
|
console.log(USAGE);
|
|
108
128
|
return;
|
|
109
129
|
}
|
|
130
|
+
if (sub === "audit") return runAudit(args.slice(1));
|
|
110
131
|
const known = ["list", "revoke", "rotate", "scopes"];
|
|
111
132
|
if (!known.includes(sub)) {
|
|
112
133
|
console.error(` Unknown mesh command: ${sub}\n${USAGE}`);
|
package/src/cli/index.ts
CHANGED
|
@@ -113,7 +113,7 @@ function printHelp(): void {
|
|
|
113
113
|
` ${pc.cyan("backup")} Snapshots and checkpoints (now/list/show/pin/restore)`,
|
|
114
114
|
);
|
|
115
115
|
console.log(
|
|
116
|
-
` ${pc.cyan("mesh")} Device credentials (list/revoke/rotate/scopes)`,
|
|
116
|
+
` ${pc.cyan("mesh")} Device credentials + command audit (list/revoke/rotate/scopes/audit)`,
|
|
117
117
|
);
|
|
118
118
|
console.log(` ${pc.cyan("config")} View/edit configuration`);
|
|
119
119
|
console.log(
|
package/src/cli/status.ts
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import pc from "picocolors";
|
|
7
|
-
import { existsSync } from "node:fs";
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
8
|
import { findRunningInstance } from "../core/daemon/discovery.js";
|
|
9
|
+
import { files } from "../util/paths.js";
|
|
9
10
|
import { printBanner, loadConfig } from "./config.js";
|
|
10
11
|
import { CONFIG_FILE } from "./context.js";
|
|
11
12
|
|
|
@@ -38,6 +39,26 @@ export function formatAlertLines(health: Record<string, unknown>): string[] {
|
|
|
38
39
|
});
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
/**
|
|
43
|
+
* The native bridge's TLS certificate fingerprint, from its discovery file —
|
|
44
|
+
* what a talon-node pins on first use, shown so an operator can compare the
|
|
45
|
+
* two. Null when the bridge is off, serves plain http, or never ran.
|
|
46
|
+
*/
|
|
47
|
+
export function bridgeFingerprint(
|
|
48
|
+
path: string = files.nativeBridge,
|
|
49
|
+
): string | null {
|
|
50
|
+
try {
|
|
51
|
+
const raw = JSON.parse(readFileSync(path, "utf-8")) as {
|
|
52
|
+
fingerprint?: unknown;
|
|
53
|
+
};
|
|
54
|
+
return typeof raw.fingerprint === "string" && raw.fingerprint
|
|
55
|
+
? raw.fingerprint
|
|
56
|
+
: null;
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
41
62
|
export async function showStatus(): Promise<void> {
|
|
42
63
|
printBanner();
|
|
43
64
|
const instance = await findRunningInstance();
|
|
@@ -60,6 +81,8 @@ export async function showStatus(): Promise<void> {
|
|
|
60
81
|
console.log(` ${pc.dim("Messages")} ${h.messages}`);
|
|
61
82
|
console.log(` ${pc.dim("Queue")} ${h.queue} pending`);
|
|
62
83
|
console.log(` ${pc.dim("Errors")} ${h.errors}`);
|
|
84
|
+
const fingerprint = bridgeFingerprint();
|
|
85
|
+
if (fingerprint) console.log(` ${pc.dim("Bridge TLS")} ${fingerprint}`);
|
|
63
86
|
console.log(` ${pc.dim("Last active")} ${h.lastActivity}\n`);
|
|
64
87
|
const alerts = formatAlertLines(h);
|
|
65
88
|
if (alerts.length > 0) {
|
package/src/core/config/index.ts
CHANGED
|
@@ -234,11 +234,11 @@ const nativeConfigSchema = z
|
|
|
234
234
|
legacySharedToken: z.boolean().optional(),
|
|
235
235
|
/**
|
|
236
236
|
* Scopes a companion's per-device credential carries — at pairing and
|
|
237
|
-
* on the in-band upgrade. Default ["device", "client"]:
|
|
238
|
-
* the chat UI
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
* ["device"].
|
|
237
|
+
* on the in-band upgrade. Default ["device", "client", "operator"]:
|
|
238
|
+
* the mesh, the chat UI and its settings (config writes, plugin
|
|
239
|
+
* toggles, logs) — what the shared token allowed. Set ["device",
|
|
240
|
+
* "client"] to keep paired phones out of config; `talon mesh scopes
|
|
241
|
+
* <device> <list>` changes one device. Nodes always get ["device"].
|
|
242
242
|
*/
|
|
243
243
|
companionScopes: z
|
|
244
244
|
.array(z.enum(["device", "client", "operator"]))
|
|
@@ -677,14 +677,15 @@ const configSchema = z.object({
|
|
|
677
677
|
.optional(),
|
|
678
678
|
braveApiKey: z.string().optional(),
|
|
679
679
|
/**
|
|
680
|
-
* `fetch_url`
|
|
681
|
-
*
|
|
682
|
-
*
|
|
683
|
-
*
|
|
684
|
-
*
|
|
680
|
+
* `fetch_url` reaches any address by default, local services included (a
|
|
681
|
+
* home lab, a dev server, the LAN). Set `allowPrivateNetworks: false` to
|
|
682
|
+
* opt into the SSRF guard: it then refuses hosts that resolve to
|
|
683
|
+
* loopback, private (RFC 1918, CGNAT, ULA), link-local (incl. the
|
|
684
|
+
* 169.254.169.254 metadata endpoint) or reserved addresses, re-checking
|
|
685
|
+
* every redirect hop — worth it on a cloud VM.
|
|
685
686
|
*/
|
|
686
687
|
fetchUrl: z
|
|
687
|
-
.object({ allowPrivateNetworks: z.boolean().default(
|
|
688
|
+
.object({ allowPrivateNetworks: z.boolean().default(true) })
|
|
688
689
|
.strict()
|
|
689
690
|
.optional(),
|
|
690
691
|
/**
|
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
* direct, redirect and static-DNS paths; rebinding needs a hostile DNS
|
|
18
18
|
* server and a lucky race.
|
|
19
19
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* The guard is opt-in: `fetch_url` applies it only when the operator sets
|
|
21
|
+
* `fetchUrl.allowPrivateNetworks: false`. By default the agent can read
|
|
22
|
+
* local services (a home lab, a local dev server).
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import { lookup } from "node:dns/promises";
|
|
@@ -163,7 +163,7 @@ export async function assertPublicUrl(
|
|
|
163
163
|
if (blocked) {
|
|
164
164
|
throw new BlockedUrlError(
|
|
165
165
|
`Refusing to fetch ${host}: it resolves to a private, loopback or link-local address (${blocked}). ` +
|
|
166
|
-
`
|
|
166
|
+
`Remove fetchUrl.allowPrivateNetworks: false from config.json (or set it to true) to allow local addresses.`,
|
|
167
167
|
);
|
|
168
168
|
}
|
|
169
169
|
}
|
|
@@ -75,8 +75,9 @@ export const fetchUrlHandlers: SharedActionHandlers = {
|
|
|
75
75
|
const invalid = urlError(url);
|
|
76
76
|
if (invalid) return { ok: false, error: invalid };
|
|
77
77
|
try {
|
|
78
|
-
//
|
|
79
|
-
//
|
|
78
|
+
// Local addresses are reachable by default; with
|
|
79
|
+
// `fetchUrl.allowPrivateNetworks: false` every hop is checked against
|
|
80
|
+
// private/loopback/link-local ranges (see guard.ts).
|
|
80
81
|
const resp = await guardedFetch(
|
|
81
82
|
url,
|
|
82
83
|
{
|
|
@@ -85,7 +86,7 @@ export const fetchUrlHandlers: SharedActionHandlers = {
|
|
|
85
86
|
},
|
|
86
87
|
{
|
|
87
88
|
allowPrivateNetworks:
|
|
88
|
-
getPoolConfig()?.fetchUrl?.allowPrivateNetworks
|
|
89
|
+
getPoolConfig()?.fetchUrl?.allowPrivateNetworks !== false,
|
|
89
90
|
},
|
|
90
91
|
);
|
|
91
92
|
if (!resp.ok) return { ok: false, error: `HTTP ${resp.status}` };
|
|
@@ -68,6 +68,7 @@ export const meshHandlers: SharedActionHandlers = {
|
|
|
68
68
|
body.device ?? body.deviceId,
|
|
69
69
|
body.apk_path,
|
|
70
70
|
body.remote_path,
|
|
71
|
+
body.allow_downgrade,
|
|
71
72
|
),
|
|
72
73
|
// Remote self-update for a headless talon-node: push a new binary and have
|
|
73
74
|
// the node verify, swap, and restart into it. binary_path is optional —
|
|
@@ -78,6 +79,7 @@ export const meshHandlers: SharedActionHandlers = {
|
|
|
78
79
|
body.device ?? body.deviceId,
|
|
79
80
|
body.binary_path,
|
|
80
81
|
body.remote_path,
|
|
82
|
+
body.allow_downgrade,
|
|
81
83
|
),
|
|
82
84
|
// Node provisioning: materialize a talon-node binary for any arch, and
|
|
83
85
|
// mint single-use bridge-served install links for fresh hosts.
|
|
@@ -204,6 +204,22 @@ const ROUTES: readonly GatewayRoute[] = [
|
|
|
204
204
|
sendJson(res, 200, await credentialAdmin(ctx, body));
|
|
205
205
|
},
|
|
206
206
|
},
|
|
207
|
+
{
|
|
208
|
+
// The mesh command audit — the transport for `talon mesh audit`.
|
|
209
|
+
// Prefix match: the query (?limit=&device=) follows the path.
|
|
210
|
+
method: "GET",
|
|
211
|
+
path: "/mesh/audit",
|
|
212
|
+
match: "prefix",
|
|
213
|
+
handle: async ({ res, url }) => {
|
|
214
|
+
const limit = Number(url.searchParams.get("limit") ?? "");
|
|
215
|
+
const device = url.searchParams.get("device") ?? "";
|
|
216
|
+
const entries = await getMeshService().readAudit({
|
|
217
|
+
...(Number.isInteger(limit) && limit > 0 ? { limit } : {}),
|
|
218
|
+
...(device ? { device } : {}),
|
|
219
|
+
});
|
|
220
|
+
sendJson(res, 200, { ok: true, entries });
|
|
221
|
+
},
|
|
222
|
+
},
|
|
207
223
|
{
|
|
208
224
|
// MCP hub — daemon-hosted MCP-over-HTTP endpoints for every backend
|
|
209
225
|
// (see core/mcp-hub).
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mesh command audit — one line per device command the daemon dispatched:
|
|
3
|
+
* when, who asked (chat / turn / sender, when a turn issued it), which
|
|
4
|
+
* device, which command, a hash of its arguments, how it ended, and how
|
|
5
|
+
* long it took.
|
|
6
|
+
*
|
|
7
|
+
* Arguments are never written, only `argsHash`: the SHA-256 of their
|
|
8
|
+
* canonical JSON (keys sorted at every depth). Command lines and file
|
|
9
|
+
* bodies stay out of the file, but an operator holding a suspect command
|
|
10
|
+
* can still prove whether it is the one that ran.
|
|
11
|
+
*
|
|
12
|
+
* The log is a bounded ring on disk: JSON lines appended to
|
|
13
|
+
* ~/.talon/data/mesh-audit.jsonl (0600). When the next line would take the
|
|
14
|
+
* file past `maxBytes` it rotates to `mesh-audit.jsonl.1`, replacing the
|
|
15
|
+
* previous generation, so the pair never holds more than about twice the
|
|
16
|
+
* cap. Appends are serialized, so lines never interleave.
|
|
17
|
+
*
|
|
18
|
+
* Recording is fire-and-forget and can never fail a command: every error
|
|
19
|
+
* is caught here and logged (once per failure streak), and the command
|
|
20
|
+
* path carries on.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createHash } from "node:crypto";
|
|
24
|
+
import {
|
|
25
|
+
appendFile,
|
|
26
|
+
chmod,
|
|
27
|
+
mkdir,
|
|
28
|
+
readFile,
|
|
29
|
+
rename,
|
|
30
|
+
stat,
|
|
31
|
+
} from "node:fs/promises";
|
|
32
|
+
import { dirname, resolve } from "node:path";
|
|
33
|
+
import { dirs } from "../../util/paths.js";
|
|
34
|
+
import { logWarn } from "../../util/log.js";
|
|
35
|
+
import { currentTurn } from "../../util/logging/turn-scope.js";
|
|
36
|
+
|
|
37
|
+
const DEFAULT_FILE = resolve(dirs.data, "mesh-audit.jsonl");
|
|
38
|
+
/** One generation's cap; the file plus its `.1` stay under twice this. */
|
|
39
|
+
const DEFAULT_MAX_BYTES = 1024 * 1024;
|
|
40
|
+
const DEFAULT_READ_LIMIT = 50;
|
|
41
|
+
const MAX_READ_LIMIT = 5_000;
|
|
42
|
+
/** Longest failure reason kept (the device's own one-line summary). */
|
|
43
|
+
const MAX_ERROR_CHARS = 200;
|
|
44
|
+
|
|
45
|
+
/** Who issued a command. Present only when a turn issued it. */
|
|
46
|
+
export type MeshAuditIssuer = {
|
|
47
|
+
chatId: string;
|
|
48
|
+
turnId: string;
|
|
49
|
+
/** The sender's operator key or display name, when the turn had one. */
|
|
50
|
+
sender?: string;
|
|
51
|
+
/** What started the turn: message, cron, trigger, pulse, agent. */
|
|
52
|
+
source?: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type MeshAuditEntry = {
|
|
56
|
+
/** ISO-8601 dispatch time. */
|
|
57
|
+
time: string;
|
|
58
|
+
issuer: MeshAuditIssuer | null;
|
|
59
|
+
deviceId: string;
|
|
60
|
+
deviceName: string;
|
|
61
|
+
command: string;
|
|
62
|
+
/** SHA-256 (hex) of the canonical JSON of the command's params. */
|
|
63
|
+
argsHash: string;
|
|
64
|
+
ok: boolean;
|
|
65
|
+
/** Why it failed (one line, truncated); absent on success. */
|
|
66
|
+
error?: string;
|
|
67
|
+
durationMs: number;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export type MeshAuditQuery = {
|
|
71
|
+
/** Newest entries to return (default 50). */
|
|
72
|
+
limit?: number;
|
|
73
|
+
/** Device id (exact) or name (case-insensitive, substring). */
|
|
74
|
+
device?: string;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Sort object keys at every depth, so equal params hash equally. */
|
|
78
|
+
function canonical(value: unknown): unknown {
|
|
79
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
80
|
+
if (value !== null && typeof value === "object") {
|
|
81
|
+
const record = value as Record<string, unknown>;
|
|
82
|
+
// fromEntries defines own properties, so a `__proto__` key stays data.
|
|
83
|
+
return Object.fromEntries(
|
|
84
|
+
Object.keys(record)
|
|
85
|
+
.sort()
|
|
86
|
+
.map((key) => [key, canonical(record[key])]),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** SHA-256 (hex) of the canonical JSON of a command's params. */
|
|
93
|
+
export function hashCommandArgs(params: unknown): string {
|
|
94
|
+
const json = JSON.stringify(canonical(params ?? {})) ?? "null";
|
|
95
|
+
return createHash("sha256").update(json).digest("hex");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Who is issuing a command right now: the running turn of the current
|
|
100
|
+
* async chain (tool actions re-enter it through the gateway), or null for
|
|
101
|
+
* a dispatch no turn made (a `/mesh` command, a bridge UI request).
|
|
102
|
+
*/
|
|
103
|
+
export function auditIssuer(): MeshAuditIssuer | null {
|
|
104
|
+
const turn = currentTurn();
|
|
105
|
+
if (!turn) return null;
|
|
106
|
+
const { sender, source } = turn.issuer;
|
|
107
|
+
return {
|
|
108
|
+
chatId: turn.chatId,
|
|
109
|
+
turnId: turn.turnId,
|
|
110
|
+
...(sender ? { sender } : {}),
|
|
111
|
+
...(source ? { source } : {}),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A device's failure message, kept to one short line. */
|
|
116
|
+
export function auditErrorText(message: string | undefined): string {
|
|
117
|
+
const line = (message ?? "failed").split(/\r?\n/, 1)[0]!.trim();
|
|
118
|
+
return line.length > MAX_ERROR_CHARS
|
|
119
|
+
? `${line.slice(0, MAX_ERROR_CHARS - 1)}…`
|
|
120
|
+
: line || "failed";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function matchesDevice(entry: MeshAuditEntry, device: string): boolean {
|
|
124
|
+
const q = device.toLowerCase();
|
|
125
|
+
return (
|
|
126
|
+
entry.deviceId === device ||
|
|
127
|
+
(typeof entry.deviceName === "string" &&
|
|
128
|
+
entry.deviceName.toLowerCase().includes(q))
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Parse JSON lines, skipping any torn or foreign line. */
|
|
133
|
+
function parseLines(raw: string): MeshAuditEntry[] {
|
|
134
|
+
const out: MeshAuditEntry[] = [];
|
|
135
|
+
for (const line of raw.split("\n")) {
|
|
136
|
+
if (!line.trim()) continue;
|
|
137
|
+
try {
|
|
138
|
+
const entry = JSON.parse(line) as MeshAuditEntry;
|
|
139
|
+
if (entry && typeof entry.command === "string") out.push(entry);
|
|
140
|
+
} catch {
|
|
141
|
+
// A half-written line from a crash mid-append: skip it.
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export class MeshAuditLog {
|
|
148
|
+
private chain: Promise<void> = Promise.resolve();
|
|
149
|
+
/** Bytes in the current generation; null until first checked on disk. */
|
|
150
|
+
private size: number | null = null;
|
|
151
|
+
private failing = false;
|
|
152
|
+
|
|
153
|
+
constructor(
|
|
154
|
+
private readonly file: string = DEFAULT_FILE,
|
|
155
|
+
private readonly maxBytes: number = DEFAULT_MAX_BYTES,
|
|
156
|
+
) {}
|
|
157
|
+
|
|
158
|
+
/** Queue one entry. Never throws and never rejects. */
|
|
159
|
+
record(entry: MeshAuditEntry): void {
|
|
160
|
+
let line: string;
|
|
161
|
+
try {
|
|
162
|
+
line = `${JSON.stringify(entry)}\n`;
|
|
163
|
+
} catch (err) {
|
|
164
|
+
this.warn(err);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
this.chain = this.chain
|
|
168
|
+
.then(() => this.append(line))
|
|
169
|
+
.then(() => {
|
|
170
|
+
this.failing = false;
|
|
171
|
+
})
|
|
172
|
+
.catch((err: unknown) => this.warn(err));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Resolves once every queued entry has been written (or given up on). */
|
|
176
|
+
flush(): Promise<void> {
|
|
177
|
+
return this.chain;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The newest matching entries, oldest first. */
|
|
181
|
+
async read(query: MeshAuditQuery = {}): Promise<MeshAuditEntry[]> {
|
|
182
|
+
await this.flush();
|
|
183
|
+
const limit = Math.min(
|
|
184
|
+
MAX_READ_LIMIT,
|
|
185
|
+
Math.max(1, Math.floor(query.limit ?? DEFAULT_READ_LIMIT)),
|
|
186
|
+
);
|
|
187
|
+
const [older, current] = await Promise.all([
|
|
188
|
+
readFile(`${this.file}.1`, "utf8").catch(() => ""),
|
|
189
|
+
readFile(this.file, "utf8").catch(() => ""),
|
|
190
|
+
]);
|
|
191
|
+
let entries = [...parseLines(older), ...parseLines(current)];
|
|
192
|
+
if (query.device) {
|
|
193
|
+
const device = query.device;
|
|
194
|
+
entries = entries.filter((e) => matchesDevice(e, device));
|
|
195
|
+
}
|
|
196
|
+
return entries.slice(-limit);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private async append(line: string): Promise<void> {
|
|
200
|
+
if (this.size === null) this.size = await this.open();
|
|
201
|
+
const bytes = Buffer.byteLength(line);
|
|
202
|
+
if (this.size > 0 && this.size + bytes > this.maxBytes) {
|
|
203
|
+
await rename(this.file, `${this.file}.1`);
|
|
204
|
+
this.size = 0;
|
|
205
|
+
}
|
|
206
|
+
await appendFile(this.file, line, { mode: 0o600 });
|
|
207
|
+
this.size += bytes;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** First write this process: make the directory, tighten an old file. */
|
|
211
|
+
private async open(): Promise<number> {
|
|
212
|
+
await mkdir(dirname(this.file), { recursive: true, mode: 0o700 });
|
|
213
|
+
let size: number;
|
|
214
|
+
try {
|
|
215
|
+
size = (await stat(this.file)).size;
|
|
216
|
+
} catch {
|
|
217
|
+
return 0; // no file yet: appendFile creates it 0600
|
|
218
|
+
}
|
|
219
|
+
// A file from an older build or a copied home may be wider than 0600.
|
|
220
|
+
await chmod(this.file, 0o600).catch(() => {});
|
|
221
|
+
return size;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private warn(err: unknown): void {
|
|
225
|
+
if (this.failing) return;
|
|
226
|
+
this.failing = true;
|
|
227
|
+
logWarn(
|
|
228
|
+
"mesh",
|
|
229
|
+
`mesh.audit event=write_failed file=${this.file} err=${err instanceof Error ? err.message : String(err)}`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
}
|