[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:
Peter Steinberger
2026-05-06 02:15:17 +01:00
committed by GitHub
parent 61481eb34f
commit 538605ff44
356 changed files with 4918 additions and 11913 deletions

View File

@@ -1,148 +1,2 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
export type MovePathToTrashOptions = {
allowedRoots?: Iterable<string>;
};
const TRASH_DESTINATION_COLLISION_CODES = new Set(["EEXIST", "ENOTEMPTY", "ERR_FS_CP_EEXIST"]);
const TRASH_DESTINATION_RETRY_LIMIT = 4;
function getFsErrorCode(error: unknown): string | undefined {
if (!error || typeof error !== "object" || !("code" in error)) {
return undefined;
}
const code = (error as NodeJS.ErrnoException).code;
return typeof code === "string" ? code : undefined;
}
function isTrashDestinationCollision(error: unknown): boolean {
const code = getFsErrorCode(error);
return Boolean(code && TRASH_DESTINATION_COLLISION_CODES.has(code));
}
function isSameOrChildPath(candidate: string, parent: string): boolean {
return candidate === parent || candidate.startsWith(`${parent}${path.sep}`);
}
function resolveAllowedTrashRoots(allowedRoots?: Iterable<string>): string[] {
const roots = [...(allowedRoots ?? [os.homedir(), os.tmpdir()])].map((root) => {
try {
return path.resolve(fs.realpathSync.native(root));
} catch {
return path.resolve(root);
}
});
return [...new Set(roots)];
}
function assertAllowedTrashTarget(targetPath: string, allowedRoots?: Iterable<string>): void {
let resolvedTargetPath = path.resolve(targetPath);
try {
resolvedTargetPath = path.resolve(fs.realpathSync.native(targetPath));
} catch {
// The subsequent move will surface missing or inaccessible targets.
}
const isAllowed = resolveAllowedTrashRoots(allowedRoots).some(
(root) => resolvedTargetPath !== root && isSameOrChildPath(resolvedTargetPath, root),
);
if (!isAllowed) {
throw new Error(`Refusing to trash path outside allowed roots: ${targetPath}`);
}
}
function resolveTrashDir(): string {
const homeDir = os.homedir();
const trashDir = path.join(homeDir, ".Trash");
fs.mkdirSync(trashDir, { recursive: true, mode: 0o700 });
const trashDirStat = fs.lstatSync(trashDir);
if (!trashDirStat.isDirectory() || trashDirStat.isSymbolicLink()) {
throw new Error(`Refusing to use non-directory/symlink trash directory: ${trashDir}`);
}
const realHome = path.resolve(fs.realpathSync.native(homeDir));
const resolvedTrashDir = path.resolve(fs.realpathSync.native(trashDir));
if (resolvedTrashDir === realHome || !isSameOrChildPath(resolvedTrashDir, realHome)) {
throw new Error(`Trash directory escaped home directory: ${trashDir}`);
}
return resolvedTrashDir;
}
function trashBaseName(targetPath: string): string {
const resolvedTargetPath = path.resolve(targetPath);
if (resolvedTargetPath === path.parse(resolvedTargetPath).root) {
throw new Error(`Refusing to trash root path: ${targetPath}`);
}
const base = path.basename(resolvedTargetPath).replace(/[\\/]+/g, "");
if (!base) {
throw new Error(`Unable to derive safe trash basename for: ${targetPath}`);
}
return base;
}
function resolveContainedPath(root: string, leaf: string): string {
const resolvedRoot = path.resolve(root);
const resolvedPath = path.resolve(resolvedRoot, leaf);
if (!isSameOrChildPath(resolvedPath, resolvedRoot) || resolvedPath === resolvedRoot) {
throw new Error(`Trash destination escaped trash directory: ${resolvedPath}`);
}
return resolvedPath;
}
function reserveTrashDestination(trashDir: string, base: string, timestamp: number): string {
const containerPrefix = resolveContainedPath(trashDir, `${base}-${timestamp}-`);
const container = fs.mkdtempSync(containerPrefix);
const resolvedContainer = path.resolve(container);
const resolvedTrashDir = path.resolve(trashDir);
if (
resolvedContainer === resolvedTrashDir ||
!isSameOrChildPath(resolvedContainer, resolvedTrashDir)
) {
throw new Error(`Trash destination escaped trash directory: ${container}`);
}
return resolveContainedPath(container, base);
}
function movePathToDestination(targetPath: string, dest: string): boolean {
try {
fs.renameSync(targetPath, dest);
return true;
} catch (error) {
if (getFsErrorCode(error) !== "EXDEV") {
if (isTrashDestinationCollision(error)) {
return false;
}
throw error;
}
}
try {
fs.cpSync(targetPath, dest, { recursive: true, force: false, errorOnExist: true });
fs.rmSync(targetPath, { recursive: true, force: false });
return true;
} catch (error) {
if (isTrashDestinationCollision(error)) {
return false;
}
throw error;
}
}
export async function movePathToTrash(
targetPath: string,
options: MovePathToTrashOptions = {},
): Promise<string> {
// Avoid resolving external trash helpers through the service PATH during cleanup.
const base = trashBaseName(targetPath);
assertAllowedTrashTarget(targetPath, options.allowedRoots);
const trashDir = resolveTrashDir();
const timestamp = Date.now();
for (let attempt = 0; attempt < TRASH_DESTINATION_RETRY_LIMIT; attempt += 1) {
const dest = reserveTrashDestination(trashDir, base, timestamp);
if (movePathToDestination(targetPath, dest)) {
return dest;
}
}
throw new Error(`Unable to choose a unique trash destination for ${targetPath}`);
}
import "../infra/fs-safe-defaults.js";
export { movePathToTrash, type MovePathToTrashOptions } from "@openclaw/fs-safe/advanced";

View File

@@ -324,7 +324,7 @@ describe("loadBundledEntryExportSync", () => {
const jitiLoad = vi.fn(() => ({ load: 42 }));
const createJiti = vi.fn(() => jitiLoad);
vi.doMock("../infra/boundary-file-read.js", () => ({
openBoundaryFileSync: () => ({
openRootFileSync: () => ({
ok: true,
path: "C:\\Users\\alice\\openclaw\\dist\\extensions\\feishu\\helper.ts",
fd: fs.openSync(openedFdPath, "r"),

View File

@@ -7,7 +7,7 @@ import type { ChannelConfigSchema } from "../channels/plugins/types.config.js";
import type { ChannelLegacyStateMigrationPlan } from "../channels/plugins/types.core.js";
import type { ChannelPlugin } from "../channels/plugins/types.plugin.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 {
createProfiler,
formatPluginLoadProfileLine,
@@ -262,7 +262,7 @@ function formatBundledEntryModuleOpenFailure(params: {
specifier: string;
resolvedPath: string;
boundaryRoot: string;
failure: Extract<ReturnType<typeof openBoundaryFileSync>, { ok: false }>;
failure: Extract<ReturnType<typeof openRootFileSync>, { ok: false }>;
}): string {
const importerPath = fileURLToPath(params.importMetaUrl);
const errorDetail =
@@ -286,11 +286,11 @@ function resolveBundledEntryModulePath(importMetaUrl: string, specifier: string)
let firstFailure: {
candidate: BundledEntryModuleCandidate;
failure: Extract<ReturnType<typeof openBoundaryFileSync>, { ok: false }>;
failure: Extract<ReturnType<typeof openRootFileSync>, { ok: false }>;
} | null = null;
for (const candidate of candidates) {
const opened = openBoundaryFileSync({
const opened = openRootFileSync({
absolutePath: candidate.path,
rootPath: candidate.boundaryRoot,
boundaryLabel: "plugin root",

View File

@@ -2,7 +2,7 @@ import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
import { openRootFileSync } from "../infra/boundary-file-read.js";
import { resolveBundledPluginsDir } from "../plugins/bundled-dir.js";
import {
getCachedPluginModuleLoader,
@@ -145,7 +145,7 @@ export function loadFacadeModuleAtLocationSync<T extends object>(params: {
return cached as T;
}
const opened = openBoundaryFileSync({
const opened = openRootFileSync({
absolutePath: location.modulePath,
rootPath: location.boundaryRoot,
boundaryLabel:
@@ -224,7 +224,7 @@ export async function loadBundledPluginPublicSurfaceModule<T extends object>(par
return cached as T;
}
const opened = openBoundaryFileSync({
const opened = openRootFileSync({
absolutePath: preparedLocation.modulePath,
rootPath: preparedLocation.boundaryRoot,
boundaryLabel:

View File

@@ -1,4 +1,9 @@
// Safe local-file helpers for plugin runtime media and bridge code.
export { readFileWithinRoot, writeFileWithinRoot } from "../infra/fs-safe.js";
export {
readFileWithinRoot,
readLocalFileFromRoots,
root,
writeFileWithinRoot,
} from "../infra/fs-safe.js";
export { basenameFromMediaSource, safeFileURLToPath } from "../infra/local-file-access.js";

View File

@@ -1,8 +1,10 @@
import fsSync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import "../infra/fs-safe-defaults.js";
import {
acquireFileLock as acquireFsSafeFileLock,
drainFileLockManagerForTest,
resetFileLockManagerForTest,
} from "@openclaw/fs-safe/file-lock";
import { isPidAlive } from "../shared/pid-alive.js";
import { resolveProcessScopedMap } from "../shared/process-scoped-map.js";
export type FileLockOptions = {
retries: {
@@ -16,108 +18,10 @@ export type FileLockOptions = {
};
type LockFilePayload = {
pid: number;
createdAt: string;
pid?: number;
createdAt?: string;
};
type HeldLock = {
count: number;
handle: fs.FileHandle;
lockPath: string;
};
const HELD_LOCKS_KEY = Symbol.for("openclaw.fileLockHeldLocks");
const HELD_LOCKS = resolveProcessScopedMap<HeldLock>(HELD_LOCKS_KEY);
const CLEANUP_REGISTERED_KEY = Symbol.for("openclaw.fileLockCleanupRegistered");
function releaseAllLocksSync(): void {
for (const [normalizedFile, held] of HELD_LOCKS) {
// Kick off best-effort async closes before dropping references so tests
// don't leave FileHandle objects for GC to close later.
void held.handle.close().catch(() => undefined);
rmLockPathSync(held.lockPath);
HELD_LOCKS.delete(normalizedFile);
}
}
async function drainAllLocks(): Promise<void> {
for (const [normalizedFile, held] of Array.from(HELD_LOCKS.entries())) {
HELD_LOCKS.delete(normalizedFile);
await held.handle.close().catch(() => undefined);
await fs.rm(held.lockPath, { force: true }).catch(() => undefined);
}
}
function rmLockPathSync(lockPath: string): void {
try {
fsSync.rmSync(lockPath, { force: true });
} catch {
// Best-effort exit cleanup only.
}
}
function ensureExitCleanupRegistered(): void {
const proc = process as NodeJS.Process & { [CLEANUP_REGISTERED_KEY]?: boolean };
if (proc[CLEANUP_REGISTERED_KEY]) {
return;
}
proc[CLEANUP_REGISTERED_KEY] = true;
process.on("exit", releaseAllLocksSync);
}
function computeDelayMs(retries: FileLockOptions["retries"], attempt: number): number {
const base = Math.min(
retries.maxTimeout,
Math.max(retries.minTimeout, retries.minTimeout * retries.factor ** attempt),
);
const jitter = retries.randomize ? 1 + Math.random() : 1;
return Math.min(retries.maxTimeout, Math.round(base * jitter));
}
async function readLockPayload(lockPath: string): Promise<LockFilePayload | null> {
try {
const raw = await fs.readFile(lockPath, "utf8");
const parsed = JSON.parse(raw) as Partial<LockFilePayload>;
if (typeof parsed.pid !== "number" || typeof parsed.createdAt !== "string") {
return null;
}
return { pid: parsed.pid, createdAt: parsed.createdAt };
} catch {
return null;
}
}
async function resolveNormalizedFilePath(filePath: string): Promise<string> {
const resolved = path.resolve(filePath);
const dir = path.dirname(resolved);
await fs.mkdir(dir, { recursive: true });
try {
const realDir = await fs.realpath(dir);
return path.join(realDir, path.basename(resolved));
} catch {
return resolved;
}
}
async function isStaleLock(lockPath: string, staleMs: number): Promise<boolean> {
const payload = await readLockPayload(lockPath);
if (payload?.pid && !isPidAlive(payload.pid)) {
return true;
}
if (payload?.createdAt) {
const createdAt = Date.parse(payload.createdAt);
if (!Number.isFinite(createdAt) || Date.now() - createdAt > staleMs) {
return true;
}
}
try {
const stat = await fs.stat(lockPath);
return Date.now() - stat.mtimeMs > staleMs;
} catch {
return true;
}
}
export type FileLockHandle = {
lockPath: string;
release: () => Promise<void>;
@@ -130,37 +34,51 @@ export type FileLockTimeoutError = Error & {
lockPath: string;
};
function createFileLockTimeoutError(
normalizedFile: string,
lockPath: string,
): FileLockTimeoutError {
const error = new Error(`file lock timeout for ${normalizedFile}`);
return Object.assign(error, {
code: FILE_LOCK_TIMEOUT_ERROR_CODE,
lockPath,
}) as FileLockTimeoutError;
const FILE_LOCK_MANAGER_KEY = "openclaw.plugin-sdk.file-lock";
function readLockPayload(value: Record<string, unknown> | null): LockFilePayload | null {
if (!value) {
return null;
}
return {
pid: typeof value.pid === "number" ? value.pid : undefined,
createdAt: typeof value.createdAt === "string" ? value.createdAt : undefined,
};
}
async function releaseHeldLock(normalizedFile: string): Promise<void> {
const current = HELD_LOCKS.get(normalizedFile);
if (!current) {
return;
async function shouldReclaimPluginLock(params: {
lockPath: string;
payload: Record<string, unknown> | null;
staleMs: number;
nowMs: number;
}): Promise<boolean> {
const payload = readLockPayload(params.payload);
if (payload?.pid && !isPidAlive(payload.pid)) {
return true;
}
current.count -= 1;
if (current.count > 0) {
return;
if (payload?.createdAt) {
const createdAt = Date.parse(payload.createdAt);
return !Number.isFinite(createdAt) || params.nowMs - createdAt > params.staleMs;
}
HELD_LOCKS.delete(normalizedFile);
await current.handle.close().catch(() => undefined);
await fs.rm(current.lockPath, { force: true }).catch(() => undefined);
return true;
}
function normalizeTimeoutError(err: unknown): never {
if ((err as { code?: unknown }).code === FILE_LOCK_TIMEOUT_ERROR_CODE) {
throw Object.assign(new Error((err as Error).message), {
code: FILE_LOCK_TIMEOUT_ERROR_CODE,
lockPath: (err as { lockPath?: string }).lockPath ?? "",
}) as FileLockTimeoutError;
}
throw err;
}
export function resetFileLockStateForTest(): void {
releaseAllLocksSync();
resetFileLockManagerForTest(FILE_LOCK_MANAGER_KEY, FILE_LOCK_MANAGER_KEY);
}
export async function drainFileLockStateForTest(): Promise<void> {
await drainAllLocks();
await drainFileLockManagerForTest(FILE_LOCK_MANAGER_KEY, FILE_LOCK_MANAGER_KEY);
}
/** Acquire a re-entrant process-local file lock backed by a `.lock` sidecar file. */
@@ -168,53 +86,19 @@ export async function acquireFileLock(
filePath: string,
options: FileLockOptions,
): Promise<FileLockHandle> {
ensureExitCleanupRegistered();
const normalizedFile = await resolveNormalizedFilePath(filePath);
const lockPath = `${normalizedFile}.lock`;
const held = HELD_LOCKS.get(normalizedFile);
if (held) {
held.count += 1;
return {
lockPath,
release: () => releaseHeldLock(normalizedFile),
};
try {
const lock = await acquireFsSafeFileLock(filePath, {
managerKey: FILE_LOCK_MANAGER_KEY,
staleMs: options.stale,
retry: options.retries,
allowReentrant: true,
payload: () => ({ pid: process.pid, createdAt: new Date().toISOString() }),
shouldReclaim: shouldReclaimPluginLock,
});
return { lockPath: lock.lockPath, release: lock.release };
} catch (err) {
return normalizeTimeoutError(err);
}
for (let attempt = 0; attempt <= options.retries.retries; attempt += 1) {
try {
const handle = await fs.open(lockPath, "wx");
try {
await handle.writeFile(
JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }, null, 2),
"utf8",
);
} catch (writeError) {
await handle.close().catch(() => undefined);
await fs.rm(lockPath, { force: true }).catch(() => undefined);
throw writeError;
}
HELD_LOCKS.set(normalizedFile, { count: 1, handle, lockPath });
return {
lockPath,
release: () => releaseHeldLock(normalizedFile),
};
} catch (err) {
const code = (err as { code?: string }).code;
if (code !== "EEXIST") {
throw err;
}
if (await isStaleLock(lockPath, options.stale)) {
await fs.rm(lockPath, { force: true }).catch(() => undefined);
continue;
}
if (attempt >= options.retries.retries) {
break;
}
await new Promise((resolve) => setTimeout(resolve, computeDelayMs(options.retries, attempt)));
}
}
throw createFileLockTimeoutError(normalizedFile, lockPath);
}
/** Run an async callback while holding a file lock, always releasing the lock afterward. */

View File

@@ -0,0 +1,49 @@
import fs from "node:fs";
import path from "node:path";
import { loadSecretFileSync as loadSecretFileSyncFromCore } from "openclaw/plugin-sdk/core";
import { readFileWithinRoot, writeFileWithinRoot } from "openclaw/plugin-sdk/file-access-runtime";
import {
loadSecretFileSync,
type SecretFileReadResult,
} from "openclaw/plugin-sdk/secret-file-runtime";
import { describe, expect, it } from "vitest";
import { withTempDir } from "../test-helpers/temp-dir.js";
describe("plugin SDK fs-safe compatibility exports", () => {
it("keeps deprecated secret-file result helpers on public SDK subpaths", async () => {
await withTempDir({ prefix: "openclaw-sdk-secret-compat-" }, async (root) => {
const secretPath = path.join(root, "token.txt");
fs.writeFileSync(secretPath, "secret\n", { mode: 0o600 });
const result: SecretFileReadResult = loadSecretFileSync(secretPath, "token");
expect(result).toMatchObject({
ok: true,
secret: "secret",
resolvedPath: secretPath,
});
expect(loadSecretFileSyncFromCore(secretPath, "token")).toMatchObject({
ok: true,
secret: "secret",
});
});
});
it("keeps deprecated root-bounded read/write helpers on file-access-runtime", async () => {
await withTempDir({ prefix: "openclaw-sdk-file-access-compat-" }, async (root) => {
await writeFileWithinRoot({
rootDir: root,
relativePath: "nested/file.txt",
data: "hello",
mkdir: true,
});
const result = await readFileWithinRoot({
rootDir: root,
relativePath: "nested/file.txt",
});
expect(result.buffer.toString("utf8")).toBe("hello");
expect(result.realPath).toBe(fs.realpathSync(path.join(root, "nested", "file.txt")));
});
});
});

View File

@@ -1,40 +1,33 @@
import fs from "node:fs";
import { loadJsonFile, saveJsonFile } from "../infra/json-file.js";
import { writeJsonAtomic } from "../infra/json-files.js";
import { safeParseJson } from "../utils.js";
import "../infra/fs-safe-defaults.js";
import { pathExists } from "../infra/fs-safe.js";
import { tryReadJson, tryReadJsonSync, writeJson, writeJsonSync } from "../infra/json-files.js";
/** Read small JSON blobs synchronously for token/state caches. */
export { loadJsonFile };
// oxlint-disable-next-line typescript-eslint/no-unnecessary-type-parameters -- public SDK compatibility helper.
export function loadJsonFile<T = unknown>(filePath: string): T | undefined {
return tryReadJsonSync<T>(filePath) ?? undefined;
}
/** Persist small JSON blobs synchronously with restrictive permissions. */
export { saveJsonFile };
export const saveJsonFile = writeJsonSync;
/** Read JSON from disk and fall back cleanly when the file is missing or invalid. */
export async function readJsonFileWithFallback<T>(
filePath: string,
fallback: T,
): Promise<{ value: T; exists: boolean }> {
try {
const raw = await fs.promises.readFile(filePath, "utf-8");
const parsed = safeParseJson<T>(raw);
if (parsed == null) {
return { value: fallback, exists: true };
}
const parsed = await tryReadJson<T>(filePath);
if (parsed != null) {
return { value: parsed, exists: true };
} catch (err) {
const code = (err as { code?: string }).code;
if (code === "ENOENT") {
return { value: fallback, exists: false };
}
return { value: fallback, exists: false };
}
return { value: fallback, exists: await pathExists(filePath) };
}
/** Write JSON with secure file permissions and atomic replacement semantics. */
export async function writeJsonFileAtomically(filePath: string, value: unknown): Promise<void> {
await writeJsonAtomic(filePath, value, {
await writeJson(filePath, value, {
mode: 0o600,
dirMode: 0o700,
trailingNewline: true,
ensureDirMode: 0o700,
});
}

View File

@@ -1,3 +1,3 @@
// Narrow media store helpers for channel runtimes that do not need the full media runtime.
export { resolveMediaBufferPath, saveMediaBuffer } from "../media/store.js";
export { readMediaBuffer, resolveMediaBufferPath, saveMediaBuffer } from "../media/store.js";

View File

@@ -32,7 +32,7 @@ export type {
MemoryQmdSearchMode,
} from "../config/types.memory.js";
export type { MemorySearchConfig } from "../config/types.tools.js";
export { writeFileWithinRoot } from "../infra/fs-safe.js";
export { root } from "../infra/fs-safe.js";
export { createSubsystemLogger } from "../logging/subsystem.js";
export { detectMime } from "../media/mime.js";
export { onSessionTranscriptUpdate } from "../sessions/transcript-events.js";

View File

@@ -3,6 +3,7 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { pathExists } from "../infra/fs-safe.js";
import type {
MigrationApplyResult,
MigrationItem,
@@ -67,12 +68,7 @@ export function withCachedMigrationConfigRuntime(
}
async function exists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
return await pathExists(filePath);
}
async function backupExistingMigrationTarget(

View File

@@ -48,3 +48,12 @@ export {
type PluginCommandRunResult,
} from "./run-command.js";
export { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
export {
tempWorkspace,
tempWorkspaceSync,
type TempWorkspace,
type TempWorkspaceOptions,
type TempWorkspaceSync,
withTempWorkspace,
withTempWorkspaceSync,
} from "../infra/private-temp-workspace.js";

View File

@@ -1,5 +1,7 @@
// Public security/policy helpers for plugins that need shared trust and DM gating logic.
import { root as fsRoot, type OpenResult } from "../infra/fs-safe.js";
export * from "../secrets/channel-secret-collector-runtime.js";
export * from "../secrets/runtime-shared.js";
export * from "../secrets/shared.js";
@@ -17,10 +19,51 @@ export {
export * from "../security/external-content.js";
export * from "../security/safe-regex.js";
export {
SafeOpenError,
openFileWithinRoot,
writeFileFromPathWithinRoot,
appendRegularFile,
appendRegularFileSync,
FsSafeError,
FsSafeError as SafeOpenError,
openLocalFileSafely,
pathExists,
pathExistsSync,
readRegularFile,
resolveLocalPathFromRootsSync,
readRegularFileSync,
resolveRegularFileAppendFlags,
root,
statRegularFileSync,
withTimeout,
type FsSafeErrorCode as SafeOpenErrorCode,
} from "../infra/fs-safe.js";
export async function openFileWithinRoot(params: {
rootDir: string;
relativePath: string;
rejectHardlinks?: boolean;
nonBlockingRead?: boolean;
allowSymlinkTargetWithinRoot?: boolean;
}): Promise<OpenResult> {
const root = await fsRoot(params.rootDir);
return await root.open(params.relativePath, {
hardlinks: params.rejectHardlinks === false ? "allow" : "reject",
nonBlockingRead: params.nonBlockingRead,
symlinks: params.allowSymlinkTargetWithinRoot === true ? "follow-within-root" : "reject",
});
}
export async function writeFileFromPathWithinRoot(params: {
rootDir: string;
relativePath: string;
sourcePath: string;
mkdir?: boolean;
}): Promise<void> {
const root = await fsRoot(params.rootDir);
await root.copyIn(params.relativePath, params.sourcePath, {
mkdir: params.mkdir,
sourceHardlinks: "reject",
});
}
export { extractErrorCode, formatErrorMessage } from "../infra/errors.js";
export { hasProxyEnvConfigured } from "../infra/net/proxy-env.js";
export { normalizeHostname } from "../infra/net/hostname.js";
@@ -34,8 +77,54 @@ export {
type SsrFPolicy,
} from "../infra/net/ssrf.js";
export { isNotFoundPathError, isPathInside } from "../infra/path-guards.js";
export {
assertAbsolutePathInput,
canonicalPathFromExistingAncestor,
findExistingAncestor,
resolveAbsolutePathForRead,
resolveAbsolutePathForWrite,
type AbsolutePathSymlinkPolicy,
type ResolvedAbsolutePath,
type ResolvedWritableAbsolutePath,
} from "../infra/fs-safe.js";
export { sanitizeUntrustedFileName } from "../infra/fs-safe-advanced.js";
export {
privateFileStore,
privateFileStoreSync,
type PrivateFileStore,
} from "../infra/private-file-store.js";
export {
movePathWithCopyFallback,
replaceFileAtomic,
replaceFileAtomicSync,
type MovePathWithCopyFallbackOptions,
type ReplaceFileAtomicFileSystem,
type ReplaceFileAtomicOptions,
type ReplaceFileAtomicResult,
type ReplaceFileAtomicSyncFileSystem,
type ReplaceFileAtomicSyncOptions,
} from "../infra/replace-file.js";
export {
writeSiblingTempFile,
type WriteSiblingTempFileOptions,
type WriteSiblingTempFileResult,
} from "../infra/sibling-temp-file.js";
export {
assertNoSymlinkParents,
assertNoSymlinkParentsSync,
type AssertNoSymlinkParentsOptions,
} from "../infra/fs-safe-advanced.js";
export { ensurePortAvailable } from "../infra/ports.js";
export { generateSecureToken } from "../infra/secure-random.js";
export {
resolveExistingPathsWithinRoot,
pathScope,
resolvePathsWithinRoot,
resolvePathWithinRoot,
resolveStrictExistingPathsWithinRoot,
resolveWritablePathWithinRoot,
} from "../infra/root-paths.js";
export { writeViaSiblingTempPath } from "../infra/fs-safe-advanced.js";
export { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
export { redactSensitiveText } from "../logging/redact.js";
export { safeEqualSecret } from "../security/secret-equal.js";

View File

@@ -1,3 +1,4 @@
import fsSync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
@@ -5,8 +6,13 @@ import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
import { buildRandomTempFilePath, withTempDownloadPath } from "./temp-path.js";
function expectPathInsideTmpRoot(resultPath: string) {
const tmpRoot = path.resolve(resolvePreferredOpenClawTmpDir());
const resolved = path.resolve(resultPath);
const tmpRoot = fsSync.realpathSync(resolvePreferredOpenClawTmpDir());
let resolved = path.resolve(resultPath);
try {
resolved = path.join(fsSync.realpathSync(path.dirname(resultPath)), path.basename(resultPath));
} catch {
// The temp parent is intentionally gone after withTempDownloadPath cleanup.
}
const rel = path.relative(tmpRoot, resolved);
expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
expect(resultPath).not.toContain("..");

View File

@@ -5,3 +5,12 @@ export {
sanitizeTempFileName,
withTempDownloadPath,
} from "../infra/temp-download.js";
export {
tempWorkspace,
tempWorkspaceSync,
type TempWorkspace,
type TempWorkspaceOptions,
type TempWorkspaceSync,
withTempWorkspace,
withTempWorkspaceSync,
} from "../infra/private-temp-workspace.js";