diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index cedb736..2081dfb 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -26,10 +26,11 @@ review contract is .greptile/rules.md. 质量与安全项来自 README 审查标 - [ ] Reviewed the complete diff — no credentials, tokens, authorization codes, real user data, unrelated generated files, or `node_modules`. 已 review 完整 diff —— 无凭证、令牌、授权码、真实用户数据、无关生成文件或 `node_modules`。 -- [ ] `ghost.json` declares only capabilities actually used (network hosts / - slots / secrets); any new capability is justified in this PR description. - `ghost.json` 只声明实际用到的能力(网络域名 / slots / 密钥);新增能力已在 - PR 描述中说明必要性。 +- [ ] `ghost.json` declares only capabilities actually used (direct capability + fields / autonomous network hosts / secrets / Node runtime); any new + capability is justified in this PR description. + `ghost.json` 只声明实际用到的能力(直接能力字段 / 自主网络域名 / 密钥 / Node + Runtime);新增能力已在 PR 描述中说明必要性。 - [ ] Tools with irreversible external side effects (send / post / delete) handle every failure path as "not executed / executed / unknown", and never suggest a blind retry on "unknown". @@ -42,11 +43,15 @@ review contract is .greptile/rules.md. 质量与安全项来自 README 审查标 changed; the new SemVer is greater than the version on `main`. 每个打包内容发生变化的插件都已 bump `ghost.json` 的 `version`,且新 SemVer 大于 `main` 上的当前版本。 -- [ ] If `minCindyVersion` was added or raised, installed the packaged `.cindy` - on that exact Cindy version and recorded the result below. Lowering or - removing it is explicitly called out for maintainer review. - 如新增或提高 `minCindyVersion`,已在该精确 Cindy 版本安装真实包并在下方记录; - 如降低或删除,已明确标注交维护者人工 review。 +- [ ] **Production Cindy verification / 生产版 Cindy 验证** — Installed every + changed plugin's packaged `.cindy` on a real device running a stable + production Cindy build and verified its core functionality. When a plugin + declares `minCindyVersion`, the verified Cindy version was greater than or + equal to it. 已在运行正式稳定版 Cindy 的实际设备上安装每个改动插件的真实 + `.cindy` 包并验证核心功能;插件声明 `minCindyVersion` 时,验证所用 Cindy + 版本不低于该最低版本。 +- [ ] Lowering or removing `minCindyVersion` is explicitly called out for + maintainer review. 降低或删除 `minCindyVersion` 已明确标注交维护者人工 review。 - [ ] For a new plugin, linked the acknowledged proposal issue and recorded the `provisioning.json` audience decision below. 新插件已关联维护者确认过的提案 issue,并在下方记录 audience 决策。 @@ -71,20 +76,17 @@ Agent behaviour — that description is the manual the Agent reads. ## Verification / 验证 ## Sensitive changes / 敏感变更 diff --git a/.github/workflows/pr-verify.yml b/.github/workflows/pr-verify.yml index 5b30022..7d97ee8 100644 --- a/.github/workflows/pr-verify.yml +++ b/.github/workflows/pr-verify.yml @@ -7,6 +7,7 @@ on: - synchronize - reopened - ready_for_review + - edited branches: - main @@ -30,9 +31,12 @@ jobs: - name: Validate official plugin publish contract env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: node --test .tests/plugin-contract.test.mjs + run: | + # Event base.sha can lag behind the checked-out PR merge commit. + test "$(git rev-parse HEAD^2)" = "${HEAD_SHA}" + export BASE_SHA="$(git rev-parse HEAD^1)" + node --test .tests/plugin-contract.test.mjs - name: Validate plugin localization resources run: node --test .tests/localization.test.mjs @@ -56,13 +60,15 @@ jobs: - name: Detect changed plugin directories id: changes env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | set -euo pipefail - git ls-tree -r --name-only "${HEAD_SHA}" \ + # Use both parents of the checked-out merge, not event base.sha. + test "$(git rev-parse HEAD^2)" = "${HEAD_SHA}" + BASE_SHA="$(git rev-parse HEAD^1)" + git ls-tree -r --name-only HEAD \ | sed -n 's|/ghost\.json$||p' | sort -u > "${RUNNER_TEMP}/plugin-roots" - git diff --name-only --no-renames --diff-filter=AMD "${BASE_SHA}" "${HEAD_SHA}" \ + git diff --name-only --no-renames --diff-filter=AMD "${BASE_SHA}" HEAD \ > "${RUNNER_TEMP}/changed-files" if grep -Eq '^(LICENSE|NOTICE|TRADEMARKS\.md|TRADEMARKS\.zh-CN\.md)$' \ "${RUNNER_TEMP}/changed-files"; then @@ -140,3 +146,14 @@ jobs: done echo "::endgroup::" done + + - name: Require production Cindy verification attestation + if: ${{ steps.changes.outputs.plugins != '[]' }} + run: | + set -euo pipefail + pr_body="$(jq -r '.pull_request.body // ""' "${GITHUB_EVENT_PATH}")" + if ! grep -Eq '^- \[[xX]\] \*\*Production Cindy verification / 生产版 Cindy 验证\*\*' \ + <<<"${pr_body}"; then + echo "::error::Check the Production Cindy verification / 生产版 Cindy 验证 box in the PR body after installing and exercising every changed plugin on a real device with a stable production Cindy build. If a plugin declares minCindyVersion, the verified Cindy version must be greater than or equal to it." + exit 1 + fi diff --git a/.greptile/config.json b/.greptile/config.json index 4705b89..4bbccb6 100644 --- a/.greptile/config.json +++ b/.greptile/config.json @@ -54,7 +54,7 @@ "includeConfidenceScore": true, "shouldUpdateDescription": false, "ignorePatterns": "**/.DS_Store\n**/THIRD-PARTY-LICENSES.txt", - "instructions": "这是 Cindy 官方插件仓:每个合入 main 的插件都会自动提交到 CN/Global 的 Plugin Platform 审核队列,批准后才对对应区域用户可见;安全与体验风险由真实用户承担,审查标准从严。审查依据按优先级:本目录 rules.md 的完整规则 > README.md 设计原则 > 一般工程判断。对安全类规则(凭证、网络边界、子进程、vendor):结论必须给出核查证据(查了哪些文件、grep 了什么、结果如何),不允许只写『未发现问题』。summary 首行必须显式列出所有未解决的 P1(如有),不要让『可安全合并』的措辞掩盖挂起的阻断项。以下变更属于敏感审查路由:新增插件目录;删除/重命名已有插件目录或修改已发布 id;新增、升级或删除第三方依赖及 vendor/dist/worker 生成物;新增/替换无法由文本 review 验证内容的图片、二进制文件或可执行资源;扩大 OAuth/API scope;修改凭证注入、认证或授权边界;新增 network.hosts、secret、slot、tool、Node 或子进程能力;降低或删除 minCindyVersion;放宽 provisioning audience;修改发布 workflow、OIDC、权限或 secrets;修改 .greptile/ 或 AGENTS.md 审查策略。仅修改版本号或 name/description/author/icon 路径等纯展示文本元数据不属于敏感变更。命中敏感审查路由时必须将 PR 风险标为 high 或 critical,使 riskCeiling 阻止自动 approve,并在 summary 首行标注『敏感变更,需要维护者人工 review』;审查路由本身不是实现缺陷,如果没有发现真实代码问题,不得仅因需要人工 review 而生成 P1/行级评论、降低 confidence score 或使 status check 失败。评论语言跟随 PR 作者的主语言:根据 PR 描述、commit message 与作者历史评论判断——中文作者用简体中文回复,英文作者用英文回复,其他语言作者用英文;无法判断时用英文。规则条文本身是中文不代表必须用中文回复。", + "instructions": "这是 Cindy 官方插件仓:每个合入 main 的插件都会自动提交到 CN/Global 的 Plugin Platform 审核队列,批准后才对对应区域用户可见;安全与体验风险由真实用户承担,审查标准从严。审查依据按优先级:本目录 rules.md 的完整规则 > README.md 设计原则 > 一般工程判断。对安全类规则(凭证、网络边界、子进程、vendor):结论必须给出核查证据(查了哪些文件、grep 了什么、结果如何),不允许只写『未发现问题』。summary 首行必须显式列出所有未解决的 P1(如有),不要让『可安全合并』的措辞掩盖挂起的阻断项。以下变更属于敏感审查路由:新增插件目录;删除/重命名已有插件目录或修改已发布 id;新增、升级或删除第三方依赖及 vendor/dist/worker 生成物;新增/替换无法由文本 review 验证内容的图片、二进制文件或可执行资源;扩大 OAuth/API scope;修改凭证注入、认证或授权边界;新增 network.hosts、secret、直接能力字段、tool、Node 或子进程能力;降低或删除 minCindyVersion;放宽 provisioning audience;修改发布 workflow、OIDC、权限或 secrets;修改 .greptile/ 或 AGENTS.md 审查策略。仅修改版本号或 name/description/author/icon 路径等纯展示文本元数据不属于敏感变更。命中敏感审查路由时必须将 PR 风险标为 high 或 critical,使 riskCeiling 阻止自动 approve,并在 summary 首行标注『敏感变更,需要维护者人工 review』;审查路由本身不是实现缺陷,如果没有发现真实代码问题,不得仅因需要人工 review 而生成 P1/行级评论、降低 confidence score 或使 status check 失败。评论语言跟随 PR 作者的主语言:根据 PR 描述、commit message 与作者历史评论判断——中文作者用简体中文回复,英文作者用英文回复,其他语言作者用英文;无法判断时用英文。规则条文本身是中文不代表必须用中文回复。", "rules": [ { "id": "no-hardcoded-secrets", @@ -69,12 +69,12 @@ { "id": "network-allowlist", "severity": "high", - "rule": "main.js/settings.js/worker 中所有网络访问目标必须命中该插件 ghost.json 的 network.hosts 白名单。发现未声明的域名/IP、硬编码内网或调试地址(localhost/127.x/192.168.x/10.x)、或绕过 cindy.fetch 的直连(原生 fetch 到外部域名/XMLHttpRequest/WebSocket/EventSource)必须按 P1 指出。白名单本身新增条目时,必须能在 PR 描述或源码中找到必要性出处。例外:声明了 node slot 的插件,其 node/worker.cjs 的网络访问按 node 能力边界审查(固定 IMAP/SMTP/API 端点),不适用 network.hosts 白名单要求;此类审查参照 capability-minimization 规则。" + "rule": "插件自主联网(Panel/订阅/scheduler/常驻进程等脱离当前 Agent 调用的场景)的所有目标必须命中 ghost.json 的 network.hosts 白名单。当前 Agent 工具调用内的普通 HTTPS 可以经 cindy.fetch 携带 Host 下发且严格在途的 callId 复用 Agent 授权,review 不得因未声明该域名误报;此途径不得注入未在 manifest 声明并命中目标 host 的托管凭证。发现硬编码内网或调试地址(localhost/127.x/192.168.x/10.x)、或绕过 cindy.fetch 的直连(原生 fetch/XMLHttpRequest/WebSocket/EventSource)必须按 P1 指出。自主白名单新增条目时,必须能在 PR 描述或源码中找到必要性出处。例外:声明顶层 node 字段的插件,其 node/worker.cjs 网络按 node 自主能力边界审查(固定 IMAP/SMTP/API 端点)。" }, { "id": "capability-minimization", "severity": "high", - "rule": "ghost.json 变更时:slots/node/network.hosts 只能最小化声明,新增任何能力(新 slot、新域名、新 secret、新 tool)必须在 PR 描述中有对应必要性说明。声明 node slot 的插件必须固定 entry/entries 入口;childSpawn 产生的子进程边界必须最小——固定命令、参数不可注入外部输入。任何 spawn/exec 调用路径的变更(包括从受控封装改回原生 spawn)都必须分析新的参数注入面并在评论中给出结论。" + "rule": "ghost.json 变更时:所有直接能力字段、node/network.hosts 只能最小化声明,新增任何能力(新顶层能力字段、新域名、新 secret、新 tool)必须在 PR 描述中有对应必要性说明。声明 node 的插件必须固定 entry/entries 入口;childSpawn 产生的子进程边界必须最小——固定命令、参数不可注入外部输入。任何 spawn/exec 调用路径的变更(包括从受控封装改回原生 spawn)都必须分析新的参数注入面并在评论中给出结论。新插件和实际打包内容有变化的现有插件必须使用 schemaVersion 3、按该插件实际依赖填写首个稳定版 minCindyVersion、移除 slots;仓库不设置统一 Cindy 版本下限,未触及的 v2 插件不得被要求批量迁移。" }, { "id": "irreversible-side-effect-tri-state", @@ -115,7 +115,7 @@ { "id": "minimum-client-version-evidence", "severity": "high", - "rule": "新增或提高 minCindyVersion 时,PR Verification 必须记录真实打包 .cindy 在声明的精确最低 Cindy 版本上安装成功,并列出实际验证的核心功能;缺失证据按 P1 要求补齐。降低或删除 minCindyVersion 会扩大支持范围,必须转维护者人工 review,不能只凭静态 CI 放行。" + "rule": "每个改动插件包的 PR Body 都必须勾选生产版 Cindy 验证项,确认真实打包 .cindy 已在运行正式稳定版 Cindy 的实际设备上安装并验证核心功能;CI 会确定性检查该勾选项。插件声明 minCindyVersion 时,验证所用 Cindy 版本必须不低于它。降低或删除 minCindyVersion 会扩大支持范围,必须转维护者人工 review,不能只凭静态 CI 放行。" } ] } diff --git a/.greptile/rules.md b/.greptile/rules.md index 20d59f3..8960200 100644 --- a/.greptile/rules.md +++ b/.greptile/rules.md @@ -8,6 +8,15 @@ ## 设计契约 +### 授权跟随执行者 + +当前 Agent 工具调用内的普通 HTTPS 与 workdir 文件操作,使用 Host 下发且严格在途的 +`callId` 复用 Cindy 既有 Agent 授权;CLI 继续走已有 Node 工作进程,工具是否执行由 +当前 `ghost_call` 的既有 Agent 授权决定。不得仅为了预登记具体命令、域名或路径新增 +Slot 或 Manifest 字段。只有 Panel、订阅、scheduler、常驻进程等脱离当前 Agent 调用的 +自主 Host 能力,才必须在 `ghost.json` 直接声明;自主 Node Runtime 仍须声明顶层 +`node`、固定入口和最小子进程边界。Host 托管凭证也仍须按声明守门。 + ### tool description 即契约 `ghost.json` 里每个 tool 的 `description` 与顶层 `whenToUse` 是 Agent 读到的唯一 @@ -82,13 +91,20 @@ manifest/icon/locale/Skill/Manual 单文件上限、安全路径、大小写路 `x-manager`);携带 Manual 的插件必须声明 `minCindyVersion`。不要用 Greptile 对 manifest schema 的猜测替代 CI 的确定性校验结果。 +`.tests/plugin-contract.test.mjs` 同时接受未改动的 legacy v2 与合法 v3;但新插件, +以及实际打包内容发生变化的现有插件,必须在同一 PR 迁移到 `schemaVersion: 3`、 +按该插件实际依赖填写首个稳定版 `minCindyVersion`、移除 `slots` 并保持直接能力声明等价; +仓库不设置统一 Cindy 版本下限。 +只改仓库级文档、CI 或其它插件时,不得要求顺手迁移无关的 v2 清单。 + ### 最低客户端版本 -`minCindyVersion` 表示这个 release 能被安装和运行的最低 Cindy 版本。新增或提高 -该字段时,PR 描述必须记录真实打包 `.cindy` 在声明的精确版本上安装成功,并列出实际 -验证的核心功能;没有证据按 P1 要求补齐。降低或删除该字段会扩大支持范围,静态 CI -无法证明旧客户端可用,必须转维护者人工 review。未声明字段的旧插件继续按现有兼容 -语义处理,不要求为了补字段而批量修改。 +每个改动插件包的 PR Body 都必须勾选生产版 Cindy 验证项,确认真实打包 `.cindy` 已在 +运行正式稳定版 Cindy 的实际设备上安装并验证核心功能;CI 会确定性检查该勾选项。 +`minCindyVersion` 表示这个 release 能被安装和运行的最低 Cindy 版本,插件声明该字段 +时,验证所用 Cindy 版本必须不低于它。降低或删除该字段会扩大支持范围,静态 CI 无法 +证明旧客户端可用,必须转维护者人工 review。未声明字段的旧插件继续按现有兼容语义 +处理,不要求为了补字段而批量修改。 ### 发布与审核链路 @@ -120,6 +136,8 @@ PR 新增插件目录(出现新的 `ghost.json`)时,必须通读仓内现 新插件 PR 缺以下任何一项时逐条指出: +- `ghost.json` 使用 `schemaVersion: 3`、按该插件实际依赖填写首个稳定版 `minCindyVersion`、 + 不含 `slots`,并以直接顶层字段声明能力; - `provisioning.json` 有对应条目,且 audience 取值有 PR 描述里的决策依据 (尤其 `"all"`); - 四语言 locale 资源齐全(zh-CN/en/ja/ko,`.tests/localization.test.mjs` 口径); @@ -130,6 +148,10 @@ PR 新增插件目录(出现新的 `ghost.json`)时,必须通读仓内现 - PR 描述包含实机验证说明(在 Cindy 客户端安装 `.cindy` 包实测过哪些工具); 没有实测的必须如实标注,reviewer 应在 summary 里显式提示「未经实机验证」。 +现有 v2 插件不做专项批量迁移;但 PR 一旦改变该插件目录内会进入 `.cindy` 的实际 +打包内容,就必须在同一 PR 把清单迁移到 v3,并保持能力等价。只改仓库级文档、CI 或 +其它插件时,不得要求顺手迁移未触及的 v2 清单。 + ## 其他判定口径 ### 图片资源 diff --git a/.tests/contracts/plugin-manifest.dbbf169.mjs b/.tests/contracts/plugin-manifest.dae1c66.mjs similarity index 59% rename from .tests/contracts/plugin-manifest.dbbf169.mjs rename to .tests/contracts/plugin-manifest.dae1c66.mjs index 8b90437..8c40cb8 100644 --- a/.tests/contracts/plugin-manifest.dbbf169.mjs +++ b/.tests/contracts/plugin-manifest.dae1c66.mjs @@ -1,28 +1,96 @@ -// Generated from makecindy/cindy-protocol@dbbf1697037c6025541ab4bae38f906982628423 -// packages/plugin-protocol/src/manifest.ts. Do not edit this snapshot by hand. -// Licensed under Apache-2.0; see .tests/contracts/NOTICE. +// Generated from Cindy packages/plugin-protocol/src/manifest.ts +// source sha256: dae1c66de845889a98745ea876be48e540fdc8fa68084d526495135b82d2a8e4 +// Do not edit this snapshot by hand. Licensed under Apache-2.0; see NOTICE. +/** `.cindy` 包根目录中的 manifest 文件名。 */ export const GHOST_MANIFEST_FILE = 'ghost.json'; +/** 意识文件扩展名。 */ export const CINDY_FILE_EXT = '.cindy'; -export const GHOST_MANIFEST_SCHEMA_VERSION = 2; +/** 新建 ghost.json 使用的格式版本;与 Plugin HTTP API envelope 版本独立演进。 */ +export const GHOST_MANIFEST_SCHEMA_VERSION = 3; +/** ghost.json 的 description / whenToUse 字符上限。 */ export const GHOST_MANIFEST_SUMMARY_MAX_CHARS = 300; +/** Cindy host locales supported by Plugin manifest resources. */ export const GHOST_LOCALES = ['zh-CN', 'en', 'ja', 'ko']; +/** Maximum size of one locale JSON resource in a `.cindy` package. */ export const GHOST_LOCALE_MAX_BYTES = 64 * 1024; +/** + * 意识 id 规则:小写字母/数字开头,后续允许小写字母/数字/连字符,总长 1–32。 + * 收紧到这个集合并排除 Windows 设备保留名,因为 id 直接用作安装目录名 + * (userData/brain/),必须在 macOS / Windows 双平台都是安全的文件夹名, + * 且天然杜绝路径穿越。 + */ const GHOST_ID_RE = /^[a-z0-9][a-z0-9-]{0,31}$/; +/** Windows 设备保留名;带扩展名时仍不能作为文件或目录名。 */ const WINDOWS_RESERVED_NAME_RE = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i; function isWindowsReservedName(name) { return WINDOWS_RESERVED_NAME_RE.test(name); } -export const GHOST_SLOTS = [ +/** + * schemaVersion 2 能力名清单,仅用于兼容旧包。 + * 'cindy' = 请 Cindy 本体代办(借主机自带 AI 能力干活;2026-07-11 定案 + * 由 'model' 更名——本质是 Cindy 在干活,与选模型无关;旧名在校验层作 + * 静默别名兼容,已装老包不消失)。 + * 'network' = 意识自带服务:域名白名单 + * 内的 HTTP 经主机代发(沙箱本身保持零直连),凭证锁主机保险库按声明注入。 + * 'notify' = 系统提示(2026-07-14):意识经管子请主机弹一条轻提示(toast), + * 意识只供纯文本,整块 UI 主机画并带意识身份头(与订阅槽红条同一信任边界); + * 无阻塞、无按钮、无回执——确认类交互不在此槽(走面板自绘或卡槽③交互卡)。 + * 'fs' = 写文件(2026-07-14):意识经管子请主机代写文件(创建/修改)。三档 + * 目的地:自己的私有数据目录(userData/ghost-fs/,免确认)、当前会话 + * workdir(跟随会话 permission 模式:免批模式直写、逐条模式弹确认卡、 + * plan/只读拒)、save 票据目录(主 agent 过户,复用 saveDeposit 预算)。 + * 字节永远由主机落盘,沙箱本身仍无 fs——本槽是"申请主机代写"的资格, + * 不是文件系统访问权。 + * 'main-view' = 应用级插件主视图:宿主只挂载已批准 manifest 声明的包内 + * HTML 入口。它与会话内 panel 独立授权,本身不附赠网络、文件或 + * 凭证能力。 + * 'session-context' = 会话上下文(2026-07-23):agent 派活(tool-call)时,主机把 + * 当次会话的可信 {session_id, workdir, workdir_is_local} 注入 args.session_context。 + * 只注入宿主认证过的事实,插件与 agent 自报的同名字段一律被剥除;远程工作区 + * (workdir 不在本机)时 workdir_is_local=false,插件不得把它当本机路径用。 + * 'pick' = 目录选择(2026-07-23):插件经管子申请主机弹**系统级**选文件夹窗口, + * 用户亲手选中即授权(与浏览器文件选择同一哲学:决定权在用户的点击上)。 + * 返回票据(dir_deposit,同 ghost_call dir 通道);声明了 node 槽的插件额外 + * 拿到绝对路径(Node 侧本就有用户级本机权限,路径保密无意义,可信才是重点)。 + * 'preview' = 面板预览(2026-07-23):插件经管子申请在右侧栏内置浏览器里打开 + * 一个网址标签页。网址范围装入时在 preview.hosts 白名单里定死(同 network + * 域名白名单语法),运行期主机逐次校验,范围外一律拒——防钓鱼是结构性的。 + * 'skill' = 捆绑 Agent Skills(2026-07-25):插件随包携带 SKILL.md 技能目录, + * 装入且启用后由主机链接进共享技能根 ~/.agents/skills/--(win32 用 + * junction),Claude Code 与 Codex 都能发现。信任面与其它槽完全不同量级:技能 + * 指令由主 Agent 以**用户全部权限**执行、对所有项目与会话生效、不受插件沙箱 + * 约束,也不随"某工作目录停用本插件"而隐藏——仅全局停用/卸载才撤链。因此 + * manifest 全声明式(items 的 name/description 必须与 SKILL.md frontmatter 逐字 + * 一致,打包与装入双侧强制),插件详情逐条列出并置于能力清单最上部。 + * 'workspace' = 工作区会话(2026-07-25):插件请主机在指定本机项目目录下确保 + * 存在一个会话入口并显示在侧边栏——目录下已有 active 会话即复用,没有才创建 + * 空 draft 会话(不拉起 agent 进程)。目录授权两条路:系统选文件夹窗口亲选 + * 即授权(pick 模式,路径不回沙箱),或 tool-call 语境下带在途 callId + 绝对 + * 路径(目录在该会话 workdir 内自动放行,workdir 外弹确认卡)。远程工作区 + * v1 一律拒(fail closed)。 + * 'ios-simulator' = Host 托管的内嵌 iOS 模拟器入口:插件只能读取当前任务的 + * 脱敏状态并请求 Host 打开控制面板。视频帧、输入、设备标识、Native Helper、 + * 生命周期与恢复均不跨插件边界,仍由 Cindy Host 独占管理。 + * + * 以下名称只用于 schemaVersion 2 的兼容校验。schemaVersion 3 使用顶层直接 + * 字段声明能力,不再提供 slots。未知 v2 slot 会被保留供兼容诊断,但不会 + * 被猜测成某个 Host 能力,也不会阻止安装。 + */ +export const LEGACY_GHOST_SLOTS = [ 'subscribe', 'tool', 'card', 'panel', + 'main-view', 'cindy', 'agent', 'node', 'network', 'notify', + 'badge', + 'confirm', 'fs', + 'library', 'session-context', 'pick', 'preview', @@ -30,59 +98,165 @@ export const GHOST_SLOTS = [ 'workspace', 'ios-simulator', ]; +/** v2 slot 只约束可安全展示/传输的形状。 */ +const GHOST_SLOT_NAME_RE = /^[a-z][a-z0-9._:-]{0,127}$/; +/** + * 电子脑启动模式(2026-07-12): + * - 'on-demand'(缺省):被需要才拉起(agent 派活 / 面板 /wake / 重载); + * - 'resident':唤醒即启动——enable 时拉起,应用启动时对"已唤醒"者拉起。 + * 两种模式下沉睡 / 抽离 / 更新换代一律先熄灯,规则不变;resident 会在装入 + * 确认框多一行如实告知(常驻 = 用户要背一个后台进程,必须知情)。 + */ export const GHOST_LAUNCH_MODES = ['on-demand', 'resident']; +/** + * 面板显示形态(相对主聊天窗)。left / right = 顶层布局树停靠 pane; + * 'tab' = 不进布局树,作为右侧栏(right-tabs)里的每会话单例页签 + * (2026-07-24 定案)。top / bottom 需要嵌套上下分割(树操作/拖缝/卸载 + * 查找全链路),排期中——校验层先收词并明确拒绝,不静默降级(规则 9)。 + */ export const GHOST_PANEL_POSITIONS = ['left', 'right', 'tab']; +/** 应用级插件主视图可用的 Cindy 系统线性图标。枚举值与图标名保持一致。 */ +export const GHOST_MAIN_VIEW_ICONS = [ + 'puzzle', + 'globe', + 'code', + 'folder', + 'database', + 'chart-column', + 'image', + 'message-circle', + 'calendar-days', +]; +/** 插件随包本地 Node 工作进程使用的 stdio 协议。 */ export const GHOST_NODE_PROTOCOLS = ['json-rpc-stdio', 'mcp-stdio']; +/** Node 工作进程生命周期;常驻档会在插件详情中单列高风险权限。 */ export const GHOST_NODE_LIFECYCLES = ['on-demand', 'resident']; +/** 单插件最多可声明的 Node 凭证绑定数。 */ export const GHOST_NODE_MAX_SECRET_BINDINGS = 4; +/** 单条 Node 凭证最多可绑定的 JSON-RPC 方法数。 */ export const GHOST_NODE_MAX_SECRET_METHODS = 16; const GHOST_NODE_MCP_RESERVED_METHODS = new Set(['initialize', 'notifications/initialized']); +/** MCP 握手由宿主发起,插件业务请求与凭证绑定均不得占用这些方法。 */ export function isGhostNodeMcpReservedMethod(method) { return GHOST_NODE_MCP_RESERVED_METHODS.has(method); } +/** node 额外入口条数上限(1 主 + 4 额外 = 每插件至多 5 个工作进程)。 */ export const GHOST_NODE_MAX_EXTRA_ENTRIES = 4; +/** cindy 槽·图像类可申请的动作(主机代办菜单的"图像"类目)。 */ export const GHOST_MODEL_IMAGE_ACTIONS = ['generate', 'edit']; +/** cindy 槽·视频类可申请的动作(generate=文生视频,edit=参考图生视频)。 */ export const GHOST_MODEL_VIDEO_ACTIONS = ['generate', 'edit']; +/** cindy 槽·媒体类可申请的动作(deposit=寄存自己手里的媒体字节入总仓)。 */ export const GHOST_CINDY_MEDIA_ACTIONS = ['deposit']; +/** + * cindy 槽·文本类可申请的动作(2026-07-31 开闸)。 + * + * `oneshot` = 快问快答:意识递一段文字,主机经**轻量任务模型链**(与会话 + * 起标题、任务一句话总结同一条通道)直答一次并把文字原样递回。不拉起 + * agent、无工具、无用户权限、不进任何会话——只花模型额度,拿不到任何 + * 宿主能力。 + */ export const GHOST_CINDY_TEXT_ACTIONS = ['oneshot']; +/** + * cindy 槽·向量类可申请的动作(2026-08-04 开闸)。 + * + * `text` = 文本转向量:意识递一批文字,主机经统一 embedding 通道返回等长的 + * 向量数组。只生成、不存储;向量原样递回意识自己保管。与 text.oneshot + * 分成两档:两者花不同的钱(轻量任务模型链 vs embedding 模型),合成一档 + * 就没法只授权其中一样。 + */ export const GHOST_CINDY_EMBED_ACTIONS = ['text']; +/** cindy 槽·搜索类可申请的动作(web=Cindy 托管的公网搜索)。 */ export const GHOST_CINDY_SEARCH_ACTIONS = ['web']; +/** + * 订阅槽 did- 旁听主题(卡槽①,2026-07-12 开闸)。v1 全部是**元数据级**: + * turn = 轮次开始/结束(agent/模型/耗时/用量,不含消息内容); + * session = 会话创建/归档/切换(切换 = 用户把哪个会话切到台前,2026-07-13 增)。 + * 正文级主题(消息内容旁听)刻意不开——隐私最重,等真实场景再议,权限文案 + * 也要另分一档。 + */ export const GHOST_SUBSCRIBE_TOPICS = ['turn', 'session']; +/** + * 订阅槽 will- 拦截钩子(真 hook:主机停等裁决)。两个点,一进一出: + * - `will-user-message`(入口):用户消息即将交给 agent 之前(拦下 = turn 压根 + * 不启动);动作 allow/block/rewrite。 + * - `will-assistant-message`(出口):AI 这轮回复完成、最终气泡定案之前,把全文 + * 交给意识做润色/合规改写或自绘结果卡片;动作 allow/rewrite/render(无 block + * ——AI 已生成,拦无意义)。**不拦流式输出**(照常打字机),只在 turn 结束 + * 边界的独立异步续跑里裁决,不搅 maker-core 热路径;超时/崩溃 fail-open 用原文。 + * 两个钩子都不拦 tool-call。声明了任一钩子必须 launch:'resident'(要挡路就得常驻 + * 在场,冷启动延迟不可接受),校验强制。 + */ export const GHOST_SUBSCRIBE_HOOKS = ['will-user-message', 'will-assistant-message']; +/* ── network 槽详单────────────────────────────────── + * 意识自带服务:作者声明域名白名单 + 凭证需求,装入时钉死、确认框逐项展示。 + * 运行期沙箱仍零直连,所有出网经管子 fetch-request 由主机代发;凭证明文 + * 永不进沙箱——主机只在"该凭证声明的注入位置"拼进请求头。 */ +/** network 槽:域名白名单条数上限(声明面越小越好,超了说明设计有问题)。 */ export const GHOST_NETWORK_MAX_HOSTS = 8; +/** network 槽:凭证声明条数上限。 */ export const GHOST_NETWORK_MAX_SECRETS = 4; +/** network 槽:多连接(connections)声明条数上限(一段意识通常只对接一种自建服务)。 */ export const GHOST_NETWORK_MAX_CONNECTION_DECLS = 2; +/** network 槽:每条连接声明下用户可添加的连接数上限(同时是 maxConnections 缺省值)。 */ export const GHOST_NETWORK_MAX_CONNECTIONS_PER_DECL = 8; +/** + * 域名白名单条目格式:小写域名,至少两段(拒绝裸 TLD / 单段内网名), + * 通配只允许最左一段(`*.example.com`);不收 IP、端口、路径、协议。 + */ const GHOST_NETWORK_LABEL_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/; export function isValidGhostNetworkHostPattern(p) { if (typeof p !== 'string' || p.length === 0 || p.length > 253) return false; const bare = p.startsWith('*.') ? p.slice(2) : p; const labels = bare.split('.'); + // 至少两段:`*.com` / `localhost` / 裸 TLD 全部拒绝,命中面必须是具体域。 if (labels.length < 2) return false; + // 纯数字四段视作 IP,拒收(白名单只认域名)。 if (labels.every((l) => /^\d+$/.test(l))) return false; return labels.every((l) => GHOST_NETWORK_LABEL_RE.test(l)); } +/** + * hostname 是否命中白名单条目。精确条目要求逐字相等;通配条目 + * (`*.example.com`)命中任意深度的子域(`a.example.com`、`a.b.example.com`), + * 但不命中裸域本身——作者两者都要就两条都声明,规则不做隐式扩张。 + * 注意:此行为必须与桌面端 base 的同名校验器逐字一致(两端同一契约), + * 任何语义调整都属于协议变更,需两侧同步。 + */ function ghostNetworkHostMatches(pattern, hostname) { if (pattern.startsWith('*.')) return hostname.endsWith(pattern.slice(1)) && hostname.length > pattern.length - 1; return hostname === pattern; } +/** 凭证交换(key 换令牌)请求体类型白名单。 */ export const GHOST_SECRET_EXCHANGE_CONTENT_TYPES = [ 'application/json', 'application/x-www-form-urlencoded', ]; +/** 凭证交换:请求体模板长度上限。 */ export const GHOST_SECRET_EXCHANGE_BODY_MAX_CHARS = 2048; +/** 凭证交换:令牌缓存时长(秒)缺省 / 下限 / 上限(上限 30 天)。 */ export const GHOST_SECRET_EXCHANGE_TTL_DEFAULT_S = 3600; export const GHOST_SECRET_EXCHANGE_TTL_MIN_S = 60; export const GHOST_SECRET_EXCHANGE_TTL_MAX_S = 30 * 24 * 3600; +/** 凭证交换:tokenPath 点分路径形状(不支持数组下标,段名字母/数字/_/-)。 */ export const GHOST_SECRET_EXCHANGE_TOKEN_PATH_RE = /^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$/; +/** oauth.identity.displayTemplate:占位符提取(`{点分路径}`;路径形状同 tokenPath)。 */ export const GHOST_OAUTH_IDENTITY_TEMPLATE_PLACEHOLDER_RE = /\{([^{}]*)\}/g; +/** oauth.identity.displayTemplate 长度上限(渲染产物也按同上限截断降级)。 */ export const GHOST_OAUTH_IDENTITY_TEMPLATE_MAX_CHARS = 200; +/** OAuth 凭证:scopes 条数上限(超出拒装;确认框逐条展示要可读)。 */ export const GHOST_OAUTH_SCOPES_MAX = 256; +/** OAuth broker 模式允许声明的备用 client ID 数量上限。 */ +export const GHOST_OAUTH_CLIENT_ID_ALTERNATIVES_MAX = 8; +/** OAuth 凭证:extraAuthorizeParams 条数上限。 */ export const GHOST_OAUTH_EXTRA_PARAMS_MAX = 8; +/** + * OAuth 授权 URL 的协议保留参数:由主机授权引擎独占,意识的 + * extraAuthorizeParams 不许声明(装入拒;引擎侧同名单防御性忽略)。 + */ export const GHOST_OAUTH_RESERVED_AUTHORIZE_PARAMS = [ 'response_type', 'client_id', @@ -92,8 +266,40 @@ export const GHOST_OAUTH_RESERVED_AUTHORIZE_PARAMS = [ 'code_challenge', 'code_challenge_method', ]; +/** tokenBroker slug 形状(小写字母开头,小写/数字/下划线/连字符,1–32)。 */ export const GHOST_OAUTH_TOKEN_BROKER_RE = /^[a-z][a-z0-9_-]{0,31}$/; +/** brokerBounce 路径形状(/ 开头的站内绝对路径,段字符限字母/数字/_/-,≤128)。 */ export const GHOST_OAUTH_BOUNCE_PATH_RE = /^\/[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/; +/** + * 凭证值来源(2026-07-13 定型): + * - 'user'(缺省):值由用户在该意识设置页填入主机保险库; + * - 'login-email':值 = 主机当前登录账号的邮箱——主机注入时现取登录态, + * 设置页只读展示该邮箱(不可编辑、不进保险库);未登录 / 登录态缺 email + * 时 fail-closed 报错并引导重新登录。适用于"服务端按登录邮箱派生鉴权" + * 的第一方服务(经 inject.format 的静态前缀完成派生)。 + * - 'oauth'(2026-07-13 增补):值 = 主机托管 OAuth + * 授权换来的 access token——用户在意识设置页填 client 凭证并点"连接账号", + * 主机跑授权流程并保管全部令牌,出网时现取新鲜 token 注入(见 + * GhostSecretOauthDecl;必须同时声明 oauth 详单)。 + * - 'login-feishu-token'(2026-07-16 增补):值 = 主机 + * 飞书登录态的 user access token——主机注入时现取(登录态管理器 + * 自刷新,token 明文不进沙箱、不进错误消息);未连接飞书 / 刷新链路判 + * AUTH_EXPIRED 时 fail-closed 报错并引导重新登录飞书。适用于"能力面 + * 直接复用产品飞书登录身份"的第一方服务;与 login-email 同族:用户 + * 不填值,禁 url / exchange / oauth,settingsHtml 豁免。 + * - 'oidc-token'(2026-08-03 增补):值 = 主机为当前企业 Membership + * 按受信组织插件 provenance 按需签发的短时 Cindy Connection JWT; + * 插件和 Node Worker 都不能读取或保存令牌。必须固定注入 + * `Authorization: Bearer {value}` 并显式声明非空的精确 inject.hosts; + * 不允许 url / exchange / oauth / input,也不要求 settingsHtml。只有 + * organization scope 的插件可以通过 Plugin Market 发布该来源。 + * - 'gh-cli'(2026-08-05 增补):值优先取主机本地 GitHub CLI + * 登录态(`gh auth token`),不可用时回落到用户在该插件设置页 + * 保存的备用 Token。两种值都只允许固定注入 + * `https://api.github.com` 的 `Authorization: Bearer {value}`;插件和 + * Node Worker 不能读取令牌。必须声明 settingsHtml 作为 + * 备用 Token 的管理入口,不允许 exchange / oauth / input。 + */ export const GHOST_SECRET_SOURCES = [ 'user', 'login-email', @@ -102,6 +308,10 @@ export const GHOST_SECRET_SOURCES = [ 'oidc-token', 'gh-cli', ]; +/** + * 注入头名黑名单:协议关键头由主机独占,作者声明命中即拒装 + * (小写比较;Cookie 也拒——会话粘连语义不给意识)。 + */ export const GHOST_NETWORK_FORBIDDEN_INJECT_HEADERS = [ 'host', 'content-length', @@ -110,28 +320,56 @@ export const GHOST_NETWORK_FORBIDDEN_INJECT_HEADERS = [ 'cookie', 'origin', 'referer', + // content-type 由请求语义决定(上传通道的 multipart boundary 依赖它), + // 不许被凭证注入声明占用——401 重换/跨域跳转的重注入会砸掉 boundary。 'content-type', ]; +/** preview 槽:可打开预览的域名模式条数上限(范围越小越好,同 network 精神)。 */ export const GHOST_PREVIEW_MAX_HOSTS = 4; +/** preview.hosts 特批的 loopback 主机名(network 域名语法之外唯一的例外)。 */ export const GHOST_PREVIEW_LOOPBACK_HOSTS = new Set([ 'localhost', '127.0.0.1', '[::1]', ]); +/** skill 槽:单插件最多捆绑的 Agent Skill 数(范围越小越好,同 preview 精神)。 */ export const GHOST_SKILL_MAX_ITEMS = 4; +/** skill 槽:SKILL.md 单文件字节上限。打包与装入两侧共用,避免契约漂移。 */ export const GHOST_SKILL_MD_MAX_BYTES = 64 * 1024; +/** skill 槽:技能 name 长度上限(链接目录名的一半,克制)。 */ export const GHOST_SKILL_NAME_MAX_CHARS = 64; +/** + * skill 槽:技能 name 形状——小写字母/数字,连字符仅作单段分隔(禁首尾与连续 + * 连字符)。比 SkillHub 的技能名规则更严:意识 id 允许含 `--`(GHOST_ID_RE), + * 共享技能根的链接名是 `--`,只有 name 侧禁 `--`, + * 按"最后一个 `--`"拆分才唯一,不同插件才不可能撞出同一个链接名。 + */ 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; +export const GHOST_SETUP_MAX_GROUPS = 8; +export const GHOST_SETUP_MAX_ITEMS_PER_GROUP = 8; +export const GHOST_SETUP_KV_KEY_RE = /^[A-Za-z0-9_.-]{1,64}$/; +/** 判断已校验的 manifest 是否请求 Host 托管的企业身份凭证。 */ export function ghostManifestUsesOidcToken(manifest) { return manifest.network?.secrets?.some((secret) => secret.source === 'oidc-token') ?? false; } +/** 判断值是否可作为 `ghost.json.id` 和跨平台安全的安装目录名。 */ export function isValidGhostId(id) { return typeof id === 'string' && GHOST_ID_RE.test(id) && !isWindowsReservedName(id); } +/** + * 插件详情与不兼容安装提醒共用的单项能力说明。 + * 纯数据描述:renderer 拼 `settings.ghosts.perm.` 翻译,`detail` 是 + * 作者自由文本(如工具描述)如实展示不翻译,`detailKey` 是主机固定说明的 + * i18n 后缀(如可执行代码的沙箱说明)——两者互斥。 + */ const GHOST_ICON_MIME_BY_EXT = { '.png': 'image/png', '.jpg': 'image/jpeg', @@ -139,12 +377,19 @@ const GHOST_ICON_MIME_BY_EXT = { '.webp': 'image/webp', '.gif': 'image/gif', }; +/** icon 路径 → mime;扩展名不在白名单返回 null(即校验不通过)。 */ export function ghostIconMimeType(p) { const dot = p.lastIndexOf('.'); if (dot < 0) return null; return GHOST_ICON_MIME_BY_EXT[p.slice(dot).toLowerCase()] ?? null; } +/** + * 校验"安装目录内相对路径"(entry / panel.html / settingsHtml 共用): + * 只允许 `a/b/c.ext` 形态——正斜杠分段、无盘符、无反斜杠、无空段、 + * 无 `.`/`..` 段、无 Windows 设备保留名、字符集收敛,天然杜绝路径穿越 + * 与跨平台歧义。 + */ const GHOST_PATH_SEGMENT_RE = /^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,63}$/; export function isSafeGhostRelativePath(p) { if (typeof p !== 'string' || p.length === 0 || p.length > 256) @@ -175,12 +420,15 @@ function parseCindyVersion(value) { } return { core, prerelease }; } +/** 判断值是否是可参与兼容性比较的 Cindy SemVer。 */ export function isValidCindyVersion(value) { return typeof value === 'string' && value.length <= 32 && parseCindyVersion(value) !== null; } +/** 开发构建没有正式版本号,按当前源码兼容处理。 */ export function isVersionlessCindyVersion(value) { return value === '0.0.0' || value.startsWith('0.0.0-'); } +/** 按 SemVer 2.0.0 比较两个 Cindy 版本;任一格式非法时返回 null。 */ export function compareCindyVersions(leftValue, rightValue) { const left = parseCindyVersion(leftValue); const right = parseCindyVersion(rightValue); @@ -217,6 +465,7 @@ export function compareCindyVersions(leftValue, rightValue) { } return 0; } +/** 缺少最低版本即不限制;非法当前版本按不兼容处理。 */ export function supportsCindyVersion(currentVersion, minCindyVersion) { if (minCindyVersion === undefined) return true; @@ -230,15 +479,182 @@ export function supportsCindyVersion(currentVersion, minCindyVersion) { function isPlainObject(v) { return typeof v === 'object' && v !== null && !Array.isArray(v); } -export function validateGhostManifest(raw) { - if (!isPlainObject(raw)) +const GHOST_MANIFEST_RESERVED_RECORD_KEYS = new Set(['__proto__', 'constructor', 'prototype']); +function isGhostManifestReservedRecordKey(value) { + return GHOST_MANIFEST_RESERVED_RECORD_KEYS.has(value); +} +const GHOST_MANIFEST_KNOWN_TOP_LEVEL_FIELDS = new Set([ + 'schemaVersion', + 'id', + 'name', + 'version', + 'minCindyVersion', + 'author', + 'locales', + 'description', + 'whenToUse', + 'icon', + 'kind', + 'entry', + 'launch', + 'agent', + 'node', + 'settingsHtml', + 'settingsHeight', + 'slots', + 'card', + 'tools', + 'cindy', + 'model', + 'subscribe', + 'network', + 'command', + 'keywords', + 'panel', + 'mainView', + 'preview', + 'skill', + 'manual', + 'setup', + 'notify', + 'badge', + 'confirm', + 'fs', + 'library', + 'sessionContext', + 'pick', + 'workspace', + 'iosSimulator', +]); +const V3_BOOLEAN_CAPABILITY_FIELDS = [ + 'notify', + 'badge', + 'confirm', + 'fs', + 'library', + 'sessionContext', + 'pick', + 'workspace', + 'iosSimulator', +]; +const V3_DECLARATION_TO_LEGACY_SLOT = [ + ['tools', 'tool'], + ['card', 'card'], + ['panel', 'panel'], + ['mainView', 'main-view'], + ['subscribe', 'subscribe'], + ['skill', 'skill'], + ['cindy', 'cindy'], + ['agent', 'agent'], + ['node', 'node'], + ['network', 'network'], + ['preview', 'preview'], +]; +const V3_BOOLEAN_TO_LEGACY_SLOT = { + notify: 'notify', + badge: 'badge', + confirm: 'confirm', + fs: 'fs', + library: 'library', + sessionContext: 'session-context', + pick: 'pick', + workspace: 'workspace', + iosSimulator: 'ios-simulator', +}; +function prepareGhostManifestForValidation(value) { + if (!isPlainObject(value)) return { ok: false, reason: '清单不是对象' }; - if (raw.schemaVersion !== GHOST_MANIFEST_SCHEMA_VERSION) { + if (value.schemaVersion !== 2 && value.schemaVersion !== 3) { return { ok: false, - reason: `schemaVersion 必须是 ${GHOST_MANIFEST_SCHEMA_VERSION},得到 ${JSON.stringify(raw.schemaVersion)}(v1 声明型已于 2026-07-12 移除)`, + reason: `schemaVersion 必须是 2 或 3,得到 ${JSON.stringify(value.schemaVersion)}(v1 声明型已于 2026-07-12 移除)`, + }; + } + if (value.schemaVersion === 2) { + return { + ok: true, + prepared: { + raw: Array.isArray(value.slots) + ? { ...value, slots: dropEmptyLegacyCapabilitySlots(value, value.slots) } + : value, + schemaVersion: 2, + v3BaseCard: false, + v3BaseAgent: false, + unknownV3Fields: {}, + }, }; } + if (value.slots !== undefined) { + return { ok: false, reason: 'schemaVersion 3 不再支持 slots;请直接声明对应能力字段' }; + } + if (value.minCindyVersion === undefined) { + return { ok: false, reason: 'schemaVersion 3 必须声明 minCindyVersion' }; + } + for (const field of V3_BOOLEAN_CAPABILITY_FIELDS) { + if (value[field] !== undefined && value[field] !== true) { + return { ok: false, reason: `${field} 出现时必须是 true;不需要时请省略` }; + } + } + const syntheticSlots = []; + for (const [field, slot] of V3_DECLARATION_TO_LEGACY_SLOT) { + if (value[field] !== undefined) + syntheticSlots.push(slot); + } + for (const field of V3_BOOLEAN_CAPABILITY_FIELDS) { + if (value[field] === true) + syntheticSlots.push(V3_BOOLEAN_TO_LEGACY_SLOT[field]); + } + const v3BaseCard = isPlainObject(value.card) && Object.keys(value.card).length === 0; + const v3BaseAgent = isPlainObject(value.agent) && Object.keys(value.agent).length === 0; + return { + ok: true, + prepared: { + raw: { + ...value, + slots: syntheticSlots, + ...(v3BaseCard ? { card: undefined } : {}), + ...(v3BaseAgent ? { agent: undefined } : {}), + }, + schemaVersion: 3, + v3BaseCard, + v3BaseAgent, + unknownV3Fields: Object.fromEntries(Object.entries(value).filter(([key]) => key === 'model' || !GHOST_MANIFEST_KNOWN_TOP_LEVEL_FIELDS.has(key))), + }, + }; +} +/** v2 允许历史上只有名字、没有实际能力详单的 slot;校验结果会将其丢弃。 */ +function dropEmptyLegacyCapabilitySlots(value, slots) { + return slots.filter((slot) => { + const normalized = slot === 'model' ? 'cindy' : slot; + if (normalized === 'tool') + return value.tools !== undefined; + if (normalized === 'panel') + return value.panel !== undefined; + if (normalized === 'cindy') + return value.cindy !== undefined || value.model !== undefined; + if (normalized === 'subscribe') + return value.subscribe !== undefined; + if (normalized === 'node') + return value.node !== undefined; + if (normalized === 'network') + return value.network !== undefined; + if (normalized === 'preview') + return value.preview !== undefined; + if (normalized === 'skill') + return value.skill !== undefined; + return true; + }); +} +/** + * 校验一份 JSON.parse 后的未知 `ghost.json` 值。已知字段严格检查;v2 未知 + * 字段忽略,v3 未知顶层字段原样保留但不解释、不授权。 + */ +export function validateGhostManifest(value) { + const preparation = prepareGhostManifestForValidation(value); + if (!preparation.ok) + return preparation; + const prepared = preparation.prepared; + const raw = prepared.raw; if (!isValidGhostId(raw.id)) { return { ok: false, @@ -257,6 +673,8 @@ export function validateGhostManifest(raw) { (typeof raw.minCindyVersion !== 'string' || !isValidCindyVersion(raw.minCindyVersion))) { return { ok: false, reason: 'minCindyVersion 必须是合法的 SemVer 字符串' }; } + // kind 可省略(2026-07-12 晚定案:单形态后字段纯冗余,缺省即 chip); + // 写了就必须是 chip——写错值仍拒,不静默纠正(规则 9)。 if (raw.kind !== undefined && raw.kind !== 'chip') { return { ok: false, @@ -273,6 +691,7 @@ export function validateGhostManifest(raw) { raw.icon, raw.settingsHtml, isPlainObject(raw.panel) ? raw.panel.html : undefined, + isPlainObject(raw.mainView) ? raw.mainView.html : undefined, isPlainObject(raw.node) ? raw.node.entry : undefined, ...(isPlainObject(raw.node) && Array.isArray(raw.node.entries) ? raw.node.entries : []), ] @@ -283,7 +702,10 @@ export function validateGhostManifest(raw) { let locales; if (raw.locales !== undefined) { if (!isPlainObject(raw.locales)) { - return { ok: false, reason: 'locales 必须是语言到 locale JSON 路径的对象' }; + return { + ok: false, + reason: 'locales 必须是语言到 locale JSON 路径的对象', + }; } const unknownLocale = Object.keys(raw.locales).find((locale) => !GHOST_LOCALES.includes(locale)); if (unknownLocale) { @@ -293,7 +715,10 @@ export function validateGhostManifest(raw) { }; } if (raw.locales.en === undefined) { - return { ok: false, reason: 'locales 必须提供 en,作为所有不支持语言的固定回退' }; + return { + ok: false, + reason: 'locales 必须提供 en,作为所有不支持语言的固定回退', + }; } const normalized = {}; const seenPaths = []; @@ -328,7 +753,10 @@ export function validateGhostManifest(raw) { }; } if (seenPaths.includes(normalizedLocalePath)) { - return { ok: false, reason: `locales 含重复路径 ${JSON.stringify(localePath)}` }; + return { + ok: false, + reason: `locales 含重复路径 ${JSON.stringify(localePath)}`, + }; } if (seenPaths.some((path) => isSameOrDescendant(path, normalizedLocalePath) || isSameOrDescendant(normalizedLocalePath, path))) { @@ -383,8 +811,13 @@ export function validateGhostManifest(raw) { (typeof p.title !== 'string' || p.title.length === 0 || p.title.length > 64)) { return { ok: false, reason: 'panel.title 必须是 1–64 字符的字符串' }; } + // 面板一律由意识自绘(html 必填):declaration 时代的静态 body 面板已随 + // 单形态定案(2026-07-12)移除。 if (!isSafeGhostRelativePath(p.html)) { - return { ok: false, reason: 'panel.html 必填,且必须是安装目录内的安全相对路径' }; + return { + ok: false, + reason: 'panel.html 必填,且必须是安装目录内的安全相对路径', + }; } if (p.minWidth !== undefined && (typeof p.minWidth !== 'number' || @@ -398,18 +831,26 @@ export function validateGhostManifest(raw) { !Number.isFinite(p.defaultFraction) || p.defaultFraction < 0.05 || p.defaultFraction > 0.8)) { - return { ok: false, reason: 'panel.defaultFraction 必须是 0.05–0.8 之间的数字' }; + return { + ok: false, + reason: 'panel.defaultFraction 必须是 0.05–0.8 之间的数字', + }; } if (p.position !== undefined) { if (p.position === 'top' || p.position === 'bottom') { + // 收词但明确拒绝(规则 9 不静默降级):上下停靠等布局引擎嵌套分割就绪后开放。 return { ok: false, reason: 'panel.position 的 top / bottom 暂未支持(排期中),当前可用:left / right / tab', }; } if (!GHOST_PANEL_POSITIONS.includes(p.position)) { - return { ok: false, reason: `panel.position 必须是 ${GHOST_PANEL_POSITIONS.join(' / ')}` }; + return { + ok: false, + reason: `panel.position 必须是 ${GHOST_PANEL_POSITIONS.join(' / ')}`, + }; } + // 页签形态没有拖缝宽度语义:收词明确拒绝而非静默忽略(规则 9)。 if (p.position === 'tab' && (p.minWidth !== undefined || p.defaultFraction !== undefined)) { return { ok: false, @@ -425,12 +866,58 @@ export function validateGhostManifest(raw) { ...(p.defaultFraction !== undefined ? { defaultFraction: p.defaultFraction } : {}), }; } + let mainView; + if (raw.mainView !== undefined) { + if (!isPlainObject(raw.mainView)) { + return { ok: false, reason: 'mainView 必须是对象' }; + } + const unknownMainViewField = Object.keys(raw.mainView).find((field) => field !== 'html' && field !== 'title' && field !== 'icon'); + if (unknownMainViewField) { + return { + ok: false, + reason: `mainView 含不允许的字段 ${JSON.stringify(unknownMainViewField)}`, + }; + } + if (raw.mainView.title !== undefined && + (typeof raw.mainView.title !== 'string' || + raw.mainView.title.trim().length === 0 || + raw.mainView.title.length > 64)) { + return { + ok: false, + reason: 'mainView.title 必须是 1–64 字符的非空字符串', + }; + } + if (raw.mainView.icon !== undefined && + !GHOST_MAIN_VIEW_ICONS.includes(raw.mainView.icon)) { + return { + ok: false, + reason: `mainView.icon 必须是以下系统图标之一:${GHOST_MAIN_VIEW_ICONS.join(' / ')}`, + }; + } + if (!isSafeGhostRelativePath(raw.mainView.html)) { + return { + ok: false, + reason: 'mainView.html 必填,且必须是安装目录内的安全相对路径', + }; + } + mainView = { + ...(raw.mainView.title !== undefined ? { title: raw.mainView.title } : {}), + ...(raw.mainView.icon !== undefined ? { icon: raw.mainView.icon } : {}), + html: raw.mainView.html, + }; + } if (!isSafeGhostRelativePath(raw.entry)) { - return { ok: false, reason: '必须提供 entry(安装目录内的安全相对路径,电子脑逻辑入口)' }; + return { + ok: false, + reason: '必须提供 entry(安装目录内的安全相对路径,电子脑逻辑入口)', + }; } if (raw.launch !== undefined && !GHOST_LAUNCH_MODES.includes(raw.launch)) { - return { ok: false, reason: `launch 必须是 ${GHOST_LAUNCH_MODES.join(' / ')}` }; + return { + ok: false, + reason: `launch 必须是 ${GHOST_LAUNCH_MODES.join(' / ')}`, + }; } if (raw.settingsHtml !== undefined && !isSafeGhostRelativePath(raw.settingsHtml)) { return { ok: false, reason: 'settingsHtml 必须是安装目录内的安全相对路径' }; @@ -449,16 +936,17 @@ export function validateGhostManifest(raw) { return { ok: false, reason: 'settingsHeight 必须是 160–800 之间的数字' }; } } - if (!Array.isArray(raw.slots) || raw.slots.length === 0) { - return { ok: false, reason: '必须声明 slots(非空数组,能力白名单)' }; + if (!Array.isArray(raw.slots)) { + return { ok: false, reason: 'schemaVersion 2 的 slots 必须是数组' }; } const slots = []; for (const s of raw.slots) { + // 旧名兼容:'model' 静默归一化为 'cindy'(2026-07-11 更名,已装老包不消失)。 const name = s === 'model' ? 'cindy' : s; - if (typeof name !== 'string' || !GHOST_SLOTS.includes(name)) { + if (typeof name !== 'string' || !GHOST_SLOT_NAME_RE.test(name)) { return { ok: false, - reason: `slots 含未知卡槽 ${JSON.stringify(s)}(可用:${GHOST_SLOTS.join(' / ')})`, + reason: `slots 含格式非法的卡槽名称 ${JSON.stringify(s)}`, }; } if (slots.includes(name)) { @@ -466,30 +954,53 @@ export function validateGhostManifest(raw) { } slots.push(name); } - if (slots.includes('ios-simulator') && raw.minCindyVersion === undefined) { + // 声明了面板却没申请 panel 槽(或反之有槽无面板)都是清单自相矛盾。 + if (panel !== undefined && !slots.includes('panel')) { + return { ok: false, reason: '声明了 panel 但 slots 未包含 "panel"' }; + } + if (slots.includes('panel') && panel === undefined) { return { ok: false, - reason: 'slots 声明了 "ios-simulator" 时必须同时声明 minCindyVersion', + reason: 'slots 声明了 "panel" 但缺少 panel(面板由意识自绘,html 必填)', }; } - if (panel !== undefined && !slots.includes('panel')) { - return { ok: false, reason: '声明了 panel 但 slots 未包含 "panel"' }; + if (mainView !== undefined && !slots.includes('main-view')) { + return { ok: false, reason: '声明了 mainView 但 slots 未包含 "main-view"' }; } - if (slots.includes('panel') && panel === undefined) { - return { ok: false, reason: 'slots 声明了 "panel" 但缺少 panel(面板由意识自绘,html 必填)' }; + if (slots.includes('main-view') && mainView === undefined) { + return { + ok: false, + reason: 'slots 声明了 "main-view" 但缺少 mainView(html 必填)', + }; + } + if (slots.includes('badge') && panel === undefined) { + return { + ok: false, + reason: 'slots 声明了 "badge" 但缺少 panel——未读点必须有可打开的面板内容', + }; } + // card 槽能力详单:缺省 = 仅渲染;externalLinks: true 是独立加档权限。 let card; if (raw.card !== undefined) { if (!isPlainObject(raw.card)) { - return { ok: false, reason: 'card 能力详单必须是对象(如 { "externalLinks": true })' }; + return { + ok: false, + reason: 'card 能力详单必须是对象(如 { "externalLinks": true })', + }; } if (!slots.includes('card')) { - return { ok: false, reason: '声明了 card 能力详单但 slots 未包含 "card"' }; + return { + ok: false, + reason: '声明了 card 能力详单但 slots 未包含 "card"', + }; } const cardRaw = raw.card; const unknownCardField = Object.keys(cardRaw).find((key) => key !== 'externalLinks'); if (unknownCardField) { - return { ok: false, reason: `card 含不允许的字段 ${JSON.stringify(unknownCardField)}` }; + return { + ok: false, + reason: `card 含不允许的字段 ${JSON.stringify(unknownCardField)}`, + }; } if (cardRaw.externalLinks !== undefined && typeof cardRaw.externalLinks !== 'boolean') { return { ok: false, reason: 'card.externalLinks 必须是布尔值' }; @@ -498,6 +1009,7 @@ export function validateGhostManifest(raw) { card = { externalLinks: true }; } } + // 工具声明(卡槽②):与 slots 含 'tool' 严格成对,规则同 panel。 let tools; if (raw.tools !== undefined) { if (!Array.isArray(raw.tools) || raw.tools.length === 0 || raw.tools.length > 16) { @@ -515,16 +1027,25 @@ export function validateGhostManifest(raw) { }; } if (seenNames.has(t.name)) - return { ok: false, reason: `tools 含重名工具 ${JSON.stringify(t.name)}` }; + return { + ok: false, + reason: `tools 含重名工具 ${JSON.stringify(t.name)}`, + }; seenNames.add(t.name); if (typeof t.description !== 'string' || t.description.trim().length === 0 || t.description.length > 1024) { - return { ok: false, reason: 'tools[].description 必须是 1–1024 字符的非空字符串' }; + return { + ok: false, + reason: 'tools[].description 必须是 1–1024 字符的非空字符串', + }; } if (t.parameters !== undefined) { if (!isPlainObject(t.parameters)) - return { ok: false, reason: 'tools[].parameters 必须是对象(JSON Schema)' }; + return { + ok: false, + reason: 'tools[].parameters 必须是对象(JSON Schema)', + }; try { if (JSON.stringify(t.parameters).length > 16_384) { return { ok: false, reason: 'tools[].parameters 过大(上限 16KB)' }; @@ -547,18 +1068,32 @@ export function validateGhostManifest(raw) { return { ok: false, reason: '声明了 tools 但 slots 未包含 "tool"' }; } if (slots.includes('tool') && tools === undefined) { - return { ok: false, reason: 'slots 声明了 "tool" 但缺少 tools(注册什么工具要写清楚)' }; + return { + ok: false, + reason: 'slots 声明了 "tool" 但缺少 tools(注册什么工具要写清楚)', + }; } - const cindyRaw = raw.cindy !== undefined ? raw.cindy : raw.model; + // cindy 槽能力详单:与 slots 含 'cindy' 成对(有详单必有槽;有槽无详单 + // 允许装入但运行时零能力——老包不消失,只是代办被拒并提示作者更新)。 + // 字段旧名 model 作别名收入(两个都写以 cindy 为准)。 + const cindyRaw = raw.cindy !== undefined ? raw.cindy : prepared.schemaVersion === 2 ? raw.model : undefined; let cindy; if (cindyRaw !== undefined) { if (!isPlainObject(cindyRaw)) { - return { ok: false, reason: 'cindy 能力详单必须是对象(如 { "image": ["generate"] })' }; + return { + ok: false, + reason: 'cindy 能力详单必须是对象(如 { "image": ["generate"] })', + }; } if (!slots.includes('cindy')) { - return { ok: false, reason: '声明了 cindy 能力详单但 slots 未包含 "cindy"' }; + return { + ok: false, + reason: '声明了 cindy 能力详单但 slots 未包含 "cindy"', + }; } cindy = {}; + // 类目 → 合法动作表(image / video / media / text / embed / search;动作集恰好 + // 同名,但按类目查表,未来某类目动作分叉时这里天然承接)。 const actionTable = { image: GHOST_MODEL_IMAGE_ACTIONS, video: GHOST_MODEL_VIDEO_ACTIONS, @@ -569,7 +1104,7 @@ export function validateGhostManifest(raw) { }; for (const [category, actionsRaw] of Object.entries(cindyRaw)) { if (category === 'oneshotModel') - continue; + continue; // 标量意图键,不是类目,单独校验 const allowed = actionTable[category]; if (!allowed) { return { @@ -589,7 +1124,10 @@ export function validateGhostManifest(raw) { }; } if (actions.includes(a)) { - return { ok: false, reason: `cindy.${category} 含重复动作 ${JSON.stringify(a)}` }; + return { + ok: false, + reason: `cindy.${category} 含重复动作 ${JSON.stringify(a)}`, + }; } actions.push(a); } @@ -619,6 +1157,7 @@ export function validateGhostManifest(raw) { cindy.search === undefined) { return { ok: false, reason: 'cindy 能力详单不能是空对象' }; } + // oneshotModel(快问快答偏好模型)是标量意图键,不是类目:先摘出,不进类目循环。 const oneshotModelRaw = cindyRaw.oneshotModel; if (oneshotModelRaw !== undefined) { if (typeof oneshotModelRaw !== 'string' || @@ -644,13 +1183,22 @@ export function validateGhostManifest(raw) { }; } } + // 订阅槽详单(卡槽①):与 slots 含 'subscribe' 成对(有详单必有槽;有槽 + // 无详单允许装入但零事件,同 cindy 语义)。硬规则:声明了 hooks(拦截) + // 必须 launch:'resident'——要挡路就得常驻在场,每条消息等冷启动不可接受。 let subscribe; if (raw.subscribe !== undefined) { if (!isPlainObject(raw.subscribe)) { - return { ok: false, reason: 'subscribe 订阅详单必须是对象(如 { "topics": ["turn"] })' }; + return { + ok: false, + reason: 'subscribe 订阅详单必须是对象(如 { "topics": ["turn"] })', + }; } if (!slots.includes('subscribe')) { - return { ok: false, reason: '声明了 subscribe 订阅详单但 slots 未包含 "subscribe"' }; + return { + ok: false, + reason: '声明了 subscribe 订阅详单但 slots 未包含 "subscribe"', + }; } subscribe = {}; const subRaw = raw.subscribe; @@ -673,7 +1221,10 @@ export function validateGhostManifest(raw) { }; } if (list.includes(item)) { - return { ok: false, reason: `subscribe.${field} 含重复项 ${JSON.stringify(item)}` }; + return { + ok: false, + reason: `subscribe.${field} 含重复项 ${JSON.stringify(item)}`, + }; } list.push(item); } @@ -692,15 +1243,28 @@ export function validateGhostManifest(raw) { }; } } + // network 槽详单:与 slots 含 'network' 成对(有详单必有槽;有槽 + // 无详单允许装入但零能力,同 cindy / subscribe 语义)。hosts 是核心声明; + // secrets 每条必须带 inject(没有注入位置的凭证无处可用),inject.hosts + // 必须是 hosts 声明条目的子集——结构上钉死"key 只流向它声明的域名"。 let network; if (raw.network !== undefined) { if (!isPlainObject(raw.network)) { - return { ok: false, reason: 'network 详单必须是对象(如 { "hosts": ["api.example.com"] })' }; + return { + ok: false, + reason: 'network 详单必须是对象(如 { "hosts": ["api.example.com"] })', + }; } if (!slots.includes('network')) { - return { ok: false, reason: '声明了 network 详单但 slots 未包含 "network"' }; + return { + ok: false, + reason: '声明了 network 详单但 slots 未包含 "network"', + }; } const n = raw.network; + // 连接声明在场性先探一眼(合法性下面细验):静态 hosts 的必填性依赖它—— + // 声明了 connections(动态连接地址)时 hosts 允许缺省/空数组,否则维持 + // 原规则必填 1–8 条(静态域名与动态连接至少有其一)。 const hasConnectionDecls = Array.isArray(n.connections) && n.connections.length > 0; if (n.hosts === undefined && !hasConnectionDecls) { return { @@ -710,7 +1274,10 @@ export function validateGhostManifest(raw) { } if (n.hosts !== undefined && (!Array.isArray(n.hosts) || n.hosts.length > GHOST_NETWORK_MAX_HOSTS)) { - return { ok: false, reason: `network.hosts 必须是 1–${GHOST_NETWORK_MAX_HOSTS} 条的数组` }; + return { + ok: false, + reason: `network.hosts 必须是 1–${GHOST_NETWORK_MAX_HOSTS} 条的数组`, + }; } if (Array.isArray(n.hosts) && n.hosts.length === 0 && !hasConnectionDecls) { return { @@ -728,7 +1295,10 @@ export function validateGhostManifest(raw) { } const host = h.trim().toLowerCase(); if (hosts.includes(host)) { - return { ok: false, reason: `network.hosts 含重复条目 ${JSON.stringify(h)}` }; + return { + ok: false, + reason: `network.hosts 含重复条目 ${JSON.stringify(h)}`, + }; } hosts.push(host); } @@ -754,12 +1324,20 @@ export function validateGhostManifest(raw) { }; } if (seenKeys.has(s.key)) { - return { ok: false, reason: `network.secrets 含重复 key ${JSON.stringify(s.key)}` }; + return { + ok: false, + reason: `network.secrets 含重复 key ${JSON.stringify(s.key)}`, + }; } seenKeys.add(s.key); if (typeof s.label !== 'string' || s.label.trim().length === 0 || s.label.length > 64) { - return { ok: false, reason: 'network.secrets[].label 必须是 1–64 字符的非空字符串' }; + return { + ok: false, + reason: 'network.secrets[].label 必须是 1–64 字符的非空字符串', + }; } + // 来源:缺省 'user';'login-email' = 主机登录邮箱派生(用户不填值)。 + // 归一化:'user' 不落清单(与缺省同义,权限 diff 不 churn)。 let source; if (s.source !== undefined) { if (typeof s.source !== 'string' || @@ -780,12 +1358,18 @@ export function validateGhostManifest(raw) { if (s.source === 'gh-cli') source = 'gh-cli'; } + // 输入面字段已退役(2026-07-13 宿主凭证渲染整体退役):user 凭证 + // 一律意识 settingsHtml 收单。遗留 `input: "ghost"` 接受并忽略 + // (与现状同义、不落清单);其余值(含 'host')一律拒。 if (s.input !== undefined && s.input !== 'ghost') { return { ok: false, reason: 'network.secrets[].input 已退役:宿主收单不存在,用户填写的凭证一律由意识 settingsHtml 收单(删掉 input 字段即可;唯一可接受的遗留值是 "ghost")', }; } + // login-email / login-feishu-token 同族:值取自主机登录态派生,用户 + // 不填、没有输入面,禁 url / exchange,settingsHtml 豁免。oidc-token + // 同样由 Host 托管,但值来自当前组织 Membership 的 Connection JWT。 const loginDerived = source === 'login-email' || source === 'login-feishu-token'; const oidcManaged = source === 'oidc-token'; const ghCliManaged = source === 'gh-cli'; @@ -808,6 +1392,8 @@ export function validateGhostManifest(raw) { }; } if (loginDerived && s.exchange !== undefined) { + // 组合会把登录态凭证作为原始值 POST 给交换端点,而确认框文案只 + // 承诺"派生注入请求头"——语义盖不住,结构上禁掉(有真实场景再议)。 return { ok: false, reason: `network.secrets[].source 为 ${source} 时不允许声明 exchange(登录态凭证不外送交换端点)`, @@ -833,18 +1419,27 @@ export function validateGhostManifest(raw) { } if (s.hint !== undefined && (typeof s.hint !== 'string' || s.hint.trim().length === 0 || s.hint.length > 200)) { - return { ok: false, reason: 'network.secrets[].hint 必须是 1–200 字符的非空字符串' }; + return { + ok: false, + reason: 'network.secrets[].hint 必须是 1–200 字符的非空字符串', + }; } if (s.url !== undefined) { if (typeof s.url !== 'string' || s.url.length === 0 || s.url.length > 200) { - return { ok: false, reason: 'network.secrets[].url 必须是 1–200 字符的字符串' }; + return { + ok: false, + reason: 'network.secrets[].url 必须是 1–200 字符的字符串', + }; } let parsed; try { parsed = new URL(s.url); } catch { - return { ok: false, reason: 'network.secrets[].url 不是合法的绝对地址' }; + return { + ok: false, + reason: 'network.secrets[].url 不是合法的绝对地址', + }; } if (parsed.protocol !== 'https:' || parsed.username || parsed.password) { return { @@ -943,6 +1538,9 @@ export function validateGhostManifest(raw) { }; } } + // oauth(source: 'oauth' 时必填):主机托管 OAuth 授权详单。授权页与 + // token 端点域名都必须落在 hosts 白名单内——确认框展示的域名集合就是 + // 全部出网面(clientSecret 只流向 tokenUrl,授权只发生在 authorizeUrl)。 let oauth; if (source === 'oauth' && s.oauth === undefined) { return { @@ -957,6 +1555,7 @@ export function validateGhostManifest(raw) { }; } if (source === 'oauth' && s.exchange !== undefined) { + // access token 已是最终注入物,再叠一段 key 换令牌没有语义。 return { ok: false, reason: 'network.secrets[].source 为 oauth 时不允许声明 exchange(access token 直接注入,无二段交换)', @@ -982,7 +1581,10 @@ export function validateGhostManifest(raw) { parsed2 = new URL(raw2); } catch { - return { ok: false, reason: `network.secrets[].oauth.${field} 不是合法的绝对地址` }; + return { + ok: false, + reason: `network.secrets[].oauth.${field} 不是合法的绝对地址`, + }; } if (parsed2.protocol !== 'https:' || parsed2.port !== '' || @@ -1007,6 +1609,7 @@ export function validateGhostManifest(raw) { const tokenParsed = parseHostBoundUrl(oa.tokenUrl, 'tokenUrl'); if (!tokenParsed.ok) return tokenParsed; + // 内置 client 凭证(可选,成对语义:secret 不许孤儿声明)。 if (oa.clientId !== undefined) { if (typeof oa.clientId !== 'string' || oa.clientId.trim().length === 0 || @@ -1018,6 +1621,42 @@ export function validateGhostManifest(raw) { }; } } + let oaClientIdAlternatives; + if (oa.clientIdAlternatives !== undefined) { + if (oa.clientId === undefined) { + return { + ok: false, + reason: 'network.secrets[].oauth.clientIdAlternatives 必须与默认 clientId 一起声明', + }; + } + if (!Array.isArray(oa.clientIdAlternatives) || + oa.clientIdAlternatives.length === 0 || + oa.clientIdAlternatives.length > GHOST_OAUTH_CLIENT_ID_ALTERNATIVES_MAX) { + return { + ok: false, + reason: `network.secrets[].oauth.clientIdAlternatives 必须是 1–${GHOST_OAUTH_CLIENT_ID_ALTERNATIVES_MAX} 条的数组`, + }; + } + oaClientIdAlternatives = []; + for (const clientId of oa.clientIdAlternatives) { + if (typeof clientId !== 'string' || + clientId.trim().length === 0 || + clientId.length > 200 || + /\s/.test(clientId)) { + return { + ok: false, + reason: 'network.secrets[].oauth.clientIdAlternatives 含非法条目(须为 1–200 字符、不含空白的字符串)', + }; + } + if (clientId === oa.clientId || oaClientIdAlternatives.includes(clientId)) { + return { + ok: false, + reason: `network.secrets[].oauth.clientIdAlternatives 含重复条目 ${JSON.stringify(clientId)}`, + }; + } + oaClientIdAlternatives.push(clientId); + } + } if (oa.clientSecret !== undefined) { if (oa.clientId === undefined) { return { @@ -1064,7 +1703,10 @@ export function validateGhostManifest(raw) { } } if (oa.pkce !== undefined && typeof oa.pkce !== 'boolean') { - return { ok: false, reason: 'network.secrets[].oauth.pkce 必须是布尔值(缺省 true)' }; + return { + ok: false, + reason: 'network.secrets[].oauth.pkce 必须是布尔值(缺省 true)', + }; } if (oa.scopeDelimiter !== undefined && oa.scopeDelimiter !== ',') { return { @@ -1110,6 +1752,7 @@ export function validateGhostManifest(raw) { oaExtra[pk] = pv; } } + // redirectPort(可选):loopback 回调固定端口(Atlassian 等回调精确匹配)。 if (oa.redirectPort !== undefined) { if (typeof oa.redirectPort !== 'number' || !Number.isInteger(oa.redirectPort) || @@ -1121,6 +1764,8 @@ export function validateGhostManifest(raw) { }; } } + // tokenBroker(可选):服务端 broker slug;secret 在服务端,与 + // clientSecret 互斥(同时声明说明作者没想清 secret 到底在哪)。 if (oa.tokenBroker !== undefined) { if (typeof oa.tokenBroker !== 'string' || !GHOST_OAUTH_TOKEN_BROKER_RE.test(oa.tokenBroker)) { @@ -1136,6 +1781,14 @@ export function validateGhostManifest(raw) { }; } } + if (oaClientIdAlternatives !== undefined && oa.tokenBroker === undefined) { + return { + ok: false, + reason: 'network.secrets[].oauth.clientIdAlternatives 仅允许与 tokenBroker 一起声明', + }; + } + // brokerBounce(可选):双地址弹跳回调,必须与 tokenBroker + redirectPort + // 成套声明(302 目标端口/路径在 broker 服务端写死,三者是一套约定)。 let oaBounce; if (oa.brokerBounce !== undefined) { if (oa.tokenBroker === undefined || oa.redirectPort === undefined) { @@ -1145,7 +1798,10 @@ export function validateGhostManifest(raw) { }; } if (!isPlainObject(oa.brokerBounce)) { - return { ok: false, reason: 'network.secrets[].oauth.brokerBounce 必须是对象' }; + return { + ok: false, + reason: 'network.secrets[].oauth.brokerBounce 必须是对象', + }; } const bb = oa.brokerBounce; for (const field of ['path', 'callbackPath']) { @@ -1157,12 +1813,18 @@ export function validateGhostManifest(raw) { }; } } - oaBounce = { path: bb.path, callbackPath: bb.callbackPath }; + oaBounce = { + path: bb.path, + callbackPath: bb.callbackPath, + }; } let oaIdentity; if (oa.identity !== undefined) { if (!isPlainObject(oa.identity)) { - return { ok: false, reason: 'network.secrets[].oauth.identity 必须是对象' }; + return { + ok: false, + reason: 'network.secrets[].oauth.identity 必须是对象', + }; } const idn = oa.identity; const idnUrl = parseHostBoundUrl(idn.url, 'identity.url'); @@ -1176,6 +1838,9 @@ export function validateGhostManifest(raw) { reason: 'network.secrets[].oauth.identity.labelPath 必须是 ≤128 字符的点分路径(段名限字母/数字/_/-,如 "email" / "user.name")', }; } + // displayTemplate(可选):展示名模板,占位符 `{点分路径}`(路径 + // 形状同 labelPath),至少一个占位符——纯静态字符串没有"身份展示" + // 语义,多半是作者笔误。 let idnTemplate; if (idn.displayTemplate !== undefined) { if (typeof idn.displayTemplate !== 'string' || @@ -1206,16 +1871,32 @@ export function validateGhostManifest(raw) { } idnTemplate = idn.displayTemplate; } + let idnAvatarPath; + if (idn.avatarPath !== undefined) { + if (typeof idn.avatarPath !== 'string' || + idn.avatarPath.length > 128 || + !GHOST_SECRET_EXCHANGE_TOKEN_PATH_RE.test(idn.avatarPath)) { + return { + ok: false, + reason: 'network.secrets[].oauth.identity.avatarPath 必须是 ≤128 字符的点分路径(段名限字母/数字/_/-,如 "data.avatar_thumb")', + }; + } + idnAvatarPath = idn.avatarPath; + } oaIdentity = { url: idnUrl.url, labelPath: idn.labelPath, ...(idnTemplate !== undefined ? { displayTemplate: idnTemplate } : {}), + ...(idnAvatarPath !== undefined ? { avatarPath: idnAvatarPath } : {}), }; } oauth = { authorizeUrl: authorizeParsed.url, tokenUrl: tokenParsed.url, ...(oa.clientId !== undefined ? { clientId: oa.clientId } : {}), + ...(oaClientIdAlternatives !== undefined + ? { clientIdAlternatives: oaClientIdAlternatives } + : {}), ...(oa.clientSecret !== undefined ? { clientSecret: oa.clientSecret } : {}), ...(oaScopes !== undefined ? { scopes: oaScopes } : {}), ...(oa.scopeDelimiter !== undefined @@ -1229,6 +1910,8 @@ export function validateGhostManifest(raw) { ...(oaBounce !== undefined ? { brokerBounce: oaBounce } : {}), }; } + // exchange(可选):key 换令牌二段式。交换端点必须落在 hosts 白名单 + // 内——结构上保证原始 key 也只流向用户同意过的域名。 let exchange; if (s.exchange !== undefined) { if (!isPlainObject(s.exchange)) { @@ -1249,7 +1932,10 @@ export function validateGhostManifest(raw) { exUrl = new URL(ex.url); } catch { - return { ok: false, reason: 'network.secrets[].exchange.url 不是合法的绝对地址' }; + return { + ok: false, + reason: 'network.secrets[].exchange.url 不是合法的绝对地址', + }; } if (exUrl.protocol !== 'https:' || exUrl.port !== '' || @@ -1331,6 +2017,10 @@ export function validateGhostManifest(raw) { }); } } + // connections(可选):多连接声明——"地址 + 凭证成对多条"的凭证形态 + // (自建实例场景)。作者只声明连接类型与注入形态,地址与 token 由用户在 + // 设置页添加(每次新增地址都过主机受信确认);连接凭证的注入范围恒等于 + // 各连接自身地址,inject.hosts 禁止声明。 let connections; if (n.connections !== undefined) { if (!Array.isArray(n.connections) || @@ -1341,6 +2031,8 @@ export function validateGhostManifest(raw) { reason: `network.connections 必须是 1–${GHOST_NETWORK_MAX_CONNECTION_DECLS} 条的数组`, }; } + // 没人收地址和 token:连接的地址与凭证一律由意识 settingsHtml 收单 + // (经 /connections 协议通道),没有界面就没有入口。 if (raw.settingsHtml === undefined) { return { ok: false, @@ -1360,8 +2052,12 @@ export function validateGhostManifest(raw) { }; } if (seenConnKeys.has(c.key)) { - return { ok: false, reason: `network.connections 含重复 key ${JSON.stringify(c.key)}` }; + return { + ok: false, + reason: `network.connections 含重复 key ${JSON.stringify(c.key)}`, + }; } + // 与 secrets 共用键命名空间(保险库派生键同一前缀),撞名拒装。 if (secretKeySet.has(c.key)) { return { ok: false, @@ -1370,11 +2066,17 @@ export function validateGhostManifest(raw) { } seenConnKeys.add(c.key); if (typeof c.label !== 'string' || c.label.trim().length === 0 || c.label.length > 64) { - return { ok: false, reason: 'network.connections[].label 必须是 1–64 字符的非空字符串' }; + return { + ok: false, + reason: 'network.connections[].label 必须是 1–64 字符的非空字符串', + }; } if (c.hint !== undefined && (typeof c.hint !== 'string' || c.hint.trim().length === 0 || c.hint.length > 200)) { - return { ok: false, reason: 'network.connections[].hint 必须是 1–200 字符的非空字符串' }; + return { + ok: false, + reason: 'network.connections[].hint 必须是 1–200 字符的非空字符串', + }; } if (!isPlainObject(c.inject)) { return { @@ -1405,6 +2107,8 @@ export function validateGhostManifest(raw) { }; } if (cinj.hosts !== undefined) { + // 注入范围恒等于各连接自身地址(用户添加哪条注入哪条),作者无从 + // 收窄或扩张——声明即结构性误解,直接拒装。 return { ok: false, reason: 'network.connections[].inject.hosts 不允许声明(连接凭证只注入对应连接自身的地址)', @@ -1436,16 +2140,23 @@ export function validateGhostManifest(raw) { ...(connections !== undefined ? { connections } : {}), }; } + // agent 能力详单:缺省 = 仅点击票据;可选加档由 Desktop 展示并由 Host 守门。 let agent; if (raw.agent !== undefined) { if (!isPlainObject(raw.agent)) { - return { ok: false, reason: 'agent 能力详单必须是对象(如 { "background": true })' }; + return { + ok: false, + reason: 'agent 能力详单必须是对象(如 { "background": true })', + }; } if (!slots.includes('agent')) { - return { ok: false, reason: '声明了 agent 能力详单但 slots 未包含 "agent"' }; + return { + ok: false, + reason: '声明了 agent 能力详单但 slots 未包含 "agent"', + }; } const agentRaw = raw.agent; - const unknownAgentField = Object.keys(agentRaw).find((key) => key !== 'background'); + const unknownAgentField = Object.keys(agentRaw).find((key) => key !== 'background' && key !== 'errand' && key !== 'schedule'); if (unknownAgentField) { return { ok: false, @@ -1455,21 +2166,36 @@ export function validateGhostManifest(raw) { if (agentRaw.background !== undefined && typeof agentRaw.background !== 'boolean') { return { ok: false, reason: 'agent.background 必须是布尔值' }; } - if (agentRaw.background !== true) { + if (agentRaw.errand !== undefined && typeof agentRaw.errand !== 'boolean') { + return { ok: false, reason: 'agent.errand 必须是布尔值' }; + } + if (agentRaw.schedule !== undefined && typeof agentRaw.schedule !== 'boolean') { + return { ok: false, reason: 'agent.schedule 必须是布尔值' }; + } + if (agentRaw.background !== true && agentRaw.errand !== true && agentRaw.schedule !== true) { return { ok: false, - reason: 'agent 能力详单目前只有 background: true 这一项;仅需用户点击触发时请省略 agent 字段', + reason: 'agent 能力详单只有 background: true / errand: true / schedule: true 三项加档;仅需用户点击触发时请省略 agent 字段', }; } - agent = { background: true }; + agent = { + ...(agentRaw.background === true ? { background: true } : {}), + ...(agentRaw.errand === true ? { errand: true } : {}), + ...(agentRaw.schedule === true ? { schedule: true } : {}), + }; } + // node 槽详单:只收包内入口 + 固定 stdio 协议 + 生命周期。这里刻意采用 + // 字段白名单,command/args/shell/env 等任意命令启动面一律在装入前拒绝。 let node; if (raw.node !== undefined) { if (!isPlainObject(raw.node)) { return { ok: false, reason: 'node 能力详单必须是对象' }; } if (!slots.includes('node')) { - return { ok: false, reason: '声明了 node 能力详单但 slots 未包含 "node"' }; + return { + ok: false, + reason: '声明了 node 能力详单但 slots 未包含 "node"', + }; } const nodeRaw = raw.node; const allowedNodeFields = new Set([ @@ -1492,55 +2218,97 @@ export function validateGhostManifest(raw) { return { ok: false, reason: 'node.entry 必须是安装目录内的安全相对路径' }; } if (!/\.(?:c?js)$/.test(nodeRaw.entry)) { - return { ok: false, reason: 'node.entry 必须是 CommonJS .js / .cjs 文件' }; + return { + ok: false, + reason: 'node.entry 必须是 CommonJS .js / .cjs 文件', + }; } + // 入口重合判定折叠大小写:Windows / macOS 默认文件系统大小写不敏感, + // main.js 与 Main.js 是同一个文件,原样比较会漏判。 if (nodeRaw.entry.toLowerCase() === raw.entry.toLowerCase()) { - return { ok: false, reason: 'node.entry 不能与浏览器沙箱 entry 使用同一个文件' }; + return { + ok: false, + reason: 'node.entry 不能与浏览器沙箱 entry 使用同一个文件', + }; } if (typeof nodeRaw.protocol !== 'string' || !GHOST_NODE_PROTOCOLS.includes(nodeRaw.protocol)) { - return { ok: false, reason: `node.protocol 必须是 ${GHOST_NODE_PROTOCOLS.join(' / ')}` }; + return { + ok: false, + reason: `node.protocol 必须是 ${GHOST_NODE_PROTOCOLS.join(' / ')}`, + }; } if (nodeRaw.lifecycle !== undefined && (typeof nodeRaw.lifecycle !== 'string' || !GHOST_NODE_LIFECYCLES.includes(nodeRaw.lifecycle))) { - return { ok: false, reason: `node.lifecycle 必须是 ${GHOST_NODE_LIFECYCLES.join(' / ')}` }; + return { + ok: false, + reason: `node.lifecycle 必须是 ${GHOST_NODE_LIFECYCLES.join(' / ')}`, + }; } if (nodeRaw.idleTimeoutSeconds !== undefined && (typeof nodeRaw.idleTimeoutSeconds !== 'number' || !Number.isInteger(nodeRaw.idleTimeoutSeconds) || nodeRaw.idleTimeoutSeconds < 30 || nodeRaw.idleTimeoutSeconds > 3600)) { - return { ok: false, reason: 'node.idleTimeoutSeconds 必须是 30–3600 的整数' }; + return { + ok: false, + reason: 'node.idleTimeoutSeconds 必须是 30–3600 的整数', + }; } if (nodeRaw.lifecycle === 'resident' && nodeRaw.idleTimeoutSeconds !== undefined) { - return { ok: false, reason: 'node.lifecycle 为 resident 时不能再声明 idleTimeoutSeconds' }; + return { + ok: false, + reason: 'node.lifecycle 为 resident 时不能再声明 idleTimeoutSeconds', + }; } + // 额外入口(多进程窄版):同一套入口纪律——包内安全相对路径、CJS 文件、 + // 不与浏览器沙箱 entry / 主入口 / 彼此重复;条数封顶。 let nodeEntries; if (nodeRaw.entries !== undefined) { if (!Array.isArray(nodeRaw.entries) || nodeRaw.entries.length === 0) { - return { ok: false, reason: 'node.entries 必须是非空数组(额外工作进程入口清单)' }; + return { + ok: false, + reason: 'node.entries 必须是非空数组(额外工作进程入口清单)', + }; } if (nodeRaw.entries.length > GHOST_NODE_MAX_EXTRA_ENTRIES) { - return { ok: false, reason: `node.entries 最多 ${GHOST_NODE_MAX_EXTRA_ENTRIES} 条` }; + return { + ok: false, + reason: `node.entries 最多 ${GHOST_NODE_MAX_EXTRA_ENTRIES} 条`, + }; } + // 同上折叠大小写:大小写变体在大小写不敏感文件系统上是同一个文件, + // 不能被当作不同入口通过查重。 const seen = new Set(); for (const extra of nodeRaw.entries) { if (!isSafeGhostRelativePath(extra)) { - return { ok: false, reason: 'node.entries 每项必须是安装目录内的安全相对路径' }; + return { + ok: false, + reason: 'node.entries 每项必须是安装目录内的安全相对路径', + }; } if (!/\.(?:c?js)$/.test(extra)) { - return { ok: false, reason: 'node.entries 每项必须是 CommonJS .js / .cjs 文件' }; + return { + ok: false, + reason: 'node.entries 每项必须是 CommonJS .js / .cjs 文件', + }; } const extraFold = extra.toLowerCase(); if (extraFold === raw.entry.toLowerCase()) { return { ok: false, reason: 'node.entries 不能包含浏览器沙箱 entry' }; } if (extraFold === nodeRaw.entry.toLowerCase()) { - return { ok: false, reason: 'node.entries 不能重复主入口 node.entry' }; + return { + ok: false, + reason: 'node.entries 不能重复主入口 node.entry', + }; } if (seen.has(extraFold)) { - return { ok: false, reason: `node.entries 含重复入口 ${JSON.stringify(extra)}` }; + return { + ok: false, + reason: `node.entries 含重复入口 ${JSON.stringify(extra)}`, + }; } seen.add(extraFold); } @@ -1664,7 +2432,10 @@ export function validateGhostManifest(raw) { parsed = new URL(binding.url); } catch { - return { ok: false, reason: 'node.secretBindings[].url 不是合法的绝对地址' }; + return { + ok: false, + reason: 'node.secretBindings[].url 不是合法的绝对地址', + }; } if (parsed.protocol !== 'https:' || parsed.username || parsed.password) { return { @@ -1698,7 +2469,10 @@ export function validateGhostManifest(raw) { }; } if (slots.includes('node') && node === undefined) { - return { ok: false, reason: 'slots 声明了 "node" 但缺少 node 工作进程详单' }; + return { + ok: false, + reason: 'slots 声明了 "node" 但缺少 node 工作进程详单', + }; } if (node?.secretBindings) { for (const binding of node.secretBindings) { @@ -1716,33 +2490,57 @@ export function validateGhostManifest(raw) { } } } + // preview 槽详单:与 slots 含 'preview' **严格成对**(有槽必有详单——域名 + // 范围是本能力的全部知情面,不允许"先装后说");域名语法与 network 白名单 + // 同一套(插件详情逐条展示)。 let preview; if (raw.preview !== undefined) { if (!isPlainObject(raw.preview)) { - return { ok: false, reason: 'preview 详单必须是对象(如 { "hosts": ["*.example.com"] })' }; + return { + ok: false, + reason: 'preview 详单必须是对象(如 { "hosts": ["*.example.com"] })', + }; } if (!slots.includes('preview')) { - return { ok: false, reason: '声明了 preview 详单但 slots 未包含 "preview"' }; + return { + ok: false, + reason: '声明了 preview 详单但 slots 未包含 "preview"', + }; } const previewRaw = raw.preview; const unknownPreviewField = Object.keys(previewRaw).find((key) => key !== 'hosts'); if (unknownPreviewField !== undefined) { - return { ok: false, reason: `preview 含不允许的字段 ${JSON.stringify(unknownPreviewField)}` }; + return { + ok: false, + reason: `preview 含不允许的字段 ${JSON.stringify(unknownPreviewField)}`, + }; } if (!Array.isArray(previewRaw.hosts) || previewRaw.hosts.length === 0) { - return { ok: false, reason: 'preview.hosts 必须是非空数组(可打开预览的域名白名单)' }; + return { + ok: false, + reason: 'preview.hosts 必须是非空数组(可打开预览的域名白名单)', + }; } if (previewRaw.hosts.length > GHOST_PREVIEW_MAX_HOSTS) { - return { ok: false, reason: `preview.hosts 最多 ${GHOST_PREVIEW_MAX_HOSTS} 条` }; + return { + ok: false, + reason: `preview.hosts 最多 ${GHOST_PREVIEW_MAX_HOSTS} 条`, + }; } const seenPreviewHosts = new Set(); for (const host of previewRaw.hosts) { if (!isValidGhostNetworkHostPattern(host) && !(typeof host === 'string' && GHOST_PREVIEW_LOOPBACK_HOSTS.has(host))) { - return { ok: false, reason: `preview.hosts 含不合法域名模式 ${JSON.stringify(host)}` }; + return { + ok: false, + reason: `preview.hosts 含不合法域名模式 ${JSON.stringify(host)}`, + }; } if (seenPreviewHosts.has(host)) { - return { ok: false, reason: `preview.hosts 含重复域名 ${JSON.stringify(host)}` }; + return { + ok: false, + reason: `preview.hosts 含重复域名 ${JSON.stringify(host)}`, + }; } seenPreviewHosts.add(host); } @@ -1754,6 +2552,10 @@ export function validateGhostManifest(raw) { reason: 'slots 声明了 "preview" 但缺少 preview 详单(hosts 域名白名单必填)', }; } + // skill 槽详单:与 slots 含 'skill' **严格成对**(有槽必有详单——捆绑了什么 + // 技能是本能力的全部知情面)。name/description 与 SKILL.md 的逐字一致性在 + // 打包与装入两侧另行强制,这里只管声明本身的形状。name/dir 大小写折叠去重: + // win32 文件系统折叠大小写,共享技能根的链接名不允许折叠后相撞。 let skill; if (raw.skill !== undefined) { if (!isPlainObject(raw.skill)) { @@ -1768,20 +2570,32 @@ export function validateGhostManifest(raw) { const skillRaw = raw.skill; const unknownSkillField = Object.keys(skillRaw).find((key) => key !== 'items'); if (unknownSkillField !== undefined) { - return { ok: false, reason: `skill 含不允许的字段 ${JSON.stringify(unknownSkillField)}` }; + return { + ok: false, + reason: `skill 含不允许的字段 ${JSON.stringify(unknownSkillField)}`, + }; } if (!Array.isArray(skillRaw.items) || skillRaw.items.length === 0) { - return { ok: false, reason: 'skill.items 必须是非空数组(随包捆绑的技能清单)' }; + return { + ok: false, + reason: 'skill.items 必须是非空数组(随包捆绑的技能清单)', + }; } if (skillRaw.items.length > GHOST_SKILL_MAX_ITEMS) { - return { ok: false, reason: `skill.items 最多 ${GHOST_SKILL_MAX_ITEMS} 条` }; + return { + ok: false, + reason: `skill.items 最多 ${GHOST_SKILL_MAX_ITEMS} 条`, + }; } const skillItems = []; const seenSkillNames = new Set(); const seenSkillDirs = new Set(); for (const item of skillRaw.items) { if (!isPlainObject(item)) { - return { ok: false, reason: 'skill.items 每项必须是对象({ dir, name, description })' }; + return { + ok: false, + reason: 'skill.items 每项必须是对象({ dir, name, description })', + }; } const itemRaw = item; const unknownItemField = Object.keys(itemRaw).find((key) => key !== 'dir' && key !== 'name' && key !== 'description'); @@ -1808,25 +2622,43 @@ export function validateGhostManifest(raw) { if (typeof itemRaw.description !== 'string' || itemRaw.description.trim().length === 0 || itemRaw.description.length > 1024) { - return { ok: false, reason: 'skill.items[].description 必须是 1–1024 字符的非空字符串' }; + return { + ok: false, + reason: 'skill.items[].description 必须是 1–1024 字符的非空字符串', + }; } const nameFold = itemRaw.name.toLowerCase(); if (seenSkillNames.has(nameFold)) { - return { ok: false, reason: `skill.items 含重复 name ${JSON.stringify(itemRaw.name)}` }; + return { + ok: false, + reason: `skill.items 含重复 name ${JSON.stringify(itemRaw.name)}`, + }; } seenSkillNames.add(nameFold); const dirFold = itemRaw.dir.toLowerCase(); if (seenSkillDirs.has(dirFold)) { - return { ok: false, reason: `skill.items 含重复 dir ${JSON.stringify(itemRaw.dir)}` }; + return { + ok: false, + reason: `skill.items 含重复 dir ${JSON.stringify(itemRaw.dir)}`, + }; } seenSkillDirs.add(dirFold); - skillItems.push({ dir: itemRaw.dir, name: itemRaw.name, description: itemRaw.description }); + skillItems.push({ + dir: itemRaw.dir, + name: itemRaw.name, + description: itemRaw.description, + }); } skill = { items: skillItems }; } if (slots.includes('skill') && skill === undefined) { - return { ok: false, reason: 'slots 声明了 "skill" 但缺少 skill 详单(items 技能清单必填)' }; + return { + ok: false, + reason: 'slots 声明了 "skill" 但缺少 skill 详单(items 技能清单必填)', + }; } + // manual 是独立顶层字段,不是能力 slot 或授权项。这里只校验一级逻辑索引; + // MANUAL.md 存在性、Markdown 文本与逐文件 64KB 上限由打包/装入两侧校验。 let manual; if (raw.manual !== undefined) { if (!isPlainObject(raw.manual)) { @@ -1838,20 +2670,29 @@ export function validateGhostManifest(raw) { const manualRaw = raw.manual; const unknownManualField = Object.keys(manualRaw).find((key) => key !== 'items'); if (unknownManualField !== undefined) { - return { ok: false, reason: `manual 含不允许的字段 ${JSON.stringify(unknownManualField)}` }; + 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} 条` }; + return { + ok: false, + reason: `manual.items 最多 ${GHOST_MANUAL_MAX_ITEMS} 条`, + }; } const manualItems = []; 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 })' }; + return { + ok: false, + reason: 'manual.items 每项必须是对象({ dir, name, description })', + }; } const itemRaw = item; const unknownItemField = Object.keys(itemRaw).find((key) => key !== 'dir' && key !== 'name' && key !== 'description'); @@ -1892,11 +2733,17 @@ export function validateGhostManifest(raw) { } const nameFold = itemRaw.name.toLowerCase(); if (seenManualNames.has(nameFold)) { - return { ok: false, reason: `manual.items 含重复 name ${JSON.stringify(itemRaw.name)}` }; + 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)}` }; + return { + ok: false, + reason: `manual.items 含重复 dir ${JSON.stringify(itemRaw.dir)}`, + }; } seenManualDirs.add(dirFold); manualItems.push({ @@ -1907,24 +2754,167 @@ export function validateGhostManifest(raw) { } manual = { items: manualItems }; } + // setup 引用必须在交付边界就与同一份 manifest 的凭证、连接和设置入口对齐; + // 不能让服务端接受、Desktop 装入时才拒绝。 + let setup; + if (raw.setup !== undefined) { + if (!isPlainObject(raw.setup)) { + return { + ok: false, + reason: 'setup 必须是对象(如 { "requires": [{ "anyOf": ["secret:api_key"] }] })', + }; + } + const setupRaw = raw.setup; + if (!Array.isArray(setupRaw.requires) || + setupRaw.requires.length > GHOST_SETUP_MAX_GROUPS) { + return { + ok: false, + reason: `setup.requires 必须是 0–${GHOST_SETUP_MAX_GROUPS} 组的数组(空数组 = 显式声明无使用前置需求)`, + }; + } + const secretByKey = new Map([ + ...(network?.secrets ?? []).map((secret) => [ + secret.key, + { + hostDerivedSource: secret.source === 'login-email' || + secret.source === 'gh-cli' || + secret.source === 'oidc-token' + ? secret.source + : null, + }, + ]), + ...(node?.secretBindings ?? []).map((secret) => [secret.key, { hostDerivedSource: null }]), + ]); + const connectionKeys = new Set((network?.connections ?? []).map((connection) => connection.key)); + const groups = []; + for (const group of setupRaw.requires) { + if (!isPlainObject(group) || + !Array.isArray(group.anyOf) || + group.anyOf.length === 0 || + group.anyOf.length > GHOST_SETUP_MAX_ITEMS_PER_GROUP) { + return { + ok: false, + reason: `setup.requires 每组必须是 { "anyOf": [...] } 且组内 1–${GHOST_SETUP_MAX_ITEMS_PER_GROUP} 条`, + }; + } + const items = []; + const seenRefs = new Set(); + for (const requirement of group.anyOf) { + let item; + if (typeof requirement === 'string') { + const match = /^(secret|connection):(.+)$/.exec(requirement); + if (!match) { + return { + ok: false, + reason: `setup 条目 ${JSON.stringify(requirement)} 形态不对(字符串条目须为 "secret:" 或 "connection:";kv 用对象 { "kv": "", "label": "..." })`, + }; + } + const [, kind, key] = match; + if (kind === 'secret') { + const declaration = secretByKey.get(key); + if (!declaration) { + return { + ok: false, + reason: `setup 引用了未声明的凭证 ${JSON.stringify(key)}(必须逐字取自 network.secrets[].key 或 node.secretBindings[].key)`, + }; + } + if (declaration.hostDerivedSource) { + return { + ok: false, + reason: `setup 不允许引用 ${declaration.hostDerivedSource} 源凭证 ${JSON.stringify(key)}(Host 派生身份没有用户配置动作可引导)`, + }; + } + item = { kind: 'secret', key }; + } + else { + if (!connectionKeys.has(key)) { + return { + ok: false, + reason: `setup 引用了未声明的连接 ${JSON.stringify(key)}(必须逐字取自 network.connections[].key)`, + }; + } + item = { kind: 'connection', key }; + } + } + else if (isPlainObject(requirement)) { + if (typeof requirement.kv === 'string' && + isGhostManifestReservedRecordKey(requirement.kv)) { + return { + ok: false, + reason: `setup kv 条目的 kv 不允许使用对象保留键名 ${JSON.stringify(requirement.kv)}`, + }; + } + if (typeof requirement.kv !== 'string' || + !GHOST_SETUP_KV_KEY_RE.test(requirement.kv)) { + return { + ok: false, + reason: 'setup kv 条目的 kv 必须是 1–64 位字母/数字/下划线/点/连字符的键名', + }; + } + if (typeof requirement.label !== 'string' || + requirement.label.trim().length === 0 || + requirement.label.length > 64) { + return { + ok: false, + reason: 'setup kv 条目必须带 1–64 字符的 label(kv 键名宿主无先验,弹窗要有名字可展示)', + }; + } + if (raw.settingsHtml === undefined) { + return { + ok: false, + reason: 'setup 引用了 kv 参数但没有 settingsHtml——参数由意识设置界面收单,没有界面就没人填', + }; + } + item = { kind: 'kv', key: requirement.kv, label: requirement.label }; + } + else { + return { + ok: false, + reason: 'setup.requires[].anyOf 每条必须是字符串引用或 { "kv", "label" } 对象', + }; + } + const ref = `${item.kind}:${item.key}`; + if (seenRefs.has(ref)) { + return { ok: false, reason: `setup 同组内含重复条目 ${JSON.stringify(ref)}` }; + } + seenRefs.add(ref); + items.push(item); + } + groups.push({ anyOf: items }); + } + setup = { requires: groups }; + } + // 显式触发指令:1–32 字符、无空白、无 '/'(允许中文,如 /画图); + // 必须有工具可干活。跨意识查重在装入时由 GhostManager 执行(需要本地清单)。 if (raw.command !== undefined) { if (typeof raw.command !== 'string' || raw.command.length === 0 || raw.command.length > 32 || /[\s/]/.test(raw.command)) { - return { ok: false, reason: 'command 必须是 1–32 字符、不含空白与 "/" 的字符串' }; + return { + ok: false, + reason: 'command 必须是 1–32 字符、不含空白与 "/" 的字符串', + }; } if (tools === undefined) { - return { ok: false, reason: '声明了 command 但没有 tools——没有工具的指令无事可做' }; + return { + ok: false, + reason: '声明了 command 但没有 tools——没有工具的指令无事可做', + }; } } + // 语义触发扩展词表:≤8 个、每个 2–24 字符(单字词命中面失控,拒收)、 + // 去首尾空白后不许为空;大小写折叠去重。必须有工具可干活(同 command)。 let keywords; if (raw.keywords !== undefined) { if (!Array.isArray(raw.keywords) || raw.keywords.length === 0 || raw.keywords.length > 8) { return { ok: false, reason: 'keywords 必须是 1–8 项的数组' }; } if (tools === undefined) { - return { ok: false, reason: '声明了 keywords 但没有 tools——没有工具的触发词无事可做' }; + return { + ok: false, + reason: '声明了 keywords 但没有 tools——没有工具的触发词无事可做', + }; } const seen = new Set(); keywords = []; @@ -1940,7 +2930,7 @@ export function validateGhostManifest(raw) { } const fold = word.toLowerCase(); if (seen.has(fold)) - continue; + continue; // 重复词静默去重,不拒装 seen.add(fold); keywords.push(word); } @@ -1948,7 +2938,8 @@ export function validateGhostManifest(raw) { return { ok: true, manifest: { - schemaVersion: GHOST_MANIFEST_SCHEMA_VERSION, + ...prepared.unknownV3Fields, + schemaVersion: prepared.schemaVersion, id: raw.id, name: raw.name, version: raw.version, @@ -1963,12 +2954,18 @@ export function validateGhostManifest(raw) { ...(raw.icon !== undefined ? { icon: raw.icon } : {}), entry: raw.entry, ...(raw.launch !== undefined ? { launch: raw.launch } : {}), - ...(agent !== undefined ? { agent } : {}), + ...(agent !== undefined || prepared.v3BaseAgent + ? { agent: agent ?? {} } + : {}), ...(node !== undefined ? { node } : {}), ...(raw.settingsHtml !== undefined ? { settingsHtml: raw.settingsHtml } : {}), ...(raw.settingsHeight !== undefined ? { settingsHeight: raw.settingsHeight } : {}), - slots, - ...(card !== undefined ? { card } : {}), + ...(prepared.schemaVersion === 2 ? { slots } : {}), + ...(card !== undefined + || prepared.v3BaseCard + || (prepared.schemaVersion === 3 && slots.includes('card')) + ? { card: card ?? {} } + : {}), ...(tools !== undefined ? { tools } : {}), ...(cindy !== undefined ? { cindy } : {}), ...(subscribe !== undefined ? { subscribe } : {}), @@ -1976,9 +2973,28 @@ export function validateGhostManifest(raw) { ...(raw.command !== undefined ? { command: raw.command } : {}), ...(keywords !== undefined ? { keywords } : {}), ...(panel !== undefined ? { panel } : {}), + ...(mainView !== undefined ? { mainView } : {}), ...(preview !== undefined ? { preview } : {}), ...(skill !== undefined ? { skill } : {}), + ...(prepared.schemaVersion === 3 && slots.includes('notify') ? { notify: true } : {}), + ...(prepared.schemaVersion === 3 && slots.includes('badge') ? { badge: true } : {}), + ...(prepared.schemaVersion === 3 && slots.includes('confirm') ? { confirm: true } : {}), + ...(prepared.schemaVersion === 3 && slots.includes('fs') ? { fs: true } : {}), + ...(prepared.schemaVersion === 3 && slots.includes('library') + ? { library: true } + : {}), + ...(prepared.schemaVersion === 3 && slots.includes('session-context') + ? { sessionContext: true } + : {}), + ...(prepared.schemaVersion === 3 && slots.includes('pick') ? { pick: true } : {}), + ...(prepared.schemaVersion === 3 && slots.includes('workspace') + ? { workspace: true } + : {}), + ...(prepared.schemaVersion === 3 && slots.includes('ios-simulator') + ? { iosSimulator: true } + : {}), ...(manual !== undefined ? { manual } : {}), + ...(setup !== undefined ? { setup } : {}), }, }; } diff --git a/.tests/plugin-contract.test.mjs b/.tests/plugin-contract.test.mjs index 45a8b4c..b2a2d97 100644 --- a/.tests/plugin-contract.test.mjs +++ b/.tests/plugin-contract.test.mjs @@ -3,7 +3,7 @@ import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import test from 'node:test'; -import { validateGhostManifest } from './contracts/plugin-manifest.dbbf169.mjs'; +import { validateGhostManifest } from './contracts/plugin-manifest.dae1c66.mjs'; const root = path.resolve(import.meta.dirname, '..'); @@ -21,7 +21,7 @@ const PUBLIC_SKILL_EXEMPTIONS = new Set(['ios-simulator', 'x-manager']); const OFFICIAL_SLOTS = new Set([ 'subscribe', 'tool', 'card', 'panel', 'cindy', 'agent', 'node', 'network', 'notify', 'badge', 'confirm', 'fs', 'session-context', 'pick', 'preview', - 'skill', 'workspace', 'ios-simulator', + 'library', 'skill', 'workspace', 'ios-simulator', ]); const pluginDirs = pluginRootsAt('HEAD'); @@ -122,7 +122,7 @@ function validateOfficialManifest(pluginDir, manifest) { assert.ok(manifest && typeof manifest === 'object' && !Array.isArray(manifest), `${pluginDir}/ghost.json must be an object`); const protocolResult = validateGhostManifest(manifest); assert.ok(protocolResult.ok, `${pluginDir}/ghost.json is rejected by Cindy: ${protocolResult.reason}`); - assert.equal(manifest.schemaVersion, 2, `${pluginDir}: unsupported ghost.json schemaVersion`); + assert.ok(manifest.schemaVersion === 2 || manifest.schemaVersion === 3, `${pluginDir}: unsupported ghost.json schemaVersion`); assert.match(manifest.id ?? '', /^[a-z0-9][a-z0-9-]{0,31}$/, `${pluginDir}: invalid plugin id`); stableSemver(manifest.version, pluginDir); assertText(manifest.name, `${pluginDir}.name`, 64); @@ -131,9 +131,14 @@ function validateOfficialManifest(pluginDir, manifest) { if (manifest.author !== undefined) assertText(manifest.author, `${pluginDir}.author`, 64); assert.ok(isSafePackagePath(manifest.entry), `${pluginDir}.entry must be a safe relative path`); assert.ok(manifest.launch === undefined || manifest.launch === 'on-demand' || manifest.launch === 'resident', `${pluginDir}.launch must be on-demand or resident`); - assert.ok(Array.isArray(manifest.slots) && manifest.slots.length > 0, `${pluginDir}.slots must be a non-empty array`); - assert.equal(new Set(manifest.slots).size, manifest.slots.length, `${pluginDir}.slots contains duplicates`); - for (const slot of manifest.slots) assert.ok(OFFICIAL_SLOTS.has(slot), `${pluginDir}: unknown slot ${JSON.stringify(slot)}`); + if (manifest.schemaVersion === 2) { + assert.ok(Array.isArray(manifest.slots), `${pluginDir}.slots must be an array`); + assert.equal(new Set(manifest.slots).size, manifest.slots.length, `${pluginDir}.slots contains duplicates`); + for (const slot of manifest.slots) assert.ok(OFFICIAL_SLOTS.has(slot), `${pluginDir}: unknown slot ${JSON.stringify(slot)}`); + } else { + assert.equal(Object.hasOwn(manifest, 'slots'), false, `${pluginDir}: Manifest v3 must not contain slots`); + assertManifestV3MinCindyVersion(pluginDir, manifest); + } if (manifest.tools !== undefined) { assert.ok(Array.isArray(manifest.tools) && manifest.tools.length > 0 && manifest.tools.length <= 16, `${pluginDir}.tools must contain 1-16 entries`); const toolNames = new Set(); @@ -149,7 +154,9 @@ function validateOfficialManifest(pluginDir, manifest) { } } } - assert.equal(manifest.slots.includes('tool'), manifest.tools !== undefined, `${pluginDir}: tool slot and tools declarations must appear together`); + if (manifest.schemaVersion === 2) { + assert.equal(manifest.slots.includes('tool'), manifest.tools !== undefined, `${pluginDir}: tool slot and tools declarations must appear together`); + } return manifest; } @@ -360,7 +367,7 @@ function validatePluginSource(pluginDir, manifest) { assert.ok(totalBytes <= maxTotal, `${pluginDir}: uncompressed package exceeds ${maxTotal} bytes`); requireTrackedFile(files, pluginDir, 'ghost.json', MAX_MANIFEST_BYTES); - const declaredFiles = [manifest.entry, manifest.panel?.html, manifest.settingsHtml, manifest.icon] + const declaredFiles = [manifest.entry, manifest.panel?.html, manifest.mainView?.html, manifest.settingsHtml, manifest.icon] .filter(Boolean); if (manifest.node) declaredFiles.push(manifest.node.entry, ...(manifest.node.entries ?? [])); for (const relativePath of declaredFiles) { @@ -402,12 +409,26 @@ function pluginRootsAt(revision) { .sort(); } -function stableSemver(version, pluginDir) { +function stableSemver(version, pluginDir, field = 'ghost.json.version') { const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(version); - assert.ok(match, `${pluginDir}: ghost.json.version must use major.minor.patch SemVer`); + assert.ok(match, `${pluginDir}: ${field} must use major.minor.patch SemVer`); return match.slice(1).map(BigInt); } +function assertManifestV3MinCindyVersion(pluginDir, manifest) { + const parts = stableSemver(manifest.minCindyVersion, pluginDir, 'minCindyVersion'); + assert.ok( + parts.some((part) => part > 0n), + `${pluginDir}: minCindyVersion must name a released stable Cindy version, not 0.0.0`, + ); +} + +function assertChangedPluginUsesManifestV3(pluginDir, manifest) { + assert.equal(manifest.schemaVersion, 3, `${pluginDir}: changed plugin packages must migrate to schemaVersion 3`); + assert.equal(Object.hasOwn(manifest, 'slots'), false, `${pluginDir}: Manifest v3 must not contain slots`); + assertManifestV3MinCindyVersion(pluginDir, manifest); +} + function compareStableSemver(left, right, pluginDir) { const leftParts = stableSemver(left, pluginDir); const rightParts = stableSemver(right, pluginDir); @@ -443,25 +464,132 @@ function validateReleaseDiff() { compareStableSemver(after.version, before.version, pluginDir) > 0, `${pluginDir}: packaged content changed, so ghost.json.version must be greater than ${before.version}`, ); + assertChangedPluginUsesManifestV3(pluginDir, after); } } for (const pluginDir of targetRoots) { if (baseRootSet.has(pluginDir)) continue; const manifest = readJson(path.join(root, pluginDir, 'ghost.json'), MAX_MANIFEST_BYTES); stableSemver(manifest.version, pluginDir); + assertChangedPluginUsesManifestV3(pluginDir, manifest); } } test('the pinned Cindy manifest contract rejects client-incompatible shapes', () => { - const pluginDir = pluginDirs[0]; - const manifest = readJson(path.join(root, pluginDir, 'ghost.json'), MAX_MANIFEST_BYTES); - assert.equal(validateGhostManifest(manifest).ok, true, `${pluginDir}: baseline fixture must be valid`); - assert.equal(validateGhostManifest({ ...manifest, kind: 'declaration' }).ok, false, 'legacy kind must be rejected'); + const legacy = { + schemaVersion: 2, + id: 'legacy-contract-fixture', + name: 'Legacy contract fixture', + version: '1.0.0', + entry: 'main.js', + slots: ['tool'], + tools: [{ name: 'run', description: 'Run the fixture' }], + }; + assert.equal(validateGhostManifest(legacy).ok, true, 'legacy fixture must be valid'); + assert.equal(validateGhostManifest({ ...legacy, kind: 'declaration' }).ok, false, 'legacy kind must be rejected'); assert.equal( - validateGhostManifest({ ...manifest, panel: { html: manifest.entry } }).ok, + validateGhostManifest({ ...legacy, panel: { html: legacy.entry } }).ok, false, 'panel declaration without the panel slot must be rejected', ); + const direct = { + schemaVersion: 3, + minCindyVersion: '0.1.0', + id: 'direct-contract-fixture', + name: 'Direct contract fixture', + version: '1.0.0', + entry: 'main.js', + tools: [{ name: 'run', description: 'Run the fixture' }], + notify: true, + futureCapability: { mode: 'preserved' }, + }; + const directResult = validateGhostManifest(direct); + assert.equal(directResult.ok, true, 'Manifest v3 direct declarations must be accepted'); + assert.equal(Object.hasOwn(directResult.manifest, 'slots'), false, 'Manifest v3 output must not contain slots'); + assert.deepEqual(directResult.manifest.futureCapability, { mode: 'preserved' }, 'unknown v3 fields must survive normalization'); + assert.equal(validateGhostManifest({ ...direct, slots: ['tool'] }).ok, false, 'Manifest v3 slots must be rejected'); + assert.equal(validateGhostManifest({ ...direct, notify: false }).ok, false, 'Manifest v3 boolean capabilities must be literal true'); + assert.equal( + validateGhostManifest({ ...direct, minCindyVersion: '0.0.1' }).ok, + true, + 'Manifest v3 must not impose a repository-wide Cindy version floor', + ); + assert.throws( + () => assertManifestV3MinCindyVersion('direct-contract-fixture', { minCindyVersion: '0.0.0' }), + /released stable Cindy version/, + 'official releases must not use the versionless development sentinel', + ); +}); + +test('mainView HTML must exist among tracked package files', () => { + const pluginDir = 'cindy-github'; + const fixture = { + ...readJson(path.join(root, pluginDir, 'ghost.json'), MAX_MANIFEST_BYTES), + schemaVersion: 3, + minCindyVersion: '1.0.0', + }; + delete fixture.slots; + assert.doesNotThrow(() => validatePluginSource(pluginDir, fixture), 'mainView remains optional'); + + const present = { ...fixture, mainView: { html: 'settings.html' } }; + assert.doesNotThrow(() => validateOfficialManifest(pluginDir, present)); + assert.doesNotThrow(() => validatePluginSource(pluginDir, present)); + + const missing = { ...fixture, mainView: { html: 'missing-main-view.html' } }; + assert.doesNotThrow(() => validateOfficialManifest(pluginDir, missing), 'a safe path is valid at the manifest layer'); + assert.throws( + () => validatePluginSource(pluginDir, missing), + /declared file is not tracked: missing-main-view\.html/, + 'package validation must reject a missing mainView HTML file', + ); +}); + +test('authoring docs provide a harness-independent Manifest-v3 path', () => { + const docs = new Map([ + ['README.md', readUtf8(path.join(root, 'README.md'))], + ['README.zh-CN.md', readUtf8(path.join(root, 'README.zh-CN.md'))], + ['CONTRIBUTING.md', readUtf8(path.join(root, 'CONTRIBUTING.md'))], + ['CONTRIBUTING.zh-CN.md', readUtf8(path.join(root, 'CONTRIBUTING.zh-CN.md'))], + ]); + + for (const [file, contents] of docs) { + for (const required of ['ghost.json', '.cindy', 'scripts/validate-plugin-manifest.mjs']) { + assert.ok(contents.includes(required), `${file} must document ${required}`); + } + assert.match(contents, /harness/i, `${file} must make the authoring flow harness-independent`); + assert.doesNotMatch(contents, /ghost_forge_guide/, `${file} must not require a Cindy-only guide tool`); + assert.match(contents, /\bv2\b/i, `${file} must identify the legacy manifest version`); + if (file.endsWith('.zh-CN.md')) { + assert.ok(contents.includes('不要复制'), `${file} must warn against copying an existing ghost.json`); + } else { + assert.match(contents, /do \*\*not\*\* copy/i, `${file} must warn against copying an existing ghost.json`); + } + } + + for (const file of ['README.md', 'README.zh-CN.md']) { + const contents = docs.get(file); + for (const runtimeContract of ['cindy.onHostMessage', 'cindy.send', 'tool-result']) { + assert.ok(contents.includes(runtimeContract), `${file} must document ${runtimeContract}`); + } + const jsonBlocks = [...contents.matchAll(/```json\n([\s\S]*?)\n```/g)].map((match) => match[1]); + const manifestSource = jsonBlocks.find((block) => block.includes('"schemaVersion": 3')); + assert.ok(manifestSource, `${file} must contain a minimal Manifest-v3 example`); + const manifest = JSON.parse(manifestSource); + const result = validateGhostManifest(manifest); + assert.ok(result.ok, `${file} Manifest-v3 example is rejected by Cindy: ${result.reason}`); + assert.equal(Object.hasOwn(manifest, 'slots'), false, `${file} Manifest-v3 example must not contain slots`); + } + + const validatorOutput = execFileSync( + process.execPath, + [path.join(root, 'scripts', 'validate-plugin-manifest.mjs'), path.join(root, 'cindy-art')], + { cwd: root, encoding: 'utf8' }, + ); + assert.match(validatorOutput, /valid Cindy plugin manifest/, 'the documented validator command must be executable'); + + const pullRequestTemplate = readUtf8(path.join(root, '.github/PULL_REQUEST_TEMPLATE.md')); + assert.doesNotMatch(pullRequestTemplate, /\bslots?\b/i, 'the PR template must use Manifest-v3 direct capability language'); + assert.match(pullRequestTemplate, /direct capability\s+fields/, 'the PR template must review direct capability declarations'); }); test('all official plugins satisfy the repository publish contract', () => { diff --git a/.tests/publish-workflows.test.mjs b/.tests/publish-workflows.test.mjs index 0985723..0a7808a 100644 --- a/.tests/publish-workflows.test.mjs +++ b/.tests/publish-workflows.test.mjs @@ -1,5 +1,8 @@ import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import test from 'node:test'; const workflowRoot = new URL('../.github/workflows/', import.meta.url); @@ -11,6 +14,87 @@ const globalWorkflow = readFileSync( new URL('publish-cindy-plugins-global.yml', workflowRoot), 'utf8', ); +const prWorkflow = readFileSync(new URL('pr-verify.yml', workflowRoot), 'utf8'); + +test('changed-plugin detection uses the merge parents even when event base is stale', (t) => { + const fixture = mkdtempSync(path.join(os.tmpdir(), 'cindy-plugin-pr-diff-')); + t.after(() => rmSync(fixture, { recursive: true, force: true })); + const git = (...args) => execFileSync('git', args, { cwd: fixture, encoding: 'utf8' }).trim(); + const commit = () => { + git('add', '.'); + git('-c', 'core.hooksPath=/dev/null', '-c', 'commit.gpgsign=false', 'commit', '-qm', 'fixture'); + return git('rev-parse', 'HEAD'); + }; + const write = (file, content) => { + const target = path.join(fixture, file); + mkdirSync(path.dirname(target), { recursive: true }); + writeFileSync(target, content); + }; + git('init', '-q', '-b', 'main'); + git('config', 'user.name', 'Fixture'); + git('config', 'user.email', 'fixture@example.test'); + write('base-only/ghost.json', '{}'); + write('pr-changed/ghost.json', '{}'); + write('LICENSE', 'fixture license'); + const staleBase = commit(); + git('checkout', '-qb', 'feature'); + write('README.md', 'documentation-only PR'); + const head = commit(); + git('checkout', '-q', 'main'); + write('base-only/main.js', '// upstream-only change'); + const base = commit(); + git('checkout', '-q', 'feature'); + + const step = prWorkflow.match(/ - name: Detect changed plugin directories\n([\s\S]*?)(?=\n - name:)/)?.[1]; + assert.ok(step, 'workflow must contain the changed-plugin detector'); + const source = step.split(' run: |\n')[1]; + assert.ok(source, 'detector must contain an executable shell script'); + const script = source.split('\n').map((line) => line.replace(/^ /, '')).join('\n'); + let mergeNumber = 0; + const detect = (prHead) => { + git('checkout', '-qb', `merge-${++mergeNumber}`, base); + git('-c', 'core.hooksPath=/dev/null', '-c', 'commit.gpgsign=false', 'merge', '--no-ff', '-qm', 'PR merge result', prHead); + const run = mkdtempSync(path.join(os.tmpdir(), 'cindy-plugin-pr-output-')); + t.after(() => rmSync(run, { recursive: true, force: true })); + const output = path.join(run, 'output'); + execFileSync('bash', ['-c', script], { + cwd: fixture, + encoding: 'utf8', + env: { ...process.env, BASE_SHA: staleBase, HEAD_SHA: prHead, RUNNER_TEMP: run, GITHUB_OUTPUT: output }, + }); + git('checkout', '-q', 'feature'); + return JSON.parse(readFileSync(output, 'utf8').trim().replace(/^plugins=/, '')); + }; + + assert.deepEqual(detect(head), [], 'a documentation-only PR must not include a base-only plugin change'); + write('pr-changed/main.js', '// PR plugin change'); + assert.deepEqual(detect(commit()), [{ directory: 'pr-changed' }], 'a PR plugin change must still require verification'); + write('LICENSE', 'changed shared package license'); + assert.deepEqual(detect(commit()), [{ directory: 'base-only' }, { directory: 'pr-changed' }], 'shared packaged files must still select every plugin'); + + const contractStep = prWorkflow.match(/ - name: Validate official plugin publish contract\n([\s\S]*?)(?=\n - name:)/)?.[1]; + assert.ok(contractStep, 'workflow must contain the package contract gate'); + assert.ok(contractStep.includes('test "$(git rev-parse HEAD^2)" = "${HEAD_SHA}"')); + assert.ok(contractStep.includes('export BASE_SHA="$(git rev-parse HEAD^1)"')); + assert.doesNotMatch(prWorkflow, /github\.event\.pull_request\.base\.sha/); +}); + +test('pull request verification requires the production Cindy attestation', () => { + assert.match(prWorkflow, /^ - edited$/m); + assert.match( + prWorkflow, + /name: Require production Cindy verification attestation\n if: \$\{\{ steps\.changes\.outputs\.plugins != '\[\]' \}\}/, + ); + assert.match(prWorkflow, /Production Cindy verification \/ 生产版 Cindy 验证/); + assert.match(prWorkflow, /\.pull_request\.body/); + assert.match(prWorkflow, /GITHUB_EVENT_PATH/); + assert.doesNotMatch(prWorkflow, /github\.event\.pull_request\.body/); + assert.ok( + prWorkflow.indexOf('name: Dry-run plugin packaging') < + prWorkflow.indexOf('name: Require production Cindy verification attestation'), + 'package validation must run before a pending manual attestation blocks the job', + ); +}); test('CN and Global plugin publishers are operationally independent', () => { assert.match(cnWorkflow, /^name: Publish Cindy Plugins \(CN\)$/m); diff --git a/AGENTS.md b/AGENTS.md index 2716376..09572cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,17 @@ experience risks land on real users, so review is strict by design. 每次合入 `main` 都会自动向 CN / Global 审核队列提交真实包,审核通过后才对用户 可见,审查从严。 +## Authoring reference / 编写参考 + +For plugin creation or maintenance, read the +[authoring and migration reference](./docs/plugin-authoring.md) +([中文](./docs/plugin-authoring.zh-CN.md)) alongside the README. It documents +equivalent v2/v3 declarations, existing runtime APIs, and verification boundaries. +Infer routine adaptations from the requested functionality and existing code; +do not require authors to perform the migration checklist themselves. +编写或维护插件时,依据上述参考与现有代码自行完成格式适配、声明保留和校验; +只把无法从事实确定的功能取舍、目标版本或实机验证缺口交给作者,不增加手工迁移步骤。 + ## Review contract / 审查契约 - **Authoritative ruleset: [`.greptile/rules.md`](./.greptile/rules.md)** plus @@ -34,8 +45,11 @@ flagged: a settings page saving the user-entered credential through same-origin `PUT /secrets/` (the sanctioned write-only path), and handing credentials to an approved third-party runtime (e.g. TapTap Maker) without copying them into Cindy KV/Secret or retaining plaintext anywhere else (logs, page state); -network targets ⊆ `ghost.json` allowlist (Node -workers reviewed against their fixed endpoints instead); tools with +autonomous plugin network targets ⊆ `ghost.json` allowlist (ordinary HTTPS +performed inside the current Agent tool call may instead use the Host-issued, +strictly in-flight `callId`; managed credentials still require an explicit +declaration and matching host); plugins declaring the top-level `node` field +have their autonomous workers reviewed against fixed endpoints instead; tools with irreversible external side effects must distinguish "not executed / executed / unknown" on every failure path; no `Math.random` for externally-visible ids; vendor/dist changes require itemized evidence, never a bare "looks fine". @@ -46,8 +60,8 @@ vendor/dist changes require itemized evidence, never a bare "looks fine". the new `major.minor.patch` SemVer must be greater than the version on `main`. - CI must enforce the official repository manifest and package contract in `.tests/plugin-contract.test.mjs` without checking out another repository. -- Run `node --test .tests/localization.test.mjs .tests/provisioning.test.mjs - .tests/publish-workflows.test.mjs`, plus the changed plugin's own +- Run `node --test .tests/plugin-contract.test.mjs .tests/localization.test.mjs + .tests/provisioning.test.mjs .tests/publish-workflows.test.mjs`, plus the changed plugin's own `.tests/.test.mjs` if present. - Four-language locales (`zh-CN` / `en` / `ja` / `ko`) complete; `docs/localization.md` defines the English-fallback contract — do not demand @@ -60,9 +74,11 @@ vendor/dist changes require itemized evidence, never a bare "looks fine". of the PR and must not be flagged. - Bundled third-party dependencies changed → update that plugin's `THIRD-PARTY-LICENSES.txt`. -- If `minCindyVersion` is added or raised, record a real packaged `.cindy` - install on that exact Cindy version in the PR. Lowering/removing it requires - maintainer review. +- Every changed plugin package requires the PR's production Cindy verification + checkbox, attesting that its packaged `.cindy` was installed and exercised on + a real device running a stable production Cindy build. If the plugin declares + `minCindyVersion`, that Cindy build must be greater than or equal to it. + Lowering/removing the field requires maintainer review. - Paired bilingual docs (`README.md` ↔ `README.zh-CN.md`, etc.) must change in the same PR. 双语文档必须同 PR 同步。 - Never commit credentials, real user data, or `node_modules`; fixtures use diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5f49a20..60a5977 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,10 +14,20 @@ maintained in separate repositories and are outside the scope of this repository - Read [`README.md`](README.md) first. It is the source of truth for the repository layout, the plugin list, the review standards, and the publish flow; this guide does not duplicate them. -- The complete plugin-authoring contract (all `ghost.json` fields, slots, the - `cindy.send` pipe API, the packaging flow) is defined by the manual returned by - the `ghost_forge_guide` tool built into the Cindy client — just say "help me - build a plugin" in a Cindy conversation to get the current version. +- The plugin-authoring contract lives in this repository: `README.md`, this + guide, the pinned Manifest validator under `.tests/contracts/`, and the + packaging checks. It is independent of the Agent or harness used to create + the files. Cindy Forge commands are optional shortcuts only. +- Use the [authoring and migration reference](docs/plugin-authoring.md) for + field mappings and concrete runtime calls. The implementing Agent handles + adaptations from existing code; authors do not need to supply migration steps. +- Decide who performs an operation before adding a manifest field. Whether the + plugin tool runs is decided by existing Agent authorization. Ordinary HTTPS and + workdir operations use the Host-issued in-flight `callId`; CLIs continue through + the existing Node worker. Specific commands, hosts, and paths are not pre-registered; + autonomous Host use outside that call requires the corresponding declaration. + Bundled Node entries and Host-managed credentials remain explicitly declared, + including when used inside a tool call. - Follow [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md) when participating in the community. For ordinary usage questions, see [`SUPPORT.md`](SUPPORT.md). - Do not commit credentials, tokens, mailbox authorization codes, OAuth refresh @@ -26,14 +36,24 @@ maintained in separate repositories and are outside the scope of this repository ## Development and verification -Typical flow: - -1. Scaffold with the client's `ghost_forge_scaffold`, or copy the layout of any - plugin in this repository. -2. In a dev environment, import the plugin directory or a `.cindy` package - directly for verification. -3. When done, package it with `ghost_forge_pack` into a `.cindy` and install it to - verify. +Start with the +[harness-independent quick path in `README.md`](README.md#harness-independent-quick-path): + +1. Align the design and minimum capabilities, then create a new root-level + plugin directory using ordinary file operations. +2. Start from the documented Manifest-v3 and `main.js` examples. Do **not** copy + an existing official plugin's `ghost.json`: unchanged official plugins may + intentionally retain legacy v2 manifests. Existing source may be consulted + only for implementation patterns. +3. Validate the Manifest with + `node scripts/validate-plugin-manifest.mjs ./`. +4. Package the directory contents—not the directory itself—as a `.cindy` ZIP + archive and import that exact package through Cindy's local plugin entry. +5. If the chosen harness exposes Cindy Forge commands, they may replace the + manual scaffold/package steps, but they do not change the source format or + the review contract. Installation still requires an explicit user request. +6. Before opening an official-plugin PR, add the `provisioning.json` entry and + complete exactly four locale resources: `zh-CN`, `en`, `ja`, and `ko`. The `*.test.mjs` files under `.tests/` run on Node's built-in test runner: @@ -83,19 +103,38 @@ do not edit the generated `dist/maker.js` by hand. 3. **Any change to plugin content must bump `ghost.json`'s `version` in the same pull request.** The new `major.minor.patch` SemVer must be greater than the version on `main`; otherwise CI blocks the pull request. + The same change must migrate an existing schema-v2 manifest to + `schemaVersion: 3`: add `minCindyVersion`, remove `slots`, and express the + same capabilities through their direct fields. Follow the + [mapping table](docs/plugin-authoring.md#manifest-v2-to-v3-preserve-behavior-change-representation), + retaining presence-only capabilities such as `card` and `sessionContext`; + deleting `slots` alone is not a complete migration. Set `minCindyVersion` to the + first stable Cindy release that supports the concrete plugin's required Host + capabilities and manifest fields; Manifest v3 has no repository-wide Cindy + version floor. + Unchanged v2 plugins are intentionally left alone; do not bulk-migrate them. + Plugin Server selects the newest previously listed release compatible with the + user's Cindy version. If the current release is incompatible, an eligible + historical release is delivered instead; without one, the plugin is hidden. + Desktop treats that Server selection as authoritative and does not add a + second `minCindyVersion` filter or install confirmation, so keep this field accurate. 4. When changing `ghost.json` tool declarations (`tools[].description` or parameters), explain the impact on Agent behaviour in the pull request description — that description is the usage manual the Agent reads. - If `minCindyVersion` is added or raised, also record a real packaged `.cindy` - install on that exact Cindy version. Lowering or removing the field expands - claimed compatibility and requires maintainer review. + Check the production Cindy verification item only after installing every + changed plugin's packaged `.cindy` on a real device running a stable + production Cindy build and exercising its core functionality. If a plugin + declares `minCindyVersion`, that Cindy build must be greater than or equal to + it. Lowering or removing the field expands claimed compatibility and + requires maintainer review. Every non-draft pull request is verified by the `Verify pull request` workflow: it runs the Server/Desktop delivery contract, localization and provisioning gates, runs the `*.test.mjs` tests of every changed plugin (installing that plugin's dependencies first), and dry-runs the exact packaging -step the publish pipeline uses. The actual upload still happens only after -merge to `main`. +step the publish pipeline uses. For every changed plugin package, CI also +requires the production Cindy verification checkbox in the pull request body. +The actual upload still happens only after merge to `main`. 5. Review the complete diff and confirm it contains no credentials, unrelated generated files, or an accidentally committed `node_modules`. diff --git a/CONTRIBUTING.zh-CN.md b/CONTRIBUTING.zh-CN.md index 024232c..4e83bf1 100644 --- a/CONTRIBUTING.zh-CN.md +++ b/CONTRIBUTING.zh-CN.md @@ -12,9 +12,15 @@ - 先读 [README.zh-CN.md](README.zh-CN.md):仓库结构、插件清单、审查标准和发布流程 都以它为准,本指南不重复维护副本。 -- 完整的插件编写契约(`ghost.json` 全字段、卡槽、`cindy.send` 管子 API、打包流程)由 - Cindy 客户端内置的 `ghost_forge_guide` 工具返回——在 Cindy 对话里说「帮我做一个插件」 - 即可拿到当时版本的手册。 +- 插件编写契约就在本仓库中:`README.zh-CN.md`、本指南、`.tests/contracts/` 中固定 + 版本的 Manifest 校验器和打包门禁共同组成正本。无论用哪一种 Agent 或 harness + 创建文件都遵循同一契约;Cindy Forge 命令只是可选捷径。 +- 字段映射与具体运行时调用见[插件编写与迁移参考](docs/plugin-authoring.zh-CN.md)。 + 执行任务的 Agent 根据现有代码处理适配,作者不需要自行提供迁移步骤。 +- 增加 Manifest 字段前先判断谁执行:插件工具是否执行由既有 Agent 授权决定;普通 + HTTPS 与 workdir 操作使用 Host 下发的在途 `callId`,CLI 继续走已有 Node 工作进程。 + 具体命令、域名或路径无需预登记;脱离该调用的插件自主 Host 能力须有对应声明。 + 随包 Node 入口与 Host 托管凭证即使在工具调用内使用,也仍需显式声明。 - 参与社区时请遵守 [`CODE_OF_CONDUCT.zh-CN.md`](CODE_OF_CONDUCT.zh-CN.md);普通使用问题见 [`SUPPORT.zh-CN.md`](SUPPORT.zh-CN.md)。 - 不要提交凭证、令牌、邮箱授权码、OAuth refresh token、个人数据或真实用户邮件内容—— @@ -22,11 +28,20 @@ ## 开发与验证 -典型流程: - -1. 用客户端的 `ghost_forge_scaffold` 生成骨架,或直接照抄本仓任一插件的目录结构。 -2. 在开发环境里导入插件目录或 `.cindy` 包验证。 -3. 完成后用 `ghost_forge_pack` 打成 `.cindy` 安装验证。 +从 [`README.zh-CN.md` 的「工具无关的快速路径」](README.zh-CN.md#工具无关的快速路径) +开始: + +1. 对齐设计与最小能力后,使用普通文件操作在仓库根目录创建新的插件目录。 +2. 从文档中的 Manifest v3 与 `main.js` 示例开始。**不要复制本仓现有插件的 + `ghost.json`**:未改动的官方插件可能刻意保留旧 v2 清单。现有源码只能用于参考 + 实现方式。 +3. 运行 `node scripts/validate-plugin-manifest.mjs ./<插件目录>` 校验 Manifest。 +4. 把插件目录的**内容**(不是目录本身)压成 `.cindy` ZIP 包,并从 Cindy 本地插件 + 入口导入这一个确切的包。 +5. 如果当前 harness 提供 Cindy Forge 命令,可以用它替代手工脚手架/打包步骤;它不会 + 改变源码格式和审查契约。安装仍然必须来自用户的明确要求。 +6. 提交官方插件 PR 前,补充 `provisioning.json` 条目,并完成恰好 `zh-CN`、`en`、 + `ja`、`ko` 四份 locale 资源。 `.tests/` 下的 `*.test.mjs` 用 Node 内置 test runner 运行,例如: @@ -67,15 +82,27 @@ cd 163-mail && npm ci && npm run build `build` / `ci`。 3. **改动插件内容必须在同一个 PR 里 bump `ghost.json` 的 `version`。** 新的 `major.minor.patch` SemVer 必须大于 `main` 上的当前版本,否则 CI 会阻止合并。 + 同一个改动还必须把现有 v2 清单迁移为 `schemaVersion: 3`:增加 + `minCindyVersion`、移除 `slots`,并用对应顶层字段表达等价能力。按 + [映射表](docs/plugin-authoring.zh-CN.md#manifest-v2-到-v3保留行为转换表达) + 保留 `card`、`sessionContext` 等纯声明能力;仅删除 `slots` 不算完成迁移。`minCindyVersion` + 应填写支持这个具体插件所需 Host 能力与 Manifest 字段的第一个 Cindy 正式稳定版本; + Manifest v3 本身不设置仓库级 Cindy 版本下限。 + 未改动的 v2 插件刻意保持原样,禁止批量迁移。 + Plugin Server 按用户当前 Cindy 版本选择最近曾上架的兼容 Release;current 不兼容时 + 回退到兼容历史版本,没有兼容历史版本时不展示该插件。 + Desktop 以该 Server 选择为准,不再追加 `minCindyVersion` 二次筛选或安装确认, + 因此必须准确填写这个字段。 4. 改动 `ghost.json` 的工具声明(`tools[].description` / 参数)时,在 PR 描述里说明对 - Agent 行为的影响——这段描述就是 Agent 读到的使用手册。新增或提高 - `minCindyVersion` 时,还要记录真实 `.cindy` 包在该精确 Cindy 版本的安装结果;降低 - 或删除该字段会扩大声称支持的范围,必须交维护者人工 review。 + Agent 行为的影响——这段描述就是 Agent 读到的使用手册。每个改动插件都必须先在 + 运行正式稳定版 Cindy 的实际设备上安装真实 `.cindy` 包并验证核心功能,再勾选 PR + 的生产版 Cindy 验证项;插件声明 `minCindyVersion` 时,验证所用 Cindy 版本必须 + 不低于该最低版本。降低或删除该字段会扩大声称支持的范围,必须交维护者人工 review。 每个非草稿 PR 都会由 `Verify pull request` workflow 验证:跑 Server / Desktop 交付 契约、localization 与 provisioning 门禁、跑每个被改动插件的 `*.test.mjs` 测试(先装 -该插件的依赖),并用与发布流水线完全相同的打包步骤做 dry-run。真正上传仍只在合入 -`main` 后发生。 +该插件的依赖),并用与发布流水线完全相同的打包步骤做 dry-run。只要 PR 改动了插件 +包,CI 还会要求 PR Body 勾选生产版 Cindy 验证项。真正上传仍只在合入 `main` 后发生。 5. Review 完整 diff,确认没有凭证、无关生成文件或误提交的 `node_modules`。 6. 等待 review;不要直接向 `main` 推送。合并到 `main` 后区域 Workflow 会把改动包提交 diff --git a/README.md b/README.md index 92d42f0..8f17d03 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,14 @@ This is the source for every official plugin (Ghost) in the region approves it. Start at [Submit your plugin](#submit-your-plugin). +Already-installed marketplace plugins follow their recorded source and update +silently. A merged version bump can therefore reach installed users without +another click or capability-confirmation dialog. Treat capability expansion and +runtime changes as immediate production changes: declare the minimum required +capabilities in the package's `ghost.json`, and preserve Host authorization and +credential boundaries. Marketplace summaries are not a separate installation +permission gate. + ## Plugins | | Plugin | Directory | Description | @@ -67,15 +75,14 @@ The full path from idea to marketplace: (163 / iCloud / QQ mail) are fine, but a second generic web search is not. 2. **Align** — open a [new plugin proposal](https://github.com/makecindy/cindy-official-plugins/issues/new?template=new_plugin_proposal.yml) - describing the scenario, boundaries, and required capabilities (network - hosts, credentials, Node runtime). Wait for a maintainer ack before writing + describing the scenario, boundaries, and any autonomous Host capabilities + (network hosts, credentials, long-lived Node runtime). Wait for a maintainer ack before writing code — it keeps you from building something that overlaps or won't be accepted. -3. **Build** — in a Cindy conversation, say "help me build a plugin" to get - the complete authoring manual (`ghost_forge_guide`: every `ghost.json` - field, slots, the `cindy.send` pipe API, packaging). Scaffold with - `ghost_forge_scaffold` or copy the layout of any plugin here. Import the - directory or a packaged `.cindy` into a dev environment to verify. +3. **Build** — follow the [harness-independent quick path](#harness-independent-quick-path) + with any coding Agent or development environment. The repository documents + the file format, runtime messages, validation command, and packaging format; + no Cindy-specific authoring tool is required. 4. **Open a PR** — title `feat(): …`; bump `ghost.json.version`; add the `provisioning.json` entry; complete four-language locales (`zh-CN` / `en` / `ja` / `ko`); sign off every commit (`git commit -s`, @@ -89,18 +96,24 @@ The full path from idea to marketplace: 6. **Submit and approve** — after merge to `main`, the CN and Global workflows submit the real package through Plugin Platform. Each region reviews its own pending release; only an approved release becomes available to compatible - clients. Rejection leaves the previous approved release in service. + clients and is then picked up silently by installations following that market + source. Rejection leaves the previous approved release in service. ## Review standards Every official plugin is installed by real users who carry its security and experience risk, so review is strict by design. Four hard principles: -1. **Pure sandbox by default, capabilities declared explicitly.** Regular - plugins run in Cindy's isolated sandbox and may only use the network - allowlist and host channels declared in `ghost.json`. Official plugins that - genuinely need the Node Runtime must explicitly declare the `node` slot, a - fixed entry point, and a minimal child-process boundary. +1. **Pure sandbox by default; authorization follows the executor.** Regular + plugins run in Cindy's isolated sandbox. Whether a plugin tool runs is decided + by the current `ghost_call` and Cindy's existing Agent authorization. Ordinary + HTTPS and workdir operations use the Host-issued, strictly in-flight `callId`; + bundled code and CLIs continue to use the existing Node worker. Do not add a + Slot or manifest field just to pre-register a specific command, host, or path. + A plugin that uses Host capabilities autonomously from a panel, + subscription, scheduler, or long-lived process must declare the corresponding + direct field in `ghost.json`. An autonomous Node Runtime still requires the + top-level `node` field, a fixed entry point, and a minimal child-process boundary. 2. **Clear secret ownership.** Ordinary API tokens are stored through the host's write-only `/secrets` channel. When a Node plugin needs plaintext credentials, use `node.secretBindings` to restrict them to specific Worker @@ -119,9 +132,10 @@ experience risk, so review is strict by design. Four hard principles: ### Self-check before requesting review -- [ ] `ghost.json` declares only the network hosts and host channels actually - used; no `node` slot unless genuinely required -- [ ] Node plugins: explicit `node` slot, fixed entry, minimal child-process +- [ ] Execution ownership is explicit: plugin tools use existing Agent authorization, + HTTPS/workdir operations use the current `callId`, and CLIs use the existing + Node worker without pre-registering individual commands +- [ ] Node plugins: explicit `node` field, fixed entry, minimal child-process boundary; `node/worker.cjs` is an esbuild artifact rebuilt from `src/` - [ ] No plaintext credentials anywhere: tokens go through the write-only `/secrets` channel or `node.secretBindings`; never through `main.js`, @@ -134,9 +148,10 @@ experience risk, so review is strict by design. Four hard principles: - [ ] User-facing errors are actionable; no raw status codes or stack traces - [ ] Four-language locales complete; `node --test .tests/localization.test.mjs` passes -- [ ] If `minCindyVersion` is added or raised, the packaged `.cindy` was - installed on that exact Cindy version and the result is recorded in the - PR; lowering or removing it requires maintainer review +- [ ] Every changed plugin's packaged `.cindy` was installed and exercised on a + real device running a stable production Cindy build, and the PR + verification box is checked; when the plugin declares `minCindyVersion`, + the verified Cindy build is greater than or equal to it - [ ] `ghost.json.version` bumped; `provisioning.json` entry present with an audience decision stated in the PR - [ ] No credentials, real user data, `node_modules`, or unrelated generated @@ -210,7 +225,8 @@ Both are active and use the same submission flow: After approval, compatible clients receive that release; clients below its `minCindyVersion` continue to receive the newest older compatible release, when -one exists. +one exists. Desktop trusts this Server projection and does not add a second +version-confirmation step. When changing plugin content you must bump `ghost.json.version` in the same change. The new `major.minor.patch` SemVer must be greater than the version on @@ -218,19 +234,144 @@ change. The new `major.minor.patch` SemVer must be greater than the version on ## Local development -The complete plugin-authoring contract (all `ghost.json` fields, slots, the -`cindy.send` pipe API, packaging flow) is defined by the manual returned by the -`ghost_forge_guide` tool built into the Cindy client — just say "help me build -a plugin" in a Cindy conversation to get it on the spot. +The authoring contract is defined by this repository: the guidance below and in +[`CONTRIBUTING.md`](./CONTRIBUTING.md), the pinned Cindy Manifest validator under +`.tests/contracts/`, and the repository packaging checks. It is independent of +the Agent or harness used to edit files. Cindy's Forge tools are optional +shortcuts, not part of the plugin format and not a prerequisite for development. + +For existing-plugin maintenance, v2/v3 field mappings, and concrete HTTPS, +file, and Node/CLI calls, use the +[authoring and migration reference](./docs/plugin-authoring.md). An Agent can +derive the required adaptations from this reference and the existing code; +authors do not need to perform a separate migration checklist. + +New plugins use `schemaVersion: 3` and declare capabilities directly through +fields such as `tools`, `network`, `node`, or `notify: true`; v3 must not contain +`slots`. Every v3 package declares its own `minCindyVersion`: use the first +stable Cindy version that supports every Host capability and manifest field +the concrete plugin actually depends on. Manifest v3 itself does not impose a +repository-wide Cindy version floor. Existing v2 manifests stay untouched until +that plugin's packaged content actually changes. The PR that changes it must +migrate the manifest to v3—there is no repository-wide bulk migration or +release solely for the schema change. + +Direct fields describe plugin contributions and **autonomous** Host use. They are +not a pre-registration list for a specific command, host, or path. Whether the +plugin tool runs is decided by the current `ghost_call` and existing Agent +authorization; ordinary HTTPS and workdir operations pass the Host-issued +`callId` to `cindy.fetch` or `cindy.fs`. Bundled code and CLIs continue to use the +existing Node worker. Managed credentials and any use outside that in-flight call +still require the corresponding explicit declaration. + +### Harness-independent quick path + +Paste this into any coding Agent or harness that can edit files and run commands: + +```text +Using only the authoring contract in this repository, build a Cindy plugin for +[what it should do]. Read AGENTS.md and docs/plugin-authoring.md, infer the +necessary declarations and runtime interfaces from the task, and clarify only +product choices or verification gaps that the repository cannot establish. Create a new +Manifest-v3 plugin directory without copying an existing v2 ghost.json. Validate +its manifest with the repository validator, package the directory contents as a +.cindy ZIP archive, and report the artifact path. Do not install it unless I +explicitly ask you to. +``` + +Create a new directory with this minimum layout: + +```text +my-plugin/ +├── ghost.json +├── main.js +└── assets/ + └── icon.png +``` + +Do **not** copy an existing official plugin's `ghost.json`: the repository +intentionally retains legacy v2 manifests until those plugins change. Existing +source may be consulted only for implementation patterns. + +Start `ghost.json` from this minimal runnable Manifest-v3 shape: + +The `1.2.3` below is only an example. Replace it with the first stable Cindy +version that supports the concrete plugin you are building. + +```json +{ + "schemaVersion": 3, + "minCindyVersion": "1.2.3", + "id": "my-plugin", + "name": "My Plugin", + "description": "A one-sentence description for people.", + "whenToUse": "Use this when the user needs the plugin's capability.", + "version": "1.0.0", + "kind": "chip", + "entry": "main.js", + "icon": "assets/icon.png", + "tools": [ + { + "name": "hello", + "description": "Return a greeting to verify that the plugin works.", + "parameters": { "type": "object", "properties": {} } + } + ] +} +``` + +Place a real PNG at `assets/icon.png`. If no icon is ready, remove both the +`icon` field and the unused `assets/` entry; never package a path declared by +the Manifest without its file. -Typical flow: +Implement the declared tool in `main.js` using the Host message contract: + +```js +cindy.onHostMessage(async function (message) { + if (message.type !== 'tool-call' || message.tool !== 'hello') return; + + await cindy.send({ + type: 'tool-result', + callId: message.callId, + ok: true, + result: { message: 'The plugin is working.' } + }); +}); +``` + +The `callId` belongs to that one in-flight tool call. Return exactly one +`tool-result` with the same `callId`. Ordinary HTTPS and workdir file operations +also carry this Host-issued `callId` through `cindy.fetch` and `cindy.fs`; they +use Cindy's existing runtime authorization instead of pre-registering a command, +host, or path in the Manifest. Declare a direct top-level capability only for a +plugin contribution or autonomous Host use outside that in-flight call. + +Validate the Manifest from the repository root: + +```bash +node scripts/validate-plugin-manifest.mjs ./my-plugin +``` + +A `.cindy` file is a ZIP archive whose root contains `ghost.json`, `main.js`, +and the declared resources—do not wrap them in an extra `my-plugin/` directory. +Create it with any ZIP implementation. For example on macOS/Linux: + +```bash +(cd my-plugin && zip -r ../my-plugin-1.0.0.cindy . \ + -x '*.cindy' 'node_modules/*' '.git/*' '.DS_Store') +``` -1. Scaffold with the client's `ghost_forge_scaffold`, or copy the layout of any - plugin in this repository. -2. In a dev environment, import the plugin directory or a `.cindy` package - directly for verification. -3. When done, package it with `ghost_forge_pack` into a `.cindy` and install it - to verify. +The user can import that file through Cindy's local plugin entry. If the chosen +harness exposes Cindy Forge tools, `ghost_forge_scaffold` can create the same v3 +baseline, `ghost_forge_pack` can validate and package it, and +`ghost_forge_install` can install it after an explicit user request. These are +optional accelerators; the source and `.cindy` format are identical. + +Before submitting to this official repository, add a `provisioning.json` entry +and declare locale files for exactly `zh-CN`, `en`, `ja`, and `ko`, covering the +plugin text and every tool description. Then follow +[`CONTRIBUTING.md`](./CONTRIBUTING.md) and install the exact packaged `.cindy` on +a real device running an eligible stable production Cindy build. `taptap-maker/vendor/taptap-maker/` ships the official `@taptap/maker@0.0.32` with the plugin. When upgrading, replace the published npm package content diff --git a/README.zh-CN.md b/README.zh-CN.md index aea0b16..909b5cd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -28,6 +28,11 @@ CN / Global 两区审核队列;只有对应区域审核通过后才会在客户端可见。从 [提交你的插件](#提交你的插件)开始。 +已安装的市场插件会沿安装时记录的来源静默更新。因此,合入后的版本升级可以在 +用户无需再次点击、也不会出现能力确认弹窗的情况下到达已安装用户。请把能力扩张 +和运行时行为变更视为立即生效的线上变更:包内 `ghost.json` 只声明实际必需的最小能力, +遵守 Host 授权及凭证边界;市场摘要不是另一套安装权限门禁。 + ## 插件列表 | | 插件 | 目录 | 说明 | @@ -64,10 +69,9 @@ [新插件提案 issue](https://github.com/makecindy/cindy-official-plugins/issues/new?template=new_plugin_proposal.yml), 说明场景、边界和所需能力(网络域名、凭证类型、是否需要 Node Runtime)。 **拿到维护者确认后再动手写代码**——避免做出重叠或不会被接受的东西。 -3. **开发**——在 Cindy 对话里说「帮我做一个插件」即可拿到完整编写手册 - (`ghost_forge_guide`:`ghost.json` 全字段、卡槽、`cindy.send` 管子 API、 - 打包流程)。用 `ghost_forge_scaffold` 生成骨架或参考本仓任一插件;dev - 环境下导入插件目录或 `.cindy` 包验证。 +3. **开发**——在任意 coding Agent 或开发环境中按照 + [工具无关的快速路径](#工具无关的快速路径)操作。文件格式、运行时消息、校验命令和 + 打包格式都由本仓库说明,不要求 Cindy 专用的插件制作工具。 4. **提交 PR**——标题 `feat(<目录名>): …`;bump `ghost.json.version`;补 `provisioning.json` 条目;四语言 locale(`zh-CN` / `en` / `ja` / `ko`) 齐全;每个 commit 带签名(`git commit -s`,[DCO](./DCO))。细节见 @@ -79,16 +83,20 @@ [审查标准](#审查标准)人工审查。请求 review 前先过一遍下方自查清单。 6. **提交并上架**——合入 `main` 后 CN / Global Workflow 自动通过 Plugin Platform 提交真实包。两区分别审核;只有审核通过的 release 才会下发给兼容客户端,拒绝 - 不会影响此前已通过的版本。 + 不会影响此前已通过的版本。审核通过后,绑定该市场来源的已安装客户端会静默更新。 ## 审查标准 每个官方插件都会被真实用户安装,安全与体验风险由用户承担,因此审查从严。 四条硬原则: -1. **默认纯沙箱、能力显式声明**:普通插件运行在 Cindy 的隔离沙箱中,只能使用 - `ghost.json` 声明的网络白名单与主机通道。确需 Node Runtime 的官方插件必须 - 显式声明 `node` slot、固定入口和最小子进程边界。 +1. **默认纯沙箱,授权跟随执行者**:普通插件运行在 Cindy 的隔离沙箱中。插件工具 + 是否执行由当前 `ghost_call` 的既有 Agent 授权决定;普通 HTTPS 与 workdir 文件操作 + 使用 Host 下发且严格在途的 `callId`,随包代码与 CLI 继续走已有 Node 工作进程。 + 不要仅为了预登记具体命令、域名或路径新增 Slot 或 Manifest 字段。插件若要从 + Panel、订阅、scheduler 或常驻进程中自主使用 Host + 能力,才在 `ghost.json` 声明对应直接字段。自主 Node Runtime 仍必须显式声明顶层 + `node` 字段、固定入口和最小子进程边界。 2. **密钥归属明确**:普通 API token 通过主机的 `/secrets` 只写通道保存;Node 插件需明文凭证时,用 `node.secretBindings` 将其限制到指定 Worker 方法并由 宿主临时注入,不经过浏览器 `main.js`、Agent 参数或日志。若官方第三方 @@ -102,8 +110,9 @@ ### 请求 review 前的自查清单 -- [ ] `ghost.json` 只声明实际用到的网络域名与主机通道;非必要不声明 `node` slot -- [ ] Node 插件:显式 `node` slot、固定入口、最小子进程边界;`node/worker.cjs` +- [ ] 已分清执行者:插件工具调用由现有 Agent 授权,HTTPS/workdir 操作用当前 + `callId`;CLI 走已有 Node 工作进程,`ghost.json` 不预登记具体命令 +- [ ] Node 插件:显式 `node` 字段、固定入口、最小子进程边界;`node/worker.cjs` 是随 `src/` 重建的 esbuild 产物 - [ ] 任何地方无明文凭证:token 走 `/secrets` 只写通道或 `node.secretBindings`; 不经过 `main.js`、Agent 参数、日志、KV、页面状态 @@ -112,8 +121,9 @@ - [ ] 每个 tool 的 `description` 与实际行为一致——能力、限制、返回值、副作用 - [ ] 面向用户的报错可行动;无裸状态码、无英文堆栈 - [ ] 四语言 locale 齐全;`node --test .tests/localization.test.mjs` 通过 -- [ ] 新增或提高了 `minCindyVersion` 时,已在该精确 Cindy 版本安装真实 `.cindy` - 包并在 PR 记录结果;降低或删除最低版本需维护者人工 review +- [ ] 每个改动插件都已在运行正式稳定版 Cindy 的实际设备上安装真实 `.cindy` 包并 + 验证核心功能,且已勾选 PR 验证项;插件声明 `minCindyVersion` 时,验证所用 + Cindy 版本不低于该最低版本 - [ ] `ghost.json.version` 已 bump;`provisioning.json` 有对应条目且 PR 描述里 写明 audience 决策 - [ ] diff 中无凭证、真实用户数据、`node_modules` 或无关生成文件;fixture 用 @@ -175,20 +185,131 @@ cindy-art/ Workflow。 审核通过后,兼容客户端会收到该 release;低于 `minCindyVersion` 的客户端会继续收到 -已有的最新兼容旧 release(如果存在)。 +已有的最新兼容旧 release(如果存在)。Desktop 信任该 Server 投影,不再追加版本确认。 修改插件内容时必须同步更新 `ghost.json.version`。新的 `major.minor.patch` SemVer 必须 大于 `main` 上的当前版本,否则 CI 会在提交 Server 前阻止合并。 ## 本地开发 -插件编写的完整契约(`ghost.json` 全字段、卡槽、`cindy.send` 管子 API、打包流程)以 Cindy 客户端内置的 `ghost_forge_guide` 工具返回的手册为准 —— 在 Cindy 对话里说"帮我做一个插件"即可现拿现读。 +插件编写契约由本仓库定义:以下说明与 [`CONTRIBUTING.zh-CN.md`](./CONTRIBUTING.zh-CN.md)、 +`.tests/contracts/` 中固定版本的 Cindy Manifest 校验器,以及仓库打包门禁共同组成 +正本。无论使用哪一种 Agent 或 harness,写出的文件都遵循同一契约。Cindy Forge 工具 +只是可选捷径,不属于插件格式,也不是开发前置条件。 + +维护已有插件、v2/v3 字段映射、HTTPS、文件和 Node/CLI 的具体调用见 +[插件编写与迁移参考](./docs/plugin-authoring.zh-CN.md)。Agent 可依据这些事实和 +现有代码自行完成必要适配,作者不需要另外手工执行迁移清单。 + +新插件使用 `schemaVersion: 3`,并通过 `tools`、`network`、`node`、`notify: true` +等顶层字段直接声明能力;v3 不得再有 `slots`。每个 v3 插件包都必须独立填写 +`minCindyVersion`:它应是同时支持这个具体插件所依赖的全部 Host 能力和 Manifest 字段的 +第一个 Cindy 正式稳定版本。Manifest v3 本身不设置仓库级 Cindy 版本下限。现有 v2 清单 +保持原样,直到该插件的实际打包内容发生变化;改动它的 PR +必须同时迁移到 v3。本仓不会只为 schema 变化批量迁移、批量发布现有插件。 + +直接字段表达插件贡献项和**自主** Host 能力,不是具体命令、域名或路径的预登记清单。 +插件工具是否执行由当前 `ghost_call` 的既有 Agent 授权决定;普通 HTTPS 与 workdir +文件操作把 Host 下发的 `callId` 传给 `cindy.fetch` 或 `cindy.fs`。随包代码与 CLI +继续走已有 Node 工作进程。Host 托管凭证以及脱离该在途调用的使用仍须对应的显式声明。 + +### 工具无关的快速路径 + +把下面这段发给任意能够编辑文件、运行命令的 coding Agent 或 harness: + +```text +只按照当前仓库中的插件编写契约,帮我制作一个用于[具体用途]的 Cindy 插件。先读 +AGENTS.md 和 docs/plugin-authoring.zh-CN.md,根据任务自行判断所需声明与运行时接口, +只确认仓库事实无法确定的功能取舍或验证缺口。新建 Manifest v3 插件目录, +不要复制现有 v2 ghost.json。使用仓库校验器检查 Manifest,把目录内容打成 .cindy ZIP +包并返回产物路径。除非我明确要求,否则不要安装插件。 +``` + +先创建最小目录: + +```text +my-plugin/ +├── ghost.json +├── main.js +└── assets/ + └── icon.png +``` + +**不要复制本仓现有插件的 `ghost.json`**:仓库会刻意保留尚未发生内容改动的旧 v2 +清单。现有源码只能用于参考实现方式。 + +`ghost.json` 从下面这份最小可运行 Manifest v3 开始: + +下面的 `1.2.3` 只是示例;请替换成实际支持当前插件的第一个 Cindy 正式稳定版本。 + +```json +{ + "schemaVersion": 3, + "minCindyVersion": "1.2.3", + "id": "my-plugin", + "name": "My Plugin", + "description": "给用户看的单句说明。", + "whenToUse": "当用户需要这个插件提供的能力时使用。", + "version": "1.0.0", + "kind": "chip", + "entry": "main.js", + "icon": "assets/icon.png", + "tools": [ + { + "name": "hello", + "description": "返回一句问候,用来确认插件已经正常工作。", + "parameters": { "type": "object", "properties": {} } + } + ] +} +``` + +在 `assets/icon.png` 放入真实 PNG;如果暂时没有图标,就同时删除 Manifest 的 `icon` +字段和未使用的 `assets/` 项。禁止打包 Manifest 已声明、包内却不存在的文件。 + +在 `main.js` 中按 Host 消息契约实现已经声明的工具: + +```js +cindy.onHostMessage(async function (message) { + if (message.type !== 'tool-call' || message.tool !== 'hello') return; + + await cindy.send({ + type: 'tool-result', + callId: message.callId, + ok: true, + result: { message: '插件已经正常工作。' } + }); +}); +``` + +`callId` 只属于当前这一次在途工具调用;插件必须使用相同 `callId` 返回且只返回一个 +`tool-result`。普通 HTTPS 与 workdir 文件操作同样把 Host 下发的 `callId` 传给 +`cindy.fetch` / `cindy.fs`,沿用 Cindy 现有运行时授权,不需要在 Manifest 中预登记 +具体命令、域名或路径。只有插件贡献项或脱离该调用的自主 Host 使用才声明对应顶层能力。 + +在仓库根目录校验 Manifest: + +```bash +node scripts/validate-plugin-manifest.mjs ./my-plugin +``` + +`.cindy` 是普通 ZIP:压缩包根目录必须直接包含 `ghost.json`、`main.js` 和声明的资源, +不能在外层再套一层 `my-plugin/`。可以使用任意 ZIP 实现;macOS/Linux 示例: + +```bash +(cd my-plugin && zip -r ../my-plugin-1.0.0.cindy . \ + -x '*.cindy' 'node_modules/*' '.git/*' '.DS_Store') +``` -常用流程: +用户可以从 Cindy 的本地插件入口导入这个包。如果当前 harness 恰好提供 Cindy Forge +工具,`ghost_forge_scaffold` 可以生成同样的 v3 基线,`ghost_forge_pack` 可以校验并 +打包,`ghost_forge_install` 可以在用户明确要求后安装。它们只是可选加速器;源码与 +`.cindy` 格式完全相同。 -1. 用客户端的 `ghost_forge_scaffold` 生成骨架,或直接参考本仓任一插件的写法。 -2. dev 环境下直接导入插件目录或 `.cindy` 包验证。 -3. 完成后用 `ghost_forge_pack` 打包成 `.cindy` 装入验证。 +提交到官方仓库前,还必须补充 `provisioning.json` 条目,并在 Manifest 中声明恰好 +`zh-CN`、`en`、`ja`、`ko` 四份 locale 文件,完整覆盖插件文案和全部工具描述;随后 +按 [`CONTRIBUTING.zh-CN.md`](./CONTRIBUTING.zh-CN.md) 自查,并在符合最低版本要求的 +Cindy 正式稳定版实机上安装真实 `.cindy` 包完成验证。 `taptap-maker/vendor/taptap-maker/` 固定随插件分发官方 `@taptap/maker@0.0.32`。升级时应整体替换 npm 包发布内容并同步更新插件版本, diff --git a/docs/localization.md b/docs/localization.md index 6f57c2e..4c77f5e 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -64,8 +64,9 @@ The Cindy client is responsible for: 2. Validating at Forge packaging time and at install time that the resource files exist, are valid JSON, are no larger than 64KB each, and fully cover the manifest's existing fields and all tools. -3. Resolving the plugin list, details, install confirmation, and the Agent tool - catalog using the host's current language. +3. Resolving the plugin list, details (including declared capabilities), and + the Agent tool catalog using the host's current language. Installation and + source-bound updates do not add a separate capability-confirmation dialog. 4. Falling back to the English resources when the plugin lacks the target language, the host language is unsupported, or the installed target resource is corrupted. diff --git a/docs/localization.zh-CN.md b/docs/localization.zh-CN.md index c05cea5..c1ea11e 100644 --- a/docs/localization.zh-CN.md +++ b/docs/localization.zh-CN.md @@ -59,7 +59,8 @@ Cindy 客户端负责: 1. 校验 `locales` 只包含四种受支持语言,且必须有 `en`。 2. 在 Forge 打包和安装时校验资源文件存在、是合法 JSON、单文件不超过 64KB,并完整 覆盖清单已有字段与所有工具。 -3. 使用宿主当前语言解析插件列表、详情、安装确认和 Agent 工具目录。 +3. 使用宿主当前语言解析插件列表、详情(含声明能力)和 Agent 工具目录;安装与 + 绑定来源的更新不再追加独立的能力确认弹窗。 4. 插件缺少目标语言、宿主语言不受支持,或已安装的目标资源损坏时,重新尝试英文资源。 5. 应用内切换语言后重新广播本地化插件清单,并重载已打开的插件设置页和面板。 6. 通过 `cindy.request({ kind: 'app-context' })` 和同源 `GET /app-context` 返回: diff --git a/docs/plugin-authoring.md b/docs/plugin-authoring.md new file mode 100644 index 0000000..e24a449 --- /dev/null +++ b/docs/plugin-authoring.md @@ -0,0 +1,315 @@ +

+ 简体中文 · English +

+ +# Plugin authoring and migration reference + +This reference works with any coding Agent, editor, or human developer. Cindy +Forge and a particular harness are not prerequisites. See the +[README](../README.md#local-development) for the minimum layout, complete starter +Manifest, and ZIP packaging, and [Contributing](../CONTRIBUTING.md) for official +publishing requirements. This document adds migration mappings and common runtime calls. + +## Derive changes from the task + +The author describes the desired functionality. The implementing Agent reads the +existing `ghost.json`, entry code, and relevant resources, then handles format +conversion, declarations, versions, localized text, and validation. Do not hand +the field-mapping checklist back to the author or require them to paste this +reference into every task. + +- New plugin: start with the README's v3 example and add only declarations and + resources required by the functionality. +- Existing plugin: preserve its ID, entry points, tool interfaces, and existing + capabilities. If package content changes and the manifest is v2, apply the + table below. Do not replace an existing Manifest with a minimal example or + migrate unrelated plugins. +- Ordinary HTTPS, workdir writes, and CLIs: use the existing interfaces below; + do not add Slots, client allowlists, or execution APIs for particular hosts, + paths, or commands. +- Resolve facts available in the implementation yourself. Surface only genuine + product choices, an unconfirmed target client version, or missing real-device + verification evidence. Never guess a minimum version or claim an unperformed check. + +## Manifest v2 to v3: preserve behavior, change representation + +`schemaVersion` identifies the manifest format; `minCindyVersion` identifies the +client version this particular package needs. They are not interchangeable. +Set `schemaVersion: 3`, establish the package's minimum version, apply these +mappings, and only then remove `slots`. These are equivalent representations of +existing capabilities, not a requirement to add capabilities the plugin never had. + +| v2 slot / field | v3 representation | Preserve during migration | +|---|---|---| +| `tool` | `tools` | Entire tool list, parameters, and descriptions; retain the existing field | +| `card` | `card: {}` or existing `card` object | Empty object for basic rendering; retain `externalLinks` when present | +| `agent` | `agent: {}` or existing `agent` object | Empty object for basic click-triggered use; retain optional fields without adding background access | +| `panel` | `panel` | HTML path, title, position, and sizing | +| `main-view` | `mainView` | Application-level view; not interchangeable with `panel` | +| `cindy`, legacy alias `model` | `cindy` | Action declarations; rename a lone `model` field, keeping the original `cindy` when both exist | +| `subscribe` | `subscribe` | topics/hooks and associated `launch` configuration | +| `node` | `node` | entry/entries, protocol, lifecycle, `childSpawn`, and secret bindings | +| `network` | `network` | hosts, secrets, connections, and their injection boundaries | +| `preview` | `preview` | Original hosts, without broadening allowed URLs | +| `skill` | `skill` | items and bundled SKILL.md files; do not rename or replace them with Manual | +| `notify` | `notify: true` | Notifications | +| `badge` | `badge: true` | Unread badge; still requires `panel` | +| `confirm` | `confirm: true` | Plugin business confirmation, not installation authorization | +| `fs` | `fs: true` | Plugin-private data directory access | +| `library` | `library: true` | Persistent user works; do not substitute `fs` | +| `session-context` | `sessionContext: true` | Host-injected trusted session context | +| `pick` | `pick: true` | User directory picker | +| `workspace` | `workspace: true` | Workspace session entry | +| `ios-simulator` | `iosSimulator: true` | Host embedded simulator entry; retain accompanying Skill and other declarations | + +Boolean capabilities must be literal `true` when present; omit unused ones rather +than setting `false` or `null`. Keep independent fields such as `manual`, `setup`, +`locales`, `settingsHtml`, `command`, and `keywords`; their absence from `slots` +does not make them disposable. A format-only migration does not change tool +behavior or execution paths. Historical empty slot names without declarations +should not be filled with new permissions; inspect their actual usage. Do not +guess mappings for unfamiliar declarations or silently drop them: establish the +target Host's actual contract. + +For example, migrate `slots: ["tool", "card", "node", "session-context", "pick", "preview"]` +by keeping `tools`, `node`, `preview`, and the existing `card` (or adding `{}` when +absent), then adding `sessionContext: true` and `pick: true`. Merely deleting +`slots` loses those presence-only capabilities. + +The validator preserves unknown v3 top-level fields. Preservation neither grants +permission nor proves Host implementation. Business functionality built on +existing network, file, or Node interfaces does not require client-side business +registration. An invented field or method cannot create an unimplemented Host API. + +## Version and installation facts + +- Determine `minCindyVersion` from the package's manifest format, required Host + interfaces, and stable release evidence. Keeping the old v2 value is not + automatically correct; merged code is not proof of release. The README's + `1.2.3` is a placeholder, not a repository-wide client floor. +- Current official CI requires v3 for new plugins and changed package content, + including descriptions, icons, and bundled documentation. Shared packaged + LICENSE/NOTICE/TRADEMARKS files affect every plugin. Root README files, this + reference, and CI files are not bundled and do not trigger plugin migration. +- Package changes require increasing the plugin's own `version`. After moving to + v3, do not claim support for clients that only understand v2. Server selects a + compatible historical release using the package's minimum version, or hides + the plugin if none exists. If the task explicitly requires new fixes for + v2-only clients, the current repository gate does not support that path: + explain the conflict instead of lowering metadata or changing CI yourself. +- Installation and updates do not ask for per-capability confirmation because + declarations changed. Package source does not create another capability + authorization policy. Existing automatic installation entry points remain; + development-tool packaging only produces an artifact, and installation needs + an appropriate user request. Runtime Agent authorization, account connection, + credential setup, and user directory selection may still be necessary. +- Package manifests accurately describe contributions and autonomous Host use. + Do not introduce an installation gate treating market summaries as the actual + package's capability ceiling. Host support, credential boundaries, and existing + runtime authorization govern actual use. + +## Runtime interfaces: choose the execution boundary first + +`main.js` runs in a browser sandbox, not Node. It cannot directly +`require('node:fs')`, launch a CLI, or bypass Host networking. The code below runs +inside the plugin; the authoring Agent does not need Cindy tools of its own. + +### Tool calls and completion + +Host sends `{ type: 'tool-call', tool, args, callId }`. Dispatch by `tool`, use that +call's `callId`, complete operations dependent on it, then return exactly one +`tool-result`. Never persist, forge, or reuse the ID after completion, and never +reroute an operation to bypass a permission denial. + +```js +cindy.onHostMessage(async function (msg) { + if (msg.type !== 'tool-call' || msg.tool !== 'hello') return; + await cindy.send({ + type: 'tool-result', callId: msg.callId, ok: true, + result: { message: 'The plugin is working.' } + }); +}); +``` + +Return failures as `{ type: 'tool-result', callId, ok: false, errorCode, message }` +with actionable text, not raw stacks, credentials, or sensitive request content. +Timeouts after sends/deletes may mean the external action already happened: +report an unknown outcome requiring verification, rather than blindly retrying +as if it were a read-only operation. + +### Ordinary HTTPS + +Call this helper inside the relevant `tool-call` handler and put its return value +in the final `tool-result.result`; catch failures there and send the failure reply +described above. Replace the placeholder `example.test` with +the actual business endpoint before use. + +```js +async function readRemoteText(msg) { + const response = await cindy.fetch({ + url: 'https://example.test/info', method: 'GET', + headers: { Accept: 'text/plain' }, + as: 'text', timeoutMs: 30000, callId: msg.callId + }); + if (!response.ok) throw new Error(response.message || 'Request incomplete; check networking or authorization.'); + // ok means the Host completed the request, not that HTTP succeeded. + if (response.status < 200 || response.status >= 300) { + throw new Error('The remote service rejected the request; check service status and account permissions.'); + } + if (response.truncated) throw new Error('Response too large; narrow the query.'); + return { text: response.body }; +} +``` + +Ordinary HTTPS during an in-flight Agent call does not need host pre-registration. +Host still enforces URL, SSRF, timeout, and redirect checks. Autonomous networking +outside the call needs `network`. Host-managed credentials, OAuth, and dynamic +connections also need declarations matching the target host; `callId` does not +authorize credential access or injection to arbitrary domains. Do not put tokens +in `headers` or chat arguments. A text request's `body` is a string, used only +with methods supporting request bodies. HTTP 4xx/5xx can return `ok: true`, so +check `status` explicitly. + +### File operations + +| Target | Operations | Authorization and lifetime | +|---|---|---| +| `root: 'workdir'` | Only `write` | Current `callId`, following session permission mode; no `fs: true` needed | +| `root: 'save'` | Only `write` | Host-injected `args.save_deposit.token`; no `fs: true` needed | +| `root: 'data'` | `write/read/list/delete` | Requires `fs: true`; plugin-private directory removed on uninstall | + +```js +// Complete inside tool-call, before sending tool-result. +const written = await cindy.fs({ + op: 'write', root: 'workdir', path: 'output/summary.md', + content: '# Summary\n', callId: msg.callId +}); +if (!written.ok) throw new Error(written.message || 'Write incomplete; check workdir and authorization.'); +// Success includes op, path, and bytes; return the actual result to the Agent. +``` + +`cindy.fs(options)` is shorthand for `cindy.send({ type: 'fs-request', ...options })`. +`path` is relative: absolute paths, `..`, and symlink traversal are rejected. +Content defaults to UTF-8; binary data can use `encoding: 'base64'`. Session +workdir writes are denied in plan/read-only mode; remote workspaces are not local +directories. Save tickets must exist and remain valid; never fabricate a token. +Reading/deleting workspace files is not supported by changing the workdir `op`: +use existing Agent file tools or a genuinely needed Node worker, respecting that +execution route's authorization and scope. Preserve `library` for persistent +user works rather than moving them into uninstall-deleted private `data`. + +### Bundled Node / CLI + +Add this field fragment to the existing Manifest and declare the actual tool +(for example `git_version`). `entry: 'main.js'` remains the sandbox entry; +`node.entry` refers to a different, real bundled file. + +```json +{ + "node": { + "entry": "node/worker.cjs", + "protocol": "json-rpc-stdio", + "lifecycle": "on-demand" + } +} +``` + +This example requires Git installed and available on the worker's PATH. It runs +only `git --version` with fixed arguments, without shell interpolation or an extra +Node dependency. Missing Git is a setup failure, not success: + +```js +// node/worker.cjs: stdout is newline-delimited JSON-RPC only; log to stderr. +const readline = require('node:readline'); +const { execFile } = require('node:child_process'); +const reply = (value) => process.stdout.write(JSON.stringify(value) + '\n'); +readline.createInterface({ input: process.stdin }).on('line', (line) => { + let request; + try { request = JSON.parse(line); } + catch { reply({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }); return; } + if (!request || request.id === undefined) return; + if (request.method !== 'cli/git-version') { + reply({ jsonrpc: '2.0', id: request.id, error: { code: -32601, message: 'Method not found' } }); + return; + } + execFile('git', ['--version'], { timeout: 5000 }, (error, stdout) => { + reply(error + ? { jsonrpc: '2.0', id: request.id, error: { code: -32000, message: 'Git check failed; ensure Git is installed and available on PATH' } } + : { jsonrpc: '2.0', id: request.id, result: { version: stdout.trim() } }); + }); +}); +``` + +```js +// main.js: complete tool reply; Manifest tools must include git_version. +cindy.onHostMessage(async function (msg) { + if (msg.type !== 'tool-call' || msg.tool !== 'git_version') return; + let result; + try { + result = await cindy.node.request({ method: 'cli/git-version', params: {}, timeoutMs: 10000 }); + } catch { + result = { ok: false, message: 'No result from the local worker; check plugin runtime status.' }; + } + await cindy.send(result.ok + ? { type: 'tool-result', callId: msg.callId, ok: true, result: result.result } + : { type: 'tool-result', callId: msg.callId, ok: false, + errorCode: 'NODE_REQUEST_FAILED', message: result.message }); +}); +``` + +The worker has no global `cindy` or Electron IPC; return data to `main.js` for +Host operations. `cindy.node.request.timeoutMs` is 1000–120000, default 30000. +Optional `entry` must name an extra entry declared in `node.entries`. `mcp-stdio` +uses the same request entry; Host handles MCP initialization, so plugins do not +call `initialize` themselves. A particular CLI needs no client registration or +new `cindy.process.run` API. Verify installation, platform support, and argument +safety for real external CLIs. + +`node.childSpawn: true` enables the Host's `globalThis.__CINDY_NODE__.spawnEntry` +bridge for JS files listed in `node.entry` / `node.entries`. It is not a switch +for ordinary `child_process.execFile` and is not needed by this Git example. +Do not use `process.execPath` as a Node executable: packaged Cindy disables +Electron RunAsNode. Use the Host bridge for a bundled JS subprocess instead. +Node workers have the current OS user's permissions, not the browser sandbox; +keep commands and file/network access within the requested task's scope. + +Use `network.secrets[].inject` / `network.connections[].inject` for managed HTTP +credentials. If a worker genuinely needs plaintext, restrict methods and entry +using `node.secretBindings`; Host injects `cindy.secrets` into the request. +Never send it through ordinary `params`, cache it, return it, or log it. Keep +credential setup separate from business calls: installation success is not +proof of account connection. + +## Validation and delivery: handled by the Agent + +1. Compare existing declarations and code with the migration; preserve resources, + tool parameters, and four-language text. Change behavior only when requested; + do not drop permissions merely to satisfy validation. +2. `node scripts/validate-plugin-manifest.mjs ./` checks JSON/Manifest + shape only, not file existence, package contents, actual client support, or + every official publishing rule. +3. Run the repository gates and relevant plugin tests from Contributing. The + official `.github/scripts/package-plugin.sh ` + archives committed **HEAD**, not uncommitted changes. The README's ZIP command + can package a working copy, but production verification must cover the final + submitted contents. Inspect the archive root and declared files; do not wrap + them in an outer plugin directory. +4. Install the final package in a production stable Cindy build meeting its real + minimum, exercise core tools and failures, and check retained capabilities. + The Agent does this when authorized operating tools are available; otherwise + explicitly hand off the unverified steps. Never falsely check the author's + verification box. CI reads the PR attestation; it does not collect device + evidence, prove the minimum version correct, or compare migration capabilities. + +## Interpreting documentation and comments + +Runtime examples follow Cindy's +[Host authoring reference](https://github.com/makecindy/cindy/blob/d1171fc58cc3368005a92c63afe8b4b7bc0ccda3/apps/desktop/src/main/cindy-brain/forge.ts). +They are reproduced here for any harness; invoking Forge or checking out the +client is unnecessary. The pinned validator records its source in the header of +`.tests/contracts/plugin-manifest.*.mjs`. It is generated, not hand-edited, and is +not a complete SDK manual. Historical v2 slot/installation-confirmation comments +do not authorize reinstating installation permission gates. Use this reference, +Contributing, and the target Host's actual interfaces for current behavior. +Keep both languages synchronized; correcting wording does not require changing +plugin packages, Host behavior, or CI restrictions. diff --git a/docs/plugin-authoring.zh-CN.md b/docs/plugin-authoring.zh-CN.md new file mode 100644 index 0000000..5cb26d3 --- /dev/null +++ b/docs/plugin-authoring.zh-CN.md @@ -0,0 +1,263 @@ +

