@jini-ai/platform 0.6.11 → 0.6.15-rc.1

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 (54) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +52 -0
  3. package/dist/asset-cache.js +27 -13
  4. package/dist/command.d.ts +1 -1
  5. package/dist/command.js +11 -8
  6. package/dist/download.js +26 -32
  7. package/dist/fetch-with-timeout.d.ts +54 -0
  8. package/dist/fetch-with-timeout.js +129 -2
  9. package/dist/fs/directory-user-config.js +2 -1
  10. package/dist/fs/directory-workspace.js +3 -1
  11. package/dist/fs/guarded-reader.d.ts +19 -47
  12. package/dist/fs/guarded-reader.js +82 -13
  13. package/dist/index.d.ts +2 -2
  14. package/dist/index.js +1 -1
  15. package/dist/mail/adapter-module.d.ts +6 -0
  16. package/dist/mail/delivery-wrapper.d.ts +46 -0
  17. package/dist/mail/delivery-wrapper.js +109 -0
  18. package/dist/mail/domain-read.d.ts +21 -0
  19. package/dist/mail/domain-read.js +17 -0
  20. package/dist/mail/email-sender-registry.d.ts +15 -0
  21. package/dist/mail/email-sender-registry.js +22 -0
  22. package/dist/mail/email-sender.d.ts +109 -0
  23. package/dist/mail/email-sender.js +10 -0
  24. package/dist/mail/engine-contracts.d.ts +129 -0
  25. package/dist/mail/engine-contracts.js +2 -0
  26. package/dist/mail/index.d.ts +15 -2
  27. package/dist/mail/index.js +8 -1
  28. package/dist/mail/mail-status-cache.d.ts +33 -0
  29. package/dist/mail/mail-status-cache.js +56 -0
  30. package/dist/mail/prepare-sender-email.d.ts +35 -0
  31. package/dist/mail/prepare-sender-email.js +68 -0
  32. package/dist/mail/provider-error.d.ts +18 -0
  33. package/dist/mail/provider-error.js +24 -0
  34. package/dist/mail/purpose-scoped-mailer.d.ts +12 -1
  35. package/dist/mail/purpose-scoped-mailer.js +23 -12
  36. package/dist/mail/resolve-mailer.d.ts +95 -0
  37. package/dist/mail/resolve-mailer.js +348 -0
  38. package/dist/mail/sender-domain-verification.d.ts +40 -0
  39. package/dist/mail/sender-domain-verification.js +71 -0
  40. package/dist/mail/senders.d.ts +5 -0
  41. package/dist/mail/senders.js +5 -0
  42. package/dist/mail/types.d.ts +19 -0
  43. package/dist/process.d.ts +27 -2
  44. package/dist/process.js +85 -2
  45. package/dist/secrets/credential-sets/vault.repo.memory.d.ts +1 -4
  46. package/dist/secrets/credential-sets/vault.repo.memory.js +3 -1
  47. package/dist/secrets/credential-sets/vault.service.d.ts +17 -9
  48. package/dist/secrets/credential-sets/vault.service.js +18 -9
  49. package/dist/secrets/credential-sets/vault.types.d.ts +10 -4
  50. package/dist/secrets/index.d.ts +2 -0
  51. package/dist/secrets/index.js +1 -0
  52. package/dist/secrets/passphrase-envelope.d.ts +26 -0
  53. package/dist/secrets/passphrase-envelope.js +125 -0
  54. package/package.json +8 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,45 @@
1
1
  # @jini-ai/platform
2
2
 
3
+ ## 0.6.13
4
+
5
+ ### Patch Changes
6
+
7
+ - 5634614: Add vendor-neutral email sender contracts, source discovery and template preparation to the mail
8
+ surface, with a universal `@jini-ai/platform/mail/senders` entry. Add the Forms sender to
9
+ `@jini-ai/cms/forms/email-sender`, deriving tokens from stable field IDs, resolving authorized submissions and
10
+ permitted recipients, and configuring sender-owned email actions through an injected guarded writer.
11
+ Delivery planning and production readiness remain host responsibilities.
12
+ The dedicated sender entry requires the optional platform peer at ^0.6.15; the existing Forms
13
+ entry continues to load without platform installed.
14
+ - 0728914: Engine guard clean-up (`pnpm guard` 106 -> 0): layer, subpath-isolation and module-state findings fixed by moving
15
+ shared contracts to their owners instead of reaching across domains. Additive public API: `@jini-ai/core/filesystem`
16
+ (guarded-reader contracts), `useFetchQueryScope` from `@jini-ai/ui` fetch-query (per-provider query scope),
17
+ `@jini-ai/chat/react` re-exports of the accepted-input, run-state and agent-plugin chat-state helpers. The Claude
18
+ filesystem probe cache in `@jini-ai/agent-runtime` is instance-owned. No behaviour change intended; host-facing
19
+ breaks are listed in `owner-store-to-server` and `guard-host-ports`.
20
+ - 26bd2d2: Expose the mail engine through `@jini-ai/platform/mail`: host contracts, redacted
21
+ provider diagnostics, permission-aware domain reads, sender verification, delivery
22
+ policy wrapping, the purpose-gate predicate, provider resolution and status caching.
23
+ Retain existing exports and keep credential binding, persistence and product policy
24
+ in host-supplied ports.
25
+ - c14aeb4: Add optional fetch injection, bounded/strict UTF-8 response text reading, text consumption within the fetch timeout scope, and descriptor-based guarded file reads. Existing defaults and positional fetch signatures remain unchanged.
26
+
27
+ Add an opt-in details overload to readFetchResponseText with retained-byte metadata, bounded-prefix truncation, and abortable reads with prompt cleanup. fetchTextWithTimeout now races every body read against its timeout, so an injected transport whose body ignores the abort signal still times out.
28
+
29
+ - Updated dependencies [7479159]
30
+ - Updated dependencies [c31264d]
31
+ - Updated dependencies [0728914]
32
+ - Updated dependencies [6ecbdb2]
33
+ - Updated dependencies [63bacc5]
34
+ - @jini-ai/core@0.6.13
35
+
36
+ ## 0.6.12
37
+
38
+ ### Patch Changes
39
+
40
+ - platform: `listProcessUsage()` RSS/CPU sampler for macOS, Windows and Linux; `@jini-ai/platform/secrets` adds `sealWithPassphrase`/`openWithPassphrase` (portable passphrase envelope for encrypted exports); internal mail engine contracts, provider-error redaction, domain reads and per-provider delivery wrapper (not yet exported).
41
+ - @jini-ai/core@0.6.12
42
+
3
43
  ## 0.6.11
