| @@ -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. | |||
| @@ -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 | |||
| @@ -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. | |||
| @@ -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') | |||
| @@ -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 | |||
| @@ -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." | |||
Powered by TurnKey Linux.