From 9efb8a284e3d841ea874cdf930f129c5a7795255 Mon Sep 17 00:00:00 2001 From: Chumaniac <3408601995@qq.com> Date: Sun, 1 Nov 2026 13:02:12 +0800 Subject: [PATCH] fix: separate observed packet digests from independent expectations --- docs/workbench-history.md | 11 +++++++++++ src/workbench/page.ts | 11 +++++++---- tests/workbench/page.test.ts | 11 +++++++++-- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/docs/workbench-history.md b/docs/workbench-history.md index e574406..1dfc7b2 100644 --- a/docs/workbench-history.md +++ b/docs/workbench-history.md @@ -127,3 +127,14 @@ or authenticate its author. Editing a directory or contract invalidates the displayed evidence on input, before blur can collapse the report under a click. Consent stays disabled while a plan is loading, and clearing an already empty report does not remount its frame. + + +## Packet paths and independent expected digests + +Opening a packet shows its observed SHA-256 in the result, without copying that +value into the optional expected-digest field. That field is reserved for an +independent trusted source. Selecting or editing another packet clears the old +report and success summary immediately, while preserving any manually supplied +expected digest. An incorrect expected digest still rejects the read. Packet +contents are never changed by this UI flow. Cancelled-output recovery remains a +read-only integrity inspection, not authorship or current-input acceptance. diff --git a/src/workbench/page.ts b/src/workbench/page.ts index abf39c7..007cbfe 100644 --- a/src/workbench/page.ts +++ b/src/workbench/page.ts @@ -3,7 +3,7 @@ export function workbenchPage(nonce: string): string { return `SkillSync 核验台

SkillSync

本机核验台

选择要检查的目录或交付,核对证据后再决定下一步。

- +

核验一个目录

选择目录与范围 → 核验 → 查看结果

选择要完成的任务

尚未核验

