diff --git a/CHANGELOG.md b/CHANGELOG.md index ca4b71b..1fd5e5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased source - 2026-10-21 +- Add a loopback graphical workbench for real Skill and contract directories, + with native macOS selection, read-only cancellation and offline report review. +- Review safe fixes, explicitly apply with backup, and recheck separately. Bind + plans to the selected input snapshot; limit child output and saved evidence. + - Open offline verification reports at the first priority needing review and jump directly to other severity groups, including without JavaScript. - Keep complete counts and the existing detail budget; omit empty groups and diff --git a/README.md b/README.md index 846f671..fa9d8c2 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,21 @@ GitHub v0.3.0 adds a declared cross-file checklist to HTML, text and JSON report [![Terminal demo](https://raw.githubusercontent.com/Chumaniac/skillsync/main/docs/assets/verify-demo.svg)](https://github.com/Chumaniac/skillsync/blob/main/docs/assets/verify-demo.svg) +## Local graphical workbench + +Run `skillsync gui --state-dir /absolute/private/report-directory`, then open +`http://127.0.0.1:14986`. Select a Skill directory, or select a contract and its +artifact directory. The macOS folder/file picker also accepts an explicit path. +No Skill instructions run. Reports, plans and receipts stay in the private state +directory, outside the selected input. + +For a single Skill, review the plan, explicitly confirm safe changes with backup, +then run **Recheck**. Applying a plan does not establish a passing verification. +Changed inputs invalidate the plan. The server accepts loopback clients only, +runs one bounded CLI process, and stops at 64 MiB of saved evidence rather than +deleting prior results. Use a new state directory when full. Cancelling a +read-only operation reaps its child; applying changes must finish before recheck. + ## Install **From GitHub releases (no npm account required):** diff --git a/package.json b/package.json index a4df2e8..8c47da7 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,8 @@ "build": "tsc", "lint": "eslint .", "check": "npm test && npm run type-check && npm run lint && npm run build", - "prepack": "npm run build" + "prepack": "npm run build", + "pretest": "npm run build" }, "dependencies": { "commander": "^13.1.0", diff --git a/src/cli/index.ts b/src/cli/index.ts index 70473fa..0bc56ed 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -244,6 +244,20 @@ export function createCli(io: CliIO = defaultCliIO): Command { .description("Provenance, compatibility, and behavior verification for Agent Skills.") .version(VERSION); + program.command("gui") + .description("Open the loopback-only graphical verification workbench.") + .requiredOption("--state-dir ", "Private directory for plans and reports (outside Skill roots)") + .option("--port ", "Loopback port", "14986") + .action(async (options: {stateDir: string; port: string}) => { + const port = Number(options.port); + if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error("Invalid port."); + const { startWorkbench } = await import("../workbench/server.js"); + const workbench = await startWorkbench({port, stateDir: options.stateDir}); + io.writeOut(`SkillSync: ${workbench.url}\n`); + const close = () => { void workbench.close(); }; + process.once("SIGINT", close); process.once("SIGTERM", close); + }); + program .command("scan") .description("Inspect skill files without executing their instructions.") diff --git a/src/workbench/page.ts b/src/workbench/page.ts new file mode 100644 index 0000000..c445356 --- /dev/null +++ b/src/workbench/page.ts @@ -0,0 +1,34 @@ +/** Static local UI. All evidence is inserted as text or in a scriptless report frame. */ +export function workbenchPage(nonce: string): string { + return `SkillSync 核验台
+
SkillSync · 本机

核验台

选择实际目录,检查结果,再决定是否修复。

+

1. 选择目录

+

尚未核验

+
只调用本机核验器,不执行 Skill 指令。修复计划、回执和报告保存在私有状态目录。
`; +} + +export const workbenchStyle = `:root{font:16px/1.6 system-ui;color:#19343c;background:#f3f6f6}*{box-sizing:border-box}main{max-width:1040px;margin:auto;padding:32px 20px}header{margin-bottom:28px}h1{margin:8px 0;font-size:36px}h2{font-size:20px}section{padding:20px;background:white;border:1px solid #c8d8dc;border-radius:8px;margin:20px 0}label{display:block;margin:12px 0 6px}input:not([type=checkbox]),select{font:inherit;min-height:44px;padding:8px;width:100%;border:1px solid #829fa7;border-radius:4px}input[type=checkbox]{width:24px;height:24px;vertical-align:middle;margin-right:8px}label:has(input[type=checkbox]){min-height:44px;display:flex;align-items:center}button{font:inherit;min-height:44px;padding:8px 18px;border:1px solid #6f939d;border-radius:4px;color:#19343c;background:#edf5f6;cursor:pointer}button:disabled{opacity:.55;cursor:default}.row{display:flex;gap:12px;align-items:center}.row input{flex:1;min-width:0}.row button{white-space:nowrap}.actions{flex-wrap:wrap;margin-top:18px}:focus-visible{outline:3px solid #087a91;outline-offset:3px}iframe{width:100%;height:580px;border:1px solid #c8d8dc;margin-bottom:16px}pre{white-space:pre-wrap;overflow-wrap:anywhere;font:14px/1.7 ui-monospace,monospace}footer{font-size:13px;color:#496771}#status{padding:12px;border-left:3px solid #318496}[hidden]{display:none!important}@media(max-width:580px){.row{flex-wrap:wrap}.row input{flex-basis:100%}main{padding:16px}section{padding:16px}}`; + +export const workbenchScript = `"use strict"; +const $ = id => document.getElementById(id); +const nonce = document.querySelector('meta[name="client"]').content; +let busy = false, planId = null, selected = null; +async function api(action, body = {}) { + const response = await fetch('/api/' + action, {method:'POST',headers:{'Content-Type':'application/json','X-SkillSync-Client':nonce},body:JSON.stringify(body)}); + const data = await response.json(); if(!response.ok) throw new Error(data.error || '请求未完成'); return data; +} +function status(text){ $('status').textContent = text; } +function lock(value, applying = false){ busy=value; ['verify','plan','reverify','choose','choose-contract','root','contract','kind','delivery'].forEach(id => $(id).disabled=value); $('cancel').disabled=!value || applying; $('apply').disabled=value || !$('confirm').checked || !planId; } +function invalidate(){ selected=null; planId=null; $('result').hidden=true; $('fix').hidden=true; $('confirm').checked=false; $('report').srcdoc=''; status('目录或模式已更改,请重新核验'); } +['root','contract','kind','delivery'].forEach(id => $(id).addEventListener('change', () => {invalidate(); $('contract-fields').hidden=$('kind').value!=='artifacts';})); +for(const [button,field] of [['choose','root'],['choose-contract','contract']]) $(button).onclick=async()=>{ lock(true,true); try { const data=await api('choose',{file:field==='contract'}); $(field).value=data.path; invalidate(); } catch(e){status(e.message);} finally{lock(false);} }; +async function verify(recheck=false){ lock(true); status(recheck?'正在重新核验…':'正在核验…'); try { + if(!recheck){ await api('select',{path:$('root').value,kind:$('kind').value,contract:$('contract').value,delivery:$('delivery').checked}); planId=null; $('fix').hidden=true; $('confirm').checked=false; } + const data=await api('verify'); selected=true; $('report').srcdoc=data.html; $('result').hidden=false; $('plan').hidden=$('kind').value!=='skill'; status((recheck?'重新核验完成':'核验完成')+' · '+data.status); if(recheck) $('receipt').textContent='最新核验:'+data.status; + }catch(e){status(e.message);}finally{lock(false);} } +$('verify').onclick=()=>verify(); $('reverify').onclick=()=>verify(true); +$('cancel').onclick=async()=>{try{await api('cancel');status('已请求取消,等待核验器退出…');}catch(e){status(e.message);}}; +$('plan').onclick=async()=>{if(!selected)return;lock(true);try{const data=await api('plan');planId=data.id; $('changes').textContent=data.description; $('fix').hidden=false; $('confirm').checked=false; $('receipt').textContent='仅生成计划,尚未应用。'; status('计划已生成,请审阅');}catch(e){status(e.message);}finally{lock(false);}}; +$('confirm').onchange=()=>{$('apply').disabled=busy||!$('confirm').checked||!planId;}; +$('apply').onclick=async()=>{lock(true,true);try{const data=await api('apply',{id:planId,confirm:$('confirm').checked}); planId=null; $('confirm').checked=false; $('receipt').textContent=data.message; status('修复已应用,尚未重新核验');}catch(e){planId=null;$('confirm').checked=false;status(e.message+';请重新核验当前目录');}finally{lock(false);}}; +`; diff --git a/src/workbench/server.ts b/src/workbench/server.ts new file mode 100644 index 0000000..025b4ba --- /dev/null +++ b/src/workbench/server.ts @@ -0,0 +1,166 @@ +import { execFile, spawn, type ChildProcess } from "node:child_process"; +import { randomBytes, randomUUID } from "node:crypto"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { chmod, lstat, mkdir, readdir, realpath, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import type { ActionPlan } from "../domain/action-plan.js"; +import type { VerificationReport } from "../cli/commands/verify.js"; +import type { ArtifactReport } from "../cli/commands/artifacts.js"; +import { renderVerificationHtml } from "../reporters/verification-html.js"; +import { renderArtifactsHtml } from "../reporters/artifacts-html.js"; +import { scanStagedWorkspace } from "../sandbox/workspace-tree.js"; +import { workbenchPage, workbenchScript, workbenchStyle } from "./page.js"; + +const MAX_OUTPUT = 8 * 1024 * 1024; +const MAX_STATE = 64 * 1024 * 1024; +type Selection = { path: string; identity: string; kind: "skill" | "artifacts"; contract?: string; delivery: boolean }; +const identity = (s: {dev: number; ino: number}) => `${s.dev}:${s.ino}`; +const inside = (root: string, target: string) => { const r = relative(root, target); return !r || (!r.startsWith("..") && !isAbsolute(r)); }; + +/** One loopback client, one bounded CLI process. No shell or Skill execution API. */ +export async function startWorkbench(options: { port?: number; stateDir: string; cliPath?: string; timeoutMs?: number }) { + const stateDir = resolve(options.stateDir); + await mkdir(stateDir, { recursive: true, mode: 0o700 }); + if ((await lstat(stateDir)).isSymbolicLink() || await realpath(stateDir) !== stateDir) throw new Error("State directory must be a real directory."); + await chmod(stateDir, 0o700); + const nonce = randomBytes(32).toString("hex"); + const cliPath = options.cliPath ?? fileURLToPath(new URL("../cli/index.js", import.meta.url)); + let active: ChildProcess | undefined, busy = false, applying = false, cancelled = false; + let selection: Selection | undefined; + let actionable: string[] = []; + let plan: { id: string; path: string; value: ActionPlan; snapshot: string } | undefined; + let origin = ""; + + async function capacity() { + const entries = await readdir(stateDir); + if (entries.length >= 120) throw new Error("状态目录已满,请选择新的状态目录;原结果已保留。"); + let bytes = 0; + for (const name of entries) { const s = await lstat(join(stateDir, name)); if (!s.isFile()) throw new Error("状态目录包含非普通文件。"); bytes += s.size; } + // Reserve a complete bounded output before a command, including mutating apply. + if (bytes + MAX_OUTPUT > MAX_STATE) throw new Error("状态目录达到 64 MiB 上限,请选择新的状态目录。"); + } + async function save(name: string, value: string) { + if (Buffer.byteLength(value) > MAX_OUTPUT) throw new Error("报告超过大小上限。"); + const path = join(stateDir, `${randomUUID()}-${name}`); + await writeFile(path, value, { flag: "wx", mode: 0o600 }); return path; + } + async function checkSelection() { + if (!selection) throw new Error("请先选择目录。"); + const s = await lstat(selection.path); + if (!s.isDirectory() || s.isSymbolicLink() || identity(s) !== selection.identity || await realpath(selection.path) !== selection.path) throw new Error("目录已被替换,请重新选择。"); + return selection; + } + async function command(args: string[]): Promise { + await capacity(); if (cancelled) throw new Error("Cancelled"); + return new Promise((accept, reject) => { + const child = spawn(process.execPath, [cliPath, ...args], { shell: false, stdio: ["ignore", "pipe", "pipe"], env: { PATH: dirname(process.execPath), LANG: "en_US.UTF-8" } }); + active = child; const chunks: Buffer[] = []; let size = 0, failure = ""; + const stop = (reason: string) => { failure = reason; child.kill("SIGTERM"); }; + const timer = setTimeout(() => stop(applying ? "修复超时,结果未确认,请重新核验。" : "核验超时,已停止。"), options.timeoutMs ?? 120_000); + // A killed child must be reaped even if it ignores SIGTERM. + const killer = setTimeout(() => child.kill("SIGKILL"), (options.timeoutMs ?? 120_000) + 2000); killer.unref(); + child.stdout!.on("data", (chunk: Buffer) => { size += chunk.length; if (size > MAX_OUTPUT) stop("报告超过 8 MiB 上限。"); else chunks.push(chunk); }); + child.stderr!.on("data", (chunk: Buffer) => { size += chunk.length; if (size > MAX_OUTPUT) stop("核验器输出超过上限。"); }); + child.on("error", () => { failure = "无法启动本机核验器。"; }); + child.on("close", code => { clearTimeout(timer); clearTimeout(killer); active = undefined; + if (cancelled || failure) reject(new Error(cancelled ? "核验已取消,未产生完成结果。" : failure)); + else if (!Buffer.concat(chunks).toString("utf8").trim()) reject(new Error(`核验器未返回报告(${code})。`)); else accept(Buffer.concat(chunks).toString("utf8")); + }); + }); + } + async function body(req: IncomingMessage): Promise> { + let bytes = 0, text = ""; + for await (const chunk of req) { bytes += chunk.length; if (bytes > 8192) throw new Error("请求过大。"); text += chunk.toString(); } + const value: unknown = JSON.parse(text || "{}"); + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("请求无效。"); + return value as Record; + } + function send(res: ServerResponse, code: number, value: unknown, type = "application/json") { + res.writeHead(code, { "Content-Type": `${type}; charset=utf-8`, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer", "Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' about:; frame-ancestors 'none'; object-src 'none'; base-uri 'none'; form-action 'none'" }); + res.end(type === "application/json" ? JSON.stringify(value) : value); + } + const server = createServer(async (req, res) => { + if (req.headers.host !== new URL(origin).host || (req.headers.origin && req.headers.origin !== origin) || req.headers["sec-fetch-site"] === "cross-site") return send(res, 403, {error: "仅允许本机核验台访问。"}); + if (req.method === "GET") { + if (req.url === "/favicon.ico") { res.writeHead(204); return res.end(); } + if (req.url === "/health") return send(res, 200, {service: "skillsync-workbench", schema: 1}); + if (req.url === "/") return send(res, 200, workbenchPage(nonce), "text/html"); + if (req.url === "/app.js") return send(res, 200, workbenchScript, "text/javascript"); + if (req.url === "/style.css") return send(res, 200, workbenchStyle, "text/css"); + return send(res, 404, {}); + } + if (req.method !== "POST" || req.headers["x-skillsync-client"] !== nonce || !req.headers["content-type"]?.startsWith("application/json")) return send(res, 403, {error: "请从核验台发起操作。"}); + try { + const input = await body(req); + if (req.url === "/api/cancel") { if (applying) throw new Error("修复写入中,请等待回执后重新核验。"); cancelled = true; active?.kill("SIGTERM"); const child = active; const timer = setTimeout(() => child?.kill("SIGKILL"), 2000); timer.unref(); return send(res, 200, {cancelled: true}); } + if (busy) return send(res, 409, {error: "已有操作进行中。"}); + busy = true; cancelled = false; + try { + if (req.url === "/api/choose") { + if (process.platform !== "darwin") throw new Error("请在路径框中输入目录或合同的完整路径。"); + const script = input.file === true ? 'POSIX path of (choose file with prompt "选择核验合同")' : 'POSIX path of (choose folder with prompt "选择核验目录")'; + const {stdout} = await promisify(execFile)("/usr/bin/osascript", ["-e", script], {timeout: 60_000, maxBuffer: 8192}); + return send(res, 200, {path: stdout.trim()}); + } + if (req.url === "/api/select") { + if (typeof input.path !== "string" || !isAbsolute(input.path) || input.path.length > 4096) throw new Error("请输入完整目录路径。"); + const path = resolve(input.path), s = await lstat(path); + if (!s.isDirectory() || s.isSymbolicLink() || await realpath(path) !== path || path === "/" || path === homedir() || inside(path, stateDir) || inside(stateDir, path)) throw new Error("请选择独立的真实 Skill 或交付目录。"); + if (input.kind !== "skill" && input.kind !== "artifacts") throw new Error("请选择核验内容。"); + let contract: string | undefined; + if (input.kind === "artifacts") { + if (typeof input.contract !== "string" || !isAbsolute(input.contract)) throw new Error("请选择合同文件。"); + contract = resolve(input.contract); const file = await lstat(contract); + if (!file.isFile() || file.isSymbolicLink() || file.size > 65536 || await realpath(contract) !== contract) throw new Error("合同必须是小于 64 KiB 的普通文件。"); + } + selection = {path, identity: identity(s), kind: input.kind, contract, delivery: input.delivery === true}; plan = undefined; actionable = []; + return send(res, 200, {selected: true}); + } + const selected = await checkSelection(); + if (req.url === "/api/verify") { + const output = await command(selected.kind === "skill" ? ["verify", "--path", selected.path, "--target", "codex", "--format", "json"] : ["artifacts", "--contract", selected.contract!, selected.delivery ? "--delivery" : "--path", selected.path, "--format", "json"]); + const report = JSON.parse(output) as VerificationReport & ArtifactReport; + if (selected.kind === "skill") actionable = report.issues.filter(i => i.finding.status === "fail" || i.finding.status === "warn").map(i => i.id); + const html = selected.kind === "skill" ? renderVerificationHtml(report) : renderArtifactsHtml(report); + await save("report.json", output); + return send(res, 200, {html, status: report.exitCode === 0 ? "已声明检查通过" : "存在待处理项", exitCode: report.exitCode}); + } + if (selected.kind !== "skill") throw new Error("交付核验不提供自动修复。"); + if (req.url === "/api/plan") { + plan = undefined; + if (!actionable.length) throw new Error("No actionable findings"); + const snapshot = (await scanStagedWorkspace(selected.path)).digest; + if (cancelled) throw new Error("Cancelled"); + const output = await command(["fix", "--plan", "--path", selected.path, "--target", "codex", "--issue", ...actionable, "--format", "json"]); + const value = JSON.parse(output) as ActionPlan; + if (value.rootPath !== selected.path || !Array.isArray(value.changes) || !value.planDigest) throw new Error("未能生成有效计划,请先核验单个 Skill 目录。"); + if (snapshot !== (await scanStagedWorkspace(selected.path)).digest) throw new Error("Directory changed"); + plan = {snapshot, id: randomUUID(), path: await save("plan.json", output), value}; + const description = [...value.changes.map(c => `${c.path} · ${c.safety} · ${c.modeBefore?.toString(8) ?? "内容"} → ${c.modeAfter?.toString(8) ?? "内容"}`), ...value.manualSteps.map(s => `人工处理:${s.title}\n${s.steps.join("\n")}`)].join("\n\n"); + return send(res, 200, {id: plan.id, description: description || "没有可自动应用的变更。"}); + } + if (req.url === "/api/apply") { + if (!plan || input.id !== plan.id || input.confirm !== true) throw new Error("请审阅当前计划并明确确认。"); + const current = plan; plan = undefined; + if (!current.value.changes.length || current.value.changes.length > 256 || current.value.changes.some(c => c.safety !== "safe")) throw new Error("此计划包含需人工审阅的变更,请使用 CLI 逐项处理。"); + if (current.snapshot !== (await scanStagedWorkspace(selected.path)).digest) throw new Error("Plan is stale"); + applying = true; + const output = await command(["fix", "--apply", "--plan", current.path, "--yes", "--backup", "--format", "json"]); + await save("receipt.json", output); + const receipt = JSON.parse(output) as {status?: string; backupPath?: string}; + if (receipt.status !== "applied") throw new Error("修复未完成;已保存可用回执,请重新核验。"); + return send(res, 200, {message: `已应用并备份。尚未重新核验。备份:${receipt.backupPath ?? "见私有回执"}`}); + } + return send(res, 404, {error: "未知操作。"}); + } finally { busy = false; applying = false; } + } catch { send(res, 400, {error: cancelled ? "核验已取消,未产生完成结果。" : "操作未完成:请检查目录、合同、计划是否失效或状态目录容量,再重新核验。"}); } + }); + server.requestTimeout = 130_000; server.headersTimeout = 10_000; server.maxConnections = 8; + await new Promise((accept, reject) => { server.once("error", reject); server.listen(options.port ?? 14986, "127.0.0.1", accept); }); + const address = server.address(); if (!address || typeof address === "string") throw new Error("No local address"); + origin = `http://127.0.0.1:${address.port}`; + return {url: origin, async close() { active?.kill("SIGTERM"); server.closeAllConnections(); await new Promise(accept => server.close(() => accept())); }}; +} diff --git a/tests/workbench/server.test.ts b/tests/workbench/server.test.ts new file mode 100644 index 0000000..10cf4cd --- /dev/null +++ b/tests/workbench/server.test.ts @@ -0,0 +1,61 @@ +import { chmod, mkdir, mkdtemp, readFile, realpath, rename, stat, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, describe, expect, it } from "vitest"; +import { startWorkbench } from "../../src/workbench/server.js"; + +const running: Array>> = []; +afterEach(async () => { for (const app of running.splice(0)) await app.close(); }); +async function setup(cliPath = resolve("dist/cli/index.js")) { + const base = await realpath(await mkdtemp(join(tmpdir(), "sync-gui-"))); + const root = join(base, "review"); await mkdir(join(root, "scripts"), {recursive: true}); + await writeFile(join(root, "SKILL.md"), "---\nname: review\ndescription: Review a change.\n---\n"); + const script = join(root, "scripts", "check.sh"); + await writeFile(script, "#!/bin/sh\nexit 47\n", {mode: 0o777}); await chmod(script, 0o777); + const app = await startWorkbench({port: 0, stateDir: join(base, "state"), cliPath}); running.push(app); + const html = await (await fetch(app.url)).text(); const token = html.match(/name="client" content="([a-f0-9]+)"/)![1]; + const api = async (action: string, body = {}) => { + const response = await fetch(`${app.url}/api/${action}`, {method: "POST", headers: {"Content-Type": "application/json", "X-SkillSync-Client": token}, body: JSON.stringify(body)}); + return {status: response.status, data: await response.json()}; + }; + return {base, root, script, app, api, token}; +} +describe("real CLI graphical workbench", () => { + it("runs read-only verification → plan → explicit apply + backup → separate recheck", async () => { + const {root, script, api, base} = await setup(); const original = await readFile(script); + expect((await api("select", {path: root, kind: "skill"})).status).toBe(200); + const first = await api("verify"); expect(first.status).toBe(200); expect(first.data.html).toContain("structure.invalid-script-mode"); + expect((await stat(script)).mode & 0o777).toBe(0o777); expect(await readFile(script)).toEqual(original); + const planned = await api("plan"); expect(planned.status).toBe(200); expect(planned.data.description).toContain("777 → 755"); + expect((await api("apply", {id: planned.data.id})).status).toBe(400); + const applied = await api("apply", {id: planned.data.id, confirm: true}); expect(applied.status).toBe(200); + expect(applied.data.message).toContain("尚未重新核验"); expect(applied.data.message).toContain("备份"); + expect((await stat(script)).mode & 0o777).toBe(0o755); expect(await readFile(script)).toEqual(original); + const after = await api("verify"); expect(after.status).toBe(200); expect(after.data.html).not.toContain("structure.invalid-script-mode"); + expect((await api("apply", {id: planned.data.id, confirm: true})).status).toBe(400); + expect((await stat(join(base, "state"))).mode & 0o777).toBe(0o700); + }); + it("rejects stale plans, replaced roots, cross-origin requests and missing client capability", async () => { + const {root, script, api, app, token} = await setup(); + await api("select", {path: root, kind: "skill"}); await api("verify"); const plan = await api("plan"); + await writeFile(script, "changed by owner\n"); + expect((await api("apply", {id: plan.data.id, confirm: true})).status).toBe(400); + expect(await readFile(script, "utf8")).toBe("changed by owner\n"); + await rename(root, root + "-original"); await mkdir(root); + expect((await api("verify")).status).toBe(400); + for (const headers of [{"Content-Type":"application/json"}, {"Content-Type":"application/json", "X-SkillSync-Client":token, Origin:"https://example.invalid"}]) { + expect((await fetch(app.url + "/api/verify", {method:"POST",headers,body:"{}"})).status).toBe(403); + } + }); + it("cancels and reaps one active process without reporting completion", async () => { + const base = await realpath(await mkdtemp(join(tmpdir(), "sync-slow-"))); + const slow = join(base, "slow.cjs"); await writeFile(slow, "setInterval(()=>{},1000)"); + const {api, root} = await setup(slow); await api("select", {path:root,kind:"skill"}); + const verification = api("verify"); + await new Promise(resolve => setTimeout(resolve, 100)); + expect((await api("plan")).status).toBe(409); + expect((await api("cancel")).status).toBe(200); + expect((await verification).data.error).toContain("已取消"); + expect((await api("select", {path:root,kind:"skill"})).status).toBe(200); + }); +});