diff --git a/Controllers/SelfTestController.wsc b/Controllers/SelfTestController.wsc
new file mode 100644
index 0000000..4965919
--- /dev/null
+++ b/Controllers/SelfTestController.wsc
@@ -0,0 +1,160 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Framework/Application.wsc b/Framework/Application.wsc
index ab1758b..124c2ff 100644
--- a/Framework/Application.wsc
+++ b/Framework/Application.wsc
@@ -64,6 +64,46 @@ Sub Run(ctx, statusLine, contentType, body)
contentType = "text/html; charset=utf-8"
body = helloBody
Set ctrl = Nothing
+ ElseIf path = "/self-test" Then
+ Set ctrl = Nothing
+ On Error Resume Next
+ Set ctrl = CreateObject("WscMvc.SelfTestController")
+ If Err.Number <> 0 Then
+ Err.Clear
+ On Error Goto 0
+ statusLine = "500 Internal Server Error"
+ contentType = "text/plain; charset=utf-8"
+ body = "Internal Server Error"
+ LogOutcome ctx, statusLine
+ Exit Sub
+ End If
+ On Error Goto 0
+
+ Dim selfTestBody
+ selfTestBody = ""
+ On Error Resume Next
+ ctrl.RunSelfTest ctx.LogDir, selfTestBody
+ If Err.Number <> 0 Then
+ Err.Clear
+ On Error Goto 0
+ statusLine = "500 Internal Server Error"
+ contentType = "text/plain; charset=utf-8"
+ body = "Internal Server Error"
+ Set ctrl = Nothing
+ LogOutcome ctx, statusLine
+ Exit Sub
+ End If
+ On Error Goto 0
+
+ ' Always 200: the self-test HTTP call itself succeeded. Whether the
+ ' underlying checks passed is reported in the JSON body's "ok" field
+ ' and per-check "pass" fields, not the HTTP status - this matches
+ ' conventional health-check endpoint design (5xx is reserved for the
+ ' diagnostics mechanism itself being broken, handled above).
+ statusLine = "200 OK"
+ contentType = "application/json; charset=utf-8"
+ body = selfTestBody
+ Set ctrl = Nothing
Else
' Expected outcome, not a failure: no matching route yet (M3 adds a real table).
statusLine = "404 Not Found"
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 21f9e89..a35e66e 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -29,6 +29,19 @@ 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)
+
+`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).
+
+```
+curl http://100.127.62.31:8090/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.
+
+`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.
+
## COM identity
| Component | ProgID | CLSID |
@@ -36,6 +49,7 @@ GET /hello
| `Framework/RequestContext.wsc` | `WscMvc.RequestContext` | `{1C36FA55-34DF-4974-94B9-D657389362B2}` |
| `Framework/Application.wsc` | `WscMvc.Application` | `{851C7763-1638-42FE-A166-BF3DD3A96A88}` |
| `Controllers/HomeController.wsc` | `WscMvc.HomeController` | `{87488446-60BE-4068-8368-0B709BB68F3F}` |
+| `Controllers/SelfTestController.wsc` | `WscMvc.SelfTestController` | `{D2634944-4646-4C55-956E-4C05E7E10904}` |
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.
diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md
index 2a6c11e..3e3ae93 100644
--- a/docs/DECISIONS.md
+++ b/docs/DECISIONS.md
@@ -63,3 +63,11 @@ Three real defects were found and fixed while building this, all confirmed exper
## Investigated and resolved: an inherited `IUSR:(F)` ACE appeared under `logs/`
While diagnosing the concurrency finding above, `icacls C:\Projects\wsc-mvc\logs` showed an inherited `NT AUTHORITY\IUSR:(I)(F)` (Full Control) entry that wasn't present as an explicit ACE on `C:\Projects\wsc-mvc` or `C:\Projects` themselves. Initially flagged as a possible SPEC §10 "no broad filesystem write privileges" violation (anonymous web identity with Full Control beyond what it needs). Investigated properly rather than left as a guess: a recursive `icacls C:\Projects\wsc-mvc /T` scan shows the `IUSR:(F)` ACE present **only** on `logs/` and files/folders created underneath it during testing (`app.log`, diagnostic marker files, etc.) — confirmed absent on `Framework/`, `Controllers/`, `Default.asp`, `web.config`, `tools/`, `tests/`. Both `C:\Projects` and `C:\Projects\wsc-mvc` carry an inherited `CREATOR OWNER:(I)(OI)(CI)(IO)(F)` ACE (the `(IO)` = inherit-only flag), which is standard NTFS behavior: it doesn't grant CREATOR OWNER anything on the folder itself, but materializes into a concrete Full Control ACE for whichever security principal actually creates each new child file/folder underneath. Since IUSR (impersonated by classic ASP for anonymous requests, per this site's `anonymousAuthentication` config) was the one creating files under `logs/` during testing, it received Full Control on exactly those files it created there — nowhere else. **Resolved, not a defect**: this is expected NTFS ownership semantics, correctly scoped to only the dynamically-created log tree (which is already denied over HTTP via `hiddenSegments`), not a broad grant across the source tree. No remediation needed; no M6 follow-up required for this specific concern.
+
+## Test harness: HTTP+JSON self-test endpoint (2026-09-19)
+
+Daniel asked for the test harness to be "frontend agnostic" — runnable via a plain HTTP/API call from any CLI (not tied to PowerShell/cscript, which require either being on the VM or an SSH hop to it). Added `GET /self-test` (`Controllers/SelfTestController.wsc`), which runs the same checks as `tests/Test-Components.vbs` in-process against the live registered components and returns JSON (`{"ok":bool,"checks":[{"name","pass","detail"}, ...]}`). Verified directly from the Linux OpenClaw host with plain `curl` (no SSH, no PowerShell): a passing run, and a genuine failing run (deliberately removed `WscMvc.HomeController`'s registry entries, confirmed `/self-test` correctly reported `"ok":false` with `application_run_hello` pinpointed and a clear detail message, while every other check still correctly reported `true` — then re-registered and confirmed full recovery).
+
+Design choice: HTTP status is always `200` when the self-test mechanism itself executes; pass/fail lives in the JSON body (`"ok"` and per-check `"pass"`). A `5xx` is reserved for the diagnostics mechanism itself being broken (e.g. `SelfTestController` fails to instantiate), which still flows through `Application.Run`'s existing central error handling unchanged. This mirrors conventional health-check endpoint design and keeps a clean separation between "the test infrastructure is broken" and "a test found a real problem."
+
+`SelfTestController` is the first component whose failure details intentionally include raw `Err.Description` — everywhere else in this codebase that would violate SPEC §10 ("avoid... raw exception text to clients"), but a diagnostics-only endpoint's entire job is reporting what broke. Flagged in `docs/ARCHITECTURE.md` for reconsideration (gate or remove) before any production deployment, during the M6 hardening pass — not a concern for the current dev/test milestones.
diff --git a/docs/TEST-RESULTS.md b/docs/TEST-RESULTS.md
index d9386f6..b7f822f 100644
--- a/docs/TEST-RESULTS.md
+++ b/docs/TEST-RESULTS.md
@@ -149,6 +149,30 @@ Result: **PASS** — `/hello` returns `500` while unregistered and `200` after r
- A logging mechanism with guaranteed no-loss-under-load delivery — explicitly deferred to M6 (see `docs/DECISIONS.md`).
- Formal 400/405 method-not-allowed handling — still waiting on M3's route table.
+## Test harness: GET /self-test (frontend-agnostic HTTP+JSON)
+
+Requested by Daniel: the harness should be callable "via an api call" with a "json response," runnable from the CLI over plain HTTP, not tied to any specific frontend/tooling.
+
+Command (from the Linux OpenClaw host, no SSH/PowerShell/cscript involved):
+```
+curl http://100.127.62.31:8090/self-test
+```
+Result on a healthy deployment: **PASS**
+```json
+{"ok":true,"checks":[
+ {"name":"request_context_contract","pass":true,"detail":""},
+ {"name":"correlation_id_uniqueness","pass":true,"detail":""},
+ {"name":"application_run_hello","pass":true,"detail":""},
+ {"name":"application_run_unknown_route","pass":true,"detail":""}
+]}
+```
+
+Verified the failure-reporting path is real, not just the happy path: deliberately removed `WscMvc.HomeController`'s registry entries, re-ran `curl .../self-test`, got `"ok":false` with `application_run_hello` pinpointed (`"detail":"Expected 200 OK, got [500 Internal Server Error]"`) while the other three checks correctly still reported `true`; confirmed `GET /hello` itself also correctly degraded to `500` at the same time. Re-registered and confirmed both `/self-test` and `/hello` fully recovered. **PASS**
+
+Also verified `tests/run-self-test.sh` (`curl` + `python3 -m json.tool`, zero Windows tooling dependency) and the updated `tests/Test-Http.ps1` (now folds each `/self-test` check into its own PASS/FAIL output alongside its existing direct `/hello` and denied-`.wsc` checks) both work end to end. **PASS**
+
+`GET /Controllers/SelfTestController.wsc` (the component's own source file) still correctly denied — `404`, same `hiddenSegments` rule as every other component. **PASS**
+
## M2 gate status: **PASS**
The SPEC §13 M2 gate ("repeated/concurrent requests and failure cases behave predictably") was run on real Windows/IIS and holds unconditionally for HTTP response correctness. Four real defects were found and fixed while building this milestone (WSC property-syntax limitation, a locale-formatting bug, a `Randomize` collision, and a naive concurrent-logging approach that made things worse before a working lock-file mutex was verified) — see `docs/DECISIONS.md` for full detail on each. Proceeding to M3 (routing) is unblocked.
diff --git a/tests/Test-Http.ps1 b/tests/Test-Http.ps1
index 53dc106..90a2dab 100644
--- a/tests/Test-Http.ps1
+++ b/tests/Test-Http.ps1
@@ -25,6 +25,22 @@ 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.
+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 ([bool]$selfTestJson.ok) "GET /self-test overall ok" "detail in individual checks above"
+} catch {
+ Report $false "GET /self-test request" $_.Exception.Message
+}
+
try {
$wscResp = Invoke-WebRequest -Uri "$BaseUrl/Framework/Application.wsc" -UseBasicParsing
Report $false "GET /Framework/Application.wsc denied" "got $($wscResp.StatusCode)"
diff --git a/tests/run-self-test.sh b/tests/run-self-test.sh
new file mode 100755
index 0000000..b24febd
--- /dev/null
+++ b/tests/run-self-test.sh
@@ -0,0 +1,23 @@
+#!/usr/bin/env bash
+# Frontend-agnostic test runner: plain curl + a JSON parser, no PowerShell,
+# no cscript, no SSH access to the VM required. Runnable from any CLI that
+# can reach the site over HTTP.
+#
+# Usage: ./run-self-test.sh http://100.127.62.31:8090
+set -euo pipefail
+
+BASE_URL="${1:?Usage: run-self-test.sh , e.g. http://100.127.62.31:8090}"
+
+response=$(curl -sf "$BASE_URL/self-test")
+
+echo "$response" | python3 -m json.tool
+
+ok=$(echo "$response" | python3 -c 'import json, sys; print("true" if json.load(sys.stdin)["ok"] else "false")')
+
+if [ "$ok" = "true" ]; then
+ echo "RESULT: ALL PASS"
+ exit 0
+else
+ echo "RESULT: FAILURE"
+ exit 1
+fi
diff --git a/tools/Register-Components.ps1 b/tools/Register-Components.ps1
index a9409a8..d130831 100644
--- a/tools/Register-Components.ps1
+++ b/tools/Register-Components.ps1
@@ -10,7 +10,8 @@ if (-not $ProjectRoot) {
$components = @(
(Join-Path $ProjectRoot 'Framework\RequestContext.wsc'),
(Join-Path $ProjectRoot 'Framework\Application.wsc'),
- (Join-Path $ProjectRoot 'Controllers\HomeController.wsc')
+ (Join-Path $ProjectRoot 'Controllers\HomeController.wsc'),
+ (Join-Path $ProjectRoot 'Controllers\SelfTestController.wsc')
)
foreach ($path in $components) {
diff --git a/tools/Unregister-Components.ps1 b/tools/Unregister-Components.ps1
index 7f40056..8c25c17 100644
--- a/tools/Unregister-Components.ps1
+++ b/tools/Unregister-Components.ps1
@@ -15,6 +15,7 @@ if (-not $ProjectRoot) {
# can never be touched even if it happened to reuse a ProgID string.
$components = @(
+ @{ Path = (Join-Path $ProjectRoot 'Controllers\SelfTestController.wsc'); ProgId = 'WscMvc.SelfTestController'; ClassId = '{D2634944-4646-4C55-956E-4C05E7E10904}' },
@{ 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}' },
@{ Path = (Join-Path $ProjectRoot 'Framework\RequestContext.wsc'); ProgId = 'WscMvc.RequestContext'; ClassId = '{1C36FA55-34DF-4974-94B9-D657389362B2}' }
diff --git a/web.config b/web.config
index 21f2ca1..d34d4c8 100644
--- a/web.config
+++ b/web.config
@@ -25,6 +25,10 @@
+
+
+
+