首页
/ career-ops 插件注册表维护指南:基于 docs/PLUGIN_REVIEW.md 的评审、信任分级与自动化合并实践

career-ops 插件注册表维护指南:基于 docs/PLUGIN_REVIEW.md 的评审、信任分级与自动化合并实践

2026-09-05 12:26:30作者:廉皓灿Ida

career-ops 的插件注册表是社区代码进入用户机器的唯一关口:CI 负责机械检查,维护者负责安全与适配性判断。本文以 docs/PLUGIN_REVIEW.md(维护者评审指南)为主体,完整还原 "Listed / Bundled" 两级体系、注册表条目格式、CI 确定性门禁(validate-plugin-registry.mjs.github/workflows/plugin-registry-validate.yml)、八项人工评审清单、supersedesBundled 继任者评审规则、自动合并条件与 ToS 灰色集成边界,并结合 plugins/_registry.mjsplugin-install.mjsplugin-audit.mjs 的源码逐条印证评审决策的落地实现。

为什么评审是"真正的控制点"

docs/PLUGIN_REVIEW.md 开篇即定调:注册表的 pinned-SHA(固定提交指针)是唯一咽喉点——社区插件在没有任何已合并的注册表条目之前,永远无法触达任何用户。因此:

  • CI 只做机械检查(形状校验、克隆固定 SHA、静态审计);
  • 维护者做判断类决策(安全意图、能力面、措辞、数据方向)。

这一设计的工程含义在 plugins/_registry.mjs 的模块注释中写得很直白:注册表是 SYSTEM 层区域,"一个条目的存在只意味着维护者已在某个精确固定的提交上审查并合并了它";用户安装时只信任已发布注册表所命名的 pinned SHA,作者无法绕过已合并的 PR 直接触达用户。

两级体系:Listed 与 Bundled

文档将插件分为两级,这是整个评审流程的分流依据:

级别 存储位置 代码位置 特征 适用场景
Listed(默认,低负担) plugins-registry/<id>.json 单个条目文件 作者的 career-ops-plugin-<name> 仓库 用户 add 安装,固定 SHA 绝大多数社区插件
Bundled(提升级) 直接进入 plugins/ 目录 随 career-ops 一同发布、自动更新 附带维护承诺 + config/plugins.example.yml 配置块 + .env.example 条目 广泛有用、低/零密钥、测试完善的插件(apify/gmail/notion 即以此路径被吸收)

Bundled 级别意味着维护承诺:一旦提升,项目就要在核心版本演进中持续保证它工作(下文"参考种子"一节会说明这对功能 PR 的限制)。

注册表条目的实际形态

Listed 条目是 plugins-registry/ 目录下的单个 JSON 文件,文件名必须等于 <id>.json——文件名本身就是无冲突的唯一性保证(两个插件无法在不触碰同一文件的情况下争夺同一个 id)。以 plugins-registry/serper.json 为例:

{
  "id": "serper",
  "name": "career-ops-plugin-serper",
  "version": "0.1.0",
  "license": "MIT",
  "description": "Google Search provider via Serper.dev — drop-in replacement ...",
  "repo": "https://github.com/uelkerd/career-ops-plugin-serper",
  "sha": "ed769c59601d26b6cf2e48cf5e621f7b4f9e5aa5",
  "hooks": ["provider"],
  "requiredEnv": ["SERPER_API_KEY"],
  "allowedHosts": ["google.serper.dev"],
  "registrationIssue": "https://github.com/career-ops-hq/career-ops/issues/1645",
  "addedAt": "2026-07-07"
}

字段与评审要求的对应关系:id 必须等于仓库名去掉 career-ops-plugin- 前缀后的名称;sha 必须是你实际读过的那个 40 位十六进制提交;hooks 是五个钩子类型的子集;allowedHosts 必须是真实公网主机。这些"文档要求"全部由 plugins/_registry.mjs 中的 validateRegistryEntry() 做确定性校验(name 前缀、id 正则 ^[a-z0-9][a-z0-9-]*$、repo 必须是 GitHub URL、sha 必须是 40-hex、license/version 必填、supersedesBundled 若存在必须是布尔 true),评审者无需手工重复。

