cc 2 tuần trước cách đây
mục cha
commit
062ca9c16c

+ 29 - 1
ai-electron/electron/service/codex/skillService.test.ts

@@ -1,6 +1,6 @@
 import { mkdir, mkdtemp, readdir, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises';
 import { tmpdir } from 'node:os';
-import { join } from 'node:path';
+import { join, resolve } from 'node:path';
 import { crc32 } from 'node:zlib';
 import { afterEach, beforeEach, describe, expect, it, vi, type TestContext } from 'vitest';
 import {
@@ -317,6 +317,13 @@ describe('remove', () => {
     expect(await stat(join(skillsDir, 'demo-skill')).catch(() => null)).toBeNull();
   });
 
+  it('按 UI 回传的 SKILL.md 路径删除整个 skill 目录', async () => {
+    const source = await writeSkillSource(join(root, 'src'));
+    await service.installFromFolder(source);
+    await service.remove({ path: join(skillsDir, 'demo-skill', 'SKILL.md') });
+    expect(await stat(join(skillsDir, 'demo-skill')).catch(() => null)).toBeNull();
+  });
+
   it('拒绝删除内置目录', async () => {
     const systemDir = join(skillsDir, '.system', 'imagegen');
     await mkdir(systemDir, { recursive: true });
@@ -400,6 +407,27 @@ describe('read', () => {
     expect(result.content).toContain('demo-skill');
     expect(result.files.map((file) => file.path).sort()).toEqual(['SKILL.md', 'notes.md']);
   });
+
+  it('内置 skill 可查(path 给目录或 SKILL.md 都认),但仍不可删改', async () => {
+    const systemDir = join(skillsDir, '.system', 'imagegen');
+    await mkdir(systemDir, { recursive: true });
+    await writeFile(join(systemDir, 'SKILL.md'), SKILL_MD, 'utf8');
+
+    // Codex 的 skills/list 回的是 SKILL.md 文件路径,UI 原样回传
+    for (const path of [join(systemDir, 'SKILL.md'), systemDir]) {
+      const result = await service.read({ path });
+      expect(result.dir).toBe(resolve(systemDir));
+      expect(result.content).toContain('demo-skill');
+    }
+
+    await expect(service.remove({ path: join(systemDir, 'SKILL.md') })).rejects.toThrow(/内置或暂存目录/);
+  });
+
+  it('越界路径与缺 SKILL.md 仍被拒绝', async () => {
+    await expect(service.read({ path: root })).rejects.toThrow(/skills 目录/);
+    await expect(service.read({ path: join(skillsDir, 'nope') })).rejects.toThrow(/SKILL\.md/);
+    await expect(service.read({})).rejects.toThrow(/path 或 name/);
+  });
 });
 
 describe('assertSafeRelativePath(第二道防线)', () => {

+ 34 - 2
ai-electron/electron/service/codex/skillService.ts

@@ -92,7 +92,7 @@ export class SkillService {
   }
 
   async read(target: SkillTarget): Promise<SkillReadResult> {
-    const dir = await this.#resolveManagedDir(target);
+    const dir = await this.#resolveReadableDir(target);
     const content = await readFile(join(dir, SKILL_FILE_NAME), 'utf8');
     const files = await collectFiles(dir);
     return { dir, content, files };
@@ -169,7 +169,7 @@ export class SkillService {
   async #resolveManagedDir(target: SkillTarget): Promise<string> {
     const skillsDir = this.#skillsDir;
     if (target.path) {
-      const dir = resolve(target.path);
+      const dir = resolve(toSkillDir(target.path));
       assertManagedSkillDir(skillsDir, dir);
       const manifest = await stat(join(dir, SKILL_FILE_NAME)).catch(() => null);
       if (!manifest?.isFile()) throw new Error('该目录下没有 SKILL.md');
@@ -186,6 +186,30 @@ export class SkillService {
     throw new Error('必须提供 skill 的 path 或 name');
   }
 
+  /**
+   * 只读解析:内置(.system/.curated/.experimental)与暂存目录允许「查看」,只是不可删改。
+   * 与 {@link #resolveManagedDir} 的区别就是不要求一级子目录、也不拦点号目录,
+   * 边界仍然锁在 skills 目录内且必须有 SKILL.md。
+   */
+  async #resolveReadableDir(target: SkillTarget): Promise<string> {
+    const skillsDir = this.#skillsDir;
+    let dir: string;
+    if (target.path) {
+      dir = resolve(toSkillDir(target.path));
+    } else if (target.name) {
+      // 点号目录进不了 normalizeSkillName,所以按 name 只能查到用户自装 skill
+      dir = join(skillsDir, normalizeSkillName(target.name));
+    } else {
+      throw new Error('必须提供 skill 的 path 或 name');
+    }
+    if (!isInsideDir(skillsDir, dir)) throw new Error('只能查看 skills 目录内的 skill');
+    const manifest = await stat(join(dir, SKILL_FILE_NAME)).catch(() => null);
+    if (!manifest?.isFile()) {
+      throw new Error(target.path ? '该目录下没有 SKILL.md' : `未找到名为 ${target.name} 的 skill`);
+    }
+    return dir;
+  }
+
   async #resolveSelector(target: SkillTarget): Promise<{ path?: string; name?: string }> {
     if (target.path) return { path: target.path };
     if (target.name) return { name: normalizeSkillName(target.name) };
@@ -220,6 +244,14 @@ export class SkillService {
   }
 }
 
+/**
+ * Codex 的 skills/list 与 skills/config 都用 SKILL.md 文件路径标识一个 skill(见 scripts/codex-ipc-probe.json),
+ * UI 把该 path 原样回传,这里先收敛成目录,否则 join(dir, 'SKILL.md') 会拼出不存在的路径。
+ */
+function toSkillDir(pathOrFile: string): string {
+  return basename(pathOrFile) === SKILL_FILE_NAME ? dirname(pathOrFile) : pathOrFile;
+}
+
 /** 内置目录(.system/.curated/.experimental)与暂存目录一律不可管理 */
 function isManagedSkillPath(skillsDir: string, skillPath: string): boolean {
   if (!isInsideDir(skillsDir, skillPath)) return false;

+ 2 - 1
ai-electron/frontend/src/core/layouts/default/header/index.vue

@@ -52,7 +52,8 @@
       <SettingDrawer v-if="getShowSetting" />
 
       <LogoutButton class="logout-item" />
-      <UserDropdown class="user-dropdown-item" />
+      <!-- UserDropdown 是多根节点(Dropdown + 两个 Modal),传 class 无法继承会被丢弃,只会产生告警 -->
+      <UserDropdown />
       <span :class="`${prefixCls}-search-divider`"></span>
       <WindowControls class="window-controls-item" />
     </div>