refactor: centralize bounded file reads in fs-safe (#111104)

* refactor: use fs-safe bounded descriptor reads

* build: update fs-safe to 0.4.2

* build: refresh root npm shrinkwrap

* fix: satisfy bounded read return paths

* fix: update fs-safe integration for latest main

* fix: adopt fs-safe overflow compatibility release

* build: complete fs-safe lockfile update

* build: update fs-safe to 0.4.4

* build: refresh plugin SDK API baseline

* test: follow fs-safe bounded read seam
This commit is contained in:
Peter Steinberger
2026-07-19 01:29:23 -07:00
committed by GitHub
parent 59b2fbbb4b
commit f07a1fb502
20 changed files with 98 additions and 183 deletions
+13 -13
View File
@@ -13,7 +13,7 @@ e5e67ddf3cab38fcbf9220bc3160715897e2709d9a9ff6ff36f1ecc9453c2367 module/agent-c
30452bae2a689fb75dcb6dba9ef11e5b95f9cbe0c62eb190a0fcb82d0a19ae52 module/agent-core
74daa746deb548379d3f0d6eac3c4d082df1034c4360cc03bf51fee0f10a2e4d module/agent-harness
e09226afd443cee02ed648f032f53bfeb60585617bb523a649995764d3c38da9 module/agent-harness-exec-review-runtime
71ac9e3d66263fc973f3ef724b70300d66d79bdf2f61c6e8478fb49790d81a29 module/agent-harness-runtime
ca9810b66aff3c8b60278b80d634c9c280322034964c05cb727d84252080aabf module/agent-harness-runtime
ec22d7a039fb58d0b8343ad149322960d3d8ca58b3f4c70f2fa8a099f8186d0c module/agent-harness-task-runtime
5f63bf587bf3547d59d0dc5d0dc2fee54745aa6edaab4aa3ae700dba03443edb module/agent-harness-tool-runtime
5168648cd946abad8a92822889f13ceacc87ed502314a66190d0b1eb8ebe76ea module/agent-media-payload
@@ -35,13 +35,13 @@ a5e9215460bc99fb6e6cdd4bc67eb6466a5e99f28279e13d1769599c18dfe0b3 module/approva
6c81b9122ab0a5c3190700c0a234047274a8d48edddcc8f26d3859b886696068 module/async-lock-runtime
ad60ccc4fe9084d47f0477e02d9296bacad32f26d7456e2a84be8d25a53a25c2 module/boolean-param
333a906d4ad9d3e89102ab7eb9059a7f9c7277328041223ee3b0713442f56a29 module/browser-config
3a048c3733715f98e839f41a3a50c92e16d432e35bd255987424b7edd3711782 module/bundled-channel-config-schema
0de3c53c3ba6ff739d4b047b440588ac25fdea3b795e92b37cab90bfd1520a8c module/bundled-channel-config-schema
b6e53fb9c69840d71785325cd9a244809ffd6d3f0dd08ed0cccf816dd993f210 module/channel-actions
fcfd76dbfe818e8e8574a425f340fee935acf53d4484e961f54c3ec760fb15fe module/channel-activity-runtime
24c53c9cefacd8c1bec2aa91ba2b01e606f8b8477bc5cb2f99567225a4dcf67c module/channel-config-helpers
a82cc04b1cda7a647850a13c3de552ab638550c7042f887f32bb0ced7227d143 module/channel-config-primitives
929b3f55b60e3885e33644ed3e1757b82349e1beeeede02eda80f7bf7eea4631 module/channel-config-schema
16e36f0ddc2ce83cd60e1a96cb4838887e7b328c912ccebbdc9a5d45c550bd07 module/channel-config-schema-legacy
16dc9d32e8ca3ef78fc63e0fc4b20e6b943633e9572de1a49d24a880b6ffc66c module/channel-config-primitives
5da2caccf30780a4cf86ac710e2ca42cc9576cb1c49291c0929889fec0cc2257 module/channel-config-schema
080d51451ad15a2787eada3dcc04985154cc394058dfbf2194c6560801f3a1c1 module/channel-config-schema-legacy
8740f7cc786a380043e41aaf710ac47931b3587c122bb152d5f0231f98fd5351 module/channel-config-writes
2dd98659d9600e755f09ef00dd91c36692b8562ed3700461937e94f6cd1e640a module/channel-contract
3db1a968e5b8aa97623a48a9ee76393d578d4bb90634fa59f499a2fd87b9da72 module/channel-core
@@ -89,13 +89,13 @@ a224ce0381a32ed7a9d2261669c444f7f46701c15bf2f6121e9b057d9910da78 module/command
eb4c757fe0086c1dbfa4c3f3caf3dcff0d3cab3924c608237f08f740a6ee5f59 module/command-status
10f073c8c7c7384843cb89548e01cf66241e23ebabea01ccc1f9ac7e774f563e module/command-status-runtime
d340686cf814326b5a554a32cc5ca324a9e1254a933c4d9d8d1ada5ac7109a10 module/command-surface
251197439b471bc18d7470023b75a4b342884d7096217de9c7e8831ad037818f module/compat
e26e0f75b43c5bbadd34401b21d8c76406ca0b87cc997be5abd100462d318f1a module/compat
2cf2f7732af5491a14466fc984f833ddce2b0f6a20b1c82796c3ee856e2966ce module/concurrency-runtime
a821f9cc4e6f9339399d99e73f58c3b00baf139aa9d85c22d73c89d8be5702d2 module/config-contracts
235a9e4d983042c3db156efcab0e333984cbbf13ebdfcc44a3a5ab40cf3edb4f module/config-contracts
20f3f8042de53e4eee61b64de9102c8c202b9299e6a29235647a4729f70145f2 module/config-mutation
316949815affe623ac63951a5db580527f02663576dffa084f02768f612c0c1c module/config-runtime
0415d7b2b042d44990b3c1ffb7e648bd791bcded3128af35d1a02444f24bce91 module/config-schema
566ad45c5702acbd7606e2473f5a00c0d4f21722fb9056243b43171cfc856e3e module/config-types
adb5fa3476c1f6b4e6c6d2d170254ed781949e7b609be1679661003911635de3 module/config-schema
1531347939d51c528b7230b951da194faafdfc3d8284765e7e9a0373d1ce5ef8 module/config-types
42d15153981cfe3adc1d5f91621434c56f07a9bd48c15ce34742f72dd040c142 module/context-visibility-runtime
03636897fb99cb73e4d8620c8a0e0d72b4d52fc32bf94f525af2aa88c489c6c2 module/conversation-binding-runtime
c1ea9510dfda047609a99d5d2cd1f1560f5d469a36e6b695766213d695c25b0f module/conversation-runtime
@@ -111,7 +111,7 @@ f70c93d28053ca2e8353e45e6515ce7acef188097c6117d1545965d0699c8004 module/device-
371ee1fd78810526745c93c24c4b85562c981676adcc33aaa0c627f5d2d45810 module/direct-dm-guard-policy
91278c800e0f87d7111f226e1cfcb6f29552936fec7215b3b69be4da7e2ee8fb module/directory-config-runtime
ea81ef06956c1bc0853fa00afbbc2b5a4019116aaf8a436e1b27d06f7a2c9e88 module/directory-runtime
eb069b02d837a4657f8230d0efaaf7cdc913a5ba4866d90f44620f1a8d06c3fe module/discord
c443b68d232f28b7241e1be10d1604def0d52d99ce3ae1ca8e2be3c6fd8b2d2f module/discord
f41f9b34ab771c894293453bcdf072860c7cd509dd4e0172156634a816b8d727 module/document-extractor
3ac20ebba52de5a2f18807c0c8ade6e61f59e260a35fc1d077c9dedb9960dabd module/embedding-providers
46c05a90b66032d1d7ad08445840a4bf81aa2bd325348f87710ad4538daf38f6 module/error-runtime
@@ -225,7 +225,7 @@ c543747f32aebde42508e31606db325739f0c54fcc7cf683f68ac941c835f737 module/provide
096f53f25cde2c3428b6ff042f1687c5a7c1b161a375ba97aefcdf2f1c38887f module/provider-setup
413af0d4597c1bab45e2f1a260b520445a25b9d11abd46a98fdfb74c60ddd601 module/provider-stream
ea8bd63655e983dacfd306fe77d0793097166228a2dabd9b7fc8e33252eb359f module/provider-stream-family
dc720974ed8d5cfd98fb3bb2958630736883f38317ee4744ce0b4042fa4485d8 module/provider-stream-shared
2285e2c23d64af94ff6b9bd6108e854343f1ec55eca6212fc4fd22138253363b module/provider-stream-shared
5a907292055c941ec7420163ea50e8bc90ead816b3c5867d94a23024d3b579e8 module/provider-tools
8236935f56423a26aeb7219173c0c8c8c7aacb4a13d0d851f05bdf2d35790cd4 module/provider-transport-runtime
009a594b19aa9f0d0c0c28f54968f807cad4b3ee40ec13c685367116d63aafdc module/provider-usage
@@ -272,7 +272,7 @@ e9c4398b04d04fead0e46ec618589d8f597a0b6087805ab132138e941b76e190 module/sandbox
596a315d426121c9620b314e3a9a7f523840b46e007d94d0d5e83cdedf789d15 module/security-runtime
50beebb77e461deaccdbff038f6a461dff1d5773426322dc6d7991f6e05a7c37 module/self-hosted-provider-setup
250476d121ffa4ed67d497c59d9c7bb1886973e92ebed39325a02609217bade0 module/session-binding-runtime
2866ff45859edc6a299cd36eaaf0b9ef32181cc6f0370b7e71eb9deddea5989c module/session-catalog
e3cfdf7ff5181ecd517bcb965c11f78b8363c8adf9f7f53f18bdbb5e0ceca1a6 module/session-catalog
f07839f5b8929a179857a0b4b89f8448864078cd5972dd53f9a30e50bf55408d module/session-key-runtime
f59099aa2d536246d4b1297f01bcea66796351a9259debdd45909afeb7a42d86 module/session-store-runtime
b1d0a76337122cb9dbb0c89fbb8bfacc1a88ce689270d3d684019c9a03ce669a module/session-transcript-hit
@@ -299,7 +299,7 @@ eb9a25321eaa2bbea1721f7d4b8b218bed398379494e09a21a621f264435b39a module/string-
32f031eb75c887b24b8eaa693cd0aa1a4648dca85eab7bfdfb3d08136861c274 module/system-event-runtime
65361dc9a23578787c1ccf98f7df99e443574614e7ce95889f9a295725e12fae module/talk-config-runtime
9efd666b8c2cc8a9abf816751fd9420f3c048d158e48570cae8b7a4cbc52a83f module/target-resolver-runtime
cb77f0dcdd4f360cbc5efbb657f57f8ed5840e66adc903a23385ba6d42ca0101 module/telegram-account
da05dc1506e226fc25285a2963bd8d96dd0e882d6baa24b8073a0c53fef9fc81 module/telegram-account
7729f9f201c08f114925da75ee86a5a8deb6677e5d1b5ee86bc60aacaa01f63b module/telegram-command-config
110944726884fca94f38c9c329b5950629438b9a719f4782c4beeade8bd67746 module/temp-path
a65f17db3d04c2ca1b34f9a4ebe8748952bbcb00318b741adbe3f227d2e2de20 module/text-autolink-runtime
+5 -5
View File
@@ -23,7 +23,7 @@
"@mistralai/mistralai": "2.4.0",
"@modelcontextprotocol/sdk": "1.29.0",
"@mozilla/readability": "0.6.0",
"@openclaw/fs-safe": "0.4.1",
"@openclaw/fs-safe": "0.4.4",
"@openclaw/proxyline": "0.3.3",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2",
@@ -434,16 +434,16 @@
}
},
"node_modules/@openclaw/fs-safe": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@openclaw/fs-safe/-/fs-safe-0.4.1.tgz",
"integrity": "sha512-hQi+BxO10KdRFlYUot1syC+hTaUnGeQNdqX5kwkKJig8CFq1tKsYJLPm+zkiiGsSKOprPAquQl/txejEhpKPgg==",
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/@openclaw/fs-safe/-/fs-safe-0.4.4.tgz",
"integrity": "sha512-QklFGshaQDXl7RFyxPeSN/1moYq/X+SJcmX/nY3oXJO/tzVasL9i1g3m4nJqIfd6qUTJQjeu+41aIqw8+SbUww==",
"license": "MIT",
"engines": {
"node": ">=22"
},
"optionalDependencies": {
"jszip": "^3.10.1",
"tar": "7.5.19"
"tar": "7.5.20"
}
},
"node_modules/@openclaw/proxyline": {
+1 -1
View File
@@ -2055,7 +2055,7 @@
"@modelcontextprotocol/sdk": "1.29.0",
"@mozilla/readability": "0.6.0",
"@openclaw/ai": "workspace:*",
"@openclaw/fs-safe": "0.4.1",
"@openclaw/fs-safe": "0.4.4",
"@openclaw/proxyline": "0.3.3",
"@silvia-odwyer/photon-node": "0.3.4",
"chalk": "5.6.2",
-72
View File
@@ -1,72 +0,0 @@
diff --git a/dist/pinned-python.js b/dist/pinned-python.js
index c6177eaf56295780ef2edba47ca8bee02cdcdbf2..f6f361974d48f8f4abd3f1e47f1fdb10bc3f2f77 100644
--- a/dist/pinned-python.js
+++ b/dist/pinned-python.js
@@ -93,6 +93,12 @@ def write_all(fd, data):
if written <= 0:
raise OSError(errno.EIO, "short write")
view = view[written:]
+def fsync_best_effort(fd):
+ try:
+ os.fsync(fd)
+ except OSError as error:
+ if error.errno != errno.EPERM:
+ raise
def link_unsupported(exc):
unsupported = (errno.EPERM, errno.EOPNOTSUPP, getattr(errno, "ENOTSUP", errno.EOPNOTSUPP))
return getattr(exc, "errno", None) in unsupported
@@ -298,13 +304,13 @@ def write_path(root_fd, payload):
temp_name, temp_fd = create_temp_file(parent_fd, basename, mode)
os.fchmod(temp_fd, mode)
write_all(temp_fd, data)
- os.fsync(temp_fd)
+ fsync_best_effort(temp_fd)
temp_stat = os.fstat(temp_fd)
os.close(temp_fd)
temp_fd = None
result_stat = commit_temp_file(parent_fd, temp_name, basename, overwrite, mode, temp_stat)
temp_name = None
- os.fsync(parent_fd)
+ fsync_best_effort(parent_fd)
return {"dev": result_stat.st_dev, "ino": result_stat.st_ino}
finally:
if temp_fd is not None:
diff --git a/dist/pinned-write.js b/dist/pinned-write.js
index 77d0ddfa5a1df2de0449c806d19781daaed4910b..09dd77aef95659865b93ce29e15f26e874dd0c25 100644
--- a/dist/pinned-write.js
+++ b/dist/pinned-write.js
@@ -31,6 +31,16 @@ function assertWithinMaxBytes(bytes, maxBytes) {
throw new FsSafeError("too-large", `file exceeds limit of ${maxBytes} bytes (got at least ${bytes})`);
}
}
+async function syncFileBestEffort(handle) {
+ try {
+ await handle.sync();
+ }
+ catch (error) {
+ if (error?.code !== "EPERM") {
+ throw error;
+ }
+ }
+}
async function writeStreamToHandle(stream, handle, maxBytes) {
let bytes = 0;
for await (const chunk of stream) {
@@ -192,7 +202,7 @@ async function runPinnedWriteFallback(params) {
else {
await writeStreamToHandle(params.input.stream, handle, params.maxBytes);
}
- await handle.sync();
+ await syncFileBestEffort(handle);
const stat = await handle.stat();
await handle.close().catch(() => undefined);
await syncDirectoryBestEffort(parentPath);
@@ -237,7 +247,7 @@ async function runPinnedWriteFallback(params) {
throw new FsSafeError("path-mismatch", "fallback temp path changed during write");
}
const expectedTempStat = tempStat;
- await handle.sync();
+ await syncFileBestEffort(handle);
await handle.close().catch(() => undefined);
handle = undefined;
await withAsyncDirectoryGuards([parentGuard], async () => {
+5 -8
View File
@@ -36,9 +36,6 @@ overrides:
packageExtensionsChecksum: sha256-zZ8fyodhMTumshonC7kktCqTPsiHL3UAyS9vltFAlMo=
patchedDependencies:
'@openclaw/fs-safe@0.4.1': e49004277fcb3e714125baf15a996682c7310e8e9269c865cc5bcb9fef46a57c
importers:
.:
@@ -86,8 +83,8 @@ importers:
specifier: workspace:*
version: link:packages/ai
'@openclaw/fs-safe':
specifier: 0.4.1
version: 0.4.1(patch_hash=e49004277fcb3e714125baf15a996682c7310e8e9269c865cc5bcb9fef46a57c)
specifier: 0.4.4
version: 0.4.4
'@openclaw/proxyline':
specifier: 0.3.3
version: 0.3.3(undici@8.5.0)
@@ -3702,8 +3699,8 @@ packages:
engines: {node: '>=22'}
hasBin: true
'@openclaw/fs-safe@0.4.1':
resolution: {integrity: sha512-hQi+BxO10KdRFlYUot1syC+hTaUnGeQNdqX5kwkKJig8CFq1tKsYJLPm+zkiiGsSKOprPAquQl/txejEhpKPgg==}
'@openclaw/fs-safe@0.4.4':
resolution: {integrity: sha512-QklFGshaQDXl7RFyxPeSN/1moYq/X+SJcmX/nY3oXJO/tzVasL9i1g3m4nJqIfd6qUTJQjeu+41aIqw8+SbUww==}
engines: {node: '>=22'}
'@openclaw/libterminal@0.3.2':
@@ -10072,7 +10069,7 @@ snapshots:
- bufferutil
- utf-8-validate
'@openclaw/fs-safe@0.4.1(patch_hash=e49004277fcb3e714125baf15a996682c7310e8e9269c865cc5bcb9fef46a57c)':
'@openclaw/fs-safe@0.4.4':
optionalDependencies:
jszip: 3.10.1
tar: 7.5.19
+1 -2
View File
@@ -9,7 +9,7 @@ minimumReleaseAge: 2880
minimumReleaseAgeExclude:
- "@openclaw/crabline@0.1.11"
- "@openclaw/fs-safe@0.4.1"
- "@openclaw/fs-safe@0.4.4"
- "@openclaw/libterminal@0.3.2"
- "@openclaw/proxyline@0.3.3"
- "@openclaw/uirouter@0.1.0"
@@ -155,4 +155,3 @@ packageExtensions:
optional: true
patchedDependencies:
"@openclaw/fs-safe@0.4.1": patches/@openclaw__fs-safe@0.4.1.patch
-2
View File
@@ -8,8 +8,6 @@ import { fileURLToPath } from "node:url";
import YAML from "yaml";
const ALLOWED_PATCHED_DEPENDENCIES = new Map([
// Remove after fs-safe ships pinned-write fsync with best-effort EPERM handling.
["@openclaw/fs-safe@0.4.1", "patches/@openclaw__fs-safe@0.4.1.patch"],
["baileys@7.0.0-rc12", "patches/baileys@7.0.0-rc12.patch"],
["baileys@7.0.0-rc13", "patches/baileys@7.0.0-rc13.patch"],
]);
+1 -1
View File
@@ -4,7 +4,7 @@ import path from "node:path";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { openRootFileSync } from "../infra/boundary-file-read.js";
import { readFileDescriptorBoundedSync } from "../infra/file-descriptor-read.js";
import { readFileDescriptorBoundedSync } from "../infra/boundary-file-read.js";
import { isRenderableAvatarImageDataUrl } from "../shared/avatar-limits.js";
import {
AVATAR_MAX_BYTES,
+1 -1
View File
@@ -1,4 +1,4 @@
import { readFileDescriptorBounded } from "../infra/file-descriptor-read.js";
import { readFileDescriptorBounded } from "../infra/boundary-file-read.js";
// Workspace bootstrap files are model context, not arbitrary attachments. Keep every
// read path on the same bound so compaction and sandbox copies cannot bypass it.
+1 -1
View File
@@ -5,8 +5,8 @@ import {
normalizeStringifiedOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import JSON5 from "json5";
import { readFileDescriptorBoundedSync } from "../infra/boundary-file-read.js";
import { hasErrnoCode } from "../infra/errors.js";
import { readFileDescriptorBoundedSync } from "../infra/file-descriptor-read.js";
export type ConfigSetOptions = {
strictJson?: boolean;
+1 -1
View File
@@ -20,6 +20,7 @@ import { getTerminalTableWidth, renderTable } from "../../packages/terminal-core
import { isRich, theme } from "../../packages/terminal-core/src/theme.js";
import { readBestEffortConfig, type OpenClawConfig } from "../config/config.js";
import { ADMIN_SCOPE, APPROVALS_SCOPE, type OperatorScope } from "../gateway/method-scopes.js";
import { readFileDescriptorBounded } from "../infra/boundary-file-read.js";
import { formatErrorMessage } from "../infra/errors.js";
import {
collectExecPolicyScopeSnapshots,
@@ -35,7 +36,6 @@ import {
type ExecApprovalsDefaults,
type ExecApprovalsFile,
} from "../infra/exec-approvals.js";
import { readFileDescriptorBounded } from "../infra/file-descriptor-read.js";
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
import { defaultRuntime } from "../runtime.js";
import { callGatewayFromCli } from "./gateway-rpc.js";
+1 -1
View File
@@ -64,7 +64,7 @@ async function readPlanFile(pathname: string): Promise<SecretsApplyPlan> {
// Apply consumes a generated plan shape, not arbitrary JSON.
const [fsModule, { readFileDescriptorBounded }, { isSecretsApplyPlan }] = await Promise.all([
fsModuleLoader.load(),
import("../infra/file-descriptor-read.js"),
import("../infra/boundary-file-read.js"),
import("../secrets/plan.js"),
]);
const fsConstants = fsModule.constants as typeof fsModule.constants & { O_NONBLOCK?: number };
+1 -1
View File
@@ -28,7 +28,7 @@ import {
import { isGatewaySecretRefUnavailableError } from "../gateway/credentials.js";
import { ADMIN_SCOPE } from "../gateway/operator-scopes.js";
import { createAbortError } from "../infra/abort-signal.js";
import { readFileDescriptorBounded } from "../infra/file-descriptor-read.js";
import { readFileDescriptorBounded } from "../infra/boundary-file-read.js";
import { parseStrictNonNegativeInteger } from "../infra/parse-finite-number.js";
import { routeLogsToStderr } from "../logging/console.js";
import {
+1 -1
View File
@@ -13,13 +13,13 @@ import {
} from "../agents/identity-avatar.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { matchRootFileOpenFailure, openRootFileSync } from "../infra/boundary-file-read.js";
import { readFileDescriptorBounded } from "../infra/boundary-file-read.js";
import {
isPackageProvenControlUiRootSync,
resolveControlUiRootSync,
} from "../infra/control-ui-assets.js";
import { resolveDevInstallGitBranch } from "../infra/dev-install-branch.js";
import { listDevicePairing, verifyDeviceToken } from "../infra/device-pairing.js";
import { readFileDescriptorBounded } from "../infra/file-descriptor-read.js";
import { openLocalFileSafely, FsSafeError } from "../infra/fs-safe.js";
import { safeFileURLToPath } from "../infra/local-file-access.js";
import { verifyPairingToken } from "../infra/pairing-token.js";
+1 -1
View File
@@ -5,7 +5,7 @@ import { normalizeTrimmedStringList } from "@openclaw/normalization-core/string-
import { MANIFEST_KEY } from "../compat/legacy-names.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { openRootFileSync } from "../infra/boundary-file-read.js";
import { readFileDescriptorBoundedSync } from "../infra/file-descriptor-read.js";
import { readFileDescriptorBoundedSync } from "../infra/boundary-file-read.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { isPathInsideWithRealpath } from "../security/scan-paths.js";
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
+30 -1
View File
@@ -1,8 +1,13 @@
// Tests safe boundary file reads against upstream fs-safe behavior.
import fs from "node:fs";
import path from "node:path";
import * as upstream from "@openclaw/fs-safe/advanced";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import * as shim from "./boundary-file-read.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
describe("root file open shim", () => {
it("re-exports the fs-safe root file helpers", () => {
expect(shim.canUseRootFileOpen).toBe(upstream.canUseRootFileOpen);
@@ -10,4 +15,28 @@ describe("root file open shim", () => {
expect(shim.openRootFile).toBe(upstream.openRootFile);
expect(shim.openRootFileSync).toBe(upstream.openRootFileSync);
});
it("preserves the existing overflow error for fs-safe descriptor reads", async () => {
const dir = tempDirs.make("openclaw-boundary-file-read-");
const filePath = path.join(dir, "oversized.txt");
fs.writeFileSync(filePath, "oversized");
const asyncFd = fs.openSync(filePath, "r");
try {
await expect(shim.readFileDescriptorBounded(asyncFd, 4)).rejects.toThrow(
new RangeError("File exceeds 4 bytes"),
);
} finally {
fs.closeSync(asyncFd);
}
const syncFd = fs.openSync(filePath, "r");
try {
expect(() => shim.readFileDescriptorBoundedSync(syncFd, 4)).toThrow(
new RangeError("File exceeds 4 bytes"),
);
} finally {
fs.closeSync(syncFd);
}
});
});
+30
View File
@@ -1,5 +1,10 @@
// Exposes root-scoped file open helpers with fs-safe defaults.
import "./fs-safe-defaults.js";
import {
readFileDescriptorBounded as readFileDescriptorBoundedFsSafe,
readFileDescriptorBoundedSync as readFileDescriptorBoundedSyncFsSafe,
} from "@openclaw/fs-safe/advanced";
import { FsSafeError } from "@openclaw/fs-safe/errors";
// Root-scoped file open helpers. Use these for user paths that must stay under
// an already trusted boundary.
@@ -11,3 +16,28 @@ export {
type RootFileOpenFailure,
type RootFileOpenResult,
} from "@openclaw/fs-safe/advanced";
function preserveOpenClawOverflowError(error: unknown, maxBytes: number): never {
if (error instanceof FsSafeError && error.code === "too-large") {
throw new RangeError(`File exceeds ${maxBytes} bytes`, { cause: error });
}
throw error;
}
/** Read a pinned descriptor without changing OpenClaw's user-facing overflow error. */
export async function readFileDescriptorBounded(fd: number, maxBytes: number): Promise<Buffer> {
try {
return await readFileDescriptorBoundedFsSafe(fd, maxBytes);
} catch (error) {
return preserveOpenClawOverflowError(error, maxBytes);
}
}
/** Synchronous variant for callers that own a pinned descriptor. */
export function readFileDescriptorBoundedSync(fd: number, maxBytes: number): Buffer {
try {
return readFileDescriptorBoundedSyncFsSafe(fd, maxBytes);
} catch (error) {
return preserveOpenClawOverflowError(error, maxBytes);
}
}
-63
View File
@@ -1,63 +0,0 @@
// Bounded reads for file descriptors already pinned by a boundary open.
import fs from "node:fs";
const READ_CHUNK_BYTES = 64 * 1024;
function createScratchBuffer(maxBytes: number): Buffer {
return Buffer.allocUnsafe(Math.min(READ_CHUNK_BYTES, Math.max(1, maxBytes + 1)));
}
function appendChunk(params: {
chunks: Buffer[];
scratch: Buffer;
bytesRead: number;
total: number;
maxBytes: number;
}): number {
const total = params.total + params.bytesRead;
if (total > params.maxBytes) {
throw new RangeError(`File exceeds ${params.maxBytes} bytes`);
}
params.chunks.push(Buffer.from(params.scratch.subarray(0, params.bytesRead)));
return total;
}
/** Read at most maxBytes from the descriptor without an unbounded allocation. */
export function readFileDescriptorBoundedSync(fd: number, maxBytes: number): Buffer {
const chunks: Buffer[] = [];
const scratch = createScratchBuffer(maxBytes);
let total = 0;
while (true) {
const bytesRead = fs.readSync(fd, scratch, 0, scratch.length, null);
if (bytesRead === 0) {
return Buffer.concat(chunks, total);
}
total = appendChunk({ chunks, scratch, bytesRead, total, maxBytes });
}
}
function readChunk(fd: number, scratch: Buffer): Promise<number> {
return new Promise((resolve, reject) => {
fs.read(fd, scratch, 0, scratch.length, null, (error, bytesRead) => {
if (error) {
reject(error);
return;
}
resolve(bytesRead);
});
});
}
/** Async variant for request paths; caller retains descriptor ownership. */
export async function readFileDescriptorBounded(fd: number, maxBytes: number): Promise<Buffer> {
const chunks: Buffer[] = [];
const scratch = createScratchBuffer(maxBytes);
let total = 0;
while (true) {
const bytesRead = await readChunk(fd, scratch);
if (bytesRead === 0) {
return Buffer.concat(chunks, total);
}
total = appendChunk({ chunks, scratch, bytesRead, total, maxBytes });
}
}
+5 -5
View File
@@ -656,12 +656,12 @@ describe("secret ref resolver", () => {
const sampleHandle = await fs.open(filePath, "r");
const fileHandlePrototype = Object.getPrototypeOf(sampleHandle) as {
readFile: typeof sampleHandle.readFile;
read: typeof sampleHandle.read;
};
await sampleHandle.close();
const readFileSpy = vi
.spyOn(fileHandlePrototype, "readFile")
.mockImplementation(() => new Promise<Buffer>(() => {}) as never);
const readSpy = vi
.spyOn(fileHandlePrototype, "read")
.mockImplementation(() => new Promise(() => {}) as never);
try {
await expect(
@@ -681,7 +681,7 @@ describe("secret ref resolver", () => {
),
).rejects.toThrow('File provider "filemain" timed out');
} finally {
readFileSpy.mockRestore();
readSpy.mockRestore();
}
});
@@ -60,7 +60,6 @@ describe("check-package-patches", () => {
`packages:
- .
patchedDependencies:
"@openclaw/fs-safe@0.4.1": "patches/@openclaw__fs-safe@0.4.1.patch"
"baileys@7.0.0-rc12": "patches/baileys@7.0.0-rc12.patch"
`,
"utf8",
@@ -69,13 +68,11 @@ patchedDependencies:
path.join(dir, "pnpm-lock.yaml"),
`lockfileVersion: '9.0'
patchedDependencies:
"@openclaw/fs-safe@0.4.1": fs-safe-hash
baileys@7.0.0-rc12: a9aea1790d2c65b1ae543c77faca4119bbfb91ee3b6ca6c38d1cad4f5702ada2
`,
"utf8",
);
writeFileSync(path.join(dir, "patches", "baileys@7.0.0-rc12.patch"), "diff\n", "utf8");
writeFileSync(path.join(dir, "patches", "@openclaw__fs-safe@0.4.1.patch"), "diff\n", "utf8");
git(dir, ["add", "pnpm-workspace.yaml", "pnpm-lock.yaml", "patches"]);
expect(collectPackagePatchViolations(dir)).toEqual([]);