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

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 (77) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +7 -8
  3. package/dist/asset-cache.adapter.d.ts +2 -2
  4. package/dist/asset-cache.adapter.js +2 -2
  5. package/dist/asset-cache.d.ts +1 -3
  6. package/dist/asset-cache.js +2 -14
  7. package/dist/blob-storage.js +10 -9
  8. package/dist/child-environment.js +1 -1
  9. package/dist/command.js +4 -1
  10. package/dist/core/effective-port.d.ts +6 -0
  11. package/dist/core/effective-port.js +8 -0
  12. package/dist/download.js +25 -24
  13. package/dist/fetch-with-timeout.d.ts +27 -7
  14. package/dist/fetch-with-timeout.js +51 -11
  15. package/dist/fs/atomic-write.js +5 -2
  16. package/dist/fs/boot-identity.d.ts +0 -4
  17. package/dist/fs/boot-identity.js +9 -3
  18. package/dist/fs/directory-registry.js +2 -2
  19. package/dist/fs/directory-running-lock.js +9 -4
  20. package/dist/fs/directory-sync.js +2 -3
  21. package/dist/fs/directory-user-config.d.ts +9 -0
  22. package/dist/fs/directory-user-config.js +13 -6
  23. package/dist/fs/directory-workspace.js +11 -14
  24. package/dist/fs/durable-json.js +22 -16
  25. package/dist/fs/errno.d.ts +9 -0
  26. package/dist/fs/errno.js +21 -0
  27. package/dist/fs/file-lock.d.ts +1 -0
  28. package/dist/fs/file-lock.js +17 -19
  29. package/dist/fs/guarded-reader.d.ts +22 -1
  30. package/dist/fs/guarded-reader.js +85 -15
  31. package/dist/fs/index.d.ts +1 -0
  32. package/dist/fs/index.js +1 -0
  33. package/dist/fs/user-data-directories.js +5 -7
  34. package/dist/fs/workspace-directory.d.ts +7 -1
  35. package/dist/fs/workspace-directory.js +9 -2
  36. package/dist/fs.js +2 -1
  37. package/dist/http/guarded/client.js +2 -1
  38. package/dist/http.js +2 -1
  39. package/dist/index.d.ts +1 -1
  40. package/dist/index.js +1 -1
  41. package/dist/mail/builtin-providers.js +4 -2
  42. package/dist/mail/feedback.js +1 -1
  43. package/dist/mail/prepare-sender-email.js +7 -1
  44. package/dist/mail/provider.js +14 -8
  45. package/dist/mail/rate-limit.js +3 -1
  46. package/dist/messages.js +1 -1
  47. package/dist/net/endpoint-policy.js +15 -7
  48. package/dist/net/index.d.ts +1 -0
  49. package/dist/net/index.js +1 -0
  50. package/dist/net/local-http-probe.d.ts +16 -0
  51. package/dist/net/local-http-probe.js +36 -0
  52. package/dist/object-store/local-fs.js +3 -1
  53. package/dist/process.js +10 -9
  54. package/dist/proxy-env.js +22 -14
  55. package/dist/secrets/credential-sets/index.d.ts +1 -0
  56. package/dist/secrets/credential-sets/index.js +1 -0
  57. package/dist/secrets/credential-sets/provider-matches.js +14 -3
  58. package/dist/secrets/credential-sets/vault-archive.d.ts +41 -0
  59. package/dist/secrets/credential-sets/vault-archive.js +64 -0
  60. package/dist/secrets/credential-sets/vault.meta.js +6 -2
  61. package/dist/secrets/credential-sets/vault.repo.memory.js +3 -5
  62. package/dist/secrets/credential-sets/vault.service.js +22 -17
  63. package/dist/secrets/credential-sets/vault.types.d.ts +6 -0
  64. package/dist/secrets/credential-sets/vault.types.js +7 -0
  65. package/dist/secrets/credential-token.js +9 -2
  66. package/dist/secrets/keyring.env.js +2 -4
  67. package/dist/shell.js +2 -1
  68. package/dist/terminal.js +4 -7
  69. package/dist/toolchain.js +4 -9
  70. package/dist/web/first-distinct.d.ts +11 -0
  71. package/dist/web/first-distinct.js +16 -0
  72. package/dist/web/html-document.js +35 -39
  73. package/dist/web/html-to-markdown.js +20 -9
  74. package/dist/web/web-page.js +28 -25
  75. package/dist/windows-system-tools.d.ts +15 -0
  76. package/dist/windows-system-tools.js +16 -0
  77. package/package.json +10 -6
