@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
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
|
|
2
2
|
import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js";
|
|
3
|
+
import { computeBackoff, sleepWithAbort } from "../infra/backoff.js";
|
|
3
4
|
import { formatErrorMessage } from "../infra/errors.js";
|
|
4
5
|
import { resetDirectoryCache } from "../infra/outbound/target-resolver.js";
|
|
5
6
|
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
|
|
7
|
+
const CHANNEL_RESTART_POLICY = {
|
|
8
|
+
initialMs: 5_000,
|
|
9
|
+
maxMs: 5 * 60_000,
|
|
10
|
+
factor: 2,
|
|
11
|
+
jitter: 0.1,
|
|
12
|
+
};
|
|
13
|
+
const MAX_RESTART_ATTEMPTS = 10;
|
|
6
14
|
function createRuntimeStore() {
|
|
7
15
|
return {
|
|
8
16
|
aborts: new Map(),
|
|
@@ -11,8 +19,9 @@ function createRuntimeStore() {
|
|
|
11
19
|
};
|
|
12
20
|
}
|
|
13
21
|
function isAccountEnabled(account) {
|
|
14
|
-
if (!account || typeof account !== "object")
|
|
22
|
+
if (!account || typeof account !== "object") {
|
|
15
23
|
return true;
|
|
24
|
+
}
|
|
16
25
|
const enabled = account.enabled;
|
|
17
26
|
return enabled !== false;
|
|
18
27
|
}
|
|
@@ -25,15 +34,18 @@ function cloneDefaultRuntime(channelId, accountId) {
|
|
|
25
34
|
}
|
|
26
35
|
// Channel docking: lifecycle hooks (`plugin.gateway`) flow through this manager.
|
|
27
36
|
export function createChannelManager(opts) {
|
|
28
|
-
const { loadConfig, channelLogs, channelRuntimeEnvs } = opts;
|
|
37
|
+
const { loadConfig, channelLogs, channelRuntimeEnvs, channelRuntime } = opts;
|
|
29
38
|
const channelStores = new Map();
|
|
39
|
+
// Tracks restart attempts per channel:account. Reset on successful start.
|
|
30
40
|
const restartAttempts = new Map();
|
|
41
|
+
// Tracks accounts that were manually stopped so we don't auto-restart them.
|
|
31
42
|
const manuallyStopped = new Set();
|
|
32
43
|
const restartKey = (channelId, accountId) => `${channelId}:${accountId}`;
|
|
33
44
|
const getStore = (channelId) => {
|
|
34
45
|
const existing = channelStores.get(channelId);
|
|
35
|
-
if (existing)
|
|
46
|
+
if (existing) {
|
|
36
47
|
return existing;
|
|
48
|
+
}
|
|
37
49
|
const next = createRuntimeStore();
|
|
38
50
|
channelStores.set(channelId, next);
|
|
39
51
|
return next;
|
|
@@ -49,20 +61,24 @@ export function createChannelManager(opts) {
|
|
|
49
61
|
store.runtimes.set(accountId, next);
|
|
50
62
|
return next;
|
|
51
63
|
};
|
|
52
|
-
const
|
|
64
|
+
const startChannelInternal = async (channelId, accountId, opts = {}) => {
|
|
53
65
|
const plugin = getChannelPlugin(channelId);
|
|
54
66
|
const startAccount = plugin?.gateway?.startAccount;
|
|
55
|
-
if (!startAccount)
|
|
67
|
+
if (!startAccount) {
|
|
56
68
|
return;
|
|
69
|
+
}
|
|
70
|
+
const { preserveRestartAttempts = false, preserveManualStop = false } = opts;
|
|
57
71
|
const cfg = loadConfig();
|
|
58
72
|
resetDirectoryCache({ channel: channelId, accountId });
|
|
59
73
|
const store = getStore(channelId);
|
|
60
74
|
const accountIds = accountId ? [accountId] : plugin.config.listAccountIds(cfg);
|
|
61
|
-
if (accountIds.length === 0)
|
|
75
|
+
if (accountIds.length === 0) {
|
|
62
76
|
return;
|
|
77
|
+
}
|
|
63
78
|
await Promise.all(accountIds.map(async (id) => {
|
|
64
|
-
if (store.tasks.has(id))
|
|
79
|
+
if (store.tasks.has(id)) {
|
|
65
80
|
return;
|
|
81
|
+
}
|
|
66
82
|
const account = plugin.config.resolveAccount(cfg, id);
|
|
67
83
|
const enabled = plugin.config.isEnabled
|
|
68
84
|
? plugin.config.isEnabled(account, cfg)
|
|
@@ -70,6 +86,8 @@ export function createChannelManager(opts) {
|
|
|
70
86
|
if (!enabled) {
|
|
71
87
|
setRuntime(channelId, id, {
|
|
72
88
|
accountId: id,
|
|
89
|
+
enabled: false,
|
|
90
|
+
configured: true,
|
|
73
91
|
running: false,
|
|
74
92
|
lastError: plugin.config.disabledReason?.(account, cfg) ?? "disabled",
|
|
75
93
|
});
|
|
@@ -82,18 +100,30 @@ export function createChannelManager(opts) {
|
|
|
82
100
|
if (!configured) {
|
|
83
101
|
setRuntime(channelId, id, {
|
|
84
102
|
accountId: id,
|
|
103
|
+
enabled: true,
|
|
104
|
+
configured: false,
|
|
85
105
|
running: false,
|
|
86
106
|
lastError: plugin.config.unconfiguredReason?.(account, cfg) ?? "not configured",
|
|
87
107
|
});
|
|
88
108
|
return;
|
|
89
109
|
}
|
|
110
|
+
const rKey = restartKey(channelId, id);
|
|
111
|
+
if (!preserveManualStop) {
|
|
112
|
+
manuallyStopped.delete(rKey);
|
|
113
|
+
}
|
|
90
114
|
const abort = new AbortController();
|
|
91
115
|
store.aborts.set(id, abort);
|
|
116
|
+
if (!preserveRestartAttempts) {
|
|
117
|
+
restartAttempts.delete(rKey);
|
|
118
|
+
}
|
|
92
119
|
setRuntime(channelId, id, {
|
|
93
120
|
accountId: id,
|
|
121
|
+
enabled: true,
|
|
122
|
+
configured: true,
|
|
94
123
|
running: true,
|
|
95
124
|
lastStartAt: Date.now(),
|
|
96
125
|
lastError: null,
|
|
126
|
+
reconnectAttempts: preserveRestartAttempts ? (restartAttempts.get(rKey) ?? 0) : 0,
|
|
97
127
|
});
|
|
98
128
|
const log = channelLogs[channelId];
|
|
99
129
|
const task = startAccount({
|
|
@@ -105,29 +135,79 @@ export function createChannelManager(opts) {
|
|
|
105
135
|
log,
|
|
106
136
|
getStatus: () => getRuntime(channelId, id),
|
|
107
137
|
setStatus: (next) => setRuntime(channelId, id, next),
|
|
138
|
+
...(channelRuntime ? { channelRuntime } : {}),
|
|
108
139
|
});
|
|
109
|
-
const
|
|
140
|
+
const trackedPromise = Promise.resolve(task)
|
|
110
141
|
.catch((err) => {
|
|
111
142
|
const message = formatErrorMessage(err);
|
|
112
143
|
setRuntime(channelId, id, { accountId: id, lastError: message });
|
|
113
144
|
log.error?.(`[${id}] channel exited: ${message}`);
|
|
114
145
|
})
|
|
115
146
|
.finally(() => {
|
|
116
|
-
store.aborts.delete(id);
|
|
117
|
-
store.tasks.delete(id);
|
|
118
147
|
setRuntime(channelId, id, {
|
|
119
148
|
accountId: id,
|
|
120
149
|
running: false,
|
|
121
150
|
lastStopAt: Date.now(),
|
|
122
151
|
});
|
|
152
|
+
})
|
|
153
|
+
.then(async () => {
|
|
154
|
+
if (manuallyStopped.has(rKey)) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const attempt = (restartAttempts.get(rKey) ?? 0) + 1;
|
|
158
|
+
restartAttempts.set(rKey, attempt);
|
|
159
|
+
if (attempt > MAX_RESTART_ATTEMPTS) {
|
|
160
|
+
log.error?.(`[${id}] giving up after ${MAX_RESTART_ATTEMPTS} restart attempts`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const delayMs = computeBackoff(CHANNEL_RESTART_POLICY, attempt);
|
|
164
|
+
log.info?.(`[${id}] auto-restart attempt ${attempt}/${MAX_RESTART_ATTEMPTS} in ${Math.round(delayMs / 1000)}s`);
|
|
165
|
+
setRuntime(channelId, id, {
|
|
166
|
+
accountId: id,
|
|
167
|
+
reconnectAttempts: attempt,
|
|
168
|
+
});
|
|
169
|
+
try {
|
|
170
|
+
await sleepWithAbort(delayMs, abort.signal);
|
|
171
|
+
if (manuallyStopped.has(rKey)) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (store.tasks.get(id) === trackedPromise) {
|
|
175
|
+
store.tasks.delete(id);
|
|
176
|
+
}
|
|
177
|
+
if (store.aborts.get(id) === abort) {
|
|
178
|
+
store.aborts.delete(id);
|
|
179
|
+
}
|
|
180
|
+
await startChannelInternal(channelId, id, {
|
|
181
|
+
preserveRestartAttempts: true,
|
|
182
|
+
preserveManualStop: true,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// abort or startup failure — next crash will retry
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
.finally(() => {
|
|
190
|
+
if (store.tasks.get(id) === trackedPromise) {
|
|
191
|
+
store.tasks.delete(id);
|
|
192
|
+
}
|
|
193
|
+
if (store.aborts.get(id) === abort) {
|
|
194
|
+
store.aborts.delete(id);
|
|
195
|
+
}
|
|
123
196
|
});
|
|
124
|
-
store.tasks.set(id,
|
|
197
|
+
store.tasks.set(id, trackedPromise);
|
|
125
198
|
}));
|
|
126
199
|
};
|
|
200
|
+
const startChannel = async (channelId, accountId) => {
|
|
201
|
+
await startChannelInternal(channelId, accountId);
|
|
202
|
+
};
|
|
127
203
|
const stopChannel = async (channelId, accountId) => {
|
|
128
204
|
const plugin = getChannelPlugin(channelId);
|
|
129
|
-
const cfg = loadConfig();
|
|
130
205
|
const store = getStore(channelId);
|
|
206
|
+
// Fast path: nothing running and no explicit plugin shutdown hook to run.
|
|
207
|
+
if (!plugin?.gateway?.stopAccount && store.aborts.size === 0 && store.tasks.size === 0) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const cfg = loadConfig();
|
|
131
211
|
const knownIds = new Set([
|
|
132
212
|
...store.aborts.keys(),
|
|
133
213
|
...store.tasks.keys(),
|
|
@@ -140,8 +220,10 @@ export function createChannelManager(opts) {
|
|
|
140
220
|
await Promise.all(Array.from(knownIds.values()).map(async (id) => {
|
|
141
221
|
const abort = store.aborts.get(id);
|
|
142
222
|
const task = store.tasks.get(id);
|
|
143
|
-
if (!abort && !task && !plugin?.gateway?.stopAccount)
|
|
223
|
+
if (!abort && !task && !plugin?.gateway?.stopAccount) {
|
|
144
224
|
return;
|
|
225
|
+
}
|
|
226
|
+
manuallyStopped.add(restartKey(channelId, id));
|
|
145
227
|
abort?.abort();
|
|
146
228
|
if (plugin?.gateway?.stopAccount) {
|
|
147
229
|
const account = plugin.config.resolveAccount(cfg, id);
|
|
@@ -178,8 +260,9 @@ export function createChannelManager(opts) {
|
|
|
178
260
|
};
|
|
179
261
|
const markChannelLoggedOut = (channelId, cleared, accountId) => {
|
|
180
262
|
const plugin = getChannelPlugin(channelId);
|
|
181
|
-
if (!plugin)
|
|
263
|
+
if (!plugin) {
|
|
182
264
|
return;
|
|
265
|
+
}
|
|
183
266
|
const cfg = loadConfig();
|
|
184
267
|
const resolvedId = accountId ??
|
|
185
268
|
resolveChannelDefaultAccountId({
|
|
@@ -219,6 +302,8 @@ export function createChannelManager(opts) {
|
|
|
219
302
|
const configured = described?.configured;
|
|
220
303
|
const current = store.runtimes.get(id) ?? cloneDefaultRuntime(plugin.id, id);
|
|
221
304
|
const next = { ...current, accountId: id };
|
|
305
|
+
next.enabled = enabled;
|
|
306
|
+
next.configured = typeof configured === "boolean" ? configured : (next.configured ?? true);
|
|
222
307
|
if (!next.running) {
|
|
223
308
|
if (!enabled) {
|
|
224
309
|
next.lastError ??= plugin.config.disabledReason?.(account, cfg) ?? "disabled";
|
|
@@ -224,6 +224,95 @@ export function buildGatewayCronService(params) {
|
|
|
224
224
|
}
|
|
225
225
|
})();
|
|
226
226
|
}
|
|
227
|
+
// --- onFailure alert ---
|
|
228
|
+
if (evt.status === "error" && job?.onFailure && job.onFailure.mode !== "none") {
|
|
229
|
+
if (job.onFailure.mode === "webhook") {
|
|
230
|
+
const failureUrl = normalizeHttpWebhookUrl(job.onFailure.to);
|
|
231
|
+
if (failureUrl) {
|
|
232
|
+
const failureHeaders = {
|
|
233
|
+
"Content-Type": "application/json",
|
|
234
|
+
};
|
|
235
|
+
if (webhookToken) {
|
|
236
|
+
failureHeaders.Authorization = `Bearer ${webhookToken}`;
|
|
237
|
+
}
|
|
238
|
+
const failureAbort = new AbortController();
|
|
239
|
+
const failureTimeout = setTimeout(() => {
|
|
240
|
+
failureAbort.abort();
|
|
241
|
+
}, CRON_WEBHOOK_TIMEOUT_MS);
|
|
242
|
+
void (async () => {
|
|
243
|
+
try {
|
|
244
|
+
const result = await fetchWithSsrFGuard({
|
|
245
|
+
url: failureUrl,
|
|
246
|
+
init: {
|
|
247
|
+
method: "POST",
|
|
248
|
+
headers: failureHeaders,
|
|
249
|
+
body: JSON.stringify({ ...evt, alertKind: "onFailure" }),
|
|
250
|
+
signal: failureAbort.signal,
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
await result.release();
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
if (job.onFailure?.bestEffort) {
|
|
257
|
+
cronLogger.debug({
|
|
258
|
+
err: formatErrorMessage(err),
|
|
259
|
+
jobId: evt.jobId,
|
|
260
|
+
webhookUrl: redactWebhookUrl(failureUrl),
|
|
261
|
+
}, "cron: onFailure webhook silenced (bestEffort)");
|
|
262
|
+
}
|
|
263
|
+
else if (err instanceof SsrFBlockedError) {
|
|
264
|
+
cronLogger.warn({
|
|
265
|
+
reason: formatErrorMessage(err),
|
|
266
|
+
jobId: evt.jobId,
|
|
267
|
+
webhookUrl: redactWebhookUrl(failureUrl),
|
|
268
|
+
}, "cron: onFailure webhook blocked by SSRF guard");
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
cronLogger.warn({
|
|
272
|
+
err: formatErrorMessage(err),
|
|
273
|
+
jobId: evt.jobId,
|
|
274
|
+
webhookUrl: redactWebhookUrl(failureUrl),
|
|
275
|
+
}, "cron: onFailure webhook delivery failed");
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
finally {
|
|
279
|
+
clearTimeout(failureTimeout);
|
|
280
|
+
}
|
|
281
|
+
})();
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
cronLogger.warn({ jobId: evt.jobId, onFailureTo: job.onFailure.to }, "cron: skipped onFailure webhook, onFailure.to must be a valid http(s) URL");
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
else if (job.onFailure.mode === "announce") {
|
|
288
|
+
const failureMsg = `[Cron failure] Job "${job.name ?? job.id}" failed: ${evt.error ?? "unknown error"}`;
|
|
289
|
+
try {
|
|
290
|
+
const { agentId, cfg: runtimeConfig } = resolveCronAgent(job.agentId);
|
|
291
|
+
const sessionKey = resolveCronSessionKey({
|
|
292
|
+
runtimeConfig,
|
|
293
|
+
agentId,
|
|
294
|
+
requestedSessionKey: job.sessionKey,
|
|
295
|
+
});
|
|
296
|
+
enqueueSystemEvent(failureMsg, {
|
|
297
|
+
sessionKey,
|
|
298
|
+
contextKey: `cron:${job.id}:onFailure`,
|
|
299
|
+
});
|
|
300
|
+
requestHeartbeatNow({
|
|
301
|
+
reason: `cron:${job.id}:onFailure`,
|
|
302
|
+
agentId,
|
|
303
|
+
sessionKey,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
catch (err) {
|
|
307
|
+
if (job.onFailure.bestEffort) {
|
|
308
|
+
cronLogger.debug({ err: formatErrorMessage(err), jobId: evt.jobId }, "cron: onFailure announce silenced (bestEffort)");
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
cronLogger.warn({ err: formatErrorMessage(err), jobId: evt.jobId }, "cron: onFailure announce failed");
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
227
316
|
const logPath = resolveCronRunLogPath({
|
|
228
317
|
storePath,
|
|
229
318
|
jobId: evt.jobId,
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP health probe endpoints for container orchestrators (Docker, Kubernetes).
|
|
3
|
+
*
|
|
4
|
+
* Paths:
|
|
5
|
+
* /health, /healthz — Liveness probe. Returns 200 if the process is running.
|
|
6
|
+
* /ready, /readyz — Readiness probe. Returns 200 when the gateway has a
|
|
7
|
+
* cached health snapshot (channels probed at least once),
|
|
8
|
+
* 503 otherwise.
|
|
9
|
+
*
|
|
10
|
+
* These endpoints run *before* any authentication so orchestrators can probe
|
|
11
|
+
* without credentials.
|
|
12
|
+
*/
|
|
13
|
+
import { sendJson } from "./http-common.js";
|
|
14
|
+
import { getHealthCache } from "./server/health-state.js";
|
|
15
|
+
const LIVENESS_PATHS = new Set(["/health", "/healthz"]);
|
|
16
|
+
const READINESS_PATHS = new Set(["/ready", "/readyz"]);
|
|
17
|
+
/**
|
|
18
|
+
* Attempt to handle an HTTP health probe request.
|
|
19
|
+
*
|
|
20
|
+
* @returns `true` if the request was handled (caller should stop processing),
|
|
21
|
+
* `false` if the path is not a health probe.
|
|
22
|
+
*/
|
|
23
|
+
export function handleHealthProbe(req, res) {
|
|
24
|
+
const url = req.url ?? "/";
|
|
25
|
+
// Fast path: skip URL parsing for the common non-probe case.
|
|
26
|
+
if (!url.startsWith("/health") && !url.startsWith("/ready")) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
const pathname = new URL(url, "http://localhost").pathname;
|
|
30
|
+
if (LIVENESS_PATHS.has(pathname)) {
|
|
31
|
+
sendJson(res, 200, {
|
|
32
|
+
status: "ok",
|
|
33
|
+
uptime: Math.round(process.uptime()),
|
|
34
|
+
});
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
if (READINESS_PATHS.has(pathname)) {
|
|
38
|
+
const cached = getHealthCache();
|
|
39
|
+
if (cached) {
|
|
40
|
+
sendJson(res, 200, {
|
|
41
|
+
status: "ok",
|
|
42
|
+
uptime: Math.round(process.uptime()),
|
|
43
|
+
ts: cached.ts,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
sendJson(res, 503, {
|
|
48
|
+
status: "unavailable",
|
|
49
|
+
reason: "health snapshot not yet available",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
@@ -15,6 +15,7 @@ import { getBearerToken } from "./http-utils.js";
|
|
|
15
15
|
import { handleOpenAiHttpRequest } from "./openai-http.js";
|
|
16
16
|
import { handleOpenResponsesHttpRequest } from "./openresponses-http.js";
|
|
17
17
|
import { GATEWAY_CLIENT_MODES, normalizeGatewayClientMode } from "./protocol/client-info.js";
|
|
18
|
+
import { handleHealthProbe } from "./server-health-probes.js";
|
|
18
19
|
import { handleToolsInvokeHttpRequest } from "./tools-invoke-http.js";
|
|
19
20
|
const HOOK_AUTH_FAILURE_LIMIT = 20;
|
|
20
21
|
const HOOK_AUTH_FAILURE_WINDOW_MS = 60_000;
|
|
@@ -344,6 +345,10 @@ export function createGatewayHttpServer(opts) {
|
|
|
344
345
|
if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket") {
|
|
345
346
|
return;
|
|
346
347
|
}
|
|
348
|
+
// HTTP health probes run before auth so orchestrators can probe without credentials.
|
|
349
|
+
if (handleHealthProbe(req, res)) {
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
347
352
|
try {
|
|
348
353
|
const configSnapshot = loadConfig();
|
|
349
354
|
const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
|
|
@@ -9,6 +9,7 @@ import os from "node:os";
|
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import { resolveAgentWorkspaceDir } from "../../../agents/agent-scope.js";
|
|
11
11
|
import { resolveStateDir } from "../../../config/paths.js";
|
|
12
|
+
import { writeFileWithinRoot } from "../../../infra/fs-safe.js";
|
|
12
13
|
import { createSubsystemLogger } from "../../../logging/subsystem.js";
|
|
13
14
|
import { resolveAgentIdFromSessionKey } from "../../../routing/session-key.js";
|
|
14
15
|
import { hasInterSessionUserProvenance } from "../../../sessions/input-provenance.js";
|
|
@@ -249,8 +250,13 @@ const saveSessionToMemory = async (event) => {
|
|
|
249
250
|
entryParts.push("## Conversation Summary", "", sessionContent, "");
|
|
250
251
|
}
|
|
251
252
|
const entry = entryParts.join("\n");
|
|
252
|
-
// Write
|
|
253
|
-
await
|
|
253
|
+
// Write under memory root with alias-safe file validation.
|
|
254
|
+
await writeFileWithinRoot({
|
|
255
|
+
rootDir: memoryDir,
|
|
256
|
+
relativePath: filename,
|
|
257
|
+
data: entry,
|
|
258
|
+
encoding: "utf-8",
|
|
259
|
+
});
|
|
254
260
|
log.debug("Memory file written successfully");
|
|
255
261
|
// Log completion (but don't send user-visible confirmation - it's internal housekeeping)
|
|
256
262
|
const relPath = memoryFilePath.replace(os.homedir(), "~");
|
|
@@ -5,8 +5,18 @@
|
|
|
5
5
|
* like command processing, session lifecycle, etc.
|
|
6
6
|
*/
|
|
7
7
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
|
8
|
-
/**
|
|
9
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Registry of hook handlers by event key.
|
|
10
|
+
*
|
|
11
|
+
* Uses a globalThis singleton so that registerInternalHook and
|
|
12
|
+
* triggerInternalHook always share the same Map even when the bundler
|
|
13
|
+
* emits multiple copies of this module into separate chunks (bundle
|
|
14
|
+
* splitting). Without the singleton, handlers registered in one chunk
|
|
15
|
+
* are invisible to triggerInternalHook in another chunk, causing hooks
|
|
16
|
+
* to silently fire with zero handlers.
|
|
17
|
+
*/
|
|
18
|
+
const _g = globalThis;
|
|
19
|
+
const handlers = (_g.__poolbot_internal_hook_handlers__ ??= new Map());
|
|
10
20
|
const log = createSubsystemLogger("internal-hooks");
|
|
11
21
|
/**
|
|
12
22
|
* Register a hook handler for a specific event type or event:action combination
|
|
@@ -112,45 +122,80 @@ export function createInternalHookEvent(type, action, sessionKey, context = {})
|
|
|
112
122
|
messages: [],
|
|
113
123
|
};
|
|
114
124
|
}
|
|
125
|
+
function isHookEventTypeAndAction(event, type, action) {
|
|
126
|
+
return event.type === type && event.action === action;
|
|
127
|
+
}
|
|
128
|
+
function getHookContext(event) {
|
|
129
|
+
const context = event.context;
|
|
130
|
+
if (!context || typeof context !== "object") {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
return context;
|
|
134
|
+
}
|
|
135
|
+
function hasStringContextField(context, key) {
|
|
136
|
+
return typeof context[key] === "string";
|
|
137
|
+
}
|
|
138
|
+
function hasBooleanContextField(context, key) {
|
|
139
|
+
return typeof context[key] === "boolean";
|
|
140
|
+
}
|
|
115
141
|
export function isAgentBootstrapEvent(event) {
|
|
116
|
-
if (event
|
|
142
|
+
if (!isHookEventTypeAndAction(event, "agent", "bootstrap")) {
|
|
117
143
|
return false;
|
|
118
144
|
}
|
|
119
|
-
const context = event
|
|
120
|
-
if (!context
|
|
145
|
+
const context = getHookContext(event);
|
|
146
|
+
if (!context) {
|
|
121
147
|
return false;
|
|
122
148
|
}
|
|
123
|
-
if (
|
|
149
|
+
if (!hasStringContextField(context, "workspaceDir")) {
|
|
124
150
|
return false;
|
|
125
151
|
}
|
|
126
152
|
return Array.isArray(context.bootstrapFiles);
|
|
127
153
|
}
|
|
128
154
|
export function isGatewayStartupEvent(event) {
|
|
129
|
-
if (event
|
|
155
|
+
if (!isHookEventTypeAndAction(event, "gateway", "startup")) {
|
|
130
156
|
return false;
|
|
131
157
|
}
|
|
132
|
-
|
|
133
|
-
return Boolean(context && typeof context === "object");
|
|
158
|
+
return Boolean(getHookContext(event));
|
|
134
159
|
}
|
|
135
160
|
export function isMessageReceivedEvent(event) {
|
|
136
|
-
if (event
|
|
161
|
+
if (!isHookEventTypeAndAction(event, "message", "received")) {
|
|
137
162
|
return false;
|
|
138
163
|
}
|
|
139
|
-
const context = event
|
|
140
|
-
if (!context
|
|
164
|
+
const context = getHookContext(event);
|
|
165
|
+
if (!context) {
|
|
141
166
|
return false;
|
|
142
167
|
}
|
|
143
|
-
return
|
|
168
|
+
return hasStringContextField(context, "from") && hasStringContextField(context, "channelId");
|
|
144
169
|
}
|
|
145
170
|
export function isMessageSentEvent(event) {
|
|
146
|
-
if (event
|
|
171
|
+
if (!isHookEventTypeAndAction(event, "message", "sent")) {
|
|
147
172
|
return false;
|
|
148
173
|
}
|
|
149
|
-
const context = event
|
|
150
|
-
if (!context
|
|
174
|
+
const context = getHookContext(event);
|
|
175
|
+
if (!context) {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
return (hasStringContextField(context, "to") &&
|
|
179
|
+
hasStringContextField(context, "channelId") &&
|
|
180
|
+
hasBooleanContextField(context, "success"));
|
|
181
|
+
}
|
|
182
|
+
export function isMessageTranscribedEvent(event) {
|
|
183
|
+
if (!isHookEventTypeAndAction(event, "message", "transcribed")) {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
const context = getHookContext(event);
|
|
187
|
+
if (!context) {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
return (hasStringContextField(context, "transcript") && hasStringContextField(context, "channelId"));
|
|
191
|
+
}
|
|
192
|
+
export function isMessagePreprocessedEvent(event) {
|
|
193
|
+
if (!isHookEventTypeAndAction(event, "message", "preprocessed")) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
const context = getHookContext(event);
|
|
197
|
+
if (!context) {
|
|
151
198
|
return false;
|
|
152
199
|
}
|
|
153
|
-
return (
|
|
154
|
-
typeof context.channelId === "string" &&
|
|
155
|
-
typeof context.success === "boolean");
|
|
200
|
+
return hasStringContextField(context, "channelId");
|
|
156
201
|
}
|