4
44
 
5
45
  ### Patch Changes
package/README.md CHANGED
@@ -98,6 +98,15 @@ The package also exposes `./http/guarded`, `./mail`, `./mail/smtp`,
98
98
  `./fs/guarded-reader`, `./fs/durable-json`, and `./secrets`. New APIs take a required
99
99
  object followed by an optional object. Application identity and policy values belong to the caller.
100
100
 
101
+ Email event senders use the universal `@jini-ai/platform/mail/senders` entry (also exported by `/mail`).
102
+ `EmailSender` exposes `listSources`, `describeSource`, `resolveEvent`, and `configureEmail`.
103
+ Each operation authorizes its workspace and principal; the sender owns values, recipients and binding.
104
+ Delivery providers are separate; `createEmailSenderRegistry` reuses core's keyed registry.
105
+ `listEmailSources` lists events across senders; `validateEmailSourceTemplate` checks compatibility.
106
+ `prepareSenderEmail` refuses disabled bindings and events before the latest activation.
107
+ Preparation renders through the injected version-checked template port and sends nothing.
108
+ Its result can be frozen in the feature's existing job; missing optional values produce token warnings.
109
+
101
110
  `@jini-ai/platform/web` loads these optional peers at import time: `parse5@7.3.0`,
102
111
  `hast-util-from-parse5@8.0.3`, `hast-util-to-mdast@10.1.2`,
103
112
  `mdast-util-to-markdown@2.1.2`, and `mdast-util-gfm-table@2.0.0`. Install all five
@@ -142,6 +151,49 @@ The SMTP subpath accepts a transport and clock; its nodemailer factory accepts t
142
151
  as a structural port. Core loads no mail vendor and adds no mail dependency.
143
152
 
144
153
  The guarded reader requires root, deny rules, all read/traversal limits, and a filesystem port.
154
+ Its existing options object also accepts `descriptorRead: { exactMode?: number, rejectGrowth?: boolean }`.
155
+ When present (including `{}`), reads open with `O_RDONLY | O_NOFOLLOW | O_NONBLOCK`, verify
156
+ regular-file status and size with `fstatSync`, and read that same descriptor. `exactMode` compares
157
+ exact permission bits including setuid/setgid/sticky (`mode & 0o7777`); `rejectGrowth: true` rejects bytes beyond its initial size.
158
+ The filesystem's optional `fstatSync` and `constants` capabilities become required for this policy;
159
+ unsupported flags fail closed. No-follow protects the final path component; ancestor-directory
160
+ replacement races remain outside this guarantee. Omitting the policy retains existing reads.
161
+
162
+ `@jini-ai/platform/fetch-with-timeout` also exports `TextReadOptions`,
163
+ `readFetchResponseText({ response }, { maxResponseBytes?, strictUtf8? })`, and
164
+ `fetchTextWithTimeout({ url, timeoutMs, init? }, { fetch?, maxResponseBytes?, strictUtf8? })`.
165
+ Text defaults to unlimited bytes and replacement UTF-8 decoding. Caps must be nonnegative safe
166
+ integers; exactly the cap is accepted, overflow throws `RangeError`, and strict UTF-8 throws on
167
+ malformed/truncated input. Streamed bytes are counted before decoding, independent of Content-Length;
168
+ failed guarded consumption cancels and releases the reader without masking the original error.
169
+ Incoming chunk allocation is controlled by the upstream stream. The text-fetch helper maps timeout
170
+ errors during both headers and body consumption to `FetchTimeoutError`, returns `{ response, text }`,
171
+ and leaves the response metadata available with its body consumed. The existing positional
172
+ `fetchWithTimeout(url, init, { timeoutMs, fetch? })` now accepts transport injection and still returns
173
+ the original unconsumed response. Neither function retries.
174
+
175
+ For retained-byte metadata and optional truncation, use the single-object overload:
176
+
177
+ ```ts
178
+ const { text, byteLength, truncated } = await readFetchResponseText({
179
+ response,
180
+ maxResponseBytes: 1024,
181
+ result: "details",
182
+ onLimit: "truncate",
183
+ signal,
184
+ });
185
+ ```
186
+
187
+ This overload requires a nonnegative safe-integer cap. `onLimit` defaults to `"reject"`;
188
+ `strictUtf8` defaults to `false`. `byteLength` counts retained source bytes before decoding,
189
+ and `truncated` becomes true only after observing bytes beyond the cap. An exact-sized body
190
+ (including an empty body at a zero cap) requires EOF to establish completeness. Truncation
191
+ cancels consumption and flushes the decoder; cutting a UTF-8 character produces replacement
192
+ text by default or throws with `strictUtf8: true`. A signal interrupts pending reads and
193
+ preserves its abort reason. Details-mode cancellation failures are observed without waiting
194
+ for cancellation to settle, and every acquired reader's lock is released. Existing string
195
+ overloads, defaults, and text-fetch behavior remain unchanged.
196
+
145
197
  The durable JSON factory requires file path, filesystem/time/sleep/liveness/random/reporting ports,
