codexArtifactService.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. import { promises as fs } from 'node:fs';
  2. import { basename, extname, isAbsolute, join, resolve, sep } from 'node:path';
  3. import { dialog, shell } from 'electron';
  4. import { logger } from 'ee-core/log';
  5. import { getCodexDataDir } from './codexHome';
  6. /**
  7. * 产物(Codex 在会话工作目录里生成的文件)的读写服务。
  8. *
  9. * 安全边界:
  10. * 1. **只允许访问「代码决定的工作目录」** —— 案件工作空间与客户端默认目录。
  11. * 白名单支持事后补登记(见 requireAllowedRoot),但仅限绝对路径且真实存在的目录;
  12. * 2. 所有相对路径都做 resolve + 前缀校验,挡住 `..` 穿越与绝对路径注入。
  13. *
  14. * 只做「列目录 / 读内容 / 另存 / 打开」,不提供删除与写入:
  15. * 产物是 Codex 的工作成果,客户端不应替用户改它。
  16. */
  17. /** 单次列目录返回的条目上限 */
  18. const MAX_ENTRIES = 2000;
  19. /** 递归列举的目录深度上限(防死循环软链 / 过深目录树) */
  20. const MAX_WALK_DEPTH = 6;
  21. /** 递归列举返回的文件条目上限 */
  22. const MAX_WALK_FILES = 3000;
  23. /** 文本预览字节上限 */
  24. const MAX_TEXT_BYTES = 2 * 1024 * 1024;
  25. /** 二进制预览字节上限(图片 / PDF) */
  26. const MAX_BINARY_BYTES = 16 * 1024 * 1024;
  27. /** 已注册的会话工作目录(规范化后的小写路径,Windows 下大小写不敏感) */
  28. const allowedRoots = new Set<string>();
  29. /** 白名单落盘位置:客户端重启后历史会话仍要能读产物 */
  30. const ROOTS_FILE = () => join(getCodexDataDir(), 'artifact-roots.json');
  31. /** 白名单条目上限,超出丢最早的 */
  32. const MAX_ROOTS = 50;
  33. let rootsLoaded = false;
  34. function normalizePath(value: string): string {
  35. const resolved = resolve(value);
  36. return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
  37. }
  38. /** 首次使用时把落盘的白名单读回内存 */
  39. async function ensureRootsLoaded(): Promise<void> {
  40. if (rootsLoaded) return;
  41. rootsLoaded = true;
  42. try {
  43. const raw = await fs.readFile(ROOTS_FILE(), 'utf8');
  44. const parsed = JSON.parse(raw);
  45. if (Array.isArray(parsed)) {
  46. for (const item of parsed) {
  47. if (typeof item === 'string' && item.trim()) allowedRoots.add(normalizePath(item));
  48. }
  49. }
  50. } catch {
  51. // 首次运行 / 文件损坏:当作空白名单
  52. }
  53. }
  54. async function persistRoots(): Promise<void> {
  55. try {
  56. const list = [...allowedRoots].slice(-MAX_ROOTS);
  57. await fs.mkdir(getCodexDataDir(), { recursive: true });
  58. await fs.writeFile(ROOTS_FILE(), JSON.stringify(list, null, 2), 'utf8');
  59. } catch {
  60. // 落盘失败只影响下次启动,不阻塞当前会话
  61. }
  62. }
  63. /** 登记一个可访问的会话工作目录,返回解析后的绝对路径 */
  64. export function registerArtifactRoot(dir: string | null | undefined): string | null {
  65. if (!dir || !String(dir).trim()) return null;
  66. const resolved = resolve(String(dir).trim());
  67. const key = normalizePath(resolved);
  68. if (!allowedRoots.has(key)) {
  69. allowedRoots.add(key);
  70. void persistRoots();
  71. }
  72. return resolved;
  73. }
  74. /** 目录名合法性:必须存在且是目录,才允许进入白名单 */
  75. async function isUsableDirectory(dir: string): Promise<boolean> {
  76. try {
  77. const stat = await fs.stat(dir);
  78. return stat.isDirectory();
  79. } catch {
  80. return false;
  81. }
  82. }
  83. /**
  84. * 解析并校验访问根目录。
  85. *
  86. * 白名单**不再要求事先登记**:工作目录是由代码决定的(案件工作空间 / 客户端默认目录),
  87. * 而产物栏在「还没建会话」时就要列出案件工作空间的数据文件,
  88. * 那时 threadStart 还没执行过、目录自然没登记过 —— 以前就会在这里被拒。
  89. *
  90. * 因此改成:不在白名单但**是绝对路径且确实存在的目录**时补登记(记一条日志便于排查),
  91. * 不满足这两个条件仍然拒绝。真正防越界的还是下面 resolveInside 的路径穿越校验。
  92. */
  93. async function requireAllowedRoot(root: string): Promise<string> {
  94. const raw = String(root ?? '').trim();
  95. if (!raw) throw new Error('缺少会话工作目录');
  96. const resolved = resolve(raw);
  97. await ensureRootsLoaded();
  98. if (allowedRoots.has(normalizePath(resolved))) return resolved;
  99. if (!isAbsolute(raw) || !(await isUsableDirectory(resolved))) {
  100. throw new Error(`工作目录不存在或不可访问:${resolved}`);
  101. }
  102. registerArtifactRoot(resolved);
  103. logger.warn(`[codexArtifact] 补登记工作目录:${resolved}`);
  104. return resolved;
  105. }
  106. /** 把相对路径解析到 root 之内;越界(.. / 绝对路径)直接抛错 */
  107. function resolveInside(root: string, relativePath: string | undefined): string {
  108. const base = resolve(root);
  109. const target = resolve(base, String(relativePath ?? '').replace(/^[/\\]+/, '') || '.');
  110. const prefix = base.endsWith(sep) ? base : `${base}${sep}`;
  111. if (target !== base && !target.startsWith(prefix)) {
  112. throw new Error('路径超出会话工作目录');
  113. }
  114. return target;
  115. }
  116. /** 相对路径统一成 POSIX 分隔符(渲染进程侧只认 /) */
  117. function toPosix(value: string): string {
  118. return value.split(sep).join('/');
  119. }
  120. /* ------------------------------------------------------------------ *
  121. * 类型判定(与前端 utils/codexArtifact.ts 保持同一套扩展名表)
  122. * ------------------------------------------------------------------ */
  123. const IMAGE_EXT = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'ico'];
  124. const CODE_EXT = [
  125. 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'vue', 'py', 'java', 'go', 'rs', 'rb', 'php',
  126. 'c', 'h', 'cpp', 'cs', 'sh', 'ps1', 'bat', 'sql', 'yml', 'yaml', 'toml', 'ini', 'xml',
  127. 'less', 'scss', 'css',
  128. ];
  129. const TABLE_EXT = ['csv', 'tsv', 'xlsx', 'xls'];
  130. const TEXT_EXT = ['txt', 'log', 'rst', 'conf', 'env'];
  131. export type ArtifactKind =
  132. | 'dir' | 'markdown' | 'html' | 'code' | 'json' | 'image' | 'pdf' | 'table' | 'text' | 'binary';
  133. export function artifactKindOf(name: string, isDir = false): ArtifactKind {
  134. if (isDir) return 'dir';
  135. const ext = extname(name).replace('.', '').toLowerCase();
  136. if (ext === 'md' || ext === 'markdown') return 'markdown';
  137. if (ext === 'html' || ext === 'htm') return 'html';
  138. if (ext === 'pdf') return 'pdf';
  139. if (ext === 'json') return 'json';
  140. if (IMAGE_EXT.includes(ext)) return 'image';
  141. if (TABLE_EXT.includes(ext)) return 'table';
  142. if (CODE_EXT.includes(ext)) return 'code';
  143. if (TEXT_EXT.includes(ext)) return 'text';
  144. return 'binary';
  145. }
  146. function mimeOf(name: string): string {
  147. const ext = extname(name).replace('.', '').toLowerCase();
  148. const map: Record<string, string> = {
  149. png: 'image/png',
  150. jpg: 'image/jpeg',
  151. jpeg: 'image/jpeg',
  152. gif: 'image/gif',
  153. webp: 'image/webp',
  154. bmp: 'image/bmp',
  155. svg: 'image/svg+xml',
  156. ico: 'image/x-icon',
  157. pdf: 'application/pdf',
  158. json: 'application/json',
  159. csv: 'text/csv',
  160. md: 'text/markdown',
  161. txt: 'text/plain',
  162. };
  163. return map[ext] ?? 'application/octet-stream';
  164. }
  165. /* ------------------------------------------------------------------ *
  166. * 对外能力
  167. * ------------------------------------------------------------------ */
  168. export interface ArtifactEntry {
  169. name: string;
  170. relative: string;
  171. isDir: boolean;
  172. size: number;
  173. mtime: string;
  174. kind: ArtifactKind;
  175. }
  176. export interface ArtifactListResult {
  177. root: string;
  178. relative: string;
  179. entries: ArtifactEntry[];
  180. truncated: boolean;
  181. }
  182. /** 列出目录下的条目(不递归);目录不存在时返回空列表而不是报错 */
  183. export async function listArtifacts(root: string, dir?: string): Promise<ArtifactListResult> {
  184. const base = await requireAllowedRoot(root);
  185. const relativeDir = toPosix(String(dir ?? '').replace(/^[/\\]+/, '')).replace(/\/+$/, '');
  186. const target = resolveInside(base, relativeDir);
  187. let names: string[];
  188. try {
  189. names = await fs.readdir(target);
  190. } catch (error: any) {
  191. if (error?.code === 'ENOENT') return { root: base, relative: relativeDir, entries: [], truncated: false };
  192. throw new Error(`无法读取目录:${error?.message ?? error}`);
  193. }
  194. const truncated = names.length > MAX_ENTRIES;
  195. const picked = truncated ? names.slice(0, MAX_ENTRIES) : names;
  196. const entries: ArtifactEntry[] = [];
  197. for (const name of picked) {
  198. const absolute = resolveInside(base, relativeDir ? `${relativeDir}/${name}` : name);
  199. try {
  200. const stat = await fs.stat(absolute);
  201. const isDir = stat.isDirectory();
  202. entries.push({
  203. name,
  204. relative: toPosix(relativeDir ? `${relativeDir}/${name}` : name),
  205. isDir,
  206. size: isDir ? 0 : stat.size,
  207. mtime: stat.mtime.toISOString(),
  208. kind: artifactKindOf(name, isDir),
  209. });
  210. } catch {
  211. // 单个条目读不到(被删除 / 无权限)不影响整次列目录
  212. }
  213. }
  214. return { root: base, relative: relativeDir, entries, truncated };
  215. }
  216. /**
  217. * 递归列出工作空间里的全部文件(扁平、相对路径)。
  218. *
  219. * 「本会话产物」视图靠它:会话共用同一个案件工作空间,
  220. * 单层列目录只能看到根下的文件,子目录里的产物(图表、输出目录)会漏。
  221. * 有深度与数量上限,异常目录不会拖垮主进程。
  222. */
  223. export async function listAllArtifacts(root: string): Promise<{ root: string; entries: ArtifactEntry[]; truncated: boolean }> {
  224. const base = await requireAllowedRoot(root);
  225. const entries: ArtifactEntry[] = [];
  226. let truncated = false;
  227. const walk = async (relativeDir: string, depth: number): Promise<void> => {
  228. if (truncated || depth > MAX_WALK_DEPTH || entries.length >= MAX_WALK_FILES) {
  229. truncated = truncated || entries.length >= MAX_WALK_FILES;
  230. return;
  231. }
  232. const target = resolveInside(base, relativeDir);
  233. let names: string[] = [];
  234. try {
  235. names = await fs.readdir(target);
  236. } catch {
  237. // 单个子目录读不到(被删 / 无权限)就跳过,不影响整体
  238. return;
  239. }
  240. for (const name of names) {
  241. if (entries.length >= MAX_WALK_FILES) {
  242. truncated = true;
  243. return;
  244. }
  245. const relative = relativeDir ? `${relativeDir}/${name}` : name;
  246. const absolute = resolveInside(base, relative);
  247. let stat;
  248. try {
  249. stat = await fs.stat(absolute);
  250. } catch {
  251. continue;
  252. }
  253. if (stat.isDirectory()) {
  254. await walk(relative, depth + 1);
  255. continue;
  256. }
  257. entries.push({
  258. name,
  259. relative: toPosix(relative),
  260. isDir: false,
  261. size: stat.size,
  262. mtime: stat.mtime.toISOString(),
  263. kind: artifactKindOf(name, false),
  264. });
  265. }
  266. };
  267. await walk('', 1);
  268. entries.sort((x, y) => Date.parse(y.mtime) - Date.parse(x.mtime));
  269. return { root: base, entries, truncated };
  270. }
  271. export interface ArtifactTextResult {
  272. content: string;
  273. size: number;
  274. truncated: boolean;
  275. }
  276. /** 读文本内容,超出上限只取前一段 */
  277. export async function readArtifactText(
  278. root: string,
  279. relativePath: string,
  280. maxBytes = MAX_TEXT_BYTES,
  281. ): Promise<ArtifactTextResult> {
  282. const base = await requireAllowedRoot(root);
  283. const target = resolveInside(base, relativePath);
  284. const stat = await fs.stat(target);
  285. if (stat.isDirectory()) throw new Error('目标是目录,无法作为文件读取');
  286. const limit = Math.max(1024, Math.min(Number(maxBytes) || MAX_TEXT_BYTES, MAX_TEXT_BYTES));
  287. const handle = await fs.open(target, 'r');
  288. try {
  289. const length = Math.min(stat.size, limit);
  290. const buffer = Buffer.alloc(length);
  291. await handle.read(buffer, 0, length, 0);
  292. return {
  293. // 二进制文件按 utf8 读出来会是乱码,但预览侧只对文本类调用本方法
  294. content: buffer.toString('utf8'),
  295. size: stat.size,
  296. truncated: stat.size > length,
  297. };
  298. } finally {
  299. await handle.close();
  300. }
  301. }
  302. export interface ArtifactBinaryResult {
  303. base64: string;
  304. mime: string;
  305. size: number;
  306. truncated: boolean;
  307. }
  308. /** 读二进制内容(图片 / PDF),超出上限时截断并标记 */
  309. export async function readArtifactBase64(
  310. root: string,
  311. relativePath: string,
  312. maxBytes = MAX_BINARY_BYTES,
  313. ): Promise<ArtifactBinaryResult> {
  314. const base = await requireAllowedRoot(root);
  315. const target = resolveInside(base, relativePath);
  316. const stat = await fs.stat(target);
  317. if (stat.isDirectory()) throw new Error('目标是目录,无法作为文件读取');
  318. const limit = Math.max(1024, Math.min(Number(maxBytes) || MAX_BINARY_BYTES, MAX_BINARY_BYTES));
  319. const length = Math.min(stat.size, limit);
  320. const handle = await fs.open(target, 'r');
  321. try {
  322. const buffer = Buffer.alloc(length);
  323. await handle.read(buffer, 0, length, 0);
  324. return {
  325. base64: buffer.toString('base64'),
  326. mime: mimeOf(target),
  327. size: stat.size,
  328. truncated: stat.size > length,
  329. };
  330. } finally {
  331. await handle.close();
  332. }
  333. }
  334. /** 另存为:弹系统保存对话框并复制。用户取消时 saved=false */
  335. export async function saveArtifactAs(
  336. root: string,
  337. relativePath: string,
  338. suggestedName?: string,
  339. ): Promise<{ saved: boolean; target: string | null }> {
  340. const base = await requireAllowedRoot(root);
  341. const source = resolveInside(base, relativePath);
  342. const stat = await fs.stat(source);
  343. if (stat.isDirectory()) throw new Error('暂不支持导出目录');
  344. const result = await dialog.showSaveDialog({
  345. title: '另存为',
  346. defaultPath: basename(suggestedName || source),
  347. });
  348. if (result.canceled || !result.filePath) return { saved: false, target: null };
  349. await fs.copyFile(source, result.filePath);
  350. return { saved: true, target: result.filePath };
  351. }
  352. /** 在系统文件管理器中定位该产物 */
  353. export async function revealArtifact(root: string, relativePath: string): Promise<void> {
  354. const base = await requireAllowedRoot(root);
  355. const target = resolveInside(base, relativePath);
  356. if (!(await pathExists(target))) throw new Error('文件不存在');
  357. shell.showItemInFolder(target);
  358. }
  359. /** 用系统默认程序打开该产物 */
  360. export async function openArtifact(root: string, relativePath: string): Promise<void> {
  361. const base = await requireAllowedRoot(root);
  362. const target = resolveInside(base, relativePath);
  363. if (!(await pathExists(target))) throw new Error('文件不存在');
  364. const message = await shell.openPath(target);
  365. if (message) throw new Error(message);
  366. }
  367. /** 用系统文件管理器打开会话工作目录本身 */
  368. export async function openArtifactRoot(root: string): Promise<void> {
  369. const base = await requireAllowedRoot(root);
  370. const message = await shell.openPath(base);
  371. if (message) throw new Error(message);
  372. }
  373. async function pathExists(target: string): Promise<boolean> {
  374. try {
  375. await fs.access(target);
  376. return true;
  377. } catch {
  378. return false;
  379. }
  380. }