diff --git a/plugins/git-worktree-cockpit/CHANGELOG.md b/plugins/git-worktree-cockpit/CHANGELOG.md new file mode 100644 index 00000000..17055074 --- /dev/null +++ b/plugins/git-worktree-cockpit/CHANGELOG.md @@ -0,0 +1,12 @@ +# 更新日志 + +## 0.1.0 + +- 引入只读 Git 工作树、分支及状态快照。 +- 所有写操作仅生成只读预案,不执行 Git 变更。 +- 新增只读取当前界面授权仓库的智能体/MCP 快照工具;其他工作树不执行 `status`,响应不暴露绝对路径。 +- 收紧空对象工具输入,并统一授权仓库、Git 解析与检查失败时的撤权及脱敏错误。 +- 将插件界面、状态提示、对话框与 Markdown 快照完整本地化为简体中文。 +- 为未压缩 `dist` 增加递归 14.5 MB(14,500,000 字节)大小门禁,并在验证时打印精确字节数。 +- 在真实 Git 检查前后复验包含 ctime 的仓库身份,任一步失败都会撤销授权,MCP 错误不包含路径。 +- 复制、保存与 MCP 使用稳定匿名的可分享投影;Markdown 转义 HTML/表格元字符,保存以跨平台“仅新建”策略拒绝覆盖已有文件。 diff --git a/plugins/git-worktree-cockpit/README.md b/plugins/git-worktree-cockpit/README.md new file mode 100644 index 00000000..b4f3fbc6 --- /dev/null +++ b/plugins/git-worktree-cockpit/README.md @@ -0,0 +1,19 @@ +# Git 工作树驾驶舱 + +v0.1 是强只读插件。它只在用户选择并获得短期授权的普通 Git 仓库中安全探测 Git,通过固定的 `git worktree list` 与 `status --porcelain` 参数读取状态,导出 Markdown/JSON 快照;裸仓库暂不支持。 + +不会执行暂存、清理、重置、移除、强制操作,也不会创建或删除工作树。界面里的创建入口只会给出只读预案,明确不会运行命令。所有 Git 可执行文件必须是已验证的绝对路径,调用始终为 `shell:false`。 + +执行 Git 前后都会重新核对已授权仓库的 realpath 与包含 ctime 的文件身份,并使用无可选锁、禁用 fsmonitor 的只读参数;任一步失败都会撤销授权。Git 不能绑定一个已打开的仓库目录描述符执行,因此同账户恶意并发替换仍是平台级边界;插件不将此描述为完全无竞态。 + +## 验证 + +`npm test && npm run build` + +只读 Git 参数、三平台路径与可执行文件候选、生命周期边界、源码/产物一致性和 Chromium 渲染已验证;构建会递归统计未压缩 `dist`、打印精确字节数,并在超过 14.5 MB(14,500,000 字节)时失败。真实 Windows、macOS、Linux ZTools 宿主加载与对话框尚未测试,Windows/Linux 的 Git 进程执行仍属于契约测试。 + +## 智能体 / MCP + +ZTools 2.4+ 会把清单短名 `snapshot_approved` 暴露为 `git_worktree_cockpit_snapshot_approved`。智能体只能读取用户最近在插件界面选择的当前仓库授权;工具没有路径、授权 ID、Git 可执行文件或操作参数,也不提供任何写操作。旧宿主没有 `registerTool` 时仍可正常使用原有界面。 + +MCP 快照会在 Git 检查前后重新验证授权仓库,只对该仓库自身执行 `status`。虽然 `git worktree list` 必然读取仓库记录的工作树元数据,但不会再对未授权的其他工作树执行命令。MCP、复制与保存共用独立的可分享投影:仓库、工作树和分支使用稳定匿名标签,只保留布尔状态、超前/落后与变更条目数量,不返回名称、目录结构、用户名、变更文件、提交标识、绝对路径或原始错误文本。Markdown 会转义 HTML 与表格元字符。保存采用跨平台的原子“仅新建”策略,明确拒绝覆盖已有路径。处理器会拒绝任何字段、Symbol、访问器和异常原型;授权仓库失效或 Git 检查失败时会撤销授权并返回稳定错误。ZTools MCP 的 1 MiB 请求体边界下,本工具只接受空对象。 diff --git a/plugins/git-worktree-cockpit/index.html b/plugins/git-worktree-cockpit/index.html new file mode 100644 index 00000000..68581822 --- /dev/null +++ b/plugins/git-worktree-cockpit/index.html @@ -0,0 +1,28 @@ +Git 工作树驾驶舱
只读工作台 / v0.1

Git 工作树驾驶舱

选择一个本地 Git 仓库。所有命令固定、使用绝对路径执行且 shell:false;v0.1 不会改动任何工作树。

分支图

