Files
isphere-ai-bridge/scripts/extract-broute-bridge-map.ps1
2026-07-12 02:36:59 +08:00

538 lines
27 KiB
PowerShell

param(
[string]$IlDir = "runs/offline-evidence-intake/zyl-qqfile-20260709/metadata/c28-il",
[string]$BinaryDir = "runs/offline-real-client-window/full/zyl/Impp",
[string]$OutputDir = "runs/offline-broute-reverse",
[string]$ReportPath = "docs/source-discovery/2026-07-12-broute-bridge-static-map.md",
[int]$WindowRadius = 4,
[int]$MaxEvidencePerCandidate = 18
)
$ErrorActionPreference = "Stop"
$repo = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path
$ilDirFull = Join-Path $repo $IlDir
$binaryDirFull = Join-Path $repo $BinaryDir
$outDir = Join-Path $repo $OutputDir
$reportFullPath = Join-Path $repo $ReportPath
New-Item -ItemType Directory -Force -Path $outDir | Out-Null
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $reportFullPath) | Out-Null
$textSources = @(
@{ path = "IMPlatformClient.exe.il"; role = "main client IL; login, plugin manager, local HTTP starter, chat/runtime" },
@{ path = "IMPP.Common.dll.il"; role = "common helpers; WinCommand, ProcessComm, DTOs" },
@{ path = "IMPP.ISphere.exe.il"; role = "ISphere companion app IL; CEF and command helpers" },
@{ path = "IMPP.UI.dll.il"; role = "UI/browser support IL" },
@{ path = "IMPP.Service.dll.il"; role = "file service implementation IL" },
@{ path = "smack.dll.il"; role = "XMPP/socks5/network IL" }
)
$binarySources = @(
@{ path = "HttpServerLib.dll"; role = "local HTTP server framework binary" },
@{ path = "IMPlatformClient.Web.exe"; role = "CEF/web helper process binary" },
@{ path = "INetwork.dll"; role = "native network abstraction" },
@{ path = "IOClientNetwork.dll"; role = "native network implementation" },
@{ path = "IMPlatformClient.exe"; role = "main client binary" }
)
$liveProbeSources = @(
"runs/returned-live-probe/isphere-live-probe-20260710-121958/isphere-live-probe-20260710-121958/services_registry_shortcuts.json",
"runs/returned-live-probe/isphere-live-probe-20260710-121958/isphere-live-probe-20260710-121958/network_inventory.json"
)
$candidates = @(
@{
key = "local_http_win_server"
display = "frmLogin.StartWinServer / HttpServerLib.HttpService"
category = "localhost_http"
classification = "requires_logged_in_process"
usable_for_broute_send = $false
recommendation = "do_not_pursue_before_in_process_adapter"
reason = "Static IL builds a local HTTP service from logged-in user/auth state, but no send route or active live local listener was confirmed."
patterns = @("StartWinServer", "HttpServerLib", "HttpService::\.ctor", "HttpServer::Start", "GetFreePort", "0\.0\.0\.0", "messageAuth", "RepairDatabase")
},
@{
key = "http_route_framework"
display = "HttpServerLib route/module framework"
category = "localhost_http"
classification = "static_only_unknown"
usable_for_broute_send = $false
recommendation = "no_existing_ipc_first"
reason = "HttpServerLib has ServiceModule/RouteAttribute primitives, but client IL does not show RegisterModule or send-message routes."
patterns = @("RouteAttribute", "RouteMethod", "ServiceModule", "ServiceRoute", "RegisterModule", "OnGet\s*\(", "OnPost\s*\(", "ExecuteRoute", "SearchRoute")
},
@{
key = "plugin_named_pipe_stub"
display = "PluginLightAppMulProcessCommunication NamedPipeServerStream"
category = "named_pipe"
classification = "not_a_bridge"
usable_for_broute_send = $false
recommendation = "do_not_use"
reason = "A NamedPipeServerStream field exists, but StartLightAppMultProssCommunication has a one-instruction ret body."
patterns = @("NamedPipeServerStream", "PluginLightAppMulProcessCommunication", "StartLightAppMultProssCommunication", "MulProcess", "LightApp")
},
@{
key = "process_comm_wm_copydata"
display = "IMPP.Common.Helper.ProcessComm WM_COPYDATA helper"
category = "window_message"
classification = "static_only_unknown"
usable_for_broute_send = $false
recommendation = "not_a_send_bridge"
reason = "ProcessComm can send WM_COPYDATA to a foreground or titled window, but no receive handler or send-command contract is confirmed."
patterns = @("IMPP\.Common\.Helper\.ProcessComm", "\bProcessComm\b", "COPYDATASTRUCT", "WM_COPYDATA", "SendMessageToTargetWindow", "FindWindow\(string lpClassName", "GetForegroundWindow\(\)")
},
@{
key = "plugin_manager_command_surface"
display = "PluginManager / IMPlugin command surface"
category = "plugin"
classification = "requires_logged_in_process"
usable_for_broute_send = $false
recommendation = "plugin_extension_not_first"
reason = "Plugin evidence is UI/app-store/context-menu oriented and depends on logged-in PluginManager state; no Assembly.Load/LoadFrom or send API bridge was confirmed."
patterns = @("PluginManager", "IMPlugin::get_Command", "LoadPluginContextMenu", "FrmAppStore", "FrmCefHomePage", "ShowPlugins", "Assembly\.Load", "LoadFrom", "AddPluginInfo")
},
@{
key = "cef_web_helper"
display = "IMPlatformClient.Web / CEF helper"
category = "child_process"
classification = "requires_logged_in_process"
usable_for_broute_send = $false
recommendation = "preview_only_not_send"
reason = "The web helper/CEF surface exists, but static evidence points to UI/plugin browser support, not an external send/file API."
patterns = @("IMPlatformClient\.Web", "CefProcessName", "CefRuntime", "WinCefBrowser", "NavigateTo", "SendProcessMessage", "V8Context")
},
@{
key = "native_network_stack"
display = "INetwork / IOClientNetwork native stack"
category = "native_network"
classification = "not_a_bridge"
usable_for_broute_send = $false
recommendation = "do_not_pursue_as_ipc"
reason = "Native NETIO send/recv symbols are low-level transport, not a local command bridge into logged-in chat objects."
patterns = @("INetwork", "IOClientNetwork", "NETIO_", "NETIO_C_", "StreamClient", "RegistRecvCallback", "SendPacket", "BasePipe")
},
@{
key = "live_probe_named_pipe"
display = "returned live-probe named pipe inventory"
category = "named_pipe"
classification = "static_only_unknown"
usable_for_broute_send = $false
recommendation = "do_not_assume_isphere_api"
reason = "Live probe saw \\.\\pipe\\zfpinject-msg-server, but no matching iSphere static string or send contract was found in the configured samples."
patterns = @("zfpinject-msg-server", "named_pipes", "connections", "remote_port", "10088")
},
@{
key = "local_tcp_listener_candidates"
display = "local TcpListener candidates"
category = "tcp_listener"
classification = "not_a_bridge"
usable_for_broute_send = $false
recommendation = "not_existing_ipc"
reason = "TcpListener evidence is tied to TCP file manage or smack socks5, not a command API for text/file send. Returned live probe had only one remote XMPP connection."
patterns = @("TcpListener", "TCPFileManager::_listener", "TCPSocks5Server", "GetActiveTcpListeners", "BeginAcceptSocket", "remote_port")
}
)
function Get-RelativePath([string]$Path) {
if ($Path.StartsWith($repo, [System.StringComparison]::OrdinalIgnoreCase)) {
return $Path.Substring($repo.Length).TrimStart('\', '/')
}
return $Path
}
function Get-TextEvidenceKind([string]$Line) {
if ($Line -match "^\s*\.class\b") { return "type_definition" }
if ($Line -match "^\s*\.method\b") { return "method_header" }
if ($Line -match "\bend of method\b") { return "method_end" }
if ($Line -match "\bcall(?:virt)?\b") { return "callsite" }
if ($Line -match "\bnewobj\b") { return "constructor_call" }
if ($Line -match "\bldstr\b") { return "string_literal" }
if ($Line -match "^\s*(instance|static|StartWinServer|GetFreePort|SendMessageToTargetWindow)\b.*\(") { return "signature_continuation" }
return "reference"
}
function Get-MethodContext($Lines, [int]$Index) {
$start = [Math]::Max(0, $Index - 120)
$methodHeader = $null
$classHeader = $null
for ($i = $Index; $i -ge $start; $i--) {
$line = $Lines[$i]
if ($null -eq $methodHeader -and $line -match "^\s*\.method\b") {
$methodHeader = [pscustomobject]@{ line_number = $i + 1; text = $line.Trim() }
}
if ($null -eq $classHeader -and $line -match "^\s*\.class\b") {
$classHeader = [pscustomobject]@{ line_number = $i + 1; text = $line.Trim() }
}
if ($methodHeader -and $classHeader) { break }
}
[pscustomobject]@{ method_header = $methodHeader; class_header = $classHeader }
}
function New-TextEvidence($Lines, [int]$Index, [string]$SourceRel) {
$from = [Math]::Max(0, $Index - $WindowRadius)
$to = [Math]::Min($Lines.Count - 1, $Index + $WindowRadius)
$window = New-Object System.Collections.Generic.List[object]
for ($i = $from; $i -le $to; $i++) {
$window.Add([pscustomobject]@{ line_number = $i + 1; text = $Lines[$i].TrimEnd() })
}
$context = Get-MethodContext -Lines $Lines -Index $Index
[pscustomobject]@{
source = $SourceRel
source_type = "text_il"
line_number = $Index + 1
string_index = $null
kind = Get-TextEvidenceKind -Line $Lines[$Index]
text = $Lines[$Index].Trim()
method_header = $context.method_header
class_header = $context.class_header
window = $window
}
}
function Get-PrintableStrings([string]$Path, [int]$MinLength = 4) {
$bytes = [System.IO.File]::ReadAllBytes($Path)
$strings = New-Object System.Collections.Generic.List[string]
$sb = New-Object System.Text.StringBuilder
foreach ($b in $bytes) {
if ($b -ge 32 -and $b -le 126) {
[void]$sb.Append([char]$b)
} else {
if ($sb.Length -ge $MinLength) { $strings.Add($sb.ToString()) }
$sb.Clear() | Out-Null
}
}
if ($sb.Length -ge $MinLength) { $strings.Add($sb.ToString()) }
$sb = New-Object System.Text.StringBuilder
for ($i = 0; $i -lt ($bytes.Length - 1); $i += 2) {
$b = $bytes[$i]
$z = $bytes[$i + 1]
if ($z -eq 0 -and $b -ge 32 -and $b -le 126) {
[void]$sb.Append([char]$b)
} else {
if ($sb.Length -ge $MinLength) { $strings.Add($sb.ToString()) }
$sb.Clear() | Out-Null
}
}
if ($sb.Length -ge $MinLength) { $strings.Add($sb.ToString()) }
return $strings.ToArray()
}
function Get-HttpServerReflection([string]$Path) {
$result = [ordered]@{ path = Get-RelativePath -Path $Path; ok = $false; error = ""; types = @() }
try {
$asm = [System.Reflection.Assembly]::LoadFile($Path)
$types = New-Object System.Collections.Generic.List[object]
foreach ($type in $asm.GetTypes()) {
if ($type.FullName -notmatch "HTTPServerLib\.(HttpServer|HttpService|ServiceModule|ServiceRoute|RouteAttribute|IServer|ActionResult)") { continue }
$ctors = @($type.GetConstructors([System.Reflection.BindingFlags]'Public,NonPublic,Instance') | ForEach-Object {
$params = @($_.GetParameters() | ForEach-Object { $_.ParameterType.Name + " " + $_.Name })
[pscustomobject]@{ is_public = $_.IsPublic; signature = "$($type.Name)($([string]::Join(', ', $params)))" }
})
$methods = @($type.GetMethods([System.Reflection.BindingFlags]'Public,NonPublic,Instance,Static,DeclaredOnly') | ForEach-Object {
$params = @($_.GetParameters() | ForEach-Object { $_.ParameterType.Name + " " + $_.Name })
[pscustomobject]@{ name = $_.Name; signature = "$($_.ReturnType.Name) $($_.Name)($([string]::Join(', ', $params)))" }
})
$types.Add([pscustomobject]@{ full_name = $type.FullName; constructors = $ctors; methods = $methods })
}
$result.ok = $true
$result.types = $types.ToArray()
} catch {
$result.error = $_.Exception.Message
}
return [pscustomobject]$result
}
$textSourceResults = New-Object System.Collections.Generic.List[object]
$binarySourceResults = New-Object System.Collections.Generic.List[object]
$candidateBuckets = @{}
foreach ($candidate in $candidates) {
$candidateBuckets[[string]$candidate['key']] = New-Object System.Collections.Generic.List[object]
}
foreach ($source in $textSources) {
$sourceFullPath = Join-Path $ilDirFull ([string]$source['path'])
$sourceRel = Get-RelativePath -Path $sourceFullPath
if (-not (Test-Path -LiteralPath $sourceFullPath -PathType Leaf)) {
$textSourceResults.Add([pscustomobject]@{ path = $sourceRel; role = $source['role']; exists = $false; line_count = 0; size_bytes = $null })
continue
}
$item = Get-Item -LiteralPath $sourceFullPath
$lines = [System.IO.File]::ReadAllLines($sourceFullPath)
$textSourceResults.Add([pscustomobject]@{ path = $sourceRel; role = $source['role']; exists = $true; line_count = $lines.Count; size_bytes = $item.Length })
foreach ($candidate in $candidates) {
$key = [string]$candidate['key']
$compiled = @($candidate['patterns'] | ForEach-Object { [regex]::new($_, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) })
for ($i = 0; $i -lt $lines.Count; $i++) {
$line = $lines[$i]
foreach ($rx in $compiled) {
if ($rx.IsMatch($line)) {
$candidateBuckets[$key].Add((New-TextEvidence -Lines $lines -Index $i -SourceRel $sourceRel))
break
}
}
}
}
}
foreach ($source in $binarySources) {
$sourceFullPath = Join-Path $binaryDirFull ([string]$source['path'])
$sourceRel = Get-RelativePath -Path $sourceFullPath
if (-not (Test-Path -LiteralPath $sourceFullPath -PathType Leaf)) {
$binarySourceResults.Add([pscustomobject]@{ path = $sourceRel; role = $source['role']; exists = $false; string_count = 0; size_bytes = $null })
continue
}
$item = Get-Item -LiteralPath $sourceFullPath
$strings = Get-PrintableStrings -Path $sourceFullPath -MinLength 4
$binarySourceResults.Add([pscustomobject]@{ path = $sourceRel; role = $source['role']; exists = $true; string_count = $strings.Count; size_bytes = $item.Length })
foreach ($candidate in $candidates) {
$key = [string]$candidate['key']
$compiled = @($candidate['patterns'] | ForEach-Object { [regex]::new($_, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) })
for ($i = 0; $i -lt $strings.Count; $i++) {
$s = $strings[$i]
foreach ($rx in $compiled) {
if ($rx.IsMatch($s)) {
$candidateBuckets[$key].Add([pscustomobject]@{
source = $sourceRel
source_type = "binary_string"
line_number = $null
string_index = $i
kind = "binary_string"
text = $s
method_header = $null
class_header = $null
window = @()
})
break
}
}
}
}
}
$liveProbe = New-Object System.Collections.Generic.List[object]
foreach ($relPath in $liveProbeSources) {
$fullPath = Join-Path $repo $relPath
if (Test-Path -LiteralPath $fullPath -PathType Leaf) {
$raw = Get-Content -LiteralPath $fullPath -Raw
$liveProbe.Add([pscustomobject]@{ path = $relPath; exists = $true; length = $raw.Length; data = ($raw | ConvertFrom-Json) })
foreach ($candidate in $candidates) {
$key = [string]$candidate['key']
$compiled = @($candidate['patterns'] | ForEach-Object { [regex]::new($_, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) })
foreach ($rx in $compiled) {
if ($rx.IsMatch($raw)) {
$candidateBuckets[$key].Add([pscustomobject]@{
source = $relPath
source_type = "live_probe_json"
line_number = $null
string_index = $null
kind = "live_probe_json"
text = ($raw -replace "\s+", " ").Trim()
method_header = $null
class_header = $null
window = @()
})
break
}
}
}
} else {
$liveProbe.Add([pscustomobject]@{ path = $relPath; exists = $false; length = 0; data = $null })
}
}
$httpServerPath = Join-Path $binaryDirFull "HttpServerLib.dll"
$httpReflection = if (Test-Path -LiteralPath $httpServerPath -PathType Leaf) { Get-HttpServerReflection -Path $httpServerPath } else { [pscustomobject]@{ path = Get-RelativePath -Path $httpServerPath; ok = $false; error = "missing"; types = @() } }
$httpReflectionPath = Join-Path $outDir "httpserverlib-reflection.json"
$httpReflection | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $httpReflectionPath -Encoding UTF8
if ($httpReflection.ok) {
$reflectionText = ($httpReflection.types | ConvertTo-Json -Depth 8 -Compress)
foreach ($candidate in $candidates) {
$key = [string]$candidate['key']
$compiled = @($candidate['patterns'] | ForEach-Object { [regex]::new($_, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) })
foreach ($rx in $compiled) {
if ($rx.IsMatch($reflectionText)) {
$candidateBuckets[$key].Add([pscustomobject]@{
source = Get-RelativePath -Path $httpReflectionPath
source_type = "reflection"
line_number = $null
string_index = $null
kind = "reflection_metadata"
text = "HttpServerLib reflection exposes matching types/methods; see httpserverlib-reflection.json"
method_header = $null
class_header = $null
window = @()
})
break
}
}
}
}
$candidateResults = New-Object System.Collections.Generic.List[object]
foreach ($candidate in $candidates) {
$key = [string]$candidate['key']
$allEvidence = $candidateBuckets[$key].ToArray()
$evidenceForReport = @(
$allEvidence |
Sort-Object @{ Expression = { if ($_.source_type -eq "text_il") { 0 } elseif ($_.source_type -eq "reflection") { 1 } elseif ($_.source_type -eq "live_probe_json") { 2 } else { 3 } } }, source, line_number, string_index |
Select-Object -First $MaxEvidencePerCandidate
)
$classification = if ($allEvidence.Count -eq 0) { "not_found" } else { [string]$candidate['classification'] }
$candidateResults.Add([pscustomobject]@{
key = $key
display = $candidate['display']
category = $candidate['category']
classification = $classification
usable_for_broute_send = [bool]$candidate['usable_for_broute_send']
recommendation = $candidate['recommendation']
reason = $candidate['reason']
match_count = $allEvidence.Count
evidence = $evidenceForReport
})
}
$recommendation = [pscustomobject]@{
pursue_existing_ipc_or_plugin_first = $false
decision = "in_process_adapter_research"
fallback_boundary = "external_sidecar_preview_only_until_bridge_exists"
rationale = "Offline static evidence found no existing local IPC/plugin route that exposes send-message or send-file. HttpServerLib and plugin surfaces either require logged-in runtime state or are UI/framework-only; the named-pipe stub is empty."
}
$summary = [pscustomobject]@{
generated_at = (Get-Date).ToUniversalTime().ToString("o")
repository = $repo
il_dir = $IlDir
binary_dir = $BinaryDir
text_sources = $textSourceResults
binary_sources = $binarySourceResults
live_probe = $liveProbe
httpserverlib_reflection = Get-RelativePath -Path $httpReflectionPath
candidates = $candidateResults
recommendation = $recommendation
originals_copied = $false
originals_modified = $false
}
$jsonPath = Join-Path $outDir "bridge-map.json"
$summary | ConvertTo-Json -Depth 14 | Set-Content -LiteralPath $jsonPath -Encoding UTF8
$linesOut = New-Object System.Collections.Generic.List[string]
$linesOut.Add("# B-Route Bridge/IPC/Plugin Static Map")
$linesOut.Add("")
$linesOut.Add("Date: 2026-07-12")
$linesOut.Add("")
$linesOut.Add("## Scope")
$linesOut.Add("")
$linesOut.Add("This report uses offline IL, binary string metadata, reflection metadata for ``HttpServerLib.dll``, and returned live-probe JSON. It does not log in, open sockets, attach hooks, inject into a process, replay traffic, or mutate client binaries.")
$linesOut.Add("")
$linesOut.Add("Machine-readable evidence: ``$OutputDir/bridge-map.json``")
$linesOut.Add("Reflection metadata: ``$OutputDir/httpserverlib-reflection.json``")
$linesOut.Add("")
$linesOut.Add("## Recommendation")
$linesOut.Add("")
$linesOut.Add("| Question | Answer |")
$linesOut.Add("| --- | --- |")
$linesOut.Add("| Should B-route pursue existing IPC/plugin before an in-process adapter? | **No** |")
$linesOut.Add("| Offline decision | ``$($recommendation.decision)`` |")
$linesOut.Add("| External sidecar boundary | ``$($recommendation.fallback_boundary)`` |")
$linesOut.Add("| Reason | $($recommendation.rationale) |")
$linesOut.Add("")
$linesOut.Add("## Sources")
$linesOut.Add("")
$linesOut.Add("### Text/IL sources")
$linesOut.Add("")
$linesOut.Add("| Source | Role | Exists | Lines | Size bytes |")
$linesOut.Add("| --- | --- | --- | ---: | ---: |")
foreach ($sourceResult in $textSourceResults) {
$exists = if ($sourceResult.exists) { "yes" } else { "no" }
$sizeText = if ($null -ne $sourceResult.size_bytes) { [string]$sourceResult.size_bytes } else { "" }
$linesOut.Add("| ``$($sourceResult.path)`` | $($sourceResult.role) | $exists | $($sourceResult.line_count) | $sizeText |")
}
$linesOut.Add("")
$linesOut.Add("### Binary/string sources")
$linesOut.Add("")
$linesOut.Add("| Source | Role | Exists | Strings | Size bytes |")
$linesOut.Add("| --- | --- | --- | ---: | ---: |")
foreach ($sourceResult in $binarySourceResults) {
$exists = if ($sourceResult.exists) { "yes" } else { "no" }
$sizeText = if ($null -ne $sourceResult.size_bytes) { [string]$sourceResult.size_bytes } else { "" }
$linesOut.Add("| ``$($sourceResult.path)`` | $($sourceResult.role) | $exists | $($sourceResult.string_count) | $sizeText |")
}
$linesOut.Add("")
$linesOut.Add("## Candidate classification")
$linesOut.Add("")
$linesOut.Add("| Candidate | Category | Classification | Usable for B-route send | Recommendation | Matches |")
$linesOut.Add("| --- | --- | --- | --- | --- | ---: |")
foreach ($candidateResult in $candidateResults) {
$usable = if ($candidateResult.usable_for_broute_send) { "yes" } else { "no" }
$linesOut.Add("| ``$($candidateResult.display)`` | ``$($candidateResult.category)`` | ``$($candidateResult.classification)`` | $usable | ``$($candidateResult.recommendation)`` | $($candidateResult.match_count) |")
}
$linesOut.Add("")
$linesOut.Add("## Verified facts")
$linesOut.Add("")
$linesOut.Add("- ``frmLogin.StartWinServer`` constructs ``HTTPServerLib.HttpService`` on ``0.0.0.0`` and starts ``HttpServer.Start`` in a thread, but it depends on ``IMPPManager.UserInfo`` and ``ServiceManager.UnifiedAuthentication`` state.")
$linesOut.Add("- ``HttpServerLib.dll`` exposes route/module primitives such as ``HttpService.RegisterModule``, ``ServiceModule``, ``ServiceRoute``, ``RouteAttribute``, ``OnGet``, and ``OnPost``.")
$linesOut.Add("- Main client IL does not show a concrete ``RegisterModule`` call or send-message/send-file HTTP route registration.")
$linesOut.Add("- ``PluginLightAppMulProcessCommunication`` declares a ``NamedPipeServerStream`` field, but ``StartLightAppMultProssCommunication`` returns immediately.")
$linesOut.Add("- Returned live probe showed one remote TCP connection to port ``10088`` and a named pipe ``\\.\\pipe\\zfpinject-msg-server``; neither proves an iSphere send API.")
$linesOut.Add("- ``PluginManager``/``IMPlugin`` evidence is UI/app-store/context-menu oriented; no local assembly-load plugin bridge or send route was confirmed.")
$linesOut.Add("")
$linesOut.Add("## Candidate evidence")
foreach ($candidateResult in $candidateResults) {
$linesOut.Add("")
$linesOut.Add("### $($candidateResult.display)")
$linesOut.Add("")
$linesOut.Add("Classification: ``$($candidateResult.classification)``; usable for B-route send: ``$($candidateResult.usable_for_broute_send)``; matches: $($candidateResult.match_count).")
$linesOut.Add("")
$linesOut.Add("Reason: $($candidateResult.reason)")
if (@($candidateResult.evidence).Count -eq 0) {
$linesOut.Add("")
$linesOut.Add("No evidence found in configured offline sources.")
continue
}
$linesOut.Add("")
$linesOut.Add("| Source | Kind | Snippet | Context |")
$linesOut.Add("| --- | --- | --- | --- |")
foreach ($evidence in $candidateResult.evidence) {
$sourceLoc = if ($evidence.line_number) { "$($evidence.source):$($evidence.line_number)" } elseif ($null -ne $evidence.string_index) { "$($evidence.source)#str$($evidence.string_index)" } else { $evidence.source }
$method = if ($evidence.method_header) { "L$($evidence.method_header.line_number): $($evidence.method_header.text)" } else { "" }
$snippet = (($evidence.text -replace "\|", "\\|") -replace "`r?`n", " ")
if ($snippet.Length -gt 220) { $snippet = $snippet.Substring(0, 220) + "..." }
$method = ($method -replace "\|", "\\|")
$linesOut.Add("| ``$sourceLoc`` | ``$($evidence.kind)`` | ``$snippet`` | ``$method`` |")
}
}
$linesOut.Add("")
$linesOut.Add("## Decision")
$linesOut.Add("")
$linesOut.Add("No existing IPC/plugin/local HTTP bridge is confirmed usable for B-route text send or file send. The next B-route branch should be ``in_process_adapter_research``. Until that exists, an external sidecar can only do preview/probe work, while A-route/RPA remains backup-only.")
$linesOut.Add("")
$linesOut.Add("## Next work")
$linesOut.Add("")
$linesOut.Add("1. Update the B-route next plan and capability source matrix with ``in_process_adapter_research`` as the offline decision.")
$linesOut.Add("2. Create a separate plan before implementing any in-process adapter work.")
$linesOut.Add("3. Do not promote RPA beyond backup in this branch.")
($linesOut -join "`n") + "`n" | Set-Content -LiteralPath $reportFullPath -Encoding UTF8
$requiredNames = @("HttpServerLib", "NamedPipe", "Plugin", "IMPlatformClient.Web", "ProcessComm", "INetwork", "IOClientNetwork", "in_process_adapter_research")
$reportText = Get-Content -LiteralPath $reportFullPath -Raw
$missing = @($requiredNames | Where-Object { $reportText -notlike "*$_*" })
if ($missing.Count -gt 0) {
throw "Report missing required names: $($missing -join ', ')"
}
[pscustomobject]@{
ok = $true
json_path = $jsonPath
report_path = $reportFullPath
reflection_path = $httpReflectionPath
candidates = $candidateResults.Count
decision = $recommendation.decision
pursue_existing_ipc_or_plugin_first = $false
originals_copied = $false
originals_modified = $false
} | ConvertTo-Json -Depth 6