agent-dir 0.4.1 → 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 +6 -5
- package/dist/src/setup.js +67 -6
- package/dist/src/telemetry/privacy.js +46 -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 +22 -0
- 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 +35 -18
- package/dist/src/types.d.ts +16 -2
- package/package.json +1 -1
|
@@ -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
|
@@ -14,8 +14,12 @@ function cleanOutput(value) {
|
|
|
14
14
|
function writeLine(line = "") {
|
|
15
15
|
process.stdout.write(`${line}\r\n`);
|
|
16
16
|
}
|
|
17
|
-
function publicUrl(
|
|
18
|
-
|
|
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.");
|
|
19
23
|
}
|
|
20
24
|
function reasonCategory(reason) {
|
|
21
25
|
if (/timeout/i.test(reason))
|
|
@@ -35,8 +39,8 @@ function failureFromLine(line) {
|
|
|
35
39
|
return registration?.[1] ? `Wormhole registration failed: ${registration[1].trim()}` : undefined;
|
|
36
40
|
}
|
|
37
41
|
export function startTunnel(options) {
|
|
38
|
-
if (options.provider
|
|
39
|
-
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."));
|
|
40
44
|
return new Promise((resolve, reject) => {
|
|
41
45
|
const telemetry = options.telemetry && options.telemetry.level !== "none"
|
|
42
46
|
? new TelemetryRecorder({
|
|
@@ -146,12 +150,13 @@ export function startTunnel(options) {
|
|
|
146
150
|
if (!url || stopped || reconnecting)
|
|
147
151
|
return;
|
|
148
152
|
try {
|
|
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.
|
|
149
155
|
const response = await fetch(new URL(HEALTH_PATH, url), {
|
|
150
156
|
method: "HEAD",
|
|
151
|
-
...(options.token ? { headers: { authorization: `Bearer ${options.token}` } } : {}),
|
|
152
157
|
signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
|
|
153
158
|
});
|
|
154
|
-
if (!response.ok)
|
|
159
|
+
if (!response.ok && response.status !== 401)
|
|
155
160
|
throw new Error(`Health check returned HTTP ${response.status}.`);
|
|
156
161
|
healthFailures = 0;
|
|
157
162
|
if (healthState === "degraded") {
|
|
@@ -167,7 +172,7 @@ export function startTunnel(options) {
|
|
|
167
172
|
}
|
|
168
173
|
if (healthFailures >= HEALTH_FAILURE_THRESHOLD) {
|
|
169
174
|
healthFailures = 0;
|
|
170
|
-
handleDisconnect("
|
|
175
|
+
handleDisconnect("Tunnel health check failed.");
|
|
171
176
|
}
|
|
172
177
|
}
|
|
173
178
|
}, HEALTH_INTERVAL_MS);
|
|
@@ -177,10 +182,16 @@ export function startTunnel(options) {
|
|
|
177
182
|
if (stopped)
|
|
178
183
|
return;
|
|
179
184
|
reconnecting = false;
|
|
180
|
-
const args = ["http", String(options.port)];
|
|
181
|
-
if (!options.random && options.subdomain)
|
|
185
|
+
const args = options.provider === "wormhole" ? ["http", String(options.port)] : ["tunnel", "run"];
|
|
186
|
+
if (options.provider === "wormhole" && !options.random && options.subdomain)
|
|
182
187
|
args.push("--subdomain", options.subdomain);
|
|
183
|
-
|
|
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
|
+
});
|
|
184
195
|
let buffer = "";
|
|
185
196
|
let connected = false;
|
|
186
197
|
const cleanupAttempt = () => {
|
|
@@ -220,7 +231,7 @@ export function startTunnel(options) {
|
|
|
220
231
|
if (!resolved) {
|
|
221
232
|
if (!child) {
|
|
222
233
|
stop();
|
|
223
|
-
reject(new Error(
|
|
234
|
+
reject(new Error(`${options.provider} connected without a child process.`));
|
|
224
235
|
return;
|
|
225
236
|
}
|
|
226
237
|
resolved = true;
|
|
@@ -245,10 +256,16 @@ export function startTunnel(options) {
|
|
|
245
256
|
return;
|
|
246
257
|
}
|
|
247
258
|
const explicit = line.match(/https:\/\/[^\s"'<>]+/)?.[0]?.replace(/[),.;]+$/, "");
|
|
248
|
-
if (explicit)
|
|
259
|
+
if (options.provider === "wormhole" && explicit)
|
|
249
260
|
connectedAt(explicit);
|
|
250
|
-
else if (
|
|
251
|
-
|
|
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));
|
|
252
269
|
};
|
|
253
270
|
const onData = (chunk) => {
|
|
254
271
|
buffer += cleanOutput(chunk);
|
|
@@ -257,12 +274,12 @@ export function startTunnel(options) {
|
|
|
257
274
|
for (const line of lines)
|
|
258
275
|
processLine(line);
|
|
259
276
|
};
|
|
260
|
-
const onError = (error) => disconnect(
|
|
277
|
+
const onError = (error) => disconnect(`${executable} error: ${error.message}`);
|
|
261
278
|
const onExit = (code, signal) => {
|
|
262
279
|
const detail = signal ? `signal ${signal}` : `code ${code}`;
|
|
263
280
|
disconnect(startup
|
|
264
|
-
?
|
|
265
|
-
:
|
|
281
|
+
? `${executable} exited before the tunnel was established (${detail}).`
|
|
282
|
+
: `${executable} process exited (${detail}).`);
|
|
266
283
|
};
|
|
267
284
|
child.stdout?.on("data", onData);
|
|
268
285
|
child.stderr?.on("data", onData);
|
|
@@ -270,7 +287,7 @@ export function startTunnel(options) {
|
|
|
270
287
|
child.once("exit", onExit);
|
|
271
288
|
timer = setTimeout(() => {
|
|
272
289
|
if (!connected)
|
|
273
|
-
disconnect(`Timed out waiting for
|
|
290
|
+
disconnect(`Timed out waiting for ${executable} to establish the tunnel after ${STARTUP_TIMEOUT_MS / 1000}s.`);
|
|
274
291
|
}, STARTUP_TIMEOUT_MS);
|
|
275
292
|
};
|
|
276
293
|
const handleDisconnect = (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;
|
package/package.json
CHANGED