feat: add audited one-shot prepare gate

This commit is contained in:
2026-07-18 12:29:09 +09:00
parent 1302b1b92f
commit 5379511b8a
25 changed files with 6188 additions and 41 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,402 @@
#Requires -Version 5.1
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string] $SpecificationPath,
[Parameter(Mandatory = $true)]
[string] $OutputPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$utf8NoBom = [Text.UTF8Encoding]::new($false, $true)
$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
$orchestratorPath = Join-Path $PSScriptRoot 'Invoke-LegacyPgmPrepareGate.ps1'
$nodeHelperPath = Join-Path $PSScriptRoot 'Test-LegacyPgmPrepareGate.mjs'
$userProfilePath = [Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile)
$approvedNodePath = [IO.Path]::GetFullPath((Join-Path $userProfilePath '.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe'))
$expectedRemote = 'https://gitea.comtropy.synology.me/Wickedness/MBN_STOCK_WEBVIEW.git'
$expectedUrl = 'https://legacy-parity.mbn.local/index.html'
$approvedSceneDirectory = 'C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\bin\Debug\Cuts'
$approvedCutPath = Join-Path $approvedSceneDirectory '5001.t2s'
$approvedCutSha256 = '99CE3B689A42D8C42BEB09A86FA10C2D7C1AEF4F50D324D81276C1A1E4C4D8A7'
$approvedMsixPath = Join-Path $repositoryRoot 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\AppPackages\MBN_STOCK_WEBVIEW.LegacyParityApp_0.1.0.0_x64_Test\MBN_STOCK_WEBVIEW.LegacyParityApp_0.1.0.0_x64.msix'
$approvedMsixSha256 = '0E44F87FDD31852B7DDFCD6CF3A6325DA674F1F5A23AD39CB262A725AEB84367'
$approvedDatabaseConfigPath = Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) 'MBN_STOCK_WEBVIEW\Config\database.local.json'
$expectedMarketText = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('7L2U7Iqk7ZS8'))
$expectedStockName = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('7IK87ISx7KCE7J6Q'))
$expectedCutLabel = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('MeyXtO2MkOq4sOuzuF/tmITsnqzqsIA='))
$expectedGraphicType = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('MeyXtO2MkOq4sOuzuA=='))
$expectedSubtype = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('7ZiE7J6s6rCA'))
function Fail([string] $Message) {
throw [InvalidOperationException]::new($Message)
}
function FullPath([string] $Path, [string] $Name) {
if ([string]::IsNullOrWhiteSpace($Path) -or -not [IO.Path]::IsPathRooted($Path)) {
Fail "$Name must be an absolute path."
}
return [IO.Path]::GetFullPath($Path)
}
function SamePath([string] $Left, [string] $Right) {
return [string]::Equals(
[IO.Path]::GetFullPath($Left).TrimEnd('\'),
[IO.Path]::GetFullPath($Right).TrimEnd('\'),
[StringComparison]::OrdinalIgnoreCase)
}
function Assert-NoReparse([string] $Path, [string] $Name) {
$current = [IO.Path]::GetFullPath($Path)
while (-not [string]::IsNullOrWhiteSpace($current)) {
if ([IO.File]::Exists($current) -or [IO.Directory]::Exists($current)) {
if (([IO.File]::GetAttributes($current) -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
Fail "$Name contains a reparse point."
}
}
$parent = [IO.Directory]::GetParent($current)
if ($null -eq $parent) { break }
$current = $parent.FullName
}
}
function Read-Json([string] $Path, [string] $Name) {
$full = FullPath $Path $Name
if (-not [IO.File]::Exists($full)) { Fail "$Name does not exist." }
Assert-NoReparse $full $Name
$bytes = [IO.File]::ReadAllBytes($full)
try {
if ($bytes.Length -eq 0 -or $bytes.Length -gt 1MB) { Fail "$Name has an unsafe size." }
if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
Fail "$Name must be UTF-8 without BOM."
}
try { $text = $utf8NoBom.GetString($bytes) }
catch { Fail "$Name is not strict UTF-8." }
try { return $text | ConvertFrom-Json -ErrorAction Stop }
catch { Fail "$Name is not valid JSON." }
}
finally { [Array]::Clear($bytes, 0, $bytes.Length) }
}
function Require([object] $Object, [string] $Name, [string] $Context) {
if ($null -eq $Object -or -not ($Object.PSObject.Properties.Name -ccontains $Name)) {
Fail "$Context.$Name is required."
}
return $Object.$Name
}
function Assert-ExactProperties([object] $Object, [string[]] $Expected, [string] $Context) {
if ($null -eq $Object) { Fail "$Context is required." }
$actual = @($Object.PSObject.Properties.Name)
if ($actual.Count -ne $Expected.Count) { Fail "$Context property count is not exact." }
foreach ($name in $Expected) {
if (@($actual | Where-Object { $_ -ceq $name }).Count -ne 1) {
Fail "$Context property set is not exact: $name."
}
}
}
function Assert-JsonInt([object] $Value, [int] $Expected, [string] $Name) {
if ($Value -isnot [int] -or [int]$Value -ne $Expected) { Fail "$Name must be JSON integer $Expected." }
}
function Assert-JsonBool([object] $Value, [bool] $Expected, [string] $Name) {
if ($Value -isnot [bool] -or [bool]$Value -ne $Expected) { Fail "$Name must be JSON boolean $Expected." }
}
function Assert-JsonString([object] $Value, [string] $Expected, [string] $Name) {
if ($Value -isnot [string] -or [string]$Value -cne $Expected) { Fail "$Name must be the exact JSON string." }
}
function New-FilePin([string] $Path, [string] $Name) {
$full = FullPath $Path $Name
if (-not [IO.File]::Exists($full)) { Fail "$Name does not exist." }
Assert-NoReparse $full $Name
$item = Get-Item -LiteralPath $full -Force
return [ordered]@{
path = $full
length = [int64]$item.Length
sha256 = (Get-FileHash -LiteralPath $full -Algorithm SHA256).Hash
}
}
function Get-RuntimeTreePin([string] $Root) {
$fullRoot = FullPath $Root 'package.installLocation'
if (-not [IO.Directory]::Exists($fullRoot)) { Fail 'package.installLocation does not exist.' }
Assert-NoReparse $fullRoot 'package.installLocation'
foreach ($directory in [IO.Directory]::GetDirectories($fullRoot, '*', [IO.SearchOption]::AllDirectories)) {
Assert-NoReparse $directory 'package runtime directory'
}
$files = @([IO.Directory]::GetFiles($fullRoot, '*', [IO.SearchOption]::AllDirectories))
if ($files.Count -eq 0 -or $files.Count -gt 4096) { Fail 'The package runtime tree count is unsafe.' }
$relative = New-Object 'Collections.Generic.List[string]'
$rootPrefix = $fullRoot.TrimEnd('\') + '\'
foreach ($file in $files) {
Assert-NoReparse $file 'package runtime file'
if (-not $file.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase)) {
Fail 'A package runtime file escaped the install root.'
}
$name = $file.Substring($rootPrefix.Length).Replace('\', '/')
if ($name.Contains('|') -or $name.Contains("`r") -or $name.Contains("`n")) {
Fail 'A package runtime relative path cannot be represented safely.'
}
[void]$relative.Add($name)
}
$names = $relative.ToArray()
[Array]::Sort($names, [StringComparer]::Ordinal)
$builder = [Text.StringBuilder]::new()
foreach ($name in $names) {
$file = Join-Path $fullRoot $name.Replace('/', '\')
$item = Get-Item -LiteralPath $file -Force
$hash = (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash
[void]$builder.Append($name).Append('|').Append(([int64]$item.Length).ToString([Globalization.CultureInfo]::InvariantCulture)).Append('|').Append($hash).Append("`n")
}
$manifestBytes = $utf8NoBom.GetBytes($builder.ToString())
try {
$algorithm = [Security.Cryptography.SHA256]::Create()
try { $digest = ([BitConverter]::ToString($algorithm.ComputeHash($manifestBytes))).Replace('-', '') }
finally { $algorithm.Dispose() }
}
finally { [Array]::Clear($manifestBytes, 0, $manifestBytes.Length) }
return [ordered]@{ fileCount = $names.Length; manifestSha256 = $digest }
}
function Assert-GateConfig([string] $Path, [string] $SceneDirectory) {
$config = Read-Json $Path 'configuration.gate'
$names = @(
'mode','host','port','tcpMode','clientPort','outputChannel','layoutIndex',
'legacySceneFadeDuration','legacySceneBackgroundKind',
'legacySceneBackgroundAssetPath','legacySceneBackgroundVideoLoopCount',
'legacySceneBackgroundVideoLoopInfinite','legacyBackgroundDirectory',
'sceneDirectory','testProcessWindowTitlePattern','testSceneAllowlist',
'trustedLiveOutputEnabled','queueCapacity','connectTimeoutMilliseconds',
'operationTimeoutMilliseconds','disconnectTimeoutMilliseconds',
'processPollIntervalMilliseconds','reconnectDelayMilliseconds',
'maximumReconnectAttempts','reconnectEnabled',
'maximumAutomaticRefreshesPerTakeIn')
Assert-ExactProperties $config $names 'configuration.gate JSON'
if ($config.testSceneAllowlist -isnot [Array]) {
Fail 'configuration.gate.testSceneAllowlist must be a JSON array.'
}
$allowlist = @(Require $config 'testSceneAllowlist' 'configuration.gate JSON')
Assert-JsonString $config.mode 'DryRun' 'configuration.gate.mode'
Assert-JsonString $config.host '127.0.0.1' 'configuration.gate.host'
Assert-JsonInt $config.port 30001 'configuration.gate.port'
Assert-JsonInt $config.tcpMode 1 'configuration.gate.tcpMode'
Assert-JsonInt $config.clientPort 0 'configuration.gate.clientPort'
Assert-JsonInt $config.layoutIndex 10 'configuration.gate.layoutIndex'
Assert-JsonInt $config.legacySceneFadeDuration 6 'configuration.gate.legacySceneFadeDuration'
Assert-JsonString $config.legacySceneBackgroundKind 'None' 'configuration.gate.legacySceneBackgroundKind'
Assert-JsonInt $config.legacySceneBackgroundVideoLoopCount 2004 'configuration.gate.legacySceneBackgroundVideoLoopCount'
Assert-JsonBool $config.legacySceneBackgroundVideoLoopInfinite $true 'configuration.gate.legacySceneBackgroundVideoLoopInfinite'
Assert-JsonBool $config.trustedLiveOutputEnabled $true 'configuration.gate.trustedLiveOutputEnabled'
Assert-JsonInt $config.queueCapacity 64 'configuration.gate.queueCapacity'
Assert-JsonInt $config.connectTimeoutMilliseconds 5000 'configuration.gate.connectTimeoutMilliseconds'
Assert-JsonInt $config.operationTimeoutMilliseconds 5000 'configuration.gate.operationTimeoutMilliseconds'
Assert-JsonInt $config.disconnectTimeoutMilliseconds 3000 'configuration.gate.disconnectTimeoutMilliseconds'
Assert-JsonInt $config.processPollIntervalMilliseconds 1000 'configuration.gate.processPollIntervalMilliseconds'
Assert-JsonInt $config.reconnectDelayMilliseconds 1000 'configuration.gate.reconnectDelayMilliseconds'
Assert-JsonInt $config.maximumReconnectAttempts 0 'configuration.gate.maximumReconnectAttempts'
Assert-JsonBool $config.reconnectEnabled $false 'configuration.gate.reconnectEnabled'
Assert-JsonInt $config.maximumAutomaticRefreshesPerTakeIn 0 'configuration.gate.maximumAutomaticRefreshesPerTakeIn'
if (
$null -ne (Require $config 'outputChannel' 'configuration.gate JSON') -or
$null -ne (Require $config 'legacySceneBackgroundAssetPath' 'configuration.gate JSON') -or
$null -ne (Require $config 'legacyBackgroundDirectory' 'configuration.gate JSON') -or
-not (SamePath ([string](Require $config 'sceneDirectory' 'configuration.gate JSON')) $SceneDirectory) -or
$null -ne (Require $config 'testProcessWindowTitlePattern' 'configuration.gate JSON') -or
$allowlist.Count -ne 1 -or $allowlist[0] -isnot [string] -or [string]$allowlist[0] -cne '5001') {
Fail 'configuration.gate JSON is not the closed 5001-only persistent-DryRun configuration.'
}
}
$specificationPath = FullPath $SpecificationPath 'SpecificationPath'
$outputPath = FullPath $OutputPath 'OutputPath'
if ($outputPath.StartsWith($repositoryRoot.TrimEnd('\') + '\', [StringComparison]::OrdinalIgnoreCase)) {
Fail 'OutputPath must remain outside the Git repository so the execution checkout can be clean.'
}
if ([IO.File]::Exists($outputPath) -or [IO.Directory]::Exists($outputPath)) {
Fail 'OutputPath already exists; a frozen plan is never overwritten.'
}
if (SamePath $specificationPath $outputPath) { Fail 'SpecificationPath and OutputPath must differ.' }
if (-not [IO.File]::Exists($orchestratorPath) -or -not [IO.File]::Exists($nodeHelperPath)) {
Fail 'The committed Gate orchestrator and Node helper must both exist before sealing a plan.'
}
$spec = Read-Json $specificationPath 'SpecificationPath'
Assert-ExactProperties $spec @(
'schemaVersion','roundId','repository','package','automation','configuration',
'cut','k3d','pgm','webView') 'specification'
Assert-JsonInt (Require $spec 'schemaVersion' 'specification') 1 'specification.schemaVersion'
$roundId = [string](Require $spec 'roundId' 'specification')
if ($roundId -cnotmatch '^MBNWEB-[0-9]{8}-[A-Z0-9][A-Z0-9-]{0,31}$') { Fail 'roundId is invalid.' }
$repo = Require $spec 'repository' 'specification'
Assert-ExactProperties $repo @('path','branch','commit','upstream','remoteUrl') 'repository'
if (-not (SamePath ([string]$repo.path) $repositoryRoot) -or
[string]$repo.branch -cne 'main' -or [string]$repo.upstream -cne 'origin/main' -or
[string]$repo.remoteUrl -cne $expectedRemote -or
[string]$repo.commit -cnotmatch '^[0-9a-f]{40}$') { Fail 'repository pin is not exact.' }
$package = Require $spec 'package' 'specification'
Assert-ExactProperties $package @(
'name','familyName','fullName','publisher','version','architecture',
'applicationId','installLocation','executablePath','msixPath') 'package'
if ([string]$package.name -cne 'Wickedness.MBNStockWebView.LegacyParity' -or
[string]$package.familyName -cne 'Wickedness.MBNStockWebView.LegacyParity_qbv3jkvsn3aj0' -or
[string]$package.fullName -cne 'Wickedness.MBNStockWebView.LegacyParity_0.1.0.0_x64__qbv3jkvsn3aj0' -or
[string]$package.publisher -cne 'CN=Comtrophy' -or [string]$package.version -cne '0.1.0.0' -or
[string]$package.architecture -cne 'X64' -or [string]$package.applicationId -cne 'LegacyParityApp') {
Fail 'package identity is not the exact LegacyParity Release x64 package.'
}
$installLocation = FullPath ([string]$package.installLocation) 'package.installLocation'
$expectedInstallLocation = Join-Path $repositoryRoot 'src\MBN_STOCK_WEBVIEW.LegacyParityApp\bin\x64\Release\net8.0-windows10.0.19041.0\win-x64'
if (-not (SamePath $installLocation $expectedInstallLocation)) {
Fail 'package.installLocation is not the exact Release x64 output.'
}
$executablePath = FullPath ([string]$package.executablePath) 'package.executablePath'
if (-not (SamePath $executablePath (Join-Path $installLocation 'MBN_STOCK_WEBVIEW.LegacyParityApp.exe'))) {
Fail 'package executable is outside the exact install root.'
}
$msixPath = FullPath ([string]$package.msixPath) 'package.msixPath'
if (-not (SamePath $msixPath $approvedMsixPath)) {
Fail 'package.msixPath is not the exact Release x64 AppPackages MSIX.'
}
if ((Get-FileHash -LiteralPath $msixPath -Algorithm SHA256).Hash -cne $approvedMsixSha256) {
Fail 'The approved Release x64 MSIX SHA-256 changed.'
}
$automation = Require $spec 'automation' 'specification'
Assert-ExactProperties $automation @('nodePath') 'automation'
if (-not (SamePath ([string]$automation.nodePath) $approvedNodePath)) {
Fail 'automation.nodePath is not the approved bundled Node runtime.'
}
$configuration = Require $spec 'configuration' 'specification'
Assert-ExactProperties $configuration @('originalPath','gatePath','sceneDirectory') 'configuration'
$originalConfigPath = FullPath ([string]$configuration.originalPath) 'configuration.originalPath'
$gateConfigPath = FullPath ([string]$configuration.gatePath) 'configuration.gatePath'
$sceneDirectory = FullPath ([string]$configuration.sceneDirectory) 'configuration.sceneDirectory'
if (-not (SamePath $sceneDirectory $approvedSceneDirectory)) {
Fail 'configuration.sceneDirectory is not the approved read-only original Cuts root.'
}
if (SamePath $originalConfigPath $gateConfigPath) { Fail 'Original and Gate config paths must differ.' }
Assert-GateConfig $gateConfigPath $sceneDirectory
$cut = Require $spec 'cut' 'specification'
Assert-ExactProperties $cut @('path') 'cut'
$cutPath = FullPath ([string]$cut.path) 'cut.path'
if (-not (SamePath $cutPath $approvedCutPath)) { Fail 'cut.path is not the approved read-only original 5001.t2s.' }
if ((Get-FileHash -LiteralPath $cutPath -Algorithm SHA256).Hash -cne $approvedCutSha256) {
Fail 'The approved original 5001.t2s SHA-256 changed.'
}
$k3d = Require $spec 'k3d' 'specification'
Assert-ExactProperties $k3d @('nativePath','interopPath') 'k3d'
$pgm = Require $spec 'pgm' 'specification'
Assert-ExactProperties $pgm @(
'processId','startTimeUtc','processName','executablePath','windowTitle',
'listenerAddress','host','port') 'pgm'
if ($pgm.processId -isnot [int] -or [int]$pgm.processId -le 0 -or $pgm.port -isnot [int] -or [string]$pgm.startTimeUtc -cnotmatch '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{7}Z$' -or
[string]$pgm.windowTitle -cne 'PGM' -or [string]$pgm.listenerAddress -cne '0.0.0.0' -or
[string]$pgm.host -cne '127.0.0.1' -or [int]$pgm.port -ne 30001) { Fail 'PGM pin is not the exact current PGM listener contract.' }
$webView = Require $spec 'webView' 'specification'
Assert-ExactProperties $webView @('address','port','url') 'webView'
if ($webView.port -isnot [int] -or [string]$webView.address -cne '127.0.0.1' -or [int]$webView.port -lt 1024 -or [int]$webView.port -gt 65535 -or
[int]$webView.port -eq 30001 -or [string]$webView.url -cne $expectedUrl) { Fail 'WebView pin is invalid.' }
$plan = [ordered]@{
schemaVersion = 1
kind = 'LegacyPgmPrepareGatePlan'
roundId = $roundId
createdAtUtc = [DateTime]::UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss.fffffff'Z'", [Globalization.CultureInfo]::InvariantCulture)
repository = [ordered]@{
path = $repositoryRoot; branch = 'main'; commit = [string]$repo.commit
upstream = 'origin/main'; remoteUrl = $expectedRemote
}
package = [ordered]@{
name = [string]$package.name; familyName = [string]$package.familyName
fullName = [string]$package.fullName; publisher = [string]$package.publisher
version = [string]$package.version; architecture = 'X64'
applicationId = [string]$package.applicationId; installLocation = $installLocation
executable = New-FilePin $executablePath 'package executable'
msix = New-FilePin $msixPath 'package MSIX'
runtimeTree = Get-RuntimeTreePin $installLocation
}
automation = [ordered]@{
orchestrator = New-FilePin $orchestratorPath 'Gate orchestrator'
node = New-FilePin ([string]$automation.nodePath) 'Node runtime'
helper = New-FilePin $nodeHelperPath 'Node Gate helper'
}
configuration = [ordered]@{
original = New-FilePin $originalConfigPath 'original DryRun config'
gate = New-FilePin $gateConfigPath 'Gate DryRun config'
database = New-FilePin $approvedDatabaseConfigPath 'database config'
sceneDirectory = $sceneDirectory
}
cut = [ordered]@{
sceneCode = '5001'; pageNumberOneBased = 1
file = New-FilePin $cutPath '5001 cut'
}
selector = [ordered]@{
marketText = $expectedMarketText; groupCode = 'KOSPI'; stockName = $expectedStockName
stockCode = '005930'; cutLabel = $expectedCutLabel
graphicType = $expectedGraphicType; subtype = $expectedSubtype
normalizedSubtype = 'CURRENT'; sceneAlias = '5001'
}
k3d = [ordered]@{
native = New-FilePin ([string]$k3d.nativePath) 'K3D native binary'
interop = New-FilePin ([string]$k3d.interopPath) 'K3D interop assembly'
}
pgm = [ordered]@{
processId = [int]$pgm.processId; startTimeUtc = [string]$pgm.startTimeUtc
processName = [string]$pgm.processName
executable = New-FilePin ([string]$pgm.executablePath) 'PGM executable'
windowTitle = 'PGM'; listenerAddress = '0.0.0.0'; host = '127.0.0.1'; port = 30001
}
webView = [ordered]@{ address = '127.0.0.1'; port = [int]$webView.port; url = $expectedUrl }
approvalPolicy = [ordered]@{
requiresSeparateAuthorization = $true; maximumAgeSeconds = 900; maximumClockSkewSeconds = 5
}
budgets = [ordered]@{
connect = 1; prepare = 1; takeIn = 0; next = 0; pageNext = 0
takeOut = 0; databaseWrite = 0; appClose = 0; retry = 0
}
safety = [ordered]@{
automaticConnectOnly = $true; nodeHelperInvocationMaximum = 1
nodeHelperFailStopRequired = $true
packageWrapperFailStopRequired = $true
persistentGateConfigModeDryRun = $true; restoreOriginalBeforePrepare = $true
nativeOneShotPrepareGateRequired = $true
leaveApplicationAndSessionUntouched = $true; forceCloseForbidden = $true
automaticSessionCleanupForbiddenAfterLaunch = $true; retryForbidden = $true
}
}
$parent = [IO.Path]::GetDirectoryName($outputPath)
if ([string]::IsNullOrWhiteSpace($parent)) { Fail 'OutputPath parent is invalid.' }
[void][IO.Directory]::CreateDirectory($parent)
Assert-NoReparse $parent 'OutputPath parent'
$json = ($plan | ConvertTo-Json -Depth 12) + [Environment]::NewLine
$bytes = $utf8NoBom.GetBytes($json)
try {
$stream = [IO.File]::Open($outputPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::Read)
try { $stream.Write($bytes, 0, $bytes.Length); $stream.Flush($true) }
finally { $stream.Dispose() }
}
finally { [Array]::Clear($bytes, 0, $bytes.Length) }
[pscustomobject][ordered]@{
result = 'PLAN_CREATED_COMMAND_FREE'
roundId = $roundId
planPath = $outputPath
planLength = (Get-Item -LiteralPath $outputPath).Length
planSha256 = (Get-FileHash -LiteralPath $outputPath -Algorithm SHA256).Hash
authorizationCreated = $false
appCommandsIssued = 0
vendorCommandsIssued = 0
}

View File

@@ -0,0 +1,821 @@
import fs from "node:fs";
import net from "node:net";
import path from "node:path";
import { createHash } from "node:crypto";
const exactUrl = "https://legacy-parity.mbn.local/index.html";
const requiredSelector = Object.freeze({
searchText: "삼성전자",
marketText: "코스피",
stockName: "삼성전자",
cutLabel: "1열판기본_현재가",
graphicType: "1열판기본",
subtype: "현재가",
preparedCode: "5001",
pageNumberOneBased: 1
});
const acceptedArguments = new Set([
"port", "plan", "plan-sha256", "authorization", "authorization-sha256",
"output", "screenshot", "capability-sha256", "permit-pipe", "timeout-ms"
]);
const forbiddenCdpMethods = new Set([
"Browser.close", "Page.close", "Runtime.terminateExecution", "Target.closeTarget"
]);
class GateFailure extends Error {
constructor(category, message) {
super(`${category}: ${message}`);
this.name = "GateFailure";
this.category = category;
}
}
const known = message => { throw new GateFailure("KNOWN_FAILURE", message); };
const unknown = message => { throw new GateFailure("OUTCOME_UNKNOWN", message); };
const sha256 = bytes => createHash("sha256").update(bytes).digest("hex").toUpperCase();
const isSha256 = value => /^[0-9A-F]{64}$/u.test(String(value || ""));
const isSafeId = value => /^[A-Za-z0-9._:-]{1,128}$/u.test(String(value || ""));
function parseArguments(argv) {
if (argv.length === 0 || argv.length % 2 !== 0) known("Arguments must be --name value pairs.");
const values = new Map();
for (let index = 0; index < argv.length; index += 2) {
const raw = argv[index];
const value = argv[index + 1];
if (!raw?.startsWith("--") || !value) known("Every argument requires a value.");
const name = raw.slice(2);
if (!acceptedArguments.has(name) || values.has(name)) known(`Invalid argument --${name}.`);
values.set(name, value);
}
for (const name of [
"port", "plan", "plan-sha256", "authorization", "authorization-sha256",
"output", "screenshot", "capability-sha256", "permit-pipe"
]) {
if (!values.has(name)) known(`--${name} is required.`);
}
const port = Number(values.get("port"));
if (!Number.isInteger(port) || port < 1 || port > 65535) known("--port is invalid.");
const timeoutMilliseconds = values.has("timeout-ms") ? Number(values.get("timeout-ms")) : 90000;
if (!Number.isInteger(timeoutMilliseconds) || timeoutMilliseconds < 15000 ||
timeoutMilliseconds > 180000) known("--timeout-ms must be 15000 through 180000.");
const result = {
port,
timeoutMilliseconds,
planPath: path.resolve(values.get("plan")),
planSha256: String(values.get("plan-sha256")).toUpperCase(),
authorizationPath: path.resolve(values.get("authorization")),
authorizationSha256: String(values.get("authorization-sha256")).toUpperCase(),
outputPath: path.resolve(values.get("output")),
screenshotPath: path.resolve(values.get("screenshot")),
capabilitySha256: String(values.get("capability-sha256")).toUpperCase(),
permitPipeName: String(values.get("permit-pipe"))
};
for (const [name, candidate, extension] of [
["plan", result.planPath, ".json"],
["authorization", result.authorizationPath, ".json"],
["output", result.outputPath, ".json"],
["screenshot", result.screenshotPath, ".png"]
]) {
if (!path.isAbsolute(values.get(name)) || path.extname(candidate).toLowerCase() !== extension) {
known(`--${name} must be an absolute ${extension} path.`);
}
}
if (!isSha256(result.planSha256) || !isSha256(result.authorizationSha256) ||
!isSha256(result.capabilitySha256)) {
known("A supplied SHA-256 is invalid.");
}
if (!/^MBN_STOCK_WEBVIEW\.GateA\.Node\.[0-9A-F]{32}$/u.test(result.permitPipeName)) {
known("The parent-owned JIT permit pipe name is invalid.");
}
if (new Set([result.planPath, result.authorizationPath, result.outputPath, result.screenshotPath]
.map(value => value.toLowerCase())).size !== 4) known("Evidence paths must be distinct.");
if (fs.existsSync(result.outputPath) || fs.existsSync(result.screenshotPath)) {
known("Output evidence is never overwritten.");
}
return result;
}
function readFrozenJson(filePath, label, expectedHash = null) {
const bytes = fs.readFileSync(filePath);
const actualHash = sha256(bytes);
if (expectedHash && actualHash !== expectedHash) known(`${label} SHA-256 changed.`);
let value;
try { value = JSON.parse(bytes.toString("utf8")); }
catch { known(`${label} is not strict JSON.`); }
return { bytes, hash: actualHash, value };
}
const configuration = parseArguments(process.argv.slice(2));
if (Object.keys(process.env).some(name => name.startsWith("MBN_LEGACY_PGM_GATE_A_"))) {
known("Gate A bearer material must not be inherited through the Node environment.");
}
const startedAtUtc = new Date().toISOString();
const hardDeadline = Date.now() + configuration.timeoutMilliseconds;
const evidence = {
schemaVersion: 1,
result: "RUNNING",
startedAtUtc,
completedAtUtc: null,
planSha256: configuration.planSha256,
roundId: null,
target: { expectedUrl: exactUrl, port: configuration.port, id: null },
commandBudget: { connect: 1, prepare: 1, takeIn: 0, next: 0, pageNext: 0, takeOut: 0, databaseWrite: 0, appClose: 0, retry: 0 },
observed: {
prepareDispatchPossible: false,
prepareIssued: false,
busyObserved: false,
jitPermitRequested: false,
jitPermitReceived: false,
outboundTypes: [],
blocked: []
},
checkpoints: [],
screenshot: null,
finalState: null,
error: null
};
let socket = null;
let nextRpcId = 1;
const pending = new Map();
let prepareDispatchPossible = false;
let prepareIssued = false;
function assertDeadline(label) {
if (Date.now() >= hardDeadline) unknown(`Global Gate A deadline expired ${label}; no retry.`);
}
async function sleep(milliseconds) {
assertDeadline("before wait");
await new Promise(resolve => setTimeout(resolve, Math.min(milliseconds, hardDeadline - Date.now())));
assertDeadline("after wait");
}
function validateFrozenContract() {
const planFile = readFrozenJson(configuration.planPath, "plan", configuration.planSha256);
const plan = planFile.value;
if (plan?.schemaVersion !== 1 || plan?.kind !== "LegacyPgmPrepareGatePlan" ||
!isSafeId(plan.roundId) || plan?.webView?.url !== exactUrl ||
plan?.webView?.address !== "127.0.0.1" ||
plan?.webView?.port !== configuration.port ||
plan?.safety?.nodeHelperInvocationMaximum !== 1 ||
plan?.safety?.nodeHelperFailStopRequired !== true ||
plan?.safety?.packageWrapperFailStopRequired !== true ||
plan?.safety?.forceCloseForbidden !== true ||
plan?.safety?.automaticSessionCleanupForbiddenAfterLaunch !== true) {
known("Plan identity or target contract is invalid.");
}
const databasePin = plan?.configuration?.database;
if (typeof databasePin?.path !== "string" || !path.isAbsolute(databasePin.path) ||
!Number.isSafeInteger(databasePin?.length) || databasePin.length <= 0 ||
!isSha256(databasePin?.sha256)) {
known("Plan database configuration pin is invalid.");
}
const selectorMap = {
marketText: requiredSelector.marketText,
stockName: requiredSelector.stockName,
cutLabel: requiredSelector.cutLabel,
graphicType: requiredSelector.graphicType,
subtype: requiredSelector.subtype,
sceneAlias: requiredSelector.preparedCode
};
for (const [name, value] of Object.entries(selectorMap)) {
if (plan?.selector?.[name] !== value) known(`Plan selector ${name} is not exact.`);
}
if (plan?.selector?.groupCode !== "KOSPI" || plan?.selector?.stockCode !== "005930" ||
plan?.selector?.normalizedSubtype !== "CURRENT" ||
plan?.cut?.sceneCode !== "5001" ||
plan?.cut?.pageNumberOneBased !== requiredSelector.pageNumberOneBased) {
known("Plan selector identity is incomplete.");
}
const limits = plan?.budgets;
if (limits?.connect !== 1 || limits?.prepare !== 1 || limits?.takeIn !== 0 ||
limits?.next !== 0 || limits?.pageNext !== 0 || limits?.takeOut !== 0 ||
limits?.databaseWrite !== 0 || limits?.appClose !== 0 || limits?.retry !== 0) {
known("Plan command budget is not exact Gate A.");
}
const authorizationFile = readFrozenJson(
configuration.authorizationPath,
"authorization",
configuration.authorizationSha256);
const authorization = authorizationFile.value;
const approvedAt = Date.parse(authorization?.authorizedAtUtc || "");
const expiresAt = Date.parse(authorization?.expiresAtUtc || "");
if (authorization?.schemaVersion !== 1 ||
authorization?.kind !== "LegacyPgmPrepareGateAuthorization" ||
authorization?.roundId !== plan.roundId ||
String(authorization?.planSha256 || "").toUpperCase() !== configuration.planSha256 ||
authorization?.authorizationStatement !==
"I_AUTHORIZE_CURRENT_PGM_LIVE_CONNECT_ONCE_AND_PREPARE_5001_PAGE_1_ONCE" ||
authorization?.currentK3dLicenseValid !== true ||
authorization?.runningTornadoIsLicensedMainProgram !== true ||
authorization?.currentPgmApproved !== true ||
!Number.isFinite(approvedAt) || !Number.isFinite(expiresAt) ||
expiresAt <= approvedAt || expiresAt - approvedAt > 15 * 60 * 1000 ||
Date.now() < approvedAt - 5000 || Date.now() >= expiresAt - 5000) {
known("Gate A authorization is invalid, expired, or too near expiry.");
}
const authorizationBudget = authorization?.budgets;
if (authorizationBudget?.connect !== 1 || authorizationBudget?.prepare !== 1 ||
authorizationBudget?.takeIn !== 0 || authorizationBudget?.next !== 0 ||
authorizationBudget?.pageNext !== 0 || authorizationBudget?.takeOut !== 0 ||
authorizationBudget?.databaseWrite !== 0 || authorizationBudget?.appClose !== 0 ||
authorizationBudget?.retry !== 0) {
known("Authorization command budget is not exact Gate A.");
}
evidence.roundId = plan.roundId;
return { plan, authorization, authorizationHash: authorizationFile.hash, expiresAt };
}
async function discoverTarget() {
let lastError = null;
while (Date.now() < hardDeadline) {
try {
const response = await fetch(`http://127.0.0.1:${configuration.port}/json/list`, {
redirect: "error", signal: AbortSignal.timeout(1000)
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const targets = await response.json();
const exact = Array.isArray(targets) ? targets.filter(target =>
target?.type === "page" && target.url === exactUrl) : [];
if (exact.length > 1) known("More than one exact WebView target exists.");
if (exact.length === 1) return exact[0];
} catch (error) { lastError = String(error?.message || error); }
await sleep(100);
}
known(`Exact WebView target was not found${lastError ? `: ${lastError}` : "."}`);
}
function validateEndpoint(target) {
if (!target?.id || !target.webSocketDebuggerUrl) known("Target has no CDP endpoint.");
const endpoint = new URL(target.webSocketDebuggerUrl);
if (endpoint.protocol !== "ws:" || endpoint.hostname !== "127.0.0.1" ||
Number(endpoint.port) !== configuration.port ||
endpoint.pathname !== `/devtools/page/${target.id}`) known("CDP endpoint is not exact loopback.");
evidence.target.id = target.id;
return endpoint;
}
async function connect(endpoint) {
if (typeof WebSocket !== "function") known("Node WebSocket API is unavailable.");
socket = new WebSocket(endpoint.href);
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new GateFailure("OUTCOME_UNKNOWN", "CDP open timed out.")), 5000);
socket.addEventListener("open", () => { clearTimeout(timer); resolve(); }, { once: true });
socket.addEventListener("error", () => {
clearTimeout(timer); reject(new GateFailure("OUTCOME_UNKNOWN", "CDP open failed."));
}, { once: true });
});
socket.addEventListener("message", event => {
let message;
try { message = JSON.parse(String(event.data)); } catch { return; }
if (!Object.hasOwn(message, "id")) return;
const request = pending.get(message.id);
if (!request) return;
pending.delete(message.id);
clearTimeout(request.timer);
if (message.error) request.reject(new GateFailure("KNOWN_FAILURE", `${request.method}: ${message.error.message}`));
else request.resolve(message.result);
});
socket.addEventListener("close", () => {
for (const request of pending.values()) {
clearTimeout(request.timer);
request.reject(new GateFailure("OUTCOME_UNKNOWN", `${request.method} interrupted by CDP close.`));
}
pending.clear();
});
}
function rpc(method, params = {}, timeoutMilliseconds = 10000) {
assertDeadline(`before ${method}`);
if (forbiddenCdpMethods.has(method)) known(`Forbidden CDP method ${method}.`);
if (!socket || socket.readyState !== WebSocket.OPEN) unknown(`CDP is unavailable for ${method}.`);
const id = nextRpcId++;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(id);
reject(new GateFailure("OUTCOME_UNKNOWN", `${method} timed out; no retry.`));
}, Math.min(timeoutMilliseconds, hardDeadline - Date.now()));
pending.set(id, { method, resolve, reject, timer });
socket.send(JSON.stringify({ id, method, params }));
});
}
async function evaluate(expression) {
const response = await rpc("Runtime.evaluate", {
expression, awaitPromise: true, returnByValue: true, userGesture: false
});
if (response.exceptionDetails) known(response.exceptionDetails.exception?.description ||
response.exceptionDetails.text || "Runtime evaluation failed.");
return response.result?.value;
}
async function installGate(planSha256, capabilitySha256) {
const token = `legacy-pgm-prepare-${Date.now()}`;
const result = await evaluate(`(() => {
if (!window.chrome?.webview) throw new Error("WebView bridge unavailable");
if (globalThis.__legacyPgmPrepareGate) throw new Error("Gate already installed");
const allowedBeforePrepare = new Set([
"ready", "search-stocks", "select-stock", "cut-pointer-down", "drop-selected-cuts"
]);
const gate = {
version: 1,
token: ${JSON.stringify(token)},
planSha256: ${JSON.stringify(planSha256)},
capabilityHash: ${JSON.stringify(capabilitySha256)},
original: window.chrome.webview.postMessage,
wrapper: null,
listener: null,
states: [],
outbound: [],
blocked: [],
armed: false,
prepareCapability: null,
prepareCount: 0,
prepareIssuedAtUtc: null,
stateCountAtPrepare: null,
expectedPreRevision: null,
busyObservedAtPreRevision: false
};
const hashText = async value => {
const bytes = new TextEncoder().encode(value);
const digest = await crypto.subtle.digest("SHA-256", bytes);
return Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, "0"))
.join("").toUpperCase();
};
gate.listener = event => {
if (event.data?.type !== "state" || !event.data.payload) return;
const state = structuredClone(event.data.payload);
gate.states.push(state);
if (gate.states.length > 500) gate.states.shift();
if (gate.prepareCount === 1 && state?.isBusy === true &&
state?.revision === gate.expectedPreRevision) {
gate.busyObservedAtPreRevision = true;
}
};
gate.wrapper = message => {
const type = typeof message?.type === "string" ? message.type : null;
const payload = message?.payload;
const record = { type, atUtc: new Date().toISOString() };
gate.outbound.push(record);
let allowed = false;
if (type === "gate-a-prepare") {
const empty = payload && typeof payload === "object" &&
!Array.isArray(payload) && Object.keys(payload).length === 1 &&
typeof payload.capability === "string" &&
payload.capability === gate.prepareCapability;
allowed = gate.armed && gate.prepareCount === 0 && empty;
if (allowed) {
gate.armed = false;
gate.prepareCapability = null;
gate.prepareCount = 1;
gate.prepareIssuedAtUtc = record.atUtc;
gate.stateCountAtPrepare = gate.states.length;
}
} else {
allowed = !gate.armed && gate.prepareCount === 0 && allowedBeforePrepare.has(type);
}
if (!allowed) {
if (gate.armed) {
gate.armed = false;
gate.prepareCapability = null;
}
gate.blocked.push(record);
return;
}
return gate.original.call(window.chrome.webview, message);
};
gate.arm = async (supplied, expectedPreRevision) => {
if (gate.armed || gate.prepareCount !== 0 || gate.blocked.length !== 0 ||
!Number.isSafeInteger(expectedPreRevision) || expectedPreRevision < 0 ||
await hashText(String(supplied || "")) !== gate.capabilityHash) {
throw new Error("One-shot PREPARE capability rejected");
}
gate.expectedPreRevision = expectedPreRevision;
gate.prepareCapability = String(supplied);
gate.armed = true;
return { armed: true, stateCount: gate.states.length };
};
gate.inspect = () => ({
version: gate.version,
token: gate.token,
planSha256: gate.planSha256,
wrapperCurrent: window.chrome.webview.postMessage === gate.wrapper,
stateCount: gate.states.length,
states: gate.states.slice(),
outbound: gate.outbound.slice(),
blocked: gate.blocked.slice(),
armed: gate.armed,
prepareCount: gate.prepareCount,
prepareIssuedAtUtc: gate.prepareIssuedAtUtc,
stateCountAtPrepare: gate.stateCountAtPrepare,
expectedPreRevision: gate.expectedPreRevision,
busyObservedAtPreRevision: gate.busyObservedAtPreRevision
});
window.chrome.webview.postMessage = gate.wrapper;
if (window.chrome.webview.postMessage !== gate.wrapper) throw new Error("Gate identity failed");
window.chrome.webview.addEventListener("message", gate.listener);
globalThis.__legacyPgmPrepareGate = Object.freeze({
arm: gate.arm,
inspect: gate.inspect
});
window.chrome.webview.postMessage({ type: "ready", payload: {} });
return { token: gate.token, wrapperCurrent: window.chrome.webview.postMessage === gate.wrapper };
})()`);
if (result?.token !== token || result.wrapperCurrent !== true) unknown("Page gate did not install exactly.");
}
function projectedState(state) {
if (!state) return null;
const playout = state.playout || {};
return {
revision: state.revision, isBusy: state.isBusy, statusMessage: state.statusMessage,
statusKind: state.statusKind,
playlist: (state.playlist || []).map(row => ({
rowId: row.rowId, isEnabled: row.isEnabled, isActive: row.isActive,
marketText: row.marketText, stockName: row.stockName,
graphicType: row.graphicType, subtype: row.subtype, pageText: row.pageText
})),
playout: {
mode: playout.mode, connectionState: playout.connectionState, phase: playout.phase,
isConnected: playout.isConnected, isCommandAvailable: playout.isCommandAvailable,
liveTakeInAllowed: playout.liveTakeInAllowed,
isPlayCompletionPending: playout.isPlayCompletionPending,
isTakeOutCompletionPending: playout.isTakeOutCompletionPending,
outcomeUnknown: playout.outcomeUnknown, preparedCode: playout.preparedCode,
onAirCode: playout.onAirCode, currentEntryId: playout.currentEntryId,
pageIndexZeroBased: playout.pageIndexZeroBased, pageCount: playout.pageCount,
pageSize: playout.pageSize, itemCount: playout.itemCount,
currentPageItemCount: playout.currentPageItemCount, isLastPage: playout.isLastPage,
nextKind: playout.nextKind, isBusy: playout.isBusy,
refreshActive: playout.refreshActive, refreshCompletedCount: playout.refreshCompletedCount,
refreshMaximumCount: playout.refreshMaximumCount,
refreshLimitReached: playout.refreshLimitReached, message: playout.message
}
};
}
async function snapshot() {
return await evaluate(`(() => {
const gate = globalThis.__legacyPgmPrepareGate;
if (!gate || typeof gate.inspect !== "function") return null;
const audit = gate.inspect();
const state = audit.states.at(-1) || null;
return {
audit,
state,
dom: {
readyState: document.readyState,
url: location.href,
connection: (document.getElementById("playout-connection-state")?.textContent || "").trim(),
status: (document.getElementById("playout-status")?.textContent || "").trim(),
dialogHidden: document.getElementById("legacy-dialog")?.hidden === true,
dialogText: (document.getElementById("legacy-dialog-message")?.textContent || "").trim()
}
};
})()`);
}
function assertCommon(value, label) {
if (!value?.state?.playout || value.dom?.url !== exactUrl ||
value.dom.readyState !== "complete" || value.audit?.wrapperCurrent !== true) {
unknown(`Authoritative state or gate missing at ${label}.`);
}
if (value.audit.blocked.length !== 0) unknown(`An unapproved message was blocked at ${label}.`);
if (value.state.playout.outcomeUnknown === true) unknown(`Native outcome is unknown at ${label}.`);
if (value.state.playout.onAirCode != null || value.state.playout.phase === "program") {
unknown(`PROGRAM output was observed at ${label}.`);
}
if (value.dom.dialogHidden !== true) known(`Legacy dialog is visible at ${label}: ${value.dom.dialogText}`);
}
async function waitFor(label, predicate, timeoutMilliseconds = null) {
const deadline = Math.min(hardDeadline, Date.now() + (timeoutMilliseconds || configuration.timeoutMilliseconds));
let last = null;
while (Date.now() < deadline) {
last = await snapshot();
if (last?.state?.playout) assertCommon(last, label);
if (predicate(last)) return last;
await sleep(100);
}
unknown(`Timed out waiting for ${label}; no retry. Last=${JSON.stringify(projectedState(last?.state))}`);
}
function isConnectedIdle(value) {
const p = value?.state?.playout;
return p?.mode === "live" && p.connectionState === "connected" && p.phase === "idle" &&
p.isConnected === true && p.isCommandAvailable === true && p.liveTakeInAllowed === false &&
p.outcomeUnknown !== true && p.preparedCode == null && p.onAirCode == null &&
p.isBusy !== true && p.isPlayCompletionPending !== true &&
p.isTakeOutCompletionPending !== true && p.refreshActive !== true &&
value.state.isBusy !== true;
}
function exactPlaylist(state) {
const rows = state?.playlist || [];
return rows.length === 1 && rows[0].isEnabled === true && rows[0].isActive === true &&
rows[0].marketText === requiredSelector.marketText &&
rows[0].stockName === requiredSelector.stockName &&
rows[0].graphicType === requiredSelector.graphicType &&
rows[0].subtype === requiredSelector.subtype && rows[0].pageText === "1/1";
}
function assertExactOutbound(audit, expectPrepare) {
const types = audit?.outbound?.map(item => item.type) || [];
const count = type => types.filter(value => value === type).length;
const selectedCount = count("select-stock");
if (count("ready") !== 1 || count("search-stocks") !== 1 ||
selectedCount > 1 || count("cut-pointer-down") !== 1 ||
count("drop-selected-cuts") !== 1 ||
count("gate-a-prepare") !== (expectPrepare ? 1 : 0) ||
audit?.blocked?.length !== 0 ||
audit?.prepareCount !== (expectPrepare ? 1 : 0)) {
unknown("Gate A outbound ledger counts are not exact.");
}
const expected = ["ready", "search-stocks"];
if (selectedCount === 1) expected.push("select-stock");
expected.push("cut-pointer-down", "drop-selected-cuts");
if (expectPrepare) expected.push("gate-a-prepare");
if (types.length !== expected.length ||
types.some((type, index) => type !== expected[index])) {
unknown(`Gate A outbound ledger order is not exact: ${JSON.stringify(types)}`);
}
}
function isPrepared5001(value) {
const p = value?.state?.playout;
const row = value?.state?.playlist?.[0];
return exactPlaylist(value?.state) && p?.mode === "live" &&
p.connectionState === "connected" && p.phase === "prepared" && p.isConnected === true &&
p.isCommandAvailable === true && p.liveTakeInAllowed === false && p.outcomeUnknown !== true &&
p.preparedCode === "5001" && p.onAirCode == null && p.currentEntryId === row?.rowId &&
p.pageIndexZeroBased === 0 && p.pageCount === 1 && p.itemCount === 1 &&
p.currentPageItemCount === 1 && p.isLastPage === true &&
String(p.nextKind).toLowerCase() === "endofplaylist" &&
p.isBusy !== true && p.isPlayCompletionPending !== true &&
p.isTakeOutCompletionPending !== true && p.refreshActive !== true &&
value.state.isBusy !== true;
}
async function click(expression, label) {
const geometry = await evaluate(`(() => {
const element = ${expression};
if (!element || element.disabled || element.getAttribute("aria-disabled") === "true") return null;
const r = element.getBoundingClientRect();
if (r.width <= 0 || r.height <= 0) return null;
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
})()`);
if (!geometry) known(`${label} is unavailable.`);
await rpc("Input.dispatchMouseEvent", { type: "mouseMoved", ...geometry });
await rpc("Input.dispatchMouseEvent", {
type: "mousePressed", ...geometry, button: "left", buttons: 1, clickCount: 1
});
await rpc("Input.dispatchMouseEvent", {
type: "mouseReleased", ...geometry, button: "left", buttons: 0, clickCount: 1
});
}
async function configurePlaylist() {
await evaluate(`(() => {
const input = document.getElementById("stock-search");
if (!input) throw new Error("stock search unavailable");
input.value = ${JSON.stringify(requiredSelector.searchText)};
})()`);
await click('document.getElementById("stock-search-button")', "stock search button");
let current = await waitFor("exact stock search", value => {
const state = value?.state;
return state?.isBusy === false && state.searchText === requiredSelector.searchText &&
(state.searchResults || []).filter(row => row.displayName === requiredSelector.stockName).length === 1;
}, 60000);
const exact = current.state.searchResults.filter(row => row.displayName === requiredSelector.stockName)[0];
if (current.state.selectedStockIndex !== exact.index) {
await click(`document.querySelector('#stock-results button[data-result-index="${exact.index}"]')`,
"exact stock result");
current = await waitFor("exact stock selection", value =>
value?.state?.selectedStockIndex === exact.index && value.state.isBusy === false, 15000);
}
const geometry = await evaluate(`(() => {
const state = globalThis.__legacyPgmPrepareGate.inspect().states.at(-1);
const cut = (state?.cutRows || []).find(row => row.rawLabel ===
${JSON.stringify(requiredSelector.cutLabel)} && row.isSeparator !== true);
if (!cut) return null;
const source = document.querySelector('#cut-list .cut-row[data-physical-index="' +
String(cut.physicalIndex) + '"]');
const target = document.getElementById("playlist-drop-zone");
if (!source || !target) return null;
const a = source.getBoundingClientRect();
const b = target.getBoundingClientRect();
return {
physicalIndex: cut.physicalIndex,
source: { x: a.left + a.width / 2, y: a.top + a.height / 2 },
threshold: { x: a.left + a.width / 2 + 20, y: a.top + a.height / 2 + 20 },
target: { x: b.left + b.width / 2, y: b.top + Math.min(80, b.height / 2) }
};
})()`);
if (!geometry) known("Exact 5001 cut row or drop target is unavailable.");
await rpc("Input.dispatchMouseEvent", { type: "mouseMoved", ...geometry.source });
await rpc("Input.dispatchMouseEvent", {
type: "mousePressed", ...geometry.source, button: "left", buttons: 1, clickCount: 1
});
await rpc("Input.dispatchMouseEvent", {
type: "mouseMoved", ...geometry.threshold, button: "left", buttons: 1
});
await rpc("Input.dispatchMouseEvent", {
type: "mouseMoved", ...geometry.target, button: "left", buttons: 1
});
await rpc("Input.dispatchMouseEvent", {
type: "mouseReleased", ...geometry.target, button: "left", buttons: 0, clickCount: 1
});
current = await waitFor("exact 5001 playlist", value =>
value?.state?.isBusy === false && exactPlaylist(value.state), 30000);
assertExactOutbound(current.audit, false);
evidence.checkpoints.push({ name: "playlist-5001", state: projectedState(current.state) });
return current;
}
async function receiveJitCapability(expiresAt) {
if (Date.now() >= expiresAt - 5000) known("Authorization is too near expiry before JIT permit.");
evidence.observed.jitPermitRequested = true;
const remaining = Math.min(15000, hardDeadline - Date.now(), expiresAt - 5000 - Date.now());
if (remaining <= 0) known("No safe time remains for the JIT permit.");
const pipePath = `\\\\.\\pipe\\${configuration.permitPipeName}`;
const capability = await new Promise((resolve, reject) => {
let settled = false;
let total = 0;
const chunks = [];
const client = net.createConnection(pipePath);
const finish = (error, value = null) => {
if (settled) return;
settled = true;
clearTimeout(timer);
client.destroy();
if (error) reject(error);
else resolve(value);
};
const timer = setTimeout(() => finish(new GateFailure(
"KNOWN_FAILURE", "The parent did not grant the one-shot JIT permit.")), remaining);
client.on("data", chunk => {
total += chunk.length;
if (total > 64) {
finish(new GateFailure("KNOWN_FAILURE", "The JIT permit payload length is invalid."));
return;
}
chunks.push(chunk);
});
client.on("end", () => {
const bytes = Buffer.concat(chunks, total);
try {
const value = bytes.toString("ascii");
if (bytes.length !== 64 || !/^[0-9A-F]{64}$/u.test(value) ||
sha256(bytes) !== configuration.capabilitySha256) {
finish(new GateFailure("KNOWN_FAILURE", "The JIT permit did not match the sealed capability hash."));
return;
}
finish(null, value);
} finally {
bytes.fill(0);
for (const chunk of chunks) chunk.fill(0);
}
});
client.on("error", () => finish(new GateFailure(
"KNOWN_FAILURE", "The parent-owned JIT permit pipe closed without authority.")));
});
evidence.observed.jitPermitReceived = true;
return capability;
}
async function prepareOnce(expiresAt) {
if (Date.now() >= expiresAt - 5000) known("Authorization is too near expiry before PREPARE.");
let before = await snapshot();
assertCommon(before, "PREPARE boundary");
if (!isConnectedIdle(before) || !exactPlaylist(before.state)) known("PREPARE boundary is not exact IDLE 5001.");
const preRevision = before.state.revision;
let capability = await receiveJitCapability(expiresAt);
before = await snapshot();
assertCommon(before, "post-permit PREPARE boundary");
if (before.state.revision !== preRevision || !isConnectedIdle(before) ||
!exactPlaylist(before.state)) {
capability = null;
known("The exact PREPARE boundary changed while the parent granted the JIT permit.");
}
const armed = await evaluate(`globalThis.__legacyPgmPrepareGate.arm(
${JSON.stringify(capability)}, ${JSON.stringify(preRevision)})`);
if (armed?.armed !== true) unknown("PREPARE permit did not arm.");
if (Date.now() >= expiresAt - 5000) known("Authorization expired at PREPARE boundary.");
prepareDispatchPossible = true;
evidence.observed.prepareDispatchPossible = true;
const dispatched = await evaluate(`(() => {
if (!globalThis.__legacyPgmPrepareGate) throw new Error("PREPARE gate unavailable");
window.chrome.webview.postMessage({
type: "gate-a-prepare",
payload: { capability: ${JSON.stringify(capability)} }
});
return true;
})()`);
capability = null;
if (dispatched !== true) unknown("PREPARE bridge dispatch did not return exactly.");
prepareIssued = true;
evidence.observed.prepareIssued = true;
let current;
let busyObserved = false;
while (Date.now() < hardDeadline) {
current = await snapshot();
assertCommon(current, "PREPARE observation");
if (current.audit.prepareCount !== 1) unknown("PREPARE one-shot ledger changed.");
if (current.audit.busyObservedAtPreRevision === true) {
busyObserved = true;
evidence.observed.busyObserved = true;
}
if (busyObserved && isPrepared5001(current) && current.state.revision > preRevision) {
evidence.checkpoints.push({ name: "prepared-5001", state: projectedState(current.state) });
return current;
}
const p = current.state.playout;
if (busyObserved && current.state.isBusy === false && current.state.revision > preRevision &&
p.phase === "idle" && p.preparedCode == null && p.onAirCode == null &&
p.outcomeUnknown !== true && String(current.state.statusKind).toLowerCase() === "error" &&
String(current.state.statusMessage || "").length > 0) {
throw new GateFailure("KNOWN_FAILURE", `PREPARE rejected: ${current.state.statusMessage}`);
}
await sleep(100);
}
unknown("PREPARE outcome timed out; no retry or cleanup command was sent.");
}
async function captureScreenshot() {
const response = await rpc("Page.captureScreenshot", {
format: "png", fromSurface: true, captureBeyondViewport: false
}, 15000);
if (!response?.data) known("Screenshot data is unavailable.");
const bytes = Buffer.from(response.data, "base64");
const pngSignature = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
if (bytes.length < 1024 || !bytes.subarray(0, pngSignature.length).equals(pngSignature)) {
unknown("Screenshot is not a nontrivial PNG image.");
}
fs.mkdirSync(path.dirname(configuration.screenshotPath), { recursive: true });
fs.writeFileSync(configuration.screenshotPath, bytes, { flag: "wx" });
evidence.screenshot = {
path: configuration.screenshotPath, bytes: bytes.length, sha256: sha256(bytes)
};
}
async function execute() {
const contract = validateFrozenContract();
evidence.authorizationSha256 = contract.authorizationHash;
const target = await discoverTarget();
await connect(validateEndpoint(target));
await rpc("Runtime.enable");
await rpc("Page.enable");
await installGate(configuration.planSha256, configuration.capabilitySha256);
const connected = await waitFor("Live connected IDLE", value => isConnectedIdle(value), 60000);
if ((connected.state.playlist || []).length !== 0) known("Gate A requires an empty playlist.");
evidence.checkpoints.push({ name: "connected-idle", state: projectedState(connected.state) });
await configurePlaylist();
const prepared = await prepareOnce(contract.expiresAt);
assertExactOutbound(prepared.audit, true);
await captureScreenshot();
const final = await snapshot();
assertCommon(final, "post-screenshot final verification");
assertExactOutbound(final.audit, true);
if (final.audit.busyObservedAtPreRevision !== true || !isPrepared5001(final)) {
unknown("Post-screenshot state is not the exact prepared 5001 result.");
}
evidence.finalState = projectedState(final.state);
evidence.observed.outboundTypes = final.audit.outbound.map(item => item.type);
evidence.observed.blocked = final.audit.blocked;
evidence.result = "PASS_PREPARED";
}
try {
await execute();
} catch (error) {
let category = error instanceof GateFailure ? error.category : "HARNESS_ERROR";
if (socket?.readyState === WebSocket.OPEN) {
try {
const final = await snapshot();
evidence.finalState = projectedState(final?.state);
evidence.observed.outboundTypes = final?.audit?.outbound?.map(item => item.type) || [];
evidence.observed.blocked = final?.audit?.blocked || [];
if (final?.audit?.prepareCount === 1) {
prepareIssued = true;
evidence.observed.prepareIssued = true;
}
if (!fs.existsSync(configuration.screenshotPath)) await captureScreenshot();
} catch (captureError) {
evidence.captureError = String(captureError?.message || captureError);
}
}
const isExactKnownRejection = prepareIssued && category === "KNOWN_FAILURE" &&
String(error?.message || "").includes("PREPARE rejected:");
if (prepareDispatchPossible && !isExactKnownRejection) category = "OUTCOME_UNKNOWN";
evidence.result = category;
evidence.error = { name: error?.name || "Error", message: String(error?.message || error) };
process.exitCode = category === "KNOWN_FAILURE" ? 2 : 3;
} finally {
evidence.completedAtUtc = new Date().toISOString();
fs.mkdirSync(path.dirname(configuration.outputPath), { recursive: true });
fs.writeFileSync(configuration.outputPath, JSON.stringify(evidence, null, 2) + "\n", {
encoding: "utf8", flag: "wx"
});
try { socket?.close(); } catch { /* The page and its installed guard stay open. */ }
}