@poolzin/pool-bot 2026.3.17 → 2026.3.18
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/CHANGELOG.md +56 -0
- package/dist/agents/tools/web-fetch.js +1 -1
- package/dist/build-info.json +2 -2
- package/dist/commands/skills-openclaw.command.js +123 -0
- package/dist/config/paths.js +7 -0
- package/dist/infra/net/fetch-guard.js +191 -146
- package/dist/media/fetch.js +83 -112
- package/dist/media/inbound-path-policy.js +90 -97
- package/dist/media/read-response-with-limit.js +49 -26
- package/dist/media-understanding/attachments.js +1 -1
- package/dist/plugin-sdk/audio.js +7 -0
- package/dist/plugin-sdk/bluebubbles.js +7 -0
- package/dist/plugin-sdk/browser.js +7 -0
- package/dist/plugin-sdk/canvas.js +7 -0
- package/dist/plugin-sdk/cron.js +7 -0
- package/dist/plugin-sdk/discord-actions.js +6 -0
- package/dist/plugin-sdk/discord.js +7 -0
- package/dist/plugin-sdk/image.js +7 -0
- package/dist/plugin-sdk/imessage.js +6 -0
- package/dist/plugin-sdk/keyed-async-queue.js +35 -0
- package/dist/plugin-sdk/media.js +8 -0
- package/dist/plugin-sdk/memory.js +7 -0
- package/dist/plugin-sdk/pdf.js +7 -0
- package/dist/plugin-sdk/sessions.js +7 -0
- package/dist/plugin-sdk/signal.js +6 -0
- package/dist/plugin-sdk/slack-actions.js +7 -0
- package/dist/plugin-sdk/slack.js +7 -0
- package/dist/plugin-sdk/telegram-actions.js +6 -0
- package/dist/plugin-sdk/telegram.js +6 -0
- package/dist/plugin-sdk/test-utils.js +110 -0
- package/dist/plugin-sdk/tts.js +7 -0
- package/dist/plugin-sdk/whatsapp.js +6 -0
- package/dist/providers/github-copilot-auth.js +53 -76
- package/dist/providers/github-copilot-models.js +63 -35
- package/dist/providers/github-copilot-token.js +46 -89
- package/dist/security/audit-findings.js +165 -0
- package/dist/security/audit.js +141 -572
- package/dist/skills/openclaw-skill-loader.js +191 -0
- package/dist/slack/monitor/media.js +2 -1
- package/docs/improvements/OPENCLAW-IMPLEMENTATION.md +45 -0
- package/docs/skills/openclaw-integration.md +295 -0
- package/docs/testing/TEST-PLAN-2026-03-13.md +338 -0
- package/extensions/acpx/package.json +19 -0
- package/extensions/acpx/poolbot.plugin.json +9 -0
- package/extensions/acpx/src/index.ts +34 -0
- package/extensions/bluebubbles/src/runtime.ts +1 -0
- package/extensions/diffs/package.json +15 -0
- package/extensions/diffs/poolbot.plugin.json +10 -0
- package/extensions/diffs/src/index.ts +106 -0
- package/extensions/discord/src/runtime.ts +1 -0
- package/extensions/feishu/src/runtime.ts +1 -0
- package/extensions/github-copilot/package.json +28 -0
- package/extensions/github-copilot/poolbot.plugin.json +29 -0
- package/extensions/github-copilot/src/index.ts +126 -0
- package/extensions/github-copilot/tsconfig.json +10 -0
- package/extensions/googlechat/src/runtime.ts +1 -0
- package/extensions/imessage/src/runtime.ts +1 -0
- package/extensions/irc/src/runtime.ts +1 -0
- package/extensions/line/src/runtime.ts +1 -0
- package/extensions/matrix/src/runtime.ts +1 -0
- package/extensions/mattermost/src/mattermost/monitor-helpers.ts +10 -1
- package/extensions/mattermost/src/runtime.ts +6 -3
- package/extensions/msteams/src/runtime.ts +1 -0
- package/extensions/nextcloud-talk/src/runtime.ts +1 -0
- package/extensions/nostr/src/runtime.ts +5 -2
- package/extensions/ollama/package.json +20 -0
- package/extensions/ollama/poolbot.plugin.json +14 -0
- package/extensions/ollama/src/index.ts +95 -0
- package/extensions/sglang/package.json +18 -0
- package/extensions/sglang/poolbot.plugin.json +13 -0
- package/extensions/sglang/src/index.ts +62 -0
- package/extensions/signal/src/runtime.ts +1 -0
- package/extensions/slack/src/runtime.ts +1 -0
- package/extensions/telegram/src/runtime.ts +1 -0
- package/extensions/test-utils/package.json +17 -0
- package/extensions/test-utils/poolbot.plugin.json +16 -0
- package/extensions/test-utils/src/index.ts +220 -0
- package/extensions/tlon/src/runtime.ts +1 -0
- package/extensions/twitch/src/runtime.ts +1 -0
- package/extensions/vllm/package.json +19 -0
- package/extensions/vllm/poolbot.plugin.json +13 -0
- package/extensions/vllm/src/index.ts +90 -0
- package/extensions/whatsapp/src/runtime.ts +1 -0
- package/extensions/zalo/src/runtime.ts +1 -0
- package/extensions/zalouser/src/runtime.ts +1 -0
- package/package.json +77 -3
package/dist/media/fetch.js
CHANGED
|
@@ -1,21 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secure Media Fetch with SSRF Protection
|
|
3
|
+
*/
|
|
1
4
|
import path from "node:path";
|
|
2
|
-
import {
|
|
5
|
+
import { fetchWithGuard } from "../infra/net/fetch-guard.js";
|
|
3
6
|
import { detectMime, extensionForMime } from "./mime.js";
|
|
7
|
+
import { readResponseWithLimit } from "./read-response-with-limit.js";
|
|
4
8
|
export class MediaFetchError extends Error {
|
|
5
9
|
code;
|
|
6
|
-
|
|
10
|
+
httpStatus;
|
|
11
|
+
constructor(code, message, httpStatus) {
|
|
7
12
|
super(message);
|
|
8
13
|
this.code = code;
|
|
9
14
|
this.name = "MediaFetchError";
|
|
15
|
+
this.httpStatus = httpStatus;
|
|
10
16
|
}
|
|
11
17
|
}
|
|
12
18
|
function stripQuotes(value) {
|
|
13
19
|
return value.replace(/^["']|["']$/g, "");
|
|
14
20
|
}
|
|
15
21
|
function parseContentDispositionFileName(header) {
|
|
16
|
-
if (!header)
|
|
22
|
+
if (!header)
|
|
17
23
|
return undefined;
|
|
18
|
-
}
|
|
19
24
|
const starMatch = /filename\*\s*=\s*([^;]+)/i.exec(header);
|
|
20
25
|
if (starMatch?.[1]) {
|
|
21
26
|
const cleaned = stripQuotes(starMatch[1].trim());
|
|
@@ -36,139 +41,105 @@ function parseContentDispositionFileName(header) {
|
|
|
36
41
|
async function readErrorBodySnippet(res, maxChars = 200) {
|
|
37
42
|
try {
|
|
38
43
|
const text = await res.text();
|
|
39
|
-
if (!text)
|
|
44
|
+
if (!text)
|
|
40
45
|
return undefined;
|
|
41
|
-
}
|
|
42
46
|
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
43
|
-
if (!collapsed)
|
|
47
|
+
if (!collapsed)
|
|
44
48
|
return undefined;
|
|
45
|
-
}
|
|
46
|
-
if (collapsed.length <= maxChars) {
|
|
47
|
-
return collapsed;
|
|
48
|
-
}
|
|
49
|
-
return `${collapsed.slice(0, maxChars)}…`;
|
|
49
|
+
return collapsed.length <= maxChars ? collapsed : `${collapsed.slice(0, maxChars)}…`;
|
|
50
50
|
}
|
|
51
51
|
catch {
|
|
52
52
|
return undefined;
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
export async function fetchRemoteMedia(options) {
|
|
56
|
-
const { url,
|
|
57
|
-
let
|
|
58
|
-
let finalUrl = url;
|
|
59
|
-
let release = null;
|
|
56
|
+
const { url, maxBytes = 100 * 1024 * 1024, readIdleTimeoutMs = 30000, maxRedirects = 5, timeoutMs = 60000, allowPrivateIPs = false, filePathHint, fetchImpl: _fetchImpl, ssrfPolicy, } = options;
|
|
57
|
+
let parsedUrl;
|
|
60
58
|
try {
|
|
61
|
-
|
|
62
|
-
url,
|
|
63
|
-
fetchImpl,
|
|
64
|
-
maxRedirects,
|
|
65
|
-
policy: ssrfPolicy,
|
|
66
|
-
lookupFn,
|
|
67
|
-
});
|
|
68
|
-
res = result.response;
|
|
69
|
-
finalUrl = result.finalUrl;
|
|
70
|
-
release = result.release;
|
|
59
|
+
parsedUrl = new URL(url);
|
|
71
60
|
}
|
|
72
|
-
catch (
|
|
73
|
-
throw new MediaFetchError("fetch_failed", `
|
|
61
|
+
catch (error) {
|
|
62
|
+
throw new MediaFetchError("fetch_failed", `Invalid URL: ${error instanceof Error ? error.message : String(error)}`);
|
|
74
63
|
}
|
|
64
|
+
let response;
|
|
75
65
|
try {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
66
|
+
const guardOptions = {
|
|
67
|
+
allowPrivateIPs,
|
|
68
|
+
maxRedirects,
|
|
69
|
+
timeoutMs,
|
|
70
|
+
...ssrfPolicy,
|
|
71
|
+
};
|
|
72
|
+
response = await fetchWithGuard(url, undefined, guardOptions);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (error instanceof Error && error.name.includes("FetchGuard")) {
|
|
76
|
+
const guardError = error;
|
|
77
|
+
if (guardError.code === "ssrf_blocked") {
|
|
78
|
+
throw new MediaFetchError("ssrf_blocked", "SSRF protection blocked this request");
|
|
88
79
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const contentLength = res.headers.get("content-length");
|
|
92
|
-
if (maxBytes && contentLength) {
|
|
93
|
-
const length = Number(contentLength);
|
|
94
|
-
if (Number.isFinite(length) && length > maxBytes) {
|
|
95
|
-
throw new MediaFetchError("max_bytes", `Failed to fetch media from ${url}: content length ${length} exceeds maxBytes ${maxBytes}`);
|
|
80
|
+
if (guardError.code === "timeout") {
|
|
81
|
+
throw new MediaFetchError("timeout", "Request timeout");
|
|
96
82
|
}
|
|
97
83
|
}
|
|
98
|
-
|
|
99
|
-
? await readResponseWithLimit(res, maxBytes)
|
|
100
|
-
: Buffer.from(await res.arrayBuffer());
|
|
101
|
-
let fileNameFromUrl;
|
|
102
|
-
try {
|
|
103
|
-
const parsed = new URL(finalUrl);
|
|
104
|
-
const base = path.basename(parsed.pathname);
|
|
105
|
-
fileNameFromUrl = base || undefined;
|
|
106
|
-
}
|
|
107
|
-
catch {
|
|
108
|
-
// ignore parse errors; leave undefined
|
|
109
|
-
}
|
|
110
|
-
const headerFileName = parseContentDispositionFileName(res.headers.get("content-disposition"));
|
|
111
|
-
let fileName = headerFileName || fileNameFromUrl || (filePathHint ? path.basename(filePathHint) : undefined);
|
|
112
|
-
const filePathForMime = headerFileName && path.extname(headerFileName) ? headerFileName : (filePathHint ?? finalUrl);
|
|
113
|
-
const contentType = await detectMime({
|
|
114
|
-
buffer,
|
|
115
|
-
headerMime: res.headers.get("content-type"),
|
|
116
|
-
filePath: filePathForMime,
|
|
117
|
-
});
|
|
118
|
-
if (fileName && !path.extname(fileName) && contentType) {
|
|
119
|
-
const ext = extensionForMime(contentType);
|
|
120
|
-
if (ext) {
|
|
121
|
-
fileName = `${fileName}${ext}`;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
return {
|
|
125
|
-
buffer,
|
|
126
|
-
contentType: contentType ?? undefined,
|
|
127
|
-
fileName,
|
|
128
|
-
};
|
|
84
|
+
throw new MediaFetchError("fetch_failed", error instanceof Error ? error.message : String(error));
|
|
129
85
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
86
|
+
if (!response.ok) {
|
|
87
|
+
const errorBody = await readErrorBodySnippet(response);
|
|
88
|
+
throw new MediaFetchError("http_error", `HTTP ${response.status}${errorBody ? `: ${errorBody}` : ""}`, response.status);
|
|
134
89
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (!
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
90
|
+
const contentType = response.headers.get("content-type") || undefined;
|
|
91
|
+
const contentDisposition = response.headers.get("content-disposition");
|
|
92
|
+
let fileName = parseContentDispositionFileName(contentDisposition);
|
|
93
|
+
if (!fileName && filePathHint) {
|
|
94
|
+
fileName = path.basename(filePathHint);
|
|
95
|
+
}
|
|
96
|
+
if (!fileName) {
|
|
97
|
+
const urlPath = parsedUrl.pathname;
|
|
98
|
+
if (urlPath && urlPath !== "/") {
|
|
99
|
+
fileName = path.basename(urlPath);
|
|
142
100
|
}
|
|
143
|
-
return fallback;
|
|
144
101
|
}
|
|
145
|
-
|
|
146
|
-
const chunks = [];
|
|
147
|
-
let total = 0;
|
|
102
|
+
let buffer;
|
|
148
103
|
try {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
104
|
+
buffer = await readResponseWithLimit(response, {
|
|
105
|
+
maxBytes,
|
|
106
|
+
readIdleTimeoutMs,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
if (error instanceof Error && error.name === "ReadResponseError") {
|
|
111
|
+
const readError = error;
|
|
112
|
+
if (readError.code === "max_bytes") {
|
|
113
|
+
throw new MediaFetchError("max_bytes", `Download exceeds size limit (${maxBytes} bytes)`);
|
|
153
114
|
}
|
|
154
|
-
if (
|
|
155
|
-
|
|
156
|
-
if (total > maxBytes) {
|
|
157
|
-
try {
|
|
158
|
-
await reader.cancel();
|
|
159
|
-
}
|
|
160
|
-
catch { }
|
|
161
|
-
throw new MediaFetchError("max_bytes", `Failed to fetch media from ${res.url || "response"}: payload exceeds maxBytes ${maxBytes}`);
|
|
162
|
-
}
|
|
163
|
-
chunks.push(value);
|
|
115
|
+
if (readError.code === "timeout") {
|
|
116
|
+
throw new MediaFetchError("timeout", "Download timeout - connection stalled");
|
|
164
117
|
}
|
|
165
118
|
}
|
|
119
|
+
throw new MediaFetchError("fetch_failed", error instanceof Error ? error.message : String(error));
|
|
166
120
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
121
|
+
let finalContentType = contentType;
|
|
122
|
+
if (!finalContentType) {
|
|
123
|
+
finalContentType = await detectMime({ buffer });
|
|
124
|
+
}
|
|
125
|
+
if (fileName && finalContentType) {
|
|
126
|
+
const ext = extensionForMime(finalContentType);
|
|
127
|
+
if (ext && !fileName.toLowerCase().endsWith(ext.toLowerCase())) {
|
|
128
|
+
fileName = fileName + ext;
|
|
170
129
|
}
|
|
171
|
-
catch { }
|
|
172
130
|
}
|
|
173
|
-
return
|
|
131
|
+
return {
|
|
132
|
+
buffer,
|
|
133
|
+
contentType: finalContentType,
|
|
134
|
+
fileName,
|
|
135
|
+
size: buffer.length,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
export async function quickFetchMedia(url) {
|
|
139
|
+
return fetchRemoteMedia({
|
|
140
|
+
url,
|
|
141
|
+
maxBytes: 50 * 1024 * 1024,
|
|
142
|
+
readIdleTimeoutMs: 15000,
|
|
143
|
+
timeoutMs: 30000,
|
|
144
|
+
});
|
|
174
145
|
}
|
|
@@ -1,114 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound Path Policy
|
|
3
|
+
*
|
|
4
|
+
* Validates and sanitizes file paths to prevent path traversal attacks
|
|
5
|
+
*/
|
|
1
6
|
import path from "node:path";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
if (!trimmed || trimmed.includes("\0")) {
|
|
9
|
-
return undefined;
|
|
7
|
+
export class InboundPathError extends Error {
|
|
8
|
+
code;
|
|
9
|
+
constructor(code, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.name = "InboundPathError";
|
|
10
13
|
}
|
|
11
|
-
const normalized = path.posix.normalize(trimmed.replaceAll("\\", "/"));
|
|
12
|
-
const isAbsolute = normalized.startsWith("/") || WINDOWS_DRIVE_ABS_RE.test(normalized);
|
|
13
|
-
if (!isAbsolute || normalized === "/") {
|
|
14
|
-
return undefined;
|
|
15
|
-
}
|
|
16
|
-
const withoutTrailingSlash = normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
|
|
17
|
-
if (WINDOWS_DRIVE_ROOT_RE.test(withoutTrailingSlash)) {
|
|
18
|
-
return undefined;
|
|
19
|
-
}
|
|
20
|
-
return withoutTrailingSlash;
|
|
21
|
-
}
|
|
22
|
-
function splitPathSegments(value) {
|
|
23
|
-
return value.split("/").filter(Boolean);
|
|
24
14
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Validate and resolve an inbound file path
|
|
17
|
+
*/
|
|
18
|
+
export function validateInboundPath(inputPath, options) {
|
|
19
|
+
const { baseDir, allowOutside = false, allowedExtensions, maxLength = 1024, } = options;
|
|
20
|
+
// Check path length
|
|
21
|
+
if (inputPath.length > maxLength) {
|
|
22
|
+
throw new InboundPathError("too_long", `Path exceeds maximum length (${maxLength} chars)`);
|
|
30
23
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
continue;
|
|
36
|
-
}
|
|
37
|
-
if (expected !== actual) {
|
|
38
|
-
return false;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
return true;
|
|
42
|
-
}
|
|
43
|
-
export function isValidInboundPathRootPattern(value) {
|
|
44
|
-
const normalized = normalizePosixAbsolutePath(value);
|
|
45
|
-
if (!normalized) {
|
|
46
|
-
return false;
|
|
47
|
-
}
|
|
48
|
-
const segments = splitPathSegments(normalized);
|
|
49
|
-
if (segments.length === 0) {
|
|
50
|
-
return false;
|
|
24
|
+
// Check for invalid characters
|
|
25
|
+
const invalidChars = /["<>:"|?*]/g;
|
|
26
|
+
if (invalidChars.test(inputPath)) {
|
|
27
|
+
throw new InboundPathError("invalid_chars", "Path contains invalid characters");
|
|
51
28
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
continue;
|
|
63
|
-
}
|
|
64
|
-
const candidate = normalizePosixAbsolutePath(root);
|
|
65
|
-
if (!candidate || seen.has(candidate)) {
|
|
66
|
-
continue;
|
|
29
|
+
// Normalize and resolve path
|
|
30
|
+
const normalizedPath = path.normalize(inputPath);
|
|
31
|
+
const resolvedPath = path.resolve(baseDir, normalizedPath);
|
|
32
|
+
// Check for path traversal
|
|
33
|
+
if (!allowOutside) {
|
|
34
|
+
const normalizedBaseDir = path.resolve(baseDir);
|
|
35
|
+
// Ensure resolved path starts with base directory
|
|
36
|
+
if (!resolvedPath.startsWith(normalizedBaseDir + path.sep) &&
|
|
37
|
+
resolvedPath !== normalizedBaseDir) {
|
|
38
|
+
throw new InboundPathError("path_traversal", "Path traversal detected - path must be within base directory");
|
|
67
39
|
}
|
|
68
|
-
seen.add(candidate);
|
|
69
|
-
normalized.push(candidate);
|
|
70
40
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
for (const roots of rootsLists) {
|
|
77
|
-
const normalized = normalizeInboundPathRoots(roots);
|
|
78
|
-
for (const root of normalized) {
|
|
79
|
-
if (seen.has(root)) {
|
|
80
|
-
continue;
|
|
81
|
-
}
|
|
82
|
-
seen.add(root);
|
|
83
|
-
merged.push(root);
|
|
41
|
+
// Check file extension
|
|
42
|
+
if (allowedExtensions && allowedExtensions.length > 0) {
|
|
43
|
+
const ext = path.extname(resolvedPath).toLowerCase();
|
|
44
|
+
if (!allowedExtensions.includes(ext)) {
|
|
45
|
+
throw new InboundPathError("invalid_extension", `Extension ${ext} not allowed. Allowed: ${allowedExtensions.join(", ")}`);
|
|
84
46
|
}
|
|
85
47
|
}
|
|
86
|
-
return
|
|
48
|
+
return resolvedPath;
|
|
87
49
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Sanitize a filename for safe storage
|
|
52
|
+
*/
|
|
53
|
+
export function sanitizeFilename(filename) {
|
|
54
|
+
// Remove path components
|
|
55
|
+
let safe = path.basename(filename);
|
|
56
|
+
// Remove or replace unsafe characters
|
|
57
|
+
safe = safe.replace(/["<>:"|?*]/g, "_");
|
|
58
|
+
// Trim leading/trailing spaces and dots
|
|
59
|
+
safe = safe.replace(/^[\s.]+|[\s.]+$/g, "");
|
|
60
|
+
// Limit length
|
|
61
|
+
if (safe.length > 255) {
|
|
62
|
+
const ext = path.extname(safe);
|
|
63
|
+
const name = path.basename(safe, ext);
|
|
64
|
+
safe = name.slice(0, 255 - ext.length) + ext;
|
|
92
65
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
return false;
|
|
66
|
+
// Ensure not empty
|
|
67
|
+
if (!safe) {
|
|
68
|
+
safe = "unnamed";
|
|
97
69
|
}
|
|
98
|
-
return
|
|
70
|
+
return safe;
|
|
99
71
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Check if a path is safe (within allowed directory)
|
|
74
|
+
*/
|
|
75
|
+
export function isPathSafe(targetPath, baseDir) {
|
|
76
|
+
try {
|
|
77
|
+
validateInboundPath(targetPath, { baseDir });
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return false;
|
|
104
82
|
}
|
|
105
|
-
return params.cfg.channels?.imessage?.accounts?.[accountId];
|
|
106
83
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
84
|
+
/**
|
|
85
|
+
* Check if a path matches allowed patterns
|
|
86
|
+
*/
|
|
87
|
+
export function isValidInboundPathRootPattern(pattern) {
|
|
88
|
+
// Basic validation - should not contain traversal sequences
|
|
89
|
+
return !pattern.includes("..") && !pattern.includes("\\");
|
|
110
90
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
91
|
+
/**
|
|
92
|
+
* Check if an inbound path is allowed
|
|
93
|
+
* Supports both (targetPath, baseDir) and ({ filePath, roots }) signatures
|
|
94
|
+
*/
|
|
95
|
+
export function isInboundPathAllowed(targetPathOrOptions, baseDir) {
|
|
96
|
+
// Handle object signature: { filePath, roots }
|
|
97
|
+
if (typeof targetPathOrOptions === "object") {
|
|
98
|
+
const { filePath, roots } = targetPathOrOptions;
|
|
99
|
+
// Check if path is within any of the allowed roots
|
|
100
|
+
return roots.some((root) => isPathSafe(filePath, root));
|
|
101
|
+
}
|
|
102
|
+
// Handle string signature: (targetPath, baseDir)
|
|
103
|
+
if (baseDir) {
|
|
104
|
+
return isPathSafe(targetPathOrOptions, baseDir);
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
114
107
|
}
|
|
@@ -1,41 +1,64 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Read HTTP response body with limits and idle timeout
|
|
3
|
+
*
|
|
4
|
+
* Prevents hanging on slow/stalled downloads
|
|
5
|
+
*/
|
|
6
|
+
export class ReadResponseError extends Error {
|
|
7
|
+
code;
|
|
8
|
+
constructor(code, message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.name = "ReadResponseError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export async function readResponseWithLimit(response, options = {}) {
|
|
15
|
+
const { maxBytes = 100 * 1024 * 1024, // 100MB default
|
|
16
|
+
readIdleTimeoutMs = 30000, // 30s default
|
|
17
|
+
} = options;
|
|
18
|
+
if (!response.body) {
|
|
19
|
+
throw new ReadResponseError("read_failed", "Response body is null");
|
|
11
20
|
}
|
|
12
|
-
const reader = body.getReader();
|
|
13
21
|
const chunks = [];
|
|
14
|
-
let
|
|
22
|
+
let totalBytes = 0;
|
|
23
|
+
let lastReadTime = Date.now();
|
|
24
|
+
const reader = response.body.getReader();
|
|
15
25
|
try {
|
|
16
26
|
while (true) {
|
|
17
|
-
|
|
27
|
+
// Check idle timeout
|
|
28
|
+
const idleTime = Date.now() - lastReadTime;
|
|
29
|
+
if (idleTime > readIdleTimeoutMs) {
|
|
30
|
+
throw new ReadResponseError("timeout", `Read idle timeout exceeded (${readIdleTimeoutMs}ms)`);
|
|
31
|
+
}
|
|
32
|
+
const { done, value } = await Promise.race([
|
|
33
|
+
reader.read(),
|
|
34
|
+
new Promise((_, reject) => {
|
|
35
|
+
setTimeout(() => {
|
|
36
|
+
reject(new ReadResponseError("timeout", "Read timeout"));
|
|
37
|
+
}, readIdleTimeoutMs);
|
|
38
|
+
}),
|
|
39
|
+
]);
|
|
40
|
+
lastReadTime = Date.now();
|
|
18
41
|
if (done) {
|
|
19
42
|
break;
|
|
20
43
|
}
|
|
21
|
-
if (value
|
|
22
|
-
|
|
23
|
-
if (
|
|
24
|
-
|
|
25
|
-
await reader.cancel();
|
|
26
|
-
}
|
|
27
|
-
catch { }
|
|
28
|
-
throw onOverflow({ size: total, maxBytes, res });
|
|
44
|
+
if (value) {
|
|
45
|
+
totalBytes += value.length;
|
|
46
|
+
if (totalBytes > maxBytes) {
|
|
47
|
+
throw new ReadResponseError("max_bytes", `Response size exceeds limit (${maxBytes} bytes)`);
|
|
29
48
|
}
|
|
30
49
|
chunks.push(value);
|
|
31
50
|
}
|
|
32
51
|
}
|
|
33
52
|
}
|
|
34
53
|
finally {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
54
|
+
reader.releaseLock();
|
|
55
|
+
}
|
|
56
|
+
// Concatenate chunks
|
|
57
|
+
const result = new Uint8Array(totalBytes);
|
|
58
|
+
let offset = 0;
|
|
59
|
+
for (const chunk of chunks) {
|
|
60
|
+
result.set(chunk, offset);
|
|
61
|
+
offset += chunk.length;
|
|
39
62
|
}
|
|
40
|
-
return Buffer.
|
|
63
|
+
return Buffer.from(result);
|
|
41
64
|
}
|
|
@@ -205,7 +205,7 @@ export class MediaAttachmentCache {
|
|
|
205
205
|
throw new MediaUnderstandingSkipError("empty", `Attachment ${params.attachmentIndex + 1} has no path or URL.`);
|
|
206
206
|
}
|
|
207
207
|
try {
|
|
208
|
-
const fetchImpl = (input, init) => fetchWithTimeout(resolveRequestUrl(input), init ?? {}, params.timeoutMs, fetch);
|
|
208
|
+
const fetchImpl = ((input, init) => fetchWithTimeout(resolveRequestUrl(input), init ?? {}, params.timeoutMs, fetch));
|
|
209
209
|
const fetched = await fetchRemoteMedia({ url, fetchImpl, maxBytes: params.maxBytes });
|
|
210
210
|
entry.buffer = fetched.buffer;
|
|
211
211
|
entry.bufferMime =
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pool Bot Plugin SDK - BlueBubbles
|
|
3
|
+
*
|
|
4
|
+
* Re-exports BlueBubbles-specific plugin utilities
|
|
5
|
+
*/
|
|
6
|
+
export { createBlueBubblesChannel, } from "../bluebubbles/channel.js";
|
|
7
|
+
export { BLUEBUBBLES_ACTIONS, BLUEBUBBLES_ACTION_NAMES, BLUEBUBBLES_GROUP_ACTIONS, } from "../bluebubbles/actions.js";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pool Bot Plugin SDK - Discord Actions
|
|
3
|
+
*
|
|
4
|
+
* Re-exports Discord action utilities
|
|
5
|
+
*/
|
|
6
|
+
export { discordSendMessage, discordEditMessage, discordDeleteMessage, discordCreateThread, discordPinMessage, discordUnpinMessage, discordAddReaction, discordRemoveReaction, } from "../discord/actions.js";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pool Bot Plugin SDK - Discord
|
|
3
|
+
*
|
|
4
|
+
* Re-exports Discord-specific plugin utilities
|
|
5
|
+
*/
|
|
6
|
+
export { createDiscordChannel, } from "../discord/channel.js";
|
|
7
|
+
export { autoBindSpawnedDiscordSubagent, listThreadBindingsBySessionKey, unbindThreadBindingsBySessionKey, } from "../discord/monitor/thread-bindings.js";
|