agent-dir 0.4.1 → 1.0.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 +40 -374
- package/SECURITY.md +39 -0
- package/assets/agent-dir-banner.svg +38 -0
- package/dist/bin/agent-dir.js +105 -51
- package/dist/src/auth.js +3 -1
- package/dist/src/config.js +31 -3
- package/dist/src/logging.d.ts +1 -1
- package/dist/src/logging.js +13 -70
- package/dist/src/mcp.js +304 -665
- package/dist/src/server.d.ts +1 -1
- package/dist/src/server.js +41 -146
- package/dist/src/setup.js +67 -6
- package/dist/src/telemetry/privacy.js +48 -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 -0
- package/dist/src/tools/code.d.ts +0 -12
- package/dist/src/tools/code.js +9 -29
- package/dist/src/tools/commands.d.ts +7 -0
- package/dist/src/tools/commands.js +63 -5
- package/dist/src/tools/diagnostics.js +3 -1
- package/dist/src/tools/files.d.ts +2 -21
- package/dist/src/tools/files.js +85 -70
- package/dist/src/tools/git.d.ts +0 -7
- package/dist/src/tools/git.js +10 -68
- package/dist/src/tools/metadata.d.ts +0 -1
- package/dist/src/tools/metadata.js +0 -22
- package/dist/src/tools/npm.js +7 -5
- package/dist/src/tools/skills.d.ts +1 -0
- package/dist/src/tools/skills.js +65 -0
- package/dist/src/tunnel.js +35 -18
- package/dist/src/types.d.ts +16 -4
- package/docs/CONFIGURATION.md +154 -0
- package/docs/MCP.md +106 -0
- package/docs/RELEASING.md +213 -0
- package/docs/telemetry.md +109 -0
- package/package.json +4 -1
package/dist/src/tools/git.js
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
|
|
3
|
-
if (!Array.isArray(paths) ||
|
|
4
|
-
paths.length === 0 ||
|
|
5
|
-
paths.some((path) => typeof path !== "string" || !path)) {
|
|
6
|
-
throw new Error("paths must contain at least one non-empty path.");
|
|
7
|
-
}
|
|
8
|
-
}
|
|
2
|
+
import { appendBoundedOutput, commandEnvironment, processTreeSpawnOptions, terminateProcessTree, } from "./commands.js";
|
|
9
3
|
function runGit(root, args, timeoutMs = 30000) {
|
|
10
4
|
return new Promise((resolve, reject) => {
|
|
11
|
-
const child = spawn("git", args, {
|
|
5
|
+
const child = spawn("git", args, {
|
|
6
|
+
cwd: root,
|
|
7
|
+
shell: false,
|
|
8
|
+
env: commandEnvironment(),
|
|
9
|
+
...processTreeSpawnOptions(),
|
|
10
|
+
});
|
|
12
11
|
let stdout = "";
|
|
13
12
|
let stderr = "";
|
|
14
13
|
let finished = false;
|
|
@@ -16,14 +15,14 @@ function runGit(root, args, timeoutMs = 30000) {
|
|
|
16
15
|
if (finished)
|
|
17
16
|
return;
|
|
18
17
|
finished = true;
|
|
19
|
-
child
|
|
18
|
+
terminateProcessTree(child);
|
|
20
19
|
reject(new Error(`git ${args.join(" ")} timed out after ${timeoutMs}ms.`));
|
|
21
20
|
}, timeoutMs);
|
|
22
21
|
child.stdout.on("data", (chunk) => {
|
|
23
|
-
stdout
|
|
22
|
+
stdout = appendBoundedOutput(stdout, chunk);
|
|
24
23
|
});
|
|
25
24
|
child.stderr.on("data", (chunk) => {
|
|
26
|
-
stderr
|
|
25
|
+
stderr = appendBoundedOutput(stderr, chunk);
|
|
27
26
|
});
|
|
28
27
|
child.on("error", (error) => {
|
|
29
28
|
if (finished)
|
|
@@ -41,63 +40,6 @@ function runGit(root, args, timeoutMs = 30000) {
|
|
|
41
40
|
});
|
|
42
41
|
});
|
|
43
42
|
}
|
|
44
|
-
export function gitStage(root, paths) {
|
|
45
|
-
validatePaths(paths);
|
|
46
|
-
return runGit(root, ["add", "--", ...paths]);
|
|
47
|
-
}
|
|
48
|
-
export function gitUnstage(root, paths) {
|
|
49
|
-
validatePaths(paths);
|
|
50
|
-
return runGit(root, ["restore", "--staged", "--", ...paths]);
|
|
51
|
-
}
|
|
52
|
-
export function gitCommit(root, message) {
|
|
53
|
-
if (!message.trim())
|
|
54
|
-
throw new Error("commit message must not be empty.");
|
|
55
|
-
return runGit(root, ["commit", "-m", message]);
|
|
56
|
-
}
|
|
57
|
-
export function gitPush(root, remote, branch) {
|
|
58
|
-
const args = ["push"];
|
|
59
|
-
if (remote) {
|
|
60
|
-
if (!/^[A-Za-z0-9._-]+$/.test(remote))
|
|
61
|
-
throw new Error("Invalid Git remote name.");
|
|
62
|
-
args.push(remote);
|
|
63
|
-
if (branch) {
|
|
64
|
-
if (!/^[A-Za-z0-9._/-]+$/.test(branch))
|
|
65
|
-
throw new Error("Invalid Git branch name.");
|
|
66
|
-
args.push(branch);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
else if (branch) {
|
|
70
|
-
throw new Error("A Git remote is required when specifying a branch.");
|
|
71
|
-
}
|
|
72
|
-
return runGit(root, args);
|
|
73
|
-
}
|
|
74
43
|
export function gitStatus(root) {
|
|
75
44
|
return runGit(root, ["status", "--short", "--branch"]);
|
|
76
45
|
}
|
|
77
|
-
export function gitDiff(root, staged = false, pathspec) {
|
|
78
|
-
const args = ["diff", "--no-ext-diff", "--no-color"];
|
|
79
|
-
if (staged)
|
|
80
|
-
args.push("--cached");
|
|
81
|
-
if (pathspec)
|
|
82
|
-
args.push("--", pathspec);
|
|
83
|
-
return runGit(root, args);
|
|
84
|
-
}
|
|
85
|
-
export function gitRestore(root, paths, staged = false) {
|
|
86
|
-
validatePaths(paths);
|
|
87
|
-
return runGit(root, ["restore", ...(staged ? ["--staged"] : []), "--", ...paths]);
|
|
88
|
-
}
|
|
89
|
-
export function gitLog(root, limit = 20, pathspec) {
|
|
90
|
-
if (!Number.isInteger(limit) || limit < 1 || limit > 200)
|
|
91
|
-
throw new Error("limit must be an integer between 1 and 200.");
|
|
92
|
-
const args = [
|
|
93
|
-
"log",
|
|
94
|
-
"--no-color",
|
|
95
|
-
"--date=iso-strict",
|
|
96
|
-
"--format=%H%x09%ad%x09%an%x09%s",
|
|
97
|
-
"-n",
|
|
98
|
-
String(limit),
|
|
99
|
-
];
|
|
100
|
-
if (pathspec)
|
|
101
|
-
args.push("--", pathspec);
|
|
102
|
-
return runGit(root, args);
|
|
103
|
-
}
|
|
@@ -15,5 +15,4 @@ export interface ProjectOverview {
|
|
|
15
15
|
};
|
|
16
16
|
}
|
|
17
17
|
export declare function fileInfo(root: string, relativePath: string): Promise<Record<string, unknown>>;
|
|
18
|
-
export declare function packageInfo(root: string): Promise<Record<string, unknown>>;
|
|
19
18
|
export declare function projectOverview(root: string): Promise<ProjectOverview>;
|
|
@@ -23,28 +23,6 @@ export async function fileInfo(root, relativePath) {
|
|
|
23
23
|
createdAt: stat.birthtime.toISOString(),
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
|
-
export async function packageInfo(root) {
|
|
27
|
-
const packagePath = path.join(await fs.realpath(root), "package.json");
|
|
28
|
-
try {
|
|
29
|
-
const packageJson = JSON.parse(await fs.readFile(packagePath, "utf8"));
|
|
30
|
-
return {
|
|
31
|
-
path: "package.json",
|
|
32
|
-
name: packageJson.name ?? null,
|
|
33
|
-
version: packageJson.version ?? null,
|
|
34
|
-
description: packageJson.description ?? null,
|
|
35
|
-
packageManager: packageJson.packageManager ?? null,
|
|
36
|
-
engines: packageJson.engines ?? null,
|
|
37
|
-
scripts: packageJson.scripts ?? {},
|
|
38
|
-
dependencies: packageJson.dependencies ?? {},
|
|
39
|
-
devDependencies: packageJson.devDependencies ?? {},
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
catch (error) {
|
|
43
|
-
if (error.code === "ENOENT")
|
|
44
|
-
return { path: "package.json", exists: false };
|
|
45
|
-
throw new Error("package.json exists but is not valid JSON.");
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
26
|
export async function projectOverview(root) {
|
|
49
27
|
const entries = await listFiles(root);
|
|
50
28
|
const files = entries.filter((entry) => entry.type === "file");
|
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/tools/skills.js
CHANGED
|
@@ -5,9 +5,15 @@ import { listFiles } from "./files.js";
|
|
|
5
5
|
const SKILL_ROOTS = ["skills", ".agents/skills", ".claude/skills", ".github/skills"];
|
|
6
6
|
const MAX_RESOURCES = 512;
|
|
7
7
|
const MAX_TOTAL_BYTES = 16 * 1024 * 1024;
|
|
8
|
+
const MAX_RESOURCE_RESPONSE_BYTES = 32_000;
|
|
8
9
|
const SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
9
10
|
const MAX_SKILL_NAME_LENGTH = 64;
|
|
10
11
|
const MAX_DESCRIPTION_LENGTH = 1024;
|
|
12
|
+
const DISCOVERY_CACHE_TTL_MS = 2_000;
|
|
13
|
+
const discoveryCache = new Map();
|
|
14
|
+
export function invalidateSkillDiscoveryCache(root) {
|
|
15
|
+
discoveryCache.delete(path.resolve(root));
|
|
16
|
+
}
|
|
11
17
|
export async function listSkills(root) {
|
|
12
18
|
return (await discoverSkills(root)).map((skill) => skill.entry);
|
|
13
19
|
}
|
|
@@ -22,8 +28,51 @@ export async function readSkillResource(root, uri) {
|
|
|
22
28
|
const filePath = skill.resources.get(uri);
|
|
23
29
|
if (!filePath)
|
|
24
30
|
continue;
|
|
31
|
+
const stats = await fs.stat(filePath);
|
|
32
|
+
if (stats.size > MAX_RESOURCE_RESPONSE_BYTES) {
|
|
33
|
+
const handle = await fs.open(filePath, "r");
|
|
34
|
+
let previewBytes;
|
|
35
|
+
try {
|
|
36
|
+
previewBytes = Buffer.alloc(MAX_RESOURCE_RESPONSE_BYTES);
|
|
37
|
+
const { bytesRead } = await handle.read(previewBytes, 0, previewBytes.length, 0);
|
|
38
|
+
previewBytes = previewBytes.subarray(0, bytesRead);
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
await handle.close();
|
|
42
|
+
}
|
|
43
|
+
if (previewBytes.includes(0)) {
|
|
44
|
+
return {
|
|
45
|
+
uri,
|
|
46
|
+
mimeType: "text/plain",
|
|
47
|
+
text: `Binary resource omitted: ${stats.size} bytes exceeds the ${MAX_RESOURCE_RESPONSE_BYTES}-byte response limit.`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const marker = "\n…[resource truncated at 32000 bytes]";
|
|
51
|
+
let preview = previewBytes
|
|
52
|
+
.subarray(0, MAX_RESOURCE_RESPONSE_BYTES - Buffer.byteLength(marker, "utf8"))
|
|
53
|
+
.toString("utf8");
|
|
54
|
+
while (preview.endsWith("\uFFFD"))
|
|
55
|
+
preview = preview.slice(0, -1);
|
|
56
|
+
return { uri, mimeType: mimeTypeFor(filePath), text: `${preview}${marker}` };
|
|
57
|
+
}
|
|
25
58
|
const bytes = await fs.readFile(filePath);
|
|
26
59
|
const text = bytes.toString("utf8");
|
|
60
|
+
if (bytes.length > MAX_RESOURCE_RESPONSE_BYTES) {
|
|
61
|
+
if (Buffer.from(text, "utf8").equals(bytes)) {
|
|
62
|
+
const marker = "\n…[resource truncated at 32000 bytes]";
|
|
63
|
+
let preview = bytes
|
|
64
|
+
.subarray(0, MAX_RESOURCE_RESPONSE_BYTES - Buffer.byteLength(marker, "utf8"))
|
|
65
|
+
.toString("utf8");
|
|
66
|
+
while (preview.endsWith("\uFFFD"))
|
|
67
|
+
preview = preview.slice(0, -1);
|
|
68
|
+
return { uri, mimeType: mimeTypeFor(filePath), text: `${preview}${marker}` };
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
uri,
|
|
72
|
+
mimeType: "text/plain",
|
|
73
|
+
text: `Binary resource omitted: ${bytes.length} bytes exceeds the ${MAX_RESOURCE_RESPONSE_BYTES}-byte response limit.`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
27
76
|
if (Buffer.from(text, "utf8").equals(bytes)) {
|
|
28
77
|
return { uri, mimeType: mimeTypeFor(filePath), text };
|
|
29
78
|
}
|
|
@@ -89,6 +138,22 @@ export async function skillResourceUrisForPath(root, relativePath) {
|
|
|
89
138
|
return [...matches].sort();
|
|
90
139
|
}
|
|
91
140
|
async function discoverSkills(root) {
|
|
141
|
+
const resolvedRoot = await fs.realpath(root);
|
|
142
|
+
const cached = discoveryCache.get(resolvedRoot);
|
|
143
|
+
if (cached && cached.expiresAt > Date.now())
|
|
144
|
+
return cached.promise;
|
|
145
|
+
const promise = discoverSkillsUncached(resolvedRoot);
|
|
146
|
+
discoveryCache.set(resolvedRoot, { expiresAt: Date.now() + DISCOVERY_CACHE_TTL_MS, promise });
|
|
147
|
+
try {
|
|
148
|
+
return await promise;
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
if (discoveryCache.get(resolvedRoot)?.promise === promise)
|
|
152
|
+
discoveryCache.delete(resolvedRoot);
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async function discoverSkillsUncached(root) {
|
|
92
157
|
const resolvedRoot = await fs.realpath(root);
|
|
93
158
|
const entries = await listFiles(root);
|
|
94
159
|
const skills = [];
|
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,13 +6,24 @@ 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[];
|
|
14
26
|
blacklistedCommands?: string[];
|
|
15
|
-
git?: boolean;
|
|
16
27
|
}
|
|
17
28
|
export interface AgentConfig {
|
|
18
29
|
version: 1;
|
|
@@ -23,7 +34,6 @@ export interface CommandConfig {
|
|
|
23
34
|
npm?: NpmConfig;
|
|
24
35
|
commands?: string[];
|
|
25
36
|
blacklistedCommands?: string[];
|
|
26
|
-
git?: boolean;
|
|
27
37
|
}
|
|
28
38
|
export interface ServerOptions {
|
|
29
39
|
root: string;
|
|
@@ -40,9 +50,11 @@ export interface RequestLog {
|
|
|
40
50
|
tool?: string;
|
|
41
51
|
}
|
|
42
52
|
export interface TunnelOptions {
|
|
43
|
-
provider: "wormhole";
|
|
53
|
+
provider: "wormhole" | "cloudflared";
|
|
44
54
|
port: number;
|
|
55
|
+
cloudflaredToken?: string;
|
|
45
56
|
subdomain?: string;
|
|
57
|
+
hostname?: string;
|
|
46
58
|
random?: boolean;
|
|
47
59
|
token?: string;
|
|
48
60
|
telemetry?: TelemetryConfig;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# Configuration and tunnel setup
|
|
2
|
+
|
|
3
|
+
This guide covers first-run setup, saved profiles, authentication tokens, tunnels, and command permissions.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Node.js 24 or newer
|
|
8
|
+
- npm
|
|
9
|
+
- Wormhole CLI or `cloudflared` CLI for remote access
|
|
10
|
+
|
|
11
|
+
## First-run setup
|
|
12
|
+
|
|
13
|
+
When run interactively for the first time without an existing config, Agent Dir offers a setup wizard. It collects a profile name, project directory, and port, then optionally configures Cloudflare Tunnel and Wormhole. Cloudflare connector tokens are entered without being echoed. You can enable either provider, both, or neither; Cloudflare is the default when both are configured.
|
|
14
|
+
|
|
15
|
+
Run the wizard directly at any time:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
agent-dir setup
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The wizard saves the profile and exits without starting a tunnel. Non-interactive invocations and invocations with explicit launch options do not start the wizard.
|
|
22
|
+
|
|
23
|
+
To skip profile setup and start a temporary random Wormhole tunnel:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
agent-dir . --random --tunnel wormhole
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Profiles
|
|
30
|
+
|
|
31
|
+
Profiles store the project directory, port, tunnel settings, allowed npm scripts, allowed non-npm commands, and blocked command prefixes.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
agent-dir config add my-project \
|
|
35
|
+
--directory ~/src/my-project \
|
|
36
|
+
--tunnel wormhole \
|
|
37
|
+
--subdomain my-project-x7k4m2 \
|
|
38
|
+
--npm typecheck,lint,format,test,build \
|
|
39
|
+
--command grep,find,rg,git \
|
|
40
|
+
--blacklist "git commit,git push --force"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Start a saved profile by name:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
agent-dir my-project
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Manage profiles:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
agent-dir config list
|
|
53
|
+
agent-dir config show my-project
|
|
54
|
+
agent-dir config delete my-project
|
|
55
|
+
agent-dir config delete --all
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Deletion asks for confirmation by default. Use `--yes` for automation. The older `config remove <name>` command is an alias for `config delete <name>`.
|
|
59
|
+
|
|
60
|
+
Profile configuration is stored at `~/.config/agent-dir/config.json`, outside the project repository, with user-only permissions. CLI options can override saved values for a single run. `config show` redacts configured tunnel tokens.
|
|
61
|
+
|
|
62
|
+
### Example profile
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{
|
|
66
|
+
"directory": "~/src/my-project",
|
|
67
|
+
"port": 3002,
|
|
68
|
+
"tunnel": "cloudflared",
|
|
69
|
+
"defaultTunnel": "cloudflared",
|
|
70
|
+
"tunnels": {
|
|
71
|
+
"wormhole": { "subdomain": "my-project-x7k4m2" },
|
|
72
|
+
"cloudflared": {
|
|
73
|
+
"hostname": "mcp.example.com",
|
|
74
|
+
"token": "CLOUDFLARE_TUNNEL_TOKEN"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The Cloudflare connector token is separate from the Agent Dir MCP Bearer token. Avoid passing secrets as command-line arguments because they may appear in shell history or process listings; prefer the interactive prompt when possible.
|
|
81
|
+
|
|
82
|
+
## Tunnel setup
|
|
83
|
+
|
|
84
|
+
### Wormhole
|
|
85
|
+
|
|
86
|
+
Temporary random URL:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
agent-dir . --random --tunnel wormhole
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Custom subdomain:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
agent-dir . --tunnel wormhole --subdomain my-project-x7k4m2
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Choose a distinctive subdomain to reduce collisions. Wormhole limits how many subdomains a user can have registered or active. If registration fails with a subdomain limit error, stop unused Wormhole processes, release an existing subdomain, or use `--random`.
|
|
99
|
+
|
|
100
|
+
The tunnel is only transport; Agent Dir's local server still enforces its own authentication.
|
|
101
|
+
|
|
102
|
+
### Cloudflare Tunnel
|
|
103
|
+
|
|
104
|
+
Configure a Cloudflare Tunnel and DNS/ingress route for the hostname before launching Agent Dir:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
agent-dir . --tunnel cloudflared --hostname mcp.example.com
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
For saved profiles, Cloudflare is selected by default when a `tunnels.cloudflared` entry exists. Use `-c` to explicitly select Cloudflare or `-w` to select Wormhole:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
agent-dir my-project -c
|
|
114
|
+
agent-dir my-project -w
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The `--hostname` value is the public URL Agent Dir displays; the named tunnel's DNS and ingress configuration must already exist. Agent Dir monitors `cloudflared tunnel run` and restarts the process if it disconnects. See the [Cloudflare Tunnel setup guide](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/get-started/).
|
|
118
|
+
|
|
119
|
+
## Authentication tokens
|
|
120
|
+
|
|
121
|
+
Agent Dir generates a random Bearer token when the server starts unless a profile token or `--token` is configured. Authentication cannot be disabled.
|
|
122
|
+
|
|
123
|
+
Retrieve or rotate a profile token:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
agent-dir config token my-project
|
|
127
|
+
agent-dir config token my-project --rotate
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The token command intentionally prints the secret. Do not share or commit its output.
|
|
131
|
+
|
|
132
|
+
Clients should send `Authorization: Bearer <token>`. Query-string tokens are supported for clients that cannot set headers, but are less secure because URLs may be recorded in logs or copied.
|
|
133
|
+
|
|
134
|
+
## Command permissions
|
|
135
|
+
|
|
136
|
+
npm scripts and general executables have separate allowlists.
|
|
137
|
+
|
|
138
|
+
Allow npm scripts:
|
|
139
|
+
|
|
140
|
+
```text
|
|
141
|
+
--npm typecheck,lint,format,test,build
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Allow executables:
|
|
145
|
+
|
|
146
|
+
```text
|
|
147
|
+
--command grep,find,rg,git
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Commands are launched with `shell: false`; executable and arguments are passed separately. Git uses the same allowlisted command interface as other executables; there are no dedicated Git MCP tools.
|
|
151
|
+
|
|
152
|
+
A configured `blacklistedCommands` entry overrides the allowlist and blocks matching command/argument prefixes. For example, allowing `git` while blacklisting `git commit` and `git push --force` still permits `git status`. Prefix entries are separated by commas.
|
|
153
|
+
|
|
154
|
+
Avoid allowing `sh`, `bash`, `zsh`, `cmd`, `node`, or `python` unless you intentionally want to grant much broader execution capability.
|