@jini-ai/platform 0.6.15-rc.2 → 0.6.15

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 (56) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +4 -2
  3. package/dist/blob-storage.js +10 -9
  4. package/dist/child-environment.js +1 -1
  5. package/dist/command.js +4 -1
  6. package/dist/core/effective-port.d.ts +6 -0
  7. package/dist/core/effective-port.js +8 -0
  8. package/dist/download.js +25 -24
  9. package/dist/fetch-with-timeout.d.ts +26 -6
  10. package/dist/fetch-with-timeout.js +50 -10
  11. package/dist/fs/atomic-write.js +5 -2
  12. package/dist/fs/boot-identity.d.ts +0 -4
  13. package/dist/fs/boot-identity.js +9 -3
  14. package/dist/fs/directory-registry.js +2 -2
  15. package/dist/fs/directory-running-lock.js +9 -4
  16. package/dist/fs/directory-sync.js +2 -3
  17. package/dist/fs/directory-workspace.js +11 -14
  18. package/dist/fs/durable-json.js +22 -16
  19. package/dist/fs/errno.d.ts +9 -0
  20. package/dist/fs/errno.js +21 -0
  21. package/dist/fs/file-lock.js +17 -19
  22. package/dist/fs/guarded-reader.js +15 -9
  23. package/dist/fs/user-data-directories.js +5 -7
  24. package/dist/fs.js +2 -1
  25. package/dist/http/guarded/client.js +2 -1
  26. package/dist/http.js +2 -1
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.js +1 -1
  29. package/dist/mail/builtin-providers.js +4 -2
  30. package/dist/mail/feedback.js +1 -1
  31. package/dist/mail/prepare-sender-email.js +7 -1
  32. package/dist/mail/provider.js +14 -8
  33. package/dist/mail/rate-limit.js +3 -1
  34. package/dist/net/endpoint-policy.js +15 -7
  35. package/dist/object-store/local-fs.js +3 -1
  36. package/dist/process.js +10 -9
  37. package/dist/proxy-env.js +22 -14
  38. package/dist/secrets/credential-sets/provider-matches.js +14 -3
  39. package/dist/secrets/credential-sets/vault.meta.js +6 -2
  40. package/dist/secrets/credential-sets/vault.repo.memory.js +3 -5
  41. package/dist/secrets/credential-sets/vault.service.js +22 -17
  42. package/dist/secrets/credential-sets/vault.types.d.ts +6 -0
  43. package/dist/secrets/credential-sets/vault.types.js +7 -0
  44. package/dist/secrets/credential-token.js +9 -2
  45. package/dist/secrets/keyring.env.js +2 -4
  46. package/dist/shell.js +2 -1
  47. package/dist/terminal.js +4 -7
  48. package/dist/toolchain.js +4 -9
  49. package/dist/web/first-distinct.d.ts +11 -0
  50. package/dist/web/first-distinct.js +16 -0
  51. package/dist/web/html-document.js +35 -39
  52. package/dist/web/html-to-markdown.js +20 -9
  53. package/dist/web/web-page.js +28 -25
  54. package/dist/windows-system-tools.d.ts +15 -0
  55. package/dist/windows-system-tools.js +16 -0
  56. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # @jini-ai/platform
2
2
 
3
+ ## 0.6.15
4
+
5
+ ### Minor Changes
6
+
7
+ - Add optional `now` and opaque per-install `settings` to `MailAdapterCreateContext`, inherited by
8
+ `MailProviderModule.create`. Export `createMailAdapterContext` from `@jini-ai/platform/mail` so hosts
9
+ can supply a live real clock and fresh empty settings at composition time while legacy direct
10
+ create calls remain compatible. Provider modules retain ownership of settings validation.
11
+
12
+ - Add opt-in plugin source suffix exclusions and test-file inclusion. Explicit allow-paths and their ancestors now override name exclusions, while excluded descendants and suffix exclusions still skip. Default packing stays unchanged when the new options and conflicting allow/name rules are absent.
13
+
14
+ Export ResponseLimitExceededError with limitBytes from platform/fetch-with-timeout and the platform root. Guarded response-text overflow throws this RangeError subclass; invalid caps keep plain RangeError and existing truncation behavior is preserved.
15
+
16
+ Accept jsdom 26 and 27 as optional CMS peers alongside 25; media uses DOM APIs shared across these versions.
17
+
18
+ - Add `createDeadlineSignal` and `DeadlineSignalOptions` to `@jini-ai/platform/fetch-with-timeout`
19
+ and the root entry. Compose a tool's remaining wait budget with optional caller cancellation,
20
+ subtract elapsed time through an optional start time and clock port, and clamp remaining time to zero.
21
+
22
+ - `createDeadlineSignal` accepts an optional cancellable `wait({ ms, signal })`
23
+ timer port alongside its existing clock port. It now returns `{ signal, dispose }`;
24
+ rc.1/rc.2 consumers must destructure the signal and call `dispose()` in `finally`.
25
+ Disposal cancels the pending wait and detaches parent listeners without aborting the
26
+ returned signal. Deadline expiry and parent cancellation also release resources;
27
+ parent abort reasons and elapsed-budget accounting are preserved.
28
+
3
29
  ## 0.6.13
4
30
 
5
31
  ### Patch Changes
package/README.md CHANGED
@@ -37,7 +37,8 @@ need a `PtySpawn` implementation supplied by the caller — this package defines
37
37
  so profile-only `PATH` entries are visible).
38
38
  - **Filesystem** — `pathContains`, `atomicCopyFile`, `removePathBestEffort`, `readLogTail`.
39
39
  - **HTTP readiness** — `waitForHttpOk`.
