@poolzin/pool-bot 2026.3.4 → 2026.3.6

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/assets/pool-bot-icon-dark.png +0 -0
  3. package/assets/pool-bot-logo-1.png +0 -0
  4. package/assets/pool-bot-mascot.png +0 -0
  5. package/dist/agents/pi-embedded-runner/tool-result-truncation.js +62 -7
  6. package/dist/agents/poolbot-tools.js +12 -0
  7. package/dist/agents/session-write-lock.js +93 -8
  8. package/dist/agents/tools/pdf-native-providers.js +102 -0
  9. package/dist/agents/tools/pdf-tool.helpers.js +86 -0
  10. package/dist/agents/tools/pdf-tool.js +508 -0
  11. package/dist/build-info.json +3 -3
  12. package/dist/cron/normalize.js +3 -0
  13. package/dist/cron/service/jobs.js +48 -0
  14. package/dist/gateway/protocol/schema/cron.js +3 -0
  15. package/dist/gateway/server-channels.js +99 -14
  16. package/dist/gateway/server-cron.js +89 -0
  17. package/dist/gateway/server-health-probes.js +55 -0
  18. package/dist/gateway/server-http.js +5 -0
  19. package/dist/hooks/bundled/session-memory/handler.js +8 -2
  20. package/dist/infra/abort-signal.js +12 -0
  21. package/dist/infra/boundary-file-read.js +118 -0
  22. package/dist/infra/boundary-path.js +594 -0
  23. package/dist/infra/file-identity.js +12 -0
  24. package/dist/infra/fs-safe.js +377 -12
  25. package/dist/infra/hardlink-guards.js +30 -0
  26. package/dist/infra/json-utf8-bytes.js +8 -0
  27. package/dist/infra/net/fetch-guard.js +63 -13
  28. package/dist/infra/net/proxy-env.js +17 -0
  29. package/dist/infra/net/ssrf.js +74 -272
  30. package/dist/infra/path-alias-guards.js +21 -0
  31. package/dist/infra/path-guards.js +13 -1
  32. package/dist/infra/ports-probe.js +19 -0
  33. package/dist/infra/prototype-keys.js +4 -0
  34. package/dist/infra/restart-stale-pids.js +254 -0
  35. package/dist/infra/safe-open-sync.js +71 -0
  36. package/dist/infra/secure-random.js +7 -0
  37. package/dist/media/ffmpeg-limits.js +4 -0
  38. package/dist/media/input-files.js +6 -2
  39. package/dist/media/temp-files.js +12 -0
  40. package/dist/memory/embedding-chunk-limits.js +5 -2
  41. package/dist/memory/embeddings-ollama.js +91 -138
  42. package/dist/memory/embeddings-remote-fetch.js +11 -10
  43. package/dist/memory/embeddings.js +25 -9
  44. package/dist/memory/manager-embedding-ops.js +1 -1
  45. package/dist/memory/post-json.js +23 -0
  46. package/dist/memory/qmd-manager.js +272 -77
  47. package/dist/memory/remote-http.js +33 -0
  48. package/dist/plugin-sdk/windows-spawn.js +214 -0
  49. package/dist/shared/net/ip-test-fixtures.js +1 -0
  50. package/dist/shared/net/ip.js +303 -0
  51. package/dist/shared/net/ipv4.js +8 -11
  52. package/dist/shared/pid-alive.js +59 -2
  53. package/dist/test-helpers/ssrf.js +13 -0
  54. package/dist/tui/tui.js +9 -4
  55. package/dist/utils/fetch-timeout.js +12 -1
  56. package/docs/adr/003-feature-gap-analysis.md +112 -0
  57. 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 startChannel = async (channelId, accountId) => {
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 tracked = Promise.resolve(task)
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, tracked);
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 to new memory file
253
- await fs.writeFile(memoryFilePath, entry, "utf-8");
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(), "~");
@@ -0,0 +1,12 @@
1
+ export async function waitForAbortSignal(signal) {
2
+ if (!signal || signal.aborted) {
3
+ return;
4
+ }
5
+ await new Promise((resolve) => {
6
+ const onAbort = () => {
7
+ signal.removeEventListener("abort", onAbort);
8
+ resolve();
9
+ };
10
+ signal.addEventListener("abort", onAbort, { once: true });
11
+ });
12
+ }
@@ -0,0 +1,118 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { resolveBoundaryPath, resolveBoundaryPathSync, } from "./boundary-path.js";
4
+ import { openVerifiedFileSync, } from "./safe-open-sync.js";
5
+ export function canUseBoundaryFileOpen(ioFs) {
6
+ return (typeof ioFs.openSync === "function" &&
7
+ typeof ioFs.closeSync === "function" &&
8
+ typeof ioFs.fstatSync === "function" &&
9
+ typeof ioFs.lstatSync === "function" &&
10
+ typeof ioFs.realpathSync === "function" &&
11
+ typeof ioFs.readFileSync === "function" &&
12
+ typeof ioFs.constants === "object" &&
13
+ ioFs.constants !== null);
14
+ }
15
+ export function openBoundaryFileSync(params) {
16
+ const ioFs = params.ioFs ?? fs;
17
+ const resolved = resolveBoundaryFilePathGeneric({
18
+ absolutePath: params.absolutePath,
19
+ resolve: (absolutePath) => resolveBoundaryPathSync({
20
+ absolutePath,
21
+ rootPath: params.rootPath,
22
+ rootCanonicalPath: params.rootRealPath,
23
+ boundaryLabel: params.boundaryLabel,
24
+ skipLexicalRootCheck: params.skipLexicalRootCheck,
25
+ }),
26
+ });
27
+ if (resolved instanceof Promise) {
28
+ return toBoundaryValidationError(new Error("Unexpected async boundary resolution"));
29
+ }
30
+ return finalizeBoundaryFileOpen({
31
+ resolved,
32
+ maxBytes: params.maxBytes,
33
+ rejectHardlinks: params.rejectHardlinks,
34
+ allowedType: params.allowedType,
35
+ ioFs,
36
+ });
37
+ }
38
+ function openBoundaryFileResolved(params) {
39
+ const opened = openVerifiedFileSync({
40
+ filePath: params.absolutePath,
41
+ resolvedPath: params.resolvedPath,
42
+ rejectHardlinks: params.rejectHardlinks ?? true,
43
+ maxBytes: params.maxBytes,
44
+ allowedType: params.allowedType,
45
+ ioFs: params.ioFs,
46
+ });
47
+ if (!opened.ok) {
48
+ return opened;
49
+ }
50
+ return {
51
+ ok: true,
52
+ path: opened.path,
53
+ fd: opened.fd,
54
+ stat: opened.stat,
55
+ rootRealPath: params.rootRealPath,
56
+ };
57
+ }
58
+ function finalizeBoundaryFileOpen(params) {
59
+ if ("ok" in params.resolved) {
60
+ return params.resolved;
61
+ }
62
+ return openBoundaryFileResolved({
63
+ absolutePath: params.resolved.absolutePath,
64
+ resolvedPath: params.resolved.resolvedPath,
65
+ rootRealPath: params.resolved.rootRealPath,
66
+ maxBytes: params.maxBytes,
67
+ rejectHardlinks: params.rejectHardlinks,
68
+ allowedType: params.allowedType,
69
+ ioFs: params.ioFs,
70
+ });
71
+ }
72
+ export async function openBoundaryFile(params) {
73
+ const ioFs = params.ioFs ?? fs;
74
+ const maybeResolved = resolveBoundaryFilePathGeneric({
75
+ absolutePath: params.absolutePath,
76
+ resolve: (absolutePath) => resolveBoundaryPath({
77
+ absolutePath,
78
+ rootPath: params.rootPath,
79
+ rootCanonicalPath: params.rootRealPath,
80
+ boundaryLabel: params.boundaryLabel,
81
+ policy: params.aliasPolicy,
82
+ skipLexicalRootCheck: params.skipLexicalRootCheck,
83
+ }),
84
+ });
85
+ const resolved = maybeResolved instanceof Promise ? await maybeResolved : maybeResolved;
86
+ return finalizeBoundaryFileOpen({
87
+ resolved,
88
+ maxBytes: params.maxBytes,
89
+ rejectHardlinks: params.rejectHardlinks,
90
+ allowedType: params.allowedType,
91
+ ioFs,
92
+ });
93
+ }
94
+ function toBoundaryValidationError(error) {
95
+ return { ok: false, reason: "validation", error };
96
+ }
97
+ function mapResolvedBoundaryPath(absolutePath, resolved) {
98
+ return {
99
+ absolutePath,
100
+ resolvedPath: resolved.canonicalPath,
101
+ rootRealPath: resolved.rootCanonicalPath,
102
+ };
103
+ }
104
+ function resolveBoundaryFilePathGeneric(params) {
105
+ const absolutePath = path.resolve(params.absolutePath);
106
+ try {
107
+ const resolved = params.resolve(absolutePath);
108
+ if (resolved instanceof Promise) {
109
+ return resolved
110
+ .then((value) => mapResolvedBoundaryPath(absolutePath, value))
111
+ .catch((error) => toBoundaryValidationError(error));
112
+ }
113
+ return mapResolvedBoundaryPath(absolutePath, resolved);
114
+ }
115
+ catch (error) {
116
+ return toBoundaryValidationError(error);
117
+ }
118
+ }