Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Fixed

- **Native proxy publication now fails closed.** Both Windows build entry points share one implementation that compiles in private staging, validates a destination-side candidate, and checks final replacement before reporting success. Compile or publication failures return non-zero, preserve an existing proxy before replacement, and clean only invocation-owned staging and candidate files.

## [0.22.0] - 2026-08-01

### Internal
Expand Down
6 changes: 6 additions & 0 deletions Docs/SPEC_CORE.md
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,12 @@ Everything lives in one place: `YourProject/Plugins/Monolith/`

This folder is both the working copy and the git repo (`git@github.com:tumourlove/monolith.git`). Edit, build, commit, push, and release all happen here — no file copying.

#### Native proxy publication

`Tools\MonolithProxy\build_proxy.bat` is the authoritative Windows native-proxy build; `build.bat` delegates to it. Compilation is isolated in a private staging directory and never writes the compiler output directly over the live proxy. Publication uses a unique candidate in the destination directory, verifies the candidate byte count, and replaces `Binaries\monolith_proxy.exe` only as the final same-directory move. Every directory, copy, move, existence, and size gate fails with a non-zero exit code and no success message. Failures before replacement preserve the existing executable, and cleanup is restricted to staging and candidate paths created by the active invocation.

The regression harness is `Scripts\test_proxy_build.ps1`. It builds through both entry points in a GUID-named temporary root, injects an invalid translation unit, locks a sentinel destination executable to force native publication failure, verifies exact prior bytes are preserved, checks candidates/staging are removed, and never targets the repository `Binaries` directory.

#### Publishing a release

1. Bump version in `Source/MonolithCore/Public/MonolithCoreModule.h` (`MONOLITH_VERSION`) and `Monolith.uplugin` (`VersionName`)
Expand Down
31 changes: 31 additions & 0 deletions Docs/testing/2026-08-04-native-proxy-publication-failure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Native Proxy Publication Failure Verification

**Date:** 2026-08-04
**Scope:** `Tools/MonolithProxy` Windows build and publication

---

## 1. Root cause

Both native proxy build entry points compiled into the source tree, copied directly over `Binaries\monolith_proxy.exe`, left output-directory creation and publication unchecked, and printed success unconditionally. A failed or partial publication could therefore lie about the usable binary and risk changing an existing proxy.

## 2. Publication contract

| Phase | Contract |
|---|---|
| Compile | Write only to an invocation-owned private staging directory |
| Candidate | Copy beside the destination under a unique name and verify byte count |
| Replace | Rename the verified candidate over the target as the final publication step |
| Failure | Return non-zero, print no success, preserve the prior target before replacement, and remove owned candidates/staging |
| Entry points | `build_proxy.bat` owns the workflow; `build.bat` delegates without changing its exit code |

## 3. Verification

| Gate | Expected result | Result |
|---|---|---|
| Windows PowerShell 5.1 regression harness | Success through both entry points; injected compile and locked-target publication failures preserve sentinel bytes | Pass (`5.1.26100.8655`) |
| PowerShell 7 regression harness | The same build and failure contract holds in the current cross-shell runner | Pass (`7.5.5`) |
| Candidate/staging cleanup | No failure leaves an owned candidate or stage directory | Pass in both harness runs |
| Patch hygiene | `git diff --check` succeeds | Pass |

No Unreal module, asset, editor presentation, or gameplay surface changes. Screenshot and Discord upload verification are not applicable.
156 changes: 156 additions & 0 deletions Scripts/test_proxy_build.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
[CmdletBinding()]
param(
[string] $TemporaryBase = [IO.Path]::GetTempPath()
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$repositoryRoot = Split-Path -Parent $PSScriptRoot
$buildScript = Join-Path $repositoryRoot 'Tools\MonolithProxy\build_proxy.bat'
$compatibilityBuildScript = Join-Path $repositoryRoot 'Tools\MonolithProxy\build.bat'
$proxySource = Join-Path $repositoryRoot 'Tools\MonolithProxy\monolith_proxy.cpp'
$temporaryBasePath = [IO.Path]::GetFullPath($TemporaryBase)
$testRoot = Join-Path $temporaryBasePath ("MonolithProxyBuildTest-{0}" -f [Guid]::NewGuid().ToString('N'))

function Invoke-ProxyBuild {
param(
[Parameter(Mandatory)] [string] $SourceFile,
[Parameter(Mandatory)] [string] $OutputDirectory,
[Parameter(Mandatory)] [string] $StagingDirectory,
[Parameter(Mandatory)] [string] $EntryPoint
)

$startInfo = [Diagnostics.ProcessStartInfo]::new()
$startInfo.FileName = $env:ComSpec
$startInfo.UseShellExecute = $false
$startInfo.CreateNoWindow = $true
$startInfo.RedirectStandardOutput = $true
$startInfo.RedirectStandardError = $true
$startInfo.WorkingDirectory = Split-Path -Parent $EntryPoint
$startInfo.Arguments = '/d /s /c ""{0}""' -f $EntryPoint.Replace('"', '""')
$startInfo.EnvironmentVariables['MONOLITH_PROXY_SOURCE_FILE'] = $SourceFile
$startInfo.EnvironmentVariables['MONOLITH_PROXY_OUTPUT_DIR'] = $OutputDirectory
$startInfo.EnvironmentVariables['MONOLITH_PROXY_STAGING_DIR'] = $StagingDirectory

$process = [Diagnostics.Process]::new()
$process.StartInfo = $startInfo
if (-not $process.Start()) {
throw 'Failed to start the native proxy build script'
}

$stdoutTask = $process.StandardOutput.ReadToEndAsync()
$stderrTask = $process.StandardError.ReadToEndAsync()
$process.WaitForExit()

[pscustomobject]@{
ExitCode = $process.ExitCode
StdOut = $stdoutTask.GetAwaiter().GetResult()
StdErr = $stderrTask.GetAwaiter().GetResult()
}
}

function Assert-Condition {
param(
[Parameter(Mandatory)] [bool] $Condition,
[Parameter(Mandatory)] [string] $Message
)

if (-not $Condition) {
throw $Message
}
}

function Assert-NoCandidateOrStage {
param(
[Parameter(Mandatory)] [string] $OutputDirectory,
[Parameter(Mandatory)] [string] $StagingDirectory
)

$candidates = @()
if (Test-Path -LiteralPath $OutputDirectory -PathType Container) {
$candidates = @(Get-ChildItem -LiteralPath $OutputDirectory -File -Filter 'monolith_proxy.exe.new-*')
}
Assert-Condition -Condition ($candidates.Count -eq 0) -Message 'Build left a publication candidate behind'
Assert-Condition -Condition (-not (Test-Path -LiteralPath $StagingDirectory)) -Message 'Build left its private staging directory behind'
}

New-Item -ItemType Directory -Path $temporaryBasePath -Force | Out-Null
New-Item -ItemType Directory -Path $testRoot | Out-Null
try {
$successOutput = Join-Path $testRoot 'success-output'
$successStage = Join-Path $testRoot 'success-stage'
$successResult = Invoke-ProxyBuild -SourceFile $proxySource -OutputDirectory $successOutput `
-StagingDirectory $successStage -EntryPoint $buildScript
if ($successResult.ExitCode -ne 0) {
throw "Expected a successful native build.`n$($successResult.StdOut)`n$($successResult.StdErr)"
}
$successBinary = Join-Path $successOutput 'monolith_proxy.exe'
Assert-Condition -Condition (Test-Path -LiteralPath $successBinary -PathType Leaf) `
-Message 'Successful build did not publish monolith_proxy.exe'
Assert-Condition -Condition ((Get-Item -LiteralPath $successBinary).Length -gt 0) `
-Message 'Successful build published an empty executable'
Assert-Condition -Condition ($successResult.StdOut -match 'SUCCESS: Built and published') `
-Message 'Successful build did not report publication success'
Assert-NoCandidateOrStage -OutputDirectory $successOutput -StagingDirectory $successStage

$compatibilityOutput = Join-Path $testRoot 'compatibility-output'
$compatibilityStage = Join-Path $testRoot 'compatibility-stage'
$compatibilityResult = Invoke-ProxyBuild -SourceFile $proxySource -OutputDirectory $compatibilityOutput `
-StagingDirectory $compatibilityStage -EntryPoint $compatibilityBuildScript
if ($compatibilityResult.ExitCode -ne 0) {
throw "Expected build.bat to delegate successfully.`n$($compatibilityResult.StdOut)`n$($compatibilityResult.StdErr)"
}
$compatibilityBinary = Join-Path $compatibilityOutput 'monolith_proxy.exe'
Assert-Condition -Condition ((Get-Item -LiteralPath $compatibilityBinary).Length -eq (Get-Item -LiteralPath $successBinary).Length) `
-Message 'Compatibility entry point did not publish the authoritative build output'
Assert-NoCandidateOrStage -OutputDirectory $compatibilityOutput -StagingDirectory $compatibilityStage

$failureOutput = Join-Path $testRoot 'compile-failure-output'
$failureStage = Join-Path $testRoot 'compile-failure-stage'
New-Item -ItemType Directory -Path $failureOutput | Out-Null
$protectedBinary = Join-Path $failureOutput 'monolith_proxy.exe'
$sentinelBytes = [byte[]](0x4D, 0x4F, 0x4E, 0x4F, 0x4C, 0x49, 0x54, 0x48)
[IO.File]::WriteAllBytes($protectedBinary, $sentinelBytes)
$invalidSource = Join-Path $testRoot 'invalid_proxy.cpp'
[IO.File]::WriteAllText($invalidSource, "#error Intentional proxy build regression fixture`r`n")

$failureResult = Invoke-ProxyBuild -SourceFile $invalidSource -OutputDirectory $failureOutput `
-StagingDirectory $failureStage -EntryPoint $buildScript
Assert-Condition -Condition ($failureResult.ExitCode -ne 0) -Message 'Compile failure returned exit code 0'
Assert-Condition -Condition ([Convert]::ToBase64String([IO.File]::ReadAllBytes($protectedBinary)) -eq [Convert]::ToBase64String($sentinelBytes)) `
-Message 'Compile failure changed the pre-existing proxy binary'
Assert-Condition -Condition ($failureResult.StdOut -notmatch 'SUCCESS:') -Message 'Compile failure printed success'
Assert-NoCandidateOrStage -OutputDirectory $failureOutput -StagingDirectory $failureStage

$publishOutput = Join-Path $testRoot 'publish-failure-output'
$publishStage = Join-Path $testRoot 'publish-failure-stage'
New-Item -ItemType Directory -Path $publishOutput | Out-Null
$lockedBinary = Join-Path $publishOutput 'monolith_proxy.exe'
[IO.File]::WriteAllBytes($lockedBinary, $sentinelBytes)
$lock = [IO.File]::Open($lockedBinary, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None)
try {
$publishResult = Invoke-ProxyBuild -SourceFile $proxySource -OutputDirectory $publishOutput `
-StagingDirectory $publishStage -EntryPoint $buildScript
}
finally {
$lock.Dispose()
}

Assert-Condition -Condition ($publishResult.ExitCode -ne 0) -Message 'Publication failure returned exit code 0'
Assert-Condition -Condition ([Convert]::ToBase64String([IO.File]::ReadAllBytes($lockedBinary)) -eq [Convert]::ToBase64String($sentinelBytes)) `
-Message 'Publication failure changed the pre-existing proxy binary'
Assert-Condition -Condition ($publishResult.StdOut -match 'could not replace') -Message 'Publication failure did not identify replacement failure'
Assert-Condition -Condition ($publishResult.StdOut -notmatch 'SUCCESS:') -Message 'Publication failure printed success'
Assert-NoCandidateOrStage -OutputDirectory $publishOutput -StagingDirectory $publishStage

Write-Host 'PASS: both entry points publish, and compile or publication failures preserve the previous proxy without stale candidates.'
}
finally {
$resolvedTestRoot = [IO.Path]::GetFullPath($testRoot)
$requiredPrefix = $temporaryBasePath.TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
if ($resolvedTestRoot.StartsWith($requiredPrefix, [StringComparison]::OrdinalIgnoreCase) -and
(Split-Path -Leaf $resolvedTestRoot).StartsWith('MonolithProxyBuildTest-', [StringComparison]::Ordinal)) {
Remove-Item -LiteralPath $resolvedTestRoot -Recurse -Force -ErrorAction SilentlyContinue
}
}
8 changes: 8 additions & 0 deletions Tools/MonolithProxy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ Then restart Claude Code.
- **Backend:** Both connect to the same Monolith HTTP server running in the Unreal Editor
- **Editor-down startup:** Both proxies return a cached Monolith tool list when available, or a stable seed list of namespace/meta tools. This prevents MCP clients that do not fully refresh on `tools/list_changed` from starting with an empty Monolith catalog.

## Native Proxy Build

Run `build_proxy.bat` from `Tools\MonolithProxy`; `build.bat` is a compatibility entry point that delegates to the same implementation. The build uses `cl.exe` from the active developer environment or discovers the installed x64 Visual C++ toolchain through `vswhere.exe`.

Compilation happens in a private staging directory. Publication then copies the completed executable to a unique candidate beside the destination, verifies its size, and renames that candidate over `Binaries\monolith_proxy.exe`. Output-directory creation, candidate copy, final replacement, and final size are all checked. A failure returns non-zero without printing success, removes only files owned by that invocation, and preserves any existing proxy when replacement did not occur.

For isolated verification, `MONOLITH_PROXY_SOURCE_FILE`, `MONOLITH_PROXY_OUTPUT_DIR`, and `MONOLITH_PROXY_STAGING_DIR` override the source, destination, and not-yet-existing staging directory. `MONOLITH_PROXY_VSWHERE` may point to an explicit `vswhere.exe`. Run `powershell -NoProfile -ExecutionPolicy Bypass -File Scripts\test_proxy_build.ps1` to verify both entry points plus compile- and publication-failure preservation without writing to the repository's `Binaries` directory.

## Call Log

Both proxies append one JSONL line per upstream MCP roundtrip to:
Expand Down
23 changes: 4 additions & 19 deletions Tools/MonolithProxy/build.bat
Original file line number Diff line number Diff line change
@@ -1,20 +1,5 @@
@echo off
REM Try direct cl.exe first (run from VS Developer Command Prompt)
where cl >nul 2>&1
if %ERRORLEVEL% equ 0 (
echo Building with cl.exe...
cl /EHsc /std:c++17 /O2 /MT /I ThirdParty monolith_proxy.cpp winhttp.lib /Fe:monolith_proxy.exe
if %ERRORLEVEL% equ 0 goto :copy
)
echo cl.exe not found, trying CMake...
if not exist build mkdir build
cd build
cmake .. -A x64
cmake --build . --config Release
cd ..
copy /Y build\Release\monolith_proxy.exe monolith_proxy.exe

:copy
if not exist ..\..\Binaries mkdir ..\..\Binaries
copy /Y monolith_proxy.exe ..\..\Binaries\monolith_proxy.exe
echo Built: Plugins\Monolith\Binaries\monolith_proxy.exe
REM Backward-compatible entry point. Keep compilation, publication, and error
REM handling in one authoritative script.
call "%~dp0build_proxy.bat"
exit /b %ERRORLEVEL%
Loading