40
- - **Deadline signals** — `createDeadlineSignal({ waitMs }, { signal, startedAt, now })` from `@jini-ai/platform/fetch-with-timeout`; subtracts elapsed milliseconds using the optional `now: () => number` clock (default `Date.now`) and combines the remaining budget with caller cancellation.
40
+ - **Response byte limits** — `ResponseLimitExceededError` from `@jini-ai/platform/fetch-with-timeout` carries `limitBytes` and extends `RangeError` for guarded response-text overflow.
41
+ - **Deadline signals** — `createDeadlineSignal({ waitMs }, { signal, startedAt, now, wait })` from `@jini-ai/platform/fetch-with-timeout` (also root) returns `{ signal, dispose }`. It subtracts elapsed milliseconds using the optional `now: () => number` clock (default `Date.now`) and combines the remaining budget with caller cancellation. The optional `wait({ ms, signal }): Promise<void>` timer port resolves after the delay and must release its timer and settle when its signal aborts; real timers are the default. Call idempotent `dispose()` in `finally` to cancel the pending wait and detach parent listeners without aborting the returned signal. Deadline expiry uses a `DOMException` named `TimeoutError`; parent abort preserves its reason.
41
42
  - **Toolchain discovery** — `wellKnownUserToolchainBins` (npm/pnpm/bun/cargo/deno/go/pyenv,
42
43
  asdf/volta/mise/nvm/fnm shims).
