chore: establish YCode replication baseline and TODO
This commit is contained in:
commit
6dc59f89ba
1118 files changed
+196002
No files matched your search
@@ -0,0 +1,98 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { authorizedElectronFile } from './ElectronFileScope.js';
|
||||
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 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';
|
||||
}
|
||||
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;}
|
||||
}
|
||||
|
||||
/** Only a caller-selected IMG and its already loaded main-frame resource are eligible. */
|
||||
export async function captureElectronMediaAssets(snapshot,frames,selectors,send){
|
||||
if(!selectors?.length)return undefined;
|
||||
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.',
|
||||
'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]??'';
|
||||
const document=snapshot.documents?.find(value=>str(value.frameId)===main.frame_id);
|
||||
const stable=async()=>{const frame=(await send('Page.getFrameTree',{})).frameTree?.frame;if(!frame||frame.id!==main.frame_id||await authorizedElectronFile(frame.url)!==main.file_path)throw Error('frame_changed');};
|
||||
for(const selector of selectors.slice(0,MAX_ASSETS)){
|
||||
const item={selector,frame_id:main.frame_id,status:'unknown',reason:'unavailable'};
|
||||
result.items.push(item);
|
||||
try{
|
||||
await stable();
|
||||
if(!document||await authorizedElectronFile(str(document.documentURL))!==main.file_path)throw Error('snapshot_frame_unavailable');
|
||||
const root=(await send('DOM.getDocument',{depth:0,pierce:false})).root;
|
||||
const ids=(await send('DOM.querySelectorAll',{nodeId:root.nodeId,selector})).nodeIds??[];
|
||||
if(ids.length!==1)throw Error(ids.length?'selector_ambiguous':'selector_absent');
|
||||
const node=(await send('DOM.describeNode',{nodeId:ids[0],depth:0,pierce:false})).node;
|
||||
if(node?.nodeName!=='IMG')throw Error('selected_node_not_img');
|
||||
const raw=document.nodes??{},index=raw.backendNodeId?.indexOf(node.backendNodeId)??-1;
|
||||
if(index<0||str(raw.nodeName?.[index])!=='IMG')throw Error('snapshot_identity_unavailable');
|
||||
item.node_index=index;item.backend_node_id=node.backendNodeId;
|
||||
let excluded=false;
|
||||
for(let n=index;n>=0;n=raw.parentIndex?.[n]??-1){const tag=str(raw.nodeName?.[n]),attrs=raw.attributes?.[n]??[];if(/^(INPUT|TEXTAREA|SELECT|SCRIPT|STYLE)$/.test(tag))excluded=true;for(let a=0;a<attrs.length;a+=2)if(str(attrs[a])==='contenteditable'&&str(attrs[a+1])!=='false')excluded=true;}
|
||||
if(excluded)throw Error('editable_subtree_excluded');
|
||||
const layoutIndex=document.layout?.nodeIndex?.indexOf(index)??-1,bounds=document.layout?.bounds?.[layoutIndex];
|
||||
item.visibility={status:'unknown',reason:'no_visible_layout'};
|
||||
if(layoutIndex>=0&&bounds&&bounds[2]>0&&bounds[3]>0)try{
|
||||
const viewport=(await send('Page.getLayoutMetrics',{})).cssLayoutViewport;
|
||||
const x=bounds[0]-(viewport.pageX??document.scrollOffsetX??0),y=bounds[1]-(viewport.pageY??document.scrollOffsetY??0);
|
||||
const left=Math.max(0,x),top=Math.max(0,y),right=Math.min(viewport.clientWidth,x+bounds[2]),bottom=Math.min(viewport.clientHeight,y+bounds[3]);
|
||||
if(right<=left||bottom<=top)item.visibility={status:'unknown',reason:'outside_visible_viewport'};
|
||||
else{
|
||||
item.visibility={status:'covered',reason:'sampled_points_hit_other_layers'};item.visibility_samples=[];
|
||||
for(const [fx,fy] of [[.5,.05],[.5,.5],[.5,.95],[.05,.05],[.95,.05],[.05,.5],[.95,.5],[.05,.95],[.95,.95]]){
|
||||
const point={x:Math.floor(left+(right-left)*fx),y:Math.floor(top+(bottom-top)*fy)};
|
||||
const hit=await send('DOM.getNodeForLocation',{...point,includeUserAgentShadowDOM:false,ignorePointerEventsNone:true});
|
||||
const hitIndex=raw.backendNodeId?.indexOf(hit.backendNodeId)??-1,hitAttrs=raw.attributes?.[hitIndex]??[];
|
||||
let hitClass='';for(let a=0;a<hitAttrs.length;a+=2)if(str(hitAttrs[a])==='class')hitClass=str(hitAttrs[a+1]).slice(0,500);
|
||||
item.visibility_samples.push({...point,...(hit.backendNodeId?{hit_backend_node_id:hit.backendNodeId}:{}),...(hitIndex>=0?{hit_node_index:hitIndex,hit_node_name:str(raw.nodeName?.[hitIndex]),hit_class:hitClass}:{})});
|
||||
if(hit.backendNodeId===node.backendNodeId){item.visibility={status:'observed'};break;}
|
||||
}
|
||||
}
|
||||
}catch{item.visibility={status:'unknown',reason:'hit_test_unavailable'};}
|
||||
const current=raw.currentSourceURL,at=current?.index?.indexOf(index)??-1;
|
||||
let source=at>=0?str(current.value?.[at]):'',selection='current_source_url';
|
||||
if(!source){selection='src';const attrs=raw.attributes?.[index]??[];for(let a=0;a<attrs.length;a+=2)if(str(attrs[a])==='src')source=str(attrs[a+1]);}
|
||||
if(!source)throw Error('source_url_unavailable');
|
||||
const selectedUrl=new URL(source,str(document.documentURL)),url=new URL(selectedUrl);url.hash='';
|
||||
const selectedAttributes={},nodeAttrs=raw.attributes?.[index]??[];
|
||||
for(let a=0;a<nodeAttrs.length;a+=2){const name=str(nodeAttrs[a]),value=str(nodeAttrs[a+1]);if(['class','id','width','height','loading','decoding','alt'].includes(name))selectedAttributes[name]=value.slice(0,500);else if(name==='src'){try{selectedAttributes.src=safeSourceUrl(new URL(value,str(document.documentURL)).href)??'[URL omitted]';}catch{selectedAttributes.src='[URL unavailable]';}}}
|
||||
item.selected_node={node_name:'IMG',attributes:selectedAttributes,current_source_url:at>=0?safeSourceUrl(str(current.value?.[at])):null};
|
||||
const tree=(await send('Page.getResourceTree',{})).frameTree;
|
||||
if(!tree?.frame||tree.frame.id!==main.frame_id||await authorizedElectronFile(tree.frame.url)!==main.file_path)throw Error('frame_changed');
|
||||
const resource=tree.resources?.find(value=>value.url===url.href&&value.type==='Image'&&!value.failed&&!value.canceled);
|
||||
if(!resource)throw Error('selected_source_not_loaded_in_main_frame');
|
||||
const contentType=mime(resource.mimeType?.toLowerCase());
|
||||
item.source={url:safeSourceUrl(selectedUrl.href),url_sha256:sha(selectedUrl.href),selection,mime_type:contentType??'unknown'};
|
||||
if(!types.has(contentType))throw Error('unsupported_image_type');
|
||||
if(resource.contentSize>MAX_ASSET_BYTES)throw Error('asset_byte_budget_exceeded');
|
||||
const content=await send('Page.getResourceContent',{frameId:main.frame_id,url:resource.url});
|
||||
if(typeof content.content!=='string'||!content.base64Encoded)throw Error('binary_content_unavailable');
|
||||
if(content.content.length>4*Math.ceil(MAX_ASSET_BYTES/3))throw Error('asset_byte_budget_exceeded');
|
||||
const bytes=decodeCanonicalBase64(content.content);
|
||||
if(!bytes?.length)throw Error('invalid_base64');
|
||||
if(bytes.length>MAX_ASSET_BYTES)throw Error('asset_byte_budget_exceeded');
|
||||
if(result.total_bytes+bytes.length>MAX_TOTAL_BYTES)throw Error('total_byte_budget_exceeded');
|
||||
if(sniff(bytes)!==contentType)throw Error('image_signature_mismatch');
|
||||
await stable();
|
||||
item.status='exported';delete item.reason;
|
||||
item.artifact={mime_type:contentType,bytes:bytes.length,sha256:sha(bytes),data_base64:bytes.toString('base64')};
|
||||
result.total_bytes+=bytes.length;
|
||||
}catch(error){const allowed=new Set(['frame_changed','snapshot_frame_unavailable','selector_ambiguous','selector_absent','selected_node_not_img','snapshot_identity_unavailable','editable_subtree_excluded','no_visible_layout','outside_visible_viewport','visibility_unconfirmed_or_covered','source_url_unavailable','selected_source_not_loaded_in_main_frame','unsupported_image_type','asset_byte_budget_exceeded','binary_content_unavailable','invalid_base64','total_byte_budget_exceeded','image_signature_mismatch']);item.reason=allowed.has(error.message)?error.message:'protocol_resource_unavailable';}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Read actual fonts used for bounded, visible message/name/code samples. No renderer evaluation.
|
||||
export async function captureElectronPlatformFonts(snapshot,frames,send) {
|
||||
const categories=['assistant-name','chinese-body','english-body','user-bubble','code'];
|
||||
const samples=[],missing=[];
|
||||
const document=snapshot.documents?.find(d=>snapshot.strings[d.frameId]===frames[0].frame_id);
|
||||
if(!document)return {samples,missing:categories.map(category=>({category,reason:'Authorized main document absent'}))};
|
||||
const strings=snapshot.strings,nodes=document.nodes,layout=document.layout;
|
||||
const value=index=>strings[index]??'';
|
||||
const className=index=>{const a=nodes.attributes[index]??[];for(let i=0;i<a.length;i+=2)if(value(a[i])==='class')return value(a[i+1]);return ''};
|
||||
const hasClass=(index,name)=>className(index).split(' ').includes(name);
|
||||
const under=(index,test)=>{for(let n=index;n>=0;n=nodes.parentIndex[n])if(test(n))return true;return false};
|
||||
const code=index=>under(index,n=>/^(CODE|PRE)$/.test(value(nodes.nodeName[n])));
|
||||
const text=index=>{let out='';for(let n=0;n<nodes.nodeName.length;n++)if(value(nodes.nodeName[n])==='#text'&&under(n,i=>i===index))out+=value(nodes.nodeValue[n]);return out};
|
||||
const viewport=snapshot.reaViewport;
|
||||
const positionOf=new Map(layout.nodeIndex.map((n,p)=>[n,p]));
|
||||
const visible=new Set(layout.nodeIndex.filter((_,position)=>{
|
||||
if(!viewport)return true;
|
||||
const [x,y,w,h]=layout.bounds[position]??[];
|
||||
let left=viewport.page_x,top=viewport.page_y,right=left+viewport.width,bottom=top+viewport.height;
|
||||
for(let n=nodes.parentIndex[layout.nodeIndex[position]];n>=0;n=nodes.parentIndex[n]){
|
||||
const parentPosition=positionOf.get(n);if(parentPosition===undefined)continue;
|
||||
const overflowIndex=snapshot.reaLayoutProperties.indexOf('overflow');
|
||||
const overflow=value((layout.styles[parentPosition]??[])[overflowIndex]);
|
||||
if(!/auto|scroll|hidden|clip/.test(overflow))continue;
|
||||
const [px,py,pw,ph]=layout.bounds[parentPosition];
|
||||
left=Math.max(left,px);top=Math.max(top,py);right=Math.min(right,px+pw);bottom=Math.min(bottom,py+ph);
|
||||
}
|
||||
return w>0&&h>0&&x>=left&&y>=top&&x+w<=right&&y+h<=bottom;
|
||||
}));
|
||||
const candidates=[];
|
||||
for(let n=0;n<nodes.nodeName.length;n++)if(value(nodes.nodeName[n])==='#text'){
|
||||
const parent=nodes.parentIndex[n];if(parent>=0&&visible.has(parent)&&!candidates.includes(parent))candidates.push(parent);
|
||||
}
|
||||
await send('DOM.getDocument',{depth:0});
|
||||
for(const category of categories){
|
||||
let found=false,reason='No short visible sample of this category in the current document';
|
||||
const selected=category==='assistant-name'?layout.nodeIndex.filter(n=>visible.has(n)&&hasClass(n,'cr-agent__name')):candidates;
|
||||
for(const index of selected){
|
||||
if(under(index,n=>/^(INPUT|TEXTAREA|SELECT)$/.test(value(nodes.nodeName[n]))||(nodes.attributes[n]??[]).some((a,i,attrs)=>i%2===0&&value(a)==='contenteditable'&&value(attrs[i+1])!=='false')))continue;
|
||||
const sample=text(index);if(!sample.trim()||sample.length>256)continue;
|
||||
const body=under(index,n=>hasClass(n,'cr-agent__content'))&&under(index,n=>hasClass(n,'cr-text-block'))&&!under(index,n=>hasClass(n,'cr-code-like-box'));
|
||||
// Plain leaf prose avoids treating a composite bold/inline subtree as one default-body font sample.
|
||||
const proseLeaf=!nodes.parentIndex.some((parent,n)=>parent===index&&value(nodes.nodeName[n])!=='#text'&&value(nodes.nodeName[n])!=='BR');
|
||||
if((category==='chinese-body'||category==='english-body')&&!proseLeaf)continue;
|
||||
const weightProperty=snapshot.reaLayoutProperties.indexOf('font-weight');
|
||||
if((category==='chinese-body'||category==='english-body')&&weightProperty>=0&&value((layout.styles[positionOf.get(index)]??[])[weightProperty])!=='400')continue;
|
||||
if(category==='chinese-body'&&(!body||code(index)||!/[\u3400-\u9fff]/.test(sample)))continue;
|
||||
if(category==='english-body'&&(!body||code(index)||/[\u3400-\u9fff]/.test(sample)||!/[A-Za-z]{3}/.test(sample)))continue;
|
||||
if(category==='user-bubble'&&!under(index,n=>hasClass(n,'cr-self-bubble')))continue;
|
||||
if(category==='code'&&!code(index))continue;
|
||||
const backend=nodes.backendNodeId?.[index];if(!backend){reason='Backend node identity absent';continue;}
|
||||
try{
|
||||
const pushed=await send('DOM.pushNodesByBackendIdsToFrontend',{backendNodeIds:[backend]});const nodeId=pushed.nodeIds?.[0];if(!nodeId)continue;
|
||||
const fonts=(await send('CSS.getPlatformFontsForNode',{nodeId})).fonts;
|
||||
if(!Array.isArray(fonts)||!fonts.some(f=>f.glyphCount>0)){reason='No rendered glyph fonts reported';continue;}
|
||||
const position=layout.nodeIndex.indexOf(index),styleIndexes=layout.styles[position]??[];
|
||||
// The order is supplied by the visuals module, not inferred from font names.
|
||||
const properties=snapshot.reaLayoutProperties;
|
||||
const styles=Object.fromEntries(properties.map((p,i)=>[p,value(styleIndexes[i])]));
|
||||
samples.push({category,text:sample,fontFamily:styles['font-family']??'',...(category==='code'?{codeContext:under(index,n=>value(nodes.nodeName[n])==='PRE')?'block':'inline'}:{}),platformFonts:fonts.map(f=>({familyName:f.familyName,postScriptName:f.postScriptName??'',isCustomFont:f.isCustomFont,glyphCount:f.glyphCount})),bounds:layout.bounds[position],styles});
|
||||
found=true;break;
|
||||
}catch{reason='Platform font query failed for the visible candidate';}
|
||||
}
|
||||
if(!found)missing.push({category,reason});
|
||||
}
|
||||
return {samples,missing};
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { digestCanonicalValue } from "../domain/canonicalDigest.js";
|
||||
import { createWebTextArtifact } from "../domain/webContentArtifact.js";
|
||||
import { authorizedElectronFile } from "./ElectronFileScope.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
const MAX_SHEETS = 1024;
|
||||
const MAX_SHEET_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_TOTAL_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
/** Retain only stylesheet lifecycle metadata from the selected target session. */
|
||||
export const ingestElectronStyleEvent = (event, stylesMap) => {
|
||||
if (event?.method === "CSS.styleSheetRemoved") {
|
||||
const styleSheetId = stringValue(recordValue(event.params)?.styleSheetId);
|
||||
if (styleSheetId !== undefined)
|
||||
stylesMap.delete(styleSheetId);
|
||||
return;
|
||||
}
|
||||
if (event?.method !== "CSS.styleSheetAdded")
|
||||
return;
|
||||
const header = recordValue(recordValue(event.params)?.header);
|
||||
const styleSheetId = stringValue(header?.styleSheetId);
|
||||
if (styleSheetId === undefined || styleSheetId === "")
|
||||
return;
|
||||
stylesMap.set(styleSheetId, {
|
||||
styleSheetId,
|
||||
frameId: stringValue(header?.frameId) ?? null,
|
||||
sourceURL: stringValue(header?.sourceURL) ?? null,
|
||||
origin: stringValue(header?.origin) ?? null,
|
||||
isInline: header?.isInline === true,
|
||||
isConstructed: header?.isConstructed === true,
|
||||
});
|
||||
};
|
||||
|
||||
/** Capture CSS text only for authorized file frames and explicit source opt-in. */
|
||||
export const captureElectronStyles = async (input) => {
|
||||
const styles = [...input.styles.values()];
|
||||
const items = [];
|
||||
let excluded = 0;
|
||||
let sourceFailures = 0;
|
||||
let truncated = false;
|
||||
let includedBytes = 0;
|
||||
let sourceRequests = 0;
|
||||
const limitationNotes = new Set();
|
||||
void input.completeness;
|
||||
|
||||
limitationNotes.add("Stylesheet inventory includes only CSS.styleSheetAdded events still present at the capture snapshot.");
|
||||
|
||||
for (const style of styles) {
|
||||
const scoped = await authorizedStyle(style, input.frameIds);
|
||||
if (scoped === undefined) {
|
||||
excluded += 1;
|
||||
continue;
|
||||
}
|
||||
if (input.request?.include_style_sources !== true)
|
||||
continue;
|
||||
|
||||
const identity = {
|
||||
style_sheet_id: style.styleSheetId,
|
||||
frame_id: scoped.frameId,
|
||||
file_path: scoped.filePath,
|
||||
origin: style.origin,
|
||||
is_inline: style.isInline,
|
||||
is_constructed: style.isConstructed,
|
||||
};
|
||||
const item = {
|
||||
style_sheet_key: `electron_stylesheet_${digestCanonicalValue(identity, "CDP capture")}`,
|
||||
style_sheet_id: style.styleSheetId,
|
||||
frame_id: scoped.frameId,
|
||||
file_path: scoped.filePath,
|
||||
origin: style.origin,
|
||||
is_inline: style.isInline,
|
||||
is_constructed: style.isConstructed,
|
||||
source: { included: false, reason: "CSS source was not requested" },
|
||||
};
|
||||
|
||||
if (sourceRequests >= MAX_SHEETS) {
|
||||
sourceFailures += 1;
|
||||
truncated = true;
|
||||
item.source = { included: false, reason: "stylesheet source count limit reached" };
|
||||
limitationNotes.add(`CSS source capture reached the ${String(MAX_SHEETS)} stylesheet limit.`);
|
||||
items.push(item);
|
||||
continue;
|
||||
}
|
||||
sourceRequests += 1;
|
||||
|
||||
let sourceText;
|
||||
try {
|
||||
const result = await input.connection.send(
|
||||
"CSS.getStyleSheetText",
|
||||
{ styleSheetId: style.styleSheetId },
|
||||
input.sessionId,
|
||||
input.signal
|
||||
);
|
||||
sourceText = stringValue(recordValue(result)?.text);
|
||||
}
|
||||
catch (cause) {
|
||||
if (input.signal?.aborted === true)
|
||||
throw cause;
|
||||
sourceFailures += 1;
|
||||
item.source = { included: false, reason: "CSS.getStyleSheetText failed" };
|
||||
limitationNotes.add("CSS text could not be retrieved for one or more authorized stylesheets.");
|
||||
items.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sourceText === undefined) {
|
||||
sourceFailures += 1;
|
||||
item.source = { included: false, reason: "CSS.getStyleSheetText returned invalid text" };
|
||||
limitationNotes.add("CSS text was invalid or absent for one or more authorized stylesheets.");
|
||||
items.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
const bytes = Buffer.byteLength(sourceText, "utf8");
|
||||
if (bytes > MAX_SHEET_BYTES) {
|
||||
sourceFailures += 1;
|
||||
truncated = true;
|
||||
item.source = { included: false, reason: "stylesheet source exceeds the per-stylesheet byte limit" };
|
||||
limitationNotes.add(`CSS source capture omitted text larger than ${String(MAX_SHEET_BYTES)} bytes per stylesheet.`);
|
||||
items.push(item);
|
||||
continue;
|
||||
}
|
||||
if (includedBytes + bytes > MAX_TOTAL_BYTES) {
|
||||
sourceFailures += 1;
|
||||
truncated = true;
|
||||
item.source = { included: false, reason: "aggregate stylesheet source byte limit reached" };
|
||||
limitationNotes.add(`CSS source capture omitted text after reaching the ${String(MAX_TOTAL_BYTES)} byte aggregate limit.`);
|
||||
items.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
includedBytes += bytes;
|
||||
item.source = { included: true, artifact: createWebTextArtifact(sourceText, "text/css") };
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
if (input.request?.include_style_sources !== true)
|
||||
limitationNotes.add("Stylesheet source capture was not selected; no CSS.getStyleSheetText requests were sent.");
|
||||
if (excluded > 0)
|
||||
limitationNotes.add(`${String(excluded)} stylesheet(s) were excluded because their origin or frame was outside the authorized local scope.`);
|
||||
if (sourceFailures > 0)
|
||||
limitationNotes.add(`${String(sourceFailures)} stylesheet source(s) failed retrieval or exceeded a source budget.`);
|
||||
input.limitations.push(...limitationNotes);
|
||||
|
||||
return {
|
||||
total: styles.length,
|
||||
items: items.sort((left, right) => compareUnicodeCodePoints(left.style_sheet_key, right.style_sheet_key)),
|
||||
excluded,
|
||||
source_failures: sourceFailures,
|
||||
truncated,
|
||||
limits: {
|
||||
max_sheets: MAX_SHEETS,
|
||||
max_sheet_bytes: MAX_SHEET_BYTES,
|
||||
max_total_bytes: MAX_TOTAL_BYTES,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const authorizedStyle = async (style, frameIds) => {
|
||||
if (style.frameId === null || !frameIds.has(style.frameId) || style.origin !== "regular")
|
||||
return undefined;
|
||||
const sourceURL = style.sourceURL ?? "";
|
||||
if (sourceURL === "") {
|
||||
if (!style.isInline && !style.isConstructed)
|
||||
return undefined;
|
||||
return { frameId: style.frameId, filePath: null };
|
||||
}
|
||||
const filePath = await authorizedElectronFile(sourceURL);
|
||||
return filePath === undefined ? undefined : { frameId: style.frameId, filePath };
|
||||
};
|
||||
|
||||
const recordValue = (value) => typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? value
|
||||
: undefined;
|
||||
const stringValue = (value) => typeof value === "string" ? value : undefined;
|
||||
@@ -0,0 +1,122 @@
|
||||
import { createWebScreenshotArtifact, decodeCanonicalBase64 } from '../domain/webScreenshot.js';
|
||||
import { readPngHeaderDimensions } from '../domain/pngHeader.js';
|
||||
import { authorizedElectronFile } from './ElectronFileScope.js';
|
||||
|
||||
export async function runElectronVisualProbe(context,frames,send,makeCleanupSend) {
|
||||
const probe=context.input.visual_probe;
|
||||
if(!probe||probe.actions.length===0)return undefined;
|
||||
const result={status:'completed',steps:[],frames:[],restoration:[],limitations:[
|
||||
'Native debugging input only; no renderer JavaScript evaluation or model request is performed by this provider.',
|
||||
'Frames are sequential PNG samples with measured timestamps, not a guaranteed video frame rate.',
|
||||
'Viewport and media cleanup clear the overrides; any overrides that existed before this request were not observable.',
|
||||
'Click restoration is suitable for caller-selected toggles only; other application effects are not rolled back.',
|
||||
'Scroll restoration applies the inverse wheel input; clipping, scroll inertia and layout changes can prevent exact position restoration.',
|
||||
'Keyboard focus and other keyboard effects are not restored. Pointer cleanup moves to the viewport origin, not the prior pointer position.'
|
||||
,'Text replacement is limited to visible, enabled single-line text/search inputs; it does not submit the input or restore its prior value.'
|
||||
]};
|
||||
const start=performance.now(),undo=[];let viewport=false,media=false,hover=false,totalBytes=0;
|
||||
const main=frames[0];
|
||||
const stable=async()=>{
|
||||
const tree=await send('Page.getFrameTree',{});const frame=tree.frameTree?.frame;
|
||||
if(!frame||frame.id!==main.frame_id||await authorizedElectronFile(frame.url)!==main.file_path)throw new Error('Selected Electron frame changed');
|
||||
};
|
||||
const select=async(selector,index)=>{
|
||||
const document=await send('DOM.getDocument',{depth:0});
|
||||
const selected=await send('DOM.querySelectorAll',{nodeId:document.root.nodeId,selector});
|
||||
if(index===undefined&&selected.nodeIds.length!==1)throw new Error('Selector must match exactly one node or supply an explicit index');
|
||||
const nodeId=selected.nodeIds[index??0];if(!nodeId)throw new Error('Selected node is absent');
|
||||
return nodeId;
|
||||
};
|
||||
const point=async(nodeId)=>{
|
||||
const box=(await send('DOM.getBoxModel',{nodeId})).model?.content;
|
||||
if(!box||box.length!==8)throw new Error('Selected node has no visible box');
|
||||
const x=(box[0]+box[2]+box[4]+box[6])/4,y=(box[1]+box[3]+box[5]+box[7])/4;
|
||||
const viewport=(await send('Page.getLayoutMetrics',{})).cssLayoutViewport;
|
||||
if(x<0||y<0||x>=viewport.clientWidth||y>=viewport.clientHeight)throw new Error('Selected node is outside the visible viewport');
|
||||
const hit=await send('DOM.getNodeForLocation',{x:Math.floor(x),y:Math.floor(y),includeUserAgentShadowDOM:false});
|
||||
const target=(await send('DOM.describeNode',{nodeId,depth:-1,pierce:false})).node;
|
||||
let visited=0;const contains=node=>{
|
||||
if(!node||++visited>5000)return false;
|
||||
return node.backendNodeId===hit.backendNodeId||(node.children??[]).some(contains);
|
||||
};
|
||||
if(!hit.backendNodeId||!contains(target))throw new Error('Selected node center is clipped or covered by another element');
|
||||
return {x,y};
|
||||
};
|
||||
const click=async(action)=>{
|
||||
const at=await point(await select(action.selector,action.index));
|
||||
await send('Input.dispatchMouseEvent',{type:'mouseMoved',...at});
|
||||
await send('Input.dispatchMouseEvent',{type:'mousePressed',...at,button:'left',clickCount:1});
|
||||
await send('Input.dispatchMouseEvent',{type:'mouseReleased',...at,button:'left',clickCount:1});
|
||||
hover=true;
|
||||
};
|
||||
const scroll=async(action)=>{
|
||||
const at=await point(await select(action.selector,action.index));
|
||||
await send('Input.dispatchMouseEvent',{type:'mouseWheel',...at,deltaX:0,deltaY:action.delta_y});
|
||||
};
|
||||
const sample=async(step_id)=>{
|
||||
await stable();
|
||||
const captured=await send('Page.captureScreenshot',{format:'png',fromSurface:true,captureBeyondViewport:false});
|
||||
const bytes=decodeCanonicalBase64(captured.data);
|
||||
if(!bytes||!readPngHeaderDimensions(bytes))throw new Error('Invalid scenario PNG');
|
||||
totalBytes+=bytes.length;if(totalBytes>32*1024*1024)throw new Error('Scenario PNG budget exceeded');
|
||||
const regions=[];
|
||||
for(const selector of probe.track_selectors){
|
||||
let bounds=null;
|
||||
try{const b=(await send('DOM.getBoxModel',{nodeId:await select(selector)})).model.border;const xs=[b[0],b[2],b[4],b[6]],ys=[b[1],b[3],b[5],b[7]];bounds=[Math.min(...xs),Math.min(...ys),Math.max(...xs)-Math.min(...xs),Math.max(...ys)-Math.min(...ys)];}catch{}
|
||||
regions.push({selector,bounds});
|
||||
}
|
||||
result.frames.push({step_id,at_ms:performance.now()-start,artifact:createWebScreenshotArtifact(bytes),regions});
|
||||
};
|
||||
const pause=milliseconds=>new Promise((resolve,reject)=>{const timer=setTimeout(done,milliseconds);function done(){context.signal?.removeEventListener('abort',abort);resolve()}function abort(){clearTimeout(timer);context.signal?.removeEventListener('abort',abort);reject(new Error('Visual probe cancelled'))}context.signal?.addEventListener('abort',abort,{once:true});if(context.signal?.aborted)abort()});
|
||||
try{
|
||||
for(const action of probe.actions){
|
||||
await stable();
|
||||
try{
|
||||
if(action.kind==='click'){await click(action);if(action.restore)undo.push({kind:'click',action});}
|
||||
else if(action.kind==='hover'){await send('Input.dispatchMouseEvent',{type:'mouseMoved',...await point(await select(action.selector,action.index))});hover=true;}
|
||||
else if(action.kind==='scroll'){await scroll(action);if(action.restore)undo.push({kind:'scroll',action:{...action,delta_y:-action.delta_y}});}
|
||||
else if(action.kind==='viewport'){await send('Emulation.setDeviceMetricsOverride',{width:action.width,height:action.height,deviceScaleFactor:0,mobile:false});viewport=true;}
|
||||
else if(action.kind==='media'){await send('Emulation.setEmulatedMedia',{features:[{name:'prefers-reduced-motion',value:action.reduced_motion}]});media=true;}
|
||||
else if(action.kind==='wait')await pause(action.duration_ms);
|
||||
else if(action.kind==='text'){
|
||||
const nodeId=await select(action.selector,action.index);
|
||||
await point(nodeId);
|
||||
const node=(await send('DOM.describeNode',{nodeId})).node,raw=node.attributes??[];
|
||||
const attrs=Object.fromEntries(Array.from({length:raw.length/2},(_,index)=>[raw[index*2].toLowerCase(),raw[index*2+1]]));
|
||||
if(node.nodeName!=='INPUT'||!['text','search'].includes((attrs.type??'text').toLowerCase())||'readonly'in attrs||'disabled'in attrs)throw new Error('Text replacement requires an enabled text/search INPUT');
|
||||
await send('DOM.focus',{nodeId});
|
||||
const selectAll={key:'a',code:'KeyA',windowsVirtualKeyCode:65,nativeVirtualKeyCode:65,modifiers:2};
|
||||
await send('Input.dispatchKeyEvent',{type:'keyDown',...selectAll});
|
||||
await send('Input.dispatchKeyEvent',{type:'keyUp',...selectAll});
|
||||
const remove={key:'Backspace',code:'Backspace',windowsVirtualKeyCode:8,nativeVirtualKeyCode:8,modifiers:0};
|
||||
await send('Input.dispatchKeyEvent',{type:'keyDown',...remove});
|
||||
await send('Input.dispatchKeyEvent',{type:'keyUp',...remove});
|
||||
if(action.text)await send('Input.insertText',{text:action.text});
|
||||
}
|
||||
else if(action.kind==='key'){
|
||||
if(action.selector){const nodeId=await select(action.selector,action.index);const node=(await send('DOM.describeNode',{nodeId})).node;const attrs=node.attributes??[];
|
||||
if(['Enter',' '].includes(action.key)&&(/^(INPUT|TEXTAREA)$/i.test(node.nodeName)||attrs.includes('contenteditable')))throw new Error('Activation key on editable content is excluded');
|
||||
await send('DOM.focus',{nodeId});
|
||||
}else if(['Enter',' '].includes(action.key))throw new Error('Activation keys require an explicit noneditable target');
|
||||
const codes={Escape:27,Enter:13,Tab:9,ArrowDown:40,ArrowUp:38,ArrowLeft:37,ArrowRight:39,Home:36,End:35,' ':32,'+':187,'-':189,'0':48};
|
||||
const packet={key:action.key,windowsVirtualKeyCode:codes[action.key],nativeVirtualKeyCode:codes[action.key],modifiers:action.modifiers};
|
||||
await send('Input.dispatchKeyEvent',{type:'keyDown',...packet,...(action.key==='Enter'?{text:'\r'}:action.key===' '?{text:' '}:{})});
|
||||
await send('Input.dispatchKeyEvent',{type:'keyUp',...packet});
|
||||
}
|
||||
const actionCompleted=performance.now()-start;
|
||||
for(let n=0;n<action.frames;n++){if(n)await pause(action.interval_ms);await sample(action.step_id);}
|
||||
result.steps.push({step_id:action.step_id,kind:action.kind,status:'completed',error:null,action_completed_at_ms:actionCompleted});
|
||||
}catch(error){result.status='failed';result.steps.push({step_id:action.step_id,kind:action.kind,status:'failed',error:String(error.message??error)});break;}
|
||||
}
|
||||
}finally{
|
||||
if(makeCleanupSend)send=makeCleanupSend();
|
||||
if(viewport){try{await send('Emulation.clearDeviceMetricsOverride',{});result.restoration.push({kind:'viewport',status:'completed',error:null});}catch(error){result.status='failed';result.restoration.push({kind:'viewport',status:'failed',error:String(error.message??error)});}}
|
||||
for(const restoration of undo.reverse()){
|
||||
try{await stable();if(restoration.kind==='click')await click(restoration.action);else await scroll(restoration.action);result.restoration.push({kind:restoration.kind,status:'completed',error:null});}
|
||||
catch(error){result.status='failed';result.restoration.push({kind:restoration.kind,status:'failed',error:String(error.message??error)});}
|
||||
}
|
||||
if(media){try{await send('Emulation.setEmulatedMedia',{features:[]});result.restoration.push({kind:'media',status:'completed',error:null});}catch(error){result.status='failed';result.restoration.push({kind:'media',status:'failed',error:String(error.message??error)});}}
|
||||
if(hover){try{await send('Input.dispatchMouseEvent',{type:'mouseMoved',x:0,y:0});result.restoration.push({kind:'neutral-pointer',status:'completed',error:null});}catch(error){result.status='failed';result.restoration.push({kind:'neutral-pointer',status:'failed',error:String(error.message??error)});}}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { createWebScreenshotArtifact, decodeCanonicalBase64 } from '../domain/webScreenshot.js';
|
||||
import { readPngHeaderDimensions } from '../domain/pngHeader.js';
|
||||
import { BrowserObservationError } from '../domain/browserObservationError.js';
|
||||
import { authorizedElectronFile } from './ElectronFileScope.js';
|
||||
import { runElectronVisualProbe } from './CdpElectronVisualProbe.js';
|
||||
import { captureElectronPlatformFonts } from './CdpElectronPlatformFonts.js';
|
||||
import { captureElectronMediaAssets } from './CdpElectronMediaAssets.js';
|
||||
|
||||
export const ELECTRON_LAYOUT_PROPERTIES = ['display', 'position', 'font-family', 'font-size', 'font-weight', 'line-height', 'letter-spacing', 'color', 'background-color', 'border-radius', 'border-top-width', 'border-top-color', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', 'gap', 'align-items', 'justify-content', 'box-shadow', 'overflow', 'max-width', 'min-height','transition-property','transition-duration','transition-timing-function','opacity','visibility','transform','text-rendering','-webkit-font-smoothing','font-kerning','font-synthesis','font-optical-sizing','font-feature-settings','text-shadow','zoom','-webkit-text-size-adjust'];
|
||||
|
||||
// Optional bounded message DOM text for reproducing identical fixture content.
|
||||
// Attribute values outside the style identity allowlist and all editable/input subtrees are omitted.
|
||||
export function captureElectronMessageContent(snapshot,frames) {
|
||||
const strings=snapshot.strings??[],value=i=>strings[i]??'';
|
||||
const document=snapshot.documents?.find(d=>value(d.frameId)===frames[0].frame_id);
|
||||
if(!document)return {nodes:[],text_characters:0,truncated:false};
|
||||
const raw=document.nodes,selected=[],roots=new Set(),excluded=new Set();let characters=0,truncated=false;
|
||||
const siblingCounts=new Map(),ordinals=new Map();
|
||||
for(let n=0;n<(raw.nodeName?.length??0);n++){const tag=value(raw.nodeName[n]);if(tag.startsWith('#'))continue;const key=raw.parentIndex[n]+'|'+tag;const count=(siblingCounts.get(key)??0)+1;siblingCounts.set(key,count);ordinals.set(n,count)}
|
||||
const classOf=n=>{const attrs=raw.attributes?.[n]??[];for(let a=0;a<attrs.length;a+=2)if(value(attrs[a])==='class')return value(attrs[a+1]).split(/\s+/);return []};
|
||||
const messageLists=raw.nodeName.map((_,n)=>n).filter(n=>classOf(n).includes('cr-message-list'));
|
||||
const selectorFor=index=>{const parts=[];for(let n=index;n>=0;n=raw.parentIndex[n]){if(messageLists.length===1&&n===messageLists[0]){parts.unshift('.cr-message-list');break}const tag=value(raw.nodeName[n]);if(!tag.startsWith('#'))parts.unshift(tag.toLowerCase()+':nth-of-type('+ordinals.get(n)+')')}return parts.join(' > ')};
|
||||
for(let n=0;n<(raw.nodeName?.length??0);n++){
|
||||
const attrs=raw.attributes?.[n]??[],attributes={};
|
||||
for(let a=0;a<attrs.length;a+=2)if(['class','id','role','lang','data-language'].includes(value(attrs[a])))attributes[value(attrs[a])]=value(attrs[a+1]);
|
||||
const names=(attributes.class??'').split(/\s+/),tag=value(raw.nodeName[n]),parent=raw.parentIndex[n];
|
||||
if(names.includes('cr-self-message')||names.includes('cr-agent'))roots.add(n);
|
||||
if(/^(INPUT|TEXTAREA|SELECT|SCRIPT|STYLE)$/.test(tag)||excluded.has(parent)||attrs.some((a,i)=>i%2===0&&value(a)==='contenteditable'&&value(attrs[i+1])!=='false'))excluded.add(n);
|
||||
let inside=false;for(let p=n;p>=0;p=raw.parentIndex[p])if(roots.has(p)){inside=true;break}
|
||||
if(!inside||excluded.has(n))continue;
|
||||
if(selected.length>=5000){truncated=true;break}
|
||||
const text=tag==='#text'?value(raw.nodeValue[n]):'';
|
||||
if(characters+text.length>65536){truncated=true;break}
|
||||
characters+=text.length;
|
||||
selected.push({node_index:n,parent_index:parent,node_name:tag,attributes,...(tag==='#text'?{text}:{}),...(names.some(name=>['cr-agent','cr-self-message','cr-collapse'].includes(name))?{selector:selectorFor(n)}:{})});
|
||||
}
|
||||
return {nodes:selected,text_characters:characters,truncated};
|
||||
}
|
||||
|
||||
// Called only after inspectCdpElectronPage has authorized the selected file page.
|
||||
export async function captureElectronVisuals(context, frames) {
|
||||
if (!context.input.include_visuals) return undefined;
|
||||
const signal = context.signal ? AbortSignal.any([context.signal, AbortSignal.timeout(30000)]) : AbortSignal.timeout(30000);
|
||||
const send = async (method, params) => {
|
||||
if (process.env.REA_VISUAL_TRACE === '1') process.stderr.write(`[REA visuals] ${method}\n`);
|
||||
return context.connection.send(method, params, context.sessionId, signal);
|
||||
};
|
||||
const metrics = await send('Page.getLayoutMetrics', {});
|
||||
let environmentGpu;
|
||||
if (context.input.visual_probe?.include_fonts) {
|
||||
try {
|
||||
// Browser metadata only; no renderer evaluation or GPU benchmark.
|
||||
const {gpu} = await context.connection.send('SystemInfo.getInfo', {}, undefined, signal);
|
||||
const allowed = ['inProcessGpu','skiaBackendType','glImplementationParts','subpixelFontRendering','sandboxed','passthroughCmdDecoder','targetCpuBits','driverVendor','driverVersion','displayType'];
|
||||
const aux = Object.fromEntries(allowed.filter(key => ['string','number','boolean'].includes(typeof gpu.auxAttributes?.[key]) || gpu.auxAttributes?.[key] === null).map(key => [key,gpu.auxAttributes[key]]));
|
||||
environmentGpu = { status:'observed', devices:(gpu.devices??[]).slice(0,8).map(device=>({vendor_id:device.vendorId,device_id:device.deviceId,...(typeof device.deviceString==='string'?{device_string:device.deviceString.slice(0,256)}:{}),...(typeof device.active==='boolean'?{active:device.active}:{})})),aux_attributes:aux,feature_status:Object.fromEntries(Object.entries(gpu.featureStatus??{}).filter(([key,value])=>key.length<100&&typeof value==='string'&&value.length<100)) };
|
||||
} catch { environmentGpu = { status:'unavailable' }; }
|
||||
}
|
||||
if (context.input.visual_probe) { await send('DOM.enable', {}); await send('CSS.enable', {}); }
|
||||
let environmentMedia;
|
||||
if(context.input.visual_probe){
|
||||
try{const media=await send('CSS.getMediaQueries',{});const reduced=(media.medias??[]).find(m=>/^\(\s*prefers-reduced-motion\s*:\s*reduce\s*\)$/i.test(m.text??''));environmentMedia={reduced_motion:reduced?.mediaList?.[0]?.active??null};}
|
||||
catch{environmentMedia={reduced_motion:null}}
|
||||
}
|
||||
const snapshot = await send('DOMSnapshot.captureSnapshot', { computedStyles: ELECTRON_LAYOUT_PROPERTIES, includePaintOrder: false, includeDOMRects: true });
|
||||
const strings = snapshot.strings ?? [];
|
||||
const str = index => typeof strings[index] === 'string' ? strings[index] : '';
|
||||
const frameIds = new Set(frames.map(frame => frame.frame_id));
|
||||
const documents = [];
|
||||
for (const document of snapshot.documents ?? []) {
|
||||
if (!frameIds.has(str(document.frameId)) || await authorizedElectronFile(str(document.documentURL)) === undefined) continue;
|
||||
const nodes = document.nodes ?? {};
|
||||
const layout = document.layout ?? {};
|
||||
const items = [];
|
||||
const svgNodes = [];
|
||||
const svgTags = new Set(['svg', 'path', 'g', 'circle', 'rect', 'line', 'polygon', 'polyline', 'ellipse', 'defs', 'clippath', 'mask']);
|
||||
const svgAttributes = new Set(['class', 'id', 'viewBox', 'width', 'height', 'd', 'points', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'cx', 'cy', 'r', 'rx', 'ry', 'fill', 'fill-rule', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'transform', 'clip-path', 'opacity']);
|
||||
for (let n = 0; n < (nodes.nodeName?.length ?? 0); n++) {
|
||||
const tag = str(nodes.nodeName[n]);
|
||||
if (!svgTags.has(tag.toLowerCase())) continue;
|
||||
const attributes = {};
|
||||
const attrs = nodes.attributes?.[n] ?? [];
|
||||
for (let a = 0; a < attrs.length; a += 2) if (svgAttributes.has(str(attrs[a]))) attributes[str(attrs[a])] = str(attrs[a + 1]);
|
||||
svgNodes.push({ node_index: n, parent_index: nodes.parentIndex?.[n] ?? -1, node_name: tag, attributes });
|
||||
}
|
||||
for (let i = 0; i < (layout.nodeIndex?.length ?? 0); i++) {
|
||||
const node = layout.nodeIndex[i];
|
||||
const attrs = nodes.attributes?.[node] ?? [];
|
||||
const attributes = {};
|
||||
for (let a = 0; a < attrs.length; a += 2) {
|
||||
const key = str(attrs[a]);
|
||||
// Retain style identity only; never input values, URL attributes or hidden payloads.
|
||||
if (['class', 'id', 'role'].includes(key)) attributes[key] = str(attrs[a + 1]);
|
||||
}
|
||||
const styles = {};
|
||||
for (let p = 0; p < ELECTRON_LAYOUT_PROPERTIES.length; p++) styles[ELECTRON_LAYOUT_PROPERTIES[p]] = str(layout.styles?.[i]?.[p]);
|
||||
const bounds = layout.bounds?.[i];
|
||||
if (!Array.isArray(bounds) || bounds.length !== 4 || !bounds.every(Number.isFinite)) continue;
|
||||
items.push({ node_index: node, parent_index: nodes.parentIndex?.[node] ?? -1, node_name: str(nodes.nodeName?.[node]), attributes, bounds, styles });
|
||||
}
|
||||
documents.push({ frame_id: str(document.frameId), file_path: await authorizedElectronFile(str(document.documentURL)), scroll_x: document.scrollOffsetX ?? 0, scroll_y: document.scrollOffsetY ?? 0, nodes: items, svg_nodes: svgNodes });
|
||||
}
|
||||
const captured = await send('Page.captureScreenshot', { format: 'png', fromSurface: true, captureBeyondViewport: false });
|
||||
if (typeof captured.data !== 'string' || captured.data.length > 24 * 1024 * 1024) throw new BrowserObservationError('inspect_electron_page', 'protocol_error');
|
||||
const bytes = decodeCanonicalBase64(captured.data);
|
||||
const viewport = bytes && readPngHeaderDimensions(bytes);
|
||||
if (!viewport) throw new BrowserObservationError('inspect_electron_page', 'protocol_error');
|
||||
const css = metrics.cssLayoutViewport ?? metrics.layoutViewport;
|
||||
if (!css || !Number.isFinite(css.clientWidth) || !Number.isFinite(css.clientHeight)) throw new BrowserObservationError('inspect_electron_page', 'protocol_error');
|
||||
const fonts = context.input.visual_probe?.include_fonts ? await captureElectronPlatformFonts({...snapshot,reaLayoutProperties:ELECTRON_LAYOUT_PROPERTIES,reaViewport:{width:css.clientWidth,height:css.clientHeight,page_x:css.pageX??0,page_y:css.pageY??0}},frames,send) : undefined;
|
||||
const mediaAssets = await captureElectronMediaAssets(snapshot,frames,context.input.visual_probe?.media_selectors,send);
|
||||
// Baseline DOM/fonts/media acquisition must not consume the scenario's input budget.
|
||||
// Both stages remain bounded and still inherit the caller's cancellation.
|
||||
const probeSignal = context.signal ? AbortSignal.any([context.signal, AbortSignal.timeout(30000)]) : AbortSignal.timeout(30000);
|
||||
const probeSend = (method,params)=>context.connection.send(method,params,context.sessionId,probeSignal);
|
||||
const probe = await runElectronVisualProbe({...context,signal:probeSignal},frames,probeSend,()=>{const cleanupSignal=AbortSignal.timeout(5000);return (method,params)=>context.connection.send(method,params,context.sessionId,cleanupSignal)});
|
||||
const environment = { ...(environmentMedia?{environment_media:environmentMedia}:{}), ...(environmentGpu?{environment_gpu:environmentGpu}:{}), ...(mediaAssets?{media_assets:mediaAssets}:{}) };
|
||||
return { captured_at: new Date().toISOString(), viewport, css_viewport: { width: css.clientWidth, height: css.clientHeight, page_x: css.pageX ?? 0, page_y: css.pageY ?? 0 }, ...environment,artifact: createWebScreenshotArtifact(bytes), layout: { properties: ELECTRON_LAYOUT_PROPERTIES, documents }, ...(fonts ? {font_samples:fonts.samples,font_missing:fonts.missing} : {}), ...(context.input.visual_probe?.include_message_content?{message_content:captureElectronMessageContent(snapshot,frames)}:{}), ...(probe ? {probe} : {}), limitations: ['Baseline captures the visible viewport; optional native visual_probe actions are recorded separately. No renderer JavaScript is evaluated.', 'DOM layout and screenshot are sequential observations, not an atomic rendering snapshot.', 'Layout omits text and input values; include_fonts retains bounded visible samples; optional include_message_content retains bounded message DOM text including collapsed message descendants, excluding editable/input subtrees.'] };
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Native metadata diagnostic owned by the local REA provider. No target injection or termination.
|
||||
import { spawn } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
import { createEvidence } from '../domain/evidence.js';
|
||||
export async function inspectDesktopElectronProcesses(processName) {
|
||||
if (process.platform !== 'win32') throw new Error('Windows process metadata provider requires Windows');
|
||||
if (!/^[A-Za-z0-9_.-]{1,80}\.exe$/i.test(processName)) throw new Error('Select one literal executable basename');
|
||||
const script = `$targets=Get-CimInstance Win32_Process -Filter "Name='${processName}'"; $rows=foreach($p in $targets){$debugPort=$null;$processType='main';if($p.CommandLine -match '--remote-debugging-port[= ]+(\\d+)'){$debugPort=[int]$Matches[1]};if($p.CommandLine -match '--type[= ]+([^\\s]+)'){$processType=$Matches[1]};[pscustomobject]@{pid=$p.ProcessId;parentPid=$p.ParentProcessId;executable=$p.ExecutablePath;type=$processType;debugPort=$debugPort}};$listeners=Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue|Where-Object{$_.OwningProcess -in $targets.ProcessId}|Select-Object LocalAddress,LocalPort,OwningProcess;[pscustomobject]@{processes=@($rows);listeners=@($listeners)}|ConvertTo-Json -Depth 5 -Compress`;
|
||||
const result = await new Promise((resolve,reject)=>{
|
||||
const child=spawn('C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe',['-NoLogo','-NoProfile','-NonInteractive','-EncodedCommand',Buffer.from(script,'utf16le').toString('base64')],{windowsHide:true,stdio:['ignore','pipe','pipe']});
|
||||
const chunks=[];let size=0;const timer=setTimeout(()=>{child.kill();reject(new Error('Windows metadata query timed out'))},15000);
|
||||
child.stdout.on('data',chunk=>{size+=chunk.length;if(size>1024*1024){child.kill();reject(new Error('Windows metadata budget exceeded'))}else chunks.push(chunk)});
|
||||
child.stderr.on('data',()=>{});
|
||||
child.once('error',error=>{clearTimeout(timer);reject(error)});
|
||||
child.once('close',code=>{clearTimeout(timer);if(code!==0)return reject(new Error('Windows metadata query failed'));try{resolve(JSON.parse(Buffer.concat(chunks).toString('utf8').replace(/^\uFEFF/,'')))}catch(error){reject(error)}});
|
||||
});
|
||||
return createEvidence(undefined,{id:'rea-windows-electron-processes',name:'Local REA Windows Electron metadata provider',version:'1+state-local.1'},{
|
||||
predicateType:'rea.desktop-electron-processes',operation:'inspect_desktop_electron_processes',parameters:{process_name:processName},result,
|
||||
confidence:'observed',authority:'external-service',environment:{id:`${os.platform()}-${os.release()}`,platform:os.platform(),architecture:os.arch(),isolation:'none'},
|
||||
limitations:['Native process metadata and listening sockets only; no target injection, file extraction, restart or termination.','Command lines are reduced to process type and declared remote-debugging port; other argument values are omitted.']
|
||||
});
|
||||
}
|
||||
|
||||
// Explicit lifecycle operation. The caller must already have authorization to interrupt WorkBuddy.
|
||||
export async function restartDesktopElectronDebug(executable,port,authorized) {
|
||||
if(process.platform!=='win32'||authorized!==true)throw new Error('An authorized Windows restart is required');
|
||||
if(!Number.isInteger(port)||port<1024||port>65535)throw new Error('Select one debugging port from 1024 to 65535');
|
||||
const expected=path.join(process.env.LOCALAPPDATA,'Programs','WorkBuddy','WorkBuddy.exe');
|
||||
const actual=await fs.realpath(executable),allowed=await fs.realpath(expected);
|
||||
if(actual.toLowerCase()!==allowed.toLowerCase())throw new Error('Restart is limited to the installed WorkBuddy executable');
|
||||
const quoted=actual.replaceAll("'","''");
|
||||
const script=`$ErrorActionPreference='Stop';$exe='${quoted}';$before=@(Get-CimInstance Win32_Process -Filter "Name='WorkBuddy.exe'"|Where-Object{$_.ExecutablePath -eq $exe});$close=@();foreach($p in $before){$native=Get-Process -Id $p.ProcessId -ErrorAction SilentlyContinue;if($native -and $native.MainWindowHandle -ne 0){$close+= [pscustomobject]@{pid=$p.ProcessId;requested=$native.CloseMainWindow()}}};$deadline=[datetime]::UtcNow.AddSeconds(8);do{$remaining=@(Get-CimInstance Win32_Process -Filter "Name='WorkBuddy.exe'"|Where-Object{$_.ExecutablePath -eq $exe});if($remaining.Count -eq 0){break};Start-Sleep -Milliseconds 200}while([datetime]::UtcNow -lt $deadline);$forced=@();foreach($p in $before){$current=Get-CimInstance Win32_Process -Filter ("ProcessId="+$p.ProcessId);if($current -and $current.ExecutablePath -eq $exe -and $current.CreationDate -eq $p.CreationDate){Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue;$forced+=$p.ProcessId}};$deadline=[datetime]::UtcNow.AddSeconds(4);do{$remaining=@(Get-CimInstance Win32_Process -Filter "Name='WorkBuddy.exe'"|Where-Object{$_.ExecutablePath -eq $exe});if($remaining.Count -eq 0){break};Start-Sleep -Milliseconds 100}while([datetime]::UtcNow -lt $deadline);if($remaining.Count -ne 0){throw 'Selected WorkBuddy processes did not exit; no replacement was launched'};[pscustomobject]@{beforePids=@($before|ForEach-Object{$_.ProcessId});closeRequests=@($close);forcedPids=@($forced);remaining=0}|ConvertTo-Json -Depth 4 -Compress`;
|
||||
const stopped=await new Promise((resolve,reject)=>{
|
||||
const child=spawn('C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe',['-NoLogo','-NoProfile','-NonInteractive','-EncodedCommand',Buffer.from(script,'utf16le').toString('base64')],{windowsHide:true,stdio:['ignore','pipe','pipe']});
|
||||
let output='',errors='';const timer=setTimeout(()=>{child.kill();reject(new Error('Authorized restart shutdown timed out'))},20000);
|
||||
child.stdout.on('data',b=>{output+=b;if(output.length>65536){child.kill();reject(new Error('Restart result budget exceeded'))}});child.stderr.on('data',b=>errors+=b);
|
||||
child.once('error',e=>{clearTimeout(timer);reject(e)});child.once('close',code=>{clearTimeout(timer);if(code!==0)return reject(new Error('Restart shutdown failed: '+errors.slice(0,1000)));try{resolve(JSON.parse(output.replace(/^\uFEFF/,'')))}catch(e){reject(e)}});
|
||||
});
|
||||
const env={...process.env};for(const key of Object.keys(env))if(key.toUpperCase()==='ELECTRON_RUN_AS_NODE')delete env[key];
|
||||
const args=[`--remote-debugging-port=${port}`,'--remote-debugging-address=127.0.0.1'];
|
||||
const launched=spawn(actual,args,{detached:true,windowsHide:true,stdio:'ignore',env});
|
||||
await new Promise((resolve,reject)=>{launched.once('spawn',resolve);launched.once('error',reject)});launched.unref();
|
||||
return createEvidence(undefined,{id:'rea-windows-electron-processes',name:'Local REA Windows Electron lifecycle provider',version:'1+state-local.2'},{
|
||||
predicateType:'rea.desktop-electron-restart',operation:'restart_desktop_electron_debug',parameters:{executable:actual,port,restart_authorized:true},result:{...stopped,launchedPid:launched.pid,arguments:args},confidence:'observed',authority:'controlled-replay',environment:{id:`${os.platform()}-${os.release()}`,platform:os.platform(),architecture:os.arch(),isolation:'none'},
|
||||
limitations:['Caller-authorized restart can interrupt in-flight tasks.','Graceful close is attempted first; surviving launch identities belonging to the exact selected executable are terminated.','Launch evidence confirms process creation only; inspect the debugging endpoint separately.','No original file contents or application data are extracted by this lifecycle operation.']
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { z } from 'zod';
|
||||
import { webScreenshotArtifactSchema } from '../webScreenshot.js';
|
||||
const sampling = { step_id:z.string().min(1).max(80), frames:z.number().int().min(0).max(24).default(1), interval_ms:z.number().int().min(16).max(500).default(50) };
|
||||
const target = {selector:z.string().min(1).max(500),index:z.number().int().min(0).max(100).optional()};
|
||||
export const electronVisualProbeInputSchema = z.strictObject({
|
||||
include_fonts:z.boolean().default(false),
|
||||
include_message_content:z.boolean().default(false),
|
||||
media_selectors:z.array(z.string().trim().min(1).max(500)).max(4).default([]),
|
||||
track_selectors:z.array(z.string().min(1).max(500)).max(12).default([]),
|
||||
actions:z.array(z.discriminatedUnion('kind',[
|
||||
z.strictObject({...sampling,...target,kind:z.literal('hover')}),
|
||||
z.strictObject({...sampling,...target,kind:z.literal('click'),restore:z.boolean().default(false)}),
|
||||
z.strictObject({...sampling,...target,kind:z.literal('text'),text:z.string().max(2048).refine(value=>!/[\u0000-\u001f\u007f]/u.test(value),'Single-line text only')}),
|
||||
z.strictObject({...sampling,...target,kind:z.literal('scroll'),delta_y:z.number().min(-3000).max(3000),restore:z.boolean().default(false)}),
|
||||
z.strictObject({...sampling,kind:z.literal('key'),selector:z.string().min(1).max(500).optional(),index:z.number().int().min(0).max(100).optional(),key:z.enum(['Escape','Enter','Tab','ArrowDown','ArrowUp','ArrowLeft','ArrowRight','Home','End',' ','+','-','0']),modifiers:z.number().int().min(0).max(15).default(0)}),
|
||||
z.strictObject({...sampling,kind:z.literal('wait'),duration_ms:z.number().int().min(0).max(2000)}),
|
||||
z.strictObject({...sampling,kind:z.literal('viewport'),width:z.number().int().min(160).max(3840),height:z.number().int().min(160).max(2160)})
|
||||
,z.strictObject({...sampling,kind:z.literal('media'),reduced_motion:z.enum(['reduce','no-preference'])})
|
||||
])).max(12).default([])
|
||||
}).superRefine((value,ctx)=>{
|
||||
if(value.actions.reduce((n,a)=>n+a.frames,0)>64)ctx.addIssue({code:'custom',message:'At most 64 scenario frames'});
|
||||
if(value.actions.reduce((n,a)=>n+a.frames*a.interval_ms+(a.duration_ms??0),0)>15000)ctx.addIssue({code:'custom',message:'Declared sampling and waits must fit 15 seconds'});
|
||||
if(new Set(value.actions.map(a=>a.step_id)).size!==value.actions.length)ctx.addIssue({code:'custom',message:'Step IDs must be unique'});
|
||||
if(new Set(value.media_selectors).size!==value.media_selectors.length)ctx.addIssue({code:'custom',message:'Media selectors must be unique'});
|
||||
});
|
||||
const mediaIdentity = {
|
||||
selector:z.string(),frame_id:z.string(),node_index:z.number().int().optional(),backend_node_id:z.number().int().optional(),
|
||||
selected_node:z.object({node_name:z.literal('IMG'),attributes:z.record(z.string(),z.string()),current_source_url:z.string().nullable()}).optional(),
|
||||
visibility:z.object({status:z.enum(['observed','covered','unknown']),reason:z.string().optional()}).optional(),
|
||||
visibility_samples:z.array(z.object({x:z.number(),y:z.number(),hit_backend_node_id:z.number().int().optional(),hit_node_index:z.number().int().optional(),hit_node_name:z.string().optional(),hit_class:z.string().optional()})).max(9).optional(),
|
||||
source:z.object({url:z.string().nullable(),url_sha256:z.string().regex(/^[a-f0-9]{64}$/),selection:z.enum(['current_source_url','src']),mime_type:z.string()}).optional()
|
||||
};
|
||||
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('unknown'),reason:z.string()})
|
||||
])).max(4),
|
||||
total_bytes:z.number().int().min(0).max(8388608),
|
||||
limits:z.object({max_assets:z.literal(4),max_asset_bytes:z.literal(2097152),max_total_bytes:z.literal(8388608)}),
|
||||
limitations:z.array(z.string())
|
||||
});
|
||||
export const platformFontSampleSchema = z.object({category:z.string(),text:z.string().max(256),fontFamily:z.string(),codeContext:z.enum(['inline','block']).optional(),platformFonts:z.array(z.object({familyName:z.string(),postScriptName:z.string(),isCustomFont:z.boolean(),glyphCount:z.number().int().min(0)}))});
|
||||
export const visualProbeOutputSchema = z.object({
|
||||
status:z.enum(['completed','failed']),
|
||||
steps:z.array(z.object({step_id:z.string(),kind:z.string(),status:z.enum(['completed','failed']),error:z.string().nullable(),action_completed_at_ms:z.number().optional()})),
|
||||
frames:z.array(z.object({step_id:z.string(),at_ms:z.number(),artifact:webScreenshotArtifactSchema,regions:z.array(z.object({selector:z.string(),bounds:z.tuple([z.number(),z.number(),z.number(),z.number()]).nullable()}))})),
|
||||
restoration:z.array(z.object({kind:z.string(),status:z.enum(['completed','failed']),error:z.string().nullable()})),
|
||||
limitations:z.array(z.string())
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const MAX_SELECTED_FILES = 16
|
||||
const MAX_FILE_BYTES = 8 * 1024 * 1024
|
||||
const MAX_TOTAL_BYTES = 16 * 1024 * 1024
|
||||
const SHA256_PATTERN = /^[a-f0-9]{64}$/u
|
||||
|
||||
function assertCanonicalSelectedPath(value, binary = false) {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new TypeError('Selected paths must be non-empty relative strings')
|
||||
}
|
||||
if (
|
||||
value.startsWith('/') ||
|
||||
value.endsWith('/') ||
|
||||
value.includes('\\') ||
|
||||
value.includes('\0') ||
|
||||
/^[a-zA-Z]:/u.test(value) ||
|
||||
value.split('/').some((part) => part === '' || part === '.' || part === '..') ||
|
||||
path.posix.normalize(value) !== value
|
||||
) {
|
||||
throw new TypeError(`Selected path is not canonical: ${value}`)
|
||||
}
|
||||
if (binary ? !/\.(?:png|jpe?g|gif|webp|svg|woff2?|ttf)$/u.test(value) : !/\.(?:js|css|json|md|txt|yaml|yml|py)$/u.test(value)) {
|
||||
if (binary) throw new TypeError(`Selected binary path must name a supported image or font: ${value}`)
|
||||
throw new TypeError(`Selected path must name a supported UTF-8 text file (.js/.css/.json/.md/.txt/.yaml/.yml/.py): ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
function validateSelection(expectedRootSha256, selectedPaths, binary = false) {
|
||||
if (typeof expectedRootSha256 !== 'string' || !SHA256_PATTERN.test(expectedRootSha256)) {
|
||||
throw new TypeError('Expected ASAR root SHA-256 must be 64 lowercase hexadecimal characters')
|
||||
}
|
||||
if (!Array.isArray(selectedPaths) || selectedPaths.length === 0 || selectedPaths.length > MAX_SELECTED_FILES) {
|
||||
throw new RangeError(`Select between 1 and ${MAX_SELECTED_FILES} exact files`)
|
||||
}
|
||||
|
||||
const paths = [...selectedPaths]
|
||||
const seen = new Set()
|
||||
for (const selectedPath of paths) {
|
||||
assertCanonicalSelectedPath(selectedPath, binary)
|
||||
if (seen.has(selectedPath)) throw new TypeError(`Duplicate selected path: ${selectedPath}`)
|
||||
seen.add(selectedPath)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
async function loadReaRuntime() {
|
||||
const runtimeRoot = path.resolve(process.env.REA_RUNTIME_ROOT || '.tmp/rea-css-runtime')
|
||||
const runtimeUrl = (relativePath) => pathToFileURL(path.join(runtimeRoot, relativePath)).href
|
||||
const [readerModule, evidenceModule] = await Promise.all([
|
||||
import(runtimeUrl('dist/artifacts/AsarArtifactReader.js')),
|
||||
import(runtimeUrl('dist/domain/evidence.js')),
|
||||
])
|
||||
const { createRequire } = await import('node:module')
|
||||
const requireFromRuntime = createRequire(path.join(runtimeRoot, 'package.json'))
|
||||
const packageMetadata = requireFromRuntime('./package.json')
|
||||
return {
|
||||
AsarArtifactReader: readerModule.AsarArtifactReader,
|
||||
createEvidence: evidenceModule.createEvidence,
|
||||
version: packageMetadata.version,
|
||||
}
|
||||
}
|
||||
|
||||
async function readSelectedEntry(reader, entry, totalBytes, binary = false) {
|
||||
if (entry.kind !== 'file') throw new TypeError(`Selected path is not a regular file: ${entry.path}`)
|
||||
if (!Number.isSafeInteger(entry.declaredSize) || entry.declaredSize < 0) {
|
||||
throw new Error(`Selected file has no valid declared size: ${entry.path}`)
|
||||
}
|
||||
if (entry.declaredSize > MAX_FILE_BYTES) {
|
||||
throw new RangeError(`Selected file exceeds the ${MAX_FILE_BYTES}-byte per-file budget: ${entry.path}`)
|
||||
}
|
||||
if (typeof entry.declaredSha256 !== 'string' || !SHA256_PATTERN.test(entry.declaredSha256)) {
|
||||
throw new Error(`Selected file has no valid declared SHA-256: ${entry.path}`)
|
||||
}
|
||||
|
||||
const chunks = []
|
||||
const digest = createHash('sha256')
|
||||
let bytes = 0
|
||||
const stream = await reader.open(entry)
|
||||
for await (const value of stream) {
|
||||
if (!(Buffer.isBuffer(value) || value instanceof Uint8Array)) {
|
||||
throw new TypeError(`Selected file stream returned non-byte data: ${entry.path}`)
|
||||
}
|
||||
const chunk = Buffer.from(value)
|
||||
bytes += chunk.length
|
||||
if (bytes > MAX_FILE_BYTES || bytes > entry.declaredSize || totalBytes + bytes > MAX_TOTAL_BYTES) {
|
||||
throw new RangeError(`Selected text exceeds its declared size or aggregate budget: ${entry.path}`)
|
||||
}
|
||||
digest.update(chunk)
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
if (bytes !== entry.declaredSize) {
|
||||
throw new Error(`Selected file size mismatch for ${entry.path}: declared ${entry.declaredSize}, read ${bytes}`)
|
||||
}
|
||||
const actualSha256 = digest.digest('hex')
|
||||
if (actualSha256 !== entry.declaredSha256) {
|
||||
throw new Error(`Selected file SHA-256 mismatch for ${entry.path}`)
|
||||
}
|
||||
|
||||
if (binary) return { path: entry.path, size_bytes: bytes, sha256: actualSha256, unpacked: entry.unpacked,
|
||||
encoding: 'base64', data_base64: Buffer.concat(chunks, bytes).toString('base64') }
|
||||
let text
|
||||
try {
|
||||
text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks, bytes))
|
||||
} catch (cause) {
|
||||
throw new Error(`Selected file is not strict UTF-8: ${entry.path}`, { cause })
|
||||
}
|
||||
|
||||
return {
|
||||
path: entry.path,
|
||||
size_bytes: bytes,
|
||||
sha256: actualSha256,
|
||||
unpacked: entry.unpacked,
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
/** Export bounded UTF-8 text from exact selected files in a root-pinned ASAR. */
|
||||
export async function exportSelectedArtifactText(inputPath, expectedRootSha256, selectedPaths, binary = false) {
|
||||
if (typeof inputPath !== 'string' || inputPath.length === 0) {
|
||||
throw new TypeError('ASAR input path must be a non-empty string')
|
||||
}
|
||||
const paths = validateSelection(expectedRootSha256, selectedPaths, binary)
|
||||
const { AsarArtifactReader, createEvidence, version } = await loadReaRuntime()
|
||||
const reader = new AsarArtifactReader(inputPath)
|
||||
let primaryFailure
|
||||
let primaryFailed = false
|
||||
let evidence
|
||||
|
||||
try {
|
||||
await reader.prepareContainer(expectedRootSha256)
|
||||
|
||||
const wanted = new Set(paths)
|
||||
const entries = new Map()
|
||||
for await (const entry of reader.entries()) {
|
||||
if (!wanted.has(entry.path)) continue
|
||||
if (entries.has(entry.path)) throw new Error(`ASAR inventory contains a duplicate path: ${entry.path}`)
|
||||
entries.set(entry.path, entry)
|
||||
}
|
||||
|
||||
for (const selectedPath of paths) {
|
||||
if (!entries.has(selectedPath)) throw new Error(`Selected path is absent from the ASAR inventory: ${selectedPath}`)
|
||||
}
|
||||
|
||||
let declaredTotalBytes = 0
|
||||
for (const selectedPath of paths) {
|
||||
const entry = entries.get(selectedPath)
|
||||
if (!Number.isSafeInteger(entry.declaredSize) || entry.declaredSize < 0) {
|
||||
throw new Error(`Selected file has no valid declared size: ${selectedPath}`)
|
||||
}
|
||||
if (entry.declaredSize > MAX_FILE_BYTES) {
|
||||
throw new RangeError(`Selected file exceeds the ${MAX_FILE_BYTES}-byte per-file budget: ${selectedPath}`)
|
||||
}
|
||||
declaredTotalBytes += entry.declaredSize
|
||||
if (declaredTotalBytes > MAX_TOTAL_BYTES) {
|
||||
throw new RangeError(`Selected files exceed the ${MAX_TOTAL_BYTES}-byte aggregate budget`)
|
||||
}
|
||||
}
|
||||
|
||||
const files = []
|
||||
let actualTotalBytes = 0
|
||||
for (const selectedPath of paths) {
|
||||
const file = await readSelectedEntry(reader, entries.get(selectedPath), actualTotalBytes, binary)
|
||||
actualTotalBytes += file.size_bytes
|
||||
files.push(file)
|
||||
}
|
||||
|
||||
const unselectedScope = 'Not read; unpacked payload content and availability are unknown.'
|
||||
evidence = createEvidence(
|
||||
{ path: inputPath, sha256: expectedRootSha256, format: 'asar' },
|
||||
{
|
||||
id: binary ? 'rea-selected-asar-binary' : 'rea-selected-asar-text',
|
||||
name: binary ? 'REA selected ASAR binary adapter' : 'REA selected ASAR text adapter',
|
||||
version,
|
||||
},
|
||||
{
|
||||
predicateType: binary ? 'rea.artifact.selected-binary' : 'rea.artifact.selected-text',
|
||||
operation: binary ? 'export_selected_asar_artifact_binary' : 'export_selected_asar_artifact_text',
|
||||
parameters: {
|
||||
expected_root_sha256: expectedRootSha256,
|
||||
selected_paths: paths,
|
||||
max_file_bytes: MAX_FILE_BYTES,
|
||||
max_total_bytes: MAX_TOTAL_BYTES,
|
||||
},
|
||||
result: {
|
||||
root_sha256: expectedRootSha256,
|
||||
scope: {
|
||||
kind: 'selected-files-only',
|
||||
selected_paths: paths,
|
||||
unselected_members: unselectedScope,
|
||||
},
|
||||
files,
|
||||
},
|
||||
confidence: 'observed',
|
||||
authority: 'shipped-artifact',
|
||||
limitations: [
|
||||
'Only selected file streams were read and validated.',
|
||||
`Unselected ASAR members were not opened; ${unselectedScope}`,
|
||||
],
|
||||
locations: paths.map((selectedPath) => ({ kind: 'artifact-path', path: selectedPath })),
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
primaryFailure = error
|
||||
primaryFailed = true
|
||||
}
|
||||
|
||||
try {
|
||||
await reader.close()
|
||||
} catch (cleanupFailure) {
|
||||
if (primaryFailed) {
|
||||
throw new AggregateError(
|
||||
[primaryFailure, cleanupFailure],
|
||||
'Selected ASAR text export failed and reader cleanup also failed',
|
||||
{ cause: primaryFailure },
|
||||
)
|
||||
}
|
||||
throw cleanupFailure
|
||||
}
|
||||
if (primaryFailed) throw primaryFailure
|
||||
return evidence
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import path from 'node:path'
|
||||
import { DirectoryArtifactReader } from './DirectoryArtifactReader.js'
|
||||
import { createEvidence } from '../domain/evidence.js'
|
||||
|
||||
const MAX_BYTES = 32 * 1024 * 1024
|
||||
const MAX_ENTRIES = 20000
|
||||
export async function exportSelectedConversationText(projectsRoot, sessionId, includeImages = false) {
|
||||
if (!path.isAbsolute(projectsRoot) || !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(sessionId)) {
|
||||
throw new TypeError('An absolute projects root and exact UUID session ID are required')
|
||||
}
|
||||
const reader = new DirectoryArtifactReader(projectsRoot)
|
||||
const matches = []
|
||||
let examined = 0
|
||||
try {
|
||||
for await (const entry of reader.entries()) {
|
||||
if (++examined > MAX_ENTRIES) throw new RangeError('Conversation inventory entry budget exceeded')
|
||||
if (entry.path.split('/').at(-1) === `${sessionId}.jsonl`) matches.push(entry)
|
||||
}
|
||||
if (matches.length !== 1) throw new Error(`Expected one exact session transcript, found ${matches.length}`)
|
||||
const entry = matches[0]
|
||||
if (entry.kind !== 'file' || !Number.isSafeInteger(entry.declaredSize) || entry.declaredSize > MAX_BYTES) {
|
||||
throw new RangeError('Selected transcript is not a bounded regular file')
|
||||
}
|
||||
const chunks = []
|
||||
let bytes = 0
|
||||
for await (const chunk of await reader.open(entry)) {
|
||||
const data = Buffer.from(chunk)
|
||||
bytes += data.length
|
||||
if (bytes > MAX_BYTES) throw new RangeError('Transcript grew beyond the byte budget')
|
||||
chunks.push(data)
|
||||
}
|
||||
const data = Buffer.concat(chunks)
|
||||
if (data.length !== entry.declaredSize) throw new Error('Transcript size changed during read')
|
||||
const text = new TextDecoder('utf-8', { fatal: true }).decode(data)
|
||||
const sha256 = createHash('sha256').update(data).digest('hex')
|
||||
let records = 0
|
||||
const imageRefs = new Map()
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
if (!line.trim()) continue
|
||||
const record = JSON.parse(line)
|
||||
if (!record || typeof record !== 'object' || Array.isArray(record)) throw new Error('JSONL record is not an object')
|
||||
if (record.sessionId && record.sessionId !== sessionId) throw new Error('Transcript contains another session ID')
|
||||
if (includeImages && record.type === 'message' && Array.isArray(record.content)) {
|
||||
for (const block of record.content) {
|
||||
const image = block?.type === 'input_image' ? block.image : undefined
|
||||
if (image?.type !== 'image_blob_ref') continue
|
||||
if (!/^[a-f0-9]{64}$/.test(image.blob_id) || !['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes(image.mime)) {
|
||||
throw new Error('Unsupported or invalid transcript image reference')
|
||||
}
|
||||
imageRefs.set(image.blob_id, image)
|
||||
}
|
||||
}
|
||||
records++
|
||||
}
|
||||
const attachments = includeImages ? await readImages(projectsRoot, [...imageRefs.values()]) : []
|
||||
return createEvidence({ path: path.join(projectsRoot, entry.path), sha256, format: 'file' },
|
||||
{ id: 'rea-selected-conversation-text', name: 'REA selected local conversation transcript', version: '1+local.1' }, {
|
||||
predicateType: 'rea.conversation.selected-text', operation: 'export_selected_conversation_text',
|
||||
parameters: { projects_root: projectsRoot, session_id: sessionId, include_images: includeImages, max_bytes: MAX_BYTES, max_entries: MAX_ENTRIES },
|
||||
result: { session_id: sessionId, file: { path: entry.path, sha256, bytes, records, text }, attachments,
|
||||
scope: 'One exact session JSONL and optionally its referenced image blobs; other file contents are not read' },
|
||||
confidence: 'observed', authority: 'historical-reference',
|
||||
limitations: ['Stable directory-reader identity checks do not establish an atomic snapshot of an actively written conversation.',
|
||||
'Exports stored history only; non-image external files and live kernel state are not copied.'],
|
||||
locations: [{ kind: 'artifact-path', path: entry.path }]
|
||||
})
|
||||
} finally { await reader.close() }
|
||||
}
|
||||
|
||||
async function readImages(projectsRoot, references) {
|
||||
if (!references.length) return []
|
||||
if (references.length > 32) throw new RangeError('Too many image references')
|
||||
const root = path.resolve(projectsRoot, '../blobs')
|
||||
const selected = new Map()
|
||||
for (const ref of references) {
|
||||
const target = path.resolve(ref.blob_path)
|
||||
const relative = path.relative(root, target).split(path.sep).join('/')
|
||||
if (!new RegExp(`^${ref.blob_id.slice(0, 2)}/${ref.blob_id}\\.(png|jpg|jpeg|webp|gif)$`).test(relative)) {
|
||||
throw new Error('Image reference is outside the selected content-addressed blob root')
|
||||
}
|
||||
selected.set(relative, ref)
|
||||
}
|
||||
const reader = new DirectoryArtifactReader(root)
|
||||
const files = []
|
||||
let total = 0
|
||||
try {
|
||||
for await (const entry of reader.entries(undefined, { descendInto: directory => [...selected.keys()].some(file => file.startsWith(directory + '/')) })) {
|
||||
const ref = selected.get(entry.path)
|
||||
if (!ref) continue
|
||||
if (entry.kind !== 'file' || !Number.isSafeInteger(entry.declaredSize) || entry.declaredSize > 8 * 1024 * 1024) {
|
||||
throw new Error('Referenced image is not a bounded regular file')
|
||||
}
|
||||
const chunks = []
|
||||
for await (const chunk of await reader.open(entry)) {
|
||||
total += chunk.length
|
||||
if (total > 16 * 1024 * 1024) throw new RangeError('Image export byte budget exceeded')
|
||||
chunks.push(Buffer.from(chunk))
|
||||
}
|
||||
const data = Buffer.concat(chunks)
|
||||
const sha256 = createHash('sha256').update(data).digest('hex')
|
||||
if (sha256 !== ref.blob_id || data.length !== ref.size || data.length !== entry.declaredSize) {
|
||||
throw new Error('Image blob digest/size does not match the transcript reference')
|
||||
}
|
||||
files.push({ blob_id: ref.blob_id, mime: ref.mime, bytes: data.length, sha256, data_base64: data.toString('base64') })
|
||||
}
|
||||
if (files.length !== references.length) throw new Error('A referenced image blob is absent')
|
||||
return files
|
||||
} finally { await reader.close() }
|
||||
}
|
||||
Reference in new issue
Block a user