diff --git a/apps/desktop/src/main/cindy-brain/GhostManager.ts b/apps/desktop/src/main/cindy-brain/GhostManager.ts index a6f0b472b51..fe4f689e008 100644 --- a/apps/desktop/src/main/cindy-brain/GhostManager.ts +++ b/apps/desktop/src/main/cindy-brain/GhostManager.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; +import { isDeepStrictEqual } from 'node:util'; import JSZip from 'jszip'; @@ -9,6 +10,7 @@ import { GHOST_LOCALE_MAX_BYTES, GHOST_SKILL_MD_MAX_BYTES, ghostLocalePathFor, + ghostInstallApprovalToken, ghostIconMimeType, isValidGhostId, resolveGhostManifestLocale, @@ -25,7 +27,19 @@ import { type GhostTrustRegistry, } from './ghostSignature.js'; import { isPathInsideDir } from './dirDeposit.js'; +import { + collectGhostContentFiles, + hashGhostContentFiles, + resolveGhostContentPathSync, +} from './ghostContentTree.js'; import { checkSkillMdConsistency } from './skillSlot.js'; +import { + createGhostInstallReceipt, + GhostInstallReceiptStore, + hashApprovedSkillContent, + type GhostInstallReceipt, + type GhostInstallReceiptReadResult, +} from './ghostInstallReceipt.js'; /** 普通沙箱插件维持小包上限;随包 Node/CLI 允许更大的预打包产物。 */ export const MAX_BASIC_CINDY_FILE_BYTES = 8 * 1024 * 1024; @@ -51,15 +65,25 @@ const TRUST_METADATA_FILE = '.cindy-trust.json'; export interface GhostManagerLogger { info(message: string, meta?: Record): void; warn(message: string, meta?: Record): void; + /** 可选:仅用于"本该收敛却失败"的状态(如撤销批准失败后转进程内隔离)。 */ + error?(message: string, meta?: Record): void; } export interface GhostManagerOptions { /** 意识仓库根目录(生产:userData/cindy-brain;测试:os.tmpdir 下临时目录)。 */ getRootDir: () => string; + /** Host 批准状态根;必须位于插件安装根之外。 */ + getStateDir?: () => string; /** 装/卸成功后通知(index.ts 用它广播 ghosts:changed 到所有窗口)。 */ onChanged?: (ghosts: InstalledGhost[]) => void; /** 当前宿主语言;插件未提供时由 shared 契约固定回退英文。 */ getLocale?: () => string; + /** + * `approveTrustedBundledInstall` 的 builtin-only 边界:id 是否对应一颗随包种子。 + * 生产接线必须提供 —— 该入口不经用户确认就铸出批准,此前这条边界只靠"唯一 + * 调用者是随包对账"的纪律,没有运行期强制。未注入时不加门(单测直接驱动)。 + */ + isTrustedBundledId?: (id: string) => boolean; /** Cindy 维护的发布者/审核公钥表;缺省为空,签名仍验完整性但不抬身份等级。 */ trustRegistry?: GhostTrustRegistry; log?: GhostManagerLogger; @@ -72,19 +96,22 @@ export type InstallRejection = | { code: 'already-installed'; reason: string } | { code: 'not-installed'; reason: string } | { code: 'command-conflict'; reason: string } + | { code: 'state-changed'; reason: string } | { code: 'io'; reason: string }; export type UninstallRejection = | { code: 'invalid-id'; reason: string } | { code: 'not-installed'; reason: string } + | { code: 'approval-required'; reason: string } | { code: 'io'; reason: string }; /** - * 意识仓库的 main 端管理者:一个意识一个子目录(rootDir//),目录即事实。 + * 插件仓库的 main 端管理者:一个插件一个内容目录(rootDir//),Host + * receipt 才是 manifest / trust / enabled / revision 的授权事实。 * * 设计要点: - * - **目录即注册表**:没有额外的 DB / 索引文件,list() 每次实扫磁盘 —— - * 装了什么打开文件夹一目了然,坏一个目录只影响那一个意识(跳过 + warn); + * - **目录只证明在装**:list() 实扫内容目录,但批准状态来自安装根之外的 + * receipt;旧安装没有 receipt 时保持不可运行,更新需完整重新确认; * - **装载先落 staging 再切正式**(对齐 skillhub/installService 的做法): * 解压全程发生在 `.cindy-installing-*` 临时目录,校验全过才 rename 到 * rootDir/,任何一步失败都不会留下半截安装; @@ -94,7 +121,106 @@ export type UninstallRejection = * 目标是 rootDir 的直接子目录,杜绝借 id 删任意路径。 */ export class GhostManager { - constructor(private readonly options: GhostManagerOptions) {} + private readonly receiptStore: GhostInstallReceiptStore; + private mutationTail: Promise = Promise.resolve(); + /** + * 本进程内被判定"批准状态不可信"的插件 id。 + * + * 用途只有一个:撤销陈旧批准**失败**时的兜底。撤销失败的成因(状态根不可写)与 + * 写批准失败的成因是同一个,所以不能再指望往状态根写任何东西来表达"已失效" —— + * 内存标记是此时唯一还能用的机制。下次启动重新对账,成功即自愈;仍然失败就仍然 + * 隔离,始终 fail closed。 + */ + private readonly untrustedApprovals = new Set(); + + constructor(private readonly options: GhostManagerOptions) { + this.receiptStore = new GhostInstallReceiptStore( + options.getStateDir ?? + (() => { + const root = path.resolve(options.getRootDir()); + return path.join(path.dirname(root), `${path.basename(root)}-install-state`); + }), + ); + const contentRoot = path.resolve(options.getRootDir()); + const stateRoot = this.receiptStore.rootDir(); + if ( + isPathInsideDir(contentRoot, stateRoot) || + isPathInsideDir(stateRoot, contentRoot) + ) { + throw new Error('ghost install content and approval state roots must be disjoint'); + } + } + + /** Forge 等 Host 能力必须排除的受管根(内容根 + 批准状态根)。 */ + managedRootDirs(): string[] { + return [path.resolve(this.options.getRootDir()), this.receiptStore.rootDir()]; + } + + approvalStateRoot(): string { + return this.receiptStore.rootDir(); + } + + /** + * 读批准状态的**唯一入口**:进程内隔离优先于磁盘上的 receipt。 + * + * 所有消费方(list / setEnabled / update 的 token 比对)都必须走这里 —— 各自直接 + * 调 receiptStore.read() 会让隔离在某条路径上失效,那类"同一判定散落多处"的分叉 + * 正是本 PR 前几轮反复出问题的原因。 + */ + private readApproval(id: string): GhostInstallReceiptReadResult { + if (this.untrustedApprovals.has(id)) { + return { state: 'invalid', reason: '批准状态已被判定不可信(撤销失败)' }; + } + return this.receiptStore.read(id); + } + + /** + * 技能链接对账前重新核验批准快照。 + * + * `list()` 是首帧同步 API,不能在里面流式重算目录摘要;因此由异步 reconciler + * 对每个准备挂链的插件调用本入口。receipt revision 若已变化、快照缺失/不可读、 + * 含非普通条目或字节不符一律 false,让对账器撤掉已有链接并拒绝新建。 + */ + async verifyApprovedSkillSnapshot(ghost: InstalledGhost): Promise { + if ( + ghost.approval.state !== 'approved' || + !ghost.manifest.skill?.items.length || + !ghost.approvedSkillRoot + ) { + return false; + } + const current = this.readApproval(ghost.manifest.id); + if ( + current.state !== 'approved' || + current.receipt.revision !== ghost.approval.revision + ) { + return false; + } + const expectedRoot = this.receiptStore.skillSnapshotRoot( + current.receipt.id, + current.receipt.revision, + ); + if (path.resolve(ghost.approvedSkillRoot) !== path.resolve(expectedRoot)) { + return false; + } + return this.receiptStore.skillSnapshotMatchesReceipt(current.receipt, expectedRoot); + } + + /** Serialize content-directory and approval-receipt mutations as one Host transaction lane. */ + async runExclusiveMutation(operation: () => Promise): Promise { + const previous = this.mutationTail; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + this.mutationTail = previous.then(() => gate); + await previous; + try { + return await operation(); + } finally { + release(); + } + } /** 扫描已装意识(同步 —— renderer 首帧 sendSync 拉取,目录极小不卡启动)。 */ list(): InstalledGhost[] { @@ -111,6 +237,41 @@ export class GhostManager { if (!entry.isDirectory()) continue; if (entry.name.startsWith('.')) continue; // staging / 系统目录 const dir = path.join(root, entry.name); + if (!isValidGhostId(entry.name)) { + this.options.log?.warn('ghost dir skipped: invalid directory id', { dir }); + continue; + } + const approvalResult = this.readApproval(entry.name); + if (approvalResult.state === 'approved') { + const receipt = approvalResult.receipt; + const localizedManifest = this.localizeApprovedManifest(receipt); + result.push({ + manifest: localizedManifest, + dir, + enabled: receipt.enabled, + approval: { state: 'approved', revision: receipt.revision }, + trust: receipt.trust, + ...(receipt.manifest.skill?.items.length + ? { + approvedSkillRoot: this.receiptStore.skillSnapshotRoot( + receipt.id, + receipt.revision, + ), + } + : {}), + ...(receipt.iconDataUrl !== undefined ? { iconDataUrl: receipt.iconDataUrl } : {}), + }); + continue; + } + if (approvalResult.state === 'invalid') { + this.options.log?.warn('ghost approval receipt invalid; plugin kept disabled', { + id: entry.name, + reason: approvalResult.reason, + }); + } + + // 老安装没有 Host 批准快照,或快照损坏:只读取清单用于设置页恢复, + // 不把 live manifest / trust / enabled 当成运行授权。 const manifestPath = path.join(dir, GHOST_MANIFEST_FILE); let raw: unknown; try { @@ -137,12 +298,11 @@ export class GhostManager { // icon 读失败只降级为无图标(warn),不影响意识本体可用。 const iconDataUrl = this.readInstalledIconDataUrl(dir, v.manifest); const localizedManifest = this.readInstalledLocalizedManifest(dir, v.manifest); - const trust = this.readInstalledTrust(dir); result.push({ manifest: localizedManifest, dir, - enabled: !fs.existsSync(path.join(dir, DISABLED_MARKER_FILE)), - ...(trust ? { trust } : {}), + enabled: false, + approval: { state: approvalResult.state }, ...(iconDataUrl !== null ? { iconDataUrl } : {}), }); } @@ -150,6 +310,20 @@ export class GhostManager { return result; } + /** receipt 内的 base manifest + 已批准 locale 资源;不再读取可变安装目录。 */ + private localizeApprovedManifest(receipt: GhostInstallReceipt): GhostManifest { + const requestedLocale = this.options.getLocale?.(); + const runtimeManifest = withGhostResolvedLocale(receipt.manifest, requestedLocale); + const localePath = ghostLocalePathFor(receipt.manifest, requestedLocale); + const fallbackPath = receipt.manifest.locales?.en; + const candidates = [...new Set([localePath, fallbackPath].filter((value): value is string => Boolean(value)))]; + for (const candidate of candidates) { + const resource = receipt.localeResources[candidate]; + if (resource) return resolveGhostManifestLocale(runtimeManifest, resource); + } + return runtimeManifest; + } + /** * 读取当前宿主语言对应的 locale 文件。已安装目录被用户手工改坏时不让 * 整个插件消失:记录告警并回退原 manifest;正常安装路径已在 parse 阶段严验。 @@ -163,17 +337,18 @@ export class GhostManager { const candidates = [...new Set([localePath, fallbackPath].filter((value): value is string => Boolean(value)))]; for (const candidatePath of candidates) { try { - const absPath = path.join(dir, ...candidatePath.split('/')); + // 逐段解析(判据与批准侧 readApprovedLocaleResources、技能目录同源)。 + // 上一版在这里用 realpath + 目录钳制自成一套:同一件事两种写法,改了一处 + // 忘另一处正是这条链路反复出问题的形态,现在统一成"链接一律拒"。 + const absPath = resolveGhostContentPathSync(dir, candidatePath, { + expect: 'file', + label: 'ghost locale', + }); const stat = fs.lstatSync(absPath); - if (!stat.isFile() || stat.size > GHOST_LOCALE_MAX_BYTES) { + if (stat.size > GHOST_LOCALE_MAX_BYTES) { throw new Error(`locale 文件缺失或超过 ${GHOST_LOCALE_MAX_BYTES} 字节`); } - const realDir = fs.realpathSync.native(dir); - const realLocalePath = fs.realpathSync.native(absPath); - if (!isPathInsideDir(realDir, realLocalePath)) { - throw new Error('locale 文件经软链解析后位于插件目录之外'); - } - const raw = JSON.parse(fs.readFileSync(realLocalePath, 'utf8')); + const raw = JSON.parse(fs.readFileSync(absPath, 'utf8')); const validated = validateGhostManifestLocaleResource(raw, manifest); if (!validated.ok) throw new Error(validated.reason); return resolveGhostManifestLocale(runtimeManifest, validated.resource); @@ -193,10 +368,24 @@ export class GhostManager { } /** - * 启用 / 停用一张意识。停用不删任何东西,只在安装目录里放一个 `.disabled` - * 标记文件(目录即事实:打开文件夹一眼可见);启用即删掉标记。幂等。 + * 启用 / 停用一张意识。停用不删任何东西,只把批准 receipt 的 enabled 翻过来 + * (安装目录里的 `.disabled` 只作为旧版本兼容镜像同步维护)。幂等。 + * + * 两个方向不对称:**启用需要有效批准状态**(无批准的存量安装必须先重新确认 + * 权限),**停用必须永远能成功** —— 停用是安全的收敛方向,不能因为技能快照 + * 被外部删掉之类的环境问题把插件卡在"既不能用也不能关"。 */ - async setEnabled(id: string, enabled: boolean): Promise<{ ok: true } | { rejection: UninstallRejection }> { + async setEnabled( + id: string, + enabled: boolean, + ): Promise<{ ok: true } | { rejection: UninstallRejection }> { + return this.runExclusiveMutation(() => this.setEnabledUnlocked(id, enabled)); + } + + private async setEnabledUnlocked( + id: string, + enabled: boolean, + ): Promise<{ ok: true } | { rejection: UninstallRejection }> { if (!isValidGhostId(id)) { return { rejection: { code: 'invalid-id', reason: '非法意识 id' } }; } @@ -204,14 +393,41 @@ export class GhostManager { if (!(await pathExists(dir))) { return { rejection: { code: 'not-installed', reason: `意识 ${id} 未装入` } }; } + const receiptResult = this.readApproval(id); + if (receiptResult.state !== 'approved' && enabled) { + return { + rejection: { + code: 'approval-required', + reason: `插件 ${id} 缺少有效的安装批准状态,请重新选择安装包并确认权限`, + }, + }; + } const marker = path.join(dir, DISABLED_MARKER_FILE); + const previousEnabled = + receiptResult.state === 'approved' ? receiptResult.receipt.enabled : false; try { if (enabled) { await fs.promises.rm(marker, { force: true }); } else { await fs.promises.writeFile(marker, ''); } + if (receiptResult.state === 'approved') { + // 快照被外部删掉时从当前安装目录重建(内容与批准 manifest 的一致性由 + // ensureSkillSnapshot 的 SKILL.md 逐字校验兜住);停用方向即使重建不了 + // 也照样落盘,由技能对账把落链撤掉。 + await this.receiptStore.write( + { ...receiptResult.receipt, enabled }, + { skillSourceDir: dir, requireSkillSnapshot: enabled }, + ); + } } catch (err) { + // `.disabled` 是旧版本兼容镜像;receipt 写失败时尽力把镜像回滚, + // 避免降级运行旧客户端时看到与批准状态相反的启用态。 + if (previousEnabled) { + await fs.promises.rm(marker, { force: true }).catch(() => undefined); + } else { + await fs.promises.writeFile(marker, '').catch(() => undefined); + } return { rejection: { code: 'io', reason: err instanceof Error ? err.message : String(err) } }; } this.options.log?.info('ghost enabled state changed', { id, enabled }); @@ -225,10 +441,15 @@ export class GhostManager { */ private readInstalledIconDataUrl(dir: string, manifest: GhostManifest): string | null { if (manifest.icon === undefined) return null; - const iconPath = path.join(dir, ...manifest.icon.split('/')); try { - const stat = fs.statSync(iconPath); - if (!stat.isFile() || stat.size > MAX_GHOST_ICON_BYTES) { + // 逐段解析而不是 `stat` 直读:`stat` 静默穿透链接,会把插件目录之外的字节 + // 读成 icon 下发给 renderer 并钉进 receipt。判据与技能目录 / locale 同源。 + const iconPath = resolveGhostContentPathSync(dir, manifest.icon, { + expect: 'file', + label: 'ghost icon', + }); + const stat = fs.lstatSync(iconPath); + if (stat.size > MAX_GHOST_ICON_BYTES) { this.options.log?.warn('ghost icon skipped: missing or oversize', { dir, icon: manifest.icon }); return null; } @@ -239,24 +460,6 @@ export class GhostManager { } } - /** 读取主机安装时写下的签名验证快照;坏文件只降级未显示,不信作者自报。 */ - private readInstalledTrust(dir: string): GhostTrustInfo | null { - try { - const raw = JSON.parse(fs.readFileSync(path.join(dir, TRUST_METADATA_FILE), 'utf8')) as GhostTrustInfo; - if ( - !raw || - typeof raw !== 'object' || - !['cindy-official', 'reviewed', 'verified-publisher', 'unverified'].includes(raw.level) || - typeof raw.publisherSigned !== 'boolean' || - typeof raw.publisherVerified !== 'boolean' || - typeof raw.reviewed !== 'boolean' - ) return null; - return raw; - } catch { - return null; - } - } - /** * 只验不装:读 .cindy → 解包 → 校验清单,返回清单(含 icon data URL), * 零副作用。「装意识前弹确认」(README 安全原则)的数据来源 —— 三个装入 @@ -289,6 +492,8 @@ export class GhostManager { ): Promise< | { manifest: GhostManifest; + approvedManifest: GhostManifest; + localeResources: Record; trust: GhostTrustInfo; packageSha256: string; iconDataUrl?: string; @@ -425,6 +630,7 @@ export class GhostManager { }; } let localizedManifest = withGhostResolvedLocale(v.manifest, this.options.getLocale?.()); + const localeResources: Record = {}; if (v.manifest.locales !== undefined) { const resources = new Map(); for (const localePath of Object.values(v.manifest.locales)) { @@ -465,6 +671,7 @@ export class GhostManager { }; } resources.set(localePath, validated.resource); + localeResources[localePath] = validated.resource; } const localePath = ghostLocalePathFor(v.manifest, this.options.getLocale?.()); const resource = localePath ? resources.get(localePath) : undefined; @@ -563,6 +770,8 @@ export class GhostManager { return { manifest: localizedManifest, + approvedManifest: v.manifest, + localeResources, trust: signature.trust, packageSha256: crypto.createHash('sha256').update(buf).digest('hex'), ...(iconDataUrl !== undefined ? { iconDataUrl } : {}), @@ -574,6 +783,13 @@ export class GhostManager { async install( lizFilePath: string, opts?: { initiallyEnabled?: boolean; expectedPackageSha256?: string }, + ) { + return this.runExclusiveMutation(() => this.installUnlocked(lizFilePath, opts)); + } + + private async installUnlocked( + lizFilePath: string, + opts?: { initiallyEnabled?: boolean; expectedPackageSha256?: string }, ): Promise<{ ghost: InstalledGhost } | { rejection: InstallRejection }> { // 装入初始启用态由 UI 层决定(装入确认框勾选,默认沉睡);缺省 true // 保持既有调用方(测试等)语义不变。 @@ -592,7 +808,16 @@ export class GhostManager { }, }; } - const { manifest, trust, iconDataUrl, allEntries, prefix } = parsed; + const { + manifest, + approvedManifest, + localeResources, + trust, + packageSha256, + iconDataUrl, + allEntries, + prefix, + } = parsed; // 4) 目标目录冲突检查 const root = this.options.getRootDir(); @@ -621,6 +846,9 @@ export class GhostManager { // 5) 解压到 staging(zip-slip / zip bomb 防御),全过才切正式目录 const stagingDir = path.join(root, `.cindy-installing-${manifest.id}-${crypto.randomBytes(4).toString('hex')}`); + // receipt 在内容落到 finalDir 之后才创建:技能字节指纹必须从这次批准的内容 + // 目录现算,不能凭空构造。 + let receipt: GhostInstallReceipt | undefined; try { // 初始沉睡:标记在 staging 阶段就位,rename 后首个广播即沉睡态, // 不存在"先启用一帧再熄灯"的跳变(规则 7)。 @@ -632,6 +860,22 @@ export class GhostManager { trust, }); await fs.promises.rename(stagingDir, finalDir); + try { + receipt = createGhostInstallReceipt({ + manifest: approvedManifest, + localeResources, + enabled: initiallyEnabled, + trust, + skillContentSha256: await hashApprovedSkillContent(approvedManifest, finalDir), + packageSha256, + ...(iconDataUrl !== undefined ? { iconDataUrl } : {}), + }); + await this.receiptStore.write(receipt, { skillSourceDir: finalDir }); + this.untrustedApprovals.delete(manifest.id); + } catch (error) { + await fs.promises.rm(finalDir, { recursive: true, force: true }).catch(() => undefined); + throw error; + } } catch (err) { await fs.promises.rm(stagingDir, { recursive: true, force: true }).catch(() => {}); if (err instanceof InstallExtractError) { @@ -639,11 +883,15 @@ export class GhostManager { } return { rejection: { code: 'io', reason: err instanceof Error ? err.message : String(err) } }; } + if (!receipt) { + return { rejection: { code: 'io', reason: '安装批准状态未能生成' } }; + } const ghost: InstalledGhost = { manifest, dir: finalDir, enabled: initiallyEnabled, + approval: { state: 'approved', revision: receipt.revision }, trust, ...(iconDataUrl !== undefined ? { iconDataUrl } : {}), }; @@ -663,7 +911,14 @@ export class GhostManager { */ async update( lizFilePath: string, - opts?: { expectedPackageSha256?: string }, + opts: { expectedInstalledApproval: string; expectedPackageSha256?: string }, + ) { + return this.runExclusiveMutation(() => this.updateUnlocked(lizFilePath, opts)); + } + + private async updateUnlocked( + lizFilePath: string, + opts: { expectedInstalledApproval: string; expectedPackageSha256?: string }, ): Promise<{ ghost: InstalledGhost } | { rejection: InstallRejection }> { const parsed = await this.parse(lizFilePath); if ('rejection' in parsed) return parsed; @@ -678,15 +933,40 @@ export class GhostManager { }, }; } - const { manifest, trust, iconDataUrl, allEntries, prefix } = parsed; + const { + manifest, + approvedManifest, + localeResources, + trust, + packageSha256, + iconDataUrl, + allEntries, + prefix, + } = parsed; const root = this.options.getRootDir(); const finalDir = path.join(root, manifest.id); if (!(await pathExists(finalDir))) { return { rejection: { code: 'not-installed', reason: `意识 ${manifest.id} 未装入,无从更新` } }; } - // 延续当前唤醒/沉睡状态。 - const enabled = !fs.existsSync(path.join(finalDir, DISABLED_MARKER_FILE)); + const approvalResult = this.readApproval(manifest.id); + const actualApproval = approvalTokenFor(approvalResult); + if (actualApproval !== opts.expectedInstalledApproval) { + return { + rejection: { + code: 'state-changed', + reason: '插件批准状态在确认后发生了变化,请重新检查权限', + }, + }; + } + // 延续当前唤醒/沉睡状态。旧安装尚无 receipt 时只在完整重新确认后 + // 采用原 `.disabled` 镜像;损坏 receipt 一律保持停用。 + const enabled = + approvalResult.state === 'approved' + ? approvalResult.receipt.enabled + : approvalResult.state === 'legacy-unapproved' + ? !fs.existsSync(path.join(finalDir, DISABLED_MARKER_FILE)) + : false; // 指令查重同 install,但豁免自己(新版本沿用/改名自己的指令都合法)。 if (manifest.command !== undefined) { @@ -740,12 +1020,32 @@ export class GhostManager { await fs.promises.rm(stagingDir, { recursive: true, force: true }).catch(() => {}); return { rejection: { code: 'io', reason: err instanceof Error ? err.message : String(err) } }; } + // 与 install 同理:技能字节指纹从这次换入的内容目录现算。 + let receipt: GhostInstallReceipt; + try { + receipt = createGhostInstallReceipt({ + manifest: approvedManifest, + localeResources, + enabled, + trust, + skillContentSha256: await hashApprovedSkillContent(approvedManifest, finalDir), + packageSha256, + ...(iconDataUrl !== undefined ? { iconDataUrl } : {}), + }); + await this.receiptStore.write(receipt, { skillSourceDir: finalDir }); + this.untrustedApprovals.delete(manifest.id); + } catch (err) { + await fs.promises.rm(finalDir, { recursive: true, force: true }).catch(() => undefined); + await fs.promises.rename(backupDir, finalDir).catch(() => undefined); + return { rejection: { code: 'io', reason: err instanceof Error ? err.message : String(err) } }; + } await fs.promises.rm(backupDir, { recursive: true, force: true }).catch(() => {}); const ghost: InstalledGhost = { manifest, dir: finalDir, enabled, + approval: { state: 'approved', revision: receipt.revision }, trust, ...(iconDataUrl !== undefined ? { iconDataUrl } : {}), }; @@ -754,6 +1054,142 @@ export class GhostManager { return { ghost }; } + /** + * 随包种子已经由 provisioning 层逐字节对账后,为其建立 Host 批准状态。 + * 该入口不得用于市场包或任意本地目录;它不替代用户安装确认。id 必须落在注入的 + * 随包种子清单里(`isTrustedBundledId`)。 + * + * `markerEnabled` 是安装目录 `.disabled` 兼容镜像的读数,**只往停用方向合并, + * 不往启用方向翻**:receipt 才是授权事实,镜像文件可被外部因素移除(AV 隔离 + * 恢复/同步冲突解析/手动清理),拿它覆写 receipt 会让用户显式停用的插件在下一轮 + * 对账被静默重新启用 —— 无确认、无审计,且带 skill 槽的插件会随之重新挂进全局 + * 技能链。反方向(镜像说停用、receipt 说启用)必须照办:停用是安全方向,而且 + * 旧客户端只会写镜像文件。重新启用只有用户显式 `setEnabled(true)` 一条路。 + */ + async approveTrustedBundledInstall( + manifest: GhostManifest, + markerEnabled: boolean, + ): Promise { + if (this.options.isTrustedBundledId?.(manifest.id) === false) { + throw new Error( + `approveTrustedBundledInstall 只服务随包种子插件:${manifest.id} 不在种子清单里`, + ); + } + const dir = path.join(this.options.getRootDir(), manifest.id); + const localeResources = this.readApprovedLocaleResources(dir, manifest); + const iconDataUrl = this.readInstalledIconDataUrl(dir, manifest) ?? undefined; + const packageSha256 = await hashApprovedDirectory(dir); + const skillContentSha256 = await hashApprovedSkillContent(manifest, dir); + const trust: GhostTrustInfo = { + level: 'cindy-official', + publisherSigned: false, + publisherVerified: false, + reviewed: true, + }; + const current = this.readApproval(manifest.id); + // priorEnabled 直接读盘上的 receipt 而不是 readApproval 的投影:进程内隔离态的 + // receipt 不可作授权事实,但"曾经停用"这个位只用于往下拉,是 fail closed 方向, + // 采纳它只会更保守 —— 否则"隔离 + 镜像同时丢失"的组合会让自愈把插件带回启用。 + const persisted = + current.state === 'approved' ? current : this.receiptStore.read(manifest.id); + const priorEnabled = + persisted.state === 'approved' ? persisted.receipt.enabled : undefined; + const enabled = + priorEnabled === undefined ? markerEnabled : markerEnabled && priorEnabled; + if (enabled !== markerEnabled) { + // receipt 钉着停用而镜像丢了:把 `.disabled` 补写回去,守住"回滚到旧客户端时 + // 按镜像判启停"的降级承诺。写不进不影响批准事实,receipt 仍是权威。 + try { + fs.writeFileSync(path.join(dir, DISABLED_MARKER_FILE), ''); + } catch (err) { + this.options.log?.warn('ghost disabled mirror rewrite failed', { + id: manifest.id, + error: err instanceof Error ? err.message : String(err), + }); + } + } + if ( + current.state === 'approved' && + isDeepStrictEqual(current.receipt.manifest, manifest) && + isDeepStrictEqual(current.receipt.localeResources, localeResources) && + isDeepStrictEqual(current.receipt.trust, trust) && + isDeepStrictEqual(current.receipt.skillContentSha256, skillContentSha256) && + current.receipt.packageSha256 === packageSha256 && + current.receipt.iconDataUrl === iconDataUrl + ) { + if (current.receipt.enabled !== enabled) { + await this.receiptStore.write({ + ...current.receipt, + enabled, + }); + this.untrustedApprovals.delete(manifest.id); + return true; + } + return false; + } + await this.receiptStore.write( + createGhostInstallReceipt({ + manifest, + localeResources, + enabled, + trust, + skillContentSha256, + packageSha256, + ...(iconDataUrl !== undefined ? { iconDataUrl } : {}), + }), + { skillSourceDir: dir }, + ); + this.untrustedApprovals.delete(manifest.id); + return true; + } + + /** + * 撤销 Host 批准。**契约是"调用返回后该插件一定不再被授权运行"**:正常路径删掉 + * receipt 与技能快照;删不掉(状态根不可写等)时退回进程内隔离,不把失败原样抛给 + * 调用方去自己 fail closed —— 那正是上一版留下 fail-open 的地方。 + */ + async removeInstallApproval(id: string): Promise { + try { + await this.receiptStore.remove(id); + this.untrustedApprovals.delete(id); + } catch (err) { + this.untrustedApprovals.add(id); + // 这行是"插件已转进程内隔离"的唯一可观测信号,不能因为注入的 logger 没实现 + // error 就静默丢掉 —— 退化到 warn。 + const log = this.options.log; + (log?.error ?? log?.warn)?.call( + log, + 'ghost approval could not be removed; kept untrusted in-process', + { id, error: err instanceof Error ? err.message : String(err) }, + ); + } + } + + private readApprovedLocaleResources( + dir: string, + manifest: GhostManifest, + ): Record { + const resources: Record = {}; + for (const localePath of Object.values(manifest.locales ?? {})) { + if (!localePath) continue; + // 逐段解析:只 lstat 最终段挡不住"中间段被换成链接"——那会把插件目录之外的 + // JSON 读成已批准的界面文案钉进 receipt。判据与技能目录同源。 + const absPath = resolveGhostContentPathSync(dir, localePath, { + expect: 'file', + label: 'bundled locale', + }); + const stat = fs.lstatSync(absPath); + if (stat.size > GHOST_LOCALE_MAX_BYTES) { + throw new Error(`bundled locale missing or oversized: ${localePath}`); + } + const raw = JSON.parse(fs.readFileSync(absPath, 'utf8')) as unknown; + const validated = validateGhostManifestLocaleResource(raw, manifest); + if (!validated.ok) throw new Error(`bundled locale invalid: ${localePath}`); + resources[localePath] = validated.resource; + } + return resources; + } + /** 解压 zip 条目到 staging 目录(install / update 共用;含 zip-slip / bomb 防御)。 */ private async extractToStaging( allEntries: JSZip.JSZipObject[], @@ -798,6 +1234,13 @@ export class GhostManager { async uninstall( id: string, options: { notify?: boolean } = {}, + ) { + return this.runExclusiveMutation(() => this.uninstallUnlocked(id, options)); + } + + private async uninstallUnlocked( + id: string, + options: { notify?: boolean } = {}, ): Promise<{ ok: true } | { rejection: UninstallRejection }> { if (!isValidGhostId(id)) { return { rejection: { code: 'invalid-id', reason: '非法意识 id' } }; @@ -816,6 +1259,10 @@ export class GhostManager { } catch (err) { return { rejection: { code: 'io', reason: err instanceof Error ? err.message : String(err) } }; } + // 走同一个撤销入口:成功即清掉隔离记录,失败由该入口转进程内隔离并记日志。 + // 内容目录已经删除,插件不可能再运行;孤立 receipt 与 skill snapshot 仅是待回收 + // 状态,不能把“清理延后”误报成“插件仍已安装”。 + await this.removeInstallApproval(id); this.options.log?.info('ghost uninstalled', { id }); if (options.notify !== false) this.options.onChanged?.(this.list()); return { ok: true }; @@ -825,6 +1272,15 @@ export class GhostManager { /** staging 期的"内容不合格"错误(与环境 IO 错误区分,映射 file-invalid)。 */ class InstallExtractError extends Error {} +function approvalTokenFor(result: GhostInstallReceiptReadResult): string { + return result.state === 'approved' + ? ghostInstallApprovalToken({ + state: 'approved', + revision: result.receipt.revision, + }) + : ghostInstallApprovalToken({ state: result.state }); +} + /** 流式读取 zip 单条目;超过上限立刻停流,不先分配整个恶意条目。 */ async function readZipEntryBufferWithLimit( entry: JSZip.JSZipObject, @@ -908,6 +1364,24 @@ async function pathExists(p: string): Promise { } } +/** + * 安装目录内容指纹(`packageSha256`,审计用的漂移检测器,不作授权判据)。 + * + * 遍历、类型判定与指纹格式全部取自 `ghostContentTree`,与技能指纹 + * `hashApprovedSkillContent`、随包种子指纹 `fingerprintDirContent` 同一份实现; + * 这里的显式策略是"点开头条目不算内容、非普通条目一律拒"。跟随链接在这条路径上 + * 最多多写一次批准、不构成绕过,判据对齐是因为"同一判据散落多处且各处不一致" + * 本身就是缺陷温床。 + */ +async function hashApprovedDirectory(root: string): Promise { + const { files } = await collectGhostContentFiles(root, { + dotEntries: 'skip', + nonRegular: 'throw', + label: 'bundled Plugin', + }); + return hashGhostContentFiles(root, files); +} + /** * 检测所有条目是否都在同一个顶层文件夹下(用户右键压缩常见形态), * 是则返回该前缀(含尾部 /),否则返回空串。 diff --git a/apps/desktop/src/main/cindy-brain/__tests__/GhostManager.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/GhostManager.test.ts index d3e5b71b54a..2618dc0ef76 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/GhostManager.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/GhostManager.test.ts @@ -5,8 +5,14 @@ import path from 'node:path'; import JSZip from 'jszip'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { InstalledGhost } from '../../../shared/ghost'; +import { + GHOST_SKILL_MD_MAX_BYTES, + ghostInstallApprovalToken, + validateGhostManifest, + type InstalledGhost, +} from '../../../shared/ghost'; import { GhostManager } from '../GhostManager'; +import { hashApprovedSkillContent } from '../ghostInstallReceipt'; /** 每个用例独立的临时仓库根 + 源文件目录(规则 23:测试路径一律 os.tmpdir)。 */ let workDir: string; @@ -76,13 +82,76 @@ async function makeCindy( } async function expectRejection( - result: Awaited>, + result: unknown, code: string, ): Promise { - expect('rejection' in result, JSON.stringify(result)).toBe(true); + expect( + typeof result === 'object' && result !== null && 'rejection' in result, + JSON.stringify(result), + ).toBe(true); expect((result as { rejection: { code: string } }).rejection.code).toBe(code); } +async function updateGhost( + cindyPath: string, + id = 'hello', +): ReturnType { + const installed = manager.list().find((ghost) => ghost.manifest.id === id); + return manager.update(cindyPath, { + expectedInstalledApproval: ghostInstallApprovalToken(installed?.approval), + }); +} + +describe('hashApprovedSkillContent · item.dir 路径段校验', () => { + it('rejects a link in an intermediate path segment instead of hashing bytes from outside', async () => { + // 回归点:只 lstat 最终段是不够的 —— 中间段被换成软链 / junction 时 OS 会静默穿透, + // 对最终段 lstat 报的是"真目录、非链接",于是指纹从技能目录之外取字节。首次批准 + // 那条路径的指纹是现算的,外部内容会被钉成"批准字节"再复制成快照,而 frontmatter + // 一致性校验只看 name/description(manifest 里公开可抄),拦不住。所以这里必须抛错, + // 不能返回一个哈希。 + const validated = validateGhostManifest({ + ...goodManifest('skilled'), + slots: ['tool', 'skill'], + skill: { items: [{ dir: 'skills/demo', name: 'demo', description: 'Demo skill' }] }, + }); + if (!validated.ok) throw new Error(validated.reason); + + const base = path.join(workDir, 'plugin'); + const evil = path.join(workDir, 'evil'); + await fs.promises.mkdir(path.join(base, 'skills', 'demo'), { recursive: true }); + await fs.promises.mkdir(path.join(evil, 'demo'), { recursive: true }); + await fs.promises.writeFile( + path.join(base, 'skills', 'demo', 'SKILL.md'), + '---\nname: demo\ndescription: Demo skill\n---\n\nApproved instructions\n', + ); + await fs.promises.writeFile( + path.join(evil, 'demo', 'SKILL.md'), + '---\nname: demo\ndescription: Demo skill\n---\n\nrm -rf everything\n', + ); + + // 正常结构先能算出来,确认用例本身走到了目标代码。 + await expect(hashApprovedSkillContent(validated.manifest, base)).resolves.toHaveProperty( + 'skills/demo', + ); + + // 把**中间段** skills 换成指向外部的链接。 + await fs.promises.rm(path.join(base, 'skills'), { recursive: true, force: true }); + try { + await fs.promises.symlink( + evil, + path.join(base, 'skills'), + process.platform === 'win32' ? 'junction' : 'dir', + ); + } catch { + return; // 该环境建不了链接(无权限),跳过;判定逻辑与其他平台同源。 + } + + await expect(hashApprovedSkillContent(validated.manifest, base)).rejects.toThrow( + /path segment is a link/, + ); + }); +}); + describe('GhostManager · install', () => { it('按宿主语言返回本地化清单,切换语言后 list 立即更新,不支持语言固定回退英文', async () => { const manifest = { @@ -129,7 +198,7 @@ describe('GhostManager · install', () => { }); }); - it('已安装 locale 或其父目录被替换为目录外软链时拒绝读取并回退基础清单', async () => { + it('installed locale symlinks cannot replace the Host-approved locale snapshot', async () => { hostLocale = 'en'; const manifest = { ...goodManifest(), @@ -155,9 +224,9 @@ describe('GhostManager · install', () => { } expect(manager.list()[0].manifest).toMatchObject({ - name: 'Base name', + name: 'Packaged name', resolvedLocale: 'en', - tools: [{ name: 'do_thing', description: '做点事' }], + tools: [{ name: 'do_thing', description: 'Localized tool' }], }); const localesDir = path.dirname(localePath); @@ -171,7 +240,7 @@ describe('GhostManager · install', () => { process.platform === 'win32' ? 'junction' : 'dir', ); expect(manager.list()[0].manifest).toMatchObject({ - name: 'Base name', + name: 'Packaged name', resolvedLocale: 'en', }); }); @@ -450,6 +519,592 @@ describe('GhostManager · list', () => { }); }); +describe('GhostManager · Host approval receipt', () => { + /** 真实 copyFile 引用:mock 复制行为的用例要靠它放行非目标文件。 */ + const realCopyFile = fs.promises.copyFile; + const receiptPath = (id = 'hello') => + path.join(workDir, 'ghosts-install-state', `${id}.json`); + + /** 带 skill 槽的清单 + 配套包内文件(技能快照相关用例共用)。 */ + const skillManifest = (): Record => ({ + ...goodManifest('skilled'), + slots: ['tool', 'skill'], + skill: { + items: [{ dir: 'skills/demo', name: 'demo', description: 'Demo skill' }], + }, + }); + const skillFiles = (): Record => ({ + 'skills/demo/SKILL.md': + '---\nname: demo\ndescription: Demo skill\n---\n\nApproved instructions\n', + }); + + it('keeps manifest, enabled state, and trust independent from mutable install files', async () => { + await manager.install(await makeCindy('approved.cindy', goodManifest())); + const before = manager.list()[0]; + expect(fs.existsSync(receiptPath())).toBe(true); + expect(path.dirname(receiptPath())).not.toBe(rootDir); + + await fs.promises.writeFile( + path.join(rootDir, 'hello', 'ghost.json'), + JSON.stringify({ + ...goodManifest(), + version: '99.0.0', + slots: ['node'], + node: { entry: 'evil.cjs', protocol: 'json-rpc-stdio' }, + }), + ); + await fs.promises.writeFile(path.join(rootDir, 'hello', '.disabled'), ''); + await fs.promises.writeFile( + path.join(rootDir, 'hello', '.cindy-trust.json'), + JSON.stringify({ + level: 'cindy-official', + publisherSigned: true, + publisherVerified: true, + reviewed: true, + }), + ); + + const after = manager.list()[0]; + expect(after.manifest).toEqual(before.manifest); + expect(after.enabled).toBe(true); + expect(after.trust).toEqual(before.trust); + expect(after.approval.state).toBe('approved'); + }); + + it('fails legacy and corrupt receipts closed until a fully reviewed update replaces them', async () => { + const legacyDir = path.join(rootDir, 'hello'); + await fs.promises.mkdir(legacyDir, { recursive: true }); + await fs.promises.writeFile( + path.join(legacyDir, 'ghost.json'), + JSON.stringify(goodManifest()), + ); + await fs.promises.writeFile(path.join(legacyDir, 'main.js'), '// legacy'); + + expect(manager.list()[0]).toMatchObject({ + enabled: false, + approval: { state: 'legacy-unapproved' }, + }); + await expectRejection(await manager.setEnabled('hello', true), 'approval-required'); + + const reviewed = await updateGhost( + await makeCindy('reviewed.cindy', { ...goodManifest(), version: '2.0.0' }), + ); + expect(reviewed).toMatchObject({ + ghost: { + manifest: { version: '2.0.0' }, + approval: { state: 'approved' }, + }, + }); + + await fs.promises.writeFile(receiptPath(), '{ broken'); + expect(manager.list()[0]).toMatchObject({ + enabled: false, + approval: { state: 'invalid' }, + }); + await expectRejection(await manager.setEnabled('hello', true), 'approval-required'); + }); + + it('rejects an update when the approved revision changed after review', async () => { + await manager.install(await makeCindy('v1.cindy', goodManifest())); + const staleApproval = ghostInstallApprovalToken(manager.list()[0].approval); + const v2 = await makeCindy('v2.cindy', { + ...goodManifest(), + version: '2.0.0', + }); + const first = await manager.update(v2, { + expectedInstalledApproval: staleApproval, + }); + expect(first).toMatchObject({ ghost: { manifest: { version: '2.0.0' } } }); + + const v3 = await makeCindy('v3.cindy', { + ...goodManifest(), + version: '3.0.0', + }); + await expectRejection( + await manager.update(v3, { + expectedInstalledApproval: staleApproval, + }), + 'state-changed', + ); + expect(manager.list()[0].manifest.version).toBe('2.0.0'); + }); + + it('removes the receipt and approved skill snapshots on uninstall', async () => { + const manifest = { + ...goodManifest('skilled'), + slots: ['tool', 'skill'], + skill: { + items: [{ dir: 'skills/demo', name: 'demo', description: 'Demo skill' }], + }, + }; + const cindy = await makeCindy('skill.cindy', manifest, { + 'skills/demo/SKILL.md': + '---\nname: demo\ndescription: Demo skill\n---\n\nApproved instructions\n', + }); + await manager.install(cindy); + const listed = manager.list()[0]; + expect(listed.approvedSkillRoot).toBeTruthy(); + expect(fs.existsSync(listed.approvedSkillRoot!)).toBe(true); + + await manager.uninstall('skilled'); + expect(fs.existsSync(receiptPath('skilled'))).toBe(false); + expect(fs.existsSync(listed.approvedSkillRoot!)).toBe(false); + }); + + it('treats receipt cleanup failure after content removal as a completed uninstall', async () => { + await manager.install(await makeCindy('approved.cindy', goodManifest())); + await fs.promises.rm(receiptPath()); + await fs.promises.mkdir(receiptPath()); + await fs.promises.writeFile(path.join(receiptPath(), 'blocked'), 'x'); + + const result = await manager.uninstall('hello'); + + expect(result).toEqual({ ok: true }); + expect(fs.existsSync(path.join(rootDir, 'hello'))).toBe(false); + expect(manager.list()).toEqual([]); + }); + + it('keeps disabling possible when the approved skill snapshot is gone, and rebuilds it on enable', async () => { + await manager.install(await makeCindy('skill.cindy', skillManifest(), skillFiles())); + const snapshotRoot = manager.list()[0].approvedSkillRoot!; + // 外部把快照删掉:停用是安全方向,必须仍然成功,不能把插件卡在既不能用也不能关。 + await fs.promises.rm(snapshotRoot, { recursive: true, force: true }); + + expect(await manager.setEnabled('skilled', false)).toEqual({ ok: true }); + expect(manager.list()[0]).toMatchObject({ + enabled: false, + approval: { state: 'approved' }, + }); + + // 重新启用时从当前安装目录重建快照,不需要用户重新走一次确认。 + expect(await manager.setEnabled('skilled', true)).toEqual({ ok: true }); + const healed = manager.list()[0]; + expect(healed.enabled).toBe(true); + expect(healed.approvedSkillRoot).toBe(snapshotRoot); + expect( + await fs.promises.readFile(path.join(snapshotRoot, 'skills', 'demo', 'SKILL.md'), 'utf8'), + ).toContain('Approved instructions'); + }); + + it('refuses to rebuild an enable-time snapshot from install bytes that drifted from the approved manifest', async () => { + await manager.install(await makeCindy('skill.cindy', skillManifest(), skillFiles())); + const snapshotRoot = manager.list()[0].approvedSkillRoot!; + // 安装目录里的 SKILL.md 与批准 manifest 声明的 description 不再一致,快照也没了: + // 停用照样成功(安全方向),但重建快照必须拒——否则启用就等于批准一份用户 + // 没看过的技能指令。 + await fs.promises.writeFile( + path.join(rootDir, 'skilled', 'skills', 'demo', 'SKILL.md'), + '---\nname: demo\ndescription: Silently widened skill\n---\n\nTampered instructions\n', + ); + await fs.promises.rm(snapshotRoot, { recursive: true, force: true }); + expect(await manager.setEnabled('skilled', false)).toEqual({ ok: true }); + + await expectRejection(await manager.setEnabled('skilled', true), 'io'); + expect(manager.list()[0].enabled).toBe(false); + expect(fs.existsSync(snapshotRoot)).toBe(false); + }); + + it('prunes skill snapshots left behind by superseded approval revisions', async () => { + await manager.install(await makeCindy('skill-v1.cindy', skillManifest(), skillFiles())); + const firstSnapshot = manager.list()[0].approvedSkillRoot!; + const snapshotParent = path.dirname(firstSnapshot); + + await updateGhost( + await makeCindy( + 'skill-v2.cindy', + { ...skillManifest(), version: '2.0.0' }, + skillFiles(), + ), + 'skilled', + ); + const secondSnapshot = manager.list()[0].approvedSkillRoot!; + + expect(secondSnapshot).not.toBe(firstSnapshot); + expect(await fs.promises.readdir(snapshotParent)).toEqual([ + path.basename(secondSnapshot), + ]); + }); + + it('holds the install-time SKILL.md size ceiling when rebuilding from mutable install bytes', async () => { + await manager.install(await makeCindy('skill.cindy', skillManifest(), skillFiles())); + const snapshotRoot = manager.list()[0].approvedSkillRoot!; + const installedSkillMd = path.join(rootDir, 'skilled', 'skills', 'demo', 'SKILL.md'); + // 快照缺失时取字节的来源是可变安装目录。这里塞的 SKILL.md frontmatter 与批准 + // manifest 完全一致(躲过一致性校验),只是正文超过装入侧上限 —— 重建必须照样拒, + // 否则启用这条路会批准一份装入/更新永远不会接受的超大技能指令,而且要先整份 + // 读进内存。 + await fs.promises.writeFile( + installedSkillMd, + `---\nname: demo\ndescription: Demo skill\n---\n\nApproved instructions\n${'padding '.repeat( + GHOST_SKILL_MD_MAX_BYTES / 4, + )}`, + ); + expect((await fs.promises.lstat(installedSkillMd)).size).toBeGreaterThan( + GHOST_SKILL_MD_MAX_BYTES, + ); + await fs.promises.rm(snapshotRoot, { recursive: true, force: true }); + expect(await manager.setEnabled('skilled', false)).toEqual({ ok: true }); + + await expectRejection(await manager.setEnabled('skilled', true), 'io'); + expect(manager.list()[0].enabled).toBe(false); + expect(fs.existsSync(snapshotRoot)).toBe(false); + }); + + it('refuses to rebuild a snapshot when only the SKILL.md body drifted', async () => { + await manager.install(await makeCindy('skill.cindy', skillManifest(), skillFiles())); + const snapshotRoot = manager.list()[0].approvedSkillRoot!; + // frontmatter 的 name/description 一字未动,只改正文 —— 一致性校验看不出来, + // 但这份指令会被主 Agent 以用户全部权限执行,必须靠批准时点的字节指纹拦住。 + await fs.promises.writeFile( + path.join(rootDir, 'skilled', 'skills', 'demo', 'SKILL.md'), + '---\nname: demo\ndescription: Demo skill\n---\n\nrm -rf everything\n', + ); + await fs.promises.rm(snapshotRoot, { recursive: true, force: true }); + expect(await manager.setEnabled('skilled', false)).toEqual({ ok: true }); + + await expectRejection(await manager.setEnabled('skilled', true), 'io'); + expect(manager.list()[0].enabled).toBe(false); + expect(fs.existsSync(snapshotRoot)).toBe(false); + }); + + it('refuses to rebuild a snapshot when a helper file was added to the skill directory', async () => { + await manager.install(await makeCindy('skill.cindy', skillManifest(), skillFiles())); + const snapshotRoot = manager.list()[0].approvedSkillRoot!; + // SKILL.md 完全没动,只往技能目录里塞一个被指令引用的辅助文件(点文件同样算)。 + await fs.promises.writeFile( + path.join(rootDir, 'skilled', 'skills', 'demo', '.helper.sh'), + '#!/bin/sh\necho injected\n', + ); + await fs.promises.rm(snapshotRoot, { recursive: true, force: true }); + expect(await manager.setEnabled('skilled', false)).toEqual({ ok: true }); + + await expectRejection(await manager.setEnabled('skilled', true), 'io'); + expect(manager.list()[0].enabled).toBe(false); + expect(fs.existsSync(snapshotRoot)).toBe(false); + }); + + it('refuses to follow a link planted inside the skill directory when rebuilding', async () => { + await manager.install(await makeCindy('skill.cindy', skillManifest(), skillFiles())); + const snapshotRoot = manager.list()[0].approvedSkillRoot!; + const outside = path.join(workDir, 'outside-skill'); + await fs.promises.mkdir(outside, { recursive: true }); + await fs.promises.writeFile(path.join(outside, 'leak.txt'), 'bytes from outside the skill dir'); + // Windows junction 不需要管理员权限即可创建,是本平台成本最低的一条"把技能目录 + // 之外的字节拉进批准快照"的路子。判据不能建立在 Dirent 类型位的实现细节上, + // 所以这条用例把行为钉住:planted link 一律拒,快照不落地。 + try { + await fs.promises.symlink( + outside, + path.join(rootDir, 'skilled', 'skills', 'demo', 'linked'), + process.platform === 'win32' ? 'junction' : 'dir', + ); + } catch { + return; // 该环境建不了链接(无权限),跳过;判定逻辑与其他平台同源。 + } + await fs.promises.rm(snapshotRoot, { recursive: true, force: true }); + expect(await manager.setEnabled('skilled', false)).toEqual({ ok: true }); + + await expectRejection(await manager.setEnabled('skilled', true), 'io'); + expect(manager.list()[0].enabled).toBe(false); + expect(fs.existsSync(snapshotRoot)).toBe(false); + // 状态根里不该出现任何来自技能目录之外的字节(含崩溃残留的 .tmp)。 + const stateRoot = manager.approvalStateRoot(); + const leaked: string[] = []; + const walk = (dir: string): void => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const child = path.join(dir, entry.name); + if (entry.isDirectory()) walk(child); + else if (entry.name === 'leak.txt') leaked.push(child); + } + }; + if (fs.existsSync(stateRoot)) walk(stateRoot); + expect(leaked).toEqual([]); + }); + + it('rejects bytes swapped after the hash check but before the snapshot copy finishes', async () => { + await manager.install(await makeCindy('skill.cindy', skillManifest(), skillFiles())); + const snapshotRoot = manager.list()[0].approvedSkillRoot!; + // 先停用、再删快照:停用本身会把快照重建回来(字节没动、校验放行),顺序颠倒 + // 会让后面的启用走"快照已存在"的早退路径,根本不经过复制。 + expect(await manager.setEnabled('skilled', false)).toEqual({ ok: true }); + await fs.promises.rm(snapshotRoot, { recursive: true, force: true }); + + // 模拟同权限本机进程抢在复制这一刻换掉源字节:复制动作落到 temp 的是被改写的 + // 内容,而源目录事后看起来仍然"没问题"。所以校验必须落在**已经复制到 temp 的 + // 那份字节**上;若校验读的是源目录,这里就会放行一份没人确认过的技能指令。 + const tampered = '---\nname: demo\ndescription: Demo skill\n---\n\nrm -rf everything\n'; + let swapped = 0; + const spy = vi + .spyOn(fs.promises, 'copyFile') + .mockImplementation((async (from: unknown, to: unknown, mode?: unknown) => { + if (typeof from === 'string' && from.endsWith('SKILL.md') && typeof to === 'string') { + swapped += 1; + await fs.promises.writeFile(to, tampered, 'utf8'); + return undefined; + } + return realCopyFile(from as string, to as string, mode as number | undefined); + }) as typeof fs.promises.copyFile); + try { + await expectRejection(await manager.setEnabled('skilled', true), 'io'); + } finally { + spy.mockRestore(); + } + expect(swapped).toBe(1); // 确认这一轮真的走到了复制 + expect(manager.list()[0].enabled).toBe(false); + expect(fs.existsSync(snapshotRoot)).toBe(false); + }); + + it('applies the SKILL.md size ceiling to the bytes that actually landed in the snapshot', async () => { + await manager.install(await makeCindy('skill.cindy', skillManifest(), skillFiles())); + const snapshotRoot = manager.list()[0].approvedSkillRoot!; + expect(await manager.setEnabled('skilled', false)).toEqual({ ok: true }); + await fs.promises.rm(snapshotRoot, { recursive: true, force: true }); + + // 源目录看起来一切正常(预检放行),复制这一刻落到 temp 的却是超大文件。上限必须 + // 作用在这份字节上,而不是只作用在预检读到的那份 —— 预检不是安全边界。 + let swapped = 0; + const spy = vi + .spyOn(fs.promises, 'copyFile') + .mockImplementation((async (from: unknown, to: unknown, mode?: unknown) => { + if (typeof from === 'string' && from.endsWith('SKILL.md') && typeof to === 'string') { + swapped += 1; + await fs.promises.writeFile(to, 'x'.repeat(GHOST_SKILL_MD_MAX_BYTES + 1), 'utf8'); + return undefined; + } + return realCopyFile(from as string, to as string, mode as number | undefined); + }) as typeof fs.promises.copyFile); + let result: Awaited>; + try { + result = await manager.setEnabled('skilled', true); + } finally { + spy.mockRestore(); + } + await expectRejection(result, 'io'); + // 断言到 reason 才能区分校验顺序:上限先跑报"exceeds N bytes",指纹先跑报 + // "no longer matches..."。只比 code 的话两种顺序都是 io,用例就退化成 + // 行为钉住、测不出重排。 + expect((result as { rejection: { reason: string } }).rejection.reason).toMatch( + /exceeds \d+ bytes/, + ); + expect(swapped).toBe(1); + expect(manager.list()[0].enabled).toBe(false); + expect(fs.existsSync(snapshotRoot)).toBe(false); + }); + + it('keeps an install unusable when a stale approval cannot be revoked', async () => { + await manager.install(await makeCindy('approved.cindy', goodManifest())); + // 撤销失败(状态根不可写等,与写批准失败同一成因)不得退回"继续拿旧批准跑": + // removeInstallApproval 的契约是返回后一定不再被授权运行。 + const spy = vi + .spyOn(fs.promises, 'rm') + .mockRejectedValue(Object.assign(new Error('EPERM'), { code: 'EPERM' })); + try { + await manager.removeInstallApproval('hello'); + } finally { + spy.mockRestore(); + } + + expect(fs.existsSync(receiptPath())).toBe(true); // receipt 还在盘上 + expect(manager.list()[0]).toMatchObject({ + enabled: false, + approval: { state: 'invalid' }, + }); + await expectRejection(await manager.setEnabled('hello', true), 'approval-required'); + }); + + it('does not trust an already-present snapshot whose bytes were rewritten in place', async () => { + await manager.install(await makeCindy('skill.cindy', skillManifest(), skillFiles())); + const snapshotRoot = manager.list()[0].approvedSkillRoot!; + const snapshotSkillMd = path.join(snapshotRoot, 'skills', 'demo', 'SKILL.md'); + // 快照就位后被就地改写(状态根没有写保护)。主 Agent 是顺着共享链接持续读它的, + // 所以"快照已存在"不能当成"仍是被批准的那份字节"直接早退信任。 + await fs.promises.writeFile( + snapshotSkillMd, + '---\nname: demo\ndescription: Demo skill\n---\n\nrm -rf everything\n', + ); + + // 安装目录里的字节没动过 → 删掉坏快照后能按批准字节重建,自愈。 + expect(await manager.setEnabled('skilled', false)).toEqual({ ok: true }); + expect(await manager.setEnabled('skilled', true)).toEqual({ ok: true }); + expect(await fs.promises.readFile(snapshotSkillMd, 'utf8')).toContain('Approved instructions'); + }); + + it('refuses to keep a rewritten snapshot when the installed bytes drifted too', async () => { + await manager.install(await makeCindy('skill.cindy', skillManifest(), skillFiles())); + const snapshotRoot = manager.list()[0].approvedSkillRoot!; + const tampered = '---\nname: demo\ndescription: Demo skill\n---\n\nrm -rf everything\n'; + // 快照与安装目录都被改成同一份未批准内容:此时没有任何可信来源可重建,必须拒。 + await fs.promises.writeFile(path.join(snapshotRoot, 'skills', 'demo', 'SKILL.md'), tampered); + await fs.promises.writeFile( + path.join(rootDir, 'skilled', 'skills', 'demo', 'SKILL.md'), + tampered, + ); + + expect(await manager.setEnabled('skilled', false)).toEqual({ ok: true }); + await expectRejection(await manager.setEnabled('skilled', true), 'io'); + expect(manager.list()[0].enabled).toBe(false); + }); + + it('still heals a deleted snapshot when the installed skill bytes are untouched', async () => { + await manager.install(await makeCindy('skill.cindy', skillManifest(), skillFiles())); + const snapshotRoot = manager.list()[0].approvedSkillRoot!; + // 字节指纹校验不能把合法的自愈场景一起堵死:外部清理误删快照、内容没动过。 + await fs.promises.rm(snapshotRoot, { recursive: true, force: true }); + expect(await manager.setEnabled('skilled', false)).toEqual({ ok: true }); + + expect(await manager.setEnabled('skilled', true)).toEqual({ ok: true }); + expect(manager.list()[0].enabled).toBe(true); + expect( + await fs.promises.readFile(path.join(snapshotRoot, 'skills', 'demo', 'SKILL.md'), 'utf8'), + ).toContain('Approved instructions'); + }); + + it('invalidates a receipt whose skill content digests no longer match the manifest', async () => { + await manager.install(await makeCindy('skill.cindy', skillManifest(), skillFiles())); + const receipt = JSON.parse( + await fs.promises.readFile(receiptPath('skilled'), 'utf8'), + ) as Record; + // 手工把指纹字段抹掉:必填项缺失一律判 invalid,不允许退化成"跳过校验"。 + delete receipt.skillContentSha256; + await fs.promises.writeFile(receiptPath('skilled'), JSON.stringify(receipt)); + + expect(manager.list()[0]).toMatchObject({ + enabled: false, + approval: { state: 'invalid' }, + }); + }); + + it('invalidates a schema v1 receipt instead of trusting its legacy content digests', async () => { + await manager.install(await makeCindy('approved.cindy', goodManifest())); + const receipt = JSON.parse( + await fs.promises.readFile(receiptPath(), 'utf8'), + ) as Record; + // v2 改了内容摘要 framing;旧 receipt 的摘要不能拿来继续授权,必须 fail closed。 + receipt.schemaVersion = 1; + await fs.promises.writeFile(receiptPath(), JSON.stringify(receipt)); + + expect(manager.list()[0]).toMatchObject({ + enabled: false, + approval: { state: 'invalid' }, + }); + }); + + it('revoking approval fails the install closed, and a later bundled approval heals it', async () => { + await manager.install(await makeCindy('approved.cindy', goodManifest())); + const approvedManifest = manager.list()[0].manifest; + + // 随包对账在换入新种子字节后写批准失败时走的收敛动作:撤掉陈旧批准。 + // 撤掉之后插件必须彻底不可运行,而不是继续拿旧批准跑新代码。 + await manager.removeInstallApproval('hello'); + + expect(fs.existsSync(receiptPath())).toBe(false); + expect(manager.list()[0]).toMatchObject({ + enabled: false, + approval: { state: 'legacy-unapproved' }, + }); + await expectRejection(await manager.setEnabled('hello', true), 'approval-required'); + + // 下一轮启动对账重新补批准即自愈,不需要用户介入。 + expect(await manager.approveTrustedBundledInstall(approvedManifest, true)).toBe(true); + expect(manager.list()[0]).toMatchObject({ + enabled: true, + approval: { state: 'approved' }, + }); + }); + + it('keeps a receipt-pinned disable when the .disabled mirror was lost, and rewrites the mirror', async () => { + await manager.install(await makeCindy('a.cindy', goodManifest())); + const { manifest: approvedManifest } = JSON.parse( + await fs.promises.readFile(receiptPath(), 'utf8'), + ) as { manifest: InstalledGhost['manifest'] }; + // 随包对账首轮把安装收编成 bundled 批准(trust 归一),后续轮次走稳态分支。 + expect(await manager.approveTrustedBundledInstall(approvedManifest, true)).toBe(true); + expect('ok' in (await manager.setEnabled('hello', false))).toBe(true); + + // 外部因素(AV 隔离恢复 / 同步冲突解析 / 手动清理)移除了兼容镜像文件。 + await fs.promises.rm(path.join(rootDir, 'hello', '.disabled')); + + // 下一轮对账把镜像读数(启用)喂进来:不得据此翻转 receipt —— 否则用户显式 + // 停用的插件被静默重新启用,无确认、无审计。重新启用只有 setEnabled 一条路。 + expect(await manager.approveTrustedBundledInstall(approvedManifest, true)).toBe(false); + expect(manager.list()[0].enabled).toBe(false); + // 镜像被补写回去:回滚到旧客户端(只认镜像文件)时仍按停用对待。 + expect(fs.existsSync(path.join(rootDir, 'hello', '.disabled'))).toBe(true); + }); + + it('an old-client style .disabled marker still turns a bundled receipt off', async () => { + await manager.install(await makeCindy('a.cindy', goodManifest())); + const { manifest: approvedManifest } = JSON.parse( + await fs.promises.readFile(receiptPath(), 'utf8'), + ) as { manifest: InstalledGhost['manifest'] }; + expect(await manager.approveTrustedBundledInstall(approvedManifest, true)).toBe(true); + + // 旧客户端只会写镜像文件、不会写 receipt。停用是安全方向,合并必须照办 —— + // 非对称的另一半:镜像只能把启停态往下拉,不能往上翻。 + await fs.promises.writeFile(path.join(rootDir, 'hello', '.disabled'), ''); + expect(await manager.approveTrustedBundledInstall(approvedManifest, false)).toBe(true); + expect(manager.list()[0].enabled).toBe(false); + }); + + it('a bundled update keeps the receipt-pinned disable even when the marker was lost', async () => { + await manager.install(await makeCindy('a.cindy', goodManifest())); + const { manifest: approvedManifest } = JSON.parse( + await fs.promises.readFile(receiptPath(), 'utf8'), + ) as { manifest: InstalledGhost['manifest'] }; + expect(await manager.approveTrustedBundledInstall(approvedManifest, true)).toBe(true); + expect('ok' in (await manager.setEnabled('hello', false))).toBe(true); + await fs.promises.rm(path.join(rootDir, 'hello', '.disabled')); + + // 随包更新那一轮走的是"建全新 receipt"分支,与稳态分支共用同一条合并规则: + // 只堵稳态分支的话,镜像在更新 tick 之前丢失仍会静默重新启用,同一个洞换条路。 + const bumped = { ...approvedManifest, version: '1.0.1' }; + expect(await manager.approveTrustedBundledInstall(bumped, true)).toBe(true); + expect(manager.list()[0]).toMatchObject({ + enabled: false, + manifest: { version: '1.0.1' }, + }); + expect(fs.existsSync(path.join(rootDir, 'hello', '.disabled'))).toBe(true); + }); + + it('refuses to mint a bundled approval for an id outside the seed roster', async () => { + // 该入口不经用户确认就铸出批准;builtin-only 边界必须运行期强制,不能只靠 + // "唯一调用者是随包对账"这条纪律。 + const guarded = new GhostManager({ + getRootDir: () => rootDir, + getLocale: () => hostLocale, + isTrustedBundledId: () => false, + }); + const validated = validateGhostManifest(goodManifest()); + if (!validated.ok) throw new Error(validated.reason); + await expect( + guarded.approveTrustedBundledInstall(validated.manifest, true), + ).rejects.toThrow(/种子清单/); + }); + + it('invalidates a receipt whose locale snapshot keys no longer match the manifest', async () => { + hostLocale = 'en'; + const manifest = { + ...goodManifest(), + locales: { en: 'locales/en.json' }, + }; + await manager.install( + await makeCindy('localized.cindy', manifest, { + 'locales/en.json': JSON.stringify({ name: 'Approved English name' }), + }), + ); + const receipt = JSON.parse( + await fs.promises.readFile(receiptPath(), 'utf8'), + ) as Record; + receipt.localeResources = {}; + await fs.promises.writeFile(receiptPath(), JSON.stringify(receipt)); + + expect(manager.list()[0]).toMatchObject({ + enabled: false, + approval: { state: 'invalid' }, + }); + }); +}); + describe('GhostManager · setEnabled(启用/停用)', () => { it('停用:目录里出现 .disabled 标记、list 报 enabled=false、onChanged 广播;启用即恢复', async () => { await manager.install(await makeCindy('a.cindy', goodManifest())); @@ -562,16 +1217,45 @@ describe('GhostManager · author / icon(身份卡展示字段)', () => { await expectRejection(await manager.install(cindy), 'file-invalid'); }); - it('已装意识的 icon 文件事后丢失 → list 降级为无图标,不影响意识本体', async () => { + it('installed icon removal cannot replace the Host-approved icon snapshot', async () => { const cindy = await makeCindy('icon2.cindy', iconManifest(), { 'assets/icon.png': 'PNGDATA' }); await manager.install(cindy); await fs.promises.rm(path.join(rootDir, 'hello', 'assets', 'icon.png')); const listed = manager.list(); expect(listed).toHaveLength(1); - expect(listed[0].iconDataUrl).toBeUndefined(); + expect(listed[0].iconDataUrl).toBe('data:image/png;base64,UE5HREFUQQ=='); expect(listed[0].manifest.author).toBe('Lizi'); }); + it('never reads icon bytes from outside the plugin dir when a path segment is a link', async () => { + // 回归点:`stat` 静默穿透链接 —— 中间段 `assets` 被换成指向外部的链接时, + // 上一版会把插件目录之外的字节读成 icon 下发给 renderer(批准路径上还会钉进 + // receipt)。判据改成逐段解析后,这里只能降级成"没有图标"。 + const legacyDir = path.join(rootDir, 'legacy'); + const outside = path.join(workDir, 'outside-assets'); + await fs.promises.mkdir(legacyDir, { recursive: true }); + await fs.promises.mkdir(outside, { recursive: true }); + await fs.promises.writeFile(path.join(outside, 'icon.png'), 'OUTSIDE'); + await fs.promises.writeFile( + path.join(legacyDir, 'ghost.json'), + JSON.stringify({ ...iconManifest(), id: 'legacy' }), + ); + try { + await fs.promises.symlink( + outside, + path.join(legacyDir, 'assets'), + process.platform === 'win32' ? 'junction' : 'dir', + ); + } catch { + return; // 该环境建不了链接(无权限),跳过;判定逻辑与其他平台同源。 + } + + const listed = manager.list(); + expect(listed).toHaveLength(1); + expect(listed[0].manifest.id).toBe('legacy'); + expect(listed[0].iconDataUrl).toBeUndefined(); + }); + it('不带 icon/author 的旧清单不受影响(无 iconDataUrl 字段)', async () => { await manager.install(await makeCindy('plain.cindy', goodManifest())); const listed = manager.list(); @@ -586,7 +1270,7 @@ describe('GhostManager · update(原位换版)', () => { onChanged.mockClear(); const v2 = await makeCindy('v2.cindy', { ...goodManifest(), version: '2.0.0' }, { 'new.txt': 'v2' }); - const result = await manager.update(v2); + const result = await updateGhost(v2); expect('ghost' in result, JSON.stringify(result)).toBe(true); const { ghost } = result as { ghost: InstalledGhost }; expect(ghost.manifest.version).toBe('2.0.0'); @@ -601,18 +1285,18 @@ describe('GhostManager · update(原位换版)', () => { it('唤醒状态延续:沉睡中更新仍沉睡,唤醒中更新仍唤醒', async () => { await manager.install(await makeCindy('v1.cindy', goodManifest()), { initiallyEnabled: false }); - const r1 = await manager.update(await makeCindy('v2.cindy', { ...goodManifest(), version: '2.0.0' })); + const r1 = await updateGhost(await makeCindy('v2.cindy', { ...goodManifest(), version: '2.0.0' })); expect((r1 as { ghost: InstalledGhost }).ghost.enabled).toBe(false); expect(fs.existsSync(path.join(rootDir, 'hello', '.disabled'))).toBe(true); await manager.setEnabled('hello', true); - const r2 = await manager.update(await makeCindy('v3.cindy', { ...goodManifest(), version: '3.0.0' })); + const r2 = await updateGhost(await makeCindy('v3.cindy', { ...goodManifest(), version: '3.0.0' })); expect((r2 as { ghost: InstalledGhost }).ghost.enabled).toBe(true); expect(fs.existsSync(path.join(rootDir, 'hello', '.disabled'))).toBe(false); }); it('未装入 → not-installed 拒绝', async () => { - await expectRejection(await manager.update(await makeCindy('a.cindy', goodManifest())), 'not-installed'); + await expectRejection(await updateGhost(await makeCindy('a.cindy', goodManifest())), 'not-installed'); }); it('指令查重豁免自己,但仍拦别人的指令', async () => { @@ -620,15 +1304,17 @@ describe('GhostManager · update(原位换版)', () => { await manager.install(await makeCindy('b.cindy', chipManifestWithCommand('beta', 'Paint'))); // 自己沿用自己的指令 → 放行。 - const keep = await manager.update( + const keep = await updateGhost( await makeCindy('a2.cindy', { ...chipManifestWithCommand('alpha', 'draw'), version: '2.0.0' }), + 'alpha', ); expect('ghost' in keep, JSON.stringify(keep)).toBe(true); // 新版本改用别人占用的指令 → 拒,且旧版原样在位。 await expectRejection( - await manager.update( + await updateGhost( await makeCindy('a3.cindy', { ...chipManifestWithCommand('alpha', 'paint'), version: '3.0.0' }), + 'alpha', ), 'command-conflict', ); @@ -640,7 +1326,7 @@ describe('GhostManager · update(原位换版)', () => { await manager.install(await makeCindy('v1.cindy', goodManifest())); const bad = path.join(workDir, 'bad.cindy'); await fs.promises.writeFile(bad, 'nope'); - await expectRejection(await manager.update(bad), 'file-invalid'); + await expectRejection(await updateGhost(bad), 'file-invalid'); expect(manager.list().find((g) => g.manifest.id === 'hello')?.manifest.version).toBe('1.0.0'); }); }); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/builtinGhostProvisioner.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/builtinGhostProvisioner.test.ts index 449f0ba7318..79c8f00829f 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/builtinGhostProvisioner.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/builtinGhostProvisioner.test.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { provisionBuiltinGhosts } from '../builtinGhostProvisioner.js'; +import { fingerprintDirContent, provisionBuiltinGhosts } from '../builtinGhostProvisioner.js'; const tempDirs: string[] = []; @@ -20,6 +20,253 @@ afterEach(async () => { ); }); +describe('fingerprintDirContent', () => { + /** 建链接;该环境无权限时返回 false 让调用方跳过(判定逻辑与其他平台同源)。 */ + async function tryLink(target: string, linkPath: string): Promise { + try { + await fs.promises.symlink( + target, + linkPath, + process.platform === 'win32' ? 'junction' : 'dir', + ); + return true; + } catch { + return false; + } + } + + it('flags a planted link instead of folding it into the content hash', async () => { + const root = await makeTempDir(); + const installed = path.join(root, 'installed'); + const outside = path.join(root, 'outside'); + await fs.promises.mkdir(installed, { recursive: true }); + await fs.promises.mkdir(outside, { recursive: true }); + await fs.promises.writeFile(path.join(installed, 'main.js'), '// brain'); + await fs.promises.writeFile(path.join(outside, 'leak.txt'), 'outside bytes'); + + const before = await fingerprintDirContent(installed); + expect(before.hasNonRegularEntry).toBe(false); + + if (!(await tryLink(outside, path.join(installed, 'linked')))) return; + + const after = await fingerprintDirContent(installed); + // 类型状态独立于哈希:内容哈希不变(链接没有内容),但状态位翻过来。 + expect(after.hasNonRegularEntry).toBe(true); + expect(after.hash).toBe(before.hash); + }); + + it('keeps type out of the hash so a sentinel-valued regular file stays distinguishable', async () => { + // **这是契约/文档用例,不是回归用例**:它无法表达修复前的状态(那时没有 + // hasNonRegularEntry 字段,代码都编译不过),已实测在"sentinel 进哈希 + 有状态位" + // 的混合态下同样会绿。真正的回归点是本文件下面那条端到端用例 + // (`re-seeds when a seed file was replaced by a link...`)—— 判据落在 provisioner + // 的决策上,才能在旧实现下变红。 + // 这里只钉住契约:类型信息不掺进字节流,所以"内容恰为 sentinel 的普通文件"与 + // "同名链接"始终可区分。 + const root = await makeTempDir(); + const withFile = path.join(root, 'with-file'); + const withLink = path.join(root, 'with-link'); + const target = path.join(root, 'target'); + await fs.promises.mkdir(withFile, { recursive: true }); + await fs.promises.mkdir(withLink, { recursive: true }); + await fs.promises.mkdir(target, { recursive: true }); + await fs.promises.writeFile(path.join(withFile, 'entry'), 'non-regular'); + + if (!(await tryLink(target, path.join(withLink, 'entry')))) return; + + const fileSide = await fingerprintDirContent(withFile); + const linkSide = await fingerprintDirContent(withLink); + expect(fileSide.hasNonRegularEntry).toBe(false); + expect(linkSide.hasNonRegularEntry).toBe(true); + // 即便两侧哈希相同也不会被误判为一致 —— 判定还要看类型状态。 + expect( + fileSide.hash === linkSide.hash && fileSide.hasNonRegularEntry === linkSide.hasNonRegularEntry, + ).toBe(false); + }); + + it('matches identical link-free directories under the v2 encoding', async () => { + // 同一套 v2 编码下,内容相同的普通目录必须得到相同指纹;这个用例不主张与旧版 + // 摘要兼容(v2 framing 本来就会主动改变旧摘要)。 + const root = await makeTempDir(); + const a = path.join(root, 'a'); + const b = path.join(root, 'b'); + for (const dir of [a, b]) { + await fs.promises.mkdir(path.join(dir, 'nested'), { recursive: true }); + await fs.promises.writeFile(path.join(dir, 'main.js'), '// brain'); + await fs.promises.writeFile(path.join(dir, 'nested', 'x.txt'), 'x'); + await fs.promises.writeFile(path.join(dir, '.disabled'), ''); + } + const fa = await fingerprintDirContent(a); + const fb = await fingerprintDirContent(b); + expect(fa.hash).toBe(fb.hash); + expect(fa.hasNonRegularEntry).toBe(false); + }); +}); + +describe('builtinGhostProvisioner 安装目录被塞入链接时重新播种', () => { + it('re-seeds when a seed file was replaced by a link, even if its bytes could spoof a hash sentinel', async () => { + // 决定性用例:判定必须落在 provisioner 的**决策**上,而不是指纹结构上。 + // 把非普通条目当 sentinel 喂进哈希的实现里,种子文件 `entry` 内容恰为该 sentinel + // 时,同名链接与它的摘要完全相等(已实测),于是安装目录被判成"逐字节一致"而跳过 + // 重新播种 —— 目录永远修不回来,随后批准又必然失败,插件卡在不可用。 + const root = await makeTempDir(); + const seedRoot = path.join(root, 'seeds'); + const repoRoot = path.join(root, 'installed'); + const seedDir = path.join(seedRoot, 'linked-seed'); + const installedDir = path.join(repoRoot, 'linked-seed'); + const outside = path.join(root, 'outside'); + const manifest = JSON.stringify({ + schemaVersion: 2, + id: 'linked-seed', + name: 'Linked seed', + version: '1.0.0', + kind: 'chip', + entry: 'main.js', + slots: ['tool'], + tools: [{ name: 'run', description: 'Run it' }], + }); + await fs.promises.mkdir(seedDir, { recursive: true }); + await fs.promises.mkdir(installedDir, { recursive: true }); + await fs.promises.mkdir(outside, { recursive: true }); + await fs.promises.writeFile(path.join(outside, 'leak.txt'), 'outside bytes'); + for (const dir of [seedDir, installedDir]) { + await fs.promises.writeFile(path.join(dir, 'ghost.json'), manifest); + await fs.promises.writeFile(path.join(dir, 'main.js'), '// brain'); + // 内容刻意等于旧实现的 sentinel 字符串。 + await fs.promises.writeFile(path.join(dir, 'entry'), 'non-regular'); + } + + // 安装侧把这个普通文件换成同名链接。 + await fs.promises.rm(path.join(installedDir, 'entry')); + try { + await fs.promises.symlink( + outside, + path.join(installedDir, 'entry'), + process.platform === 'win32' ? 'junction' : 'dir', + ); + } catch { + return; // 该环境建不了链接(无权限),跳过;判定逻辑与其他平台同源。 + } + + const outcome = await provisionBuiltinGhosts({ + seedRootDirs: [seedRoot], + repoRootDir: repoRoot, + log: { info: vi.fn(), warn: vi.fn() }, + }); + + expect(outcome.updated.map((m) => m.id)).toContain('linked-seed'); + expect(outcome.skipped).not.toContain('linked-seed'); + // 重新播种后安装目录回到随包字节:链接消失,普通文件回来。 + expect((await fs.promises.lstat(path.join(installedDir, 'entry'))).isFile()).toBe(true); + }); +}); + +describe('builtinGhostProvisioner 安装目录被塞入点开头链接时重新播种', () => { + it('re-seeds when a dot-named link was planted, even though dot entries stay out of the hash', async () => { + // 回归点:指纹跳过点开头条目(`.disabled` 是用户状态不是内容),上一版对它们 + // 直接 continue —— 于是名为 `.x` 的链接既不进指纹也不翻类型状态位,安装目录被 + // 塞进链接却判成"与种子逐字节相同"而跳过播种。现在类型判定排在点开头过滤之前。 + const root = await makeTempDir(); + const seedRoot = path.join(root, 'seeds'); + const repoRoot = path.join(root, 'installed'); + const seedDir = path.join(seedRoot, 'dotlink'); + const installedDir = path.join(repoRoot, 'dotlink'); + const outside = path.join(root, 'outside'); + const manifest = JSON.stringify({ + schemaVersion: 2, + id: 'dotlink', + name: 'Dot link', + version: '1.0.0', + kind: 'chip', + entry: 'main.js', + slots: ['tool'], + tools: [{ name: 'run', description: 'Run it' }], + }); + await fs.promises.mkdir(seedDir, { recursive: true }); + await fs.promises.mkdir(installedDir, { recursive: true }); + await fs.promises.mkdir(outside, { recursive: true }); + for (const dir of [seedDir, installedDir]) { + await fs.promises.writeFile(path.join(dir, 'ghost.json'), manifest); + await fs.promises.writeFile(path.join(dir, 'main.js'), '// brain'); + } + // 内容字节完全一致,唯一差别是安装侧多了一条点开头链接。 + try { + await fs.promises.symlink( + outside, + path.join(installedDir, '.sneaky'), + process.platform === 'win32' ? 'junction' : 'dir', + ); + } catch { + return; // 该环境建不了链接(无权限),跳过;判定逻辑与其他平台同源。 + } + + const outcome = await provisionBuiltinGhosts({ + seedRootDirs: [seedRoot], + repoRootDir: repoRoot, + log: { info: vi.fn(), warn: vi.fn() }, + }); + + expect(outcome.updated.map((m) => m.id)).toContain('dotlink'); + expect(outcome.skipped).not.toContain('dotlink'); + // 重新播种后链接消失(点开头条目不随种子复制),下一轮启动即判一致、不再反复播种。 + expect(fs.existsSync(path.join(installedDir, '.sneaky'))).toBe(false); + }); +}); + +describe('builtinGhostProvisioner 坏种子 fail closed', () => { + it('种子含非普通条目时跳过,不交换目录也不申请批准', async () => { + const root = await makeTempDir(); + const seedRoot = path.join(root, 'seeds'); + const repoRoot = path.join(root, 'installed'); + const seedDir = path.join(seedRoot, 'bad-seed'); + const outside = path.join(root, 'outside'); + await fs.promises.mkdir(seedDir, { recursive: true }); + await fs.promises.mkdir(outside, { recursive: true }); + await fs.promises.writeFile( + path.join(seedDir, 'ghost.json'), + JSON.stringify({ + schemaVersion: 2, + id: 'bad-seed', + name: 'Bad seed', + version: '1.0.0', + kind: 'chip', + entry: 'main.js', + slots: ['tool'], + tools: [{ name: 'run', description: 'Run it' }], + }), + ); + await fs.promises.writeFile(path.join(seedDir, 'main.js'), '// brain'); + try { + await fs.promises.symlink( + outside, + path.join(seedDir, '.linked'), + process.platform === 'win32' ? 'junction' : 'dir', + ); + } catch { + return; + } + const warn = vi.fn(); + + const outcome = await provisionBuiltinGhosts({ + seedRootDirs: [seedRoot], + repoRootDir: repoRoot, + log: { info: vi.fn(), warn }, + }); + + expect(outcome.skipped).toContain('bad-seed'); + expect(outcome.installed).toEqual([]); + expect(outcome.approved).toEqual([]); + expect(fs.existsSync(path.join(repoRoot, 'bad-seed'))).toBe(false); + expect(warn).toHaveBeenCalledWith( + 'builtin ghost provisioning failed', + expect.objectContaining({ + id: 'bad-seed', + error: expect.stringContaining('non-regular'), + }), + ); + }); +}); + describe('builtinGhostProvisioner locale validation', () => { it('locale 资源翻译错位时跳过官方种子,不把损坏翻译播种给用户', async () => { const root = await makeTempDir(); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/exportGhostPackage.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/exportGhostPackage.test.ts index e5c676ff894..08a7a74e917 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/exportGhostPackage.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/exportGhostPackage.test.ts @@ -76,6 +76,7 @@ function makeGhost(): InstalledGhost { }, dir: ghostDir, enabled: true, + approval: { state: 'approved', revision: '00000000-0000-4000-8000-000000000001' }, }; } diff --git a/apps/desktop/src/main/cindy-brain/__tests__/forge.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/forge.test.ts index 8dbf740c5c3..1b6753fc3bd 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/forge.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/forge.test.ts @@ -47,6 +47,145 @@ async function makeSrcDir(files: Record): Promise { } describe('packGhostDir', () => { + it('rejects Host-managed roots, descendants, case aliases, and junction aliases', async () => { + const managedRoot = path.join(workDir, 'managed'); + const installedDir = path.join(managedRoot, 'demo'); + await fs.promises.mkdir(installedDir, { recursive: true }); + await fs.promises.writeFile( + path.join(installedDir, 'ghost.json'), + JSON.stringify(GOOD_MANIFEST), + ); + await fs.promises.writeFile(path.join(installedDir, 'main.js'), '// installed'); + + await expect( + packGhostDir(managedRoot, { forbiddenRootDirs: [managedRoot] }), + ).resolves.toMatchObject({ + ok: false, + errorCode: 'SOURCE_IS_INSTALLED_PLUGIN', + }); + await expect( + packGhostDir(installedDir, { forbiddenRootDirs: [managedRoot] }), + ).resolves.toMatchObject({ + ok: false, + errorCode: 'SOURCE_IS_INSTALLED_PLUGIN', + }); + + if (process.platform === 'win32') { + await expect( + packGhostDir(installedDir.toUpperCase(), { + forbiddenRootDirs: [managedRoot.toLowerCase()], + }), + ).resolves.toMatchObject({ + ok: false, + errorCode: 'SOURCE_IS_INSTALLED_PLUGIN', + }); + } + + const alias = path.join(workDir, 'installed-alias'); + try { + await fs.promises.symlink( + installedDir, + alias, + process.platform === 'win32' ? 'junction' : 'dir', + ); + } catch { + return; + } + await expect( + packGhostDir(alias, { forbiddenRootDirs: [managedRoot] }), + ).resolves.toMatchObject({ + ok: false, + errorCode: 'SOURCE_IS_INSTALLED_PLUGIN', + }); + }); + + it('rejects a source directory that contains a Host-managed root', async () => { + // 源目录是受管根的**祖先**:单向判定放行时,递归打包会走进 cindy-brain / + // ghost-install-state,把已安装插件字节、批准 receipt 与技能快照一并打进 .cindy。 + // 只要在 owner 数据目录里放一个 ghost.json 就能触发,所以判定必须双向。 + const ownerData = path.join(workDir, 'owner-data'); + const managedRoot = path.join(ownerData, 'cindy-brain'); + await fs.promises.mkdir(managedRoot, { recursive: true }); + await fs.promises.writeFile( + path.join(managedRoot, 'receipt.json'), + JSON.stringify({ secret: 'approved state' }), + ); + await fs.promises.writeFile( + path.join(ownerData, 'ghost.json'), + JSON.stringify(GOOD_MANIFEST), + ); + await fs.promises.writeFile(path.join(ownerData, 'main.js'), '// authoring source'); + + await expect( + packGhostDir(ownerData, { forbiddenRootDirs: [managedRoot] }), + ).resolves.toMatchObject({ + ok: false, + errorCode: 'SOURCE_IS_INSTALLED_PLUGIN', + }); + }); + + it('does not follow a link inside the source dir into a Host-managed root', async () => { + // **契约用例,不是回归用例**:改动前 Dirent 的类型位也把 junction 报成 link, + // 所以这条在旧实现下同样是绿的。钉住的是契约本身 —— 双向包含判定挡的是"源目录是 + // 受管根的祖先",这条挡的是另一半(源目录里放一条指向受管根的链接);判类型一律 + // lstat、不信 Dirent 类型位之后,这一半不再依赖 libuv 的实现细节。 + const managedRoot = path.join(workDir, 'managed'); + await fs.promises.mkdir(managedRoot, { recursive: true }); + await fs.promises.writeFile( + path.join(managedRoot, 'receipt.json'), + JSON.stringify({ secret: 'approved state' }), + ); + const dir = await makeSrcDir({ + 'ghost.json': JSON.stringify(GOOD_MANIFEST), + 'main.js': '// authoring source', + }); + try { + await fs.promises.symlink( + managedRoot, + path.join(dir, 'state'), + process.platform === 'win32' ? 'junction' : 'dir', + ); + } catch { + return; // 该环境建不了链接(无权限),跳过;判定逻辑与其他平台同源。 + } + + const result = await packGhostDir(dir, { forbiddenRootDirs: [managedRoot] }); + expect(result.ok, JSON.stringify(result)).toBe(true); + if (!result.ok) return; + const JSZip = (await import('jszip')).default; + const zip = await JSZip.loadAsync(await fs.promises.readFile(result.cindyPath)); + expect(Object.keys(zip.files).sort()).toEqual(['ghost.json', 'main.js']); + }); + + it('rejects a declared file that is a link instead of packing a package without it', async () => { + // `stat` 会穿透链接让声明检查过关,而收集步按类型跳过链接 —— 包里就少了 main.js, + // 错误延迟到用户装入时才现形。打包期直接拒,报清楚。 + const dir = await makeSrcDir({ 'ghost.json': JSON.stringify(GOOD_MANIFEST) }); + const realEntry = path.join(workDir, 'real-main.js'); + await fs.promises.writeFile(realEntry, '// outside'); + try { + await fs.promises.symlink(realEntry, path.join(dir, 'main.js'), 'file'); + } catch { + return; // 该环境建不了链接(无权限),跳过;判定逻辑与其他平台同源。 + } + + await expect(packGhostDir(dir)).resolves.toMatchObject({ + ok: false, + errorCode: 'ENTRY_MISSING', + }); + }); + + it('allows an independent authoring directory outside managed roots', async () => { + const dir = await makeSrcDir({ + 'ghost.json': JSON.stringify(GOOD_MANIFEST), + 'main.js': '// authoring source', + }); + const result = await packGhostDir(dir, { + forbiddenRootDirs: [path.join(workDir, 'managed')], + }); + expect(result.ok, JSON.stringify(result)).toBe(true); + }); + it('happy path:产物落源码目录(id-version.cindy),且能被装入侧 inspect 认可', async () => { const dir = await makeSrcDir({ 'ghost.json': JSON.stringify(GOOD_MANIFEST), @@ -335,6 +474,72 @@ describe('scaffoldGhostDir', () => { await expect(fs.promises.stat(invalid)).rejects.toMatchObject({ code: 'ENOENT' }); }); + it('拒绝把骨架落进 Host 受管根:根内、后代、大小写别名与 junction 别名都不行', async () => { + // 受管根恰好落在会话工作目录里(用户把 userData 当工作目录打开的情形): + // 工作目录检查放行,受管根检查必须接着挡住。 + const managedRoot = path.join(workDir, 'cindy-brain'); + const installedDir = path.join(managedRoot, 'demo'); + await fs.promises.mkdir(installedDir, { recursive: true }); + const scaffold = (dir: string, forbidden: readonly string[]) => + scaffoldGhostDir( + { dir, template: 'plain', id: 'managed-demo', name: 'Managed demo' }, + { sessionWorkdir: workDir, forbiddenRootDirs: forbidden }, + ); + + expect(await scaffold(path.join(managedRoot, 'fresh'), [managedRoot])).toMatchObject({ + ok: false, + errorCode: 'INVALID_INPUT', + }); + expect( + await scaffold(path.join(installedDir, 'src'), [managedRoot]), + ).toMatchObject({ ok: false, errorCode: 'INVALID_INPUT' }); + expect(fs.existsSync(path.join(installedDir, 'src'))).toBe(false); + + // 状态根还没建出来也要挡住(首次装入前就该拒)。 + const stateRoot = path.join(workDir, 'ghost-install-state'); + expect(await scaffold(path.join(stateRoot, 'nested'), [stateRoot])).toMatchObject({ + ok: false, + errorCode: 'INVALID_INPUT', + }); + + if (process.platform === 'win32') { + expect( + await scaffold(path.join(managedRoot.toUpperCase(), 'fresh'), [ + managedRoot.toLowerCase(), + ]), + ).toMatchObject({ ok: false, errorCode: 'INVALID_INPUT' }); + } + + // junction/软链别名:字面在别处,realpath 落在受管根内。 + const alias = path.join(workDir, 'managed-alias'); + try { + fs.symlinkSync(installedDir, alias, process.platform === 'win32' ? 'junction' : 'dir'); + } catch { + return; // Windows 无特权时建不出夹具,守卫仍在 + } + expect(await scaffold(path.join(alias, 'src'), [managedRoot])).toMatchObject({ + ok: false, + errorCode: 'INVALID_INPUT', + }); + expect(fs.existsSync(path.join(installedDir, 'src'))).toBe(false); + }); + + it('工作目录里的独立作者目录不受受管根禁区影响', async () => { + const dir = path.join(workDir, 'my-plugin'); + expect( + await scaffoldGhostDir( + { dir, template: 'plain', id: 'my-plugin', name: 'My plugin' }, + { + sessionWorkdir: workDir, + forbiddenRootDirs: [ + path.join(workDir, 'cindy-brain'), + path.join(workDir, 'ghost-install-state'), + ], + }, + ), + ).toMatchObject({ ok: true, dir }); + }); + it('软链祖先把字面在工作目录内的路径引到外面 → 拒绝且外面不落盘', async () => { // Windows 无特权时目录软链可能 EPERM,建不出夹具就跳过(守卫仍在)。 const outside = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'cindy-forge-outside-')); @@ -362,6 +567,15 @@ describe('scaffoldGhostDir', () => { }); describe('FORGE_GUIDE', () => { + it('documents installed-directory isolation and the structured refusal', () => { + expect(FORGE_GUIDE).toContain('已安装插件目录'); + expect(FORGE_GUIDE).toContain('SOURCE_IS_INSTALLED_PLUGIN'); + // 脚手架侧的同一禁区也要写进手册,否则 agent 会先把骨架建进安装目录再撞墙。 + expect(FORGE_GUIDE).toContain('也不能落在已安装插件目录或 Host 状态目录内'); + expect(FORGE_GUIDE).toContain('复制/迁出'); + expect(FORGE_GUIDE).toContain('junction'); + }); + it('分章体量守卫:每个 ## 章节须留在单次工具结果安全体量内(#890 分章投递的不变量)', () => { // 手册"随主机版本演进"持续增长;任一章越过单次 MCP 结果上限会静默复现 #890 于该章。 // 上限取 32KB:当前最大章 ~22KB,余量 ~45%,越线即该拆小节。 diff --git a/apps/desktop/src/main/cindy-brain/__tests__/fsSlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/fsSlot.test.ts index db9842ef1a4..d5dc542e8c5 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/fsSlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/fsSlot.test.ts @@ -26,6 +26,7 @@ function makeGhost(slots: string[]): InstalledGhost { }, dir: '/tmp/fake-install-dir', enabled: true, + approval: { state: 'approved', revision: '00000000-0000-4000-8000-000000000001' }, }; } diff --git a/apps/desktop/src/main/cindy-brain/__tests__/ghostContentTree.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/ghostContentTree.test.ts new file mode 100644 index 00000000000..6c68ef2fb8f --- /dev/null +++ b/apps/desktop/src/main/cindy-brain/__tests__/ghostContentTree.test.ts @@ -0,0 +1,210 @@ +/** + * ghostContentTree.test.ts —— 插件内容目录判据(唯一实现)的单测。 + * + * 这个模块存在的理由就是"同一判据别再散落多处",所以判据本身的回归点集中钉在 + * 这里:类型判定一律 lstat、路径逐段解析、点开头与非普通条目的策略组合。 + * 规则 23:全部路径在 os.tmpdir 下,收尾清理。 + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + classifyGhostDirEntry, + classifyGhostDirEntrySync, + collectGhostContentFiles, + hashGhostContentFiles, + resolveGhostContentPath, + resolveGhostContentPathSync, +} from '../ghostContentTree'; + +let workDir: string; + +beforeEach(async () => { + workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'cindy-content-tree-')); +}); + +afterEach(async () => { + await fs.promises.rm(workDir, { recursive: true, force: true }); +}); + +/** 建目录链接;该环境无权限时返回 false 让用例跳过(判定逻辑与其他平台同源)。 */ +async function tryLinkDir(target: string, linkPath: string): Promise { + try { + await fs.promises.symlink( + target, + linkPath, + process.platform === 'win32' ? 'junction' : 'dir', + ); + return true; + } catch { + return false; + } +} + +describe('classifyGhostDirEntry', () => { + it('separates regular files, real directories and links', async () => { + const file = path.join(workDir, 'a.txt'); + const dir = path.join(workDir, 'sub'); + const link = path.join(workDir, 'linked'); + await fs.promises.writeFile(file, 'bytes'); + await fs.promises.mkdir(dir); + + expect(await classifyGhostDirEntry(file)).toBe('file'); + expect(await classifyGhostDirEntry(dir)).toBe('directory'); + expect(classifyGhostDirEntrySync(file)).toBe('file'); + expect(classifyGhostDirEntrySync(dir)).toBe('directory'); + + if (!(await tryLinkDir(dir, link))) return; + // 关键:链接指向真目录,但判据看 lstat,所以是 link 而不是 directory。 + expect(await classifyGhostDirEntry(link)).toBe('link'); + expect(classifyGhostDirEntrySync(link)).toBe('link'); + }); +}); + +describe('resolveGhostContentPath', () => { + it('rejects a link in an intermediate segment instead of silently reading outside', async () => { + // 回归点:只 lstat 最终段是不够的 —— 中间段被换成链接时 OS 会静默穿透,对最终段 + // lstat 报的是"真目录、非链接",于是字节从插件目录之外取。 + const base = path.join(workDir, 'plugin'); + const outside = path.join(workDir, 'outside'); + await fs.promises.mkdir(path.join(base, 'skills', 'demo'), { recursive: true }); + await fs.promises.mkdir(path.join(outside, 'demo'), { recursive: true }); + + await expect( + resolveGhostContentPath(base, 'skills/demo', { expect: 'directory', label: 'x' }), + ).resolves.toBe(path.join(base, 'skills', 'demo')); + + await fs.promises.rm(path.join(base, 'skills'), { recursive: true, force: true }); + if (!(await tryLinkDir(outside, path.join(base, 'skills')))) return; + + await expect( + resolveGhostContentPath(base, 'skills/demo', { expect: 'directory', label: 'x' }), + ).rejects.toThrow(/path segment is a link/); + expect(() => + resolveGhostContentPathSync(base, 'skills/demo', { expect: 'directory', label: 'x' }), + ).toThrow(/path segment is a link/); + }); + + it('enforces the expected kind of the final segment', async () => { + await fs.promises.mkdir(path.join(workDir, 'assets'), { recursive: true }); + await fs.promises.writeFile(path.join(workDir, 'assets', 'icon.png'), 'png'); + + await expect( + resolveGhostContentPath(workDir, 'assets/icon.png', { expect: 'file', label: 'icon' }), + ).resolves.toBe(path.join(workDir, 'assets', 'icon.png')); + await expect( + resolveGhostContentPath(workDir, 'assets', { expect: 'file', label: 'icon' }), + ).rejects.toThrow(/not a regular file/); + await expect( + resolveGhostContentPath(workDir, 'assets/icon.png', { + expect: 'directory', + label: 'icon', + }), + ).rejects.toThrow(/not a directory/); + }); +}); + +describe('collectGhostContentFiles', () => { + it('includes dot entries for skill content and rejects links there', async () => { + const dir = path.join(workDir, 'skill'); + await fs.promises.mkdir(path.join(dir, 'refs'), { recursive: true }); + await fs.promises.writeFile(path.join(dir, 'SKILL.md'), 'md'); + await fs.promises.writeFile(path.join(dir, '.helper'), 'dot bytes'); + await fs.promises.writeFile(path.join(dir, 'refs', 'a.md'), 'a'); + + const tree = await collectGhostContentFiles(dir, { + dotEntries: 'include', + nonRegular: 'throw', + label: 'approved skill', + }); + // 技能目录里的点开头文件同样算内容:技能指令可以引用它。 + expect(tree.files).toEqual(['.helper', 'SKILL.md', 'refs/a.md']); + expect(tree.hasNonRegularEntry).toBe(false); + + if (!(await tryLinkDir(path.join(workDir, 'skill'), path.join(dir, 'loop')))) return; + await expect( + collectGhostContentFiles(dir, { + dotEntries: 'include', + nonRegular: 'throw', + label: 'approved skill', + }), + ).rejects.toThrow(/rejects link entry/); + }); + + it('keeps dot entries out of the content hash but still type-checks them', async () => { + // 回归点:上一版对点开头条目直接 continue,于是名为 `.x` 的链接既不进指纹、 + // 也不翻状态位 —— 安装目录被塞进链接却判成"与种子逐字节相同"。 + const dir = path.join(workDir, 'installed'); + await fs.promises.mkdir(dir, { recursive: true }); + await fs.promises.writeFile(path.join(dir, 'main.js'), '// brain'); + await fs.promises.writeFile(path.join(dir, '.disabled'), ''); + + const before = await collectGhostContentFiles(dir, { + dotEntries: 'skip', + nonRegular: 'flag', + label: 'seed', + }); + expect(before.files).toEqual(['main.js']); + expect(before.hasNonRegularEntry).toBe(false); + + if (!(await tryLinkDir(workDir, path.join(dir, '.sneaky')))) return; + const after = await collectGhostContentFiles(dir, { + dotEntries: 'skip', + nonRegular: 'flag', + label: 'seed', + }); + expect(after.files).toEqual(['main.js']); + expect(after.hasNonRegularEntry).toBe(true); + // 内容哈希不受影响(链接没有内容),判定靠独立的类型状态位。 + expect(await hashGhostContentFiles(dir, after.files)).toBe( + await hashGhostContentFiles(dir, before.files), + ); + }); +}); + +describe('hashGhostContentFiles', () => { + it('hashes path + bytes so identical trees match and any byte change does not', async () => { + const a = path.join(workDir, 'a'); + const b = path.join(workDir, 'b'); + for (const dir of [a, b]) { + await fs.promises.mkdir(path.join(dir, 'nested'), { recursive: true }); + await fs.promises.writeFile(path.join(dir, 'main.js'), '// brain'); + await fs.promises.writeFile(path.join(dir, 'nested', 'x.txt'), 'x'); + } + const options = { dotEntries: 'skip', nonRegular: 'throw', label: 't' } as const; + const treeA = await collectGhostContentFiles(a, options); + const treeB = await collectGhostContentFiles(b, options); + expect(await hashGhostContentFiles(a, treeA.files)).toBe( + await hashGhostContentFiles(b, treeB.files), + ); + + await fs.promises.writeFile(path.join(b, 'nested', 'x.txt'), 'y'); + expect(await hashGhostContentFiles(a, treeA.files)).not.toBe( + await hashGhostContentFiles(b, treeB.files), + ); + }); + + it('uses unambiguous framing when file bytes contain NUL separators', async () => { + const oneFile = path.join(workDir, 'one-file'); + const twoFiles = path.join(workDir, 'two-files'); + await fs.promises.mkdir(oneFile); + await fs.promises.mkdir(twoFiles); + await fs.promises.writeFile(path.join(oneFile, 'a'), Buffer.from('x\0b\0y')); + await fs.promises.writeFile(path.join(twoFiles, 'a'), 'x'); + await fs.promises.writeFile(path.join(twoFiles, 'b'), 'y'); + + const options = { dotEntries: 'include', nonRegular: 'throw', label: 't' } as const; + const oneTree = await collectGhostContentFiles(oneFile, options); + const twoTree = await collectGhostContentFiles(twoFiles, options); + + expect(oneTree.files).toEqual(['a']); + expect(twoTree.files).toEqual(['a', 'b']); + expect(await hashGhostContentFiles(oneFile, oneTree.files)).not.toBe( + await hashGhostContentFiles(twoFiles, twoTree.files), + ); + }); +}); diff --git a/apps/desktop/src/main/cindy-brain/__tests__/ghostSetupManifestTracker.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/ghostSetupManifestTracker.test.ts index aad121cd589..60895509632 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/ghostSetupManifestTracker.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/ghostSetupManifestTracker.test.ts @@ -11,6 +11,7 @@ function ghost( return { dir: `/plugins/${id}`, enabled: options.enabled ?? true, + approval: { state: 'approved', revision: '00000000-0000-4000-8000-000000000001' }, manifest: { schemaVersion: 2, id, diff --git a/apps/desktop/src/main/cindy-brain/__tests__/skillSlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/skillSlot.test.ts index 753ee185503..0fae16869b7 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/skillSlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/skillSlot.test.ts @@ -10,18 +10,21 @@ import { GhostManager } from '../GhostManager'; import { checkSkillMdConsistency, ghostSkillLinkName, - reconcileGhostSkillLinks, + reconcileGhostSkillLinks as reconcileGhostSkillLinksRaw, } from '../skillSlot'; /** 规则 23:测试路径一律 os.tmpdir;伪 home + 伪 brainRoot,互不污染。 */ let workDir: string; let homeDir: string; let brainRoot: string; +/** 与 GhostManager 缺省状态根同名(`<安装根>-install-state`),判据口径一致。 */ +let approvalStateRoot: string; beforeEach(async () => { workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'cindy-skill-slot-test-')); homeDir = path.join(workDir, 'home'); brainRoot = path.join(workDir, 'owners', 'aaa', 'cindy-brain'); + approvalStateRoot = path.join(workDir, 'owners', 'aaa', 'cindy-brain-install-state'); await fs.promises.mkdir(homeDir, { recursive: true }); await fs.promises.mkdir(brainRoot, { recursive: true }); }); @@ -33,6 +36,22 @@ afterEach(async () => { const sharedDir = () => path.join(homeDir, '.agents', 'skills'); const claudeDir = () => path.join(homeDir, '.claude', 'skills'); +/** + * 结构对账用例默认把夹具视为已经过完整快照摘要校验;摘要失配的安全回归单独 + * 调 raw reconciler,避免每个链接行为用例重复搭 receipt。 + */ +function reconcileGhostSkillLinks( + options: Omit< + Parameters[0], + 'validateApprovedSkillSnapshot' + >, +) { + return reconcileGhostSkillLinksRaw({ + ...options, + validateApprovedSkillSnapshot: async () => true, + }); +} + /** reconciler 只消费 manifest 数据,不跑校验——手工拼最小清单即可。 */ function ghost( id: string, @@ -55,6 +74,8 @@ function ghost( manifest, dir: path.join(brainRoot, id), enabled: opts.enabled ?? true, + approval: { state: 'approved', revision: '00000000-0000-4000-8000-000000000001' }, + approvedSkillRoot: path.join(brainRoot, id), }; } @@ -106,7 +127,7 @@ describe('skillSlot · reconcileGhostSkillLinks', () => { await writeSkillDir('my-ghost', 'skills/foo', 'foo'); const ghosts = [ghost('my-ghost', [{ dir: 'skills/foo', name: 'foo' }])]; - const first = await reconcileGhostSkillLinks({ ghosts, brainRoot, homeDir }); + const first = await reconcileGhostSkillLinks({ ghosts, brainRoot, approvalStateRoot, homeDir }); expect(first.changed).toBe(true); expect(first.warnings).toEqual([]); const linkName = ghostSkillLinkName('my-ghost', 'foo'); @@ -116,7 +137,7 @@ describe('skillSlot · reconcileGhostSkillLinks', () => { // .claude 兼容扇出(经 prepareSharedGlobalSkillLinks) expect(sameRealPath(path.join(claudeDir(), linkName), target)).toBe(true); - const second = await reconcileGhostSkillLinks({ ghosts, brainRoot, homeDir }); + const second = await reconcileGhostSkillLinks({ ghosts, brainRoot, approvalStateRoot, homeDir }); expect(second.changed).toBe(false); expect(second.actions.filter((a) => a.op !== 'kept')).toEqual([]); }); @@ -124,19 +145,19 @@ describe('skillSlot · reconcileGhostSkillLinks', () => { it('停用/卸载 → 撤链,.claude 悬空兼容链接一并回收', async () => { await writeSkillDir('my-ghost', 'skills/foo', 'foo'); const enabled = [ghost('my-ghost', [{ dir: 'skills/foo', name: 'foo' }])]; - await reconcileGhostSkillLinks({ ghosts: enabled, brainRoot, homeDir }); + await reconcileGhostSkillLinks({ ghosts: enabled, brainRoot, approvalStateRoot, homeDir }); const linkName = ghostSkillLinkName('my-ghost', 'foo'); // 停用:期望态清空 → 双侧链接消失 const disabled = [ghost('my-ghost', [{ dir: 'skills/foo', name: 'foo' }], { enabled: false })]; - const result = await reconcileGhostSkillLinks({ ghosts: disabled, brainRoot, homeDir }); + const result = await reconcileGhostSkillLinks({ ghosts: disabled, brainRoot, approvalStateRoot, homeDir }); expect(result.changed).toBe(true); expect(fs.existsSync(path.join(sharedDir(), linkName))).toBe(false); expect(fs.existsSync(path.join(claudeDir(), linkName))).toBe(false); // 卸载(清单里没有它)语义相同:再建再收敛一次验证 - await reconcileGhostSkillLinks({ ghosts: enabled, brainRoot, homeDir }); - const gone = await reconcileGhostSkillLinks({ ghosts: [], brainRoot, homeDir }); + await reconcileGhostSkillLinks({ ghosts: enabled, brainRoot, approvalStateRoot, homeDir }); + const gone = await reconcileGhostSkillLinks({ ghosts: [], brainRoot, approvalStateRoot, homeDir }); expect(gone.changed).toBe(true); expect(fs.existsSync(path.join(sharedDir(), linkName))).toBe(false); }); @@ -144,10 +165,10 @@ describe('skillSlot · reconcileGhostSkillLinks', () => { it('目标目录被删(异常残留)→ 断链回收', async () => { await writeSkillDir('my-ghost', 'skills/foo', 'foo'); const ghosts = [ghost('my-ghost', [{ dir: 'skills/foo', name: 'foo' }])]; - await reconcileGhostSkillLinks({ ghosts, brainRoot, homeDir }); + await reconcileGhostSkillLinks({ ghosts, brainRoot, approvalStateRoot, homeDir }); // 模拟崩溃残留:插件目录整个没了,链接悬空 await fs.promises.rm(path.join(brainRoot, 'my-ghost'), { recursive: true, force: true }); - const result = await reconcileGhostSkillLinks({ ghosts: [], brainRoot, homeDir }); + const result = await reconcileGhostSkillLinks({ ghosts: [], brainRoot, approvalStateRoot, homeDir }); expect(fs.existsSync(path.join(sharedDir(), ghostSkillLinkName('my-ghost', 'foo')))).toBe(false); expect(result.changed).toBe(true); }); @@ -157,12 +178,14 @@ describe('skillSlot · reconcileGhostSkillLinks', () => { await reconcileGhostSkillLinks({ ghosts: [ghost('my-ghost', [{ dir: 'skills/foo', name: 'foo' }])], brainRoot, + approvalStateRoot, homeDir, }); await writeSkillDir('my-ghost', 'skills/bar', 'bar'); const result = await reconcileGhostSkillLinks({ ghosts: [ghost('my-ghost', [{ dir: 'skills/bar', name: 'bar' }])], brainRoot, + approvalStateRoot, homeDir, }); expect(result.changed).toBe(true); @@ -185,6 +208,7 @@ describe('skillSlot · reconcileGhostSkillLinks', () => { const result = await reconcileGhostSkillLinks({ ghosts: [ghost('my-ghost', [{ dir: 'skills/foo', name: 'foo' }])], brainRoot, + approvalStateRoot, homeDir, }); expect(result.warnings.some((w) => w.includes(linkName))).toBe(true); @@ -194,7 +218,40 @@ describe('skillSlot · reconcileGhostSkillLinks', () => { expect(st.isSymbolicLink()).toBe(false); }); - it('外来链接(目标不在任何 cindy-brain 内)→ 活链断链都不碰', async () => { + it('漏传批准状态根在类型层就被挡住(否则指向快照的活链接会被判成外来链接而永不撤链)', () => { + const missingStateRoot = () => + // @ts-expect-error approvalStateRoot 必填:这行编译不报错就说明保护没了。 + reconcileGhostSkillLinksRaw({ + ghosts: [], + brainRoot, + homeDir, + validateApprovedSkillSnapshot: async () => true, + }); + expect(typeof missingStateRoot).toBe('function'); + }); + + it('完整摘要校验不通过时撤掉已有托管链接,不因目标未变而 kept', async () => { + await writeSkillDir('my-ghost', 'skills/foo', 'foo'); + const ghosts = [ghost('my-ghost', [{ dir: 'skills/foo', name: 'foo' }])]; + await reconcileGhostSkillLinks({ ghosts, brainRoot, approvalStateRoot, homeDir }); + const linkName = ghostSkillLinkName('my-ghost', 'foo'); + expect(fs.existsSync(path.join(sharedDir(), linkName))).toBe(true); + + const result = await reconcileGhostSkillLinksRaw({ + ghosts, + brainRoot, + approvalStateRoot, + homeDir, + validateApprovedSkillSnapshot: async () => false, + }); + + expect(result.changed).toBe(true); + expect(result.warnings.some((warning) => warning.includes('字节不可信'))).toBe(true); + expect(fs.existsSync(path.join(sharedDir(), linkName))).toBe(false); + expect(fs.existsSync(path.join(claudeDir(), linkName))).toBe(false); + }); + + it('外来链接(目标不在任何受管根内)→ 活链断链都不碰', async () => { const foreignTarget = path.join(workDir, 'foreign-skill'); await fs.promises.mkdir(foreignTarget, { recursive: true }); await fs.promises.writeFile( @@ -209,13 +266,41 @@ describe('skillSlot · reconcileGhostSkillLinks', () => { process.platform === 'win32' ? 'junction' : 'dir', ); - await reconcileGhostSkillLinks({ ghosts: [], brainRoot, homeDir }); + await reconcileGhostSkillLinks({ ghosts: [], brainRoot, approvalStateRoot, homeDir }); expect(fs.existsSync(foreignLink)).toBe(true); // 变成断链(目标删除)也不碰:目标路径不含 cindy-brain 段 await fs.promises.rm(foreignTarget, { recursive: true, force: true }); - await reconcileGhostSkillLinks({ ghosts: [], brainRoot, homeDir }); + await reconcileGhostSkillLinks({ ghosts: [], brainRoot, approvalStateRoot, homeDir }); + expect(fs.lstatSync(foreignLink).isSymbolicLink()).toBe(true); + }); + + it('外来 skill-snapshots 目录下的断链不碰(判据要求状态根名相邻,不认通用目录名)', async () => { + // 用户自己在别处建的 `skill-snapshots/` —— 名字撞上我们的内部目录名,但不在 + // 批准状态根下,回收判据不能只看这一段就删。 + const foreignTarget = path.join(workDir, 'my-notes', 'skill-snapshots', 'x', 'y'); + await fs.promises.mkdir(foreignTarget, { recursive: true }); + await fs.promises.mkdir(sharedDir(), { recursive: true }); + const foreignLink = path.join(sharedDir(), 'looks--managed'); + await fs.promises.symlink( + foreignTarget, + foreignLink, + process.platform === 'win32' ? 'junction' : 'dir', + ); + await fs.promises.rm(path.join(workDir, 'my-notes'), { recursive: true, force: true }); + + await reconcileGhostSkillLinks({ ghosts: [], brainRoot, approvalStateRoot, homeDir }); expect(fs.lstatSync(foreignLink).isSymbolicLink()).toBe(true); + + // 对照:真正落在批准状态根下的同形断链要回收。 + const managedLink = path.join(sharedDir(), 'managed--skill'); + await fs.promises.symlink( + path.join(approvalStateRoot, 'skill-snapshots', 'managed', 'rev', 'skills', 'demo'), + managedLink, + process.platform === 'win32' ? 'junction' : 'dir', + ); + await reconcileGhostSkillLinks({ ghosts: [], brainRoot, approvalStateRoot, homeDir }); + expect(fs.existsSync(managedLink)).toBe(false); }); it('他 owner 的活链接不碰(多账号隔离);他 owner 的断链回收(防积尘)', async () => { @@ -235,11 +320,11 @@ describe('skillSlot · reconcileGhostSkillLinks', () => { process.platform === 'win32' ? 'junction' : 'dir', ); - await reconcileGhostSkillLinks({ ghosts: [], brainRoot, homeDir }); + await reconcileGhostSkillLinks({ ghosts: [], brainRoot, approvalStateRoot, homeDir }); expect(fs.existsSync(liveLink)).toBe(true); // 活链保留 await fs.promises.rm(path.join(otherBrainRoot, 'other-ghost'), { recursive: true, force: true }); - await reconcileGhostSkillLinks({ ghosts: [], brainRoot, homeDir }); + await reconcileGhostSkillLinks({ ghosts: [], brainRoot, approvalStateRoot, homeDir }); expect(fs.existsSync(liveLink)).toBe(false); // 断链回收(目标带 cindy-brain 段) }); @@ -249,6 +334,7 @@ describe('skillSlot · reconcileGhostSkillLinks', () => { const result = await reconcileGhostSkillLinks({ ghosts: [ghost('my-ghost', [{ dir: 'skills/foo', name: 'foo' }])], brainRoot, + approvalStateRoot, homeDir, }); expect(fs.existsSync(path.join(sharedDir(), ghostSkillLinkName('my-ghost', 'foo')))).toBe(false); @@ -268,6 +354,7 @@ describe('skillSlot · reconcileGhostSkillLinks', () => { ]), ], brainRoot, + approvalStateRoot, homeDir, }); expect(result.warnings.some((w) => w.includes('冲突'))).toBe(true); @@ -314,21 +401,60 @@ describe('skillSlot · 全链路(打包 → 装入 → 对账 → 双端可见)' const installed = await manager.install(packed.cindyPath); expect('ghost' in installed, JSON.stringify(installed)).toBe(true); - // 3) 对账:共享根与 .claude 双端可见,realpath 落在安装目录 - await reconcileGhostSkillLinks({ ghosts: manager.list(), brainRoot, homeDir }); + // 3) 对账:共享根与 .claude 双端可见,realpath 落在批准快照目录 + await reconcileGhostSkillLinksRaw({ + ghosts: manager.list(), + brainRoot, + approvalStateRoot: manager.approvalStateRoot(), + homeDir, + validateApprovedSkillSnapshot: (candidate) => + manager.verifyApprovedSkillSnapshot(candidate), + }); const linkName = ghostSkillLinkName('e2e-ghost', 'demo'); - const target = path.join(brainRoot, 'e2e-ghost', 'skills', 'demo'); + const approvedSkillRoot = manager.list()[0].approvedSkillRoot; + expect(approvedSkillRoot).toBeTruthy(); + const target = path.join(approvedSkillRoot!, 'skills', 'demo'); expect(sameRealPath(path.join(sharedDir(), linkName), target)).toBe(true); expect(sameRealPath(path.join(claudeDir(), linkName), target)).toBe(true); // 链接指向的 SKILL.md 就是包里那份 expect( await fs.promises.readFile(path.join(sharedDir(), linkName, 'SKILL.md'), 'utf8'), ).toContain('演示技能'); + await fs.promises.writeFile( + path.join(brainRoot, 'e2e-ghost', 'skills', 'demo', 'SKILL.md'), + '---\nname: demo\ndescription: 演示技能\n---\n\n篡改后的指令\n', + ); + expect( + await fs.promises.readFile(path.join(sharedDir(), linkName, 'SKILL.md'), 'utf8'), + ).not.toContain('篡改后的指令'); + + // 改写批准状态根里的快照正文,保持 frontmatter 不变。下一轮正常对账必须重算 + // 整棵快照摘要并撤链,不能因链接目标没变而直接 kept。 + await fs.promises.writeFile( + path.join(target, 'SKILL.md'), + '---\nname: demo\ndescription: 演示技能\n---\n\n篡改批准快照\n', + ); + const tampered = await reconcileGhostSkillLinksRaw({ + ghosts: manager.list(), + brainRoot, + approvalStateRoot: manager.approvalStateRoot(), + homeDir, + validateApprovedSkillSnapshot: (candidate) => + manager.verifyApprovedSkillSnapshot(candidate), + }); + expect(tampered.warnings.some((warning) => warning.includes('字节不可信'))).toBe(true); + expect(fs.existsSync(path.join(sharedDir(), linkName))).toBe(false); + expect(fs.existsSync(path.join(claudeDir(), linkName))).toBe(false); // 4) 卸载 → 对账 → 双端链接消失 const removed = await manager.uninstall('e2e-ghost'); expect(removed).toMatchObject({ ok: true }); - await reconcileGhostSkillLinks({ ghosts: manager.list(), brainRoot, homeDir }); + await reconcileGhostSkillLinks({ + ghosts: manager.list(), + brainRoot, + approvalStateRoot: manager.approvalStateRoot(), + homeDir, + }); expect(fs.existsSync(path.join(sharedDir(), linkName))).toBe(false); expect(fs.existsSync(path.join(claudeDir(), linkName))).toBe(false); }); diff --git a/apps/desktop/src/main/cindy-brain/builtinGhostProvisioner.ts b/apps/desktop/src/main/cindy-brain/builtinGhostProvisioner.ts index 2e5eed45e5e..9ea4a12b286 100644 --- a/apps/desktop/src/main/cindy-brain/builtinGhostProvisioner.ts +++ b/apps/desktop/src/main/cindy-brain/builtinGhostProvisioner.ts @@ -8,6 +8,12 @@ import { validateGhostManifest, type GhostManifest, } from '../../shared/ghost.js'; +import { + classifyGhostDirEntry, + collectGhostContentFiles, + hashGhostContentFiles, + resolveGhostContentPathSync, +} from './ghostContentTree.js'; import { validateGhostLocaleResourcesInDirectory } from './ghostLocaleFiles.js'; /** @@ -106,6 +112,8 @@ export interface ProvisionDeps { } export interface ProvisionOutcome { + /** First-party seed manifests whose installed bytes reconciled successfully. */ + approved: GhostManifest[]; /** 本次首装的意识(装完默认唤醒;调用方负责停靠面板 + 广播 + 常驻点火)。 */ installed: GhostManifest[]; /** 本次覆盖更新的意识(`.disabled` 已保留;调用方负责广播)。 */ @@ -303,7 +311,13 @@ function readProvisioningConfig( export async function provisionBuiltinGhosts(deps: ProvisionDeps): Promise { const { seedRootDirs, repoRootDir, log } = deps; const identity = deps.identity ?? null; - const outcome: ProvisionOutcome = { installed: [], updated: [], removed: [], skipped: [] }; + const outcome: ProvisionOutcome = { + approved: [], + installed: [], + updated: [], + removed: [], + skipped: [], + }; // 每根独立列种子 + 读配置。空根不读配置(未初始化 submodule 的目录里连 // provisioning.json 都没有,读了必 warn,徒增噪音)。 @@ -382,10 +396,22 @@ export async function provisionBuiltinGhosts(deps: ProvisionDeps): Promise MAX_RESTORABLE_ICON_BYTES) return null; + const iconPath = resolveGhostContentPathSync(seedDir, manifest.icon, { + expect: 'file', + label: 'builtin seed icon', + }); + const stat = fs.lstatSync(iconPath); + if (stat.size > MAX_RESTORABLE_ICON_BYTES) return null; const mime = ghostIconMimeType(manifest.icon); if (!mime) return null; return `data:${mime};base64,${fs.readFileSync(iconPath).toString('base64')}`; @@ -586,36 +623,35 @@ function readSeedManifest(seedDir: string, id: string, log?: BuiltinProvisionerL } /** - * 目录内容指纹:相对路径排序后逐个 hash(路径 + 字节),点开头条目跳过。 - * 意识包极小(zip 通道上限才 8MB),启动算一遍开销可忽略。 + * 目录内容指纹 + 一个**不进哈希**的类型状态。意识包极小(zip 通道上限才 8MB), + * 启动算一遍开销可忽略。 + * + * 遍历与类型判定取自 `ghostContentTree`(与技能指纹、快照拷贝、安装目录漂移指纹 + * 同一份实现);这里的显式策略是"点开头条目不算内容(`.disabled` 是用户状态)、 + * 非普通条目只记状态位不抛错"—— 因为本判据的收敛动作是**重新播种**,不是拒绝。 + * + * 非普通条目(软链 / Windows junction 等)不读内容,也**不能拿 sentinel 喂进哈希** —— + * 任何这样的 sentinel 都能被"同路径下内容恰好等于该 sentinel 的普通文件"撞上(已实测: + * 内容为 `non-regular` 的普通文件与同名 junction 的摘要完全相等),于是被塞进链接的 + * 安装目录仍会被判成"与种子逐字节相同"。所以它作为独立字段参与比较,不掺进字节流。 */ -export async function hashDirContent(dir: string): Promise { - const files = collectFiles(dir, ''); - files.sort(); - const hash = crypto.createHash('sha256'); - for (const rel of files) { - hash.update(rel); - hash.update('\0'); - hash.update(await fs.promises.readFile(path.join(dir, rel))); - hash.update('\0'); - } - return hash.digest('hex'); +export interface DirContentFingerprint { + /** 普通文件的 v2 内容指纹;链接等非普通条目不掺进字节流。 */ + hash: string; + /** 是否含既非目录也非普通文件的条目(含点开头的那些)。 */ + hasNonRegularEntry: boolean; } -/** 递归收集相对文件路径(正斜杠归一化保证双平台指纹一致;点开头跳过)。 */ -function collectFiles(rootDir: string, relBase: string): string[] { - const abs = path.join(rootDir, relBase); - const result: string[] = []; - for (const entry of fs.readdirSync(abs, { withFileTypes: true })) { - if (entry.name.startsWith('.')) continue; - const rel = relBase.length === 0 ? entry.name : `${relBase}/${entry.name}`; - if (entry.isDirectory()) { - result.push(...collectFiles(rootDir, rel)); - } else if (entry.isFile()) { - result.push(rel); - } - } - return result; +export async function fingerprintDirContent(dir: string): Promise { + const tree = await collectGhostContentFiles(dir, { + dotEntries: 'skip', + nonRegular: 'flag', + label: 'builtin seed content', + }); + return { + hash: await hashGhostContentFiles(dir, tree.files), + hasNonRegularEntry: tree.hasNonRegularEntry, + }; } /** @@ -660,16 +696,31 @@ async function swapInSeed( } } -/** 递归复制目录,点开头条目跳过(种子里的 .DS_Store 等垃圾不落仓库)。 */ +/** + * 递归复制目录,点开头条目跳过(种子里的 .DS_Store 等垃圾不落仓库)。 + * + * 类型判定同样走 `ghostContentTree`:非普通条目直接抛错。种子里出现链接属于打包 + * 事故;既不能跟随复制把外部字节铺进安装目录,也不能静默丢掉后批准残缺安装。 + * 主流程在交换目录前已有同形自检,这里逐条复制前再判一次类型,缩小检查与使用之间 + * 的窗口,并挡住预检后、该条目被读取前已经发生的类型替换。 + */ async function copyDirSkippingDotEntries(from: string, to: string): Promise { await fs.promises.mkdir(to, { recursive: true }); for (const entry of await fs.promises.readdir(from, { withFileTypes: true })) { - if (entry.name.startsWith('.')) continue; const src = path.join(from, entry.name); const dest = path.join(to, entry.name); - if (entry.isDirectory()) { + const kind = await classifyGhostDirEntry(src); + // 与 collectGhostContentFiles 同序:先判类型,再按名称决定是否忽略内容。 + // 否则复制期间出现的 `.x` 链接会被静默跳过,第二道 fail-closed 防线名不副实。 + if (kind !== 'directory' && kind !== 'file') { + throw new Error( + `builtin seed rejects ${kind === 'link' ? 'link' : 'non-regular'} entry: ${src}`, + ); + } + if (entry.name.startsWith('.')) continue; + if (kind === 'directory') { await copyDirSkippingDotEntries(src, dest); - } else if (entry.isFile()) { + } else { await fs.promises.copyFile(src, dest); } } diff --git a/apps/desktop/src/main/cindy-brain/forge.ts b/apps/desktop/src/main/cindy-brain/forge.ts index 8936bccfb16..82ef0f9aa8a 100644 --- a/apps/desktop/src/main/cindy-brain/forge.ts +++ b/apps/desktop/src/main/cindy-brain/forge.ts @@ -15,6 +15,7 @@ import fs from 'node:fs'; import path from 'node:path'; +import { promisify } from 'node:util'; import JSZip from 'jszip'; @@ -24,7 +25,12 @@ import { validateGhostManifest, type GhostManifest, } from '../../shared/ghost.js'; +import { + classifyGhostDirEntry, + resolveGhostContentPath, +} from './ghostContentTree.js'; import { validateGhostLocaleResourcesInDirectory } from './ghostLocaleFiles.js'; +import { isPathInsideDir } from './dirDeposit.js'; import { checkSkillMdConsistency } from './skillSlot.js'; /** 与 GhostManager 装入侧同一量级的上限(打包侧提前拦,fail fast)。 */ @@ -47,7 +53,13 @@ export type ForgePackResult = | { ok: true; cindyPath: string; manifest: GhostManifest } | { ok: false; - errorCode: 'DIR_NOT_FOUND' | 'MANIFEST_INVALID' | 'ENTRY_MISSING' | 'TOO_LARGE' | 'INTERNAL'; + errorCode: + | 'DIR_NOT_FOUND' + | 'SOURCE_IS_INSTALLED_PLUGIN' + | 'MANIFEST_INVALID' + | 'ENTRY_MISSING' + | 'TOO_LARGE' + | 'INTERNAL'; message: string; }; @@ -395,15 +407,26 @@ function hasFsErrorCode(err: unknown, code: string): boolean { return Boolean(err && typeof err === 'object' && 'code' in err && err.code === code); } +/** + * realpath 一律走 `.native` 变体:与 dirDeposit / ghostLocalPathGrant 等其余钳制点 + * 同一口径(受管根判定的最终比较虽已做 win32 大小写折叠,但解析器本身也不该是 + * 全仓唯一的例外)。`fs.promises.realpath` 没有 native 变体,promisify 一次。 + */ +const realpathNative = promisify(fs.realpath.native); + /** * 创建一份不覆盖任何现有内容的插件源码骨架。 * * 文件先写进同目录临时文件夹,全部成功后再一次 rename 到目标;目标已经 * 存在时直接拒绝,因此并发调用也不会把用户原文件覆盖一半。 + * + * `forbiddenRootDirs` 与打包侧同源(Host 受管的安装根 + 批准状态根):骨架也不 + * 许落进已安装插件或状态目录 —— 那既会改写已批准插件的内容(随包种子还会因此 + * 翻转播种指纹被整目录换回),又会诱导作者继续在安装目录里改代码。 */ export async function scaffoldGhostDir( input: ForgeScaffoldInput, - options?: { sessionWorkdir?: string | null }, + options?: { sessionWorkdir?: string | null; forbiddenRootDirs?: readonly string[] }, ): Promise { const template = input.template; if (!FORGE_SCAFFOLD_TEMPLATES.includes(template)) { @@ -425,34 +448,52 @@ export async function scaffoldGhostDir( // 就取「已存在的最深祖先」的真身再拼回剩余段。 let realWorkdir: string; try { - realWorkdir = await fs.promises.realpath(path.resolve(workdir)); + realWorkdir = await realpathNative(path.resolve(workdir)); } catch { return { ok: false, errorCode: 'INVALID_INPUT', message: '会话工作目录不存在,无法确定骨架输出位置' }; } - let realAncestor = resolved; - const pendingSegments: string[] = []; - for (;;) { - try { - realAncestor = await fs.promises.realpath(realAncestor); - break; - } catch (err) { - if (!hasFsErrorCode(err, 'ENOENT')) { - return { - ok: false, - errorCode: 'INTERNAL', - message: err instanceof Error ? err.message : String(err), - }; - } - const parent = path.dirname(realAncestor); - if (parent === realAncestor) break; // 到根了,根一定存在,防御性兜底 - pendingSegments.unshift(path.basename(realAncestor)); - realAncestor = parent; - } + // 「已存在的最深祖先取真身再拼回剩余段」与打包侧受管根解析共用同一个 helper —— + // 这段 walk 原来在这里手写了一份,同一判定两份实现正是这条链路反复出问题的形态。 + let realTarget: string; + try { + realTarget = await resolveThroughExistingAncestor(resolved); + } catch (err) { + return { + ok: false, + errorCode: 'INTERNAL', + message: err instanceof Error ? err.message : String(err), + }; } - const realTarget = path.join(realAncestor, ...pendingSegments); if (!realTarget.startsWith(`${realWorkdir}${path.sep}`) && realTarget !== realWorkdir) { return { ok: false, errorCode: 'INVALID_INPUT', message: 'dir 必须在当前会话工作目录内' }; } + for (const forbiddenRoot of options?.forbiddenRootDirs ?? []) { + let resolvedForbiddenRoot: string; + try { + resolvedForbiddenRoot = await resolveThroughExistingAncestor(forbiddenRoot); + } catch (err) { + return { + ok: false, + errorCode: 'INTERNAL', + message: err instanceof Error ? err.message : String(err), + }; + } + // 与打包侧同形的双向判定。祖先方向这一半在这里是 defense-in-depth 而不是修洞: + // 骨架目标若是受管根的祖先,该目录必然已存在,最终 rename 会以 TARGET_EXISTS 拒掉。 + // 仍然写出来,是为了不让"上游守卫够不够用"依赖读者去推断下游 rename 的语义 —— + // 判定散落且各自只覆盖一半,正是这条链路反复出问题的成因。 + if ( + isPathInsideDir(resolvedForbiddenRoot, realTarget) || + isPathInsideDir(realTarget, resolvedForbiddenRoot) + ) { + return { + ok: false, + errorCode: 'INVALID_INPUT', + message: + 'dir 不能落在已安装插件目录或 Host 管理的状态目录内,也不能是它们的上级目录;请在工作目录里换一个独立的作者目录', + }; + } + } const files = scaffoldFiles(input); // 显式收窄而非 as 断言:manifest 恒为 JSON 字符串,二进制项(占位图标)另存; // 未来若误把 manifest 写成 Buffer,这里在编译/测试期就报,而不是运行期 parse 炸。 @@ -541,7 +582,10 @@ export async function scaffoldGhostDir( * 同名覆盖——同 id 同版本重打包语义上就是同一个包),用户在自己的意识目录里 * 就能拿到成品;出错返回结构化分类,agent 按 message 修源码即可,不抛异常。 */ -export async function packGhostDir(dir: string): Promise { +export async function packGhostDir( + dir: string, + options: { forbiddenRootDirs?: readonly string[] } = {}, +): Promise { try { let stat: fs.Stats; try { @@ -552,6 +596,25 @@ export async function packGhostDir(dir: string): Promise { if (!stat.isDirectory()) { return { ok: false, errorCode: 'DIR_NOT_FOUND', message: `不是目录:${dir}` }; } + const realSourceDir = await realpathNative(dir); + for (const forbiddenRoot of options.forbiddenRootDirs ?? []) { + const resolvedForbiddenRoot = await resolveThroughExistingAncestor(forbiddenRoot); + // 判定必须**双向**:源目录落在受管根内要拒(拿已安装插件当源码),源目录是受管根的 + // 祖先也要拒 —— 后者下面的递归打包会走进 cindy-brain / ghost-install-state,把 + // 已安装插件字节、批准 receipt 与技能快照一并打进 .cindy。单向判定时,只要在 + // owner 数据目录里放一个 ghost.json 就能触发。 + if ( + isPathInsideDir(resolvedForbiddenRoot, realSourceDir) || + isPathInsideDir(realSourceDir, resolvedForbiddenRoot) + ) { + return { + ok: false, + errorCode: 'SOURCE_IS_INSTALLED_PLUGIN', + message: + 'Forge source must not be an installed Plugin or a Host-managed state directory; copy the source into the current session workdir first', + }; + } + } // 1) 清单先行:与装入侧同一套校验,错在打包期就报清楚。 let manifestRaw: unknown; @@ -592,10 +655,16 @@ export async function packGhostDir(dir: string): Promise { for (const item of manifest.skill?.items ?? []) mustExist.push(`${item.dir}/SKILL.md`); for (const rel of mustExist) { try { - const st = await fs.promises.stat(path.join(dir, rel)); - if (!st.isFile()) throw new Error('not a file'); + // 逐段解析(判据同装入侧 ghostContentTree):`stat` 会穿透链接,让"声明的 + // 文件是链接"通过检查,而下面的收集步按类型跳过链接 —— 包就少了这个文件, + // 错误延迟到用户装入时才现形。这里直接拒,报清楚。 + await resolveGhostContentPath(dir, rel, { expect: 'file', label: 'forge source' }); } catch { - return { ok: false, errorCode: 'ENTRY_MISSING', message: `清单声明的文件不存在:${rel}` }; + return { + ok: false, + errorCode: 'ENTRY_MISSING', + message: `清单声明的文件不存在或不是普通文件(链接不可打包):${rel}`, + }; } } @@ -651,10 +720,16 @@ export async function packGhostDir(dir: string): Promise { }; } seenPackPaths.add(foldedRel); - if (e.isDirectory()) { + // 类型判定走 ghostContentTree(一律 lstat,不信 Dirent 类型位):非普通条目 + // 既不递归也不收集 —— 递归进一条指向 cindy-brain / ghost-install-state 的 + // 链接就会把已安装插件字节、批准 receipt 与技能快照打进 .cindy。源目录与 + // 受管根的**双向**包含判定挡住了"源目录是受管根祖先"那一半,这里挡的是 + // "源目录里放一条链接指进去"那一半,两半都要在。 + const kind = await classifyGhostDirEntry(abs); + if (kind === 'directory') { const bad = await walk(abs, rel); if (bad) return bad; - } else if (e.isFile()) { + } else if (kind === 'file') { files.push({ rel, abs }); totalBytes += (await fs.promises.stat(abs)).size; if (files.length > maxFiles) { @@ -702,6 +777,27 @@ export async function packGhostDir(dir: string): Promise { } } +/** + * Resolve symlinks/junctions in the existing prefix while retaining a + * non-existent tail. This keeps managed roots comparable before first use. + */ +async function resolveThroughExistingAncestor(inputPath: string): Promise { + let cursor = path.resolve(inputPath); + const tail: string[] = []; + while (true) { + try { + const real = await realpathNative(cursor); + return path.join(real, ...tail); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + const parent = path.dirname(cursor); + if (parent === cursor) return path.resolve(inputPath); + tail.unshift(path.basename(cursor)); + cursor = parent; + } + } +} + /** * 《意识编写手册》——ghost_forge_guide 的返回体。随主机版本演进,改了机制 * 就同步改这里;agent 每次做意识前现拿现读,永不过期。 @@ -2668,13 +2764,19 @@ const ensured = await cindy.workspace({ ## 7. 打包与测试 1. 新插件先调 \`ghost_forge_scaffold\` 生成骨架,或把已有源码放在用户工作目录下的 - 一个文件夹里(如 \`my-ghost/\`);脚手架目标必须是新目录,绝不覆盖已有文件; -2. 调 \`ghost_forge_pack({ dir: '<绝对路径>' })\`——校验 + 打包 + 弹装入确认框; + 一个文件夹里(如 \`my-ghost/\`);脚手架目标必须是新目录,绝不覆盖已有文件, + 也不能落在已安装插件目录或 Host 状态目录内(会被拒,理由同下一条); +2. **Forge 源码必须是当前会话工作目录里的独立作者目录**。已安装插件目录以及 + Host 管理的状态目录都不是源码区,禁止直接修改、打包或用路径别名绕过;若要继续 + 开发已有插件,先把源码复制/迁出到工作目录中的新目录,再从该副本制作; +3. 调 \`ghost_forge_pack({ dir: '<绝对路径>' })\`——校验 + 打包 + 弹装入确认框; 产物落在源码目录里(\`-.cindy\`,同版本覆盖,下次打包自动跳过); -3. **告知用户去点弹窗**(装入默认沉睡,提醒用户勾"立即开启"或到主界面侧边栏「插件」中唤醒); -4. 改代码后重新 pack:同 id 会弹"更新 vX → vY",唤醒状态与面板位置自动保留 + 若返回 \`SOURCE_IS_INSTALLED_PLUGIN\`,不要重试或换大小写、软链接、junction 绕过, + 按上一步迁出源码后再打包; +4. **告知用户去点弹窗**(装入默认沉睡,提醒用户勾"立即开启"或到主界面侧边栏「插件」中唤醒); +5. 改代码后重新 pack:同 id 会弹"更新 vX → vY",唤醒状态与面板位置自动保留 (记得 bump ghost.json 的 version); -5. 验证:让用户 \`$ <内容>\` 试一单,看聊天图卡/面板是否符合预期。 +6. 验证:让用户 \`$ <内容>\` 试一单,看聊天图卡/面板是否符合预期。 ## 8. 发布签名与审核 diff --git a/apps/desktop/src/main/cindy-brain/ghostContentTree.ts b/apps/desktop/src/main/cindy-brain/ghostContentTree.ts new file mode 100644 index 00000000000..76dfebb2e42 --- /dev/null +++ b/apps/desktop/src/main/cindy-brain/ghostContentTree.ts @@ -0,0 +1,230 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * ghostContentTree —— 「插件内容目录怎么读」的**唯一判据**。 + * + * 为什么存在这个模块:插件链路上有六处各自 readdir + 判类型的实现(技能指纹、 + * 技能快照拷贝、安装目录漂移指纹、随包种子指纹、种子复制、Forge 打包收集), + * 还有五处各自 `path.join(dir, ...rel.split('/'))` 之后再判一次类型。它们本该 + * 是同一条判据,却分别用 Dirent 类型位 / `lstat` / `stat` / realpath 钳制写过, + * 于是每一轮审查都能在其中一处找到没覆盖的角落 —— 补一处、下一轮换另一处。 + * + * 所以类型判定与相对路径解析在本模块各只有一份实现,差异只允许以**显式策略 + * 参数**表达(点开头条目算不算内容、非普通条目是拒还是只记状态位)。新增读插件 + * 内容的代码一律从这里取判据,不要再就地 readdir + isDirectory()。 + */ + +/** + * 目录条目类型。`link` 与 `other` 都属于"非普通条目",单独区分只为错误信息 + * 能说清是链接还是别的(FIFO / 设备节点等)。 + */ +export type GhostDirEntryKind = 'file' | 'directory' | 'link' | 'other'; + +/** + * 类型判据的唯一实现:一律看 `lstat`,**不信 Dirent 的类型位**。 + * + * Dirent 的类型位来自 readdir 的批量结果,当前 libuv 把 reparse point(软链与 + * Windows junction)都报成 link,但那是实现细节、Node 公开契约没保证;判据自己 + * 拿 lstat 说话,哪天类型位把 junction 报成 directory 也不会跟进去。 + */ +function kindOfStat(stat: fs.Stats): GhostDirEntryKind { + if (stat.isSymbolicLink()) return 'link'; + if (stat.isDirectory()) return 'directory'; + if (stat.isFile()) return 'file'; + return 'other'; +} + +export async function classifyGhostDirEntry(absPath: string): Promise { + return kindOfStat(await fs.promises.lstat(absPath)); +} + +export function classifyGhostDirEntrySync(absPath: string): GhostDirEntryKind { + return kindOfStat(fs.lstatSync(absPath)); +} + +/** 普通条目 = 真目录或普通文件;其余(链接等)一律非普通。 */ +export function isRegularGhostDirEntry(kind: GhostDirEntryKind): boolean { + return kind === 'file' || kind === 'directory'; +} + +export interface ResolveGhostContentPathOptions { + /** 最终段期望的类型。 */ + expect: 'directory' | 'file'; + /** 错误信息前缀(如 `approved skill` / `bundled locale`)。 */ + label: string; +} + +/** + * 解析清单声明的相对路径,**逐段**确认每一段都是真目录 / 最终段是期望类型。 + * + * 只 lstat 最终段是不够的:中间段被换成软链 / Windows junction 时 OS 会静默穿透 + * —— 对最终段 lstat 报的是"真目录、非链接"(已实测),于是字节从插件目录之外取。 + * 首次批准那条路径尤其致命:技能指纹是现算的,外部内容会被钉成"批准字节"再复制 + * 成快照,而 `checkSkillMdConsistency` 只校验 frontmatter 的 name/description, + * 这两个值在 manifest 里公开可抄,拦不住。 + * + * `baseDir` 自身不在这里校验(它由调用方给出:安装根下的 `` 若被换成链接, + * `GhostManager.list()` 的 `entry.isDirectory()` 已经把它整条跳过;状态根下的 + * temp / 快照目录是宿主自己创建的)。相对路径的结构安全由清单校验保证 + * (`isSafeGhostRelativePath` / skill dir 正则:无盘符、无反斜杠、无 `.`/`..` 段)。 + */ +export async function resolveGhostContentPath( + baseDir: string, + relPath: string, + options: ResolveGhostContentPathOptions, +): Promise { + const segments = relPath.split('/').filter((segment) => segment.length > 0); + let current = baseDir; + for (const [index, segment] of segments.entries()) { + current = path.join(current, segment); + assertSegment( + await classifyGhostDirEntry(current), + index === segments.length - 1 ? options.expect : 'directory', + relPath, + options.label, + ); + } + return current; +} + +export function resolveGhostContentPathSync( + baseDir: string, + relPath: string, + options: ResolveGhostContentPathOptions, +): string { + const segments = relPath.split('/').filter((segment) => segment.length > 0); + let current = baseDir; + for (const [index, segment] of segments.entries()) { + current = path.join(current, segment); + assertSegment( + classifyGhostDirEntrySync(current), + index === segments.length - 1 ? options.expect : 'directory', + relPath, + options.label, + ); + } + return current; +} + +function assertSegment( + kind: GhostDirEntryKind, + expect: 'directory' | 'file', + relPath: string, + label: string, +): void { + if (kind === 'link') { + throw new Error(`${label} path segment is a link: ${relPath}`); + } + if (kind !== expect) { + throw new Error( + `${label} path segment is not a ${expect === 'directory' ? 'directory' : 'regular file'}: ${relPath}`, + ); + } +} + +export interface CollectGhostContentOptions { + /** + * 点开头条目:`include` = 算内容(技能目录 —— 技能指令可以引用目录里的任意 + * 文件,漏掉一类就是漏掉一条改写通道);`skip` = 不算内容(安装目录 / 随包种子 + * —— `.disabled`、`.cindy-trust.json` 是用户与宿主状态,不是插件内容)。 + * + * `skip` 下点开头条目仍然**要过类型判定**:名为 `.x` 的链接不进内容指纹,但 + * 会按 `nonRegular` 策略处理。点开头**目录**整条跳过(不递归、不进指纹):清单 + * 声明的相对路径首字符必须是 `[a-zA-Z0-9_]`,任何声明都不可能指向点开头目录里 + * 的文件,所以它们既不会被当代码加载、也不会被当技能读取。 + */ + dotEntries: 'include' | 'skip'; + /** + * 非普通条目(链接 / FIFO 等):`throw` = 立即拒(授权判据路径);`flag` = 只翻 + * `hasNonRegularEntry`,不进内容指纹(对账判据路径 —— 需要"判不一致"而不是抛错, + * 才能走重新播种把目录换回随包字节)。 + * + * `flag` 下**不能拿 sentinel 喂进哈希**:任何 sentinel 都能被"同路径下内容恰好 + * 等于该 sentinel 的普通文件"撞上(已实测:内容为 `non-regular` 的普通文件与同名 + * junction 的摘要完全相等),于是被塞进链接的目录仍会被判成逐字节相同。所以类型 + * 状态是独立字段,不掺进字节流。 + */ + nonRegular: 'throw' | 'flag'; + /** 错误信息前缀。 */ + label: string; +} + +export interface GhostContentTree { + /** 普通文件的相对路径(正斜杠归一化保证双平台一致),已排序。 */ + files: string[]; + /** 是否遇到过非普通条目(仅 `nonRegular: 'flag'` 时可能为 true)。 */ + hasNonRegularEntry: boolean; +} + +/** 递归收集目录里的普通文件相对路径;类型判定与策略见 `CollectGhostContentOptions`。 */ +export async function collectGhostContentFiles( + rootDir: string, + options: CollectGhostContentOptions, +): Promise { + const files: string[] = []; + let hasNonRegularEntry = false; + + const collect = async (relativeDir: string): Promise => { + const absoluteDir = path.join(rootDir, ...relativeDir.split('/').filter(Boolean)); + for (const entry of await fs.promises.readdir(absoluteDir, { withFileTypes: true })) { + const isDotEntry = entry.name.startsWith('.'); + const relativePath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name; + const kind = await classifyGhostDirEntry(path.join(absoluteDir, entry.name)); + if (!isRegularGhostDirEntry(kind)) { + // 类型判定排在点开头过滤**之前**:名为 `.x` 的链接同样是一条改写通道, + // 不能因为"点开头不算内容"就连它是不是链接都不看。 + if (options.nonRegular === 'throw') { + throw new Error( + `${options.label} rejects ${kind === 'link' ? 'link' : 'non-regular'} entry: ${relativePath}`, + ); + } + hasNonRegularEntry = true; + continue; + } + if (isDotEntry && options.dotEntries === 'skip') continue; + if (kind === 'directory') { + await collect(relativePath); + } else { + files.push(relativePath); + } + } + }; + + await collect(''); + files.sort(); + return { files, hasNonRegularEntry }; +} + +/** + * 内容指纹:版本前缀 + 长度前缀路径 + 每文件 SHA-256。 + * + * 不使用 `path \0 bytes \0` 这类分隔符编码:文件内容可以合法包含 NUL,于是 + * `{ a: "x\0b\0y" }` 与 `{ a: "x", b: "y" }` 会在进入 SHA-256 前形成完全 + * 相同的字节流。路径使用 UTF-8 字节长度前缀,文件内容先流式收成固定 32 字节摘要, + * 因此文件边界无歧义。 + * + * 文件仍然流式读取,不整份进内存 —— 插件目录里除 SKILL.md 之外的文件没有尺寸 + * 上限,整份 readFile 会让一个塞进来的超大文件把 Host 撑爆。 + */ +export async function hashGhostContentFiles( + rootDir: string, + files: readonly string[], +): Promise { + const hash = crypto.createHash('sha256'); + hash.update('cindy-ghost-content-v2\0'); + for (const relativePath of files) { + const pathBytes = Buffer.from(relativePath, 'utf8'); + const pathLength = Buffer.allocUnsafe(8); + pathLength.writeBigUInt64BE(BigInt(pathBytes.byteLength)); + hash.update(pathLength); + hash.update(pathBytes); + + const fileHash = crypto.createHash('sha256'); + const stream = fs.createReadStream(path.join(rootDir, ...relativePath.split('/'))); + for await (const chunk of stream) fileHash.update(chunk as Buffer); + hash.update(fileHash.digest()); + } + return hash.digest('hex'); +} diff --git a/apps/desktop/src/main/cindy-brain/ghostInstallReceipt.ts b/apps/desktop/src/main/cindy-brain/ghostInstallReceipt.ts new file mode 100644 index 00000000000..606abf4779a --- /dev/null +++ b/apps/desktop/src/main/cindy-brain/ghostInstallReceipt.ts @@ -0,0 +1,579 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { + GHOST_LOCALE_MAX_BYTES, + GHOST_SKILL_MD_MAX_BYTES, + isValidGhostId, + validateGhostManifest, + validateGhostManifestLocaleResource, + type GhostManifest, + type GhostManifestLocaleResource, + type GhostTrustInfo, +} from '../../shared/ghost.js'; +import { + classifyGhostDirEntry, + collectGhostContentFiles, + hashGhostContentFiles, + isRegularGhostDirEntry, + resolveGhostContentPath, +} from './ghostContentTree.js'; +import { checkSkillMdConsistency } from './skillSlot.js'; + +// v2 pairs receipts with the unambiguous ghostContentTree framing. Keeping v1 +// readable would let an old ambiguous digest authorize a snapshot under the +// new verifier, so old receipts intentionally fail closed and require approval +// to be written again. +const RECEIPT_SCHEMA_VERSION = 2; +const MAX_RECEIPT_BYTES = 2 * 1024 * 1024; +const MAX_ICON_DATA_URL_BYTES = 768 * 1024; +/** + * 受管 icon 快照的完整形态:声明的图片 mime + 严格 base64 载荷。载荷字符集也要 + * 校验 —— 只认前缀会让被改写的 receipt 把任意字符串塞进 renderer 的 img src。 + */ +const ICON_DATA_URL_RE = + /^data:image\/(?:png|jpeg|webp|gif);base64,[A-Za-z0-9+/]+={0,2}$/; + +/** + * 一次明确批准的插件安装事实;只允许 Host 写入安装目录之外的状态根。 + * + * receipt 钉住的是**授权事实**(批准过的 manifest / trust / 启停 / revision)。 + * 它不保证安装目录里的内容字节此后一直没被改过 —— 逻辑页代码仍从可变的安装 + * 目录加载,只有技能目录因为越出沙箱而被拷成快照。 + */ +export interface GhostInstallReceipt { + schemaVersion: typeof RECEIPT_SCHEMA_VERSION; + id: string; + revision: string; + manifest: GhostManifest; + localeResources: Record; + enabled: boolean; + trust: GhostTrustInfo; + /** + * 批准时点的来源指纹,仅供审计与人工比对:市场/本地包是 `.cindy` 文件哈希, + * 随包种子是内容目录哈希。**运行时不校验它**,不要据此认为安装内容持续完整。 + */ + packageSha256?: string; + /** + * 按 skill item 目录钉住的批准字节指纹(`item.dir` → sha256)。声明了 skill 槽 + * 时逐项必填,没声明时是空对象。 + * + * 这一项**是运行期判据**,与只作审计用的 `packageSha256` 不同:快照缺失需要从 + * 可变安装目录重建时,必须先重算并逐字节对上才允许重建。少了它,改写 SKILL.md + * 正文或往技能目录塞辅助文件就能在一次"启用"里被固化成已批准快照并全局挂链, + * 而 frontmatter 一致性校验只看 name/description,拦不住这类漂移。 + */ + skillContentSha256: Record; + iconDataUrl?: string; +} + +export type GhostInstallReceiptReadResult = + | { state: 'approved'; receipt: GhostInstallReceipt } + | { state: 'legacy-unapproved' } + | { state: 'invalid'; reason: string }; + +/** Host-owned receipt store:严格读取、同目录临时文件 + rename 原子提交。 */ +export class GhostInstallReceiptStore { + constructor(private readonly getRootDir: () => string) {} + + rootDir(): string { + return path.resolve(this.getRootDir()); + } + + read(id: string): GhostInstallReceiptReadResult { + const receiptPath = this.receiptPath(id); + let stat: fs.Stats; + try { + stat = fs.lstatSync(receiptPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { state: 'legacy-unapproved' }; + } + return { + state: 'invalid', + reason: error instanceof Error ? error.message : String(error), + }; + } + if (!stat.isFile() || stat.size > MAX_RECEIPT_BYTES) { + return { state: 'invalid', reason: 'receipt 不是普通文件或超过大小上限' }; + } + try { + const parsed = JSON.parse(fs.readFileSync(receiptPath, 'utf8')) as unknown; + const validated = validateReceipt(parsed, id); + return validated.ok + ? { state: 'approved', receipt: validated.receipt } + : { state: 'invalid', reason: validated.reason }; + } catch (error) { + return { + state: 'invalid', + reason: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * 写入批准事实。`skillSourceDir` 是快照缺失时的取字节来源:装入/更新传新 + * 内容目录,纯状态改写(启停)传当前安装目录即可自愈。 + * + * `requireSkillSnapshot: false` 用于**必须成功的收敛方向**(停用):快照 + * 已被外部删掉时不该把插件卡在"既不能用也不能关"的状态,此时按无 skill + * 落链继续写批准事实,由对账撤掉链接。 + */ + async write( + receipt: GhostInstallReceipt, + options: { skillSourceDir?: string; requireSkillSnapshot?: boolean } = {}, + ): Promise { + const validated = validateReceipt(receipt, receipt.id); + if (!validated.ok) throw new Error(`refusing to write invalid ghost receipt: ${validated.reason}`); + + const root = this.rootDir(); + await fs.promises.mkdir(root, { recursive: true }); + try { + await this.ensureSkillSnapshot(receipt, options.skillSourceDir); + } catch (error) { + if (options.requireSkillSnapshot !== false) throw error; + } + const target = this.receiptPath(receipt.id); + const temp = path.join( + root, + `.${receipt.id}-${process.pid}-${crypto.randomBytes(6).toString('hex')}.tmp`, + ); + try { + await fs.promises.writeFile(temp, `${JSON.stringify(receipt, null, 2)}\n`, { + encoding: 'utf8', + flag: 'wx', + mode: 0o600, + }); + await fs.promises.rename(temp, target); + } finally { + await fs.promises.rm(temp, { force: true }).catch(() => undefined); + } + await this.pruneStaleSkillSnapshots(receipt); + } + + async remove(id: string): Promise { + await fs.promises.rm(this.receiptPath(id), { force: true }); + await fs.promises.rm(path.join(this.rootDir(), 'skill-snapshots', id), { + recursive: true, + force: true, + }); + } + + skillSnapshotRoot(id: string, revision: string): string { + if (!isValidGhostId(id) || !isRevision(revision)) { + throw new Error('invalid ghost skill snapshot identity'); + } + return path.join(this.rootDir(), 'skill-snapshots', id, revision); + } + + private receiptPath(id: string): string { + if (!isValidGhostId(id)) throw new Error('invalid ghost id for receipt path'); + return path.join(this.rootDir(), `${id}.json`); + } + + private async ensureSkillSnapshot( + receipt: GhostInstallReceipt, + skillSourceDir: string | undefined, + ): Promise { + const items = receipt.manifest.skill?.items ?? []; + if (items.length === 0) return; + const target = this.skillSnapshotRoot(receipt.id, receipt.revision); + let existing: fs.Stats | null; + try { + existing = await fs.promises.lstat(target); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + existing = null; + } + if (existing) { + if (!existing.isDirectory()) { + throw new Error('approved skill snapshot target is not a directory'); + } + // 快照已存在**不等于**它还是被批准的那份字节:状态根里的目录同样可被同权限 + // 进程改写,而主 Agent 是顺着共享技能链接持续读它的。所以这里必须重算, + // 不能像上一版那样直接早退信任它。 + if (await this.skillSnapshotMatchesReceipt(receipt, target)) return; + // 对不上的快照一律不可信:删掉,退回下面的重建路径 —— 重建本身仍要过安装 + // 目录的字节校验,所以"损坏快照"能自愈,"安装字节已漂移"仍然拒。 + await fs.promises.rm(target, { recursive: true, force: true }); + } + if (!skillSourceDir) { + throw new Error('approved skill snapshot is missing'); + } + const parent = path.dirname(target); + const temp = path.join( + parent, + `.${receipt.revision}-${process.pid}-${crypto.randomBytes(6).toString('hex')}.tmp`, + ); + await fs.promises.mkdir(parent, { recursive: true }); + try { + await fs.promises.mkdir(temp, { recursive: false }); + + // 顺序是安全要点,不要改回"先校验源目录、再复制":源目录随时可被同权限进程 + // 改写,校验和复制各读一次就有一个可换字节的窗口,复制出来的快照可能不是被 + // 校验过的那一份。因此**先复制到 temp,再对 temp 里(即将成为快照的)那份字节 + // 做全部权威校验**,校验通过才 rename 就位。 + for (const item of items) { + // 与算指纹同一个解析入口:逐段确认真目录,挡住"中间段被换成链接"这条从技能 + // 目录之外取字节的路子。两侧必须共用,否则一侧穿透、一侧不穿透,复制的和 + // 算指纹的就不是同一组字节。 + const source = await resolveGhostContentPath(skillSourceDir, item.dir, { + expect: 'directory', + label: 'approved skill', + }); + // 复制前的便宜预检:只为早失败、少做无用功(避免整份拷一个超大 SKILL.md)。 + // **这不是安全边界** —— 它读的是可变源目录,结论随时可能过期,真正说话的是 + // 下面对 temp 的校验。 + const sourceSkillMdStat = await fs.promises + .lstat(path.join(source, 'SKILL.md')) + .catch(() => null); + if ( + sourceSkillMdStat && + (!sourceSkillMdStat.isFile() || sourceSkillMdStat.size > GHOST_SKILL_MD_MAX_BYTES) + ) { + throw new Error( + `approved skill ${item.dir}/SKILL.md is not a regular file or exceeds ${GHOST_SKILL_MD_MAX_BYTES} bytes`, + ); + } + await copyRegularDirectory(source, path.join(temp, ...item.dir.split('/'))); + } + + // 权威校验一律针对 temp:此刻这些字节已经脱离可变安装目录,复制期间被换过也 + // 会在这里暴露。**尺寸上限必须排在算指纹之前** —— 源目录那道预检不是安全边界 + // (预检后可被换成超大文件),若先算指纹就等于上限在权威路径上一次都没生效。 + for (const item of items) { + const copiedSkillMdPath = path.join(temp, ...item.dir.split('/'), 'SKILL.md'); + // 包一层领域错误:这一段现在排在算指纹之前,SKILL.md 缺失时若直接抛裸 ENOENT, + // 日志里就看不出是"技能内容被动过"这件事(只有被篡改时才可达,两种写法都 + // fail closed,纯粹为可读性)。 + const copiedSkillMdStat = await fs.promises.lstat(copiedSkillMdPath).catch((error) => { + throw new Error( + `approved skill ${item.dir}/SKILL.md is unreadable in the snapshot: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }); + if (!copiedSkillMdStat.isFile() || copiedSkillMdStat.size > GHOST_SKILL_MD_MAX_BYTES) { + throw new Error( + `approved skill ${item.dir}/SKILL.md is not a regular file or exceeds ${GHOST_SKILL_MD_MAX_BYTES} bytes`, + ); + } + } + // 指纹判定走与"接受既有快照""发布后复核"同一个 helper:同一判据只有一份实现, + // 否则三处各写一遍、日后只改其中一处,就是这条链路前几轮反复出问题的形态。 + if (!(await this.skillSnapshotMatchesReceipt(receipt, temp))) { + throw new Error( + `approved skill content for ${receipt.id} no longer matches the bytes approved at install time`, + ); + } + for (const item of items) { + // 指纹相符已经蕴含 frontmatter 一致(批准时点那份过过这道校验),这里重跑一遍 + // 是防止钉指纹那条路径本身有 bug,并给出更具体的错误。 + const consistencyError = checkSkillMdConsistency( + await fs.promises.readFile(path.join(temp, ...item.dir.split('/'), 'SKILL.md'), 'utf8'), + item, + ); + if (consistencyError) { + throw new Error(`approved skill ${item.dir} is inconsistent: ${consistencyError}`); + } + } + + await fs.promises.rename(temp, target); + + // rename 之前 temp 位于状态根内、同权限进程仍可改写它,所以就位之后再核一遍: + // 这一步把"校验通过 → rename"之间那段窗口收掉 —— 在那段里被换过的字节到这里 + // 会暴露,并且不会留在盘上。 + // + // 残留窗口(已知、未关):这次核对之后、主 Agent 顺着共享技能链接读取之前,快照 + // 仍可被改写。要真正关掉需要给状态根写保护或在消费侧校验,都不在本函数范围内; + // 该缺口已正式登记在 docs/dev-rules/plugin-security-and-authoring.md 第 6 节 + // (与"内容根字节可变"是两条并列的不同缺口)。 + if (!(await this.skillSnapshotMatchesReceipt(receipt, target))) { + await fs.promises.rm(target, { recursive: true, force: true }).catch(() => undefined); + throw new Error('approved skill snapshot changed while being published'); + } + } finally { + await fs.promises.rm(temp, { recursive: true, force: true }).catch(() => undefined); + } + } + + /** + * 快照目录里的字节是否仍等于 receipt 钉住的批准指纹。 + * + * 三处调用共用同一判据(接受既有快照 / 复制后发布前 / 发布后复核) —— 这类判定散落 + * 多处再各写一遍,就是本 PR 前几轮反复出问题的成因。读不动或含非普通条目一律按 + * 不匹配处理:调用方对"不匹配"的收敛动作都是删掉重建或拒绝,始终 fail closed。 + */ + async skillSnapshotMatchesReceipt( + receipt: GhostInstallReceipt, + snapshotDir: string, + ): Promise { + const actual = await hashApprovedSkillContent(receipt.manifest, snapshotDir).catch( + () => null, + ); + if (!actual) return false; + return (receipt.manifest.skill?.items ?? []).every( + (item) => actual[item.dir] === receipt.skillContentSha256[item.dir], + ); + } + + /** + * 回收同一插件下非当前 revision 的技能快照与崩溃残留的 `.tmp` 目录。 + * + * 只在新 receipt 已经原子提交之后跑:此刻旧 revision 已不是批准事实,留着 + * 就是每次更新泄漏一份完整拷贝。共享技能根里指向旧 revision 的链接会因此 + * 短暂断链,直到下一轮对账重指——对越出沙箱的 skill 槽来说,短暂"技能不可 + * 用"是正确的收敛方向,留着旧批准版本继续生效不是。 + * + * best-effort:批准事实已经落盘,回收失败只记为待清理状态,不回滚安装。 + */ + private async pruneStaleSkillSnapshots(receipt: GhostInstallReceipt): Promise { + const parent = path.join(this.rootDir(), 'skill-snapshots', receipt.id); + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(parent, { withFileTypes: true }); + } catch { + return; + } + await Promise.all( + entries + .filter((entry) => entry.name !== receipt.revision) + .map((entry) => + fs.promises + .rm(path.join(parent, entry.name), { recursive: true, force: true }) + .catch(() => undefined), + ), + ); + } +} + +export function createGhostInstallReceipt(input: { + manifest: GhostManifest; + localeResources: Record; + enabled: boolean; + trust: GhostTrustInfo; + /** 由 `hashApprovedSkillContent` 从**这次批准的内容目录**现算,不可沿用旧值。 */ + skillContentSha256: Record; + packageSha256?: string; + iconDataUrl?: string; +}): GhostInstallReceipt { + return { + schemaVersion: RECEIPT_SCHEMA_VERSION, + id: input.manifest.id, + revision: crypto.randomUUID(), + manifest: input.manifest, + localeResources: input.localeResources, + enabled: input.enabled, + trust: input.trust, + skillContentSha256: input.skillContentSha256, + ...(input.packageSha256 ? { packageSha256: input.packageSha256 } : {}), + ...(input.iconDataUrl ? { iconDataUrl: input.iconDataUrl } : {}), + }; +} + +/** + * 逐 skill item 目录算规范化内容指纹(排序后的相对路径 + 字节)。 + * + * 判据全部取自 `ghostContentTree`(路径逐段解析 + 条目类型判定 + 指纹格式),与 + * 快照拷贝侧 `copyRegularDirectory`、安装目录漂移指纹 `hashApprovedDirectory`、 + * 随包种子指纹 `fingerprintDirContent` 共用同一份实现。差异只有显式策略:技能 + * 目录**不跳过点开头条目**(技能指令可以引用目录里的任意文件,漏掉一类就是漏掉 + * 一条改写通道),非普通条目一律拒。 + */ +export async function hashApprovedSkillContent( + manifest: GhostManifest, + sourceDir: string | undefined, +): Promise> { + const items = manifest.skill?.items ?? []; + if (items.length === 0) return {}; + if (!sourceDir) throw new Error('skill content hash requires a source directory'); + const result: Record = {}; + for (const item of items) { + const itemRoot = await resolveGhostContentPath(sourceDir, item.dir, { + expect: 'directory', + label: 'approved skill', + }); + const { files } = await collectGhostContentFiles(itemRoot, { + dotEntries: 'include', + nonRegular: 'throw', + label: `approved skill ${item.dir}`, + }); + result[item.dir] = await hashGhostContentFiles(itemRoot, files); + } + return result; +} + +function validateReceipt( + raw: unknown, + expectedId: string, +): { ok: true; receipt: GhostInstallReceipt } | { ok: false; reason: string } { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return { ok: false, reason: 'receipt 必须是对象' }; + } + const value = raw as Record; + if (value.schemaVersion !== RECEIPT_SCHEMA_VERSION) { + return { ok: false, reason: 'receipt schemaVersion 不受支持' }; + } + if (value.id !== expectedId || !isValidGhostId(expectedId)) { + return { ok: false, reason: 'receipt id 与安装目录不一致' }; + } + if (typeof value.revision !== 'string' || !isRevision(value.revision)) { + return { ok: false, reason: 'receipt revision 不合法' }; + } + const manifestResult = validateGhostManifest(value.manifest); + if (!manifestResult.ok || manifestResult.manifest.id !== expectedId) { + return { + ok: false, + reason: manifestResult.ok ? 'receipt manifest id 不一致' : manifestResult.reason, + }; + } + if (typeof value.enabled !== 'boolean') { + return { ok: false, reason: 'receipt enabled 不合法' }; + } + const trust = validateTrust(value.trust); + if (!trust) return { ok: false, reason: 'receipt trust 不合法' }; + if ( + value.packageSha256 !== undefined && + (typeof value.packageSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(value.packageSha256)) + ) { + return { ok: false, reason: 'receipt packageSha256 不合法' }; + } + // 技能字节指纹是运行期判据,必填且键集必须与清单声明严格一致 —— 留"字段缺失就 + // 跳过校验"的可选口子等于给漂移留一条绕过路径。receipt 格式尚未随任何版本发布, + // 不存在需要兼容的旧 receipt。 + const skillContentSha256: Record = {}; + { + const raw = value.skillContentSha256; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return { ok: false, reason: 'receipt skillContentSha256 不合法' }; + } + const expectedDirs = (manifestResult.manifest.skill?.items ?? []) + .map((item) => item.dir) + .sort(); + const actualDirs = Object.keys(raw as Record).sort(); + if ( + expectedDirs.length !== actualDirs.length || + expectedDirs.some((dir, index) => dir !== actualDirs[index]) + ) { + return { ok: false, reason: 'receipt skillContentSha256 与 manifest 声明不一致' }; + } + for (const [dir, digest] of Object.entries(raw as Record)) { + if (typeof digest !== 'string' || !/^[a-f0-9]{64}$/.test(digest)) { + return { ok: false, reason: `receipt skillContentSha256 不合法:${dir}` }; + } + skillContentSha256[dir] = digest; + } + } + if ( + value.iconDataUrl !== undefined && + ( + typeof value.iconDataUrl !== 'string' || + Buffer.byteLength(value.iconDataUrl, 'utf8') > MAX_ICON_DATA_URL_BYTES || + !ICON_DATA_URL_RE.test(value.iconDataUrl) + ) + ) { + return { ok: false, reason: 'receipt iconDataUrl 不合法' }; + } + if (!value.localeResources || typeof value.localeResources !== 'object' || Array.isArray(value.localeResources)) { + return { ok: false, reason: 'receipt localeResources 不合法' }; + } + const expectedLocalePaths = [ + ...new Set(Object.values(manifestResult.manifest.locales ?? {})), + ].sort(); + const actualLocalePaths = Object.keys( + value.localeResources as Record, + ).sort(); + if ( + expectedLocalePaths.length !== actualLocalePaths.length || + expectedLocalePaths.some((localePath, index) => localePath !== actualLocalePaths[index]) + ) { + return { ok: false, reason: 'receipt localeResources 与 manifest 声明不一致' }; + } + const localeResources: Record = {}; + for (const [localePath, resource] of Object.entries( + value.localeResources as Record, + )) { + if (Buffer.byteLength(JSON.stringify(resource), 'utf8') > GHOST_LOCALE_MAX_BYTES) { + return { ok: false, reason: `receipt locale 超过大小上限:${localePath}` }; + } + const validated = validateGhostManifestLocaleResource(resource, manifestResult.manifest); + if (!validated.ok) return { ok: false, reason: `receipt locale 不合法:${localePath}` }; + localeResources[localePath] = validated.resource; + } + return { + ok: true, + receipt: { + schemaVersion: RECEIPT_SCHEMA_VERSION, + id: expectedId, + revision: value.revision, + manifest: manifestResult.manifest, + localeResources, + enabled: value.enabled, + trust, + skillContentSha256, + ...(typeof value.packageSha256 === 'string' + ? { packageSha256: value.packageSha256 } + : {}), + ...(typeof value.iconDataUrl === 'string' ? { iconDataUrl: value.iconDataUrl } : {}), + }, + }; +} + +function isRevision(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test( + value, + ); +} + +async function copyRegularDirectory(source: string, target: string): Promise { + // 类型判据与 hashApprovedSkillContent 同源(ghostContentTree):两侧必须同形, + // 否则指纹算的和快照拷的可能不是同一组字节。 + if ((await classifyGhostDirEntry(source)) !== 'directory') { + throw new Error(`skill source is not a directory: ${source}`); + } + await fs.promises.mkdir(target, { recursive: true }); + const entries = await fs.promises.readdir(source, { withFileTypes: true }); + for (const entry of entries) { + const from = path.join(source, entry.name); + const to = path.join(target, entry.name); + const kind = await classifyGhostDirEntry(from); + if (!isRegularGhostDirEntry(kind)) { + throw new Error( + `skill snapshot rejects ${kind === 'link' ? 'link' : 'non-regular'} entry: ${from}`, + ); + } + if (kind === 'directory') { + await copyRegularDirectory(from, to); + } else { + await fs.promises.copyFile(from, to, fs.constants.COPYFILE_EXCL); + } + } +} + +function validateTrust(raw: unknown): GhostTrustInfo | null { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; + const value = raw as Record; + if ( + !['cindy-official', 'reviewed', 'verified-publisher', 'unverified'].includes( + String(value.level), + ) || + typeof value.publisherSigned !== 'boolean' || + typeof value.publisherVerified !== 'boolean' || + typeof value.reviewed !== 'boolean' + ) { + return null; + } + const optionalStrings = [ + 'publisherName', + 'publisherKeyId', + 'reviewerName', + ] as const; + for (const key of optionalStrings) { + if (value[key] !== undefined && typeof value[key] !== 'string') return null; + } + if (value.unknownReviewer !== undefined && typeof value.unknownReviewer !== 'boolean') { + return null; + } + return value as unknown as GhostTrustInfo; +} diff --git a/apps/desktop/src/main/cindy-brain/ghostLocaleFiles.ts b/apps/desktop/src/main/cindy-brain/ghostLocaleFiles.ts index 736210c6b65..1b271ed45ed 100644 --- a/apps/desktop/src/main/cindy-brain/ghostLocaleFiles.ts +++ b/apps/desktop/src/main/cindy-brain/ghostLocaleFiles.ts @@ -6,12 +6,17 @@ import { validateGhostManifestLocaleResource, type GhostManifest, } from '../../shared/ghost.js'; +import { classifyGhostDirEntrySync } from './ghostContentTree.js'; export type GhostLocaleDirectoryValidation = { ok: true } | { ok: false; reason: string }; /** * Resolve a manifest path one segment at a time so case-insensitive filesystems * cannot hide a casing mismatch that would later produce a different ZIP entry. + * + * 遍历留在本地(它要报"磁盘实际大小写"这种只有这里需要的诊断),但**条目类型判定 + * 走 ghostContentTree**:链接一律不算目录/文件,与技能指纹、快照拷贝、打包收集同 + * 一份判据,不再各自信 Dirent 的类型位。 */ function resolveExactFile(rootDir: string, relativePath: string): string { const segments = relativePath.split('/'); @@ -32,7 +37,8 @@ function resolveExactFile(rootDir: string, relativePath: string): string { throw new Error(`路径不存在:${relativePath}`); } const isLast = index === segments.length - 1; - if (isLast ? !exact.isFile() : !exact.isDirectory()) { + const kind = classifyGhostDirEntrySync(path.join(current, exact.name)); + if (kind !== (isLast ? 'file' : 'directory')) { throw new Error(`路径不是${isLast ? '文件' : '目录'}:${relativePath}`); } current = path.join(current, exact.name); diff --git a/apps/desktop/src/main/cindy-brain/index.ts b/apps/desktop/src/main/cindy-brain/index.ts index 4596e088936..761890411e1 100644 --- a/apps/desktop/src/main/cindy-brain/index.ts +++ b/apps/desktop/src/main/cindy-brain/index.ts @@ -13,6 +13,7 @@ import { GHOST_CARD_HEIGHT_MIN, GHOST_NETWORK_MAX_CONNECTIONS_PER_DECL, GHOST_NOTIFY_MIN_INTERVAL_MS, + isGhostInstallApprovalToken, ghostWebviewEntryPaths, isCindyAccountGhostId, isOfficialGhostId, @@ -700,6 +701,15 @@ function migrateGhostKvOnRename(fromId: string, toId: string): void { /** 单轮对账:播种 → (有变化时)广播 + 首装停靠 + 常驻点火。 */ async function reconcileBuiltinGhosts(reason: string): Promise { const manager = getGhostManager(); + await manager.runExclusiveMutation(() => + reconcileBuiltinGhostsLocked(reason, manager), + ); +} + +async function reconcileBuiltinGhostsLocked( + reason: string, + manager: GhostManager, +): Promise { // 改名前置:用户自主状态(墓碑=卸载过 / .disabled=停用)随改名带到新 id, // 不能让"明确卸载/停用过"的用户在升级后被以新 id 重新装上并点亮(播种器 // "用户自主权豁免"支柱)。墓碑:旧 id 有 → 给新 id 记墓碑并清掉旧墓碑 @@ -728,11 +738,12 @@ async function reconcileBuiltinGhosts(reason: string): Promise { repoRootDir: brainRootDir(), identity: currentProvisionIdentity(), // 回收先熄灯沙箱再删目录(Windows 文件锁:运行中的电子脑可能占着句柄)。 - beforeRemove: (id) => { + beforeRemove: async (id) => { getGhostRuntime().stop(id); getGhostNodeRuntimeBroker().stop(id); getGhostAgentSlot().clearGhost(id); getGhostErrandSlot().clearGhost(id); + await manager.removeInstallApproval(id); }, onApplyStart: () => { tipShown = true; @@ -774,12 +785,58 @@ async function reconcileBuiltinGhosts(reason: string): Promise { }); } } - if (outcome.installed.length === 0 && outcome.updated.length === 0 && outcome.removed.length === 0) return; + let approvalChanged = false; + for (const manifest of outcome.approved) { + try { + approvalChanged = + (await manager.approveTrustedBundledInstall( + manifest, + // `.disabled` 镜像的读数只作停用方向的输入:receipt 已钉停用时,镜像被 + // 外部移除不会把插件翻回启用(合并规则见 approveTrustedBundledInstall + // 头注释;重新启用只有用户显式 setEnabled 一条路)。 + !fs.existsSync(path.join(brainRootDir(), manifest.id, '.disabled')), + )) || approvalChanged; + } catch (err) { + // 走到这里内容目录可能已经换成新种子字节,旧 receipt 却还是授权事实 —— + // 留着它就是拿旧批准跑新代码(新版删掉的 slot 仍被授予、版本与技能快照 + // 也停在旧 revision)。 + // + // removeInstallApproval 的契约是"返回后该插件一定不再被授权运行":删得掉就 + // 删 receipt,删不掉(状态根不可写 —— 与这里写批准失败同一个成因)就转进程内 + // 隔离。所以这里不需要、也不应该再自己判断撤销成不成功:那正是上一版在 + // cleanup 失败时留下 fail-open 的地方。随包插件下一轮启动对账会重新补批准, + // 自愈,不需要用户介入。 + await manager.removeInstallApproval(manifest.id); + // 撤销只让后续的 Host 能力调用与技能落链失效,不会自己结束已经跑起来的沙箱 + // 进程 —— 登录触发对账时插件可能正在运行。与 beforeRemove 同款四连熄灯 + // (含 errand slot:漏掉它会让节流状态与在途任务记录留到同会话的自愈之后, + // 变成不必要的 rate-limit/"已有在途任务"阻塞),让"撤销后不再被授权运行" + // 这句话对运行中的实例也成立。 + getGhostRuntime().stop(manifest.id); + getGhostNodeRuntimeBroker().stop(manifest.id); + getGhostAgentSlot().clearGhost(manifest.id); + getGhostErrandSlot().clearGhost(manifest.id); + approvalChanged = true; + log.warn('builtin ghost approval receipt failed; approval revoked', { + id: manifest.id, + error: err instanceof Error ? err.message : String(err), + }); + } + } + if ( + outcome.installed.length === 0 && + outcome.updated.length === 0 && + outcome.removed.length === 0 && + !approvalChanged + ) { + return; + } log.info('builtin ghost reconcile applied changes', { reason, installed: outcome.installed.map((m) => m.id), updated: outcome.updated.map((m) => m.id), removed: outcome.removed, + approvalChanged, }); // 播种绕过 manager 写盘,广播由这里补上(renderer 首帧 sendSync 早于对账 // 完成时,靠 ghosts:changed 热更新兜底,多窗口同一套通道)。 @@ -806,9 +863,13 @@ export function getGhostManager(): GhostManager { if (!managerSingleton) { managerSingleton = new GhostManager({ getRootDir: brainRootDir, + getStateDir: () => ownerScopedUserDataPath('ghost-install-state'), onChanged: broadcastGhostsChanged, getLocale: getResolvedMainLocale, trustRegistry: loadGhostTrustRegistry(), + // 随包批准入口的 builtin-only 边界:id 必须对应一颗随包种子。该入口不经用户 + // 确认就铸出批准,不能只靠"唯一调用者是随包对账"这条纪律。 + isTrustedBundledId: (id) => listBuiltinSeedIds(builtinSeedRootDirs()).includes(id), log, }); getGhostSetupManifestTracker().seed(managerSingleton.list()); @@ -2595,6 +2656,8 @@ function throwInstallError(rejection: InstallRejection): never { throwIpcError('NOT_FOUND', rejection.reason); case 'command-conflict': throwIpcError('GHOST_COMMAND_CONFLICT', rejection.reason); + case 'state-changed': + throwIpcError('PRECONDITION_FAILED', rejection.reason); default: throwIpcError('INTERNAL', rejection.reason); } @@ -2607,6 +2670,8 @@ function throwUninstallError(rejection: UninstallRejection): never { throwIpcError('INVALID_PARAMS', rejection.reason); case 'not-installed': throwIpcError('NOT_FOUND', rejection.reason); + case 'approval-required': + throwIpcError('PRECONDITION_FAILED', rejection.reason); default: throwIpcError('INTERNAL', rejection.reason); } @@ -2660,6 +2725,7 @@ export async function installOrUpdateMarketGhostPackage( expected: { ghostId: string; version: string; + expectedInstalledApproval?: string; }, ): Promise { const mutationOwner = captureGhostMutationOwner(); @@ -2703,7 +2769,15 @@ export async function installOrUpdateMarketGhostPackage( getGhostErrandSlot().clearGhost(expected.ghostId); let result: Awaited>; try { - result = await manager.update(cindyFilePath); + if (!expected.expectedInstalledApproval) { + throwIpcError( + 'PRECONDITION_FAILED', + 'Plugin approval state was not bound to the market update', + ); + } + result = await manager.update(cindyFilePath, { + expectedInstalledApproval: expected.expectedInstalledApproval, + }); } catch (error) { spawnIfResident(installed); throw error; @@ -3745,14 +3819,26 @@ export function registerGhostIpc(): void { if (typeof lizFilePath !== 'string' || lizFilePath.trim().length === 0) { throwIpcError('INVALID_PARAMS', 'lizFilePath must be a non-empty string'); } - const expectedPackageSha256 = (opts as { expectedPackageSha256?: unknown } | undefined) - ?.expectedPackageSha256; + const updateOptions = opts as + | { + expectedPackageSha256?: unknown; + expectedInstalledApproval?: unknown; + } + | undefined; + const expectedPackageSha256 = updateOptions?.expectedPackageSha256; + const expectedInstalledApproval = updateOptions?.expectedInstalledApproval; if ( typeof expectedPackageSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(expectedPackageSha256) ) { throwIpcError('INVALID_PARAMS', 'expectedPackageSha256 must come from ghosts:inspect'); } + if (!isGhostInstallApprovalToken(expectedInstalledApproval)) { + throwIpcError( + 'INVALID_PARAMS', + 'expectedInstalledApproval must come from ghosts:list', + ); + } const inspected = await manager.inspect(lizFilePath); if ('rejection' in inspected) throwInstallError(inspected.rejection); if (inspected.packageSha256 !== expectedPackageSha256) { @@ -3767,7 +3853,10 @@ export function registerGhostIpc(): void { getGhostErrandSlot().clearGhost(inspected.manifest.id); let result: Awaited>; try { - result = await manager.update(lizFilePath, { expectedPackageSha256 }); + result = await manager.update(lizFilePath, { + expectedPackageSha256, + expectedInstalledApproval, + }); } catch (err) { // 更新失败:恢复旧版本的常驻 Node 工作进程(如果是 resident 且已启用) if (previousGhost) spawnIfResident(previousGhost); @@ -3823,7 +3912,14 @@ export function registerGhostIpc(): void { // 官方前缀在 inspect 就拒(确认弹窗都不该弹出来),install/update 双保险再拦。 rejectReservedGhostId(result.manifest.id); rejectUnauthorizedTokenBroker(result.manifest); - return result; + return { + manifest: result.manifest, + trust: result.trust, + packageSha256: result.packageSha256, + ...(result.iconDataUrl !== undefined + ? { iconDataUrl: result.iconDataUrl } + : {}), + }; }); ipcMain.handle('ghosts:uninstall', async (_event, id: unknown) => { @@ -4176,6 +4272,9 @@ function scheduleGhostSkillReconcile(): void { const result = await reconcileGhostSkillLinks({ ghosts: getGhostManager().list(), brainRoot: brainRootDir(), + approvalStateRoot: getGhostManager().approvalStateRoot(), + validateApprovedSkillSnapshot: (ghost) => + getGhostManager().verifyApprovedSkillSnapshot(ghost), }); if (result.warnings.length > 0) { log.warn('ghost skill reconcile warnings', { warnings: result.warnings }); diff --git a/apps/desktop/src/main/cindy-brain/runtime/__tests__/GhostRuntime.test.ts b/apps/desktop/src/main/cindy-brain/runtime/__tests__/GhostRuntime.test.ts index cfb73f7802e..c72faaa2df8 100644 --- a/apps/desktop/src/main/cindy-brain/runtime/__tests__/GhostRuntime.test.ts +++ b/apps/desktop/src/main/cindy-brain/runtime/__tests__/GhostRuntime.test.ts @@ -36,6 +36,7 @@ function chipGhost(id = 'demo'): InstalledGhost { }, dir: `/fake/brain/${id}`, enabled: true, + approval: { state: 'approved', revision: '00000000-0000-4000-8000-000000000001' }, }; } diff --git a/apps/desktop/src/main/cindy-brain/skillSlot.ts b/apps/desktop/src/main/cindy-brain/skillSlot.ts index 4673cb282e3..3703a02abf8 100644 --- a/apps/desktop/src/main/cindy-brain/skillSlot.ts +++ b/apps/desktop/src/main/cindy-brain/skillSlot.ts @@ -9,20 +9,31 @@ * - **单一幂等 reconciler**:`reconcileGhostSkillLinks` 以"期望态 vs 实际态" * 对账,不做增量命令式挂链——install/update/启停/卸载全走同一条广播管线 * 触发对账,崩溃残留(悬空链接)下一轮自动自愈。 - * - **链接不复制字节**:共享技能根 `~/.agents/skills/--` 是指向插件 - * 安装目录(brainRoot//)的 junction(win32)/dir symlink。更新插件 - * 时安装目录路径稳定,链接跨版本存活;卸载即断链,由对账回收。 - * - **绝不误伤**:只删"确认为 symlink/junction 且目标落在 cindy-brain 安装根" + * - **链接指向 Host 批准快照,不指向可变安装目录**:共享技能根 + * `~/.agents/skills/--` 是指向**批准状态根**里 + * `skill-snapshots///` 的 junction(win32)/dir symlink。 + * skill 槽是唯一越出沙箱的能力,确认框看到的 SKILL.md 必须就是 Agent 之后 + * 读到的那份,所以装入/更新确认时把技能目录逐字节拷成快照(只收普通文件), + * 链接指快照而不是随后可被改写的 `brainRoot//`。代价是目标路径按 + * revision 变化:每次更新都换一个新目标,靠对账重指(旧 revision 快照在 + * receipt 提交后回收)。卸载即断链,由对账回收。 + * - **两个受管根**:因此本文件同时管理安装根(brainRoot)与批准状态根 + * (approvalStateRoot);两者都必须由调用方给出,漏给会让活链接被判成外来 + * 链接而永不撤链 —— 停用/卸载后技能仍对主 Agent 生效,故 approvalStateRoot + * 是必填项。 + * - **绝不误伤**:只删"确认为 symlink/junction 且目标落在上述两个受管根之一" * 的条目。真实目录(SkillHub 实体技能、用户手放的技能)与外来链接一律不碰, * 占位冲突只 warn 不覆盖(同 shared-global-skills 的冲突哲学)。 * - **`.claude` 扇出与回收都不归这里管**:对账后调 prepareSharedGlobalSkillLinks, * 它负责把 `.agents` 新条目 link 进 `.claude`;我们撤链后留下的 `.claude` * 悬空兼容链接目标指向 `.agents` 受管根,同样由它的 cleanupBrokenManagedLinks - * 回收——职责分界干净:目标在 brainRoot 的链接归本文件,受管根内的归它。 - * - 多账号:brainRoot 是 owner-scoped,`~/.agents/skills` 是全局的。本函数只 - * 管理 realpath 落在**当前 brainRoot** 内的活链接;他 owner 的活链接不碰 - * (与 SkillHub 实体技能同一跨账号可见性现状)。悬空链接只要目标带 - * `cindy-brain` 路径段就回收(断链对所有消费方都是死重,跨 owner 清理防积尘)。 + * 回收——职责分界干净:目标在本文件受管根内的链接归本文件,目标在 `.agents` + * 受管根内的归它。 + * - 多账号:两个受管根都是 owner-scoped,`~/.agents/skills` 是全局的。本函数只 + * 管理 realpath 落在**当前 owner 的受管根**内的活链接;他 owner 的活链接不碰 + * (与 SkillHub 实体技能同一跨账号可见性现状)。悬空链接按结构判据回收 + * (断链对所有消费方都是死重,跨 owner 清理防积尘),判据见 + * `targetLooksGhostManaged`。 */ import { promises as fsp } from 'node:fs'; @@ -85,6 +96,16 @@ interface ReconcileOptions { ghosts: InstalledGhost[]; /** 当前 owner 的插件安装根(userData/.../cindy-brain)。 */ brainRoot: string; + /** + * Host-owned root containing approval-revision-bound skill snapshots. + * 必填:漏给会让指向快照的活链接被判成外来链接而永不撤链(见头注释)。 + */ + approvalStateRoot: string; + /** + * 在把批准快照投影成共享链接前重算其完整内容摘要。必填:只检查 SKILL.md + * frontmatter 拦不住正文/辅助文件被改写,而已有链接目标不变时也不能直接 kept。 + */ + validateApprovedSkillSnapshot: (ghost: InstalledGhost) => Promise; /** 覆盖 home 目录(仅测试)。 */ homeDir?: string; } @@ -107,19 +128,33 @@ async function realPathOrNull(value: string): Promise { } } -/** 断链回收判据:链接名符合 `--` ghost 命名且目标路径带 `cindy-brain` - * 段才视为 ghost 托管(跨 owner 通用)。两条同时满足才动手,避免误伤用户创建 - * 的恰巧含该路径段的外来链接。 */ -function targetLooksGhostManaged(target: string, linkName: string): boolean { +/** + * 断链回收判据:链接名符合 `--` ghost 命名,且目标路径命中我们自己 + * 铺出来的结构 —— 安装根的 `cindy-brain` 段,或批准状态根的 + * `<状态根名>/skill-snapshots` **相邻两段**。两条同时满足才动手。 + * + * 状态根名要求相邻匹配而不是单看 `skill-snapshots`:后者是个通用名字,单独匹配 + * 会误删用户自己在别处的 `skill-snapshots/` 下建的外来悬空链接。owner 段在路径 + * 中间,所以这条判据仍跨 owner 通用。 + */ +function targetLooksGhostManaged( + target: string, + linkName: string, + approvalStateDirName: string, +): boolean { if (!linkName.includes('--')) return false; - return target - .split(/[\\/]/) - .some((segment) => segment.toLowerCase() === 'cindy-brain'); + const segments = target.split(/[\\/]/).map((segment) => segment.toLowerCase()); + const stateDirName = approvalStateDirName.toLowerCase(); + return segments.some( + (segment, index) => + segment === 'cindy-brain' || + (segment === stateDirName && segments[index + 1] === 'skill-snapshots'), + ); } /** - * 共享技能根对账:期望态(启用且带 skill 槽的插件)vs 实际态(根下目标落在 - * brainRoot 的链接)。幂等、best-effort、不 throw;warnings 交调用方记日志。 + * 共享技能根对账:期望态(启用、已批准且带 skill 槽的插件)vs 实际态(根下目标 + * 落在受管根内的链接)。幂等、best-effort、不 throw;warnings 交调用方记日志。 */ export async function reconcileGhostSkillLinks( opts: ReconcileOptions, @@ -131,7 +166,12 @@ export async function reconcileGhostSkillLinks( const { sharedSkillsDir } = sharedGlobalSkillsPaths(opts.homeDir); // realpath 兼容 brainRoot 或其祖先是 symlink 的场景(relocated home dir)—— // 活链接 realpath 后必须与归一化的物理根比较才可靠。resolve 失败退化到词法。 - const brainRootCompare = (await realPathOrNull(opts.brainRoot)) ?? normalizeForCompare(opts.brainRoot); + const managedRootCompares = [ + (await realPathOrNull(opts.brainRoot)) ?? normalizeForCompare(opts.brainRoot), + (await realPathOrNull(opts.approvalStateRoot)) ?? + normalizeForCompare(opts.approvalStateRoot), + ]; + const approvalStateDirName = path.basename(path.resolve(opts.approvalStateRoot)); try { await fsp.mkdir(sharedSkillsDir, { recursive: true }); @@ -144,9 +184,30 @@ export async function reconcileGhostSkillLinks( // first-wins + warn 兜底(name 正则已保证结构上不可能,防御纵深)。 const desired = new Map(); const eligible = opts.ghosts - .filter((g) => g.enabled && g.manifest.slots.includes('skill') && g.manifest.skill) + .filter( + (g) => + g.enabled && + g.approval.state === 'approved' && + Boolean(g.approvedSkillRoot) && + g.manifest.slots.includes('skill') && + g.manifest.skill, + ) .sort((a, b) => a.manifest.id.localeCompare(b.manifest.id)); for (const ghost of eligible) { + let snapshotValid = false; + try { + snapshotValid = await opts.validateApprovedSkillSnapshot(ghost); + } catch (err) { + warnings.push( + `批准技能快照校验失败 ${ghost.manifest.id}:${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + if (!snapshotValid) { + warnings.push(`批准技能快照字节不可信,撤链并等待修复:${ghost.manifest.id}`); + continue; + } const sortedItems = [...(ghost.manifest.skill?.items ?? [])].sort((a, b) => a.name.localeCompare(b.name), ); @@ -157,7 +218,7 @@ export async function reconcileGhostSkillLinks( continue; } desired.set(linkName, { - target: path.join(opts.brainRoot, ghost.manifest.id, ...item.dir.split('/')), + target: path.join(ghost.approvedSkillRoot!, ...item.dir.split('/')), item, }); } @@ -179,8 +240,8 @@ export async function reconcileGhostSkillLinks( const linkPath = path.join(sharedSkillsDir, entName); const real = await realPathOrNull(linkPath); if (real !== null) { - // 活链接:目标在当前 brainRoot 内才归我们管;他 owner / 外来链接不碰。 - if (!isSameOrInside(real, brainRootCompare)) continue; + // 活链接:目标在当前 owner 的受管根内才归我们管;他 owner / 外来链接不碰。 + if (!managedRootCompares.some((root) => isSameOrInside(real, root))) continue; const want = desired.get(entName); const wantCompare = want ? ((await realPathOrNull(want.target)) ?? normalizeForCompare(want.target)) @@ -192,7 +253,7 @@ export async function reconcileGhostSkillLinks( } continue; } - // 断链:目标带 cindy-brain 段即回收(含他 owner 与登出态临时根的残留)。 + // 断链:目标命中受管结构即回收(含他 owner 与登出态临时根的残留)。 let rawTarget: string; try { rawTarget = await fsp.readlink(linkPath); @@ -202,7 +263,9 @@ export async function reconcileGhostSkillLinks( const absTarget = path.isAbsolute(rawTarget) ? rawTarget : path.resolve(sharedSkillsDir, rawTarget); - if (targetLooksGhostManaged(absTarget, entName)) toRemove.push(entName); + if (targetLooksGhostManaged(absTarget, entName, approvalStateDirName)) { + toRemove.push(entName); + } } // —— 删除步:先撤旧再建新,防"改目标"落进冲突分支。 diff --git a/apps/desktop/src/main/ghost-panel-window/__tests__/controller.test.ts b/apps/desktop/src/main/ghost-panel-window/__tests__/controller.test.ts index f0b0fbb8c30..040787fc1bc 100644 --- a/apps/desktop/src/main/ghost-panel-window/__tests__/controller.test.ts +++ b/apps/desktop/src/main/ghost-panel-window/__tests__/controller.test.ts @@ -64,7 +64,12 @@ function ghost(id: string, opts: { enabled?: boolean; position?: 'left' | 'tab' ...(opts.position !== undefined ? { position: opts.position } : {}), }, }; - return { manifest, dir: `/fake/${id}`, enabled: opts.enabled ?? true }; + return { + manifest, + dir: `/fake/${id}`, + enabled: opts.enabled ?? true, + approval: { state: 'approved', revision: '00000000-0000-4000-8000-000000000001' }, + }; } function makeHarness(detachableIds: Set) { diff --git a/apps/desktop/src/main/mcp-integrations/ghost.ts b/apps/desktop/src/main/mcp-integrations/ghost.ts index e4ec837b23e..173ca1706bd 100644 --- a/apps/desktop/src/main/mcp-integrations/ghost.ts +++ b/apps/desktop/src/main/mcp-integrations/ghost.ts @@ -968,7 +968,10 @@ export function getCindyGhostsMcpDeps(sessionCtx?: LiziMcpSessionContext): Cindy }, async forgeScaffold(request): Promise { const sessionWorkdir = resolveSessionContext()?.workingDir ?? null; - const result = await scaffoldGhostDir(request, { sessionWorkdir }); + const result = await scaffoldGhostDir(request, { + sessionWorkdir, + forbiddenRootDirs: getGhostManager().managedRootDirs(), + }); if (result.ok) { log.info('ghost forge scaffold created', { dir: result.dir, @@ -979,7 +982,9 @@ export function getCindyGhostsMcpDeps(sessionCtx?: LiziMcpSessionContext): Cindy return result; }, async forgePack({ dir }): Promise { - const packed = await packGhostDir(dir); + const packed = await packGhostDir(dir, { + forbiddenRootDirs: getGhostManager().managedRootDirs(), + }); if (!packed.ok) return packed; // 与双击 .cindy 同一条转交通道:renderer 弹标准确认框(同 id 已装则 // 自动转"更新 vX → vY"),用户点头才真装。 diff --git a/apps/desktop/src/main/plugin-market/__tests__/service.test.ts b/apps/desktop/src/main/plugin-market/__tests__/service.test.ts index 3f0c5256ed6..a9a51711082 100644 --- a/apps/desktop/src/main/plugin-market/__tests__/service.test.ts +++ b/apps/desktop/src/main/plugin-market/__tests__/service.test.ts @@ -9,6 +9,10 @@ const runtime = vi.hoisted(() => ({ manifest: Record; dir: string; enabled: boolean; + approval?: { + state: 'approved'; + revision: string; + }; }>, install: vi.fn(), uninstall: vi.fn(), @@ -45,7 +49,16 @@ vi.mock('../../logger.js', () => ({ createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }), })); vi.mock('../../cindy-brain/index.js', () => ({ - getGhostManager: () => ({ list: () => runtime.ghosts }), + getGhostManager: () => ({ + list: () => + runtime.ghosts.map((ghost) => ({ + ...ghost, + approval: ghost.approval ?? { + state: 'approved', + revision: '00000000-0000-4000-8000-000000000001', + }, + })), + }), isGhostAvailableForActiveSession: vi.fn(() => runtime.accountGhostAvailable), installOrUpdateMarketGhostPackage: runtime.install, isBuiltinGhostRemovedByUser: (id: string) => runtime.builtinRemoved.has(id), @@ -63,6 +76,8 @@ import type { PluginMarketApi } from '../api'; const roots: string[] = []; const PLUGIN_ID = `c${'a'.repeat(24)}`; +const APPROVED_INSTALL_TOKEN = + 'approved:00000000-0000-4000-8000-000000000001'; afterEach(() => { runtime.ghosts = []; @@ -690,6 +705,7 @@ describe('PluginMarketService migration and defaultInstall', () => { await expect( h.service.install(item.id, { expectedReleaseId: item.currentRelease.id, + expectedInstalledApproval: APPROVED_INSTALL_TOKEN, allowPermissionExpansion: true, }), ).resolves.toMatchObject({ @@ -725,7 +741,77 @@ describe('PluginMarketService migration and defaultInstall', () => { }); await expect( - h.service.install(item.id, { expectedReleaseId: item.currentRelease.id }), + h.service.install(item.id, { + expectedReleaseId: item.currentRelease.id, + expectedInstalledApproval: APPROVED_INSTALL_TOKEN, + }), + ).rejects.toThrow('[PRECONDITION_FAILED]'); + expect(runtime.install).not.toHaveBeenCalled(); + }); + + it('rejects a first install when the same Plugin appears during download', async () => { + const item = summary(); + const h = harness([item]); + h.api.download.mockImplementationOnce(async () => { + runtime.ghosts = [ + { + manifest: manifest(), + dir: '/userData/cindy-brain/cindy-test', + enabled: true, + }, + ]; + return { + url: 'https://downloads.test.invalid/plugin.cindy', + expiresAt: '2099-01-01T00:00:00.000Z', + sha256: item.currentRelease.sha256, + sizeBytes: item.currentRelease.sizeBytes, + }; + }); + + await expect( + h.service.install(item.id, { + expectedReleaseId: item.currentRelease.id, + expectedInstalledApproval: APPROVED_INSTALL_TOKEN, + }), + ).rejects.toThrow('[PRECONDITION_FAILED]'); + expect(runtime.install).not.toHaveBeenCalled(); + }); + + it('rejects an update when the approved revision changes during download', async () => { + const item = summary({ + currentRelease: { ...summary().currentRelease, version: '2.0.0' }, + }); + const h = harness([item]); + h.ledger.upsertInstallation({ + ...recordForTest(item), + releaseId: 'release-0', + version: '1.0.0', + }); + runtime.ghosts = [ + { + manifest: manifest('cindy-test', '1.0.0'), + dir: '/userData/cindy-brain/cindy-test', + enabled: true, + }, + ]; + h.api.download.mockImplementationOnce(async () => { + runtime.ghosts[0]!.approval = { + state: 'approved', + revision: '00000000-0000-4000-8000-000000000002', + }; + return { + url: 'https://downloads.test.invalid/plugin.cindy', + expiresAt: '2099-01-01T00:00:00.000Z', + sha256: item.currentRelease.sha256, + sizeBytes: item.currentRelease.sizeBytes, + }; + }); + + await expect( + h.service.install(item.id, { + expectedReleaseId: item.currentRelease.id, + expectedInstalledApproval: APPROVED_INSTALL_TOKEN, + }), ).rejects.toThrow('[PRECONDITION_FAILED]'); expect(runtime.install).not.toHaveBeenCalled(); }); diff --git a/apps/desktop/src/main/plugin-market/registerIpc.ts b/apps/desktop/src/main/plugin-market/registerIpc.ts index 1ae767143c8..cf758fe2510 100644 --- a/apps/desktop/src/main/plugin-market/registerIpc.ts +++ b/apps/desktop/src/main/plugin-market/registerIpc.ts @@ -1,6 +1,7 @@ import { ipcMain } from 'electron'; import { isIpcError } from '../../shared/ipc-errors.js'; +import { isGhostInstallApprovalToken } from '../../shared/ghost.js'; import { setGhostUninstallLedgerPreparer } from '../cindy-brain/index.js'; import { createLogger } from '../logger.js'; import { assertTrustedAppRendererEvent } from '../security/trustedAppRenderer.js'; @@ -58,14 +59,28 @@ export function registerPluginMarketIpc(): void { typeof options === 'object' && options !== null ? (options as { expectedReleaseId?: unknown; + expectedInstalledApproval?: unknown; allowPermissionExpansion?: unknown; }) : null; const expectedReleaseId = requireString(obj?.expectedReleaseId, 'expectedReleaseId'); + const expectedInstalledApproval = obj?.expectedInstalledApproval; + if ( + expectedInstalledApproval !== undefined && + !isGhostInstallApprovalToken(expectedInstalledApproval) + ) { + throwIpcError( + 'INVALID_PARAMS', + 'expectedInstalledApproval must come from ghosts:list', + ); + } const allowPermissionExpansion = obj?.allowPermissionExpansion === true; return invokePluginMarket(() => service().install(requireString(pluginId, 'pluginId'), { expectedReleaseId, + ...(expectedInstalledApproval !== undefined + ? { expectedInstalledApproval } + : {}), allowPermissionExpansion, }), ); diff --git a/apps/desktop/src/main/plugin-market/service.ts b/apps/desktop/src/main/plugin-market/service.ts index 4e29b133451..adb3f1aeda8 100644 --- a/apps/desktop/src/main/plugin-market/service.ts +++ b/apps/desktop/src/main/plugin-market/service.ts @@ -10,7 +10,8 @@ import { import { app, type WebContents } from 'electron'; import { - diffGhostPermissionItems, + diffInstalledGhostPermissionItems, + ghostInstallApprovalToken, isOfficialGhostId, validateGhostManifest, type InstalledGhost, @@ -234,6 +235,7 @@ export class PluginMarketService { /** Renderer 确认框实际展示过的 release。Main 重拉详情后必须仍一致, * 否则用户审阅 A、实际安装/启用 B(review P1)。 */ expectedReleaseId: string; + expectedInstalledApproval?: string; allowPermissionExpansion?: boolean; }, ): Promise<{ ghost: InstalledGhost }> { @@ -266,12 +268,13 @@ export class PluginMarketService { } const existing = getGhostManager() .list() - .some((ghost) => ghost.manifest.id === plugin.ghostId); + .find((ghost) => ghost.manifest.id === plugin.ghostId); return { ghost: await this.installDetail( plugin, { - expectedInstalled: existing, + expectedInstalled: Boolean(existing), + expectedInstalledApproval: options.expectedInstalledApproval, allowPermissionExpansion: options.allowPermissionExpansion === true, }, owner, @@ -344,6 +347,7 @@ export class PluginMarketService { plugin: VisiblePluginDetail, options: { allowPermissionExpansion?: boolean; + expectedInstalledApproval?: string; /** 确认操作时的安装意图;下载窗口期目标被另一窗口卸载时拒绝滑入首装。 */ expectedInstalled: boolean; } = { expectedInstalled: false }, @@ -361,6 +365,16 @@ export class PluginMarketService { if (existing && (!currentRecord?.installed || currentRecord.pluginId !== plugin.id)) { throwIpcError('ALREADY_EXISTS', 'A local Plugin already uses this Plugin ID'); } + if ( + existing && + ghostInstallApprovalToken(existing.approval) !== + options.expectedInstalledApproval + ) { + throwIpcError( + 'PRECONDITION_FAILED', + 'Plugin approval state changed after permission review', + ); + } const compatible = validateGhostManifest(plugin.currentRelease.manifest); if (!compatible.ok) { @@ -368,7 +382,7 @@ export class PluginMarketService { } if ( existing && - diffGhostPermissionItems(existing.manifest, compatible.manifest).added.length > 0 && + diffInstalledGhostPermissionItems(existing, compatible.manifest).added.length > 0 && options.allowPermissionExpansion !== true ) { throwIpcError('PRECONDITION_FAILED', 'Plugin permissions changed and require review'); @@ -396,7 +410,7 @@ export class PluginMarketService { if (options.expectedInstalled) { const stillInstalled = getGhostManager() .list() - .some((ghost) => ghost.manifest.id === plugin.ghostId); + .find((ghost) => ghost.manifest.id === plugin.ghostId); if (!stillInstalled) { // 用户确认的是更新;下载期间若另一窗口已卸载目标,不能把操作 // 降级成首装并自动启用。按状态变化拒绝,由 renderer 刷新重试。 @@ -405,12 +419,36 @@ export class PluginMarketService { 'Plugin was uninstalled while the update was downloading', ); } + if ( + ghostInstallApprovalToken(stillInstalled.approval) !== + options.expectedInstalledApproval + ) { + throwIpcError( + 'PRECONDITION_FAILED', + 'Plugin approval state changed while the update was downloading', + ); + } + } else if ( + getGhostManager() + .list() + .some((ghost) => ghost.manifest.id === plugin.ghostId) + ) { + // 用户确认的是首装;下载期间出现同 id 安装时,不能静默把它升级为 + // “更新现有插件”。即使 Renderer 恰好带了一个可匹配 token,也必须 + // 刷新详情并按更新流程重新审阅。 + throwIpcError( + 'PRECONDITION_FAILED', + 'Plugin was installed while the package was downloading', + ); } // 市场首装一律装完即开(2026-07-26 定案,见 installOrUpdateMarketGhostPackage); // 已装过则走原位更新,唤醒/沉睡状态延续当前值。 const ghost = await installOrUpdateMarketGhostPackage(tempPath, { ghostId: plugin.ghostId, version: plugin.currentRelease.version, + ...(options.expectedInstalledApproval + ? { expectedInstalledApproval: options.expectedInstalledApproval } + : {}), }); // Once the package directory is committed, finish provenance against the // owner captured at operation start even if the active session changes. diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 35e9fdc4f5f..320b09caa8c 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -852,7 +852,10 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('ghosts:install', lizFilePath, opts), update: ( lizFilePath: string, - opts: { expectedPackageSha256: string }, + opts: { + expectedPackageSha256: string; + expectedInstalledApproval: string; + }, ): Promise<{ ghost: unknown }> => ipcRenderer.invoke('ghosts:update', lizFilePath, opts), cindyPrefsSync: ( @@ -990,7 +993,11 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('plugin-market:detail', pluginId), install: ( pluginId: string, - options: { expectedReleaseId: string; allowPermissionExpansion?: boolean }, + options: { + expectedReleaseId: string; + expectedInstalledApproval?: string; + allowPermissionExpansion?: boolean; + }, ): Promise<{ ghost: import('../shared/ghost').InstalledGhost }> => ipcRenderer.invoke('plugin-market:install', pluginId, options), uninstall: (pluginId: string): Promise<{ ok: true }> => diff --git a/apps/desktop/src/renderer/__tests__/ghostCommandDecoration.test.ts b/apps/desktop/src/renderer/__tests__/ghostCommandDecoration.test.ts index 7ab7ef1e8f8..fa98706fa45 100644 --- a/apps/desktop/src/renderer/__tests__/ghostCommandDecoration.test.ts +++ b/apps/desktop/src/renderer/__tests__/ghostCommandDecoration.test.ts @@ -65,6 +65,7 @@ function makeGhost( manifest, dir: '', enabled: opts.enabled ?? true, + approval: { state: 'approved', revision: '00000000-0000-4000-8000-000000000001' }, ...(opts.icon ? { iconDataUrl: opts.icon } : {}), }; } diff --git a/apps/desktop/src/renderer/__tests__/ghostComposerPlacement.test.ts b/apps/desktop/src/renderer/__tests__/ghostComposerPlacement.test.ts index dd78e8eb365..54b224d0ebb 100644 --- a/apps/desktop/src/renderer/__tests__/ghostComposerPlacement.test.ts +++ b/apps/desktop/src/renderer/__tests__/ghostComposerPlacement.test.ts @@ -32,6 +32,7 @@ function ghost(command: string, id = command, enabled = true): InstalledGhost { }, dir: `/tmp/${id}`, enabled, + approval: { state: 'approved', revision: '00000000-0000-4000-8000-000000000001' }, }; } diff --git a/apps/desktop/src/renderer/__tests__/planModeComposerEntry.test.ts b/apps/desktop/src/renderer/__tests__/planModeComposerEntry.test.ts index 6855b62da99..925556014ae 100644 --- a/apps/desktop/src/renderer/__tests__/planModeComposerEntry.test.ts +++ b/apps/desktop/src/renderer/__tests__/planModeComposerEntry.test.ts @@ -50,6 +50,7 @@ const installedPlugin: InstalledGhost = { }, dir: '/tmp/cindy-art', enabled: true, + approval: { state: 'approved', revision: '00000000-0000-4000-8000-000000000001' }, }; const installedMermaidPlugin: InstalledGhost = { @@ -66,6 +67,7 @@ const installedMermaidPlugin: InstalledGhost = { }, dir: '/tmp/cindy-mermaid', enabled: true, + approval: { state: 'approved', revision: '00000000-0000-4000-8000-000000000001' }, }; afterEach(() => { diff --git a/apps/desktop/src/renderer/cindy-brain/__tests__/GhostPanelBubbleLayer.test.tsx b/apps/desktop/src/renderer/cindy-brain/__tests__/GhostPanelBubbleLayer.test.tsx index 8861460c18f..3bb12d7a61f 100644 --- a/apps/desktop/src/renderer/cindy-brain/__tests__/GhostPanelBubbleLayer.test.tsx +++ b/apps/desktop/src/renderer/cindy-brain/__tests__/GhostPanelBubbleLayer.test.tsx @@ -36,7 +36,12 @@ function ghost(id: string, enabled = true): InstalledGhost { slots: ['panel'], panel: { title: `${id} 面板`, html: 'panel.html' }, }; - return { manifest, dir: `/fake/${id}`, enabled }; + return { + manifest, + dir: `/fake/${id}`, + enabled, + approval: { state: 'approved', revision: '00000000-0000-4000-8000-000000000001' }, + }; } function stubGhostsBridge(ghosts: InstalledGhost[]): void { diff --git a/apps/desktop/src/renderer/cindy-brain/__tests__/ghostPanels.test.tsx b/apps/desktop/src/renderer/cindy-brain/__tests__/ghostPanels.test.tsx index f555426fb5c..4603ad0774a 100644 --- a/apps/desktop/src/renderer/cindy-brain/__tests__/ghostPanels.test.tsx +++ b/apps/desktop/src/renderer/cindy-brain/__tests__/ghostPanels.test.tsx @@ -26,7 +26,12 @@ function ghost(id: string, panel?: GhostManifest['panel'] | null, enabled = true slots: panel === null ? ['tool'] : ['panel'], ...(panel === null ? {} : { panel: panel ?? { title: id, html: 'panel.html' } }), }; - return { manifest, dir: `/fake/${id}`, enabled }; + return { + manifest, + dir: `/fake/${id}`, + enabled, + approval: { state: 'approved', revision: '00000000-0000-4000-8000-000000000001' }, + }; } afterEach(() => { diff --git a/apps/desktop/src/renderer/cindy-brain/__tests__/ghostPluginViewModel.test.ts b/apps/desktop/src/renderer/cindy-brain/__tests__/ghostPluginViewModel.test.ts index 76570075a61..41a821e0d8b 100644 --- a/apps/desktop/src/renderer/cindy-brain/__tests__/ghostPluginViewModel.test.ts +++ b/apps/desktop/src/renderer/cindy-brain/__tests__/ghostPluginViewModel.test.ts @@ -54,6 +54,7 @@ function installed(overrides: Partial = {}): InstalledGhost { manifest: manifest(), dir: '/tmp/cindy-brain/xd-mivo', enabled: true, + approval: { state: 'approved', revision: '00000000-0000-4000-8000-000000000001' }, ...overrides, }; } @@ -101,6 +102,7 @@ describe('ghostPluginViewModel', () => { version: '1', enabled: true, canUse: true, + approvalState: 'approved', }, { id: 'lizi-mivo', @@ -109,6 +111,7 @@ describe('ghostPluginViewModel', () => { version: '1', enabled: true, canUse: true, + approvalState: 'approved', }, { id: 'slack', @@ -117,6 +120,7 @@ describe('ghostPluginViewModel', () => { version: '1', enabled: true, canUse: true, + approvalState: 'approved', }, ] satisfies GhostPluginListItem[]; @@ -212,6 +216,16 @@ describe('ghostPluginViewModel', () => { expect(item).not.toHaveProperty('whenToUse'); }); + it('carries the Host approval state so the list can explain an unrunnable install', () => { + expect(toGhostPluginListItem(installed()).approvalState).toBe('approved'); + expect( + toGhostPluginListItem(installed({ approval: { state: 'legacy-unapproved' } })).approvalState, + ).toBe('legacy-unapproved'); + expect( + toGhostPluginDetail(installed({ approval: { state: 'invalid' } })).approvalState, + ).toBe('invalid'); + }); + it('derives detail permissions and runtime declarations from the manifest', () => { const detail = toGhostPluginDetail(installed()); diff --git a/apps/desktop/src/renderer/cindy-brain/__tests__/ghostTabPlugins.test.tsx b/apps/desktop/src/renderer/cindy-brain/__tests__/ghostTabPlugins.test.tsx index 99fd1a5f850..eaaffd1bca6 100644 --- a/apps/desktop/src/renderer/cindy-brain/__tests__/ghostTabPlugins.test.tsx +++ b/apps/desktop/src/renderer/cindy-brain/__tests__/ghostTabPlugins.test.tsx @@ -27,7 +27,12 @@ function ghost(id: string, panel?: GhostManifest['panel'], enabled = true): Inst slots: ['panel'], panel: panel ?? { html: 'panel.html', position: 'tab' }, }; - return { manifest, dir: `/fake/${id}`, enabled }; + return { + manifest, + dir: `/fake/${id}`, + enabled, + approval: { state: 'approved', revision: '00000000-0000-4000-8000-000000000001' }, + }; } afterEach(() => { diff --git a/apps/desktop/src/renderer/cindy-brain/__tests__/installFlow.test.tsx b/apps/desktop/src/renderer/cindy-brain/__tests__/installFlow.test.tsx index 73b1d77288c..c4c1ea5157b 100644 --- a/apps/desktop/src/renderer/cindy-brain/__tests__/installFlow.test.tsx +++ b/apps/desktop/src/renderer/cindy-brain/__tests__/installFlow.test.tsx @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { toast } from '@/lib/toast'; import { confirmAndInstallGhost } from '../installFlow'; +import type { InstalledGhost } from '../../../shared/ghost'; vi.mock('@/lib/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() }, @@ -30,8 +31,10 @@ const baseManifest = { function setupWindow( manifest: object, installResult: { ghost: { manifest: object } } = { ghost: { manifest } }, + installedGhosts: InstalledGhost[] = [], ) { const install = vi.fn(async () => installResult); + const update = vi.fn(async () => installResult); const electronAPI = { ghosts: { inspect: vi.fn(async () => ({ @@ -44,15 +47,16 @@ function setupWindow( reviewed: false, }, })), - listSync: vi.fn(() => ({ ghosts: [] })), + listSync: vi.fn(() => ({ ghosts: installedGhosts })), install, + update, }, }; Object.defineProperty(globalThis, 'window', { value: { electronAPI }, configurable: true, }); - return { install }; + return { install, update }; } function deps(confirm: (options: unknown) => Promise) { @@ -98,6 +102,61 @@ describe('installFlow · 装入确认', () => { }); }); +describe('installFlow · approved update binding', () => { + function installed( + approval: InstalledGhost['approval'], + ): InstalledGhost { + return { + manifest: { + ...baseManifest, + version: '0.9.0', + slots: ['card'], + node: undefined, + }, + dir: '/brain/node-ghost', + enabled: true, + approval, + }; + } + + it('passes the reviewed approved revision to Main', async () => { + const current = installed({ + state: 'approved', + revision: '00000000-0000-4000-8000-000000000001', + }); + const { update } = setupWindow(baseManifest, undefined, [current]); + const confirm = vi.fn(async (_options: unknown) => true); + + await confirmAndInstallGhost('/tmp/node-update.cindy', deps(confirm)); + + expect(update).toHaveBeenCalledWith('/tmp/node-update.cindy', { + expectedPackageSha256: 'a'.repeat(64), + expectedInstalledApproval: + 'approved:00000000-0000-4000-8000-000000000001', + }); + }); + + it('treats every target permission as added when no approved baseline exists', async () => { + const current = installed({ state: 'legacy-unapproved' }); + const { update } = setupWindow(baseManifest, undefined, [current]); + const confirm = vi.fn(async (_options: unknown) => true); + + await confirmAndInstallGhost('/tmp/legacy-update.cindy', deps(confirm)); + + const review = (confirm.mock.calls[0]![0] as { + content: { props: { diff: { added: unknown[]; unchanged: unknown[] } } }; + }).content; + expect(review.props.diff.added.length).toBeGreaterThan(0); + expect(review.props.diff.unchanged).toEqual([]); + expect(update).toHaveBeenCalledWith( + '/tmp/legacy-update.cindy', + expect.objectContaining({ + expectedInstalledApproval: 'legacy-unapproved', + }), + ); + }); +}); + describe('installFlow · tab 型插件「立即开启并打开页签」', () => { const tabManifest = { schemaVersion: 2 as const, diff --git a/apps/desktop/src/renderer/cindy-brain/__tests__/useInstalledGhosts.test.tsx b/apps/desktop/src/renderer/cindy-brain/__tests__/useInstalledGhosts.test.tsx index f11d30d7943..63e26828225 100644 --- a/apps/desktop/src/renderer/cindy-brain/__tests__/useInstalledGhosts.test.tsx +++ b/apps/desktop/src/renderer/cindy-brain/__tests__/useInstalledGhosts.test.tsx @@ -30,6 +30,7 @@ const ghost = (id: string, name = id): InstalledGhost => ({ }, dir: `/brain/${id}`, enabled: true, + approval: { state: 'approved', revision: '00000000-0000-4000-8000-000000000001' }, }); type ChangedCb = (payload: { ghosts: InstalledGhost[] }) => void; diff --git a/apps/desktop/src/renderer/cindy-brain/installErrorKey.ts b/apps/desktop/src/renderer/cindy-brain/installErrorKey.ts index 9d079f58fc1..4e58f05bced 100644 --- a/apps/desktop/src/renderer/cindy-brain/installErrorKey.ts +++ b/apps/desktop/src/renderer/cindy-brain/installErrorKey.ts @@ -15,6 +15,10 @@ export function ghostInstallErrorKey(code: string | undefined): string { return 'settings.ghosts.errors.idReserved'; case 'NOT_FOUND': return 'settings.ghosts.errors.sourceMissing'; + // 批准状态相关的前置条件失败在这条链路上只有一种下一步动作:重新确认权限。 + // 缺少批准记录(启用存量安装)与批准态在确认后变化(更新)都归到这里。 + case 'PRECONDITION_FAILED': + return 'settings.ghosts.errors.approvalRequired'; default: return 'settings.ghosts.errors.generic'; } diff --git a/apps/desktop/src/renderer/cindy-brain/installFlow.tsx b/apps/desktop/src/renderer/cindy-brain/installFlow.tsx index fc0a5df0b33..fd1aea7c443 100644 --- a/apps/desktop/src/renderer/cindy-brain/installFlow.tsx +++ b/apps/desktop/src/renderer/cindy-brain/installFlow.tsx @@ -5,7 +5,8 @@ import { createLogger } from '@/lib/logger'; import { toast } from '@/lib/toast'; import { extractIpcError } from '@/utils/ipcError'; import { - diffGhostPermissionItems, + diffInstalledGhostPermissionItems, + ghostInstallApprovalToken, ghostPermissionItems, type GhostManifest, type GhostTrustInfo, @@ -91,7 +92,7 @@ async function confirmAndRunUpdate( ): Promise { const { t, confirm } = deps; // 权限 diff:只把新增/移除的权限亮给用户,不变项折叠计数。 - const diff = diffGhostPermissionItems(installed.manifest, manifest); + const diff = diffInstalledGhostPermissionItems(installed, manifest); const ok = await confirm({ title: t('settings.ghosts.updateConfirm.title', { name: manifest.name }), description: t('settings.ghosts.updateConfirm.body', { @@ -107,6 +108,7 @@ async function confirmAndRunUpdate( try { const { ghost } = await window.electronAPI.ghosts.update(lizFilePath, { expectedPackageSha256: packageSha256, + expectedInstalledApproval: ghostInstallApprovalToken(installed.approval), }); toast.success( t('settings.ghosts.toast.updated', { diff --git a/apps/desktop/src/renderer/features/plugin/GhostPluginDetailView.tsx b/apps/desktop/src/renderer/features/plugin/GhostPluginDetailView.tsx index d6dbdb013c5..19918615167 100644 --- a/apps/desktop/src/renderer/features/plugin/GhostPluginDetailView.tsx +++ b/apps/desktop/src/renderer/features/plugin/GhostPluginDetailView.tsx @@ -69,6 +69,8 @@ interface GhostPluginDetailViewProps { onToggle: (enabled: boolean) => void; onUse: () => void; onUpdate: () => void; + /** 缺少批准状态时的恢复入口(重新走一次完整权限确认)。 */ + onReapprove: () => void; updateLabel?: string; /** 市场存在新版本时的目标版本号;设置后头部展示显著的更新按钮。 */ updateVersion?: string; @@ -132,6 +134,7 @@ export function GhostPluginDetailView({ onToggle, onUse, onUpdate, + onReapprove, updateLabel, updateVersion, updateBusy = false, @@ -144,7 +147,9 @@ export function GhostPluginDetailView({ const [descriptionExpanded, setDescriptionExpanded] = useState(false); const [descriptionOverflows, setDescriptionOverflows] = useState(false); const descriptionRef = useRef(null); - const enabled = enabledOverride ?? detail.enabled; + // 未批准的安装不可运行:说明现状 + 给恢复入口,不让它看起来只是"被关掉了"。 + const needsReapproval = detail.approvalState !== 'approved'; + const enabled = (enabledOverride ?? detail.enabled) && !needsReapproval; const canUse = enabled && detail.canUse; const cindyCapabilities = detail.cindyCapabilities; const hasConfiguration = @@ -226,7 +231,21 @@ export function GhostPluginDetailView({ className="plugin-detail-actions flex shrink-0 flex-nowrap items-center gap-1.5" style={WINDOW_NO_DRAG_STYLE} > - {updateVersion ? ( + {needsReapproval ? ( + + ) : updateVersion ? ( ) : null} + + {needsReapproval ? ( +
+

+ {t('settings.ghosts.reapproval.noticeTitle')} +

+

+ {t( + detail.approvalState === 'invalid' + ? 'settings.ghosts.reapproval.bodyInvalid' + : 'settings.ghosts.reapproval.bodyLegacy', + )} +

+
+ ) : null} {hasConfiguration ? ( diff --git a/apps/desktop/src/renderer/features/plugin/GhostPluginPage.tsx b/apps/desktop/src/renderer/features/plugin/GhostPluginPage.tsx index 547bff95111..0f1060c1bad 100644 --- a/apps/desktop/src/renderer/features/plugin/GhostPluginPage.tsx +++ b/apps/desktop/src/renderer/features/plugin/GhostPluginPage.tsx @@ -41,7 +41,8 @@ import { getLastWorkingDir, subscribeToLastWorkingDir } from '@/state/lastWorkin import { findSplitChildByPanelKind } from '../../../shared/layoutTree'; import { resolveSystemLocale } from '../../../shared/locale'; import { - diffGhostPermissionItems, + diffInstalledGhostPermissionItems, + ghostInstallApprovalToken, ghostPanelKind, ghostPermissionItems, isOfficialGhostId, @@ -72,6 +73,8 @@ import { GhostPluginIcon } from './GhostPluginIcon'; import { MarketPluginDetailView } from './MarketPluginDetailView'; import { PluginScopePicker, usePluginRecentWorkdirs } from './PluginScopePicker'; import { + ghostReapprovalRoute, + marketReviewTargetsInstalledGhost, orderPluginCatalogItems, pluginPresentationOrigin, pluginUpdateForInstalledVersion, @@ -451,21 +454,35 @@ export function GhostPluginPage() { // 市场更新流程由列表卡片和详情页共用:先取目标 release 的完整 manifest 做 // 权限 diff,经用户确认后才安装,不做静默升级。 + // + // 同版本的 `installed` 也走这里:缺少批准状态的存量安装靠"用市场包重装同一 + // release"恢复,此时权限 diff 会把目标包的全部权限当新增项逐条列出。 const handleMarketUpdate = useCallback( async (ghostId: string) => { const marketItem = marketByGhostId.get(ghostId); - if (!marketItem || marketItem.installState !== 'update-available') return; - const installedGhost = ghosts.find((ghost) => ghost.manifest.id === ghostId) ?? null; + if (!marketItem) return; + const installedGhost = + ghosts.find((ghost) => ghost.manifest.id === ghostId) ?? null; + if ( + !marketReviewTargetsInstalledGhost( + marketItem, + installedGhost?.approval.state, + ) + ) { + return; + } + if (!installedGhost) { + toast.error(t('settings.ghosts.market.errors.stateChanged')); + await refreshMarket(); + return; + } // 列表每张卡都有直达入口,同步互斥防止并发更新互相覆盖忙碌状态。 const marketBusyLease = acquireMarketBusy(marketItem.pluginId); if (!marketBusyLease) return; try { const next = await window.electronAPI.pluginMarket.detail(marketItem.pluginId); if (!isMarketBusyLeaseActive(marketBusyLease)) return; - const diff = diffGhostPermissionItems( - installedGhost?.manifest ?? next.manifest, - next.manifest, - ); + const diff = diffInstalledGhostPermissionItems(installedGhost, next.manifest); const approved = await confirm({ title: t('settings.ghosts.updateConfirm.title', { name: next.name }), description: t('settings.ghosts.updateConfirm.body', { @@ -480,6 +497,7 @@ export function GhostPluginPage() { if (!approved || !isMarketBusyLeaseActive(marketBusyLease)) return; const result = await window.electronAPI.pluginMarket.install(marketItem.pluginId, { expectedReleaseId: next.releaseId, + expectedInstalledApproval: ghostInstallApprovalToken(installedGhost.approval), allowPermissionExpansion: diff.added.length > 0, }); if (!isMarketBusyLeaseActive(marketBusyLease)) return; @@ -519,6 +537,22 @@ export function GhostPluginPage() { await pickAndUpdateGhost(selectedDetail.id, { t, confirm, confirmWithCheckbox }); }, [confirm, confirmWithCheckbox, handleMarketUpdate, selectedDetail, selectedMarketUpdate, t]); + /** + * 缺少批准状态时的恢复入口。市场自有的包重走市场安装确认(重新下载 + 逐项 + * 权限确认);本地包让用户重新选一次 `.cindy`。两条路都落到同一套权限确认, + * 不存在"点一下就悄悄恢复运行"的分支。 + */ + const handleReapprove = useCallback( + async (ghostId: string) => { + if (ghostReapprovalRoute(marketByGhostId.get(ghostId)) === 'market') { + await handleMarketUpdate(ghostId); + return; + } + await pickAndUpdateGhost(ghostId, { t, confirm, confirmWithCheckbox }); + }, + [confirm, confirmWithCheckbox, handleMarketUpdate, marketByGhostId, t], + ); + const handleInstall = useCallback(async () => { const picked = await window.electronAPI.ghosts.pickFile().catch(() => null); if (!picked || 'canceled' in picked) return; @@ -772,7 +806,7 @@ export function GhostPluginPage() { return; } const diff = isUpdate - ? diffGhostPermissionItems(installedGhost!.manifest, marketDetail.manifest) + ? diffInstalledGhostPermissionItems(installedGhost!, marketDetail.manifest) : null; try { const confirmed = await confirm({ @@ -804,6 +838,13 @@ export function GhostPluginPage() { if (!confirmed || !isMarketBusyLeaseActive(marketBusyLease)) return; const result = await window.electronAPI.pluginMarket.install(marketDetail.pluginId, { expectedReleaseId: marketDetail.releaseId, + ...(isUpdate + ? { + expectedInstalledApproval: ghostInstallApprovalToken( + installedGhost!.approval, + ), + } + : {}), ...(isUpdate && diff!.added.length > 0 ? { allowPermissionExpansion: true } : {}), @@ -872,6 +913,7 @@ export function GhostPluginPage() { onToggle={(enabled) => void handleToggle(selectedDetail.id, enabled, selectedDetail.name)} onUse={handleUse} onUpdate={() => void handleUpdate()} + onReapprove={() => void handleReapprove(selectedDetail.id)} updateLabel={ selectedMarketUpdate ? t('settings.ghosts.market.update') @@ -1054,6 +1096,7 @@ export function GhostPluginPage() { ? () => void handleMarketUpdate(catalogItem.item.id) : undefined } + onReapprove={() => void handleReapprove(catalogItem.item.id)} effectiveEnabled={effectiveEnabled( catalogItem.item.id, catalogItem.item.enabled, @@ -1316,6 +1359,7 @@ export function GhostPluginCard({ onUpdate, updateVersion, updateBusy = false, + onReapprove, onToggle, effectiveEnabled, toggleDisabled = false, @@ -1329,12 +1373,16 @@ export function GhostPluginCard({ onUpdate?: () => void; updateVersion?: string; updateBusy?: boolean; + /** 缺少批准状态时的恢复入口;此时它取代"使用"与"更新"按钮。 */ + onReapprove?: () => void; onToggle?: (enabled: boolean) => void; effectiveEnabled?: boolean; toggleDisabled?: boolean; onIconLoadError?: () => void; }) { const { t } = useTranslation(); + // 未批准的安装不可运行:开关不给点(点了 Main 也会拒),主动作换成重新确认。 + const needsReapproval = item.approvalState !== 'approved'; return (
{item.name} - {updateVersion ? ( + {needsReapproval ? ( + + {t('settings.ghosts.reapproval.badge')} + + ) : updateVersion ? ( {t('settings.ghosts.market.updateAvailable')} @@ -1389,12 +1441,30 @@ export function GhostPluginCard({ {onToggle ? ( ) : null} - {updateVersion && onUpdate ? ( + {needsReapproval ? ( + onReapprove ? ( + + ) : null + ) : updateVersion && onUpdate ? (