herdr-plugin-amq 0.1.2 → 0.1.4
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 +1 -0
- package/herdr-plugin.toml +1 -1
- package/package.json +1 -1
- package/src/blobs.mjs +39 -8
- package/src/panes.mjs +9 -5
- package/src/server.mjs +54 -12
- package/src/store.mjs +3 -34
package/README.md
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# Herdr AMQ Plugin
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/herdr-plugin-amq)
|
|
3
4
|
[](https://github.com/cabra-lat/herdr-plugin-amq/actions/workflows/ci.yml)
|
|
4
5
|
[](https://github.com/cabra-lat/herdr-plugin-amq/actions/workflows/security.yml)
|
|
5
6
|
[](https://github.com/cabra-lat/herdr-plugin-amq)
|
package/herdr-plugin.toml
CHANGED
package/package.json
CHANGED
package/src/blobs.mjs
CHANGED
|
@@ -159,6 +159,14 @@ 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
|
+
|
|
165
|
+
export function clearGitRefCache() {
|
|
166
|
+
gitRefCache.clear();
|
|
167
|
+
headCommitCache.clear();
|
|
168
|
+
}
|
|
169
|
+
|
|
162
170
|
/**
|
|
163
171
|
* Pin a repository file to a specific commit or HEAD.
|
|
164
172
|
* Returns immutable git ref descriptor.
|
|
@@ -166,14 +174,34 @@ export function getBlob(sha256, amqRoot) {
|
|
|
166
174
|
export function pinGitRef(repoRoot, relativePath, commit = "HEAD") {
|
|
167
175
|
if (!repoRoot || !relativePath) return null;
|
|
168
176
|
|
|
177
|
+
const cleanRel = relativePath.replace(/^[/\\]+/, "");
|
|
178
|
+
const cacheKey = `${repoRoot}:${commit}:${cleanRel}`;
|
|
179
|
+
if (gitRefCache.has(cacheKey)) {
|
|
180
|
+
return gitRefCache.get(cacheKey);
|
|
181
|
+
}
|
|
182
|
+
|
|
169
183
|
try {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
184
|
+
let commitSha;
|
|
185
|
+
if (commit === "HEAD") {
|
|
186
|
+
const cachedHead = headCommitCache.get(repoRoot);
|
|
187
|
+
const now = Date.now();
|
|
188
|
+
if (cachedHead && now - cachedHead.at < 5000) {
|
|
189
|
+
commitSha = cachedHead.sha;
|
|
190
|
+
} else {
|
|
191
|
+
commitSha = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
192
|
+
cwd: repoRoot,
|
|
193
|
+
encoding: "utf8",
|
|
194
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
195
|
+
}).trim();
|
|
196
|
+
headCommitCache.set(repoRoot, { sha: commitSha, at: now });
|
|
197
|
+
}
|
|
198
|
+
} else {
|
|
199
|
+
commitSha = execFileSync("git", ["rev-parse", commit], {
|
|
200
|
+
cwd: repoRoot,
|
|
201
|
+
encoding: "utf8",
|
|
202
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
203
|
+
}).trim();
|
|
204
|
+
}
|
|
177
205
|
|
|
178
206
|
// Resolve git blob hash
|
|
179
207
|
const blobSha = execFileSync("git", ["rev-parse", `${commitSha}:${cleanRel}`], {
|
|
@@ -194,7 +222,7 @@ export function pinGitRef(repoRoot, relativePath, commit = "HEAD") {
|
|
|
194
222
|
const isImage = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp"].includes(ext);
|
|
195
223
|
const isLog = [".log", ".txt", ".csv", ".json", ".out", ".diff", ".patch"].includes(ext);
|
|
196
224
|
|
|
197
|
-
|
|
225
|
+
const ref = {
|
|
198
226
|
type: "git",
|
|
199
227
|
commit: commitSha,
|
|
200
228
|
shortCommit: commitSha.slice(0, 10),
|
|
@@ -209,7 +237,10 @@ export function pinGitRef(repoRoot, relativePath, commit = "HEAD") {
|
|
|
209
237
|
exists: true,
|
|
210
238
|
url: `/api/git-file?commit=${commitSha}&path=${encodeURIComponent(cleanRel)}`,
|
|
211
239
|
};
|
|
240
|
+
gitRefCache.set(cacheKey, ref);
|
|
241
|
+
return ref;
|
|
212
242
|
} catch {
|
|
243
|
+
gitRefCache.set(cacheKey, null);
|
|
213
244
|
return null;
|
|
214
245
|
}
|
|
215
246
|
}
|
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
|
}
|
|
@@ -264,8 +233,8 @@ export function extractAttachments(body = "", metaAttachments = [], amqRoot = nu
|
|
|
264
233
|
}
|
|
265
234
|
}
|
|
266
235
|
|
|
267
|
-
// Auto-scan body for referenced files (/tmp/...,
|
|
268
|
-
const regex = /(?:(
|
|
236
|
+
// Auto-scan body for referenced files (/tmp/..., paths with slashes, or image/log basenames)
|
|
237
|
+
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
238
|
const matches = body.match(regex) || [];
|
|
270
239
|
|
|
271
240
|
for (const m of matches) {
|