Browse Source

M1: fix regsvr32 unregister gap, add Setup-Site tool, record test evidence and decisions

master
Bottybotsterson 2 weeks ago
parent
commit
0e59732828
6 changed files with 206 additions and 19 deletions
  1. +12
    -12
      IMPLEMENTATION_PLAN.md
  2. +11
    -1
      docs/DECISIONS.md
  3. +94
    -0
      docs/TEST-RESULTS.md
  4. +5
    -1
      tools/Register-Components.ps1
  5. +34
    -0
      tools/Setup-Site.ps1
  6. +50
    -5
      tools/Unregister-Components.ps1

+ 12
- 12
IMPLEMENTATION_PLAN.md View File

@@ -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.


+ 11
- 1
docs/DECISIONS.md View File

@@ -26,7 +26,17 @@ This resolves the open question pragmatically: no experiment needed because the

## Registration tooling

`regsvr32.exe /s "<path>.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 "<path>.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\<ProgID>` and `HKLM:\SOFTWARE\Classes\CLSID\<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\<ProgID>` 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 `<asp .../>` 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



+ 94
- 0
docs/TEST-RESULTS.md View File

@@ -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.

+ 5
- 1
tools/Register-Components.ps1 View File

@@ -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')


+ 34
- 0
tools/Setup-Site.ps1 View File

@@ -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

+ 50
- 5
tools/Unregister-Components.ps1 View File

@@ -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."

Loading…
Cancel
Save

Powered by TurnKey Linux.