CI 已经检查了什么:不要手工重做

.github/workflows/plugin-registry-validate.yml 在触碰 plugins-registry/**(或遗留的 plugins-registry.json)的 PR 上触发,其沙箱设计本身就是评审信任的前提:

  • 使用 pull_request而非 pull_request_target)、contents: read 只读权限、不注入任何 secret——克隆并审计提交者代码也永远碰不到 token、写不了仓库;
  • 不执行任何插件代码(manifest 只被解析,审计是纯静态的)。

CI 依次执行三道确定性门禁:

  1. 形状校验node validate-plugin-registry.mjs(无网络)。validate-plugin-registry.mjs 检查:
    • registryVersion 必须为 1;
    • 每个文件都能解析为 JSON 对象,且文件名 === <id>.json
    • name/id 全局唯一(duplicate name / duplicate id);
    • 逐条调用 validateRegistryEntry()(见上节);
    • supersedesBundled: true 反幻影检查:条目的 id 必须对应真实存在的 plugins/<id>/manifest.json,否则报 "no bundled plugin exists to supersede"(validate-plugin-registry.mjs)。
  2. 深度门禁node validate-plugin-registry.mjs --deep——按每个条目的 pinned SHA 克隆仓库并跑最小文件集 + manifest + 静态审计检查,全程在无 secret 的只读沙箱中完成。克隆逻辑在 plugin-install.mjssafeClone()execFileSync 数组参数(永不拼 shell 字符串)、URL 严格白名单到 https://github.com/<owner>/<repo>、禁用 protocol.ext/protocol.file 替代传输、浅拉取精确 SHA(tag 漂移或 force-push 无法换入别的代码)、检出后删除 .git(连同其中的 VCS hooks)。
  3. One plugin per PR:单个注册表 PR 只允许新增或升版恰好一个 plugins-registry/<id>.json(迁移遗留单文件格式的 PR 有一次性豁免)。由于"一个插件一个文件"的存储设计,两个并发注册 PR 永远不会触碰同一行,可任意顺序合并——这正是 plugins/_registry.mjs 注释里说明的、旧单数组格式必然冲突的问题的解法。

CI 红了就停。 文档的原始表述:"If it's red, stop." 以下判断才是评审者要做的事。

人工评审清单:八项判断

1. 命名与身份

仓库名必须是 career-ops-plugin-<name>,条目的 id 等于仓库名去掉前缀后的部分,sha 固定到你实际读过代码的那个提交。后一条件的含义是:你签字的是这个 SHA,作者之后推什么都与已合并条目无关——除非再走一次评审。

2. 阅读 diff

对更新类 PR,读 old→new 的 diff,判断"它是否只做了它声称做的事"。警惕三类模式:time-bombs(条件触发的延迟生效逻辑)、env-gated branches(依据环境变量切换行为的分支)、obfuscation(混淆)。这是纯人工判断,CI 无法替代。

3. 出网面(Egress)

allowedHosts 必须是真实公网主机;不允许 IP 字面量、云元数据地址、*.internal 域名;localhost 只有在陈述理由后才可接受。该约束的运行时落点是引擎的受控 fetch:插件必须走 ctx.fetch(HTTPS-only、钉死在声明的 allowedHostsredirect:'manual 逐跳再校验、主机名变化时剥离凭据,见 plugins/README.md 的 ctx 说明),直接调用全局 fetch 会绕过出网护栏。

4. 能力面(Capability Surface)

  • hooks 必须是五个类型的子集——引擎中这是一个封闭集合 HOOK_KINDS = ['provider', 'ingest', 'search', 'notify', 'export']plugins/_engine.mjs),apply/submit 等任何"代用户提交"的钩子类型不存在
  • requiredEnv 中不得出现核心自有密钥。引擎用 RESERVED_ENV 集合做 manifest 层背板:GEMINI_API_KEYOPENROUTER_API_KEYOPENAI_API_KEYANTHROPIC_API_KEYCAREER_OPS_*PATHHOMENODE_OPTIONSLD_PRELOAD 等,外加 AWS_* 前缀整体禁止(plugins/_engine.mjsRESERVED_ENV 注释明确:目的是防止插件把核心密钥混出去、并让 doctor 把它渲染成合法的 ✓;这是 manifest 审查背板,不是运行时隔离边界);
  • 更新即新增同意面:任何 hooks / env / hosts 的增长都按"新上架"标准重审。运行时也有对应机制:plugins/_lock.mjsdiffPlugin() 会比对锁定文件里已同意的 consentconsentSurface() 记录的 hooks/requiredEnv/allowedHosts),能力面变宽即要求用户重新 node plugins.mjs enable <id> 表态,文件被改而版本未升则要求重新 trust

5. 数据方向

只允许读取公开数据或用户自己的账号数据;不允许中心化基础设施、不允许自动提交(auto-submit)、不允许盲投(blind-apply)。这与项目整体定位一致:career-ops 是决策支持工具,不是 spam bot,humanInTheLoop: true 在 manifest 校验中是强制项(plugins/_engine.mjsvalidateManifest 会直接拒绝 humanInTheLoop !== true 的插件)。

6. 措辞(永久公开)

description / README / skill 文案不得含商业化、托管服务、变现类语言。career-ops 免费且本地优先;"approved" 的含义只是"我们审查过这个提交",不附加任何背书含义。静态审计会把这条规则前移为机器检查:plugin-audit.mjs 内置了 commercial/monetization 措辞的正则(revenue、pricing、paywall、monetiz*、moat),命中即报 "commercial/monetization wording in a shipped community plugin (keep it mission-framed)"。

7. Skill(若有)

skill 文档必须域内收敛:只教如何驱动该插件,不得指示 agent 编辑核心文件、修改评分、泄露密钥、或越过插件钩子行事。

8. 许可证

MIT 兼容(注册表条目的 license 字段必填,CI 形状门禁会校验其存在性)。

静态审计还覆盖了什么(给评审者的对照表)

--deep 门禁背后是 plugin-audit.mjsauditPlugin() 静态启发式扫描(明确自我声明:这是静态启发式而非containment,目的是抬高诚实/懒惰作者的门槛、给评审者一份清单)。它逐文件检查:

  • 禁用模块child_processplaywrightworker_threadsvmnode:http/https/net/dns/tls/dgramnode:clusternode:v8node:inspectornode:repl——社区插件出网只能走 ctx.fetch,不得 spawn/裸 socket;
  • node 内建白名单:仅 node:cryptonode:urlnode:pathnode:buffernode:utilnode:querystringnode:string_decodernode:assertnode:eventsnode:fs(/promises)node:osnode:zlibnode:stream
  • 依赖零容忍:bare-specifier import 直接报错——"registry plugins must be dependency-free (relative + allowlisted node: builtins only)";
  • 直接出网/代码执行原语:全局 fetch(XMLHttpRequest/WebSocketeval(/new Function(process.binding/dlopen

另外最小文件集在 plugin-install.mjs 中固定为 manifest.jsonindex.mjsREADME.mdLICENSE 四个文件,缺一即拒。

评审 supersedesBundled: true 的继任者条目

文档对此给了专门的评审权重——该标记声明"当用户以此 pin 安装时,本插件应优先于同 id 的 bundled 插件":

  1. id 必须匹配 plugins/ 下真实存在的 bundled 插件。给一个不存在的种子指定继任者毫无意义——直接拒绝(CI 的反幻影检查即为此设);
  2. 信任标准与任何注册表条目完全相同(命名、manifest、egress、静态审计、pinned sha),外加一层意识:批准它意味着用户将替换一个已审查的 bundled 集成,因此要对照种子读 diff;
  3. 优先级仅在引擎侧、且仅对以精确 pinned sha 安装的用户生效(引擎的 resolveSuccessorIds() 同时检查安装状态与 sha);bundled 种子永远是兜底——被遗弃的继任者会优雅降级,种子继续在主位(从源码结构看,plugins/_registry.mjssuccessorFor() 只负责在 plugins.mjs list/available展示这层关系,真正的优先级判定留在引擎侧);
  4. 原作者继任是自然且被鼓励的路径——把迁移热络地交给当初贡献了该 bundled 插件的人。

自动合并:只在真正安全时

文档给出了一条 AND 条件链,全部成立才允许自动合并一个更新:

  • 已知作者(2FA + 在 pinned SHA 上有已验证的提交签名);
  • diff 可证明地非逻辑性——仅元数据/版本号/字符串/注释/空白变化,不触碰控制流或任何可执行语句;
  • 新增能力(hooks/env/hosts/deps 均无增长);
  • 所有确定性门禁绿;
  • agentic reviewer 未提出任何标记。

满足后,自动合并也只是让条目以 staged(暂存) 状态落地;用户实际在用的 shipped pin 要等金丝雀窗口结束后才推进。两条硬边界:

  • 首次上架和任何能力或逻辑变更,无论作者多可信,一律人工评审
  • agentic reviewer 只能提升风险(升级给人工),永不批准

ToS 灰色与需认证集成的边界

任何在登录墙后抓取平台、或其服务条款禁止自动化访问的集成(认证态 LinkedIn、会话门控的招聘板),不进 bundled、也不进注册表上架。但它们仍然可以以 career-ops-plugin-<name> 仓库的形式存在,由用户显式安装到 plugins.local/,并伴随完整的 "你正在信任这位作者" 提示——项目不在树内(in-tree)承载这类责任。

这一边界与信任模型的分级一致(plugins/_registry.mjsclassifySource() 给出四级信任徽章):

来源 含义 徽章
bundled 位于 plugins/,树内审查 📦 bundled
approved 位于 plugins.local/ 且已装 sha 与注册表条目一致 ✓ approved
off-registry 装的是社区仓库但与已批准的不同提交 ⚠️ off-registry
unverified 本地安装、不在注册表中(用户自行信任作者) ❓ community-unverified

注意:信任来源只从文件系统(bundled 判断)与实时注册表推导,绝不取自用户可写的 plugins.lock

Bundled 插件是"参考种子":不接受功能 PR

文档最后一段划定了 plugins/ 目录的维护规则:

  • plugins/apifyplugins/gmailplugins/notion(以及 h1b-sponsor 等)是参考种子——已审查、最小、稳定的示例;
  • 拒绝一切功能 PR:对功能请求 close 并 redirect 到"发布 career-ops-plugin-<id>,我们会把它注册为维护中的继任者"(即上文 supersedesBundled 路径);
  • bundled 插件只接受安全或发布兼容性修复 PR(保证种子在核心版本间持续可用)。

结语:评审者的信任边界

整套体系的前提是文档如实声明的诚实边界:career-ops 是纯 ESM、无构建步骤,引擎无法真正沙箱一个插件的 import——allowedHosts、scoped ctx.env 与"无自动提交"的钩子分类学约束的是诚实插件,恶意代码仍可直达 process.env 或网络。因此控制链是:注册表 pinned-SHA 咽喉点 + CI 确定性门禁 + 八项人工判断 + lock 文件的同意/篡改证据 + staged/金丝雀发布。理解这条链,你就能独立评审任何一个注册表 PR,也能判断何时该说"staged 但先别推进"。

延伸阅读(仓库内路径)

登录后查看全文
热门项目推荐
相关项目推荐