@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.
- package/CHANGELOG.md +26 -0
- package/README.md +7 -8
- package/dist/asset-cache.adapter.d.ts +2 -2
- package/dist/asset-cache.adapter.js +2 -2
- package/dist/asset-cache.d.ts +1 -3
- package/dist/asset-cache.js +2 -14
- package/dist/blob-storage.js +10 -9
- package/dist/child-environment.js +1 -1
- package/dist/command.js +4 -1
- package/dist/core/effective-port.d.ts +6 -0
- package/dist/core/effective-port.js +8 -0
- package/dist/download.js +25 -24
- package/dist/fetch-with-timeout.d.ts +27 -7
- package/dist/fetch-with-timeout.js +51 -11
- package/dist/fs/atomic-write.js +5 -2
- package/dist/fs/boot-identity.d.ts +0 -4
- package/dist/fs/boot-identity.js +9 -3
- package/dist/fs/directory-registry.js +2 -2
- package/dist/fs/directory-running-lock.js +9 -4
- package/dist/fs/directory-sync.js +2 -3
- package/dist/fs/directory-user-config.d.ts +9 -0
- package/dist/fs/directory-user-config.js +13 -6
- package/dist/fs/directory-workspace.js +11 -14
- package/dist/fs/durable-json.js +22 -16
- package/dist/fs/errno.d.ts +9 -0
- package/dist/fs/errno.js +21 -0
- package/dist/fs/file-lock.d.ts +1 -0
- package/dist/fs/file-lock.js +17 -19
- package/dist/fs/guarded-reader.d.ts +22 -1
- package/dist/fs/guarded-reader.js +85 -15
- package/dist/fs/index.d.ts +1 -0
- package/dist/fs/index.js +1 -0
- package/dist/fs/user-data-directories.js +5 -7
- package/dist/fs/workspace-directory.d.ts +7 -1
- package/dist/fs/workspace-directory.js +9 -2
- package/dist/fs.js +2 -1
- package/dist/http/guarded/client.js +2 -1
- package/dist/http.js +2 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/mail/builtin-providers.js +4 -2
- package/dist/mail/feedback.js +1 -1
- package/dist/mail/prepare-sender-email.js +7 -1
- package/dist/mail/provider.js +14 -8
- package/dist/mail/rate-limit.js +3 -1
- package/dist/messages.js +1 -1
- package/dist/net/endpoint-policy.js +15 -7
- package/dist/net/index.d.ts +1 -0
- package/dist/net/index.js +1 -0
- package/dist/net/local-http-probe.d.ts +16 -0
- package/dist/net/local-http-probe.js +36 -0
- package/dist/object-store/local-fs.js +3 -1
- package/dist/process.js +10 -9
- package/dist/proxy-env.js +22 -14
- package/dist/secrets/credential-sets/index.d.ts +1 -0
- package/dist/secrets/credential-sets/index.js +1 -0
- package/dist/secrets/credential-sets/provider-matches.js +14 -3
- package/dist/secrets/credential-sets/vault-archive.d.ts +41 -0
- package/dist/secrets/credential-sets/vault-archive.js +64 -0
- package/dist/secrets/credential-sets/vault.meta.js +6 -2
- package/dist/secrets/credential-sets/vault.repo.memory.js +3 -5
- package/dist/secrets/credential-sets/vault.service.js +22 -17
- package/dist/secrets/credential-sets/vault.types.d.ts +6 -0
- package/dist/secrets/credential-sets/vault.types.js +7 -0
- package/dist/secrets/credential-token.js +9 -2
- package/dist/secrets/keyring.env.js +2 -4
- package/dist/shell.js +2 -1
- package/dist/terminal.js +4 -7
- package/dist/toolchain.js +4 -9
- package/dist/web/first-distinct.d.ts +11 -0
- package/dist/web/first-distinct.js +16 -0
- package/dist/web/html-document.js +35 -39
- package/dist/web/html-to-markdown.js +20 -9
- package/dist/web/web-page.js +28 -25
- package/dist/windows-system-tools.d.ts +15 -0
- package/dist/windows-system-tools.js +16 -0
- 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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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)
|
package/dist/fs/durable-json.js
CHANGED
|
@@ -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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
package/dist/fs/errno.js
ADDED
|
@@ -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
|
package/dist/fs/file-lock.d.ts
CHANGED
|
@@ -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;
|
package/dist/fs/file-lock.js
CHANGED
|
@@ -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
|
|
48
|
-
return
|
|
48
|
+
function isContended(error) {
|
|
49
|
+
return isContendedLockError({ code: errorCodeOf(error), platform: process.platform });
|
|
49
50
|
}
|
|
50
51
|
function isAbsentOrContended(error) {
|
|
51
|
-
const code =
|
|
52
|
+
const code = errorCodeOf(error);
|
|
52
53
|
return code === "ENOENT" || isContendedLockError({ code, platform: process.platform });
|
|
53
54
|
}
|
|
54
|
-
function rethrowUnlessMissing(error) { if (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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):
|
|
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
|
|
38
|
-
const
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
if (segment === "..") {
|
|
50
|
+
// Empty and "." segments are no-ops; ".." climbs one level; anything else descends.
|
|
51
|
+
if (segment === "..")
|
|
49
52
|
segments.pop();
|
|
50
|
-
|
|
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 >
|
|
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
|
-
|
|
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,
|
|
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 '${
|
|
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
|
-
|
|
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 (!
|
|
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)
|
package/dist/fs/index.d.ts
CHANGED
|
@@ -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
|