146
198
  and host notices. It preserves atomic replacement and corrupt-file states; parent-directory
147
199
  persistence and the inherited simultaneous stale-lock-break race remain filesystem/locking limits.
@@ -29,7 +29,7 @@ import { isPrivateAddress } from './net/address.js';
29
29
  /** Media extensions we are willing to cache + proxy. Anything else (HTML,
30
30
  * CSS, JS, fonts) is intentionally left alone so this surface never becomes a
31
31
  * general-purpose open proxy. */
32
- export const CACHEABLE_MEDIA_EXT = /\.(?:png|jpe?g|webp|gif|avif|svg|mp4|webm|mov|m4v|ogg|mp3|wav)(?:$|[?#])/i;
32
+ export const CACHEABLE_MEDIA_EXT = /\.(?:png|jpe?g|webp|gif|avif|mp4|webm|mov|m4v|ogg|mp3|wav)$/i;
33
33
  const EXT_TO_MIME = {
34
34
  '.png': 'image/png',
35
35
  '.jpg': 'image/jpeg',
@@ -37,7 +37,6 @@ const EXT_TO_MIME = {
37
37
  '.webp': 'image/webp',
38
38
  '.gif': 'image/gif',
39
39
  '.avif': 'image/avif',
40
- '.svg': 'image/svg+xml',
41
40
  '.mp4': 'video/mp4',
42
41
  '.webm': 'video/webm',
43
42
  '.mov': 'video/quicktime',
@@ -46,6 +45,7 @@ const EXT_TO_MIME = {
46
45
  '.mp3': 'audio/mpeg',
47
46
  '.wav': 'audio/wav',
48
47
  };
48
+ const CACHEABLE_MEDIA_MIME = new Set(Object.values(EXT_TO_MIME));
49
49
  /** True when `raw` is an absolute http(s) URL pointing at a media file we are
50
50
  * willing to route through the cache. Pure + side-effect free so the URL
51
51
  * rewriter and tests can share the exact predicate. */
@@ -55,11 +55,14 @@ export function isCacheableExternalUrl(raw) {
55
55
  const value = raw.trim();
56
56
  if (!/^https?:\/\//i.test(value))
57
57
  return false;
58
- // `String#split` always returns a non-empty array (even `''.split(x)` is
59
- // `['']`), so index 0 is never `undefined` — the `!` narrows a
60
- // `noUncheckedIndexedAccess` false positive, not a real fallback path.
61
- const pathOnly = value.split(/[?#]/)[0];
62
- return CACHEABLE_MEDIA_EXT.test(pathOnly) || CACHEABLE_MEDIA_EXT.test(value);
58
+ // Only the parsed pathname establishes eligibility; a query/fragment that
59
+ // happens to end in .png must not turn an arbitrary endpoint into media.
60
+ try {
61
+ return CACHEABLE_MEDIA_EXT.test(new URL(value).pathname);
62
+ }
63
+ catch {
64
+ return false;
65
+ }
63
66
  }
64
67
  /** Map a cacheable external URL to its same-origin proxy URL. `routePath`
65
68
  * defaults to `/api/asset-cache` but is caller-configurable since the route
@@ -158,13 +161,16 @@ export function createAssetCache(opts, options = {}) {
158
161
  const suppliedDispatcher = options.dispatcher ?? opts.dispatcher;
159
162
  const dispatcher = suppliedDispatcher ?? createAssetCacheDispatcher({}, opts.lookupImpl ? { lookupImpl: opts.lookupImpl } : {});
160
163
  const inflight = new Map();
161
- async function readFromDisk(key) {
164
+ async function readFromDisk({ key }, _options = {}) {
162
165
  const blobPath = path.join(cacheDir, key);
163
166
  const metaPath = `${blobPath}.json`;
164
167
  try {
165
168
  const [buf, metaRaw] = await Promise.all([readFile(blobPath), readFile(metaPath, 'utf8')]);
166
169
  const meta = JSON.parse(metaRaw);
167
- const contentType = typeof meta.contentType === 'string' && meta.contentType ? meta.contentType : 'application/octet-stream';
170
+ const contentType = meta.contentType;
171
+ // Old cache entries must satisfy the same passive-media policy as new fetches.
172
+ if (typeof contentType !== 'string' || !CACHEABLE_MEDIA_MIME.has(contentType))
173
+ return null;
168
174
  return { buf, contentType };
169
175
  }
170
176
  catch {
@@ -179,15 +185,23 @@ export function createAssetCache(opts, options = {}) {
179
185
  await rename(tmpPath, blobPath);
180
186
  await writeFile(`${blobPath}.json`, JSON.stringify({ contentType: result.contentType }));
181
187
  }
182
- function resolveContentType(headerValue, url) {
188
+ function resolveContentType({ headerValue, url }, _options = {}) {
189
+ // Moved from URL eligibility, which now uses URL.pathname instead of split:
190
+ // `String#split` always returns a non-empty array (even `''.split(x)` is
191
+ // `['']`), so index 0 is never `undefined` — the `!` narrows a
192
+ // `noUncheckedIndexedAccess` false positive, not a real fallback path.
183
193
  // `(headerValue ?? '')` is reachable (no content-type header at all). The
184
194
  // trailing `?? ''` after the optional-chained `.split(';')[0]?.trim()` is
185
195
  // not: `String#split` always returns a non-empty array, so `[0]` is never
186
196
  // `undefined` and `?.` never short-circuits — kept for
187
197
  // `noUncheckedIndexedAccess`, not a real fallback path.
188
198
  const header = (headerValue ?? '').split(';')[0].trim().toLowerCase();
189
- if (/^(?:image|video|audio)\//.test(header))
199
+ if (CACHEABLE_MEDIA_MIME.has(header))
190
200
  return header;
201
+ // An explicit active/unsupported type must never be relabelled by its URL.
202
+ if (header && header !== 'application/octet-stream') {
203
+ throw new AssetCacheError(415, `unsupported content-type: ${header}`);
204
+ }
191
205
  const ext = path.posix.extname(url.pathname).toLowerCase();
192
206
  const guessed = EXT_TO_MIME[ext];
193
207
  if (guessed)
@@ -262,7 +276,7 @@ export function createAssetCache(opts, options = {}) {
262
276
  const declaredLength = Number(response.headers.get('content-length') ?? '');
263
277
  if (Number.isFinite(declaredLength))
264
278
  assertWithinLimit(declaredLength);
265
- const contentType = resolveContentType(response.headers.get('content-type'), url);
279
+ const contentType = resolveContentType({ headerValue: response.headers.get('content-type'), url });
266
280
  const buf = await readBodyCapped(response, controller);
267
281
  const result = { buf, contentType };
268
282
  try {
@@ -283,7 +297,7 @@ export function createAssetCache(opts, options = {}) {
283
297
  }
284
298
  const url = rawUrl.trim();
285
299
  const key = assetCacheKey(url);
286
- const cached = await readFromDisk(key);
300
+ const cached = await readFromDisk({ key });
287
301
  if (cached)
288
302
  return cached;
289
303
  const existing = inflight.get(key);
package/dist/command.d.ts CHANGED
@@ -17,7 +17,7 @@ export type CommandInvocationRequest = {
17
17
  * @param request - The command, optional args, and env to base the invocation on.
18
18
  * @returns The `{ command, args }` (plus `windowsVerbatimArguments` when a shim is used) to spawn.
19
19
  */
20
- export declare function createCommandInvocation({ args, command, env }: CommandInvocationRequest): CommandInvocation;
20
+ export declare function createCommandInvocation({ args, command, env }: CommandInvocationRequest, _options?: {}): CommandInvocation;
21
21
  /**
22
22
  * Build the invocation for re-entering the repo-pinned package manager. Prefers
23
23
  * `npm_execpath` (routing Node-loadable managers through `process.execPath`),
package/dist/command.js CHANGED
@@ -25,10 +25,13 @@ import { extname } from "node:path";
25
25
  // into one literal arg with the `%` preserved. The two layers cancel, so the
26
26
  // child receives the original arg byte-for-byte while cmd never has a chance
27
27
  // to expand anything inside it.
28
- function quoteWindowsCommandArg(value) {
29
- if (!/[\s"&<>|^%]/.test(value))
28
+ function quoteWindowsCommandArg({ value }, _options = {}) {
29
+ if (/[\r\n]/.test(value))
30
+ throw new Error("Windows command shim arguments cannot contain line breaks");
31
+ if (value !== "" && !/[\s"&<>|^%=,;()]/.test(value))
30
32
  return value;
31
- const escaped = value.replace(/"/g, '""').replace(/%/g, '"^%"');
33
+ // Backslashes before the closing quote must survive the child's argv parser.
34
+ const escaped = value.replace(/"/g, '""').replace(/%/g, '"^%"').replace(/\\+$/, '$&$&');
32
35
  return `"${escaped}"`;
33
36
  }
34
37
  // Build the `cmd.exe /d /s /c "<line>"` invocation Node uses internally for
@@ -45,8 +48,8 @@ function quoteWindowsCommandArg(value) {
45
48
  // Without this, paths containing spaces (`C:\Users\First Last\...\foo.cmd`)
46
49
  // get split on the first space and cmd.exe reports "not recognized as an
47
50
  // internal or external command" — see issue #315.
48
- function buildCmdShimInvocation(command, args, env) {
49
- const inner = [command, ...args].map(quoteWindowsCommandArg).join(" ");
51
+ function buildCmdShimInvocation({ command, args, env }, _options = {}) {
52
+ const inner = [command, ...args].map((value) => quoteWindowsCommandArg({ value })).join(" ");
50
53
  return {
51
54
  args: ["/d", "/s", "/c", `"${inner}"`],
52
55
  command: env.ComSpec ?? process.env.ComSpec ?? "cmd.exe",
@@ -63,9 +66,9 @@ const nodeLoadablePackageManagerExtensions = new Set([".js", ".cjs", ".mjs"]);
63
66
  * @param request - The command, optional args, and env to base the invocation on.
64
67
  * @returns The `{ command, args }` (plus `windowsVerbatimArguments` when a shim is used) to spawn.
65
68
  */
66
- export function createCommandInvocation({ args = [], command, env = process.env }) {
69
+ export function createCommandInvocation({ args = [], command, env = process.env }, _options = {}) {
67
70
  if (process.platform === "win32" && /\.(bat|cmd)$/i.test(command)) {
68
- return buildCmdShimInvocation(command, args, env);
71
+ return buildCmdShimInvocation({ command, args, env });
69
72
  }
70
73
  return { args, command };
71
74
  }
@@ -94,7 +97,7 @@ export function createPackageManagerInvocation(args, env = process.env) {
94
97
  // package manager out of the box, while a bare `pnpm` only resolves when a
95
98
  // separate global install or a `corepack enable` shim is present. See #2438.
96
99
  if (process.platform === "win32") {
97
- return buildCmdShimInvocation("corepack", ["pnpm", ...args], env);
100
+ return buildCmdShimInvocation({ command: "corepack", args: ["pnpm", ...args], env });
98
101
  }
99
102
  return { args: ["pnpm", ...args], command: "corepack" };
100
103
  }
package/dist/download.js CHANGED
@@ -186,17 +186,16 @@ async function writeSentinel(basePath) {
186
186
  schemaVersion: STORE_SCHEMA_VERSION,
187
187
  });
188
188
  }
189
- async function resetOwnedBase(basePath) {
189
+ /** Clear only this target's data after rechecking store ownership; keep its lock held. */
190
+ async function resetOwnedTarget({ target }, _options = {}) {
191
+ const { basePath } = target;
190
192
  const sentinel = await readJson(join(basePath, STORE_SENTINEL));
191
193
  if (!isStoreSentinel(sentinel)) {
192
194
  throw new ManagedDownloadError(MANAGED_DOWNLOAD_ERROR_CODES.STORE_NOT_OWNED, `download base is not owned: ${basePath}`);
193
195
  }
194
- const entries = await readdir(basePath).catch(() => []);
195
- for (const entry of entries) {
196
- await rm(join(basePath, entry), { force: true, recursive: true }).catch(() => undefined);
196
+ for (const path of [target.finalPath, target.partialPath, target.manifestPath]) {
197
+ await rm(path, { force: true }).catch(() => undefined);
197
198
  }
198
- await writeSentinel(basePath);
199
- await ensureStoreDirs(basePath);
200
199
  }
201
200
  async function ensureStoreDirs(basePath) {
202
201
  await mkdir(join(basePath, STATE_DIR), { recursive: true });
@@ -540,8 +539,8 @@ async function releaseLock(lock) {
540
539
  return;
541
540
  await rm(lock.path, { force: true }).catch(() => undefined);
542
541
  }
543
- async function suspiciousReset(target) {
544
- await resetOwnedBase(target.basePath);
542
+ async function suspiciousReset({ target }, _options = {}) {
543
+ await resetOwnedTarget({ target });
545
544
  return { manifest: null, reset: true };
546
545
  }
547
546
  async function loadReusableState(target) {
@@ -549,18 +548,18 @@ async function loadReusableState(target) {
549
548
  const finalExists = await pathExists(target.finalPath);
550
549
  const partialExists = await pathExists(target.partialPath);
551
550
  if (manifest === "invalid") {
552
- return await suspiciousReset(target);
551
+ return await suspiciousReset({ target });
553
552
  }
554
553
  if (manifest == null) {
555
- return finalExists || partialExists ? await suspiciousReset(target) : { manifest: null };
554
+ return finalExists || partialExists ? await suspiciousReset({ target }) : { manifest: null };
556
555
  }
557
556
  if (!manifestMatchesTarget(manifest, target)) {
558
- return await suspiciousReset(target);
557
+ return await suspiciousReset({ target });
559
558
  }
560
559
  if (manifest.state === "complete")
561
560
  return await reusableCompleteState({ manifest, target });
562
561
  if (!partialExists)
563
- return await suspiciousReset(target);
562
+ return await suspiciousReset({ target });
564
563
  return { manifest };
565
564
  }
566
565
  /** Reuse a complete download only when its final file exists and still hashes to the requested checksum. */
@@ -570,10 +569,10 @@ async function reusableCompleteState(input) {
570
569
  // maps to `null` and fails the checksum comparison into the same reset.
571
570
  const actual = await hashFile(target.finalPath, target.checksum.algorithm).catch(() => null);
572
571
  if (actual !== target.checksum.value)
573
- return await suspiciousReset(target);
572
+ return await suspiciousReset({ target });
574
573
  const bytes = await statFileSize(target.finalPath);
575
574
  if (bytes == null)
576
- return await suspiciousReset(target);
575
+ return await suspiciousReset({ target });
577
576
  return {
578
577
  manifest,
579
578
  result: {
@@ -591,13 +590,13 @@ async function reusableCompleteState(input) {
591
590
  async function runManagedDownload(target, options) {
592
591
  await ensureManagedBase(target.basePath);
593
592
  await pruneManagedDownloads({ basePath: target.basePath }).catch(() => undefined);
594
- const held = { lock: await acquireLock(target) };
593
+ const lock = await acquireLock(target);
595
594
  try {
596
- const state = await loadStateAllowingOneReset({ held, target });
595
+ const state = await loadStateAllowingOneReset({ target });
597
596
  if (state.result != null)
598
597
  return state.result;
599
598
  const download = await downloadWithRetries(target, state.manifest, options);
600
- const bytes = await promoteVerifiedPartial(target);
599
+ const bytes = await promoteVerifiedPartial({ target });
601
600
  await writeJson(target.manifestPath, createManifest(target, "complete", { totalBytes: download.totalBytes }));
602
601
  return {
603
602
  bucket: target.bucket,
@@ -611,36 +610,31 @@ async function runManagedDownload(target, options) {
611
610
  };
612
611
  }
613
612
  finally {
614
- await releaseLock(held.lock);
613
+ await releaseLock(lock);
615
614
  }
616
615
  }
617
- /** Load reusable state, re-locking and reloading once if the first load had to reset the managed base. */
618
- async function loadStateAllowingOneReset(input) {
619
- const { held, target } = input;
616
+ /** Reload once after target cleanup, retaining the lock until the run finishes. */
617
+ async function loadStateAllowingOneReset({ target }, _options = {}) {
620
618
  const state = await loadReusableState(target);
621
619
  if (state.reset !== true)
622
620
  return state;
623
- // A reset removes the lock as part of the managed base cleanup, so
624
- // reacquire it before writing the fresh target state. `held.lock` is
625
- // nulled first so a failed re-acquire never releases (deletes) a lock
626
- // another process may have taken in the meantime.
627
- await releaseLock(held.lock);
628
- held.lock = null;
621
+ // Whole-base cleanup used to remove the lock and require re-acquisition.
622
+ // Keep it throughout target cleanup so another process cannot take it
623
+ // before we write fresh state, or have its own lock deleted by our finally.
629
624
  await ensureManagedBase(target.basePath);
630
- held.lock = await acquireLock(target);
631
625
  const reloaded = await loadReusableState(target);
632
626
  if (reloaded.reset === true) {
633
- throw new ManagedDownloadError(MANAGED_DOWNLOAD_ERROR_CODES.STORE_CORRUPT, "download state kept resetting after base cleanup");
627
+ throw new ManagedDownloadError(MANAGED_DOWNLOAD_ERROR_CODES.STORE_CORRUPT, "download state kept resetting after target cleanup");
634
628
  }
635
629
  return reloaded;
636
630
  }
637
631
  /** Verify the downloaded partial's checksum, promote it to the final path, and return the final size. */
638
- async function promoteVerifiedPartial(target) {
632
+ async function promoteVerifiedPartial({ target }, _options = {}) {
639
633
  const actual = await hashFile(target.partialPath, target.checksum.algorithm).catch((error) => {
640
634
  throw new ManagedDownloadError(MANAGED_DOWNLOAD_ERROR_CODES.STORE_CORRUPT, `downloaded partial could not be hashed: ${errorMessage(error)}`);
641
635
  });
642
636
  if (actual !== target.checksum.value) {
643
- await resetOwnedBase(target.basePath).catch(() => undefined);
637
+ await resetOwnedTarget({ target }).catch(() => undefined);
644
638
  throw new ManagedDownloadError(MANAGED_DOWNLOAD_ERROR_CODES.CHECKSUM_MISMATCH, "downloaded file checksum did not match requested payload", {
645
639
  actual,
646
640
  expected: target.checksum.value,
@@ -650,7 +644,7 @@ async function promoteVerifiedPartial(target) {
650
644
  if (await pathExists(target.finalPath)) {
651
645
  const existing = await hashFile(target.finalPath, target.checksum.algorithm).catch(() => null);
652
646
  if (existing !== target.checksum.value) {
653
- await resetOwnedBase(target.basePath).catch(() => undefined);
647
+ await resetOwnedTarget({ target }).catch(() => undefined);
654
648
  throw new ManagedDownloadError(MANAGED_DOWNLOAD_ERROR_CODES.STORE_CORRUPT, "existing complete file did not match requested payload");
655
649
  }
656
650
  await rm(target.partialPath, { force: true }).catch(() => undefined);
@@ -86,7 +86,44 @@ export interface FetchWithTimeoutOptions {
86
86
  /** Milliseconds before the call is aborted. Pick one of {@link FETCH_TIMEOUT_MS}, or a specific
87
87
  * justified number for a call site that doesn't fit those classes. */
88
88
  timeoutMs: number;
89
+ /** Host-supplied transport; defaults to the global fetch. */
90
+ fetch?: typeof fetch;
89
91
  }
92
+ /** Opt-in byte and decoding guards; defaults remain unlimited and replacement-decoded. */
93
+ export type TextReadOptions = {
94
+ /** Nonnegative safe integer. Actual streamed bytes, independent of Content-Length. */
95
+ maxResponseBytes?: number;
96
+ /** Reject malformed UTF-8, including an incomplete trailing character. */
97
+ strictUtf8?: boolean;
98
+ };
99
+ /**
100
+ * Consumes a response as UTF-8, checking bytes before decoding/retaining each chunk.
101
+ * Details mode reports retained source bytes and truncates only on observed overflow;
102
+ * reaching the cap still requires EOF to establish completeness. Defaults reject overflow
103
+ * and replace malformed UTF-8. A supplied signal interrupts even a stalled read.
104
+ * @throws {RangeError} An invalid cap or a response exceeding it.
105
+ * @throws {TypeError} Invalid UTF-8 when strict decoding is enabled.
106
+ * Read failures and abort reasons propagate; acquired readers are cancelled on failure
107
+ * and always released. Details mode observes cancellation failures without awaiting cleanup.
108
+ * @complexity O(n) time and retained text for n bytes, bounded by the optional cap.
109
+ * The upstream stream controls the allocation of individual incoming chunks.
110
+ * @example readFetchResponseText({ response, maxResponseBytes: 1024, result: "details", onLimit: "truncate" })
111
+ */
112
+ export declare function readFetchResponseText(input: {
113
+ response: Response;
114
+ maxResponseBytes: number;
115
+ result: "details";
116
+ onLimit?: "reject" | "truncate";
117
+ strictUtf8?: boolean;
118
+ signal?: AbortSignal;
119
+ }): Promise<{
120
+ text: string;
121
+ byteLength: number;
122
+ truncated: boolean;
123
+ }>;
124
+ export declare function readFetchResponseText({ response }: {
125
+ response: Response;
126
+ }, options?: TextReadOptions): Promise<string>;
90
127
  /**
91
128
  * `fetch`, but aborted after `options.timeoutMs` if it hasn't settled by then. If `init.signal` is
92
129
  * already set, the call aborts on EITHER that signal or the timeout, via `AbortSignal.any` — a
@@ -103,4 +140,21 @@ export interface FetchWithTimeoutOptions {
103
140
  * genuine network error) propagates unchanged.
104
141
  */
105
142
  export declare function fetchWithTimeout(url: string | URL, init: RequestInit | undefined, options: FetchWithTimeoutOptions): Promise<Response>;
143
+ /**
144
+ * Fetches and consumes text inside one timeout scope. The returned response keeps its metadata
145
+ * and has a consumed body. No retries; other fetch/read errors propagate unchanged.
146
+ * @throws {FetchTimeoutError} The timeout ends fetch or body consumption before caller abort.
147
+ * @throws {RangeError | TypeError} See {@link readFetchResponseText} for opt-in guards.
148
+ * @complexity O(n) time and text storage for n response bytes, optionally capped.
149
+ */
150
+ export declare function fetchTextWithTimeout(required: {
151
+ url: string | URL;
152
+ timeoutMs: number;
153
+ init?: RequestInit;
154
+ }, optional?: TextReadOptions & {
155
+ fetch?: typeof fetch;
156
+ }): Promise<{
157
+ response: Response;
158
+ text: string;
159
+ }>;
106
160
  //# sourceMappingURL=fetch-with-timeout.d.ts.map
@@ -87,6 +87,107 @@ export const FETCH_TIMEOUT_MS = {
87
87
  * legitimately still-generating request early. */
88
88
  GENERATE: 10 * 60_000,
89
89
  };
90
+ export async function readFetchResponseText(input, { maxResponseBytes, strictUtf8 = false } = {}) {
91
+ const details = "result" in input && input.result === "details";
92
+ const options = details ? input : { ...(maxResponseBytes === undefined ? {} : { maxResponseBytes }), strictUtf8 };
93
+ validateTextReadOptions({ maxResponseBytes: options.maxResponseBytes, requireCap: details });
94
+ if (!details && options.maxResponseBytes === undefined && !options.strictUtf8)
95
+ return input.response.text();
96
+ const result = await consumeResponseText({ response: input.response, awaitCancel: !details }, options);
97
+ return details ? result : result.text;
98
+ }
99
+ /** Owns reader cleanup for every caller; legacy failure cleanup keeps its awaited behavior.
100
+ * Signal-driven callers skip that wait: a body that ignores its signal may never settle cancel. */
101
+ async function consumeResponseText({ response, awaitCancel }, options = {}) {
102
+ options.signal?.throwIfAborted();
103
+ if (response.body === null)
104
+ return { text: "", byteLength: 0, truncated: false };
105
+ const reader = response.body.getReader();
106
+ let failed = true;
107
+ try {
108
+ const result = await decodeResponseStream({ reader }, options);
109
+ if (result.truncated)
110
+ void cancelResponseReader({ reader });
111
+ failed = false;
112
+ return result;
113
+ }
114
+ catch (error) {
115
+ const cancellation = cancelResponseReader({ reader, reason: error });
116
+ if (awaitCancel)
117
+ await cancellation;
118
+ throw error;
119
+ }
120
+ finally {
121
+ try {
122
+ reader.releaseLock();
123
+ }
124
+ catch (error) {
125
+ if (!failed)
126
+ throw error;
127
+ }
128
+ }
129
+ }
130
+ /** Observe synchronous/asynchronous cancellation failure without masking the consumption outcome. */
131
+ function cancelResponseReader({ reader, reason }) {
132
+ try {
133
+ return reader.cancel(reason).catch(() => { });
134
+ }
135
+ catch {
136
+ return Promise.resolve();
137
+ }
138
+ }
139
+ /** Reject invalid caps before consumption, including for empty responses. */
140
+ function validateTextReadOptions({ maxResponseBytes, requireCap = false }) {
141
+ if (maxResponseBytes === undefined && !requireCap)
142
+ return;
143
+ if (typeof maxResponseBytes !== "number" || !Number.isSafeInteger(maxResponseBytes) || maxResponseBytes < 0) {
144
+ throw new RangeError("maxResponseBytes must be a nonnegative safe integer");
145
+ }
146
+ }
147
+ /** Byte counting precedes decoding; final flush detects truncated UTF-8 in strict mode.
148
+ * Retains only the allowed prefix, independently of upstream chunk size or headers. */
149
+ async function decodeResponseStream({ reader }, { maxResponseBytes = Infinity, strictUtf8 = false, onLimit = "reject", signal } = {}) {
150
+ const decoder = new TextDecoder("utf-8", { fatal: strictUtf8 });
151
+ let total = 0;
152
+ let text = "";
153
+ for (;;) {
154
+ const { done, value } = await readResponseChunk({ reader, signal });
155
+ if (done)
156
+ break;
157
+ const remaining = maxResponseBytes - total;
158
+ if (value.byteLength > remaining) {
159
+ if (onLimit === "reject")
160
+ throw new RangeError(`response exceeds the ${maxResponseBytes}-byte limit`);
161
+ text += decoder.decode(value.subarray(0, remaining), { stream: true });
162
+ return { text: text + decoder.decode(), byteLength: maxResponseBytes, truncated: true };
163
+ }
164
+ total += value.byteLength;
165
+ text += decoder.decode(value, { stream: true });
166
+ }
167
+ return { text: text + decoder.decode(), byteLength: total, truncated: false };
168
+ }
169
+ /** Race one read against cancellation, removing the listener on every outcome.
170
+ * Per-read listeners avoid accumulating pending race handlers over a long response. */
171
+ async function readResponseChunk({ reader, signal }) {
172
+ if (!signal)
173
+ return reader.read();
174
+ signal.throwIfAborted();
175
+ let abort = () => { };
176
+ try {
177
+ return await Promise.race([
178
+ new Promise((_resolve, reject) => {
179
+ abort = () => reject(signal.reason);
180
+ signal.addEventListener("abort", abort, { once: true });
181
+ if (signal.aborted)
182
+ abort();
183
+ }),
184
+ Promise.resolve().then(() => { signal.throwIfAborted(); return reader.read(); }),
185
+ ]);
186
+ }
187
+ finally {
188
+ signal.removeEventListener("abort", abort);
189
+ }
190
+ }
90
191
  /**
91
192
  * `fetch`, but aborted after `options.timeoutMs` if it hasn't settled by then. If `init.signal` is
92
193
  * already set, the call aborts on EITHER that signal or the timeout, via `AbortSignal.any` — a
@@ -103,11 +204,37 @@ export const FETCH_TIMEOUT_MS = {
103
204
  * genuine network error) propagates unchanged.
104
205
  */
105
206
  export async function fetchWithTimeout(url, init = {}, options) {
106
- const { timeoutMs } = options;
207
+ return executeFetchWithTimeout({ url, init, timeoutMs: options.timeoutMs, consume: async ({ response }) => response }, options);
208
+ }
209
+ /**
210
+ * Fetches and consumes text inside one timeout scope. The returned response keeps its metadata
211
+ * and has a consumed body. No retries; other fetch/read errors propagate unchanged.
212
+ * @throws {FetchTimeoutError} The timeout ends fetch or body consumption before caller abort.
213
+ * @throws {RangeError | TypeError} See {@link readFetchResponseText} for opt-in guards.
214
+ * @complexity O(n) time and text storage for n response bytes, optionally capped.
215
+ */
216
+ export async function fetchTextWithTimeout(required, optional = {}) {
217
+ return executeFetchWithTimeout({
218
+ url: required.url,
219
+ timeoutMs: required.timeoutMs,
220
+ init: required.init ?? {},
221
+ consume: async ({ response, signal }) => {
222
+ const { maxResponseBytes, strictUtf8 = false } = optional;
223
+ validateTextReadOptions({ maxResponseBytes });
224
+ // Always stream under the scope's signal: an injected fetch's body may ignore that signal,
225
+ // and only racing each read lets the timeout end consumption.
226
+ const read = await consumeResponseText({ response, awaitCancel: false }, { ...(maxResponseBytes === undefined ? {} : { maxResponseBytes }), strictUtf8, signal });
227
+ return { response, text: read.text };
228
+ },
229
+ }, optional);
230
+ }
231
+ /** Shared signal composition/error mapping; consumption decides the timeout scope's endpoint. */
232
+ async function executeFetchWithTimeout({ url, init, timeoutMs, consume }, options = {}) {
107
233
  const timeoutSignal = AbortSignal.timeout(timeoutMs);
108
234
  const signal = init.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal;
109
235
  try {
110
- return await fetch(url, { ...init, signal });
236
+ const response = await (options.fetch ?? fetch)(url, { ...init, signal });
237
+ return await consume({ response, signal });
111
238
  }
112
239
  catch (error) {
113
240
  // `timeoutSignal` is the original object this function created, not the (possibly composed)
@@ -68,7 +68,8 @@ export function createDirectoryUserConfig({ homeDir, defaultFolder, envName, pol
68
68
  }
69
69
  function configuredRoots(saved) {
70
70
  const fromEnv = env[policy.rootsEnvName];
71
- return fromEnv !== undefined ? fromEnv.split(",").map((root) => root.trim()).filter(Boolean) : saved.siteRoots ?? policy.sourceRoots ?? [];
71
+ // Saved roots are extras; policy roots remain implicit unless the environment overrides them.
72
+ return fromEnv !== undefined ? fromEnv.split(",").map((root) => root.trim()).filter(Boolean) : [...(policy.sourceRoots ?? []), ...(saved.siteRoots ?? [])];
72
73
  }
73
74
  // Prefer explicit settings, never coerce an existing regular file into a directory.
74
75
  function assertDirectories(checked) {