Skip to content
This repository was archived by the owner on Aug 11, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/plugin-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 是模型调用
Comment thread
fmfsaisai marked this conversation as resolved.
`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 方法同时命中时临时注入:
Expand Down
230 changes: 230 additions & 0 deletions packages/plugin-protocol/src/__tests__/manifest.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 });
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<string, unknown>) =>
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<string, unknown>, dir: string) =>
validateGhostManifest({
...manifest,
manual: { items: [{ dir, name: 'guide', description: 'Manual guide.' }] },
});
const declaredFileCases: Array<{
label: string;
manifest: Record<string, unknown>;
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({
Expand Down
Loading
Loading