codexArtifactService.ts 11 KB

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