@@ -96,13 +96,16 @@ $('history-next').onclick=()=>{if(historyLoading)return;historyOffset+=12;loadHi $('export-summary').onclick=async()=>{lock(true,true);try{const data=await api('history/export',{id:historyId});const url=URL.createObjectURL(new Blob([data.html],{type:'text/html;charset=utf-8'})),link=document.createElement('a');link.href=url;link.download='skillsync-comparison.html';link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}catch(e){status(e.message);}finally{lock(false);}}; function clearBatchContext(){recoveryPath=null;$('batch-recovery').hidden=true;$('batch-receipt').textContent='';clearPlan();selected=null;historyId=null;baselineId=null;$('plan').hidden=true;setFix(false);$('export-summary').disabled=true;$('comparison').textContent='';hideResult();clearReport();} $('batch-plan').addEventListener('input',()=>{batchReady=false;$('batch-run').disabled=true;$('batch-info').textContent='计划已改变,请重新预检。';}); -for(const [button,field]of [['choose-batch-plan','batch-plan'],['choose-batch-package','batch-package']])$(button).onclick=async()=>{lock(true,true);try{const data=await api('choose',{file:true});$(field).value=data.path;if(field==='batch-plan'){batchReady=false;$('batch-info').textContent='请选择预检。';}}catch(e){status(e.message);}finally{lock(false);}}; +for(const [button,field]of [['choose-batch-plan','batch-plan'],['choose-batch-package','batch-package']])$(button).onclick=async()=>{lock(true,true);try{const data=await api('choose',{file:true});$(field).value=data.path;if(field==='batch-package')batchPackageEdited();if(field==='batch-plan'){batchReady=false;$('batch-info').textContent='请选择预检。';}}catch(e){status(e.message);}finally{lock(false);}}; $('batch-preview').onclick=async()=>{clearBatchContext();batchReady=false;lock(true);status('正在预检多批次计划…');try{const data=await api('batch/preview',{path:$('batch-plan').value});batchReady=true;$('batch-output').value=data.suggestedOutput;$('batch-info').textContent=data.batches.map(item=>item.id+' · '+item.files+' 文件 / '+item.bytes+' 字节'+(item.available?'':' · 来源不可用')).join(';');status('计划预检完成,尚未生成复核包。');}catch(e){status(e.message);}finally{lock(false);}}; -function showBatch(data){$('task-summary').hidden=true;$('plan').hidden=true;setFix(false);const c=data.summary.counts;$('batch-receipt').textContent='复核包完整性已验证 · 通过 '+c.passed+' · 失败 '+c.failed+' · 不完整 '+c.incomplete+' · SHA-256 '+data.package_sha256;$('batch-digest').value=data.package_sha256;status('已读取有界验收证据;原始数据未在打开时重新核验,作者未认证。');revealResult('batch');showReport(data.html);} +function batchPackageEdited(){clearBatchContext();status('复核包路径已改变,请重新检查。手动输入的预期摘要会保留并用于下一次复核。');} +$('batch-package').addEventListener('input',batchPackageEdited); +$('batch-digest').addEventListener('input',()=>{clearBatchContext();status('预期摘要已改变,请重新检查所选包。');}); +function showBatch(data){$('task-summary').hidden=true;$('plan').hidden=true;setFix(false);const c=data.summary.counts;$('batch-receipt').textContent='复核包完整性已验证 · 通过 '+c.passed+' · 失败 '+c.failed+' · 不完整 '+c.incomplete+' · SHA-256 '+data.package_sha256;status('已读取有界验收证据;原始数据未在打开时重新核验,作者未认证。');revealResult('batch');showReport(data.html);} function offerBatchRecovery(path,message){ recoveryPath=typeof path==='string'&&path.trim()&&path.length<=4096&&!/[\u0000-\u001f]/.test(path)?path:null; $('batch-recovery').hidden=!recoveryPath;if(!recoveryPath)return; - $('batch-package').value=recoveryPath;$('batch-digest').value='';$('batch-recovery-state').textContent=message+' 此次输出:'+recoveryPath+'。先只读检查可能已生成的包;文件不存在时会明确说明,原输出不会被覆盖。'; + $('batch-package').value=recoveryPath;$('batch-recovery-state').textContent=message+' 此次输出:'+recoveryPath+'。先只读检查可能已生成的包;文件不存在时会明确说明,原输出不会被覆盖。'; } $('batch-run').onclick=async()=>{const output=$('batch-output').value;clearBatchContext();batchReady=false;lock(true);status('正在逐批核验并生成新复核包…');try{const data=await api('batch/run',{path:$('batch-plan').value,output,previous:$('batch-previous').value,retryFailed:$('batch-retry').checked});$('batch-package').value=data.packagePath;showBatch(data);}catch(e){status(e.message);offerBatchRecovery(output,e.message);}finally{lock(false);}}; $('batch-recover').onclick=async()=>{if(busy||!recoveryPath)return;const path=recoveryPath;lock(true);status('正在只读核对此次输出…');try{showBatch(await api('batch/open',{path,expectSha256:''}));$('batch-package').value=path;recoveryPath=null;$('batch-recovery').hidden=true;}catch(e){$('batch-recovery-state').textContent=e.message+';路径保留,可稍后再检查。';status(e.message);}finally{lock(false);}}; diff --git a/tests/workbench/page.test.ts b/tests/workbench/page.test.ts index 4650aeb..a451c20 100644 --- a/tests/workbench/page.test.ts +++ b/tests/workbench/page.test.ts @@ -24,7 +24,7 @@ async function page(failure?: "network" | "invalid-json" | "expired-session", de const get = (id: string) => { if (!elements.has(id)) elements.set(id, new Element()); return elements.get(id)!; }; get("kind").value = "skill"; get("root").value = "/synthetic/review"; const offsets: number[] = [], actions: string[] = []; - const rejected = new Map(); + const rejected = new Map(),responses=new Map(); let releaseHistory!: () => void, historyCalls = 0; const initialHistory = new Promise(resolve => { releaseHistory = resolve; }); const entries = Array.from({length: 14}, (_, i) => ({id: String(i), kind: "verify", label: `record ${i}`, createdAt: "2026-01-01T00:00:00Z"})); @@ -35,6 +35,7 @@ async function page(failure?: "network" | "invalid-json" | "expired-session", de const savedHistory = {total: entries.length, items: entries.slice(input.offset, input.offset + 12), capacity: {files: entries.length * 2, maxFiles: 120}}; if (action === "select" && failure === "network") throw new TypeError("Failed to fetch"); return {ok: failure !== "expired-session" || action !== "select", status: failure === "expired-session" && action === "select" ? 403 : 200, json: async () => { + if(responses.has(action))return responses.get(action); if (action === "select" && failure === "invalid-json") throw new SyntaxError("Unexpected token '<'"); if (action === "history") { offsets.push(input.offset); @@ -52,10 +53,16 @@ async function page(failure?: "network" | "invalid-json" | "expired-session", de }; runInNewContext(workbenchScript, {document: {getElementById: get, querySelector: () => get("meta"), querySelectorAll: () => [], createElement: () => new Element()}, fetch, URL, Blob, setTimeout}); await new Promise(resolve => setImmediate(resolve)); - return {get, offsets, actions, fail(action: string, message: string) { rejected.set(action, message); }, async finishInitialHistory() { releaseHistory(); await new Promise(resolve => setImmediate(resolve)); }, async click(id: string) { await get(id).onclick?.(); await new Promise(resolve => setImmediate(resolve)); }}; + return {get, offsets, actions,respond(action:string,value:unknown){responses.set(action,value);}, fail(action: string, message: string) { rejected.set(action, message); }, async finishInitialHistory() { releaseHistory(); await new Promise(resolve => setImmediate(resolve)); }, async click(id: string) { await get(id).onclick?.(); await new Promise(resolve => setImmediate(resolve)); }}; } describe("workbench interactions", () => { + it('switching packet paths drops only derived old digests and hides stale success',async()=>{ + const ui=await page();await ui.click('nav-batch');ui.get('batch-package').value='/synthetic/a.json';ui.respond('batch/open',{summary:{counts:{passed:1,failed:0,incomplete:0}},package_sha256:'a'.repeat(64),html:'

A

'});await ui.click('batch-open');expect(ui.get('batch-digest').value).toBe('');expect(ui.get('batch-receipt').textContent).toContain('a'.repeat(64)); + ui.get('batch-package').value='/synthetic/b.json';ui.get('batch-package').events.get('input')?.();expect(ui.get('batch-digest').value).toBe('');expect(ui.get('result').hidden).toBe(true);expect(ui.get('batch-receipt').textContent).toBe(''); + ui.get('batch-digest').value='trusted'.repeat(8);ui.get('batch-digest').events.get('input')?.();ui.get('batch-package').value='/synthetic/c.json';ui.get('batch-package').events.get('input')?.();expect(ui.get('batch-digest').value).toBe('trusted'.repeat(8)); + }); + it("invalidates prior evidence while typing, before blur can move the verification button",async()=>{ const ui=await page();await ui.click("verify");expect(ui.get("result").hidden).toBe(false);const requests=ui.actions.length; ui.get("root").value="/synthetic/another";ui.get("root").events.get("input")?.();expect(ui.get("result").hidden).toBe(true);expect(ui.get("plan").disabled).toBe(true);expect(ui.actions.length).toBe(requests);