mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 00:48:10 +00:00
486 lines
17 KiB
TypeScript
486 lines
17 KiB
TypeScript
import { app, nativeImage } from 'electron';
|
|
import crypto from 'node:crypto';
|
|
import { homedir } from 'node:os';
|
|
import { basename, extname, join, relative, resolve, sep } from 'node:path';
|
|
import type {
|
|
FilePreviewTreeNode,
|
|
FilePreviewTreeOptions,
|
|
FileReadBinaryOptions,
|
|
} from '@shared/host-api/contract';
|
|
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
|
|
import { expandPath } from '../utils/paths';
|
|
import { isRecord } from './payload-utils';
|
|
|
|
const EXT_MIME_MAP: Record<string, string> = {
|
|
'.png': 'image/png',
|
|
'.jpg': 'image/jpeg',
|
|
'.jpeg': 'image/jpeg',
|
|
'.gif': 'image/gif',
|
|
'.webp': 'image/webp',
|
|
'.svg': 'image/svg+xml',
|
|
'.bmp': 'image/bmp',
|
|
'.ico': 'image/x-icon',
|
|
'.mp4': 'video/mp4',
|
|
'.webm': 'video/webm',
|
|
'.mov': 'video/quicktime',
|
|
'.avi': 'video/x-msvideo',
|
|
'.mkv': 'video/x-matroska',
|
|
'.mp3': 'audio/mpeg',
|
|
'.wav': 'audio/wav',
|
|
'.ogg': 'audio/ogg',
|
|
'.flac': 'audio/flac',
|
|
'.pdf': 'application/pdf',
|
|
'.zip': 'application/zip',
|
|
'.gz': 'application/gzip',
|
|
'.tar': 'application/x-tar',
|
|
'.7z': 'application/x-7z-compressed',
|
|
'.rar': 'application/vnd.rar',
|
|
'.json': 'application/json',
|
|
'.xml': 'application/xml',
|
|
'.csv': 'text/csv',
|
|
'.txt': 'text/plain',
|
|
'.md': 'text/markdown',
|
|
'.html': 'text/html',
|
|
'.htm': 'text/html',
|
|
'.css': 'text/css',
|
|
'.js': 'text/javascript',
|
|
'.ts': 'text/typescript',
|
|
'.py': 'text/x-python',
|
|
'.doc': 'application/msword',
|
|
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
'.xls': 'application/vnd.ms-excel',
|
|
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
'.ppt': 'application/vnd.ms-powerpoint',
|
|
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
};
|
|
|
|
const OUTBOUND_DIR = join(homedir(), '.openclaw', 'media', 'outbound');
|
|
const DIRECTORY_MIME_TYPE = 'application/x-directory';
|
|
const FILE_PREVIEW_MAX_TEXT_BYTES = 2 * 1024 * 1024;
|
|
const FILE_PREVIEW_MAX_BINARY_BYTES = 50 * 1024 * 1024;
|
|
const FILE_PREVIEW_TREE_MAX_DEPTH = 6;
|
|
const FILE_PREVIEW_TREE_MAX_NODES = 5000;
|
|
const FILE_PREVIEW_DIR_BLACKLIST = new Set([
|
|
'node_modules',
|
|
'.venv',
|
|
'__pycache__',
|
|
'.git',
|
|
'dist',
|
|
'build',
|
|
'.next',
|
|
'.turbo',
|
|
'.cache',
|
|
]);
|
|
|
|
type StagePathsPayload = {
|
|
filePaths?: unknown;
|
|
};
|
|
|
|
type StageBufferPayload = {
|
|
base64?: unknown;
|
|
fileName?: unknown;
|
|
mimeType?: unknown;
|
|
};
|
|
|
|
type PathPayload = {
|
|
path?: unknown;
|
|
content?: unknown;
|
|
opts?: unknown;
|
|
};
|
|
|
|
type ResolvedSandboxedPath = {
|
|
realPath: string;
|
|
readOnly: boolean;
|
|
};
|
|
|
|
function getMimeType(ext: string): string {
|
|
return EXT_MIME_MAP[ext.toLowerCase()] || 'application/octet-stream';
|
|
}
|
|
|
|
function mimeToExt(mimeType: string): string {
|
|
for (const [ext, mime] of Object.entries(EXT_MIME_MAP)) {
|
|
if (mime === mimeType) return ext;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
async function generateImagePreview(filePath: string, mimeType: string): Promise<string | null> {
|
|
try {
|
|
const img = nativeImage.createFromPath(filePath);
|
|
if (img.isEmpty()) return null;
|
|
const size = img.getSize();
|
|
const maxDim = 512;
|
|
if (size.width > maxDim || size.height > maxDim) {
|
|
const resized = size.width >= size.height
|
|
? img.resize({ width: maxDim })
|
|
: img.resize({ height: maxDim });
|
|
return `data:image/png;base64,${resized.toPNG().toString('base64')}`;
|
|
}
|
|
const { readFile } = await import('node:fs/promises');
|
|
const buf = await readFile(filePath);
|
|
return `data:${mimeType};base64,${buf.toString('base64')}`;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function requirePath(payload: unknown): string {
|
|
const path = isRecord(payload) ? payload.path : payload;
|
|
if (typeof path !== 'string' || !path.trim()) {
|
|
throw new Error('Invalid file path');
|
|
}
|
|
return path;
|
|
}
|
|
|
|
function isPathInside(child: string, parent: string): boolean {
|
|
const c = resolve(child);
|
|
const p = resolve(parent);
|
|
if (process.platform === 'win32') {
|
|
const cl = c.toLowerCase();
|
|
const pl = p.toLowerCase();
|
|
return cl === pl || cl.startsWith(pl + sep);
|
|
}
|
|
return c === p || c.startsWith(p + sep);
|
|
}
|
|
|
|
function getFilePreviewWriteRoots(): string[] {
|
|
const roots: string[] = [];
|
|
roots.push(resolve(join(homedir(), '.openclaw')));
|
|
try {
|
|
roots.push(resolve(app.getPath('userData')));
|
|
} catch {
|
|
// ignore
|
|
}
|
|
roots.push(resolve(OUTBOUND_DIR));
|
|
return roots;
|
|
}
|
|
|
|
async function resolveSandboxedPath(
|
|
input: string,
|
|
mode: 'read' | 'write' = 'read',
|
|
): Promise<ResolvedSandboxedPath> {
|
|
if (!input.trim()) {
|
|
throw new Error('outsideSandbox');
|
|
}
|
|
const expanded = expandPath(input);
|
|
const fsP = await import('node:fs/promises');
|
|
let real: string;
|
|
try {
|
|
real = await fsP.realpath(expanded);
|
|
} catch {
|
|
real = resolve(expanded);
|
|
}
|
|
const writeRoots = getFilePreviewWriteRoots();
|
|
if (writeRoots.some((root) => isPathInside(real, root))) {
|
|
return { realPath: real, readOnly: false };
|
|
}
|
|
if (mode === 'write') {
|
|
throw new Error('readOnlyRoot');
|
|
}
|
|
return { realPath: real, readOnly: true };
|
|
}
|
|
|
|
function looksLikeBinary(buf: Buffer): boolean {
|
|
const limit = Math.min(buf.length, 8192);
|
|
for (let i = 0; i < limit; i += 1) {
|
|
if (buf[i] === 0) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function shouldSkipDirEntry(name: string, includeHidden: boolean): boolean {
|
|
if (FILE_PREVIEW_DIR_BLACKLIST.has(name)) return true;
|
|
if (!includeHidden && name.startsWith('.')) return true;
|
|
return false;
|
|
}
|
|
|
|
function shouldSkipFileEntry(name: string, includeHidden: boolean): boolean {
|
|
if (!includeHidden && name.startsWith('.')) return true;
|
|
return false;
|
|
}
|
|
|
|
function getTreeOptions(opts: unknown): FilePreviewTreeOptions {
|
|
return isRecord(opts) ? opts as FilePreviewTreeOptions : {};
|
|
}
|
|
|
|
function getBinaryOptions(opts: unknown): FileReadBinaryOptions {
|
|
return isRecord(opts) ? opts as FileReadBinaryOptions : {};
|
|
}
|
|
|
|
export function createFilesApi(): CompleteHostServiceRegistry['files'] {
|
|
return {
|
|
stagePaths: async (payload) => {
|
|
const body = isRecord(payload) ? payload as StagePathsPayload : {};
|
|
const filePaths = Array.isArray(body.filePaths)
|
|
? body.filePaths.filter((value): value is string => typeof value === 'string')
|
|
: [];
|
|
const fsP = await import('node:fs/promises');
|
|
await fsP.mkdir(OUTBOUND_DIR, { recursive: true });
|
|
|
|
const results = [];
|
|
for (const filePath of filePaths) {
|
|
const id = crypto.randomUUID();
|
|
const fileName = basename(filePath);
|
|
const sourceStat = await fsP.stat(filePath);
|
|
if (sourceStat.isDirectory()) {
|
|
results.push({
|
|
id,
|
|
fileName,
|
|
mimeType: DIRECTORY_MIME_TYPE,
|
|
fileSize: 0,
|
|
stagedPath: filePath,
|
|
preview: null,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const ext = extname(filePath);
|
|
const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`);
|
|
await fsP.copyFile(filePath, stagedPath);
|
|
const s = await fsP.stat(stagedPath);
|
|
const mimeType = getMimeType(ext);
|
|
const preview = mimeType.startsWith('image/')
|
|
? await generateImagePreview(stagedPath, mimeType)
|
|
: null;
|
|
results.push({ id, fileName, mimeType, fileSize: s.size, stagedPath, preview });
|
|
}
|
|
return results;
|
|
},
|
|
stageBuffer: async (payload) => {
|
|
const body = isRecord(payload) ? payload as StageBufferPayload : {};
|
|
if (typeof body.base64 !== 'string' || typeof body.fileName !== 'string') {
|
|
throw new Error('Invalid staged buffer payload');
|
|
}
|
|
const fsP = await import('node:fs/promises');
|
|
await fsP.mkdir(OUTBOUND_DIR, { recursive: true });
|
|
|
|
const id = crypto.randomUUID();
|
|
const payloadMimeType = typeof body.mimeType === 'string' ? body.mimeType : '';
|
|
const ext = extname(body.fileName) || mimeToExt(payloadMimeType);
|
|
const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`);
|
|
const buffer = Buffer.from(body.base64, 'base64');
|
|
await fsP.writeFile(stagedPath, buffer);
|
|
|
|
const mimeType = payloadMimeType || getMimeType(ext);
|
|
const preview = mimeType.startsWith('image/')
|
|
? await generateImagePreview(stagedPath, mimeType)
|
|
: null;
|
|
return {
|
|
id,
|
|
fileName: body.fileName,
|
|
mimeType,
|
|
fileSize: buffer.length,
|
|
stagedPath,
|
|
preview,
|
|
};
|
|
},
|
|
readText: async (payload) => {
|
|
try {
|
|
const { realPath: real, readOnly } = await resolveSandboxedPath(requirePath(payload), 'read');
|
|
const fsP = await import('node:fs/promises');
|
|
const stat = await fsP.stat(real);
|
|
if (!stat.isFile()) return { ok: false, error: 'notFound' };
|
|
if (stat.size > FILE_PREVIEW_MAX_TEXT_BYTES) return { ok: false, error: 'tooLarge', size: stat.size };
|
|
const buf = await fsP.readFile(real);
|
|
if (looksLikeBinary(buf)) return { ok: false, error: 'binary', size: stat.size };
|
|
return {
|
|
ok: true,
|
|
content: buf.toString('utf8'),
|
|
mimeType: getMimeType(extname(real)),
|
|
size: stat.size,
|
|
readOnly,
|
|
};
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
if (message === 'outsideSandbox') return { ok: false, error: 'outsideSandbox' };
|
|
if (message.includes('ENOENT')) return { ok: false, error: 'notFound' };
|
|
return { ok: false, error: message };
|
|
}
|
|
},
|
|
readBinary: async (payload) => {
|
|
try {
|
|
const body = isRecord(payload) ? payload as PathPayload : {};
|
|
const opts = getBinaryOptions(body.opts);
|
|
const { realPath: real, readOnly } = await resolveSandboxedPath(requirePath(payload), 'read');
|
|
const fsP = await import('node:fs/promises');
|
|
const stat = await fsP.stat(real);
|
|
if (!stat.isFile()) return { ok: false, error: 'notFound' };
|
|
const maxBytes = typeof opts.maxBytes === 'number' ? opts.maxBytes : undefined;
|
|
const cap = Math.max(1, Math.min(maxBytes ?? FILE_PREVIEW_MAX_BINARY_BYTES, FILE_PREVIEW_MAX_BINARY_BYTES));
|
|
if (stat.size > cap) return { ok: false, error: 'tooLarge', size: stat.size };
|
|
const buf = await fsP.readFile(real);
|
|
const view = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
return {
|
|
ok: true,
|
|
data: view,
|
|
mimeType: getMimeType(extname(real)),
|
|
size: stat.size,
|
|
readOnly,
|
|
};
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
if (message === 'outsideSandbox') return { ok: false, error: 'outsideSandbox' };
|
|
if (message.includes('ENOENT')) return { ok: false, error: 'notFound' };
|
|
return { ok: false, error: message };
|
|
}
|
|
},
|
|
writeText: async (payload) => {
|
|
try {
|
|
const body = isRecord(payload) ? payload as PathPayload : {};
|
|
if (typeof body.content !== 'string') return { ok: false, error: 'invalidContent' };
|
|
if (Buffer.byteLength(body.content, 'utf8') > FILE_PREVIEW_MAX_TEXT_BYTES) {
|
|
return { ok: false, error: 'tooLarge' };
|
|
}
|
|
const { realPath: real } = await resolveSandboxedPath(requirePath(payload), 'write');
|
|
const fsP = await import('node:fs/promises');
|
|
let stat;
|
|
try {
|
|
stat = await fsP.stat(real);
|
|
} catch {
|
|
return { ok: false, error: 'notFound' };
|
|
}
|
|
if (!stat.isFile()) return { ok: false, error: 'notFound' };
|
|
await fsP.writeFile(real, body.content, 'utf8');
|
|
return { ok: true };
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
if (message === 'outsideSandbox') return { ok: false, error: 'outsideSandbox' };
|
|
if (message === 'readOnlyRoot') return { ok: false, error: 'readOnlyRoot' };
|
|
return { ok: false, error: message };
|
|
}
|
|
},
|
|
stat: async (payload) => {
|
|
try {
|
|
const { realPath: real, readOnly } = await resolveSandboxedPath(requirePath(payload), 'read');
|
|
const fsP = await import('node:fs/promises');
|
|
const stat = await fsP.stat(real);
|
|
return {
|
|
ok: true,
|
|
size: stat.size,
|
|
mtime: stat.mtimeMs,
|
|
isFile: stat.isFile(),
|
|
isDir: stat.isDirectory(),
|
|
readOnly,
|
|
};
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
if (message === 'outsideSandbox') return { ok: false, error: 'outsideSandbox' };
|
|
if (message.includes('ENOENT')) return { ok: false, error: 'notFound' };
|
|
return { ok: false, error: message };
|
|
}
|
|
},
|
|
listDir: async (payload) => {
|
|
try {
|
|
const { realPath: real } = await resolveSandboxedPath(requirePath(payload), 'read');
|
|
const fsP = await import('node:fs/promises');
|
|
const dirents = await fsP.readdir(real, { withFileTypes: true });
|
|
const entries = await Promise.all(dirents.map(async (entry) => {
|
|
const abs = join(real, entry.name);
|
|
let size = 0;
|
|
try {
|
|
if (entry.isFile()) size = (await fsP.stat(abs)).size;
|
|
} catch {
|
|
// non-fatal
|
|
}
|
|
return {
|
|
name: entry.name,
|
|
path: abs,
|
|
isDir: entry.isDirectory(),
|
|
size,
|
|
};
|
|
}));
|
|
return { ok: true, entries };
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
if (message === 'outsideSandbox') return { ok: false, error: 'outsideSandbox' };
|
|
if (message.includes('ENOENT')) return { ok: false, error: 'notFound' };
|
|
return { ok: false, error: message };
|
|
}
|
|
},
|
|
listTree: async (payload) => {
|
|
try {
|
|
const body = isRecord(payload) ? payload as PathPayload : {};
|
|
const opts = getTreeOptions(body.opts);
|
|
const { realPath: real } = await resolveSandboxedPath(requirePath(payload), 'read');
|
|
const fsP = await import('node:fs/promises');
|
|
const stat = await fsP.stat(real);
|
|
if (!stat.isDirectory()) return { ok: false, error: 'notDirectory' };
|
|
const maxDepth = Math.max(1, Math.min(opts.maxDepth ?? FILE_PREVIEW_TREE_MAX_DEPTH, 12));
|
|
const maxNodes = Math.max(1, Math.min(opts.maxNodes ?? FILE_PREVIEW_TREE_MAX_NODES, 50000));
|
|
const includeHidden = !!opts.includeHidden;
|
|
|
|
let nodeCount = 0;
|
|
let truncated = false;
|
|
|
|
const walk = async (absDir: string, depth: number): Promise<FilePreviewTreeNode[] | undefined> => {
|
|
if (depth > maxDepth || truncated) return undefined;
|
|
let dirents;
|
|
try {
|
|
dirents = await fsP.readdir(absDir, { withFileTypes: true });
|
|
} catch {
|
|
return [];
|
|
}
|
|
const children: FilePreviewTreeNode[] = [];
|
|
for (const entry of dirents) {
|
|
if (truncated) break;
|
|
const isDir = entry.isDirectory();
|
|
const isFile = entry.isFile();
|
|
if (!isDir && !isFile) continue;
|
|
if (isDir && shouldSkipDirEntry(entry.name, includeHidden)) continue;
|
|
if (isFile && shouldSkipFileEntry(entry.name, includeHidden)) continue;
|
|
if (nodeCount >= maxNodes) {
|
|
truncated = true;
|
|
break;
|
|
}
|
|
nodeCount += 1;
|
|
const abs = join(absDir, entry.name);
|
|
const node: FilePreviewTreeNode = {
|
|
name: entry.name,
|
|
relPath: relative(real, abs).split(sep).join('/'),
|
|
absPath: abs,
|
|
isDir,
|
|
};
|
|
if (isFile) {
|
|
try {
|
|
const fstat = await fsP.stat(abs);
|
|
node.size = fstat.size;
|
|
node.mtime = fstat.mtimeMs;
|
|
} catch {
|
|
// non-fatal
|
|
}
|
|
} else {
|
|
try {
|
|
node.mtime = (await fsP.stat(abs)).mtimeMs;
|
|
} catch {
|
|
// non-fatal
|
|
}
|
|
node.children = await walk(abs, depth + 1) ?? [];
|
|
}
|
|
children.push(node);
|
|
}
|
|
children.sort((a, b) => {
|
|
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
return children;
|
|
};
|
|
|
|
const root: FilePreviewTreeNode = {
|
|
name: basename(real) || real,
|
|
relPath: '',
|
|
absPath: real,
|
|
isDir: true,
|
|
mtime: stat.mtimeMs,
|
|
children: (await walk(real, 1)) ?? [],
|
|
};
|
|
return { ok: true, root, truncated };
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
if (message === 'outsideSandbox') return { ok: false, error: 'outsideSandbox' };
|
|
if (message.includes('ENOENT')) return { ok: false, error: 'notFound' };
|
|
return { ok: false, error: message };
|
|
}
|
|
},
|
|
};
|
|
}
|