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 M1master
| @@ -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 <repo>\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://<host>: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`. | |||||
| @@ -26,13 +26,21 @@ Prefer a bootstrap with `Option Explicit`, no business logic, no includes, no em | |||||
| ## 5. Target project structure | ## 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/ | WSC-MVC/ | ||||
| public/ | |||||
| public/ # production site's IIS physical path | |||||
| Default.asp | Default.asp | ||||
| web.config | 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/ | Framework/ | ||||
| Application.wsc | Application.wsc | ||||
| Router.wsc # phase 3 | Router.wsc # phase 3 | ||||
| @@ -46,7 +54,7 @@ WSC-MVC/ | |||||
| Home.html # phase 4 | Home.html # phase 4 | ||||
| Layout.html # phase 4 | Layout.html # phase 4 | ||||
| logs/ | logs/ | ||||
| app.log | |||||
| app.log # production site's log | |||||
| tests/ | tests/ | ||||
| Test-Components.vbs | Test-Components.vbs | ||||
| Test-Http.ps1 | Test-Http.ps1 | ||||
| @@ -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`). | - `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. | - `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":""}, ...]} | {"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 | ## 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. | 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 `<location path="WscMvc">` 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 `<location>` 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) | ## Deferred to later milestones (do not implement early) | ||||
| @@ -46,7 +46,7 @@ M1's original approach: IIS `requestFiltering/hiddenSegments` (blocks any URL pa | |||||
| ## Site/port allocation | ## 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) | ## 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. | **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). | **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. | |||||
| @@ -2,7 +2,7 @@ | |||||
| Host under test: `win2025test` (Tailscale 100.127.62.31), Windows Server 2025 Standard, build 10.0.26100, 64-bit. | 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. | 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. | Date: 2026-09-19. | ||||
| ## M0 — Environment and feasibility | ## 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** | `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** | 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**. | |||||
| @@ -21,16 +21,20 @@ | |||||
| </fileExtensions> | </fileExtensions> | ||||
| </requestFiltering> | </requestFiltering> | ||||
| </security> | </security> | ||||
| <!-- | |||||
| No /self-test rewrite rule on this (production) site on purpose: the | |||||
| diagnostics endpoint is only exposed on the separate test-app/ site | |||||
| (see docs/ARCHITECTURE.md). Application.wsc's Run method still | |||||
| technically has a "/self-test" case - that's shared framework code, | |||||
| used by whichever site's web.config chooses to route to it - but | |||||
| nothing on this site's rewrite rules can ever reach it. | |||||
| --> | |||||
| <rewrite> | <rewrite> | ||||
| <rules> | <rules> | ||||
| <rule name="WscMvc-Hello" stopProcessing="true"> | <rule name="WscMvc-Hello" stopProcessing="true"> | ||||
| <match url="^hello/?$" /> | <match url="^hello/?$" /> | ||||
| <action type="Rewrite" url="Default.asp?route=/hello" appendQueryString="false" /> | <action type="Rewrite" url="Default.asp?route=/hello" appendQueryString="false" /> | ||||
| </rule> | </rule> | ||||
| <rule name="WscMvc-SelfTest" stopProcessing="true"> | |||||
| <match url="^self-test/?$" /> | |||||
| <action type="Rewrite" url="Default.asp?route=/self-test" appendQueryString="false" /> | |||||
| </rule> | |||||
| </rules> | </rules> | ||||
| </rewrite> | </rewrite> | ||||
| <defaultDocument> | <defaultDocument> | ||||
| @@ -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 | |||||
| %> | |||||
| @@ -0,0 +1,36 @@ | |||||
| <?xml version="1.0" encoding="UTF-8"?> | |||||
| <configuration> | |||||
| <system.webServer> | |||||
| <!-- | |||||
| Same physical-separation model as the production site's web.config | |||||
| (see public/web.config and docs/ARCHITECTURE.md): this site's physical | |||||
| path is test-app/public/ only. Framework/, Controllers/, tests/, | |||||
| tools/, docs/, and this app's own logs/ (test-app/logs/) are all | |||||
| outside it. | |||||
| --> | |||||
| <security> | |||||
| <requestFiltering> | |||||
| <fileExtensions> | |||||
| <add fileExtension=".wsc" allowed="false" /> | |||||
| <add fileExtension=".vbs" allowed="false" /> | |||||
| <add fileExtension=".ps1" allowed="false" /> | |||||
| <add fileExtension=".md" allowed="false" /> | |||||
| </fileExtensions> | |||||
| </requestFiltering> | |||||
| </security> | |||||
| <rewrite> | |||||
| <rules> | |||||
| <rule name="WscMvcTests-SelfTest" stopProcessing="true"> | |||||
| <match url="^self-test/?$" /> | |||||
| <action type="Rewrite" url="Default.asp?route=/self-test" appendQueryString="false" /> | |||||
| </rule> | |||||
| </rules> | |||||
| </rewrite> | |||||
| <defaultDocument> | |||||
| <files> | |||||
| <clear /> | |||||
| <add value="Default.asp" /> | |||||
| </files> | |||||
| </defaultDocument> | |||||
| </system.webServer> | |||||
| </configuration> | |||||
| @@ -25,20 +25,23 @@ try { | |||||
| Report $false "GET /hello request" $_.Exception.Message | 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 { | try { | ||||
| $selfTestResp = Invoke-WebRequest -Uri "$BaseUrl/self-test" -UseBasicParsing | $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 { | } 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 | # Framework/ is a sibling of the "public" webroot, not a rule-denied | ||||
| @@ -6,6 +6,8 @@ param( | |||||
| [int]$Port = 8090 | [int]$Port = 8090 | ||||
| ) | ) | ||||
| $ErrorActionPreference = 'Stop' | |||||
| Import-Module WebAdministration | Import-Module WebAdministration | ||||
| if (-not $PhysicalPath) { | if (-not $PhysicalPath) { | ||||
| @@ -15,6 +17,10 @@ if (-not $PhysicalPath) { | |||||
| $PhysicalPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'public' | $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")) { | if (-not (Test-Path "IIS:\AppPools\$PoolName")) { | ||||
| New-WebAppPool -Name $PoolName | Out-Null | New-WebAppPool -Name $PoolName | Out-Null | ||||
| Set-ItemProperty "IIS:\AppPools\$PoolName" -Name managedRuntimeVersion -Value '' | 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 | $parentPathsNow = & $appcmd list config $SiteName -section:system.webServer/asp | ||||
| Write-Output "enableParentPaths config: $parentPathsNow" | 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-WebAppPool -Name $PoolName -ErrorAction SilentlyContinue | ||||
| Start-Website -Name $SiteName -ErrorAction SilentlyContinue | Start-Website -Name $SiteName -ErrorAction SilentlyContinue | ||||
Powered by TurnKey Linux.