// 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) 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){ const requested=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);} let fallbackReason=null; let fallbackCandidates=[]; if(!fs.existsSync(file)||!fs.statSync(file).isFile()){ fallbackCandidates=collectFallbackCandidates(selected); const fallback=fallbackCandidates.find((item)=>{const candidate=path.join(dir,item); return fs.existsSync(candidate)&&fs.statSync(candidate).isFile();}); if(fallback){fallbackReason='requested-file-missing'; 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',requestedFile:requested,resolvedFile:selected,file:selected,fallbackCandidates:(fallbackCandidates.length>0?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',requestedFile:requested,resolvedFile:selected,fallbackReason,file:{path:file,relative,requestedRelative:requested,resolvedRelative:selected,fallbackReason,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): Record { 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, ): Record { 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[] { 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 { 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 --view project-mdtodo-summary", analyze: "bun scripts/cli.ts web-probe observe analyze ", 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 | 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 | null; screenshots: Record[]; 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, }; }