工作树分支同步状态
diff --git a/plugins/git-worktree-cockpit/logo.svg b/plugins/git-worktree-cockpit/logo.svg new file mode 100644 index 00000000..dab7c6d8 --- /dev/null +++ b/plugins/git-worktree-cockpit/logo.svg @@ -0,0 +1 @@ + diff --git a/plugins/git-worktree-cockpit/package-lock.json b/plugins/git-worktree-cockpit/package-lock.json new file mode 100644 index 00000000..e193cec9 --- /dev/null +++ b/plugins/git-worktree-cockpit/package-lock.json @@ -0,0 +1 @@ +{ "name": "git-worktree-cockpit", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "git-worktree-cockpit", "version": "0.1.0", "engines": { "node": ">=16" } } } } diff --git a/plugins/git-worktree-cockpit/package.json b/plugins/git-worktree-cockpit/package.json new file mode 100644 index 00000000..5a51ef49 --- /dev/null +++ b/plugins/git-worktree-cockpit/package.json @@ -0,0 +1,8 @@ +{ + "name": "git-worktree-cockpit", + "version": "0.1.0", + "private": true, + "description": "Read-only local Git worktree cockpit for ZTools.", + "scripts": { "test": "node --test tests/*.test.cjs", "build": "npm test && node scripts/build.mjs && node scripts/verify-dist.mjs", "verify-dist": "node scripts/verify-dist.mjs" }, + "engines": { "node": ">=16" } +} diff --git a/plugins/git-worktree-cockpit/plugin.json b/plugins/git-worktree-cockpit/plugin.json new file mode 100644 index 00000000..0406a8f4 --- /dev/null +++ b/plugins/git-worktree-cockpit/plugin.json @@ -0,0 +1,41 @@ +{ + "name": "git-worktree-cockpit", + "title": "Git 工作树驾驶舱", + "description": "本地只读查看 Git 工作树、分支与未提交状态,生成可分享快照。", + "version": "0.1.0", + "author": "harris", + "main": "index.html", + "preload": "preload/services.cjs", + "logo": "logo.svg", + "platform": [ + "darwin", + "win32", + "linux" + ], + "categories": [ + "development" + ], + "features": [ + { + "code": "git-worktree-cockpit-open", + "explain": "查看已授权 Git 仓库的工作树状态", + "icon": "logo.svg", + "cmds": [ + "查看 Git 工作树", + "Git 工作树状态", + "分支驾驶舱" + ] + } + ], + "tools": { + "snapshot_approved": { + "title": "读取已授权 Git 工作树快照", + "description": "只读检查用户当前在插件界面授权的 Git 仓库,不接受路径,不访问或返回其他工作树的绝对路径。", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } + } + } +} diff --git a/plugins/git-worktree-cockpit/preload/git-core.cjs b/plugins/git-worktree-cockpit/preload/git-core.cjs new file mode 100644 index 00000000..3dc87850 --- /dev/null +++ b/plugins/git-worktree-cockpit/preload/git-core.cjs @@ -0,0 +1,130 @@ +'use strict' +const path = require('node:path') +const fs = require('node:fs/promises') +const { execFile } = require('node:child_process') +const MAX_OUTPUT = 4 * 1024 * 1024 +const MAX_ARGS = 24 +const MAX_REPOS = 30 +const MAX_WORKTREES = 50 +function platformPath(platform) { return platform === 'win32' ? path.win32 : path } +function isAbsolute(value, platform) { return typeof value === 'string' && value.length > 0 && value.length < 4096 && platformPath(platform).isAbsolute(value) } +function gitCandidates(platform, env) { + const p = platformPath(platform); const separator = platform === 'win32' ? ';' : ':' + const names = platform === 'win32' ? ['git.exe'] : ['git'] + const pathEntries = String((env || process.env).PATH || '').split(separator).filter(Boolean) + const common = platform === 'win32' ? ['C:\\Program Files\\Git\\cmd\\git.exe', 'C:\\Program Files\\Git\\bin\\git.exe'] : ['/usr/bin/git', '/usr/local/bin/git', '/opt/homebrew/bin/git'] + return [...new Set([...pathEntries.flatMap(dir => names.map(name => p.join(dir, name))), ...common])] +} +function parseWorktreeList(payload) { + const records = String(payload || '').split('\0').filter(Boolean); const result = []; let current + for (const record of records) { + const space = record.indexOf(' '); const key = space < 0 ? record : record.slice(0, space); const value = space < 0 ? '' : record.slice(space + 1) + if (key === 'worktree') { if (current) result.push(current); current = { path: value, head: null, branch: null, detached: false, bare: false, locked: null, prunable: null } } + else if (current && key === 'HEAD') current.head = value + else if (current && key === 'branch') current.branch = value.replace(/^refs\/heads\//, '') + else if (current && key === 'detached') current.detached = true + else if (current && key === 'bare') current.bare = true + else if (current && key === 'locked') current.locked = value || true + else if (current && key === 'prunable') current.prunable = value || true + } + if (current) result.push(current); if (result.length > MAX_WORKTREES) throw new RangeError('Git 工作树数量超过限制。'); return result +} +function parseStatus(payload) { + const values = String(payload || '').split('\0'); const status = { head: null, oid: null, upstream: null, ahead: 0, behind: 0, dirty: false, entries: 0 } + for (let index = 0; index < values.length; index += 1) { + const line = values[index] + if (!line) continue + if (line.startsWith('# branch.head ')) status.head = line.slice(14) + else if (line.startsWith('# branch.oid ')) status.oid = line.slice(13) + else if (line.startsWith('# branch.upstream ')) status.upstream = line.slice(18) + else if (line.startsWith('# branch.ab ')) { const match = line.match(/\+(\d+)\s+-(\d+)/); if (match) { status.ahead = Number(match[1]); status.behind = Number(match[2]) } } + else if (!line.startsWith('#')) { + status.dirty = true; status.entries += 1 + if (line.startsWith('2 ')) index += 1 + } + } + return status +} +function repositoryInputError() { return Object.assign(new Error('仓库路径必须是绝对路径。'), { code: 'REPOSITORY_INVALID' }) } +function repositoryInvalidError() { return Object.assign(new Error('所选目录不是可用的 Git 仓库目录。'), { code: 'REPOSITORY_INVALID' }) } +function repositoryUnavailableError() { return Object.assign(new Error('无法访问或验证所选仓库,请重新选择。'), { code: 'REPOSITORY_UNAVAILABLE' }) } +async function authorizeRepository(candidate, dependencies) { + const io = dependencies || fs + try { + if (!isAbsolute(candidate, process.platform)) throw repositoryInputError() + const entry = await io.lstat(candidate); if (!entry.isDirectory() || entry.isSymbolicLink()) throw repositoryInvalidError() + const real = await io.realpath(candidate); if (path.relative(real, candidate).startsWith('..')) throw repositoryInvalidError() + const gitDir = path.join(real, '.git'); const gitEntry = await io.lstat(gitDir); if (gitEntry.isSymbolicLink()) throw repositoryInvalidError() + return real + } catch (error) { + if (error && (error.code === 'REPOSITORY_INVALID' || error.code === 'REPOSITORY_UNAVAILABLE')) throw error + throw repositoryUnavailableError() + } +} +function createRunner(dependencies) { + const invoke = (dependencies && dependencies.execFile) || execFile + return Object.freeze({ run(file, args, cwd) { + if (!isAbsolute(file, process.platform)) return Promise.reject(new Error('Git 可执行文件必须使用绝对路径。')) + if (!Array.isArray(args) || !args.length || args.length > MAX_ARGS || args.some(arg => typeof arg !== 'string' || arg.length > 1024)) return Promise.reject(new Error('固定 Git 参数无效。')) + const allowed = (args.join('\0') === ['--no-optional-locks', '--version'].join('\0')) || + (args.join('\0') === ['--no-optional-locks', 'worktree', 'list', '--porcelain', '-z'].join('\0')) || + (args.length === 9 && args[0] === '--no-optional-locks' && args[1] === '-c' && args[2] === 'core.fsmonitor=false' && args[3] === '-C' && isAbsolute(args[4], process.platform) && args.slice(5).join('\0') === ['status', '--porcelain=v2', '-z', '--branch'].join('\0')) + if (!allowed) return Promise.reject(new Error('Git 参数不在只读允许列表中。')) + if (!isAbsolute(cwd, process.platform)) return Promise.reject(new Error('仓库路径必须是绝对路径。')) + const env = Object.fromEntries(Object.entries({ PATH: process.env.PATH, SystemRoot: process.env.SystemRoot, WINDIR: process.env.WINDIR, HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE, TMP: process.env.TMP, TEMP: process.env.TEMP, GIT_OPTIONAL_LOCKS: '0', GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' }).filter(([, value]) => typeof value === 'string' && value.length)) + return new Promise((resolve, reject) => invoke(file, args, { cwd, shell: false, windowsHide: true, timeout: 15000, maxBuffer: MAX_OUTPUT, env }, (error, stdout, stderr) => error ? reject(Object.assign(error, { stderr: String(stderr).slice(0, 2000) })) : resolve(String(stdout)))) + } }) +} +async function resolveGit(override, platform, env, dependencies) { + const targetPlatform = platform || process.platform; const io = dependencies && dependencies.fs || fs + const runner = createRunner(dependencies); const values = override ? [override] : gitCandidates(targetPlatform, env) + for (const value of values) { + if (!isAbsolute(value, targetPlatform)) continue + try { + const resolved = await io.realpath(value); const entry = await io.stat(resolved) + if (!entry.isFile()) continue + await runner.run(resolved, ['--no-optional-locks', '--version'], process.cwd()) + return resolved + } catch {} + } + throw new Error('未找到经过验证且使用绝对路径的 Git 可执行文件。') +} +function snapshotMarkdown(data) { + const cell = (value) => String(value || '—').replace(/[\r\n]+/g, ' ').replace(/&/g, '&').replace(//g, '>').replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/`/g, '\\`') + const lines = ['# Git 工作树快照', '', '仓库:' + cell(data.repository), '', '| 工作树 | 分支 | 状态 |', '| --- | --- | --- |'] + for (const item of data.worktrees) lines.push('| ' + cell(item.label || item.path) + ' | ' + cell(item.branch || (item.detached ? '游离' : '—')) + ' | ' + (item.status ? (item.status.dirty ? '有改动' : '干净') : '不可用') + ' |') + return lines.join('\n') +} +function samePath(left, right, platform) { + const p = platformPath(platform || process.platform) + if (!isAbsolute(left, platform || process.platform) || !isAbsolute(right, platform || process.platform)) return false + const normalizedLeft = p.normalize(left) + const normalizedRight = p.normalize(right) + return (platform || process.platform) === 'win32' ? normalizedLeft.toLocaleLowerCase('en-US') === normalizedRight.toLocaleLowerCase('en-US') : normalizedLeft === normalizedRight +} +async function inspect(repository, gitExecutable, dependencies) { + const safeRepo = await authorizeRepository(repository, dependencies && dependencies.fs); const runner = createRunner(dependencies) + const list = parseWorktreeList(await runner.run(gitExecutable, ['--no-optional-locks', 'worktree', 'list', '--porcelain', '-z'], safeRepo)) + const policy = dependencies && typeof dependencies.shouldInspect === 'function' ? dependencies.shouldInspect : () => true + const filtered = dependencies && dependencies.filterWorktrees === true + const selected = [] + for (const item of list) { + let allowed = false + try { allowed = policy(item, safeRepo) === true } catch {} + if (allowed) selected.push(item) + else if (!filtered) selected.push({ ...item, __skipStatus: true }) + } + const worktrees = new Array(selected.length); const deadline = Date.now() + 20000; let cursor = 0 + async function worker() { + while (cursor < selected.length) { + const index = cursor++; const item = selected[index] + if (item.__skipStatus) { const { __skipStatus, ...visible } = item; worktrees[index] = { ...visible, status: null, statusUnavailable: 'not-authorized' }; continue } + if (item.prunable || Date.now() > deadline) { worktrees[index] = { ...item, status: null, statusUnavailable: item.prunable ? 'prunable' : 'deadline' }; continue } + try { worktrees[index] = { ...item, status: parseStatus(await runner.run(gitExecutable, ['--no-optional-locks', '-c', 'core.fsmonitor=false', '-C', item.path, 'status', '--porcelain=v2', '-z', '--branch'], safeRepo)) } } + catch (error) { worktrees[index] = { ...item, status: null, statusUnavailable: String(error.message || 'unavailable').slice(0, 120) } } + } + } + await Promise.all(Array.from({ length: Math.min(4, selected.length) }, worker)) + return { repository: safeRepo, worktrees, markdown: snapshotMarkdown({ repository: safeRepo, worktrees }) } +} +module.exports = Object.freeze({ MAX_REPOS, isAbsolute, gitCandidates, parseWorktreeList, parseStatus, authorizeRepository, createRunner, resolveGit, inspect, snapshotMarkdown, samePath }) diff --git a/plugins/git-worktree-cockpit/preload/package-lock.json b/plugins/git-worktree-cockpit/preload/package-lock.json new file mode 100644 index 00000000..3fd566ba --- /dev/null +++ b/plugins/git-worktree-cockpit/preload/package-lock.json @@ -0,0 +1 @@ +{ "name": "git-worktree-cockpit-preload", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "git-worktree-cockpit-preload", "version": "0.1.0" } } } diff --git a/plugins/git-worktree-cockpit/preload/package.json b/plugins/git-worktree-cockpit/preload/package.json new file mode 100644 index 00000000..5bbefffb --- /dev/null +++ b/plugins/git-worktree-cockpit/preload/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/plugins/git-worktree-cockpit/preload/services.cjs b/plugins/git-worktree-cockpit/preload/services.cjs new file mode 100644 index 00000000..5079f683 --- /dev/null +++ b/plugins/git-worktree-cockpit/preload/services.cjs @@ -0,0 +1,276 @@ +'use strict' +const fs = require('node:fs/promises') +const path = require('node:path') +const crypto = require('node:crypto') +const core = require('./git-core.cjs') +const grants = new Map() +let verifiedGit = null +let activeGrantId = null +// Every asynchronous operation is bound to the ZTools view that started it. +// onPluginOut invalidates that view before clearing its capabilities, so a +// delayed native dialog or Git subprocess can never rehydrate a later view. +let sessionEpoch = 0 +const MAX_SNAPSHOTS = 30 +const TOOL_NAMES = Object.freeze({ snapshotApproved: 'snapshot_approved' }) +const RENDERER_SESSION_ENDED = 'git-worktree-cockpit-session-ended' +const registeredHosts = new WeakSet() +function host() { return typeof window !== 'undefined' && window.ztools ? window.ztools : {} } +function sessionEndedError() { return Object.assign(new Error('插件会话已结束,请重新打开后操作。'), { code: 'SESSION_ENDED' }) } +function repositoryUnavailableError() { return Object.assign(new Error('无法访问或验证所选仓库,请重新选择。'), { code: 'REPOSITORY_UNAVAILABLE' }) } +function copyFailedError() { return Object.assign(new Error('无法复制快照,请重试。'), { code: 'COPY_FAILED' }) } +function assertSession(epoch) { if (epoch !== sessionEpoch) throw sessionEndedError() } +function notifyRendererSessionEnded() { + // This is deliberately only a lifecycle signal: no grant, path, repository + // or error detail crosses the preload/renderer boundary when ZTools reuses a + // renderer after plugin out. + if (typeof window === 'undefined' || typeof window.dispatchEvent !== 'function' || typeof Event !== 'function') return + try { window.dispatchEvent(new Event(RENDERER_SESSION_ENDED)) } catch {} +} +function clearSession() { sessionEpoch += 1; grants.clear(); verifiedGit = null; activeGrantId = null; notifyRendererSessionEnded() } +function dialogPath(result) { return Array.isArray(result) ? result[0] : typeof result === 'string' ? result : result && Array.isArray(result.filePaths) ? result.filePaths[0] : null } +function grantId() { return crypto.randomBytes(12).toString('hex') } +function revokeGrant(id) { grants.delete(id); if (activeGrantId === id) activeGrantId = null } +function getGrant(id) { const grant = grants.get(id); if (!grant || grant.expiresAt < Date.now()) { revokeGrant(id); throw new Error('仓库授权已过期,请重新选择。') } return grant } +function identity(stat) { return { dev: stat.dev, ino: stat.ino, mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs } } +function sameIdentity(grant, current) { return grant.dev === current.dev && grant.ino === current.ino && grant.mtimeMs === current.mtimeMs && grant.ctimeMs === current.ctimeMs } +async function revalidateGrant(grant) { + const stable = await core.authorizeRepository(grant.repository) + const current = identity(await fs.stat(stable)) + if (stable !== grant.repository || !sameIdentity(grant, current)) throw new Error('已授权仓库在选择后发生变化。') + return stable +} +async function chooseRepository() { + const epoch = sessionEpoch + const api = host(); if (typeof api.showOpenDialog !== 'function') return { ok: false, code: 'DIALOG_UNAVAILABLE' } + let selected + try { selected = await api.showOpenDialog({ title: '选择 Git 仓库目录', properties: ['openDirectory'] }) } + catch (error) { assertSession(epoch); throw repositoryUnavailableError() } + const value = dialogPath(selected) + assertSession(epoch) + if (!value) return { ok: false, code: 'CANCELED' } + try { + const repository = await core.authorizeRepository(value) + assertSession(epoch) + if (grants.size >= MAX_SNAPSHOTS) grants.delete(grants.keys().next().value) + const selectedIdentity = identity(await fs.stat(repository)) + assertSession(epoch) + const id = grantId(); grants.set(id, { repository, ...selectedIdentity, expiresAt: Date.now() + 30 * 60 * 1000, snapshot: null }); activeGrantId = id + return { ok: true, grantId: id, repository: path.basename(repository) || repository } + } catch (error) { + if (error && error.code === 'SESSION_ENDED') throw error + throw repositoryUnavailableError() + } +} +async function inspectGrant(id) { + const epoch = sessionEpoch + try { + const grant = getGrant(id) + await revalidateGrant(grant) + assertSession(epoch) + if (!verifiedGit) verifiedGit = await core.resolveGit(undefined, process.platform) + assertSession(epoch) + const snapshot = await core.inspect(grant.repository, verifiedGit) + assertSession(epoch) + await revalidateGrant(grant) + assertSession(epoch) + grant.snapshot = snapshot + return { repository: path.basename(snapshot.repository) || snapshot.repository, worktrees: snapshot.worktrees } + } catch { + if (epoch === sessionEpoch) { revokeGrant(id); verifiedGit = null } + throw new Error('已授权仓库不可用或检查失败,请重新选择。') + } +} +function dryPlan(action) { return Object.freeze({ executable: false, version: '0.1', action: String(action || 'unknown'), message: 'v0.1 只读:不会创建、移除或修改 Git 工作树。' }) } +function shareableSnapshot(snapshot) { + const branchLabels = new Map() + const branchLabel = (value) => { + if (!value) return null + if (!branchLabels.has(value)) branchLabels.set(value, `branch-${branchLabels.size + 1}`) + return branchLabels.get(value) + } + return { + schemaVersion: 1, + repository: 'repository-1', + totalWorktrees: snapshot.worktrees.length, + worktrees: snapshot.worktrees.map((item, index) => ({ + label: `worktree-${index + 1}`, + branch: branchLabel(item.branch), + detached: item.detached === true, + bare: item.bare === true, + locked: Boolean(item.locked), + prunable: Boolean(item.prunable), + status: item.status ? { ahead: Number.isSafeInteger(item.status.ahead) && item.status.ahead >= 0 ? item.status.ahead : 0, behind: Number.isSafeInteger(item.status.behind) && item.status.behind >= 0 ? item.status.behind : 0, dirty: item.status.dirty === true, changedEntryCount: Number.isSafeInteger(item.status.entries) && item.status.entries >= 0 ? item.status.entries : 0 } : null, + ...(item.status ? {} : { statusUnavailable: item.statusUnavailable === 'prunable' ? 'prunable' : item.statusUnavailable === 'deadline' ? 'deadline' : 'unavailable' }) + })) + } +} +function humanAnonymousLabel(value) { + if (typeof value !== 'string') return value + const match = /^(repository|worktree|branch)-(\d+)$/.exec(value) + if (!match) return value + return `${{ repository: '仓库', worktree: '工作树', branch: '分支' }[match[1]]}-${match[2]}` +} +function humanShareableSnapshot(snapshot) { + return { + ...snapshot, + repository: humanAnonymousLabel(snapshot.repository), + worktrees: snapshot.worktrees.map((item) => ({ ...item, label: humanAnonymousLabel(item.label), branch: humanAnonymousLabel(item.branch) })) + } +} +function stringifySnapshot(snapshot, format) { + const shareable = shareableSnapshot(snapshot) + return format === 'json' ? JSON.stringify(shareable, null, 2) + '\n' : core.snapshotMarkdown(humanShareableSnapshot(shareable)) + '\n' +} +function existingDestinationError() { const error = new Error('不能覆盖已有文件,请选择新的文件名。'); error.code = 'DESTINATION_EXISTS'; return error } +function saveFailedError() { return Object.assign(new Error('无法保存快照,请重新选择保存位置。'), { code: 'SAVE_FAILED' }) } +function temporaryDestination(destination) { return path.join(path.dirname(destination), `.${path.basename(destination)}.${crypto.randomBytes(12).toString('hex')}.tmp`) } +async function removeIfSameIdentity(io, candidate, reference) { + try { + const candidateStat = await io.lstat(candidate) + if (candidateStat.dev !== reference.dev || candidateStat.ino !== reference.ino) return false + await io.unlink(candidate) + return true + } catch (error) { return Boolean(error && error.code === 'ENOENT') } +} +async function closeQuietly(handle) { try { await handle.close() } catch { return false } return true } +async function writeNewFile(destination, content, io = fs, assertCurrent = () => {}) { + const temporary = temporaryDestination(destination) + let temporaryCreated = false + let temporaryIdentity = null + let handle = null + let destinationLinked = false + let linkAttempted = false + let cleanupFailed = false + try { + assertCurrent() + await io.lstat(destination) + throw existingDestinationError() + } catch (error) { + if (error && error.code !== 'ENOENT') throw error + } + try { + assertCurrent() + // The exclusive handle establishes ownership. Never claim ownership or + // unlink a name until this succeeds: an EEXIST temp may belong to another + // process using the same directory. + handle = await io.open(temporary, 'wx', 0o600) + temporaryCreated = true + temporaryIdentity = identity(await handle.stat()) + await handle.writeFile(content, { encoding: 'utf8' }) + if (!await closeQuietly(handle)) throw saveFailedError() + handle = null + assertCurrent() + try { + await io.lstat(destination) + throw existingDestinationError() + } catch (error) { + if (error && error.code !== 'ENOENT') throw error + } + assertCurrent() + linkAttempted = true + await io.link(temporary, destination) + destinationLinked = true + try { assertCurrent() } catch (error) { + if (!await removeIfSameIdentity(io, destination, temporaryIdentity)) cleanupFailed = true + throw error + } + await io.unlink(temporary) + temporaryCreated = false + try { assertCurrent() } catch (error) { + if (!await removeIfSameIdentity(io, destination, temporaryIdentity)) cleanupFailed = true + throw error + } + } catch (error) { + if (handle && !await closeQuietly(handle)) cleanupFailed = true + // A linked destination is rolled back only after verifying that it still + // points at this operation's temporary inode. This protects a concurrent + // replacement and gives unlink(temp) failures the same all-or-nothing + // contract as a stale session. + if (destinationLinked && temporaryIdentity && !await removeIfSameIdentity(io, destination, temporaryIdentity)) cleanupFailed = true + if (temporaryCreated && temporaryIdentity && !await removeIfSameIdentity(io, temporary, temporaryIdentity)) cleanupFailed = true + if (cleanupFailed) throw saveFailedError() + if (error && error.code === 'EEXIST' && linkAttempted) throw existingDestinationError() + if (error && (error.code === 'DESTINATION_EXISTS' || error.code === 'SESSION_ENDED' || error.code === 'SAVE_FAILED')) throw error + throw saveFailedError() + } +} +async function saveSnapshot(id, format) { + const epoch = sessionEpoch + const grant = getGrant(id); if (!grant.snapshot) throw new Error('请先检查仓库,再导出快照。') + const kind = format === 'json' ? 'json' : 'markdown'; const api = host() + if (typeof api.showSaveDialog !== 'function') throw new Error('当前 ZTools 版本不支持保存对话框。') + let chosen + try { chosen = await api.showSaveDialog({ title: '保存 Git 工作树快照', defaultPath: 'Git-工作树快照.' + (kind === 'json' ? 'json' : 'md'), filters: [{ name: kind === 'json' ? 'JSON 文件' : 'Markdown 文件', extensions: [kind === 'json' ? 'json' : 'md'] }] }) } + catch (error) { assertSession(epoch); throw saveFailedError() } + assertSession(epoch) + const destination = typeof chosen === 'string' ? chosen : chosen && !chosen.canceled ? chosen.filePath : null + if (!destination || !path.isAbsolute(destination)) return { canceled: true } + try { await writeNewFile(destination, stringifySnapshot(grant.snapshot, kind), fs, () => assertSession(epoch)) } + catch (error) { + if (error && error.code === 'SESSION_ENDED') throw error + if (error && error.code === 'DESTINATION_EXISTS') throw error + throw saveFailedError() + } + assertSession(epoch) + return { canceled: false, fileName: path.basename(destination) } +} +async function copySnapshot(id, format) { + const epoch = sessionEpoch + const grant = getGrant(id); if (!grant.snapshot) throw new Error('请先检查仓库,再复制快照。') + const api = host(); if (typeof api.copyText !== 'function') throw new Error('当前 ZTools 版本不支持复制文本。') + let copied + try { copied = await api.copyText(stringifySnapshot(grant.snapshot, format === 'json' ? 'json' : 'markdown')) } + catch (error) { assertSession(epoch); throw copyFailedError() } + assertSession(epoch) + return copied !== false +} +function invalidTool(message) { const error = new Error(message); error.code = 'INVALID_TOOL_INPUT'; throw error } +function validateSnapshotToolInput(input) { + if (!input || typeof input !== 'object' || Array.isArray(input)) invalidTool('快照参数必须是对象。') + let prototype, keys + try { prototype = Object.getPrototypeOf(input); keys = Reflect.ownKeys(input) } catch { invalidTool('快照参数结构无效。') } + if (prototype !== Object.prototype && prototype !== null) invalidTool('快照参数原型无效。') + for (const key of keys) { + if (typeof key !== 'string') invalidTool('snapshot_approved 不接受 Symbol 字段。') + let descriptor + try { descriptor = Object.getOwnPropertyDescriptor(input, key) } catch { invalidTool('快照参数字段无效。') } + if (!descriptor || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) invalidTool('snapshot_approved 只允许数据字段。') + invalidTool('snapshot_approved 不接受路径、令牌或其他字段。') + } + return input +} +const sanitizedSnapshot = shareableSnapshot +async function snapshotApproved(input) { + validateSnapshotToolInput(input) + if (!activeGrantId) throw Object.assign(new Error('请先在插件界面选择并授权一个 Git 仓库。'), { code: 'UI_APPROVAL_REQUIRED' }) + const epoch = sessionEpoch + const id = activeGrantId + try { + const grant = getGrant(id) + await revalidateGrant(grant) + assertSession(epoch) + if (!verifiedGit) verifiedGit = await core.resolveGit(undefined, process.platform) + assertSession(epoch) + const snapshot = await core.inspect(grant.repository, verifiedGit, { filterWorktrees: true, shouldInspect: (item, repository) => core.samePath(item.path, repository, process.platform) }) + assertSession(epoch) + await revalidateGrant(grant) + assertSession(epoch) + if (snapshot.worktrees.length !== 1) throw new Error('未找到已授权的 Git 工作树。') + return shareableSnapshot(snapshot) + } catch { + if (epoch === sessionEpoch) { revokeGrant(id); verifiedGit = null } + throw Object.assign(new Error('已授权 Git 仓库不可用或检查失败,请在插件界面重新选择。'), { code: 'APPROVED_REPOSITORY_FAILED' }) + } +} +function registerTools(api) { + if (!api || typeof api.registerTool !== 'function') return false + if (registeredHosts.has(api)) return false + let registered = false + try { api.registerTool.call(api, TOOL_NAMES.snapshotApproved, (input) => snapshotApproved(input)); registered = true } catch {} + registeredHosts.add(api) + return registered +} +if (typeof host().onPluginOut === 'function') host().onPluginOut(clearSession) +registerTools(host()) +window.gitWorktreeCockpit = Object.freeze({ chooseRepository, inspectGrant, dryPlan, saveSnapshot, copySnapshot }) +module.exports = Object.freeze({ TOOL_NAMES, validateSnapshotToolInput, sanitizedSnapshot, shareableSnapshot, snapshotApproved, registerTools, __test: { RENDERER_SESSION_ENDED, clearSession, activeGrantId: () => activeGrantId, grant: (id) => grants.get(id), sessionEpoch: () => sessionEpoch, stringifySnapshot, writeNewFile } }) diff --git a/plugins/git-worktree-cockpit/scripts/build.mjs b/plugins/git-worktree-cockpit/scripts/build.mjs new file mode 100644 index 00000000..5c54dd1f --- /dev/null +++ b/plugins/git-worktree-cockpit/scripts/build.mjs @@ -0,0 +1,8 @@ +import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const dist = path.join(root, 'dist') +await rm(dist, { recursive: true, force: true }); await mkdir(dist, { recursive: true }) +for (const file of ['index.html', 'logo.svg', 'README.md', 'plugin.json']) await cp(path.join(root, file), path.join(dist, file)) +await cp(path.join(root, 'preload'), path.join(dist, 'preload'), { recursive: true }) +const manifest = JSON.parse(await readFile(path.join(dist, 'plugin.json'), 'utf8')); delete manifest.development; await writeFile(path.join(dist, 'plugin.json'), JSON.stringify(manifest, null, 2) + '\n') diff --git a/plugins/git-worktree-cockpit/scripts/dist-size.mjs b/plugins/git-worktree-cockpit/scripts/dist-size.mjs new file mode 100644 index 00000000..80762b49 --- /dev/null +++ b/plugins/git-worktree-cockpit/scripts/dist-size.mjs @@ -0,0 +1,24 @@ +import { lstat, readdir } from 'node:fs/promises' +import path from 'node:path' + +export const MAX_DIST_BYTES = 14_500_000 + +export async function measureDirectoryBytes(directory) { + let total = 0 + for (const name of await readdir(directory)) { + const target = path.join(directory, name) + const metadata = await lstat(target) + if (metadata.isSymbolicLink()) throw new Error(`dist must not contain symbolic links: ${name}`) + if (metadata.isDirectory()) total += await measureDirectoryBytes(target) + else if (metadata.isFile()) total += metadata.size + else throw new Error(`dist contains an unsupported filesystem entry: ${name}`) + if (!Number.isSafeInteger(total)) throw new Error('dist byte count exceeds the safe integer range') + } + return total +} + +export function assertDistSize(bytes, label = 'dist') { + if (!Number.isSafeInteger(bytes) || bytes < 0) throw new TypeError('dist byte count must be a non-negative safe integer') + if (bytes > MAX_DIST_BYTES) throw new Error(`${label} is ${bytes} bytes, exceeding the ${MAX_DIST_BYTES}-byte limit`) + return bytes +} diff --git a/plugins/git-worktree-cockpit/scripts/verify-dist.mjs b/plugins/git-worktree-cockpit/scripts/verify-dist.mjs new file mode 100644 index 00000000..fa527941 --- /dev/null +++ b/plugins/git-worktree-cockpit/scripts/verify-dist.mjs @@ -0,0 +1,18 @@ +import { access, readFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { assertDistSize, MAX_DIST_BYTES, measureDirectoryBytes } from './dist-size.mjs' + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const dist = path.join(root, 'dist') +const manifest = JSON.parse(await readFile(path.join(dist, 'plugin.json'), 'utf8')) +if (manifest.development) throw new Error('dist manifest must not contain development') +for (const file of [manifest.main, manifest.logo, manifest.preload, 'preload/git-core.cjs']) await access(path.join(dist, file)) +const declared = Object.keys(manifest.tools || {}) +if (JSON.stringify(declared) !== JSON.stringify(['snapshot_approved'])) throw new Error('dist MCP tool declaration is incomplete') +const preload = await readFile(path.join(dist, manifest.preload), 'utf8') +if (!preload.includes("'snapshot_approved'")) throw new Error('dist preload does not register snapshot_approved') +const distBytes = await measureDirectoryBytes(dist) +console.log(`git-worktree-cockpit dist size: ${distBytes} bytes (limit ${MAX_DIST_BYTES} bytes)`) +assertDistSize(distBytes, 'git-worktree-cockpit dist') +console.log('Verified git-worktree-cockpit dist') diff --git a/plugins/git-worktree-cockpit/tests/dist-size.test.cjs b/plugins/git-worktree-cockpit/tests/dist-size.test.cjs new file mode 100644 index 00000000..5d45130d --- /dev/null +++ b/plugins/git-worktree-cockpit/tests/dist-size.test.cjs @@ -0,0 +1,22 @@ +'use strict' +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs/promises') +const os = require('node:os') +const path = require('node:path') + +test('dist size gate recursively counts exact bytes against the decimal 14.5 MB limit', async () => { + const { assertDistSize, MAX_DIST_BYTES, measureDirectoryBytes } = await import('../scripts/dist-size.mjs') + assert.equal(MAX_DIST_BYTES, 14_500_000) + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'worktree-dist-size-')) + try { + await fs.mkdir(path.join(directory, 'nested')) + await fs.writeFile(path.join(directory, 'root.bin'), Buffer.alloc(7)) + await fs.writeFile(path.join(directory, 'nested', 'child.bin'), Buffer.alloc(11)) + assert.equal(await measureDirectoryBytes(directory), 18) + assert.equal(assertDistSize(MAX_DIST_BYTES), MAX_DIST_BYTES) + assert.throws(() => assertDistSize(MAX_DIST_BYTES + 1), /14500001 bytes.*14500000-byte limit/) + } finally { + await fs.rm(directory, { recursive: true, force: true }) + } +}) diff --git a/plugins/git-worktree-cockpit/tests/git-core.test.cjs b/plugins/git-worktree-cockpit/tests/git-core.test.cjs new file mode 100644 index 00000000..b4ba5456 --- /dev/null +++ b/plugins/git-worktree-cockpit/tests/git-core.test.cjs @@ -0,0 +1,15 @@ +'use strict' +const test=require('node:test');const assert=require('node:assert/strict');const core=require('../preload/git-core.cjs') +test('parses NUL worktree porcelain including branches and state',()=>{const rows=core.parseWorktreeList('worktree /tmp/a b\0HEAD abc\0branch refs/heads/main\0locked reason\0worktree /tmp/b\0HEAD def\0detached\0prunable stale\0');assert.equal(rows.length,2);assert.equal(rows[0].branch,'main');assert.equal(rows[0].locked,'reason');assert.equal(rows[1].detached,true);assert.equal(rows[1].prunable,'stale')}) +test('status parser handles dirty, ahead and behind',()=>{const data=core.parseStatus('# branch.head main\0# branch.oid abc\0# branch.upstream origin/main\0# branch.ab +2 -3\0'+'1 M. N... 100644 100644 100644 a b file with space\0');assert.equal(data.dirty,true);assert.equal(data.ahead,2);assert.equal(data.behind,3)}) +test('status parser counts ordinary records with spaces and newlines by NUL boundary',()=>{const data=core.parseStatus('1 M. N... 100644 100644 100644 aaa bbb file with space\nand newline.txt\0? untracked file with space\nline.txt\0');assert.equal(data.dirty,true);assert.equal(data.entries,2)}) +test('status parser consumes rename and copy origin paths instead of counting them twice',()=>{const renamed='2 R. N... 100644 100644 100644 aaa bbb R100 renamed file\nwith newline.txt\0original file with space.txt\0',copied='2 C. N... 100644 100644 100644 ccc ddd C075 copied file with space.txt\0source file\nwith newline.txt\0',ordinary='1 .M N... 100644 100644 100644 eee fff ordinary.txt\0',data=core.parseStatus(renamed+copied+ordinary);assert.equal(data.dirty,true);assert.equal(data.entries,3)}) +test('candidate resolution works for three platforms without cmd wrappers',()=>{const windows=core.gitCandidates('win32',{PATH:'C:\\Git\\cmd;D:\\bin'}).join('|');assert.match(windows,/git\.exe/i);assert.ok(!windows.includes('git.cmd'));assert.match(core.gitCandidates('darwin',{PATH:'/usr/bin'}).join('|'),/\/git/);assert.match(core.gitCandidates('linux',{PATH:'/usr/bin'}).join('|'),/\/git/)}) +test('absolute detection follows platform rules',()=>{assert.equal(core.isAbsolute('C:\\Git\\bin\\git.exe','win32'),true);assert.equal(core.isAbsolute('/usr/bin/git','linux'),true);assert.equal(core.isAbsolute('git','linux'),false)}) +test('safe runner rejects relative executable and oversized args',async()=>{const runner=core.createRunner({execFile(){throw new Error('not reached')}});await assert.rejects(runner.run('git',['status'],'/tmp'));await assert.rejects(runner.run('/usr/bin/git',Array(25).fill('x'),'/tmp'))}) +test('safe runner keeps shell false and exact readonly status argv',async()=>{let call;const runner=core.createRunner({execFile(...args){call=args;args[3](null,'ok','')}});const argv=['--no-optional-locks','-c','core.fsmonitor=false','-C','/tmp','status','--porcelain=v2','-z','--branch'];assert.equal(await runner.run('/usr/bin/git',argv,'/tmp'),'ok');assert.equal(call[2].shell,false);assert.deepEqual(call[1],argv)}) +test('safe runner keeps a filtered noninteractive Git environment',async()=>{let options;const runner=core.createRunner({execFile(file,args,value,done){options=value;done(null,'ok','')}});await runner.run('/usr/bin/git',['--no-optional-locks','--version'],'/tmp');assert.equal(typeof options.env.PATH,'string');assert.equal(options.env.GIT_OPTIONAL_LOCKS,'0');assert.equal(options.env.GIT_CONFIG_NOSYSTEM,'1');assert.equal(options.env.GIT_TERMINAL_PROMPT,'0');assert.equal(options.env.GIT_CONFIG_GLOBAL,'/dev/null');assert.ok(Object.values(options.env).every(value=>typeof value==='string'&&value.length>0));assert.ok(!Object.prototype.hasOwnProperty.call(options.env,'UNRELATED_ENV'))}) +test('safe runner rejects unknown bridge command',async()=>{const runner=core.createRunner({execFile(){throw new Error('not reached')}});await assert.rejects(runner.run('/usr/bin/git',['reset','--hard'],'/tmp'))}) +test('snapshot uses Chinese headings and supports Chinese and spaced worktree paths',()=>{const report=core.snapshotMarkdown({repository:'/tmp/项目',worktrees:[{path:'/tmp/a b/中文',branch:'main',status:{dirty:true}}]});assert.match(report,/# Git 工作树快照/);assert.match(report,/仓库:/);assert.match(report,/中文/);assert.match(report,/有改动/)}) +test('snapshot escapes HTML and Markdown metacharacters in every human field',()=>{const tick=String.fromCharCode(96),slash=String.fromCharCode(92);const repository='/tmp/&'+tick+'x|\nnext';const report=core.snapshotMarkdown({repository,worktrees:[{path:'/tmp/&|evil\nnext',branch:'x|y',status:{dirty:false}}]});assert.ok(report.includes('<img>&'+slash+tick+'x'+slash+'| next'));assert.match(report,/<img src=x onerror=alert\(1\)>&\\\|evil/);assert.equal(report.includes('{const repository='/approved/repo',sibling='/outside/sibling',calls=[];const fakeFs={async lstat(){return{isDirectory:()=>true,isSymbolicLink:()=>false}},async realpath(value){return value}};const execFile=(file,args,options,done)=>{calls.push(args);if(args[1]==='worktree')done(null,`worktree ${repository}\0HEAD aaa\0branch refs/heads/main\0worktree ${sibling}\0HEAD bbb\0branch refs/heads/other\0`,'');else done(null,'# branch.head main\0# branch.oid aaa\0','')};const result=await core.inspect(repository,'/usr/bin/git',{fs:fakeFs,execFile,filterWorktrees:true,shouldInspect:(item,approved)=>core.samePath(item.path,approved,'linux')});const statusCalls=calls.filter((args)=>args.includes('status'));assert.equal(statusCalls.length,1);assert.equal(statusCalls[0][4],repository);assert.equal(result.worktrees.length,1);assert.equal(result.worktrees[0].path,repository)}) diff --git a/plugins/git-worktree-cockpit/tests/services-security.test.cjs b/plugins/git-worktree-cockpit/tests/services-security.test.cjs new file mode 100644 index 00000000..75f76003 --- /dev/null +++ b/plugins/git-worktree-cockpit/tests/services-security.test.cjs @@ -0,0 +1,393 @@ +'use strict' +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs/promises') +const os = require('node:os') +const path = require('node:path') +const { execFileSync } = require('node:child_process') +function loadWithHost(ztools) { + global.window = { ztools } + delete require.cache[require.resolve('../preload/services.cjs')] + return require('../preload/services.cjs') +} +test('manifest declarations and preload registrations use the same short MCP name', () => { + const calls = new Map() + const service = loadWithHost({ registerTool(name, handler) { calls.set(name, handler) } }) + const manifest = require('../plugin.json') + assert.deepEqual(Object.keys(manifest.tools), Object.values(service.TOOL_NAMES)) + assert.deepEqual([...calls.keys()], Object.values(service.TOOL_NAMES)) + delete global.window +}) +test('legacy or failing registerTool hosts retain the renderer bridge', () => { + loadWithHost({ registerTool() { throw new Error('unsupported') } }) + assert.equal(typeof global.window.gitWorktreeCockpit.chooseRepository, 'function') + assert.equal(typeof global.window.gitWorktreeCockpit.inspectGrant, 'function') + delete global.window +}) +test('the single plugin-out callback emits only a path-free renderer session-ended signal', () => { + let pluginOut; const events = [] + const service = loadWithHost({ onPluginOut(handler) { assert.equal(pluginOut, undefined); pluginOut = handler } }) + global.window.dispatchEvent = event => { events.push(event); return true } + pluginOut() + assert.equal(events.length, 1) + assert.equal(events[0].type, service.__test.RENDERER_SESSION_ENDED) + assert.equal(events[0].detail, undefined) + assert.equal(Object.keys(global.window.gitWorktreeCockpit).includes('clearSession'), false) + delete global.window +}) +test('MCP snapshot returns only a changed-entry count even for a large status fixture', () => { + const service = loadWithHost({}) + const rawPaths = Array.from({ length: 5000 }, (_, index) => `/secret/root/file-${index}`) + const value = service.sanitizedSnapshot({ repository: '/approved/repo', worktrees: [{ path: '/approved/repo', head: 'abc', branch: 'main', status: { head: 'main', oid: 'abc', upstream: null, ahead: 0, behind: 0, dirty: true, entries: rawPaths.length, files: rawPaths } }] }) + assert.equal(value.repository, 'repository-1') + assert.equal(value.totalWorktrees, 1) + assert.equal(value.worktrees[0].label, 'worktree-1') + assert.equal(value.worktrees[0].branch, 'branch-1') + assert.equal(value.worktrees[0].status.changedEntryCount, 5000) + assert.equal(value.worktrees[0].status.files, undefined) + for (const secret of ['/secret/root', '/approved/repo', 'main', 'abc']) assert.equal(JSON.stringify(value).includes(secret), false) + delete global.window +}) +test('human Markdown localizes anonymous labels while JSON keeps stable machine values', () => { + const service = loadWithHost({}) + const snapshot = { repository: '/private/repo', worktrees: [{ path: '/private/repo', branch: 'main', detached: false, bare: false, locked: false, prunable: false, status: { ahead: 0, behind: 0, dirty: false, entries: 0 } }] } + const markdown = service.__test.stringifySnapshot(snapshot, 'markdown') + assert.match(markdown, /仓库:仓库-1/) + assert.match(markdown, /工作树-1/) + assert.match(markdown, /分支-1/) + assert.doesNotMatch(markdown, /repository-1|worktree-1|branch-1/) + const json = JSON.parse(service.__test.stringifySnapshot(snapshot, 'json')) + assert.equal(json.repository, 'repository-1') + assert.equal(json.worktrees[0].label, 'worktree-1') + assert.equal(json.worktrees[0].branch, 'branch-1') + assert.doesNotMatch(JSON.stringify(json), /仓库-1|工作树-1|分支-1/) + delete global.window +}) +test('MCP snapshot calls the real handler for the latest UI grant and strips absolute paths', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'worktree-mcp-')) + let repository = path.join(directory, 'approved-repository') + execFileSync('git', ['init', '-q', repository], { stdio: 'ignore' }) + repository = await fs.realpath(repository) + const calls = new Map() + const service = loadWithHost({ showOpenDialog: async () => [repository], registerTool(name, handler) { calls.set(name, handler) } }) + await assert.rejects(calls.get(service.TOOL_NAMES.snapshotApproved)({}), (error) => error.code === 'UI_APPROVAL_REQUIRED') + const chosen = await global.window.gitWorktreeCockpit.chooseRepository() + assert.equal(typeof service.__test.grant(chosen.grantId).ctimeMs, 'number') + const result = await calls.get(service.TOOL_NAMES.snapshotApproved)({}) + assert.equal(result.repository, 'repository-1') + assert.equal(result.worktrees.length, 1) + assert.equal(result.worktrees[0].label, 'worktree-1') + for (const secret of [directory, repository, 'approved-repository']) assert.equal(JSON.stringify(result).includes(secret), false) + await assert.rejects(calls.get(service.TOOL_NAMES.snapshotApproved)({ path: repository }), (error) => error.code === 'INVALID_TOOL_INPUT') + const hostile = JSON.parse('{"__proto__":{"polluted":true}}') + await assert.rejects(calls.get(service.TOOL_NAMES.snapshotApproved)(hostile), (error) => error.code === 'INVALID_TOOL_INPUT') + let getterCalled = false + const accessor = {}; Object.defineProperty(accessor, 'path', { enumerable: true, get() { getterCalled = true; return repository } }) + await assert.rejects(calls.get(service.TOOL_NAMES.snapshotApproved)(accessor), (error) => error.code === 'INVALID_TOOL_INPUT') + assert.equal(getterCalled, false) + const symbolInput = {}; symbolInput[Symbol('hidden')] = true + await assert.rejects(calls.get(service.TOOL_NAMES.snapshotApproved)(symbolInput), (error) => error.code === 'INVALID_TOOL_INPUT') + delete global.window + await fs.rm(directory, { recursive: true, force: true }) +}) +test('human inspection performs a post-Git identity check and revokes on mismatch', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'worktree-human-postcheck-')) + let repository = path.join(directory, 'approved-repository') + execFileSync('git', ['init', '-q', repository], { stdio: 'ignore' }) + repository = await fs.realpath(repository) + const service = loadWithHost({ showOpenDialog: async () => [repository] }) + const chosen = await global.window.gitWorktreeCockpit.chooseRepository() + const originalStat = fs.stat + let repositoryChecks = 0 + fs.stat = async function patchedStat(value, ...rest) { + const result = await originalStat.call(this, value, ...rest) + if (path.resolve(String(value)) === repository) { + repositoryChecks += 1 + if (repositoryChecks === 2) return { ...result, ctimeMs: result.ctimeMs + 1 } + } + return result + } + try { + await assert.rejects(global.window.gitWorktreeCockpit.inspectGrant(chosen.grantId), /重新选择/) + } finally { + fs.stat = originalStat + } + assert.equal(repositoryChecks, 2) + assert.equal(service.__test.activeGrantId(), null) + delete global.window + await fs.rm(directory, { recursive: true, force: true }) +}) +test('MCP inspection performs a post-Git identity check, revokes, and keeps errors path-free', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'worktree-mcp-postcheck-')) + let repository = path.join(directory, 'approved-repository') + execFileSync('git', ['init', '-q', repository], { stdio: 'ignore' }) + repository = await fs.realpath(repository) + const calls = new Map() + const service = loadWithHost({ showOpenDialog: async () => [repository], registerTool(name, handler) { calls.set(name, handler) } }) + await global.window.gitWorktreeCockpit.chooseRepository() + const originalStat = fs.stat + let repositoryChecks = 0 + fs.stat = async function patchedStat(value, ...rest) { + const result = await originalStat.call(this, value, ...rest) + if (path.resolve(String(value)) === repository) { + repositoryChecks += 1 + if (repositoryChecks === 2) return { ...result, ctimeMs: result.ctimeMs + 1 } + } + return result + } + try { + await assert.rejects(calls.get(service.TOOL_NAMES.snapshotApproved)({}), (error) => { + assert.equal(error.code, 'APPROVED_REPOSITORY_FAILED') + assert.equal(error.message.includes(directory), false) + assert.equal(error.message.includes(repository), false) + return true + }) + } finally { + fs.stat = originalStat + } + assert.equal(repositoryChecks, 2) + assert.equal(service.__test.activeGrantId(), null) + delete global.window + await fs.rm(directory, { recursive: true, force: true }) +}) +test('ctime detects a same-directory identity rewrite even when mtime is restored', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'worktree-ctime-')) + let repository = path.join(directory, 'approved-repository') + execFileSync('git', ['init', '-q', repository], { stdio: 'ignore' }) + repository = await fs.realpath(repository) + const service = loadWithHost({ showOpenDialog: async () => [repository] }) + const chosen = await global.window.gitWorktreeCockpit.chooseRepository() + const grant = service.__test.grant(chosen.grantId) + const before = await fs.stat(repository) + const marker = path.join(repository, 'identity-marker') + await fs.writeFile(marker, 'x') + await fs.rm(marker) + await fs.utimes(repository, before.atime, before.mtime) + const changed = await fs.stat(repository) + grant.mtimeMs = changed.mtimeMs + assert.equal(changed.dev, grant.dev) + assert.equal(changed.ino, grant.ino) + assert.notEqual(changed.ctimeMs, grant.ctimeMs) + await assert.rejects(global.window.gitWorktreeCockpit.inspectGrant(chosen.grantId), /重新选择/) + assert.equal(service.__test.activeGrantId(), null) + delete global.window + await fs.rm(directory, { recursive: true, force: true }) +}) +test('copy and save use an independent anonymous projection and refuse existing targets', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'worktree-share-')) + let repository = path.join(directory, 'alice-private-project') + execFileSync('git', ['init', '-q', repository], { stdio: 'ignore' }) + repository = await fs.realpath(repository) + const destination = path.join(directory, 'snapshot.json') + const existing = path.join(directory, 'existing.md') + await fs.writeFile(existing, 'keep-me') + const copied = [] + let savePath = destination + loadWithHost({ showOpenDialog: async () => [repository], showSaveDialog: async () => ({ canceled: false, filePath: savePath }), copyText(value) { copied.push(value); return true } }) + const chosen = await global.window.gitWorktreeCockpit.chooseRepository() + await global.window.gitWorktreeCockpit.inspectGrant(chosen.grantId) + await global.window.gitWorktreeCockpit.copySnapshot(chosen.grantId, 'markdown') + await global.window.gitWorktreeCockpit.copySnapshot(chosen.grantId, 'json') + await global.window.gitWorktreeCockpit.saveSnapshot(chosen.grantId, 'json') + const saved = await fs.readFile(destination, 'utf8') + for (const output of [...copied, saved]) { + for (const secret of [directory, repository, 'alice-private-project', os.userInfo().username]) assert.equal(output.includes(secret), false) + } + assert.match(copied[0], /仓库-1/) + assert.match(copied[0], /工作树-1/) + assert.doesNotMatch(copied[0], /repository-1|worktree-1|branch-1/) + for (const output of [copied[1], saved]) assert.match(output, /repository-1[\s\S]*worktree-1/) + savePath = existing + await assert.rejects(global.window.gitWorktreeCockpit.saveSnapshot(chosen.grantId, 'markdown'), (error) => error.code === 'DESTINATION_EXISTS' && /不能覆盖已有文件/.test(error.message)) + assert.equal(await fs.readFile(existing, 'utf8'), 'keep-me') + delete global.window + await fs.rm(directory, { recursive: true, force: true }) +}) +test('Windows and POSIX saves share the same atomic no-overwrite creation contract', async () => { + const service = loadWithHost({}) + let options; const files = new Map(); let nextInode = 1 + const missing = Object.assign(new Error('missing'), { code: 'ENOENT' }) + const io = { + async lstat(value) { if (!files.has(value)) throw missing; return files.get(value).stat }, + async open(destination, flag, mode) { + assert.match(destination, /snapshot\.json\.[a-f0-9]{24}\.tmp$/); assert.equal(flag, 'wx'); assert.equal(mode, 0o600); options = { flag, mode } + if (files.has(destination)) throw Object.assign(new Error('exists'), { code: 'EEXIST' }) + const record = { content: '', stat: { dev: 1, ino: nextInode++ } }; files.set(destination, record) + return { async stat() { return record.stat }, async writeFile(content, value) { assert.equal(content, '{}\n'); assert.deepEqual(value, { encoding: 'utf8' }); record.content = content }, async close() {} } + }, + async link(source, destination) { if (files.has(destination)) throw Object.assign(new Error('exists'), { code: 'EEXIST' }); files.set(destination, files.get(source)) }, + async unlink(value) { files.delete(value) } + } + await service.__test.writeNewFile('C:\\reports\\snapshot.json', '{}\n', io) + assert.equal(options.flag, 'wx'); assert.equal(options.mode, 0o600) + assert.equal(files.get('C:\\reports\\snapshot.json').content, '{}\n') + await assert.rejects(service.__test.writeNewFile('/reports/existing.md', 'new', { async lstat() { return { isFile: () => true } }, async open() { throw new Error('must not open') } }), (error) => error.code === 'DESTINATION_EXISTS') + await assert.rejects(service.__test.writeNewFile('/reports/raced.md', 'new', { async lstat(value) { if (value === '/reports/raced.md') throw missing; return { dev: 1, ino: 1 } }, async open() { return { async stat() { return { dev: 1, ino: 1 } }, async writeFile() {}, async close() {} } }, async link() { throw Object.assign(new Error('exists'), { code: 'EEXIST' }) }, async unlink() {} }), (error) => error.code === 'DESTINATION_EXISTS') + delete global.window +}) +test('a stale save operation removes its temporary or just-linked output', async () => { + const service = loadWithHost({}) + const missing = Object.assign(new Error('missing'), { code: 'ENOENT' }) + for (const staleAfter of ['write', 'link', 'cleanup']) { + const files = new Map(); let current = true + const io = { + async lstat(value) { if (!files.has(value)) throw missing; return files.get(value).stat }, + async open(value) { const record = { content: '', stat: { dev: 1, ino: 1 } }; files.set(value, record); return { async stat() { return record.stat }, async writeFile(content) { record.content = content; if (staleAfter === 'write') current = false }, async close() {} } }, + async link(source, destination) { files.set(destination, files.get(source)); if (staleAfter === 'link') current = false }, + async unlink(value) { files.delete(value); if (staleAfter === 'cleanup' && value.endsWith('.tmp')) current = false } + } + await assert.rejects(service.__test.writeNewFile('/reports/snapshot.json', '{}\n', io, () => { if (!current) throw Object.assign(new Error('ended'), { code: 'SESSION_ENDED' }) }), error => error.code === 'SESSION_ENDED') + assert.equal(files.size, 0) + } + delete global.window +}) +test('temporary unlink failure rolls back only this operation destination and clears its temp', async () => { + const service = loadWithHost({}); const files = new Map(); const missing = Object.assign(new Error('missing'), { code: 'ENOENT' }); let failTempUnlink = true + const io = { + async lstat(value) { if (!files.has(value)) throw missing; return files.get(value).stat }, + async open(value) { const record = { content: '', stat: { dev: 1, ino: 10 } }; files.set(value, record); return { async stat() { return record.stat }, async writeFile(content) { record.content = content }, async close() {} } }, + async link(source, destination) { files.set(destination, files.get(source)) }, + async unlink(value) { if (value.endsWith('.tmp') && failTempUnlink) { failTempUnlink = false; throw Object.assign(new Error('/secret/temp: busy'), { code: 'EBUSY' }) } files.delete(value) } + } + await assert.rejects(service.__test.writeNewFile('/reports/snapshot.json', '{}\n', io), error => error.code === 'SAVE_FAILED' && !error.message.includes('/secret/temp')) + assert.equal(files.size, 0) + delete global.window +}) +test('rollback failure is path-free and never unlinks a destination replaced by another writer', async () => { + const service = loadWithHost({}); const files = new Map(); const missing = Object.assign(new Error('missing'), { code: 'ENOENT' }); let temporary; let temporaryUnlinkAttempts = 0 + const foreign = { content: 'other-writer', stat: { dev: 9, ino: 99 } } + const io = { + async lstat(value) { if (!files.has(value)) throw missing; return files.get(value).stat }, + async open(value) { temporary = value; const record = { content: '', stat: { dev: 1, ino: 10 } }; files.set(value, record); return { async stat() { return record.stat }, async writeFile(content) { record.content = content }, async close() {} } }, + async link(source, destination) { files.set(destination, files.get(source)) }, + async unlink(value) { + if (value === temporary) { + temporaryUnlinkAttempts += 1 + if (temporaryUnlinkAttempts === 1) { files.set('/reports/snapshot.json', foreign); throw Object.assign(new Error('/private/secret/temp busy'), { code: 'EBUSY' }) } + } + files.delete(value) + } + } + await assert.rejects(service.__test.writeNewFile('/reports/snapshot.json', '{}\n', io), error => error.code === 'SAVE_FAILED' && !error.message.includes('/private/secret')) + assert.equal(files.get('/reports/snapshot.json'), foreign) + assert.equal([...files.keys()].some(value => value.endsWith('.tmp')), false) + delete global.window +}) +test('a colliding temporary name is never claimed or unlinked before exclusive open succeeds', async () => { + const service = loadWithHost({}); const missing = Object.assign(new Error('missing'), { code: 'ENOENT' }); let opened = 0; let unlinked = 0 + const io = { + async lstat() { throw missing }, + async open() { opened += 1; throw Object.assign(new Error('already exists'), { code: 'EEXIST' }) }, + async unlink() { unlinked += 1 } + } + await assert.rejects(service.__test.writeNewFile('/reports/snapshot.json', '{}\n', io), error => error.code === 'SAVE_FAILED') + assert.equal(opened, 1); assert.equal(unlinked, 0) + delete global.window +}) +test('dialog and clipboard host errors use stable Chinese errors without paths', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'worktree-host-errors-')) + let repository = path.join(directory, 'approved-repository'); execFileSync('git', ['init', '-q', repository], { stdio: 'ignore' }); repository = await fs.realpath(repository) + let phase = 'save' + const service = loadWithHost({ showOpenDialog: async () => [repository], showSaveDialog: async () => { throw new Error('/private/secret/save-path') }, copyText: async () => { throw new Error('/private/secret/clipboard') } }) + const chosen = await global.window.gitWorktreeCockpit.chooseRepository(); await global.window.gitWorktreeCockpit.inspectGrant(chosen.grantId) + await assert.rejects(global.window.gitWorktreeCockpit.saveSnapshot(chosen.grantId, 'json'), error => error.code === 'SAVE_FAILED' && !error.message.includes('/private/secret')) + phase = 'copy' + await assert.rejects(global.window.gitWorktreeCockpit.copySnapshot(chosen.grantId, 'json'), error => error.code === 'COPY_FAILED' && !error.message.includes('/private/secret')) + assert.equal(phase, 'copy') + delete global.window; await fs.rm(directory, { recursive: true, force: true }) +}) +test('MCP snapshot revokes a deleted repository grant without leaking its absolute path', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'worktree-mcp-deleted-')) + let repository = path.join(directory, 'deleted-repository') + execFileSync('git', ['init', '-q', repository], { stdio: 'ignore' }) + repository = await fs.realpath(repository) + const calls = new Map() + const service = loadWithHost({ showOpenDialog: async () => [repository], registerTool(name, handler) { calls.set(name, handler) } }) + await global.window.gitWorktreeCockpit.chooseRepository() + await fs.rm(repository, { recursive: true, force: true }) + await assert.rejects(calls.get(service.TOOL_NAMES.snapshotApproved)({}), (error) => { + assert.equal(error.code, 'APPROVED_REPOSITORY_FAILED') + assert.equal(error.message.includes(directory), false) + assert.equal(error.message.includes(repository), false) + return true + }) + assert.equal(service.__test.activeGrantId(), null) + await assert.rejects(calls.get(service.TOOL_NAMES.snapshotApproved)({}), (error) => error.code === 'UI_APPROVAL_REQUIRED') + delete global.window + await fs.rm(directory, { recursive: true, force: true }) +}) +test('renderer bridge only accepts authorization ids, not repository or executable paths', () => { + global.window = { ztools: {} } + delete require.cache[require.resolve('../preload/services.cjs')] + require('../preload/services.cjs') + const api = global.window.gitWorktreeCockpit + assert.equal(typeof api.inspectGrant, 'function') + assert.equal(api.inspect, undefined) + assert.equal(api.resolveGit, undefined) + assert.equal(api.gitCandidates, undefined) + delete global.window +}) +test('showOpenDialog accepts official string array result', async () => { + global.window = { ztools: { showOpenDialog: async () => ['/definitely-not-a-repository'] } } + delete require.cache[require.resolve('../preload/services.cjs')] + require('../preload/services.cjs') + await assert.rejects(global.window.gitWorktreeCockpit.chooseRepository(), error => error.code === 'REPOSITORY_UNAVAILABLE' && !error.message.includes('/definitely-not-a-repository')) + delete global.window +}) +test('a directory chooser that resolves after plugin out cannot restore its grant', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'worktree-session-choose-')) + let repository = path.join(directory, 'approved-repository') + execFileSync('git', ['init', '-q', repository], { stdio: 'ignore' }) + repository = await fs.realpath(repository) + let resolveDialog + const dialog = new Promise(resolve => { resolveDialog = resolve }) + const service = loadWithHost({ showOpenDialog: () => dialog }) + const pending = global.window.gitWorktreeCockpit.chooseRepository() + service.__test.clearSession() + resolveDialog([repository]) + await assert.rejects(pending, error => error.code === 'SESSION_ENDED') + assert.equal(service.__test.activeGrantId(), null) + assert.equal(service.__test.grant('anything'), undefined) + delete global.window + await fs.rm(directory, { recursive: true, force: true }) +}) +test('a save dialog that resolves after plugin out cannot create its selected file', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'worktree-session-save-')) + let repository = path.join(directory, 'approved-repository') + execFileSync('git', ['init', '-q', repository], { stdio: 'ignore' }) + repository = await fs.realpath(repository) + const destination = path.join(directory, 'must-not-exist.json') + let resolveDialog + const dialog = new Promise(resolve => { resolveDialog = resolve }) + const service = loadWithHost({ showOpenDialog: async () => [repository], showSaveDialog: () => dialog }) + const chosen = await global.window.gitWorktreeCockpit.chooseRepository() + await global.window.gitWorktreeCockpit.inspectGrant(chosen.grantId) + const pending = global.window.gitWorktreeCockpit.saveSnapshot(chosen.grantId, 'json') + service.__test.clearSession() + resolveDialog({ canceled: false, filePath: destination }) + await assert.rejects(pending, error => error.code === 'SESSION_ENDED' && !error.message.includes(directory)) + await assert.rejects(fs.lstat(destination), error => error.code === 'ENOENT') + delete global.window + await fs.rm(directory, { recursive: true, force: true }) +}) +test('human and MCP snapshots started before plugin out never return stale results', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'worktree-session-mcp-')) + let repository = path.join(directory, 'approved-repository') + execFileSync('git', ['init', '-q', repository], { stdio: 'ignore' }) + repository = await fs.realpath(repository) + const calls = new Map() + const service = loadWithHost({ showOpenDialog: async () => [repository], registerTool(name, handler) { calls.set(name, handler) } }) + const chosen = await global.window.gitWorktreeCockpit.chooseRepository() + const human = global.window.gitWorktreeCockpit.inspectGrant(chosen.grantId) + service.__test.clearSession() + await assert.rejects(human, /重新选择/) + assert.equal(service.__test.activeGrantId(), null) + const chosenAgain = await global.window.gitWorktreeCockpit.chooseRepository() + const mcp = calls.get(service.TOOL_NAMES.snapshotApproved)({}) + service.__test.clearSession() + await assert.rejects(mcp, error => error.code === 'APPROVED_REPOSITORY_FAILED' && !error.message.includes(repository)) + assert.equal(service.__test.activeGrantId(), null) + assert.equal(service.__test.grant(chosenAgain.grantId), undefined) + delete global.window + await fs.rm(directory, { recursive: true, force: true }) +}) diff --git a/plugins/git-worktree-cockpit/tests/ui-security.test.cjs b/plugins/git-worktree-cockpit/tests/ui-security.test.cjs new file mode 100644 index 00000000..f5b89e7c --- /dev/null +++ b/plugins/git-worktree-cockpit/tests/ui-security.test.cjs @@ -0,0 +1,28 @@ +'use strict' +const test = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +test('worktree UI renders runtime values through textContent, never innerHTML', () => { + const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8') + assert.match(html, /textContent=String\(value/) + assert.ok(!html.includes('.innerHTML')) +}) +test('worktree UI is localized to simplified Chinese', () => { + const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8') + assert.match(html, //) + assert.match(html, /Git 工作树驾驶舱<\/title>/) + assert.match(html, />工作树</) + assert.match(html, /当前 ZTools 版本不支持目录选择对话框/) + for (const visibleEnglish of ['READ-ONLY FLIGHT DECK', 'Branch graph', '>WORKTREE<', '>BRANCH<', '>SYNC<', '>STATE<']) assert.equal(html.includes(visibleEnglish), false) +}) +test('plugin-out reset clears renderer-held authorization and stale async callbacks are epoch gated', () => { + const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8') + assert.match(html, /addEventListener\('git-worktree-cockpit-session-ended',resetRenderer\)/) + assert.match(html, /function resetRenderer\(\)\{rendererEpoch\+=1;grantId=null;enabled\(false\);chooseButton\.disabled=false;renderHeader\(\)/) + assert.match(html, /function runCurrent\(button,operation,onSuccess,onFailure\)\{const epoch=rendererEpoch;/) + assert.match(html, /if\(!isCurrent\(epoch\)\)return;onSuccess\(result\)/) + assert.match(html, /if\(!isCurrent\(epoch\)\)return;onFailure\(error\)/) + assert.match(html, /finally\{if\(!isCurrent\(epoch\)\)return;button\.disabled=false\}/) + assert.equal(html.includes('error.message'), false) +})