From 6196b788fd10d57f3d7608c9fc9d8ad1bc93b59a Mon Sep 17 00:00:00 2001 From: Bottybotsterson Date: Sat, 19 Sep 2026 12:24:51 -0400 Subject: [PATCH] Test harness as its own app (WscMvcTests site), sharing the same framework At Daniel's explicit direction: /self-test moves off the production site entirely, onto a second, separate IIS site/app. - test-app/public/: own site (WscMvcTests, *:8091), own web.config (routes only /self-test), own logs/ - genuinely isolated from production traffic - Default.asp duplicated into test-app/public/ (intentionally byte- identical - the bootstrap is fully generic, IIS just requires each site to have its own physical files; documented 'keep in sync' in both copies) - No framework/business-logic duplication: both sites share the exact same registered Framework/Controllers COM components. Application.wsc's /self-test route case is unchanged; only removed the production site's web.config rewrite rule that used to expose it there - production public/web.config: dropped /self-test rewrite rule; GET /self-test on production now plain 404 (nothing routes there) - tools/Setup-Site.ps1: now automates the logs/ IIS_IUSRS ACL grant (previously a manual one-off command), scoped per-site; also hardened with ='Stop' + a PhysicalPath pre-check after finding it silently continued past a real New-Website failure - tests/Test-Http.ps1: production-only now (checks /hello + confirms /self-test is genuinely unreachable there); tests/run-self-test.sh points at the new WscMvcTests site - Verified both sites end-to-end: correct routes, correct 404s for cross-site/Framework access, separate log files, idempotent Setup-Site.ps1 reruns for both sites - SPEC.md, docs/ARCHITECTURE.md, docs/DECISIONS.md, docs/TEST-RESULTS.md, README.md updated to match; README also brought current after being stale since M1 --- README.md | 43 ++++++++++++++----- SPEC.md | 14 +++++-- docs/ARCHITECTURE.md | 41 ++++++++++++------ docs/DECISIONS.md | 16 ++++++- docs/TEST-RESULTS.md | 27 +++++++++++- public/web.config | 12 ++++-- test-app/public/Default.asp | 84 +++++++++++++++++++++++++++++++++++++ test-app/public/web.config | 36 ++++++++++++++++ tests/Test-Http.ps1 | 23 +++++----- tools/Setup-Site.ps1 | 18 ++++++++ 10 files changed, 272 insertions(+), 42 deletions(-) create mode 100644 test-app/public/Default.asp create mode 100644 test-app/public/web.config diff --git a/README.md b/README.md index 3585f91..94fddac 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,37 @@ -# WSC-MVC Agent Starter Pack +# WSC-MVC -This is a *planning and agent-control package*, not yet an executable framework. +A small, WSC-first MVC framework for Classic ASP on IIS: VBScript Windows Script Components registered as COM, dispatched by a single thin `Default.asp` bootstrap. See `SPEC.md` for the full contract, `IMPLEMENTATION_PLAN.md` for milestone status, and `docs/` for architecture, decisions, and test evidence — those are the authoritative, kept-current docs; this file is just an orientation map. -## Files -- `SPEC.md` — authoritative product, architectural, security, milestone and acceptance specification. -- `AGENTS.md` — shared instructions for coding agents. -- `CLAUDE.md` — Claude Code entry point; defers to AGENTS.md. -- `IMPLEMENTATION_PLAN.md` — gated execution checklist. +## Layout -## Start a coding agent -Copy these files to the **root of the new WSC-MVC repository**, then issue: +Two separate IIS sites, sharing one set of framework/controller COM components: -> Read AGENTS.md, SPEC.md, IMPLEMENTATION_PLAN.md. Execute M0 and then the smallest testable piece of M1. Record actual commands/results and do not claim IIS tests passed unless you ran them on Windows IIS. +- `public/` — the production site's IIS physical path. Contains only `Default.asp` and `web.config`. +- `test-app/public/` — a second, separate site exposing `GET /self-test` (JSON test harness), so diagnostics aren't reachable on the production site/port. See `docs/ARCHITECTURE.md`. +- `Framework/`, `Controllers/` — the actual WSC components (`.wsc`), registered once via COM and used by both sites. Never served over HTTP by either site. +- `logs/`, `test-app/logs/` — each site's own runtime log, written by the app, never served over HTTP. +- `tests/`, `tools/`, `docs/` — test scripts, deployment/registration tooling, and documentation. Never served over HTTP. -Choose the Windows/IIS machine as the execution host for M0/M1 wherever possible. A Linux-only agent can prepare text/config but cannot establish Windows COM/IIS behavior. +## Deploy and register (on the target Windows/IIS host) + +```powershell +powershell -File tools\Register-Components.ps1 +powershell -File tools\Setup-Site.ps1 +powershell -File tools\Setup-Site.ps1 -SiteName WscMvcTests -PoolName WscMvcTests -PhysicalPath \test-app\public -Port 8091 +``` + +Both `Setup-Site.ps1` invocations are idempotent — safe to re-run after any deploy. + +## Test + +```powershell +cscript //nologo tests\Test-Components.vbs # WSH smoke test, no IIS needed +powershell -File tests\Test-Http.ps1 -BaseUrl http://localhost:8090 # production site +``` +```bash +./tests/run-self-test.sh http://:8091 # test-app site, plain curl+JSON, any CLI +``` + +## Status + +M0–M2 gated PASS with real test evidence on Windows/IIS; see `docs/TEST-RESULTS.md`. Next milestone: M3 (explicit route table) per `IMPLEMENTATION_PLAN.md`. diff --git a/SPEC.md b/SPEC.md index bebcf8d..c58cfe2 100644 --- a/SPEC.md +++ b/SPEC.md @@ -26,13 +26,21 @@ Prefer a bootstrap with `Option Explicit`, no business logic, no includes, no em ## 5. Target project structure -Revised 2026-09-19 (explicit user direction, superseding the original v0.1 tree): IIS's site physical path is the `public/` folder **only**. Every other project directory is a sibling of `public/`, entirely outside the served tree — not reachable by IIS regardless of `web.config`, which is a stronger guarantee than request-filtering rules over a shared directory. `Default.asp` reaches sibling directories (e.g. `logs/`) via a parent-relative `Server.MapPath`, which requires `enableParentPaths=true` for the site. See `docs/DECISIONS.md` for the full rationale and `docs/ARCHITECTURE.md` for how it's wired up. +Revised 2026-09-19 (explicit user direction, superseding the original v0.1 tree): IIS's site physical path is a `public/` folder **only** — never the project root. Every other project directory is a sibling of the folder actually served, entirely outside the served tree — not reachable by IIS regardless of `web.config`, which is a stronger guarantee than request-filtering rules over a shared directory. `Default.asp` reaches sibling directories (e.g. `logs/`) via a parent-relative `Server.MapPath`, which requires `enableParentPaths=true` for the site. See `docs/DECISIONS.md` for the full rationale and `docs/ARCHITECTURE.md` for how it's wired up. + +Further revised 2026-09-19 (same day, same direction): the diagnostics/test harness (`GET /self-test`) is its own separate IIS site/app (`test-app/`), not a route on the production site. Both sites share the exact same `Framework/`/`Controllers/` COM components (registered once, globally) — nothing about the framework or business logic is duplicated, only the thin per-site `Default.asp`+`web.config` wiring exists twice (and `Default.asp` is intentionally byte-identical in both places; see `docs/DECISIONS.md`). ``` WSC-MVC/ - public/ + public/ # production site's IIS physical path Default.asp web.config + test-app/ + public/ # test-app site's IIS physical path (separate site/port) + Default.asp # intentionally identical to public/Default.asp + web.config # only routes /self-test + logs/ + app.log # this site's own log, separate from the production one Framework/ Application.wsc Router.wsc # phase 3 @@ -46,7 +54,7 @@ WSC-MVC/ Home.html # phase 4 Layout.html # phase 4 logs/ - app.log + app.log # production site's log tests/ Test-Components.vbs Test-Http.ps1 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 64842b2..824f100 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -29,18 +29,24 @@ GET /hello - `RequestContext.Initialize` generates a correlation id from `Fix(Timer)` plus `Scripting.FileSystemObject.GetTempName()` — not `Randomize`/`Rnd()`, which was tried first and shown experimentally to collide when two contexts are created within the same clock tick (see `docs/DECISIONS.md`). - `Application.LogOutcome` appends one line per request (timestamp, correlation id, method, path, status line, elapsed ms) to `logs/app.log`, guarded end-to-end by `On Error Resume Next` so a logging failure can never affect the HTTP response. Concurrent writers are serialized with a manual lock-file mutex (`logs/app.log.lock`, via `CreateTextFile(..., OverwriteExisting:=False)`) since no ASP intrinsic locking primitive (`Application.Lock`) is available to code that must not reference ASP intrinsics. The retry budget is deliberately short (see `docs/DECISIONS.md`): logging is explicitly best-effort and may drop lines under heavy concurrency without affecting correctness of the response. -## Test harness: GET /self-test (JSON, frontend-agnostic) +## Test harness: GET /self-test — its own app, same shared framework -`Controllers/SelfTestController.wsc` (`WscMvc.SelfTestController`) exposes the same checks as `tests/Test-Components.vbs`, but callable over plain HTTP and returning JSON — no PowerShell, cscript, or SSH access to the VM required. `Application.Run` routes `path = "/self-test"` to it. The HTTP status is always `200` if the self-test mechanism itself ran (a broken `SelfTestController`/`Application` still degrades to the normal `500` path); the JSON body's top-level `"ok"` field and each check's `"pass"` field carry the actual test results — this is conventional health-check design (5xx is reserved for the diagnostics mechanism being broken, not for a failed assertion inside it). +`Controllers/SelfTestController.wsc` (`WscMvc.SelfTestController`) exposes the same checks as `tests/Test-Components.vbs`, but callable over plain HTTP and returning JSON — no PowerShell, cscript, or SSH access to the VM required. `Application.Run` routes `path = "/self-test"` to it. + +**This lives on its own IIS site (`test-app/`), not the production site.** Changed 2026-09-19 at Daniel's explicit direction: a diagnostics endpoint permanently reachable on the same site/port as real traffic was the wrong shape. `test-app/public/` is a second, separate IIS site's physical path (own site name, own app pool, own port, own `logs/`), whose `web.config` routes `/self-test` and nothing else. The production site's `web.config` no longer has a `/self-test` rewrite rule at all — `GET /self-test` against the production site now returns a plain `404` (nothing rewrites that path to `Default.asp`). + +**"Own app, same framework" is achieved without duplicating any business logic**: `Application.wsc`'s `Run` method still has (and needs) its `path = "/self-test"` branch — that's shared framework code, registered once globally via COM, used by whichever site's `web.config` chooses to route a URL to it. The only genuinely duplicated file is `Default.asp` itself (also present at `test-app/public/Default.asp`), and only because IIS requires each site to have its own physical files — the bootstrap logic in that file is fully generic (doesn't hardcode routes or reference which site is calling it), so the two copies are intentionally byte-identical. If `Default.asp`'s bootstrap logic ever needs to change, change both copies the same way (see the comment at the top of each file). + +The HTTP status is always `200` if the self-test mechanism itself ran (a broken `SelfTestController`/`Application` still degrades to the normal `500` path via the same central error handling); the JSON body's top-level `"ok"` field and each check's `"pass"` field carry the actual test results — conventional health-check design (5xx is reserved for the diagnostics mechanism being broken, not for a failed assertion inside it). ``` -curl http://100.127.62.31:8090/self-test +curl http://100.127.62.31:8091/self-test {"ok":true,"checks":[{"name":"request_context_contract","pass":true,"detail":""}, ...]} ``` -`tests/run-self-test.sh` wraps this in `curl` + `python3 -m json.tool`, runnable from any CLI (this was verified working directly from the Linux OpenClaw host, not just from the VM). `tests/Test-Http.ps1` also calls `/self-test` and folds each check into its own PASS/FAIL output, complementing (not duplicating) its own direct `/hello` and denied-`.wsc`-access checks, which `/self-test` cannot observe about itself since those are IIS-layer/`web.config` concerns, not component-layer ones. +`tests/run-self-test.sh` wraps this in `curl` + `python3 -m json.tool`, runnable from any CLI (verified working directly from the Linux OpenClaw host, not just from the VM) — point it at the test-app site's URL. `tests/Test-Http.ps1` tests the production site only (`/hello`, and confirms `/self-test` is genuinely unreachable there). -`SelfTestController`'s error details intentionally include raw `Err.Description` text — unlike every other route, which must never leak internals to an arbitrary caller, this route's entire purpose is diagnostics. It is a dev/test-milestone tool; reconsider whether it should be gated or removed before any production deployment during the M6 hardening pass. +`SelfTestController`'s error details intentionally include raw `Err.Description` text — unlike every other route, which must never leak internals to an arbitrary caller, this route's entire purpose is diagnostics. It is a dev/test-milestone tool; reconsider whether the test-app site should be gated or removed before any production deployment during the M6 hardening pass. ## COM identity @@ -53,14 +59,25 @@ curl http://100.127.62.31:8090/self-test CLSIDs are fixed at creation and must never be recycled for a different component (AGENTS.md). `Application`'s public `Run` signature changed between M1 and M2 (added a leading `ctx` parameter) under the same CLSID; acceptable because this is active pre-release (v0.1) development with exactly one caller (`Default.asp`, updated in lockstep) — not a claim that live interface changes are safe for a published/external client. -## IIS site (test host: win2025test, 100.127.62.31) +## IIS sites (test host: win2025test, 100.127.62.31) -- Site: `WscMvc`, binding `*:8090`, physical path `C:\Projects\wsc-mvc\public` — **not** the project root. `public/` contains only `Default.asp` and `web.config`; every other directory (`Framework/`, `Controllers/`, `tests/`, `tools/`, `docs/`, `logs/`) is a sibling of `public/`, outside IIS's site physical path entirely. Changed 2026-09-19 at Daniel's explicit direction (see `docs/DECISIONS.md`); previously the site root was the whole project directory with those folders hidden via `hiddenSegments`. -- App pool: `WscMvc`, 64-bit, no managed code. Anonymous authentication identity: `IUSR`. -- `public/web.config` keeps only a `.wsc/.vbs/.ps1/.md` extension denylist as defense-in-depth (in case a stray file ever lands directly in `public/`); no `hiddenSegments` are needed since the directories they used to hide no longer exist under the served root at all. -- `enableParentPaths=true` is set for this site (scoped via `appcmd ... /commit:apphost`, a `` block in `applicationHost.config` — not a machine-wide unlock of the locked `system.webServer/asp` section) so `Default.asp` can `Server.MapPath("../logs")` to reach `logs/`. This setting affects only server-side script `MapPath`/`#include` resolution, not client-supplied URL paths — IIS's own URL normalization independently rejects `..`-traversal in an incoming request regardless of this setting; verified with a direct request attempt, see `docs/DECISIONS.md`. -- `logs/` (`C:\Projects\wsc-mvc\logs`, unchanged physical location from before this restructure) has an explicit, scoped `icacls ... /grant "IIS_IUSRS:(OI)(CI)M"` so classic ASP (impersonating `IUSR` for anonymous requests) can write `app.log`. No other project directory grants `IUSR`/`IIS_IUSRS` write access — verified with a recursive `icacls /T` scan, see `docs/DECISIONS.md`. -- Default document is `Default.asp`; `/hello` and `/self-test` are served via rewrite rules, not the default document. +Two separate IIS sites now, both set up/reconciled by the same `tools/Setup-Site.ps1` (parameterized by name/pool/path/port; idempotent, safe to re-run): + +| | Production | Test app | +|---|---|---| +| Site name | `WscMvc` | `WscMvcTests` | +| Port | `*:8090` | `*:8091` | +| Physical path | `C:\Projects\wsc-mvc\public` | `C:\Projects\wsc-mvc\test-app\public` | +| App pool | `WscMvc` | `WscMvcTests` | +| Routes | `/hello` | `/self-test` | +| Logs | `C:\Projects\wsc-mvc\logs\app.log` | `C:\Projects\wsc-mvc\test-app\logs\app.log` | + +Both are 64-bit, no managed code, anonymous auth identity `IUSR`. Each site's `public/` contains only `Default.asp` and `web.config`; every other directory (`Framework/`, `Controllers/`, `tests/`, `tools/`, `docs/`, both `logs/` folders) is a sibling of whichever `public/` is actually served, outside both sites' physical paths entirely. Changed 2026-09-19 at Daniel's explicit direction (see `docs/DECISIONS.md`); previously there was one site with the whole project directory as its root and those folders hidden via `hiddenSegments`, and `/self-test` was a route on that same site. + +- Each site's `web.config` keeps only a `.wsc/.vbs/.ps1/.md` extension denylist as defense-in-depth (in case a stray file ever lands directly in its `public/`); no `hiddenSegments` are needed since the directories they used to hide no longer exist under either served root at all. +- `enableParentPaths=true` is set for **both** sites (scoped via `appcmd ... /commit:apphost`, a separate `` block per site in `applicationHost.config` — not a machine-wide unlock of the locked `system.webServer/asp` section) so each site's `Default.asp` can `Server.MapPath("../logs")` to reach its own `logs/`. This setting affects only server-side script `MapPath`/`#include` resolution, not client-supplied URL paths — IIS's own URL normalization independently rejects `..`-traversal in an incoming request regardless of this setting; verified with a direct request attempt, see `docs/DECISIONS.md`. +- Each site's `logs/` has an explicit, scoped `icacls ... /grant "IIS_IUSRS:(OI)(CI)M"` (now automated by `tools/Setup-Site.ps1`, previously a manual one-off command) so classic ASP (impersonating `IUSR` for anonymous requests) can write `app.log`. No other project directory grants `IUSR`/`IIS_IUSRS` write access — verified with a recursive `icacls /T` scan, see `docs/DECISIONS.md`. +- Default document is `Default.asp` on both sites; each site's one route is served via a rewrite rule, not the default document. ## Deferred to later milestones (do not implement early) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 0fdc8a0..a63f3ae 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -46,7 +46,7 @@ M1's original approach: IIS `requestFiltering/hiddenSegments` (blocks any URL pa ## Site/port allocation -New dedicated site `WscMvc`, app pool `WscMvc` (64-bit, no managed code), binding `*:8090`, physical path `C:\Projects\wsc-mvc`. Chosen to avoid the existing `*:80` and `*:8080` bindings already in use on this VM. +New dedicated site `WscMvc`, app pool `WscMvc` (64-bit, no managed code), binding `*:8090`. Chosen to avoid the existing `*:80` and `*:8080` bindings already in use on this VM. (Physical path later changed to `C:\Projects\wsc-mvc\public` — see the `public/`-only webroot entry. A second site, `WscMvcTests` on `*:8091`, was added later still — see the "test harness as its own app" entry.) ## M2 — Lifecycle, central error handling, correlation-safe diagnostics (2026-09-19) @@ -85,3 +85,17 @@ Daniel asked for `Default.asp` to be served out of a `public/` folder with `publ **Verified, not assumed, that this doesn't open a traversal hole**: `enableParentPaths` only affects server-side `MapPath`/`#include` resolution of literal strings already in the script (here, the hardcoded `"../logs"` - never user input). It has no effect on how IIS resolves an incoming client URL. Confirmed directly: `GET /../Framework/Application.wsc`, `GET /..%2fFramework/Application.wsc`, `GET /..%252fFramework/Application.wsc`, and `GET /../logs/app.log` against the live site all returned `404`/`403` — IIS's own URL normalization and request filtering reject these independently of the ASP-level setting. **Full regression re-run after the restructure**: `tests/Test-Components.vbs` (WSH), `tests/Test-Http.ps1` (HTTP), and `tests/run-self-test.sh` (curl+JSON) all pass; `/hello` and `/self-test` work correctly; `logs/app.log` confirmed actually being written via the new parent-relative path (not just assumed - read back the full file content over SSH); `GET /Framework/Application.wsc` returns `404` (now because the path genuinely doesn't exist under the site root, not because of a `hiddenSegments` rule). + +## Test harness as its own app, sharing the same framework (2026-09-19) + +Daniel: "the tests need to be its own app in its own folder but still uses the same framework as the real app." `/self-test` had been a route on the production `WscMvc` site — a permanent diagnostics endpoint reachable on the same port as real traffic, which is the wrong shape once you think about it as "would this be here in production." + +**Design**: a second IIS site, `WscMvcTests` (`*:8091`, physical path `test-app/public/`), sharing every `Framework/`/`Controllers/` COM component with production (registered once, globally — COM resolution doesn't care which IIS site's worker process calls `Server.CreateObject`). The only file that exists twice is `Default.asp` (`public/Default.asp` and `test-app/public/Default.asp`), and only because IIS requires each site to have its own physical files, not because the bootstrap logic differs between them — it's fully generic (doesn't hardcode which routes exist or which site is calling it), so the two copies are deliberately byte-identical, each carrying a comment pointing at the other with a "keep in sync" note. Considered an NTFS hardlink to guarantee they can never drift, but that adds real complexity (git doesn't preserve hardlinks across a checkout; a deployment step would need to re-establish it) for a ~40-line file that's designed to almost never change — plain, documented duplication is simpler and matches AGENTS.md's "prefer the simplest viable contracts" guidance. + +**What moved where**: `public/web.config` (production) no longer has a `/self-test` rewrite rule — `Application.wsc`'s `Run` method still technically has that route case (shared framework code), but nothing on the production site's `web.config` can ever reach it, so `GET /self-test` against production now just 404s like any other undefined path. `test-app/public/web.config` has only the `/self-test` rule (no `/hello`) since testing `/hello`'s behavior is already covered indirectly — `SelfTestController.RunSelfTest` exercises `Application.Run("/hello", ...)` in-process as one of its own checks. + +**Tooling gap closed while doing this**: the `icacls ... /grant "IIS_IUSRS:(OI)(CI)M"` grant on a site's `logs/` folder had been a manual one-off SSH command during earlier milestones, never actually scripted. Adding a second site made this an immediate, concrete problem (a manual step that's easy to forget once there are two sites to set up), so `tools/Setup-Site.ps1` now performs it automatically, idempotently (creates the `logs/` folder if missing, `icacls /grant` is itself idempotent - doesn't duplicate the ACE on repeat runs), scoped to whichever site's own logs folder is being set up (`Join-Path (Split-Path -Parent $PhysicalPath) 'logs'`, so it's always the correct sibling for that specific site). + +**Real bug found and fixed while doing this, in my own tooling**: my first attempt to invoke `Setup-Site.ps1 -PhysicalPath "C:\Projects\wsc-mvc\test-app\public"` over a chained SSH/cmd/PowerShell command failed with `New-Website : Parameter 'PhysicalPath' should point to existing path` — nested shell-quoting layers had passed the literal string `'C:\Projects\wsc-mvc\test-app\public'` (with the single quotes as literal characters) as the parameter value. The script's own `Write-Output "Created site..."` line printed unconditionally regardless of whether `New-Website` actually succeeded (it hadn't - `$ErrorActionPreference` was the default `Continue`, so the error was non-fatal and the script kept going, prompting a misleading "success" message followed by a real `IIS:\Sites\WscMvcTests` not-found error on the next line). Fixed two ways: (1) stopped fighting nested quoting and instead wrote the actual invocation into a small script file copied to the VM and run with `-File`, which sidesteps the quoting problem entirely; (2) added `$ErrorActionPreference = 'Stop'` and an explicit `Test-Path $PhysicalPath` pre-check to `Setup-Site.ps1` itself, so a bad path now fails loudly and immediately instead of printing a false-success message and failing confusingly two lines later. Re-verified both sites set up correctly and idempotently after the fix. + +**Verified after the split**: production `GET /hello` → `200`; production `GET /self-test` → `404` (genuinely unreachable now); test-app `GET /self-test` → `200` with correct JSON; test-app `GET /hello` → `404` (not routed there, expected); both sites' `Framework/Application.wsc` → `404`; both sites write to their own separate `logs/app.log` (confirmed distinct file paths, distinct content); re-running `Setup-Site.ps1` for both sites a second time is a true no-op. Full `tests/Test-Components.vbs`, `tests/Test-Http.ps1` (now production-only), and `tests/run-self-test.sh` (now pointed at the test-app site) all pass. diff --git a/docs/TEST-RESULTS.md b/docs/TEST-RESULTS.md index 701c7e0..8c6d9ee 100644 --- a/docs/TEST-RESULTS.md +++ b/docs/TEST-RESULTS.md @@ -2,7 +2,7 @@ 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\public` (updated 2026-09-19; project root through the M1/M2 sections below), app pool `WscMvc` (64-bit, no managed code). +Sites: `WscMvc` (`*:8090`, physical path `C:\Projects\wsc-mvc\public`; project root through the M1/M2 sections below before the `public/` restructure) and, from the "test harness split" section onward, `WscMvcTests` (`*:8091`, `C:\Projects\wsc-mvc\test-app\public`). Both 64-bit, no managed code. Date: 2026-09-19. ## M0 — Environment and feasibility @@ -198,3 +198,28 @@ All three: **PASS**, identical to pre-restructure output (the client-facing cont `GET /Framework/Application.wsc` -> `404`, now because the path is genuinely absent from the served tree rather than a `hiddenSegments` rule. **PASS** Path-traversal safety of `enableParentPaths` verified directly, not assumed: `GET /../Framework/Application.wsc`, `GET /..%2fFramework/Application.wsc`, `GET /..%252fFramework/Application.wsc`, and `GET /../logs/app.log` against the live site all returned `404`/`403` — confirms `enableParentPaths` (a server-side script `MapPath` setting) has no effect on how IIS resolves client-supplied URLs. **PASS** + +## Test harness split into its own app: `WscMvcTests` site (2026-09-19) + +Commands run on `win2025test` after creating `test-app/public/` and adding the `logs/` ACL automation to `tools/Setup-Site.ps1`: + +``` +powershell -File tools\Setup-Site.ps1 +powershell -File tools\Setup-Site.ps1 -SiteName WscMvcTests -PoolName WscMvcTests -PhysicalPath C:\Projects\wsc-mvc\test-app\public -Port 8091 +``` +Result: **PASS** for both. First run reconciled the existing production site and additionally granted the (previously manual, now automated) `logs/` ACL. Second run created the new `WscMvcTests` site, app pool, and its own `test-app/logs/` with the ACL grant. Re-ran both commands a second time to confirm idempotency: no changes reported either time. **PASS** + +One real bug found and fixed in `tools/Setup-Site.ps1` itself while doing this: a bad nested-quoting invocation caused `New-Website` to fail (`Parameter 'PhysicalPath' should point to existing path`) but the script printed "Created site..." anyway and kept going, since `$ErrorActionPreference` defaulted to `Continue`. Fixed the invocation (script file instead of an inline nested-quoted command) and hardened the script itself: added `$ErrorActionPreference = 'Stop'` plus an explicit `Test-Path $PhysicalPath` pre-check, so a bad path now fails loudly immediately rather than printing a misleading success message. Re-verified clean runs after the fix. + +``` +curl http://100.127.62.31:8090/hello -> 200, unchanged +curl http://100.127.62.31:8090/self-test -> 404 (no longer exposed on production) +curl http://100.127.62.31:8091/self-test -> 200, correct JSON +curl http://100.127.62.31:8091/hello -> 404 (not routed on the test-app site) +curl http://100.127.62.31:8091/Framework/Application.wsc -> 404 (unreachable on this site too) +``` +All: **PASS** + +Confirmed the two sites write to genuinely separate log files (`C:\Projects\wsc-mvc\logs\app.log` vs `C:\Projects\wsc-mvc\test-app\logs\app.log`), read back over SSH with distinct content. **PASS** + +Full regression: `tests/Test-Components.vbs` (WSH, unaffected by the site split — doesn't go through IIS), `tests/Test-Http.ps1` (updated to test the production site only: `/hello` plus confirming `/self-test` is genuinely unreachable there), and `tests/run-self-test.sh` (now pointed at the `WscMvcTests` site, `http://100.127.62.31:8091`). All: **PASS**. diff --git a/public/web.config b/public/web.config index afd6b8d..ebeafa5 100644 --- a/public/web.config +++ b/public/web.config @@ -21,16 +21,20 @@ + - - - - diff --git a/test-app/public/Default.asp b/test-app/public/Default.asp new file mode 100644 index 0000000..1a30075 --- /dev/null +++ b/test-app/public/Default.asp @@ -0,0 +1,84 @@ +<%@ Language="VBScript" %> +<% Option Explicit %> +<% +' Intentionally byte-identical to /public/Default.asp. This is the test +' app's own bootstrap (separate IIS site/physical path from production), +' but the bootstrap logic itself is fully generic - it doesn't hardcode +' which routes exist (that lives in Framework/Application.wsc, shared by +' both sites via COM registration) or which site is calling it. If you +' change this file's logic, change public/Default.asp the same way, and +' vice versa. See docs/ARCHITECTURE.md for why there are two IIS sites. +Dim route, httpMethod, logDir, ctx, app, statusLine, contentType, body + +route = Request.QueryString("route") +httpMethod = Request.ServerVariables("REQUEST_METHOD") +' logs/ deliberately lives outside the served "public" webroot (IIS site +' physical path = public/), so a parent-relative MapPath is required here - +' requires enableParentPaths=true for this site. See docs/DECISIONS.md. +logDir = Server.MapPath("../logs") + +Set ctx = Nothing +On Error Resume Next +Set ctx = Server.CreateObject("WscMvc.RequestContext") +If Err.Number <> 0 Then + Err.Clear + On Error Goto 0 + Response.Status = "500 Internal Server Error" + Response.ContentType = "text/plain; charset=utf-8" + Response.Write "Internal Server Error" + Response.End +End If +On Error Goto 0 + +On Error Resume Next +ctx.Initialize route, httpMethod, logDir +If Err.Number <> 0 Then + Err.Clear + On Error Goto 0 + Set ctx = Nothing + Response.Status = "500 Internal Server Error" + Response.ContentType = "text/plain; charset=utf-8" + Response.Write "Internal Server Error" + Response.End +End If +On Error Goto 0 + +Set app = Nothing +On Error Resume Next +Set app = Server.CreateObject("WscMvc.Application") +If Err.Number <> 0 Then + Err.Clear + On Error Goto 0 + Set ctx = Nothing + Response.Status = "500 Internal Server Error" + Response.ContentType = "text/plain; charset=utf-8" + Response.Write "Internal Server Error" + Response.End +End If +On Error Goto 0 + +statusLine = "" +contentType = "" +body = "" + +On Error Resume Next +app.Run ctx, statusLine, contentType, body +If Err.Number <> 0 Then + Err.Clear + On Error Goto 0 + Set app = Nothing + Set ctx = Nothing + Response.Status = "500 Internal Server Error" + Response.ContentType = "text/plain; charset=utf-8" + Response.Write "Internal Server Error" + Response.End +End If +On Error Goto 0 + +Response.Status = statusLine +Response.ContentType = contentType +Response.Write body + +Set app = Nothing +Set ctx = Nothing +%> diff --git a/test-app/public/web.config b/test-app/public/web.config new file mode 100644 index 0000000..cf4e7ba --- /dev/null +++ b/test-app/public/web.config @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Test-Http.ps1 b/tests/Test-Http.ps1 index ae56b1d..10908df 100644 --- a/tests/Test-Http.ps1 +++ b/tests/Test-Http.ps1 @@ -25,20 +25,23 @@ try { Report $false "GET /hello request" $_.Exception.Message } -# GET /self-test is the frontend-agnostic JSON harness (Controllers/SelfTestController.wsc) - -# callable from any CLI via plain HTTP, not just PowerShell/cscript on the VM. This exercises -# the same component-level checks as tests/Test-Components.vbs, in-process, over HTTP. +# /self-test lives on the separate test-app/ site now (see docs/ARCHITECTURE.md +# and tests/run-self-test.sh, which is what actually exercises it) - this +# script tests the production app only. Confirm the diagnostics endpoint is +# genuinely NOT exposed here, since that separation is the point. try { $selfTestResp = Invoke-WebRequest -Uri "$BaseUrl/self-test" -UseBasicParsing - Report ($selfTestResp.StatusCode -eq 200) "GET /self-test status 200" "got $($selfTestResp.StatusCode)" - Report ($selfTestResp.Headers['Content-Type'] -eq 'application/json; charset=utf-8') "GET /self-test content-type" "got $($selfTestResp.Headers['Content-Type'])" - $selfTestJson = $selfTestResp.Content | ConvertFrom-Json - foreach ($check in $selfTestJson.checks) { - Report ([bool]$check.pass) "self-test: $($check.name)" $check.detail + Report $false "GET /self-test not exposed on production" "got $($selfTestResp.StatusCode)" +} catch [System.Net.WebException] { + $webResp = $_.Exception.Response + if ($webResp) { + $code = [int]$webResp.StatusCode + Report ($code -eq 404) "GET /self-test not exposed on production" "got $code" + } else { + Report $false "GET /self-test not exposed on production" $_.Exception.Message } - Report ([bool]$selfTestJson.ok) "GET /self-test overall ok" "detail in individual checks above" } catch { - Report $false "GET /self-test request" $_.Exception.Message + Report $false "GET /self-test not exposed on production" $_.Exception.Message } # Framework/ is a sibling of the "public" webroot, not a rule-denied diff --git a/tools/Setup-Site.ps1 b/tools/Setup-Site.ps1 index 201f6a4..fdc370a 100644 --- a/tools/Setup-Site.ps1 +++ b/tools/Setup-Site.ps1 @@ -6,6 +6,8 @@ param( [int]$Port = 8090 ) +$ErrorActionPreference = 'Stop' + Import-Module WebAdministration if (-not $PhysicalPath) { @@ -15,6 +17,10 @@ if (-not $PhysicalPath) { $PhysicalPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'public' } +if (-not (Test-Path $PhysicalPath)) { + throw "PhysicalPath does not exist: $PhysicalPath" +} + if (-not (Test-Path "IIS:\AppPools\$PoolName")) { New-WebAppPool -Name $PoolName | Out-Null Set-ItemProperty "IIS:\AppPools\$PoolName" -Name managedRuntimeVersion -Value '' @@ -48,6 +54,18 @@ $appcmd = "$env:windir\system32\inetsrv\appcmd.exe" $parentPathsNow = & $appcmd list config $SiteName -section:system.webServer/asp Write-Output "enableParentPaths config: $parentPathsNow" +# logs/ is a sibling of $PhysicalPath (see Default.asp's Server.MapPath("../logs")). +# Classic ASP impersonates IUSR for anonymous requests on this host - grant it +# (via IIS_IUSRS) write access scoped to exactly this folder, nothing else. +# Idempotent: re-running icacls /grant is safe, it doesn't duplicate the ACE. +$logsPath = Join-Path (Split-Path -Parent $PhysicalPath) 'logs' +if (-not (Test-Path $logsPath)) { + New-Item -ItemType Directory -Path $logsPath | Out-Null + Write-Output "Created $logsPath" +} +& icacls $logsPath /grant "IIS_IUSRS:(OI)(CI)M" | Out-Null +Write-Output "Granted IIS_IUSRS write access on $logsPath" + Start-WebAppPool -Name $PoolName -ErrorAction SilentlyContinue Start-Website -Name $SiteName -ErrorAction SilentlyContinue