agent-dir 0.4.0 → 0.5.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 +59 -12
- package/dist/bin/agent-dir.js +105 -44
- package/dist/src/config.js +31 -1
- package/dist/src/logging.d.ts +1 -0
- package/dist/src/logging.js +7 -1
- package/dist/src/mcp.js +115 -14
- package/dist/src/server.js +80 -12
- package/dist/src/setup.js +67 -6
- package/dist/src/telemetry/privacy.js +47 -0
- package/dist/src/telemetry/recorder.js +8 -17
- package/dist/src/telemetry/storage.d.ts +9 -0
- package/dist/src/telemetry/storage.js +133 -0
- package/dist/src/telemetry/types.d.ts +24 -1
- package/dist/src/tools/code.js +1 -1
- package/dist/src/tools/commands.d.ts +7 -0
- package/dist/src/tools/commands.js +63 -5
- package/dist/src/tools/git.js +10 -4
- package/dist/src/tools/npm.js +7 -5
- package/dist/src/tunnel.js +62 -23
- package/dist/src/types.d.ts +19 -2
- package/package.json +1 -1
package/dist/src/mcp.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
2
3
|
import { createRequire } from "node:module";
|
|
3
|
-
import { hostname, userInfo } from "node:os";
|
|
4
|
+
import { hostname, release, userInfo } from "node:os";
|
|
4
5
|
import { CodeGraphIntegration } from "./codegraph.js";
|
|
5
6
|
import { TelemetryRecorder } from "./telemetry/recorder.js";
|
|
6
7
|
import { findDefinition, findExports, findFiles, findImports, findReferences, findSymbol, searchCode, searchFiles, } from "./tools/code.js";
|
|
@@ -345,6 +346,9 @@ export function createMcpHandler(root, commandConfig = {}, telemetry = { level:
|
|
|
345
346
|
let sessionToolCallCount = 0;
|
|
346
347
|
let sessionCommandCallCount = 0;
|
|
347
348
|
let sessionRecorded = false;
|
|
349
|
+
let sessionOutputBytes = 0;
|
|
350
|
+
let sessionEstimatedOutputTokens = 0;
|
|
351
|
+
const seenOutputFingerprints = new Set();
|
|
348
352
|
const telemetryProjectPromise = telemetry.level === "detailed" || telemetry.level === "diagnostic"
|
|
349
353
|
? buildTelemetryProjectContext(root, codeGraph).catch(() => undefined)
|
|
350
354
|
: undefined;
|
|
@@ -570,16 +574,30 @@ export function createMcpHandler(root, commandConfig = {}, telemetry = { level:
|
|
|
570
574
|
const result = await callTool(toolName, argumentsValue, root, npmAllowed, allowedCommands, blacklistedCommands, gitEnabled, notify, request.signal, codeGraph);
|
|
571
575
|
const durationMs = performance.now() - startedAt;
|
|
572
576
|
const count = resultCount(result);
|
|
577
|
+
const serializedResult = JSON.stringify(result);
|
|
578
|
+
const outputBytes = Buffer.byteLength(serializedResult, "utf8");
|
|
579
|
+
const estimatedOutputTokens = estimateTokens(serializedResult);
|
|
580
|
+
sessionOutputBytes += outputBytes;
|
|
581
|
+
sessionEstimatedOutputTokens += estimatedOutputTokens;
|
|
573
582
|
const runtime = telemetryRuntime(params);
|
|
574
583
|
telemetryRecorder.record({
|
|
575
584
|
event: "tool_call",
|
|
576
585
|
tool: toolName,
|
|
586
|
+
requestIdHash: telemetryHash(String(message.id)),
|
|
577
587
|
success: true,
|
|
578
588
|
durationMs,
|
|
579
589
|
inputBytes: Buffer.byteLength(JSON.stringify(argumentsValue), "utf8"),
|
|
580
|
-
outputBytes
|
|
590
|
+
outputBytes,
|
|
591
|
+
estimatedInputTokens: estimateTokens(JSON.stringify(argumentsValue)),
|
|
592
|
+
estimatedOutputTokens,
|
|
593
|
+
cumulativeOutputBytes: sessionOutputBytes,
|
|
594
|
+
cumulativeEstimatedOutputTokens: sessionEstimatedOutputTokens,
|
|
595
|
+
inputFingerprint: telemetryHash(JSON.stringify({ toolName, argumentsValue })),
|
|
596
|
+
...(telemetry.level === "diagnostic"
|
|
597
|
+
? { diagnosticDetails: diagnosticToolDetails(argumentsValue, result) }
|
|
598
|
+
: {}),
|
|
581
599
|
...(count === undefined ? {} : { resultCount: count }),
|
|
582
|
-
...toolTelemetryMetrics(toolName, result),
|
|
600
|
+
...toolTelemetryMetrics(toolName, result, seenOutputFingerprints),
|
|
583
601
|
}, runtime, telemetryProject);
|
|
584
602
|
const command = commandTelemetryDescriptor(toolName);
|
|
585
603
|
if (command) {
|
|
@@ -590,7 +608,7 @@ export function createMcpHandler(root, commandConfig = {}, telemetry = { level:
|
|
|
590
608
|
operation: command.operation,
|
|
591
609
|
success: true,
|
|
592
610
|
durationMs,
|
|
593
|
-
outputBytes
|
|
611
|
+
outputBytes,
|
|
594
612
|
}, runtime, telemetryProject);
|
|
595
613
|
}
|
|
596
614
|
mcpSuccess = true;
|
|
@@ -607,9 +625,21 @@ export function createMcpHandler(root, commandConfig = {}, telemetry = { level:
|
|
|
607
625
|
telemetryRecorder.record({
|
|
608
626
|
event: "tool_call",
|
|
609
627
|
tool: toolName,
|
|
628
|
+
requestIdHash: telemetryHash(String(message.id)),
|
|
610
629
|
success: false,
|
|
611
630
|
durationMs,
|
|
612
631
|
inputBytes: Buffer.byteLength(JSON.stringify(argumentsValue), "utf8"),
|
|
632
|
+
estimatedInputTokens: estimateTokens(JSON.stringify(argumentsValue)),
|
|
633
|
+
inputFingerprint: telemetryHash(JSON.stringify({ toolName, argumentsValue })),
|
|
634
|
+
...(telemetry.level === "diagnostic"
|
|
635
|
+
? {
|
|
636
|
+
diagnosticDetails: diagnosticToolDetails(argumentsValue, {
|
|
637
|
+
error: error instanceof Error
|
|
638
|
+
? { name: error.name, message: error.message, stack: error.stack }
|
|
639
|
+
: String(error),
|
|
640
|
+
}),
|
|
641
|
+
}
|
|
642
|
+
: {}),
|
|
613
643
|
errorCategory,
|
|
614
644
|
}, runtime, telemetryProject);
|
|
615
645
|
const command = commandTelemetryDescriptor(toolName);
|
|
@@ -1554,6 +1584,8 @@ function telemetryRuntime(params) {
|
|
|
1554
1584
|
...(typeof clientInfo?.name === "string" ? { mcpClientName: clientInfo.name } : {}),
|
|
1555
1585
|
...(typeof clientInfo?.version === "string" ? { mcpClientVersion: clientInfo.version } : {}),
|
|
1556
1586
|
nodeVersion: process.versions.node,
|
|
1587
|
+
...linuxDistribution(),
|
|
1588
|
+
kernelVersion: release(),
|
|
1557
1589
|
hostname: hostname(),
|
|
1558
1590
|
username: userInfo().username,
|
|
1559
1591
|
processId: process.pid,
|
|
@@ -1562,37 +1594,106 @@ function telemetryRuntime(params) {
|
|
|
1562
1594
|
memoryRssBytes: process.memoryUsage().rss,
|
|
1563
1595
|
};
|
|
1564
1596
|
}
|
|
1565
|
-
function
|
|
1597
|
+
function linuxDistribution() {
|
|
1598
|
+
if (process.platform !== "linux")
|
|
1599
|
+
return {};
|
|
1600
|
+
try {
|
|
1601
|
+
const values = Object.fromEntries(readFileSync("/etc/os-release", "utf8")
|
|
1602
|
+
.split("\n")
|
|
1603
|
+
.map((line) => line.match(/^([A-Z_]+)=(.*)$/))
|
|
1604
|
+
.filter((match) => match !== null)
|
|
1605
|
+
.map((match) => [match[1], match[2]?.replace(/^"|"$/g, "")]));
|
|
1606
|
+
return {
|
|
1607
|
+
...(values.ID ? { distroId: values.ID } : {}),
|
|
1608
|
+
...(values.PRETTY_NAME ? { distroName: values.PRETTY_NAME } : {}),
|
|
1609
|
+
...(values.VERSION_ID ? { distroVersion: values.VERSION_ID } : {}),
|
|
1610
|
+
};
|
|
1611
|
+
}
|
|
1612
|
+
catch {
|
|
1613
|
+
return {};
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
function diagnosticToolDetails(input, output) {
|
|
1617
|
+
const serialize = (value) => {
|
|
1618
|
+
try {
|
|
1619
|
+
const json = JSON.stringify(value);
|
|
1620
|
+
if (json === undefined)
|
|
1621
|
+
return null;
|
|
1622
|
+
if (Buffer.byteLength(json, "utf8") <= 64_000)
|
|
1623
|
+
return value;
|
|
1624
|
+
return {
|
|
1625
|
+
truncated: true,
|
|
1626
|
+
byteLength: Buffer.byteLength(json, "utf8"),
|
|
1627
|
+
preview: json.slice(0, 64_000),
|
|
1628
|
+
};
|
|
1629
|
+
}
|
|
1630
|
+
catch {
|
|
1631
|
+
return { serializationFailed: true };
|
|
1632
|
+
}
|
|
1633
|
+
};
|
|
1634
|
+
return { input: serialize(input), output: serialize(output) };
|
|
1635
|
+
}
|
|
1636
|
+
function telemetryHash(value) {
|
|
1637
|
+
return createHash("sha256").update(value).digest("hex");
|
|
1638
|
+
}
|
|
1639
|
+
// A rough byte-based proxy, not a model-tokenizer count or Claude billing measurement.
|
|
1640
|
+
function estimateTokens(value) {
|
|
1641
|
+
return Math.ceil(Buffer.byteLength(value, "utf8") / 4);
|
|
1642
|
+
}
|
|
1643
|
+
function toolTelemetryMetrics(toolName, result, seenOutputFingerprints) {
|
|
1566
1644
|
const structured = result.structuredContent;
|
|
1567
1645
|
if (!Array.isArray(structured) && !isRecord(structured))
|
|
1568
1646
|
return {};
|
|
1569
1647
|
const paths = new Set();
|
|
1648
|
+
const files = new Map();
|
|
1570
1649
|
let truncated = false;
|
|
1571
1650
|
let paginated = false;
|
|
1651
|
+
let textOutputBytes = 0;
|
|
1572
1652
|
const visit = (value) => {
|
|
1653
|
+
if (typeof value === "string") {
|
|
1654
|
+
textOutputBytes += Buffer.byteLength(value, "utf8");
|
|
1655
|
+
return;
|
|
1656
|
+
}
|
|
1657
|
+
if (Array.isArray(value)) {
|
|
1658
|
+
for (const child of value)
|
|
1659
|
+
visit(child);
|
|
1660
|
+
return;
|
|
1661
|
+
}
|
|
1573
1662
|
if (!isRecord(value))
|
|
1574
1663
|
return;
|
|
1575
|
-
if (typeof value.path === "string")
|
|
1664
|
+
if (typeof value.path === "string") {
|
|
1576
1665
|
paths.add(value.path);
|
|
1666
|
+
const content = typeof value.content === "string" ? value.content : undefined;
|
|
1667
|
+
files.set(value.path, {
|
|
1668
|
+
pathHash: telemetryHash(value.path),
|
|
1669
|
+
...(content === undefined ? {} : { contentHash: telemetryHash(content) }),
|
|
1670
|
+
bytes: content === undefined ? 0 : Buffer.byteLength(content, "utf8"),
|
|
1671
|
+
});
|
|
1672
|
+
}
|
|
1577
1673
|
if (value.truncated === true)
|
|
1578
1674
|
truncated = true;
|
|
1579
1675
|
if (value.nextCursor !== undefined)
|
|
1580
1676
|
paginated = true;
|
|
1581
|
-
for (const child of Object.values(value))
|
|
1582
|
-
|
|
1583
|
-
for (const item of child)
|
|
1584
|
-
visit(item);
|
|
1585
|
-
else if (isRecord(child))
|
|
1586
|
-
visit(child);
|
|
1587
|
-
}
|
|
1677
|
+
for (const child of Object.values(value))
|
|
1678
|
+
visit(child);
|
|
1588
1679
|
};
|
|
1589
1680
|
visit(structured);
|
|
1681
|
+
const outputJson = JSON.stringify(result);
|
|
1682
|
+
const outputFingerprint = telemetryHash(outputJson);
|
|
1683
|
+
const outputBytes = Buffer.byteLength(outputJson, "utf8");
|
|
1684
|
+
const duplicateOutputBytes = seenOutputFingerprints.has(outputFingerprint) ? outputBytes : 0;
|
|
1685
|
+
seenOutputFingerprints.add(outputFingerprint);
|
|
1590
1686
|
return {
|
|
1591
1687
|
...(paths.size > 0 && /^(write|patch|delete|read|file|git_changes|inspect)/.test(toolName)
|
|
1592
1688
|
? { affectedFiles: paths.size }
|
|
1593
1689
|
: {}),
|
|
1594
1690
|
...(truncated ? { truncated: true } : {}),
|
|
1595
1691
|
...(paginated ? { paginated: true } : {}),
|
|
1692
|
+
outputFingerprint,
|
|
1693
|
+
duplicateOutputBytes,
|
|
1694
|
+
textOutputBytes,
|
|
1695
|
+
uniqueFileCount: files.size,
|
|
1696
|
+
...(files.size ? { files: [...files.values()].slice(0, 100) } : {}),
|
|
1596
1697
|
};
|
|
1597
1698
|
}
|
|
1598
1699
|
function commandTelemetryDescriptor(toolName) {
|
package/dist/src/server.js
CHANGED
|
@@ -5,23 +5,40 @@ import { createMcpHandler } from "./mcp.js";
|
|
|
5
5
|
import { TelemetryRecorder } from "./telemetry/recorder.js";
|
|
6
6
|
import { deleteFile, listFiles, readFile, writeFile } from "./tools/files.js";
|
|
7
7
|
const MAX_BODY_BYTES = 10 * 1024 * 1024;
|
|
8
|
+
const REQUEST_BODY_TIMEOUT_MS = 30_000;
|
|
9
|
+
const REQUEST_TIMEOUT_MS = 120_000;
|
|
10
|
+
const HEADERS_TIMEOUT_MS = 30_000;
|
|
11
|
+
const KEEP_ALIVE_TIMEOUT_MS = 5_000;
|
|
12
|
+
const HEALTH_PATH = "/__health";
|
|
8
13
|
export function startServer({ root, port, token, commandConfig = {}, telemetry = { level: "none" }, }) {
|
|
9
14
|
const mcp = createMcpHandler(root, commandConfig, telemetry);
|
|
10
15
|
const httpTelemetry = new TelemetryRecorder({
|
|
11
16
|
level: telemetry.level,
|
|
12
17
|
persist: telemetry.persist ?? false,
|
|
13
18
|
...(telemetry.configId ? { configId: telemetry.configId } : {}),
|
|
14
|
-
sessionId: `http-${Date.now()}`,
|
|
19
|
+
sessionId: telemetry.sessionId ?? `http-${Date.now()}`,
|
|
15
20
|
});
|
|
16
21
|
const server = http.createServer(async (req, res) => {
|
|
17
22
|
const startedAt = performance.now();
|
|
23
|
+
const requestController = new AbortController();
|
|
24
|
+
req.once("aborted", () => requestController.abort());
|
|
25
|
+
res.once("close", () => {
|
|
26
|
+
if (!res.writableFinished)
|
|
27
|
+
requestController.abort();
|
|
28
|
+
});
|
|
18
29
|
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
19
30
|
let authenticated = false;
|
|
20
31
|
try {
|
|
21
|
-
|
|
32
|
+
if (url.pathname === HEALTH_PATH && req.method === "HEAD") {
|
|
33
|
+
res.setHeader("cache-control", "no-store");
|
|
34
|
+
sendText(res, 200, "ok\n");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const body = await readBody(req, requestController.signal);
|
|
22
38
|
const requestInit = {
|
|
23
39
|
method: req.method ?? "GET",
|
|
24
40
|
headers: toFetchHeaders(req.headers),
|
|
41
|
+
signal: requestController.signal,
|
|
25
42
|
};
|
|
26
43
|
if (body.length)
|
|
27
44
|
requestInit.body = body;
|
|
@@ -117,7 +134,12 @@ export function startServer({ root, port, token, commandConfig = {}, telemetry =
|
|
|
117
134
|
if (req.method === "DELETE") {
|
|
118
135
|
await deleteFile(root, relative);
|
|
119
136
|
sendText(res, 200, "File deleted successfully\n");
|
|
120
|
-
logRequest({
|
|
137
|
+
logRequest({
|
|
138
|
+
method: req.method,
|
|
139
|
+
path: url.pathname,
|
|
140
|
+
status: 200,
|
|
141
|
+
detail: "REST DELETE",
|
|
142
|
+
});
|
|
121
143
|
recordHttpTelemetry(httpTelemetry, req.method, url.pathname, 200, authenticated, startedAt);
|
|
122
144
|
return;
|
|
123
145
|
}
|
|
@@ -131,8 +153,16 @@ export function startServer({ root, port, token, commandConfig = {}, telemetry =
|
|
|
131
153
|
recordHttpTelemetry(httpTelemetry, req.method ?? "UNKNOWN", url.pathname, 404, authenticated, startedAt);
|
|
132
154
|
}
|
|
133
155
|
catch (error) {
|
|
156
|
+
if (requestController.signal.aborted || res.destroyed)
|
|
157
|
+
return;
|
|
134
158
|
const code = error.code;
|
|
135
|
-
const status = code === "ENOENT"
|
|
159
|
+
const status = code === "ENOENT"
|
|
160
|
+
? 404
|
|
161
|
+
: code === "PAYLOAD_TOO_LARGE"
|
|
162
|
+
? 413
|
|
163
|
+
: code === "REQUEST_TIMEOUT"
|
|
164
|
+
? 408
|
|
165
|
+
: 500;
|
|
136
166
|
sendText(res, status, `${error instanceof Error ? error.message : String(error)}\n`);
|
|
137
167
|
logRequest({
|
|
138
168
|
method: req.method ?? "UNKNOWN",
|
|
@@ -143,6 +173,9 @@ export function startServer({ root, port, token, commandConfig = {}, telemetry =
|
|
|
143
173
|
recordHttpTelemetry(httpTelemetry, req.method ?? "UNKNOWN", url.pathname, status, authenticated, startedAt);
|
|
144
174
|
}
|
|
145
175
|
});
|
|
176
|
+
server.requestTimeout = REQUEST_TIMEOUT_MS;
|
|
177
|
+
server.headersTimeout = HEADERS_TIMEOUT_MS;
|
|
178
|
+
server.keepAliveTimeout = KEEP_ALIVE_TIMEOUT_MS;
|
|
146
179
|
return new Promise((resolve, reject) => {
|
|
147
180
|
server.once("error", reject);
|
|
148
181
|
server.listen(port, "127.0.0.1", () => resolve({
|
|
@@ -531,12 +564,39 @@ function toFetchHeaders(headers) {
|
|
|
531
564
|
}
|
|
532
565
|
return result;
|
|
533
566
|
}
|
|
534
|
-
function readBody(req) {
|
|
567
|
+
function readBody(req, signal) {
|
|
535
568
|
return new Promise((resolve, reject) => {
|
|
536
569
|
const chunks = [];
|
|
537
570
|
let size = 0;
|
|
538
571
|
let tooLarge = false;
|
|
539
|
-
|
|
572
|
+
let settled = false;
|
|
573
|
+
const timer = setTimeout(() => {
|
|
574
|
+
const error = new Error(`Request body timed out after ${REQUEST_BODY_TIMEOUT_MS / 1000}s.`);
|
|
575
|
+
error.code = "REQUEST_TIMEOUT";
|
|
576
|
+
finish(error);
|
|
577
|
+
}, REQUEST_BODY_TIMEOUT_MS);
|
|
578
|
+
const cleanup = () => {
|
|
579
|
+
clearTimeout(timer);
|
|
580
|
+
req.removeListener("aborted", onAborted);
|
|
581
|
+
req.removeListener("error", onError);
|
|
582
|
+
req.removeListener("data", onData);
|
|
583
|
+
req.removeListener("end", onEnd);
|
|
584
|
+
signal.removeEventListener("abort", onSignalAbort);
|
|
585
|
+
};
|
|
586
|
+
const finish = (error, value) => {
|
|
587
|
+
if (settled)
|
|
588
|
+
return;
|
|
589
|
+
settled = true;
|
|
590
|
+
cleanup();
|
|
591
|
+
if (error)
|
|
592
|
+
reject(error);
|
|
593
|
+
else
|
|
594
|
+
resolve(value ?? Buffer.alloc(0));
|
|
595
|
+
};
|
|
596
|
+
const onAborted = () => finish(new Error("Request aborted by the client."));
|
|
597
|
+
const onError = (error) => finish(error);
|
|
598
|
+
const onSignalAbort = () => finish(new Error("Request aborted by the client."));
|
|
599
|
+
const onData = (chunk) => {
|
|
540
600
|
if (tooLarge)
|
|
541
601
|
return;
|
|
542
602
|
const buffer = Buffer.from(chunk);
|
|
@@ -546,17 +606,25 @@ function readBody(req) {
|
|
|
546
606
|
return;
|
|
547
607
|
}
|
|
548
608
|
chunks.push(buffer);
|
|
549
|
-
}
|
|
550
|
-
|
|
609
|
+
};
|
|
610
|
+
const onEnd = () => {
|
|
551
611
|
if (tooLarge) {
|
|
552
612
|
const error = new Error(`Request body exceeds the ${MAX_BODY_BYTES / 1024 / 1024} MiB limit.`);
|
|
553
613
|
error.code = "PAYLOAD_TOO_LARGE";
|
|
554
|
-
|
|
614
|
+
finish(error);
|
|
555
615
|
return;
|
|
556
616
|
}
|
|
557
|
-
|
|
558
|
-
}
|
|
559
|
-
|
|
617
|
+
finish(undefined, Buffer.concat(chunks));
|
|
618
|
+
};
|
|
619
|
+
if (signal.aborted) {
|
|
620
|
+
onSignalAbort();
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
req.once("aborted", onAborted);
|
|
624
|
+
req.once("error", onError);
|
|
625
|
+
signal.addEventListener("abort", onSignalAbort, { once: true });
|
|
626
|
+
req.on("data", onData);
|
|
627
|
+
req.on("end", onEnd);
|
|
560
628
|
});
|
|
561
629
|
}
|
|
562
630
|
function sendText(res, status, text, contentType = "text/plain; charset=utf-8") {
|
package/dist/src/setup.js
CHANGED
|
@@ -104,12 +104,26 @@ export async function runFirstTimeSetup(config, defaultDirectory = ".") {
|
|
|
104
104
|
const name = await requiredText(rl, "Profile name", "default");
|
|
105
105
|
const directory = resolve(await requiredText(rl, "Directory to expose", defaultDirectory));
|
|
106
106
|
const port = await requiredPort(rl, 3002);
|
|
107
|
-
|
|
107
|
+
let hostname;
|
|
108
|
+
let cloudflaredToken;
|
|
108
109
|
let subdomain;
|
|
109
|
-
|
|
110
|
+
console.log("");
|
|
111
|
+
console.log(`${section("◆")} ${section(bold("Cloudflare Tunnel"))}`);
|
|
112
|
+
console.log(rule());
|
|
113
|
+
const useCloudflared = await confirm(rl, "Set up Cloudflare Tunnel?", true);
|
|
114
|
+
if (useCloudflared) {
|
|
115
|
+
hostname = await requiredHostname(rl, "Public hostname (must be routed to this tunnel)");
|
|
116
|
+
cloudflaredToken = await requiredSecret(rl, "Cloudflare tunnel token");
|
|
117
|
+
}
|
|
118
|
+
console.log("");
|
|
119
|
+
console.log(`${section("◆")} ${section(bold("Wormhole"))}`);
|
|
120
|
+
console.log(rule());
|
|
121
|
+
const useWormhole = await confirm(rl, "Set up Wormhole too?", false);
|
|
122
|
+
if (useWormhole &&
|
|
110
123
|
(await confirm(rl, "Use a custom Wormhole subdomain? (No uses a random URL.)", false))) {
|
|
111
124
|
subdomain = await requiredText(rl, "Wormhole subdomain");
|
|
112
125
|
}
|
|
126
|
+
const tunnel = useCloudflared ? "cloudflared" : useWormhole ? "wormhole" : "none";
|
|
113
127
|
const npm = await optionalList(rl, "Allowed npm scripts (comma-separated, blank for none)");
|
|
114
128
|
const commands = await optionalList(rl, "Allowed commands (comma-separated, blank for none)");
|
|
115
129
|
console.log("");
|
|
@@ -119,9 +133,9 @@ export async function runFirstTimeSetup(config, defaultDirectory = ".") {
|
|
|
119
133
|
bold("Privacy first"),
|
|
120
134
|
dim("Telemetry is optional and stays local on this machine."),
|
|
121
135
|
dim("It helps us understand usage, failures, and reliability improvements."),
|
|
122
|
-
dim("Standard levels
|
|
123
|
-
dim("
|
|
124
|
-
dim("
|
|
136
|
+
dim("Standard levels avoid file contents, command arguments, tokens, and project paths."),
|
|
137
|
+
dim("Diagnostic mode records bounded raw tool inputs/outputs and identifying metadata."),
|
|
138
|
+
dim("It may contain source code or other sensitive project data; it stays local."),
|
|
125
139
|
]);
|
|
126
140
|
console.log("");
|
|
127
141
|
console.log(` ${green("●")} ${white("none")} ${dim("disabled")}`);
|
|
@@ -131,9 +145,25 @@ export async function runFirstTimeSetup(config, defaultDirectory = ".") {
|
|
|
131
145
|
console.log(` ${cyan("●")} ${white("diagnostic")} ${dim("detailed + local paths, host/user, PID, Node version, memory/uptime")}`);
|
|
132
146
|
console.log("");
|
|
133
147
|
const telemetry = await choose(rl, "Telemetry level", ["none", "anonymous", "basic", "detailed", "diagnostic"], "none");
|
|
134
|
-
const profile = {
|
|
148
|
+
const profile = {
|
|
149
|
+
directory,
|
|
150
|
+
port,
|
|
151
|
+
tunnel,
|
|
152
|
+
defaultTunnel: tunnel,
|
|
153
|
+
token: randomBytes(24).toString("hex"),
|
|
154
|
+
};
|
|
135
155
|
if (subdomain)
|
|
136
156
|
profile.subdomain = subdomain;
|
|
157
|
+
if (hostname)
|
|
158
|
+
profile.hostname = hostname;
|
|
159
|
+
if (useCloudflared || useWormhole) {
|
|
160
|
+
profile.tunnels = {
|
|
161
|
+
...(useWormhole ? { wormhole: { ...(subdomain ? { subdomain } : { random: true }) } } : {}),
|
|
162
|
+
...(useCloudflared && hostname
|
|
163
|
+
? { cloudflared: { hostname, ...(cloudflaredToken ? { token: cloudflaredToken } : {}) } }
|
|
164
|
+
: {}),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
137
167
|
if (npm.length)
|
|
138
168
|
profile.npm = { allowedScripts: npm };
|
|
139
169
|
if (commands.length)
|
|
@@ -168,6 +198,37 @@ async function requiredText(rl, label, defaultValue) {
|
|
|
168
198
|
console.log(` ${error("✕")} ${dim("A value is required.")}`);
|
|
169
199
|
}
|
|
170
200
|
}
|
|
201
|
+
async function requiredSecret(rl, label) {
|
|
202
|
+
while (true) {
|
|
203
|
+
output.write(` ${bold(label)}: `);
|
|
204
|
+
const writableRl = rl;
|
|
205
|
+
const writeToOutput = writableRl._writeToOutput;
|
|
206
|
+
writableRl._writeToOutput = () => { };
|
|
207
|
+
let value;
|
|
208
|
+
try {
|
|
209
|
+
value = (await rl.question("")).trim();
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
writableRl._writeToOutput = writeToOutput;
|
|
213
|
+
output.write("\n");
|
|
214
|
+
}
|
|
215
|
+
if (value)
|
|
216
|
+
return value;
|
|
217
|
+
console.log(` ${error("✕")} ${dim("A value is required.")}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
async function requiredHostname(rl, label) {
|
|
221
|
+
while (true) {
|
|
222
|
+
const value = (await rl.question(` ${bold(label)}: `)).trim().toLowerCase();
|
|
223
|
+
const labels = value.split(".");
|
|
224
|
+
const valid = value.length <= 253 &&
|
|
225
|
+
labels.length >= 2 &&
|
|
226
|
+
labels.every((part) => part.length >= 1 && part.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(part));
|
|
227
|
+
if (valid)
|
|
228
|
+
return value;
|
|
229
|
+
console.log(` ${error("✕")} ${dim("Enter a hostname only, such as agent-dir-mcp.scriptr.uk (no spaces, https://, or path).")}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
171
232
|
async function requiredPort(rl, defaultValue) {
|
|
172
233
|
while (true) {
|
|
173
234
|
const value = (await rl.question(` ${bold("Port")} ${dim(`[${defaultValue}]`)}: `)).trim() ||
|
|
@@ -40,6 +40,33 @@ export function sanitizeAnonymousEvent(event) {
|
|
|
40
40
|
return {
|
|
41
41
|
event: "tool_call",
|
|
42
42
|
tool: sanitizeIdentifier(event.tool, SAFE_TOOL_NAME),
|
|
43
|
+
...(event.requestIdHash === undefined
|
|
44
|
+
? {}
|
|
45
|
+
: { requestIdHash: sanitizeHash(event.requestIdHash) }),
|
|
46
|
+
...(event.inputFingerprint === undefined
|
|
47
|
+
? {}
|
|
48
|
+
: { inputFingerprint: sanitizeHash(event.inputFingerprint) }),
|
|
49
|
+
...(event.outputFingerprint === undefined
|
|
50
|
+
? {}
|
|
51
|
+
: { outputFingerprint: sanitizeHash(event.outputFingerprint) }),
|
|
52
|
+
...optionalNumber("estimatedInputTokens", event.estimatedInputTokens),
|
|
53
|
+
...optionalNumber("estimatedOutputTokens", event.estimatedOutputTokens),
|
|
54
|
+
...optionalNumber("cumulativeOutputBytes", event.cumulativeOutputBytes),
|
|
55
|
+
...optionalNumber("cumulativeEstimatedOutputTokens", event.cumulativeEstimatedOutputTokens),
|
|
56
|
+
...optionalNumber("duplicateOutputBytes", event.duplicateOutputBytes),
|
|
57
|
+
...optionalNumber("textOutputBytes", event.textOutputBytes),
|
|
58
|
+
...optionalNumber("uniqueFileCount", event.uniqueFileCount),
|
|
59
|
+
...(event.files === undefined
|
|
60
|
+
? {}
|
|
61
|
+
: {
|
|
62
|
+
files: event.files.slice(0, 100).map((file) => ({
|
|
63
|
+
pathHash: sanitizeHash(file.pathHash),
|
|
64
|
+
...(file.contentHash === undefined
|
|
65
|
+
? {}
|
|
66
|
+
: { contentHash: sanitizeHash(file.contentHash) }),
|
|
67
|
+
bytes: boundedNumber(file.bytes),
|
|
68
|
+
})),
|
|
69
|
+
}),
|
|
43
70
|
success: event.success === true,
|
|
44
71
|
durationMs: boundedNumber(event.durationMs),
|
|
45
72
|
...optionalNumber("inputBytes", event.inputBytes),
|
|
@@ -57,6 +84,7 @@ export function sanitizeAnonymousEvent(event) {
|
|
|
57
84
|
return {
|
|
58
85
|
event: "tunnel",
|
|
59
86
|
state: event.state,
|
|
87
|
+
...(event.healthState === undefined ? {} : { healthState: event.healthState }),
|
|
60
88
|
...(event.attempt === undefined ? {} : { attempt: boundedInteger(event.attempt) }),
|
|
61
89
|
...(event.attempts === undefined ? {} : { attempts: boundedInteger(event.attempts) }),
|
|
62
90
|
...(event.durationMs === undefined ? {} : { durationMs: boundedNumber(event.durationMs) }),
|
|
@@ -99,6 +127,12 @@ export function createTelemetryEnvelope(level, event, runtime, project, configId
|
|
|
99
127
|
level,
|
|
100
128
|
event: sanitizeAnonymousEvent(event),
|
|
101
129
|
};
|
|
130
|
+
if (level === "diagnostic" &&
|
|
131
|
+
event.event === "tool_call" &&
|
|
132
|
+
event.diagnosticDetails &&
|
|
133
|
+
envelope.event.event === "tool_call") {
|
|
134
|
+
envelope.event.diagnosticDetails = event.diagnosticDetails;
|
|
135
|
+
}
|
|
102
136
|
if (level === "basic" || level === "detailed" || level === "diagnostic") {
|
|
103
137
|
if (runtime)
|
|
104
138
|
envelope.runtime = sanitizeRuntimeContext(runtime);
|
|
@@ -120,6 +154,16 @@ function sanitizeDiagnosticRuntimeContext(context) {
|
|
|
120
154
|
...(context.nodeVersion === undefined
|
|
121
155
|
? {}
|
|
122
156
|
: { nodeVersion: sanitizeVersion(context.nodeVersion) }),
|
|
157
|
+
...(context.distroId === undefined
|
|
158
|
+
? {}
|
|
159
|
+
: { distroId: sanitizeSensitiveIdentifier(context.distroId) }),
|
|
160
|
+
...(context.distroName === undefined ? {} : { distroName: context.distroName.slice(0, 128) }),
|
|
161
|
+
...(context.distroVersion === undefined
|
|
162
|
+
? {}
|
|
163
|
+
: { distroVersion: context.distroVersion.slice(0, 64) }),
|
|
164
|
+
...(context.kernelVersion === undefined
|
|
165
|
+
? {}
|
|
166
|
+
: { kernelVersion: context.kernelVersion.slice(0, 128) }),
|
|
123
167
|
...(context.hostname === undefined
|
|
124
168
|
? {}
|
|
125
169
|
: { hostname: sanitizeSensitiveIdentifier(context.hostname) }),
|
|
@@ -201,6 +245,9 @@ function sanitizeVersion(value) {
|
|
|
201
245
|
? normalized
|
|
202
246
|
: "unknown";
|
|
203
247
|
}
|
|
248
|
+
function sanitizeHash(value) {
|
|
249
|
+
return /^[a-f0-9]{64}$/.test(value) ? value : "unknown";
|
|
250
|
+
}
|
|
204
251
|
function sanitizeErrorCategory(value) {
|
|
205
252
|
const normalized = value.trim().toLowerCase();
|
|
206
253
|
return SAFE_ERROR_CATEGORIES.has(normalized) ? normalized : "unknown";
|
|
@@ -1,10 +1,9 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { appendFileSync, chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import {
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
4
|
import { createTelemetryEnvelope } from "./privacy.js";
|
|
5
|
+
import { telemetrySessionPath } from "./storage.js";
|
|
5
6
|
const DEFAULT_MAX_EVENTS = 500;
|
|
6
|
-
const DEFAULT_PERSISTED_EVENTS = 500;
|
|
7
|
-
const TELEMETRY_FILE = join(homedir(), ".config", "agent-dir", "telemetry.jsonl");
|
|
8
7
|
export class TelemetryRecorder {
|
|
9
8
|
level;
|
|
10
9
|
maxEvents;
|
|
@@ -21,9 +20,9 @@ export class TelemetryRecorder {
|
|
|
21
20
|
this.runtime = options.runtime;
|
|
22
21
|
this.project = options.project;
|
|
23
22
|
this.persist = options.persist ?? false;
|
|
24
|
-
this.persistPath = options.persistPath ?? TELEMETRY_FILE;
|
|
25
23
|
this.configId = options.configId ?? "default";
|
|
26
|
-
this.sessionId = options.sessionId ??
|
|
24
|
+
this.sessionId = options.sessionId ?? randomUUID();
|
|
25
|
+
this.persistPath = options.persistPath ?? telemetrySessionPath(this.configId, this.sessionId);
|
|
27
26
|
if (this.persist && this.level !== "none")
|
|
28
27
|
this.loadPersisted();
|
|
29
28
|
}
|
|
@@ -64,8 +63,9 @@ export class TelemetryRecorder {
|
|
|
64
63
|
.filter((event) => event &&
|
|
65
64
|
event.schemaVersion === 1 &&
|
|
66
65
|
event.level === this.level &&
|
|
67
|
-
event.configId === this.configId
|
|
68
|
-
.
|
|
66
|
+
event.configId === this.configId &&
|
|
67
|
+
event.sessionId === this.sessionId)
|
|
68
|
+
.slice(-this.maxEvents);
|
|
69
69
|
this.events.push(...persisted);
|
|
70
70
|
}
|
|
71
71
|
catch {
|
|
@@ -80,15 +80,6 @@ export class TelemetryRecorder {
|
|
|
80
80
|
mode: 0o600,
|
|
81
81
|
});
|
|
82
82
|
chmodSync(this.persistPath, 0o600);
|
|
83
|
-
if (this.events.length === this.maxEvents) {
|
|
84
|
-
const lines = readFileSync(this.persistPath, "utf8").split("\n").filter(Boolean);
|
|
85
|
-
if (lines.length > this.maxEvents) {
|
|
86
|
-
writeFileSync(this.persistPath, `${lines.slice(-this.maxEvents).join("\n")}\n`, {
|
|
87
|
-
encoding: "utf8",
|
|
88
|
-
mode: 0o600,
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
83
|
}
|
|
93
84
|
catch {
|
|
94
85
|
// Telemetry persistence must never affect the MCP request path.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { TelemetryEnvelope } from "./types.js";
|
|
2
|
+
export declare const TELEMETRY_DIRECTORY: string;
|
|
3
|
+
export declare const LEGACY_TELEMETRY_FILE: string;
|
|
4
|
+
export declare function telemetrySessionPath(configId: string, sessionId: string): string;
|
|
5
|
+
/** Migrate the old shared file once, preserving an archive and avoiding duplicate lines. */
|
|
6
|
+
export declare function migrateLegacyTelemetry(): void;
|
|
7
|
+
export declare function readAllTelemetryEvents(): TelemetryEnvelope[];
|
|
8
|
+
export declare function resetTelemetry(profile?: string, all?: boolean): void;
|
|
9
|
+
export declare function ensureTelemetryDirectory(): void;
|