@jini-ai/platform 0.5.7 → 0.5.9

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 CHANGED
@@ -1,5 +1,15 @@
1
1
  # @jini-ai/platform
2
2
 
3
+ ## 0.5.9 — 2026-10-16
4
+
5
+ - `fs`: best-effort directory fsync, so Windows site creation stops failing with EPERM (87720117).
6
+ - `./web` fetch default window is 20,000 characters so one result fits an MCP budget (939300e3).
7
+
8
+ ## 0.5.8 — 2026-10-15
9
+
10
+ - SECURITY: the guarded HTTP client forwards only `accept`, `accept-language` and `user-agent` on a cross-origin redirect (4de69b67). It used to strip only Authorization/Cookie, so custom credential headers (`x-api-key`, `X-Shopify-Access-Token`, …) followed a redirect to another origin. Same-origin hops keep all headers.
11
+ - `./web` page fetch applies `maxChars` to the readable text, not the raw HTML (0625844e): raw HTML uses the fixed 1,000,000-char parser ceiling, and the result says `truncated: true` when cut. A page with a large script/style head no longer returns almost no text.
12
+
3
13
  ## 0.5.7 — 2026-10-14
4
14
 
5
15
  - The asset cache's optional `undici` load moves behind `asset-cache.adapter.ts` (64eafe35); injected dispatchers, DNS validation and the MODULE_NOT_FOUND message are unchanged. No API change.
