Files
lserp_cs_6.0/插件库/Lskj.AgentBridge/Deployment/Invoke-WorkflowWriteCaseCapture.ps1
2026-08-14 14:28:28 +08:00

1379 lines
56 KiB
PowerShell

[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidatePattern('^[a-z0-9_.-]{1,128}$')]
[string]$CaseCode,
[Parameter(Mandatory = $true)]
[ValidatePattern('^[A-Za-z0-9_.:-]{1,128}$')]
[string]$CommandName,
[Parameter(Mandatory = $true)][string]$CommandInputFile,
[Parameter(Mandatory = $true)][string]$OutputPath,
[Parameter(Mandatory = $true)][string]$VerifierCliPath,
[Parameter(Mandatory = $true)][string]$RuntimeCliPath,
[Parameter(Mandatory = $true)][string]$UatAuthorizationFile,
[Parameter(Mandatory = $true)][string]$UatTokenVaultPath,
[Parameter(Mandatory = $true)]
[ValidatePattern('^[A-Fa-f0-9]{64}$')]
[string]$ExpectedUatAuthorizationSha256,
[Parameter(Mandatory = $true)]
[ValidatePattern('^[A-Fa-f0-9]{64}$')]
[string]$ExpectedVerifierCliSha256,
[Parameter(Mandatory = $true)]
[ValidatePattern('^[0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,4}$')]
[string]$ExpectedRuntimeCliVersion,
[Parameter(Mandatory = $true)]
[ValidatePattern('^[A-Fa-f0-9]{64}$')]
[string]$ExpectedRuntimeCliSha256,
[Parameter(Mandatory = $true)]
[ValidatePattern('^[A-Fa-f0-9]{40}$')]
[string]$ExpectedVerifierSignerThumbprint,
[Parameter(Mandatory = $true)]
[ValidatePattern('^[A-Fa-f0-9]{40}$')]
[string]$ExpectedRuntimeSignerThumbprint,
[Parameter(Mandatory = $true)]
[ValidateRange(1, 2147483647)]
[int]$ErpProcessId,
[switch]$Execute,
[Security.SecureString]$IdempotencyKey,
[string]$IdempotencyKeyFile = '',
[switch]$PauseAfterPlanForOperatorStaging,
[switch]$NonInteractive,
[ValidateRange(-1, 1000)]
[int]$BusinessMutationCount = -1,
[Nullable[bool]]$NativeConfirmationObserved = $null,
[ValidateRange(-1, 1000)]
[int]$AuditEventCount = -1,
[Nullable[bool]]$SourceDocumentWritePayloadBound = $null,
[ValidateRange(-1, 1000)]
[int]$SourceDocumentAuditCount = -1,
[string]$CorrelatedAuditOutputPath = '',
[string]$RestrictedWorkingRoot = '',
[ValidateRange(1000, 300000)]
[int]$BridgeTimeoutMilliseconds = 180000
)
Set-StrictMode -Version 2.0
$ErrorActionPreference = 'Stop'
if ($PSVersionTable.PSVersion -lt [Version]'5.1' -or
[string]$PSVersionTable.PSEdition -ne 'Desktop' -or
[string]::IsNullOrWhiteSpace($env:SystemRoot)) {
throw 'workflow_write_case_capture_failed:windows_powershell_51_required'
}
$windowsIdentity = [Security.Principal.WindowsIdentity]::GetCurrent()
$windowsPrincipal = [Security.Principal.WindowsPrincipal]::new($windowsIdentity)
if (-not $windowsPrincipal.IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'workflow_write_case_capture_failed:elevated_operator_required'
}
$utf8 = [Text.UTF8Encoding]::new($false, $true)
$maximumResponseCharacters = 4 * 1024 * 1024
$rawDirectory = $null
$idempotencyPlain = $null
$uatToken = $null
$uatAuditTokenSha256 = $null
$verifierCliLock = $null
$runtimeCliLock = $null
$uatAuthorizationLock = $null
$uatTokenVaultLock = $null
$summaryJson = $null
$stagedOutputs = New-Object System.Collections.Generic.List[string]
$publishedOutputs = New-Object System.Collections.Generic.List[string]
function Throw-CaptureError([string]$Code) {
throw ("workflow_write_case_capture_failed:" + $Code)
}
function Assert-NoReparseDirectoryChain([string]$Directory, [string]$Code) {
$current = [IO.DirectoryInfo]::new([IO.Path]::GetFullPath($Directory))
while ($null -ne $current) {
if (-not $current.Exists -or
(($current.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
Throw-CaptureError $Code
}
$current = $current.Parent
}
}
function Resolve-RegularFile(
[string]$Path,
[long]$MaximumBytes,
[string]$Code
) {
try {
$full = [IO.Path]::GetFullPath($Path)
if (-not [IO.File]::Exists($full)) { Throw-CaptureError $Code }
$item = Get-Item -LiteralPath $full -Force
if ($item.Length -le 0 -or $item.Length -gt $MaximumBytes -or
(($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
Throw-CaptureError $Code
}
Assert-NoReparseDirectoryChain ([IO.Path]::GetDirectoryName($full)) $Code
return $full
}
catch {
if ($_.Exception.Message.StartsWith('workflow_write_case_capture_failed:')) { throw }
Throw-CaptureError $Code
}
}
function Resolve-NewJsonPath([string]$Path, [string]$Code) {
try {
$full = [IO.Path]::GetFullPath($Path)
if ([IO.Path]::GetExtension($full) -ine '.json' -or
[IO.File]::Exists($full) -or [IO.Directory]::Exists($full)) {
Throw-CaptureError $Code
}
$directory = [IO.Path]::GetDirectoryName($full)
Assert-NoReparseDirectoryChain $directory $Code
return $full
}
catch {
if ($_.Exception.Message.StartsWith('workflow_write_case_capture_failed:')) { throw }
Throw-CaptureError $Code
}
}
function New-RestrictedDirectory([string]$Root) {
$path = $null
try {
$fullRoot = [IO.Path]::GetFullPath($Root)
[IO.Directory]::CreateDirectory($fullRoot) | Out-Null
Assert-NoReparseDirectoryChain $fullRoot 'restricted_working_root_invalid'
$path = Join-Path $fullRoot ('case-' + [Guid]::NewGuid().ToString('N'))
[IO.Directory]::CreateDirectory($path) | Out-Null
$currentUser = [Security.Principal.WindowsIdentity]::GetCurrent().User
$localSystem = [Security.Principal.SecurityIdentifier]::new(
[Security.Principal.WellKnownSidType]::LocalSystemSid,
$null)
$security = New-Object Security.AccessControl.DirectorySecurity
$security.SetOwner($currentUser)
$security.SetAccessRuleProtection($true, $false)
$inheritance = [Security.AccessControl.InheritanceFlags]::ContainerInherit -bor
[Security.AccessControl.InheritanceFlags]::ObjectInherit
$propagation = [Security.AccessControl.PropagationFlags]::None
$allow = [Security.AccessControl.AccessControlType]::Allow
foreach ($identity in @($currentUser, $localSystem)) {
$rule = [Security.AccessControl.FileSystemAccessRule]::new(
$identity,
[Security.AccessControl.FileSystemRights]::FullControl,
$inheritance,
$propagation,
$allow)
$security.AddAccessRule($rule)
}
[IO.Directory]::SetAccessControl($path, $security)
$verified = [IO.Directory]::GetAccessControl($path)
if (-not $verified.AreAccessRulesProtected) {
Throw-CaptureError 'restricted_working_acl_invalid'
}
return $path
}
catch {
if ($null -ne $path -and [IO.Directory]::Exists($path)) {
try { [IO.Directory]::Delete($path, $true) } catch { }
}
if ($_.Exception.Message.StartsWith('workflow_write_case_capture_failed:')) { throw }
Throw-CaptureError 'restricted_working_acl_invalid'
}
}
function ConvertTo-WindowsProcessArgument([string]$Value) {
if ($null -eq $Value -or $Value.Length -eq 0) { return '""' }
if (-not [Text.RegularExpressions.Regex]::IsMatch($Value, '[\s"]')) {
return $Value
}
$builder = New-Object Text.StringBuilder
[void]$builder.Append([char]34)
$slashes = 0
foreach ($character in $Value.ToCharArray()) {
if ([int]$character -eq 92) {
$slashes++
continue
}
if ([int]$character -eq 34) {
for ($index = 0; $index -lt (($slashes * 2) + 1); $index++) {
[void]$builder.Append([char]92)
}
[void]$builder.Append([char]34)
}
else {
for ($index = 0; $index -lt $slashes; $index++) {
[void]$builder.Append([char]92)
}
[void]$builder.Append($character)
}
$slashes = 0
}
for ($index = 0; $index -lt ($slashes * 2); $index++) {
[void]$builder.Append([char]92)
}
[void]$builder.Append([char]34)
return $builder.ToString()
}
function Test-ExactProperties([object]$Value, [string[]]$Expected) {
if ($null -eq $Value) { return $false }
$names = @($Value.PSObject.Properties | ForEach-Object { $_.Name })
if ($names.Count -ne $Expected.Count) { return $false }
foreach ($name in $Expected) {
if ($names -cnotcontains $name) { return $false }
}
return $true
}
function Get-Sha256Hex([byte[]]$Bytes) {
$sha = [Security.Cryptography.SHA256]::Create()
try {
return ([BitConverter]::ToString(
$sha.ComputeHash($Bytes))).Replace('-', '').ToLowerInvariant()
}
finally { $sha.Dispose() }
}
function Assert-RestrictedVaultAcl([string]$Path) {
try {
$sections = [Security.AccessControl.AccessControlSections]::All
$acl = [IO.File]::GetAccessControl($Path, $sections)
$owner = $acl.GetOwner(
[Security.Principal.SecurityIdentifier]).Value
$currentSid = $windowsIdentity.User.Value
$systemSid = [Security.Principal.SecurityIdentifier]::new(
[Security.Principal.WellKnownSidType]::LocalSystemSid,
$null).Value
if (-not $acl.AreAccessRulesProtected -or $owner -cne $currentSid) {
Throw-CaptureError 'uat_token_vault_acl_invalid'
}
$rules = @($acl.GetAccessRules(
$true,
$true,
[Security.Principal.SecurityIdentifier]))
if ($rules.Count -ne 2) {
Throw-CaptureError 'uat_token_vault_acl_invalid'
}
$seen = @{}
foreach ($rule in $rules) {
$sid = [string]$rule.IdentityReference.Value
if ($rule.IsInherited -or
$rule.AccessControlType -ne
[Security.AccessControl.AccessControlType]::Allow -or
($sid -cne $currentSid -and $sid -cne $systemSid) -or
(($rule.FileSystemRights -band
[Security.AccessControl.FileSystemRights]::FullControl) -ne
[Security.AccessControl.FileSystemRights]::FullControl) -or
$seen.ContainsKey($sid)) {
Throw-CaptureError 'uat_token_vault_acl_invalid'
}
$seen[$sid] = $true
}
if (-not $seen.ContainsKey($currentSid) -or
-not $seen.ContainsKey($systemSid)) {
Throw-CaptureError 'uat_token_vault_acl_invalid'
}
$sddl = $acl.GetSecurityDescriptorSddlForm($sections)
if ($sddl -cnotmatch 'S:.*\(ML;;NW;;;HI\)') {
Throw-CaptureError 'uat_token_vault_integrity_invalid'
}
}
catch {
if ($_.Exception.Message.StartsWith(
'workflow_write_case_capture_failed:')) { throw }
Throw-CaptureError 'uat_token_vault_acl_invalid'
}
}
function Get-PreparationCommand(
[string]$CaptureCaseCode,
[string]$TargetCommandName
) {
if ($TargetCommandName -ceq 'purchase.invoice.create' -and
$CaptureCaseCode -cne 'purchase_permission_denied') {
return 'purchase.invoice.resolve'
}
if ($TargetCommandName -ceq 'hr.leave.create' -and
$CaptureCaseCode -cnotin @(
'leave_resolution_proof_bypass_blocked',
'leave_local_time_zone_rejected',
'leave_permission_denied')) {
return 'hr.leave.resolve'
}
return $null
}
function Read-VerifiedUatGrant(
[object]$VerifiedData,
[string]$AuthorizationPath,
[string]$VaultPath,
[string]$CaptureCaseCode,
[string]$TargetCommandName
) {
try {
if ($null -eq $VerifiedData -or
[string]$VerifiedData.packageType -cne
'workflow_write_uat_authorization' -or
[string]$VerifiedData.schemaVersion -cne '1.2' -or
[string]$VerifiedData.sourceSha256 -cne
$ExpectedUatAuthorizationSha256.ToLowerInvariant() -or
[string]$VerifiedData.verifierCli.fileName -cne 'lserp-cli.exe' -or
[string]$VerifiedData.verifierCli.sha256 -cne
$expectedVerifierCliHash -or
[string]$VerifiedData.verifierCli.signerThumbprint -cne
$expectedVerifierCliSigner -or
$VerifiedData.verifierCli.requiresElevation -ne $true -or
[string]$VerifiedData.runtimeCli.fileName -cne
'lserp-agent-cli.exe' -or
[string]$VerifiedData.runtimeCli.version -cne
$ExpectedRuntimeCliVersion -or
[string]$VerifiedData.runtimeCli.sha256 -cne
$expectedRuntimeCliHash -or
[string]$VerifiedData.runtimeCli.signerThumbprint -cne
$expectedRuntimeCliSigner -or
$VerifiedData.runtimeCli.requiresElevation -ne $false -or
$VerifiedData.runtimeCli.bridgeOnly -ne $true -or
$VerifiedData.runtimeCli.databaseDirectAccess -ne $false -or
[string]$VerifiedData.runtimeCli.sessionSource -cne
'current_logged_in_erp_process' -or
$VerifiedData.signatureVerified -ne $true -or
$VerifiedData.uatAuthorized -ne $true -or
$VerifiedData.productionReady -ne $false) {
Throw-CaptureError 'uat_authorization_verification_invalid'
}
$authorizationText = [IO.File]::ReadAllText($AuthorizationPath, $utf8)
$authorization = $authorizationText | ConvertFrom-Json
$issuedAtMatches = [Text.RegularExpressions.Regex]::Matches(
$authorizationText,
'"issuedAtUtc"\s*:\s*"(?<value>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,7})?(?:Z|\+00:00))"',
[Text.RegularExpressions.RegexOptions]::CultureInvariant)
$expiresAtMatches = [Text.RegularExpressions.Regex]::Matches(
$authorizationText,
'"expiresAtUtc"\s*:\s*"(?<value>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,7})?(?:Z|\+00:00))"',
[Text.RegularExpressions.RegexOptions]::CultureInvariant)
if (-not (Test-ExactProperties $authorization @(
'schemaVersion', 'contentSha256', 'signatureAlgorithm',
'certificateThumbprint', 'signatureBase64', 'content')) -or
[string]$authorization.schemaVersion -cne '1.2' -or
$null -eq $authorization.content -or
-not (Test-ExactProperties $authorization.content.erpScope @(
'accountBook', 'subSystemId', 'userId', 'userName',
'databaseScopeFingerprint')) -or
[string]$authorization.content.erpScope.databaseScopeFingerprint -cne
[string]$VerifiedData.erpScope.databaseScopeFingerprint -or
[string]$authorization.content.authorizationId -cne
[string]$VerifiedData.authorizationId -or
$issuedAtMatches.Count -ne 1 -or $expiresAtMatches.Count -ne 1) {
Throw-CaptureError 'uat_authorization_contract_invalid'
}
$signedScope = $authorization.content.erpScope
foreach ($scopeField in @(
'accountBook', 'subSystemId', 'userId', 'userName')) {
$scopeValue = [string]$signedScope.$scopeField
if ([string]::IsNullOrWhiteSpace($scopeValue) -or
$scopeValue.Length -gt 256 -or
$scopeValue -cne $scopeValue.Trim()) {
Throw-CaptureError 'uat_authorization_scope_invalid'
}
foreach ($character in $scopeValue.ToCharArray()) {
if ([char]::IsControl($character)) {
Throw-CaptureError 'uat_authorization_scope_invalid'
}
}
}
if ((Get-Sha256Hex ($utf8.GetBytes(
[string]$signedScope.accountBook))) -cne
[string]$VerifiedData.erpScope.accountBookSha256 -or
(Get-Sha256Hex ($utf8.GetBytes(
[string]$signedScope.subSystemId))) -cne
[string]$VerifiedData.erpScope.subSystemIdSha256 -or
(Get-Sha256Hex ($utf8.GetBytes(
[string]$signedScope.userId))) -cne
[string]$VerifiedData.erpScope.userIdSha256 -or
(Get-Sha256Hex ($utf8.GetBytes(
[string]$signedScope.userName))) -cne
[string]$VerifiedData.erpScope.userNameSha256) {
Throw-CaptureError 'uat_authorization_scope_invalid'
}
$workflowName = if ($CaptureCaseCode.StartsWith(
'purchase_', [StringComparison]::Ordinal)) {
'purchase'
} elseif ($CaptureCaseCode.StartsWith(
'leave_', [StringComparison]::Ordinal)) {
'leave'
} else {
Throw-CaptureError 'uat_case_not_authorized'
}
$allCases = New-Object System.Collections.Generic.List[object]
foreach ($workflow in @($authorization.content.workflows)) {
foreach ($case in @($workflow.cases)) {
$allCases.Add([pscustomobject]@{
Workflow = [string]$workflow.workflow
Case = $case
})
}
}
$matches = @($allCases | Where-Object {
$_.Workflow -ceq $workflowName -and
[string]$_.Case.caseCode -ceq $CaptureCaseCode
})
if ($matches.Count -ne 1) {
Throw-CaptureError 'uat_case_not_authorized'
}
$caseAuthorization = $matches[0].Case
if (-not (Test-ExactProperties $caseAuthorization @(
'caseCode', 'expectedCommandName',
'allowedCommands', 'tokenSha256')) -or
[string]$caseAuthorization.expectedCommandName -cne
$TargetCommandName -or
[string]$caseAuthorization.tokenSha256 -cnotmatch
'^[a-f0-9]{64}$') {
Throw-CaptureError 'uat_case_command_mismatch'
}
$allowedCommands = @($caseAuthorization.allowedCommands |
ForEach-Object { [string]$_ })
$preparationCommand = Get-PreparationCommand `
$CaptureCaseCode $TargetCommandName
if ($allowedCommands -cnotcontains $TargetCommandName -or
($null -ne $preparationCommand -and
$allowedCommands -cnotcontains $preparationCommand)) {
Throw-CaptureError 'uat_case_command_mismatch'
}
Assert-RestrictedVaultAcl $VaultPath
$vault = [IO.File]::ReadAllText($VaultPath, $utf8) | ConvertFrom-Json
if (-not (Test-ExactProperties $vault @(
'schemaVersion', 'authorizationId',
'protectedForUserSid', 'protectionScope',
'createdAtUtc', 'entries')) -or
[string]$vault.schemaVersion -cne '1.0' -or
[string]$vault.authorizationId -cne
[string]$VerifiedData.authorizationId -or
[string]$vault.protectedForUserSid -cne
$windowsIdentity.User.Value -or
[string]$vault.protectionScope -cne
'dpapi_current_user_high_integrity') {
Throw-CaptureError 'uat_token_vault_contract_invalid'
}
$vaultEntries = @($vault.entries)
if ($vaultEntries.Count -ne $allCases.Count) {
Throw-CaptureError 'uat_token_vault_contract_invalid'
}
$entryKeys = @{}
foreach ($entry in $vaultEntries) {
if (-not (Test-ExactProperties $entry @(
'workflow', 'caseCode', 'protectedTokenBase64'))) {
Throw-CaptureError 'uat_token_vault_contract_invalid'
}
$entryKey = [string]$entry.workflow + '|' +
[string]$entry.caseCode
if ($entryKeys.ContainsKey($entryKey)) {
Throw-CaptureError 'uat_token_vault_contract_invalid'
}
$entryKeys[$entryKey] = $true
}
$tokenEntries = @($vaultEntries | Where-Object {
[string]$_.workflow -ceq $workflowName -and
[string]$_.caseCode -ceq $CaptureCaseCode
})
if ($tokenEntries.Count -ne 1 -or
[string]$tokenEntries[0].protectedTokenBase64 -cnotmatch
'^[A-Za-z0-9+/]{64,4096}={0,2}$') {
Throw-CaptureError 'uat_token_vault_entry_invalid'
}
$protectedBytes = [Convert]::FromBase64String(
[string]$tokenEntries[0].protectedTokenBase64)
$plainBytes = $null
try {
$entropy = $utf8.GetBytes([string]$VerifiedData.authorizationId)
$plainBytes = [Security.Cryptography.ProtectedData]::Unprotect(
$protectedBytes,
$entropy,
[Security.Cryptography.DataProtectionScope]::CurrentUser)
$token = $utf8.GetString($plainBytes)
}
finally {
if ($null -ne $plainBytes) {
[Array]::Clear($plainBytes, 0, $plainBytes.Length)
}
}
if ($token -cnotmatch '^[A-Za-z0-9_-]{32,128}$' -or
(Get-Sha256Hex ($utf8.GetBytes($token))) -cne
[string]$caseAuthorization.tokenSha256) {
Throw-CaptureError 'uat_token_invalid'
}
return [pscustomobject]@{
AuthorizationId = [string]$VerifiedData.authorizationId
AuthorizationIdSha256 = [string]$VerifiedData.authorizationIdSha256
AuthorizationSourceSha256 = [string]$VerifiedData.sourceSha256
AuthorizationContentSha256 = [string]$VerifiedData.contentSha256
AuthorizationIssuedAtUtc =
$issuedAtMatches[0].Groups['value'].Value
AuthorizationExpiresAtUtc =
$expiresAtMatches[0].Groups['value'].Value
CaseCode = $CaptureCaseCode
Workflow = $workflowName
TargetCommand = $TargetCommandName
PreparationCommand = $preparationCommand
AccountBook = [string]$signedScope.accountBook
SubSystemId = [string]$signedScope.subSystemId
UserId = [string]$signedScope.userId
UserName = [string]$signedScope.userName
DatabaseScopeFingerprint =
([string]$signedScope.databaseScopeFingerprint).ToLowerInvariant()
TokenSha256 = [string]$caseAuthorization.tokenSha256
Token = $token
}
}
catch {
if ($_.Exception.Message.StartsWith(
'workflow_write_case_capture_failed:')) { throw }
Throw-CaptureError 'uat_authorization_or_vault_invalid'
}
}
function Read-CliEnvelope([string]$Text, [int]$ExitCode) {
try {
if ([string]::IsNullOrWhiteSpace($Text) -or
$Text.Length -gt $maximumResponseCharacters) {
Throw-CaptureError 'cli_response_invalid'
}
$document = $Text | ConvertFrom-Json
if ($null -eq $document -or
$null -eq $document.ok -or
$document.ok -isnot [bool] -or
([string]$document.correlationId) -notmatch '^[A-Za-z0-9_.:-]{8,128}$') {
Throw-CaptureError 'cli_response_invalid'
}
if ($document.ok) {
if ($ExitCode -ne 0 -or
-not (Test-ExactProperties $document @('ok', 'correlationId', 'data')) -or
$null -eq $document.data) {
Throw-CaptureError 'cli_response_invalid'
}
}
else {
if ($ExitCode -eq 0 -or
-not (Test-ExactProperties $document @('ok', 'correlationId', 'error')) -or
-not (Test-ExactProperties $document.error @('code', 'message', 'exitCode')) -or
([string]$document.error.code) -notmatch '^[a-z0-9_.-]{1,128}$' -or
[int]$document.error.exitCode -ne $ExitCode) {
Throw-CaptureError 'cli_response_invalid'
}
}
return $document
}
catch {
if ($_.Exception.Message.StartsWith('workflow_write_case_capture_failed:')) { throw }
Throw-CaptureError 'cli_response_invalid'
}
}
function Assert-TrustedVerifierCliUnchanged {
$actual = (Get-FileHash -LiteralPath $script:verifierCliFull `
-Algorithm SHA256).Hash.ToLowerInvariant()
if ($actual -cne $script:expectedVerifierCliHash) {
Throw-CaptureError 'verifier_cli_hash_changed'
}
}
function Assert-TrustedRuntimeCliUnchanged {
$actual = (Get-FileHash -LiteralPath $script:runtimeCliFull `
-Algorithm SHA256).Hash.ToLowerInvariant()
if ($actual -cne $script:expectedRuntimeCliHash) {
Throw-CaptureError 'runtime_cli_hash_changed'
}
}
function Assert-VerifierCliArguments([string[]]$Arguments) {
$verifyAuthorization = $Arguments.Count -ge 4 -and
$Arguments[0] -ceq 'acceptance' -and
$Arguments[1] -ceq 'verify-uat-authorization'
$projectObservation = $Arguments.Count -ge 6 -and
$Arguments[0] -ceq 'adapters' -and
$Arguments[1] -ceq 'project-write-observation-files'
if (-not $verifyAuthorization -and -not $projectObservation) {
Throw-CaptureError 'verifier_cli_command_denied'
}
}
function Assert-RuntimeCliArguments([string[]]$Arguments) {
$version = $Arguments.Count -eq 3 -and
$Arguments[0] -ceq 'version' -and
$Arguments[1] -ceq '--correlation-id'
$bridge = $Arguments.Count -ge 2 -and
$Arguments[0] -ceq 'bridge' -and
$Arguments[1] -cin @('health', 'context', 'plan', 'execute')
if (-not $version -and -not $bridge) {
Throw-CaptureError 'runtime_cli_command_denied'
}
}
function Invoke-TrustedCliProcess(
[string]$ExecutablePath,
[string]$ExpectedHash,
[string[]]$Arguments,
[string]$StandardInputValue,
[string]$Step
) {
$actualHash = (Get-FileHash -LiteralPath $ExecutablePath `
-Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualHash -cne $ExpectedHash) {
Throw-CaptureError ($Step + '_cli_hash_changed')
}
$process = New-Object Diagnostics.Process
try {
$start = New-Object Diagnostics.ProcessStartInfo
$start.FileName = $ExecutablePath
$start.WorkingDirectory = [IO.Path]::GetDirectoryName($ExecutablePath)
$start.UseShellExecute = $false
$start.CreateNoWindow = $true
$start.RedirectStandardOutput = $true
$start.RedirectStandardError = $true
$start.RedirectStandardInput = $true
$start.StandardOutputEncoding = $utf8
$start.StandardErrorEncoding = $utf8
$start.Arguments = (($Arguments | ForEach-Object {
ConvertTo-WindowsProcessArgument ([string]$_)
}) -join ' ')
$process.StartInfo = $start
if (-not $process.Start()) { Throw-CaptureError ($Step + '_process_start_failed') }
$stdoutTask = $process.StandardOutput.ReadToEndAsync()
$stderrTask = $process.StandardError.ReadToEndAsync()
if ($null -ne $StandardInputValue) {
$process.StandardInput.Write($StandardInputValue)
}
$process.StandardInput.Close()
$processTimeout = [Math]::Min(330000, $BridgeTimeoutMilliseconds + 30000)
if (-not $process.WaitForExit($processTimeout)) {
try { $process.Kill() } catch { }
Throw-CaptureError ($Step + '_timeout')
}
$process.WaitForExit()
$stdout = $stdoutTask.Result
$stderr = $stderrTask.Result
if (($process.ExitCode -eq 0 -and
([string]::IsNullOrWhiteSpace($stdout) -or
-not [string]::IsNullOrWhiteSpace($stderr))) -or
($process.ExitCode -ne 0 -and
([string]::IsNullOrWhiteSpace($stderr) -or
-not [string]::IsNullOrWhiteSpace($stdout)))) {
Throw-CaptureError ($Step + '_stream_contract_invalid')
}
$raw = if ($process.ExitCode -eq 0) { $stdout } else { $stderr }
$envelope = Read-CliEnvelope $raw $process.ExitCode
$hashAfter = (Get-FileHash -LiteralPath $ExecutablePath `
-Algorithm SHA256).Hash.ToLowerInvariant()
if ($hashAfter -cne $ExpectedHash) {
Throw-CaptureError ($Step + '_cli_hash_changed')
}
return [PSCustomObject]@{
ExitCode = $process.ExitCode
Raw = $raw
Envelope = $envelope
}
}
catch {
if ($_.Exception.Message.StartsWith('workflow_write_case_capture_failed:')) { throw }
Throw-CaptureError ($Step + '_process_failed')
}
finally {
if ($null -ne $process) { $process.Dispose() }
}
}
function Invoke-TrustedVerifierCli(
[string[]]$Arguments,
[string]$StandardInputValue,
[string]$Step
) {
Assert-VerifierCliArguments $Arguments
return Invoke-TrustedCliProcess `
$script:verifierCliFull `
$script:expectedVerifierCliHash `
$Arguments `
$StandardInputValue `
$Step
}
function Invoke-TrustedRuntimeCli(
[string[]]$Arguments,
[string]$StandardInputValue,
[string]$Step
) {
Assert-RuntimeCliArguments $Arguments
return Invoke-TrustedCliProcess `
$script:runtimeCliFull `
$script:expectedRuntimeCliHash `
$Arguments `
$StandardInputValue `
$Step
}
function Read-StrictIdempotencyKeyFile([string]$Path) {
$full = Resolve-RegularFile $Path 512 'idempotency_key_file_invalid'
try {
$value = [IO.File]::ReadAllText($full, $utf8)
if ($value.EndsWith("`r`n", [StringComparison]::Ordinal)) {
$value = $value.Substring(0, $value.Length - 2)
}
elseif ($value.EndsWith("`n", [StringComparison]::Ordinal)) {
$value = $value.Substring(0, $value.Length - 1)
}
if ($value.Contains("`r") -or $value.Contains("`n")) {
Throw-CaptureError 'idempotency_key_file_invalid'
}
return $value
}
catch {
if ($_.Exception.Message.StartsWith('workflow_write_case_capture_failed:')) { throw }
Throw-CaptureError 'idempotency_key_file_invalid'
}
}
function Convert-SecureStringToPlainText([Security.SecureString]$Value) {
$pointer = [IntPtr]::Zero
try {
$pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Value)
return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer)
}
finally {
if ($pointer -ne [IntPtr]::Zero) {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer)
}
}
}
function Resolve-IntegerObservation(
[int]$Current,
[int]$Minimum,
[int]$Maximum,
[string]$Prompt,
[string]$Code
) {
if ($Current -ge $Minimum -and $Current -le $Maximum) { return $Current }
if ($NonInteractive) { Throw-CaptureError $Code }
$raw = Read-Host $Prompt
$parsed = 0
if (-not [int]::TryParse($raw, [ref]$parsed) -or
$parsed -lt $Minimum -or $parsed -gt $Maximum) {
Throw-CaptureError $Code
}
return $parsed
}
function Resolve-BooleanObservation(
[Nullable[bool]]$Current,
[string]$Prompt,
[string]$Code
) {
if ($null -ne $Current) { return [bool]$Current }
if ($NonInteractive) { Throw-CaptureError $Code }
$raw = (Read-Host ($Prompt + ' [true/false]')).Trim().ToLowerInvariant()
if ($raw -eq 'true') { return $true }
if ($raw -eq 'false') { return $false }
Throw-CaptureError $Code
}
function Write-StrictJson([object]$Value, [string]$Path) {
$json = $Value | ConvertTo-Json -Depth 20
[IO.File]::WriteAllText($Path, $json, $utf8)
}
function New-StagedOutput([string]$FinalPath) {
$directory = [IO.Path]::GetDirectoryName($FinalPath)
$path = Join-Path $directory ('capture-' + [Guid]::NewGuid().ToString('N') + '.json')
if ([IO.File]::Exists($path)) { Throw-CaptureError 'staged_output_collision' }
$stagedOutputs.Add($path)
return $path
}
function Invoke-Projection([string]$IndexPath, [string]$StagePath, [string]$Step) {
$result = Invoke-TrustedVerifierCli @(
'adapters', 'project-write-observation-files',
'--input', $IndexPath,
'--output', $StagePath,
'--correlation-id', ('projection-' + [Guid]::NewGuid().ToString('N'))
) $null $Step
if ($result.ExitCode -ne 0) {
Throw-CaptureError ($Step + '_' + [string]$result.Envelope.error.code)
}
if (-not [IO.File]::Exists($StagePath)) {
Throw-CaptureError ($Step + '_output_missing')
}
}
try {
$verifierCliFull = Resolve-RegularFile `
$VerifierCliPath 128MB 'verifier_cli_file_invalid'
$runtimeCliFull = Resolve-RegularFile `
$RuntimeCliPath 128MB 'runtime_cli_file_invalid'
if ([IO.Path]::GetFileName($verifierCliFull) -ine 'lserp-cli.exe') {
Throw-CaptureError 'verifier_cli_filename_invalid'
}
if ([IO.Path]::GetFileName($runtimeCliFull) -ine 'lserp-agent-cli.exe') {
Throw-CaptureError 'runtime_cli_filename_invalid'
}
if ($verifierCliFull -ieq $runtimeCliFull) {
Throw-CaptureError 'cli_role_path_conflict'
}
$verifierCliLock = [IO.File]::Open(
$verifierCliFull,
[IO.FileMode]::Open,
[IO.FileAccess]::Read,
[IO.FileShare]::Read)
$runtimeCliLock = [IO.File]::Open(
$runtimeCliFull,
[IO.FileMode]::Open,
[IO.FileAccess]::Read,
[IO.FileShare]::Read)
$expectedVerifierCliHash = $ExpectedVerifierCliSha256.ToLowerInvariant()
$expectedRuntimeCliHash = $ExpectedRuntimeCliSha256.ToLowerInvariant()
$expectedVerifierCliSigner =
$ExpectedVerifierSignerThumbprint.ToUpperInvariant()
$expectedRuntimeCliSigner =
$ExpectedRuntimeSignerThumbprint.ToUpperInvariant()
Assert-TrustedVerifierCliUnchanged
Assert-TrustedRuntimeCliUnchanged
$verifierSignature = Get-AuthenticodeSignature `
-LiteralPath $verifierCliFull
$actualVerifierSigner = if ($null -eq $verifierSignature.SignerCertificate) {
''
} else {
([string]$verifierSignature.SignerCertificate.Thumbprint).
Replace(' ', '').ToUpperInvariant()
}
if ($verifierSignature.Status -ne
[System.Management.Automation.SignatureStatus]::Valid -or
$actualVerifierSigner -cne $expectedVerifierCliSigner) {
Throw-CaptureError 'verifier_cli_signature_invalid'
}
$runtimeSignature = Get-AuthenticodeSignature -LiteralPath $runtimeCliFull
$actualRuntimeSigner = if ($null -eq $runtimeSignature.SignerCertificate) {
''
} else {
([string]$runtimeSignature.SignerCertificate.Thumbprint).
Replace(' ', '').ToUpperInvariant()
}
if ($runtimeSignature.Status -ne
[System.Management.Automation.SignatureStatus]::Valid -or
$actualRuntimeSigner -cne $expectedRuntimeCliSigner) {
Throw-CaptureError 'runtime_cli_signature_invalid'
}
$runtimeIdentityCorrelation =
'uat-runtime-' + [Guid]::NewGuid().ToString('N')
$runtimeIdentityResponse = Invoke-TrustedRuntimeCli @(
'version', '--correlation-id', $runtimeIdentityCorrelation
) $null 'runtime_cli_identity'
$runtimeIdentity = $runtimeIdentityResponse.Envelope.data
if ($runtimeIdentityResponse.ExitCode -ne 0 -or
-not $runtimeIdentityResponse.Envelope.ok -or
[string]$runtimeIdentityResponse.Envelope.correlationId -cne
$runtimeIdentityCorrelation -or
-not (Test-ExactProperties $runtimeIdentity @(
'component', 'version', 'protocolVersion', 'bridgeOnly',
'databaseDirectAccess', 'sessionSource')) -or
[string]$runtimeIdentity.component -cne 'lserp-agent-cli' -or
[string]$runtimeIdentity.version -cne $ExpectedRuntimeCliVersion -or
[string]$runtimeIdentity.protocolVersion -cne '1.0' -or
$runtimeIdentity.bridgeOnly -isnot [bool] -or
$runtimeIdentity.bridgeOnly -ne $true -or
$runtimeIdentity.databaseDirectAccess -isnot [bool] -or
$runtimeIdentity.databaseDirectAccess -ne $false -or
[string]$runtimeIdentity.sessionSource -cne
'current_logged_in_erp_process') {
Throw-CaptureError 'runtime_cli_identity_invalid'
}
$inputFull = Resolve-RegularFile $CommandInputFile (512KB) 'command_input_invalid'
$uatAuthorizationFull = Resolve-RegularFile `
$UatAuthorizationFile (512KB) 'uat_authorization_file_invalid'
$uatTokenVaultFull = Resolve-RegularFile `
$UatTokenVaultPath (512KB) 'uat_token_vault_file_invalid'
if ($inputFull -ieq $uatAuthorizationFull -or
$inputFull -ieq $uatTokenVaultFull -or
$uatAuthorizationFull -ieq $uatTokenVaultFull -or
$verifierCliFull -ieq $inputFull -or
$runtimeCliFull -ieq $inputFull -or
$verifierCliFull -ieq $uatAuthorizationFull -or
$verifierCliFull -ieq $uatTokenVaultFull -or
$runtimeCliFull -ieq $uatAuthorizationFull -or
$runtimeCliFull -ieq $uatTokenVaultFull) {
Throw-CaptureError 'trusted_input_path_conflict'
}
$uatAuthorizationLock = [IO.File]::Open(
$uatAuthorizationFull,
[IO.FileMode]::Open,
[IO.FileAccess]::Read,
[IO.FileShare]::Read)
$uatTokenVaultLock = [IO.File]::Open(
$uatTokenVaultFull,
[IO.FileMode]::Open,
[IO.FileAccess]::Read,
[IO.FileShare]::Read)
$actualAuthorizationHash = (Get-FileHash `
-LiteralPath $uatAuthorizationFull -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualAuthorizationHash -cne
$ExpectedUatAuthorizationSha256.ToLowerInvariant()) {
Throw-CaptureError 'uat_authorization_hash_mismatch'
}
$uatVaultSha256 = (Get-FileHash `
-LiteralPath $uatTokenVaultFull -Algorithm SHA256).Hash.ToLowerInvariant()
$uatVerification = Invoke-TrustedVerifierCli @(
'acceptance', 'verify-uat-authorization',
'--input', $uatAuthorizationFull,
'--correlation-id', ('uat-auth-' + [Guid]::NewGuid().ToString('N'))
) $null 'verify_uat_authorization'
if ($uatVerification.ExitCode -ne 0 -or
-not $uatVerification.Envelope.ok) {
Throw-CaptureError 'uat_authorization_verification_failed'
}
$uatGrant = Read-VerifiedUatGrant `
$uatVerification.Envelope.data `
$uatAuthorizationFull `
$uatTokenVaultFull `
$CaseCode `
$CommandName
$uatToken = [string]$uatGrant.Token
$outputFull = Resolve-NewJsonPath $OutputPath 'output_path_invalid'
$auditOutputFull = $null
$auditCaseCode = $null
if (-not [string]::IsNullOrWhiteSpace($CorrelatedAuditOutputPath)) {
if ($CaseCode -eq 'purchase_unique_match_commit') {
$auditCaseCode = 'purchase_audit_correlated'
}
elseif ($CaseCode -eq 'leave_create_draft_commit') {
$auditCaseCode = 'leave_audit_correlated'
}
else {
Throw-CaptureError 'correlated_audit_case_invalid'
}
$auditOutputFull = Resolve-NewJsonPath `
$CorrelatedAuditOutputPath 'correlated_audit_output_invalid'
if ($auditOutputFull -ieq $outputFull) {
Throw-CaptureError 'correlated_audit_output_invalid'
}
$uatAuditGrant = Read-VerifiedUatGrant `
$uatVerification.Envelope.data `
$uatAuthorizationFull `
$uatTokenVaultFull `
$auditCaseCode `
$CommandName
$uatAuditTokenSha256 = [string]$uatAuditGrant.TokenSha256
$uatAuditGrant.Token = $null
$uatAuditGrant = $null
}
if ([string]::IsNullOrWhiteSpace($RestrictedWorkingRoot)) {
if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) {
Throw-CaptureError 'restricted_working_root_required'
}
$RestrictedWorkingRoot = Join-Path `
$env:LOCALAPPDATA 'Langsu\Lserp\AcceptanceRaw'
}
$rawDirectory = New-RestrictedDirectory $RestrictedWorkingRoot
$sourceInputCopy = Join-Path $rawDirectory 'source-command-input.json'
$inputCopy = Join-Path $rawDirectory 'command-input.json'
$preparationResponsePath = Join-Path `
$rawDirectory 'preparation-response.json'
$contextResponsePath = Join-Path $rawDirectory 'context-response.json'
$planResponsePath = Join-Path $rawDirectory 'plan-response.json'
$executeResponsePath = Join-Path $rawDirectory 'execute-response.json'
$indexPath = Join-Path $rawDirectory 'case-index.json'
[IO.File]::Copy($inputFull, $sourceInputCopy, $false)
$postPlanStagingCases = @(
'purchase_runtime_recheck_blocked',
'leave_stale_flow_type_blocked',
'leave_runtime_recheck_blocked'
)
$requiresPostPlanStaging = $CaseCode -in $postPlanStagingCases
if ($requiresPostPlanStaging -and
(-not $Execute -or -not $PauseAfterPlanForOperatorStaging.IsPresent)) {
Throw-CaptureError 'post_plan_operator_staging_required'
}
if ($PauseAfterPlanForOperatorStaging.IsPresent -and
(-not $Execute -or $NonInteractive -or -not $requiresPostPlanStaging)) {
Throw-CaptureError 'post_plan_operator_staging_mode_invalid'
}
$hasSecureKey = $null -ne $IdempotencyKey
$hasKeyFile = -not [string]::IsNullOrWhiteSpace($IdempotencyKeyFile)
if ($hasSecureKey -and $hasKeyFile) {
Throw-CaptureError 'idempotency_key_source_conflict'
}
if (-not $Execute -and ($hasSecureKey -or $hasKeyFile)) {
Throw-CaptureError 'idempotency_key_without_execute'
}
if ($Execute) {
if ($hasKeyFile) {
$idempotencyPlain = Read-StrictIdempotencyKeyFile $IdempotencyKeyFile
}
else {
if (-not $hasSecureKey) {
if ($NonInteractive) {
Throw-CaptureError 'idempotency_key_required'
}
$IdempotencyKey = Read-Host `
'输入本次稳定业务幂等键(不会显示)' -AsSecureString
}
$idempotencyPlain = Convert-SecureStringToPlainText $IdempotencyKey
}
if ($idempotencyPlain -cnotmatch '^[A-Za-z0-9_.:-]{8,128}$') {
Throw-CaptureError 'idempotency_key_invalid'
}
}
$healthCorrelationId = 'uat-health-' + [Guid]::NewGuid().ToString('N')
$health = Invoke-TrustedRuntimeCli @(
'bridge', 'health',
'--erp-process-id', [string]$ErpProcessId,
'--expected-database-scope-fingerprint',
[string]$uatGrant.DatabaseScopeFingerprint,
'--expected-user-id', [string]$uatGrant.UserId,
'--expected-user-name', [string]$uatGrant.UserName,
'--expected-account-book', [string]$uatGrant.AccountBook,
'--expected-subsystem-id', [string]$uatGrant.SubSystemId,
'--expected-is-administrator', 'false',
'--timeout-ms', [string]$BridgeTimeoutMilliseconds,
'--correlation-id', $healthCorrelationId
) $null 'bridge_uat_health'
$healthUat = if ($null -eq $health.Envelope.data) {
$null
} else {
$health.Envelope.data.workflowUat
}
if ($health.ExitCode -ne 0 -or -not $health.Envelope.ok -or
$null -eq $healthUat -or $healthUat.enabled -ne $true -or
[string]$healthUat.sourceSha256 -cne
[string]$uatGrant.AuthorizationSourceSha256 -or
[string]$healthUat.authorizationIdSha256 -cne
[string]$uatGrant.AuthorizationIdSha256 -or
$healthUat.generalCapabilitiesHidden -ne $true) {
Throw-CaptureError 'bridge_uat_authorization_mismatch'
}
$correlationId = 'uat-' + [Guid]::NewGuid().ToString('N')
$context = Invoke-TrustedRuntimeCli @(
'bridge', 'context',
'--erp-process-id', [string]$ErpProcessId,
'--expected-database-scope-fingerprint',
[string]$uatGrant.DatabaseScopeFingerprint,
'--expected-user-id', [string]$uatGrant.UserId,
'--expected-user-name', [string]$uatGrant.UserName,
'--expected-account-book', [string]$uatGrant.AccountBook,
'--expected-subsystem-id', [string]$uatGrant.SubSystemId,
'--expected-is-administrator', 'false',
'--timeout-ms', [string]$BridgeTimeoutMilliseconds,
'--correlation-id', $correlationId
) $null 'bridge_context'
if ($context.ExitCode -ne 0 -or -not $context.Envelope.ok) {
Throw-CaptureError 'bridge_context_unavailable'
}
[IO.File]::WriteAllText($contextResponsePath, $context.Raw, $utf8)
if ($null -ne $uatGrant.PreparationCommand) {
$preparation = Invoke-TrustedRuntimeCli @(
'bridge', 'plan', [string]$uatGrant.PreparationCommand,
'--input', $sourceInputCopy,
'--uat-authorization-id', [string]$uatGrant.AuthorizationId,
'--uat-case-code', $CaseCode,
'--uat-token-stdin',
'--erp-process-id', [string]$ErpProcessId,
'--expected-database-scope-fingerprint',
[string]$uatGrant.DatabaseScopeFingerprint,
'--expected-user-id', [string]$uatGrant.UserId,
'--expected-user-name', [string]$uatGrant.UserName,
'--expected-account-book', [string]$uatGrant.AccountBook,
'--expected-subsystem-id', [string]$uatGrant.SubSystemId,
'--expected-is-administrator', 'false',
'--timeout-ms', [string]$BridgeTimeoutMilliseconds,
'--correlation-id', $correlationId
) $uatToken 'bridge_preparation_plan'
[IO.File]::WriteAllText(
$preparationResponsePath, $preparation.Raw, $utf8)
$resolvedInput = if ($null -eq $preparation.Envelope.data -or
$null -eq $preparation.Envelope.data.plan -or
$null -eq $preparation.Envelope.data.plan.data) {
$null
} else {
$preparation.Envelope.data.plan.data.resolvedInput
}
if ($preparation.ExitCode -ne 0 -or
-not $preparation.Envelope.ok -or
$preparation.Envelope.data.plan.valid -ne $true -or
$preparation.Envelope.data.plan.executionAllowed -ne $false -or
[string]$preparation.Envelope.data.plan.data.resolvedCommand -cne
$CommandName -or
$preparation.Envelope.data.plan.data.requiresFollowupPlan -ne $true -or
$null -eq $resolvedInput -or
$resolvedInput -isnot [pscustomobject] -or
[string]$resolvedInput.resolutionProof -cnotmatch
'^(?:rp1|lrp1)\.[0-9]{1,19}\.[a-f0-9]{32}\.[a-f0-9]{64}\.[A-Za-z0-9_-]{40,64}$') {
Throw-CaptureError 'bridge_preparation_not_resolved'
}
Write-StrictJson $resolvedInput $inputCopy
}
else {
[IO.File]::Copy($sourceInputCopy, $inputCopy, $false)
}
$plan = Invoke-TrustedRuntimeCli @(
'bridge', 'plan', $CommandName,
'--input', $inputCopy,
'--uat-authorization-id', [string]$uatGrant.AuthorizationId,
'--uat-case-code', $CaseCode,
'--uat-token-stdin',
'--erp-process-id', [string]$ErpProcessId,
'--expected-database-scope-fingerprint',
[string]$uatGrant.DatabaseScopeFingerprint,
'--expected-user-id', [string]$uatGrant.UserId,
'--expected-user-name', [string]$uatGrant.UserName,
'--expected-account-book', [string]$uatGrant.AccountBook,
'--expected-subsystem-id', [string]$uatGrant.SubSystemId,
'--expected-is-administrator', 'false',
'--timeout-ms', [string]$BridgeTimeoutMilliseconds,
'--correlation-id', $correlationId
) $uatToken 'bridge_plan'
[IO.File]::WriteAllText($planResponsePath, $plan.Raw, $utf8)
$executeResponseFileName = $null
if ($Execute) {
if ($plan.ExitCode -ne 0 -or -not $plan.Envelope.ok -or
$null -eq $plan.Envelope.data.plan -or
$plan.Envelope.data.plan.valid -ne $true -or
$plan.Envelope.data.plan.executionAllowed -ne $true -or
([string]$plan.Envelope.data.plan.planId) -notmatch '^[A-Fa-f0-9]{32}$') {
Throw-CaptureError 'bridge_plan_not_executable'
}
if ($PauseAfterPlanForOperatorStaging.IsPresent) {
$stagingConfirmation = 'STAGED:' + $CaseCode
$entered = Read-Host (
'计划已固定;请由授权 DBA/配置人员完成计划后场景变更,' +
'保持当前 ERP 进程运行,然后输入 ' + $stagingConfirmation)
if ([string]$entered -cne $stagingConfirmation) {
Throw-CaptureError 'post_plan_operator_staging_not_confirmed'
}
Assert-TrustedVerifierCliUnchanged
Assert-TrustedRuntimeCliUnchanged
$authorizationHashAfterStaging = (Get-FileHash `
-LiteralPath $uatAuthorizationFull -Algorithm SHA256).Hash.ToLowerInvariant()
$vaultHashAfterStaging = (Get-FileHash `
-LiteralPath $uatTokenVaultFull -Algorithm SHA256).Hash.ToLowerInvariant()
if ($authorizationHashAfterStaging -cne
$ExpectedUatAuthorizationSha256.ToLowerInvariant() -or
$vaultHashAfterStaging -cne $uatVaultSha256) {
Throw-CaptureError 'trusted_input_changed_during_operator_staging'
}
}
$execute = Invoke-TrustedRuntimeCli @(
'bridge', 'execute', [string]$plan.Envelope.data.plan.planId,
'--idempotency-key-stdin',
'--uat-authorization-id', [string]$uatGrant.AuthorizationId,
'--uat-case-code', $CaseCode,
'--uat-token-stdin',
'--erp-process-id', [string]$ErpProcessId,
'--expected-database-scope-fingerprint',
[string]$uatGrant.DatabaseScopeFingerprint,
'--expected-user-id', [string]$uatGrant.UserId,
'--expected-user-name', [string]$uatGrant.UserName,
'--expected-account-book', [string]$uatGrant.AccountBook,
'--expected-subsystem-id', [string]$uatGrant.SubSystemId,
'--expected-is-administrator', 'false',
'--timeout-ms', [string]$BridgeTimeoutMilliseconds,
'--correlation-id', $correlationId
) ($uatToken + "`n" + $idempotencyPlain) 'bridge_execute'
[IO.File]::WriteAllText($executeResponsePath, $execute.Raw, $utf8)
$executeResponseFileName = 'execute-response.json'
}
$mutationCount = Resolve-IntegerObservation `
$BusinessMutationCount 0 1000 `
'输入 DBA 只读复核得到的业务变更行数' `
'business_mutation_count_required'
if ($Execute) {
$confirmationObserved = Resolve-BooleanObservation `
$NativeConfirmationObserved `
'是否亲眼看到并处理了 ERP 原生确认窗口' `
'native_confirmation_observation_required'
}
else {
if ($null -ne $NativeConfirmationObserved -and
[bool]$NativeConfirmationObserved) {
Throw-CaptureError 'plan_only_confirmation_invalid'
}
$confirmationObserved = $false
}
$auditCount = Resolve-IntegerObservation `
$AuditEventCount 1 1000 `
'输入只读复核得到的命令审计事件数' `
'audit_event_count_required'
$sourcePayloadBound = Resolve-BooleanObservation `
$SourceDocumentWritePayloadBound `
'来源附件集合是否已在写过程 payload 中核对一致' `
'source_payload_observation_required'
$sourceAuditCount = Resolve-IntegerObservation `
$SourceDocumentAuditCount 0 1000 `
'输入同一业务审计号下去重后的来源附件审计行数' `
'source_audit_count_required'
$observedAtUtc = [DateTimeOffset]::UtcNow.ToString(
"yyyy-MM-dd'T'HH:mm:ss.fff'Z'",
[Globalization.CultureInfo]::InvariantCulture)
$index = [ordered]@{
schemaVersion = '1.3'
caseCode = $CaseCode
uatAuthorizationSourceSha256 = [string]$uatGrant.AuthorizationSourceSha256
uatAuthorizationContentSha256 = [string]$uatGrant.AuthorizationContentSha256
uatAuthorizationIdSha256 = [string]$uatGrant.AuthorizationIdSha256
uatAuthorizationIssuedAtUtc = [string]$uatGrant.AuthorizationIssuedAtUtc
uatAuthorizationExpiresAtUtc = [string]$uatGrant.AuthorizationExpiresAtUtc
uatExecutionCaseCode = [string]$uatGrant.CaseCode
uatTokenSha256 = [string]$uatGrant.TokenSha256
runtimeCliVersion = $ExpectedRuntimeCliVersion
runtimeCliSha256 = $expectedRuntimeCliHash
runtimeCliSignerThumbprint = $expectedRuntimeCliSigner
commandName = $CommandName
commandInputFile = 'command-input.json'
contextCliResponseFile = 'context-response.json'
planCliResponseFile = 'plan-response.json'
executeCliResponseFile = $executeResponseFileName
idempotencyKey = $idempotencyPlain
businessMutationCount = $mutationCount
nativeConfirmationObserved = $confirmationObserved
auditEventCount = $auditCount
sourceDocumentWritePayloadBound = $sourcePayloadBound
sourceDocumentAuditCount = $sourceAuditCount
observedAtUtc = $observedAtUtc
}
Write-StrictJson $index $indexPath
$mainStage = New-StagedOutput $outputFull
Invoke-Projection $indexPath $mainStage 'project_main_case'
$auditStage = $null
if ($null -ne $auditCaseCode) {
$index.caseCode = $auditCaseCode
$index.uatTokenSha256 = $uatAuditTokenSha256
$index.businessMutationCount = 0
Write-StrictJson $index $indexPath
$auditStage = New-StagedOutput $auditOutputFull
Invoke-Projection $indexPath $auditStage 'project_correlated_audit_case'
}
[IO.File]::Move($mainStage, $outputFull)
$publishedOutputs.Add($outputFull)
if ($null -ne $auditStage) {
[IO.File]::Move($auditStage, $auditOutputFull)
$publishedOutputs.Add($auditOutputFull)
}
Assert-TrustedVerifierCliUnchanged
Assert-TrustedRuntimeCliUnchanged
if ((Get-FileHash -LiteralPath $uatAuthorizationFull -Algorithm SHA256).Hash.ToLowerInvariant() -cne
$ExpectedUatAuthorizationSha256.ToLowerInvariant() -or
(Get-FileHash -LiteralPath $uatTokenVaultFull -Algorithm SHA256).Hash.ToLowerInvariant() -cne
$uatVaultSha256) {
Throw-CaptureError 'uat_locked_input_changed'
}
$summaryJson = ([ordered]@{
ok = $true
packageType = 'workflow_write_case_capture'
schemaVersion = '1.2'
caseCode = $CaseCode
outputFile = [IO.Path]::GetFileName($outputFull)
correlatedAuditCaseCode = $auditCaseCode
correlatedAuditOutputFile = if ($null -eq $auditOutputFull) {
$null
} else {
[IO.Path]::GetFileName($auditOutputFull)
}
rawIdentifiersEmitted = $false
rawTemporaryFilesRetained = $false
uatAuthorizationSourceSha256 = [string]$uatGrant.AuthorizationSourceSha256
verifierCli = [ordered]@{
fileName = 'lserp-cli.exe'
sha256 = $expectedVerifierCliHash
signerThumbprint = $expectedVerifierCliSigner
}
runtimeCli = [ordered]@{
fileName = 'lserp-agent-cli.exe'
version = $ExpectedRuntimeCliVersion
sha256 = $expectedRuntimeCliHash
signerThumbprint = $expectedRuntimeCliSigner
bridgeOnly = $true
databaseDirectAccess = $false
sessionSource = 'current_logged_in_erp_process'
}
uatCaseTokenEmitted = $false
registrationReady = $false
} | ConvertTo-Json -Depth 4)
}
catch {
foreach ($published in @($publishedOutputs)) {
try {
if ([IO.File]::Exists($published)) { [IO.File]::Delete($published) }
}
catch { }
}
throw
}
finally {
$idempotencyPlain = $null
$uatToken = $null
$uatAuditTokenSha256 = $null
$cleanupFailed = $false
if ($null -ne $verifierCliLock) {
try { $verifierCliLock.Dispose() }
catch { $cleanupFailed = $true }
}
if ($null -ne $runtimeCliLock) {
try { $runtimeCliLock.Dispose() }
catch { $cleanupFailed = $true }
}
if ($null -ne $uatAuthorizationLock) {
try { $uatAuthorizationLock.Dispose() }
catch { $cleanupFailed = $true }
}
if ($null -ne $uatTokenVaultLock) {
try { $uatTokenVaultLock.Dispose() }
catch { $cleanupFailed = $true }
}
foreach ($staged in @($stagedOutputs)) {
try {
if ([IO.File]::Exists($staged)) { [IO.File]::Delete($staged) }
}
catch { $cleanupFailed = $true }
}
if ($null -ne $rawDirectory -and [IO.Directory]::Exists($rawDirectory)) {
try {
[IO.Directory]::Delete($rawDirectory, $true)
}
catch { $cleanupFailed = $true }
}
if ($cleanupFailed) {
foreach ($published in @($publishedOutputs)) {
try {
if ([IO.File]::Exists($published)) { [IO.File]::Delete($published) }
}
catch { }
}
throw 'workflow_write_case_capture_failed:cleanup_failed'
}
}
Write-Output $summaryJson