Jerboa Browser MVP: programmable Qt WebEngine pane controlled from Jerboa

ober

e794e206fbd2d1ed6ba82f8b8057b361f21c5264

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1356f66
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+# CMake build trees
+build/
+**/build/
+
+# macOS
+.DS_Store
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..66b93ea
--- /dev/null
+++ b/README.md
@@ -0,0 +1,36 @@
+# Jerboa Browser
+
+**Experimental.** A programmable browser pane controlled from the Jerboa REPL.
+
+Qt WebEngine is the first backend (the logical surface is backend-neutral so a
+future Servo backend could implement the same `jwb_*` C ABI). The pane loads
+pages, navigates, evaluates JavaScript, and enforces a deny-by-default
+capability policy on top of the Chromium sandbox.
+
+## Layout
+
+```
+include/jerboa_browser.h   C ABI shared by every backend
+qt-webengine/              Qt WebEngine backend (CMake; builds libjerboa_browser)
+  src/                     C++ shim over QWebEngine{Profile,Page,View}
+  probe/                   Ticket 0.1 standalone proof-of-host (kept for re-checks)
+  tests/                   ABI/handle tests (ctest)
+scheme/                    Jerboa wrapper module + tests (.ss)
+notes/                     host pin, design watchpoints
+jerboa-browser.md          living plan / ticket log
+```
+
+## Build (macOS, Homebrew Qt)
+
+```sh
+cmake -S qt-webengine -B qt-webengine/build -DCMAKE_PREFIX_PATH=/opt/homebrew
+cmake --build qt-webengine/build
+./qt-webengine/build/jwb_demo
+```
+
+## Security model
+
+Object-capability, deny-by-default: a context grants only the capabilities
+named at creation (`JWB_CAP_NETWORK`, …); everything else is blocked. The
+Chromium sandbox stays enabled — `QTWEBENGINE_DISABLE_SANDBOX` is never set for
+normal runs. See `jerboa-browser.md`.
diff --git a/bin/jerboa-browser b/bin/jerboa-browser
new file mode 100755
index 0000000..54b7452
--- /dev/null
+++ b/bin/jerboa-browser
@@ -0,0 +1,57 @@
+#!/usr/bin/env bash
+#
+# jerboa-browser — run a Jerboa REPL or script with the (browser) library and
+# its native dylib on the path.
+#
+# Resolves the repo-local Chez + Jerboa stdlib, puts scheme/ on --libdirs so
+# (import (browser)) works, and points JERBOA_BROWSER_LIB at the built dylib.
+#
+# Usage:
+#   bin/jerboa-browser              # REPL with (browser) preloaded
+#   bin/jerboa-browser repl
+#   bin/jerboa-browser test         # run scheme/browser-test.ss
+#   bin/jerboa-browser run <file>   # run a Jerboa script
+#
+# Override JERBOA_HOME / SCHEME / JERBOA_BROWSER_LIB via the environment.
+
+set -euo pipefail
+
+REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+JERBOA_HOME="${JERBOA_HOME:-$HOME/mine/jerboa}"
+SCHEME="${SCHEME:-$JERBOA_HOME/.chez/bin/scheme}"
+LIBDIRS="$JERBOA_HOME/lib:$REPO/scheme"
+
+export JERBOA_BROWSER_LIB="${JERBOA_BROWSER_LIB:-$REPO/qt-webengine/build/libjerboa_browser.dylib}"
+
+if [ ! -x "$SCHEME" ]; then
+    echo "Error: Chez Scheme not found at $SCHEME (set SCHEME or JERBOA_HOME)" >&2
+    exit 1
+fi
+if [ ! -f "$JERBOA_BROWSER_LIB" ]; then
+    echo "Error: backend dylib not found at $JERBOA_BROWSER_LIB" >&2
+    echo "Build it: cmake -S qt-webengine -B qt-webengine/build -DCMAKE_PREFIX_PATH=/opt/homebrew && cmake --build qt-webengine/build" >&2
+    exit 1
+fi
+
+case "${1:-repl}" in
+    repl)
+        exec "$SCHEME" --libdirs "$LIBDIRS" --program <(cat <<'REPL'
+(import (jerboa prelude) (browser) (std repl))
+(display ";; (browser) loaded — call (browser-init) then (browser-open-context ...)\n")
+(jerboa-repl)
+REPL
+        )
+        ;;
+    test)
+        exec "$SCHEME" --libdirs "$LIBDIRS" --script "$REPO/scheme/browser-test.ss"
+        ;;
+    run)
+        shift
+        [ $# -ge 1 ] || { echo "usage: jerboa-browser run <file>" >&2; exit 1; }
+        exec "$SCHEME" --libdirs "$LIBDIRS" --script "$1"
+        ;;
+    *)
+        echo "usage: jerboa-browser [repl|test|run <file>]" >&2
+        exit 1
+        ;;
+esac
diff --git a/include/jerboa_browser.h b/include/jerboa_browser.h
new file mode 100644
index 0000000..6fc9b51
--- /dev/null
+++ b/include/jerboa_browser.h
@@ -0,0 +1,172 @@
+/* jerboa_browser.h — C ABI for the Jerboa browser backend (Qt WebEngine first).
+ *
+ * Backend-neutral by intent: these jwb_* operations are the logical surface a
+ * future Servo backend would also implement. The current implementation lives
+ * in qt-webengine/ and owns QWebEngineProfile / QWebEnginePage / QWebEngineView.
+ *
+ * Conventions (see jerboa-browser.md "C ABI draft"):
+ *   - 0 is never a valid handle.
+ *   - Handles are tagged internally as context or view; mixing kinds is an error.
+ *   - All Qt object access happens on the Qt GUI (main) thread.
+ *   - jwb_last_error is thread-local.
+ *   - Strings passed in are borrowed for the duration of the call only.
+ *   - Strings passed to callbacks are borrowed for the callback duration only.
+ *   - jwb_eval_js is asynchronous (QWebEnginePage::runJavaScript is async).
+ *   - A callback may fire after teardown; the impl rejects stale handles rather
+ *     than touching freed Qt objects.
+ */
+#ifndef JERBOA_BROWSER_H
+#define JERBOA_BROWSER_H
+
+#include <stdint.h>
+#include <stddef.h>
+
+#if defined(_WIN32)
+#  define JWB_API __declspec(dllexport)
+#else
+#  define JWB_API __attribute__((visibility("default")))
+#endif
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef uint64_t JwbHandle;
+
+typedef enum {
+  JWB_OK = 0,
+  JWB_ERR_INVALID_HANDLE = 1,
+  JWB_ERR_INVALID_ARGUMENT = 2,
+  JWB_ERR_QT = 3,
+  JWB_ERR_POLICY_BLOCKED = 4,
+  JWB_ERR_UNSUPPORTED = 5,
+  JWB_ERR_INTERNAL = 6,
+  JWB_ERR_TIMEOUT = 7
+} JwbStatus;
+
+typedef enum {
+  JWB_CAP_NETWORK = 1u << 0,
+  JWB_CAP_CLIPBOARD = 1u << 1,
+  JWB_CAP_FILESYSTEM = 1u << 2,
+  JWB_CAP_DOWNLOADS = 1u << 3,
+  JWB_CAP_PERSISTENT_STORAGE = 1u << 4,
+  JWB_CAP_DEVTOOLS = 1u << 5,
+  JWB_CAP_POPUPS = 1u << 6
+} JwbCapabilityFlags;
+
+typedef void (*JwbStringCallback)(const uint8_t *ptr,
+                                  uintptr_t len,
+                                  void *userdata);
+
+typedef void (*JwbStatusCallback)(JwbStatus status,
+                                  const uint8_t *ptr,
+                                  uintptr_t len,
+                                  void *userdata);
+
+/* --- version / error ---------------------------------------------------- */
+JWB_API const char *jwb_version(void);
+JWB_API const char *jwb_last_error(void); /* thread-local; valid until next call */
+
+/* --- runtime ------------------------------------------------------------ */
+/* Creates the single QApplication if none exists. Must be called on the main
+ * thread before any context/view call. Idempotent. */
+JWB_API JwbStatus jwb_runtime_init(void);
+
+/* --- contexts (own a QWebEngineProfile + capability set) ---------------- */
+JWB_API JwbStatus jwb_context_new(uint32_t capability_flags, JwbHandle *out);
+JWB_API JwbStatus jwb_context_free(JwbHandle context);
+
+/* --- views (own a QWebEngineView + QWebEnginePage) ---------------------- */
+JWB_API JwbStatus jwb_view_new(JwbHandle context, JwbHandle *out);
+JWB_API JwbStatus jwb_view_free(JwbHandle view);
+
+/* --- navigation --------------------------------------------------------- */
+JWB_API JwbStatus jwb_load_url(JwbHandle view, const uint8_t *url, uintptr_t len);
+JWB_API JwbStatus jwb_back(JwbHandle view);
+JWB_API JwbStatus jwb_forward(JwbHandle view);
+JWB_API JwbStatus jwb_reload(JwbHandle view);
+JWB_API JwbStatus jwb_stop(JwbHandle view);
+
+/* --- async JavaScript evaluation (the REPL bridge primitive) ------------ */
+JWB_API JwbStatus jwb_eval_js(JwbHandle view,
+                              const uint8_t *code,
+                              uintptr_t len,
+                              JwbStatusCallback callback,
+                              void *userdata);
+
+/* --- async observers ---------------------------------------------------- */
+JWB_API JwbStatus jwb_current_url(JwbHandle view,
+                                  JwbStringCallback callback,
+                                  void *userdata);
+JWB_API JwbStatus jwb_title(JwbHandle view,
+                            JwbStringCallback callback,
+                            void *userdata);
+
+/* === Synchronous convenience layer =====================================
+ * For single-threaded embeddings (the Jerboa REPL) that do NOT run their own
+ * Qt event loop. These drive the GUI event loop internally and block until the
+ * operation completes or times out. Hosts that already spin a Qt event loop
+ * (Ticket 2.2) should prefer the async primitives above.
+ *
+ * The _sync string getters return a heap-allocated UTF-8 C string the caller
+ * must release with jwb_string_free (NULL on error; check out_status).
+ */
+JWB_API JwbStatus jwb_pump_events(uint32_t max_ms);
+
+JWB_API JwbStatus jwb_load_url_sync(JwbHandle view,
+                                    const uint8_t *url,
+                                    uintptr_t len,
+                                    uint32_t timeout_ms);
+
+JWB_API char *jwb_eval_js_sync(JwbHandle view,
+                               const uint8_t *code,
+                               uintptr_t len,
+                               uint32_t timeout_ms,
+                               JwbStatus *out_status);
+
+JWB_API char *jwb_title_sync(JwbHandle view, JwbStatus *out_status);
+JWB_API char *jwb_current_url_sync(JwbHandle view, JwbStatus *out_status);
+
+JWB_API void jwb_string_free(char *s);
+
+/* --- pane control (Ticket 2.2) ------------------------------------------ */
+JWB_API JwbStatus jwb_view_show(JwbHandle view);
+JWB_API JwbStatus jwb_view_hide(JwbHandle view);
+JWB_API JwbStatus jwb_view_focus(JwbHandle view);
+/* Set the pane window title (NUL-terminated UTF-8). */
+JWB_API JwbStatus jwb_view_set_title(JwbHandle view, const char *utf8_title);
+
+/* --- shared event loop (Ticket 2.2) ------------------------------------- */
+/* Operate on the single QApplication (reused if the host created one). jwb_exec
+ * blocks running the loop until jwb_quit (or the last window closes). Hosts
+ * that own their own exec() never need these; the REPL uses jwb_pump_events. */
+JWB_API JwbStatus jwb_exec(void);
+JWB_API JwbStatus jwb_quit(void);
+
+/* === Jerboa-friendly facade (Ticket 2.1) ===============================
+ * The canonical ABI above uses output-pointer handles and malloc'd return
+ * strings — both awkward to bind from a Chez `foreign-procedure`. This facade
+ * composes those entry points (adding no Qt dependency) so that:
+ *   - handles are returned by value (0 on error),
+ *   - strings are returned in a thread-local buffer, borrowed until this
+ *     thread's next facade string call (the caller never frees them),
+ *   - the JwbStatus of the most recent facade call is read via jwb_last_status.
+ * Plain NUL-terminated C strings are taken instead of (ptr,len) pairs. Targets
+ * the synchronous, single-threaded Jerboa REPL embedding. The plain-int return
+ * of jwb_context_free / jwb_view_free / jwb_back / ... is already FFI-friendly,
+ * so those are called directly without a facade wrapper.
+ */
+JWB_API int jwb_last_status(void); /* JwbStatus of the most recent facade call */
+JWB_API JwbHandle jwb_context_open(uint32_t capability_flags); /* 0 on error */
+JWB_API JwbHandle jwb_view_open(JwbHandle context);            /* 0 on error */
+JWB_API int jwb_load(JwbHandle view, const char *url, uint32_t timeout_ms);
+JWB_API const char *jwb_eval(JwbHandle view, const char *code,
+                             uint32_t timeout_ms);
+JWB_API const char *jwb_get_title(JwbHandle view);
+JWB_API const char *jwb_get_url(JwbHandle view);
+
+#ifdef __cplusplus
+} /* extern "C" */
+#endif
+
+#endif /* JERBOA_BROWSER_H */
diff --git a/jerboa-browser.md b/jerboa-browser.md
new file mode 100644
index 0000000..63b2f7a
--- /dev/null
+++ b/jerboa-browser.md
@@ -0,0 +1,765 @@
+# Jerboa Browser — Qt WebEngine First Plan
+
+Living document — created 2026-05-24, rewritten 2026-05-25. Honest, no hype,
+no stubs. This is a plan for an Opus 4.7-level agent to execute in small
+tickets.
+
+## Current decision
+
+Use **Qt WebEngine as the first real Jerboa browser backend**.
+
+Servo remains the preferred long-term research backend for a literal
+single-binary, Rust-heavy, minimal-TCB browser. But Servo is not complete enough
+today to justify making it the first implementation path. The practical path is
+to build the Jerboa browser API, REPL workflow, policy layer, and Qt integration
+on top of Qt WebEngine now, while keeping the backend boundary clean enough
+that Servo can replace or supplement it later.
+
+The revised product goal is:
+
+- **First deliverable:** a useful hackable browser pane in the Jerboa/Qt
+  ecosystem, controlled from the Jerboa REPL.
+- **Security model:** Chromium/Qt WebEngine sandboxing plus Jerboa-side policy:
+  deny-by-default permissions, request interception, no ambient file/download/
+  clipboard authority, and explicit browser contexts.
+- **Distribution model:** one self-contained app artifact, not one literal
+  executable. Qt WebEngine requires `QtWebEngineProcess` and resource files.
+- **Long-term option:** keep Servo as an experimental backend once it is mature
+  enough to be worth the integration cost.
+
+This changes the old plan deliberately: **shipping a usable browser surface is
+more important than preserving the literal one-ELF ideal for the first backend**.
+
+## Why Qt WebEngine now
+
+Qt WebEngine is based on Chromium, exposes a native Qt widget
+(`QWebEngineView`), runs page rendering and JavaScript in `QtWebEngineProcess`,
+supports process isolation, receives Chromium security patches through Qt
+releases, and exposes the exact primitive needed for the gurf-style REPL bridge:
+`QWebEnginePage::runJavaScript`.
+
+The cost is deployment purity. Qt WebEngine applications must ship the WebEngine
+process executable and Chromium resource files. That means this backend cannot
+honestly satisfy "single binary" if that phrase means one executable file with
+no adjacent resources. It can satisfy "single app artifact" through a `.app`,
+AppImage, tarball, or installer bundle.
+
+## Engine decision
+
+| Engine | Role | Completeness | Qt integration | Security posture | Distribution |
+| --- | --- | --- | --- | --- | --- |
+| **Qt WebEngine** | **Primary backend now** | Highest; Chromium-based | Native `QWebEngineView` | Chromium process model + Qt policy hooks | Self-contained app artifact; not literal single binary |
+| **Servo** | Future research backend | Incomplete for arbitrary modern web | No maintained Qt widget | Best philosophical fit if mature | Best shot at literal static binary later |
+| **QtWebKit-reloaded** | Fallback only | Older, incomplete web compat | Native-ish Qt widget | C++/JavaScriptCore, older security posture | Easier than WebEngine, less capable |
+
+Decision: implement **Qt WebEngine first**. Revisit Servo only after the Qt
+backend proves the Jerboa UX and API.
+
+## Non-goals
+
+- Do not implement Servo first.
+- Do not spend Phase 0/1 time trying to force Qt WebEngine into one literal
+  executable.
+- Do not expose arbitrary browser authority to pages because Chromium has a
+  sandbox. The Jerboa host still controls network policy, downloads,
+  filesystem access, clipboard, popups, devtools, persistence, and custom
+  schemes.
+- Do not make a general daily-driver browser first. The MVP is a programmable
+  browser pane with load, navigation, policy, and JavaScript evaluation.
+
+## Desired Jerboa API
+
+The API should be backend-neutral. `webengine` should be an implementation
+detail, not visible in normal user code:
+
+```scheme
+(import (jerboa prelude) (std gui browser))
+
+(def caps
+  (browser-capabilities
+    network: (network-policy allow-hosts: '("example.com"))
+    clipboard: #f
+    filesystem: #f
+    downloads: #f
+    persistent-storage: #f
+    devtools: #f))
+
+(def ctx (browser-context caps))
+(def v (browser-open ctx "https://example.com"))
+
+(browser-eval-js v "document.title")
+(browser-load v "about:blank")
+(browser-close v)
+(browser-context-close ctx)
+```
+
+MVP may temporarily expose lower-level names, but the public target is
+`(std gui browser)`.
+
+## Architecture
+
+Use the existing Jerboa/Qt shape: Jerboa calls a small C ABI; C++ owns the Qt
+objects.
+
+```text
+Jerboa REPL (.ss)
+  (browser-open ctx url) / (browser-eval-js view code)
+        │ foreign-procedure
+        ▼
+C ABI (jwb_*)
+        │ C-compatible boundary, opaque handles
+        ▼
+Qt browser shim (C++)
+        │ owns QWebEngineProfile / QWebEnginePage / QWebEngineView
+        ▼
+Qt WebEngine / Chromium
+        │ separate QtWebEngineProcess for rendering + JavaScript
+        ▼
+Qt application window / browser pane
+```
+
+No Rust layer is needed for the Qt WebEngine backend. Keep the ABI narrow so a
+future Servo backend can implement the same logical operations.
+
+## Repository layout
+
+This is a dedicated `jerboa-browser` repo, so the workspace **is the repo root**
+— there is no extra `browser/` prefix (decided 2026-05-24). All ticket paths
+below are relative to the repo root. Keep browser work opt-in: nothing here is
+wired into a default build yet.
+
+```text
+.                          # repo root == jerboa-browser/
+  README.md
+  jerboa-browser.md        # this plan (living doc)
+  include/
+    jerboa_browser.h
+  qt-webengine/
+    probe/                 # Ticket 0.1 — DONE
+      CMakeLists.txt
+      main.cpp
+      build/               # cmake build dir (gitignore)
+    CMakeLists.txt
+    src/
+      browser_abi.cpp
+      browser_handles.cpp
+      browser_page.cpp
+      browser_policy.cpp
+      browser_view.cpp
+      main_demo.cpp
+    tests/
+  scheme/
+    browser.ss
+    browser-test.ss
+  packaging/
+    README.md
+  notes/
+    qt-webengine-pin.md    # Ticket 0.1 — DONE
+    policy-audit.md
+```
+
+Build targets should be opt-in:
+
+```text
+make browser-webengine-demo
+make browser-webengine-test
+make package-browser
+```
+
+Do not add Servo files in this phase.
+
+## C ABI draft
+
+This is the ABI Opus 4.7 should implement unless real Qt constraints force a
+small documented change.
+
+```c
+typedef uint64_t JwbHandle;
+
+typedef enum {
+  JWB_OK = 0,
+  JWB_ERR_INVALID_HANDLE = 1,
+  JWB_ERR_INVALID_ARGUMENT = 2,
+  JWB_ERR_QT = 3,
+  JWB_ERR_POLICY_BLOCKED = 4,
+  JWB_ERR_UNSUPPORTED = 5,
+  JWB_ERR_INTERNAL = 6
+} JwbStatus;
+
+typedef enum {
+  JWB_CAP_NETWORK = 1u << 0,
+  JWB_CAP_CLIPBOARD = 1u << 1,
+  JWB_CAP_FILESYSTEM = 1u << 2,
+  JWB_CAP_DOWNLOADS = 1u << 3,
+  JWB_CAP_PERSISTENT_STORAGE = 1u << 4,
+  JWB_CAP_DEVTOOLS = 1u << 5,
+  JWB_CAP_POPUPS = 1u << 6
+} JwbCapabilityFlags;
+
+typedef void (*JwbStringCallback)(const uint8_t *ptr,
+                                  uintptr_t len,
+                                  void *userdata);
+
+typedef void (*JwbStatusCallback)(JwbStatus status,
+                                  const uint8_t *ptr,
+                                  uintptr_t len,
+                                  void *userdata);
+
+const char *jwb_version(void);
+const char *jwb_last_error(void);
+
+JwbStatus jwb_runtime_init(void);
+JwbStatus jwb_context_new(uint32_t capability_flags, JwbHandle *out);
+JwbStatus jwb_context_free(JwbHandle context);
+
+JwbStatus jwb_view_new(JwbHandle context, JwbHandle *out);
+JwbStatus jwb_view_free(JwbHandle view);
+
+JwbStatus jwb_load_url(JwbHandle view, const uint8_t *url, uintptr_t len);
+JwbStatus jwb_back(JwbHandle view);
+JwbStatus jwb_forward(JwbHandle view);
+JwbStatus jwb_reload(JwbHandle view);
+JwbStatus jwb_stop(JwbHandle view);
+
+JwbStatus jwb_eval_js(JwbHandle view,
+                      const uint8_t *code,
+                      uintptr_t len,
+                      JwbStatusCallback callback,
+                      void *userdata);
+
+JwbStatus jwb_current_url(JwbHandle view,
+                          JwbStringCallback callback,
+                          void *userdata);
+JwbStatus jwb_title(JwbHandle view,
+                    JwbStringCallback callback,
+                    void *userdata);
+```
+
+Rules:
+
+- `0` is never a valid handle.
+- Handles are tagged internally as context or view.
+- All Qt object access happens on the Qt GUI thread.
+- `jwb_last_error` is thread-local.
+- Strings passed into C++ are borrowed only for the duration of the call.
+- Strings passed to callbacks are borrowed only for the callback duration.
+- `jwb_eval_js` is asynchronous because `QWebEnginePage::runJavaScript` is
+  asynchronous.
+- A callback may fire after page teardown; the implementation must detect stale
+  handles and return an error instead of touching deleted Qt objects.
+
+## Qt WebEngine policy layer
+
+Each `browser-context` owns a `QWebEngineProfile`. Start with off-the-record
+profiles unless `JWB_CAP_PERSISTENT_STORAGE` is granted.
+
+Implement these policy defaults:
+
+- Network: denied unless `JWB_CAP_NETWORK` is present. After MVP, support
+  allowlists by scheme, host, and port.
+- Clipboard: denied unless `JWB_CAP_CLIPBOARD` is present.
+- Filesystem/local file access: denied unless `JWB_CAP_FILESYSTEM` is present.
+- Downloads: denied unless `JWB_CAP_DOWNLOADS` is present.
+- Persistent storage: denied by default; use off-the-record profiles.
+- Popups/new windows: denied unless `JWB_CAP_POPUPS` is present.
+- DevTools/remote debugging: denied unless `JWB_CAP_DEVTOOLS` is present.
+- JavaScript: enabled for MVP because `browser-eval-js` is a core feature.
+  Add per-context disable later if needed.
+
+Qt hooks to verify and use:
+
+- `QWebEngineProfile` for profile isolation, settings, cookies, downloads, and
+  URL request interception.
+- `QWebEngineUrlRequestInterceptor` to block or allow requests.
+- `QWebEnginePage::runJavaScript` for REPL evaluation.
+- `QWebEnginePage` permission signals for geolocation, media, notifications,
+  clipboard, and file-system requests where available.
+- `QWebEnginePage::createWindow` / new-window handling to deny or control
+  popups.
+- `QWebEngineSettings` to disable risky local-content and clipboard behavior.
+
+Do not use `QWebChannel` in the MVP. It is useful later, but it expands the
+attack surface between page JavaScript and Jerboa.
+
+## Packaging plan
+
+For Qt WebEngine, the target is a **single app artifact**, not a literal
+single executable.
+
+Required deployment pieces normally include:
+
+- main Jerboa/Qt executable
+- Qt libraries
+- Qt WebEngine libraries
+- `QtWebEngineProcess`
+- Chromium `.pak` resource files
+- translations and platform plugins as required
+- app signing/notarization metadata on macOS
+
+Platform targets:
+
+- macOS: `.app` bundle first.
+- Linux: AppImage or self-contained tarball first.
+- Windows: optional later.
+
+Acceptance for packaging:
+
+- The packaged artifact runs from a clean temporary directory.
+- It does not depend on files from the developer's Qt install path.
+- The path to `QtWebEngineProcess` is controlled by the bundle/package, not by
+  ambient environment.
+- `QTWEBENGINE_DISABLE_SANDBOX=1` is not used for normal runs.
+
+## Security stance
+
+This backend is not "pure" in the Jerboa philosophy sense: Chromium and V8 are
+large C++/JIT components. The security argument is instead practical:
+
+- Chromium is a mature browser engine with a real process model.
+- Qt WebEngine separates rendering and JavaScript into `QtWebEngineProcess`.
+- Qt WebEngine supports site/process isolation modes.
+- Qt tracks Chromium releases and cherry-picks security patches.
+- Jerboa adds a host-side object-capability policy layer instead of giving the
+  page ambient authority.
+
+That is good enough for a first useful browser. It is likely much safer in
+practice than trying to ship a half-integrated incomplete engine.
+
+## Agent execution contract for Opus 4.7
+
+Follow this section literally.
+
+General rules:
+
+- Implement one ticket at a time.
+- Do not implement Servo in this plan.
+- Do not attempt literal single-binary Qt WebEngine.
+- Do not touch sibling repositories.
+- Keep all browser work opt-in.
+- If a Qt API name in this document is wrong, check the official Qt docs,
+  update this document with the real API, then continue.
+- Every ticket must leave commands and notes for the next ticket.
+
+Stop conditions:
+
+- Qt WebEngine is not installed and cannot be installed on the host.
+- A minimal `QWebEngineView` app cannot build.
+- `QtWebEngineProcess` cannot be found or launched in the demo.
+- `runJavaScript` cannot return `document.title`.
+- The sandbox must be disabled for ordinary page loading.
+
+If a stop condition is hit, record the command, error, Qt version, host OS, and
+recommended next action in `browser/notes/qt-webengine-pin.md`.
+
+## Implementation tickets
+
+### Ticket 0.1 — Qt WebEngine probe — DONE (2026-05-24)
+
+Goal: prove the local machine can build and run Qt WebEngine.
+
+Result: PASS on macOS 15.5 arm64, Qt 6.11.0 (Homebrew). Probe loaded
+`https://example.com` and `runJavaScript("document.title")` returned
+`"Example Domain"` with the sandbox enabled. Full host facts in
+`notes/qt-webengine-pin.md`.
+
+Created:
+
+```text
+notes/qt-webengine-pin.md
+qt-webengine/probe/{CMakeLists.txt,main.cpp}
+```
+
+The probe may be a tiny CMake project that creates a `QApplication`, a
+`QWebEngineView`, loads `https://example.com`, and runs:
+
+```cpp
+view->page()->runJavaScript("document.title", ...);
+```
+
+Record in `qt-webengine-pin.md`:
+
+- OS and version.
+- Qt version.
+- Compiler version.
+- CMake command.
+- Required Qt packages/modules.
+- Location of `QtWebEngineProcess`.
+- Whether sandboxing stayed enabled.
+
+Acceptance (met):
+
+```text
+cmake -S qt-webengine/probe -B qt-webengine/probe/build -DCMAKE_PREFIX_PATH=/opt/homebrew
+cmake --build qt-webengine/probe/build
+./qt-webengine/probe/build/jwb_probe
+```
+
+The app opens a window, loads a page, and logs the page title.
+
+### Ticket 0.2 — Browser workspace skeleton — DONE (2026-05-24)
+
+Result: PASS. `include/jerboa_browser.h` (full C ABI), `qt-webengine/`
+(`CMakeLists.txt`, `src/browser_internal.h`, `src/browser_abi.cpp`,
+`src/main_demo.cpp`), `README.md` created. `cmake --build` produces
+`libjerboa_browser.dylib` + `jwb_demo`; demo prints
+`jerboa-browser 0.0.1 (Qt WebEngine 6.11.0)` and `runtime initialized OK`
+(exit 0). `jwb_runtime_init` makes the single QApplication (reuses an existing
+one for the Ticket 2.2 host case).
+
+Goal: add the real opt-in workspace.
+
+Create the repository layout from the "Repository layout" section.
+
+Requirements:
+
+- `browser/README.md` says this is experimental and Qt WebEngine-first.
+- `browser/include/jerboa_browser.h` contains the C ABI draft.
+- `browser/qt-webengine/CMakeLists.txt` builds a placeholder library and demo.
+- Placeholder exports: `jwb_version`, `jwb_last_error`, `jwb_runtime_init`.
+- No Jerboa Scheme wrapper yet.
+
+Acceptance:
+
+```text
+cmake -S qt-webengine -B qt-webengine/build -DCMAKE_PREFIX_PATH=/opt/homebrew
+cmake --build qt-webengine/build
+```
+
+Default repository build behavior remains unchanged.
+
+### Ticket 1.1 — Handle registry and ABI tests — DONE (2026-05-24)
+
+Result: PASS. `src/browser_handles.cpp` (monotonic, never-reused handle
+registry, mutex-guarded; tagged Context/View; `jwb_context_new/free`,
+`jwb_view_new/free`), `src/browser_utf8.cpp` (well-formed UTF-8 validator),
+`tests/{abi_test.cpp,CMakeLists.txt}`. `ctest` green: covers null pointer,
+invalid handle, wrong kind, double free/stale, and UTF-8 (overlong, surrogate,
+truncated, >U+10FFFF). Test needs no QApplication. Handle payloads are minimal
+placeholders that Ticket 1.2 grows into Qt objects.
+
+Goal: make the ABI safe before it owns browser objects.
+
+Implement:
+
+- context handle allocation/free
+- view handle allocation/free placeholder
+- internal handle tags
+- stale-handle rejection
+- thread-local last error
+- null pointer checks
+- UTF-8 validation for string inputs
+
+Acceptance:
+
+- C++ tests cover invalid handle, wrong handle kind, double free, null pointer,
+  invalid UTF-8, and last error.
+- A tiny C or C++ harness can call the ABI without Qt WebEngine objects.
+
+### Ticket 1.2 — Real WebEngine context/view — DONE (2026-05-24)
+
+Result: PASS. Added `src/browser_objects.h` (Qt-backed Context/View payloads +
+inline string helpers), `src/browser_view.cpp` (backend seam: off-the-record
+`QWebEngineProfile`, `QWebEngineView`+`QWebEnginePage`; navigation via
+`triggerAction`; sync title/url observers; show/hide), `src/browser_page.cpp`
+(`jwb_eval_js` async + the whole sync layer: `pump_events`, `load_url_sync`,
+`eval_js_sync`, `title_sync`, `current_url_sync`, `string_free`). Demo output:
+`load … OK`, `document.title = "Example Domain"`, `current url =
+https://example.com/`, `freed view with eval in flight: no crash`, `closed
+cleanly` (exit 0).
+
+Design notes for later tickets:
+- The handle layer (`browser_handles.cpp`) stays Qt-free; it reaches Qt only
+  through `jwb::backend_context_create/destroy` + `backend_view_create/destroy`.
+  The ABI test injects fake backends, so `ctest` needs no QApplication.
+- The view is never `show()`n in the demo; QtWebEngineProcess loads regardless.
+- Teardown safety: freeing a view drops its page, and Qt cancels the page's
+  pending `runJavaScript` callback — no use-after-free. The sync helpers block
+  via `processEvents(WaitForMoreEvents, 20)` until done or timeout.
+- A failed/blocked load surfaces from `jwb_load_url_sync` as `JWB_ERR_QT`
+  (Ticket 1.3 reuses this for the no-network-cap case).
+
+Goal: connect ABI handles to real Qt WebEngine objects.
+
+Implement:
+
+- `jwb_context_new` creates a context with a `QWebEngineProfile`.
+- `jwb_view_new` creates a `QWebEngineView` and `QWebEnginePage`.
+- `jwb_view_free` destroys the view on the Qt GUI thread.
+- `jwb_load_url` loads a URL.
+- `jwb_title` and `jwb_current_url` report observable state.
+- `jwb_eval_js` calls `QWebEnginePage::runJavaScript`.
+
+Acceptance:
+
+- Demo opens a view, loads `https://example.com`, prints title via
+  `jwb_eval_js`, then closes without crash.
+- Closing before the JavaScript callback returns does not touch freed objects.
+
+### Ticket 1.3 — Policy MVP — DONE (2026-05-24)
+
+Result: PASS. `src/browser_policy.cpp` adds `install_policy(Context*)`, called
+from `backend_context_create`. Verified Qt 6.11 API against the framework
+headers first. Mechanisms, all keyed off capability flags:
+- Network: `NetworkInterceptor : QWebEngineUrlRequestInterceptor` blocks every
+  request whose scheme isn't about/data/qrc/blob unless `JWB_CAP_NETWORK`.
+- Downloads: `profile->downloadRequested` → `cancel()` unless `JWB_CAP_DOWNLOADS`
+  (logs `[policy] download denied`).
+- Popups: `QWebEngineSettings::JavascriptCanOpenWindows = POPUPS`; plus the
+  default `createWindow` → nullptr.
+- Filesystem: `LocalContentCanAccessFileUrls = FILESYSTEM`;
+  `LocalContentCanAccessRemoteUrls = false` always.
+- Clipboard: `JavascriptCanAccessClipboard = CLIPBOARD`.
+- DevTools/remote-debugging never enabled.
+Interceptor owned by Context, deleted after the profile in
+`backend_context_destroy`.
+
+Demo (`jwb_demo`) now asserts all four acceptance bullets and prints
+`ALL CHECKS PASSED (0 failures)`, exit 0: network ctx loads example.com;
+`window.open()` returns null (denied); download cancelled; **no-capability ctx
+is blocked from the network**. Sandbox stays enabled.
+
+Goal: deny obvious authority by default.
+
+Implement:
+
+- off-the-record profile by default
+- request interceptor that denies network when `JWB_CAP_NETWORK` is absent
+- download-deny handler unless `JWB_CAP_DOWNLOADS` is present
+- new-window/popup deny unless `JWB_CAP_POPUPS` is present
+- local file access disabled unless `JWB_CAP_FILESYSTEM` is present
+- devtools disabled unless `JWB_CAP_DEVTOOLS` is present
+
+Acceptance:
+
+- Demo with no network cap fails to load `https://example.com`.
+- Demo with network cap loads `https://example.com`.
+- Attempted download is denied by default.
+- Attempted popup is denied by default.
+
+### Ticket 2.1 — Jerboa wrapper module — DONE (2026-05-24)
+
+Result: `scheme/browser.ss` is a `(library (browser) ...)` over a Jerboa-friendly
+C facade (`qt-webengine/src/browser_ffi.cpp`: handles returned by value, return
+strings in a thread-local buffer, `jwb_last_status`) bound with `(jerboa ffi)`
+`define-c-lambda`. `scheme/browser-test.ss` drives the real backend: **15/15
+checks pass** (load example.com, `document.title == "Example Domain"`, URL read,
+view≠context type-safety, no-cap network deny, double-close rejection). Verified
+with `jerboa_compile_check` + `jerboa_verify`, then run live via
+`bin/jerboa-browser test`. Decisions: **block through the Qt event loop** (sync
+facade — no future object in MVP); contexts/views are distinct `defstruct` types;
+explicit `browser-close-view` / `browser-close-context` are primary (no
+finalizers). Actual API names (lower-level per MVP allowance): `browser-init`,
+`browser-version`, `browser-capabilities` (variadic symbols, e.g.
+`(browser-capabilities 'network 'downloads)`), `browser-open-context`,
+`browser-open-view`, `browser-load`, `browser-eval`, `browser-title`,
+`browser-url`, `browser-back/forward/reload/stop`, `browser-show/hide`,
+`browser-close-view`, `browser-close-context`, predicates `browser-context?` /
+`browser-view?` / `browser-ok?` / `browser-error?` / `browser-value`. NOTE:
+`[...]` is a list literal in the Jerboa reader — clause groups use parens.
+
+Goal: expose the ABI to Jerboa.
+
+Create:
+
+```text
+scheme/browser.ss
+scheme/browser-test.ss
+```
+
+Surface API:
+
+```scheme
+(browser-capabilities . kwargs)
+(browser-context caps)
+(browser-open ctx url)
+(browser-load view url)
+(browser-eval-js view code)
+(browser-title view)
+(browser-url view)
+(browser-close view)
+(browser-context-close ctx)
+```
+
+Requirements:
+
+- Validate argument types before FFI.
+- Tag handles so context/view handles cannot be mixed.
+- Make explicit close primary; finalizers are backup only.
+- Return Jerboa result values: `(ok value)` or `(err message)`.
+- For async JavaScript, either block through the Qt event loop in MVP or expose
+  a small promise/future object. Document the choice before implementing.
+
+Acceptance:
+
+- A Jerboa script opens a page, gets `document.title`, and closes the view.
+- Invalid handle and invalid argument tests exist.
+- After `.ss` edits, run the build target required by `AGENTS.md`.
+
+### Ticket 2.2 — Qt host integration — DONE (2026-05-24)
+
+Result: `qt-webengine/host/host_demo.cpp` (`jwb_host`) is a real Qt host that
+**owns** the QApplication + `app.exec()` loop; `jwb_runtime_init` reuses it via
+`qobject_cast` (no second QApplication — asserted live). Backend gained
+`jwb_view_focus`, `jwb_view_set_title`, `jwb_exec`, `jwb_quit`
+(`qt-webengine/src/browser_host.cpp`); Jerboa gained `browser-focus`,
+`browser-set-title`, `browser-pump`, `browser-exec`, `browser-quit`. The host
+shows a titled pane, loads example.com through its own event loop, reads
+`document.title` == "Example Domain", and frees the view exactly once (second
+free → INVALID_HANDLE): **8/8 PASS**. CMake option `JWB_HOST_ENABLE_BROWSER`
+(default ON); with `=OFF` the browser code `#ifdef`s out and `jwb_host` links
+**no** WebEngine / jerboa_browser (verified via `otool -L`) yet still starts and
+exits cleanly — the "app runs without browser support" path. Jerboa
+`browser-test.ss` now also exercises show/title/focus/pump/hide: **20/20 PASS**.
+Pages enter Qt's Discarded state once `exec()` quits, so all verification runs
+while the page is live, before the cosmetic display hold.
+
+Goal: put the working WebEngine view inside the real Qt host.
+
+Requirements:
+
+- Do not create a second `QApplication`.
+- Reuse the existing Qt event loop.
+- Add the minimum API to create, show, focus, and close a browser pane.
+- Closing the pane frees the WebEngine view exactly once.
+
+Acceptance:
+
+- Jerboa can open a browser pane inside the Qt app.
+- The normal Qt app still starts without browser support when the browser
+  target is disabled.
+
+### Ticket 3.1 — Packaged app artifact — DONE (2026-05-24)
+
+Result (macOS, the verified target):
+- `packaging/macos/build-app.sh` builds `JerboaBrowser.app` (~299 MB) via
+  `macdeployqt`, then makes it genuinely self-contained: a relative symlink so
+  the nested `QtWebEngineProcess.app` resolves `@executable_path/../Frameworks`,
+  `install_name_tool` to rebind the helper's deps + three stale install-ids off
+  `/opt/homebrew`, ad-hoc re-sign, and a final scan of every Mach-O that fails
+  the build if any `/opt/homebrew` reference survives.
+- **Acceptance met:** copied to a fresh `mktemp -d` and launched
+  (`JWB_HOST_SMOKE=1`), the WebEngine helpers spawn and load
+  `https://example.com` (`document.title = "Example Domain"`), 9/9 host checks
+  PASS, exit 0, **Chromium sandbox left enabled**. `codesign --verify --strict`
+  passes; zero `/opt/homebrew` runtime deps.
+- `packaging/README.md` lists every bundled component (19 frameworks, ~35
+  dylibs incl. `libjerboa_browser.dylib`, the helper app, WebEngine `.pak` +
+  `icudtl.dat` resources, platform/imageformat/tls/etc. plugins) and documents
+  the fixups and why macdeployqt alone SIGABRTs on first page load.
+- Linux: `packaging/linux/build-tarball.sh` stages backend `.so` + `scheme/` +
+  launcher relying on system Qt6 WebEngine; marked **not verified on the macOS
+  dev host**, with linuxdeployqt/AppImage noted as the no-prerequisite path.
+
+Goal: make deployment honest and reproducible.
+
+Implement packaging notes/scripts under `browser/packaging/`.
+
+Requirements:
+
+- Package the main executable, Qt libraries, platform plugins,
+  `QtWebEngineProcess`, WebEngine resources, and translations.
+- macOS target: `.app`.
+- Linux target: AppImage or tarball.
+- Do not rely on the developer's Qt install tree at runtime.
+- Do not disable sandboxing.
+
+Acceptance:
+
+- Running from a clean temporary directory works.
+- The package can load `https://example.com`.
+- `browser/packaging/README.md` lists every bundled component and any accepted
+  system libraries.
+
+### Ticket 4.1 — Servo backend watchpoint — DONE (2026-05-24)
+
+Result: `notes/servo-watchpoint.md` created. It records why we ship on Qt
+WebEngine now, the six conditions that would justify starting a Servo backend
+(stable embedding API, reliable offscreen surface, JS-eval API, cross-platform
+event loop, realistic page compatibility, credible static-resource story), and
+that Servo would slot in as a new backend behind the existing C ABI. **No Servo
+code added** — the note is the only Servo artifact in the repo.
+
+Goal: keep the long-term path visible without blocking Qt WebEngine.
+
+Do not implement Servo. Create only a short note:
+
+```text
+notes/servo-watchpoint.md
+```
+
+It should list conditions that would justify starting a Servo backend:
+
+- stable public embedding API
+- reliable offscreen rendering surface
+- JavaScript evaluation API
+- cross-platform event loop story
+- realistic page compatibility for the target Jerboa use cases
+- credible static resource story
+
+Acceptance:
+
+- The note exists.
+- No Servo code is added.
+
+## Build and test matrix
+
+Expected commands as implementation progresses:
+
+```text
+cmake -S qt-webengine/probe -B qt-webengine/probe/build -DCMAKE_PREFIX_PATH=/opt/homebrew
+cmake --build qt-webengine/probe/build
+
+cmake -S qt-webengine -B qt-webengine/build -DCMAKE_PREFIX_PATH=/opt/homebrew
+cmake --build qt-webengine/build
+ctest --test-dir qt-webengine/build
+
+make browser-webengine-demo
+make browser-webengine-test
+make package-browser