Files
pikasTech-unidesk/scripts/src/hwlab-node/web-observe-scripts.ts
T

800 lines
57 KiB
TypeScript

// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. web-observe-scripts module for scripts/src/hwlab-node-impl.ts.
// Moved mechanically from scripts/src/hwlab-node-impl.ts:10515-11200 for #903.
// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel.
// Responsibility: YAML-first node/lane operations, including Workbench observability control commands.
import { createHash, randomBytes } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { repoRoot, rootPath, type Config } from "../config";
import { runCommand, type CommandResult } from "../command";
import { startJob } from "../jobs";
import { classifySshTcpPoolFailure } from "../ssh";
import { HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH, hwlabNodeControlPlaneInfraHelp, runHwlabNodeControlPlaneInfra } from "../hwlab-node-control-plane";
import { hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneIds, hwlabRuntimeLaneSpec, hwlabRuntimeLaneSpecForNode, hwlabRuntimeNodeIds, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec, type HwlabRuntimeObservabilityRecordingRuleSpec, type HwlabRuntimeObservabilitySpec, type HwlabRuntimeObservabilityWarningAlertSpec, type HwlabRuntimePublicExposureSpec, type HwlabRuntimeWebProbeAlertThresholdsSpec, type HwlabRuntimeWebProbeProjectManagementSpec } from "../hwlab-node-lanes";
import { nodeWebProbeScriptRunnerSource } from "../hwlab-node-web-probe-runner-source";
import { nodeWebObserveAnalyzerSource } from "../hwlab-node-web-observe-analyzer-source";
import { nodeWebObserveRunnerSource } from "../hwlab-node-web-observe-runner-source";
import { nodeWebObserveCollectViewNodeScript, parseNodeWebProbeObserveCollectView, type NodeWebProbeObserveCollectView } from "../hwlab-node-web-observe-collect";
import { withWebObserveCollectRendered, withWebObserveCommandRendered, withWebObserveStatusRendered } from "../hwlab-node-web-observe-render";
import { buildWebObserveWrapperForObserveOptions, webObserveWrapperStateDirFromStatus } from "../hwlab-node-web-observe-wrapper";
import { renderWebObserveWrapperContract } from "../hwlab-node-web-observe-wrapper-render";
import { runWebProbeSentinelCommand, type WebProbeSentinelOptions } from "../hwlab-node-web-sentinel-cicd";
import { hwlabNodeHelp, hwlabNodeObservabilityHelp, hwlabNodeWebProbeHelp } from "../hwlab-node-help";
import { compactWebProbeResult, compactWebProbeScriptResult } from "../hwlab-node-web-probe-summary";
import { nodeObservabilityRecordingRuleExpression, nodeObservabilityRecordingRuleSummaries, nodeObservabilityWarningAlertExpression, nodeObservabilityWarningAlertSummaries } from "../hwlab-node-observability-promql";
import { runDelegatedHwlabNodeCommand, type DelegatedNodeDomain } from "../hwlab-node-transport";
import type { RenderedCliResult } from "../output";
import type { BootstrapAdminPasswordMaterial, NodeWebProbeScriptOptions, RuntimeSecretSpec } from "./entry";
import type { NodeWebProbeHostProxyEnv } from "./web-probe-observe";
import { isSafeWebProbeScriptArtifactPath, isSafeWebProbeScriptReportPath, isSafeWebProbeScriptRunDir, runTransWorkspaceStdinScript } from "./public-exposure";
import { compactCommandResultRedacted, nullableRecord, parseJsonObject, redactKnownSecrets, shellQuote } from "./utils";
import { renderWebProbeScriptResult } from "./web-observe-render";
import { nodeWebProbeHostProxyEnv } from "./web-probe-observe";
export function nodeWebObserveStatusNodeScript(tailLines: number, node: string, lane: string): string {
return `node -e ${shellQuote(`
const fs=require('fs'),path=require('path');
const dir=process.env.state_dir||process.argv[1];
const node=process.argv[2];
const lane=process.argv[3];
const tailN=Math.min(${tailLines},3);
const readJson=(name)=>{try{return JSON.parse(fs.readFileSync(path.join(dir,name),'utf8'))}catch{return null}};
const readJsonPath=(file)=>{try{return JSON.parse(fs.readFileSync(file,'utf8'))}catch{return null}};
const tailJsonl=(name)=>{try{const file=path.join(dir,name); const st=fs.statSync(file); const maxBytes=Math.min(st.size,8*1024*1024); const fd=fs.openSync(file,'r'); try{const buf=Buffer.alloc(maxBytes); fs.readSync(fd,buf,0,maxBytes,st.size-maxBytes); const lines=buf.toString('utf8').split(/\\r?\\n/).filter(Boolean); if(st.size>maxBytes&&lines.length>0) lines.shift(); return lines.slice(-tailN).map(line=>{try{return JSON.parse(line)}catch{return {parseError:true, rawTail:line.slice(-500)}}});}finally{fs.closeSync(fd)}}catch{return []}};
const short=(value)=>String(value||'').slice(0,160);
const compactReadiness=(value)=>{const raw=value&&typeof value==='object'?value:null; const snapshot=raw&&raw.snapshot&&typeof raw.snapshot==='object'?raw.snapshot:raw; return raw?{reason:short(raw.reason||snapshot.reason,80),path:short(snapshot.path,120),search:short(snapshot.search,120),blockedReason:short(snapshot.blockedReason,80),readyState:short(snapshot.readyState,24),workbenchShellVisible:snapshot.workbenchShellVisible===true,sessionCreatePresent:snapshot.sessionCreatePresent===true,sessionCreateVisible:snapshot.sessionCreateVisible===true,sessionRailPresent:snapshot.sessionRailPresent===true,sessionRailCollapsed:snapshot.sessionRailCollapsed??null,commandInputPresent:snapshot.commandInputPresent===true,activeTabPresent:snapshot.activeTabPresent===true,loginVisible:snapshot.loginVisible===true,bodyTextBytes:snapshot.bodyTextBytes??null,bodyTextHash:short(snapshot.bodyTextHash,80),valuesRedacted:true}:null};
const errorReadiness=(error)=>{const details=error&&error.details&&typeof error.details==='object'?error.details:null; return compactReadiness((error&&error.navigationReadiness)||details&&details.readiness||details&&details.readinessAfterWait||details&&details.readinessBeforeClick)};
const compactManifest=(item)=>item?{jobId:item.jobId,status:item.status,specRef:item.specRef,baseUrl:item.baseUrl,targetPath:item.targetPath,network:item.network,pageAuthority:item.pageAuthority,sampling:item.sampling,safety:item.safety,startedAt:item.startedAt,completedAt:item.completedAt,error:item.error?{message:short(item.error.message),readiness:errorReadiness(item.error),auth:item.error.auth?{lastRetryLabel:item.error.auth.lastRetryLabel||null,retryExhausted:item.error.auth.retryExhausted===true,lastError:short(item.error.auth.lastError||'')}:null}:null}:null;
const compactAuth=(auth)=>auth?{phase:auth.phase||null,lastRetryLabel:auth.lastRetryLabel||null,retryAttempt:auth.retryAttempt??null,retryMaxAttempts:auth.retryMaxAttempts??null,retryDelayMs:auth.retryDelayMs??null,lastStatus:auth.lastStatus??null,lastStatusText:auth.lastStatusText||null,retryable:auth.retryable??null,cookiePresent:auth.cookiePresent??null,retryExhausted:auth.retryExhausted===true,lastError:short(auth.lastError||'')}:null;
const compactHeartbeat=(item,diag)=>item?{ok:item.ok,jobId:item.jobId,pid:item.pid,stateDir:item.stateDir,status:item.status,pageId:item.pageId,baseUrl:item.baseUrl,currentUrl:item.currentUrl,sampleSeq:item.sampleSeq,commandSeq:item.commandSeq,activeCommandId:item.activeCommandId,auth:compactAuth(item.auth),updatedAt:item.updatedAt,uptimeMs:item.uptimeMs,ageSeconds:diag.heartbeatAgeSeconds,stale:diag.heartbeatStale,staleAfterSeconds:diag.heartbeatStaleAfterSeconds,effectiveLiveness:diag.effectiveLiveness,error:item.error?{message:short(item.error.message),readiness:errorReadiness(item.error),auth:compactAuth(item.error.auth)}:null}:null;
const retryLabel=(detail)=>detail&&detail.auth?detail.auth.lastRetryLabel||'':detail&&detail.result?detail.result.lastRetryLabel||'':detail&&detail.error&&detail.error.auth?detail.error.auth.lastRetryLabel||'':'';
const detailText=(detail)=>{const readiness=detail&&detail.error?errorReadiness(detail.error):null; return detail&&detail.error?short((detail.error.message||'')+(readiness&&readiness.reason?' readiness='+readiness.reason:'')+(readiness&&readiness.blockedReason?' blocked='+readiness.blockedReason:'')+(detail.error.auth&&detail.error.auth.lastError?' '+detail.error.auth.lastError:'')):detail&&detail.result?short([detail.result.statusText,detail.result.retryExhausted?'retry-exhausted':'',detail.result.degradedReason?'degraded='+detail.result.degradedReason:''].filter(Boolean).join(' ')):''};
const compactControl=(item)=>({ts:item.ts,seq:item.seq,phase:item.phase,type:item.type,commandId:item.commandId,durationMs:item.detail&&item.detail.durationMs||null,retry:retryLabel(item.detail),detail:detailText(item.detail)});
const compactSample=(item)=>({seq:item.seq,ts:item.ts,path:item.path,routeSessionId:item.routeSessionId||null,activeSessionId:item.activeSessionId||null,messageCount:Array.isArray(item.messages)?item.messages.length:0,traceRowCount:Array.isArray(item.traceRows)?item.traceRows.length:0,loadingCount:Array.isArray(item.loadings)?item.loadings.length:0,loadingOwners:Array.isArray(item.loadings)?Array.from(new Set(item.loadings.map((loading)=>loading.ownerLabel||loading.ownerKind||loading.ownerKey||'loading'))).slice(0,4):[],sessionRail:item.sessionRail?{visibleCount:item.sessionRail.visibleCount??null,fallbackTitleCount:item.sessionRail.fallbackTitleCount??null,fallbackTitleRatio:item.sessionRail.fallbackTitleRatio??null,fallbackItems:Array.isArray(item.sessionRail.fallbackItems)?item.sessionRail.fallbackItems.slice(0,4).map((fallback)=>({sessionIdPrefix:fallback.sessionIdPrefix??null,titlePreview:fallback.titlePreview??null,active:fallback.active===true})):[]}:null});
const compactNetwork=(item)=>({ts:item.ts,type:item.type,method:item.method,url:short(item.url),status:item.status||null,failure:item.failure?short(item.failure):null});
const commandDir=(name)=>path.join(dir,'commands',name);
const commandSummary=(name)=>{
const root=commandDir(name); let entries=[];
try{entries=fs.readdirSync(root).filter((item)=>item.endsWith('.json')).sort();}catch{}
const items=entries.slice(0,12).map((entry)=>{const file=path.join(root,entry); const parsed=readJsonPath(file)||{}; let stat=null; try{stat=fs.statSync(file)}catch{}; const createdAt=parsed.createdAt||parsed.abandonedAt||parsed.completedAt||parsed.failedAt||null; const createdMs=Date.parse(String(createdAt||'')); const ageSeconds=Number.isFinite(createdMs)?Math.max(0,Math.round((Date.now()-createdMs)/1000)):null; return {id:parsed.id||parsed.commandId||entry.replace(/[.]json$/,''),type:parsed.type||null,createdAt,ageSeconds,mtime:stat?stat.mtime.toISOString():null};});
const ages=items.map((item)=>item.ageSeconds).filter((value)=>Number.isFinite(value));
return {count:entries.length,oldestAgeSeconds:ages.length?Math.max(...ages):null,items};
};
const pidText=fs.existsSync(path.join(dir,'pid'))?fs.readFileSync(path.join(dir,'pid'),'utf8').trim():null;
let alive=false; if(pidText){try{process.kill(Number(pidText),0); alive=true}catch{}}
const manifest=readJson('manifest.json');
const heartbeat=readJson('heartbeat.json');
const terminal=/^(completed|failed|force-stopped|stopped|abandoned)$/u.test(String((heartbeat&&heartbeat.status)||(manifest&&manifest.status)||''));
const sampleIntervalMs=Number(manifest&&manifest.sampling&&manifest.sampling.sampleIntervalMs)||5000;
const staleAfterMs=Math.max(60000,sampleIntervalMs*3);
const updatedRaw=heartbeat&&(heartbeat.updatedAt||heartbeat.lastSampleAt)||null;
const updatedMs=Date.parse(String(updatedRaw||''));
const heartbeatAgeSeconds=Number.isFinite(updatedMs)?Math.max(0,Math.round((Date.now()-updatedMs)/1000)):null;
const heartbeatStale=alive&&!terminal&&(!Number.isFinite(updatedMs)||Date.now()-updatedMs>staleAfterMs);
const commandsPending=commandSummary('pending');
const commandsProcessing=commandSummary('processing');
const commandsAbandoned=commandSummary('abandoned');
const commandsFailed=commandSummary('failed');
const diagnostics={heartbeatAgeSeconds,heartbeatStale,heartbeatStaleAfterSeconds:Math.round(staleAfterMs/1000),heartbeatUpdatedAt:updatedRaw,terminal,processAlive:alive,effectiveLiveness:heartbeatStale?'stale':alive?'alive':'not-running',commandBacklog:commandsPending.count+commandsProcessing.count,oldestPendingAgeSeconds:commandsPending.oldestAgeSeconds,oldestProcessingAgeSeconds:commandsProcessing.oldestAgeSeconds,valuesRedacted:true};
const commands={pendingCount:commandsPending.count,processingCount:commandsProcessing.count,abandonedCount:commandsAbandoned.count,failedCount:commandsFailed.count,pending:commandsPending.items,processing:commandsProcessing.items,abandoned:commandsAbandoned.items.slice(0,6),failed:commandsFailed.items.slice(0,6),valuesRedacted:true};
console.log(JSON.stringify({ok:true,command:'web-probe-observe status',stateDir:dir,pid:pidText?Number(pidText):null,processAlive:alive,manifest:compactManifest(manifest),heartbeat:compactHeartbeat(heartbeat,diagnostics),diagnostics,commands,tails:{control:tailJsonl('control.jsonl').map(compactControl),samples:tailJsonl('samples.jsonl').map(compactSample),network:tailJsonl('network.jsonl').map(compactNetwork)},next:{command:'bun scripts/cli.ts web-probe observe command --node '+node+' --lane '+lane+' --state-dir '+dir+' --type mark --label checkpoint',stop:'bun scripts/cli.ts web-probe observe stop --node '+node+' --lane '+lane+' --state-dir '+dir,forceStop:'bun scripts/cli.ts web-probe observe stop --node '+node+' --lane '+lane+' --state-dir '+dir+' --force',analyze:'bun scripts/cli.ts web-probe observe analyze --node '+node+' --lane '+lane+' --state-dir '+dir},valuesRedacted:true}));
`)} "$state_dir" ${shellQuote(node)} ${shellQuote(lane)}`;
}
export function nodeWebObserveForceStopNodeScript(reason: string, forcedByCommandId: string): string {
return `node -e ${shellQuote(`
const fs=require('fs'),path=require('path'),crypto=require('crypto');
const dir=process.argv[1],reason=process.argv[2],forcedByCommandId=process.argv[3];
const now=new Date().toISOString();
const short=(value,max=160)=>value===undefined||value===null?null:String(value).replace(/\\s+/g,' ').slice(0,max);
const shaText=(value)=>'sha256:'+crypto.createHash('sha256').update(String(value||'')).digest('hex');
const readJson=(file)=>{try{return JSON.parse(fs.readFileSync(file,'utf8'))}catch{return null}};
const writeJson=(file,value)=>{fs.mkdirSync(path.dirname(file),{recursive:true,mode:0o700}); fs.writeFileSync(file,JSON.stringify(value,null,2)+'\\n',{mode:0o600});};
const appendJsonl=(file,value)=>{fs.appendFileSync(file,JSON.stringify(value)+'\\n',{mode:0o600});};
const summarizeCommand=(command,id)=>({id,type:command&&command.type||null,createdAt:command&&command.createdAt||null,source:command&&command.source||null,path:command&&command.path||null,label:command&&command.label||null,sessionId:command&&command.sessionId||null,provider:command&&command.provider||null,textHash:typeof(command&&command.text)==='string'?shaText(command.text):null,textBytes:typeof(command&&command.text)==='string'?Buffer.byteLength(command.text):null,valuesRedacted:true});
const moveCommands=(bucket)=>{
const source=path.join(dir,'commands',bucket); let names=[];
try{names=fs.readdirSync(source).filter((name)=>name.endsWith('.json')).sort();}catch{}
const moved=[];
for(const name of names){
const file=path.join(source,name);
const parsed=readJson(file)||{};
const id=String(parsed.id||parsed.commandId||name.replace(/[.]json$/,'')).replace(/[^A-Za-z0-9_.-]/g,'-')||name.replace(/[.]json$/,'');
const record={ok:false,status:'abandoned',commandId:id,type:parsed.type||null,abandonedAt:now,reason,sourceBucket:bucket,forcedByCommandId,command:summarizeCommand(parsed,id),valuesRedacted:true};
writeJson(path.join(dir,'commands','abandoned',id+'.json'),record);
try{fs.unlinkSync(file)}catch{}
appendJsonl(path.join(dir,'control.jsonl'),{ts:now,seq:null,phase:'abandoned',type:parsed.type||null,commandId:id,detail:{reason,sourceBucket:bucket,forcedByCommandId,valuesRedacted:true},valuesRedacted:true});
moved.push({id,type:parsed.type||null,sourceBucket:bucket,createdAt:parsed.createdAt||null});
}
return moved;
};
const pidFile=path.join(dir,'pid');
const pidText=fs.existsSync(pidFile)?fs.readFileSync(pidFile,'utf8').trim():'';
const pid=Number(pidText);
const alive=()=>Number.isFinite(pid)&&pid>0?(()=>{try{process.kill(pid,0); return true;}catch{return false;}})():false;
const sleep=(ms)=>Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,ms);
const aliveBefore=alive();
let termSent=false,killSent=false;
if(aliveBefore){try{process.kill(pid,'SIGTERM'); termSent=true;}catch{}}
for(let i=0;i<10&&alive();i+=1) sleep(250);
if(alive()){try{process.kill(pid,'SIGKILL'); killSent=true;}catch{}}
for(let i=0;i<8&&alive();i+=1) sleep(250);
const pending=moveCommands('pending');
const processing=moveCommands('processing');
const aliveAfter=alive();
const manifestPath=path.join(dir,'manifest.json');
const heartbeatPath=path.join(dir,'heartbeat.json');
const manifest=readJson(manifestPath)||{};
const heartbeat=readJson(heartbeatPath)||{};
writeJson(manifestPath,{...manifest,ok:false,status:'force-stopped',forceStoppedAt:now,forceStop:{reason,forcedByCommandId,pid:Number.isFinite(pid)?pid:null,termSent,killSent,aliveBefore,aliveAfter,pendingAbandoned:pending.length,processingAbandoned:processing.length,valuesRedacted:true}});
writeJson(heartbeatPath,{...heartbeat,ok:false,status:'force-stopped',updatedAt:now,forceStoppedAt:now,forceStop:{reason,forcedByCommandId,pid:Number.isFinite(pid)?pid:null,termSent,killSent,aliveBefore,aliveAfter,pendingAbandoned:pending.length,processingAbandoned:processing.length,valuesRedacted:true}});
appendJsonl(path.join(dir,'control.jsonl'),{ts:now,seq:null,phase:'completed',type:'forceStop',commandId:forcedByCommandId,detail:{reason,pid:Number.isFinite(pid)?pid:null,termSent,killSent,aliveBefore,aliveAfter,pendingAbandoned:pending.length,processingAbandoned:processing.length,valuesRedacted:true},valuesRedacted:true});
console.log(JSON.stringify({ok:!aliveAfter,command:'web-probe-observe force-stop',stateDir:dir,forced:true,reason,forcedByCommandId,pid:Number.isFinite(pid)?pid:null,termSent,killSent,aliveBefore,aliveAfter,pendingAbandoned:pending.length,processingAbandoned:processing.length,abandoned:[...pending,...processing].slice(0,20),valuesRedacted:true}));
`)} "$state_dir" ${shellQuote(reason)} ${shellQuote(forcedByCommandId)}`;
}
export function nodeWebObserveCollectNodeScript(maxFiles: number, collectFile: string | null, collectFinding: string | null, collectGrep: string | null): string {
return `node -e ${shellQuote(`
const fs=require('fs'),path=require('path'),crypto=require('crypto');
const dir=process.argv[1]; const findingFilter=process.argv[3]||''; let selected=(process.argv[2]||'') || (findingFilter ? 'analysis/report.json' : ''); const grepText=process.argv[4]||''; const maxFiles=${maxFiles}; const maxReadBytes=64*1024; const maxJsonlTailBytes=1024*1024; const maxTextPreviewBytes=4096; const maxGrepContextLines=24; const maxGrepLineText=180;
const shaFile=(file)=>'sha256:'+crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
const shaText=(value)=>'sha256:'+crypto.createHash('sha256').update(String(value||'')).digest('hex');
const safeRel=(value)=>Boolean(value)&&!path.isAbsolute(value)&&!value.includes('..')&&!value.includes('\\\\')&&value.split('/').every((part)=>part&&part!=='.'&&part!=='..');
const short=(value,max=180)=>value===undefined||value===null?undefined:String(value).replace(/\\s+/g,' ').slice(0,max);
const grepLines=(text,pattern)=>{
if(!pattern) return null;
const needle=String(pattern).toLowerCase();
const lines=String(text||'').split(/\\r?\\n/);
const contexts=[]; let matchCount=0; const emitted=new Set();
for(let index=0; index<lines.length; index++){
if(!lines[index].toLowerCase().includes(needle)) continue;
matchCount++;
for(let offset=-2; offset<=2; offset++){
const lineIndex=index+offset;
if(lineIndex<0||lineIndex>=lines.length) continue;
if(emitted.has(lineIndex)) continue;
emitted.add(lineIndex);
contexts.push({lineNo:lineIndex+1,match:offset===0,text:short(lines[lineIndex],maxGrepLineText)});
if(contexts.length>=maxGrepContextLines) break;
}
if(contexts.length>=maxGrepContextLines) break;
}
return {patternSha256:shaText(pattern),matchCount,returnedLineCount:contexts.length,truncated:matchCount>0&&contexts.length>=maxGrepContextLines,lines:contexts,valuesRedacted:true};
};
const slimObject=(value,maxKeys=24)=>{
if(!value||typeof value!=='object'||Array.isArray(value)) return value??null;
const out={};
for(const key of Object.keys(value).slice(0,maxKeys)){
const item=value[key];
if(item===null||item===undefined||typeof item==='number'||typeof item==='boolean'||typeof item==='string') out[key]=short(item,160);
else if(Array.isArray(item)) out[key]={arrayLength:item.length};
else out[key]={keys:Object.keys(item).slice(0,12)};
}
return out;
};
const sensitiveKey=(key)=>/token|secret|password|passwd|authorization|cookie|api[-_]?key|session/i.test(String(key||''));
const slimJsonValue=(value,depth=0)=>{
if(value===null||value===undefined) return value??null;
if(typeof value==='number'||typeof value==='boolean') return value;
if(typeof value==='string') return short(value,360);
if(depth>=4) return Array.isArray(value)?{arrayLength:value.length}:{keys:Object.keys(value||{}).slice(0,20)};
if(Array.isArray(value)) return value.slice(0,12).map((item)=>slimJsonValue(item,depth+1)).concat(value.length>12?[{omitted:value.length-12}]:[]);
if(typeof value==='object'){
const out={};
const keys=Object.keys(value).slice(0,40);
for(const key of keys) out[key]=sensitiveKey(key)?'[redacted]':slimJsonValue(value[key],depth+1);
const omitted=Object.keys(value).length-keys.length;
if(omitted>0) out.__omittedKeys=omitted;
return out;
}
return short(value,180);
};
const slimFinding=(item)=>{
if(!item||typeof item!=='object') return null;
const samples=Array.isArray(item.samples)?item.samples.length:null;
const groups=Array.isArray(item.groups)?item.groups.length:null;
const evidence=item.evidence&&typeof item.evidence==='object'?slimObject(item.evidence,10):null;
return {
kind:short(item.kind||item.code||item.id,80),
severity:short(item.severity||item.level,24),
count:item.count??item.sampleCount??null,
summary:short(item.summary||item.message,240),
rootCause:short(item.rootCause,140),
rootCauseStatus:short(item.rootCauseStatus,90),
rootCauseConfidence:short(item.rootCauseConfidence,40),
nextAction:short(item.nextAction,240),
evidenceSummary:evidence?short(JSON.stringify(evidence),240):null,
sampleRows:samples,
groupRows:groups
};
};
const firstArray=(...values)=>values.find((value)=>Array.isArray(value))||[];
const compactMetric=(value)=>value===undefined||value===null?undefined:(typeof value==='object'?short(JSON.stringify(slimObject(value,8)),160):short(value,160));
const compactProjectionPair=(messageCount,traceCount)=>messageCount===undefined&&traceCount===undefined?undefined:'msg='+String(messageCount??'-')+' trace='+String(traceCount??'-');
const firstNestedRef=(value)=>value&&typeof value==='object'?(value.control&&typeof value.control==='object'?value.control:(value.observer&&typeof value.observer==='object'?value.observer:null)):null;
const compactTraceList=(value)=>{
if(Array.isArray(value)) return value.length>0?value.slice(0,4).map((item)=>String(item)).join(','):undefined;
if(value===undefined||value===null||value==='') return undefined;
return String(value);
};
const traceIdsFromRows=(rows)=>{
if(!Array.isArray(rows)) return undefined;
const ids=[];
for(const row of rows){
if(!row||typeof row!=='object') continue;
const value=row.traceId??row.businessTraceId??row.id;
if(value&&!ids.includes(String(value))) ids.push(String(value));
if(ids.length>=4) break;
}
return ids.length>0?ids:undefined;
};
const messageStatusesFromRows=(rows)=>{
if(!Array.isArray(rows)) return undefined;
const values=[];
for(const row of rows){
if(!row||typeof row!=='object') continue;
const status=row.status??row.dataStatus??row.messageStatus;
const trace=row.traceId??row.businessTraceId;
const text=[status,trace].filter(Boolean).join(':');
if(text&&!values.includes(text)) values.push(String(text));
if(values.length>=4) break;
}
return values.length>0?values:undefined;
};
const slimFindingSample=(value)=>{
if(!value||typeof value!=='object') return null;
const refValue=firstNestedRef(value)||{};
const controlCount=compactProjectionPair(value.controlMessageCount,value.controlTraceRowCount)??value.controlValue??value.beforeMessageCount??value.beforeValue;
const observerCount=compactProjectionPair(value.observerMessageCount,value.observerTraceRowCount)??value.observerValue??value.afterMessageCount??value.afterValue;
const trace=compactTraceList(value.traceIds)??compactTraceList(value.missingTraceIdsInObserver)??compactTraceList(value.controlTraceIds)??compactTraceList(value.observerTraceIds)??value.traceId??refValue.traceId;
return {
seq:value.seq??value.sampleSeq??value.fromSeq??value.controlSeq??refValue.seq??null,
ts:short(value.ts??value.sampleTs??value.fromTs??value.controlTs??refValue.ts,32),
diffKind:short(value.diffKind??value.kind??value.type??value.metric??value.anomaly,48),
fromValue:value.fromValue??value.beforeValue??null,
toValue:value.toValue??value.afterValue??null,
fromDigest:short(value.fromDigest,80),
toDigest:short(value.toDigest,80),
previousSeq:value.previousSeq??null,
previousTs:short(value.previousTs,32),
delta:value.delta??null,
sampleDeltaSeconds:value.sampleDeltaSeconds??null,
allowedIncreaseSeconds:value.allowedIncreaseSeconds??null,
excessiveIncreaseSeconds:value.excessiveIncreaseSeconds??null,
promptIndex:value.promptIndex??null,
event:short(value.event??value.anomaly??(value.steerUsed===true?'steer':Array.isArray(value.submitModes)?value.submitModes.join(','):undefined),48),
expectedPattern:short(value.expectedPattern,96),
control:compactMetric(controlCount??(value.fromMessageCount!==undefined||value.fromTraceRowCount!==undefined?compactProjectionPair(value.fromMessageCount,value.fromTraceRowCount):undefined)),
observer:compactMetric(observerCount??(value.toMessageCount!==undefined||value.toTraceRowCount!==undefined?compactProjectionPair(value.toMessageCount,value.toTraceRowCount):undefined)),
sessionId:short(value.sessionId??value.routeSessionId??value.activeSessionId??value.controlSessionId??value.observerSessionId??refValue.routeSessionId??refValue.activeSessionId,64),
path:short(value.path??value.urlPath??value.url??value.controlPath??value.observerPath??refValue.url,120),
trace:short(trace,120),
detail:short(value.detail??(value.durationText!==undefined||value.activityText!==undefined||value.textPreview!==undefined?['duration='+String(value.durationText??'-'),'activity='+String(value.activityText??'-'),'status='+String(value.status??'-'),'preview='+String(value.textPreview??'-')].join(' '):undefined)??(Array.isArray(value.submitModes)||Array.isArray(value.responseStatuses)?['modes='+String(Array.isArray(value.submitModes)?value.submitModes.join(','):'-'),'statuses='+String(Array.isArray(value.responseStatuses)?value.responseStatuses.join(','):'-'),'failure='+String(value.failureKind??'-')].join(' '):undefined)??(value.fromDigest||value.toDigest?'digest '+String(value.fromDigest??'-')+' -> '+String(value.toDigest??'-'):undefined)??(value.messageTextDigestDiff?'messageDigest '+String(value.controlMessageDigest??'-')+' != '+String(value.observerMessageDigest??'-'):undefined)??value.summary??value.message??value.preview??value.expectedPattern,180),
valuesRedacted:true
};
};
const slimFindingDetail=(parsed,id)=>{
if(!id||!parsed||typeof parsed!=='object'||!Array.isArray(parsed.findings)) return null;
const finding=parsed.findings.find((item)=>item&&typeof item==='object'&&String(item.id??item.kind??item.code??'')===id);
if(!finding) return {id,found:false,samples:[],valuesRedacted:true};
const sampleSource=Array.isArray(finding.samples)?'samples':Array.isArray(finding.groups)?'groups':Array.isArray(finding.examples)?'examples':Array.isArray(finding.rows)?'rows':Array.isArray(finding.rounds)?'rounds':'none';
const samples=firstArray(finding.samples,finding.groups,finding.examples,finding.rows,finding.rounds).slice(0,4).map(slimFindingSample).filter(Boolean);
return {id:short(finding.id??finding.kind??finding.code,80),found:true,severity:short(finding.severity??finding.level,24),count:finding.count??finding.sampleCount??samples.length,summary:short(finding.summary??finding.message,180),sampleSource,samples,valuesRedacted:true};
};
const slimRunnerError=(item)=>item&&typeof item==='object'?{ts:short(item.ts||item.at,32),type:short(item.type,48),retry:item.retry||item.lastRetryLabel||null,retryExhausted:item.retryExhausted===true,attemptCount:item.attemptCount??(Array.isArray(item.attempts)?item.attempts.length:null),lastFailureKind:short(item.lastFailureKind||item.failureKind,80),lastReadinessReason:short(item.lastReadinessReason,80),message:short(item.message||item.lastError,240)}:null;
const collectFallbackCandidates=(requested)=>{
const candidates=[];
if(findingFilter&&requested!=='analysis/report.json') candidates.push('analysis/report.json');
if(requested==='analysis/report.json'||findingFilter||requested.startsWith('analysis/')){
try{
const analysisDir=path.join(dir,'analysis');
const names=fs.readdirSync(analysisDir).filter((name)=>name.endsWith('.json')).map((name)=>({name,mtime:fs.statSync(path.join(analysisDir,name)).mtimeMs})).sort((a,b)=>b.mtime-a.mtime);
for(const item of names) candidates.push('analysis/'+item.name);
}catch{}
}
return [...new Set(candidates)].filter((item)=>item&&item!==requested&&safeRel(item));
};
const slimJsonl=(value)=>{
if(!value||typeof value!=='object'||Array.isArray(value)) return {value:short(value)};
const readiness=value.readiness&&typeof value.readiness==='object'?value.readiness:null;
const snapshot=value.snapshot&&typeof value.snapshot==='object'?value.snapshot:null;
const error=value.error&&typeof value.error==='object'?value.error:null;
return {
ts:short(value.ts||value.timestamp||value.at,32),
seq:value.seq,
type:short(value.type,48),
phase:short(value.phase,32),
commandId:short(value.commandId,40),
commandType:short(value.commandType||value.command?.type,40),
pageRole:short(value.pageRole,20),
path:short(value.path||value.routePath||snapshot?.path,80),
routeSessionId:short(value.routeSessionId||snapshot?.routeSessionId,48),
activeSessionId:short(value.activeSessionId||snapshot?.activeSessionId,48),
messageCount:value.messageCount??(Array.isArray(value.messages)?value.messages.length:undefined)??snapshot?.messageCount,
messageStatuses:short(compactTraceList(messageStatusesFromRows(value.messages)),160),
traceIds:short(compactTraceList(value.traceIds??traceIdsFromRows(value.traceRows)),160),
traceEventCount:value.traceEventCount??snapshot?.traceEventCount,
traceRowCount:value.traceRowCount??(Array.isArray(value.traceRows)?value.traceRows.length:undefined)??snapshot?.traceRowCount,
loadingCount:value.loadingCount??(Array.isArray(value.loadings)?value.loadings.length:undefined)??snapshot?.loadingCount,
method:short(value.method,12),
status:value.status,
url:short(value.url,120),
failure:short(value.failure||value.failureText||value.errorText,120),
readiness:readiness?{ok:readiness.ok,reason:short(readiness.reason,80),path:short(readiness.path,80),domReady:readiness.domReady,workbenchShellVisible:readiness.workbenchShellVisible,sessionCreateVisible:readiness.sessionCreateVisible,commandInputVisible:readiness.commandInputVisible}:undefined,
message:short(value.message||error?.message||value.text,180)
};
};
if(selected){
if(!safeRel(selected)){console.log(JSON.stringify({ok:false,command:'web-probe-observe collect',stateDir:dir,mode:'file',reason:'unsafe-file',file:selected,valuesRedacted:true},null,2)); process.exit(2);}
let file=path.join(dir,selected); let relative=path.relative(dir,file);
if(relative.startsWith('..')||path.isAbsolute(relative)){console.log(JSON.stringify({ok:false,command:'web-probe-observe collect',stateDir:dir,mode:'file',reason:'file-outside-state-dir',file:selected,valuesRedacted:true},null,2)); process.exit(2);}
if(!fs.existsSync(file)||!fs.statSync(file).isFile()){
const fallback=collectFallbackCandidates(selected).find((item)=>{const candidate=path.join(dir,item); return fs.existsSync(candidate)&&fs.statSync(candidate).isFile();});
if(fallback){selected=fallback; file=path.join(dir,selected); relative=path.relative(dir,file);}
}
if(!fs.existsSync(file)||!fs.statSync(file).isFile()){console.log(JSON.stringify({ok:false,command:'web-probe-observe collect',stateDir:dir,mode:'file',reason:'file-not-found',file:selected,fallbackCandidates:collectFallbackCandidates(selected).slice(0,8),valuesRedacted:true},null,2)); process.exit(1);}
const st=fs.statSync(file); const raw=fs.readFileSync(file); const truncated=raw.length>maxReadBytes; const isJsonl=selected.endsWith('.jsonl'); const isJson=selected.endsWith('.json');
const tailReadBytes=isJsonl?maxJsonlTailBytes:maxReadBytes;
const text=(isJsonl?raw.slice(Math.max(0,raw.length-tailReadBytes)):raw.slice(0,Math.min(raw.length,maxReadBytes))).toString('utf8');
const fullTextForGrep=grepText?raw.toString('utf8'):'';
const grep=grepText?grepLines(fullTextForGrep,grepText):null;
const lines=text.split(/\\r?\\n/).filter(Boolean);
if(isJsonl&&truncated&&lines.length>0) lines.shift();
let jsonSummary=null; let jsonContent=null;
if(isJson){try{const parsed=JSON.parse(raw.toString('utf8')); const inferredFindingFilter=findingFilter||(grepText&&Array.isArray(parsed&&parsed.findings)&&parsed.findings.some((item)=>item&&typeof item==='object'&&String(item.id??item.kind??item.code??'')===grepText)?grepText:null); jsonContent=raw.length<=4096?slimJsonValue(parsed):null; jsonSummary={topLevelKeys:parsed&&typeof parsed==='object'&&!Array.isArray(parsed)?Object.keys(parsed).slice(0,24):[],counts:slimObject(parsed&&parsed.counts,16),sampleMetrics:slimObject(parsed&&parsed.sampleMetrics,16),runtimeAlerts:slimObject(parsed&&parsed.runtimeAlerts,16),pagePerformance:slimObject(parsed&&parsed.pagePerformance,16),runnerErrors:Array.isArray(parsed&&parsed.runnerErrors)?parsed.runnerErrors.slice(-4).map(slimRunnerError).filter(Boolean):[],findings:Array.isArray(parsed&&parsed.findings)?parsed.findings.slice(0,6).map(slimFinding).filter(Boolean):[],findingDetail:slimFindingDetail(parsed,inferredFindingFilter)}}catch(error){jsonSummary={parseError:String(error&&error.message||error).slice(0,160)}}}
const jsonlTail=isJsonl&&!grep?lines.slice(-8).map((line,index)=>{try{return slimJsonl(JSON.parse(line))}catch(error){return {parseError:true,index,lineTail:line.slice(-240),error:String(error&&error.message||error).slice(0,160)}}}):null;
console.log(JSON.stringify({ok:true,command:'web-probe-observe collect',stateDir:dir,mode:'file',file:{path:file,relative,byteCount:st.size,sha256:shaFile(file),truncated,content:isJsonl||isJson||jsonSummary||grep?undefined:text.slice(0,maxTextPreviewBytes),contentTruncated:!isJsonl&&!isJson&&!jsonSummary&&!grep&&text.length>maxTextPreviewBytes,jsonlTail,jsonSummary,jsonContent,grep,lineCount:lines.length},valuesRedacted:true}));
process.exit(0);
}
const out=[]; const walk=(p)=>{for(const ent of fs.readdirSync(p,{withFileTypes:true})){const full=path.join(p,ent.name); if(ent.isDirectory()) walk(full); else out.push(full); if(out.length>=maxFiles) return;}};
walk(dir);
const selectedFiles=out.slice(0,maxFiles);
const files=selectedFiles.slice(0,24).map(file=>{const st=fs.statSync(file); return {relative:path.relative(dir,file),byteCount:st.size,sha256:shaFile(file)}});
const totalBytes=selectedFiles.reduce((sum,file)=>sum+fs.statSync(file).size,0);
console.log(JSON.stringify({ok:true,command:'web-probe-observe collect',stateDir:dir,fileCount:selectedFiles.length,listedFileCount:files.length,omittedFileCount:Math.max(0,selectedFiles.length-files.length),totalBytes,files,valuesRedacted:true}));
`)} "$state_dir" ${shellQuote(collectFile ?? "")} ${shellQuote(collectFinding ?? "")} ${shellQuote(collectGrep ?? "")}`;
}
export function nodeWebObserveWaitCommandShell(commandId: string, waitMs: number): string {
if (waitMs <= 0) {
return [
`printf '{"ok":true,"queued":true,"commandId":%s,"stateDir":%s}\\n' ${shellQuote(JSON.stringify(commandId))} "$(node -e "console.log(JSON.stringify(process.argv[1]))" "$state_dir")"`,
].join("\n");
}
const waitSeconds = Math.max(1, Math.ceil(waitMs / 1000));
return [
`command_id=${shellQuote(commandId)}`,
`deadline=$(( $(date +%s) + ${waitSeconds} ))`,
"while [ \"$(date +%s)\" -le \"$deadline\" ]; do",
" if [ -f \"$state_dir/commands/done/${command_id}.json\" ]; then cat \"$state_dir/commands/done/${command_id}.json\"; exit 0; fi",
" if [ -f \"$state_dir/commands/failed/${command_id}.json\" ]; then cat \"$state_dir/commands/failed/${command_id}.json\"; exit 2; fi",
" sleep 1",
"done",
"printf '{\"ok\":true,\"queued\":true,\"waitTimedOut\":true,\"commandId\":\"%s\",\"stateDir\":\"%s\"}\\n' \"$command_id\" \"$state_dir\"",
].join("\n");
}
export function commandSummaryForOutput(payload: Record<string, unknown>): Record<string, unknown> {
const text = typeof payload.text === "string" ? payload.text : null;
const title = typeof payload.title === "string" ? payload.title : null;
const body = typeof payload.body === "string" ? payload.body : null;
const opaque = (value: unknown) => typeof value === "string" && value.length > 0
? {
hash: `sha256:${createHash("sha256").update(value).digest("hex")}`,
preview: value.length <= 18 ? value : `${value.slice(0, 10)}...${value.slice(-5)}`,
bytes: Buffer.byteLength(value),
}
: null;
return {
id: payload.id,
type: payload.type,
path: payload.path ?? null,
label: payload.label ?? null,
sessionId: payload.sessionId ?? null,
provider: payload.provider ?? null,
durationMs: payload.durationMs ?? null,
sourceId: opaque(payload.sourceId),
fileRef: opaque(payload.fileRef),
taskRef: opaque(payload.taskRef),
taskId: payload.taskId ?? null,
titleHash: title === null ? null : `sha256:${createHash("sha256").update(title).digest("hex")}`,
titleBytes: title === null ? null : Buffer.byteLength(title),
bodyHash: body === null ? null : `sha256:${createHash("sha256").update(body).digest("hex")}`,
bodyBytes: body === null ? null : Buffer.byteLength(body),
status: payload.status ?? null,
hwpodId: opaque(payload.hwpodId),
nodeId: opaque(payload.nodeId),
root: opaque(payload.root),
textHash: text === null ? null : `sha256:${createHash("sha256").update(text).digest("hex")}`,
textBytes: text === null ? null : Buffer.byteLength(text),
valuesRedacted: true,
};
}
export function isSafeWebObserveStateDir(value: string): boolean {
return value.length > 0
&& !value.includes("\0")
&& !value.includes("..")
&& /^\.state\/web-observe\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/\d{4}\/\d{2}\/\d{2}\/[A-Za-z0-9_.TZ-]+_[A-Za-z0-9_.-]+_webobs-[A-Za-z0-9_.-]+$/u.test(value);
}
export function isSafeWebObserveCollectFile(value: string): boolean {
return value.length > 0
&& value.length <= 240
&& !value.includes("\0")
&& !value.includes("..")
&& !value.startsWith("/")
&& !value.includes("\\")
&& value.split("/").every((part) => part.length > 0 && part !== "." && part !== "..")
&& /^[A-Za-z0-9_.\/-]+$/u.test(value);
}
export function isSafeWebObserveTraceId(value: string): boolean {
return /^trc_[A-Za-z0-9_-]{6,120}$/u.test(value);
}
export function isSafeWebObserveFindingId(value: string): boolean {
return value.length > 0
&& value.length <= 120
&& !value.includes("\0")
&& /^[A-Za-z0-9_.:-]+$/u.test(value);
}
export function isSafeWebObserveArchivePrefix(value: string): boolean {
return value.length > 0
&& value.length <= 120
&& !value.includes("\0")
&& !value.includes("..")
&& /^[A-Za-z0-9_.-]+$/u.test(value);
}
export function isSafeWebObserveJobId(value: string): boolean {
return /^webobs-[A-Za-z0-9_.-]+$/u.test(value);
}
export function safeWebObserveSegment(value: string): string {
return value.replace(/[^A-Za-z0-9_.-]+/gu, "-").replace(/^-+|-+$/gu, "") || "item";
}
export function safeWebObserveTargetSegment(value: string): string {
const segment = value.replace(/^https?:\/\//u, "").replace(/[^A-Za-z0-9_.-]+/gu, "-").replace(/^-+|-+$/gu, "");
return segment.slice(0, 48) || "workbench";
}
export function runNodeWebProbeScript(
options: NodeWebProbeScriptOptions,
spec: HwlabRuntimeLaneSpec,
secretSpec: RuntimeSecretSpec,
material: BootstrapAdminPasswordMaterial,
credential: Record<string, unknown>,
): Record<string, unknown> {
const commandLabel = options.commandLabel ?? `web-probe script --node ${options.node} --lane ${options.lane}`;
const webProbeProxy = nodeWebProbeHostProxyEnv(spec, options.browserProxyMode);
const script = nodeWebProbeScriptRemoteShell(options, secretSpec, material.username ?? secretSpec.bootstrapAdminUsername, material.password ?? "", webProbeProxy, spec.webProbe?.playwrightBrowsersPath);
const result = runTransWorkspaceStdinScript(options.node, spec.workspace, script, options.commandTimeoutSeconds);
const commandTimedOut = result.timedOut || result.exitCode === 124;
const stdoutReport = parseJsonObject(result.stdout);
const runPaths = webProbeScriptRunPathsFromStderr(result.stderr);
const recoveredReport = stdoutReport === null ? readNodeWebProbeScriptReport(options, spec, runPaths.reportPath) : null;
const recoveredArtifacts = stdoutReport === null || commandTimedOut ? readNodeWebProbeScriptArtifacts(options, spec, runPaths.runDir) : null;
const parsedReport = stdoutReport ?? recoveredReport?.report ?? null;
const report = compactWebProbeScriptResult(parsedReport);
const passed = result.exitCode === 0 && report?.ok === true;
const summary = nullableRecord(report?.summary);
const stdoutBytes = Buffer.byteLength(result.stdout, "utf8");
const outputFailureKind = parsedReport === null
? commandTimedOut
? "web-probe-command-timeout"
: stdoutBytes > 64 * 1024
? "web-probe-output-too-large"
: "web-probe-report-parse-failed"
: null;
const degradedReason = commandTimedOut
? "web-probe-command-timeout"
: typeof summary?.degradedReason === "string"
? summary.degradedReason
: typeof report?.failureKind === "string"
? report.failureKind
: outputFailureKind
? outputFailureKind
: commandTimedOut
? "web-probe-command-timeout"
: null;
const failureKind = commandTimedOut
? "web-probe-command-timeout"
: typeof summary?.failureKind === "string"
? summary.failureKind
: typeof report?.failureKind === "string"
? report.failureKind
: outputFailureKind;
const effectiveSummary = summary !== null ? {
...summary,
transportTimedOut: commandTimedOut,
recoveredFrom: stdoutReport !== null ? "stdout" : recoveredReport?.source ?? null,
} : (outputFailureKind === null ? null : {
ok: false,
status: "blocked",
degradedReason: outputFailureKind,
failureKind: outputFailureKind,
failedCondition: outputFailureKind === "web-probe-output-too-large"
? "remote web-probe stdout exceeded the bounded summary parser input"
: outputFailureKind === "web-probe-command-timeout"
? "remote web-probe command timed out before stdout returned a parseable report"
: "remote web-probe stdout did not contain a parseable JSON report",
runDir: runPaths.runDir,
reportPath: runPaths.reportPath,
reportLoad: recoveredReport === null ? null : {
source: recoveredReport.source,
path: recoveredReport.path,
degradedReason: recoveredReport.degradedReason,
result: recoveredReport.result === null ? null : compactCommandResultRedacted(recoveredReport.result, [material.password ?? ""]),
},
screenshots: recoveredArtifacts?.screenshots ?? [],
artifacts: recoveredArtifacts?.artifacts ?? null,
stdoutBytes,
exitCode: result.exitCode,
stderrTail: result.stderr.trim().slice(-2000),
valuesRedacted: true,
});
const issueEvidence = nullableRecord(report?.issueEvidence) ?? nullableRecord(effectiveSummary?.issueEvidence);
const compactResult = compactCommandResultRedacted(result, [material.password ?? ""]);
if (outputFailureKind !== null) {
compactResult.stdoutTail = redactKnownSecrets(result.stdout.slice(-2000), [material.password ?? ""]);
compactResult.stderrTail = redactKnownSecrets(result.stderr.slice(-2000), [material.password ?? ""]);
}
return renderWebProbeScriptResult({
ok: passed,
status: passed ? "pass" : "blocked",
command: commandLabel,
node: options.node,
lane: options.lane,
workspace: spec.workspace,
url: options.url,
network: webProbeProxy.summary,
credential,
scriptSource: options.scriptSource,
degradedReason,
failureKind,
summary: effectiveSummary,
issueEvidence,
probe: report,
reportLoad: stdoutReport !== null ? { source: "stdout", path: report?.reportPath ?? null, degradedReason: null } : recoveredReport === null ? null : {
source: recoveredReport.source,
path: recoveredReport.path,
degradedReason: recoveredReport.degradedReason,
result: recoveredReport.result === null ? null : compactCommandResultRedacted(recoveredReport.result, [material.password ?? ""]),
},
warnings: webProbeScriptGovernanceWarnings(options),
hints: webProbeScriptGovernanceHints(options),
preferredCommands: webProbeScriptPreferredCommands(options),
recoveredArtifacts: recoveredArtifacts === null ? null : {
source: recoveredArtifacts.source,
degradedReason: recoveredArtifacts.degradedReason,
result: recoveredArtifacts.result === null ? null : compactCommandResultRedacted(recoveredArtifacts.result, [material.password ?? ""]),
artifacts: recoveredArtifacts.artifacts,
},
result: compactResult,
valuesRedacted: true,
});
}
function webProbeScriptGovernanceWarnings(options: NodeWebProbeScriptOptions): Record<string, unknown>[] {
if (options.suppressAdHocWarning === true) return [];
return [{
code: "web_probe_script_ad_hoc_only",
severity: "warning",
message: "web-probe script is for one-off exploration; repeated or high-frequency checks must be promoted to a typed command instead of rerunning temporary scripts.",
node: options.node,
lane: options.lane,
valuesRedacted: true,
}];
}
function webProbeScriptGovernanceHints(options: NodeWebProbeScriptOptions): string[] {
if (options.generatedHints !== undefined) return options.generatedHints;
return [
"Prefer `web-probe observe start` plus `web-probe observe command` for interactive flows; use `observe collect/analyze` for repeated evidence reads.",
"If the same script is needed more than once, add or extend a reusable command type in the web-probe observe command surface.",
`For this target, start with: bun scripts/cli.ts web-probe observe start --node ${options.node} --lane ${options.lane} --target-path /projects/mdtodo`,
];
}
function webProbeScriptPreferredCommands(options: NodeWebProbeScriptOptions): Record<string, string> {
if (options.generatedPreferredCommands !== undefined) return options.generatedPreferredCommands;
return {
startObserver: `bun scripts/cli.ts web-probe observe start --node ${options.node} --lane ${options.lane} --target-path /projects/mdtodo`,
mdtodoSummary: "bun scripts/cli.ts web-probe observe collect <observerId> --view project-mdtodo-summary",
analyze: "bun scripts/cli.ts web-probe observe analyze <observerId>",
addCommandType: "Add a repo-owned web-probe observe command type before repeating this script.",
};
}
export function nodeWebProbeScriptRemoteShell(options: NodeWebProbeScriptOptions, secretSpec: RuntimeSecretSpec, username: string, password: string, webProbeProxy: NodeWebProbeHostProxyEnv, playwrightBrowsersPath: string | undefined): string {
const userScriptB64 = Buffer.from(options.scriptText, "utf8").toString("base64");
const runnerB64 = Buffer.from(nodeWebProbeScriptRunnerSource(), "utf8").toString("base64");
return [
"set -eu",
"run_root=.state/web-probe-script",
"mkdir -p \"$run_root\"",
"run_dir=$(mktemp -d \"$run_root/run.XXXXXX\")",
"report_file=\"$run_dir/web-probe-script-report.json\"",
"chmod 700 \"$run_dir\"",
"printf 'UNIDESK_WEB_PROBE_RUN_DIR=%s\\n' \"$run_dir\" >&2",
"printf 'UNIDESK_WEB_PROBE_REPORT_PATH=%s\\n' \"$report_file\" >&2",
"user_script=\"$run_dir/user-script.mjs\"",
"runner=\"$run_dir/runner.mjs\"",
`node -e "require('fs').writeFileSync(process.argv[1], Buffer.from(process.argv[2], 'base64'))" "$user_script" ${shellQuote(userScriptB64)}`,
`node -e "require('fs').writeFileSync(process.argv[1], Buffer.from(process.argv[2], 'base64'))" "$runner" ${shellQuote(runnerB64)}`,
[
...webProbeProxy.envAssignments,
...(playwrightBrowsersPath === undefined ? [] : [
`PLAYWRIGHT_BROWSERS_PATH=${shellQuote(playwrightBrowsersPath)}`,
]),
`HWLAB_WEB_BASE_URL=${shellQuote(options.url)}`,
`HWLAB_WEB_USER=${shellQuote(username)}`,
`HWLAB_WEB_PASS=${shellQuote(password)}`,
"UNIDESK_WEB_PROBE_RUN_DIR=\"$run_dir\"",
"UNIDESK_WEB_PROBE_USER_SCRIPT=\"$user_script\"",
`UNIDESK_WEB_PROBE_TIMEOUT_MS=${shellQuote(String(options.timeoutMs))}`,
`UNIDESK_WEB_PROBE_VIEWPORT=${shellQuote(options.viewport)}`,
`UNIDESK_WEB_PROBE_BROWSER_PROXY_MODE=${shellQuote(options.browserProxyMode)}`,
"node \"$runner\"",
].join(" "),
].join("\n");
}
export function webProbeScriptRunPathsFromStderr(stderr: string): { runDir: string | null; reportPath: string | null } {
const runDir = lineValueFromText(stderr, "UNIDESK_WEB_PROBE_RUN_DIR");
const markedReportPath = lineValueFromText(stderr, "UNIDESK_WEB_PROBE_REPORT_PATH");
const reportPath = markedReportPath ?? (runDir === null ? null : `${runDir.replace(/\/$/u, "")}/web-probe-script-report.json`);
return {
runDir: isSafeWebProbeScriptRunDir(runDir) ? runDir : null,
reportPath: isSafeWebProbeScriptReportPath(reportPath) ? reportPath : null,
};
}
export function lineValueFromText(text: string, name: string): string | null {
const pattern = new RegExp(`^${name}=([^\\r\\n]+)$`, "mu");
const match = text.match(pattern);
return match ? match[1].trim() : null;
}
export function readNodeWebProbeScriptReport(
options: NodeWebProbeScriptOptions,
spec: HwlabRuntimeLaneSpec,
reportPath: string | null,
): { source: string; report: Record<string, unknown> | null; result: CommandResult | null; degradedReason: string | null; path: string | null } | null {
if (!reportPath) return null;
if (!isSafeWebProbeScriptReportPath(reportPath)) return { source: "unsafe-path", report: null, result: null, degradedReason: "web-probe-report-path-invalid", path: reportPath };
const script = [
"set -eu",
`test -f ${shellQuote(reportPath)}`,
`node - ${shellQuote(reportPath)} <<'NODE'`,
"const fs = require('fs');",
"const reportPath = process.argv[2];",
"const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));",
"function rec(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; }",
"function compact(value, depth = 0) {",
" if (value === null || value === undefined) return value ?? null;",
" if (typeof value === 'string') return value.replace(/\\s+/gu, ' ').trim().slice(0, 120);",
" if (typeof value === 'number' || typeof value === 'boolean') return value;",
" if (depth >= 3) return '[max-depth]';",
" if (Array.isArray(value)) return value.slice(0, 4).map((item) => compact(item, depth + 1));",
" if (typeof value === 'object') {",
" const out = {};",
" for (const [key, nested] of Object.entries(value).slice(0, 8)) out[key] = compact(nested, depth + 1);",
" return out;",
" }",
" return String(value).slice(0, 240);",
"}",
"function compactSummary(value) {",
" const summary = rec(value);",
" return {",
" ok: summary.ok === true,",
" status: typeof summary.status === 'string' ? summary.status : null,",
" degradedReason: typeof summary.degradedReason === 'string' ? summary.degradedReason : null,",
" failureKind: typeof summary.failureKind === 'string' ? summary.failureKind : null,",
" failedCondition: typeof summary.failedCondition === 'string' ? compact(summary.failedCondition) : null,",
" nextAction: typeof summary.nextAction === 'string' ? compact(summary.nextAction) : null,",
" baseUrl: typeof summary.baseUrl === 'string' ? summary.baseUrl : null,",
" finalUrl: typeof summary.finalUrl === 'string' ? summary.finalUrl : null,",
" lastUrl: typeof summary.lastUrl === 'string' ? summary.lastUrl : null,",
" scriptSha256: typeof summary.scriptSha256 === 'string' ? summary.scriptSha256 : null,",
" runDir: typeof summary.runDir === 'string' ? summary.runDir : null,",
" reportPath: typeof summary.reportPath === 'string' ? summary.reportPath : null,",
" reportSha256: typeof summary.reportSha256 === 'string' ? summary.reportSha256 : null,",
" lastScreenshot: compact(summary.lastScreenshot),",
" screenshots: compact(summary.screenshots),",
" apiMatrix: compact(summary.apiMatrix),",
" stepCount: typeof summary.stepCount === 'number' ? summary.stepCount : null,",
" lastStep: compact(summary.lastStep),",
" issueEvidence: compact(summary.issueEvidence),",
" valuesRedacted: true,",
" };",
"}",
"const scriptBlock = rec(report.script);",
"const compactReport = {",
" ok: report.ok === true,",
" status: typeof report.status === 'string' ? report.status : null,",
" summary: compactSummary(report.summary),",
" issueEvidence: compact(report.issueEvidence ?? rec(report.summary).issueEvidence),",
" baseUrl: typeof report.baseUrl === 'string' ? report.baseUrl : null,",
" finalUrl: typeof report.finalUrl === 'string' ? report.finalUrl : null,",
" lastUrl: typeof report.lastUrl === 'string' ? report.lastUrl : null,",
" scriptSha256: typeof report.scriptSha256 === 'string' ? report.scriptSha256 : null,",
" runDir: typeof report.runDir === 'string' ? report.runDir : null,",
" reportPath: typeof report.reportPath === 'string' ? report.reportPath : reportPath,",
" reportSha256: typeof report.reportSha256 === 'string' ? report.reportSha256 : null,",
" auth: compact(report.auth),",
" script: { ok: scriptBlock.ok === true, result: compact(scriptBlock.result), stepCount: Array.isArray(scriptBlock.steps) ? scriptBlock.steps.length : null },",
" steps: Array.isArray(report.steps) ? report.steps.slice(-5).map((item) => compact(item)) : [],",
" failureKind: typeof report.failureKind === 'string' ? report.failureKind : null,",
" guidance: typeof report.guidance === 'string' ? report.guidance : null,",
" lastScreenshot: compact(report.lastScreenshot),",
" readiness: compact(report.readiness),",
" artifacts: compact(report.artifacts),",
" error: typeof report.error === 'string' ? report.error : null,",
" errorMessage: typeof report.errorMessage === 'string' ? report.errorMessage : null,",
" safety: compact(report.safety),",
" valuesRedacted: true,",
"};",
"console.log(JSON.stringify(compactReport));",
"NODE",
].join("\n");
const result = runTransWorkspaceStdinScript(options.node, spec.workspace, script, 55);
if (result.exitCode !== 0 || result.timedOut) {
return { source: "report-file", report: null, result, degradedReason: result.timedOut ? "web-probe-command-timeout" : "web-probe-report-read-failed", path: reportPath };
}
const report = parseJsonObject(result.stdout);
return {
source: "report-file",
report,
result,
degradedReason: report === null ? "web-probe-report-parse-failed" : null,
path: reportPath,
};
}
export function readNodeWebProbeScriptArtifacts(
options: NodeWebProbeScriptOptions,
spec: HwlabRuntimeLaneSpec,
runDir: string | null,
): { source: string; artifacts: Record<string, unknown> | null; screenshots: Record<string, unknown>[]; result: CommandResult | null; degradedReason: string | null } | null {
if (!runDir) return null;
if (!isSafeWebProbeScriptRunDir(runDir)) return { source: "unsafe-path", artifacts: null, screenshots: [], result: null, degradedReason: "web-probe-run-dir-invalid" };
const script = [
"set -eu",
`test -d ${shellQuote(runDir)}`,
`find ${shellQuote(runDir)} -maxdepth 1 -type f \\( -name '*.png' -o -name '*.json' \\) -printf '%p\\t%s\\n' | tail -n 30`,
].join("\n");
const result = runTransWorkspaceStdinScript(options.node, spec.workspace, script, 55);
if (result.exitCode !== 0 || result.timedOut) {
return { source: "run-dir", artifacts: null, screenshots: [], result, degradedReason: result.timedOut ? "web-probe-command-timeout" : "web-probe-artifact-list-failed" };
}
const items = result.stdout.split(/\r?\n/u)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [path, sizeText] = line.split("\t");
const byteCount = Number(sizeText);
return {
kind: path.endsWith(".png") ? "screenshot" : "json",
path,
byteCount: Number.isFinite(byteCount) ? byteCount : null,
};
})
.filter((item) => isSafeWebProbeScriptArtifactPath(String(item.path)));
const screenshots = items.filter((item) => item.kind === "screenshot");
return {
source: "run-dir",
artifacts: { runDir, count: items.length, items },
screenshots,
result,
degradedReason: null,
};
}