@poolzin/pool-bot 2026.3.4 → 2026.3.7
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 +10 -0
- package/assets/pool-bot-icon-dark.png +0 -0
- package/assets/pool-bot-logo-1.png +0 -0
- package/assets/pool-bot-mascot.png +0 -0
- package/dist/agents/pi-embedded-runner/tool-result-truncation.js +62 -7
- package/dist/agents/pi-tools.js +32 -2
- package/dist/agents/poolbot-tools.js +12 -0
- package/dist/agents/session-write-lock.js +93 -8
- package/dist/agents/tools/pdf-native-providers.js +102 -0
- package/dist/agents/tools/pdf-tool.helpers.js +86 -0
- package/dist/agents/tools/pdf-tool.js +508 -0
- package/dist/auto-reply/reply/get-reply.js +6 -0
- package/dist/auto-reply/reply/message-preprocess-hooks.js +17 -0
- package/dist/build-info.json +3 -3
- package/dist/cli/banner.js +20 -1
- package/dist/cli/security-cli.js +211 -2
- package/dist/cli/tagline.js +7 -0
- package/dist/config/types.cli.js +1 -0
- package/dist/config/types.security.js +33 -0
- package/dist/config/zod-schema.js +15 -0
- package/dist/config/zod-schema.providers-core.js +1 -0
- package/dist/config/zod-schema.security.js +113 -0
- package/dist/cron/normalize.js +3 -0
- package/dist/cron/service/jobs.js +48 -0
- package/dist/discord/monitor/message-handler.preflight.js +11 -2
- package/dist/gateway/http-common.js +6 -1
- package/dist/gateway/protocol/schema/cron.js +3 -0
- package/dist/gateway/server-channels.js +99 -14
- package/dist/gateway/server-cron.js +89 -0
- package/dist/gateway/server-health-probes.js +55 -0
- package/dist/gateway/server-http.js +5 -0
- package/dist/hooks/bundled/session-memory/handler.js +8 -2
- package/dist/hooks/fire-and-forget.js +6 -0
- package/dist/hooks/internal-hooks.js +64 -19
- package/dist/hooks/message-hook-mappers.js +179 -0
- package/dist/infra/abort-signal.js +12 -0
- package/dist/infra/boundary-file-read.js +118 -0
- package/dist/infra/boundary-path.js +594 -0
- package/dist/infra/file-identity.js +12 -0
- package/dist/infra/fs-safe.js +377 -12
- package/dist/infra/hardlink-guards.js +30 -0
- package/dist/infra/json-utf8-bytes.js +8 -0
- package/dist/infra/net/fetch-guard.js +63 -13
- package/dist/infra/net/proxy-env.js +17 -0
- package/dist/infra/net/ssrf.js +74 -272
- package/dist/infra/path-alias-guards.js +21 -0
- package/dist/infra/path-guards.js +13 -1
- package/dist/infra/ports-probe.js +19 -0
- package/dist/infra/prototype-keys.js +4 -0
- package/dist/infra/restart-stale-pids.js +254 -0
- package/dist/infra/safe-open-sync.js +71 -0
- package/dist/infra/secure-random.js +7 -0
- package/dist/media/ffmpeg-limits.js +4 -0
- package/dist/media/input-files.js +6 -2
- package/dist/media/temp-files.js +12 -0
- package/dist/memory/embedding-chunk-limits.js +5 -2
- package/dist/memory/embeddings-ollama.js +91 -138
- package/dist/memory/embeddings-remote-fetch.js +11 -10
- package/dist/memory/embeddings.js +25 -9
- package/dist/memory/manager-embedding-ops.js +1 -1
- package/dist/memory/post-json.js +23 -0
- package/dist/memory/qmd-manager.js +272 -77
- package/dist/memory/remote-http.js +33 -0
- package/dist/plugin-sdk/windows-spawn.js +214 -0
- package/dist/security/capability-guards.js +89 -0
- package/dist/security/capability-manager.js +76 -0
- package/dist/security/capability.js +147 -0
- package/dist/security/index.js +7 -0
- package/dist/security/middleware.js +105 -0
- package/dist/shared/net/ip-test-fixtures.js +1 -0
- package/dist/shared/net/ip.js +303 -0
- package/dist/shared/net/ipv4.js +8 -11
- package/dist/shared/pid-alive.js +59 -2
- package/dist/slack/monitor/context.js +1 -0
- package/dist/slack/monitor/message-handler/dispatch.js +14 -1
- package/dist/slack/monitor/provider.js +2 -0
- package/dist/test-helpers/ssrf.js +13 -0
- package/dist/tui/tui.js +9 -4
- package/dist/utils/fetch-timeout.js +12 -1
- package/docs/adr/003-feature-gap-analysis.md +112 -0
- package/package.json +10 -4
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import ipaddr from "ipaddr.js";
|
|
2
|
+
const BLOCKED_IPV4_SPECIAL_USE_RANGES = new Set([
|
|
3
|
+
"unspecified",
|
|
4
|
+
"broadcast",
|
|
5
|
+
"multicast",
|
|
6
|
+
"linkLocal",
|
|
7
|
+
"loopback",
|
|
8
|
+
"carrierGradeNat",
|
|
9
|
+
"private",
|
|
10
|
+
"reserved",
|
|
11
|
+
]);
|
|
12
|
+
const PRIVATE_OR_LOOPBACK_IPV4_RANGES = new Set([
|
|
13
|
+
"loopback",
|
|
14
|
+
"private",
|
|
15
|
+
"linkLocal",
|
|
16
|
+
"carrierGradeNat",
|
|
17
|
+
]);
|
|
18
|
+
const BLOCKED_IPV6_SPECIAL_USE_RANGES = new Set([
|
|
19
|
+
"unspecified",
|
|
20
|
+
"loopback",
|
|
21
|
+
"linkLocal",
|
|
22
|
+
"uniqueLocal",
|
|
23
|
+
"multicast",
|
|
24
|
+
]);
|
|
25
|
+
const RFC2544_BENCHMARK_PREFIX = [ipaddr.IPv4.parse("198.18.0.0"), 15];
|
|
26
|
+
const EMBEDDED_IPV4_SENTINEL_RULES = [
|
|
27
|
+
{
|
|
28
|
+
// IPv4-compatible form ::w.x.y.z (deprecated, but still seen in parser edge-cases).
|
|
29
|
+
matches: (parts) => parts[0] === 0 &&
|
|
30
|
+
parts[1] === 0 &&
|
|
31
|
+
parts[2] === 0 &&
|
|
32
|
+
parts[3] === 0 &&
|
|
33
|
+
parts[4] === 0 &&
|
|
34
|
+
parts[5] === 0,
|
|
35
|
+
toHextets: (parts) => [parts[6], parts[7]],
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
// NAT64 local-use prefix: 64:ff9b:1::/48.
|
|
39
|
+
matches: (parts) => parts[0] === 0x0064 &&
|
|
40
|
+
parts[1] === 0xff9b &&
|
|
41
|
+
parts[2] === 0x0001 &&
|
|
42
|
+
parts[3] === 0 &&
|
|
43
|
+
parts[4] === 0 &&
|
|
44
|
+
parts[5] === 0,
|
|
45
|
+
toHextets: (parts) => [parts[6], parts[7]],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
// 6to4 prefix: 2002::/16 (IPv4 lives in hextets 1..2).
|
|
49
|
+
matches: (parts) => parts[0] === 0x2002,
|
|
50
|
+
toHextets: (parts) => [parts[1], parts[2]],
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
// Teredo prefix: 2001:0000::/32 (client IPv4 XOR 0xffff in hextets 6..7).
|
|
54
|
+
matches: (parts) => parts[0] === 0x2001 && parts[1] === 0x0000,
|
|
55
|
+
toHextets: (parts) => [parts[6] ^ 0xffff, parts[7] ^ 0xffff],
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
// ISATAP IID marker: ....:0000:5efe:w.x.y.z with u/g bits allowed in hextet 4.
|
|
59
|
+
matches: (parts) => (parts[4] & 0xfcff) === 0 && parts[5] === 0x5efe,
|
|
60
|
+
toHextets: (parts) => [parts[6], parts[7]],
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
function stripIpv6Brackets(value) {
|
|
64
|
+
if (value.startsWith("[") && value.endsWith("]")) {
|
|
65
|
+
return value.slice(1, -1);
|
|
66
|
+
}
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
function isNumericIpv4LiteralPart(value) {
|
|
70
|
+
return /^[0-9]+$/.test(value) || /^0x[0-9a-f]+$/i.test(value);
|
|
71
|
+
}
|
|
72
|
+
function parseIpv6WithEmbeddedIpv4(raw) {
|
|
73
|
+
if (!raw.includes(":") || !raw.includes(".")) {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
const match = /^(.*:)([^:%]+(?:\.[^:%]+){3})(%[0-9A-Za-z]+)?$/i.exec(raw);
|
|
77
|
+
if (!match) {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
const [, prefix, embeddedIpv4, zoneSuffix = ""] = match;
|
|
81
|
+
if (!ipaddr.IPv4.isValidFourPartDecimal(embeddedIpv4)) {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
const octets = embeddedIpv4.split(".").map((part) => Number.parseInt(part, 10));
|
|
85
|
+
const high = ((octets[0] << 8) | octets[1]).toString(16);
|
|
86
|
+
const low = ((octets[2] << 8) | octets[3]).toString(16);
|
|
87
|
+
const normalizedIpv6 = `${prefix}${high}:${low}${zoneSuffix}`;
|
|
88
|
+
if (!ipaddr.IPv6.isValid(normalizedIpv6)) {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
return ipaddr.IPv6.parse(normalizedIpv6);
|
|
92
|
+
}
|
|
93
|
+
export function isIpv4Address(address) {
|
|
94
|
+
return address.kind() === "ipv4";
|
|
95
|
+
}
|
|
96
|
+
export function isIpv6Address(address) {
|
|
97
|
+
return address.kind() === "ipv6";
|
|
98
|
+
}
|
|
99
|
+
function normalizeIpv4MappedAddress(address) {
|
|
100
|
+
if (!isIpv6Address(address)) {
|
|
101
|
+
return address;
|
|
102
|
+
}
|
|
103
|
+
if (!address.isIPv4MappedAddress()) {
|
|
104
|
+
return address;
|
|
105
|
+
}
|
|
106
|
+
return address.toIPv4Address();
|
|
107
|
+
}
|
|
108
|
+
export function parseCanonicalIpAddress(raw) {
|
|
109
|
+
const trimmed = raw?.trim();
|
|
110
|
+
if (!trimmed) {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
const normalized = stripIpv6Brackets(trimmed);
|
|
114
|
+
if (!normalized) {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
if (ipaddr.IPv4.isValid(normalized)) {
|
|
118
|
+
if (!ipaddr.IPv4.isValidFourPartDecimal(normalized)) {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
return ipaddr.IPv4.parse(normalized);
|
|
122
|
+
}
|
|
123
|
+
if (ipaddr.IPv6.isValid(normalized)) {
|
|
124
|
+
return ipaddr.IPv6.parse(normalized);
|
|
125
|
+
}
|
|
126
|
+
return parseIpv6WithEmbeddedIpv4(normalized);
|
|
127
|
+
}
|
|
128
|
+
export function parseLooseIpAddress(raw) {
|
|
129
|
+
const trimmed = raw?.trim();
|
|
130
|
+
if (!trimmed) {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
const normalized = stripIpv6Brackets(trimmed);
|
|
134
|
+
if (!normalized) {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
if (ipaddr.isValid(normalized)) {
|
|
138
|
+
return ipaddr.parse(normalized);
|
|
139
|
+
}
|
|
140
|
+
return parseIpv6WithEmbeddedIpv4(normalized);
|
|
141
|
+
}
|
|
142
|
+
export function normalizeIpAddress(raw) {
|
|
143
|
+
const parsed = parseCanonicalIpAddress(raw);
|
|
144
|
+
if (!parsed) {
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
const normalized = normalizeIpv4MappedAddress(parsed);
|
|
148
|
+
return normalized.toString().toLowerCase();
|
|
149
|
+
}
|
|
150
|
+
export function isCanonicalDottedDecimalIPv4(raw) {
|
|
151
|
+
const trimmed = raw?.trim();
|
|
152
|
+
if (!trimmed) {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
const normalized = stripIpv6Brackets(trimmed);
|
|
156
|
+
if (!normalized) {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
return ipaddr.IPv4.isValidFourPartDecimal(normalized);
|
|
160
|
+
}
|
|
161
|
+
export function isLegacyIpv4Literal(raw) {
|
|
162
|
+
const trimmed = raw?.trim();
|
|
163
|
+
if (!trimmed) {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
const normalized = stripIpv6Brackets(trimmed);
|
|
167
|
+
if (!normalized || normalized.includes(":")) {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
if (isCanonicalDottedDecimalIPv4(normalized)) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
const parts = normalized.split(".");
|
|
174
|
+
if (parts.length === 0 || parts.length > 4) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
if (parts.some((part) => part.length === 0)) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
if (!parts.every((part) => isNumericIpv4LiteralPart(part))) {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
export function isLoopbackIpAddress(raw) {
|
|
186
|
+
const parsed = parseCanonicalIpAddress(raw);
|
|
187
|
+
if (!parsed) {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
const normalized = normalizeIpv4MappedAddress(parsed);
|
|
191
|
+
return normalized.range() === "loopback";
|
|
192
|
+
}
|
|
193
|
+
export function isPrivateOrLoopbackIpAddress(raw) {
|
|
194
|
+
const parsed = parseCanonicalIpAddress(raw);
|
|
195
|
+
if (!parsed) {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
const normalized = normalizeIpv4MappedAddress(parsed);
|
|
199
|
+
if (isIpv4Address(normalized)) {
|
|
200
|
+
return PRIVATE_OR_LOOPBACK_IPV4_RANGES.has(normalized.range());
|
|
201
|
+
}
|
|
202
|
+
return isBlockedSpecialUseIpv6Address(normalized);
|
|
203
|
+
}
|
|
204
|
+
export function isBlockedSpecialUseIpv6Address(address) {
|
|
205
|
+
if (BLOCKED_IPV6_SPECIAL_USE_RANGES.has(address.range())) {
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
// ipaddr.js does not classify deprecated site-local fec0::/10 as private.
|
|
209
|
+
return (address.parts[0] & 0xffc0) === 0xfec0;
|
|
210
|
+
}
|
|
211
|
+
export function isRfc1918Ipv4Address(raw) {
|
|
212
|
+
const parsed = parseCanonicalIpAddress(raw);
|
|
213
|
+
if (!parsed || !isIpv4Address(parsed)) {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
return parsed.range() === "private";
|
|
217
|
+
}
|
|
218
|
+
export function isCarrierGradeNatIpv4Address(raw) {
|
|
219
|
+
const parsed = parseCanonicalIpAddress(raw);
|
|
220
|
+
if (!parsed || !isIpv4Address(parsed)) {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
return parsed.range() === "carrierGradeNat";
|
|
224
|
+
}
|
|
225
|
+
export function isBlockedSpecialUseIpv4Address(address, options = {}) {
|
|
226
|
+
const inRfc2544BenchmarkRange = address.match(RFC2544_BENCHMARK_PREFIX);
|
|
227
|
+
if (inRfc2544BenchmarkRange && options.allowRfc2544BenchmarkRange === true) {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
return BLOCKED_IPV4_SPECIAL_USE_RANGES.has(address.range()) || inRfc2544BenchmarkRange;
|
|
231
|
+
}
|
|
232
|
+
function decodeIpv4FromHextets(high, low) {
|
|
233
|
+
const octets = [
|
|
234
|
+
(high >>> 8) & 0xff,
|
|
235
|
+
high & 0xff,
|
|
236
|
+
(low >>> 8) & 0xff,
|
|
237
|
+
low & 0xff,
|
|
238
|
+
];
|
|
239
|
+
return ipaddr.IPv4.parse(octets.join("."));
|
|
240
|
+
}
|
|
241
|
+
export function extractEmbeddedIpv4FromIpv6(address) {
|
|
242
|
+
if (address.isIPv4MappedAddress()) {
|
|
243
|
+
return address.toIPv4Address();
|
|
244
|
+
}
|
|
245
|
+
if (address.range() === "rfc6145") {
|
|
246
|
+
return decodeIpv4FromHextets(address.parts[6], address.parts[7]);
|
|
247
|
+
}
|
|
248
|
+
if (address.range() === "rfc6052") {
|
|
249
|
+
return decodeIpv4FromHextets(address.parts[6], address.parts[7]);
|
|
250
|
+
}
|
|
251
|
+
for (const rule of EMBEDDED_IPV4_SENTINEL_RULES) {
|
|
252
|
+
if (!rule.matches(address.parts)) {
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const [high, low] = rule.toHextets(address.parts);
|
|
256
|
+
return decodeIpv4FromHextets(high, low);
|
|
257
|
+
}
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
260
|
+
export function isIpInCidr(ip, cidr) {
|
|
261
|
+
const normalizedIp = parseCanonicalIpAddress(ip);
|
|
262
|
+
if (!normalizedIp) {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
const candidate = cidr.trim();
|
|
266
|
+
if (!candidate) {
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
const comparableIp = normalizeIpv4MappedAddress(normalizedIp);
|
|
270
|
+
if (!candidate.includes("/")) {
|
|
271
|
+
const exact = parseCanonicalIpAddress(candidate);
|
|
272
|
+
if (!exact) {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
const comparableExact = normalizeIpv4MappedAddress(exact);
|
|
276
|
+
return (comparableIp.kind() === comparableExact.kind() &&
|
|
277
|
+
comparableIp.toString() === comparableExact.toString());
|
|
278
|
+
}
|
|
279
|
+
let parsedCidr;
|
|
280
|
+
try {
|
|
281
|
+
parsedCidr = ipaddr.parseCIDR(candidate);
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
const [baseAddress, prefixLength] = parsedCidr;
|
|
287
|
+
const comparableBase = normalizeIpv4MappedAddress(baseAddress);
|
|
288
|
+
if (comparableIp.kind() !== comparableBase.kind()) {
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
if (isIpv4Address(comparableIp) && isIpv4Address(comparableBase)) {
|
|
293
|
+
return comparableIp.match([comparableBase, prefixLength]);
|
|
294
|
+
}
|
|
295
|
+
if (isIpv6Address(comparableIp) && isIpv6Address(comparableBase)) {
|
|
296
|
+
return comparableIp.match([comparableBase, prefixLength]);
|
|
297
|
+
}
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
}
|
package/dist/shared/net/ipv4.js
CHANGED
|
@@ -1,17 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
import { isCanonicalDottedDecimalIPv4 } from "./ip.js";
|
|
2
|
+
export function validateDottedDecimalIPv4Input(value) {
|
|
2
3
|
if (!value) {
|
|
3
4
|
return "IP address is required for custom bind mode";
|
|
4
5
|
}
|
|
5
|
-
|
|
6
|
-
const parts = trimmed.split(".");
|
|
7
|
-
if (parts.length !== 4) {
|
|
8
|
-
return "Invalid IPv4 address (e.g., 192.168.1.100)";
|
|
9
|
-
}
|
|
10
|
-
if (parts.every((part) => {
|
|
11
|
-
const n = parseInt(part, 10);
|
|
12
|
-
return !Number.isNaN(n) && n >= 0 && n <= 255 && part === String(n);
|
|
13
|
-
})) {
|
|
6
|
+
if (isCanonicalDottedDecimalIPv4(value)) {
|
|
14
7
|
return undefined;
|
|
15
8
|
}
|
|
16
|
-
return "Invalid IPv4 address (
|
|
9
|
+
return "Invalid IPv4 address (e.g., 192.168.1.100)";
|
|
10
|
+
}
|
|
11
|
+
// Backward-compatible alias for callers using the old helper name.
|
|
12
|
+
export function validateIPv4AddressInput(value) {
|
|
13
|
+
return validateDottedDecimalIPv4Input(value);
|
|
17
14
|
}
|
package/dist/shared/pid-alive.js
CHANGED
|
@@ -1,12 +1,69 @@
|
|
|
1
|
+
import fsSync from "node:fs";
|
|
2
|
+
function isValidPid(pid) {
|
|
3
|
+
return Number.isInteger(pid) && pid > 0;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Check if a process is a zombie on Linux by reading /proc/<pid>/status.
|
|
7
|
+
* Returns false on non-Linux platforms or if the proc file can't be read.
|
|
8
|
+
*/
|
|
9
|
+
function isZombieProcess(pid) {
|
|
10
|
+
if (process.platform !== "linux") {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
const status = fsSync.readFileSync(`/proc/${pid}/status`, "utf8");
|
|
15
|
+
const stateMatch = status.match(/^State:\s+(\S)/m);
|
|
16
|
+
return stateMatch?.[1] === "Z";
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
1
22
|
export function isPidAlive(pid) {
|
|
2
|
-
if (!
|
|
23
|
+
if (!isValidPid(pid)) {
|
|
3
24
|
return false;
|
|
4
25
|
}
|
|
5
26
|
try {
|
|
6
27
|
process.kill(pid, 0);
|
|
7
|
-
return true;
|
|
8
28
|
}
|
|
9
29
|
catch {
|
|
10
30
|
return false;
|
|
11
31
|
}
|
|
32
|
+
if (isZombieProcess(pid)) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Read the process start time (field 22 "starttime") from /proc/<pid>/stat.
|
|
39
|
+
* Returns the value in clock ticks since system boot, or null on non-Linux
|
|
40
|
+
* platforms or if the proc file can't be read.
|
|
41
|
+
*
|
|
42
|
+
* This is used to detect PID recycling: if two readings for the same PID
|
|
43
|
+
* return different starttimes, the PID has been reused by a different process.
|
|
44
|
+
*/
|
|
45
|
+
export function getProcessStartTime(pid) {
|
|
46
|
+
if (process.platform !== "linux") {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
if (!isValidPid(pid)) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const stat = fsSync.readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
54
|
+
const commEndIndex = stat.lastIndexOf(")");
|
|
55
|
+
if (commEndIndex < 0) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
// The comm field (field 2) is wrapped in parens and can contain spaces,
|
|
59
|
+
// so split after the last ")" to get fields 3..N reliably.
|
|
60
|
+
const afterComm = stat.slice(commEndIndex + 1).trimStart();
|
|
61
|
+
const fields = afterComm.split(/\s+/);
|
|
62
|
+
// field 22 (starttime) = index 19 after the comm-split (field 3 is index 0).
|
|
63
|
+
const starttime = Number(fields[19]);
|
|
64
|
+
return Number.isInteger(starttime) && starttime >= 0 ? starttime : null;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
12
69
|
}
|
|
@@ -228,6 +228,7 @@ export function createSlackMonitorContext(params) {
|
|
|
228
228
|
slashCommand: params.slashCommand,
|
|
229
229
|
textLimit: params.textLimit,
|
|
230
230
|
ackReactionScope: params.ackReactionScope,
|
|
231
|
+
typingReaction: params.typingReaction,
|
|
231
232
|
mediaMaxBytes: params.mediaMaxBytes,
|
|
232
233
|
removeAckAfterReply: params.removeAckAfterReply,
|
|
233
234
|
logger,
|
|
@@ -8,7 +8,7 @@ import { createReplyPrefixOptions } from "../../../channels/reply-prefix.js";
|
|
|
8
8
|
import { createTypingCallbacks } from "../../../channels/typing.js";
|
|
9
9
|
import { resolveStorePath, updateLastRoute } from "../../../config/sessions.js";
|
|
10
10
|
import { danger, logVerbose, shouldLogVerbose } from "../../../globals.js";
|
|
11
|
-
import { removeSlackReaction } from "../../actions.js";
|
|
11
|
+
import { reactSlackMessage, removeSlackReaction } from "../../actions.js";
|
|
12
12
|
import { createSlackDraftStream } from "../../draft-stream.js";
|
|
13
13
|
import { applyAppendOnlyStreamUpdate, buildStatusFinalPreviewText, resolveSlackStreamingConfig, } from "../../stream-mode.js";
|
|
14
14
|
import { appendSlackStream, startSlackStream, stopSlackStream } from "../../streaming.js";
|
|
@@ -78,6 +78,7 @@ export async function dispatchPreparedSlackMessage(prepared) {
|
|
|
78
78
|
hasRepliedRef,
|
|
79
79
|
});
|
|
80
80
|
const typingTarget = statusThreadTs ? `${message.channel}/${statusThreadTs}` : message.channel;
|
|
81
|
+
const typingReaction = ctx.typingReaction;
|
|
81
82
|
const typingCallbacks = createTypingCallbacks({
|
|
82
83
|
start: async () => {
|
|
83
84
|
didSetStatus = true;
|
|
@@ -86,6 +87,12 @@ export async function dispatchPreparedSlackMessage(prepared) {
|
|
|
86
87
|
threadTs: statusThreadTs,
|
|
87
88
|
status: "is typing...",
|
|
88
89
|
});
|
|
90
|
+
if (typingReaction && message.ts) {
|
|
91
|
+
await reactSlackMessage(message.channel, message.ts, typingReaction, {
|
|
92
|
+
token: ctx.botToken,
|
|
93
|
+
client: ctx.app.client,
|
|
94
|
+
}).catch(() => { });
|
|
95
|
+
}
|
|
89
96
|
},
|
|
90
97
|
stop: async () => {
|
|
91
98
|
if (!didSetStatus) {
|
|
@@ -97,6 +104,12 @@ export async function dispatchPreparedSlackMessage(prepared) {
|
|
|
97
104
|
threadTs: statusThreadTs,
|
|
98
105
|
status: "",
|
|
99
106
|
});
|
|
107
|
+
if (typingReaction && message.ts) {
|
|
108
|
+
await removeSlackReaction(message.channel, message.ts, typingReaction, {
|
|
109
|
+
token: ctx.botToken,
|
|
110
|
+
client: ctx.app.client,
|
|
111
|
+
}).catch(() => { });
|
|
112
|
+
}
|
|
100
113
|
},
|
|
101
114
|
onStartError: (err) => {
|
|
102
115
|
logTypingFailure({
|
|
@@ -90,6 +90,7 @@ export async function monitorSlackProvider(opts = {}) {
|
|
|
90
90
|
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
|
91
91
|
const mediaMaxBytes = (opts.mediaMaxMb ?? slackCfg.mediaMaxMb ?? 20) * 1024 * 1024;
|
|
92
92
|
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
|
|
93
|
+
const typingReaction = slackCfg.typingReaction ?? "";
|
|
93
94
|
const receiver = slackMode === "http"
|
|
94
95
|
? new HTTPReceiver({
|
|
95
96
|
signingSecret: signingSecret ?? "",
|
|
@@ -160,6 +161,7 @@ export async function monitorSlackProvider(opts = {}) {
|
|
|
160
161
|
slashCommand,
|
|
161
162
|
textLimit,
|
|
162
163
|
ackReactionScope,
|
|
164
|
+
typingReaction,
|
|
163
165
|
mediaMaxBytes,
|
|
164
166
|
removeAckAfterReply,
|
|
165
167
|
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { vi } from "vitest";
|
|
2
|
+
import * as ssrf from "../infra/net/ssrf.js";
|
|
3
|
+
export function mockPinnedHostnameResolution(addresses = ["93.184.216.34"]) {
|
|
4
|
+
return vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation(async (hostname) => {
|
|
5
|
+
const normalized = hostname.trim().toLowerCase().replace(/\.$/, "");
|
|
6
|
+
const pinnedAddresses = [...addresses];
|
|
7
|
+
return {
|
|
8
|
+
hostname: normalized,
|
|
9
|
+
addresses: pinnedAddresses,
|
|
10
|
+
lookup: ssrf.createPinnedLookup({ hostname: normalized, addresses: pinnedAddresses }),
|
|
11
|
+
};
|
|
12
|
+
});
|
|
13
|
+
}
|
package/dist/tui/tui.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CombinedAutocompleteProvider, Container, Key, Loader, matchesKey, ProcessTerminal, Text, TUI, } from "@mariozechner/pi-tui";
|
|
1
|
+
import { CombinedAutocompleteProvider, Container, Key, Loader, matchesKey, ProcessTerminal, Text, TUI, truncateToWidth, } from "@mariozechner/pi-tui";
|
|
2
2
|
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
|
3
3
|
import { loadConfig } from "../config/config.js";
|
|
4
4
|
import { buildAgentMainSessionKey, normalizeAgentId, normalizeMainKey, parseAgentSessionKey, } from "../routing/session-key.js";
|
|
@@ -398,7 +398,9 @@ export async function runTui(opts) {
|
|
|
398
398
|
const updateHeader = () => {
|
|
399
399
|
const sessionLabel = formatSessionKey(currentSessionKey);
|
|
400
400
|
const agentLabel = formatAgentLabel(currentAgentId);
|
|
401
|
-
|
|
401
|
+
const headerText = `poolbot tui - ${client.connection.url} - agent ${agentLabel} - session ${sessionLabel}`;
|
|
402
|
+
const maxHeaderWidth = Math.max(1, (process.stdout.columns ?? 120) - 2);
|
|
403
|
+
header.setText(theme.header(truncateToWidth(headerText, maxHeaderWidth, "…")));
|
|
402
404
|
};
|
|
403
405
|
const busyStates = new Set(["sending", "waiting", "streaming", "running"]);
|
|
404
406
|
let statusText = null;
|
|
@@ -520,7 +522,8 @@ export async function runTui(opts) {
|
|
|
520
522
|
statusLoader = null;
|
|
521
523
|
ensureStatusText();
|
|
522
524
|
const text = activityStatus ? `${connectionStatus} | ${activityStatus}` : connectionStatus;
|
|
523
|
-
|
|
525
|
+
const maxStatusWidth = Math.max(1, (process.stdout.columns ?? 120) - 2);
|
|
526
|
+
statusText?.setText(theme.dim(truncateToWidth(text, maxStatusWidth, "…")));
|
|
524
527
|
}
|
|
525
528
|
lastActivityStatus = activityStatus;
|
|
526
529
|
};
|
|
@@ -566,7 +569,9 @@ export async function runTui(opts) {
|
|
|
566
569
|
reasoningLabel,
|
|
567
570
|
tokens,
|
|
568
571
|
].filter(Boolean);
|
|
569
|
-
|
|
572
|
+
const footerText = footerParts.join(" | ");
|
|
573
|
+
const maxFooterWidth = Math.max(1, (process.stdout.columns ?? 120) - 2);
|
|
574
|
+
footer.setText(theme.dim(truncateToWidth(footerText, maxFooterWidth, "…")));
|
|
570
575
|
};
|
|
571
576
|
const { openOverlay, closeOverlay } = createOverlayHandlers(tui, editor);
|
|
572
577
|
const initialSessionAgentId = (() => {
|
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relay abort without forwarding the Event argument as the abort reason.
|
|
3
|
+
* Using .bind() avoids closure scope capture (memory leak prevention).
|
|
4
|
+
*/
|
|
5
|
+
function relayAbort() {
|
|
6
|
+
this.abort();
|
|
7
|
+
}
|
|
8
|
+
/** Returns a bound abort relay for use as an event listener. */
|
|
9
|
+
export function bindAbortRelay(controller) {
|
|
10
|
+
return relayAbort.bind(controller);
|
|
11
|
+
}
|
|
1
12
|
/**
|
|
2
13
|
* Fetch wrapper that adds timeout support via AbortController.
|
|
3
14
|
*
|
|
@@ -10,7 +21,7 @@
|
|
|
10
21
|
*/
|
|
11
22
|
export async function fetchWithTimeout(url, init, timeoutMs, fetchFn = fetch) {
|
|
12
23
|
const controller = new AbortController();
|
|
13
|
-
const timer = setTimeout(
|
|
24
|
+
const timer = setTimeout(controller.abort.bind(controller), Math.max(1, timeoutMs));
|
|
14
25
|
try {
|
|
15
26
|
return await fetchFn(url, { ...init, signal: controller.signal });
|
|
16
27
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# ADR 003: Feature Gap Analysis — Pool Bot vs OpenClaw
|
|
2
|
+
|
|
3
|
+
**Status:** Accepted
|
|
4
|
+
**Date:** 2026-03-04
|
|
5
|
+
**Author:** PLCode analysis agent
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
Pool Bot (v2026.3.4) was forked from OpenClaw. OpenClaw has continued shipping features through versions 2026.2.12 to 2026.3.3 that Pool Bot has not yet adopted. This document identifies the highest-value gaps and prioritizes implementation.
|
|
10
|
+
|
|
11
|
+
## Analysis Method
|
|
12
|
+
|
|
13
|
+
- Full diff of both CHANGELOGs (2000+ OpenClaw lines vs 297 Pool Bot lines)
|
|
14
|
+
- Deep source-tree exploration of `src/`, `extensions/`, `docs/`, and config schemas
|
|
15
|
+
- Cross-reference of every OpenClaw feature against Pool Bot codebase using grep/AST search
|
|
16
|
+
|
|
17
|
+
## Confirmed Present (No Gap)
|
|
18
|
+
|
|
19
|
+
These OpenClaw features already exist in Pool Bot:
|
|
20
|
+
|
|
21
|
+
| Feature | Evidence |
|
|
22
|
+
|---------|----------|
|
|
23
|
+
| SSRF protection | 91+ references across `src/infra/net/ssrf.ts` and callers |
|
|
24
|
+
| Draft streaming (Telegram/Discord/Slack) | Full implementation in channel modules |
|
|
25
|
+
| Config validation | 100+ Zod schema matches |
|
|
26
|
+
| Session compaction | Plugin hooks, auto-compaction, diagnostics |
|
|
27
|
+
| Feishu/LINE/Mattermost/IRC/MS Teams/Nextcloud Talk/Tlon | Extensions present |
|
|
28
|
+
| Ollama memory embeddings | v2026.3.4 |
|
|
29
|
+
| Channel auto-restart with exponential backoff | Cherry-picked from OpenClaw |
|
|
30
|
+
| Boundary file read / safe-open-sync | Cherry-picked from OpenClaw |
|
|
31
|
+
| Hardened fs-safe with atomic writes | Cherry-picked from OpenClaw |
|
|
32
|
+
|
|
33
|
+
## Confirmed Missing — Critical & High Priority
|
|
34
|
+
|
|
35
|
+
### 1. HTTP Container Health Probes
|
|
36
|
+
|
|
37
|
+
**Priority:** Critical | **Effort:** S (small)
|
|
38
|
+
|
|
39
|
+
OpenClaw 2026.3.1 added built-in HTTP liveness/readiness endpoints (`/health`, `/healthz`, `/ready`, `/readyz`) for Docker/Kubernetes orchestrators. Pool Bot's gateway has WS-based health only — no HTTP endpoint paths for container probes.
|
|
40
|
+
|
|
41
|
+
**Impact:** Blocks production Kubernetes/Docker deployments from proper health checking.
|
|
42
|
+
|
|
43
|
+
### 2. First-class PDF Tool
|
|
44
|
+
|
|
45
|
+
**Priority:** High | **Effort:** M (medium)
|
|
46
|
+
|
|
47
|
+
OpenClaw 2026.3.2 added a native PDF document extraction tool using provider-native document understanding (Anthropic/Google). Pool Bot's browser-tool has a "pdf" action that renders pages to PDF via browser, but no dedicated extraction tool.
|
|
48
|
+
|
|
49
|
+
**Impact:** Users cannot extract text/data from PDF documents using provider-native capabilities.
|
|
50
|
+
|
|
51
|
+
### 3. Cron Failure Alerts
|
|
52
|
+
|
|
53
|
+
**Priority:** High | **Effort:** L (large)
|
|
54
|
+
|
|
55
|
+
OpenClaw 2026.3.1 added `failureAlert.mode` (announce | webhook) with `failureAlert.accountId`, per-job `delivery.failureDestination`. OpenClaw 2026.3.3 extended this with repeated-failure alerting, per-job overrides, and Web UI editor support. Pool Bot's cron logs failures but has no notification mechanism.
|
|
56
|
+
|
|
57
|
+
**Impact:** Operators have no way to be notified when scheduled tasks fail.
|
|
58
|
+
|
|
59
|
+
### 4. Tools/Diffs Plugin
|
|
60
|
+
|
|
61
|
+
**Priority:** High | **Effort:** M (medium)
|
|
62
|
+
|
|
63
|
+
OpenClaw 2026.3.1 added a `diffs` plugin tool for read-only diff rendering from before/after text or unified patches, with gateway viewer URLs. OpenClaw 2026.3.2 added PDF output support.
|
|
64
|
+
|
|
65
|
+
**Impact:** Agents cannot present structured diffs to users in a readable format.
|
|
66
|
+
|
|
67
|
+
### 5. Head+Tail Truncation Strategy
|
|
68
|
+
|
|
69
|
+
**Priority:** Medium | **Effort:** S (small)
|
|
70
|
+
|
|
71
|
+
OpenClaw added head+tail truncation for long messages — keeping the beginning and end of messages while eliding the middle. Pool Bot only has simple character-limit truncation.
|
|
72
|
+
|
|
73
|
+
**Impact:** Long responses lose valuable ending content when truncated.
|
|
74
|
+
|
|
75
|
+
## Confirmed Missing — Medium Priority
|
|
76
|
+
|
|
77
|
+
| Feature | OpenClaw Version | Effort |
|
|
78
|
+
|---------|-----------------|--------|
|
|
79
|
+
| Perplexity Search API integration | 2026.2.14 | M |
|
|
80
|
+
| Telegram per-topic agentId | 2026.2.25 | S |
|
|
81
|
+
| Slack DM typing indicators | 2026.2.28 | S |
|
|
82
|
+
| Discord `allowBots` flag | 2026.2.28 | S |
|
|
83
|
+
| `config validate` CLI command | 2026.3.1 | S |
|
|
84
|
+
| OpenAI Responses WS transport | 2026.3.1 | L |
|
|
85
|
+
| Discord thread binding lifecycle | 2026.3.1 | M |
|
|
86
|
+
| Telegram DM topics | 2026.3.1 | S |
|
|
87
|
+
| Plugin runtime API enhancements | 2026.3.2 | M |
|
|
88
|
+
| Adaptive thinking budget | 2026.3.2 | M |
|
|
89
|
+
| Gateway Permissions-Policy header | 2026.3.2 | S |
|
|
90
|
+
| Config heartbeat auto-migration | 2026.3.3 | S |
|
|
91
|
+
|
|
92
|
+
## Confirmed Missing — Low Priority
|
|
93
|
+
|
|
94
|
+
| Feature | OpenClaw Version | Effort |
|
|
95
|
+
|---------|-----------------|--------|
|
|
96
|
+
| CLI banner tagline randomizer | 2026.2.14 | S |
|
|
97
|
+
| `config file` command | 2026.3.1 | S |
|
|
98
|
+
| Web UI locale additions | 2026.3.2 | S |
|
|
99
|
+
| SecretRef/Secrets management | 2026.3.1-3.3 | XL |
|
|
100
|
+
|
|
101
|
+
## Recommended Implementation Order
|
|
102
|
+
|
|
103
|
+
1. **HTTP Health Probes** — smallest effort, unblocks container orchestration
|
|
104
|
+
2. **Head+tail truncation** — small effort, immediate UX improvement
|
|
105
|
+
3. **Cron failure alerts** — high operator value, moderate effort
|
|
106
|
+
4. **First-class PDF tool** — high user value, leverages existing patterns
|
|
107
|
+
5. **Diffs plugin tool** — complements agent capabilities
|
|
108
|
+
6. **SecretRef system** — XL effort, defer to dedicated sprint
|
|
109
|
+
|
|
110
|
+
## Decision
|
|
111
|
+
|
|
112
|
+
Implement items 1-5 in a single feature batch. Defer SecretRef and medium/low items to subsequent releases.
|