43
44
  - **Proxy-aware env** — `resolveSystemProxyEnv`, `mergeProxyAwareEnv` (macOS `scutil` / Windows
@@ -164,7 +165,8 @@ replacement races remain outside this guarantee. Omitting the policy retains exi
164
165
  `readFetchResponseText({ response }, { maxResponseBytes?, strictUtf8? })`, and
165
166
  `fetchTextWithTimeout({ url, timeoutMs, init? }, { fetch?, maxResponseBytes?, strictUtf8? })`.
166
167
  Text defaults to unlimited bytes and replacement UTF-8 decoding. Caps must be nonnegative safe
167
- integers; exactly the cap is accepted, overflow throws `RangeError`, and strict UTF-8 throws on
168
+ integers; exactly the cap is accepted, overflow throws `ResponseLimitExceededError` (a `RangeError`
169
+ with `limitBytes`), invalid caps throw plain `RangeError`, and strict UTF-8 throws on
168
170
  malformed/truncated input. Streamed bytes are counted before decoding, independent of Content-Length;
169
171
  failed guarded consumption cancels and releases the reader without masking the original error.
170
172
  Incoming chunk allocation is controlled by the upstream stream. The text-fetch helper maps timeout
@@ -19,6 +19,7 @@
19
19
  */
20
20
  import path from 'node:path';
21
21
  import { promises as fsp } from 'node:fs';
22
+ import { trimChars, trimCharsEnd, trimCharsStart } from '@jini-ai/core/primitives';
22
23
  import { encodeS3PathSegment, signSigV4 } from './aws-sigv4.js';
23
24
  /** Error thrown by {@link BlobStorage} implementations, distinguishing not-found/traversal/IO failures. */
24
25
  export class StorageError extends Error {
@@ -187,12 +188,13 @@ export class S3BlobStorage {
187
188
  keyFor(namespace, relpath, { allowPrefix = false } = {}) {
188
189
  validateNamespace(namespace);
189
190
  const normalized = validateRelpath(relpath, { allowPrefix });
190
- const segments = [this.options.prefix?.replace(/^\/+|\/+$/g, ''), namespace, normalized].filter((s) => typeof s === 'string' && s.length > 0);
191
+ const prefix = this.options.prefix === undefined ? undefined : trimChars({ text: this.options.prefix, chars: '/' });
192
+ const segments = [prefix, namespace, normalized].filter((s) => typeof s === 'string' && s.length > 0);
191
193
  return segments.join('/');
192
194
  }
193
195
  endpointBase() {
194
196
  if (this.options.endpoint)
195
- return this.options.endpoint.replace(/\/+$/, '');
197
+ return trimCharsEnd({ text: this.options.endpoint, chars: '/' });
196
198
  return `https://${this.options.bucket}.s3.${this.options.region}.amazonaws.com`;
197
199
  }
198
200
  async signedRequest(args) {
@@ -224,7 +226,8 @@ export class S3BlobStorage {
224
226
  credentials: this.options.credentials,
225
227
  now,
226
228
  });
227
- const url = `${base.replace(/\/+$/, '')}${pathSegment}${args.extraQuery ? `?${args.extraQuery}` : ''}`;
229
+ const query = args.extraQuery ? `?${args.extraQuery}` : '';
230
+ const url = `${trimCharsEnd({ text: base, chars: '/' })}${pathSegment}${query}`;
228
231
  const init = {
229
232
  method: args.method,
230
233
  headers,
@@ -320,12 +323,10 @@ function namespaceEntries(entries, namespacePrefix) {
320
323
  for (const e of entries) {
321
324
  // It includes the non-empty, validated namespace and its trailing separator,
322
325
  // so sibling namespaces (ns10 vs ns1) cannot leak into this listing.
323
- if (!e.key.startsWith(namespacePrefix))
324
- continue;
325
- const rel = e.key.slice(namespacePrefix.length).replace(/^\/+/, '');
326
- if (!rel)
327
- continue; // skip the prefix marker itself
328
- out.push({ path: rel, size: e.size, mtimeMs: e.lastModifiedMs });
326
+ const rel = e.key.startsWith(namespacePrefix) ? trimCharsStart({ text: e.key.slice(namespacePrefix.length), chars: '/' }) : '';
327
+ // An empty rel is either a foreign key or the prefix marker itself; both are skipped.
328
+ if (rel)
329
+ out.push({ path: rel, size: e.size, mtimeMs: e.lastModifiedMs });
329
330
  }
330
331
  return out;
331
332
  }
@@ -44,7 +44,7 @@ export function nodeLoginShellPorts() {
44
44
  function parseNullSeparatedEnvironment({ stdout }) {
45
45
  const entries = stdout.split('\0').flatMap(pair => {
46
46
  const at = pair.indexOf('=');
47
- return at > 0 && /^[A-Za-z_][A-Za-z0-9_]*$/.test(pair.slice(0, at)) ? [[pair.slice(0, at), pair.slice(at + 1)]] : [];
47
+ return at > 0 && /^[A-Za-z_]\w*$/.test(pair.slice(0, at)) ? [[pair.slice(0, at), pair.slice(at + 1)]] : [];
48
48
  });
49
49
  return Object.fromEntries(entries);
50
50
  }
package/dist/command.js CHANGED
@@ -11,6 +11,7 @@
11
11
  * itself.
12
12
  */
13
13
  import { extname } from "node:path";
14
+ import { trimCharsEnd } from "@jini-ai/core/primitives";
14
15
  // `cmd.exe /s /c "..."` runs percent-expansion on the inner line *regardless*
15
16
  // of whether the `%name%` pair sits inside a `"..."` quoted segment, so a
16
17
  // `.cmd` / `.bat` shim spawn with an attacker-influenced argv (e.g. an LLM
@@ -31,7 +32,9 @@ function quoteWindowsCommandArg({ value }, _options = {}) {
31
32
  if (value !== "" && !/[\s"&<>|^%=,;()]/.test(value))
32
33
  return value;
33
34
  // Backslashes before the closing quote must survive the child's argv parser.
34
- const escaped = value.replace(/"/g, '""').replace(/%/g, '"^%"').replace(/\\+$/, '$&$&');
35
+ const quoted = value.replace(/"/g, '""').replace(/%/g, '"^%"');
36
+ const trailingBackslashes = quoted.slice(trimCharsEnd({ text: quoted, chars: '\\' }).length);
37
+ const escaped = quoted + trailingBackslashes;
35
38
  return `"${escaped}"`;
36
39
  }
37
40
  // Build the `cmd.exe /d /s /c "<line>"` invocation Node uses internally for
@@ -0,0 +1,6 @@
1
+ /** The port a URL actually connects to: its explicit port, else 443 for `https:` and 80 otherwise.
2
+ * @complexity O(1). */
3
+ export declare function effectivePort({ url }: {
4
+ url: URL;
5
+ }, _optional?: Record<string, never>): number;
6
+ //# sourceMappingURL=effective-port.d.ts.map
@@ -0,0 +1,8 @@
1
+ /** The port a URL actually connects to: its explicit port, else 443 for `https:` and 80 otherwise.
2
+ * @complexity O(1). */
3
+ export function effectivePort({ url }, _optional = {}) {
4
+ if (url.port)
5
+ return Number(url.port);
6
+ return url.protocol === "https:" ? 443 : 80;
7
+ }
8
+ //# sourceMappingURL=effective-port.js.map
package/dist/download.js CHANGED
@@ -75,6 +75,7 @@ function normalizeChecksum(input) {
75
75
  }
76
76
  return { algorithm: input.algorithm, value };
77
77
  }
78
+ const DOT_SEGMENTS = new Set([".", ".."]);
78
79
  function normalizeSegment(value, label) {
79
80
  if (typeof value !== "string" || value.length === 0) {
80
81
  throw new ManagedDownloadError(MANAGED_DOWNLOAD_ERROR_CODES.INVALID_TARGET, `${label} must be a non-empty string`);
@@ -83,7 +84,7 @@ function normalizeSegment(value, label) {
83
84
  // `isAbsolute`/`[\\/]` checks above don't reject it, but `path.win32.resolve`
84
85
  // treats it as a real drive reference, which can escape `basePath` onto a
85
86
  // different drive entirely.
86
- if (value === "." || value === ".." || value.includes("\0") || value.includes(":") || /[\\/]/.test(value) || isAbsolute(value)) {
87
+ if (DOT_SEGMENTS.has(value) || value.includes("\0") || /[:\\/]/.test(value) || isAbsolute(value)) {
87
88
  throw new ManagedDownloadError(MANAGED_DOWNLOAD_ERROR_CODES.INVALID_TARGET, `${label} must be a safe single path segment`);
88
89
  }
89
90
  return value;
@@ -210,15 +211,15 @@ async function ensureManagedBase(basePath) {
210
211
  }
211
212
  const sentinelPath = join(basePath, STORE_SENTINEL);
212
213
  const sentinel = await readJson(sentinelPath);
214
+ if (sentinel != null && !isStoreSentinel(sentinel)) {
215
+ throw new ManagedDownloadError(MANAGED_DOWNLOAD_ERROR_CODES.STORE_NOT_OWNED, `download base has an invalid ownership marker: ${basePath}`);
216
+ }
213
217
  if (sentinel == null) {
214
218
  if (!(await directoryIsEmpty(basePath))) {
215
219
  throw new ManagedDownloadError(MANAGED_DOWNLOAD_ERROR_CODES.STORE_NOT_OWNED, `download base is not empty and has no ownership marker: ${basePath}`);
216
220
  }
217
221
  await writeSentinel(basePath);
218
222
  }
219
- else if (!isStoreSentinel(sentinel)) {
220
- throw new ManagedDownloadError(MANAGED_DOWNLOAD_ERROR_CODES.STORE_NOT_OWNED, `download base has an invalid ownership marker: ${basePath}`);
221
- }
222
223
  await ensureStoreDirs(basePath);
223
224
  }
224
225
  function isChecksum(value) {
@@ -241,21 +242,20 @@ function isManifest(value) {
241
242
  if (!isPlainObject(value))
242
243
  return false;
243
244
  const record = value;
244
- return (record.kind === MANIFEST_KIND &&
245
- record.schemaVersion === MANIFEST_SCHEMA_VERSION &&
245
+ const versionMatches = record.kind === MANIFEST_KIND && record.schemaVersion === MANIFEST_SCHEMA_VERSION;
246
+ const knownState = record.state === "complete" || record.state === "partial";
247
+ return (versionMatches &&
246
248
  MANIFEST_STRING_FIELDS.every((field) => typeof record[field] === "string") &&
247
249
  isChecksum(record.checksum) &&
248
- (record.state === "complete" || record.state === "partial"));
250
+ knownState);
249
251
  }
250
252
  function isDownloadLockFile(value) {
251
253
  if (!isPlainObject(value))
252
254
  return false;
253
255
  const record = value;
254
- return typeof record.createdAt === "string" &&
255
- typeof record.pid === "number" &&
256
- Number.isInteger(record.pid) &&
257
- record.pid > 0 &&
258
- (record.processStartedAt == null || typeof record.processStartedAt === "string");
256
+ const validPid = typeof record.pid === "number" && Number.isInteger(record.pid) && record.pid > 0;
257
+ const validStartedAt = record.processStartedAt == null || typeof record.processStartedAt === "string";
258
+ return typeof record.createdAt === "string" && validPid && validStartedAt;
259
259
  }
260
260
  function parseTimeMs(value) {
261
261
  if (value == null)
@@ -293,14 +293,12 @@ async function readManifest(path) {
293
293
  return "invalid";
294
294
  }
295
295
  }
296
+ const MANIFEST_IDENTITY_FIELDS = ["bucket", "fileName", "targetKey", "identityDigest", "urlDigest"];
296
297
  function manifestMatchesTarget(manifest, target) {
297
- return (manifest.bucket === target.bucket &&
298
- manifest.fileName === target.fileName &&
299
- manifest.targetKey === target.targetKey &&
300
- manifest.identityDigest === target.identityDigest &&
301
- manifest.urlDigest === target.urlDigest &&
302
- manifest.checksum.algorithm === target.checksum.algorithm &&
303
- manifest.checksum.value.toLowerCase() === target.checksum.value);
298
+ const identityMatches = MANIFEST_IDENTITY_FIELDS.every((field) => manifest[field] === target[field]);
299
+ const checksumMatches = manifest.checksum.algorithm === target.checksum.algorithm &&
300
+ manifest.checksum.value.toLowerCase() === target.checksum.value;
301
+ return identityMatches && checksumMatches;
304
302
  }
305
303
  function createManifest(target, state, patch = {}) {
306
304
  const now = new Date().toISOString();
@@ -468,10 +466,12 @@ async function downloadWithRetries(target, manifest, options) {
468
466
  const deps = { emit: options.emit, fetchImpl: options.fetchImpl };
469
467
  for (let attempt = 1; attempt <= options.maxAttempts; attempt += 1) {
470
468
  try {
471
- if (nextManifest?.state === "partial") {
472
- const resume = await tryResumeDownload({ ...deps, manifest: nextManifest, target }, options);
473
- if (resume !== "restart")
474
- return resume;
469
+ const resume = nextManifest?.state === "partial"
470
+ ? await tryResumeDownload({ ...deps, manifest: nextManifest, target }, options)
471
+ : null;
472
+ if (resume !== null && resume !== "restart")
473
+ return resume;
474
+ if (resume === "restart") {
475
475
  await rm(target.partialPath, { force: true }).catch(() => undefined);
476
476
  nextManifest = null;
477
477
  }
@@ -859,9 +859,10 @@ async function removeEntriesOlderThan(root, olderThan) {
859
859
  if (info == null || info.mtimeMs > olderThan)
860
860
  continue;
861
861
  const result = await removePathBestEffort(path);
862
+ // A successful removal never carries an error, so the two checks are exclusive.
862
863
  if (result.removed)
863
864
  removed += 1;
864
- else if (result.error != null)
865
+ if (result.error != null)
865
866
  warnings.push(result.error);
866
867
  }
867
868
  return { removed, warnings };
@@ -59,6 +59,15 @@ export declare class FetchTimeoutError extends Error {
59
59
  readonly timeoutMs: number;
60
60
  constructor(url: string, timeoutMs: number);
61
61
  }
62
+ /** Observed response bytes exceeded the configured cap. Extends RangeError for existing catches;
63
+ * invalid cap configuration still throws a plain RangeError. */
64
+ export declare class ResponseLimitExceededError extends RangeError {
65
+ readonly limitBytes: number;
66
+ /** Records the byte ceiling without retaining response content. O(1) time and space. */
67
+ constructor({ limitBytes }: {
68
+ limitBytes: number;
69
+ }, _options?: Record<string, never>);
70
+ }
62
71
  /**
63
72
  * Shared, documented timeout classes for outbound `fetch()` calls. Every call site names one
64
73
  * explicitly (or its own justified number) rather than relying on an unstated default.
@@ -96,22 +105,32 @@ export interface DeadlineSignalOptions {
96
105
  startedAt?: number;
97
106
  /** Clock port in milliseconds; defaults to Date.now and is read only with startedAt. */
98
107
  now?: () => number;
108
+ /** Resolves after ms; aborting signal must release the wait and settle its promise. */
109
+ wait?: (input: {
110
+ ms: number;
111
+ signal: AbortSignal;
112
+ }) => Promise<void>;
99
113
  }
100
114
  /**
101
115
  * Creates one signal that aborts when the remaining wait budget expires or the caller cancels.
102
116
  * Spent time is subtracted once; future start times cannot increase the original budget.
103
117
  * @param input - Wait budget in milliseconds. Negative remaining budgets become zero.
104
- * @param options - Optional caller signal, start time and clock port.
105
- * @returns A native deadline signal preserving the first abort reason. Zero expires asynchronously,
118
+ * @param options - Optional caller signal, start time, clock and cancellable wait ports.
119
+ * @returns A signal preserving the first abort reason and an idempotent dispose function.
120
+ * Dispose in finally to cancel the wait and detach the parent without aborting the returned signal.
121
+ * Zero expires asynchronously,
106
122
  * following AbortSignal.timeout(0); an already-aborted caller is reflected immediately.
107
- * Creates a native timeout without mutating inputs or the caller's signal.
123
+ * Uses real timers by default without mutating inputs or the caller's signal.
108
124
  * @throws {RangeError} If the remaining delay is invalid for AbortSignal.timeout.
109
125
  * @complexity O(1) time and space.
110
- * @example createDeadlineSignal({ waitMs: 5_000 }, { signal, startedAt, now: Date.now })
126
+ * @example const { signal, dispose } = createDeadlineSignal({ waitMs: 5_000 }, { wait });
111
127
  */
112
128
  export declare function createDeadlineSignal({ waitMs }: {
113
129
  waitMs: number;
114
- }, { signal, startedAt, now }?: DeadlineSignalOptions): AbortSignal;
130
+ }, { signal, startedAt, now, wait }?: DeadlineSignalOptions): {
131
+ signal: AbortSignal;
132
+ dispose: () => void;
133
+ };
115
134
  /** Opt-in byte and decoding guards; defaults remain unlimited and replacement-decoded. */
116
135
  export type TextReadOptions = {
117
136
  /** Nonnegative safe integer. Actual streamed bytes, independent of Content-Length. */
@@ -124,7 +143,8 @@ export type TextReadOptions = {
124
143
  * Details mode reports retained source bytes and truncates only on observed overflow;
125
144
  * reaching the cap still requires EOF to establish completeness. Defaults reject overflow
126
145
  * and replace malformed UTF-8. A supplied signal interrupts even a stalled read.
127
- * @throws {RangeError} An invalid cap or a response exceeding it.
146
+ * @throws {RangeError} An invalid cap.
147
+ * @throws {ResponseLimitExceededError} Observed bytes exceed the cap (also a RangeError).
128
148
  * @throws {TypeError} Invalid UTF-8 when strict decoding is enabled.
129
149
  * Read failures and abort reasons propagate; acquired readers are cancelled on failure
130
150
  * and always released. Details mode observes cancellation failures without awaiting cleanup.
@@ -64,6 +64,17 @@ export class FetchTimeoutError extends Error {
64
64
  this.timeoutMs = timeoutMs;
65
65
  }
66
66
  }
67
+ /** Observed response bytes exceeded the configured cap. Extends RangeError for existing catches;
68
+ * invalid cap configuration still throws a plain RangeError. */
69
+ export class ResponseLimitExceededError extends RangeError {
70
+ limitBytes;
71
+ /** Records the byte ceiling without retaining response content. O(1) time and space. */
72
+ constructor({ limitBytes }, _options = {}) {
73
+ super(`response exceeds the ${limitBytes}-byte limit`);
74
+ this.name = "ResponseLimitExceededError";
75
+ this.limitBytes = limitBytes;
76
+ }
77
+ }
67
78
  /**
68
79
  * Shared, documented timeout classes for outbound `fetch()` calls. Every call site names one
69
80
  * explicitly (or its own justified number) rather than relying on an unstated default.
@@ -87,24 +98,53 @@ export const FETCH_TIMEOUT_MS = {
87
98
  * legitimately still-generating request early. */
88
99
  GENERATE: 10 * 60_000,
89
100
  };
101
+ /** Browser-safe timer adapter; completion and cancellation both release its listener. */
102
+ function waitForDeadline({ ms, signal }, _options = {}) {
103
+ return new Promise((resolve, reject) => {
104
+ signal.throwIfAborted();
105
+ const abort = () => { clearTimeout(timer); reject(signal.reason); };
106
+ const timer = setTimeout(() => { signal.removeEventListener("abort", abort); resolve(); }, ms);
107
+ // Like native timeout signals, pending deadlines must not keep a Node process alive.
108
+ if (typeof timer === "object")
109
+ timer.unref();
110
+ signal.addEventListener("abort", abort, { once: true });
111
+ });
112
+ }
90
113
  /**
91
114
  * Creates one signal that aborts when the remaining wait budget expires or the caller cancels.
92
115
  * Spent time is subtracted once; future start times cannot increase the original budget.
93
116
  * @param input - Wait budget in milliseconds. Negative remaining budgets become zero.
94
- * @param options - Optional caller signal, start time and clock port.
95
- * @returns A native deadline signal preserving the first abort reason. Zero expires asynchronously,
117
+ * @param options - Optional caller signal, start time, clock and cancellable wait ports.
118
+ * @returns A signal preserving the first abort reason and an idempotent dispose function.
119
+ * Dispose in finally to cancel the wait and detach the parent without aborting the returned signal.
120
+ * Zero expires asynchronously,
96
121
  * following AbortSignal.timeout(0); an already-aborted caller is reflected immediately.
97
- * Creates a native timeout without mutating inputs or the caller's signal.
122
+ * Uses real timers by default without mutating inputs or the caller's signal.
98
123
  * @throws {RangeError} If the remaining delay is invalid for AbortSignal.timeout.
99
124
  * @complexity O(1) time and space.
100
- * @example createDeadlineSignal({ waitMs: 5_000 }, { signal, startedAt, now: Date.now })
125
+ * @example const { signal, dispose } = createDeadlineSignal({ waitMs: 5_000 }, { wait });
101
126
  */
102
- export function createDeadlineSignal({ waitMs }, { signal, startedAt, now = Date.now } = {}) {
127
+ export function createDeadlineSignal({ waitMs }, { signal, startedAt, now = Date.now, wait = waitForDeadline } = {}) {
103
128
  const elapsedMs = startedAt === undefined ? 0 : Math.max(0, now() - startedAt);
104
- const deadline = AbortSignal.timeout(Math.max(0, waitMs - elapsedMs));
105
- if (signal === undefined)
106
- return deadline;
107
- return AbortSignal.any([deadline, signal]);
129
+ const ms = Math.max(0, waitMs - elapsedMs);
130
+ if (!Number.isSafeInteger(ms))
131
+ throw new RangeError("remaining deadline must be a nonnegative safe integer");
132
+ const deadline = new AbortController();
133
+ const timer = new AbortController();
134
+ const dispose = () => { timer.abort(); signal?.removeEventListener("abort", onAbort); };
135
+ const finish = (reason) => {
136
+ if (!timer.signal.aborted)
137
+ deadline.abort(reason);
138
+ dispose();
139
+ };
140
+ const onAbort = () => finish(signal.reason);
141
+ if (signal?.aborted)
142
+ onAbort();
143
+ else {
144
+ signal?.addEventListener("abort", onAbort, { once: true });
145
+ void wait({ ms, signal: timer.signal }).then(() => finish(new DOMException("The operation was aborted due to timeout", "TimeoutError")), finish);
146
+ }
147
+ return { signal: deadline.signal, dispose };
108
148
  }
109
149
  export async function readFetchResponseText(input, { maxResponseBytes, strictUtf8 = false } = {}) {
110
150
  const details = "result" in input && input.result === "details";
@@ -176,7 +216,7 @@ async function decodeResponseStream({ reader }, { maxResponseBytes = Infinity, s
176
216
  const remaining = maxResponseBytes - total;
177
217
  if (value.byteLength > remaining) {
178
218
  if (onLimit === "reject")
179
- throw new RangeError(`response exceeds the ${maxResponseBytes}-byte limit`);
219
+ throw new ResponseLimitExceededError({ limitBytes: maxResponseBytes });
180
220
  text += decoder.decode(value.subarray(0, remaining), { stream: true });
181
221
  return { text: text + decoder.decode(), byteLength: maxResponseBytes, truncated: true };
182
222
  }
@@ -3,6 +3,7 @@ import * as fsPromises from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { defaultPlatformMessages } from "../messages.js";
6
+ import { errorCodeOf } from "./errno.js";
6
7
  /** Optional adapter wiring: no layout, env key, or application name is selected here. */
7
8
  export function createNodeAtomicFilesystem(_required, { filesystem = fs } = {}) {
8
9
  return {
@@ -19,7 +20,8 @@ export function createNodeAtomicFilesystem(_required, { filesystem = fs } = {})
19
20
  }
20
21
  const DIRECTORY_SYNC_UNSUPPORTED = ["EPERM", "EISDIR", "EINVAL", "EACCES", "ENOTSUP"];
21
22
  function hasErrorCode(error, codes) {
22
- return typeof error === "object" && error !== null && "code" in error && typeof error.code === "string" && codes.includes(error.code);
23
+ const code = errorCodeOf(error);
24
+ return code !== undefined && codes.includes(code);
23
25
  }
24
26
  function isMissing(error) {
25
27
  return hasErrorCode(error, ["ENOENT"]);
@@ -78,8 +80,9 @@ function checkedTempPath({ filePath, temporary }, options) {
78
80
  throw new RangeError(messagesFor(options).atomicTempPathInvalid());
79
81
  return temporary;
80
82
  }
83
+ const DOT_SEGMENTS = new Set([".", ".."]);
81
84
  function tokenTempPath({ filePath, token }, options) {
82
- if (!token || token === "." || token === ".." || /[/\\\x00]/.test(token))
85
+ if (!token || DOT_SEGMENTS.has(token) || token.includes("\0") || /[/\\]/.test(token))
83
86
  throw new RangeError(messagesFor(options).atomicTempNameInvalid());
84
87
  return path.join(path.dirname(filePath), `.${path.basename(filePath)}.${token}.tmp`);
85
88
  }
@@ -14,10 +14,6 @@ export declare function readBootIdentity(_required: Record<string, never>, _opti
14
14
  * beyond this tolerance proves an earlier boot; shorter gaps fall back to the PID probe.
15
15
  */
16
16
  export declare const BOOT_CLOCK_TOLERANCE_MS: number;
17
- /** True only when a recorded holder provably predates this boot. Linux boot_id is a random UUID and is
18
- * compared exactly; numeric (time-derived) ids and legacy timestamps get the tolerance above. A record
19
- * from a "later" boot is clock skew, never proof of death. @complexity O(1).
20
- */
21
17
  export declare function fromEarlierBoot({ boot, bootId, atMs }: {
22
18
  boot: BootIdentity | undefined;
23
19
  bootId: string | undefined;
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import { execFileSync } from "node:child_process";
3
+ import { windowsPowerShell } from "../windows-system-tools.js";
3
4
  let cached;
4
5
  const NATIVE_TIMEOUT_MS = 3000;
5
6
  function linuxBootIdentity() {
@@ -16,8 +17,7 @@ function darwinBootIdentity() {
16
17
  return timestampIdentity(Number(/sec\s*=\s*(\d+)/.exec(text)?.[1]) * 1000);
17
18
  }
18
19
  function windowsBootIdentity() {
19
- // eslint-disable-next-line sonarjs/no-os-command-from-path -- Fixed OS utility with fixed argv, resolved through the desktop operator's trusted PATH; no request controls the executable.
20
- const text = execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", "(Get-CimInstance Win32_OperatingSystem).LastBootUpTime.ToUniversalTime().ToString('o')"], { encoding: "utf8", timeout: NATIVE_TIMEOUT_MS, windowsHide: true });
20
+ const text = execFileSync(windowsPowerShell(), ["-NoProfile", "-NonInteractive", "-Command", "(Get-CimInstance Win32_OperatingSystem).LastBootUpTime.ToUniversalTime().ToString('o')"], { encoding: "utf8", timeout: NATIVE_TIMEOUT_MS, windowsHide: true });
21
21
  return timestampIdentity(Date.parse(text.trim()));
22
22
  }
23
23
  const PLATFORM_READERS = { linux: linuxBootIdentity, darwin: darwinBootIdentity, win32: windowsBootIdentity };
@@ -46,10 +46,16 @@ export const BOOT_CLOCK_TOLERANCE_MS = 10 * 60_000;
46
46
  * compared exactly; numeric (time-derived) ids and legacy timestamps get the tolerance above. A record
47
47
  * from a "later" boot is clock skew, never proof of death. @complexity O(1).
48
48
  */
49
+ /** Legacy records carry only a timestamp; time-derived boot ids are numeric; a UUID id has no time. */
50
+ function recordedBootTimeMs({ bootId, atMs }) {
51
+ if (bootId === undefined)
52
+ return atMs;
53
+ return /^\d+$/.test(bootId) ? Number(bootId) : undefined;
54
+ }
49
55
  export function fromEarlierBoot({ boot, bootId, atMs }, _options = {}) {
50
56
  if (!boot)
51
57
  return false;
52
- const recordedAtMs = bootId === undefined ? atMs : /^\d+$/.test(bootId) ? Number(bootId) : undefined;
58
+ const recordedAtMs = recordedBootTimeMs({ bootId, atMs });
53
59
  return recordedAtMs === undefined ? bootId !== boot.id : recordedAtMs < boot.startedAtMs - BOOT_CLOCK_TOLERANCE_MS;
54
60
  }
55
61
  //# sourceMappingURL=boot-identity.js.map
@@ -14,8 +14,8 @@ export function createDirectoryRegistry(
14
14
  if (result.state !== "ok")
15
15
  throw new Error("The shared directory registry is unreadable; nothing was overwritten.");
16
16
  const state = result.value;
17
- if (state?.version !== 1 || !Array.isArray(state.entries) || !Array.isArray(state.migrations) ||
18
- !state.migrations.every((item) => typeof item === "string") || !state.entries.every(validRecord)) {
17
+ const shapeOk = state?.version === 1 && Array.isArray(state.entries) && Array.isArray(state.migrations);
18
+ if (!shapeOk || !state.migrations.every((item) => typeof item === "string") || !state.entries.every(validRecord)) {
19
19
  throw new Error("The shared directory registry has an invalid format; nothing was overwritten.");
20
20
  }
21
21
  return state;
@@ -67,11 +67,16 @@ function validRunningRecord(value) {
67
67
  if (!value || typeof value !== "object")
68
68
  return false;
69
69
  const record = value;
70
- return Number.isSafeInteger(record.pid) && record.pid > 0 && Number.isInteger(record.port) && record.port >= 0 && record.port <= 65535 &&
71
- validRunningIdentity({ record }) && ["http", "https"].includes(record.scheme);
70
+ const validPid = Number.isSafeInteger(record.pid) && record.pid > 0;
71
+ const validPort = Number.isInteger(record.port) && record.port >= 0 && record.port <= 65535;
72
+ return validPid && validPort && validRunningIdentity({ record }) && ["http", "https"].includes(record.scheme);
72
73
  }
73
74
  function validRunningIdentity({ record }, _options = {}) {
74
- return typeof record.app === "string" && record.app.length > 0 && typeof record.token === "string" && record.token.length > 0 &&
75
- (record.bootId === undefined || typeof record.bootId === "string") && typeof record.started === "string" && Number.isFinite(Date.parse(record.started));
75
+ const validBootId = record.bootId === undefined || typeof record.bootId === "string";
76
+ const validStarted = typeof record.started === "string" && Number.isFinite(Date.parse(record.started));
77
+ return isNonEmptyString(record.app) && isNonEmptyString(record.token) && validBootId && validStarted;
78
+ }
79
+ function isNonEmptyString(value) {
80
+ return typeof value === "string" && value.length > 0;
76
81
  }
77
82
  //# sourceMappingURL=directory-running-lock.js.map
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { readOptionalText, readWindowsOneDriveRoots } from "./user-data-directories.js";
4
+ import { fallbackIfMissing } from "./errno.js";
4
5
  const MOUNT_PROVIDERS = [["OneDrive-", "OneDrive"], ["GoogleDrive-", "Google Drive"], ["Dropbox", "Dropbox"]];
5
6
  function canonicalOrResolved({ path: value }) { try {
6
7
  return fs.realpathSync(value);
@@ -12,9 +13,7 @@ function listDirectoriesOrEmpty(root) { try {
12
13
  return fs.readdirSync(root);
13
14
  }
14
15
  catch (error) {
15
- if (error.code === "ENOENT")
16
- return [];
17
- throw error;
16
+ return fallbackIfMissing(error, []);
18
17
  } }
19
18
  function macRoots({ homeDir, documents }, { paths, exists, listDirectories }) {
20
19
  const cloud = paths.join(homeDir, "Library", "Mobile Documents", "com~apple~CloudDocs");
@@ -7,6 +7,9 @@ import { createDirectoryUserConfig, validateDirectoryUserConfig } from "./direct
7
7
  import { resolveDirectoryHome } from "./directory-home.js";
8
8
  import { createDirectoryRegistry } from "./directory-registry.js";
9
9
  import { createDirectoryRunningLock } from "./directory-running-lock.js";
10
+ import { fallbackIfMissing } from "./errno.js";
11
+ /** Migration id recorded once the old per-app catalogs are merged into the shared registry. */
12
+ const APP_DATA_CATALOG_MIGRATION = "app-data-catalog-v1";
10
13
  /** Node assembly only. Policy modules receive explicit filesystem, clock and process ports.
11
14
  * Settings live outside the selected home so changing the setting never loses the pointer.
12
15
  * Reading a missing home/catalog does not create it. Opt-in registry recovery may prune known
@@ -23,9 +26,7 @@ export function createNodeDirectoryWorkspace({ homeDir, defaultFolder, envName,
23
26
  return true;
24
27
  }
25
28
  catch (error) {
26
- if (error.code === "ENOENT")
27
- return false;
28
- throw error;
29
+ return fallbackIfMissing(error, false);
29
30
  } },
30
31
  }, { env, cwd, report: (message) => ports.report.error({ message }), ...directoryOptions }) : undefined;
31
32
  const settings = fileAt(config?.filePath ?? settingsFile);
@@ -63,9 +64,7 @@ export function createNodeDirectoryWorkspace({ homeDir, defaultFolder, envName,
63
64
  return false;
64
65
  }
65
66
  catch (error) {
66
- if (error.code === "ENOENT")
67
- return true;
68
- throw error;
67
+ return fallbackIfMissing(error, true);
69
68
  }
70
69
  },
71
70
  reportDiscarded: ({ entries }) => ports.report.error({ message: `Directory workspace: removed ${entries.length} test/temporary catalog entries: ${entries.map((entry) => entry.id).join(", ")}` }),
@@ -91,10 +90,10 @@ export function createNodeDirectoryWorkspace({ homeDir, defaultFolder, envName,
91
90
  };
92
91
  const importOldCatalog = () => {
93
92
  const current = atHome();
94
- if (!config || current.hasMigration({ migration: "app-data-catalog-v1" }))
93
+ if (!config || current.hasMigration({ migration: APP_DATA_CATALOG_MIGRATION }))
95
94
  return;
96
95
  const previous = oldCatalogs();
97
- current.merge({ entries: previous.flatMap((catalog) => catalog.list({})), migrations: previous.flatMap((catalog) => catalog.migrations({})), migration: "app-data-catalog-v1" });
96
+ current.merge({ entries: previous.flatMap((catalog) => catalog.list({})), migrations: previous.flatMap((catalog) => catalog.migrations({})), migration: APP_DATA_CATALOG_MIGRATION });
98
97
  };
99
98
  // One lease adapter for both the app-data and the site-local (legacy) lease locations.
100
99
  const runningLockAt = (filePath) => createDirectoryRunningLock({ file: fileAt(filePath), isAlive: ports.process.isAlive,
@@ -105,7 +104,7 @@ export function createNodeDirectoryWorkspace({ homeDir, defaultFolder, envName,
105
104
  const registry = {
106
105
  list: (required, options = {}) => {
107
106
  const current = atHome();
108
- if (!config || current.hasMigration({ migration: "app-data-catalog-v1" }))
107
+ if (!config || current.hasMigration({ migration: APP_DATA_CATALOG_MIGRATION }))
109
108
  return current.list(required, options);
110
109
  const rows = [...oldCatalogs().flatMap((catalog) => catalog.list(required, options)), ...current.list(required, options)];
111
110
  return [...new Map(rows.map((row) => [canonicalPath({ path: row.path }), row])).values()];
@@ -148,9 +147,7 @@ export function createNodeDirectoryWorkspace({ homeDir, defaultFolder, envName,
148
147
  return ports.filesystem.statSync(parent).isDirectory();
149
148
  }
150
149
  catch (error) {
151
- if (error.code === "ENOENT")
152
- return false;
153
- throw error;
150
+ return fallbackIfMissing(error, false);
154
151
  }
155
152
  },
156
153
  legacyHomes: (_required, _options = {}) => config?.legacyHomes({}) ?? [],
@@ -187,9 +184,9 @@ export function createNodeDirectoryWorkspace({ homeDir, defaultFolder, envName,
187
184
  read: () => state === undefined ? { state: "missing" } : { state: "ok", value: state },
188
185
  write: ({ value: next }) => { state = next; }, withLock: ({ critical }) => critical(),
189
186
  } });
190
- if (!staged.hasMigration({ migration: "app-data-catalog-v1" })) {
187
+ if (!staged.hasMigration({ migration: APP_DATA_CATALOG_MIGRATION })) {
191
188
  const previous = oldCatalogs();
192
- staged.merge({ entries: previous.flatMap((catalog) => catalog.list({})), migrations: previous.flatMap((catalog) => catalog.migrations({})), migration: "app-data-catalog-v1" });
189
+ staged.merge({ entries: previous.flatMap((catalog) => catalog.list({})), migrations: previous.flatMap((catalog) => catalog.migrations({})), migration: APP_DATA_CATALOG_MIGRATION });
193
190
  }
194
191
  const existing = staged.list({}).find((row) => row.path === from || row.id === entry.id);
195
192
  if (existing && existing.path !== from && existing.path !== to)