Files
deta-agent-plugins/scripts/check-plugin-contract.mjs
T
ringo 1d1000ddf5
CI / contract (push) Has been cancelled
feat(hosts): WorkBuddy 接入(install-workbuddy:本机 stdio + 技能安装 + 握手自证)
WorkBuddy 自定义 MCP 支持 command/args/env(取证:其二进制 calculateConfigHash 先判 entry.command),因此走与 Codex 同款的本机 stdio full 工具面,不退远程引擎面。

落地:① 启动器多维护一个 MCP 稳定入口 ~/.deta/bin/deta-mcp-<档位>(指向当前插件副本,升级自动刷新;WB 信任哈希按命令行算,路径稳定=升级不重弹信任);② deta-cli 新增 install-workbuddy [--http]——备份并合并 ~/.workbuddy/mcp.json(同名旧条目与旧远程条目替换掉,防双工具面)、把插件内全部技能装进 ~/.workbuddy/skills/(单一事实源,带 icon.svg)、用宿主将执行的同一条命令做真实握手自证;③ aml-design 技能两处 Codex 特有措辞改宿主中立;④ 契约校验加 E7(装配源齐备 + README 指引);⑤ 探针 scripts/verify-workbuddy.sh(配置形态/稳定入口/技能/握手/WB 日志,5 项断言)。

实测:studio 0.3.16 已装副本重跑幂等通过(握手 39 工具);writer 0.1.7 在沙箱 HOME 验证(deta_writer + report-authoring 技能 + 握手 39 工具)。WB 侧需重启并点一次信任确认。
2026-09-20 17:13:15 +08:00

