mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 14:24:47 +00:00
[codex] Extract filesystem safety primitives (#77918)
* refactor: extract filesystem safety primitives * refactor: use fs-safe for file access helpers * refactor: reuse fs-safe for media reads * refactor: use fs-safe for image reads * refactor: reuse fs-safe in qqbot media opener * refactor: reuse fs-safe for local media checks * refactor: consume cleaner fs-safe api * refactor: align fs-safe json option names * fix: preserve fs-safe migration contracts * refactor: use fs-safe primitive subpaths * refactor: use grouped fs-safe subpaths * refactor: align fs-safe api usage * refactor: adapt private state store api * chore: refresh proof gate * refactor: follow fs-safe json api split * refactor: follow reduced fs-safe surface * build: default fs-safe python helper off * fix: preserve fs-safe plugin sdk aliases * refactor: consolidate fs-safe usage * refactor: unify fs-safe store usage * refactor: trim fs-safe temp workspace usage * refactor: hide low-level fs-safe primitives * build: use published fs-safe package * fix: preserve outbound recovery durability after rebase * chore: refresh pr checks
This commit is contained in:
committed by
GitHub
parent
61481eb34f
commit
538605ff44
@@ -3,7 +3,7 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
|
||||
import { openRootFileSync } from "../infra/boundary-file-read.js";
|
||||
import type { PluginManifestRecord } from "../plugins/manifest-registry.js";
|
||||
import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
|
||||
import {
|
||||
@@ -122,7 +122,7 @@ function loadExternalChannelSecretContractFromRecord(
|
||||
if (!contractPath) {
|
||||
return undefined;
|
||||
}
|
||||
const opened = openBoundaryFileSync({
|
||||
const opened = openRootFileSync({
|
||||
absolutePath: contractPath,
|
||||
rootPath: record.rootDir,
|
||||
boundaryLabel: "plugin root",
|
||||
|
||||
@@ -400,16 +400,14 @@ describe("secret ref resolver", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const originalReadFile = fs.readFile.bind(fs);
|
||||
const readFileSpy = vi.spyOn(fs, "readFile").mockImplementation(((
|
||||
targetPath: Parameters<typeof fs.readFile>[0],
|
||||
options?: Parameters<typeof fs.readFile>[1],
|
||||
) => {
|
||||
if (typeof targetPath === "string" && targetPath === filePath) {
|
||||
return new Promise<Buffer>(() => {});
|
||||
}
|
||||
return originalReadFile(targetPath, options);
|
||||
}) as typeof fs.readFile);
|
||||
const sampleHandle = await fs.open(filePath, "r");
|
||||
const fileHandlePrototype = Object.getPrototypeOf(sampleHandle) as {
|
||||
readFile: typeof sampleHandle.readFile;
|
||||
};
|
||||
await sampleHandle.close();
|
||||
const readFileSpy = vi
|
||||
.spyOn(fileHandlePrototype, "readFile")
|
||||
.mockImplementation(() => new Promise<Buffer>(() => {}) as never);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
SecretRefSource,
|
||||
} from "../config/types.secrets.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { FsSafeError, readSecureFile } from "../infra/fs-safe.js";
|
||||
import { inspectPathPermissions, safeStat } from "../security/audit-fs.js";
|
||||
import { isPathInside } from "../security/scan-paths.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
@@ -283,33 +284,18 @@ async function readFileProviderPayload(params: {
|
||||
|
||||
const filePath = resolveUserPath(params.providerConfig.path);
|
||||
const readPromise = (async () => {
|
||||
const secureFilePath = await assertSecurePath({
|
||||
targetPath: filePath,
|
||||
label: `secrets.providers.${params.providerName}.path`,
|
||||
allowInsecurePath: params.providerConfig.allowInsecurePath,
|
||||
});
|
||||
const timeoutMs = normalizePositiveInt(
|
||||
params.providerConfig.timeoutMs,
|
||||
DEFAULT_FILE_TIMEOUT_MS,
|
||||
);
|
||||
const maxBytes = normalizePositiveInt(params.providerConfig.maxBytes, DEFAULT_FILE_MAX_BYTES);
|
||||
const abortController = new AbortController();
|
||||
const timeoutErrorMessage = `File provider "${params.providerName}" timed out after ${timeoutMs}ms.`;
|
||||
let timeoutHandle: NodeJS.Timeout | null = null;
|
||||
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
abortController.abort();
|
||||
reject(new Error(timeoutErrorMessage));
|
||||
}, timeoutMs);
|
||||
});
|
||||
try {
|
||||
const payload = await Promise.race([
|
||||
fs.readFile(secureFilePath, { signal: abortController.signal }),
|
||||
timeoutPromise,
|
||||
]);
|
||||
if (payload.byteLength > maxBytes) {
|
||||
throw new Error(`File provider "${params.providerName}" exceeded maxBytes (${maxBytes}).`);
|
||||
}
|
||||
const { buffer: payload } = await readSecureFile({
|
||||
filePath,
|
||||
label: `secrets.providers.${params.providerName}.path`,
|
||||
io: { maxBytes, timeoutMs },
|
||||
permissions: { allowInsecure: params.providerConfig.allowInsecurePath },
|
||||
});
|
||||
const text = payload.toString("utf8").replace(/^\uFEFF/, "");
|
||||
if (params.providerConfig.mode === "singleValue") {
|
||||
return text.replace(/\r?\n$/, "");
|
||||
@@ -320,14 +306,12 @@ async function readFileProviderPayload(params: {
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new Error(timeoutErrorMessage, { cause: error });
|
||||
if (error instanceof FsSafeError && error.code === "timeout") {
|
||||
throw new Error(`File provider "${params.providerName}" timed out after ${timeoutMs}ms.`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { privateFileStoreSync } from "../infra/private-file-store.js";
|
||||
import { replaceFileAtomicSync } from "../infra/replace-file.js";
|
||||
export { isRecord } from "../utils.js";
|
||||
|
||||
export function isNonEmptyString(value: unknown): value is string {
|
||||
@@ -40,9 +42,9 @@ export function ensureDirForFile(filePath: string): void {
|
||||
}
|
||||
|
||||
export function writeJsonFileSecure(pathname: string, value: unknown): void {
|
||||
ensureDirForFile(pathname);
|
||||
fs.writeFileSync(pathname, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
fs.chmodSync(pathname, 0o600);
|
||||
privateFileStoreSync(path.dirname(pathname)).writeJson(path.basename(pathname), value, {
|
||||
trailingNewline: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function readTextFileIfExists(pathname: string): string | null {
|
||||
@@ -53,9 +55,14 @@ export function readTextFileIfExists(pathname: string): string | null {
|
||||
}
|
||||
|
||||
export function writeTextFileAtomic(pathname: string, value: string, mode = 0o600): void {
|
||||
ensureDirForFile(pathname);
|
||||
const tempPath = `${pathname}.tmp-${process.pid}-${Date.now()}`;
|
||||
fs.writeFileSync(tempPath, value, "utf8");
|
||||
fs.chmodSync(tempPath, mode);
|
||||
fs.renameSync(tempPath, pathname);
|
||||
if (mode !== 0o600) {
|
||||
replaceFileAtomicSync({
|
||||
filePath: pathname,
|
||||
content: value,
|
||||
mode,
|
||||
tempPrefix: ".openclaw-secrets",
|
||||
});
|
||||
return;
|
||||
}
|
||||
privateFileStoreSync(path.dirname(pathname)).writeText(path.basename(pathname), value);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user