mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 08:12:43 +00:00
refactor: unify boundary-safe reads for bootstrap and includes
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import syncFs from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { openBoundaryFile } from "../infra/boundary-file-read.js";
|
||||
import { resolveRequiredHomeDir } from "../infra/home-dir.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { isCronSessionKey, isSubagentSessionKey } from "../routing/session-key.js";
|
||||
@@ -35,33 +37,53 @@ const WORKSPACE_STATE_VERSION = 1;
|
||||
|
||||
const workspaceTemplateCache = new Map<string, Promise<string>>();
|
||||
let gitAvailabilityPromise: Promise<boolean> | null = null;
|
||||
const MAX_WORKSPACE_BOOTSTRAP_FILE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
// File content cache with mtime invalidation to avoid redundant reads
|
||||
const workspaceFileCache = new Map<string, { content: string; mtimeMs: number }>();
|
||||
// File content cache keyed by stable file identity to avoid stale reads.
|
||||
const workspaceFileCache = new Map<string, { content: string; identity: string }>();
|
||||
|
||||
/**
|
||||
* Read file with caching based on mtime. Returns cached content if file
|
||||
* hasn't changed, otherwise reads from disk and updates cache.
|
||||
* Read workspace files via boundary-safe open and cache by inode/dev/size/mtime identity.
|
||||
*/
|
||||
async function readFileWithCache(filePath: string): Promise<string> {
|
||||
type WorkspaceGuardedReadResult =
|
||||
| { ok: true; content: string }
|
||||
| { ok: false; reason: "path" | "validation" | "io"; error?: unknown };
|
||||
|
||||
function workspaceFileIdentity(stat: syncFs.Stats, canonicalPath: string): string {
|
||||
return `${canonicalPath}|${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`;
|
||||
}
|
||||
|
||||
async function readWorkspaceFileWithGuards(params: {
|
||||
filePath: string;
|
||||
workspaceDir: string;
|
||||
}): Promise<WorkspaceGuardedReadResult> {
|
||||
const opened = await openBoundaryFile({
|
||||
absolutePath: params.filePath,
|
||||
rootPath: params.workspaceDir,
|
||||
boundaryLabel: "workspace root",
|
||||
maxBytes: MAX_WORKSPACE_BOOTSTRAP_FILE_BYTES,
|
||||
});
|
||||
if (!opened.ok) {
|
||||
workspaceFileCache.delete(params.filePath);
|
||||
return opened;
|
||||
}
|
||||
|
||||
const identity = workspaceFileIdentity(opened.stat, opened.path);
|
||||
const cached = workspaceFileCache.get(params.filePath);
|
||||
if (cached && cached.identity === identity) {
|
||||
syncFs.closeSync(opened.fd);
|
||||
return { ok: true, content: cached.content };
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await fs.stat(filePath);
|
||||
const mtimeMs = stats.mtimeMs;
|
||||
const cached = workspaceFileCache.get(filePath);
|
||||
|
||||
// Return cached content if mtime matches
|
||||
if (cached && cached.mtimeMs === mtimeMs) {
|
||||
return cached.content;
|
||||
}
|
||||
|
||||
// Read from disk and update cache
|
||||
const content = await fs.readFile(filePath, "utf-8");
|
||||
workspaceFileCache.set(filePath, { content, mtimeMs });
|
||||
return content;
|
||||
const content = syncFs.readFileSync(opened.fd, "utf-8");
|
||||
workspaceFileCache.set(params.filePath, { content, identity });
|
||||
return { ok: true, content };
|
||||
} catch (error) {
|
||||
// Remove from cache if file doesn't exist or is unreadable
|
||||
workspaceFileCache.delete(filePath);
|
||||
throw error;
|
||||
workspaceFileCache.delete(params.filePath);
|
||||
return { ok: false, reason: "io", error };
|
||||
} finally {
|
||||
syncFs.closeSync(opened.fd);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +147,18 @@ export type WorkspaceBootstrapFile = {
|
||||
missing: boolean;
|
||||
};
|
||||
|
||||
export type ExtraBootstrapLoadDiagnosticCode =
|
||||
| "invalid-bootstrap-filename"
|
||||
| "missing"
|
||||
| "security"
|
||||
| "io";
|
||||
|
||||
export type ExtraBootstrapLoadDiagnostic = {
|
||||
path: string;
|
||||
reason: ExtraBootstrapLoadDiagnosticCode;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
type WorkspaceOnboardingState = {
|
||||
version: typeof WORKSPACE_STATE_VERSION;
|
||||
bootstrapSeededAt?: string;
|
||||
@@ -479,15 +513,18 @@ export async function loadWorkspaceBootstrapFiles(dir: string): Promise<Workspac
|
||||
|
||||
const result: WorkspaceBootstrapFile[] = [];
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
const content = await readFileWithCache(entry.filePath);
|
||||
const loaded = await readWorkspaceFileWithGuards({
|
||||
filePath: entry.filePath,
|
||||
workspaceDir: resolvedDir,
|
||||
});
|
||||
if (loaded.ok) {
|
||||
result.push({
|
||||
name: entry.name,
|
||||
path: entry.filePath,
|
||||
content,
|
||||
content: loaded.content,
|
||||
missing: false,
|
||||
});
|
||||
} catch {
|
||||
} else {
|
||||
result.push({ name: entry.name, path: entry.filePath, missing: true });
|
||||
}
|
||||
}
|
||||
@@ -516,16 +553,21 @@ export async function loadExtraBootstrapFiles(
|
||||
dir: string,
|
||||
extraPatterns: string[],
|
||||
): Promise<WorkspaceBootstrapFile[]> {
|
||||
const loaded = await loadExtraBootstrapFilesWithDiagnostics(dir, extraPatterns);
|
||||
return loaded.files;
|
||||
}
|
||||
|
||||
export async function loadExtraBootstrapFilesWithDiagnostics(
|
||||
dir: string,
|
||||
extraPatterns: string[],
|
||||
): Promise<{
|
||||
files: WorkspaceBootstrapFile[];
|
||||
diagnostics: ExtraBootstrapLoadDiagnostic[];
|
||||
}> {
|
||||
if (!extraPatterns.length) {
|
||||
return [];
|
||||
return { files: [], diagnostics: [] };
|
||||
}
|
||||
const resolvedDir = resolveUserPath(dir);
|
||||
let realResolvedDir = resolvedDir;
|
||||
try {
|
||||
realResolvedDir = await fs.realpath(resolvedDir);
|
||||
} catch {
|
||||
// Keep lexical root if realpath fails.
|
||||
}
|
||||
|
||||
// Resolve glob patterns into concrete file paths
|
||||
const resolvedPaths = new Set<string>();
|
||||
@@ -545,37 +587,46 @@ export async function loadExtraBootstrapFiles(
|
||||
}
|
||||
}
|
||||
|
||||
const result: WorkspaceBootstrapFile[] = [];
|
||||
const files: WorkspaceBootstrapFile[] = [];
|
||||
const diagnostics: ExtraBootstrapLoadDiagnostic[] = [];
|
||||
for (const relPath of resolvedPaths) {
|
||||
const filePath = path.resolve(resolvedDir, relPath);
|
||||
// Guard against path traversal — resolved path must stay within workspace
|
||||
if (!filePath.startsWith(resolvedDir + path.sep) && filePath !== resolvedDir) {
|
||||
// Only load files whose basename is a recognized bootstrap filename
|
||||
const baseName = path.basename(relPath);
|
||||
if (!VALID_BOOTSTRAP_NAMES.has(baseName)) {
|
||||
diagnostics.push({
|
||||
path: filePath,
|
||||
reason: "invalid-bootstrap-filename",
|
||||
detail: `unsupported bootstrap basename: ${baseName}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
// Resolve symlinks and verify the real path is still within workspace
|
||||
const realFilePath = await fs.realpath(filePath);
|
||||
if (
|
||||
!realFilePath.startsWith(realResolvedDir + path.sep) &&
|
||||
realFilePath !== realResolvedDir
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
// Only load files whose basename is a recognized bootstrap filename
|
||||
const baseName = path.basename(relPath);
|
||||
if (!VALID_BOOTSTRAP_NAMES.has(baseName)) {
|
||||
continue;
|
||||
}
|
||||
const content = await readFileWithCache(realFilePath);
|
||||
result.push({
|
||||
const loaded = await readWorkspaceFileWithGuards({
|
||||
filePath,
|
||||
workspaceDir: resolvedDir,
|
||||
});
|
||||
if (loaded.ok) {
|
||||
files.push({
|
||||
name: baseName as WorkspaceBootstrapFileName,
|
||||
path: filePath,
|
||||
content,
|
||||
content: loaded.content,
|
||||
missing: false,
|
||||
});
|
||||
} catch {
|
||||
// Silently skip missing extra files
|
||||
continue;
|
||||
}
|
||||
|
||||
const reason: ExtraBootstrapLoadDiagnosticCode =
|
||||
loaded.reason === "path" ? "missing" : loaded.reason === "validation" ? "security" : "io";
|
||||
diagnostics.push({
|
||||
path: filePath,
|
||||
reason,
|
||||
detail:
|
||||
loaded.error instanceof Error
|
||||
? loaded.error.message
|
||||
: typeof loaded.error === "string"
|
||||
? loaded.error
|
||||
: reason,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
return { files, diagnostics };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user