← Commit history

2.10.0: ship proxy-fidelity.py - prove your control-API routes forward byte-for-byte without touching John's machine

John Lauer ·0b8b38fd69 ·1mo ago ·parent 58a4a0c
3 files changed +319−5
assets/tools/proxy-fidelity.pyadded+290
@@ -0,0 +1,290 @@+#!/usr/bin/env python3+"""proxy-fidelity — prove a dock dashboard forwards the control API FAITHFULLY.++The newbie-panel module calls two routes same-origin against your dashboard:++    GET  /updater/status        (version gate)+    POST /ai-threads/run-prompt (the send)++Your app forwards both to ah's control API. This checks that it forwards them+*unchanged*, which a "does it return 200" probe cannot see.++Three assertions, each against a real bug:++  1. response bytes identical (sha256), awkward whitespace and nested+     delivered_to included          -> catches parse-then-re-emit+  2. upstream's 207 comes back 207  -> catches a hand-built 200 {ok:true} wrapper+  3. an unproxied path under the same prefix returns YOUR 404, not upstream's+     418                            -> catches blanket-proxying the control API++Why 1 matters even when the JSON "looks the same": the module splits+updater.current_version on dots and compares NUMERICALLY, and reads+delivered_to as an ARRAY. A re-serialisation that retypes either one changes+behaviour while every status probe stays green.++USAGE++  # recommended: the script runs your app itself, in a throwaway HOME, so it+  # never touches a real workspace+  proxy-fidelity.py --app-cmd 'adom-project-manager --port $PORT'++  # or point it at an already-running app (needs --home so the stub URL can be+  # published where the app reads it; the file is backed up and restored)+  proxy-fidelity.py --base http://127.0.0.1:8785 --home ~++  # negative control: prove RED actually shows, using a deliberately+  # re-serialising proxy. Run this once before you trust a green run.+  proxy-fidelity.py --self-test++Exits non-zero if any assertion is RED, so it can sit in a build gate.+python3 stdlib only.+"""+import argparse, hashlib, json, os, random, shutil, signal, socket, subprocess, sys, time+import urllib.request, urllib.error+from http.server import BaseHTTPRequestHandler, HTTPServer+from threading import Thread++# Deliberately awkward: leading/trailing spaces that survive only if the bytes are+# untouched, a NUMERIC version, and delivered_to as an ARRAY.+STUB_BODY = (b'{"updater":{"current_version":"1.0.148","build":142},'+             b'"delivered_to":["tab-7"],"spacing":"  preserved  "}')+TEAPOT = b'{"teapot":true}'+++def free_port():+    s = socket.socket()+    s.bind(('127.0.0.1', 0))+    p = s.getsockname()[1]+    s.close()+    return p+++class Stub(BaseHTTPRequestHandler):+    """Stands in for ah's control API."""+    def _send(self, code, body):+        self.send_response(code)+        self.send_header('Content-Type', 'application/json')+        self.send_header('Content-Length', str(len(body)))+        self.end_headers()+        self.wfile.write(body)++    def do_GET(self):+        if self.path == '/updater/status':+            self._send(200, STUB_BODY)+        else:+            # anything else is a teapot, so a blanket proxy is visible+            self._send(418, TEAPOT)++    def do_POST(self):+        n = int(self.headers.get('Content-Length') or 0)+        raw = self.rfile.read(n)+        try:+            echo = json.loads(raw or b'{}')+        except Exception:+            echo = {'unparsed': raw.decode('utf-8', 'replace')}+        # 207 on purpose: a hand-built 200 wrapper cannot fake it+        self._send(207, json.dumps({'echo': echo, 'delivered_to': ['tab-9']}).encode())++    def log_message(self, *a):+        pass+++class BadProxy(BaseHTTPRequestHandler):+    """The NEGATIVE CONTROL: forwards correctly except it parses and re-emits,+    which is exactly what most hand-written forwarding routes do."""+    upstream = None++    def do_GET(self):+        if self.path == '/updater/status':+            with urllib.request.urlopen(self.upstream + '/updater/status') as r:+                data = json.loads(r.read())+            out = json.dumps(data).encode()   # <-- the bug, in one line+            self.send_response(200)+            self.send_header('Content-Type', 'application/json')+            self.send_header('Content-Length', str(len(out)))+            self.end_headers()+            self.wfile.write(out)+        else:+            self.send_response(404); self.end_headers(); self.wfile.write(b'not found')++    def do_POST(self):+        n = int(self.headers.get('Content-Length') or 0)+        raw = self.rfile.read(n)+        req = urllib.request.Request(self.upstream + self.path, data=raw,+                                     headers={'Content-Type': 'application/json'})+        try:+            with urllib.request.urlopen(req) as r:+                body = r.read()+        except urllib.error.HTTPError as e:+            body = e.read()+        out = json.dumps(json.loads(body)).encode()+        self.send_response(200)                # <-- collapses 207 to 200+        self.send_header('Content-Type', 'application/json')+        self.send_header('Content-Length', str(len(out)))+        self.end_headers()+        self.wfile.write(out)++    def log_message(self, *a):+        pass+++def serve(handler, port):+    srv = HTTPServer(('127.0.0.1', port), handler)+    Thread(target=srv.serve_forever, daemon=True).start()+    return srv+++def http(method, url, body=None, timeout=20):+    req = urllib.request.Request(url, method=method,+                                 data=body.encode() if isinstance(body, str) else body,+                                 headers={'Content-Type': 'application/json'} if body else {})+    try:+        with urllib.request.urlopen(req, timeout=timeout) as r:+            return r.status, r.read()+    except urllib.error.HTTPError as e:+        return e.code, e.read()+    except Exception as e:+        return 0, str(e).encode()+++def sha(b):+    return hashlib.sha256(b).hexdigest()+++def wait_ready(base, timeout=45):+    end = time.time() + timeout+    while time.time() < end:+        code, _ = http('GET', base + '/', timeout=3)+        if code:+            return True+        time.sleep(0.4)+    return False+++def run_checks(base, stub_port):+    rows = []++    code, body = http('GET', base + '/updater/status')+    ok = (code == 200 and body == STUB_BODY)+    rows.append(('GET /updater/status forwarded byte-for-byte', ok,+                 'status %s sha %s' % (code, sha(body)[:16]),+                 'status 200 sha %s' % sha(STUB_BODY)[:16],+                 None if ok else b'want: ' + STUB_BODY + b'\n   got: ' + body))++    sent = '{"new":true,"prompt":"proxy-fidelity probe","submit":false}'+    code, body = http('POST', base + '/ai-threads/run-prompt', sent)+    echoed = False+    try:+        echoed = json.loads(body).get('echo') == json.loads(sent)+    except Exception:+        pass+    ok2 = (code == 207 and echoed)+    rows.append(('POST /ai-threads/run-prompt: body intact, upstream 207 preserved', ok2,+                 'status %s, body echoed=%s' % (code, echoed), 'status 207, body echoed=True',+                 None if ok2 else b'got: ' + body))++    code, body = http('GET', base + '/updater/definitely-not-a-real-path')+    ok3 = (code != 418 and TEAPOT not in body)+    rows.append(('unproxied path is NOT blanket-forwarded', ok3,+                 'status %s' % code, 'anything but upstream 418',+                 None if ok3 else b'upstream teapot leaked through: ' + body))+    return rows+++def report(rows):+    print()+    worst = 0+    for what, ok, got, want, detail in rows:+        print(('  \033[32mPASS\033[0m  ' if ok else '  \033[31mRED \033[0m  ') + what)+        print('         want %s' % want)+        print('         got  %s' % got)+        if detail:+            worst = 1+            for line in detail.decode('utf-8', 'replace').splitlines():+                print('         %s' % line)+    print()+    return worst+++def main():+    ap = argparse.ArgumentParser(description='Prove a dashboard forwards the control API unchanged.')+    ap.add_argument('--base', help='base URL of an already-running dashboard, e.g. http://127.0.0.1:8785')+    ap.add_argument('--home', help='the HOME that running app reads (its .adom/hydrogen-control-url is '+                                   'briefly repointed at the stub, then restored)')+    ap.add_argument('--app-cmd', help='command to start your app; $PORT and $HOME are set for you. '+                                      'Safest: runs in a throwaway HOME.')+    ap.add_argument('--self-test', action='store_true',+                    help='negative control: run a deliberately re-serialising proxy and show it go RED')+    a = ap.parse_args()++    stub_port = free_port()+    serve(Stub, stub_port)+    stub_url = 'http://127.0.0.1:%d' % stub_port++    if a.self_test:+        print('NEGATIVE CONTROL: a proxy that parses and re-emits, which should go RED.')+        BadProxy.upstream = stub_url+        bad_port = free_port()+        serve(BadProxy, bad_port)+        rows = run_checks('http://127.0.0.1:%d' % bad_port, stub_port)+        rc = report(rows)+        if all(r[1] for r in rows):+            print('SELF-TEST FAILED: the negative control PASSED, so this harness proves nothing.')+            return 2+        print('Self-test OK: the harness detects a re-serialising proxy. A green run means something.')+        return 0++    proc = tmp_home = backup = ctrl = None+    try:+        if a.app_cmd:+            tmp_home = os.path.join('/tmp', 'proxy-fidelity-%d' % random.randint(1000, 9999))+            os.makedirs(os.path.join(tmp_home, '.adom'), exist_ok=True)+            open(os.path.join(tmp_home, '.adom', 'hydrogen-control-url'), 'w').write(stub_url)+            port = free_port()+            env = dict(os.environ, HOME=tmp_home, PORT=str(port))+            cmd = a.app_cmd.replace('$PORT', str(port)).replace('$HOME', tmp_home)+            proc = subprocess.Popen(cmd, shell=True, env=env,+                                    stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,+                                    preexec_fn=os.setsid)+            base = 'http://127.0.0.1:%d' % port+            if not wait_ready(base):+                print('RED: app did not answer on %s within 45s' % base)+                return 1+        elif a.base:+            base = a.base.rstrip('/')+            home = os.path.expanduser(a.home or os.environ.get('HOME', '~'))+            ctrl = os.path.join(home, '.adom', 'hydrogen-control-url')+            os.makedirs(os.path.dirname(ctrl), exist_ok=True)+            if os.path.exists(ctrl):+                backup = ctrl + '.proxy-fidelity.bak'+                shutil.copy2(ctrl, backup)+            open(ctrl, 'w').write(stub_url)+            # Relies on the published rule that the control URL is read at REQUEST+            # time. If your app caches it at startup, use --app-cmd instead.+        else:+            ap.error('give --app-cmd (recommended), --base, or --self-test')++        print('stub control API on %s' % stub_url)+        print('testing %s' % base)+        rows = run_checks(base, stub_port)+        rc = report(rows)+        if rc:+            print('One or more assertions are RED. Run --self-test once to confirm this harness')+            print('can detect a known-bad proxy, then fix the route rather than the harness.')+        return rc+    finally:+        if proc:+            try:+                os.killpg(os.getpgid(proc.pid), signal.SIGTERM)+            except Exception:+                pass+        if backup:+            shutil.move(backup, ctrl)+        elif ctrl and os.path.exists(ctrl):+            os.remove(ctrl)+        if tmp_home:+            shutil.rmtree(tmp_home, ignore_errors=True)+++if __name__ == '__main__':+    sys.exit(main())
package.json+2−1
@@ -1,6 +1,6 @@ {   "slug": "adom-ui-design",-  "version": "2.9.0",+  "version": "2.10.0",   "type": "skill",   "description": "The Adom UI Design System plus a bundle of focused UI skills (tooltips, floating-windows, toasts, responsive-ui, app-shell, ai-driven-apps, clickable-paths, dark-default, input-feedback, settings-layout). One install drops the whole set of skill files onto your container, plus shared icon assets (the settings cog) so every Adom app draws the same glyph.",   "dependencies": {},@@ -13,6 +13,7 @@     "assets/icons/action-send.svg",     "assets/icons/settings-cog-outline.svg",     "assets/module/newbie-panel.js",+    "assets/tools/proxy-fidelity.py",     "assets/tools/shot-webview.py",     "install.sh",     "page.json",
skills/newbie-panel/SKILL.md+27−4
@@ -116,10 +116,25 @@ drops whitespace, and can turn a numeric version into a string. The module compa ARRAY, so a re-serialisation that changes either type changes behaviour while every "does it return 200" probe still passes. -You can prove fidelity without John's machine, and in about forty lines: stand up a stub-control API returning a known body, point a throwaway `HOME`'s `hydrogen-control-url` at it,-and **compare the sha256 of your route's response to the stub's**. project-manager's version-checks three things, and all three are failures a 200-probe sails past:+**The harness ships in this pack: `assets/tools/proxy-fidelity.py`.** python3 stdlib only, no+dependencies.++```sh+python3 proxy-fidelity.py --app-cmd "your-server --port $PORT"   # the mode to use+python3 proxy-fidelity.py --self-test                            # watch it go red first+```++`--app-cmd` creates a throwaway `HOME`, publishes a stub control URL into it, starts your app+with `$PORT` and `$HOME`, runs the assertions and tears it all down. **It never touches a real+workspace**, so it is safe in a build gate. There is a `--base --home` mode for an+already-running app, but prefer `--app-cmd`: the `--base` mode works only because the control+URL is read at REQUEST time, so **an app that caches the URL at startup silently tests+nothing.** Exits non-zero on any RED.++**It cannot create a thread in John's list.** The POST assertion sends `submit:false` and only+ever to its own stub, never to a real control API.++It checks three things, and all three are failures a 200-probe sails past:  | assertion | the bug it catches | |---|---|@@ -129,6 +144,14 @@ checks three things, and all three are failures a 200-probe sails past:  Run this before spending a slot. A round trip through his box tells you less and costs more. +**Run `--self-test` first, every time.** Its negative control is a proxy whose only sin is one+line of parse-then-re-emit, and it goes RED on two of the three while the third stays GREEN.+That third staying green is the point: **the assertions are independent, so a run tells you+WHICH bug you have** rather than only that something is wrong. The diff it prints on the+byte-for-byte failure shows the damage is purely `{"updater":{` becoming `{"updater": {` —+JSON that is equal, bytes that are not, and behaviour that can differ. The self-test exits 2+if the negative control somehow passes, i.e. if the harness itself is broken.+ **READ THE CONTROL PORT AT REQUEST TIME. IT IS DYNAMIC PER ah LAUNCH.** Read `~/.adom/hydrogen-control-url`; do not hardcode 47084 or any port you once observed. A pinned port works until his next ah restart and then fails silently, which is the worst failure shape