@@ -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");
@@ -1,5 +1,14 @@
1
1
  import type { DurableJsonFile } from "./durable-json.js";
2
2
  import { type UserDirectoryOptions } from "./user-data-directories.js";
3
+ /** An expected settings refusal whose message is safe to show to the caller.
4
+ * Filesystem and persistence failures retain their original errors for private diagnostics.
5
+ */
6
+ export declare class DirectoryUserConfigRefusal extends Error {
7
+ readonly name = "DirectoryUserConfigRefusal";
8
+ constructor({ message }: {
9
+ message: string;
10
+ }, _options?: {});
11
+ }
3
12
  export interface DirectoryUserConfig {
4
13
  sitesHome: string;
5
14
  siteRoots: string[];
@@ -3,6 +3,13 @@ import { createHash } from "node:crypto";
3
3
  import { resolveDirectoryHome } from "./directory-home.js";
4
4
  import { resolveUserDataDirectories } from "./user-data-directories.js";
5
5
  import { detectDirectorySync } from "./directory-sync.js";
6
+ /** An expected settings refusal whose message is safe to show to the caller.
7
+ * Filesystem and persistence failures retain their original errors for private diagnostics.
8
+ */
9
+ export class DirectoryUserConfigRefusal extends Error {
10
+ name = "DirectoryUserConfigRefusal";
11
+ constructor({ message }, _options = {}) { super(message); }
12
+ }
6
13
  const SAFE_SITE_ID = /^[a-zA-Z0-9_-]+$/;
7
14
  const invalidMarker = (markerPath) => new Error(`Interrupted database move has an invalid recovery marker: ${markerPath}. Restore the pre-move backup before starting.`);
8
15
  function assertDatabaseFilename(filename) {
@@ -78,14 +85,14 @@ export function createDirectoryUserConfig({ homeDir, defaultFolder, envName, pol
78
85
  continue;
79
86
  const expanded = expand(directory);
80
87
  if (exists(expanded) && !isDirectory(expanded))
81
- throw new Error(`Sites folder is not a directory: ${directory}`);
88
+ throw new DirectoryUserConfigRefusal({ message: `Sites folder is not a directory: ${directory}` });
82
89
  }
83
90
  }
84
91
  function assertNotOverridden(checked) {
85
92
  if (checked.sitesHome !== undefined && env[envName] !== undefined)
86
- throw new Error(`${envName} overrides this setting. Unset it before changing the directory home.`);
93
+ throw new DirectoryUserConfigRefusal({ message: `${envName} overrides this setting. Unset it before changing the directory home.` });
87
94
  if (checked.siteRoots !== undefined && env[policy.rootsEnvName] !== undefined)
88
- throw new Error(`${policy.rootsEnvName} overrides this setting. Unset it before changing site roots.`);
95
+ throw new DirectoryUserConfigRefusal({ message: `${policy.rootsEnvName} overrides this setting. Unset it before changing site roots.` });
89
96
  }
90
97
  function merged(previous, checked, incremental) {
91
98
  const next = { ...previous, ...checked,
@@ -213,13 +220,13 @@ const SETTING_RULES = [
213
220
  */
214
221
  export function validateDirectoryUserConfig({ value }, _options = {}) {
215
222
  if (!value || typeof value !== "object" || Array.isArray(value))
216
- throw new Error("User config must be an object.");
223
+ throw new DirectoryUserConfigRefusal({ message: "User config must be an object." });
217
224
  const row = value;
218
225
  if (Object.keys(row).some((key) => !SETTING_RULES.some(([known]) => known === key)))
219
- throw new Error("Unknown user config setting.");
226
+ throw new DirectoryUserConfigRefusal({ message: "Unknown user config setting." });
220
227
  for (const [key, valid, message] of SETTING_RULES)
221
228
  if (row[key] !== undefined && !valid(row[key]))
222
- throw new Error(message);
229
+ throw new DirectoryUserConfigRefusal({ message });
223
230
  return row;
224
231
  }
225
232
  //# sourceMappingURL=directory-user-config.js.map
@@ -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)
@@ -43,8 +43,9 @@ export function createNodeDurableJsonPorts(_required) {
43
43
  export function createDurableJsonFile({ filePath, filesystem: fs, clock, sleep, process, random, report, notices }, { pollMs = 10, staleMs = 30000 } = {}) {
44
44
  if (!filePath)
45
45
  throw new TypeError("filePath is required");
46
- if (!Number.isSafeInteger(pollMs) || pollMs <= 0 ||
47
- ((!Number.isSafeInteger(staleMs) && staleMs !== Infinity) || staleMs <= 0))
46
+ const validPoll = Number.isSafeInteger(pollMs) && pollMs > 0;
47
+ const validStale = (Number.isSafeInteger(staleMs) || staleMs === Infinity) && staleMs > 0;
48
+ if (!validPoll || !validStale)
48
49
  throw new TypeError("lock intervals must be positive safe integers");
49
50
  // A synchronous read-modify-write normally takes milliseconds; five seconds allows contention
50
51
  // while refusing a wedged change. Short ten-millisecond polling fits those short critical sections.
@@ -139,31 +140,36 @@ export function salvageJsonPrefix({ text }, { maxAttempts = 1000 } = {}) {
139
140
  }
140
141
  // Cut only at completed strings/objects/arrays, sufficient for string-valued lists. Damage inside a
141
142
  // bare number/boolean falls back to the previous cut and loses that element rather than guessing it.
143
+ const CLOSER_FOR = new Map([["{", "}"], ["[", "]"]]);
144
+ const CLOSERS = new Set(["}", "]"]);
142
145
  function elementEnds(text) {
143
146
  const open = [];
144
147
  const cuts = [];
145
- for (let index = 0; index < text.length; index += 1) {
148
+ let index = 0;
149
+ while (index < text.length) {
146
150
  const char = text[index];
151
+ const closer = CLOSER_FOR.get(char);
152
+ // An opener only records its closer; a completed string or container is a cut point.
153
+ if (closer !== undefined)
154
+ open.push(closer);
155
+ if (CLOSERS.has(char))
156
+ open.pop();
147
157
  if (char === '"')
148
158
  index = endOfString(text, index);
149
- else if (char === "{" || char === "[") {
150
- open.push(char === "{" ? "}" : "]");
151
- continue;
152
- }
153
- else if (char === "}" || char === "]")
154
- open.pop();
155
- else
156
- continue;
157
- cuts.push({ end: index + 1, closers: [...open].reverse().join("") });
159
+ if (char === '"' || CLOSERS.has(char))
160
+ cuts.push({ end: index + 1, closers: [...open].reverse().join("") });
161
+ index += 1;
158
162
  }
159
163
  return cuts;
160
164
  }
161
165
  function endOfString(text, start) {
162
- for (let index = start + 1; index < text.length; index += 1) {
163
- if (text[index] === "\\")
164
- index += 1;
165
- else if (text[index] === '"')
166
+ let index = start + 1;
167
+ while (index < text.length) {
168
+ const char = text[index];
169
+ if (char === '"')
166
170
  return index;
171
+ // A backslash escapes the next character, whatever it is.
172
+ index += char === "\\" ? 2 : 1;
167
173
  }
168
174
  return text.length;
169
175
  }
@@ -0,0 +1,9 @@
1
+ /** The string `code` of a Node system error (`ENOENT`, `EEXIST`, ...), or undefined for anything else.
2
+ * @complexity O(1). */
3
+ export declare function errorCodeOf(error: unknown): string | undefined;
4
+ /** Returns `fallback` when `matches(error)` and rethrows anything else, so an expected failure reads
5
+ * `catch (error) { return fallbackIf(error, isExpected, undefined); }`. @complexity O(matches). */
6
+ export declare function fallbackIf<T>(error: unknown, matches: (error: unknown) => boolean, fallback: T): T;
7
+ /** {@link fallbackIf} for a missing entry (ENOENT). @complexity O(1). */
8
+ export declare function fallbackIfMissing<T>(error: unknown, fallback: T): T;
9
+ //# sourceMappingURL=errno.d.ts.map
@@ -0,0 +1,21 @@
1
+ /** The string `code` of a Node system error (`ENOENT`, `EEXIST`, ...), or undefined for anything else.
2
+ * @complexity O(1). */
3
+ export function errorCodeOf(error) {
4
+ if (typeof error !== "object" || error === null || !("code" in error))
5
+ return undefined;
6
+ return typeof error.code === "string" ? error.code : undefined;
7
+ }
8
+ /** Returns `fallback` when `matches(error)` and rethrows anything else, so an expected failure reads
9
+ * `catch (error) { return fallbackIf(error, isExpected, undefined); }`. @complexity O(matches). */
10
+ export function fallbackIf(error, matches, fallback) {
11
+ if (matches(error))
12
+ return fallback;
13
+ throw error;
14
+ }
15
+ /** {@link fallbackIf} for a missing entry (ENOENT). @complexity O(1). */
16
+ export function fallbackIfMissing(error, fallback) {
17
+ if (error.code === "ENOENT")
18
+ return fallback;
19
+ throw error;
20
+ }
21
+ //# sourceMappingURL=errno.js.map
@@ -35,6 +35,7 @@ export interface HeldFileLockSync {
35
35
  export type FileLockSyncFilesystem = Pick<typeof fs, "mkdirSync" | "openSync" | "writeSync" | "fstatSync" | "readFileSync" | "statSync" | "closeSync" | "rmSync">;
36
36
  export type FileLockAsyncFilesystem = Pick<typeof asyncFs, "mkdir" | "open" | "stat" | "rm">;
37
37
  export interface FileLockOptions {
38
+ /** Infinity follows an operation with its own deadline; dead local owners remain reclaimable. */
38
39
  timeoutMs?: number;
39
40
  /** Infinity disables age stealing; dead local owners remain reclaimable. */
40
41
  staleMs?: number;
@@ -18,6 +18,7 @@ import { hostname as nodeHostname } from "node:os";
18
18
  import path from "node:path";
19
19
  import { createSystemClock } from "@jini-ai/core/primitives";
20
20
  import { defaultPlatformMessages } from "../messages.js";
21
+ import { errorCodeOf, fallbackIf } from "./errno.js";
21
22
  export const DEFAULT_LOCK_TIMEOUT_MS = 15_000;
22
23
  export const DEFAULT_LOCK_STALE_AFTER_MS = 10_000;
23
24
  export class FileLockTimeoutError extends Error {
@@ -44,14 +45,14 @@ export class FileLockLostError extends Error {
44
45
  export function isContendedLockError({ code, platform }) {
45
46
  return code === "EEXIST" || (code === "EPERM" && platform === "win32");
46
47
  }
47
- function errorCode(error) {
48
- return typeof error === "object" && error !== null && "code" in error && typeof error.code === "string" ? error.code : undefined;
48
+ function isContended(error) {
49
+ return isContendedLockError({ code: errorCodeOf(error), platform: process.platform });
49
50
  }
50
51
  function isAbsentOrContended(error) {
51
- const code = errorCode(error);
52
+ const code = errorCodeOf(error);
52
53
  return code === "ENOENT" || isContendedLockError({ code, platform: process.platform });
53
54
  }
54
- function rethrowUnlessMissing(error) { if (errorCode(error) !== "ENOENT")
55
+ function rethrowUnlessMissing(error) { if (errorCodeOf(error) !== "ENOENT")
55
56
  throw error; }
56
57
  function validPid(pid) { return typeof pid === "number" && Number.isSafeInteger(pid) && pid > 0; }
57
58
  function isHolderRecord(value) {
@@ -77,7 +78,10 @@ export function isLockStale(required) {
77
78
  return (required.ownerAlive === false || required.nowMs - required.mtimeMs > required.staleMs) && required.observedInode === required.currentInode && required.observedDevice === required.currentDevice;
78
79
  }
79
80
  function intervalsValid({ timeoutMs, staleMs, pollMs }) {
80
- return Number.isSafeInteger(timeoutMs) && timeoutMs >= 0 && (Number.isSafeInteger(staleMs) || staleMs === Infinity) && staleMs > 0 && Number.isSafeInteger(pollMs) && pollMs > 0;
81
+ const validTimeout = (Number.isSafeInteger(timeoutMs) && timeoutMs >= 0) || timeoutMs === Infinity;
82
+ const validStale = (Number.isSafeInteger(staleMs) || staleMs === Infinity) && staleMs > 0;
83
+ const validPoll = Number.isSafeInteger(pollMs) && pollMs > 0;
84
+ return validTimeout && validStale && validPoll;
81
85
  }
82
86
  function intervals(options) {
83
87
  const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS, staleMs = options.staleMs ?? DEFAULT_LOCK_STALE_AFTER_MS, pollMs = options.pollMs ?? 10;
@@ -91,7 +95,7 @@ function defaultProcessPort() {
91
95
  return true;
92
96
  }
93
97
  catch (error) {
94
- return errorCode(error) !== "ESRCH";
98
+ return errorCodeOf(error) !== "ESRCH";
95
99
  } } };
96
100
  }
97
101
  function settings(options) {
@@ -102,7 +106,9 @@ function settings(options) {
102
106
  return { ...checked, processPort, clock, monotonicMs, boot: processPort.bootIdentity?.(), hostname: (options.hostname ?? nodeHostname)(), token: (options.token ?? randomUUID)() };
103
107
  }
104
108
  function ownership(s, options) {
105
- return options.format === "token" ? `${s.processPort.pid}:${s.token}${s.boot ? ":" + s.boot.id : ""}` : JSON.stringify({ pid: s.processPort.pid, hostname: s.hostname, token: s.token, acquiredAt: new Date(s.clock.nowMs()).toISOString(), ...(s.boot ? { bootId: s.boot.id } : {}) });
109
+ if (options.format === "token")
110
+ return `${s.processPort.pid}:${s.token}${s.boot ? ":" + s.boot.id : ""}`;
111
+ return JSON.stringify({ pid: s.processPort.pid, hostname: s.hostname, token: s.token, acquiredAt: new Date(s.clock.nowMs()).toISOString(), ...(s.boot ? { bootId: s.boot.id } : {}) });
106
112
  }
107
113
  function holderAlive(holder, s) {
108
114
  if (holder.hostname !== s.hostname)
@@ -155,9 +161,7 @@ function inspectSync(lockPath, filesystem) {
155
161
  descriptor = filesystem.openSync(lockPath, "r");
156
162
  }
157
163
  catch (error) {
158
- if (isAbsentOrContended(error))
159
- return undefined;
160
- throw error;
164
+ return fallbackIf(error, isAbsentOrContended, undefined);
161
165
  }
162
166
  try {
163
167
  const info = filesystem.fstatSync(descriptor), raw = filesystem.readFileSync(descriptor, "utf8");
@@ -173,9 +177,7 @@ async function inspectAsync(lockPath, filesystem) {
173
177
  handle = await filesystem.open(lockPath, "r");
174
178
  }
175
179
  catch (error) {
176
- if (isAbsentOrContended(error))
177
- return undefined;
178
- throw error;
180
+ return fallbackIf(error, isAbsentOrContended, undefined);
179
181
  }
180
182
  try {
181
183
  const info = await handle.stat(), raw = await handle.readFile("utf8");
@@ -203,9 +205,7 @@ function tryCreateSync(ctx) {
203
205
  descriptor = filesystem.openSync(lockPath, "wx", 0o600);
204
206
  }
205
207
  catch (error) {
206
- if (!isContendedLockError({ code: errorCode(error), platform: process.platform }))
207
- throw error;
208
- return undefined;
208
+ return fallbackIf(error, isContended, undefined);
209
209
  }
210
210
  let identity;
211
211
  try {
@@ -300,9 +300,7 @@ async function tryCreateAsync(ctx) {
300
300
  handle = await ctx.filesystem.open(ctx.lockPath, "wx", 0o600);
301
301
  }
302
302
  catch (error) {
303
- if (!isContendedLockError({ code: errorCode(error), platform: process.platform }))
304
- throw error;
305
- return undefined;
303
+ return fallbackIf(error, isContended, undefined);
306
304
  }
307
305
  let identity;
308
306
  try {
@@ -14,6 +14,27 @@ export type AddedReaderOptions = {
14
14
  descriptorRead?: DescriptorReadPolicy;
15
15
  };
16
16
  export type GuardedReaderFilesystem = Pick<typeof nodeFs, "existsSync" | "lstatSync" | "readdirSync" | "realpathSync" | "statSync" | "openSync" | "readSync" | "closeSync" | "createReadStream"> & DescriptorCapabilities;
17
+ /** Opt-in listing pages; omitted options preserve the legacy recursive reader contract.
18
+ * Cursors are offsets in a sorted snapshot; keep path/depth fixed and restart after tree changes.
19
+ */
20
+ export interface GuardedListingOptions {
21
+ relativePath?: string;
22
+ depth?: number;
23
+ limit?: number;
24
+ cursor?: string;
25
+ maxOutputBytes?: number;
26
+ }
27
+ export interface GuardedListingResult {
28
+ files: string[];
29
+ truncated: boolean;
30
+ remaining?: number;
31
+ nextCursor?: string;
32
+ message?: string;
33
+ }
34
+ /** Adds optional paging without changing existing guarded-reader implementations. */
35
+ export interface PagedGuardedFileReader extends GuardedFileReader {
36
+ list(required: Record<string, never>, optional?: GuardedListingOptions): GuardedListingResult;
37
+ }
17
38
  /** Native filesystem adapter; callers can replace individual operations for a virtual filesystem. */
18
39
  export declare function createNodeGuardedReaderFilesystem(_required: Record<string, never>): GuardedReaderFilesystem;
19
40
  /** Contains both requested and resolved paths, including symlink aliases and nonexistent descendants.
@@ -28,5 +49,5 @@ export declare function createGuardedFileReader({ rootPath, denyRules, limits, f
28
49
  filesystem: GuardedReaderFilesystem;
29
50
  }, { excludedListingDirs, descriptorRead }?: {
30
51
  excludedListingDirs?: ReadonlySet<string>;
31
- } & AddedReaderOptions): GuardedFileReader;
52
+ } & AddedReaderOptions): PagedGuardedFileReader;
32
53
  //# sourceMappingURL=guarded-reader.d.ts.map
@@ -1,6 +1,9 @@
1
1
  import nodeFs from "node:fs";
2
2
  import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
3
+ import { trimCharsEnd } from "@jini-ai/core/primitives";
3
4
  import { FsFilePathError } from '@jini-ai/core/filesystem';
5
+ /** Path segments that never add a component: empty (doubled separator), ".", and "..". */
6
+ const NON_DESCENDING_SEGMENTS = new Set(["", ".", ".."]);
4
7
  export { FsFilePathError } from '@jini-ai/core/filesystem';
5
8
  /** Native filesystem adapter; callers can replace individual operations for a virtual filesystem. */
6
9
  export function createNodeGuardedReaderFilesystem(_required) { return nodeFs; }
@@ -34,8 +37,9 @@ export function createGuardedFileReader({ rootPath, denyRules, limits, filesyste
34
37
  // name::$DATA as name. Compare raw AND folded names: folding may add denials, never remove them,
35
38
  // and must not change the path actually opened.
36
39
  function foldFsNameForDenylist(name) {
37
- const withoutStream = name.replace(/:[^]*$/, "");
38
- const withoutTrailing = withoutStream.replace(/[. ]+$/, "");
40
+ const streamAt = name.indexOf(":");
41
+ const withoutStream = streamAt === -1 ? name : name.slice(0, streamAt);
42
+ const withoutTrailing = trimCharsEnd({ text: withoutStream, chars: ". " });
39
43
  return withoutTrailing.toLowerCase().toUpperCase().toLowerCase();
40
44
  }
41
45
  // Split both separator spellings even on a host whose native separator is '/', so deny checks
@@ -43,13 +47,11 @@ export function createGuardedFileReader({ rootPath, denyRules, limits, filesyste
43
47
  function normalizeFsRelativePath(relativePath) {
44
48
  const segments = [];
45
49
  for (const segment of relativePath.split(/[\\/]/)) {
46
- if (segment === "" || segment === ".")
47
- continue;
48
- if (segment === "..") {
50
+ // Empty and "." segments are no-ops; ".." climbs one level; anything else descends.
51
+ if (segment === "..")
49
52
  segments.pop();
50
- continue;
51
- }
52
- segments.push(segment);
53
+ if (!NON_DESCENDING_SEGMENTS.has(segment))
54
+ segments.push(segment);
53
55
  }
54
56
  return segments.join("/");
55
57
  }
@@ -155,6 +157,10 @@ export function createGuardedFileReader({ rootPath, denyRules, limits, filesyste
155
157
  if (stat.isDirectory()) {
156
158
  if (isDeniedFsPathSegment(name) || EXCLUDED_LISTING_DIR_NAMES.has(name))
157
159
  return;
160
+ if (state.paged && depth === state.maxDepth) {
161
+ found.push(relative(base, full).split(sep).join("/") + "/");
162
+ return;
163
+ }
158
164
  walkFsDir(full, depth + 1, base, found, state);
159
165
  return;
160
166
  }
@@ -167,14 +173,17 @@ export function createGuardedFileReader({ rootPath, denyRules, limits, filesyste
167
173
  // Results, entries, and depth are independent limits; keep the decision shared by entry and
168
174
  // loop checks so adding one limit cannot leave an unbounded arm.
169
175
  function walkBoundReached(found, state, depth) {
170
- return depth > MAX_WALK_DEPTH || found.length >= MAX_LISTED_FILES || state.remaining <= 0;
176
+ return depth > state.maxDepth || found.length >= state.maxFiles || state.remaining <= 0;
171
177
  }
172
178
  function walkFsDir(dir, depth, base, found, state) {
173
179
  if (walkBoundReached(found, state, depth)) {
174
180
  state.truncated = true;
175
181
  return;
176
182
  }
177
- for (const name of readdirSync(dir)) {
183
+ const names = readdirSync(dir);
184
+ if (state.paged)
185
+ names.sort();
186
+ for (const name of names) {
178
187
  // Recheck per entry: this loop spends traversal budget and adds results. Remaining
179
188
  // entries mean a bound must set truncated rather than present a short list as complete.
180
189
  if (walkBoundReached(found, state, depth)) {
@@ -185,26 +194,36 @@ export function createGuardedFileReader({ rootPath, denyRules, limits, filesyste
185
194
  visitFsDirEntry(dir, name, depth, base, found, state);
186
195
  }
187
196
  }
197
+ /** Walk with existing containment/deny rules, then bound the serialized page independently.
198
+ * @complexity At most maxWalkEntries probes; sorting uses O(n log n) time and O(n) paths.
199
+ * Directory reads remain synchronous; traversal exhaustion is reported as an unknown remainder.
200
+ */
188
201
  function listFsFiles(required) {
202
+ const paged = [required.depth, required.limit, required.cursor, required.maxOutputBytes].some(value => value !== undefined);
203
+ const page = listingPageOptions(required, { maxDepth: MAX_WALK_DEPTH, maxFiles: MAX_LISTED_FILES });
189
204
  const { rootPath } = required;
190
205
  const relativePath = required.relativePath ?? "";
206
+ const pathLabel = relativePath || ".";
191
207
  const startDir = relativePath.length === 0 ? resolve(rootPath) : resolveFsFilePath({ rootPath, relativePath });
192
208
  // Freshly provisioned roots/subdirectories may not exist yet; an empty listing is valid.
193
209
  if (!existsSync(startDir))
194
210
  return { files: [], truncated: false };
195
- const startStat = statOrFsPathError(startDir, relativePath || ".");
211
+ const startStat = statOrFsPathError(startDir, pathLabel);
196
212
  if (!startStat)
197
213
  return { files: [], truncated: false };
198
214
  if (!startStat.isDirectory()) {
199
- throw new FsFilePathError({ message: `path '${relativePath || "."}' is not a directory` });
215
+ throw new FsFilePathError({ message: `path '${pathLabel}' is not a directory` });
200
216
  }
201
217
  const base = realpathSync(startDir);
202
218
  const found = [];
203
- const state = { remaining: MAX_WALK_ENTRIES, truncated: false };
219
+ const state = { remaining: MAX_WALK_ENTRIES, truncated: false,
220
+ maxDepth: paged ? page.depth - 1 : MAX_WALK_DEPTH,
221
+ maxFiles: paged ? MAX_WALK_ENTRIES : MAX_LISTED_FILES, paged };
204
222
  walkFsDir(base, 0, base, found, state);
205
223
  // Paths are relative to the requested walk base, so a subdirectory listing can be consumed
206
224
  // without stripping that subdirectory's prefix a second time.
207
- return { files: found.sort(), truncated: state.truncated };
225
+ const listing = { files: found.sort(), truncated: state.truncated };
226
+ return paged ? paginateListing(listing, page) : listing;
208
227
  }
209
228
  // Circular-link ELOOP and other filesystem exceptions must become the reader's refusal class,
210
229
  // not escape raw while an ordinary missing path is represented by undefined.
@@ -351,6 +370,53 @@ export function createGuardedFileReader({ rootPath, denyRules, limits, filesyste
351
370
  isDeniedPathSegment: ({ segmentName }) => isDeniedFsPathSegment(segmentName),
352
371
  };
353
372
  }
373
+ /** Validate page controls even for raw callers bypassing a tool schema; no filesystem effects. */
374
+ function listingPageOptions(input, { maxDepth, maxFiles }) {
375
+ const depth = input.depth ?? 1, limit = input.limit ?? Math.min(100, maxFiles), maxOutputBytes = input.maxOutputBytes ?? 8000;
376
+ for (const [name, value, max] of [["depth", depth, maxDepth], ["limit", limit, maxFiles], ["maxOutputBytes", maxOutputBytes, Number.MAX_SAFE_INTEGER]]) {
377
+ if (!Number.isSafeInteger(value) || value < 1 || value > max)
378
+ throw new FsFilePathError({ message: `${name} must be an integer between 1 and ${max}` });
379
+ }
380
+ if (maxOutputBytes < 512)
381
+ throw new FsFilePathError({ message: "maxOutputBytes must be at least 512 to fit listing metadata" });
382
+ return { depth, limit, maxOutputBytes, offset: listingOffset(input) };
383
+ }
384
+ /** Parse only cursors the pager can issue; refuse unsafe numeric offsets before traversal. */
385
+ function listingOffset({ cursor = "0" }, _options = {}) {
386
+ if (typeof cursor !== "string" || !/^(0|[1-9]\d*)$/.test(cursor) || !Number.isSafeInteger(Number(cursor)))
387
+ throw new FsFilePathError({ message: "cursor must be a non-negative integer string returned as nextCursor; keep path/depth fixed" });
388
+ return Number(cursor);
389
+ }
390
+ /** Bound JSON bytes as well as entry count: long or escaped filenames must not flood model input.
391
+ * Rescanning keeps cursors stateless; the existing traversal budget bounds work on every page.
392
+ * @throws FsFilePathError if one path cannot fit; narrowing path shortens relative entries.
393
+ */
394
+ function paginateListing(listing, { limit, maxOutputBytes, offset }) {
395
+ const files = [];
396
+ // Reserve space for truncation guidance, cursor and the tool's root/path envelope.
397
+ let bytes = 512;
398
+ for (const file of listing.files.slice(offset, offset + limit)) {
399
+ const size = Buffer.byteLength(JSON.stringify(file)) + 1;
400
+ if (bytes + size > maxOutputBytes)
401
+ break;
402
+ files.push(file);
403
+ bytes += size;
404
+ }
405
+ const end = offset + files.length, remaining = Math.max(0, listing.files.length - end);
406
+ if (remaining > 0 && files.length === 0)
407
+ throw new FsFilePathError({ message: "A listing entry exceeds the output budget; pass a narrower path to shorten the relative filename" });
408
+ return listingPageResult({ files, end, remaining, walkTruncated: listing.truncated });
409
+ }
410
+ /** Never claim an exact count when the traversal budget left directories unscanned. */
411
+ function listingPageResult({ files, end, remaining, walkTruncated }, _options = {}) {
412
+ if (remaining === 0 && !walkTruncated)
413
+ return { files, truncated: false };
414
+ const count = walkTruncated ? "unknown" : String(remaining);
415
+ return { files, truncated: true,
416
+ ...(!walkTruncated ? { remaining } : {}),
417
+ ...(remaining > 0 ? { nextCursor: String(end) } : {}),
418
+ message: `truncated: ${count} more entries; pass path/depth/cursor to see more. Keep path/depth fixed when paging; restart if files change.${walkTruncated ? " Traversal limit reached; narrow path to inspect unscanned directories." : ""}` };
419
+ }
354
420
  /** Validate opt-in capability support without weakening legacy filesystem adapters. */
355
421
  function descriptorOpenFlags({ filesystem, policy }) {
356
422
  if (!filesystem.fstatSync || !filesystem.constants)
@@ -362,11 +428,15 @@ function descriptorOpenFlags({ filesystem, policy }) {
362
428
  throw new TypeError("exactMode must contain only permission bits (0o0000 through 0o7777)");
363
429
  return O_RDONLY | O_NOFOLLOW | O_NONBLOCK;
364
430
  }
431
+ /** A safe, non-negative integer no larger than `max`; guards the size reported by an injected stat. */
432
+ function isByteCountWithin(value, max) {
433
+ return Number.isSafeInteger(value) && value >= 0 && value <= max;
434
+ }
365
435
  /** Both read paths validate the opened object, independent of earlier pathname metadata. */
366
436
  function assertDescriptorStat({ stat, relativePath, maxBytes }, { exactMode } = {}) {
367
437
  if (!stat.isFile())
368
438
  throw new FsFilePathError({ message: `path '${relativePath}' is not a regular file` });
369
- if (!Number.isSafeInteger(stat.size) || stat.size < 0 || stat.size > maxBytes)
439
+ if (!isByteCountWithin(stat.size, maxBytes))
370
440
  throw new FsFilePathError({ message: `file '${relativePath}' exceeds the ${maxBytes}-byte descriptor limit` });
371
441
  // Include setuid/setgid/sticky: "exact" 0o600 must refuse a 0o4600 file.
372
442
  if (exactMode !== undefined && (stat.mode & 0o7777) !== exactMode)
@@ -9,6 +9,7 @@ export * from "./directory-home.js";
9
9
  export * from "./directory-registry.js";
10
10
  export * from "./directory-running-lock.js";
11
11
  export * from "./directory-workspace.js";
12
+ export { DirectoryUserConfigRefusal, validateDirectoryUserConfig } from "./directory-user-config.js";
12
13
  export * from "./workspace-directory.js";
13
14
  export * from "./verified-directory-move.js";
14
15
  //# sourceMappingURL=index.d.ts.map
package/dist/fs/index.js CHANGED
@@ -10,6 +10,7 @@ export * from "./directory-home.js";
10
10
  export * from "./directory-registry.js";
11
11
  export * from "./directory-running-lock.js";
12
12
  export * from "./directory-workspace.js";
13
+ export { DirectoryUserConfigRefusal, validateDirectoryUserConfig } from "./directory-user-config.js";
13
14
  export * from "./workspace-directory.js";
14
15
  export * from "./verified-directory-move.js";
15
16
  //# sourceMappingURL=index.js.map