diff --git a/.cursor/plans/README.md b/.cursor/plans/README.md
index 2c4e4db..947b9b8 100644
--- a/.cursor/plans/README.md
+++ b/.cursor/plans/README.md
@@ -36,3 +36,13 @@ Do not delete unrelated plans in those directories. Same bytes in all three copi
| L — PyPI release | [compass_track_l_pypi_release_43bd556a.plan.md](compass_track_l_pypi_release_43bd556a.plan.md) |
| M — Probe credentials | [compass_track_m_probe_credentials_acfb34f5.plan.md](compass_track_m_probe_credentials_acfb34f5.plan.md) |
| N — Paid pillars | [compass_track_n_paid_pillars_fae9b18d.plan.md](compass_track_n_paid_pillars_fae9b18d.plan.md) |
+
+## Phase 3 — Browser agent + status handoff
+
+| Track | Plan |
+| --- | --- |
+| O — Generic LLM adapter | [compass_phase3_track_o_generic_adapter_a7c3e91f.plan.md](compass_phase3_track_o_generic_adapter_a7c3e91f.plan.md) |
+| P3 Status — Grok→Cursor handoff | [compass_phase3_status_handoff_20260907.plan.md](compass_phase3_status_handoff_20260907.plan.md) |
+
+Canonical narrative status: [`../../docs/CURSOR-HANDOFF.md`](../../docs/CURSOR-HANDOFF.md)
+
diff --git a/.cursor/plans/compass_phase3_status_handoff_20260907.plan.md b/.cursor/plans/compass_phase3_status_handoff_20260907.plan.md
new file mode 100644
index 0000000..33b7d68
--- /dev/null
+++ b/.cursor/plans/compass_phase3_status_handoff_20260907.plan.md
@@ -0,0 +1,73 @@
+---
+name: comPASS Phase 3 — Status handoff (Grok Bot → Cursor)
+overview: "Authoritative 2026-09-07 status for Cursor agents: Phase 1–2 complete; Phase 3 browser Wasmer + ENI6MA Gate + Track O adapter + Docker challenge client (PR #2). Next work is six-color ceremony, verify/burn, and full agent boot."
+todos:
+ - id: p3-merge-pr2
+ content: "Review/merge https://github.com/soltrinox/comPASS/pull/2 (Docker browser-client challenge UI :8088) and land .dockerignore if still local"
+ status: pending
+ - id: p3-smoke-compose
+ content: "Confirm docker compose up — agy-bridge :8791 healthy + browser-client :8088 challenge.html?handle=demo-wasm Load&Prove + Ask"
+ status: pending
+ - id: p3-ceremony-six-color
+ content: "Build interactive six-color ENI6MA ceremony UX (challenge→colors→proof→burn-before-validate); replace Path-B stub/minimal proof as product auth"
+ status: pending
+ - id: p3-verify-burn
+ content: "Wire real verify ABI + Control burn ledger (DEMO-MINT is prove-only today; Gate = digest + abi_probe stub)"
+ status: pending
+ - id: p3-agent-boot
+ content: "Full in-tab agent boot page — ceremony→policy bind→trigger loop→adapter→agy-bridge"
+ status: pending
+ - id: p3-compress-real
+ content: "Replace adapter default_compress_hook with real comPREssOR hop-safe inject on model change"
+ status: pending
+ - id: p3-live-agy
+ content: "Optional compose profile live-agy with real Antigravity CLI (default remains fake-agy)"
+ status: pending
+ - id: p3-docs-sync
+ content: "Keep docs/CURSOR-HANDOFF.md + AUDIT-GOALS-VS-BROWSER-STACK.md + WASMER-DEPLOYMENT.md aligned after each milestone"
+ status: pending
+isProject: true
+---
+
+# comPASS Phase 3 — Status handoff (Grok Bot → Cursor)
+
+**Date:** 2026-09-07 (PT)
+**Handoff doc:** [`docs/CURSOR-HANDOFF.md`](../../docs/CURSOR-HANDOFF.md)
+**Audit:** [`docs/AUDIT-GOALS-VS-BROWSER-STACK.md`](../../docs/AUDIT-GOALS-VS-BROWSER-STACK.md)
+**Stack map:** [`docs/WASMER-DEPLOYMENT.md`](../../docs/WASMER-DEPLOYMENT.md)
+
+## One-line status
+
+Lab-grade **browser Wasmer + ENI6MA digest Gate + generic adapter + Docker challenge client** is up; **six-color ceremony / verify+burn / full sovereign agent boot** are not shipped.
+
+## What is done
+
+- Phase 1–2 offline tiers, Wasmer artifacts, Track O adapter (`src/compass/serve/adapter.py`), ADRs 0005–0007
+- `services/agy-bridge` Gate (cache/fetch/digest/abi_probe) + Docker Compose fake-agy on **:8791**
+- Path-B `wasmer/browser/` circuitLoader + wasmerRunner minimal proof stub
+- **PR #2** `feat/docker-browser-challenge`: nginx `browser-client` on **:8088**, `challenge.html` (handle / binary_url → digest-pin → prove → Ask → :8791)
+
+## What is next (priority)
+
+1. Merge PR #2 + smoke compose
+2. Six-color ceremony UX
+3. Verify + burn ledger
+4. Agent boot shell (policy bind → loop → adapter)
+5. Real comPREssOR hop inject
+
+## Operator smoke
+
+```bash
+cd /Users/rosario/work/comPASS
+docker compose up --build -d
+open "http://127.0.0.1:8088/challenge.html?handle=demo-wasm"
+curl -s http://127.0.0.1:8791/healthz
+```
+
+Prefer **`gh`** on the Mac for branch/PR (auth lives there).
+
+## Non-goals (still)
+
+- No Cursor/IDE product path
+- No provider keys in browser/WASM
+- Not production SaaS / not OpenRouter replacement
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..810aaa3
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,13 @@
+.git
+**/node_modules
+**/.venv
+**/__pycache__
+**/*.pyc
+test-results
+**/target
+**/.pytest_cache
+**/dist
+**/build
+*.tgz
+.env
+.env.*
diff --git a/PLANS.md b/PLANS.md
index a9a870e..b76798b 100644
--- a/PLANS.md
+++ b/PLANS.md
@@ -1,6 +1,6 @@
# comPASS program plans
-**Date:** 2026-09-05
+**Date:** 2026-09-07
**Ground truth:** [`/Users/rosario/work/comPASS/PROTOTYPE.md`](/Users/rosario/work/comPASS/PROTOTYPE.md)
**Summary:** [`/Users/rosario/work/comPASS/SUMMARY/2026-09-03-comPASS-prototype-session.md`](/Users/rosario/work/comPASS/SUMMARY/2026-09-03-comPASS-prototype-session.md)
**Canonical compressor:** `git@github.com:soltrinox/comPREssOR.git` at [`/Users/rosario/work/comPREssOR`](/Users/rosario/work/comPREssOR) (engine 0.2.0, `main` @ `44460ba`, CC-1..CC-10)
@@ -70,3 +70,14 @@ Browser-only Wasmer appliance (ADR 0005) and generic LLM adapter (ADR 0006).
| Track | Name | Cursor plan (work) | User plans | Repo copy |
| --- | --- | --- | --- | --- |
| O | Generic LLM adapter | [`/Users/rosario/work/.cursor/plans/compass_phase3_track_o_generic_adapter_a7c3e91f.plan.md`](/Users/rosario/work/.cursor/plans/compass_phase3_track_o_generic_adapter_a7c3e91f.plan.md) | [`/Users/rosario/.cursor/plans/compass_phase3_track_o_generic_adapter_a7c3e91f.plan.md`](/Users/rosario/.cursor/plans/compass_phase3_track_o_generic_adapter_a7c3e91f.plan.md) | [`/Users/rosario/work/comPASS/.cursor/plans/compass_phase3_track_o_generic_adapter_a7c3e91f.plan.md`](/Users/rosario/work/comPASS/.cursor/plans/compass_phase3_track_o_generic_adapter_a7c3e91f.plan.md) |
+| P3 Status | Phase 3 status handoff (Grok→Cursor) | [`/Users/rosario/work/.cursor/plans/compass_phase3_status_handoff_20260907.plan.md`](/Users/rosario/work/.cursor/plans/compass_phase3_status_handoff_20260907.plan.md) | [`/Users/rosario/.cursor/plans/compass_phase3_status_handoff_20260907.plan.md`](/Users/rosario/.cursor/plans/compass_phase3_status_handoff_20260907.plan.md) | [`/Users/rosario/work/comPASS/.cursor/plans/compass_phase3_status_handoff_20260907.plan.md`](/Users/rosario/work/comPASS/.cursor/plans/compass_phase3_status_handoff_20260907.plan.md) |
+
+### Phase 3 progress notes (2026-09-07 PT)
+
+- **Handoff:** [`docs/CURSOR-HANDOFF.md`](docs/CURSOR-HANDOFF.md) — open first in Cursor after leaving Grok Bot.
+- **Audit:** [`docs/AUDIT-GOALS-VS-BROWSER-STACK.md`](docs/AUDIT-GOALS-VS-BROWSER-STACK.md); stack map [`docs/WASMER-DEPLOYMENT.md`](docs/WASMER-DEPLOYMENT.md).
+- Track O generic adapter **completed** (ADR 0006 / `src/compass/serve/adapter.py` + tests).
+- ADRs **0005** (browser ENI6MA agent), **0007** (agy behind Gate) Accepted; agy-bridge + Compose on `:8791`.
+- **PR #2** Docker `browser-client` challenge UI on `:8088` (handle / binary_url → digest-pin → minimal proof → Ask Gate) — merge + smoke still open.
+- **Outstanding:** six-color ceremony UX, verify+burn ledger, full agent boot page, real comPREssOR hop inject, live-agy optional.
+
diff --git a/docker-compose.yml b/docker-compose.yml
index 93362ca..c4a4796 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,5 +1,6 @@
-# comPASS local stack — default agy-bridge with fake-agy
+# comPASS local stack — agy-bridge + browser challenge client
# docker compose up --build -d
+# Challenge UI: http://127.0.0.1:8088/
# See docs/DOCKER.md
services:
@@ -23,8 +24,24 @@ services:
start_period: 10s
restart: unless-stopped
+ browser-client:
+ build:
+ context: .
+ dockerfile: services/browser-client/Dockerfile
+ ports:
+ - "8088:80"
+ depends_on:
+ agy-bridge:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/healthz || exit 1"]
+ interval: 10s
+ timeout: 3s
+ retries: 5
+ start_period: 5s
+ restart: unless-stopped
+
# Optional: docker compose --profile live-agy up --build -d
- # Mount host agy + credentials (advanced). Not required for default up.
agy-bridge-live:
profiles: ["live-agy"]
build: ./services/agy-bridge
diff --git a/docs/CURSOR-HANDOFF.md b/docs/CURSOR-HANDOFF.md
new file mode 100644
index 0000000..6f93a94
--- /dev/null
+++ b/docs/CURSOR-HANDOFF.md
@@ -0,0 +1,128 @@
+# Cursor handoff — comPASS (2026-09-07 PT)
+
+**Purpose:** Close out the Grok Bot (WASMER) session and give Cursor agents a single source of truth for **current status**, **what to open**, and **what to build next**.
+
+**Repo:** [`soltrinox/comPASS`](https://github.com/soltrinox/comPASS)
+**Local tree:** `/Users/rosario/work/comPASS`
+**Compressor sibling:** `soltrinox/comPREssOR` @ `main` (CC-1..CC-10)
+**Open PR:** [#2 Docker browser challenge client](https://github.com/soltrinox/comPASS/pull/2) @ `5082367` (`feat/docker-browser-challenge`)
+
+**Workspace plan (open this in Cursor):**
+[`.cursor/plans/compass_phase3_status_handoff_20260907.plan.md`](../.cursor/plans/compass_phase3_status_handoff_20260907.plan.md)
+
+Also mirrored under `/Users/rosario/work/.cursor/plans/` and `/Users/rosario/.cursor/plans/` when synced.
+
+---
+
+## 1. Product posture (locked)
+
+| Decision | ADR / doc |
+|---|---|
+| Browser-only Wasmer appliance; no Cursor/IDE product path | [ADR 0005](adr/0005-eni6ma-gated-browser-agent.md), [ARCHITECTURE.md](ARCHITECTURE.md) |
+| Generic LLM adapter: decide / catalog / proxy_override | [ADR 0006](adr/0006-generic-llm-adapter.md), [API.md](API.md) §6 |
+| Google Antigravity (`agy`) stays **native** behind ENI6MA-gated Express bridge | [ADR 0007](adr/0007-agy-behind-eni6ma-gate.md) |
+| Digest is trust root; deny-by-default egress; Probe owns keys | AUDIT + WASMER-DEPLOYMENT |
+
+Pass+ Vercel reference face (not copied wholesale): `https://circuit.eni6ma.com/passplus/?handle=ALICE` / `?binary_url=…`
+
+---
+
+## 2. Phase completion
+
+| Phase | Status |
+|---|---|
+| **1** Offline A–E (specs, compressor CCs, Graph/Route, Wasmer cut, GTM ADRs) | **Complete** |
+| **2** Test-ready F–N | **Complete** (lab/test-ready ≠ production) |
+| **3** Browser agent + Gate + adapter + Docker client | **In progress** — scaffold + lab deploy; ceremony product incomplete |
+
+---
+
+## 3. What works today (lab)
+
+### Docker Compose
+
+```bash
+docker compose up --build -d
+```
+
+| Service | Port | Role |
+|---|---|---|
+| `agy-bridge` | **8791** | OpenAI-shaped Gate → fake-agy (or live-agy profile) |
+| `browser-client` | **8088** | Challenge-first static SPA + `/circuit-proxy` + `/artifacts` |
+
+Smoke:
+
+- http://127.0.0.1:8088/ → redirects to `challenge.html`
+- http://127.0.0.1:8088/challenge.html?handle=demo-wasm
+- http://127.0.0.1:8791/healthz
+
+Demo circuit pin:
+
+- URL: `https://raw.githubusercontent.com/eni6ma/REGISTRY/feat/wasm-circuits/circuits/demo-wasm/v1/eni6ma_wasm.wasm`
+- SHA-256: `853717e421a36fc93d0791d3f2718ecf3e9c449fb3c60d4084dedab3af75c389`
+- Local mirror: `wasmer/artifacts/eni6ma/demo-wasm/v1/` + `wasmer/artifacts/pins.json`
+
+### Libraries / paths
+
+| Area | Path | Notes |
+|---|---|---|
+| Adapter | `src/compass/serve/adapter.py` | Track O done; tests `tests/test_generic_adapter.py` |
+| Proxy | `src/compass/serve/proxy.py` | Delegates to adapter |
+| Bridge allowlist | `wasmer/browser/bridge.js` | Deny-by-default hosts |
+| Circuit load | `wasmer/browser/circuitLoader.js` | SHA-256 fail-closed; `loadPinnedRemote` + proxy |
+| Minimal proof | `wasmer/browser/wasmerRunner.js` | DEMO-MINT `build_minimal_proof` |
+| Challenge UI | `wasmer/browser/challenge.html` + `challenge.js` | Pass+-style handle / binary_url |
+| Stub agent | `wasmer/browser/agent.html` | Older Path-B buttons |
+| Gate | `services/agy-bridge/src/circuitGate.js` | Digest + abi_probe; prove-only ABI |
+| Docs audit | `docs/AUDIT-GOALS-VS-BROWSER-STACK.md` | Goals → Done/Partial/Not |
+| Deploy map | `docs/WASMER-DEPLOYMENT.md` | Zones A–D lifecycle |
+| Docker ops | `docs/DOCKER.md` | Compose cheat sheet |
+
+### Readiness (rough)
+
+- Auth→route→LLM **engine** path: ~55–65% testable (compose Gate + adapter + pin)
+- Ceremony / verify / burn / full agent boot: ~15–25%
+- Grade: **lab/demo**, not production-deploy ready
+
+---
+
+## 4. Not done (Cursor should prioritize)
+
+1. **Merge PR #2** and confirm compose smoke on a clean machine
+2. **Six-color interactive ceremony** (challenge → colors → proof → burn-before-validate) — only Path-B / challenge form exists
+3. **Real verify ABI + burn ledger** — Gate stub / DEMO-MINT prove-only
+4. **Full Wasmer agent boot page** — ceremony → policy bind → triggers → loop → adapter
+5. **Real comPREssOR hop inject** — adapter still has placeholder `default_compress_hook`
+6. Live Probe keys / PyPI TestPyPI / mobile farm — still gated / NOT_RUN as in Phase 2 evidence
+7. Optional: apply leftover `services/agy-bridge-gate-harden.tgz` if still uncommitted on disk
+
+---
+
+## 5. How Cursor should work this repo
+
+- Prefer **`gh`** on the Mac (`soltrinox` keyring) for branch/PR; box/cloud may lack GitHub auth
+- Cloud Agent may need **GitHub reconnect in Cursor** if launch fails
+- Plans live in **three places** (keep bytes aligned when editing):
+ - `/Users/rosario/work/comPASS/.cursor/plans/` (repo)
+ - `/Users/rosario/work/.cursor/plans/`
+ - `/Users/rosario/.cursor/plans/`
+- Index: [`PLANS.md`](../PLANS.md), [`.cursor/plans/README.md`](../.cursor/plans/README.md)
+- Refuse edits under archived `CHAT-COMPRESSOR*` (ADR 0002/0003)
+
+---
+
+## 6. Suggested first Cursor prompt
+
+> Open `docs/CURSOR-HANDOFF.md` and `.cursor/plans/compass_phase3_status_handoff_20260907.plan.md`. Merge or finish PR #2, smoke `docker compose` challenge UI on :8088 against agy-bridge :8791, then implement Track P: six-color ENI6MA ceremony UX bound to digest-pinned circuit + burn-before-validate, replacing the minimal-proof stub for product auth.
+
+---
+
+## 7. Session trail (Grok Bot WASMER, Sep 6–7)
+
+- Rewrote architecture for browser-only + ENI6MA; ADRs 0005–0007
+- Implemented Track O generic adapter + tests
+- Built agy-bridge Gate + Compose fake-agy
+- Wrote WASMER-DEPLOYMENT + AUDIT-GOALS-VS-BROWSER-STACK
+- Built Docker `browser-client` challenge UI (PR #2) via `gh` after Cloud Agent GitHub reconnect failed
+
+**End of Grok Bot handoff.**
diff --git a/docs/DOCKER.md b/docs/DOCKER.md
index ff54fd9..d05420f 100644
--- a/docs/DOCKER.md
+++ b/docs/DOCKER.md
@@ -1,6 +1,6 @@
-# Docker Compose — agy-bridge
+# Docker Compose — agy-bridge + browser challenge client
-Bring up the local **agy-bridge** (ENI6MA Gate → chat completions) without installing Google Antigravity.
+Bring up the local **agy-bridge** (ENI6MA Gate → chat completions) and the **browser challenge client** (Pass+-style handle / binary URL → digest-pin → prove → ask).
**Prerequisite:** Docker Desktop must be running (`docker info` succeeds). On macOS: `open -a Docker`, then wait until the daemon is ready.
@@ -11,14 +11,21 @@ Bring up the local **agy-bridge** (ENI6MA Gate → chat completions) without ins
docker compose up --build -d
```
-Health check:
+| Surface | URL |
+|---------|-----|
+| **Challenge UI** | http://127.0.0.1:8088/ |
+| agy-bridge health | http://127.0.0.1:8791/healthz |
+| browser-client health | http://127.0.0.1:8088/healthz |
-```bash
-curl -s http://127.0.0.1:8791/healthz
-# {"ok":true,"service":"agy-bridge",...}
-```
+### Challenge entry (like Pass+ / `circuit.eni6ma.com/passplus`)
+
+- Handle: http://127.0.0.1:8088/challenge.html?handle=demo-wasm
+- Binary URL: http://127.0.0.1:8088/challenge.html?binary_url=https://raw.githubusercontent.com/eni6ma/REGISTRY/feat/wasm-circuits/circuits/demo-wasm/v1/eni6ma_wasm.wasm&sha256=853717e421a36fc93d0791d3f2718ecf3e9c449fb3c60d4084dedab3af75c389
+- Or click **Use local pin** to load `wasmer/artifacts` digest without GitHub.
-Sample completion (Gate DEV mode; circuit URL+sha256 for cache/fetch):
+Flow: first paint is the challenge form → **Load & Prove** fetches via same-origin `/circuit-proxy` (allowlisted GitHub hosts) → SHA-256 fail-closed → DEMO-MINT `build_minimal_proof` in-tab → **Ask / execute** posts to `:8791` with `compass.circuit`.
+
+Sample completion (Gate DEV mode):
```bash
curl -s http://127.0.0.1:8791/v1/chat/completions \
@@ -36,9 +43,7 @@ curl -s http://127.0.0.1:8791/v1/chat/completions \
}'
```
-Default compose uses `scripts/fake-agy.js` (`AGY_BIN=/app/scripts/fake-agy.js`) so no live `agy` install is needed. Bind is `0.0.0.0` inside the container (`AGY_BRIDGE_HOST`); host port `8791`.
-
-Circuit WASM cache persists in volume `agy-bridge-circuits` → `/data/circuits`.
+Default compose uses `scripts/fake-agy.js` so no live `agy` install is needed.
## Stop
@@ -48,11 +53,4 @@ docker compose down
## Live Antigravity (advanced)
-Profile `live-agy` is documented in `docker-compose.yml`. It expects host networking and mounts for the real `agy` binary plus credentials. Default `docker compose up` does **not** require this.
-
-```bash
-# after editing mounts in docker-compose.yml
-docker compose --profile live-agy up --build -d
-```
-
-Local-only default without Docker still binds `127.0.0.1` (`AGY_BRIDGE_HOST` unset).
+Profile `live-agy` is documented in `docker-compose.yml`. Default `docker compose up` does **not** require it.
diff --git a/docs/README.md b/docs/README.md
index 6a5ea2f..45faeb8 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -19,6 +19,7 @@ Track A deliverables. Implementation is Tracks B–D. Product/GTM is Track E. **
| [`WASMER.md`](WASMER.md) | Wasmer artifacts/ABI + Phase 3 browser appliance notes |
| [`WASMER-DEPLOYMENT.md`](WASMER-DEPLOYMENT.md) | **Phase 3 lifecycle:** zones A–D, Gate auth, adapter→bridge→agy, module maps, ports, operator cheat sheet |
| [`AUDIT-GOALS-VS-BROWSER-STACK.md`](AUDIT-GOALS-VS-BROWSER-STACK.md) | **Goals vs build:** charter + Phase 3 goals mapped to Done/Partial/Not; browser stack tree + module audit |
+| [`CURSOR-HANDOFF.md`](CURSOR-HANDOFF.md) | **Cursor handoff (2026-09-07):** Phase 3 status, compose ports, PR #2, next todos |
| [`RELEASE.md`](RELEASE.md) | Track L: version scheme, tag policy, TestPyPI/PyPI publish (no secrets) |
| [`abi/host-abi.v1.md`](abi/host-abi.v1.md) | Host ABI v1 (storage/clock/log/config; keys forbidden) |
| [`INTEGRATION.md`](INTEGRATION.md) | CC-1–CC-10 touchpoints; ingestion; classification reuse; bundle pointer |
diff --git a/services/browser-client/Dockerfile b/services/browser-client/Dockerfile
new file mode 100644
index 0000000..aaab068
--- /dev/null
+++ b/services/browser-client/Dockerfile
@@ -0,0 +1,13 @@
+# Serve wasmer/browser + artifacts for the challenge-first appliance.
+FROM nginx:1.27-alpine
+COPY services/browser-client/nginx.conf /etc/nginx/nginx.conf
+COPY wasmer/browser/ /usr/share/nginx/html/
+# challenge.html expects ../artifacts from browser/ → map artifacts at /artifacts and also sibling path
+COPY wasmer/artifacts/ /usr/share/nginx/artifacts/
+# Mirror layout expected by relative ../artifacts from /challenge.html
+RUN mkdir -p /usr/share/nginx/wasmer && \
+ ln -sfn /usr/share/nginx/artifacts /usr/share/nginx/wasmer/artifacts && \
+ # Pages live at /; relative ../artifacts from /challenge.html resolves to /artifacts — good.
+ true
+EXPOSE 80
+HEALTHCHECK --interval=10s --timeout=3s --retries=5 CMD wget -qO- http://127.0.0.1/healthz || exit 1
diff --git a/services/browser-client/nginx.conf b/services/browser-client/nginx.conf
new file mode 100644
index 0000000..0f74947
--- /dev/null
+++ b/services/browser-client/nginx.conf
@@ -0,0 +1,63 @@
+worker_processes auto;
+error_log /var/log/nginx/error.log warn;
+pid /tmp/nginx.pid;
+
+events { worker_connections 1024; }
+
+http {
+ include /etc/nginx/mime.types;
+ default_type application/octet-stream;
+ sendfile on;
+ server_tokens off;
+ resolver 8.8.8.8 1.1.1.1 valid=300s ipv6=off;
+
+ map $arg_url $circuit_proxy_ok {
+ default 0;
+ "~*^https://raw\.githubusercontent\.com/" 1;
+ "~*^https://github\.com/" 1;
+ "~*^https://objects\.githubusercontent\.com/" 1;
+ }
+
+ server {
+ listen 80;
+ server_name _;
+ root /usr/share/nginx/html;
+ index challenge.html;
+
+ add_header Cross-Origin-Opener-Policy same-origin always;
+ add_header Cross-Origin-Embedder-Policy require-corp always;
+ add_header Cross-Origin-Resource-Policy cross-origin always;
+ add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; connect-src 'self' http://127.0.0.1:8791 http://localhost:8791 https://raw.githubusercontent.com https://github.com https://objects.githubusercontent.com; img-src 'self' data:; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'none'" always;
+
+ location = / {
+ return 302 /challenge.html;
+ }
+
+ location / {
+ try_files $uri $uri/ =404;
+ }
+
+ location /artifacts/ {
+ alias /usr/share/nginx/artifacts/;
+ add_header Cross-Origin-Resource-Policy cross-origin always;
+ types { application/wasm wasm; }
+ }
+
+ location = /circuit-proxy {
+ if ($circuit_proxy_ok = 0) { return 403; }
+ proxy_ssl_server_name on;
+ proxy_http_version 1.1;
+ set $upstream_url $arg_url;
+ proxy_pass $upstream_url;
+ proxy_hide_header Content-Security-Policy;
+ add_header Access-Control-Allow-Origin * always;
+ add_header Cross-Origin-Resource-Policy cross-origin always;
+ add_header Cache-Control "public, max-age=60";
+ }
+
+ location = /healthz {
+ default_type application/json;
+ return 200 '{"ok":true,"service":"browser-client"}';
+ }
+ }
+}
diff --git a/wasmer/browser/README.challenge.md b/wasmer/browser/README.challenge.md
new file mode 100644
index 0000000..1d61c3d
--- /dev/null
+++ b/wasmer/browser/README.challenge.md
@@ -0,0 +1,10 @@
+# Challenge client (Pass+-style)
+
+Entry: `challenge.html` (Docker default `/`).
+
+- `?handle=demo-wasm` or `?binary_url=https://raw.githubusercontent.com/eni6ma/REGISTRY/.../eni6ma_wasm.wasm`
+- Digest fail-closed via `circuitLoader.loadPinnedRemote` (+ `/circuit-proxy` for CORS)
+- Minimal proof via DEMO-MINT wasm-bindgen pkg
+- Ask panel → `agy-bridge` `:8791` with `compass.circuit`
+
+See `docs/DOCKER.md` in the PR.
diff --git a/wasmer/browser/challenge.html b/wasmer/browser/challenge.html
new file mode 100644
index 0000000..aab96f4
--- /dev/null
+++ b/wasmer/browser/challenge.html
@@ -0,0 +1,69 @@
+
+
+
+
+
+ comPASS · ENI6MA challenge
+
+
+
+
+ ENI6MA challenge
+ Pass+-style entry: load a circuit by handle or remote binary URL, digest-pin in the browser, prove, then ask the local Gate.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Query params: ?handle= · ?binary_url= · ?sha256=
+
+ idle
+
+
+
+
+
+
+
+
+
diff --git a/wasmer/browser/challenge.js b/wasmer/browser/challenge.js
new file mode 100644
index 0000000..8733ddf
--- /dev/null
+++ b/wasmer/browser/challenge.js
@@ -0,0 +1,205 @@
+/* Challenge-first boot: handle | binary_url → digest-pin → prove → ask Gate. */
+const DEMO_WASM_URL =
+ "https://raw.githubusercontent.com/eni6ma/REGISTRY/feat/wasm-circuits/circuits/demo-wasm/v1/eni6ma_wasm.wasm";
+const DEMO_SHA256 =
+ "853717e421a36fc93d0791d3f2718ecf3e9c449fb3c60d4084dedab3af75c389";
+
+/** Handle → raw GitHub REGISTRY URL (Pass+-style). Override with window.COMPASS_HANDLE_MAP. */
+const DEFAULT_HANDLE_MAP = {
+ "demo-wasm": DEMO_WASM_URL,
+ demowasm: DEMO_WASM_URL,
+ demo: DEMO_WASM_URL,
+ alice:
+ "https://raw.githubusercontent.com/eni6ma/REGISTRY/main/circuits/alice/v1/eni6ma",
+ bob: "https://raw.githubusercontent.com/eni6ma/REGISTRY/main/circuits/bob/v1/eni6ma",
+};
+
+const statusEl = document.getElementById("status");
+const outEl = document.getElementById("out");
+const askSection = document.getElementById("askSection");
+const askOut = document.getElementById("askOut");
+const btnAsk = document.getElementById("btnAsk");
+
+/** @type {{ url: string, sha256: string, proof: any } | null} */
+let session = null;
+
+function setStatus(msg, kind) {
+ statusEl.textContent = msg;
+ statusEl.className =
+ kind === true ? "ok" : kind === false ? "err" : kind === "busy" ? "busy" : "";
+}
+
+function unlockAsk(ok) {
+ askSection.classList.toggle("locked", !ok);
+ btnAsk.disabled = !ok;
+}
+
+function qs() {
+ return new URLSearchParams(location.search);
+}
+
+function resolveHandle(handle) {
+ const h = String(handle || "").trim();
+ if (!h) return null;
+ const map = Object.assign({}, DEFAULT_HANDLE_MAP, window.COMPASS_HANDLE_MAP || {});
+ const key = h.toLowerCase();
+ if (map[key]) return map[key];
+ if (map[h]) return map[h];
+ // Generic Path-B: REGISTRY main circuits//v1/eni6ma
+ const safe = encodeURIComponent(h.toLowerCase());
+ return `https://raw.githubusercontent.com/eni6ma/REGISTRY/main/circuits/${safe}/v1/eni6ma`;
+}
+
+function applyQueryDefaults() {
+ const p = qs();
+ if (p.get("handle")) document.getElementById("handle").value = p.get("handle");
+ if (p.get("binary_url"))
+ document.getElementById("binaryUrl").value = p.get("binary_url");
+ if (p.get("sha256")) document.getElementById("sha256").value = p.get("sha256");
+}
+
+async function runProofFromPinned(pinned, label) {
+ const art = location.pathname.includes("/browser/")
+ ? new URL("../artifacts/", location.href)
+ : new URL("/artifacts/", location.origin);
+ const pkgBase = new URL("eni6ma/demo-wasm/v1/pkg/", art).href;
+ const mod = await import(pkgBase + "eni6ma_wasm.js");
+ await mod.default();
+ const challenge = JSON.stringify({
+ timestamp: Date.now(),
+ matrix_data: {
+ rows: [{ values: [1, 2, 3], row_hash: "x", row_index: 0 }],
+ },
+ });
+ const bearings = JSON.stringify(["U", "L", "R", "U"]);
+ const proof = mod.build_minimal_proof(challenge, bearings);
+ return {
+ label,
+ path_b: {
+ expected: pinned.sha256,
+ actual: pinned.sha256,
+ published_bytes: pinned.bytes.byteLength,
+ url: pinned.url || pinned.remoteUrl || null,
+ },
+ proof,
+ challenge: JSON.parse(challenge),
+ bearings: JSON.parse(bearings),
+ };
+}
+
+async function loadAndProve({ useLocalPin }) {
+ unlockAsk(false);
+ session = null;
+ outEl.textContent = "";
+ setStatus("Loading circuit…", "busy");
+
+ try {
+ let pinned;
+ let sourceUrl;
+ let expected;
+
+ if (useLocalPin) {
+ const pinsUrl = location.pathname.includes("/browser/")
+ ? new URL("../artifacts/pins.json", location.href).href
+ : new URL("/artifacts/pins.json", location.origin).href;
+ pinned = await CircuitLoader.loadPinnedById(pinsUrl, "eni6ma_demo_wasm_v1");
+ sourceUrl =
+ pinned.pin && pinned.pin.source_ref
+ ? DEMO_WASM_URL
+ : new URL("../" + pinned.pin.path, location.href).href;
+ expected = pinned.sha256;
+ } else {
+ const handle = document.getElementById("handle").value;
+ const binaryUrl = document.getElementById("binaryUrl").value.trim();
+ expected = document.getElementById("sha256").value.trim();
+ sourceUrl = binaryUrl || resolveHandle(handle);
+ if (!sourceUrl) {
+ throw Object.assign(new Error("Enter a handle or binary_url"), {
+ code: "input_missing",
+ });
+ }
+ if (!expected && sourceUrl === DEMO_WASM_URL) expected = DEMO_SHA256;
+ pinned = await CircuitLoader.loadPinnedRemote(sourceUrl, expected, {
+ useProxy: true,
+ });
+ expected = pinned.sha256;
+ }
+
+ setStatus("Digest OK — proving…", "busy");
+ const result = await runProofFromPinned(pinned, useLocalPin ? "local-pin" : "remote");
+ session = {
+ url: sourceUrl || DEMO_WASM_URL,
+ sha256: expected,
+ proof: result.proof,
+ };
+ setStatus("Digest OK · proof OK — ask unlocked", true);
+ outEl.textContent = WasmerRunner.jsonSafe({
+ mode: result.label,
+ path_b: result.path_b,
+ proof: result.proof,
+ });
+ unlockAsk(true);
+ } catch (e) {
+ setStatus(
+ e.code === "digest_mismatch" ? "DIGEST MISMATCH — fail closed" : "Error",
+ false
+ );
+ outEl.textContent = (e.stack || String(e)) +
+ (e.expected
+ ? "\n" + JSON.stringify({ expected: e.expected, actual: e.actual, bytes: e.bytes }, null, 2)
+ : "");
+ unlockAsk(false);
+ }
+}
+
+async function askBridge() {
+ if (!session) return;
+ askOut.textContent = "";
+ setStatus("Sending to agy-bridge…", "busy");
+ const base = document.getElementById("bridgeUrl").value.replace(/\/$/, "");
+ const prompt = document.getElementById("prompt").value;
+ const body = {
+ model: "agy",
+ messages: [{ role: "user", content: prompt }],
+ compass: {
+ circuit: {
+ url: session.url,
+ sha256: session.sha256,
+ proof: session.proof,
+ },
+ },
+ };
+ try {
+ const res = await fetch(base + "/v1/chat/completions", {
+ method: "POST",
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
+ body: JSON.stringify(body),
+ });
+ const text = await res.text();
+ let parsed;
+ try {
+ parsed = JSON.parse(text);
+ } catch (_) {
+ parsed = text;
+ }
+ askOut.textContent = typeof parsed === "string" ? parsed : JSON.stringify(parsed, null, 2);
+ setStatus(res.ok ? "Bridge response OK" : "Bridge HTTP " + res.status, res.ok);
+ } catch (e) {
+ setStatus("Bridge error (is compose up?)", false);
+ askOut.textContent = e.stack || String(e);
+ }
+}
+
+document.getElementById("btnLoad").addEventListener("click", () =>
+ loadAndProve({ useLocalPin: false })
+);
+document.getElementById("btnLocal").addEventListener("click", () =>
+ loadAndProve({ useLocalPin: true })
+);
+document.getElementById("btnAsk").addEventListener("click", askBridge);
+
+applyQueryDefaults();
+const p = qs();
+if (p.get("handle") || p.get("binary_url") || p.get("autoload") === "1") {
+ loadAndProve({ useLocalPin: false });
+}
diff --git a/wasmer/browser/circuitLoader.js b/wasmer/browser/circuitLoader.js
index 7599851..2ae490c 100644
--- a/wasmer/browser/circuitLoader.js
+++ b/wasmer/browser/circuitLoader.js
@@ -55,15 +55,76 @@
err.code = "pin_missing";
throw err;
}
- const url = new URL("../" + pin.path, location.href).href;
+ // pin.path is relative to wasmer/ (e.g. artifacts/...). Resolve from pins.json URL parent/parent or /artifacts sibling.
+ let url;
+ try {
+ const pinsBase = new URL(pinsUrl, location.href);
+ // pins at .../artifacts/pins.json → artifact root is dirname
+ const artRoot = new URL("./", pinsBase);
+ const rel = String(pin.path).replace(/^artifacts\//, "");
+ url = new URL(rel, artRoot).href;
+ } catch (_) {
+ url = new URL("../" + pin.path, location.href).href;
+ }
const loaded = await loadPinned(url, pin.sha256);
return Object.assign({ pinId: pinId, pin: pin, pinsDoc: pinsDoc }, loaded);
}
+ async function fetchSidecarSha256(url) {
+ try {
+ const res = await fetch(url);
+ if (!res.ok) return null;
+ const text = (await res.text()).trim().split(/\s+/)[0].toLowerCase();
+ return /^[0-9a-f]{64}$/.test(text) ? text : null;
+ } catch (_) {
+ return null;
+ }
+ }
+
+ function viaProxy(url) {
+ try {
+ const u = new URL(url, location.href);
+ if (u.origin === location.origin) return u.href;
+ return new URL(
+ "/circuit-proxy?url=" + encodeURIComponent(u.href),
+ location.href
+ ).href;
+ } catch (_) {
+ return url;
+ }
+ }
+
+ /**
+ * Load remote bytes with pin. Empty expectedSha256 → try .sha256 sidecar (proxied).
+ */
+ async function loadPinnedRemote(url, expectedSha256, opts) {
+ opts = opts || {};
+ const useProxy = opts.useProxy !== false;
+ let expected = String(expectedSha256 || "")
+ .trim()
+ .split(/\s+/)[0]
+ .toLowerCase();
+ const fetchUrl = useProxy ? viaProxy(url) : url;
+ if (!/^[0-9a-f]{64}$/.test(expected)) {
+ const sideUrl = useProxy ? viaProxy(url.replace(/\?.*$/, "") + ".sha256") : url + ".sha256";
+ expected = (await fetchSidecarSha256(sideUrl)) || "";
+ }
+ if (!/^[0-9a-f]{64}$/.test(expected)) {
+ const err = new Error("circuitLoader: missing pin and no .sha256 sidecar");
+ err.code = "pin_invalid";
+ throw err;
+ }
+ const loaded = await loadPinned(fetchUrl, expected);
+ return Object.assign({ remoteUrl: url }, loaded);
+ }
+
const api = {
sha256Hex: sha256Hex,
loadPinned: loadPinned,
loadPinnedById: loadPinnedById,
+ fetchSidecarSha256: fetchSidecarSha256,
+ viaProxy: viaProxy,
+ loadPinnedRemote: loadPinnedRemote,
};
if (typeof module !== "undefined" && module.exports) module.exports = api;
root.CircuitLoader = api;