+ 简体中文 · English +

+ +# 插件编写与迁移参考 + +本文供任何 coding Agent、编辑器或人工开发者使用,不依赖 Cindy Forge 或特定 harness。 +最小目录、完整入门 Manifest 与 ZIP 打包见 [README](../README.zh-CN.md#本地开发); +官方仓发布要求见 [贡献指南](../CONTRIBUTING.zh-CN.md)。本文补充迁移映射与常用运行时调用。 + +## 从任务事实推导改动 + +作者描述要实现的功能即可。执行任务的 Agent 应读取现有 `ghost.json`、入口代码和相关 +资源,自己完成格式转换、声明维护、版本调整、文案同步与校验,不把字段映射清单交给 +作者手工执行,也不要求作者在每次任务里重复粘贴本指南。 + +- 新建插件:从 README 的 v3 示例开始,只增加功能实际需要的声明与资源。 +- 修改已有插件:保留 ID、入口、工具接口和已有能力;若包内容变化且仍是 v2,按下表 + 迁移。不要用最小示例覆盖已有 Manifest,不要顺手迁移其它插件。 +- 普通 HTTPS、工作目录写入、CLI:先选用下文已有接口,不为具体域名、路径、命令 + 新增 Slot、客户端白名单或新的执行 API。 +- 能从现有实现判断的事项自行处理;只有功能取舍、无法确认的目标客户端版本或尚未 + 获得的实机验证证据需要向作者说明。不能猜一个最低版本,也不能把未验证写成已验证。 + +## Manifest v2 到 v3:保留行为,转换表达 + +`schemaVersion` 描述清单格式;`minCindyVersion` 描述这个包实际需要的客户端版本, +两者不是同一个概念。迁移时设 `schemaVersion: 3`,确认包的最低版本,完成下表转换 +之后才删除 `slots`。下面只描述已有能力的等价写法,不要求插件增加原来没有的能力。 + +| v2 的 slot / 字段 | v3 表达 | 迁移时保留什么 | +|---|---|---| +| `tool` | `tools` | 完整工具清单、参数、描述;已有字段保留 | +| `card` | `card: {}` 或原 `card` 对象 | 仅渲染也要保留空对象;已有 `externalLinks` 不丢失 | +| `agent` | `agent: {}` 或原 `agent` 对象 | 基础点击触发用空对象;已有可选字段保留,不自动加后台能力 | +| `panel` | `panel` | HTML 路径、标题、位置及尺寸配置 | +| `main-view` | `mainView` | 应用级主视图声明,与 `panel` 不互相替代 | +| `cindy`,旧别名 `model` | `cindy` | 原动作详单;只有旧 `model` 字段时改名,两个都有时以原 `cindy` 为准 | +| `subscribe` | `subscribe` | topics/hooks 及相关 `launch` 配置 | +| `node` | `node` | entry/entries、协议、生命周期、`childSpawn`、凭证绑定 | +| `network` | `network` | hosts、secrets、connections 和各自注入范围 | +| `preview` | `preview` | 原 hosts,不自动扩大可打开的网址范围 | +| `skill` | `skill` | items 与随包 SKILL.md,不改名或换成 Manual | +| `notify` | `notify: true` | 提示能力 | +| `badge` | `badge: true` | 未读点能力,仍需要 `panel` | +| `confirm` | `confirm: true` | 插件业务确认能力,不是安装授权弹窗 | +| `fs` | `fs: true` | 插件私有数据目录能力 | +| `library` | `library: true` | 持久作品库能力,不用 `fs` 代替 | +| `session-context` | `sessionContext: true` | Host 注入的可信会话上下文 | +| `pick` | `pick: true` | 用户选择目录的入口 | +| `workspace` | `workspace: true` | 工作区会话入口 | +| `ios-simulator` | `iosSimulator: true` | Host 内嵌模拟器入口;已有 Skill 等声明同时保留 | + +布尔能力出现时必须为 `true`,不用时省略,不能写 `false` 或 `null`。 +`manual`、`setup`、`locales`、`settingsHtml`、`command`、`keywords` 等独立字段继续保留; +不能因为它们不在 `slots` 中就删除。只做格式迁移时,不应改动工具行为或源码执行路径。 +旧清单中只有名字、没有详单的历史空声明不应被补成新增权限;结合实际代码判断。 +不认识的声明不能凭名称猜测映射或静默删除,应依据目标 Host 的真实契约确认。 + +例如,旧 `slots: ["tool", "card", "node", "session-context", "pick", "preview"]` +迁移后保留原 `tools`、`node`、`preview`,保留原 `card`(没有对象则补 `{}`), +补 `sessionContext: true`、`pick: true`。只删除 `slots` 会漏掉后三类纯声明能力。 + +未知 v3 顶层字段会由校验器保留;这既不等于自动授权,也不证明 Host 已实现该能力。 +新业务若能由现有网络、文件或 Node 接口完成,无需客户端增加具体业务能力注册。 +真正没有 Host 实现的接口,不能通过自造字段或方法名获得。 + +## 版本与安装事实 + +- Agent 应根据这个插件依赖的清单格式、Host 接口及正式发布证据确定 + `minCindyVersion`。仅沿用 v2 包的旧值不一定正确;代码已合入也不等于已发布。 + README 的 `1.2.3` 是占位示例,仓库不规定统一客户端版本下限。 +- 当前官方仓 CI 要求新插件和包内容变化的插件使用 v3;描述、图标、包内文档也算 + 包内容。公共随包的 LICENSE/NOTICE/TRADEMARKS 文件变化会影响全部插件;根目录 + README、本文或 CI 文件本身不进入插件包,不触发插件迁移。 +- 包变化需要提升插件自身 `version`。迁移 v3 后,不能再声称支持只认识 v2 的客户端。 + Server 根据包的最低版本向旧客户端选择兼容历史 release;没有则不展示。若任务 + 明确要求给仅支持 v2 的客户端继续发修复包,当前仓库门禁不支持,应说明冲突, + 不可降低版本元数据来绕过,也不可自行修改 CI。 +- 安装和更新不以能力变化为由逐项请求确认,来源不构成另一套能力授权策略。原有 + 自动安装入口仍存在;开发工具的“打包”只生成产物,安装需有相应用户请求。 + 使用时仍可能需要 Agent 授权、登录、配置凭证或用户选择目录,不能把这些取消。 +- 包内 Manifest 应准确描述插件贡献项和自主 Host 使用;不要另造“市场摘要是实际包 + 能力上限”的安装门禁。实际使用按 Host 支持、凭证边界和既有运行时授权处理。 + +## 运行时接口:先选择执行边界 + +`main.js` 是浏览器沙箱,不是 Node,不能直接 `require('node:fs')`、启动 CLI 或绕开 +Host 直连外网。以下代码在插件运行时执行;编写插件的 Agent 不需要自身拥有 Cindy 工具。 + +### 工具调用与完成 + +Host 下发 `{ type: 'tool-call', tool, args, callId }`。按 `tool` 分派,使用这一次 +`callId`,完成所有依赖该调用的操作后只返回一次 `tool-result`。不能持久保存、伪造 +或在交卷后复用 `callId`,也不能把权限拒绝改成另一条绕过路径。 + +```js +cindy.onHostMessage(async function (msg) { + if (msg.type !== 'tool-call' || msg.tool !== 'hello') return; + await cindy.send({ + type: 'tool-result', callId: msg.callId, ok: true, + result: { message: '插件已经正常工作。' } + }); +}); +``` + +失败用 `{ type: 'tool-result', callId, ok: false, errorCode, message }` 返回可行动的 +说明,避免裸堆栈、凭证和敏感请求内容。发送、删除等外部副作用在超时后可能已经完成, +应说明“结果未知,请先核对”,不能照搬只读示例自动重试。 + +### 普通 HTTPS + +下列函数在对应 `tool-call` 的处理函数内调用,返回值放进最终 `tool-result.result`; +在处理函数中捕获失败,按上面的格式发送失败回包。 +`example.test` 是占位域名,使用前替换为业务实际端点。 + +```js +async function readRemoteText(msg) { + const response = await cindy.fetch({ + url: 'https://example.test/info', method: 'GET', + headers: { Accept: 'text/plain' }, + as: 'text', timeoutMs: 30000, callId: msg.callId + }); + if (!response.ok) throw new Error(response.message || '请求未完成,请检查网络或授权。'); + // ok 表示 Host 代发成功,不表示远端 HTTP 成功。 + if (response.status < 200 || response.status >= 300) { + throw new Error('远端服务拒绝了请求,请检查服务状态与账号权限。'); + } + if (response.truncated) throw new Error('响应过大,请缩小查询范围。'); + return { text: response.body }; +} +``` + +当前在途 Agent 调用的普通 HTTPS 不需预登记域名。Host 仍执行 URL、SSRF、超时和 +重定向检查。脱离该调用的自主联网需要 `network` 声明。Host 托管凭证、OAuth、动态 +连接也需要对应声明并命中目标 host;`callId` 不授予读取或向任意域名注入凭证的权利。 +不要把 token 写入 `headers` 或聊天参数。文本请求的 `body` 是字符串,仅用于支持 +请求体的方法;4xx/5xx 也可能返回 `ok: true`,必须检查 `status`。 + +### 文件操作 + +| 目标 | 可用操作 | 授权与生命周期 | +|---|---|---| +| `root: 'workdir'` | 仅 `write` | 当前 `callId`,遵循会话权限模式;不需 `fs: true` | +| `root: 'save'` | 仅 `write` | Host 注入的 `args.save_deposit.token`;不需 `fs: true` | +| `root: 'data'` | `write/read/list/delete` | 需 `fs: true`;插件私有目录,卸载回收 | + +```js +// 在 tool-call 内、发送 tool-result 前完成。 +const written = await cindy.fs({ + op: 'write', root: 'workdir', path: 'output/summary.md', + content: '# Summary\n', callId: msg.callId +}); +if (!written.ok) throw new Error(written.message || '写入未完成,请检查工作目录和授权。'); +// 成功字段包含 op、path、bytes;将实际结果交给 Agent。 +``` + +`cindy.fs(options)` 等价于 `cindy.send({ type: 'fs-request', ...options })`。 +`path` 是相对路径,不接受绝对路径、`..` 或穿透符号链接;内容默认 UTF-8,二进制可用 +`encoding: 'base64'`。会话 workdir 的计划/只读模式会拒绝写入,远程工作区不能当本机 +目录写入。保存票据必须真实存在且未过期,不能伪造 token。 +读取/删除工作区文件不是把 `workdir` 的 `op` 换掉就能做到,应由既有 Agent 文件工具 +或确实需要的 Node worker 完成,并遵守该执行路径的授权和范围。用户持久作品需要 +`library` 时保留该能力,不能改存卸载即删的 `data`。 + +### 随包 Node / CLI + +在现有 Manifest 中加入下面的字段片段,并声明实际工具(例如 `git_version`)。 +`entry: 'main.js'` 仍是沙箱入口,`node.entry` 是另一个真实的随包文件。 + +```json +{ + "node": { + "entry": "node/worker.cjs", + "protocol": "json-rpc-stdio", + "lifecycle": "on-demand" + } +} +``` + +下例要求 Git 已安装且在 worker 的 PATH 中可用,仅固定执行 `git --version`, +不拼接用户输入到 shell,不需要额外 Node 依赖。缺少 Git 是环境配置失败,不能回报成功: + +```js +// node/worker.cjs:stdout 只写一行一个 JSON-RPC 消息,日志写 stderr。 +const readline = require('node:readline'); +const { execFile } = require('node:child_process'); +const reply = (value) => process.stdout.write(JSON.stringify(value) + '\n'); +readline.createInterface({ input: process.stdin }).on('line', (line) => { + let request; + try { request = JSON.parse(line); } + catch { reply({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }); return; } + if (!request || request.id === undefined) return; + if (request.method !== 'cli/git-version') { + reply({ jsonrpc: '2.0', id: request.id, error: { code: -32601, message: 'Method not found' } }); + return; + } + execFile('git', ['--version'], { timeout: 5000 }, (error, stdout) => { + reply(error + ? { jsonrpc: '2.0', id: request.id, error: { code: -32000, message: 'Git 检查失败,请确认已安装 Git 且 PATH 中可用' } } + : { jsonrpc: '2.0', id: request.id, result: { version: stdout.trim() } }); + }); +}); +``` + +```js +// main.js:完整工具回包示例,Manifest 的 tools 中须有 git_version。 +cindy.onHostMessage(async function (msg) { + if (msg.type !== 'tool-call' || msg.tool !== 'git_version') return; + let result; + try { + result = await cindy.node.request({ method: 'cli/git-version', params: {}, timeoutMs: 10000 }); + } catch { + result = { ok: false, message: '本地工作进程未返回结果,请检查插件运行状态。' }; + } + await cindy.send(result.ok + ? { type: 'tool-result', callId: msg.callId, ok: true, result: result.result } + : { type: 'tool-result', callId: msg.callId, ok: false, + errorCode: 'NODE_REQUEST_FAILED', message: result.message }); +}); +``` + +Node worker 没有全局 `cindy` 或 Electron IPC;需要 Host 能力时先把结果交回 `main.js`。 +`cindy.node.request` 的 `timeoutMs` 为 1000–120000,缺省 30000;可选 `entry` 只能 +指向 `node.entries` 声明的额外入口。`mcp-stdio` 沿用同一入口,MCP 初始化由 Host +处理,插件不要自行调用 `initialize`。运行具体 CLI 不需要改客户端注册表,也不存在 +本指南要求新增的 `cindy.process.run`。真实外部 CLI 的安装、平台支持和参数安全仍须验证。 + +`node.childSpawn: true` 开启的是 Host 的 `globalThis.__CINDY_NODE__.spawnEntry` +桥接,只能代启 `node.entry` / `node.entries` 中的 JS 文件。它不是普通 +`child_process.execFile` 的开关,上面的 Git 示例不需要它。不能把 `process.execPath` +当作 Node 可执行文件:正式 Cindy 包关闭了 Electron RunAsNode;随包 JS 子进程应走 +上述宿主桥接。Node worker 拥有当前系统用户权限,不受浏览器沙箱限制,命令、文件和 +网络操作仍应限定在任务要求的范围内。 + +托管 HTTP 凭证用 `network.secrets[].inject` / `network.connections[].inject`;确需 +Worker 使用明文时用 `node.secretBindings` 限定方法和入口,Host 在请求的 +`cindy.secrets` 注入,不能通过普通 `params` 传入、缓存、回传或写日志。凭证配置与 +业务调用分开,不把“安装成功”等同于“账号已连接”。 + +## 校验与交付:Agent 自行完成,不增加作者手工步骤 + +1. 对照旧声明与源码检查迁移后仍有所有原能力,保留引用的资源、工具参数和四语言 + 文案;语义变更是本次需求才调整,不自动删权限来让校验通过。 +2. `node scripts/validate-plugin-manifest.mjs ./<目录>` 只校验 JSON/Manifest 形状, + 不检查文件存在、包内容、目标客户端实际支持情况或官方仓全部发布规则。 +3. 按贡献指南运行仓库门禁及相关插件测试。官方打包脚本 + `.github/scripts/package-plugin.sh <目录> <输出.cindy>` 从已提交的 **HEAD** 归档, + 不会包含未提交修改;验证工作区产物可先用 README 的 ZIP 命令,正式验收必须对应 + 最终提交内容。查看包根目录及声明文件,不能把外层插件目录一起套进去。 +4. 在满足真实最低版本的生产稳定版 Cindy 上安装最终包,实际调用核心工具、确认失败 + 分支和原有能力仍可用。有可用且获授权的操作工具时由 Agent 完成;否则明确交接 + 尚未验证的步骤,不能替作者虚假勾选。CI 读取的是 PR 实机验证声明,不是实机证据 + 采集器,也不会替作者判定最低版本正确或自动比较迁移能力。 + +## 文档与注释的解释边界 + +运行时示例依据 Cindy [Host 编写参考](https://github.com/makecindy/cindy/blob/d1171fc58cc3368005a92c63afe8b4b7bc0ccda3/apps/desktop/src/main/cindy-brain/forge.ts), +列在这里供任意 harness 直接使用,不要求调用 Forge 或 checkout 客户端。 +固定校验器的来源在 `.tests/contracts/plugin-manifest.*.mjs` 文件头;它是生成产物, +不手改,也不是完整 SDK 手册。里面保留的 v2“槽”“安装确认框”等历史注释不能用来 +恢复安装授权门禁;当前行为以本文、贡献指南和目标 Host 的实际接口为准。修改文档时 +同步中英文,不因纠正文案去更改插件包、Host 行为或新增 CI 限制。 diff --git a/scripts/validate-plugin-manifest.mjs b/scripts/validate-plugin-manifest.mjs new file mode 100644 index 0000000..fbbb9ce --- /dev/null +++ b/scripts/validate-plugin-manifest.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const root = path.resolve(import.meta.dirname, '..'); +const input = process.argv[2]; + +if (!input) { + console.error('usage: node scripts/validate-plugin-manifest.mjs '); + process.exit(2); +} + +const inputPath = path.resolve(process.cwd(), input); +const manifestPath = fs.statSync(inputPath).isDirectory() + ? path.join(inputPath, 'ghost.json') + : inputPath; +const contractDir = path.join(root, '.tests', 'contracts'); +const validators = fs.readdirSync(contractDir) + .filter((name) => /^plugin-manifest\.[a-f0-9]+\.mjs$/.test(name)); + +if (validators.length !== 1) { + throw new Error(`expected exactly one pinned Cindy manifest validator, found ${validators.length}`); +} + +const { validateGhostManifest } = await import(pathToFileURL(path.join(contractDir, validators[0])).href); +// Shape validation only: this does not inspect bundled files or prove Host support. +// See docs/plugin-authoring.md for runtime examples and the separate delivery checks. +const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); +const result = validateGhostManifest(manifest); + +if (!result.ok) { + console.error(`${path.relative(process.cwd(), manifestPath)}: ${result.reason}`); + process.exit(1); +} + +console.log(`${path.relative(process.cwd(), manifestPath)}: valid Cindy plugin manifest`);