mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 00:30:42 +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
@@ -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";
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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. */
|
||||
|
||||
49
src/plugin-sdk/fs-safe-compat.test.ts
Normal file
49
src/plugin-sdk/fs-safe-compat.test.ts
Normal 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")));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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("..");
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user