From 0e59732828be9a657bc7147c4f5729a58335ef56 Mon Sep 17 00:00:00 2001 From: Bottybotsterson Date: Sat, 19 Sep 2026 08:45:19 -0400 Subject: [PATCH] M1: fix regsvr32 unregister gap, add Setup-Site tool, record test evidence and decisions --- IMPLEMENTATION_PLAN.md | 24 ++++----- docs/DECISIONS.md | 12 ++++- docs/TEST-RESULTS.md | 94 +++++++++++++++++++++++++++++++++ tools/Register-Components.ps1 | 6 ++- tools/Setup-Site.ps1 | 34 ++++++++++++ tools/Unregister-Components.ps1 | 55 +++++++++++++++++-- 6 files changed, 206 insertions(+), 19 deletions(-) create mode 100644 docs/TEST-RESULTS.md create mode 100644 tools/Setup-Site.ps1 diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index df0aabc..8173edb 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -3,20 +3,20 @@ Use this as an ordered queue. A checkbox is complete ONLY with actual test evidence in `docs/TEST-RESULTS.md`. ## M0 — Environment and feasibility -- [ ] Inventory Windows version, IIS/Classic ASP/URL Rewrite availability, app pool bitness/identity, WSC registration tool, permissions. -- [ ] Run a minimal registered WSC hello-world test outside IIS. Confirm `.wsc` XML/registration syntax and COM creation. -- [ ] Record tested registration/unregistration commands and path/bitness behavior. -- [ ] Record unsupported assumptions and needed adaptations in `docs/DECISIONS.md`. -Gate: a WSC can be registered, instantiated, called, unregistered, and re-registered on target Windows. +- [x] Inventory Windows version, IIS/Classic ASP/URL Rewrite availability, app pool bitness/identity, WSC registration tool, permissions. +- [x] Run a minimal registered WSC hello-world test outside IIS. Confirm `.wsc` XML/registration syntax and COM creation. +- [x] Record tested registration/unregistration commands and path/bitness behavior. +- [x] Record unsupported assumptions and needed adaptations in `docs/DECISIONS.md`. +Gate: a WSC can be registered, instantiated, called, unregistered, and re-registered on target Windows. **MET** — see `docs/TEST-RESULTS.md`. ## M1 — Vertical HTTP slice -- [ ] Create thin `Default.asp`, `Application.wsc`, and `HomeController.wsc`. -- [ ] Choose and test host-to-COM argument/response contract. If ASP intrinsic objects cannot be passed safely, implement minimal primitive adapter. -- [ ] Add explicit rewrite/deny rules; verify static-file bypass. -- [ ] Add registration/unregistration scripts and component/HTTP tests. -- [ ] Verify `GET /hello` = 200, expected content-type and exact body. -- [ ] Verify direct source access denied, broken component gives safe error, basic concurrency and clean re-registration. -Gate: all M1 SPEC acceptance tests pass on IIS, or mark blocked with evidence; do not call the milestone done without host verification. +- [x] Create thin `Default.asp`, `Application.wsc`, and `HomeController.wsc`. +- [x] Choose and test host-to-COM argument/response contract. If ASP intrinsic objects cannot be passed safely, implement minimal primitive adapter. +- [x] Add explicit rewrite/deny rules; verify static-file bypass. +- [x] Add registration/unregistration scripts and component/HTTP tests. +- [x] Verify `GET /hello` = 200, expected content-type and exact body. +- [x] Verify direct source access denied, broken component gives safe error, basic concurrency and clean re-registration. +Gate: all M1 SPEC acceptance tests pass on IIS, or mark blocked with evidence; do not call the milestone done without host verification. **MET** — see `docs/TEST-RESULTS.md` (includes one real defect found and fixed: `regsvr32 /u` does not remove `.wsc` registration on this host). ## M2 — Lifecycle and errors - [ ] Define per-request context and response ownership. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index d78c4bb..e0279bb 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -26,7 +26,17 @@ This resolves the open question pragmatically: no experiment needed because the ## Registration tooling -`regsvr32.exe /s ".wsc"` registers a Windows Script Component directly (scrobj.dll is the underlying handler associated with the `.wsc` extension). Verified working for both `Application.wsc` and `HomeController.wsc` on this host — see `docs/TEST-RESULTS.md`. `/s /u` unregisters. Both scripts require elevation (registry write); the deployment/registration step is intentionally separate from the request-handling pipeline per SPEC §10 and AGENTS.md. +`regsvr32.exe /s ".wsc"` registers a Windows Script Component directly (scrobj.dll is the underlying handler associated with the `.wsc` extension). Verified working for both `Application.wsc` and `HomeController.wsc` on this host — see `docs/TEST-RESULTS.md`. Both scripts require elevation (registry write); the deployment/registration step is intentionally separate from the request-handling pipeline per SPEC §10 and AGENTS.md. + +**Finding: `regsvr32 /s /u component.wsc` does not actually unregister.** Verified experimentally on this host (Windows Server 2025, build 26100): after `regsvr32 /s /u` on both components, `regsvr32` reports exit code 0, but `HKLM:\SOFTWARE\Classes\` and `HKLM:\SOFTWARE\Classes\CLSID\` are both still present and functional (confirmed via direct registry inspection and a follow-up `CreateObject` that still succeeded). This reproduced identically via `Start-Process -Wait` and via a direct `cmd`-level `regsvr32 /s /u` call with `%ERRORLEVEL%` checked. This is a real scrobj.dll/WSC limitation, not a script bug — do not trust `regsvr32 /u`'s exit code as evidence of removal for `.wsc` files on this host. + +**Fix implemented**: `tools/Unregister-Components.ps1` still calls `regsvr32 /s /u` first (harmless best-effort), then explicitly checks whether `HKLM:\SOFTWARE\Classes\` still resolves to this project's known CLSID, and if so removes exactly that `ProgID` key and exactly that `CLSID` key (recursive), nothing else. It refuses (throws) instead of deleting if the registered CLSID under that ProgID doesn't match what this project expects, to guarantee it can never remove a different component's registration that happens to reuse a ProgID string. Full reversibility cycle (register → verify via HTTP+WSH → unregister → verify HTTP 500 + WSH `CreateObject` failure → re-register → verify recovery) was run end-to-end and passed; see `docs/TEST-RESULTS.md`. + +## Finding: IIS `httpErrors` substitutes its own error page for remote clients by default + +With the out-of-the-box `system.webServer/httpErrors` setting (`errorMode="DetailedLocalOnly"`), a request from a **remote** client (e.g. over the tailnet, not literally `localhost` on the IIS box) that hits a 500 gets IIS's own generic friendly-error HTML page, not whatever `Default.asp` wrote to the response body — even though `Default.asp`'s own `On Error Resume Next` / `Err.Number` handling ran correctly and wrote its intended `"Internal Server Error"` / `text/plain` body. Verified by comparing a `curl` from the Linux host (tailnet IP) against a `curl` run locally on the VM (`http://localhost:8090/hello`) with components deliberately unregistered: the local request returned our own exact `Content-Type: text/plain; charset=utf-8` / `Internal Server Error` body; the remote request returned IIS's generic HTML page. Both are HTTP 500 and both satisfy the SPEC §14/§10 requirement (safe failure, no path or exception text leaked to any client) — this is IIS being conservative for non-local requests, not a defect in our error handling. Documented here so a future milestone doesn't mistake this for `Default.asp`'s error branch not firing. + +Diagnostic note: `system.webServer/asp` is locked (`overrideModeDefault="Deny"`) at the machine level by default, so it cannot be overridden from a site's own `web.config`. Toggling `scriptErrorSentToBrowser` for diagnosis requires `appcmd unlock config /section:system.webServer/asp` first; remember to `appcmd lock config /section:system.webServer/asp` again afterward and to manually strip any `` element that `Set-WebConfigurationProperty` writes into the site's `web.config` before re-locking, or the site's config becomes invalid (locked section referenced from a location that has an explicit override) and every request 500s until the stray element is removed. This happened once during M1 testing on this host and was fixed by editing `web.config` directly; the repository's `web.config` was never affected (this only happened to the deployed copy on the VM). ## Static/source protection approach diff --git a/docs/TEST-RESULTS.md b/docs/TEST-RESULTS.md new file mode 100644 index 0000000..f8492dc --- /dev/null +++ b/docs/TEST-RESULTS.md @@ -0,0 +1,94 @@ +# WSC-MVC — Test Results + +Host under test: `win2025test` (Tailscale 100.127.62.31), Windows Server 2025 Standard, build 10.0.26100, 64-bit. +Access: SSH (key-based, `Administrator`) from the OpenClaw Linux host, driving `cscript`/`powershell` remotely. +Site: `WscMvc`, binding `*:8090`, physical path `C:\Projects\wsc-mvc`, app pool `WscMvc` (64-bit, no managed code). +Date: 2026-09-19. + +## M0 — Environment and feasibility + +| Check | Result | +|---|---| +| Windows version / arch | PASS — Server 2025 Standard, 10.0.26100, 64-bit | +| IIS + Classic ASP (`Web-ASP`) + CGI/ISAPI installed | PASS | +| URL Rewrite module installed | PASS (`rewrite.dll` registered as global module) | +| WebAdministration PowerShell module | PASS (v1.0.0.0) | +| WSC/COM runtime (`scrobj.dll`) present | PASS (System32 and SysWOW64) | +| `regsvr32`/`cscript`/`wscript` present | PASS | +| Minimal WSC register/instantiate/call/unregister/re-register outside IIS | PASS — see M1 component tests below (folded into M1 since the same components serve both gates) | + +Command: `cscript //nologo tests\Test-Components.vbs` — see M1. + +## M1 — Vertical HTTP slice + +### WSH component smoke test + +Command: +``` +cscript //nologo C:\Projects\wsc-mvc\tests\Test-Components.vbs +``` +Result: **PASS** +``` +PASS: /hello -> 200 OK | text/html; charset=utf-8 | Hello from WSC-MVC! +PASS: unknown route -> 404 Not Found +RESULT: ALL PASS +``` + +### HTTP integration test + +Command: +``` +powershell -File C:\Projects\wsc-mvc\tests\Test-Http.ps1 -BaseUrl http://localhost:8090 +``` +Result: **PASS** +``` +PASS: GET /hello status 200 +PASS: GET /hello content-type +PASS: GET /hello body +PASS: GET /Framework/Application.wsc denied +RESULT: ALL PASS +``` + +Also verified directly from the Linux host over the tailnet: +``` +curl -i http://100.127.62.31:8090/hello +``` +-> `HTTP/1.1 200 OK`, `Content-Type: text/html; charset=utf-8`, body `Hello from WSC-MVC!`. **PASS** + +### Direct source/config access denied + +`GET /Framework/Application.wsc` (and by the same `hiddenSegments` mechanism, `Controllers/`, `tests/`, `tools/`, `docs/`) -> `404 Not Found`. **PASS** (covered by `Test-Http.ps1` above for the `.wsc` case; other directories share the identical `web.config` rule and were not individually re-tested by automated script, only spot-checked manually). + +### Safe failure on broken/missing registration + +Steps: unregister both components -> request `/hello` -> observe safe 500 with no leaked path/exception -> re-register -> confirm recovery. + +- From the VM itself (`curl http://localhost:8090/hello` while unregistered): `HTTP/1.1 500 Internal Server Error`, `Content-Type: text/plain; charset=utf-8`, body exactly `Internal Server Error` (our own `Default.asp` error branch). **PASS** +- From a remote/tailnet client under the same unregistered condition: `HTTP/1.1 500 Internal Server Error` with IIS's own generic friendly-error HTML (no path/exception content either). **PASS** — see `docs/DECISIONS.md` for why local vs. remote differ (IIS `httpErrors errorMode=DetailedLocalOnly`, expected default behavior, not a bug). +- `cscript //nologo tests\Test-Components.vbs` while unregistered: `FAIL: could not create WscMvc.Application - ActiveX component can't create object` (test script correctly reports FAILURE, exit code 1 — this is the *expected* result of this specific check, proving the failure path is real and detectable, not silently swallowed). +- After `tools\Register-Components.ps1`: HTTP and WSH tests both back to PASS (shown above). + +### Reversible unregistration + +Command sequence: +``` +powershell -File tools\Register-Components.ps1 +powershell -File tools\Unregister-Components.ps1 +powershell -File tools\Register-Components.ps1 +``` +Result: **PASS**, but only after a fix — see `docs/DECISIONS.md` finding on `regsvr32 /s /u` not actually removing `.wsc` registry entries on this host. `tools\Unregister-Components.ps1` now verifies and, when needed, explicitly removes exactly this project's `ProgID`/`CLSID` pair. Confirmed via direct registry inspection (`HKLM:\SOFTWARE\Classes\WscMvc.Application` / `WscMvc.HomeController` and their `CLSID` subtrees) before and after each step, not just exit codes. + +### Concurrency (basic) + +Command: two `curl` requests to `http://100.127.62.31:8090/hello` fired in parallel from bash (`&` + `wait`). +Result: **PASS** — both returned `200`, both bodies exactly `Hello from WSC-MVC!`, no cross-request leakage or interference observed. This is a basic smoke check only, not a load test (out of scope for M1). + +## Not yet run / out of scope for M1 + +- Formal 400/405 method-not-allowed handling — routing table doesn't exist until M3. +- Broader concurrency/load testing — deferred to M6 per IMPLEMENTATION_PLAN. +- 32-bit app-pool path — not exercised; all app pools on this host are 64-bit and no 32-bit requirement has appeared. Revisit if a future dependency needs 32-bit COM. + +## M1 gate status: **PASS** + +All SPEC §14 acceptance criteria for M1 were run on real Windows/IIS (not inferred) and passed, including one real defect found and fixed during testing (unregistration). Proceeding to M2 (lifecycle/error-handling generalization) is unblocked. diff --git a/tools/Register-Components.ps1 b/tools/Register-Components.ps1 index 28846df..8b20cfa 100644 --- a/tools/Register-Components.ps1 +++ b/tools/Register-Components.ps1 @@ -1,8 +1,12 @@ [CmdletBinding()] param( - [string]$ProjectRoot = (Split-Path -Parent $PSScriptRoot) + [string]$ProjectRoot ) +if (-not $ProjectRoot) { + $ProjectRoot = Split-Path -Parent $PSScriptRoot +} + $components = @( (Join-Path $ProjectRoot 'Framework\Application.wsc'), (Join-Path $ProjectRoot 'Controllers\HomeController.wsc') diff --git a/tools/Setup-Site.ps1 b/tools/Setup-Site.ps1 new file mode 100644 index 0000000..b14977d --- /dev/null +++ b/tools/Setup-Site.ps1 @@ -0,0 +1,34 @@ +[CmdletBinding()] +param( + [string]$SiteName = 'WscMvc', + [string]$PoolName = 'WscMvc', + [string]$PhysicalPath, + [int]$Port = 8090 +) + +Import-Module WebAdministration + +if (-not $PhysicalPath) { + $PhysicalPath = Split-Path -Parent $PSScriptRoot +} + +if (-not (Test-Path "IIS:\AppPools\$PoolName")) { + New-WebAppPool -Name $PoolName | Out-Null + Set-ItemProperty "IIS:\AppPools\$PoolName" -Name managedRuntimeVersion -Value '' + Set-ItemProperty "IIS:\AppPools\$PoolName" -Name enable32BitAppOnWin64 -Value $false + Write-Output "Created app pool $PoolName (64-bit, no managed code)" +} else { + Write-Output "App pool $PoolName already exists" +} + +if (-not (Test-Path "IIS:\Sites\$SiteName")) { + New-Website -Name $SiteName -Port $Port -PhysicalPath $PhysicalPath -ApplicationPool $PoolName | Out-Null + Write-Output "Created site $SiteName on port $Port -> $PhysicalPath" +} else { + Write-Output "Site $SiteName already exists" +} + +Start-WebAppPool -Name $PoolName -ErrorAction SilentlyContinue +Start-Website -Name $SiteName -ErrorAction SilentlyContinue + +Get-Website -Name $SiteName | Select-Object Name, State, PhysicalPath, ApplicationPool diff --git a/tools/Unregister-Components.ps1 b/tools/Unregister-Components.ps1 index a2a434c..ca92db1 100644 --- a/tools/Unregister-Components.ps1 +++ b/tools/Unregister-Components.ps1 @@ -1,23 +1,68 @@ [CmdletBinding()] param( - [string]$ProjectRoot = (Split-Path -Parent $PSScriptRoot) + [string]$ProjectRoot ) +if (-not $ProjectRoot) { + $ProjectRoot = Split-Path -Parent $PSScriptRoot +} + +# NOTE (see docs/DECISIONS.md): on this host, `regsvr32 /s /u component.wsc` reports +# exit code 0 but leaves the ProgID/CLSID registry entries in place - verified experimentally, +# not a theoretical concern. regsvr32 /u is still attempted first (best effort, harmless if it +# no-ops), then registry state is checked and, if the entries survived, they are removed +# explicitly - scoped to exactly this project's ProgID/CLSID pairs so an unrelated component +# can never be touched even if it happened to reuse a ProgID string. + $components = @( - (Join-Path $ProjectRoot 'Controllers\HomeController.wsc'), - (Join-Path $ProjectRoot 'Framework\Application.wsc') + @{ Path = (Join-Path $ProjectRoot 'Controllers\HomeController.wsc'); ProgId = 'WscMvc.HomeController'; ClassId = '{87488446-60BE-4068-8368-0B709BB68F3F}' }, + @{ Path = (Join-Path $ProjectRoot 'Framework\Application.wsc'); ProgId = 'WscMvc.Application'; ClassId = '{851C7763-1638-42FE-A166-BF3DD3A96A88}' } ) -foreach ($path in $components) { +function Remove-ProjectComponent { + param([string]$ProgId, [string]$ClassId) + + $progIdKey = "HKLM:\SOFTWARE\Classes\$ProgId" + $clsidKey = "HKLM:\SOFTWARE\Classes\CLSID\$ClassId" + + $registeredClassId = $null + if (Test-Path "$progIdKey\CLSID") { + $registeredClassId = (Get-ItemProperty "$progIdKey\CLSID" -ErrorAction SilentlyContinue).'(default)' + } + + if (-not (Test-Path $progIdKey) -and -not (Test-Path $clsidKey)) { + Write-Output " Already absent: $ProgId / $ClassId" + return + } + + if ($registeredClassId -and $registeredClassId -ne $ClassId) { + throw "Refusing to remove '$ProgId': registered CLSID ($registeredClassId) does not match this project's expected CLSID ($ClassId). This ProgID may belong to a different component." + } + + if (Test-Path $progIdKey) { + Remove-Item -Path $progIdKey -Recurse -Force + Write-Output " Removed $progIdKey" + } + if (Test-Path $clsidKey) { + Remove-Item -Path $clsidKey -Recurse -Force + Write-Output " Removed $clsidKey" + } +} + +foreach ($component in $components) { + $path = $component.Path if (-not (Test-Path $path)) { Write-Warning "Component not found, skipping: $path" continue } + Write-Output "Unregistering $path" $proc = Start-Process -FilePath 'regsvr32.exe' -ArgumentList "/s /u `"$path`"" -PassThru -Wait -WindowStyle Hidden if ($proc.ExitCode -ne 0) { - throw "regsvr32 /u failed for $path (exit $($proc.ExitCode))" + Write-Warning "regsvr32 /u reported exit $($proc.ExitCode) for $path; verifying registry state directly" } + + Remove-ProjectComponent -ProgId $component.ProgId -ClassId $component.ClassId } Write-Output "All WSC-MVC components unregistered."