herdr-plugin-amq 0.1.3 → 0.1.5
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/herdr-plugin.toml +1 -1
- package/package.json +1 -1
- package/src/blobs.mjs +121 -12
- package/src/panes.mjs +9 -5
- package/src/server.mjs +54 -12
- package/src/store.mjs +10 -38
package/herdr-plugin.toml
CHANGED
package/package.json
CHANGED
package/src/blobs.mjs
CHANGED
|
@@ -159,22 +159,97 @@ export function getBlob(sha256, amqRoot) {
|
|
|
159
159
|
|
|
160
160
|
// ─── Option B: Git Commit & Object Pinning ────────────────────────────────────
|
|
161
161
|
|
|
162
|
+
const gitRefCache = new Map();
|
|
163
|
+
const headCommitCache = new Map();
|
|
164
|
+
const timestampCommitCache = new Map();
|
|
165
|
+
|
|
166
|
+
export function clearGitRefCache() {
|
|
167
|
+
gitRefCache.clear();
|
|
168
|
+
headCommitCache.clear();
|
|
169
|
+
timestampCommitCache.clear();
|
|
170
|
+
}
|
|
171
|
+
|
|
162
172
|
/**
|
|
163
|
-
*
|
|
164
|
-
* Returns immutable git ref descriptor.
|
|
173
|
+
* Resolve the git commit active before or at a given ISO timestamp.
|
|
165
174
|
*/
|
|
166
|
-
export function
|
|
167
|
-
if (!repoRoot || !
|
|
175
|
+
export function getCommitAtTimestamp(repoRoot, timestamp) {
|
|
176
|
+
if (!repoRoot || !timestamp) return null;
|
|
177
|
+
const iso = typeof timestamp === "string" ? timestamp.trim() : new Date(timestamp).toISOString();
|
|
178
|
+
const cacheKey = `${repoRoot}:${iso}`;
|
|
179
|
+
if (timestampCommitCache.has(cacheKey)) {
|
|
180
|
+
return timestampCommitCache.get(cacheKey);
|
|
181
|
+
}
|
|
168
182
|
|
|
169
183
|
try {
|
|
170
|
-
const
|
|
171
|
-
// Resolve commit SHA
|
|
172
|
-
const commitSha = execFileSync("git", ["rev-parse", commit], {
|
|
184
|
+
const commit = execFileSync("git", ["rev-list", "-n", "1", `--before=${iso}`, "HEAD"], {
|
|
173
185
|
cwd: repoRoot,
|
|
174
186
|
encoding: "utf8",
|
|
175
187
|
stdio: ["ignore", "pipe", "ignore"],
|
|
176
188
|
}).trim();
|
|
177
189
|
|
|
190
|
+
const result = commit && /^[a-f0-9]{7,40}$/i.test(commit) ? commit : null;
|
|
191
|
+
timestampCommitCache.set(cacheKey, result);
|
|
192
|
+
return result;
|
|
193
|
+
} catch {
|
|
194
|
+
timestampCommitCache.set(cacheKey, null);
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Extract commit hashes explicitly mentioned in message text.
|
|
201
|
+
*/
|
|
202
|
+
export function extractMentionedCommits(text = "") {
|
|
203
|
+
if (!text || typeof text !== "string") return [];
|
|
204
|
+
const commits = [];
|
|
205
|
+
const regex = /\b(?:commit|commitado|commitada|fixado|merge|sha|rev)?[:\s*`"'(]*([0-9a-f]{7,40})\b/gi;
|
|
206
|
+
let match;
|
|
207
|
+
while ((match = regex.exec(text)) !== null) {
|
|
208
|
+
const hex = match[1].toLowerCase();
|
|
209
|
+
if (/^[0-9]+$/.test(hex) && hex.length < 40) continue;
|
|
210
|
+
if (!commits.includes(hex)) {
|
|
211
|
+
commits.push(hex);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return commits;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Pin a repository file to a specific commit or HEAD.
|
|
219
|
+
* Returns immutable git ref descriptor.
|
|
220
|
+
*/
|
|
221
|
+
export function pinGitRef(repoRoot, relativePath, commit = "HEAD") {
|
|
222
|
+
if (!repoRoot || !relativePath) return null;
|
|
223
|
+
|
|
224
|
+
const cleanRel = relativePath.replace(/^[/\\]+/, "");
|
|
225
|
+
const cacheKey = `${repoRoot}:${commit}:${cleanRel}`;
|
|
226
|
+
if (gitRefCache.has(cacheKey)) {
|
|
227
|
+
return gitRefCache.get(cacheKey);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
let commitSha;
|
|
232
|
+
if (commit === "HEAD") {
|
|
233
|
+
const cachedHead = headCommitCache.get(repoRoot);
|
|
234
|
+
const now = Date.now();
|
|
235
|
+
if (cachedHead && now - cachedHead.at < 5000) {
|
|
236
|
+
commitSha = cachedHead.sha;
|
|
237
|
+
} else {
|
|
238
|
+
commitSha = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
239
|
+
cwd: repoRoot,
|
|
240
|
+
encoding: "utf8",
|
|
241
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
242
|
+
}).trim();
|
|
243
|
+
headCommitCache.set(repoRoot, { sha: commitSha, at: now });
|
|
244
|
+
}
|
|
245
|
+
} else {
|
|
246
|
+
commitSha = execFileSync("git", ["rev-parse", commit], {
|
|
247
|
+
cwd: repoRoot,
|
|
248
|
+
encoding: "utf8",
|
|
249
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
250
|
+
}).trim();
|
|
251
|
+
}
|
|
252
|
+
|
|
178
253
|
// Resolve git blob hash
|
|
179
254
|
const blobSha = execFileSync("git", ["rev-parse", `${commitSha}:${cleanRel}`], {
|
|
180
255
|
cwd: repoRoot,
|
|
@@ -194,7 +269,7 @@ export function pinGitRef(repoRoot, relativePath, commit = "HEAD") {
|
|
|
194
269
|
const isImage = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp"].includes(ext);
|
|
195
270
|
const isLog = [".log", ".txt", ".csv", ".json", ".out", ".diff", ".patch"].includes(ext);
|
|
196
271
|
|
|
197
|
-
|
|
272
|
+
const ref = {
|
|
198
273
|
type: "git",
|
|
199
274
|
commit: commitSha,
|
|
200
275
|
shortCommit: commitSha.slice(0, 10),
|
|
@@ -209,7 +284,10 @@ export function pinGitRef(repoRoot, relativePath, commit = "HEAD") {
|
|
|
209
284
|
exists: true,
|
|
210
285
|
url: `/api/git-file?commit=${commitSha}&path=${encodeURIComponent(cleanRel)}`,
|
|
211
286
|
};
|
|
287
|
+
gitRefCache.set(cacheKey, ref);
|
|
288
|
+
return ref;
|
|
212
289
|
} catch {
|
|
290
|
+
gitRefCache.set(cacheKey, null);
|
|
213
291
|
return null;
|
|
214
292
|
}
|
|
215
293
|
}
|
|
@@ -259,7 +337,7 @@ export function readGitRef(repoRoot, commitSha, relativePath) {
|
|
|
259
337
|
* - Committed files in repoRoot can be pinned to git HEAD.
|
|
260
338
|
* - Already pinned/stored items retain their immutable URLs.
|
|
261
339
|
*/
|
|
262
|
-
export function ingestAttachment(candidate, amqRoot, repoRoot) {
|
|
340
|
+
export function ingestAttachment(candidate, amqRoot, repoRoot, { timestamp = null, text = "" } = {}) {
|
|
263
341
|
if (!candidate) return null;
|
|
264
342
|
|
|
265
343
|
// Already a structured descriptor
|
|
@@ -285,7 +363,7 @@ export function ingestAttachment(candidate, amqRoot, repoRoot) {
|
|
|
285
363
|
}
|
|
286
364
|
|
|
287
365
|
if (candidate.path) {
|
|
288
|
-
return ingestAttachment(candidate.path, amqRoot, repoRoot);
|
|
366
|
+
return ingestAttachment(candidate.path, amqRoot, repoRoot, { timestamp, text });
|
|
289
367
|
}
|
|
290
368
|
}
|
|
291
369
|
|
|
@@ -332,16 +410,47 @@ export function ingestAttachment(candidate, amqRoot, repoRoot) {
|
|
|
332
410
|
return storeBlob(diskPath, amqRoot, path.basename(diskPath));
|
|
333
411
|
}
|
|
334
412
|
|
|
335
|
-
// If it's inside repoRoot and git is present,
|
|
413
|
+
// If it's inside repoRoot and git is present, pin to commit at timestamp (or HEAD)
|
|
336
414
|
if (repoRoot && diskPath.startsWith(repoRoot)) {
|
|
337
415
|
const rel = path.relative(repoRoot, diskPath);
|
|
338
|
-
const
|
|
416
|
+
const commit = (timestamp ? getCommitAtTimestamp(repoRoot, timestamp) : null) || "HEAD";
|
|
417
|
+
const gitRef = pinGitRef(repoRoot, rel, commit);
|
|
339
418
|
if (gitRef) return gitRef;
|
|
340
419
|
}
|
|
341
420
|
|
|
342
421
|
// Default fallback for existing files: store into blobstore for guarantee
|
|
343
422
|
return storeBlob(diskPath, amqRoot, path.basename(diskPath));
|
|
344
423
|
}
|
|
424
|
+
|
|
425
|
+
// ─── Historical Git Pinning Fallback ───
|
|
426
|
+
// If the file does not exist on disk right now (e.g. deleted or renamed in subsequent commits),
|
|
427
|
+
// check if it existed in Git at the time the message was created or at a commit mentioned in the message!
|
|
428
|
+
if (!diskPath && repoRoot) {
|
|
429
|
+
const cleanRel = raw.startsWith(repoRoot) ? path.relative(repoRoot, raw) : raw.replace(/^[/\\]+/, "");
|
|
430
|
+
if (!cleanRel.includes("..") && !path.isAbsolute(cleanRel)) {
|
|
431
|
+
const candidateCommits = [];
|
|
432
|
+
if (text) {
|
|
433
|
+
candidateCommits.push(...extractMentionedCommits(text));
|
|
434
|
+
}
|
|
435
|
+
if (timestamp) {
|
|
436
|
+
const atTime = getCommitAtTimestamp(repoRoot, timestamp);
|
|
437
|
+
if (atTime && !candidateCommits.includes(atTime)) {
|
|
438
|
+
candidateCommits.push(atTime);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
candidateCommits.push("HEAD");
|
|
442
|
+
|
|
443
|
+
for (const commitSha of candidateCommits) {
|
|
444
|
+
const gitRef = pinGitRef(repoRoot, cleanRel, commitSha);
|
|
445
|
+
if (gitRef && gitRef.exists) {
|
|
446
|
+
return {
|
|
447
|
+
...gitRef,
|
|
448
|
+
pinnedAt: timestamp || null,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
345
454
|
}
|
|
346
455
|
|
|
347
456
|
return null;
|
package/src/panes.mjs
CHANGED
|
@@ -18,11 +18,15 @@ export function launchDashboardPane() {
|
|
|
18
18
|
const port = parseInt(process.env.AGMAIL_PORT || "8505", 10);
|
|
19
19
|
const server = startWebServer({ port, amqRoot });
|
|
20
20
|
|
|
21
|
-
// Open browser in background if
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
// Open browser in background if display is available and not disabled
|
|
22
|
+
if (process.env.NO_OPEN !== "1" && process.env.DISPLAY) {
|
|
23
|
+
server.once("listening", () => {
|
|
24
|
+
try {
|
|
25
|
+
const opener = process.platform === "darwin" ? "open" : "xdg-open";
|
|
26
|
+
spawn(opener, [`http://127.0.0.1:${port}`], { stdio: "ignore", detached: true }).unref();
|
|
27
|
+
} catch {}
|
|
28
|
+
});
|
|
29
|
+
}
|
|
26
30
|
|
|
27
31
|
console.log("\nPress Ctrl+C to stop the dashboard server.");
|
|
28
32
|
}
|
package/src/server.mjs
CHANGED
|
@@ -180,7 +180,7 @@ export function startWebServer({
|
|
|
180
180
|
|
|
181
181
|
|
|
182
182
|
|
|
183
|
-
const
|
|
183
|
+
const requestHandler = async (req, res) => {
|
|
184
184
|
// ─── Compliance: Strict Security Headers ───────────────────────────────
|
|
185
185
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
186
186
|
res.setHeader("X-Frame-Options", "DENY");
|
|
@@ -191,12 +191,18 @@ export function startWebServer({
|
|
|
191
191
|
res.setHeader("Referrer-Policy", "no-referrer");
|
|
192
192
|
|
|
193
193
|
// ─── Compliance: Host Header & DNS Rebinding Protection ───────────────
|
|
194
|
-
const rawHost = req.headers.host || "";
|
|
195
|
-
|
|
194
|
+
const rawHost = (req.headers.host || "").toLowerCase().trim();
|
|
195
|
+
let hostHeader = rawHost;
|
|
196
|
+
if (hostHeader.startsWith("[")) {
|
|
197
|
+
const closeBracket = hostHeader.indexOf("]");
|
|
198
|
+
hostHeader = closeBracket !== -1 ? hostHeader.slice(1, closeBracket) : hostHeader;
|
|
199
|
+
} else {
|
|
200
|
+
hostHeader = hostHeader.split(":")[0];
|
|
201
|
+
}
|
|
202
|
+
|
|
196
203
|
const isLocalHost =
|
|
197
204
|
hostHeader === "localhost" ||
|
|
198
205
|
hostHeader === "127.0.0.1" ||
|
|
199
|
-
hostHeader === "[::1]" ||
|
|
200
206
|
hostHeader === "::1" ||
|
|
201
207
|
!rawHost; // In-memory or direct tests without host header
|
|
202
208
|
|
|
@@ -718,24 +724,60 @@ export function startWebServer({
|
|
|
718
724
|
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
719
725
|
res.end("File not found");
|
|
720
726
|
}
|
|
721
|
-
}
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
const server = http.createServer(requestHandler);
|
|
730
|
+
let server6 = null;
|
|
722
731
|
|
|
723
732
|
server.listen(port, host, () => {
|
|
724
|
-
|
|
733
|
+
const boundPort = server.address()?.port || port;
|
|
734
|
+
console.log(`\x1b[32m● AGmail Webmail Server running at:\x1b[0m \x1b[1mhttp://${host}:${boundPort}\x1b[0m (local only)`);
|
|
725
735
|
console.log(` Queue Root: \x1b[36m${amqRoot}\x1b[0m`);
|
|
726
736
|
if (host !== "127.0.0.1" && host !== "localhost") {
|
|
727
737
|
console.warn(`\x1b[33m⚠️ SECURITY WARNING: Server is listening on '${host}'. AGmail contains sensitive agent data and should strictly be local-only!\x1b[0m`);
|
|
728
738
|
}
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
739
|
+
|
|
740
|
+
// If host is loopback, also listen on ::1 so browser 'localhost' connects seamlessly in IPv6-first browsers (Firefox/Chrome)
|
|
741
|
+
if (host === "127.0.0.1" || host === "localhost") {
|
|
742
|
+
try {
|
|
743
|
+
server6 = http.createServer(requestHandler);
|
|
744
|
+
server6.listen(boundPort, "::1", () => {});
|
|
745
|
+
server6.on("error", () => {
|
|
746
|
+
// Graceful fallback if system does not support IPv6 loopback
|
|
747
|
+
server6 = null;
|
|
748
|
+
});
|
|
749
|
+
} catch {}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Workspaces are the default: automatically isolate agents in worktrees in background
|
|
753
|
+
setImmediate(() => {
|
|
754
|
+
try {
|
|
755
|
+
const repoRoot = getRepoRootFromAmq(amqRoot);
|
|
756
|
+
const handles = getAgentHandles(amqRoot);
|
|
757
|
+
ensureAllWorktrees(repoRoot, handles);
|
|
758
|
+
} catch {}
|
|
759
|
+
});
|
|
735
760
|
});
|
|
736
761
|
|
|
762
|
+
const originalCloseAll = server.closeAllConnections?.bind(server);
|
|
763
|
+
server.closeAllConnections = function () {
|
|
764
|
+
if (originalCloseAll) originalCloseAll();
|
|
765
|
+
if (server6 && typeof server6.closeAllConnections === "function") {
|
|
766
|
+
try { server6.closeAllConnections(); } catch {}
|
|
767
|
+
}
|
|
768
|
+
};
|
|
769
|
+
|
|
737
770
|
server.on("close", () => {
|
|
738
771
|
isClosing = true;
|
|
772
|
+
if (server6) {
|
|
773
|
+
try {
|
|
774
|
+
if (typeof server6.closeAllConnections === "function") {
|
|
775
|
+
server6.closeAllConnections();
|
|
776
|
+
}
|
|
777
|
+
server6.close();
|
|
778
|
+
} catch {}
|
|
779
|
+
server6 = null;
|
|
780
|
+
}
|
|
739
781
|
if (herdrReconnectTimeout) clearTimeout(herdrReconnectTimeout);
|
|
740
782
|
if (watchDebounce) clearTimeout(watchDebounce);
|
|
741
783
|
if (herdrSubscription) {
|
package/src/store.mjs
CHANGED
|
@@ -34,30 +34,6 @@ export function formatAgentTitle(handle = "") {
|
|
|
34
34
|
const filePathCache = new Map();
|
|
35
35
|
let cacheTimestamp = 0;
|
|
36
36
|
|
|
37
|
-
function findInTree(dir, targetName) {
|
|
38
|
-
const skip = new Set([".git", ".godot", "node_modules", ".agent-mail", "dist", "build"]);
|
|
39
|
-
const queue = [dir];
|
|
40
|
-
while (queue.length > 0) {
|
|
41
|
-
const current = queue.shift();
|
|
42
|
-
let entries;
|
|
43
|
-
try {
|
|
44
|
-
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
45
|
-
} catch {
|
|
46
|
-
continue;
|
|
47
|
-
}
|
|
48
|
-
for (const ent of entries) {
|
|
49
|
-
if (ent.isDirectory()) {
|
|
50
|
-
if (!skip.has(ent.name)) {
|
|
51
|
-
queue.push(path.join(current, ent.name));
|
|
52
|
-
}
|
|
53
|
-
} else if (ent.name === targetName) {
|
|
54
|
-
return path.join(current, ent.name);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
return null;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
37
|
export function formatFileSize(bytes) {
|
|
62
38
|
if (!bytes || bytes <= 0) return "0 B";
|
|
63
39
|
if (bytes < 1024) return `${bytes} B`;
|
|
@@ -178,14 +154,7 @@ export function resolveAttachmentPath(ref, amqRoot) {
|
|
|
178
154
|
if (candidate) return candidate;
|
|
179
155
|
}
|
|
180
156
|
|
|
181
|
-
// 6.
|
|
182
|
-
const found = findInTree(repoRoot, base);
|
|
183
|
-
if (found) {
|
|
184
|
-
const resolved = path.resolve(found);
|
|
185
|
-
filePathCache.set(cacheKey, resolved);
|
|
186
|
-
return resolved;
|
|
187
|
-
}
|
|
188
|
-
|
|
157
|
+
// 6. Not found in standard locations: cache null
|
|
189
158
|
filePathCache.set(cacheKey, null);
|
|
190
159
|
return null;
|
|
191
160
|
}
|
|
@@ -194,7 +163,7 @@ export function resolveAttachmentPath(ref, amqRoot) {
|
|
|
194
163
|
* Extract attachments and referenced images/logs from body or frontmatter,
|
|
195
164
|
* verifying presence on disk and providing existence flags.
|
|
196
165
|
*/
|
|
197
|
-
export function extractAttachments(body = "", metaAttachments = [], amqRoot = null) {
|
|
166
|
+
export function extractAttachments(body = "", metaAttachments = [], amqRoot = null, meta = {}) {
|
|
198
167
|
const attachments = [];
|
|
199
168
|
const seen = new Set();
|
|
200
169
|
const repoRoot = getRepoRootFromAmq(amqRoot);
|
|
@@ -215,10 +184,13 @@ export function extractAttachments(body = "", metaAttachments = [], amqRoot = nu
|
|
|
215
184
|
return;
|
|
216
185
|
}
|
|
217
186
|
|
|
218
|
-
// 1. Try hybrid CAS ingestion / Git pinning (Option A + B)
|
|
187
|
+
// 1. Try hybrid CAS ingestion / Git pinning (Option A + B) with timestamp
|
|
219
188
|
if (amqRoot) {
|
|
220
189
|
try {
|
|
221
|
-
const ingested = ingestAttachment(rawRef, amqRoot, repoRoot
|
|
190
|
+
const ingested = ingestAttachment(rawRef, amqRoot, repoRoot, {
|
|
191
|
+
timestamp: meta?.created || null,
|
|
192
|
+
text: body,
|
|
193
|
+
});
|
|
222
194
|
if (ingested && ingested.exists) {
|
|
223
195
|
attachments.push({
|
|
224
196
|
...ingested,
|
|
@@ -264,8 +236,8 @@ export function extractAttachments(body = "", metaAttachments = [], amqRoot = nu
|
|
|
264
236
|
}
|
|
265
237
|
}
|
|
266
238
|
|
|
267
|
-
// Auto-scan body for referenced files (/tmp/...,
|
|
268
|
-
const regex = /(?:(
|
|
239
|
+
// Auto-scan body for referenced files (/tmp/..., paths with slashes, or image/log basenames)
|
|
240
|
+
const regex = /(?:(?:(?:\/|\.\/|[a-zA-Z0-9_.-]+\/)[a-zA-Z0-9_./-]+\.(?:png|jpg|jpeg|gif|webp|svg|bmp|log|txt|csv|json|diff|patch|out|gd|tres|tscn|sh|md))|(?:\b[a-zA-Z0-9_.-]+\.(?:png|jpg|jpeg|gif|webp|svg|bmp|log|diff|patch|out)\b))/gi;
|
|
269
241
|
const matches = body.match(regex) || [];
|
|
270
242
|
|
|
271
243
|
for (const m of matches) {
|
|
@@ -425,7 +397,7 @@ export function parseMessageFile(filePath, amqRoot = null) {
|
|
|
425
397
|
if (isOutbox) folder = "sent";
|
|
426
398
|
|
|
427
399
|
const root = amqRoot || (filePath.includes("/agents/") ? filePath.split(path.sep + "agents" + path.sep)[0] : null);
|
|
428
|
-
const attachments = extractAttachments(body, meta.attachments, root);
|
|
400
|
+
const attachments = extractAttachments(body, meta.attachments, root, meta);
|
|
429
401
|
const hasImage = attachments.some((a) => a.isImage);
|
|
430
402
|
const hasAttachment = attachments.length > 0;
|
|
431
403
|
|