@agentmemory/fs-watcher 0.1.0 → 0.2.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/package.json +1 -1
- package/watcher.mjs +200 -7
package/package.json
CHANGED
package/watcher.mjs
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
|
-
import { watch, promises as fsp, statSync } from "node:fs";
|
|
2
|
-
import {
|
|
1
|
+
import { watch, promises as fsp, readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { resolve, relative, join, extname, sep, basename, dirname } from "node:path";
|
|
3
4
|
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
|
|
7
|
+
// Same resolution order as the hooks' resolveProject (git repository basename,
|
|
8
|
+
// then directory basename) so a watched subdirectory scopes to the repository
|
|
9
|
+
// name instead of the subdirectory name.
|
|
10
|
+
function deriveProjectName(dir) {
|
|
11
|
+
try {
|
|
12
|
+
const [top, gitDir, commonDir] = execFileSync(
|
|
13
|
+
"git",
|
|
14
|
+
["rev-parse", "--show-toplevel", "--git-dir", "--git-common-dir"],
|
|
15
|
+
{
|
|
16
|
+
cwd: dir,
|
|
17
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
18
|
+
encoding: "utf8",
|
|
19
|
+
},
|
|
20
|
+
)
|
|
21
|
+
.trim()
|
|
22
|
+
.split(/\r?\n/);
|
|
23
|
+
if (top) return basename(repositoryRoot(dir, top, gitDir, commonDir));
|
|
24
|
+
} catch {
|
|
25
|
+
// not a git repo
|
|
26
|
+
}
|
|
27
|
+
return basename(dir);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function repositoryRoot(dir, top, gitDir, commonDir) {
|
|
31
|
+
if (!gitDir || !commonDir) return top;
|
|
32
|
+
const physical = realpathSync(dir);
|
|
33
|
+
const common = resolve(physical, commonDir);
|
|
34
|
+
if (resolve(physical, gitDir) === common || basename(common) !== ".git") return top;
|
|
35
|
+
return dirname(common);
|
|
36
|
+
}
|
|
4
37
|
|
|
5
38
|
const TEXT_EXTENSIONS = new Set([
|
|
6
39
|
".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
|
|
@@ -28,15 +61,156 @@ const DEFAULT_IGNORE = [
|
|
|
28
61
|
|
|
29
62
|
const MAX_PREVIEW_BYTES = 4096;
|
|
30
63
|
const DEBOUNCE_MS = 500;
|
|
64
|
+
const REDACTED = "[REDACTED]";
|
|
65
|
+
const PEM_BEGIN_RE = /-----BEGIN [A-Z ]*PRIVATE KEY-----/;
|
|
66
|
+
const PEM_END_RE = /-----END [A-Z ]*PRIVATE KEY-----/;
|
|
67
|
+
const JWT_RE = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g;
|
|
68
|
+
const JWT_MIN_LEN = 100;
|
|
69
|
+
|
|
70
|
+
function isDotEnvPath(path) {
|
|
71
|
+
const name = basename(path).toLowerCase();
|
|
72
|
+
return name === ".env" || name.startsWith(".env.");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isSensitiveKey(key) {
|
|
76
|
+
const normalized = key.replace(/[^a-z0-9]/gi, "").toLowerCase();
|
|
77
|
+
return [
|
|
78
|
+
"apikey",
|
|
79
|
+
"accesstoken",
|
|
80
|
+
"accesskey",
|
|
81
|
+
"authorization",
|
|
82
|
+
"bearer",
|
|
83
|
+
"clientsecret",
|
|
84
|
+
"password",
|
|
85
|
+
"passwd",
|
|
86
|
+
"privatekey",
|
|
87
|
+
"pwd",
|
|
88
|
+
"secret",
|
|
89
|
+
"token",
|
|
90
|
+
].some((needle) => normalized.includes(needle));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function redactJwtTokens(line) {
|
|
94
|
+
return line.replace(JWT_RE, (match) => (match.length >= JWT_MIN_LEN ? REDACTED : match));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function redactSensitiveLine(line) {
|
|
98
|
+
if (PEM_BEGIN_RE.test(line) || PEM_END_RE.test(line)) {
|
|
99
|
+
return line;
|
|
100
|
+
}
|
|
101
|
+
const assignment = line.match(
|
|
102
|
+
/^(\s*(?:export\s+)?["']?([A-Za-z_][A-Za-z0-9_.-]*)["']?\s*([=:])\s*)(.*)$/,
|
|
103
|
+
);
|
|
104
|
+
if (assignment && isSensitiveKey(assignment[2])) {
|
|
105
|
+
const bearer = assignment[3] === ":" ? assignment[4].match(/^(Bearer\s+).+/i) : null;
|
|
106
|
+
return `${assignment[1]}${bearer ? bearer[1] : ""}${REDACTED}`;
|
|
107
|
+
}
|
|
108
|
+
const bearerRedacted = line.replace(
|
|
109
|
+
/\b(Bearer\s+)[A-Za-z0-9._~+/=-]{8,}\b/gi,
|
|
110
|
+
`$1${REDACTED}`,
|
|
111
|
+
);
|
|
112
|
+
return redactJwtTokens(bearerRedacted);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function redactPemBlocks(preview) {
|
|
116
|
+
const lines = preview.split("\n");
|
|
117
|
+
const out = [];
|
|
118
|
+
let inBlock = false;
|
|
119
|
+
for (const line of lines) {
|
|
120
|
+
if (!inBlock) {
|
|
121
|
+
const beginMatch = line.match(PEM_BEGIN_RE);
|
|
122
|
+
if (!beginMatch) {
|
|
123
|
+
out.push(line);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const beginIdx = beginMatch.index;
|
|
127
|
+
const endMatch = line.match(PEM_END_RE);
|
|
128
|
+
if (endMatch && endMatch.index > beginIdx) {
|
|
129
|
+
const before = line.slice(0, beginIdx);
|
|
130
|
+
const after = line.slice(endMatch.index + endMatch[0].length);
|
|
131
|
+
out.push(`${before}${beginMatch[0]}${REDACTED}${endMatch[0]}${after}`);
|
|
132
|
+
} else {
|
|
133
|
+
out.push(`${line.slice(0, beginIdx)}${beginMatch[0]}`);
|
|
134
|
+
out.push(REDACTED);
|
|
135
|
+
inBlock = true;
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
const endMatch = line.match(PEM_END_RE);
|
|
139
|
+
if (endMatch) {
|
|
140
|
+
out.push(`${endMatch[0]}${line.slice(endMatch.index + endMatch[0].length)}`);
|
|
141
|
+
inBlock = false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return out.join("\n");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function redactSensitivePreview(preview) {
|
|
149
|
+
return redactPemBlocks(preview).split("\n").map(redactSensitiveLine).join("\n");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function usableSecret(value) {
|
|
153
|
+
const trimmed = typeof value === "string" ? value.trim() : "";
|
|
154
|
+
if (!trimmed || (trimmed.startsWith("${") && trimmed.endsWith("}"))) return "";
|
|
155
|
+
return trimmed;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function readAgentmemoryFile(name) {
|
|
159
|
+
try {
|
|
160
|
+
return readFileSync(join(homedir(), ".agentmemory", name), "utf-8");
|
|
161
|
+
} catch {
|
|
162
|
+
return "";
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function envFileSecret() {
|
|
167
|
+
let found = "";
|
|
168
|
+
for (const line of readAgentmemoryFile(".env").split("\n")) {
|
|
169
|
+
const trimmed = line.trim();
|
|
170
|
+
if (trimmed.startsWith("#")) continue;
|
|
171
|
+
const eq = trimmed.indexOf("=");
|
|
172
|
+
if (eq === -1) continue;
|
|
173
|
+
if (trimmed.slice(0, eq).replace(/^export\s+/, "").trim() !== "AGENTMEMORY_SECRET") continue;
|
|
174
|
+
let value = trimmed.slice(eq + 1).trim();
|
|
175
|
+
const quote = value[0];
|
|
176
|
+
const close = quote === '"' || quote === "'" ? value.indexOf(quote, 1) : -1;
|
|
177
|
+
if (close > 0) value = value.slice(1, close);
|
|
178
|
+
else if (value.includes(" #")) value = value.slice(0, value.indexOf(" #"));
|
|
179
|
+
found = usableSecret(value);
|
|
180
|
+
}
|
|
181
|
+
return found;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function isLoopbackUrl(url) {
|
|
185
|
+
try {
|
|
186
|
+
const host = new URL(url).hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
187
|
+
return host === "localhost" || host === "::1" || /^127(?:\.\d{1,3}){3}$/.test(host);
|
|
188
|
+
} catch {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function resolveSecret(url, explicit) {
|
|
194
|
+
const configured = usableSecret(explicit);
|
|
195
|
+
if (configured) return configured;
|
|
196
|
+
if (!isLoopbackUrl(url)) return "";
|
|
197
|
+
return envFileSecret() || usableSecret(readAgentmemoryFile("secret"));
|
|
198
|
+
}
|
|
31
199
|
|
|
32
200
|
export class FilesystemWatcher {
|
|
33
201
|
constructor(config = {}) {
|
|
34
202
|
this.roots = (config.roots || []).map((r) => resolve(r));
|
|
35
203
|
this.baseUrl = (config.baseUrl || "http://localhost:3111").replace(/\/+$/, "");
|
|
36
|
-
this.secret = config.secret;
|
|
204
|
+
this.secret = resolveSecret(this.baseUrl, config.secret);
|
|
37
205
|
this.project =
|
|
38
206
|
config.project ||
|
|
39
|
-
(this.roots[0] ?
|
|
207
|
+
(this.roots[0] ? deriveProjectName(this.roots[0]) : "filesystem-watcher");
|
|
208
|
+
// Per-root scope: a multi-root watcher must stamp each event with the
|
|
209
|
+
// project of the root that produced it, not the first root's project.
|
|
210
|
+
// An explicit config.project overrides for every root.
|
|
211
|
+
this.projectByRoot = new Map(
|
|
212
|
+
this.roots.map((r) => [r, config.project || deriveProjectName(r)]),
|
|
213
|
+
);
|
|
40
214
|
this.sessionId =
|
|
41
215
|
config.sessionId ||
|
|
42
216
|
`fs-watcher-${Date.now().toString(36)}-${randomBytes(3).toString("hex")}`;
|
|
@@ -54,7 +228,7 @@ export class FilesystemWatcher {
|
|
|
54
228
|
isTextFile(path) {
|
|
55
229
|
if (this.allowBinary) return true;
|
|
56
230
|
const ext = extname(path).toLowerCase();
|
|
57
|
-
return TEXT_EXTENSIONS.has(ext);
|
|
231
|
+
return TEXT_EXTENSIONS.has(ext) || isDotEnvPath(path);
|
|
58
232
|
}
|
|
59
233
|
|
|
60
234
|
async readPreview(path) {
|
|
@@ -121,12 +295,13 @@ export class FilesystemWatcher {
|
|
|
121
295
|
let preview = null;
|
|
122
296
|
if (exists && this.isTextFile(absPath)) {
|
|
123
297
|
preview = await this.readPreview(absPath);
|
|
298
|
+
if (preview !== null) preview = redactSensitivePreview(preview);
|
|
124
299
|
}
|
|
125
300
|
const truncated = exists && size > MAX_PREVIEW_BYTES;
|
|
126
301
|
const payload = {
|
|
127
302
|
hookType: "post_tool_use",
|
|
128
303
|
sessionId: this.sessionId,
|
|
129
|
-
project: this.project,
|
|
304
|
+
project: this.projectByRoot.get(rootDir) ?? this.project,
|
|
130
305
|
cwd: rootDir,
|
|
131
306
|
timestamp: new Date().toISOString(),
|
|
132
307
|
data: {
|
|
@@ -160,6 +335,18 @@ export class FilesystemWatcher {
|
|
|
160
335
|
const failures = [];
|
|
161
336
|
for (const root of this.roots) {
|
|
162
337
|
try {
|
|
338
|
+
// Validate the root before handing it to fs.watch: on Linux with
|
|
339
|
+
// Node 24+, fs.watch on a nonexistent path no longer throws
|
|
340
|
+
// synchronously, so a missing root would otherwise count as
|
|
341
|
+
// "attached" and be watched-in-name-only. An explicit stat keeps
|
|
342
|
+
// the failure deterministic across Node versions and platforms.
|
|
343
|
+
const st = statSync(root, { throwIfNoEntry: false });
|
|
344
|
+
if (!st) {
|
|
345
|
+
throw new Error("no such directory");
|
|
346
|
+
}
|
|
347
|
+
if (!st.isDirectory()) {
|
|
348
|
+
throw new Error("not a directory");
|
|
349
|
+
}
|
|
163
350
|
const handle = watch(
|
|
164
351
|
root,
|
|
165
352
|
{ recursive: true, persistent: true },
|
|
@@ -219,7 +406,13 @@ export function configFromEnv(env = process.env) {
|
|
|
219
406
|
roots,
|
|
220
407
|
baseUrl: env.AGENTMEMORY_URL,
|
|
221
408
|
secret: env.AGENTMEMORY_SECRET,
|
|
222
|
-
|
|
409
|
+
// AGENTMEMORY_PROJECT_NAME is the canonical override (matches the hooks);
|
|
410
|
+
// AGENTMEMORY_PROJECT stays as a deprecated alias for existing setups.
|
|
411
|
+
// Trimmed, with whitespace-only treated as unset, same as resolveProject.
|
|
412
|
+
project:
|
|
413
|
+
(env.AGENTMEMORY_PROJECT_NAME || "").trim() ||
|
|
414
|
+
(env.AGENTMEMORY_PROJECT || "").trim() ||
|
|
415
|
+
null,
|
|
223
416
|
sessionId: env.AGENTMEMORY_SESSION_ID || null,
|
|
224
417
|
ignorePatterns: extraIgnore,
|
|
225
418
|
allowBinary: env.AGENTMEMORY_FS_WATCH_ALLOW_BINARY === "1",
|