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
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
const CONFIG_DIRECTORY = join(homedir(), ".config", "agent-dir");
|
|
5
|
+
export const TELEMETRY_DIRECTORY = join(CONFIG_DIRECTORY, "telemetry");
|
|
6
|
+
export const LEGACY_TELEMETRY_FILE = join(CONFIG_DIRECTORY, "telemetry.jsonl");
|
|
7
|
+
const LEGACY_ARCHIVE_FILE = join(CONFIG_DIRECTORY, "telemetry.legacy-archive.jsonl");
|
|
8
|
+
function safeSegment(value) {
|
|
9
|
+
const safe = value
|
|
10
|
+
.replace(/[^A-Za-z0-9._-]/g, "_")
|
|
11
|
+
.replace(/^\.+$/, "_")
|
|
12
|
+
.slice(0, 100);
|
|
13
|
+
return safe || "unknown";
|
|
14
|
+
}
|
|
15
|
+
export function telemetrySessionPath(configId, sessionId) {
|
|
16
|
+
return join(TELEMETRY_DIRECTORY, safeSegment(configId), `session-${safeSegment(sessionId)}.jsonl`);
|
|
17
|
+
}
|
|
18
|
+
function parseEvents(content) {
|
|
19
|
+
const events = [];
|
|
20
|
+
for (const line of content.split("\n")) {
|
|
21
|
+
if (!line.trim())
|
|
22
|
+
continue;
|
|
23
|
+
try {
|
|
24
|
+
const value = JSON.parse(line);
|
|
25
|
+
if (value &&
|
|
26
|
+
value.schemaVersion === 1 &&
|
|
27
|
+
typeof value.configId === "string" &&
|
|
28
|
+
typeof value.sessionId === "string" &&
|
|
29
|
+
typeof value.timestamp === "string" &&
|
|
30
|
+
value.event) {
|
|
31
|
+
events.push(value);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// Ignore malformed lines without discarding other valid events.
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return events;
|
|
39
|
+
}
|
|
40
|
+
function readFileEvents(path) {
|
|
41
|
+
try {
|
|
42
|
+
return parseEvents(readFileSync(path, "utf8"));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function collectJsonlFiles(directory) {
|
|
49
|
+
if (!existsSync(directory))
|
|
50
|
+
return [];
|
|
51
|
+
const files = [];
|
|
52
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
53
|
+
const path = join(directory, entry.name);
|
|
54
|
+
if (entry.isDirectory())
|
|
55
|
+
files.push(...collectJsonlFiles(path));
|
|
56
|
+
else if (entry.isFile() && entry.name.endsWith(".jsonl"))
|
|
57
|
+
files.push(path);
|
|
58
|
+
}
|
|
59
|
+
return files;
|
|
60
|
+
}
|
|
61
|
+
/** Migrate the old shared file once, preserving an archive and avoiding duplicate lines. */
|
|
62
|
+
export function migrateLegacyTelemetry() {
|
|
63
|
+
if (!existsSync(LEGACY_TELEMETRY_FILE))
|
|
64
|
+
return;
|
|
65
|
+
const legacyEvents = readFileEvents(LEGACY_TELEMETRY_FILE);
|
|
66
|
+
mkdirSync(TELEMETRY_DIRECTORY, { recursive: true, mode: 0o700 });
|
|
67
|
+
const groups = new Map();
|
|
68
|
+
for (const event of legacyEvents) {
|
|
69
|
+
const path = telemetrySessionPath(event.configId, event.sessionId);
|
|
70
|
+
const group = groups.get(path) ?? [];
|
|
71
|
+
group.push(event);
|
|
72
|
+
groups.set(path, group);
|
|
73
|
+
}
|
|
74
|
+
for (const [path, events] of groups) {
|
|
75
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
76
|
+
let existing = new Set();
|
|
77
|
+
try {
|
|
78
|
+
existing = new Set(readFileSync(path, "utf8").split("\n").filter(Boolean));
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// New session file.
|
|
82
|
+
}
|
|
83
|
+
const missing = events
|
|
84
|
+
.map((event) => JSON.stringify(event))
|
|
85
|
+
.filter((line) => {
|
|
86
|
+
if (existing.has(line))
|
|
87
|
+
return false;
|
|
88
|
+
existing.add(line);
|
|
89
|
+
return true;
|
|
90
|
+
});
|
|
91
|
+
if (missing.length) {
|
|
92
|
+
appendFileSync(path, `${missing.join("\n")}\n`, { encoding: "utf8", mode: 0o600 });
|
|
93
|
+
chmodSync(path, 0o600);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
let archive = LEGACY_ARCHIVE_FILE;
|
|
97
|
+
if (existsSync(archive))
|
|
98
|
+
archive = `${LEGACY_ARCHIVE_FILE}.${Date.now()}`;
|
|
99
|
+
renameSync(LEGACY_TELEMETRY_FILE, archive);
|
|
100
|
+
}
|
|
101
|
+
export function readAllTelemetryEvents() {
|
|
102
|
+
migrateLegacyTelemetry();
|
|
103
|
+
return collectJsonlFiles(TELEMETRY_DIRECTORY)
|
|
104
|
+
.flatMap(readFileEvents)
|
|
105
|
+
.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
106
|
+
}
|
|
107
|
+
export function resetTelemetry(profile, all = false) {
|
|
108
|
+
if (all) {
|
|
109
|
+
rmSync(TELEMETRY_DIRECTORY, { recursive: true, force: true });
|
|
110
|
+
rmSync(LEGACY_TELEMETRY_FILE, { force: true });
|
|
111
|
+
rmSync(LEGACY_ARCHIVE_FILE, { force: true });
|
|
112
|
+
for (const entry of existsSync(CONFIG_DIRECTORY)
|
|
113
|
+
? readdirSync(CONFIG_DIRECTORY, { withFileTypes: true })
|
|
114
|
+
: []) {
|
|
115
|
+
if (entry.isFile() && entry.name.startsWith("telemetry.legacy-archive.jsonl.")) {
|
|
116
|
+
rmSync(join(CONFIG_DIRECTORY, entry.name), { force: true });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (!profile)
|
|
122
|
+
throw new Error("Specify a profile name or use --all.");
|
|
123
|
+
rmSync(join(TELEMETRY_DIRECTORY, safeSegment(profile)), { recursive: true, force: true });
|
|
124
|
+
}
|
|
125
|
+
export function ensureTelemetryDirectory() {
|
|
126
|
+
mkdirSync(TELEMETRY_DIRECTORY, { recursive: true, mode: 0o700 });
|
|
127
|
+
try {
|
|
128
|
+
chmodSync(TELEMETRY_DIRECTORY, 0o700);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// Best-effort permission hardening.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -22,9 +22,27 @@ export interface TelemetryMcpRequestEvent {
|
|
|
22
22
|
durationMs: number;
|
|
23
23
|
errorCategory?: string;
|
|
24
24
|
}
|
|
25
|
+
export interface TelemetryFileMetric {
|
|
26
|
+
pathHash: string;
|
|
27
|
+
contentHash?: string;
|
|
28
|
+
bytes: number;
|
|
29
|
+
}
|
|
25
30
|
export interface TelemetryToolEvent {
|
|
26
31
|
event: "tool_call";
|
|
27
32
|
tool: string;
|
|
33
|
+
requestIdHash?: string;
|
|
34
|
+
inputFingerprint?: string;
|
|
35
|
+
outputFingerprint?: string;
|
|
36
|
+
/** Full local-only troubleshooting payload, emitted only at diagnostic level. */
|
|
37
|
+
diagnosticDetails?: Record<string, unknown>;
|
|
38
|
+
estimatedInputTokens?: number;
|
|
39
|
+
estimatedOutputTokens?: number;
|
|
40
|
+
cumulativeOutputBytes?: number;
|
|
41
|
+
cumulativeEstimatedOutputTokens?: number;
|
|
42
|
+
duplicateOutputBytes?: number;
|
|
43
|
+
textOutputBytes?: number;
|
|
44
|
+
uniqueFileCount?: number;
|
|
45
|
+
files?: TelemetryFileMetric[];
|
|
28
46
|
success: boolean;
|
|
29
47
|
durationMs: number;
|
|
30
48
|
inputBytes?: number;
|
|
@@ -46,7 +64,8 @@ export interface TelemetryCommandEvent {
|
|
|
46
64
|
}
|
|
47
65
|
export interface TelemetryTunnelEvent {
|
|
48
66
|
event: "tunnel";
|
|
49
|
-
state: "online" | "disconnected" | "reconnecting" | "reconnected" | "failed";
|
|
67
|
+
state: "online" | "disconnected" | "reconnecting" | "reconnected" | "failed" | "health";
|
|
68
|
+
healthState?: "healthy" | "degraded" | "recovered";
|
|
50
69
|
attempt?: number;
|
|
51
70
|
attempts?: number;
|
|
52
71
|
durationMs?: number;
|
|
@@ -70,6 +89,10 @@ export interface TelemetryRuntimeContext {
|
|
|
70
89
|
mcpClientName?: string;
|
|
71
90
|
mcpClientVersion?: string;
|
|
72
91
|
nodeVersion?: string;
|
|
92
|
+
distroId?: string;
|
|
93
|
+
distroName?: string;
|
|
94
|
+
distroVersion?: string;
|
|
95
|
+
kernelVersion?: string;
|
|
73
96
|
hostname?: string;
|
|
74
97
|
username?: string;
|
|
75
98
|
processId?: number;
|
package/dist/src/tools/code.js
CHANGED
|
@@ -142,7 +142,7 @@ export function findReferences(root, symbol, maxResults = 200, maxBytes = DEFAUL
|
|
|
142
142
|
return searchCode(root, `\\b${escaped}\\b`, true, maxResults, maxBytes);
|
|
143
143
|
}
|
|
144
144
|
export async function findImports(root, maxResults = 200, maxBytes = DEFAULT_OUTPUT_BYTES) {
|
|
145
|
-
const matches = await readMatches(root, String.raw `(?:^|[;{}])
|
|
145
|
+
const matches = await readMatches(root, String.raw `(?:^|[;{}])\s*import\s*(?:[^"'\n]*?from\s*)?["']([^"']+)["']|require\(\s*["']([^"']+)["']\s*\)`, { codeOnly: true, regex: true, maxResults });
|
|
146
146
|
return boundMatches(matches.map((match) => {
|
|
147
147
|
const captured = match.text.match(/(?:from\s*)?["']([^"']+)["']|require\(\s*["']([^"']+)["']\s*\)/);
|
|
148
148
|
return {
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
import { type ChildProcess } from "node:child_process";
|
|
2
|
+
export declare function appendBoundedOutput(current: string, chunk: Buffer | string): string;
|
|
3
|
+
export declare function commandEnvironment(): NodeJS.ProcessEnv;
|
|
4
|
+
export declare function terminateProcessTree(child: ChildProcess): void;
|
|
5
|
+
export declare function processTreeSpawnOptions(): {
|
|
6
|
+
detached: boolean;
|
|
7
|
+
};
|
|
1
8
|
export interface CommandResult {
|
|
2
9
|
command: string;
|
|
3
10
|
exitCode: number;
|
|
@@ -1,5 +1,58 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
|
|
3
|
+
const MAX_CAPTURE_BYTES = 512_000;
|
|
4
|
+
const TRUNCATION_MARKER = "\n…[output truncated at 512 KB]";
|
|
5
|
+
export function appendBoundedOutput(current, chunk) {
|
|
6
|
+
if (current.endsWith(TRUNCATION_MARKER) ||
|
|
7
|
+
Buffer.byteLength(current, "utf8") >= MAX_CAPTURE_BYTES)
|
|
8
|
+
return current;
|
|
9
|
+
const incoming = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
10
|
+
const available = MAX_CAPTURE_BYTES - Buffer.byteLength(current, "utf8");
|
|
11
|
+
if (incoming.byteLength <= available)
|
|
12
|
+
return current + incoming.toString("utf8");
|
|
13
|
+
const markerBytes = Buffer.byteLength(TRUNCATION_MARKER, "utf8");
|
|
14
|
+
const payloadBytes = Math.max(0, available - markerBytes);
|
|
15
|
+
let payload = incoming.subarray(0, payloadBytes).toString("utf8");
|
|
16
|
+
while (Buffer.byteLength(payload, "utf8") > payloadBytes)
|
|
17
|
+
payload = payload.slice(0, -1);
|
|
18
|
+
return current + payload + TRUNCATION_MARKER;
|
|
19
|
+
}
|
|
20
|
+
export function commandEnvironment() {
|
|
21
|
+
return Object.fromEntries(Object.entries(process.env).filter(([key]) => !/(?:TOKEN|SECRET|PASSWORD|PASSWD|API[_-]?KEY|PRIVATE[_-]?KEY|CREDENTIAL|AUTHORIZATION|ACCESS[_-]?KEY)/i.test(key)));
|
|
22
|
+
}
|
|
23
|
+
const FORCE_KILL_DELAY_MS = 1_000;
|
|
24
|
+
export function terminateProcessTree(child) {
|
|
25
|
+
const pid = child.pid;
|
|
26
|
+
if (!pid || child.killed || child.exitCode !== null)
|
|
27
|
+
return;
|
|
28
|
+
if (process.platform === "win32") {
|
|
29
|
+
const killer = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
|
|
30
|
+
stdio: "ignore",
|
|
31
|
+
windowsHide: true,
|
|
32
|
+
});
|
|
33
|
+
killer.on("error", () => child.kill("SIGKILL"));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
process.kill(-pid, "SIGTERM");
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
child.kill("SIGTERM");
|
|
41
|
+
}
|
|
42
|
+
const forceTimer = setTimeout(() => {
|
|
43
|
+
try {
|
|
44
|
+
process.kill(-pid, "SIGKILL");
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
child.kill("SIGKILL");
|
|
48
|
+
}
|
|
49
|
+
}, FORCE_KILL_DELAY_MS);
|
|
50
|
+
forceTimer.unref();
|
|
51
|
+
child.once("close", () => clearTimeout(forceTimer));
|
|
52
|
+
}
|
|
53
|
+
export function processTreeSpawnOptions() {
|
|
54
|
+
return { detached: process.platform !== "win32" };
|
|
55
|
+
}
|
|
3
56
|
export function isCommandBlacklisted(command, args = [], blacklistedCommands = []) {
|
|
4
57
|
const invocation = [command, ...args];
|
|
5
58
|
return blacklistedCommands.some((entry) => {
|
|
@@ -17,7 +70,12 @@ export function runCommand(root, command, args = [], allowedCommands = [], black
|
|
|
17
70
|
if (!Array.isArray(args) || args.some((arg) => typeof arg !== "string"))
|
|
18
71
|
throw new Error("Command arguments must be strings.");
|
|
19
72
|
return new Promise((resolve, reject) => {
|
|
20
|
-
const child = spawn(command, args, {
|
|
73
|
+
const child = spawn(command, args, {
|
|
74
|
+
cwd: root,
|
|
75
|
+
shell: false,
|
|
76
|
+
env: commandEnvironment(),
|
|
77
|
+
...processTreeSpawnOptions(),
|
|
78
|
+
});
|
|
21
79
|
let stdout = "";
|
|
22
80
|
let stderr = "";
|
|
23
81
|
let finished = false;
|
|
@@ -27,14 +85,14 @@ export function runCommand(root, command, args = [], allowedCommands = [], black
|
|
|
27
85
|
return;
|
|
28
86
|
finished = true;
|
|
29
87
|
clearTimeout(timer);
|
|
30
|
-
child
|
|
88
|
+
terminateProcessTree(child);
|
|
31
89
|
reject(new Error(`Command '${command}' was aborted.`));
|
|
32
90
|
};
|
|
33
91
|
timer = setTimeout(() => {
|
|
34
92
|
if (finished)
|
|
35
93
|
return;
|
|
36
94
|
finished = true;
|
|
37
|
-
child
|
|
95
|
+
terminateProcessTree(child);
|
|
38
96
|
signal?.removeEventListener("abort", abort);
|
|
39
97
|
reject(new Error(`Command '${command}' timed out after ${timeoutMs}ms.`));
|
|
40
98
|
}, timeoutMs);
|
|
@@ -44,10 +102,10 @@ export function runCommand(root, command, args = [], allowedCommands = [], black
|
|
|
44
102
|
}
|
|
45
103
|
signal?.addEventListener("abort", abort, { once: true });
|
|
46
104
|
child.stdout.on("data", (chunk) => {
|
|
47
|
-
stdout
|
|
105
|
+
stdout = appendBoundedOutput(stdout, chunk);
|
|
48
106
|
});
|
|
49
107
|
child.stderr.on("data", (chunk) => {
|
|
50
|
-
stderr
|
|
108
|
+
stderr = appendBoundedOutput(stderr, chunk);
|
|
51
109
|
});
|
|
52
110
|
child.on("error", (error) => {
|
|
53
111
|
if (finished)
|
package/dist/src/tools/git.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { appendBoundedOutput, commandEnvironment, processTreeSpawnOptions, terminateProcessTree, } from "./commands.js";
|
|
2
3
|
function validatePaths(paths) {
|
|
3
4
|
if (!Array.isArray(paths) ||
|
|
4
5
|
paths.length === 0 ||
|
|
@@ -8,7 +9,12 @@ function validatePaths(paths) {
|
|
|
8
9
|
}
|
|
9
10
|
function runGit(root, args, timeoutMs = 30000) {
|
|
10
11
|
return new Promise((resolve, reject) => {
|
|
11
|
-
const child = spawn("git", args, {
|
|
12
|
+
const child = spawn("git", args, {
|
|
13
|
+
cwd: root,
|
|
14
|
+
shell: false,
|
|
15
|
+
env: commandEnvironment(),
|
|
16
|
+
...processTreeSpawnOptions(),
|
|
17
|
+
});
|
|
12
18
|
let stdout = "";
|
|
13
19
|
let stderr = "";
|
|
14
20
|
let finished = false;
|
|
@@ -16,14 +22,14 @@ function runGit(root, args, timeoutMs = 30000) {
|
|
|
16
22
|
if (finished)
|
|
17
23
|
return;
|
|
18
24
|
finished = true;
|
|
19
|
-
child
|
|
25
|
+
terminateProcessTree(child);
|
|
20
26
|
reject(new Error(`git ${args.join(" ")} timed out after ${timeoutMs}ms.`));
|
|
21
27
|
}, timeoutMs);
|
|
22
28
|
child.stdout.on("data", (chunk) => {
|
|
23
|
-
stdout
|
|
29
|
+
stdout = appendBoundedOutput(stdout, chunk);
|
|
24
30
|
});
|
|
25
31
|
child.stderr.on("data", (chunk) => {
|
|
26
|
-
stderr
|
|
32
|
+
stderr = appendBoundedOutput(stderr, chunk);
|
|
27
33
|
});
|
|
28
34
|
child.on("error", (error) => {
|
|
29
35
|
if (finished)
|
package/dist/src/tools/npm.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { promises as fs } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { appendBoundedOutput, commandEnvironment, processTreeSpawnOptions, terminateProcessTree, } from "./commands.js";
|
|
4
5
|
export function runNpm(root, script, timeoutMs = 120000, allowedScripts = [], signal) {
|
|
5
6
|
if (!/^[A-Za-z0-9:_-]+$/.test(script))
|
|
6
7
|
throw new Error("Invalid npm script name.");
|
|
@@ -21,7 +22,8 @@ export function runNpm(root, script, timeoutMs = 120000, allowedScripts = [], si
|
|
|
21
22
|
const child = spawn(executable, ["run", script], {
|
|
22
23
|
cwd: root,
|
|
23
24
|
shell: false,
|
|
24
|
-
env:
|
|
25
|
+
env: commandEnvironment(),
|
|
26
|
+
...processTreeSpawnOptions(),
|
|
25
27
|
});
|
|
26
28
|
let stdout = "";
|
|
27
29
|
let stderr = "";
|
|
@@ -32,14 +34,14 @@ export function runNpm(root, script, timeoutMs = 120000, allowedScripts = [], si
|
|
|
32
34
|
return;
|
|
33
35
|
finished = true;
|
|
34
36
|
clearTimeout(timer);
|
|
35
|
-
child
|
|
37
|
+
terminateProcessTree(child);
|
|
36
38
|
reject(new Error(`npm run ${script} was aborted.`));
|
|
37
39
|
};
|
|
38
40
|
timer = setTimeout(() => {
|
|
39
41
|
if (finished)
|
|
40
42
|
return;
|
|
41
43
|
finished = true;
|
|
42
|
-
child
|
|
44
|
+
terminateProcessTree(child);
|
|
43
45
|
signal?.removeEventListener("abort", abort);
|
|
44
46
|
reject(new Error(`npm run ${script} timed out after ${timeoutMs}ms.`));
|
|
45
47
|
}, timeoutMs);
|
|
@@ -49,10 +51,10 @@ export function runNpm(root, script, timeoutMs = 120000, allowedScripts = [], si
|
|
|
49
51
|
}
|
|
50
52
|
signal?.addEventListener("abort", abort, { once: true });
|
|
51
53
|
child.stdout.on("data", (chunk) => {
|
|
52
|
-
stdout
|
|
54
|
+
stdout = appendBoundedOutput(stdout, chunk);
|
|
53
55
|
});
|
|
54
56
|
child.stderr.on("data", (chunk) => {
|
|
55
|
-
stderr
|
|
57
|
+
stderr = appendBoundedOutput(stderr, chunk);
|
|
56
58
|
});
|
|
57
59
|
child.on("error", (error) => {
|
|
58
60
|
if (!finished) {
|
package/dist/src/tunnel.js
CHANGED
|
@@ -6,6 +6,7 @@ const HEALTH_TIMEOUT_MS = 5_000;
|
|
|
6
6
|
const HEALTH_FAILURE_THRESHOLD = 2;
|
|
7
7
|
const RECONNECT_DELAY_MS = 1_000;
|
|
8
8
|
const MAX_RECONNECT_ATTEMPTS = 5;
|
|
9
|
+
const HEALTH_PATH = "/__health";
|
|
9
10
|
const ANSI_ESCAPE = new RegExp(String.fromCharCode(0x1b) + String.raw `\[[0-?]*[ -/]*[@-~]`, "g");
|
|
10
11
|
function cleanOutput(value) {
|
|
11
12
|
return value.toString().replace(ANSI_ESCAPE, "");
|
|
@@ -13,8 +14,12 @@ function cleanOutput(value) {
|
|
|
13
14
|
function writeLine(line = "") {
|
|
14
15
|
process.stdout.write(`${line}\r\n`);
|
|
15
16
|
}
|
|
16
|
-
function publicUrl(
|
|
17
|
-
|
|
17
|
+
function publicUrl(options) {
|
|
18
|
+
if (options.provider === "cloudflared" && options.hostname)
|
|
19
|
+
return `https://${options.hostname}`;
|
|
20
|
+
if (options.subdomain)
|
|
21
|
+
return `https://${options.subdomain}.wormhole.bar`;
|
|
22
|
+
throw new Error("Tunnel URL cannot be determined from the configured options.");
|
|
18
23
|
}
|
|
19
24
|
function reasonCategory(reason) {
|
|
20
25
|
if (/timeout/i.test(reason))
|
|
@@ -34,8 +39,8 @@ function failureFromLine(line) {
|
|
|
34
39
|
return registration?.[1] ? `Wormhole registration failed: ${registration[1].trim()}` : undefined;
|
|
35
40
|
}
|
|
36
41
|
export function startTunnel(options) {
|
|
37
|
-
if (options.provider
|
|
38
|
-
return Promise.reject(new Error(
|
|
42
|
+
if (options.provider === "cloudflared" && !options.hostname)
|
|
43
|
+
return Promise.reject(new Error("Cloudflared requires a hostname routed to a named Cloudflare Tunnel."));
|
|
39
44
|
return new Promise((resolve, reject) => {
|
|
40
45
|
const telemetry = options.telemetry && options.telemetry.level !== "none"
|
|
41
46
|
? new TelemetryRecorder({
|
|
@@ -55,6 +60,7 @@ export function startTunnel(options) {
|
|
|
55
60
|
let healthTimer;
|
|
56
61
|
let stopped = false;
|
|
57
62
|
let healthFailures = 0;
|
|
63
|
+
let healthState = "healthy";
|
|
58
64
|
const record = (event) => {
|
|
59
65
|
if (!telemetry)
|
|
60
66
|
return;
|
|
@@ -75,6 +81,8 @@ export function startTunnel(options) {
|
|
|
75
81
|
});
|
|
76
82
|
else if (event.type === "reconnected")
|
|
77
83
|
telemetry.record({ event: "tunnel", state: "reconnected", attempt: event.attempt });
|
|
84
|
+
else if (event.type === "health")
|
|
85
|
+
telemetry.record({ event: "tunnel", state: "health", healthState: event.state });
|
|
78
86
|
else
|
|
79
87
|
telemetry.record({
|
|
80
88
|
event: "tunnel",
|
|
@@ -106,9 +114,16 @@ export function startTunnel(options) {
|
|
|
106
114
|
writeLine(` MCP endpoint : ${event.url}/mcp`);
|
|
107
115
|
writeLine();
|
|
108
116
|
}
|
|
117
|
+
else if (event.type === "health") {
|
|
118
|
+
if (event.state === "degraded")
|
|
119
|
+
writeLine(" ⚠ TUNNEL HEALTH DEGRADED");
|
|
120
|
+
else if (event.state === "recovered")
|
|
121
|
+
writeLine(" ✓ TUNNEL HEALTH RECOVERED");
|
|
122
|
+
}
|
|
109
123
|
else {
|
|
110
|
-
writeLine(`
|
|
111
|
-
writeLine(`
|
|
124
|
+
writeLine(` ⚠ TUNNEL RECOVERY CYCLE EXHAUSTED ${event.reason}`);
|
|
125
|
+
writeLine(` Attempts completed : ${event.attempts}`);
|
|
126
|
+
writeLine(" Starting a fresh reconnect cycle.");
|
|
112
127
|
}
|
|
113
128
|
};
|
|
114
129
|
const cleanupTimers = () => {
|
|
@@ -135,20 +150,29 @@ export function startTunnel(options) {
|
|
|
135
150
|
if (!url || stopped || reconnecting)
|
|
136
151
|
return;
|
|
137
152
|
try {
|
|
138
|
-
|
|
153
|
+
// A 401 is expected: it proves the public tunnel reaches the authenticated server.
|
|
154
|
+
// Never send the Agent Dir bearer token to the public hostname.
|
|
155
|
+
const response = await fetch(new URL(HEALTH_PATH, url), {
|
|
139
156
|
method: "HEAD",
|
|
140
|
-
...(options.token ? { headers: { authorization: `Bearer ${options.token}` } } : {}),
|
|
141
157
|
signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
|
|
142
158
|
});
|
|
159
|
+
if (!response.ok && response.status !== 401)
|
|
160
|
+
throw new Error(`Health check returned HTTP ${response.status}.`);
|
|
143
161
|
healthFailures = 0;
|
|
144
|
-
if (
|
|
145
|
-
|
|
162
|
+
if (healthState === "degraded") {
|
|
163
|
+
healthState = "healthy";
|
|
164
|
+
emit({ type: "health", state: "recovered" });
|
|
165
|
+
}
|
|
146
166
|
}
|
|
147
167
|
catch {
|
|
148
168
|
healthFailures += 1;
|
|
169
|
+
if (healthFailures === 1 && healthState === "healthy") {
|
|
170
|
+
healthState = "degraded";
|
|
171
|
+
emit({ type: "health", state: "degraded" });
|
|
172
|
+
}
|
|
149
173
|
if (healthFailures >= HEALTH_FAILURE_THRESHOLD) {
|
|
150
174
|
healthFailures = 0;
|
|
151
|
-
handleDisconnect("
|
|
175
|
+
handleDisconnect("Tunnel health check failed.");
|
|
152
176
|
}
|
|
153
177
|
}
|
|
154
178
|
}, HEALTH_INTERVAL_MS);
|
|
@@ -157,10 +181,17 @@ export function startTunnel(options) {
|
|
|
157
181
|
const spawnTunnel = (attempt) => {
|
|
158
182
|
if (stopped)
|
|
159
183
|
return;
|
|
160
|
-
|
|
161
|
-
|
|
184
|
+
reconnecting = false;
|
|
185
|
+
const args = options.provider === "wormhole" ? ["http", String(options.port)] : ["tunnel", "run"];
|
|
186
|
+
if (options.provider === "wormhole" && !options.random && options.subdomain)
|
|
162
187
|
args.push("--subdomain", options.subdomain);
|
|
163
|
-
|
|
188
|
+
const executable = options.provider === "wormhole" ? "wormhole" : "cloudflared";
|
|
189
|
+
child = spawn(executable, args, {
|
|
190
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
191
|
+
env: options.provider === "cloudflared" && options.cloudflaredToken
|
|
192
|
+
? { ...process.env, TUNNEL_TOKEN: options.cloudflaredToken }
|
|
193
|
+
: process.env,
|
|
194
|
+
});
|
|
164
195
|
let buffer = "";
|
|
165
196
|
let connected = false;
|
|
166
197
|
const cleanupAttempt = () => {
|
|
@@ -200,7 +231,7 @@ export function startTunnel(options) {
|
|
|
200
231
|
if (!resolved) {
|
|
201
232
|
if (!child) {
|
|
202
233
|
stop();
|
|
203
|
-
reject(new Error(
|
|
234
|
+
reject(new Error(`${options.provider} connected without a child process.`));
|
|
204
235
|
return;
|
|
205
236
|
}
|
|
206
237
|
resolved = true;
|
|
@@ -225,10 +256,16 @@ export function startTunnel(options) {
|
|
|
225
256
|
return;
|
|
226
257
|
}
|
|
227
258
|
const explicit = line.match(/https:\/\/[^\s"'<>]+/)?.[0]?.replace(/[),.;]+$/, "");
|
|
228
|
-
if (explicit)
|
|
259
|
+
if (options.provider === "wormhole" && explicit)
|
|
229
260
|
connectedAt(explicit);
|
|
230
|
-
else if (
|
|
231
|
-
|
|
261
|
+
else if (options.provider === "wormhole" &&
|
|
262
|
+
/Status\s+.*connected/i.test(line) &&
|
|
263
|
+
options.subdomain)
|
|
264
|
+
connectedAt(publicUrl(options));
|
|
265
|
+
else if (options.provider === "cloudflared" &&
|
|
266
|
+
/registered tunnel connection|connection .* registered/i.test(line) &&
|
|
267
|
+
options.hostname)
|
|
268
|
+
connectedAt(publicUrl(options));
|
|
232
269
|
};
|
|
233
270
|
const onData = (chunk) => {
|
|
234
271
|
buffer += cleanOutput(chunk);
|
|
@@ -237,12 +274,12 @@ export function startTunnel(options) {
|
|
|
237
274
|
for (const line of lines)
|
|
238
275
|
processLine(line);
|
|
239
276
|
};
|
|
240
|
-
const onError = (error) => disconnect(
|
|
277
|
+
const onError = (error) => disconnect(`${executable} error: ${error.message}`);
|
|
241
278
|
const onExit = (code, signal) => {
|
|
242
279
|
const detail = signal ? `signal ${signal}` : `code ${code}`;
|
|
243
280
|
disconnect(startup
|
|
244
|
-
?
|
|
245
|
-
:
|
|
281
|
+
? `${executable} exited before the tunnel was established (${detail}).`
|
|
282
|
+
: `${executable} process exited (${detail}).`);
|
|
246
283
|
};
|
|
247
284
|
child.stdout?.on("data", onData);
|
|
248
285
|
child.stderr?.on("data", onData);
|
|
@@ -250,17 +287,19 @@ export function startTunnel(options) {
|
|
|
250
287
|
child.once("exit", onExit);
|
|
251
288
|
timer = setTimeout(() => {
|
|
252
289
|
if (!connected)
|
|
253
|
-
disconnect(`Timed out waiting for
|
|
290
|
+
disconnect(`Timed out waiting for ${executable} to establish the tunnel after ${STARTUP_TIMEOUT_MS / 1000}s.`);
|
|
254
291
|
}, STARTUP_TIMEOUT_MS);
|
|
255
292
|
};
|
|
256
293
|
const handleDisconnect = (reason) => {
|
|
257
294
|
if (stopped || reconnecting)
|
|
258
295
|
return;
|
|
259
296
|
reconnecting = true;
|
|
297
|
+
if (child && !child.killed)
|
|
298
|
+
child.kill("SIGTERM");
|
|
260
299
|
emit({ type: "disconnected", reason });
|
|
261
300
|
if (attempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
262
301
|
emit({ type: "failed", reason, attempts });
|
|
263
|
-
|
|
302
|
+
attempts = 0;
|
|
264
303
|
}
|
|
265
304
|
attempts += 1;
|
|
266
305
|
emit({ type: "reconnecting", attempt: attempts, reason });
|
package/dist/src/types.d.ts
CHANGED
|
@@ -6,8 +6,20 @@ export interface NpmConfig {
|
|
|
6
6
|
export interface Profile {
|
|
7
7
|
directory: string;
|
|
8
8
|
port: number;
|
|
9
|
-
tunnel: "none" | "wormhole";
|
|
9
|
+
tunnel: "none" | "wormhole" | "cloudflared";
|
|
10
|
+
defaultTunnel?: "none" | "wormhole" | "cloudflared";
|
|
11
|
+
tunnels?: {
|
|
12
|
+
wormhole?: {
|
|
13
|
+
subdomain?: string;
|
|
14
|
+
random?: boolean;
|
|
15
|
+
};
|
|
16
|
+
cloudflared?: {
|
|
17
|
+
hostname: string;
|
|
18
|
+
token?: string;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
10
21
|
subdomain?: string;
|
|
22
|
+
hostname?: string;
|
|
11
23
|
token?: string;
|
|
12
24
|
npm?: NpmConfig;
|
|
13
25
|
commands?: string[];
|
|
@@ -40,9 +52,11 @@ export interface RequestLog {
|
|
|
40
52
|
tool?: string;
|
|
41
53
|
}
|
|
42
54
|
export interface TunnelOptions {
|
|
43
|
-
provider: "wormhole";
|
|
55
|
+
provider: "wormhole" | "cloudflared";
|
|
44
56
|
port: number;
|
|
57
|
+
cloudflaredToken?: string;
|
|
45
58
|
subdomain?: string;
|
|
59
|
+
hostname?: string;
|
|
46
60
|
random?: boolean;
|
|
47
61
|
token?: string;
|
|
48
62
|
telemetry?: TelemetryConfig;
|
|
@@ -72,4 +86,7 @@ export type TunnelEvent = {
|
|
|
72
86
|
type: "failed";
|
|
73
87
|
reason: string;
|
|
74
88
|
attempts: number;
|
|
89
|
+
} | {
|
|
90
|
+
type: "health";
|
|
91
|
+
state: "healthy" | "degraded" | "recovered";
|
|
75
92
|
};
|
package/package.json
CHANGED