feat(settings): replicate the WorkBuddy agents panel and persist team-management preference (TODO 15)
This commit is contained in:
1 parent
b3be45715e
commit
143516b3a8
33 files changed
+1071
-21
No files matched your search
@@ -19,6 +19,8 @@ try {
|
||||
const main={frame_id:'main',file_path:await fs.realpath(html)},frame={id:'main',url:page}
|
||||
const png='iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jKXcAAAAASUVORK5CYII='
|
||||
const bytes=Buffer.from(png,'base64'),digest=crypto.createHash('sha256').update(bytes).digest('hex')
|
||||
const svgBytes=Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><circle cx="8" cy="8" r="7"/></svg>')
|
||||
const svgContent=svgBytes.toString('base64'),svgDigest=crypto.createHash('sha256').update(svgBytes).digest('hex')
|
||||
const makeSnapshot=()=>({strings:[page,'main','HTML','IMG','class','selected','src','fallback.png',resourceUrl,'contenteditable','true','14px'],documents:[{
|
||||
documentURL:0,frameId:1,scrollOffsetX:0,scrollOffsetY:0,
|
||||
nodes:{nodeName:[2,3],parentIndex:[-1,0],backendNodeId:[10,20],attributes:[[],[4,5,6,7]],currentSourceURL:{index:[1],value:[8]}},
|
||||
@@ -34,10 +36,10 @@ try {
|
||||
if(method==='DOM.describeNode')return {node:{nodeName:mode==='not-img'?'DIV':'IMG',backendNodeId:mode==='foreign-backend'?999:20}}
|
||||
if(method==='Page.getLayoutMetrics')return {cssLayoutViewport:{clientWidth:100,clientHeight:80,pageX:0,pageY:0}}
|
||||
if(method==='DOM.getNodeForLocation')return {backendNodeId:mode==='covered'?999:mode==='partly-covered'&¶ms.y>20?999:20}
|
||||
if(method==='Page.getResourceTree')return {frameTree:{frame,resources:mode==='not-loaded'?[]:[{url:selectedResourceUrl,type:'Image',mimeType:mode==='svg'?'image/svg+xml':'image/png',...(mode==='declared-oversize'?{contentSize:2097153}:{})}],childFrames:[{frame:{id:'foreign',url:'https://foreign.invalid'},resources:[{url:resourceUrl,type:'Image',mimeType:'image/png'}]}]}}
|
||||
if(method==='Page.getResourceTree')return {frameTree:{frame,resources:mode==='not-loaded'?[]:[{url:selectedResourceUrl,type:'Image',mimeType:mode==='svg'?'image/svg+xml':mode==='unsupported'?'image/bmp':'image/png',...(mode==='declared-oversize'?{contentSize:2097153}:{})}],childFrames:[{frame:{id:'foreign',url:'https://foreign.invalid'},resources:[{url:resourceUrl,type:'Image',mimeType:'image/png'}]}]}}
|
||||
if(method==='Page.getResourceContent'){
|
||||
if(mode==='content-unavailable')throw Error('private-url-or-cookie-must-not-leak')
|
||||
return {base64Encoded:mode!=='not-binary',content:mode==='invalid-base64'?'not-base64':mode==='signature'?'YWJj':mode==='oversize'?Buffer.alloc(2097153).toString('base64'):png}
|
||||
return {base64Encoded:mode!=='not-binary',content:mode==='invalid-base64'?'not-base64':mode==='signature'?'YWJj':mode==='oversize'?Buffer.alloc(2097153).toString('base64'):mode==='svg'?svgContent:png}
|
||||
}
|
||||
if(method==='DOMSnapshot.captureSnapshot'){const copy=structuredClone(snapshot);for(const doc of copy.documents)doc.layout.styles=doc.layout.nodeIndex.map(()=>params.computedStyles.map(()=>11));return copy}
|
||||
if(method==='Page.captureScreenshot')return {data:png}
|
||||
@@ -58,12 +60,17 @@ try {
|
||||
assert.equal(result.total_bytes,bytes.length)
|
||||
assert.equal(calls.find(c=>c.method==='Page.getResourceContent').params.url,resourceUrl,'currentSrc wins over the fallback src attribute')
|
||||
assert.equal(result.items[0].backend_node_id,20)
|
||||
mode='svg';result=await capture()
|
||||
assert.equal(result.items[0].status,'exported')
|
||||
assert.equal(result.items[0].artifact.mime_type,'image/svg+xml')
|
||||
assert.equal(result.items[0].artifact.sha256,svgDigest)
|
||||
assert.equal(result.items[0].artifact.data_base64,svgContent)
|
||||
mode='covered';result=await capture();assert.equal(result.items[0].status,'exported');assert.equal(result.items[0].visibility.status,'covered');assert.equal(result.items[0].selected_node.current_source_url,resourceUrl);
|
||||
mode='partly-covered';assert.equal((await capture()).items[0].status,'exported','partial occlusion may still export actual loaded full image bytes')
|
||||
for(const [test,reason] of [['absent','selector_absent'],['ambiguous','selector_ambiguous'],['not-img','selected_node_not_img'],['foreign-backend','snapshot_identity_unavailable'],['not-loaded','selected_source_not_loaded_in_main_frame'],['svg','unsupported_image_type'],['declared-oversize','asset_byte_budget_exceeded'],['oversize','asset_byte_budget_exceeded'],['signature','image_signature_mismatch'],['invalid-base64','invalid_base64'],['not-binary','binary_content_unavailable'],['content-unavailable','protocol_resource_unavailable'],['navigation-after-content','frame_changed']]){
|
||||
for(const [test,reason] of [['absent','selector_absent'],['ambiguous','selector_ambiguous'],['not-img','selected_node_not_img'],['foreign-backend','snapshot_identity_unavailable'],['not-loaded','selected_source_not_loaded_in_main_frame'],['unsupported','unsupported_image_type'],['declared-oversize','asset_byte_budget_exceeded'],['oversize','asset_byte_budget_exceeded'],['signature','image_signature_mismatch'],['invalid-base64','invalid_base64'],['not-binary','binary_content_unavailable'],['content-unavailable','protocol_resource_unavailable'],['navigation-after-content','frame_changed']]){
|
||||
mode=test;result=await capture();assert.equal(result.items[0].status,'unknown',test);assert.equal(result.items[0].reason,reason,test);assert.equal(result.total_bytes,0,test)
|
||||
assert.equal(result.items[0].artifact,undefined,test)
|
||||
if(['not-loaded','svg','declared-oversize'].includes(test))assert.equal(calls.some(c=>c.method==='Page.getResourceContent'),false,test+' must reject before the read')
|
||||
if(['not-loaded','unsupported','declared-oversize'].includes(test))assert.equal(calls.some(c=>c.method==='Page.getResourceContent'),false,test+' must reject before the read')
|
||||
assert.equal(JSON.stringify(result).includes('private-url-or-cookie'),false)
|
||||
}
|
||||
mode='ok';snapshot=makeSnapshot();snapshot.documents[0].nodes.attributes[0]=[9,10]
|
||||
@@ -98,5 +105,5 @@ try {
|
||||
assert.equal(optedOut.visuals.media_assets,undefined)
|
||||
assert.equal(JSON.stringify(optedOut.visuals).includes('fallback.png'),false)
|
||||
assert.equal(listener,undefined)
|
||||
console.log('REA selected IMG resource regression passed: opt-in/schema/Evidence, snapshot currentSrc/backend attribution, covered bytes with separate visibility, scope, binary budgets/signatures, URL credential redaction, unknowns and default URL omission')
|
||||
console.log('REA selected IMG resource regression passed: opt-in/schema/Evidence, snapshot currentSrc/backend attribution, raster/SVG export, covered bytes with separate visibility, scope, binary budgets/signatures, URL credential redaction, unknowns and default URL omission')
|
||||
}finally{await fs.rm(temporary,{recursive:true,force:true})}
|
||||
@@ -4,13 +4,15 @@ import { decodeCanonicalBase64 } from '../domain/webScreenshot.js';
|
||||
|
||||
const MAX_ASSETS=4, MAX_ASSET_BYTES=2*1024*1024, MAX_TOTAL_BYTES=8*1024*1024;
|
||||
const sha=bytes=>createHash('sha256').update(bytes).digest('hex');
|
||||
const types=new Set(['image/png','image/jpeg','image/gif','image/webp']);
|
||||
const types=new Set(['image/png','image/jpeg','image/gif','image/webp','image/svg+xml']);
|
||||
const mime=value=>value==='image/jpg'?'image/jpeg':value;
|
||||
function sniff(bytes){
|
||||
if(bytes.length>=24&&bytes.subarray(0,8).equals(Buffer.from([137,80,78,71,13,10,26,10])))return 'image/png';
|
||||
if(bytes.length>=3&&bytes[0]===255&&bytes[1]===216&&bytes[2]===255)return 'image/jpeg';
|
||||
if(bytes.length>=10&&/^GIF8[79]a$/.test(bytes.toString('ascii',0,6)))return 'image/gif';
|
||||
if(bytes.length>=12&&bytes.toString('ascii',0,4)==='RIFF'&&bytes.toString('ascii',8,12)==='WEBP')return 'image/webp';
|
||||
const text=bytes.toString('utf8',0,Math.min(bytes.length,4096)).replace(/^\uFEFF/,'').trimStart();
|
||||
if(/^(?:<\?xml[\s\S]*?\?>\s*)?<svg(?:\s|>)/i.test(text))return 'image/svg+xml';
|
||||
}
|
||||
function safeSourceUrl(value){
|
||||
try{const u=new URL(value);if(!['file:','http:','https:'].includes(u.protocol))return null;u.username='';u.password='';u.search='';u.hash='';const result=u.href;return result.length<=2048?result:null;}catch{return null;}
|
||||
@@ -22,7 +24,7 @@ export async function captureElectronMediaAssets(snapshot,frames,selectors,send)
|
||||
const result={items:[],total_bytes:0,limits:{max_assets:MAX_ASSETS,max_asset_bytes:MAX_ASSET_BYTES,max_total_bytes:MAX_TOTAL_BYTES},limitations:[
|
||||
'Opt-in IMG resources only: exact unique selectors, authorized main file frame and snapshot backend identity. Visibility is reported separately and does not authorize new requests.',
|
||||
'Only Page.getResourceTree resources already loaded by the selected frame are read with Page.getResourceContent. No HTTP fetch, cookies, headers, input values or renderer JavaScript are used.',
|
||||
'PNG/JPEG/GIF/WebP only; SVG, canvas, video, CSS backgrounds and resources missing from the loaded frame tree remain unknown. Covered loaded images can be exported; bytes do not establish that this image is the currently visible layer.',
|
||||
'PNG/JPEG/GIF/WebP/SVG only; canvas, video, CSS backgrounds and resources missing from the loaded frame tree remain unknown. Covered loaded images can be exported; bytes do not establish that this image is the currently visible layer.',
|
||||
'Resource URLs in metadata omit credentials, query strings and fragments; URL SHA-256 identifies the exact selected URL. DOM and resource observations are sequential.'
|
||||
]};
|
||||
const main=frames[0],strings=snapshot.strings??[],str=index=>strings[index]??'';
|
||||
|
||||
@@ -32,7 +32,7 @@ const mediaIdentity = {
|
||||
};
|
||||
export const electronMediaAssetsSchema = z.object({
|
||||
items:z.array(z.discriminatedUnion('status',[
|
||||
z.object({...mediaIdentity,status:z.literal('exported'),artifact:z.object({mime_type:z.enum(['image/png','image/jpeg','image/gif','image/webp']),bytes:z.number().int().positive().max(2097152),sha256:z.string().regex(/^[a-f0-9]{64}$/),data_base64:z.string().max(2796204)})}),
|
||||
z.object({...mediaIdentity,status:z.literal('exported'),artifact:z.object({mime_type:z.enum(['image/png','image/jpeg','image/gif','image/webp','image/svg+xml']),bytes:z.number().int().positive().max(2097152),sha256:z.string().regex(/^[a-f0-9]{64}$/),data_base64:z.string().max(2796204)})}),
|
||||
z.object({...mediaIdentity,status:z.literal('unknown'),reason:z.string()})
|
||||
])).max(4),
|
||||
total_bytes:z.number().int().min(0).max(8388608),
|
||||
|
||||
@@ -135,7 +135,7 @@ await patch('dist/cli/electronCommands.js', text => once(text,
|
||||
' include_script_sources: options.includeScriptSources,\n include_style_sources: options.includeStyleSources,\n include_visuals: options.includeVisuals,\n ...(options.visualProbe ? {visual_probe: JSON.parse(options.visualProbe)} : {}),'))
|
||||
await patch('dist/contracts/javascript/electronToolContracts.js', text => once(text,
|
||||
'Returns frames, DOM structure, resource paths, and scripts without evaluating renderer JavaScript or invoking Electron APIs.',
|
||||
'Returns frames, DOM structure, resource paths, scripts and optional attributed CSS sources without evaluating renderer JavaScript or invoking Electron APIs. Default inspection is passive. include_style_sources selects CSS text; include_visuals selects viewport PNG and layout. Optional visual_probe declares native hover/click/scroll/key/viewport actions, sequential PNG samples, and actual platform fonts; UI actions can change application state and restoration is limited. visual_probe.media_selectors explicitly selects at most four unique main-frame IMG nodes and their already loaded PNG/JPEG/GIF/WebP resources, with byte budgets, SHA/base64 and visibility reported separately; no HTTP fetch is performed. These are local REA extensions; inspect coverage, budgets, restoration and limitations.'))
|
||||
'Returns frames, DOM structure, resource paths, scripts and optional attributed CSS sources without evaluating renderer JavaScript or invoking Electron APIs. Default inspection is passive. include_style_sources selects CSS text; include_visuals selects viewport PNG and layout. Optional visual_probe declares native hover/click/scroll/key/viewport actions, sequential PNG samples, and actual platform fonts; UI actions can change application state and restoration is limited. visual_probe.media_selectors explicitly selects at most four unique main-frame IMG nodes and their already loaded PNG/JPEG/GIF/WebP/SVG resources, with byte budgets, SHA/base64 and visibility reported separately; no HTTP fetch is performed. These are local REA extensions; inspect coverage, budgets, restoration and limitations.'))
|
||||
|
||||
const styleModule = await fs.readFile(path.join(patchRoot, 'CdpElectronStyles.js'), 'utf8')
|
||||
await fs.writeFile(path.join(output, 'dist/browser/CdpElectronStyles.js'), styleModule)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"include_fonts": false,
|
||||
"include_message_content": false,
|
||||
"media_selectors": [
|
||||
"#settings-browser-extension-chrome img",
|
||||
"#settings-browser-extension-edge img",
|
||||
"#settings-browser-extension-qq-browser img"
|
||||
],
|
||||
"actions": [
|
||||
{ "kind": "wait", "step_id": "observe-loaded-browser-extension-images", "duration_ms": 250, "frames": 1 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"include_fonts": false,
|
||||
"include_message_content": false,
|
||||
"track_selectors": [
|
||||
".settings-navigation",
|
||||
".settings-navigation__item",
|
||||
".settings-navigation__item--active",
|
||||
".settings-modal__panel"
|
||||
],
|
||||
"actions": [
|
||||
{ "kind": "scroll", "step_id": "reveal-function-group-navigation-labels", "selector": ".settings-navigation", "delta_y": 500, "frames": 1 },
|
||||
{ "kind": "wait", "step_id": "confirm-agents-navigation-label-and-order", "duration_ms": 400, "frames": 1 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"include_fonts": false,
|
||||
"include_message_content": false,
|
||||
"track_selectors": [
|
||||
".settings-navigation__item--active",
|
||||
".settings-modal__panel",
|
||||
".settings-navigation",
|
||||
".settings-navigation__item"
|
||||
],
|
||||
"actions": [
|
||||
{ "kind": "click", "step_id": "open-agents-by-observed-nav-index-7-in-dark-theme", "selector": ".settings-navigation__item", "index": 7, "frames": 1 },
|
||||
{ "kind": "wait", "step_id": "verify-agents-dark-page-stable", "duration_ms": 800, "frames": 2 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"include_fonts": false,
|
||||
"include_message_content": false,
|
||||
"track_selectors": [
|
||||
".settings-navigation__item--active",
|
||||
".settings-modal__panel",
|
||||
".settings-navigation",
|
||||
".settings-navigation__item"
|
||||
],
|
||||
"actions": [
|
||||
{ "kind": "click", "step_id": "open-agents-by-observed-nav-index-7", "selector": ".settings-navigation__item", "index": 7, "frames": 1 },
|
||||
{ "kind": "wait", "step_id": "verify-agents-page-stable", "duration_ms": 800, "frames": 4 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"include_fonts": false,
|
||||
"include_message_content": false,
|
||||
"track_selectors": [
|
||||
".settings-modal__panel",
|
||||
".settings-navigation__item--active",
|
||||
".appearance-card--light",
|
||||
".appearance-card--dark"
|
||||
],
|
||||
"actions": [
|
||||
{ "kind": "click", "step_id": "open-appearance-by-observed-nav-index-3-in-dark-theme", "selector": ".settings-navigation__item", "index": 3, "frames": 1 },
|
||||
{ "kind": "wait", "step_id": "verify-dark-appearance-before-restoring-light", "duration_ms": 500, "frames": 1 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"include_fonts": false,
|
||||
"include_message_content": false,
|
||||
"track_selectors": [
|
||||
".settings-modal__panel",
|
||||
".settings-navigation__item--active",
|
||||
".appearance-card--light",
|
||||
".appearance-card--dark"
|
||||
],
|
||||
"actions": [
|
||||
{ "kind": "click", "step_id": "open-appearance-by-observed-nav-index-3", "selector": ".settings-navigation__item", "index": 3, "frames": 1 },
|
||||
{ "kind": "wait", "step_id": "confirm-light-appearance-state", "duration_ms": 500, "frames": 1 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"include_fonts": false,
|
||||
"include_message_content": false,
|
||||
"track_selectors": [
|
||||
".settings-modal__panel",
|
||||
".appearance-card--light",
|
||||
".appearance-card--dark"
|
||||
],
|
||||
"actions": [
|
||||
{ "kind": "click", "step_id": "restore-light-theme-after-agent-settings-sample", "selector": ".appearance-card--light", "index": 0, "frames": 1 },
|
||||
{ "kind": "wait", "step_id": "wait-for-light-theme-render", "duration_ms": 800, "frames": 1 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"include_fonts": false,
|
||||
"include_message_content": false,
|
||||
"track_selectors": [
|
||||
".settings-modal__panel",
|
||||
".general-settings-panel.extensions-settings-panel",
|
||||
"#settings-agent-teams",
|
||||
"#settings-browser-control"
|
||||
],
|
||||
"actions": [
|
||||
{ "kind": "scroll", "step_id": "check-for-lower-agent-settings-content", "selector": ".settings-modal__panel", "delta_y": 700, "frames": 1 },
|
||||
{ "kind": "wait", "step_id": "confirm-panel-content-end", "duration_ms": 250, "frames": 1 },
|
||||
{ "kind": "scroll", "step_id": "return-agent-settings-to-top", "selector": ".settings-modal__panel", "delta_y": -700, "frames": 1 },
|
||||
{ "kind": "wait", "step_id": "confirm-agent-settings-top", "duration_ms": 250, "frames": 1 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"include_fonts": false,
|
||||
"include_message_content": false,
|
||||
"track_selectors": [
|
||||
".settings-modal__panel",
|
||||
".settings-navigation__item--active",
|
||||
".appearance-card--light",
|
||||
".appearance-card--dark"
|
||||
],
|
||||
"actions": [
|
||||
{ "kind": "click", "step_id": "select-dark-theme-for-agent-settings-sample", "selector": ".appearance-card--dark", "index": 0, "frames": 1 },
|
||||
{ "kind": "wait", "step_id": "wait-for-dark-theme-render", "duration_ms": 800, "frames": 1 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"include_fonts": false,
|
||||
"include_message_content": false,
|
||||
"track_selectors": [
|
||||
".settings-modal__panel",
|
||||
".wb-select",
|
||||
".wb-select__value",
|
||||
".wb-select__option"
|
||||
],
|
||||
"actions": [
|
||||
{ "kind": "click", "step_id": "open-agent-team-setting-options-without-changing-value", "selector": ".wb-select", "index": 0, "frames": 1 },
|
||||
{ "kind": "wait", "step_id": "observe-agent-team-setting-options", "duration_ms": 250, "frames": 1 },
|
||||
{ "kind": "key", "step_id": "close-agent-team-setting-options-with-escape", "key": "Escape", "frames": 1 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Recover only the three browser-extension images retained by REA Evidence.
|
||||
// This script reads the archived Evidence JSON and never accesses the WorkBuddy install.
|
||||
import { createHash } from 'node:crypto'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
const evidenceId = 'ev_9542a063514b1a980665e504a89126c7a17f00b42f0c6dff9cead085a929f5c7'
|
||||
const evidencePath = path.resolve(`.tmp/rea-css-evidence/${evidenceId}.json`)
|
||||
const outputDirectory = path.resolve('src/renderer/src/components/agents-assets')
|
||||
const expected = [
|
||||
{
|
||||
selector: '#settings-browser-extension-chrome img',
|
||||
mimeType: 'image/svg+xml',
|
||||
sha256: 'bde4af5a5d1150d3ec3108c29a5d746a00892196f3eaf1db4c4dc99aac7ba266',
|
||||
output: 'workbuddy-browser-chrome.svg'
|
||||
},
|
||||
{
|
||||
selector: '#settings-browser-extension-edge img',
|
||||
mimeType: 'image/svg+xml',
|
||||
sha256: '968cf9d827bfeaf47e4e53da55ec144bbc9565e5cb7a65cdb8dd0284fb3a5d11',
|
||||
output: 'workbuddy-browser-edge.svg'
|
||||
},
|
||||
{
|
||||
selector: '#settings-browser-extension-qq-browser img',
|
||||
mimeType: 'image/png',
|
||||
sha256: '186c82828c04b5bfe300c201ecd75cefb7984d97ea01bd491e0cfbdeb5df15a1',
|
||||
output: 'workbuddy-browser-qq.png'
|
||||
}
|
||||
]
|
||||
|
||||
const digest = (bytes) => createHash('sha256').update(bytes).digest('hex')
|
||||
const evidence = JSON.parse(await fs.readFile(evidencePath, 'utf8'))
|
||||
if (evidence.evidence_id !== evidenceId) throw new Error(`REA identity mismatch: ${evidence.evidence_id}`)
|
||||
if (evidence.completeness?.truncated_sections?.length) throw new Error('REA Evidence is truncated')
|
||||
if (!/renderer[\\/]index\.html$/i.test(evidence.normalized_result?.target?.file_path ?? '')) {
|
||||
throw new Error('REA Evidence target is not the authorized WorkBuddy renderer')
|
||||
}
|
||||
|
||||
const media = evidence.normalized_result?.visuals?.media_assets
|
||||
if (!media?.items || media.items.length !== expected.length) throw new Error('Unexpected REA media item count')
|
||||
const decoded = []
|
||||
for (const item of expected) {
|
||||
const matches = media.items.filter((candidate) => candidate.selector === item.selector)
|
||||
if (matches.length !== 1) throw new Error(`Expected one REA item for ${item.selector}`)
|
||||
const asset = matches[0]
|
||||
if (asset.status !== 'exported' || asset.visibility?.status !== 'observed') {
|
||||
throw new Error(`REA did not export a visible image for ${item.selector}: ${asset.reason}`)
|
||||
}
|
||||
if (asset.artifact?.mime_type !== item.mimeType || asset.artifact.sha256 !== item.sha256) {
|
||||
throw new Error(`REA artifact identity changed for ${item.selector}`)
|
||||
}
|
||||
const bytes = Buffer.from(asset.artifact.data_base64, 'base64')
|
||||
if (bytes.length !== asset.artifact.bytes || digest(bytes) !== item.sha256) {
|
||||
throw new Error(`REA artifact bytes changed for ${item.selector}`)
|
||||
}
|
||||
if (item.mimeType === 'image/svg+xml') {
|
||||
const source = bytes.toString('utf8')
|
||||
if (!/^<svg\s/.test(source) || !/\bviewBox=['"]/i.test(source)) throw new Error(`Invalid SVG source for ${item.output}`)
|
||||
if (/<script\b|<foreignObject\b|\b(?:href|xlink:href)=['"](?:https?:|file:|data:)/i.test(source)) {
|
||||
throw new Error(`Unsafe external or active SVG content for ${item.output}`)
|
||||
}
|
||||
} else if (item.mimeType === 'image/png') {
|
||||
if (bytes.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a') throw new Error(`Invalid PNG source for ${item.output}`)
|
||||
if (bytes.readUInt32BE(16) !== 128 || bytes.readUInt32BE(20) !== 128) throw new Error('QQ browser icon is not 128x128')
|
||||
}
|
||||
decoded.push({ bytes, output: item.output, sha256: item.sha256 })
|
||||
}
|
||||
|
||||
await fs.mkdir(outputDirectory, { recursive: true })
|
||||
for (const asset of decoded) await fs.writeFile(path.join(outputDirectory, asset.output), asset.bytes)
|
||||
console.log(JSON.stringify({ evidenceId, outputDirectory, assets: decoded.map(({ output, sha256, bytes }) => ({
|
||||
output, sha256, bytes: bytes.length
|
||||
})) }, null, 2))
|
||||
@@ -0,0 +1,123 @@
|
||||
// Recover only retained REA CSS; never access the installed application here.
|
||||
// Sources: REA light/dark Evidence for WorkBuddy 5.8.3 Settings -> Agents.
|
||||
// The lazy module contains the browser-extension status rules for this page.
|
||||
import fs from 'node:fs/promises'
|
||||
import { createHash } from 'node:crypto'
|
||||
import postcss from 'postcss'
|
||||
|
||||
const sha = value => createHash('sha256').update(value).digest('hex')
|
||||
const evidenceIds = {
|
||||
light: 'ev_1cc896e04c0cc9ceab39b0a252f4982c019305609195ad44804ae9b5fb7379a8',
|
||||
dark: 'ev_3384aa03af8a1ccdffb8e025dbd4a354636d8d6b0c207abd21e12fc5eb2ae719'
|
||||
}
|
||||
const expectedModuleSha = 'cbd4b37c6061381943d96ca454176b2f083e49eb8e04c34cb5d31eef3dbcb8f5'
|
||||
const expectedTokensSha = '13f2bf82c96e5167a7d7098e36950c6c22c475b3337dbb8bb29ad2fe79cde696'
|
||||
const moduleName = 'ExtensionsSettingsPanel-BnsXh3oa.css'
|
||||
|
||||
const loadModule = async evidenceId => {
|
||||
const evidence = JSON.parse(await fs.readFile(`.tmp/rea-css-evidence/${evidenceId}.json`, 'utf8'))
|
||||
if (evidence.evidence_id !== evidenceId) throw new Error(`REA identity mismatch: ${evidenceId}`)
|
||||
const module = evidence.normalized_result.styles.items.find(item => item.file_path.endsWith(moduleName))
|
||||
if (!module) throw new Error(`Agent settings module CSS is absent from ${evidenceId}`)
|
||||
const moduleSha = sha(module.source.artifact.text)
|
||||
if (moduleSha !== expectedModuleSha || module.source.artifact.sha256 !== expectedModuleSha) {
|
||||
throw new Error(`Agent settings module CSS changed in ${evidenceId}`)
|
||||
}
|
||||
return { filePath: module.file_path, css: module.source.artifact.text, sha: moduleSha }
|
||||
}
|
||||
|
||||
const lightModule = await loadModule(evidenceIds.light)
|
||||
const darkModule = await loadModule(evidenceIds.dark)
|
||||
if (lightModule.filePath !== darkModule.filePath) throw new Error('Agent settings module path changed across captures')
|
||||
|
||||
const tokens = await fs.readFile('.tmp/rea-css-recovered/tokens-Df-i-zsO.css', 'utf8')
|
||||
const tokensSha = sha(tokens)
|
||||
if (tokensSha !== expectedTokensSha) throw new Error('Tokens CSS changed')
|
||||
|
||||
const lightTokens = new Map()
|
||||
const darkTokens = new Map()
|
||||
for (const rule of postcss.parse(tokens).nodes) {
|
||||
if (rule.type !== 'rule') continue
|
||||
const selectors = rule.selectors ?? []
|
||||
const dark = selectors.some(selector => selector.includes('[data-theme=dark]'))
|
||||
const light = selectors.some(selector => selector.trim() === ':root')
|
||||
if (!dark && !light) continue
|
||||
for (const node of rule.nodes ?? []) {
|
||||
if (node.type !== 'decl' || !node.prop.startsWith('--')) continue
|
||||
if (dark) darkTokens.set(node.prop, node.value)
|
||||
if (light) lightTokens.set(node.prop, node.value)
|
||||
}
|
||||
}
|
||||
|
||||
const relatedSelector = /\.extensions-settings-panel__browser-extension-status(?:[\w-]*)?(?![\w-])/i
|
||||
const darkScope = /^\s*(?:\[data-theme=["']?dark["']?\]|body\[data-vscode-theme-name=["']IDE Night["']\]|body\.vscode-dark|\.vscode-dark|body\.dark|\.dark)(?=\s|$)/i
|
||||
function project(node, mode = 'all') {
|
||||
if (node.type === 'rule') {
|
||||
const selectors = []
|
||||
for (const selector of node.selectors) {
|
||||
if (!relatedSelector.test(selector)) continue
|
||||
const isDark = darkScope.test(selector)
|
||||
if (mode === 'shared' && isDark) continue
|
||||
if (mode === 'dark' && !isDark) continue
|
||||
const mapped = isDark ? selector.replace(darkScope, '[data-theme=dark]') : selector
|
||||
if (!selectors.includes(mapped)) selectors.push(mapped)
|
||||
}
|
||||
return selectors.length ? node.clone({ selector: selectors.join(',') }) : null
|
||||
}
|
||||
if (node.type === 'atrule' && node.nodes) {
|
||||
const nodes = node.nodes.map(child => project(child, mode)).filter(Boolean)
|
||||
return nodes.length ? node.clone({ nodes }) : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const sharedRoot = css => postcss.root({ nodes: postcss.parse(css).nodes.map(node => project(node, 'shared')).filter(Boolean) })
|
||||
const lightRoot = sharedRoot(lightModule.css)
|
||||
const darkSharedRoot = sharedRoot(darkModule.css)
|
||||
const darkSpecificRoot = postcss.root({ nodes: postcss.parse(darkModule.css).nodes.map(node => project(node, 'dark')).filter(Boolean) })
|
||||
const normalizeRules = root => {
|
||||
const rules = []
|
||||
root.walkRules(rule => {
|
||||
const selectors = rule.selectors.map(selector => selector.replace(/\s+/g, ' ').trim()).join(',')
|
||||
const declarations = (rule.nodes ?? [])
|
||||
.filter(node => node.type === 'decl')
|
||||
.map(node => `${node.prop}:${node.value.replace(/\b0px\b/g, '0').replace(/\s+/g, ' ').trim()}`)
|
||||
.join(';')
|
||||
rules.push(`${selectors}{${declarations}}`)
|
||||
})
|
||||
return rules.join('\n')
|
||||
}
|
||||
if (normalizeRules(lightRoot) !== normalizeRules(darkSharedRoot)) throw new Error('Light/dark agent module rules differ beyond serialization')
|
||||
|
||||
const root = postcss.root({ nodes: [...lightRoot.nodes, ...darkSpecificRoot.nodes] })
|
||||
const referenced = new Set()
|
||||
const collect = text => {
|
||||
for (const match of text.matchAll(/var\(\s*(--[\w-]+)/g)) referenced.add(match[1])
|
||||
}
|
||||
collect(root.toString())
|
||||
for (let depth = 0; depth < 8; depth++) {
|
||||
const before = referenced.size
|
||||
for (const name of [...referenced]) {
|
||||
for (const value of [lightTokens.get(name), darkTokens.get(name)]) {
|
||||
if (typeof value === 'string') collect(value)
|
||||
}
|
||||
}
|
||||
if (referenced.size === before) break
|
||||
}
|
||||
|
||||
const missingLight = [...referenced].filter(name => !lightTokens.has(name))
|
||||
if (missingLight.length) throw new Error(`Tokens missing from light root scope: ${missingLight.join(', ')}`)
|
||||
const pick = map => new Map([...referenced].map(name => [name, map.get(name) ?? lightTokens.get(name)]))
|
||||
const scope = (selector, map) => `${selector}{${[...map].map(([property, value]) => `${property}:${value}`).join(';')}}`
|
||||
const scopedTokens = `${scope('.extensions-settings-panel', pick(lightTokens))}\n${scope('[data-theme=dark] .extensions-settings-panel', pick(darkTokens))}\n`
|
||||
const evidenceList = Object.entries(evidenceIds).map(([name, evidenceId]) => `${name}=${evidenceId}`).join('; ')
|
||||
const css = `/* REA ${evidenceList}; ${moduleName} sha256=${lightModule.sha}; tokens sha256=${tokensSha}. */\n${root.toString()}\n${scopedTokens}`
|
||||
await fs.writeFile('src/renderer/src/components/source-agents-panels-recovered.css', css)
|
||||
|
||||
const rules = []
|
||||
root.walkRules(rule => rules.push(rule.selector))
|
||||
const darkRules = []
|
||||
darkSpecificRoot.walkRules(rule => darkRules.push(rule.selector))
|
||||
console.log(`Recovered ${rules.length} agent settings rules (${Buffer.byteLength(css)} bytes); ${darkRules.length} dark-specific module rules; scoped ${pick(lightTokens).size} light / ${pick(darkTokens).size} dark tokens`)
|
||||
console.log(rules.join('\n'))
|
||||
if (rules.length !== 4) throw new Error(`Expected 4 agent settings module rules, found ${rules.length}`)
|
||||
Reference in new issue
Block a user