diff --git a/docs/plugin-protocol.md b/docs/plugin-protocol.md index 97588dc..a4f67cb 100644 --- a/docs/plugin-protocol.md +++ b/docs/plugin-protocol.md @@ -143,6 +143,44 @@ Plugin 可通过可选的 `locales` 字段声明宿主支持语言对应的包 - 这是 schema v2 的可选追加字段,不需要提升 manifest 版本。旧消费方会按未知 字段忽略并继续使用顶层文案;支持该字段的消费方按宿主语言读取资源。 +### 随包渐进披露手册 + +Plugin 可通过独立顶层字段 `manual` 声明由 Host 按需读取的 Markdown 手册索引: + +```json +{ + "manual": { + "items": [ + { + "dir": "manual/getting-started", + "name": "getting-started", + "description": "安装、连接与首个任务的完整工作流" + } + ] + } +} +``` + +- `manual` 不进入 `slots`,不是运行时能力或权限授权;无手册的既有 Plugin 不受影响。 +- `items` 必须是 1–8 条。`dir` 是包内安全相对目录;`name` 是模型调用 + `ghost_manual` 时使用的逻辑路径首段,沿用小写字母、数字与单连字符分段规则, + 最长 64 字符;`description` 是一级索引说明,长度 1–300 字符。 +- 每个声明目录必须包含固定入口 `MANUAL.md`。目录树可包含任意深度的子目录;所有 + 非目录条目都必须是普通 Markdown 文件,且每个文件不超过 64 KiB。文件存在性、 + 普通文件/非符号链接、严格 UTF-8、大小与二进制拒收由打包、发布及安装侧在读取 + 制品时校验。不同逻辑 `name` 的手册单元允许声明祖先/后代目录。 +- `manual.items[].dir` 与 `ghost.json`、`entry`、`icon`、`settingsHtml`、`panel.html`、 + `node.entry`、`node.entries` 中声明的文件路径不得相同,也不得存在任一方向的 + 祖先/后代嵌套关系。Manifest 层会提前拒绝,避免 Markdown-only 手册目录在打包或 + 装入阶段才因包含已声明的非 Markdown 文件而失败。 +- `manual.items[].dir` 与任一 `locales` 文件路径不得相同,也不得存在任一方向的 + 祖先/后代嵌套关系:Manual 目录不能位于 locale 路径之下,locale 路径也不能位于 + Manual 目录之下。Manifest 层会直接拒绝这类声明,避免 Markdown-only 手册目录在 + 打包或装入阶段才因包含 locale JSON 而失败。 +- 这是 schema v2 的 append-only 可选顶层字段,不需要提升 manifest 版本。旧消费方会 + 忽略该字段,Plugin 的其它能力继续可用;首个依赖手册才能正确工作的 Release 应声明 + `minCindyVersion`,并在支持 `ghost_manual` 的 Cindy 发布后再投递。 + ### Node Worker 凭证绑定 声明了 `node` 槽的插件可以通过 `node.secretBindings` 请求主机把用户凭证安全持久化,并仅在指定 Worker 入口和 JSON-RPC 方法同时命中时临时注入: diff --git a/packages/plugin-protocol/src/__tests__/manifest.test.ts b/packages/plugin-protocol/src/__tests__/manifest.test.ts index 60101ba..79c6c4e 100644 --- a/packages/plugin-protocol/src/__tests__/manifest.test.ts +++ b/packages/plugin-protocol/src/__tests__/manifest.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest'; import { + GHOST_MANUAL_DESCRIPTION_MAX_CHARS, + GHOST_MANUAL_ENTRY_FILE, + GHOST_MANUAL_MAX_ITEMS, + GHOST_MANUAL_MD_MAX_BYTES, GHOST_MANIFEST_SUMMARY_MAX_CHARS, GHOST_MANIFEST_SCHEMA_VERSION, GHOST_OAUTH_SCOPES_MAX, @@ -25,6 +29,13 @@ const validManifest = { } as const; describe('Ghost manifest contract', () => { + it('exports the manual authoring limits', () => { + expect(GHOST_MANUAL_MAX_ITEMS).toBe(8); + expect(GHOST_MANUAL_ENTRY_FILE).toBe('MANUAL.md'); + expect(GHOST_MANUAL_MD_MAX_BYTES).toBe(64 * 1024); + expect(GHOST_MANUAL_DESCRIPTION_MAX_CHARS).toBe(300); + }); + it('accepts and normalizes a valid schema v2 manifest', () => { const result = validateGhostManifest(validManifest); expect(result).toEqual({ ok: true, manifest: validManifest }); @@ -518,6 +529,39 @@ describe('Ghost manifest contract', () => { }); }); + it('rejects locale and manual paths with either nesting direction', () => { + const validatePaths = (localePath: string, manualDir: string) => + validateGhostManifest({ + ...validManifest, + locales: { en: localePath }, + manual: { + items: [{ dir: manualDir, name: 'guide', description: 'Manual guide.' }], + }, + }); + + expect(validatePaths('content/en.json', 'content/en.json/manual')).toMatchObject({ + ok: false, + reason: expect.stringContaining('与插件其他声明文件'), + }); + expect(validatePaths('manual/docs/en.json', 'manual/docs')).toMatchObject({ + ok: false, + reason: expect.stringContaining('与插件其他声明文件'), + }); + expect(validatePaths('manual/en.json', 'manual/en.json')).toMatchObject({ + ok: false, + reason: expect.stringContaining('与插件其他声明文件'), + }); + expect(validatePaths('locales/en.json', 'manual/docs')).toMatchObject({ + ok: true, + manifest: expect.objectContaining({ + locales: { en: 'locales/en.json' }, + manual: { + items: [{ dir: 'manual/docs', name: 'guide', description: 'Manual guide.' }], + }, + }), + }); + }); + it('rejects Windows reserved device names in ids and relative paths', () => { expect(isValidGhostId('con')).toBe(false); expect(isValidGhostId('com1')).toBe(false); @@ -1056,6 +1100,192 @@ describe('Ghost manifest contract', () => { ).toMatchObject({ ok: false, reason: expect.stringContaining('重复 dir') }); }); + it('accepts a top-level manual index without requiring a slot', () => { + const items = [ + { + dir: 'manual/getting-started', + name: 'getting-started', + description: '按需读取的入门工作流', + }, + ]; + const result = validateGhostManifest({ ...validManifest, manual: { items } }); + expect(result).toMatchObject({ + ok: true, + manifest: { + slots: ['tool'], + manual: { items }, + }, + }); + }); + + it('enforces manual items shape, limits, and case-folded dedupe', () => { + const item = (patch: Record) => + validateGhostManifest({ + ...validManifest, + manual: { + items: [ + { + dir: 'manual/getting-started', + name: 'getting-started', + description: 'x', + ...patch, + }, + ], + }, + }); + + expect(validateGhostManifest({ ...validManifest, manual: { items: [] } }).ok).toBe(false); + expect(validateGhostManifest({ ...validManifest, manual: {} }).ok).toBe(false); + expect(validateGhostManifest({ ...validManifest, manual: { items: [], extra: true } }).ok).toBe( + false, + ); + expect(item({ extra: true }).ok).toBe(false); + const nine = Array.from({ length: 9 }, (_, index) => ({ + dir: `manual/unit-${index}`, + name: `unit-${index}`, + description: 'x', + })); + expect(validateGhostManifest({ ...validManifest, manual: { items: nine } })).toMatchObject({ + ok: false, + reason: expect.stringContaining('最多 8 条'), + }); + + expect(item({ dir: '../manual' }).ok).toBe(false); + expect(item({ dir: 'manual\\guide' }).ok).toBe(false); + expect(item({ name: 'GettingStarted' }).ok).toBe(false); + expect(item({ name: 'getting--started' }).ok).toBe(false); + expect(item({ name: 'a'.repeat(65) }).ok).toBe(false); + expect(item({ name: 'a'.repeat(64) }).ok).toBe(true); + expect(item({ description: '' }).ok).toBe(false); + expect(item({ description: ' '.repeat(3) }).ok).toBe(false); + expect(item({ description: 'x'.repeat(301) }).ok).toBe(false); + expect(item({ description: 'x'.repeat(300) }).ok).toBe(true); + + expect( + validateGhostManifest({ + ...validManifest, + manual: { + items: [ + { dir: 'manual/a', name: 'guide', description: 'x' }, + { dir: 'manual/b', name: 'guide', description: 'y' }, + ], + }, + }), + ).toMatchObject({ ok: false, reason: expect.stringContaining('重复 name') }); + expect( + validateGhostManifest({ + ...validManifest, + manual: { + items: [ + { dir: 'manual/A', name: 'guide-a', description: 'x' }, + { dir: 'manual/a', name: 'guide-b', description: 'y' }, + ], + }, + }), + ).toMatchObject({ ok: false, reason: expect.stringContaining('重复 dir') }); + }); + + it('intentionally allows nested manual item directories with distinct logical names', () => { + const items = [ + { dir: 'manual', name: 'overview', description: 'Overview.' }, + { dir: 'manual/advanced', name: 'advanced', description: 'Advanced topics.' }, + ]; + + expect(validateGhostManifest({ ...validManifest, manual: { items } })).toMatchObject({ + ok: true, + manifest: expect.objectContaining({ manual: { items } }), + }); + }); + + it('rejects manual directories that contain or descend from declared file paths', () => { + const withManual = (manifest: Record, dir: string) => + validateGhostManifest({ + ...manifest, + manual: { items: [{ dir, name: 'guide', description: 'Manual guide.' }] }, + }); + const declaredFileCases: Array<{ + label: string; + manifest: Record; + dir: string; + }> = [ + { label: 'ghost.json', manifest: { ...validManifest }, dir: 'ghost.json' }, + { + label: 'entry', + manifest: { ...validManifest, entry: 'manual/entry/main.js' }, + dir: 'manual/entry', + }, + { + label: 'icon', + manifest: { ...validManifest, icon: 'manual/icon/icon.png' }, + dir: 'manual/icon', + }, + { + label: 'settingsHtml', + manifest: { ...validManifest, settingsHtml: 'manual/settings/settings.html' }, + dir: 'manual/settings', + }, + { + label: 'panel.html', + manifest: { + ...validManifest, + slots: ['tool', 'panel'], + panel: { html: 'manual/panel/panel.html', position: 'tab' }, + }, + dir: 'manual/panel', + }, + { + label: 'node.entry', + manifest: { + ...validManifest, + slots: ['tool', 'node'], + node: { entry: 'manual/node/main.cjs', protocol: 'mcp-stdio' }, + }, + dir: 'manual/node', + }, + { + label: 'node.entries', + manifest: { + ...validManifest, + slots: ['tool', 'node'], + node: { + entry: 'node/main.cjs', + entries: ['manual/node-extra/child.cjs'], + protocol: 'mcp-stdio', + }, + }, + dir: 'manual/node-extra', + }, + ]; + + for (const { label, manifest, dir } of declaredFileCases) { + expect(withManual(manifest, dir), label).toMatchObject({ + ok: false, + reason: expect.stringContaining('与插件声明文件路径'), + }); + } + + expect( + withManual({ ...validManifest, entry: 'assets/main.js' }, 'assets/main.js/manual'), + ).toMatchObject({ + ok: false, + reason: expect.stringContaining('与插件声明文件路径'), + }); + expect( + withManual({ ...validManifest, entry: 'Manual/Case/main.js' }, 'manual/case'), + ).toMatchObject({ + ok: false, + reason: expect.stringContaining('与插件声明文件路径'), + }); + expect(withManual({ ...validManifest, entry: 'src/main.js' }, 'manual/guide')).toMatchObject({ + ok: true, + manifest: expect.objectContaining({ + manual: { + items: [{ dir: 'manual/guide', name: 'guide', description: 'Manual guide.' }], + }, + }), + }); + }); + it('validates card and agent capability details', () => { expect( validateGhostManifest({ diff --git a/packages/plugin-protocol/src/manifest.ts b/packages/plugin-protocol/src/manifest.ts index 244d4c8..568389a 100644 --- a/packages/plugin-protocol/src/manifest.ts +++ b/packages/plugin-protocol/src/manifest.ts @@ -735,6 +735,15 @@ export const GHOST_SKILL_NAME_MAX_CHARS = 64; */ export const GHOST_SKILL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +/** manual:单插件最多声明的渐进披露手册单元数。 */ +export const GHOST_MANUAL_MAX_ITEMS = 8; +/** manual:每个声明单元固定的入口文件名。 */ +export const GHOST_MANUAL_ENTRY_FILE = 'MANUAL.md'; +/** manual:单个 Markdown 文件的字节上限。打包与装入两侧共用。 */ +export const GHOST_MANUAL_MD_MAX_BYTES = 64 * 1024; +/** manual:一级索引说明的字符上限。 */ +export const GHOST_MANUAL_DESCRIPTION_MAX_CHARS = GHOST_MANIFEST_SUMMARY_MAX_CHARS; + /** skill 槽单条技能声明(全声明式:确认框展示的就是这里的字段)。 */ export interface GhostSkillItem { /** 包内技能目录(安全相对路径,目录内必须有 SKILL.md)。 */ @@ -756,6 +765,21 @@ export interface GhostSkillNeeds { items: GhostSkillItem[]; } +/** manual 单条手册声明。name 是模型调用时使用的逻辑路径首段,dir 是包内物理目录。 */ +export interface GhostManualItem { + /** 包内手册目录(安全相对路径,目录内必须有 MANUAL.md)。 */ + dir: string; + /** 逻辑名称;沿用 skill name 的小写连字符规则。 */ + name: string; + /** ghost_info / ghost_manual 根索引展示的手册说明。 */ + description: string; +} + +/** 插件随包提供、由 Host 按需读取的渐进披露手册索引。 */ +export interface GhostManualNeeds { + items: GhostManualItem[]; +} + /** ghost.json 清单(不变量由 validateGhostManifest 保证)。 */ export interface GhostManifest { /** 清单格式版本,恒 2(v1 声明型已于 2026-07-12 移除,无存量不留兼容)。 */ @@ -875,6 +899,11 @@ export interface GhostManifest { * 本地化(必须与 SKILL.md 逐字一致,见 GhostSkillItem)。 */ skill?: GhostSkillNeeds; + /** + * 随包渐进披露手册。它不是能力 slot 或授权项;Host 只把索引投影给模型, + * 正文经 ghost_manual 按需读取。旧客户端忽略这一可选顶层字段。 + */ + manual?: GhostManualNeeds; } /** 判断已校验的 manifest 是否请求 Host 托管的企业身份凭证。 */ @@ -1066,6 +1095,21 @@ export function validateGhostManifest(raw: unknown): ManifestValidation { ) { return { ok: false, reason: 'author 必须是 1–64 字符的非空字符串' }; } + const declaredFilePathFolds = [ + GHOST_MANIFEST_FILE, + raw.entry, + raw.icon, + raw.settingsHtml, + isPlainObject(raw.panel) ? raw.panel.html : undefined, + isPlainObject(raw.node) ? raw.node.entry : undefined, + ...(isPlainObject(raw.node) && Array.isArray(raw.node.entries) ? raw.node.entries : []), + ] + .filter((value): value is string => typeof value === 'string') + .map((value) => value.toLowerCase()); + const isSameOrDescendant = (path: string, ancestor: string): boolean => + path === ancestor || path.startsWith(`${ancestor}/`); + const pathsConflict = (left: string, right: string): boolean => + isSameOrDescendant(left, right) || isSameOrDescendant(right, left); let locales: GhostManifest['locales']; if (raw.locales !== undefined) { if (!isPlainObject(raw.locales)) { @@ -1085,25 +1129,18 @@ export function validateGhostManifest(raw: unknown): ManifestValidation { } const normalized: Partial> = {}; const seenPaths: string[] = []; - const nonLocaleFilePaths = [ - GHOST_MANIFEST_FILE, - raw.entry, - raw.icon, - raw.settingsHtml, - isPlainObject(raw.panel) ? raw.panel.html : undefined, - isPlainObject(raw.node) ? raw.node.entry : undefined, - ...(isPlainObject(raw.node) && Array.isArray(raw.node.entries) ? raw.node.entries : []), - ].filter((value): value is string => typeof value === 'string'); - const nonLocaleFilePathFolds = nonLocaleFilePaths.map((value) => value.toLowerCase()); const skillDirFolds = ( - isPlainObject(raw.skill) && Array.isArray(raw.skill.items) - ? raw.skill.items.map((item) => (isPlainObject(item) ? item.dir : undefined)) - : [] + isPlainObject(raw.skill) && Array.isArray(raw.skill.items) ? raw.skill.items : [] + ) + .map((item) => (isPlainObject(item) ? item.dir : undefined)) + .filter((value): value is string => typeof value === 'string') + .map((value) => value.toLowerCase()); + const manualDirFolds = ( + isPlainObject(raw.manual) && Array.isArray(raw.manual.items) ? raw.manual.items : [] ) + .map((item) => (isPlainObject(item) ? item.dir : undefined)) .filter((value): value is string => typeof value === 'string') .map((value) => value.toLowerCase()); - const isSameOrDescendant = (path: string, ancestor: string): boolean => - path === ancestor || path.startsWith(`${ancestor}/`); for (const locale of GHOST_LOCALES) { const localePath = raw.locales[locale]; if (localePath === undefined) continue; @@ -1118,15 +1155,16 @@ export function validateGhostManifest(raw: unknown): ManifestValidation { }; } const normalizedLocalePath = localePath.toLowerCase(); - const conflictsWithFile = nonLocaleFilePathFolds.some( - (path) => - isSameOrDescendant(path, normalizedLocalePath) || - isSameOrDescendant(normalizedLocalePath, path), + const conflictsWithFile = declaredFilePathFolds.some((path) => + pathsConflict(path, normalizedLocalePath), ); const conflictsWithSkillDir = skillDirFolds.some((dir) => isSameOrDescendant(dir, normalizedLocalePath), ); - if (conflictsWithFile || conflictsWithSkillDir) { + const conflictsWithManualDir = manualDirFolds.some((dir) => + pathsConflict(dir, normalizedLocalePath), + ); + if (conflictsWithFile || conflictsWithSkillDir || conflictsWithManualDir) { return { ok: false, reason: `locales.${locale} 路径 ${JSON.stringify(localePath)} 与插件其他声明文件大小写折叠后冲突`, @@ -2818,6 +2856,96 @@ export function validateGhostManifest(raw: unknown): ManifestValidation { return { ok: false, reason: 'slots 声明了 "skill" 但缺少 skill 详单(items 技能清单必填)' }; } + // manual 是独立顶层字段,不是能力 slot 或授权项。这里只校验一级逻辑索引; + // MANUAL.md 存在性、Markdown 文本与逐文件 64KB 上限由打包/装入两侧校验。 + let manual: GhostManualNeeds | undefined; + if (raw.manual !== undefined) { + if (!isPlainObject(raw.manual)) { + return { + ok: false, + reason: + 'manual 必须是对象(如 { "items": [{ "dir": "manual/getting-started", "name": "getting-started", "description": "..." }] })', + }; + } + const manualRaw = raw.manual as Record; + const unknownManualField = Object.keys(manualRaw).find((key) => key !== 'items'); + if (unknownManualField !== undefined) { + return { ok: false, reason: `manual 含不允许的字段 ${JSON.stringify(unknownManualField)}` }; + } + if (!Array.isArray(manualRaw.items) || manualRaw.items.length === 0) { + return { ok: false, reason: 'manual.items 必须是非空数组(随包手册索引)' }; + } + if (manualRaw.items.length > GHOST_MANUAL_MAX_ITEMS) { + return { ok: false, reason: `manual.items 最多 ${GHOST_MANUAL_MAX_ITEMS} 条` }; + } + const manualItems: GhostManualItem[] = []; + const seenManualNames = new Set(); + const seenManualDirs = new Set(); + for (const item of manualRaw.items) { + if (!isPlainObject(item)) { + return { ok: false, reason: 'manual.items 每项必须是对象({ dir, name, description })' }; + } + const itemRaw = item as Record; + const unknownItemField = Object.keys(itemRaw).find( + (key) => key !== 'dir' && key !== 'name' && key !== 'description', + ); + if (unknownItemField !== undefined) { + return { + ok: false, + reason: `manual.items 条目含不允许的字段 ${JSON.stringify(unknownItemField)}`, + }; + } + if (!isSafeGhostRelativePath(itemRaw.dir)) { + return { + ok: false, + reason: `manual.items[].dir 必须是包内安全相对路径(如 "manual/getting-started"),得到 ${JSON.stringify(itemRaw.dir)}`, + }; + } + const dirFold = itemRaw.dir.toLowerCase(); + if (declaredFilePathFolds.some((path) => pathsConflict(dirFold, path))) { + return { + ok: false, + reason: `manual.items[].dir ${JSON.stringify(itemRaw.dir)} 与插件声明文件路径大小写折叠后冲突`, + }; + } + if ( + typeof itemRaw.name !== 'string' || + itemRaw.name.length > GHOST_SKILL_NAME_MAX_CHARS || + !GHOST_SKILL_NAME_RE.test(itemRaw.name) + ) { + return { + ok: false, + reason: `manual.items[].name 必须是小写字母/数字加单连字符分段(禁首尾/连续连字符)、长度 1–${GHOST_SKILL_NAME_MAX_CHARS},得到 ${JSON.stringify(itemRaw.name)}`, + }; + } + if ( + typeof itemRaw.description !== 'string' || + itemRaw.description.trim().length === 0 || + itemRaw.description.length > GHOST_MANUAL_DESCRIPTION_MAX_CHARS + ) { + return { + ok: false, + reason: `manual.items[].description 必须是 1–${GHOST_MANUAL_DESCRIPTION_MAX_CHARS} 字符的非空字符串`, + }; + } + const nameFold = itemRaw.name.toLowerCase(); + if (seenManualNames.has(nameFold)) { + return { ok: false, reason: `manual.items 含重复 name ${JSON.stringify(itemRaw.name)}` }; + } + seenManualNames.add(nameFold); + if (seenManualDirs.has(dirFold)) { + return { ok: false, reason: `manual.items 含重复 dir ${JSON.stringify(itemRaw.dir)}` }; + } + seenManualDirs.add(dirFold); + manualItems.push({ + dir: itemRaw.dir, + name: itemRaw.name, + description: itemRaw.description, + }); + } + manual = { items: manualItems }; + } + // 显式触发指令:1–32 字符、无空白、无 '/'(允许中文,如 /画图); // 必须有工具可干活。跨意识查重在装入时由 GhostManager 执行(需要本地清单)。 if (raw.command !== undefined) { @@ -2895,6 +3023,7 @@ export function validateGhostManifest(raw: unknown): ManifestValidation { ...(panel !== undefined ? { panel } : {}), ...(preview !== undefined ? { preview } : {}), ...(skill !== undefined ? { skill } : {}), + ...(manual !== undefined ? { manual } : {}), }, }; }