267 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* 插件仓契约校验(仓库职责章程的机器化部分)
*
* 章程:本仓是**宿主适配与分发层**,零逻辑、不承载契约。
* 能力(工具实现 / MCP 协议 / 启动形态 / 工具面裁剪 / 领域技能)唯一来源在 ant-copilot;
* 本仓只保留「宿主清单 + 连接声明 + 启动器 + 宿主侧投影」。
*
* 规则:
* E1 技能文档不得出现「工具一览 / 工具清单」类章节标题
* —— 工具面随部署形态裁剪(engine / full),任何手写清单必然过期;
* 取清单的唯一途径是运行时调 `list_capabilities`。
* E2 技能文档必须给出「取当前形态工具名」的途径(出现 `list_capabilities`)
* —— 正面要求,零误报:只要技能不许自带清单、又必须教模型去哪查,
* 工具面增删就与本文档解耦。(曾试过"段落内枚举 ≥5 个标识符"的启发式,
* 误报率高——流程散文里正常提及工具名会被误判,已弃用。)
* E3 插件 README 不得解释领域服务内部常量
* —— 那是 ant-copilot 的实现细节,文档越界即漂移源。
* W1 不得出现开发检出路径(~/Dev/ant/ant-copilot)
* —— 本机引擎包落地后升级为错误(见章程 M2)。
* E6 运行时包必须自洽:runtime/dist/deta-mcp.mjs 存在,其 sha256/字节数与 runtime/manifest.json 一致,
* 且 manifest.pluginVersion 等于插件清单版本
* —— 运行时随插件分发(私有 registry 匿名读 401,走 npm 会让每台机器都要 token),
* 因此「升了版本忘了重跑 sync-runtime」会静默发出发错版本的运行时;这条规则把它变成红灯。
*
* 用法:node scripts/check-plugin-contract.mjs [--json]
* 退出码:有 error 时 1,否则 0(warning 不失败)。
*/
import { createHash } from 'node:crypto';
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
import { join, relative, basename, resolve } from 'node:path';
const ROOT = new URL('..', import.meta.url).pathname.replace(/\/$/, '');
const PLUGINS = join(ROOT, 'plugins');
/** 本目录是工具自身,不参与内容校验(其注释里必然出现被禁模式) */
const SKIP_DIRS = new Set(['node_modules', '.git', 'scripts']);
/** 遍历目录,返回全部文件绝对路径 */
function walk(dir) {
const out = [];
for (const name of readdirSync(dir)) {
if (SKIP_DIRS.has(name)) continue;
const p = join(dir, name);
const st = statSync(p);
if (st.isDirectory()) out.push(...walk(p));
else out.push(p);
}
return out;
}
const RULES = {
E1: {
level: 'error',
what: '技能文档出现「工具一览 / 工具清单」类章节标题',
hint: '删掉清单,改为「不确定有哪些工具时调 list_capabilities」——工具面按形态裁剪,手写清单必然过期',
},
E2: {
level: 'error',
what: '技能文档没有给出取当前形态工具名的途径(缺 list_capabilities 指引)',
hint: '在技能开头加一句:不确定有哪些工具时先调 list_capabilities 拿完整工具名,再按名字直接调用',
},
E3: {
level: 'error',
what: '插件 README 解释领域服务内部常量',
hint: 'ant-copilot 的实现细节不进本仓文档;本仓只写安装 / 连接 / 排障',
},
E4: {
level: 'error',
what: '市场条目与实际插件目录不一致(悬空条目或名称不匹配)',
hint: '条目 name 必须等于该目录 .codex-plugin/plugin.json 的 name,path 必须真实存在——改名/加产品时同步改市场文件',
},
W1: {
level: 'warning',
what: '出现开发检出路径(~/Dev/ant/ant-copilot)',
hint: '启动器应随包分发运行时;本机引擎包落地后本规则升级为 error',
},
E5: {
level: 'error',
what: '插件品牌标(interface.logo / logoDark)缺失、路径越出插件根或文件不存在',
hint: '插件清单用 logo / logoDark 指向插件根内相对路径(如 ./assets/logo.svg,深色主题给 logoDark);'
+ '注意 iconSmall/iconLarge 是**技能**的字段,插件里写了不生效',
},
E6: {
level: 'error',
what: '运行时包不自洽(缺 runtime/、sha256 或字节数与 manifest 不符、manifest.pluginVersion 与插件版本不符)',
hint: '运行时随插件分发:改动 ant-copilot 后跑 node scripts/sync-runtime.mjs,再升插件版本重装',
},
E7: {
level: 'error',
what: 'WorkBuddy 接入的装配源不齐:插件 skills/ 下没有技能目录,或 README 未写 install-workbuddy',
hint: 'install-workbuddy 把插件内技能装进 ~/.workbuddy/skills/(单一事实源),且 README 必须给用户这条路径',
},
};
const HEADING_BAN = /工具一览|工具清单|工具列表|工具汇总|可用工具|能力一览|能力清单/;
/**
* 只认「赋值/缺省」形态(`VAR=` 开头)——那才是"解释内部语义"的信号;
* 裸写常量名的行常常是章程里在说"不要解释它",或启动器在转发自己的用户级旋钮,
* 一律放行(宁可漏报,不要把自己的章程判成违规)。
*/
const INTERNAL_CONST = /\b(?:DETA_TOOL_SURFACE|DETA_MCP_PORT|DETA_GSA_URL)\s*=|dist\/src\/headless|dist\/deta-mcp/;
const DEV_PATH = /(?:\$HOME|~|\/Users\/[^/\s]+)\/Dev\/ant\/ant-copilot/;
const findings = [];
const add = (rule, file, line, evidence) =>
findings.push({ rule, level: RULES[rule].level, file: relative(ROOT, file), line, evidence: evidence.trim() });
const files = walk(ROOT);
for (const file of files) {
const isSkill = file.includes(`${join('skills', '')}`) && basename(file) === 'SKILL.md';
const isReadme = basename(file).toLowerCase().startsWith('readme');
const isText = /\.(md|ya?ml|json|mjs|sh|ts)$/.test(file);
let text;
try {
text = readFileSync(file, 'utf8');
} catch {
continue;
}
const lines = text.split('\n');
lines.forEach((line, i) => {
const n = i + 1;
// E1:技能文档的章节标题
if (isSkill && /^#{2,6}\s/.test(line) && HEADING_BAN.test(line)) add('E1', file, n, line);
// E3:README 里的领域服务内部常量
if (isReadme && INTERNAL_CONST.test(line)) add('E3', file, n, line);
// W1:开发检出路径
if (isText && DEV_PATH.test(line)) add('W1', file, n, line);
});
// E2:技能必须教模型去哪取工具名(正面要求,零误报)
if (isSkill && !text.includes('list_capabilities')) {
add('E2', file, 1, '全文未出现 list_capabilities');
}
}
// E4:市场条目 ↔ 插件目录一致性(改名 / 加产品时防悬空条目)
for (const rel of ['.agents/plugins/marketplace.json', '.agents/plugins/api_marketplace.json']) {
const abs = join(ROOT, rel);
if (!existsSync(abs)) continue;
let market;
try {
market = JSON.parse(readFileSync(abs, 'utf8'));
} catch (e) {
add('E4', abs, 1, `市场文件不是合法 JSON:${e.message}`);
continue;
}
for (const entry of market.plugins ?? []) {
const relPath = String(entry?.source?.path ?? '');
const manifest = join(resolve(ROOT, relPath), '.codex-plugin', 'plugin.json');
if (!relPath || !existsSync(manifest)) {
add('E4', abs, 1, `条目 name=${entry?.name ?? '?'} 指向的插件目录不存在或缺插件清单:${relPath || '(空 path)'}`);
continue;
}
let name = '';
try {
name = String(JSON.parse(readFileSync(manifest, 'utf8')).name ?? '');
} catch {
// 清单本身不合法由 validate_plugin.py 报,这里不重复
}
if (name !== entry.name) {
add('E4', abs, 1, `市场条目 name=${entry.name} 与插件清单 name=${name} 不一致`);
}
}
}
// E5:插件品牌标(logo / logoDark)必须落在插件根内且真实存在
for (const pluginDir of ['plugins/studio', 'plugins/writer']) {
const manifest = join(ROOT, pluginDir, '.codex-plugin', 'plugin.json');
if (!existsSync(manifest)) continue;
let itf;
try {
itf = JSON.parse(readFileSync(manifest, 'utf8')).interface ?? {};
} catch {
continue; // 清单不合法由 validate_plugin.py 报
}
for (const key of ['logo', 'logoDark']) {
const val = String(itf[key] ?? '');
if (!val) {
add('E5', manifest, 1, `interface.${key} 未设置(品牌标应指向 ./assets/logo.svg / ./assets/logo-dark.svg)`);
continue;
}
if (/^https?:/i.test(val)) continue; // 远端地址形态
if (val.includes('..') || !val.startsWith('./')) {
add('E5', manifest, 1, `${key} 必须是插件根内相对路径(收到 ${val})`);
continue;
}
if (!existsSync(join(ROOT, pluginDir, val.replace(/^\.\//, '')))) {
add('E5', manifest, 1, `${key} 指向的文件不存在:${val}`);
}
}
}
// E6:运行时包自洽(插件自带打包产物 → 装插件即可用;防「升版本忘重跑 sync-runtime」多发错版本)
for (const pluginDir of ['plugins/studio', 'plugins/writer']) {
const pluginRoot = join(ROOT, pluginDir);
const entry = join(pluginRoot, 'runtime', 'dist', 'deta-mcp.mjs');
const mPath = join(pluginRoot, 'runtime', 'manifest.json');
if (!existsSync(entry) || !existsSync(mPath)) {
add('E6', pluginDir, 1, '缺少运行时包(runtime/dist/deta-mcp.mjs + runtime/manifest.json)——跑 node scripts/sync-runtime.mjs');
continue;
}
let m;
try {
m = JSON.parse(readFileSync(mPath, 'utf8'));
} catch (e) {
add('E6', mPath, 1, `manifest.json 解析失败:${e.message}`);
continue;
}
const buf = readFileSync(entry);
const sha = createHash('sha256').update(buf).digest('hex');
if (sha !== m.sha256) {
add('E6', mPath, 1, `运行时 sha256 与 manifest 不一致(文件 ${sha.slice(0, 12)}… ≠ manifest ${String(m.sha256).slice(0, 12)}…)——重跑 sync-runtime`);
}
if (buf.length !== m.bytes) {
add('E6', mPath, 1, `运行时字节数与 manifest 不一致(${buf.length} ≠ ${m.bytes})`);
}
try {
const pv = JSON.parse(readFileSync(join(pluginRoot, '.codex-plugin', 'plugin.json'), 'utf8')).version;
if (m.pluginVersion !== pv) {
add('E6', mPath, 1, `manifest.pluginVersion=${m.pluginVersion} 与插件版本 ${pv} 不一致——升版本后必须重跑 sync-runtime`);
}
} catch {
// 清单本身不合法由官方校验器报
}
}
// E7:WorkBuddy 接入的装配源(插件技能目录)与 README 指引
for (const pluginDir of ['plugins/studio', 'plugins/writer']) {
const skillsDir = join(ROOT, pluginDir, 'skills');
let count = 0;
if (existsSync(skillsDir)) {
for (const name of readdirSync(skillsDir)) {
if (existsSync(join(skillsDir, name, 'SKILL.md'))) count += 1;
}
}
if (count === 0) {
add('E7', skillsDir, 1, 'skills/ 下没有任何含 SKILL.md 的技能目录(install-workbuddy 的装配源缺失)');
}
}
{
const readmePath = join(ROOT, 'README.md');
const readme = existsSync(readmePath) ? readFileSync(readmePath, 'utf8') : '';
if (!readme.includes('install-workbuddy')) {
add('E7', readmePath, 1, 'README 未写 install-workbuddy(WorkBuddy 用户没有安装路径)');
}
}
if (process.argv.includes('--json')) {
console.log(JSON.stringify({ findings, rules: RULES }, null, 2));
} else {
for (const f of findings) {
const mark = f.level === 'error' ? '✗' : '!';
console.log(`${mark} [${f.rule}] ${f.file}:${f.line}`);
console.log(` ${f.evidence.slice(0, 140)}`);
if (f.level === 'error') console.log(` → ${RULES[f.rule].hint}`);
}
const errs = findings.filter((f) => f.level === 'error').length;
const warns = findings.length - errs;
console.log(`\n契约校验:${errs} 个错误 / ${warns} 个警告`);
if (!findings.length) console.log('✓ 全部通过');
}
process.exit(findings.some((f) => f.level === 'error') ? 1 : 0);