feat(runtime): 领域服务运行时随插件分发 + 启动器自动供给(修 P0:同事机器无开发检出就起不来)
CI / contract (push) Has been cancelled

根因:启动器的运行时解析第三顺位是开发检出 ~/Dev/ant/ant-copilot,同事机器没有该目录 → 插件装上、MCP 起不来;而私有 registry 匿名读返回 401,走 npm 安装等于要求每台机器都配 Gitea token。

改法:① 打包产物随插件分发(plugins/<产品>/runtime/,3.96MB 自包含单文件 + manifest 记录来源版本/提交/大小/sha256);② 启动器每次拉起比对 sha256,缺失或被改动即从插件包安装到 ~/.deta/runtime(首次启动零操作、升级自动同步);③ 新增 deta install-runtime(显式安装 + 补装可选依赖 @resvg/resvg-js + 装完立刻做一次真实 MCP 握手自证),status/doctor 显示运行时来源与预览图依赖状态;④ 契约校验加 E6:运行时包必须自洽(sha256/字节数/manifest.pluginVersion 与插件版本一致),拦住“升版本忘重跑 sync-runtime”。

实测:全新 HOME(模拟同事机器、无开发检出、无凭据)下供给探针 10/10 通过 —— 首次启动自动安装、启动行指向沙箱运行时、install-runtime 握手 39 个工具、重复安装幂等、运行时被改动自动修复、插件包缺运行时两处都给打包缺陷指引;已装副本 0.3.14 独立验证(含 npm 自动补装 @resvg 2 包)。studio 0.3.14 / writer 0.1.5。
This commit is contained in:
ringo
2026-09-20 16:40:33 +08:00
parent abb5835310
commit 3ab20c23b0
18 changed files with 192742 additions and 26 deletions
+44
View File
@@ -18,10 +18,15 @@
* —— 那是 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';
@@ -75,6 +80,11 @@ const RULES = {
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,再升插件版本重装',
},
};
const HEADING_BAN = /工具一览|工具清单|工具列表|工具汇总|可用工具|能力一览|能力清单/;
@@ -178,6 +188,40 @@ for (const pluginDir of ['plugins/studio', 'plugins/writer']) {
}
}
// 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 {
// 清单本身不合法由官方校验器报
}
}
if (process.argv.includes('--json')) {
console.log(JSON.stringify({ findings, rules: RULES }, null, 2));
} else {
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env node
/**
* 把领域服务运行时(ant-copilot 的 esbuild 打包产物)同步进插件包。
*
* 为什么随插件分发而不是 npm 安装:`@ringoarch/ant-copilot` 发在私有 Gitea registry 上,
* 匿名 GET 包元数据返回 401 —— 走 npm 路线等于要求每个同事机器上都配一个 Gitea token。
* 打包产物本身是自包含单文件(依赖全内联),把它放进插件包即可做到「装插件 = 拿到运行时」。
*
* 用法:
* node scripts/sync-runtime.mjs # studio + writer
* node scripts/sync-runtime.mjs studio # 只同步指定插件
* ANT_COPILOT_DIR=~/Dev/ant/ant-copilot node scripts/sync-runtime.mjs
*
* 前置:源仓库已构建(`npm run build:mcp` → dist/deta-mcp.mjs)。
* 产物:plugins/<插件>/runtime/dist/deta-mcp.mjs + plugins/<插件>/runtime/manifest.json
* manifest 记录来源版本/提交/大小/sha256 —— 供启动器与 install-runtime 自校验(防拷错、防陈旧)。
*/
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const SRC_REPO = resolve(process.env.ANT_COPILOT_DIR ?? join(process.env.HOME ?? '', 'Dev/ant/ant-copilot'));
const SRC_ENTRY = join(SRC_REPO, 'dist/deta-mcp.mjs');
const plugins = process.argv.slice(2).filter((a) => !a.startsWith('-'));
const targets = plugins.length ? plugins : ['studio', 'writer'];
function die(msg) {
process.stderr.write(`[sync-runtime] ${msg}\n`);
process.exit(1);
}
if (!existsSync(SRC_ENTRY)) {
die(`源打包产物不存在:${SRC_ENTRY}\n 请先在 ${SRC_REPO} 执行 npm run build:mcp`);
}
const bytes = readFileSync(SRC_ENTRY);
const sha256 = createHash('sha256').update(bytes).digest('hex');
const version = JSON.parse(readFileSync(join(SRC_REPO, 'package.json'), 'utf8')).version;
let gitSha = '';
try {
gitSha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: SRC_REPO, encoding: 'utf8' }).trim();
} catch {
gitSha = 'unknown'; // 允许在非 git 检出(如 CI 解包目录)中运行
}
let pluginVersion = {};
const manifest = {
source: '@ringoarch/ant-copilot',
version,
gitSha,
builtAt: new Date().toISOString(),
entry: 'dist/deta-mcp.mjs',
sha256,
bytes: bytes.length,
note: '由 scripts/sync-runtime.mjs 生成;勿手工编辑。安装到 ~/.deta/runtime 由启动器/install-runtime 完成。',
};
for (const name of targets) {
const pluginDir = join(ROOT, 'plugins', name);
const pj = join(pluginDir, '.codex-plugin', 'plugin.json');
if (!existsSync(pj)) die(`插件不存在或清单缺失:${pj}`);
const pv = JSON.parse(readFileSync(pj, 'utf8')).version ?? null;
const outDir = join(pluginDir, 'runtime', 'dist');
mkdirSync(outDir, { recursive: true });
const outEntry = join(outDir, 'deta-mcp.mjs');
copyFileSync(SRC_ENTRY, outEntry);
const out = { ...manifest, plugin: name, pluginVersion: pv };
writeFileSync(join(pluginDir, 'runtime', 'manifest.json'), `${JSON.stringify(out, null, 2)}\n`);
const size = statSync(outEntry).size;
if (size !== bytes.length) die(`写入后大小不一致(${size} != ${bytes.length}):${outEntry}`);
process.stdout.write(
`[sync-runtime] ${name}: ${(size / 1048576).toFixed(2)}MB sha256=${sha256.slice(0, 12)}… `
+ `来自 ${manifest.source}@${version} (${gitSha})\n`,
);
}
process.stdout.write(`[sync-runtime] 完成。提交 runtime/ 后同事装插件即自带运行时。\n`);
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# 探针:运行时供给(模拟同事机器 —— 全新 HOME、无开发检出、无凭据、无 token)
#
# 背景:私有 registry 匿名读 401,运行时因此**随插件包分发**(plugins/<产品>/runtime/),
# 由启动器在首次启动时自动安装到 $HOME/.deta/runtime。本探针断言这条链路的五件事:
# ① 首次启动自动安装(用户零操作)
# ② install-runtime 自证(真实 MCP 握手 + 工具数)
# ③ 幂等(重复安装不重复拷贝)
# ④ 插件包缺运行时 → 报错清楚且指向打包缺陷(不是让用户去配环境)
# ⑤ 运行时被改动/截断 → 下次启动自动修复
#
# 用法:bash scripts/verify-runtime-provision.sh [插件名,缺省 studio]
set -u
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PROFILE="${1:-studio}"
PLUGIN="$ROOT/plugins/$PROFILE"
FRESH="$(mktemp -d /tmp/deta-runtime-probe.XXXXXX)"
pass=0
fail=0
ok() { printf ' PASS %s\n' "$1"; pass=$((pass + 1)); }
no() { printf ' FAIL %s\n' "$1"; fail=$((fail + 1)); }
[ -d "$PLUGIN" ] || { echo "插件不存在:$PLUGIN"; exit 2; }
echo "== 沙箱 HOME=${FRESH}(插件=${PROFILE})"
# ① 首次启动:启动器应自行从插件包安装运行时
OUT="$(HOME="$FRESH" bash "$PLUGIN/bin/deta-mcp.sh" --profile "$PROFILE" < /dev/null 2>&1 || true)"
if printf '%s' "$OUT" | grep -q "已从插件包安装运行时到"; then ok "首次启动自动安装运行时"; else no "未看到自动安装提示"; fi
if printf '%s' "$OUT" | grep -q "运行时=${FRESH}/.deta/runtime"; then ok "启动行指向沙箱运行时"; else no "启动行运行时不对:$(printf '%s' "$OUT" | grep -o '运行时=[^ ]*' | head -1)"; fi
if [ -f "$FRESH/.deta/runtime/dist/deta-mcp.mjs" ]; then ok "运行时文件已落地"; else no "运行时文件缺失"; fi
if [ -f "$FRESH/.deta/runtime/manifest.json" ]; then ok "manifest 随装(可溯源)"; else no "manifest 缺失"; fi
if printf '%s' "$OUT" | grep -q "@resvg/resvg-js"; then ok "缺可选依赖时有明确提示"; else no "未提示可选依赖缺失"; fi
# ② install-runtime:显式安装 + 真实 MCP 握手自证
CLI_OUT="$(HOME="$FRESH" node "$PLUGIN/bin/deta-cli.mjs" install-runtime --no-resvg 2>&1 || true)"
if printf '%s' "$CLI_OUT" | grep -q "MCP 握手通过"; then
ok "install-runtime 握手通过($(printf '%s' "$CLI_OUT" | grep -o '[0-9]\+ 个工具' | head -1))"
else
no "install-runtime 握手失败:$(printf '%s' "$CLI_OUT" | tail -2)"
fi
if printf '%s' "$CLI_OUT" | grep -q "已是最新"; then ok "重复安装幂等(未重复拷贝)"; else no "未识别为已是最新"; fi
# ③ 运行时被改动 → 下次启动自动修复
printf 'corrupted' >> "$FRESH/.deta/runtime/dist/deta-mcp.mjs"
OUT2="$(HOME="$FRESH" bash "$PLUGIN/bin/deta-mcp.sh" --profile "$PROFILE" < /dev/null 2>&1 || true)"
if printf '%s' "$OUT2" | grep -q "已从插件包安装运行时到"; then ok "运行时被改动后自动修复"; else no "未自动修复被改动的运行时"; fi
# ④ 插件包缺运行时(模拟打包缺陷)→ 两处都要给可执行指引
BAD="$FRESH/bad-plugin"
mkdir -p "$BAD"
cp -R "$PLUGIN/bin" "$BAD/bin"
cp -R "$PLUGIN/.codex-plugin" "$BAD/.codex-plugin"
BAD_OUT="$(HOME="$FRESH/bad-home" bash "$BAD/bin/deta-mcp.sh" --profile "$PROFILE" < /dev/null 2>&1 || true)"
if printf '%s' "$BAD_OUT" | grep -q "未找到领域服务运行时"; then ok "缺运行时启动报错清楚"; else no "缺运行时报错不明确"; fi
BAD_CLI="$(HOME="$FRESH/bad-home" node "$BAD/bin/deta-cli.mjs" install-runtime --no-resvg 2>&1 || true)"
if printf '%s' "$BAD_CLI" | grep -q "插件包内没有运行时"; then ok "install-runtime 指出这是打包缺陷"; else no "install-runtime 对缺运行时的指引不对"; fi
echo
echo "结果:${pass} 通过 / ${fail} 失败"
rm -rf "$FRESH"
[ "$fail" -eq 0 ]