@@ -72,10 +72,33 @@ export interface AtomicWriteOptions {
72
72
  export declare function createNodeAtomicFilesystem(_required: Record<string, never>, { filesystem }?: {
73
73
  filesystem?: AtomicNativeFilesystem;
74
74
  }): AtomicFilesystemPort;
75
+ /** Flush directory metadata where supported; Windows cannot fsync directory handles.
76
+ * Unsupported open/sync errors are ignored because replacement has already landed.
77
+ * Other failures propagate, and an opened descriptor is always closed.
78
+ * @complexity O(1) filesystem calls and space.
79
+ */
80
+ export declare function syncDirectoryBestEffort({ directory, open, sync, close }: {
81
+ directory: string;
82
+ open: (path: string, flags: "r") => number;
83
+ sync: (descriptor: number) => void;
84
+ close: (descriptor: number) => void;
85
+ }, { platform }: {
86
+ platform: NodeJS.Platform;
87
+ }): void;
88
+ /** Async directory flush with the same unsupported-error and handle-ownership contract.
89
+ * @complexity O(1) filesystem calls and space.
90
+ */
91
+ export declare function syncDirectoryBestEffortAsync({ directory, open }: {
92
+ directory: string;
93
+ open: (path: string, flags: "r") => Promise<Pick<fsPromises.FileHandle, "sync" | "close">>;
94
+ }, { platform }: {
95
+ platform: NodeJS.Platform;
96
+ }): Promise<void>;
75
97
  /** Same-directory rename; preserve existing mode exactly, default new files to 0600.
76
98
  * Only ENOENT means absent. Stat/write/chmod failures propagate before any rename.
77
- * Flush file bytes before rename so metadata cannot precede content; then flush the parent dir.
78
- * A directory-sync failure is reported after replacement, because rename cannot be rolled back safely.
99
+ * Flush file bytes before rename so metadata cannot precede content; directory flush is best-effort:
100
+ * skip Windows and ignore unsupported-code failures (EPERM/EISDIR/EINVAL/EACCES/ENOTSUP).
101
+ * Other directory-flush failures are reported after replacement, because rename cannot be rolled back safely.
79
102
  */
80
103
  export declare function writeFileAtomic(required: AtomicWriteRequired, options?: AtomicWriteOptions): void;
81
104
  /** Pretty JSON delegates to the same mode-preserving atomic primitive. */
@@ -42,18 +42,65 @@ function assertNotSymlink(info, options) {
42
42
  if (info.isSymbolicLink())
43
43
  throw new Error((options.messages ?? defaultPlatformMessages).atomicSymlinkRefused());
44
44
  }
45
- function assertOwnerOnly(mode, options) {
45
+ function assertOwnerOnly(mode, options, platform) {
46
46
  // Windows has no POSIX group/other bits; secret stores skip this verification there.
47
- if (options.verifyOwnerOnly && (options.platform ?? process.platform) !== "win32" && (mode & 0o077) !== 0)
47
+ if (options.verifyOwnerOnly && platform !== "win32" && (mode & 0o077) !== 0)
48
48
  throw new Error((options.messages ?? defaultPlatformMessages).atomicPermissionsRefused());
49
49
  }
50
+ function isDirectorySyncUnsupported(error) {
51
+ return typeof error === "object" && error !== null && "code" in error && typeof error.code === "string" && ["EPERM", "EISDIR", "EINVAL", "EACCES", "ENOTSUP"].includes(error.code);
52
+ }
53
+ /** Flush directory metadata where supported; Windows cannot fsync directory handles.
54
+ * Unsupported open/sync errors are ignored because replacement has already landed.
55
+ * Other failures propagate, and an opened descriptor is always closed.
56
+ * @complexity O(1) filesystem calls and space.
57
+ */
58
+ export function syncDirectoryBestEffort({ directory, open, sync, close }, { platform }) {
59
+ if (platform === "win32")
60
+ return;
61
+ let descriptor;
62
+ try {
63
+ descriptor = open(directory, "r");
64
+ sync(descriptor);
65
+ }
66
+ catch (error) {
67
+ if (!isDirectorySyncUnsupported(error))
68
+ throw error;
69
+ }
70
+ finally {
71
+ if (descriptor !== undefined)
72
+ close(descriptor);
73
+ }
74
+ }
75
+ /** Async directory flush with the same unsupported-error and handle-ownership contract.
76
+ * @complexity O(1) filesystem calls and space.
77
+ */
78
+ export async function syncDirectoryBestEffortAsync({ directory, open }, { platform }) {
79
+ if (platform === "win32")
80
+ return;
81
+ let handle;
82
+ try {
83
+ handle = await open(directory, "r");
84
+ await handle.sync();
85
+ }
86
+ catch (error) {
87
+ if (!isDirectorySyncUnsupported(error))
88
+ throw error;
89
+ }
90
+ finally {
91
+ if (handle !== undefined)
92
+ await handle.close();
93
+ }
94
+ }
50
95
  /** Same-directory rename; preserve existing mode exactly, default new files to 0600.
51
96
  * Only ENOENT means absent. Stat/write/chmod failures propagate before any rename.
52
- * Flush file bytes before rename so metadata cannot precede content; then flush the parent dir.
53
- * A directory-sync failure is reported after replacement, because rename cannot be rolled back safely.
97
+ * Flush file bytes before rename so metadata cannot precede content; directory flush is best-effort:
98
+ * skip Windows and ignore unsupported-code failures (EPERM/EISDIR/EINVAL/EACCES/ENOTSUP).
99
+ * Other directory-flush failures are reported after replacement, because rename cannot be rolled back safely.
54
100
  */
55
101
  export function writeFileAtomic(required, options = {}) {
56
102
  const { filePath, content, fs: filesystem } = required;
103
+ const platform = options.platform ?? process.platform;
57
104
  let existingMode;
58
105
  try {
59
106
  if (options.refuseSymlink) {
@@ -77,7 +124,7 @@ export function writeFileAtomic(required, options = {}) {
77
124
  try {
78
125
  filesystem.write({ descriptor, content });
79
126
  filesystem.chmod({ path: temporary, mode });
80
- assertOwnerOnly(filesystem.stat({ path: temporary }).mode, options);
127
+ assertOwnerOnly(filesystem.stat({ path: temporary }).mode, options, platform);
81
128
  filesystem.sync({ descriptor });
82
129
  const fileDescriptor = descriptor;
83
130
  descriptor = undefined;
@@ -93,13 +140,10 @@ export function writeFileAtomic(required, options = {}) {
93
140
  }
94
141
  filesystem.rename({ from: temporary, to: filePath });
95
142
  renamed = true;
96
- const directory = filesystem.open({ path: path.dirname(filePath), flags: "r" });
97
- try {
98
- filesystem.sync({ descriptor: directory });
99
- }
100
- finally {
101
- filesystem.close({ descriptor: directory });
102
- }
143
+ syncDirectoryBestEffort({ directory: path.dirname(filePath),
144
+ open: (path, flags) => filesystem.open({ path, flags }),
145
+ sync: descriptor => filesystem.sync({ descriptor }), close: descriptor => filesystem.close({ descriptor }),
146
+ }, { platform });
103
147
  }
104
148
  catch (error) {
105
149
  if (descriptor !== undefined) {
@@ -129,6 +173,7 @@ export function writeJsonFileAtomic(required, options = {}) {
129
173
  * Parent creation supports runtime-state and secret stores without another writer implementation. */
130
174
  export async function writeFileAtomicAsync(required, options = {}) {
131
175
  const { filePath, content } = required;
176
+ const platform = options.platform ?? process.platform;
132
177
  const filesystem = options.fs ?? fsPromises;
133
178
  if (options.createParent)
134
179
  await filesystem.mkdir(path.dirname(filePath), { recursive: true });
@@ -151,7 +196,7 @@ export async function writeFileAtomicAsync(required, options = {}) {
151
196
  try {
152
197
  await handle.writeFile(content, "utf8");
153
198
  await handle.chmod(mode);
154
- assertOwnerOnly((await handle.stat()).mode, options);
199
+ assertOwnerOnly((await handle.stat()).mode, options, platform);
155
200
  await handle.sync();
156
201
  await handle.close();
157
202
  closed = true;
@@ -166,13 +211,7 @@ export async function writeFileAtomicAsync(required, options = {}) {
166
211
  }
167
212
  await filesystem.rename(temporary, filePath);
168
213
  renamed = true;
169
- const directory = await filesystem.open(path.dirname(filePath), "r");
170
- try {
171
- await directory.sync();
172
- }
173
- finally {
174
- await directory.close();
175
- }
214
+ await syncDirectoryBestEffortAsync({ directory: path.dirname(filePath), open: (path, flags) => filesystem.open(path, flags) }, { platform });
176
215
  }
177
216
  catch (error) {
178
217
  if (!closed) {
@@ -74,7 +74,9 @@ export interface DurableJsonFile {
74
74
  }
75
75
  /** Atomic replacement, distinct corrupt reads, quarantine, and token-owned synchronous locks.
76
76
  * Lock stealing rechecks inode, bytes and mtime; conditional unlink is not available on all hosts.
77
- * File fsync precedes rename; directory fsync follows it. Persistence remains filesystem-dependent.
77
+ * File fsync precedes rename; directory flush afterwards is best-effort: skipped on Windows,
78
+ * unsupported-code failures ignored (EPERM/EISDIR/EINVAL/EACCES/ENOTSUP), other failures reported
79
+ * after replacement. Persistence remains filesystem-dependent.
78
80
  */
79
81
  export declare function createDurableJsonFile({ filePath, filesystem: fs, clock, sleep, process, random, report, notices }: DurableJsonPorts & {
80
82
  filePath: string;
@@ -36,7 +36,9 @@ export function createNodeDurableJsonPorts(_required) {
36
36
  }
37
37
  /** Atomic replacement, distinct corrupt reads, quarantine, and token-owned synchronous locks.
38
38
  * Lock stealing rechecks inode, bytes and mtime; conditional unlink is not available on all hosts.
39
- * File fsync precedes rename; directory fsync follows it. Persistence remains filesystem-dependent.
39
+ * File fsync precedes rename; directory flush afterwards is best-effort: skipped on Windows,
40
+ * unsupported-code failures ignored (EPERM/EISDIR/EINVAL/EACCES/ENOTSUP), other failures reported
41
+ * after replacement. Persistence remains filesystem-dependent.
40
42
  */
41
43
  export function createDurableJsonFile({ filePath, filesystem: fs, clock, sleep, process, random, report, notices }, { pollMs = 10, staleMs = 30000 } = {}) {
42
44
  if (!filePath)
@@ -70,8 +72,9 @@ export function createDurableJsonFile({ filePath, filesystem: fs, clock, sleep,
70
72
  return `${filePath}.${pid}.tmp`;
71
73
  }
72
74
  // Same-directory rename gives readers complete old/new versions rather than a torn intermediate.
73
- // Flush bytes before rename so metadata cannot precede content. The canonical writer also syncs
74
- // the parent directory; platform/filesystem support determines persistence after that sync.
75
+ // Flush bytes before rename so metadata cannot precede content. The canonical writer's parent
76
+ // directory flush is best-effort: skipped on Windows, unsupported-code failures ignored
77
+ // (EPERM/EISDIR/EINVAL/EACCES/ENOTSUP), other failures reported after replacement.
75
78
  // Keep the legacy temp path, pretty JSON bytes and new-file create mode for these shared stores.
76
79
  function writeJson(filePath, value) {
77
80
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
@@ -19,7 +19,7 @@ export declare function moveDirectoryVerified({ from, to, commit }: {
19
19
  from: string;
20
20
  to: string;
21
21
  commit: () => void;
22
- }, { excluded, filesystem, prepareDestination, relocateFiles, verifyFile, backupId }?: {
22
+ }, { excluded, filesystem, prepareDestination, relocateFiles, verifyFile, backupId, platform }?: {
23
23
  excluded?: readonly string[];
24
24
  filesystem?: typeof fs;
25
25
  prepareDestination?: (required: {
@@ -33,5 +33,6 @@ export declare function moveDirectoryVerified({ from, to, commit }: {
33
33
  path: string;
34
34
  }) => string;
35
35
  backupId?: () => string;
36
+ platform?: NodeJS.Platform;
36
37
  }): void;
37
38
  //# sourceMappingURL=verified-directory-move.d.ts.map
@@ -1,4 +1,5 @@
1
1
  import { stageVerifiedFileRelocation } from "./verified-file-relocation.js";
2
+ import { syncDirectoryBestEffort } from "./atomic-write.js";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
4
5
  import { createHash, randomUUID } from "node:crypto";
@@ -45,7 +46,7 @@ export function directoryFingerprint({ dir }, { excluded = [], filesystem = fs }
45
46
  * Same-filesystem failures roll the rename back. Commit must atomically update the host registry.
46
47
  * @complexity O(bytes + entries log entries), with two verification passes.
47
48
  */
48
- export function moveDirectoryVerified({ from, to, commit }, { excluded = [], filesystem = fs, prepareDestination, relocateFiles, verifyFile, backupId = randomUUID } = {}) {
49
+ export function moveDirectoryVerified({ from, to, commit }, { excluded = [], filesystem = fs, prepareDestination, relocateFiles, verifyFile, backupId = randomUUID, platform = process.platform } = {}) {
49
50
  const source = filesystem.realpathSync(from);
50
51
  const target = path.resolve(to);
51
52
  if (target === source || target.startsWith(source + path.sep))
@@ -88,7 +89,7 @@ export function moveDirectoryVerified({ from, to, commit }, { excluded = [], fil
88
89
  if (relocateFiles) {
89
90
  if (!verifyFile)
90
91
  throw new Error("File relocation requires a verification port.");
91
- staged = stageVerifiedFileRelocation({ groups: relocateFiles({ source, target }), verify: verifyFile }, { filesystem, backupId, backupCovered: ({ from }) => copied && path.resolve(from).startsWith(target + path.sep) });
92
+ staged = stageVerifiedFileRelocation({ groups: relocateFiles({ source, target }), verify: verifyFile }, { filesystem, backupId, platform, backupCovered: ({ from }) => copied && path.resolve(from).startsWith(target + path.sep) });
92
93
  }
93
94
  if (copied) {
94
95
  const backupRoot = path.join(path.dirname(source), `.relocation-${backupId()}-backup`);
@@ -100,23 +101,11 @@ export function moveDirectoryVerified({ from, to, commit }, { excluded = [], fil
100
101
  filesystem.mkdirSync(backupRoot);
101
102
  filesystem.renameSync(source, backup);
102
103
  retiredSource = backup;
103
- const fd = filesystem.openSync(backupRoot, "r");
104
- try {
105
- filesystem.fsyncSync(fd);
106
- }
107
- finally {
108
- filesystem.closeSync(fd);
109
- }
104
+ syncDirectoryBestEffort({ directory: backupRoot, open: filesystem.openSync, sync: filesystem.fsyncSync, close: filesystem.closeSync }, { platform });
110
105
  }
111
- // Persist directory-name changes for same-filesystem renames as well as EXDEV retirement.
106
+ // Where supported, persist directory-name changes for same-filesystem renames as well as EXDEV retirement.
112
107
  for (const directory of new Set([path.dirname(source), path.dirname(target)])) {
113
- const fd = filesystem.openSync(directory, "r");
114
- try {
115
- filesystem.fsyncSync(fd);
116
- }
117
- finally {
118
- filesystem.closeSync(fd);
119
- }
108
+ syncDirectoryBestEffort({ directory, open: filesystem.openSync, sync: filesystem.fsyncSync, close: filesystem.closeSync }, { platform });
120
109
  }
121
110
  commit();
122
111
  }
@@ -14,12 +14,13 @@ export declare function stageVerifiedFileRelocation({ groups, verify }: {
14
14
  verify: (required: {
15
15
  path: string;
16
16
  }) => string;
17
- }, { filesystem, backupCovered, backupId }?: {
17
+ }, { filesystem, backupCovered, backupId, platform }?: {
18
18
  filesystem?: typeof fs;
19
19
  backupCovered?: (required: {
20
20
  from: string;
21
21
  }) => boolean;
22
22
  backupId?: () => string;
23
+ platform?: NodeJS.Platform;
23
24
  }): {
24
25
  rollback: () => void;
25
26
  finalize: () => void;
@@ -1,12 +1,13 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
+ import { syncDirectoryBestEffort } from "./atomic-write.js";
4
5
  /** Stage related files together while both directory identities are reserved by the host. Raw
5
6
  * sources remain intact through inspection so even WAL/SHM bytes are retained. Verification opens only
6
7
  * stopped databases through an injected read-only inspector; the filesystem owner never uses SQL.
7
8
  * @complexity O(total bytes + verification cost), bounded memory via native file copies.
8
9
  */
9
- export function stageVerifiedFileRelocation({ groups, verify }, { filesystem = fs, backupCovered = () => false, backupId = randomUUID } = {}) {
10
+ export function stageVerifiedFileRelocation({ groups, verify }, { filesystem = fs, backupCovered = () => false, backupId = randomUUID, platform = process.platform } = {}) {
10
11
  const created = [], retired = [], markers = [], redundantBackups = [];
11
12
  const backupSuffix = `.relocation-${backupId()}-backup`;
12
13
  const copyExclusive = (from, to) => {
@@ -26,14 +27,8 @@ export function stageVerifiedFileRelocation({ groups, verify }, { filesystem = f
26
27
  syncParent(to);
27
28
  };
28
29
  const syncParent = (file) => {
29
- // Directory fsync persists retirement/marker ordering before the host commits its catalog.
30
- const fd = filesystem.openSync(path.dirname(file), "r");
31
- try {
32
- filesystem.fsyncSync(fd);
33
- }
34
- finally {
35
- filesystem.closeSync(fd);
36
- }
30
+ // Where supported, directory fsync persists retirement/marker ordering before the host commits its catalog.
31
+ syncDirectoryBestEffort({ directory: path.dirname(file), open: filesystem.openSync, sync: filesystem.fsyncSync, close: filesystem.closeSync }, { platform });
37
32
  };
38
33
  const rollback = () => {
39
34
  for (const item of [...retired].reverse()) {
@@ -38,7 +38,7 @@ export function classifyAddress({ ip }) {
38
38
  // it is not globally routable and belongs with RFC 1918 private ranges for egress decisions.
39
39
  return "private";
40
40
  }
41
- const SENSITIVE_HEADERS = new Set(["authorization", "cookie"]);
41
+ const CROSS_ORIGIN_HEADERS = new Set(["accept", "accept-language", "user-agent"]);
42
42
  // HTTP header names are case-insensitive; preserve the caller's explicit User-Agent in any casing.
43
43
  function hasUserAgentHeader(headers) {
44
44
  return Object.keys(headers).some((key) => key.toLowerCase() === "user-agent");
@@ -105,10 +105,10 @@ async function resolvePinnedPeer(url, policy, dns) {
105
105
  tlsServerName: isIP(hostname) === 0 ? url.hostname : undefined,
106
106
  };
107
107
  }
108
- function withStrippedSensitiveHeaders(headers) {
108
+ function withCrossOriginHeaders(headers) {
109
109
  const next = {};
110
110
  for (const [key, value] of Object.entries(headers)) {
111
- if (SENSITIVE_HEADERS.has(key.toLowerCase()))
111
+ if (!CROSS_ORIGIN_HEADERS.has(key.toLowerCase()))
112
112
  continue;
113
113
  next[key] = value;
114
114
  }
@@ -179,8 +179,9 @@ async function sendWithPolicy(transport, policy, request, redirectsFollowed, dns
179
179
  return cappedResponse;
180
180
  const nextUrl = new URL(location, url);
181
181
  // Cross-origin hops must not inherit authorization or cookies entrusted to the original origin.
182
+ // Custom headers can carry credentials too; forward only the explicit noncredential allowlist.
182
183
  const nextHeaders = isCrossOrigin(url, nextUrl)
183
- ? withStrippedSensitiveHeaders(request.headers)
184
+ ? withCrossOriginHeaders(request.headers)
184
185
  : request.headers;
185
186
  return sendWithPolicy(transport, policy, { ...request, url: nextUrl.toString(), headers: nextHeaders }, redirectsFollowed + 1, dns, redirect);
186
187
  }
@@ -9,7 +9,7 @@ import { type PageImage, type PageLink } from "./html-document.js";
9
9
  */
10
10
  export declare const WEB_FETCH_FORMATS: readonly ["markdown", "text", "html", "raw"];
11
11
  export type WebFetchFormat = (typeof WEB_FETCH_FORMATS)[number];
12
- export declare const DEFAULT_MAX_CHARS = 60000;
12
+ export declare const DEFAULT_MAX_CHARS = 20000;
13
13
  export declare const MAX_MAX_CHARS = 200000;
14
14
  export declare const MAX_URL_LENGTH = 2048;
15
15
  export declare const DEFAULT_TIMEOUT_MS = 15000;
@@ -10,7 +10,8 @@ import { htmlToText, readHtmlDocument, stripActiveHtml, MAX_LINKS, MAX_IMAGES }
10
10
  * This module only pre-validates the URL so the common mistakes get a precise message.
11
11
  */
12
12
  export const WEB_FETCH_FORMATS = ["markdown", "text", "html", "raw"];
13
- export const DEFAULT_MAX_CHARS = 60_000;
13
+ // Leave room for page facts inside an agent CLI's MCP result budget.
14
+ export const DEFAULT_MAX_CHARS = 20_000;
14
15
  export const MAX_MAX_CHARS = 200_000;
15
16
  export const MAX_URL_LENGTH = 2048;
16
17
  export const DEFAULT_TIMEOUT_MS = 15_000;
@@ -245,8 +246,8 @@ function project({ input, ports, url, response }) {
245
246
  const body = decodeBody(response, contentType, isHtml);
246
247
  if (body.includes("\u0000"))
247
248
  throw new ToolInputError({ message: "web_fetch_page: the response is binary, not a text page." });
248
- // Four input characters per output character limits synchronous parsing for small requests too.
249
- const bounded = isHtml ? boundHtml({ html: body }, { maxChars: Math.max(4096, input.maxChars * 4) }) : { html: body, truncated: false };
249
+ // A fixed ceiling limits synchronous parsing without letting a large head consume the output budget.
250
+ const bounded = isHtml ? boundHtml({ html: body }) : { html: body, truncated: false };
250
251
  const facts = isHtml ? readHtmlDocument({ html: bounded.html, pageUrl: finalUrl }) : /xml/.test(mediaType) ? sitemapFacts(body, finalUrl) : EMPTY_FACTS;
251
252
  const rendered = render(input.format, input.format === "raw" ? body : bounded.html, isHtml, finalUrl, ports);
252
253
  const content = clip(rendered, input.maxChars);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/platform",
3
- "version": "0.5.7",
3
+ "version": "0.5.9",
4
4
  "description": "Generic OS/platform primitives: process exec and lifecycle, filesystem containment, HTTP readiness polling, toolchain discovery, blob storage, AWS signing, and an SSRF-safe asset cache.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {