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 工作树驾驶舱
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)
+})