Compare commits

...

39 Commits

Author SHA1 Message Date
Laan Tungir
473e2e4a96 v0.0.56 - Auto-fallback MCP port on conflict + discovery files for multi-instance support 2026-07-21 15:40:00 -04:00
Laan Tungir
9a55fe15f7 v0.0.55 - Fix n_signer USB serial login: send kind-27235 caller auth envelope and detect /dev/ttyUSB* devices 2026-07-21 15:34:45 -04:00
Laan Tungir
5e26c502ee v0.0.54 - Update vendored nostr_core_lib to v0.6.9 (NIP-03 OpenTimestamps expansion, prefixed n_signer verbs) 2026-07-20 17:38:19 -04:00
Laan Tungir
45aa46f6c4 v0.0.53 - Fix: login dialog window title shows 'sovereign browser v0.0.53' (matching main window); remove redundant app-name header from dialog body 2026-07-20 07:29:42 -04:00
Laan Tungir
480d758241 Fix: exclude large test media files from release tarball (mp4/m4a/webm/ogg) to avoid nginx 413 on upload 2026-07-20 06:57:08 -04:00
Laan Tungir
eb093ee16a v0.0.52 - Release: login dialog shows app version in title; n_signer key-index label live-updates BIP-32 derivation path 2026-07-20 06:55:37 -04:00
Laan Tungir
618d52ff69 v0.0.51 - Reduce terminal noise: suppress processes polling logs, remove perf-probe and inject success prints 2026-07-19 15:44:00 -04:00
Laan Tungir
3d32c64b0a v0.0.50 - CPU values: black text, red only for >=90%; remove all other heat colors and shaded backgrounds 2026-07-19 14:55:53 -04:00
Laan Tungir
ca2907b357 v0.0.49 - Fix: WebKit caching sovereign:// responses — add cache-busting to processes polls; remove shaded backgrounds from CPU values; remove debug prints 2026-07-19 14:13:43 -04:00
Laan Tungir
0892a9c7e1 v0.0.48 - Fix: heat bar text color readable on all backgrounds (grey-on-grey fix) 2026-07-19 10:21:47 -04:00
Laan Tungir
b3955e4231 v0.0.47 - Release: processes diagnostics window with per-tab CPU probe 2026-07-19 10:06:01 -04:00
Laan Tungir
3bacac4a42 Fix: prevent set -e from aborting before Gitea release creation; add status logging 2026-07-18 20:35:17 -04:00
Laan Tungir
fe1112f480 v0.0.46 - Release: target=_blank links open in new tab instead of new window 2026-07-18 20:28:51 -04:00
Laan Tungir
fa5dd93c6d v0.0.45 - Release: nested bookmark tree view with HMAC d tags, bookmarks toolbar, tarball cleanup trap, install next-steps fix 2026-07-18 17:51:54 -04:00
Laan Tungir
7d91a186d4 v0.0.44 - Move tab to new window on 'Open in New Window' (close original tab); keep app alive when main window closes if aux windows have tabs (delete-event handler) 2026-07-17 19:14:35 -04:00
Laan Tungir
6a8e51c812 v0.0.43 - Add new-tab button and avatar to auxiliary windows: extract setup_notebook_action_widgets helper, wire new-tab button to its own notebook, sync avatar across all windows 2026-07-17 19:01:12 -04:00
Laan Tungir
12f8b45015 v0.0.42 - Fix segfault on onion/fips/nostr URL redirect in new tab: defer webkit_web_view_load_uri to idle callback to avoid re-entrant navigation inside decide-policy handler 2026-07-17 18:35:36 -04:00
Laan Tungir
b90ea0bb11 v0.0.41 - Fix install.sh: FIPS prebuilt binary deleted before copy (RETURN trap) — use stable mktemp file 2026-07-17 12:41:59 -04:00
Laan Tungir
5b949ec034 v0.0.40 - install.sh: download FIPS prebuilt tarball from GitHub releases by default (source build via --build-fips-from-source) 2026-07-17 12:39:22 -04:00
Laan Tungir
43ee97bc38 v0.0.39 - Fix install.sh: FIPS is a Rust project — use cargo build --release, install rustup if needed, add libclang-dev 2026-07-17 12:33:02 -04:00
Laan Tungir
3ac4d4f9dc v0.0.38 - Fix install.sh apt package names for Debian 13 (trixie t64 transition) 2026-07-17 12:11:36 -04:00
Laan Tungir
b111fffeaa v0.0.37 - Fix install.sh: read confirmation from /dev/tty, fix color rendering in summary 2026-07-17 11:55:42 -04:00
Laan Tungir
7ca05baff1 v0.0.36 - Add Install section to README 2026-07-17 11:32:29 -04:00
Laan Tungir
6c1d309aac v0.0.35 - Selective Tor onion routing (tor:// scheme) + install.sh 2026-07-17 11:30:28 -04:00
Laan Tungir
072915ef51 v0.0.34 - Add JSON tree viewer, relay-backed Nostr URIs, and Tor/FIPS network services 2026-07-16 20:37:14 -04:00
Laan Tungir
e482af8729 v0.0.33 - Fix segfault on tab close: disconnect webview signals before destroying widget 2026-07-16 15:14:45 -04:00
Laan Tungir
6f639cd419 v0.0.32 - Convert send button to stop button during agent runs (remove separate stop-btn) 2026-07-16 11:47:57 -04:00
Laan Tungir
6020df2e92 v0.0.31 - Advanced JS tests: ES Modules, Web Workers, Shared Workers, WebAssembly, cross-origin fetch; Service Worker analysis plan 2026-07-16 11:35:57 -04:00
Laan Tungir
ae102323f6 v0.0.30 - Local file browsing: test site, storage/media tests, load-failed URL fix, title logging fix, README update 2026-07-16 11:17:13 -04:00
Laan Tungir
b393308b2e v0.0.29 - Tab right-click context menu with Open in New Window, Ctrl+N new window shortcut, floating X close button on sidebar, and fixed shortcut bypass for sovereign:// pages 2026-07-16 10:37:48 -04:00
Laan Tungir
0a1b117746 v0.0.28 - Added zoom shortcuts (Ctrl+/-/0), fixed shortcut capture for Ctrl keys on settings page, and added software-rendering detection in browser.sh to fix high CPU on content-heavy pages 2026-07-16 10:22:07 -04:00
Laan Tungir
5150b7282a v0.0.27 - Embedded agent: tabbed chat, sidebar mode, skills, Nostr sync, embedded web content, bug fixes 2026-07-15 12:43:31 -04:00
Laan Tungir
b57367de1c v0.0.26 - Remove theme toggle link from pages, default restore_session=off, clear history and session on shutdown for privacy 2026-07-13 12:29:25 -04:00
Laan Tungir
1c6f5b6366 v0.0.25 - Fix theme toggle button visual state: add id to toggle div, flip on/off class in JS when theme_dark is toggled 2026-07-13 12:20:14 -04:00
Laan Tungir
21eff5c7c8 v0.0.24 - Fix theme toggle lag: replace fetch() with async XHR in toggle() and save() functions, theme_dark toggles CSS class instantly without page reload 2026-07-13 07:56:31 -04:00
Laan Tungir
aa2ad9a6cb v0.0.23 - Fix theme toggle lag: use async XHR instead of sync to avoid 5-second UI freeze 2026-07-13 07:45:55 -04:00
Laan Tungir
12054b9900 v0.0.22 - Theme toggle: instant visual response via JS class toggle, then save via XHR without page reload 2026-07-13 07:34:07 -04:00
Laan Tungir
31195c4fe2 v0.0.21 - Settings page cleanup: default new tab URL blank, remove all dimmed descriptions, fix garbled text at bottom (missing foot argument in printf), theme toggle as link instead of onclick div 2026-07-13 07:19:34 -04:00
Laan Tungir
a4b4c52a91 v0.0.20 - Per-user profile directory system: each Nostr identity gets its own browser.db (events, history, session, bookmarks) in ~/.sovereign_browser/profiles/<pubkey>/, global settings in global.db, identity persistence per-profile 2026-07-13 07:02:54 -04:00
167 changed files with 33347 additions and 1179 deletions

22
.gitignore vendored
View File

@@ -7,9 +7,31 @@ sovereign_browser
*.dll
build/
# release tarballs (created by increment_and_push.sh -r, cleaned up by trap)
*.tar.gz
# compiled test binaries
tests/test_bookmarks_tree
tests/test_nostr_url
# editor / OS
*.swp
.DS_Store
# vendored dependency (added as a subtree/checkout, not tracked here)
nostr_core_lib/
# auto-generated embedded web content (built by embed_web_files.sh)
src/embedded_web_content.c
src/embedded_web_content.h
# binary media files for local-site test (too large for git)
tests/local-site/assets/*.mp3
tests/local-site/assets/*.m4a
tests/local-site/assets/*.mp4
tests/local-site/assets/*.webm
tests/local-site/assets/*.ogg
tests/local-site/assets/*.ogv
tests/local-site/assets/*.wav
tests/local-site/assets/*.avi
tests/local-site/assets/*.mkv

View File

@@ -6,12 +6,15 @@
```bash
./browser.sh start [ARGS...] # build + launch (detached, waits for MCP ready), forwards ARGS
./browser.sh stop # kill running instance
./browser.sh stop # kill ALL running instances
./browser.sh restart [ARGS...]# stop + build + start, forwards ARGS
./browser.sh status # check if running + MCP responsive
./browser.sh log # tail last 50 lines of browser log
./browser.sh status # list all running instances + MCP status
./browser.sh log # tail the browser log
./browser.sh mcp-url # print MCP endpoint URL(s) for running instance(s)
```
**Multiple instances:** if the default MCP port (17777) is already in use by another instance, the browser automatically falls back to an OS-assigned port and records it in `/tmp/sovereign_browser_<pid>.port`. `browser.sh` reads these discovery files to find/stop/status every instance. Use `./browser.sh mcp-url` to discover the actual endpoint(s).
## Login
The browser requires Nostr login before browser tools work. The simplest way is to pass `--login-method` on the command line — this bypasses the GTK login dialog entirely, no MCP call needed:
@@ -36,7 +39,7 @@ Alternatively, you can still log in at runtime via the MCP `login` tool with `{"
## MCP Server
- Endpoint: `http://localhost:17777/mcp` (Streamable HTTP)
- Endpoint: `http://localhost:17777/mcp` (Streamable HTTP) for the first instance; subsequent instances get an OS-assigned port — use `./browser.sh mcp-url` to discover the actual endpoint(s)
- 100 tools available (login, navigation, snapshot, interaction, tabs, cookies, screenshots, etc.)
- See `.roo/agents.md` for the full tool list and workflow

View File

@@ -5,7 +5,11 @@
# libsecp256k1-dev, libssl-dev, libcurl4-openssl-dev
CC ?= gcc
CFLAGS ?= -std=c99 -Wall -Wextra -O2
# Debug build: -g for debug symbols (stack traces in gdb/core dumps),
# -O2 for optimization (runs at full speed). No ASAN (too slow for
# daily use). To temporarily enable ASAN for crash investigation:
# make clean && make CFLAGS="-std=c99 -Wall -Wextra -g -O0 -fsanitize=address" LDFLAGS="-fsanitize=address"
CFLAGS ?= -std=c99 -Wall -Wextra -g -O2
CFLAGS += $(shell pkg-config --cflags webkit2gtk-4.1 libsoup-3.0 libqrencode sqlite3)
CFLAGS += -I./nostr_core_lib
CFLAGS += -DNOSTR_ENABLE_NSIGNER_CLIENT
@@ -17,11 +21,24 @@ NOSTR_LIB = ./nostr_core_lib/libnostr_core_x64.a
NOSTR_DEPS = -lsecp256k1 -lssl -lcrypto -lcurl -lz -ldl -lpthread -lm
BIN := sovereign_browser
SRC := src/main.c src/key_store.c src/login_dialog.c src/nostr_bridge.c src/nostr_inject.c src/history.c src/settings.c src/tab_manager.c src/session.c src/agent_server.c src/agent_login.c src/agent_snapshot.c src/agent_tools.c src/agent_mcp.c src/cli.c src/qr.c src/db.c src/relay_fetch.c src/bookmarks.c src/shortcuts.c src/settings_sync.c src/search.c
SRC := src/main.c src/key_store.c src/login_dialog.c src/nostr_bridge.c src/nostr_inject.c src/nostr_url.c src/nostr_scheme.c src/history.c src/settings.c src/net_services.c src/tor_control.c src/tor_scheme.c src/fips_control.c src/tab_manager.c src/session.c src/agent_server.c src/agent_login.c src/agent_snapshot.c src/agent_tools.c src/agent_mcp.c src/cli.c src/qr.c src/db.c src/relay_fetch.c src/bookmarks.c src/shortcuts.c src/settings_sync.c src/search.c src/profile.c src/agent_fs_tools.c src/agent_llm.c src/agent_loop.c src/agent_chat_store.c src/agent_chat.c src/agent_conversations.c src/agent_skills.c src/embedded_web_content.c src/process_info.c src/perf_probe.c src/webkit_data.c src/web_context.c
$(BIN): $(SRC) $(NOSTR_LIB)
# Web files embedded into the binary as C byte arrays.
WEB_FILES := $(shell find www -type f \( -name '*.html' -o -name '*.css' -o -name '*.js' \) 2>/dev/null)
# Default goal: build the browser binary.
$(BIN): $(SRC) $(NOSTR_LIB) src/embedded_web_content.c
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ $(NOSTR_LIB) $(LDLIBS) $(NOSTR_DEPS)
# Auto-generated C byte arrays from www/. Regenerated whenever a web
# file changes (or on first build). Both the .c and .h are produced by
# a single invocation of embed_web_files.sh.
src/embedded_web_content.c: $(WEB_FILES) embed_web_files.sh
./embed_web_files.sh
src/embedded_web_content.h: src/embedded_web_content.c
@true
$(NOSTR_LIB):
@echo "Building nostr_core_lib..."
cd nostr_core_lib && ./build.sh --nips=all

325
README.md
View File

@@ -29,28 +29,125 @@ because trust moves to the layer where it belongs: your keys.
4. **Deprecated web security, deliberately.** Same-origin policy, CORS, and
certificate enforcement are stripped so pages (and the agent runtime to
come) can freely call any endpoint — including FIPS mesh services — without
the workarounds traditional browsers force on automators.
the workarounds traditional browsers force on automators. Security moves
up to the **Qube level** (isolation per VM), so the browser doesn't need
the traditional in-browser security model.
5. **Local file browsing.** Open and operate multipage websites directly from
`file://` with no web server — cross-origin access between local files,
`fetch()`/XHR, iframes, storage, media playback, all work. See
[Local file browsing](#local-file-browsing) below.
## Non-goals (for now)
## Nostr interaction policy
- Agent integration, multi-window agent hosts, didactyl hosting, and the
broader "browser as agent runtime" vision are documented in
[`docs/architecture.md`](docs/architecture.md) and the linked plans, but
**not in scope for the first build**. First: a usable browser with basic
Nostr signing. Then the rest.
sovereign_browser interacts with Nostr via **discrete events only** — no
continuous WebSocket subscriptions. The browser fetches events on demand
(via `relay_fetch.c`) and publishes events on demand (via `settings_sync.c`,
`agent_conversations.c`, `agent_skills.c`). There is no persistent
subscription that receives live updates.
This is a deliberate architectural choice:
- **Simplicity** — No subscription lifecycle management, no reconnection
logic, no event deduplication across reconnects.
- **Predictability** — The user controls when data is fetched or published.
No background traffic, no surprise events arriving.
- **Resource efficiency** — No open WebSocket connections consuming memory
and bandwidth while idle.
**Trade-off:** The browser does not receive live updates. If a conversation
or skill is created in another app (e.g., the client web app), it won't
appear in sovereign_browser until the user explicitly refreshes. UIs that
need fresh data provide **Refresh buttons** that trigger a one-shot fetch.
### What this means for the chat page
The `sovereign://agents/chat` page differs from `~/lt/client/www/ai.html`
in this respect:
- **ai.html** uses NDK with persistent subscriptions — conversations and
skills appear live as they're published to relays.
- **sovereign_browser** fetches conversations and skills on demand — the
user clicks a Refresh button to pull new data from relays.
Conversations and skills are still stored as the same Nostr event kinds
(30078 for conversations, 31123 for skills) with the same tags, so they're
**compatible** across projects — just not live-synced.
## Current status
Working browser: a C99 + WebKitGTK window with multi-tab support, a URL bar
per tab, Nostr login, and `window.nostr` injection. Verified loading
`https://laantungir.net` cleanly. See
[`docs/webkit-poc-findings.md`](docs/webkit-poc-findings.md) for the friction
report from the POC phase (and the Servo fallback exploration).
Working browser with a broad feature set:
- **Multi-tab browsing** with session restore, tab drag/reorder, context menus
- **Nostr login** (GTK dialog or CLI flags) with multiple methods: generate,
local (nsec), seed (BIP-39), readonly (npub), NIP-46, n_signer hardware
- **`window.nostr` injection** (NIP-07) into every page
- **Agent MCP server** — 100 tools for browser automation via Streamable HTTP
at `http://localhost:17777/mcp`
- **Local file browsing** — open and operate multipage websites directly from
`file://` with no web server (see below)
- **Bookmarks, history, search** — built-in bookmark manager, browsing history,
and search engine integration
- **Keyboard shortcuts** — standard browser shortcuts (Ctrl+T, Ctrl+W, Ctrl+L,
Ctrl+Tab, etc.)
- **Per-user profiles** — separate identities and settings per profile
- **Embedded web content** — `sovereign://` pages for settings, bookmarks,
profile, and agent chat/config
See [`docs/webkit-poc-findings.md`](docs/webkit-poc-findings.md) for the
friction report from the POC phase.
## Install
One-command install on Debian 13 (trixie) or similar (x86_64, WebKitGTK 4.1):
```bash
curl -fsSL https://git.laantungir.net/laantungir/sovereign_browser/raw/branch/master/install.sh | bash
```
[loaded] https://laantungir.net/ -- title: Laan Tungir
This downloads and runs [`install.sh`](install.sh), which:
1. Installs runtime dependencies via `apt` (WebKitGTK 4.1, libsoup, curl, etc.)
2. Installs **Tor** via `apt` (used for `.onion` routing)
3. Builds and installs **FIPS** from source with `CAP_NET_ADMIN` (used for `.fips` mesh routing)
4. Downloads the latest prebuilt `sovereign_browser` binary from the [Gitea releases](https://git.laantungir.net/laantungir/sovereign_browser/releases) (falls back to a source build if no binary is available)
5. Installs a `sovereign-browser` wrapper command to `/usr/local/bin`
After install, start the browser with:
```bash
sovereign-browser start --login-method generate
```
### Install options
```bash
# Non-root install to a user-writable prefix
curl -fsSL <url> | INSTALL_PREFIX=$HOME/.local bash -s -- --yes
# Force a source build instead of downloading the prebuilt binary
curl -fsSL <url> | bash -s -- --build-from-source
# Review the script before running it
curl -fsSL <url> -o install.sh
less install.sh
bash install.sh
```
Run `bash install.sh --help` for the full option list.
### What the install requires
| Requirement | Why |
|---|---|
| Debian 13+ / Ubuntu 24.04+ (WebKitGTK 4.1) | The browser is dynamically linked against `libwebkit2gtk-4.1` |
| x86_64 | Prebuilt binary is x86-64; arm64 needs `--build-from-source` |
| `sudo` / root | For `apt install`, `setcap` on the FIPS binary, and writing to `/usr/local` |
| Internet access | To download the binary, apt packages, and the FIPS source |
Tor and FIPS are **installed by default** (not optional). If either is absent at runtime the browser degrades gracefully — clearnet still works, `.onion`/`.fips` show an error page — but the installer sets them up so everything works out of the box.
---
## Build
Requires Debian 13 (trixie) or similar with WebKitGTK 4.1 dev headers:
@@ -58,9 +155,14 @@ Requires Debian 13 (trixie) or similar with WebKitGTK 4.1 dev headers:
```bash
sudo apt install libwebkit2gtk-4.1-dev
make
./sovereign_browser [url]
./browser.sh start [url] # build + launch (detached, won't hang terminal)
```
> **Note:** Always use `./browser.sh` to start/stop the browser. Running
> `./sovereign_browser` directly will hang the terminal because the GUI
> process keeps stdout/stderr open. See `./browser.sh --help` or
> [`.roo/agents.md`](.roo/agents.md) for details.
## Command-line flags
Run `./sovereign_browser --help` for the full list. Flags are parsed before
@@ -122,21 +224,22 @@ credentials.
### Examples
```bash
# Existing behavior — still works
./sovereign_browser https://example.com
# Basic usage (via browser.sh — recommended)
./browser.sh start https://example.com
# Generate a fresh key, skip the login dialog, open a page
./sovereign_browser --login-method generate --url https://example.com
./browser.sh start --login-method generate --url https://example.com
# Local key from env, custom agent port
./sovereign_browser --login-method local --nsec "$NSEC" --port 18888
./browser.sh start --login-method local --nsec "$NSEC" --port 18888
# Read-only (npub), no agent server, two tabs
./sovereign_browser --login-method readonly --npub npub1... \
./browser.sh start --login-method readonly --npub npub1... \
--no-agent --url https://a.com --url https://b.com
# Via browser.sh (forwards extra args)
./browser.sh start --login-method generate --url https://example.com
# Open a local website (no web server needed)
./browser.sh start --login-method generate \
--url "file:///path/to/website/index.html"
```
## Architecture (summary)
@@ -159,7 +262,7 @@ credentials.
┌─────────────────────────────────────────────────────┐
│ WebKitGTK (system lib, ~35 MB) │
Blink-grade renderer + V8 + libsoup network stack
WebCore renderer + JavaScriptCore + libsoup net
└─────────────────────────────────────────────────────┘
```
@@ -175,8 +278,9 @@ infrastructure, multiple uses:
| Scheme | Purpose | Status |
|--------|---------|--------|
| `sovereign://nostr/*` | `window.nostr` bridge — web pages call `fetch('sovereign://nostr/signEvent')` to sign via the C-side `nostr_signer_t` | Planned |
| `sovereign://settings` | Browser-internal pages (like `chrome://settings`) | Planned |
| `sovereign://nostr/*` | `window.nostr` bridge — web pages call `fetch('sovereign://nostr/signEvent')` to sign via the C-side `nostr_signer_t` | ✅ Working |
| `sovereign://settings`, `sovereign://bookmarks`, `sovereign://profile` | Browser-internal pages (like `chrome://settings`) | ✅ Working |
| `sovereign://agents/chat`, `sovereign://agents/config` | Agent chat and configuration pages | ✅ Working |
| `fips://` / `*.fips` | Route to FIPS mesh nodes via TUN interface | Planned |
| `nostr://` | Fetch Nostr events from relays, render as HTML | Planned |
@@ -241,79 +345,142 @@ is saved to `~/.sovereign_browser/session.txt`.
See [`plans/browser-tabs.md`](plans/browser-tabs.md) for the full
implementation plan.
### Agent tools
### Local file browsing
The browser embeds a WebSocket server (using libsoup) that allows external
AI agents to control the browser programmatically. Connect to
`ws://localhost:17777/agent` and send JSON tool commands.
sovereign_browser is designed to run websites directly from local files —
open an `index.html` from its directory and navigate as if it were served
over HTTP. No web server required. This is a deliberate design choice:
security is moved up to the Qube level, so the browser doesn't need the
traditional security restrictions (same-origin policy, CORS) that would
block local-file web apps.
The server starts before the login dialog and remains available throughout
the browser's lifecycle. The browser starts normally with the GTK login
dialog — no blocking wait. An agent can log in at any time: while the dialog
is showing, or after the browser is already running. If an agent logs in
while the dialog is open, the agent's login takes priority.
**What works on `file://` pages:**
**Login tools** (available before login):
- **Relative links** between pages — `index.html``about.html`,
`subdir/page.html`, `../index.html`, `../../../deep/page.html`
- **Cross-origin access** — all `file://` URLs are treated as same-origin.
Iframes can access their parent's DOM, `window.open()` popups are
accessible, and `fetch()`/XHR work across directories.
- **`fetch()` and `XMLHttpRequest`** — load local JSON, text, and other
files via relative paths (returns `status=0`, the `file://` convention)
- **External resources** — CSS stylesheets, JavaScript files, SVG images,
favicons, `<picture>`/`srcset`, `<object>`/`<embed>`
- **Video and audio playback** — MP4 video and M4A audio play correctly
with full duration/dimension metadata
- **All browser storage** — `localStorage`, `sessionStorage`, `cookies`,
and `IndexedDB` all work fully (open, upgrade, put, get, count)
- **`history.pushState`/`replaceState`** — SPA-style routing works on
`file://` URLs
- **Query strings and hash fragments** — preserved in `location.href`,
accessible via `location.search`/`location.hash`
- **URL-encoded filenames** — `encoded%20name.html` (file on disk:
`encoded name.html`) loads correctly
- **Form submissions** — GET forms append query strings to the target
page; POST forms navigate (body silently dropped, no server)
- **`window.open()`** — opens local pages in new tabs
- **ES Modules** — dynamic `import()` works with local module files
- **Web Workers and Shared Workers** — `new Worker()` and
`new SharedWorker()` with local scripts enable background computation
threads (including cross-tab shared workers)
- **WebAssembly** — `WebAssembly.instantiate()` works for compiled code
- **Cross-origin fetch to remote HTTPS** — `fetch('https://example.com')`
from a `file://` page works with no CORS blocking (confirms the
"deprecated web security" design goal)
- **Cross-origin remote resources** — remote `<script src>`,
`<link rel=stylesheet>`, and `<img>` from HTTPS CDNs all load correctly
- `login_status` — check if logged in
- `login` — authenticate with method: `local` (nsec), `seed` (BIP-39
mnemonic), `readonly` (npub), `nip46` (bunker:// URL), or `nsigner`
(hardware signer via serial/unix/tcp/qrexec transport)
- `logout` — clear signer and reset state
- `switch_identity` — change identity (same params as login)
**Known limitations (WebKit engine quirks, not bugs):**
**Browser tools** (available after login):
- `fetch()` for missing files throws `Load failed` instead of returning an
error Response (no HTTP status codes on `file://`)
- `<video>`/`<audio>` `error` events don't fire for missing source files
(valid media works perfectly)
- Cache API (CacheStorage) rejects non-HTTP/HTTPS URLs — use IndexedDB
instead for local-file storage
- Service Workers don't work on `file://` (W3C spec requires HTTP/HTTPS) —
use Web Workers for background processing instead
- Navigation: `open`, `back`, `forward`, `reload`, `get_url`, `get_title`
- Inspection: `snapshot` (accessibility tree with element refs), `get_text`,
`get_html`, `get_attr`, `eval` (run JavaScript)
- Interaction: `click`, `fill`, `type`, `press`, `scroll`, `hover`,
`focus`, `close`
- Tabs: `tab_list`, `tab_new`, `tab_switch`, `tab_close`
- Wait: `wait` (ms), `wait_for` (selector)
**Snapshot + ref workflow** (same pattern as agent-browser):
**Test site:** A comprehensive multipage test website with 53 edge cases
is in [`tests/local-site/`](tests/local-site/index.html). See
[`tests/local-site/LOCAL-FILE-BROWSING-REPORT.md`](tests/local-site/LOCAL-FILE-BROWSING-REPORT.md)
for the full test report.
```bash
# Using websocat (cargo install websocat)
# 1. Login
echo '{"id":1,"tool":"login","params":{"method":"readonly","npub":"npub1..."}}' | websocat ws://localhost:17777/agent
# 2. Open a page
echo '{"id":2,"tool":"open","params":{"url":"https://example.com"}}' | websocat ws://localhost:17777/agent
# 3. Snapshot — get accessibility tree with refs
echo '{"id":3,"tool":"snapshot","params":{"interactive":true}}' | websocat ws://localhost:17777/agent
# 4. Click by ref
echo '{"id":4,"tool":"click","params":{"ref":"@e2"}}' | websocat ws://localhost:17777/agent
# Open a local website
./browser.sh start --login-method generate \
--url "file:///path/to/your/website/index.html"
```
**HTTP status endpoint:** `curl http://localhost:17777/` returns server
status (running, port, client count, logged in).
### Agent tools (MCP server)
**Agent settings** (in Settings dialog or `~/.sovereign_browser/settings.conf`):
The browser embeds an MCP (Model Context Protocol) server using Streamable
HTTP transport. External AI agents can control the browser programmatically
via `http://localhost:17777/mcp`.
- `agent_server_enabled` (default: true)
- `agent_server_port` (default: 17777)
- `agent_allowed_origins` (default: `*`)
- `agent_login_timeout_ms` (default: 30000)
The server starts before the login dialog and remains available throughout
the browser's lifecycle. An agent can log in at any time: while the dialog
is showing, or after the browser is already running.
See [`plans/agent-tools.md`](plans/agent-tools.md) for the full
implementation plan and tool comparison with agent-browser.
**100 tools available**, including:
- **Login**: `login_status`, `login`, `logout`, `switch_identity`
- **Navigation**: `open`, `back`, `forward`, `reload`, `get_url`, `get_title`
- **Snapshot & inspection**: `snapshot`, `get_text`, `get_html`, `get_attr`,
`eval`, `screenshot`, `screenshot_annotated`
- **Interaction**: `click`, `dblclick`, `fill`, `type`, `press`, `scroll`,
`hover`, `focus`, `select`, `check`, `uncheck`, `drag`
- **Find elements**: `find_role`, `find_text`, `find_label`, `find_placeholder`,
`find_alt`, `find_title`, `find_testid`
- **Tabs**: `tab_list`, `tab_new`, `tab_switch`, `tab_close`, `close`, `close_all`
- **Wait**: `wait`, `wait_for`, `wait_for_text`, `wait_for_url`, `wait_for_load`
- **Cookies & storage**: `cookies_get/set/clear`, `storage_local_*`, `storage_session_*`
- **Frames**: `frame_switch`, `frame_main`
- **Dialogs**: `dialog_accept`, `dialog_dismiss`, `dialog_status`
- **Debug**: `console`, `errors`, `highlight`
- **Files**: `upload`, `pdf`
**Example workflow:**
```bash
# 1. Login (or use --login-method on the command line)
curl -s -X POST http://localhost:17777/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"login","arguments":{"method":"generate"}}}'
# 2. Open a page
curl -s -X POST http://localhost:17777/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"open","arguments":{"url":"https://example.com"}}}'
# 3. Snapshot — get accessibility tree with element refs
curl -s -X POST http://localhost:17777/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"snapshot","arguments":{}}}'
# 4. Click by ref
curl -s -X POST http://localhost:17777/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"click","arguments":{"ref":"@e2"}}}'
```
See [`.roo/agents.md`](.roo/agents.md) for the full tool list and workflow
guide, and [`plans/agent-tools.md`](plans/agent-tools.md) for the
implementation plan.
## Roadmap
1. ✅ Base browser (WebKitGTK + C99, loads pages)
2. ✅ Browser tabs (GtkNotebook, per-tab toolbar, session restore, settings)
3.Agent tools Phase 1 (WebSocket server, login tools, 31 browser automation tools)
4. ⏳ Nostr login (GTK dialog → nostr_core_lib → nostr_signer_t)
5. `window.nostr` injection (`sovereign://` URI scheme bridge)
6. ⏳ Security strip (disable SOP/CORS, accept any cert, shared context)
7. ⏳ FIPS URI scheme (`fips://` / `*.fips` via WebKitGTK scheme handler)
8. `nostr://` content scheme
9.Agent tools Phase 2 (refinements) and Phase 3 (extended tool catalog)
10. Future: agent runtime (didactyl hosting), multi-window, FIPS-distributed
3.Nostr login (GTK dialog → nostr_core_lib → nostr_signer_t, 6 methods)
4. `window.nostr` injection (`sovereign://` URI scheme bridge)
5. ✅ Agent MCP server (100 tools, Streamable HTTP at `http://localhost:17777/mcp`)
6. ✅ Embedded web content (`sovereign://settings`, `bookmarks`, `profile`, `agents/chat`, `agents/config`)
7. ✅ Bookmarks, history, search, keyboard shortcuts, per-user profiles
8. ✅ Local file browsing (multipage websites from `file://` with no web server)
9.Security strip (disable SOP/CORS, accept any cert, shared context)
10. ⏳ FIPS URI scheme (`fips://` / `*.fips` via WebKitGTK scheme handler)
11.`nostr://` content scheme
12. Future: agent runtime (didactyl hosting), multi-window, FIPS-distributed
## License

View File

@@ -1 +1 @@
0.0.19
0.0.56

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -8,10 +8,16 @@
#
# Usage:
# ./browser.sh start [ARGS...] — build + launch (detached), forwarding ARGS
# ./browser.sh stop — kill any running instance
# ./browser.sh stop — kill ALL running instances
# ./browser.sh restart [ARGS...]— stop + build + start, forwarding ARGS
# ./browser.sh status — check if running + MCP responsive
# ./browser.sh status — list all running instances + MCP status
# ./browser.sh log — tail the browser log
# ./browser.sh mcp-url — print MCP endpoint URL(s) for running instance(s)
#
# Multiple instances: if the default MCP port (17777) is already in use by
# another instance, the browser automatically falls back to an OS-assigned
# port and records it in /tmp/sovereign_browser_<pid>.port. browser.sh
# reads these discovery files to find/stop/status every instance.
#
# Extra args after start/restart are forwarded to the binary, e.g.:
# ./browser.sh start --login-method generate --url https://example.com
@@ -25,7 +31,37 @@ set -euo pipefail
BIN="./sovereign_browser"
LOG="/tmp/sovereign_browser.log"
PID_FILE="/tmp/sovereign_browser.pid"
PORT=17777
DEFAULT_PORT=17777
DISCOVERY_GLOB="/tmp/sovereign_browser_*.port"
# Read the port for a given PID from its discovery file. Falls back to
# DEFAULT_PORT (17777) if no discovery file exists (e.g. older binary, or
# the first instance that successfully bound the default port and wrote a
# discovery file with 17777). Echoes the port to stdout.
port_for_pid() {
local pid="${1:-}"
local file="/tmp/sovereign_browser_${pid}.port"
if [[ -f "$file" ]]; then
local p
p=$(awk '{print $2}' "$file" 2>/dev/null | head -n1)
if [[ -n "$p" ]]; then
echo "$p"
return 0
fi
fi
echo "$DEFAULT_PORT"
}
# Echo the port for the currently-running instance (per get_pid), or
# DEFAULT_PORT if not running.
current_port() {
local pid
if pid=$(get_pid 2>/dev/null); then
port_for_pid "$pid"
else
echo "$DEFAULT_PORT"
fi
}
# Check if a PID is actually the sovereign_browser process.
# This safety guard prevents us from accidentally killing unrelated
@@ -53,13 +89,24 @@ get_pid() {
return 0
fi
fi
# Fallback: find the process LISTENing on the port.
# Fallback 1: scan discovery files for a live sovereign_browser PID.
local f
for f in $DISCOVERY_GLOB; do
[[ -f "$f" ]] || continue
local dpid
dpid=$(awk '{print $1}' "$f" 2>/dev/null | head -n1)
if [[ -n "$dpid" ]] && is_browser_pid "$dpid"; then
echo "$dpid"
return 0
fi
done
# Fallback 2: find the process LISTENing on the default port.
# -sTCP:LISTEN matches only the listening socket, never client
# connections (e.g. Roo Code's MCP client). This is critical —
# without it, `lsof -ti :PORT` would also return Roo Code's PID
# and we'd kill the agent driving the browser.
local port_pid
port_pid=$(lsof -ti :"$PORT" -sTCP:LISTEN 2>/dev/null | head -n1 || echo "")
port_pid=$(lsof -ti :"$DEFAULT_PORT" -sTCP:LISTEN 2>/dev/null | head -n1 || echo "")
if [[ -n "$port_pid" ]] && is_browser_pid "$port_pid"; then
echo "$port_pid"
return 0
@@ -67,13 +114,36 @@ get_pid() {
return 1
}
# Echo all live sovereign_browser PIDs (one per line), discovered via the
# discovery files and the default-port listen socket. Used by cmd_stop to
# kill every running instance.
all_pids() {
local pids=()
local f
for f in $DISCOVERY_GLOB; do
[[ -f "$f" ]] || continue
local dpid
dpid=$(awk '{print $1}' "$f" 2>/dev/null | head -n1)
if [[ -n "$dpid" ]] && is_browser_pid "$dpid"; then
pids+=("$dpid")
fi
done
local pp
for pp in $(lsof -ti :"$DEFAULT_PORT" -sTCP:LISTEN 2>/dev/null || true); do
if is_browser_pid "$pp"; then
pids+=("$pp")
fi
done
# Deduplicate.
printf '%s\n' "${pids[@]}" 2>/dev/null | sort -u | grep -v '^$' || true
}
cmd_stop() {
local stopped_any=0
# Collect candidate PIDs from the PID file and from LISTENing sockets.
# We use -sTCP:LISTEN so we only match the server, never client
# connections (Roo Code / VS Code ext host connect to the MCP server
# and would be killed by a bare `lsof -ti :PORT`).
# Collect ALL running sovereign_browser PIDs from the PID file,
# discovery files, and the default-port listen socket. This kills
# every instance when multiple are running on different ports.
local candidates=()
if [[ -f "$PID_FILE" ]]; then
while IFS= read -r pid; do
@@ -82,7 +152,7 @@ cmd_stop() {
fi
while IFS= read -r pid; do
[[ -n "$pid" ]] && candidates+=("$pid")
done < <(lsof -ti :"$PORT" -sTCP:LISTEN 2>/dev/null || true)
done < <(all_pids)
# Deduplicate and kill only verified sovereign_browser PIDs.
for pid in $(printf '%s\n' "${candidates[@]}" 2>/dev/null | sort -u | grep -v '^$'); do
@@ -96,7 +166,7 @@ cmd_stop() {
# Wait for graceful shutdown, then force-kill survivors (verified only).
if [[ "$stopped_any" -eq 1 ]]; then
sleep 1
for pid in $(lsof -ti :"$PORT" -sTCP:LISTEN 2>/dev/null || true); do
for pid in $(all_pids); do
if is_browser_pid "$pid"; then
echo "[browser] Force killing PID $pid..."
kill -9 "$pid" 2>/dev/null || true
@@ -106,10 +176,21 @@ cmd_stop() {
fi
rm -f "$PID_FILE"
# Clean up any leftover discovery files (the browser removes its own on
# clean shutdown, but force-killed instances leave them behind).
local f
for f in $DISCOVERY_GLOB; do
[[ -f "$f" ]] || continue
local dpid
dpid=$(awk '{print $1}' "$f" 2>/dev/null | head -n1)
if [[ -z "$dpid" ]] || ! kill -0 "$dpid" 2>/dev/null; then
rm -f "$f"
fi
done
# Final check: is anything still LISTENing on the port?
if lsof -ti :"$PORT" -sTCP:LISTEN >/dev/null 2>&1; then
echo "[browser] Warning: process still listening on port $PORT."
# Final check: is anything still running?
if [[ -n "$(all_pids)" ]]; then
echo "[browser] Warning: some instances still running."
else
echo "[browser] Stopped."
fi
@@ -125,6 +206,45 @@ cmd_start() {
return 0
fi
# ── Rendering environment detection ──────────────────────────────
# WebKitGTK's compositor re-composites every frame. On systems with
# GPU acceleration this is cheap (hardware-accelerated). But in
# software-rendering environments (Qubes OS, headless VMs, no GPU,
# llvmpipe/softpipe Mesa), the compositor pegs the CPU at ~100% on
# content-heavy pages. In those cases we disable compositing mode and
# the DMA-BUF renderer so WebKit uses the lighter non-composited path.
#
# On a normal desktop Linux with a real GPU, we leave these unset so
# WebKit can use hardware-accelerated compositing.
local software_rendering=0
# Check 1: LIBGL_ALWAYS_SOFTWARE is set (Qubes OS sets this unconditionally)
if [[ "${LIBGL_ALWAYS_SOFTWARE:-}" == "1" ]]; then
software_rendering=1
fi
# Check 2: No DRM render device → no GPU available
if [[ ! -e /dev/dri/renderD128 ]]; then
software_rendering=1
fi
# Check 3: EGL/GL renderer is a software rasterizer
if command -v eglinfo >/dev/null 2>&1; then
if eglinfo 2>/dev/null | grep -qiE 'llvmpipe|softpipe|swrast'; then
software_rendering=1
fi
elif command -v glxinfo >/dev/null 2>&1; then
if glxinfo 2>/dev/null | grep -qiE 'llvmpipe|softpipe|swrast'; then
software_rendering=1
fi
fi
if [[ "$software_rendering" -eq 1 ]]; then
echo "[browser] Software rendering detected — disabling WebKit compositor."
export WEBKIT_DISABLE_COMPOSITING_MODE=1
export WEBKIT_DISABLE_DMABUF_RENDERER=1
fi
# Build first
echo "[browser] Building..."
if ! make -s 2>/dev/null; then
@@ -148,13 +268,18 @@ cmd_start() {
disown 2>/dev/null || true
echo "$pid" > "$PID_FILE"
# Wait for the MCP server to be ready (up to 10 seconds)
echo "[browser] Waiting for MCP server on port $PORT..."
# Wait for the MCP server to be ready (up to 10 seconds). The actual
# port may differ from DEFAULT_PORT if it was in use (the browser falls
# back to an OS-assigned port and writes it to a discovery file), so
# read the port from the discovery file each iteration.
echo "[browser] Waiting for MCP server (PID $pid)..."
local mcp_port=""
for i in $(seq 1 100); do
if curl -s -o /dev/null -X POST "http://localhost:$PORT/mcp" \
mcp_port=$(port_for_pid "$pid")
if curl -s -o /dev/null -X POST "http://localhost:$mcp_port/mcp" \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"ping"}' 2>/dev/null; then
echo "[browser] MCP server is ready (PID $pid)."
echo "[browser] MCP server is ready (PID $pid, port $mcp_port)."
return 0
fi
sleep 0.1
@@ -172,22 +297,51 @@ cmd_restart() {
}
cmd_status() {
local pids
pids=$(all_pids)
if [[ -z "$pids" ]]; then
echo "[browser] Not running."
return 0
fi
local count
count=$(echo "$pids" | wc -l)
local pid
if pid=$(get_pid 2>/dev/null); then
echo "[browser] Running (PID $pid)."
# Check MCP
if curl -s -o /dev/null -X POST "http://localhost:$PORT/mcp" \
for pid in $pids; do
local p
p=$(port_for_pid "$pid")
echo "[browser] Running (PID $pid, port $p)."
if curl -s -o /dev/null -X POST "http://localhost:$p/mcp" \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"ping"}' 2>/dev/null; then
echo "[browser] MCP server: responsive"
echo "[browser] MCP server: responsive"
else
echo "[browser] MCP server: NOT responding"
echo "[browser] MCP server: NOT responding"
fi
else
echo "[browser] Not running."
done
if [[ "$count" -gt 1 ]]; then
echo "[browser] $count instances running."
fi
}
# Print the MCP endpoint URL for the running instance (or all instances
# if multiple are running). Useful for external clients that need to
# discover the port programmatically:
# URL=$(./browser.sh mcp-url)
cmd_mcp_url() {
local pids
pids=$(all_pids)
if [[ -z "$pids" ]]; then
echo "[browser] Not running." >&2
return 1
fi
local pid
for pid in $pids; do
local p
p=$(port_for_pid "$pid")
echo "http://localhost:$p/mcp"
done
}
cmd_log() {
if [[ -f "$LOG" ]]; then
tail -50 "$LOG"
@@ -202,9 +356,11 @@ case "${1:-}" in
restart) shift; cmd_restart "$@" ;;
status) cmd_status ;;
log) cmd_log ;;
mcp-url) cmd_mcp_url ;;
*)
echo "Usage: $0 {start|stop|restart|status|log} [ARGS...]" >&2
echo "Usage: $0 {start|stop|restart|status|log|mcp-url} [ARGS...]" >&2
echo " start/restart forward extra args to the binary." >&2
echo " mcp-url prints the MCP endpoint URL(s) for running instance(s)." >&2
exit 1
;;
esac

132
embed_web_files.sh Executable file
View File

@@ -0,0 +1,132 @@
#!/bin/bash
# embed_web_files.sh — Convert www/ web files into C byte arrays
#
# Scans www/**/*.html, www/**/*.css, www/**/*.js and generates
# src/embedded_web_content.c + src/embedded_web_content.h with the
# file contents as C byte arrays. The sovereign:// URI scheme handler
# looks up files by path via get_embedded_file().
#
# Adapted from ~/lt/c-relay/embed_web_files.sh.
set -e
echo "[embed] Embedding web files into C byte arrays..."
# Output directory for generated files
OUTPUT_DIR="src"
mkdir -p "$OUTPUT_DIR"
# Function to convert a file to a C byte array
file_to_c_array() {
local input_file="$1"
local array_name="$2"
local output_file="$3"
# Get file size
local file_size=$(stat -c%s "$input_file" 2>/dev/null || stat -f%z "$input_file" 2>/dev/null || echo "0")
echo "// Auto-generated from $input_file" >> "$output_file"
echo "static const unsigned char ${array_name}_data[] = {" >> "$output_file"
# Convert file to hex bytes
hexdump -v -e '1/1 "0x%02x,"' "$input_file" >> "$output_file"
echo "};" >> "$output_file"
echo "static const size_t ${array_name}_size = $file_size;" >> "$output_file"
echo "" >> "$output_file"
}
# Generate the header file
HEADER_FILE="$OUTPUT_DIR/embedded_web_content.h"
cat > "$HEADER_FILE" <<'EOF'
// Auto-generated embedded web content header
// Do not edit manually - generated by embed_web_files.sh
#ifndef EMBEDDED_WEB_CONTENT_H
#define EMBEDDED_WEB_CONTENT_H
#include <stddef.h>
typedef struct {
const char *path; /* e.g. "agents/chat.html" */
const unsigned char *data;
size_t size;
const char *mime_type; /* "text/html", "text/css", "application/javascript" */
} embedded_file_t;
/*
* Look up an embedded file by its path (e.g. "agents/chat.html").
* Returns NULL if not found.
*/
const embedded_file_t *get_embedded_file(const char *path);
#endif /* EMBEDDED_WEB_CONTENT_H */
EOF
# Generate the C file
SOURCE_FILE="$OUTPUT_DIR/embedded_web_content.c"
cat > "$SOURCE_FILE" <<'EOF'
// Auto-generated embedded web content
// Do not edit manually - generated by embed_web_files.sh
#include "embedded_web_content.h"
#include <string.h>
EOF
# Process each web file and build the lookup table
declare -a file_entries
for file in $(find www -type f \( -name '*.html' -o -name '*.css' -o -name '*.js' -o -name '*.mjs' \) | sort); do
if [ -f "$file" ]; then
# Get path relative to www/
rel_path="${file#www/}"
# Create C identifier from path (replace non-alphanumeric with _)
c_name=$(echo "$rel_path" | sed 's/[^a-zA-Z0-9]/_/g' | sed 's/^_//')
# Determine content type
case "$file" in
*.html) content_type="text/html" ;;
*.css) content_type="text/css" ;;
*.js) content_type="text/javascript" ;;
*.mjs) content_type="text/javascript" ;;
*) content_type="text/plain" ;;
esac
echo "[embed] $rel_path -> ${c_name} ($content_type)"
# Generate the byte array
file_to_c_array "$file" "$c_name" "$SOURCE_FILE"
# Remember for the lookup table (no outer braces — the echo loop
# below wraps each entry in a single set of braces)
file_entries+=("\"$rel_path\", ${c_name}_data, ${c_name}_size, \"$content_type\"")
fi
done
# Generate the lookup table
echo "// File mapping" >> "$SOURCE_FILE"
echo "static const embedded_file_t embedded_files[] = {" >> "$SOURCE_FILE"
for entry in "${file_entries[@]}"; do
echo " { $entry }," >> "$SOURCE_FILE"
done
echo " {NULL, NULL, 0, NULL} // Sentinel" >> "$SOURCE_FILE"
echo "};" >> "$SOURCE_FILE"
echo "" >> "$SOURCE_FILE"
# Generate the lookup function
cat >> "$SOURCE_FILE" <<'EOF'
const embedded_file_t *get_embedded_file(const char *path) {
if (!path) return NULL;
for (int i = 0; embedded_files[i].path != NULL; i++) {
if (strcmp(path, embedded_files[i].path) == 0) {
return &embedded_files[i];
}
}
return NULL;
}
EOF
echo "[embed] Generated $HEADER_FILE and $SOURCE_FILE"
echo "[embed] Embedded ${#file_entries[@]} files"

View File

@@ -27,6 +27,22 @@ print_success() { echo -e "${GREEN}[SUCCESS]${NC} $1" >&2; }
print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1" >&2; }
print_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
# --- Cleanup trap -------------------------------------------------------
# Any temp files registered here are removed on exit, including when the
# script is interrupted (Ctrl+C) or aborted by `set -e`. This prevents
# leftover sovereign_browser-*.tar.gz files from accumulating in the
# project root when a release run is interrupted.
CLEANUP_FILES=()
cleanup_on_exit() {
for f in "${CLEANUP_FILES[@]}"; do
[[ -n "$f" && -f "$f" ]] && rm -f "$f"
done
}
trap cleanup_on_exit EXIT INT TERM
register_cleanup() {
[[ -n "$1" ]] && CLEANUP_FILES+=("$1")
}
# --- Config -------------------------------------------------------------
# Gitea repo for release uploads (derived from the origin remote).
@@ -234,8 +250,8 @@ git_commit_and_push() {
fi
CURRENT_BRANCH=$(git branch --show-current)
git push origin "$CURRENT_BRANCH"
git push origin "$NEW_VERSION" || git push --force origin "$NEW_VERSION"
git push origin "$CURRENT_BRANCH" || print_warning "git push branch returned non-zero (continuing)"
git push origin "$NEW_VERSION" || git push --force origin "$NEW_VERSION" || print_warning "git push tag returned non-zero (continuing)"
}
# --- Release mode: build + Gitea release --------------------------------
@@ -270,6 +286,10 @@ create_source_tarball() {
--exclude='*.log' \
--exclude='*.tar.gz' \
--exclude='nostr_core_lib' \
--exclude='tests/local-site/assets/*.mp4' \
--exclude='tests/local-site/assets/*.m4a' \
--exclude='tests/local-site/assets/*.webm' \
--exclude='tests/local-site/assets/*.ogg' \
. > /dev/null 2>&1; then
echo "$tarball_name"
else
@@ -354,16 +374,24 @@ if [[ "$RELEASE_MODE" == true ]]; then
local_tarball=""
local_tarball=$(create_source_tarball || true)
# Register the tarball for cleanup so it's removed on exit even if a
# later step fails or the script is interrupted. The trap also catches
# the case where create_gitea_release aborts via `set -e`.
register_cleanup "$local_tarball"
print_status "Creating Gitea release for $NEW_VERSION..."
release_id=""
release_id=$(create_gitea_release || true)
if [[ -n "$release_id" ]]; then
print_status "Uploading release assets (binary + tarball)..."
upload_release_assets "$release_id" "$BIN_NAME" "$local_tarball"
else
print_warning "Gitea release creation failed or returned no id. Binary not uploaded."
fi
# Clean up the tarball (the binary is gitignored already).
[[ -n "$local_tarball" && -f "$local_tarball" ]] && rm -f "$local_tarball"
# The tarball is removed by the cleanup_on_exit trap registered above,
# so no manual rm is needed here. The binary is gitignored already.
print_success "Release flow completed: $NEW_VERSION"
else

619
install.sh Executable file
View File

@@ -0,0 +1,619 @@
#!/bin/bash
set -euo pipefail
# install.sh — curl | bash installer for sovereign_browser
#
# Installs:
# * apt runtime deps (WebKitGTK 4.1, libsoup-3, tor, build toolchain)
# * FIPS prebuilt binary from GitHub releases (source-build fallback via
# --build-fips-from-source) with CAP_NET_ADMIN
# * sovereign_browser prebuilt binary from Gitea releases (source-build fallback)
# * a `sovereign-browser` wrapper that detaches the GUI
#
# Tor and FIPS are installed by default — they are NOT optional.
#
# Usage:
# curl -fsSL <url> | bash
# curl -fsSL <url> | bash -s -- --build-from-source
# ./install.sh [options]
# --- Config --------------------------------------------------------------
GITEA_API="https://git.laantungir.net/api/v1/repos/laantungir/sovereign_browser"
GITEA_DOWNLOAD_BASE="https://git.laantungir.net/laantungir/sovereign_browser/releases/download"
SB_REPO="https://git.laantungir.net/laantungir/sovereign_browser.git"
FIPS_GITHUB_API="https://api.github.com/repos/jmcorgan/fips/releases/latest"
FIPS_REPO="https://github.com/jmcorgan/fips.git"
INSTALL_PREFIX="${INSTALL_PREFIX:-/usr/local}"
BIN_NAME="sovereign_browser"
# Runtime apt packages (non-dev). git/build-essential/pkg-config are included
# because they're needed for the FIPS source build and the source-build fallback.
APT_PACKAGES=(
libwebkit2gtk-4.1-0 libsoup-3.0-0 libqrencode4 libsqlite3-0
libsecp256k1-2 libssl3t64 libcurl4t64 zlib1g
tor git build-essential pkg-config
libcap2-bin
)
# libclang-dev is only needed when building FIPS from source (bindgen).
APT_PACKAGES_FIPS_SOURCE=(libclang-dev)
# --- Output helpers ------------------------------------------------------
if [[ -t 2 && -z "${NO_COLOR:-}" ]]; then
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
else
RED='' GREEN='' YELLOW='' BLUE='' NC=''
fi
print_info() { echo -e "${BLUE}[INFO]${NC} $*" >&2; }
print_success() { echo -e "${GREEN}[SUCCESS]${NC} $*" >&2; }
print_warning() { echo -e "${YELLOW}[WARNING]${NC} $*" >&2; }
print_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
die() { print_error "$*"; exit 1; }
# --- Privilege helper ----------------------------------------------------
# Returns "sudo" if the target path is not user-writable, else "".
sudo_for() {
local target="$1"
if [[ -w "$target" ]]; then
echo ""
else
command -v sudo >/dev/null 2>&1 || die "sudo is required to write to $target but is not available."
echo "sudo"
fi
}
# --- Usage ---------------------------------------------------------------
show_usage() {
cat <<'EOF'
sovereign_browser installer
Usage: curl -fsSL <url> | bash
or: curl -fsSL <url> | bash -s -- --build-from-source
or: ./install.sh [options]
Options:
--build-from-source Build sovereign_browser from source instead of
downloading the prebuilt binary
--build-fips-from-source Build FIPS from source (cargo) instead of
downloading the prebuilt binary. Slower; needs
libclang-dev (auto-installed).
--prefix <dir> Install prefix (default: /usr/local)
--yes Skip confirmation prompts
-h, --help Show this help
EOF
}
# --- Args ----------------------------------------------------------------
BUILD_FROM_SOURCE=false
BUILD_FIPS_FROM_SOURCE=false
ASSUME_YES=false
while [[ $# -gt 0 ]]; do
case "$1" in
--build-from-source) BUILD_FROM_SOURCE=true; shift ;;
--build-fips-from-source) BUILD_FIPS_FROM_SOURCE=true; shift ;;
--prefix) INSTALL_PREFIX="${2:-}"; shift 2 ;;
--yes|-y) ASSUME_YES=true; shift ;;
-h|--help) show_usage; exit 0 ;;
*) die "Unknown option: $1 (try --help)" ;;
esac
done
# --- Platform detection --------------------------------------------------
detect_platform() {
print_info "Detecting platform..."
local os arch
os="$(uname -s)"
arch="$(uname -m)"
[[ "$os" == "Linux" ]] || die "This installer only supports Linux (got: $os)."
if [[ "$arch" != "x86_64" ]]; then
if [[ "$arch" == "aarch64" || "$arch" == "arm64" ]]; then
die "arm64 is not supported for the prebuilt binary. Re-run with --build-from-source (arm64 source builds are untested)."
fi
die "Unsupported architecture: $arch (x86_64 only)."
fi
local distro_id="" version_id=""
if [[ -r /etc/os-release ]]; then
# shellcheck disable=SC1091
. /etc/os-release
distro_id="${ID:-}"
version_id="${VERSION_ID:-}"
fi
local distro_ok=false
if [[ "$distro_id" == "debian" ]]; then
if [[ -n "$version_id" ]] && awk -v v="$version_id" 'BEGIN{exit !(v+0 >= 13)}'; then
distro_ok=true
fi
elif [[ "$distro_id" == "ubuntu" ]]; then
if [[ -n "$version_id" ]] && awk -v v="$version_id" 'BEGIN{exit !(v+0 >= 24.04)}'; then
distro_ok=true
fi
fi
if $distro_ok; then
print_info "Distro: $distro_id $version_id (supported)."
else
print_warning "Distro '$distro_id $version_id' is not explicitly supported."
print_warning "WebKitGTK 4.1 runtime libs are required. If apt cannot find libwebkit2gtk-4.1-0, use Debian 13 (trixie)+ or Ubuntu 24.04+."
fi
}
# --- apt dependencies ----------------------------------------------------
install_deps() {
print_info "Installing apt dependencies (runtime libs, tor, build toolchain)..."
if ! command -v sudo >/dev/null 2>&1 && [[ $EUID -ne 0 ]]; then
die "sudo is required for apt-get but is not available (not running as root)."
fi
local SUDO=""
[[ $EUID -ne 0 ]] && SUDO="sudo"
$SUDO apt-get update
if ! $SUDO apt-get install -y "${APT_PACKAGES[@]}"; then
print_error "apt-get install failed. One or more packages could not be found."
print_error "This usually means your distro is too old and lacks WebKitGTK 4.1."
print_error "Use Debian 13 (trixie) or Ubuntu 24.04+, then re-run this installer."
exit 1
fi
print_success "Dependencies installed."
}
# --- FIPS ----------------------------------------------------------------
#
# FIPS is a Rust project (https://github.com/jmcorgan/fips). It publishes
# prebuilt release tarballs on GitHub Releases containing the `fips` binary
# plus tools (fipsctl, fipstop) and systemd units. We download the prebuilt
# x86_64 Linux tarball by default — much faster than a cargo build.
#
# With --build-fips-from-source, we instead clone the repo and run
# `cargo build --release` (requires Rust 1.94.1+ via rustup, and libclang-dev
# for the rustables/bindgen nftables bindings).
# Download the prebuilt FIPS tarball from GitHub Releases and extract the
# `fips` binary. Outputs a stable binary path on stdout (the file is copied
# to a caller-managed temp file so it survives after this function returns).
# Returns non-zero on failure so the caller can fall back to a source build.
download_fips_prebuilt() {
print_info "Querying GitHub for latest FIPS release..."
local api_json tag asset_url
if ! api_json="$(curl -fsSL "$FIPS_GITHUB_API" 2>/dev/null)"; then
print_warning "Could not reach FIPS GitHub releases API."
return 1
fi
tag="$(printf '%s' "$api_json" | grep -oE '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/')"
if [[ -z "$tag" ]]; then
print_warning "Could not parse tag_name from FIPS GitHub release JSON."
return 1
fi
# Find the linux-x86_64 tarball asset URL.
asset_url="$(printf '%s' "$api_json" | grep -oE '"browser_download_url"[[:space:]]*:[[:space:]]*"[^"]*linux-x86_64\.tar\.gz"' | head -1 | sed -E 's/.*"browser_download_url"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/')"
if [[ -z "$asset_url" ]]; then
print_warning "No linux-x86_64 tarball found in FIPS release $tag."
return 1
fi
# Use a temp dir for download + extraction. We do NOT use a RETURN trap
# here because the caller needs to copy the binary out after this
# function returns — a RETURN trap would delete the file first. Instead
# we copy the binary to a stable temp file and clean up the extraction
# dir ourselves before returning.
local tmp fips_bin stable
tmp="$(mktemp -d -t fips_dl.XXXXXX)"
print_info "Downloading FIPS prebuilt binary: $asset_url"
if ! curl -fsSL -o "$tmp/fips.tar.gz" "$asset_url"; then
rm -rf "$tmp"
print_warning "FIPS tarball download failed."
return 1
fi
if ! tar -xzf "$tmp/fips.tar.gz" -C "$tmp" 2>/dev/null; then
rm -rf "$tmp"
print_warning "FIPS tarball extraction failed."
return 1
fi
# The tarball extracts to fips-<ver>-linux-x86_64/fips
fips_bin="$(find "$tmp" -type f -name fips 2>/dev/null | head -1 || true)"
if [[ -z "$fips_bin" ]] || ! file "$fips_bin" 2>/dev/null | grep -qi ELF; then
rm -rf "$tmp"
print_warning "FIPS tarball did not contain an ELF 'fips' binary."
return 1
fi
# Copy to a stable path so the caller can access it after we clean up
# the extraction dir. mktemp gives a file the caller can rm later.
stable="$(mktemp -t fips_binary.XXXXXX)"
cp -f "$fips_bin" "$stable"
chmod +x "$stable"
rm -rf "$tmp"
printf '%s' "$stable"
}
# --- Rust toolchain (only for --build-fips-from-source) ------------------
FIPS_MIN_RUST_MAJOR=1
FIPS_MIN_RUST_MINOR=94
rust_version_ok() {
command -v cargo >/dev/null 2>&1 || return 1
local ver
ver="$(cargo --version 2>/dev/null | grep -oE 'cargo [0-9]+\.[0-9]+\.' | head -1 | sed 's/cargo //; s/\.$//')" || return 1
[[ -z "$ver" ]] && return 1
local major minor
major="${ver%%.*}"
minor="${ver#*.}"
[[ "$major" -gt "$FIPS_MIN_RUST_MAJOR" ]] && return 0
[[ "$major" -eq "$FIPS_MIN_RUST_MAJOR" && "$minor" -ge "$FIPS_MIN_RUST_MINOR" ]] && return 0
return 1
}
ensure_rust() {
if rust_version_ok; then
print_info "Rust toolchain OK ($(cargo --version))."
return 0
fi
if command -v cargo >/dev/null 2>&1; then
print_warning "Installed cargo ($(cargo --version)) is older than FIPS requires (1.94.1+). Installing a newer toolchain via rustup."
else
print_info "No cargo found. Installing Rust toolchain via rustup."
fi
local rustup_init
rustup_init="$(mktemp -t rustup-init.XXXXXX)"
if ! curl -fsSL --proto '=https' --tlsv1.2 -o "$rustup_init" https://sh.rustup.rs; then
rm -f "$rustup_init"
die "Failed to download rustup installer."
fi
if ! sh "$rustup_init" -y --profile minimal >/dev/null 2>&1; then
rm -f "$rustup_init"
die "rustup installation failed."
fi
rm -f "$rustup_init"
# shellcheck disable=SC1091
. "$HOME/.cargo/env" 2>/dev/null || true
export PATH="$HOME/.cargo/bin:$PATH"
if ! rust_version_ok; then
die "Rust toolchain installed but cargo still reports an insufficient version. FIPS requires 1.94.1+."
fi
print_success "Rust toolchain installed: $(cargo --version)."
}
# Build FIPS from source via cargo. Outputs the binary path on stdout.
build_fips_from_source() {
print_info "Building FIPS from source ($FIPS_REPO)..."
ensure_rust
local tmp
tmp="$(mktemp -d)"
trap "rm -rf '$tmp' 2>/dev/null || true" RETURN
if ! git clone --depth 1 "$FIPS_REPO" "$tmp/fips"; then
die "Failed to clone FIPS repo: $FIPS_REPO"
fi
local fips_dir="$tmp/fips"
print_info "FIPS: running cargo build --release (this can take several minutes)..."
if ! ( cd "$fips_dir" && cargo build --release ); then
die "cargo build --release failed for FIPS. FIPS is required."
fi
local fips_bin=""
for cand in \
"$fips_dir/target/release/fips" \
"$fips_dir/target/x86_64-unknown-linux-gnu/release/fips"; do
if [[ -x "$cand" ]] && file "$cand" 2>/dev/null | grep -qi ELF; then
fips_bin="$cand"
break
fi
done
if [[ -z "$fips_bin" ]]; then
fips_bin="$(find "$fips_dir/target" -type f -name fips -executable 2>/dev/null | head -1 || true)"
fi
[[ -n "$fips_bin" ]] || die "FIPS build succeeded but no 'fips' binary was found."
printf '%s' "$fips_bin"
}
install_fips() {
print_info "Installing FIPS (required, not optional)..."
# If source build was requested, install the extra build-time apt deps.
if $BUILD_FIPS_FROM_SOURCE; then
print_info "Installing FIPS source-build deps (libclang-dev for bindgen)..."
local sudo_cmd=""
if [[ $EUID -ne 0 ]]; then sudo_cmd="sudo"; fi
$sudo_cmd apt-get install -y "${APT_PACKAGES_FIPS_SOURCE[@]}" >/dev/null 2>&1 || true
fi
local fips_bin=""
if $BUILD_FIPS_FROM_SOURCE; then
fips_bin="$(build_fips_from_source)"
else
if ! fips_bin="$(download_fips_prebuilt)"; then
print_warning "Prebuilt FIPS unavailable — falling back to source build."
# Install source-build deps on the fallback path too.
local sudo_cmd=""
if [[ $EUID -ne 0 ]]; then sudo_cmd="sudo"; fi
$sudo_cmd apt-get install -y "${APT_PACKAGES_FIPS_SOURCE[@]}" >/dev/null 2>&1 || true
fips_bin="$(build_fips_from_source)"
fi
fi
[[ -n "$fips_bin" && -x "$fips_bin" ]] || die "FIPS binary not available."
local prefix_bin="$INSTALL_PREFIX/bin"
local SUDO
SUDO="$(sudo_for "$INSTALL_PREFIX")"
$SUDO mkdir -p "$prefix_bin"
$SUDO cp -f "$fips_bin" "$prefix_bin/fips"
$SUDO chmod +x "$prefix_bin/fips"
print_success "FIPS binary installed to $prefix_bin/fips"
# Clean up the temp binary (download_fips_prebuilt copies to a stable
# mktemp file; build_fips_from_source uses a RETURN-trapped dir so its
# output is already gone, but rm -f is safe on either).
rm -f "$fips_bin" 2>/dev/null || true
# setcap for CAP_NET_ADMIN (needed to create a TUN interface in managed mode).
local setcap_cmd=""
if command -v setcap >/dev/null 2>&1; then
setcap_cmd="setcap"
elif command -v sudo >/dev/null 2>&1 && $SUDO command -v setcap >/dev/null 2>&1; then
setcap_cmd="$SUDO setcap"
fi
if [[ -n "$setcap_cmd" ]]; then
if $setcap_cmd cap_net_admin+ep "$prefix_bin/fips" 2>/dev/null; then
print_success "FIPS granted CAP_NET_ADMIN."
else
if $SUDO setcap cap_net_admin+ep "$prefix_bin/fips" 2>/dev/null; then
print_success "FIPS granted CAP_NET_ADMIN."
else
print_warning "setcap failed. FIPS managed mode (TUN) won't work without CAP_NET_ADMIN."
print_warning "Install 'libcap2-bin' and re-run, or run a system FIPS instance and set fips_mode=attach."
fi
fi
else
print_warning "'setcap' not found (libcap2-bin not installed)."
print_warning "FIPS managed mode won't work without CAP_NET_ADMIN."
print_warning "Install libcap2-bin (apt-get install -y libcap2-bin) and re-run, or run a system FIPS instance and set fips_mode=attach."
fi
}
# --- Prebuilt binary download -------------------------------------------
download_binary() {
# Outputs the downloaded binary path on stdout. Returns non-zero on failure
# so the caller can fall back to a source build.
print_info "Querying Gitea for latest release..."
local api_json tag url out
if ! api_json="$(curl -fsSL "$GITEA_API/releases/latest" 2>/dev/null)"; then
print_warning "Could not reach Gitea releases API (no release yet, or network issue)."
return 1
fi
tag="$(printf '%s' "$api_json" | grep -oE '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/')"
if [[ -z "$tag" ]]; then
print_warning "Could not parse tag_name from Gitea release JSON."
return 1
fi
url="$GITEA_DOWNLOAD_BASE/$tag/$BIN_NAME"
out="$(mktemp -t sovereign_browser_download.XXXXXX)"
print_info "Downloading prebuilt binary: $url"
if ! curl -fsSL -o "$out" "$url"; then
rm -f "$out" 2>/dev/null || true
print_warning "Download failed for $url"
return 1
fi
if ! file "$out" 2>/dev/null | grep -qi ELF; then
rm -f "$out" 2>/dev/null || true
print_warning "Downloaded file is not an ELF binary."
return 1
fi
printf '%s\n' "$out"
print_success "Downloaded sovereign_browser $tag."
}
# --- Source build fallback -----------------------------------------------
build_from_source() {
print_info "Building sovereign_browser from source ($SB_REPO)..."
local tmp
tmp="$(mktemp -d)"
trap "rm -rf '$tmp' 2>/dev/null || true" RETURN
if ! git clone --depth 1 "$SB_REPO" "$tmp/sovereign_browser"; then
die "Failed to clone sovereign_browser repo: $SB_REPO"
fi
if ! ( cd "$tmp/sovereign_browser" && make ); then
die "Source build failed (make). Ensure build deps are installed."
fi
[[ -x "$tmp/sovereign_browser/$BIN_NAME" ]] || die "Build finished but ./$BIN_NAME was not produced."
local out
out="$(mktemp -t sovereign_browser_built.XXXXXX)"
cp -f "$tmp/sovereign_browser/$BIN_NAME" "$out"
chmod +x "$out"
printf '%s\n' "$out"
print_success "Built sovereign_browser from source."
}
# --- Install binary + wrapper -------------------------------------------
install_binary() {
local src="$1"
local prefix_bin="$INSTALL_PREFIX/bin"
local SUDO
SUDO="$(sudo_for "$INSTALL_PREFIX")"
print_info "Installing binary to $prefix_bin/$BIN_NAME"
$SUDO mkdir -p "$prefix_bin"
$SUDO cp -f "$src" "$prefix_bin/$BIN_NAME"
$SUDO chmod +x "$prefix_bin/$BIN_NAME"
# Write the kebab-case wrapper that detaches the GUI.
local wrapper="$prefix_bin/sovereign-browser"
local real_bin="$prefix_bin/$BIN_NAME"
print_info "Installing wrapper to $wrapper"
# Write to a temp file then move with sudo if needed.
local wtmp
wtmp="$(mktemp -t sovereign-browser-wrapper.XXXXXX)"
cat > "$wtmp" <<WRAPPER_EOF
#!/bin/bash
# sovereign-browser — user-facing wrapper that detaches the GUI.
# Mirrors a subset of browser.sh: start | stop | restart | status | log
set -euo pipefail
SB_BIN="$real_bin"
SB_DATA_DIR="\${HOME}/.sovereign_browser"
SB_PID_FILE="\${SB_DATA_DIR}/browser.pid"
SB_LOG_FILE="\${SB_DATA_DIR}/browser.log"
sb_start() {
mkdir -p "\$SB_DATA_DIR"
# If already running, just print status.
if [[ -f "\$SB_PID_FILE" ]] && kill -0 "\$(cat "\$SB_PID_FILE" 2>/dev/null)" 2>/dev/null; then
echo "sovereign_browser already running (PID \$(cat "\$SB_PID_FILE"))" >&2
return 0
fi
nohup "\$SB_BIN" "\$@" > "\$SB_LOG_FILE" 2>&1 &
local pid=\$!
echo "\$pid" > "\$SB_PID_FILE"
disown "\$pid" 2>/dev/null || true
echo "sovereign_browser started (PID \$pid)" >&2
}
sb_stop() {
if [[ ! -f "\$SB_PID_FILE" ]]; then
echo "sovereign_browser is not running (no PID file)" >&2
return 0
fi
local pid
pid="\$(cat "\$SB_PID_FILE" 2>/dev/null || true)"
if [[ -n "\$pid" ]] && kill -0 "\$pid" 2>/dev/null; then
kill "\$pid" 2>/dev/null || true
echo "sovereign_browser stopped (PID \$pid)" >&2
else
echo "sovereign_browser was not running (stale PID file)" >&2
fi
rm -f "\$SB_PID_FILE" 2>/dev/null || true
}
sb_status() {
if [[ -f "\$SB_PID_FILE" ]] && kill -0 "\$(cat "\$SB_PID_FILE" 2>/dev/null)" 2>/dev/null; then
echo "sovereign_browser is running (PID \$(cat "\$SB_PID_FILE"))" >&2
return 0
else
echo "sovereign_browser is not running" >&2
return 1
fi
}
sb_log() {
tail -n 50 "\$SB_LOG_FILE" 2>/dev/null || echo "(no log file yet)" >&2
}
case "\${1:-start}" in
start) shift 2>/dev/null || true; sb_start "\$@" ;;
stop) sb_stop ;;
restart) sb_stop; shift 2>/dev/null || true; sb_start "\$@" ;;
status) sb_status ;;
log) sb_log ;;
*) sb_start "\$@" ;; # default: treat all args as browser args
esac
WRAPPER_EOF
$SUDO mv -f "$wtmp" "$wrapper"
$SUDO chmod +x "$wrapper"
print_success "Wrapper installed: $wrapper"
if [[ ":${PATH}:" != *":$prefix_bin:"* ]]; then
print_warning "$prefix_bin is not in your PATH. Add it: export PATH=\"$prefix_bin:\$PATH\""
fi
}
# --- Main ----------------------------------------------------------------
main() {
detect_platform
# Summary + confirmation
local method="prebuilt binary from Gitea"
$BUILD_FROM_SOURCE && method="source build from $SB_REPO"
local fips_method="prebuilt binary from GitHub releases"
$BUILD_FIPS_FROM_SOURCE && fips_method="source build ($FIPS_REPO, cargo)"
# Use print_info so colors render via `echo -e` (a plain `cat` heredoc
# would print the literal \033 escape sequences).
print_info "sovereign_browser will be installed:"
print_info " Install prefix : $INSTALL_PREFIX"
print_info " Binary method : $method"
print_info " FIPS : $fips_method, CAP_NET_ADMIN set"
print_info " Tor : apt package 'tor'"
print_info " apt deps : ${APT_PACKAGES[*]}"
if ! $ASSUME_YES; then
print_warning "This will run apt-get install (sudo) and clone/build FIPS. Continue? [y/N]"
# Read from /dev/tty, not stdin: in `curl | bash` mode stdin is the
# curl pipe (the script text), so a plain `read` would hit EOF and
# abort immediately. /dev/tty is the user's terminal.
if [[ -r /dev/tty ]]; then
read -r reply < /dev/tty || reply=""
else
# No tty available (e.g. non-interactive CI) — require --yes.
die "No tty available for confirmation. Re-run with --yes to skip prompts."
fi
reply="${reply:-n}"
if [[ ! "${reply,,}" =~ ^y(es)?$ ]]; then
die "Aborted by user."
fi
fi
install_deps
install_fips
local bin_path=""
if $BUILD_FROM_SOURCE; then
bin_path="$(build_from_source)"
else
if ! bin_path="$(download_binary)"; then
print_warning "Prebuilt binary unavailable — falling back to source build."
bin_path="$(build_from_source)"
fi
fi
install_binary "$bin_path"
rm -f "$bin_path" 2>/dev/null || true
print_success "sovereign_browser installed."
cat >&2 <<EOF
${GREEN}[SUCCESS]${NC} Next steps:
Run: sovereign-browser
Or: sovereign-browser --login-method generate
Or: sovereign-browser --login-method generate --url https://example.com
Data dir: ~/.sovereign_browser/
Logs: sovereign-browser log
Stop: sovereign-browser stop
Status: sovereign-browser status
EOF
}
main "$@"

View File

@@ -0,0 +1,261 @@
# Plan: Nested Bookmark Tree View + Bookmarks Toolbar
## Goal
1. Support **nested folders** for bookmarks (e.g. `Work/Projects/Secret`) while staying fully NIP-51 kind 30003 compliant.
2. Render the [`sovereign://bookmarks`](www/bookmarks.html:1) page as an **expandable/collapsible tree view**.
3. Add a **bookmarks toolbar** (a horizontal bar of folder/bookmark buttons) below the URL bar in [`tab_manager.c`](src/tab_manager.c:2564).
## NIP-51 Compliance + Privacy — How Nesting Works
NIP-51 kind 30003 is parameterized replaceable: each event has a `d` tag that is an **opaque identifier string** used by relays to decide which event replaces which. We need the `d` tag to be:
1. **Opaque** — relays must not learn the folder path (e.g. `Work/Projects/Secret`), because that leaks the user's organizational structure.
2. **Deterministic** — the same path must always produce the same `d` so re-publishing replaces the prior event instead of accumulating duplicates.
Encryption (NIP-4 or NIP-44) cannot satisfy (2): both use a random IV/nonce per call, so encrypting the same plaintext twice yields different ciphertexts. That would break NIP-33 replaceability.
The right tool is a **MAC**: `d = HMAC-SHA256(key, path)` is deterministic and one-way. The HMAC key is derived from the user's Nostr privkey so it is per-user, never published, and automatically available on every device the user logs in on:
```
hmac_key = HMAC-SHA256(privkey_hex, "sovereign-browser/bookmarks-folder-id-v1")
d = HMAC-SHA256(hmac_key, path) → 64 hex chars
```
### Event layout (one per folder)
```
{
"kind": 30003,
"pubkey": "<user pubkey hex>",
"tags": [["d", "<HMAC-SHA256(hmac_key, path) hex>"]],
"content": "<NIP-44 encrypted JSON>",
...
}
```
The NIP-44 encrypted `content` plaintext is:
```json
{"path": "Work/Projects/Secret", "bookmarks": [
["bookmark", "https://example.com", "Example", 1690000000]
]}
```
The real path lives **inside the encrypted content** — relays see only the HMAC hash and opaque ciphertext. The `d` tag is used by relays purely for replaceability; the client never interprets it.
### Tree reconstruction (client-side)
1. Fetch all `kind=30003` events for the user's pubkey.
2. For each event: NIP-44 decrypt the content, read `path` from the plaintext JSON.
3. Split `path` on `/` and walk/create the trie (`node_ensure_path`).
4. Attach the decrypted `bookmarks` array to that leaf node.
Intermediate (empty) folders are represented by empty-bookmarks events for that path, so folder state — including empty folders — is preserved across devices.
### Why this is safe and nostr-ish
- The `d` tag is defined by NIP-33 as an arbitrary string identifier; a 64-char hex HMAC fits cleanly.
- Content encryption (NIP-44 self-to-self) is unchanged from the existing implementation in [`src/bookmarks.c`](src/bookmarks.c:163).
- HMAC-SHA256 is a one-line standard primitive; the key is derived from the user's Nostr privkey, so no extra credentials are needed.
- Relays learn nothing about folder names, structure, or even folder count (they see only opaque hashes).
- Existing flat directories (no slashes) load as root-level folders — no migration needed for the in-memory model. Legacy events with plaintext `d = "General"` will be detected on load (HMAC hex strings are 64 chars of `[0-9a-f]`; legacy names are not), decrypted, and re-published in the new HMAC-`d` format; the old events get a kind 5 deletion.
```mermaid
flowchart TD
A["kind 30003 events in SQLite/relays"] --> B["NIP-44 decrypt each content"]
B --> C["Read path from plaintext JSON"]
C --> D["Split path on / to get segments"]
D --> E["Build trie of folders via node_ensure_path"]
E --> F["Attach decrypted bookmarks to leaf node"]
F --> G["Render tree on sovereign://bookmarks page"]
G --> H["User adds/moves/renames/deletes"]
H --> I["Compute HMAC d tag for affected path(s)"]
I --> J["NIP-44 encrypt new content and publish kind 30003"]
I --> K["Emit kind 5 deletion for old d tags if renamed/moved/deleted"]
```
---
## Implementation
### 1. Data model — [`src/bookmarks.h`](src/bookmarks.h:36) / [`src/bookmarks.c`](src/bookmarks.c:32)
The in-memory model changes from a flat `bookmark_dir_t[]` to a **tree**.
#### New struct
```c
typedef struct bookmark_node {
char *name; /* last segment of path, e.g. "Secret" */
char *path; /* full path, e.g. "Work/Projects/Secret" */
bookmark_t *bookmarks;
int bookmark_count;
struct bookmark_node *children; /* array of child nodes */
int child_count;
int child_cap;
} bookmark_node_t;
```
The root is a single `bookmark_node_t` with `name = ""`, `path = ""`, no bookmarks, and children = top-level folders.
#### New / changed API
Keep the existing public functions working (they now operate on paths, not flat names):
- `bookmarks_add(const char *path, const char *url, const char *title)``path` may contain slashes; intermediate nodes are created as empty events if they don't exist.
- `bookmarks_remove(const char *path, const char *url)`
- `bookmarks_move(const char *from_path, const char *url, const char *to_path)`
- `bookmarks_create_dir(const char *path)` — publishes an empty kind 30003 event with `d = HMAC-SHA256(hmac_key, path)`.
- `bookmarks_rename_dir(const char *old_path, const char *new_path)` — re-publishes the moved subtree: for the renamed node and every descendant, publish a new event with the new HMAC `d` tag and a kind 5 deletion for the old HMAC `d` tag.
- `bookmarks_delete_dir(const char *path, int move_to_general)` — recursively delete the subtree (kind 5 deletion for each HMAC `d` tag in the subtree); optionally move bookmarks to `General`.
- `const bookmark_node_t *bookmarks_get_root(void)` — returns the root node for traversal (replaces `bookmarks_get_dirs`).
- `const bookmark_node_t *bookmarks_find(const char *path)` — lookup by path.
#### Internal helpers
- `static bookmark_node_t *node_find_child(bookmark_node_t *parent, const char *name)`
- `static bookmark_node_t *node_ensure_path(bookmark_node_t *root, const char *path)` — walks/creates the trie.
- `static void node_free(bookmark_node_t *node)` — recursive free.
- `static void compute_hmac_key(unsigned char out[32])``HMAC-SHA256(privkey, "sovereign-browser/bookmarks-folder-id-v1")`. The privkey is obtained from the signer (add a `nostr_signer_get_privkey_hex` accessor if not already present, or compute the HMAC inside the signer to avoid exposing the raw privkey).
- `static char *path_to_d_tag(const char *path)``HMAC-SHA256(hmac_key, path)` → 64-char hex string. Caller frees.
- `static int publish_path(const char *path)` — replaces `publish_directory`; builds `d = path_to_d_tag(path)`, encrypts `{"path": path, "bookmarks": [...]}` with NIP-44, signs, publishes, stores in SQLite.
- `static int delete_event_for_path(const char *path)` — publishes a kind 5 deletion event referencing the kind 30003 event for `d = path_to_d_tag(path)`.
#### Loading
`bookmarks_init` already iterates all kind 30003 events for the pubkey. Change the loop to:
1. Read the `d` tag.
2. If the `d` tag is **not** 64 hex chars (i.e. a legacy plaintext directory name), treat it as a legacy event: decrypt content as the old flat `[[bookmark,...]]` array, use the `d` tag string as the path, then re-publish in the new HMAC-`d` format and emit a kind 5 deletion for the old event id.
3. If the `d` tag **is** 64 hex chars, decrypt the content and read `path` from the plaintext JSON `{"path": ..., "bookmarks": [...]}`.
4. `node_ensure_path(root, path)`.
5. Load bookmarks into that leaf.
### 2. JSON endpoint — [`src/nostr_bridge.c`](src/nostr_bridge.c:3715) `handle_bookmarks_list_json`
Change the JSON shape from a flat `dirs[]` array to a nested `tree` object:
```json
{
"have_signer": true,
"tree": {
"name": "",
"path": "",
"bookmarks": [],
"children": [
{ "name": "General", "path": "General", "bookmarks": [...], "children": [] },
{ "name": "Work", "path": "Work", "bookmarks": [], "children": [
{ "name": "Projects", "path": "Work/Projects", "bookmarks": [...], "children": [] }
]}
]
}
}
```
Add a small recursive helper `node_to_json(const bookmark_node_t *node, cJSON *parent)`.
### 3. Web UI — [`www/bookmarks.html`](www/bookmarks.html:1), [`www/bookmarks.js`](www/bookmarks.js:1), [`www/bookmarks.css`](www/bookmarks.css:1)
#### HTML
- Replace the flat `#bm-list` div with a `<div id="bm-tree">` container.
- Add an "Add Folder" button next to "Add Bookmark" that prompts for a path (with a parent-picker).
- Add a path breadcrumb / parent selector in the add form.
#### JS
- `renderTree(data.tree)` — recursively render nested `<ul class="tree-node">` with:
- A folder row: expand/collapse caret, folder icon, name, count, "Add Bookmark Here", "New Subfolder", "Rename", "Delete" buttons.
- A bookmark list under the folder (collapsible).
- State: keep a `Set` of expanded paths in `localStorage` so expand state persists.
- Clicking a folder caret toggles `expanded` class on the child `<ul>`.
- "New Subfolder" prompts for a name and navigates to `sovereign://bookmarks/createdir?name=<parent_path>/<new_name>`.
- "Move" action on each bookmark: prompt for destination path, navigate to `sovereign://bookmarks/move?from=<dir>&to=<new_dir>&url=<url>`.
- "Add Bookmark Here" pre-fills the add form's directory field with the folder's path.
#### CSS
- Add `.tree-node`, `.tree-children`, `.caret`, `.caret.expanded`, `.folder-row`, `.folder-icon` rules to [`www/bookmarks.css`](www/bookmarks.css:1).
- Caret uses a CSS triangle that rotates 90° when expanded.
- Indent `.tree-children` by `20px` per level.
- Reuse existing `.bm`, `.btn`, `.btn-del` classes for bookmark rows and buttons.
### 4. Bookmarks toolbar — [`src/tab_manager.c`](src/tab_manager.c:2564)
Add a **bookmarks toolbar** below the URL toolbar. This is a horizontal `GtkBox` that shows buttons for the bookmarks in a configurable "Bookmarks Bar" folder (default path: `Bookmarks Bar`), plus a dropdown for any folders.
#### Layout
```
[ hamburger ][back][fwd][refresh][ === URL entry === ][bookmark btn]
[ 📁 Bookmarks Bar: ][example.com][nostr.com][▾ Folders ▾]
[ ============ WebKitWebView ============ ]
```
#### Implementation
1. In `tab_create()`, after packing `toolbar` into `tab->page`, create:
```c
tab->bookmark_bar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_set_margin_top(tab->bookmark_bar, 2);
gtk_widget_set_margin_bottom(tab->bookmark_bar, 2);
gtk_widget_set_margin_start(tab->bookmark_bar, 4);
gtk_widget_set_margin_end(tab->bookmark_bar, 4);
gtk_box_pack_start(GTK_BOX(tab->page), tab->bookmark_bar, FALSE, FALSE, 0);
```
2. Add a `GtkToggleButton` "Bookmarks Bar" toggle (icon `user-bookmarks-symbolic`) on the left of the URL toolbar that shows/hides `tab->bookmark_bar` (persist visibility in settings).
3. `bookmark_bar_refresh(tab)` — clears and repopulates `tab->bookmark_bar`:
- Looks up the "Bookmarks Bar" node via `bookmarks_find("Bookmarks Bar")`.
- For each bookmark in that node, adds a `GtkButton` labeled with the bookmark title (or URL). Clicking loads the URL in `tab->webview`.
- For each child folder, adds a `GtkMenuButton` with a popover listing that folder's bookmarks (and subfolders, one level deep).
4. Call `bookmark_bar_refresh(tab)` after: tab creation, `bookmarks_add`/`remove`/`move`/`delete_dir` (via a global "bookmarks changed" notification — see below).
5. Add a "Bookmark this page to Bookmarks Bar" quick action: right-click on the bookmark button → menu item "Add to Bookmarks Bar" that calls `bookmarks_add("Bookmarks Bar", url, title)` directly without the directory picker dialog.
#### Cross-tab refresh
Bookmarks can change from any tab (or from relay fetch). Add a lightweight notification:
- `bookmarks_subscribe_changed(void (*cb)(void *user_data), void *user_data)` in [`src/bookmarks.h`](src/bookmarks.h:51).
- `tab_manager.c` registers a callback on startup that iterates all open tabs and calls `bookmark_bar_refresh(tab)` for each.
- `bookmarks_add`/`remove`/`move`/`create_dir`/`delete_dir`/`rename_dir` and the relay-fetch load path invoke the registered callbacks after mutating state.
### 5. Routes — [`src/nostr_bridge.c`](src/nostr_bridge.c:3993)
Existing routes already accept `dir=` query params. They continue to work — `dir` is now interpreted as a **path** (URL-encoded, may contain `%2F`). Add one new route:
- `sovereign://bookmarks/move?from=<path>&to=<path>&url=<url>` — calls `bookmarks_move`.
Update `handle_bookmarks_add`/`delete`/`createdir`/`deletedir` to pass the path straight through to the renamed API functions (no behavioral change beyond accepting slashes).
### 6. History filter — [`src/history.c`](src/history.c:27)
Already excludes `sovereign://bookmarks/{add,delete,createdir,deletedir}`. Add `sovereign://bookmarks/move` to the skip list.
### 7. Tests
- Add `tests/test_bookmarks_tree.c` (or extend existing tests) covering:
- `node_ensure_path` creates intermediate nodes.
- `path_to_d_tag` is deterministic (same path + key → same hex) and 64 hex chars.
- `path_to_d_tag` is opaque (different paths → uncorrelated hashes; no prefix leakage).
- `bookmarks_add("Work/Projects/Secret", ...)` publishes an event whose `d` tag is `HMAC-SHA256(hmac_key, "Work/Projects/Secret")` (not the plaintext path) and whose decrypted content contains `"path": "Work/Projects/Secret"`.
- `bookmarks_rename_dir("Work", "Personal")` re-publishes `Personal`, `Personal/Projects`, `Personal/Projects/Secret` (new HMAC `d` tags, new encrypted content) and emits kind 5 deletions for the old HMAC `d` tags.
- `bookmarks_delete_dir("Work", 0)` emits kind 5 deletions for the whole subtree's HMAC `d` tags.
- Loading a mix of events with HMAC `d` tags reconstructs the tree from the decrypted `path` field, not from the `d` tag.
- Legacy event with plaintext `d = "General"` is detected, loaded, re-published with an HMAC `d` tag, and the old event is marked for kind 5 deletion.
- Manual test: `./browser.sh restart --login-method generate --url sovereign://bookmarks` and exercise the tree UI.
---
## Migration
Existing flat directories (no slashes) load as root-level folders — no migration needed. The "General" default folder continues to work as a root node.
## Files touched
| File | Change |
|------|--------|
| [`src/bookmarks.h`](src/bookmarks.h:1) | Replace `bookmark_dir_t` with `bookmark_node_t`; tree API; change callback. |
| [`src/bookmarks.c`](src/bookmarks.c:1) | Rewrite in-memory model as a trie; path-based publish/delete; rename/delete recursion. |
| [`src/nostr_bridge.c`](src/nostr_bridge.c:3715) | `handle_bookmarks_list_json` emits nested `tree`; add `move` route. |
| [`www/bookmarks.html`](www/bookmarks.html:1) | Tree container, folder add form. |
| [`www/bookmarks.js`](www/bookmarks.js:1) | Recursive `renderTree`, expand/collapse, move dialog. |
| [`www/bookmarks.css`](www/bookmarks.css:1) | Tree node / caret / indentation styles. |
| [`src/tab_manager.c`](src/tab_manager.c:2564) | Bookmarks toolbar widget, refresh on change. |
| [`src/history.c`](src/history.c:27) | Skip `sovereign://bookmarks/move`. |
| `tests/test_bookmarks_tree.c` | New test for tree operations. |
| [`plans/bookmarks-tree-view.md`](plans/bookmarks-tree-view.md:1) | This plan. |

View File

@@ -0,0 +1,176 @@
# Chat Page — Discrete Event Model
## Goal
Redesign the `sovereign://agents/chat` page to work with sovereign_browser's
**discrete event** Nostr interaction model (no live subscriptions). The page
needs explicit Save/Refresh buttons for conversations and skills, matching
how the rest of the browser works.
## What's wrong now
The current chat page tries to mimic ai.html's live-subscription model:
- It auto-saves conversations after each agent response (via a debounced
`scheduleSave()`)
- It fetches the conversation list on page load but has no way to refresh
- It fetches skills on page load but has no way to refresh
- New Chat saves an empty conversation immediately (good), but there's no
explicit save button for renaming or manual saves
The user wants explicit control: **Save** to publish, **Refresh** to fetch.
## Design
### Conversation list (left pane, top section)
```
┌─────────────────────────────┐
│ [+ New Chat] [↻ Refresh] │
├─────────────────────────────┤
│ > Capital of France [×] │ ← selected, click to load
│ EU population [×] │
│ Hello world [×] │
└─────────────────────────────┘
```
- **+ New Chat** — Creates a new local session (does NOT save to Nostr yet).
The conversation is "unsaved" until the user sends a message or clicks Save.
- **↻ Refresh** — Fetches the conversation list from Nostr (one-shot relay
query via `relay_fetch.c`) and updates the list. Shows a "Refreshing..."
state while fetching.
- **Click a conversation** — Loads it from the local SQLite cache (fast, no
relay fetch). If the conversation isn't in the cache, fetch it from Nostr.
- **[×] delete button** — Deletes the conversation (publishes kind 5
tombstone, removes from local cache).
### Chat thread (right pane)
```
┌─────────────────────────────────────────────┐
│ Capital of France [✏ Rename] [💾] │ ← conversation header
├─────────────────────────────────────────────┤
│ You: What is the capital of France? │
│ Assistant: The capital of France is Paris. │
│ │
│ [⋯] │ ← dot-menu on each bubble
├─────────────────────────────────────────────┤
│ [Type a message... ] [Send] │
└─────────────────────────────────────────────┘
```
- **✏ Rename** — Inline edit the conversation title. Saves to Nostr on Enter
or blur.
- **💾 Save** — Explicitly saves the conversation to Nostr (publishes kind 30078
with the current messages + title). Shows "Saved!" feedback.
- **Auto-save is removed** — The user must click Save to persist. The only
exception: the first message exchange auto-saves once (to create the Nostr
event), so the conversation appears in the list on other devices. After
that, explicit Save only.
### Skills list (left pane, bottom section)
```
┌─────────────────────────────┐
│ Skills [↻ Refresh] │
├─────────────────────────────┤
│ [✓] Web scraper [requires: │
│ browser,fs] [×] │
│ [ ] Code reviewer [requires: │
│ shell] [×] │
│ [+ Create Skill] │
└─────────────────────────────┘
```
- **↻ Refresh** — Fetches skills from Nostr (one-shot relay query) and
updates the list.
- **Checkbox** — Toggle skill selection (saved locally, not to Nostr).
- **[×] delete** — Deletes the skill (kind 5 tombstone) — only for
user-authored skills.
- **+ Create Skill** — Opens the create form. "Publish" button saves to
Nostr.
### New endpoints needed
- **`sovereign://agents/conversations/refresh`** (GET) — Triggers a one-shot
relay fetch for kind 30078 events with `t:client-ai-chat-v1` by the user's
pubkey. Stores results in SQLite. Returns `{"status":"refreshed","count":N}`
when done. This is a blocking call (waits for relay response or timeout).
- **`sovereign://agents/skills/refresh`** (GET) — Triggers a one-shot relay
fetch for kind 31123 events. Stores in SQLite. Returns
`{"status":"refreshed","count":N}`.
- **`sovereign://agents/conversations/rename`** (GET with `?id=...&title=...`)
— Renames a conversation. Updates the local SQLite cache and re-publishes
the kind 30078 event with the new title. Returns `{"status":"renamed"}`.
### Changes to existing endpoints
- **`sovereign://agents/conversations/new`** — Should NOT save to Nostr
immediately. Just create a local session. The conversation is saved to
Nostr on the first explicit Save (or after the first message exchange).
- **`sovereign://agents/conversations/save`** — Keep as-is (explicit save).
Remove the auto-save call from `applyStatus()` in chat.js.
### Changes to `www/agents/chat.js`
1. **Remove `scheduleSave()` / `doSave()` auto-save** — Delete the debounced
auto-save logic. The user clicks Save explicitly.
2. **Add Refresh button handler**`refreshConvList()` calls
`sovereign://agents/conversations/refresh`, then re-fetches the list.
3. **Add Rename button handler**`renameConv(id)` prompts for a new title,
calls `sovereign://agents/conversations/rename?id=...&title=...`.
4. **Add Save button handler**`saveConv()` calls
`sovereign://agents/conversations/save?title=...` explicitly.
5. **Add Skills Refresh button handler**`refreshSkills()` calls
`sovereign://agents/skills/refresh`, then re-fetches the skills list.
6. **First-message auto-save** — After the first agent response in a new
conversation, auto-save once (so the conversation exists on Nostr). After
that, no auto-save.
### Changes to `src/nostr_bridge.c`
1. **Add `handle_agents_conversations_refresh()`** — Calls a new
`agent_conversations_refresh()` function that does a one-shot relay fetch.
2. **Add `handle_agents_skills_refresh()`** — Calls a new
`agent_skills_refresh()` function that does a one-shot relay fetch.
3. **Add `handle_agents_conversations_rename()`** — Calls
`agent_conversations_rename(id, title)`.
4. **Update `handle_agents_conversations_new()`** — Remove the immediate
`agent_conversations_save()` call. Just create the local session.
### Changes to `src/agent_conversations.c` / `src/agent_skills.c`
1. **Add `agent_conversations_refresh()`** — One-shot relay fetch for kind
30078 `t:client-ai-chat-v1` events by the user's pubkey. Store in SQLite.
This reuses the relay fetch logic from `relay_fetch.c` but with a specific
filter.
2. **Add `agent_conversations_rename(id, title)`** — Fetch the existing event,
decrypt, update the title, re-encrypt, re-publish.
3. **Add `agent_skills_refresh()`** — One-shot relay fetch for kind 31123
events. Store in SQLite.
### Changes to `www/agents/chat.html`
Add the Refresh, Rename, and Save buttons to the HTML structure.
## Phasing
**Phase 1:** Add Refresh buttons (conversations + skills) and the refresh
endpoints. Remove auto-save. Add explicit Save button.
**Phase 2:** Add Rename button and endpoint.
**Phase 3:** First-message auto-save (so new conversations appear on Nostr
after the first exchange, but subsequent saves are explicit).

View File

@@ -0,0 +1,174 @@
# Chat Sidebar + Tabbed Layout
## Goal
Redesign the agent chat UI for two use cases:
1. **Tabbed chat page** — Split the current cluttered `sovereign://agents/chat`
page into 3 tabs: Chat, Conversations, Skills.
2. **Sidebar mode** — Show the chat in a narrow left-hand sidebar alongside
a web page on the right, so the user can chat about the page they're
viewing.
## Current state
The `sovereign://agents/chat` page has a two-pane layout:
- Left pane: conversation list + skills list (cluttered)
- Right pane: chat messages + input
This is too much for a sidebar. The user wants tabs to organize it.
## Design
### Tabbed chat page
```
┌─────────────────────────────────────────────────┐
│ [Chat] [Conversations] [Skills] │ ← tab bar
├─────────────────────────────────────────────────┤
│ │
│ (active tab content) │
│ │
└─────────────────────────────────────────────────┘
```
**Tab 1: Chat** (default)
- Chat messages (scrollable)
- Status messages (system bubbles)
- Input area (composer + send + stop)
**Tab 2: Conversations**
- "+ New Chat" button
- "↻ Refresh" button
- Conversation list (click to load, rename, delete)
**Tab 3: Skills**
- "↻ Refresh" button
- "+ Create Skill" button
- Skills list (checkboxes, edit, delete)
- Skill editor (inline, expandable)
When a conversation is selected in Tab 2, switch to Tab 1 automatically.
When a skill is toggled in Tab 3, stay on Tab 3.
### Sidebar mode
The sidebar is a **narrow version** of the chat page, shown alongside a
web page. There are two approaches:
**Option A — Split view in the same tab:** The browser window splits into
two webviews: a narrow left webview showing `sovereign://agents/chat` (in
sidebar mode), and a right webview showing the web page. This requires
GTK-level changes to `tab_manager.c` to support split views.
**Option B — Separate sidebar panel:** A GTK panel (like a GtkPaned or
GtkBox) on the left side of the browser window, showing the chat UI
natively (not as a webview). This is more work but gives a native feel.
**Option C — Pop-out sidebar tab:** The chat page detects when it's loaded
in a narrow viewport and switches to a compact sidebar layout (tabs become
icons, messages take full width). This is the simplest — no GTK changes,
just CSS responsive design.
I recommend **Option A** (split view) for the first implementation — it
reuses the existing chat page in a narrow webview, and the tabbed layout
makes it work well in a narrow space.
### Split view implementation (Option A)
```
┌──────────────────────────────────────────────────────────┐
│ [tab strip] │
├─────────────────┬────────────────────────────────────────┤
│ [Chat][Conv] │ │
│ [Skills] │ │
│ │ Web page (right webview) │
│ Chat messages │ │
│ ... │ │
│ │ │
│ [input area] │ │
└─────────────────┴────────────────────────────────────────┘
← sidebar (300px) ← web page (flex: 1)
```
The sidebar is a second webview in the same tab, showing
`sovereign://agents/chat` in a compact layout. The user can toggle the
sidebar on/off via a menu item or keyboard shortcut.
## Implementation
### Phase 1 — Tabbed chat page
1. **`www/agents/chat.html`** — Restructure into 3 tabs:
```html
<div id="tab-bar">
<button class="tab active" data-tab="chat">Chat</button>
<button class="tab" data-tab="conversations">Conversations</button>
<button class="tab" data-tab="skills">Skills</button>
</div>
<div id="tab-chat" class="tab-content active">
<!-- messages + status + input -->
</div>
<div id="tab-conversations" class="tab-content">
<!-- new chat, refresh, conversation list -->
</div>
<div id="tab-skills" class="tab-content">
<!-- refresh, create skill, skills list -->
</div>
```
2. **`www/agents/chat.css`** — Tab bar styling (horizontal buttons, active
state), tab content (only active tab visible).
3. **`www/agents/chat.js`** — Tab switching logic:
- `switchTab(name)` — hides all tab contents, shows the selected one,
updates the active class on the tab buttons.
- `loadConv()` — after loading a conversation, switch to the "chat" tab.
- `newChat()` — after creating a new chat, switch to the "chat" tab.
### Phase 2 — Sidebar mode (split view)
1. **`src/tab_manager.c`** — Add a sidebar webview to each tab:
- Each `tab_info_t` gets a `sidebar_webview` field.
- The tab's container becomes a `GtkPaned` (horizontal) with the sidebar
on the left and the main webview on the right.
- The sidebar webview loads `sovereign://agents/chat` when shown.
- A menu item "Toggle Agent Sidebar" (or keyboard shortcut) shows/hides
the sidebar.
2. **`src/tab_manager.h`** — Add `sidebar_webview` to `tab_info_t`.
3. **`www/agents/chat.css`** — Responsive layout: when the viewport is
narrow (< 400px), switch to a compact layout (tab bar becomes icons,
messages take full width, smaller fonts).
4. **`src/main.c`** — Add a menu item "Toggle Agent Sidebar" and keyboard
shortcut (e.g., Ctrl+Shift+A).
### Phase 3 — Sidebar-aware behavior
1. **Agent tools target the main webview** — When the agent calls browser
tools (snapshot, click, eval), they should operate on the **main
webview** (the web page), not the sidebar webview (the chat page).
Update `agent_tools.c` to use the main webview, not the active webview.
2. **URL bar `;` shortcut** — When the user types `; <message>` in the URL
bar, if the sidebar is open, send the message to the sidebar's chat. If
not, open the sidebar and send the message.
## Files to modify
| File | Change |
|------|--------|
| `www/agents/chat.html` | Restructure into 3 tabs |
| `www/agents/chat.css` | Tab bar styling, responsive sidebar layout |
| `www/agents/chat.js` | Tab switching logic, sidebar-aware behavior |
| `src/tab_manager.c` | Add sidebar webview, GtkPaned split, toggle |
| `src/tab_manager.h` | Add sidebar_webview to tab_info_t |
| `src/main.c` | Add "Toggle Agent Sidebar" menu item + shortcut |
| `src/agent_tools.c` | Browser tools target main webview, not sidebar |
## Phasing
**Phase 1:** Tabbed chat page (HTML/CSS/JS only, no C changes).
**Phase 2:** Sidebar mode (split view, GTK changes).
**Phase 3:** Sidebar-aware behavior (agent tools target main webview).

View File

@@ -0,0 +1,353 @@
# Cross-Project Agent Sync — sovereign_browser ↔ client ↔ didactyl
## Goal
Align sovereign_browser's embedded agent with the patterns used in `~/lt/client`
(the web app) and `~/lt/didactyl` (the C agent daemon), so all three projects
share LLM provider settings, skills, and conversations via Nostr kind 30078
events. Also fix the immediate bug: messages don't appear in the chat UI.
## Three workstreams
```
┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ BugFix │ │ SettingsSync │ │ ChatUI │
│ Fix chat UI │────▶│ kind 30078 │────▶│ Port ai.html │
│ message render │ │ d:user-settings │ │ chat layout │
│ │ │ global_llm sync │ │ + Nostr persist │
└─────────────────┘ └──────────────────┘ └──────────────────┘
```
---
## Workstream 1 — Fix chat UI message rendering (immediate bug)
**Problem:** The user sends "hello", sees the response in the console log, but
nothing appears in the chat box — neither the user message nor the response.
**Root cause:** The chat UI's `fetchMessages()` calls
`sovereign://agents/messages` which returns the message history as JSON. The
`renderMessages()` function renders them. But the polling loop calls
`updateStatus()` and `fetchMessages()` every 500ms. The status polling works
(state transitions to complete), but `fetchMessages()` either:
1. Fails silently (XHR error on `sovereign://agents/messages`), or
2. The message count check `msgs.length !== lastCount` never triggers because
the messages endpoint returns an empty array or the wrong format.
**Fix approach:**
- Add console.error logging to `fetchMessages()` to see the actual XHR response.
- Verify `sovereign://agents/messages` returns the correct JSON array format.
- Check that `agent_chat_store_get_messages()` returns data in the format the
UI expects (`[{role, content, tool_calls, tool_call_id}, ...]`).
- The `renderMessage()` function expects `m.role`, `m.content`, `m.tool_calls`
as fields — verify the store returns these.
**Files to modify:**
- [`src/nostr_bridge.c`](src/nostr_bridge.c) — `handle_agents_chat_page()` JS,
`handle_agents_messages()` endpoint.
---
## Workstream 2 — Kind 30078 settings sync (shared LLM config)
### What the client does
The client stores all user settings in a single kind 30078 event with
`d:user-settings`, NIP-44 self-encrypted. The schema (from
[`~/lt/client/docs/SETTINGS.md`](../client/docs/SETTINGS.md:140)):
```json
{
"v": 2,
"updatedAt": 1708646400,
"global_llm": {
"provider": "ppq",
"api_key": "sk-...",
"model": "claude-opus-4.6",
"base_url": "https://api.ppq.ai",
"max_tokens": 200000,
"temperature": 0.7,
"providers": [
{
"name": "ppq",
"base_url": "https://api.ppq.ai",
"api_key": "sk-...",
"models": ["claude-opus-4.6", "claude-haiku-4.5"]
}
],
"favorites": ["claude-opus-4.6"]
}
}
```
### What didactyl does
Didactyl (C agent daemon) stores the same `global_llm` shape in its own
pubkey's `d:user-settings` event. See
[`~/lt/client/docs/SETTINGS.md`](../client/docs/SETTINGS.md:357).
### What sovereign_browser should do — Option A: Merge into d:user-settings
sovereign_browser already has kind 30078 settings sync via
[`settings_sync.c`](src/settings_sync.c), but it currently uses d-tag
`"sovereign_browser"`. We will **migrate to the shared `d:user-settings`**
event, putting browser-specific settings under a `sovereign_browser` namespace
inside the same encrypted JSON. This aligns with the client and didactyl, and
preserves privacy (no app-specific d-tag leaking which app the user runs).
**Target `d:user-settings` shape — `global` vs per-app:**
The `global` namespace holds **all shared settings** — things that should be
the same across every app the user runs. This includes LLM provider
infrastructure (under `global.agent`), and will grow to include zaps, UI
preferences, relay lists, experimental flags, etc. Each app then has its own
top-level namespace for app-specific config.
```json
{
"v": 2,
"updatedAt": 1708646400,
"global": {
"agent": {
"providers": [
{
"name": "ppq",
"base_url": "https://api.ppq.ai",
"api_key": "sk-...",
"models": ["gpt-4o-mini", "claude-haiku-4.5", "claude-opus-4.6"]
},
{
"name": "ollama-local",
"base_url": "http://localhost:11434",
"api_key": "",
"models": ["llama3.1", "qwen2.5"]
}
],
"favorites": ["gpt-4o-mini", "claude-opus-4.6"]
},
"zaps": {
"defaultAmountSats": 21,
"defaultComment": "",
"preferredMethod": "auto"
},
"ui": {
"theme": "dark",
"language": "en"
},
"relays": { },
"experimental": { }
},
"sovereign_browser": {
"agent": {
"provider": "ppq",
"model": "gpt-4o-mini",
"system_prompt": "",
"max_iterations": 100
},
"new_tab_url": "",
"tab_bar_position": 0,
"max_tabs": 50,
"bootstrap_relays": "...",
"search_engine": "duckduckgo",
"theme_dark": false,
"shortcuts": { ... }
},
"client": {
"ai": {
"provider": "ppq",
"model": "claude-opus-4.6",
"routstr_balance": 0
}
},
"didactyl": {
"agent": {
"provider": "ppq",
"model": "claude-haiku-4.5",
"admin_pubkey": "npub1...",
"dm_protocol": "nip04"
}
}
}
```
**Key principle:** `global.agent.providers` is the shared catalog (API keys,
base URLs, model lists). Each app's namespace (`sovereign_browser.agent`,
`client.ai`, `didactyl.agent`) picks which provider+model to use, and holds
app-specific agent config (system prompt, max_iterations, etc.). The `global`
namespace will grow over time to include other shared settings (zaps, UI,
relays). This way:
- API keys are entered once, shared everywhere
- The browser can use `gpt-4o-mini` while the client uses `claude-opus-4.6`
- Each app's agent config is independent
- Other shared settings (zaps, theme, etc.) live alongside `global.agent`
- The `sovereign://agents` config page manages both the shared providers
list AND the browser's per-app agent selection
**Plan:**
1. **Migrate `settings_sync.c` from d-tag `"sovereign_browser"` to
`"user-settings"`.** Change `SETTINGS_SYNC_D_TAG` to `"user-settings"`.
Move the browser-specific settings into a `sovereign_browser` namespace
inside the encrypted JSON (instead of top-level keys).
2. **Read `global_llm` from `d:user-settings` on startup.** After login,
fetch the user's kind 30078 `d:user-settings` event, NIP-44 decrypt,
parse `global_llm`, and populate `browser_settings_t` agent fields. Also
parse the `sovereign_browser` namespace for browser settings.
3. **Read-modify-write on every settings change.** When any settings change
(browser settings OR agent LLM config), do a read-modify-write:
fetch the current event, decrypt, patch the relevant namespace
(`global_llm` or `sovereign_browser`), re-encrypt, publish. This
preserves other apps' namespaces (`global_zaps`, `global_ui`, etc.).
4. **Multi-provider support.** The `global_llm.providers` array allows
multiple providers. The `sovereign://agents` config page should let the
user manage multiple providers (add/remove/edit), select the active one,
and fetch models per provider.
5. **Backward compatibility.** On first load after migration, if a
`d:sovereign_browser` event exists but no `d:user-settings` does, read
the old event and migrate the data into the new `d:user-settings` shape.
Then publish the merged event and stop using the old d-tag.
**Modified files:**
- [`src/settings_sync.h`](src/settings_sync.h) — Change
`SETTINGS_SYNC_D_TAG` to `"user-settings"`.
- [`src/settings_sync.c`](src/settings_sync.c) — Move browser settings into
`sovereign_browser` namespace, add read-modify-write logic, add
`global_llm` read/write, add backward-compat migration from old d-tag.
- [`src/settings.c`](src/settings.c) — After `settings_load_user()`, call
the sync load to fetch from Nostr.
- [`src/nostr_bridge.c`](src/nostr_bridge.c) — `handle_agents_set()` should
trigger a debounced sync publish.
- [`src/settings.h`](src/settings.h) — Add multi-provider fields to
`browser_settings_t` (providers array, active provider index).
- [`src/relay_fetch.c`](src/relay_fetch.c) — Update the fetch filter to use
`d:user-settings` instead of `d:sovereign_browser`.
---
## Workstream 3 — Port ai.html chat UI elements
### What ai.html has that we want
From [`~/lt/client/www/ai.html`](../client/www/ai.html):
1. **Chat layout** — Left pane with conversation list + skills list, right
pane with chat thread + input area. See
[`ai.html:44-80`](../client/www/ai.html:44) for the CSS layout.
2. **Conversation persistence to Nostr** — Each conversation is saved as a
kind 30078 event with `d:{conversation-id}` and `t:client-ai-chat-v1`,
NIP-44 encrypted. See
[`ai.html:788`](../client/www/ai.html:788) and
[`ai.html:1741`](../client/www/ai.html:1741).
3. **Skills (kind 31123)** — Public skills that define system prompts,
LLM params, and tool requirements. Users can select multiple skills
whose templates are concatenated into the system prompt. See
[`~/lt/client/plans/ai-skills-integration.md`](../client/plans/ai-skills-integration.md).
4. **Provider config sidenav** — Provider dropdown, model dropdown, API key
input, Routstr payment integration.
### What to port to sovereign://agents/chat
**Phase A — Chat layout:**
- Two-pane layout: conversation list (left) + chat thread (right).
- Conversation list shows saved conversations with titles, click to load.
- "New Chat" button to start a new conversation.
- Chat thread shows messages with role labels, tool call details collapsible.
- Input area at the bottom with send button.
**Phase B — Conversation persistence to Nostr:**
- Save each conversation as kind 30078, `d:{conversation-id}`,
`t:sovereign-browser-ai-chat-v1`, NIP-44 encrypted.
- Content: `{ schema: 1, messages: [...], title: "..." }`.
- Load conversations from Nostr on page load.
- Delete conversations (kind 5 tombstone).
**Phase C — Skills (kind 31123):**
- Fetch public skills from Nostr (kind 31123 events).
- Display skills in the left pane below conversations.
- Selecting a skill applies its system prompt template.
- Skills with `requires_tool` tags are highlighted (sovereign_browser HAS
tools, so they're fully usable — unlike ai.html which has no tools).
**Phase D — Save skills/agents to Nostr:**
- UI to create/edit skills (kind 31123) with system prompt, model, temp.
- Publish to Nostr so they're shared across all three projects.
**Files to modify:**
- [`src/nostr_bridge.c`](src/nostr_bridge.c) — Major rewrite of
`handle_agents_chat_page()` to include the two-pane layout, conversation
list, skills list. New endpoints: `sovereign://agents/conversations`,
`sovereign://agents/conversations/new`, `sovereign://agents/conversations/delete`,
`sovereign://agents/skills`, `sovereign://agents/skills/save`.
- [`src/agent_chat_store.c`](src/agent_chat_store.c) — Add functions to
load/save conversations as Nostr events (via `relay_fetch.c`).
- New: `src/agent_skills.h` / `src/agent_skills.c` — Fetch, parse, and
apply kind 31123 skills.
---
## Cross-project harmony
```
┌─────────────────────────────────┐
│ User's Nostr │
│ │
│ d:user-settings (k30078) │
│ └─ global_llm (NIP-44 enc) │
│ │
│ d:{convo-id} (k30078) │
│ └─ conversations (NIP-44) │
│ │
│ kind 31123 (public skills) │
└────────┬───────────┬────────────┘
│ │
┌───────────────────┼───────────┼───────────────────┐
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────────┐ ┌───────────────┐ ┌───────────────┐ ┌──────────┐
│ client │ │ sovereign_ │ │ sovereign_ │ │ didactyl │
│ ai.html │ │ browser │ │ browser │ │ daemon │
│ │ │ agents config │ │ agents/chat │ │ │
│ read/write │ │ read/write │ │ read/write │ │ read/ │
│ global_llm │ │ global_llm │ │ conversations│ │ write │
│ read/write │ │ │ │ fetch/publish│ │ global │
│ conversations │ │ │ │ skills │ │ _llm │
│ publish/fetch │ │ │ │ │ │ fetch │
│ skills │ │ │ │ │ │ skills │
└─────────────────┘ └───────────────┘ └───────────────┘ └──────────┘
```
All three projects share:
- **`global_llm`** in `d:user-settings` — provider, API key, model
- **Conversations** in `d:{convo-id}` — encrypted chat history
- **Skills** in kind 31123 — public system prompt templates
sovereign_browser's unique advantage: it has **browser tools** (snapshot,
click, eval) + **filesystem/shell tools** that ai.html and didactyl don't
have. Skills with `requires_tool` tags are fully functional in
sovereign_browser.
---
## Phasing
**Phase 1 (immediate):** Fix chat UI message rendering bug.
**Phase 2:** Kind 30078 `global_llm` sync — read from Nostr on startup,
write on config change. Single-provider first.
**Phase 3:** Port ai.html chat layout — two-pane, conversation list,
conversation persistence to Nostr.
**Phase 4:** Skills (kind 31123) — fetch, display, apply, create.
**Phase 5:** Multi-provider support in `global_llm`.

320
plans/embedded-agent.md Normal file
View File

@@ -0,0 +1,320 @@
# Embedded Agent — In-Browser LLM with First-Class MCP Access
## Concurrency model — long-running tasks
The agent loop runs on a **background GThread**, not the GTK main thread. This
is critical for long-running tasks like "follow each link, download images,
write a summary for each" which may involve dozens of LLM calls and hundreds
of tool calls.
```mermaid
sequenceDiagram
participant UI as Chat UI main thread
participant Chat as agent_chat.c
participant Thread as Agent Loop background thread
participant LLM as LLM API
participant Main as GTK main loop
participant Tools as agent_tools_dispatch
UI->>Chat: POST sovereign://agents/send?text=...
Chat->>Thread: g_thread_new agent_loop_run
Chat-->>UI: 200 OK session started
loop ReAct iterations
Thread->>LLM: POST chat/completions blocking
LLM-->>Thread: response + tool_calls
Thread->>Thread: persist assistant message
alt has tool_calls
loop each tool call
Thread->>Main: g_idle_add dispatch_tool
Main->>Tools: agent_tools_dispatch sync JS eval
Tools-->>Main: result
Main-->>Thread: result via GAsyncQueue
Thread->>Thread: persist tool result
end
else no tool_calls
Thread->>Thread: mark session complete
end
end
Thread->>Main: g_idle_add update_ui final
```
### Threading rules
1. **LLM HTTP calls** happen on the background thread — `soup_session_send`
blocks in-thread, the GTK UI stays responsive.
2. **Browser tool dispatch** (snapshot, click, eval, etc.) MUST run on the
GTK main thread because WebKitGTK is not thread-safe. The background
thread schedules each tool call via `g_idle_add()` and waits for the
result on a `GAsyncQueue` or a per-call `GMainLoop` (same pattern the
MCP HTTP handler uses for sync JS eval).
3. **Filesystem + shell tools** run directly on the background thread — they
don't touch GTK or WebKit, so no main-thread hop needed. This keeps
long shell commands from blocking the UI.
4. **SQLite writes** use the existing `SQLITE_OPEN_FULLMUTEX` connection
(thread-safe). The background thread can call `db_kv_set` /
`agent_chat_store_*` directly.
5. **UI updates** happen via `g_idle_add()` — the background thread pushes
status updates (current tool, iteration count, partial output) to a
shared struct, and an idle callback renders them in the chat page.
6. **Cancellation** — a `g_atomic_int` cancel flag is checked at the top of
each loop iteration. The chat UI's "Stop" button sets it. The background
thread exits cleanly at the next check point.
### Iteration cap
The default `max_iterations` is **100** (configurable on
`sovereign://agents`), not 20. Long tasks like "follow 30 links" need
multiple tool calls per link (open, snapshot, extract, download, write) —
easily 100+ calls. The cap is a safety valve, not a tight limit. The user
can raise it in settings for very long batch jobs.
### Status polling
The chat UI polls `sovereign://agents/status?session=...` every 500ms while
a session is active. The response includes:
- `state`: `idle` | `thinking` | `tool_call` | `complete` | `error` | `cancelled`
- `iteration`: current iteration number
- `current_tool`: name of the tool being executed (if any)
- `last_message`: most recent assistant text (for progressive display)
- `error`: error message if state is `error`
This gives the user live visibility into long-running tasks without
streaming complexity.
## Goal
Embed an LLM-powered agent directly inside sovereign_browser. The user types
`; <message>` in the URL bar to talk to the agent. The agent has first-class
access to the browser's own MCP tool set (snapshot, click, eval, tabs, etc.)
plus full filesystem and shell access (the browser runs in a dedicated Qubes
qube, so arbitrary command execution is acceptable and intended).
Provider config (base URL, API key, model name) is managed on a new
`sovereign://agents` internal page. Chat history is persisted per-session in
SQLite so it can be fed back as context on follow-up messages.
## User-facing flow
```mermaid
flowchart LR
User[User types in URL bar] --> Check{Starts with semicolon?}
Check -- yes --> Agent[Embedded Agent Chat UI]
Check -- no --> Nav[Normal navigation]
Agent --> LLM[OpenAI-compatible API call]
LLM --> ToolLoop{Tool calls in response?}
ToolLoop -- yes --> Dispatch[agent_tools_dispatch via internal MCP loopback]
Dispatch --> ToolLoop
ToolLoop -- no --> Render[Render assistant message in chat UI]
Render --> User
```
## Architecture
```mermaid
flowchart TB
subgraph URLBar
Entry[GtkEntry on_url_activate]
end
subgraph AgentModule
Router[agent_chat_route_input]
Client[agent_llm.c — HTTP client]
Loop[agent_loop.c — tool-call loop]
Store[agent_chat_store.c — SQLite persistence]
end
subgraph BrowserCore
Tools[agent_tools_dispatch]
FsTools[agent_fs_tools.c — fs + shell tools]
Bridge[nostr_bridge.c — sovereign:// scheme]
end
subgraph External
API[OpenAI-compatible API]
end
subgraph UI
ChatPage[sovereign://agents/chat — HTML/JS chat UI]
ConfigPage[sovereign://agents — provider config]
end
Entry --> Router
Router -->|chat message| Client
Client -->|HTTPS POST| API
API -->|JSON response| Client
Client --> Loop
Loop -->|tool call| Tools
Loop -->|fs/shell call| FsTools
Tools --> BrowserCore
Loop --> Store
Store --> ChatPage
Bridge --> ChatPage
Bridge --> ConfigPage
```
### Key design decisions
1. **Reuse `agent_tools_dispatch()`** — The embedded agent calls the exact same
C dispatch function the external MCP server uses. No parallel tool
implementation. The agent passes `conn = NULL` so async JS tools use the
sync `agent_js_eval_sync()` path (same as the MCP HTTP handler).
2. **New system tools (`agent_fs_tools.c`)**`fs_read`, `fs_write`,
`fs_list`, `fs_mkdir`, `fs_delete`, `shell_exec`. These are registered
alongside the existing browser tools so both the embedded agent and
external MCP clients can use them. Full shell access — the Qubes qube
provides the sandbox.
3. **OpenAI-compatible HTTP client (`agent_llm.c`)** — Uses libsoup-3.0
(already linked) to POST to `{base_url}/chat/completions` with the
`tools` array built from the same `tool_defs[]` catalog in
[`agent_mcp.c`](src/agent_mcp.c:130). Streams or polls; parses tool calls
from the response. One client covers OpenAI, OpenRouter, Ollama, LM Studio,
Groq, etc. via base URL + key.
4. **Tool-call loop (`agent_loop.c`)** — Standard ReAct loop, runs on a
background `GThread` (see Concurrency model above):
- Build messages array (system prompt + persisted history + new user msg).
- Call LLM (blocking HTTP on the background thread).
- If response contains `tool_calls`, dispatch each:
- Browser tools → hop to GTK main thread via `g_idle_add()` + wait on
`GAsyncQueue` (WebKitGTK is not thread-safe).
- Filesystem/shell tools → run directly on the background thread.
- Append tool results to messages, call LLM again.
- Repeat until no more tool calls → render final assistant text.
- Cap at N iterations (default 100, configurable) to prevent infinite
loops. Check cancel flag at the top of each iteration.
5. **Chat persistence (`agent_chat_store.c`)** — New SQLite tables
(`agent_sessions`, `agent_messages`) in the per-user `browser.db`. Each
session has an id, title, created_at. Messages store role
(user/assistant/tool), content, and tool-call JSON. The URL-bar `;` command
reuses the most recent session (or starts a new one if none exists).
6. **`sovereign://agents` config page** — Rendered by `nostr_bridge.c` (same
pattern as `sovereign://settings`). Fields: provider name, base URL, API
key, model name, system prompt (optional). Saved via
`sovereign://agents/set?key=...&value=...` to the `key_value` table
(existing `db_kv_set`). API key stored in the key_value table; since this
is a dedicated qube, that's acceptable.
7. **`sovereign://agents/chat` chat UI** — A lightweight HTML/JS page
(rendered by `nostr_bridge.c`) that:
- Shows the message history (loaded from SQLite via a
`sovereign://agents/messages?session=...` endpoint).
- Has an input box for follow-up messages (POSTed to
`sovereign://agents/send?session=...&text=...`).
- Polls `sovereign://agents/status?session=...` for in-progress tool calls
and streams the assistant's final response.
- The URL-bar `;` shortcut navigates here with the message pre-filled and
auto-submits.
8. **URL-bar `;` routing** — In [`on_url_activate`](src/tab_manager.c:913),
check if `text[0] == ';'`. If so, extract the message (`text + 1`, trimmed)
and call `agent_chat_route_input(message)` instead of `normalize_url()`.
The function opens `sovereign://agents/chat` in the active tab (or a new
tab if the active tab isn't already the chat page) and kicks off the
agent loop. If the message is empty (`;` alone), just open the chat page
without sending.
9. **System prompt** — A default system prompt explains the agent's
capabilities: it controls a web browser via MCP tools, has filesystem and
shell access, and should use the snapshot+ref pattern for page
interaction. The user can override this on `sovereign://agents`.
## New files
| File | Purpose |
|------|---------|
| `src/agent_llm.h` / `src/agent_llm.c` | OpenAI-compatible HTTP client (libsoup). Sends chat-completions request with tools, parses response + tool_calls. |
| `src/agent_loop.h` / `src/agent_loop.c` | ReAct tool-call loop. Orchestrates LLM calls ↔ tool dispatch. |
| `src/agent_chat_store.h` / `src/agent_chat_store.c` | SQLite persistence for chat sessions and messages. |
| `src/agent_fs_tools.h` / `src/agent_fs_tools.c` | Filesystem + shell tools (fs_read, fs_write, fs_list, fs_mkdir, fs_delete, shell_exec). |
| `src/agent_chat.h` / `src/agent_chat.c` | High-level entry point: `agent_chat_route_input()`, session management, bridges URL-bar → loop → UI. |
## Modified files
| File | Change |
|------|--------|
| [`src/tab_manager.c`](src/tab_manager.c:913) | `on_url_activate`: detect `;` prefix, route to `agent_chat_route_input()`. |
| [`src/nostr_bridge.c`](src/nostr_bridge.c:1723) | Add `sovereign://agents`, `sovereign://agents/chat`, `sovereign://agents/set`, `sovereign://agents/messages`, `sovereign://agents/send`, `sovereign://agents/status` route handlers. |
| [`src/agent_mcp.c`](src/agent_mcp.c:130) | Export `tool_defs[]` / `build_tools_list()` (or move to a shared header) so `agent_llm.c` can build the OpenAI tools array from the same catalog. Add fs/shell tool defs. |
| [`src/agent_tools.c`](src/agent_tools.c) | Dispatch fs/shell tools (or route them via a new `agent_fs_tools_dispatch()` called from the same dispatcher). |
| [`src/db.c`](src/db.c) / [`src/db.h`](src/db.h) | Add `agent_sessions` + `agent_messages` tables and CRUD functions. |
| [`src/settings.h`](src/settings.h) / [`src/settings.c`](src/settings.c) | Add agent provider settings fields (base_url, api_key, model, system_prompt). |
| [`Makefile`](Makefile) | Add new `.c` files to `SRC`. |
## SQLite schema
```sql
CREATE TABLE IF NOT EXISTS agent_sessions (
id TEXT PRIMARY KEY, -- UUID
title TEXT,
created_at INTEGER,
updated_at INTEGER
);
CREATE TABLE IF NOT EXISTS agent_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT,
role TEXT, -- 'user' | 'assistant' | 'tool' | 'system'
content TEXT, -- message text (or tool result JSON)
tool_calls TEXT, -- JSON array of tool calls (assistant msgs)
tool_call_id TEXT, -- for role='tool': which call this answers
created_at INTEGER,
FOREIGN KEY (session_id) REFERENCES agent_sessions(id)
);
CREATE INDEX IF NOT EXISTS idx_agent_messages_session
ON agent_messages(session_id, created_at);
```
## Tool catalog additions (fs + shell)
| Tool | Description |
|------|-------------|
| `fs_read` | Read a file's contents (text). Params: `path`. |
| `fs_write` | Write text to a file (overwrite). Params: `path`, `content`. |
| `fs_list` | List directory entries. Params: `path`. |
| `fs_mkdir` | Create a directory (recursive). Params: `path`. |
| `fs_delete` | Delete a file or empty directory. Params: `path`. |
| `shell_exec` | Run a shell command, return stdout+stderr+exit code. Params: `command`, `timeout_ms` (default 30000). |
## OpenAI tools array format
The `agent_llm.c` client builds the `tools` field from the shared
`tool_defs[]` array, converting each entry to the OpenAI format:
```json
{
"type": "function",
"function": {
"name": "snapshot",
"description": "Get the accessibility tree...",
"parameters": { ...schema... }
}
}
```
## Concurrency note
See the **Concurrency model** section at the top of this document for the
full design. Summary: the agent loop runs on a background `GThread`. LLM
HTTP calls block in-thread. Browser tool calls hop to the GTK main thread
via `g_idle_add()` + `GAsyncQueue` (WebKitGTK is not thread-safe).
Filesystem and shell tools run directly on the background thread. The UI
polls `sovereign://agents/status` for live progress. A cancel flag allows
the user to stop long-running tasks.
## Phasing
The work breaks into two phases that can be implemented sequentially:
**Phase 1 — Foundation:** fs/shell tools, LLM client, tool-call loop, chat
persistence, `sovereign://agents` config page, URL-bar `;` routing, basic
chat UI. After Phase 1 the example task ("save links to ~/temp/links.txt")
works end-to-end.
**Phase 2 — Polish:** streaming responses, tool-call progress display in the
chat UI, session list / history sidebar, editable system prompt per session,
multi-session support from the URL bar, error recovery UI.

View File

@@ -0,0 +1,179 @@
# Embedded Web Content — sovereign:// pages from files
## Problem
The `sovereign://agents/chat` page is hardcoded as a ~800-line C string literal
in [`src/nostr_bridge.c`](src/nostr_bridge.c) (`handle_agents_chat_page()`).
This causes:
- **Bugs** — C string escaping errors (`\"`, `\\`, `%%`) are easy to make and
hard to spot. The current "messages don't render" and "New Chat doesn't work"
bugs are likely caused by this.
- **Maintenance nightmare** — No syntax highlighting, no IDE support, no
formatting tools work on the embedded HTML/CSS/JS.
- **Can't copy ai.html** — The user wants to port `~/lt/client/www/ai.html`'s
structure, but that's a standalone HTML file with separate CSS/JS. Embedding
it as a C string would be thousands of lines of escaped strings.
## Solution: Copy c-relay's approach
`~/lt/c-relay` solves this with a build-time embedding script:
1. **Author web files as normal files** in a `www/` directory (HTML, CSS, JS —
no escaping needed, full IDE support).
2. **`embed_web_files.sh`** — A shell script that runs at build time. It uses
`hexdump` to convert each file into a C byte array
(`0x3c,0x21,0x44,...`) in an auto-generated `src/embedded_web_content.c`.
3. **`embedded_web_content.h`** — Declares `embedded_file_t` and
`get_embedded_file(path)`.
4. **`embedded_web_content.c`** — Auto-generated byte arrays + a lookup table
mapping paths to arrays.
5. **The `sovereign://` handler** calls `get_embedded_file()` to serve the
embedded content instead of building HTML with `g_strdup_printf()`.
## Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ www/ (normal files, edit freely) │
│ agents/chat.html (standalone HTML) │
│ agents/chat.css (separate CSS) │
│ agents/chat.js (separate JS) │
│ agents/config.html (provider config page) │
│ agents/config.css │
│ agents/config.js │
│ settings.html │
│ bookmarks.html │
│ ... │
└─────────────────────────────────────────────────────────────┘
▼ embed_web_files.sh (build time)
┌─────────────────────────────────────────────────────────────┐
│ src/embedded_web_content.c (auto-generated, do not edit) │
│ static const unsigned char agents_chat_html_data[] = { │
│ 0x3c,0x21,0x44,0x4f,0x43,0x54,0x59,0x50,0x45,... │
│ }; │
│ static const size_t agents_chat_html_size = 12345; │
│ ... │
│ static embedded_file_t embedded_files[] = { │
│ {"agents/chat.html", agents_chat_html_data, ...}, │
│ {"agents/chat.css", agents_chat_css_data, ...}, │
│ {"agents/chat.js", agents_chat_js_data, ...}, │
│ {NULL, NULL, 0, NULL} │
│ }; │
└─────────────────────────────────────────────────────────────┘
▼ get_embedded_file(path)
┌─────────────────────────────────────────────────────────────┐
│ src/nostr_bridge.c (sovereign:// handler) │
│ on_sovereign_scheme(): │
│ if (strncmp(uri, "sovereign://agents/chat", ...) == 0) │
│ serve_embedded_file(request, "agents/chat.html"); │
│ ... │
└─────────────────────────────────────────────────────────────┘
```
## Implementation plan
### Phase 1 — Set up the embedding infrastructure
1. **Create `www/` directory** at the project root for web files.
2. **Create `embed_web_files.sh`** (copy from c-relay, adapt for our file
structure). It scans `www/**/*.html`, `www/**/*.css`, `www/**/*.js` and
generates `src/embedded_web_content.c` + `src/embedded_web_content.h`.
3. **Create `src/embedded_web_content.h`**:
```c
typedef struct {
const char *path; /* e.g. "agents/chat.html" */
const unsigned char *data;
size_t size;
const char *mime_type; /* "text/html", "text/css", "application/javascript" */
} embedded_file_t;
const embedded_file_t *get_embedded_file(const char *path);
```
4. **Add a `serve_embedded_file()` helper** in `nostr_bridge.c` that calls
`get_embedded_file()` and responds with the bytes via `respond_bytes()`.
5. **Update `Makefile`** to run `embed_web_files.sh` before compiling, and
add `src/embedded_web_content.c` to `SRC`.
6. **Update `.gitignore`** to ignore `src/embedded_web_content.c` and
`src/embedded_web_content.h` (they're auto-generated).
### Phase 2 — Migrate the chat page to files
1. **Create `www/agents/chat.html`** — Extract the HTML structure from
`handle_agents_chat_page()` into a standalone HTML file. No C string
escaping needed.
2. **Create `www/agents/chat.css`** — Extract the CSS into a separate file.
Link it from the HTML: `<link rel="stylesheet" href="sovereign://agents/chat.css">`.
3. **Create `www/agents/chat.js`** — Extract the JavaScript into a separate
file. Link it: `<script src="sovereign://agents/chat.js"></script>`.
4. **Add routes** in `on_sovereign_scheme()`:
- `sovereign://agents/chat` → serve `agents/chat.html`
- `sovereign://agents/chat.css` → serve `agents/chat.css`
- `sovereign://agents/chat.js` → serve `agents/chat.js`
5. **Fix the bugs** while extracting:
- **Messages not rendering:** The `renderMessage()` function must include
`data-msg-index="N"` on `.msg-bubble-content` and `.msg-bubble-menu-host`
elements so `renderBubbleContent()` and `mountDotMenu()` can find them.
- **New Chat not working:** Verify `sovereign://agents/conversations/new`
creates a new session and sets it as active. The `newChat()` JS must
clear the message list and reset `lastCount = -1`.
### Phase 3 — Migrate other sovereign:// pages
Once the chat page works, migrate the other pages the same way:
- `sovereign://agents` (config page) → `www/agents/config.html` + `.css` + `.js`
- `sovereign://settings` → `www/settings.html` + `.css` + `.js`
- `sovereign://bookmarks` → `www/bookmarks.html` + `.css` + `.js`
- `sovereign://profile` → `www/profile.html` + `.css` + `.js`
Each page keeps its C-side data endpoints (`sovereign://agents/set`,
`sovereign://agents/messages`, etc.) but the page HTML/CSS/JS moves to files.
### Phase 4 — Port ai.html features
With the chat page as a standalone HTML file, we can directly copy:
- The ai.html message bubble CSS (from `messaging-ui.css`)
- The dot-menu CSS (from `dot-menu.css`)
- The markdown renderer (use `marked.js` from the client's vendor libs, or
keep our minimal renderer)
- The conversation list layout
- The skills list layout
- The provider config sidenav
## Benefits
- **No C string escaping** — Edit HTML/CSS/JS normally with full IDE support
- **Syntax highlighting** — IDEs recognize `.html`, `.css`, `.js` files
- **Can copy ai.html directly** — Just copy the relevant sections
- **Smaller `nostr_bridge.c`** — The 800-line chat page string literal is gone
- **Easier debugging** — Browser dev tools show the actual HTML, not escaped strings
- **Build-time embedding** — Files are bundled in the binary, no external files needed at runtime
## Files to create
| File | Purpose |
|------|---------|
| `embed_web_files.sh` | Build script: converts www/ files to C byte arrays |
| `src/embedded_web_content.h` | Header for the embedded file lookup |
| `src/embedded_web_content.c` | Auto-generated byte arrays (gitignored) |
| `www/agents/chat.html` | Chat page HTML |
| `www/agents/chat.css` | Chat page CSS |
| `www/agents/chat.js` | Chat page JS |
## Files to modify
| File | Change |
|------|--------|
| `Makefile` | Run embed script, add embedded_web_content.c to SRC |
| `.gitignore` | Ignore auto-generated embedded_web_content.* |
| `src/nostr_bridge.c` | Add serve_embedded_file(), route to embedded files, remove old handle_agents_chat_page() string literal |

View File

@@ -0,0 +1,465 @@
# FIPS Management Page — `sovereign://fips`
## Goal
A dedicated `sovereign://fips` page for managing the FIPS mesh daemon from
inside the browser. This is **not** part of the settings page — it's a
separate status + management page, like a network control panel.
The page lets the user:
1. **See FIPS status** — daemon state, node npub, TUN interface, peer count,
tree state, ownership (managed vs attached), error messages.
2. **See connected peers** — list of peers with their npub, address,
transport, and connection state.
3. **Connect to a peer** — add a new peer by npub + address + transport.
4. **Disconnect a peer** — remove a peer connection.
5. **Restart the FIPS service** — stop + start the managed daemon, or
re-attach.
6. **See Tor status** (bonus) — since Tor and FIPS are the two network
services, showing both on one "Network" page makes sense. Tor is
read-only status (bootstrap progress, SOCKS endpoint); FIPS is
interactive management.
This makes `.fips` addresses actually usable: the user can add peers from
the browser UI instead of dropping to `fipsctl` on the command line.
---
## Current State
### FIPS control API (`src/fips_control.c` / `src/fips_control.h`)
The FIPS daemon exposes a JSON-line control protocol over a Unix socket.
The browser already has a client:
- [`fips_control_connect()`](src/fips_control.c:30) — open a control
connection to the socket.
- [`fips_control_show_status()`](src/fips_control.c:142) — sends
`{"command":"show_status"}`, parses response into `fips_status_t`
(npub, peer_count, tun_state, tun_name, daemon state, tree_state,
tun_active).
- [`fips_control_show_peers()`](src/fips_control.c:171) — sends
`{"command":"show_peers"}`, returns the `data` field as a JSON string
(caller frees). **This is not currently called anywhere** — it exists
but is unused.
- [`fips_control_connect_peer()`](src/fips_control.c:185) — sends
`{"command":"connect","params":{"npub":...,"address":...,"transport":...}}`.
**Also unused.**
- [`fips_control_close()`](src/fips_control.c:205) — close the fd.
**Missing from the client:** a `disconnect` peer command. The FIPS daemon
likely supports `{"command":"disconnect","params":{"npub":...}}` (this
needs verification against the FIPS daemon's control protocol — see
`fipsctl` source or `docs/control-api.md` in the FIPS repo). We'll add a
`fips_control_disconnect_peer()` function.
### Net services layer (`src/net_services.c` / `src/net_services.h`)
- [`net_service_get_status(NET_SERVICE_FIPS)`](src/net_services.c:493)
returns a pointer to the global `g_services[NET_SERVICE_FIPS]` struct.
The `status.fips` union member has: `peer_count`, `node_npub[80]`,
`tree_state[32]`, `tun_active`, `tun_name[16]`, `daemon_state[32]`,
`control_socket[512]`.
- [`fips_poll_cb()`](src/net_services.c:213) polls `show_status` every 2s
(managed) or 5s (attached) and updates the struct. It does **not** poll
`show_peers` — only the peer *count* is tracked.
- The service `state` field (`service_state_t`) tracks the lifecycle:
`SERVICE_DISABLED`, `DISCOVERING`, `ATTACHING`, `STARTING`,
`BOOTSTRAPPING`, `READY`, `STOPPING`, `EXITED`, `FAILED`.
- `ownership` is `OWNERSHIP_NONE`, `ATTACHED`, or `MANAGED`.
- `error_msg` holds the failure reason when `state == SERVICE_FAILED`.
- [`net_service_restart(NET_SERVICE_FIPS)`](src/net_services.c) exists —
it disables then re-enables the service.
### sovereign:// page pattern (`src/nostr_bridge.c`)
The existing pages (settings, profile, bookmarks, agents) follow this
pattern:
1. **HTML page** served from an embedded `www/` file via
[`serve_embedded_file()`](src/nostr_bridge.c:132).
2. **CSS** served from `www/<name>.css`, linked via
`<link href="sovereign://fips.css">`.
3. **JS** served from `www/<name>.js`, loaded via
`<script src="sovereign://fips.js">`.
4. **JSON data endpoints**`sovereign://fips/status` returns JSON,
fetched by the JS on page load and on refresh.
5. **Action endpoints**`sovereign://fips/connect?npub=...&address=...&transport=...`
performs an action and returns JSON.
The JS uses `XMLHttpRequest` (not `fetch()`) because WebKit's custom
scheme handler doesn't support `fetch()` reliably for `sovereign://`
URLs — see the comment at [`nostr_bridge.c:1902`](src/nostr_bridge.c:1902).
A `sovereignGet()` helper wraps XHR and returns a Promise.
Routing is in the main `bridge_handle_request()` function
([`nostr_bridge.c:3238`](src/nostr_bridge.c:3238)) — a series of
`strcmp`/`strncmp` checks on the URI, calling the appropriate handler.
---
## Design
### Page layout
```
┌─────────────────────────────────────────────────────────────┐
│ FIPS Mesh Network [Refresh] │
├─────────────────────────────────────────────────────────────┤
│ ┌─ Status ──────────────────────────────────────────────┐ │
│ │ State: Ready ● │ │
│ │ Node: npub1abc...xyz │ │
│ │ TUN: fips0 (active) │ │
│ │ Peers: 3 connected │ │
│ │ Tree: healthy │ │
│ │ Ownership: managed (spawned by browser) │ │
│ │ Control: ~/.sovereign_browser/fips/control.sock │ │
│ │ [Restart Service] │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Peers ───────────────────────────────────────────────┐ │
│ │ npub1def... 203.0.113.5:4242 udp connected [✕] │ │
│ │ npub1ghi... 198.51.100.10:4242 tcp connected [✕] │ │
│ │ npub1jkl... fd00::1:4242 udp connecting [✕] │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Add Peer ────────────────────────────────────────────┐ │
│ │ npub: [npub1... ] │ │
│ │ address: [host:port ] │ │
│ │ transport: [udp ▼] │ │
│ │ [Connect] │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Tor Status (read-only) ──────────────────────────────┐ │
│ │ State: Ready ● │ │
│ │ Bootstrap: 100% │ │
│ │ SOCKS: socks5://127.0.0.1:9050 │ │
│ │ Ownership: attached (system Tor) │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### Routing
| URI | Handler | Returns |
|-----|---------|---------|
| `sovereign://fips` | serve `www/fips.html` | HTML page |
| `sovereign://fips.css` | serve `www/fips.css` | CSS |
| `sovereign://fips.js` | serve `www/fips.js` | JS |
| `sovereign://fips/status` | `handle_fips_status_json` | JSON: FIPS + Tor status |
| `sovereign://fips/peers` | `handle_fips_peers_json` | JSON: peer list from `show_peers` |
| `sovereign://fips/connect?npub=...&address=...&transport=...` | `handle_fips_connect` | JSON: connect result |
| `sovereign://fips/disconnect?npub=...` | `handle_fips_disconnect` | JSON: disconnect result |
| `sovereign://fips/restart` | `handle_fips_restart` | JSON: restart result |
### JSON endpoints
#### `sovereign://fips/status`
```json
{
"fips": {
"state": "ready",
"enabled": true,
"ownership": "managed",
"node_npub": "npub1abc...",
"peer_count": 3,
"tun_active": true,
"tun_name": "fips0",
"tree_state": "healthy",
"daemon_state": "running",
"control_socket": "~/.sovereign_browser/fips/control.sock",
"error": null
},
"tor": {
"state": "ready",
"enabled": true,
"ownership": "attached",
"bootstrap_progress": 100,
"socks_endpoint": "socks5://127.0.0.1:9050",
"circuit_info": "",
"error": null
}
}
```
When a service is `DISABLED` or `FAILED`, the fields are populated as
best as possible and `error` holds the failure message.
#### `sovereign://fips/peers`
Returns the raw `show_peers` data from the FIPS daemon (passed through
as-is, since the format is defined by FIPS):
```json
{
"peers": [
{"npub": "npub1def...", "address": "203.0.113.5:4242", "transport": "udp", "state": "connected"},
{"npub": "npub1ghi...", "address": "198.51.100.10:4242", "transport": "tcp", "state": "connected"}
]
}
```
If FIPS is not ready, returns `{"peers": [], "error": "FIPS not ready"}`.
#### `sovereign://fips/connect`
Query params: `npub`, `address`, `transport` (optional, defaults to
`udp`).
Returns `{"status": "ok"}` or `{"error": "..."}`.
#### `sovereign://fips/disconnect`
Query params: `npub`.
Returns `{"status": "ok"}` or `{"error": "..."}`.
#### `sovereign://fips/restart`
No params. Calls `net_service_restart(NET_SERVICE_FIPS)`. Returns
`{"status": "ok"}` (the restart is async — the JS polls
`sovereign://fips/status` to see when it's ready again).
---
## Changes Required
### 1. Add `fips_control_disconnect_peer()` to `src/fips_control.c` / `.h`
```c
int fips_control_disconnect_peer(int fd, const char *npub,
char *error, size_t error_size);
```
Sends `{"command":"disconnect","params":{"npub":"..."}}`. **Verify the
exact command name** against the FIPS daemon's control protocol — check
`fipsctl` source or FIPS `docs/control-api.md`. If the command is
`disconnect_peer` or `remove_peer`, adjust accordingly.
### 2. Add FIPS route handlers to `src/nostr_bridge.c`
New functions (following the existing handler pattern):
- `handle_fips_status_json(request)` — reads
`net_service_get_status(NET_SERVICE_FIPS)` and
`net_service_get_status(NET_SERVICE_TOR)`, builds a cJSON object with
the fields above, responds as JSON.
- `handle_fips_peers_json(request)` — opens a control connection to the
FIPS socket (from `status.fips.control_socket`), calls
`fips_control_show_peers()`, wraps the result in `{"peers": [...]}`,
responds as JSON. If FIPS is not ready, returns empty peers + error.
- `handle_fips_connect(request, query)` — parses `npub`, `address`,
`transport` from query string, opens a control connection, calls
`fips_control_connect_peer()`, responds as JSON.
- `handle_fips_disconnect(request, query)` — parses `npub`, opens a
control connection, calls `fips_control_disconnect_peer()`, responds
as JSON.
- `handle_fips_restart(request)` — calls
`net_service_restart(NET_SERVICE_FIPS)`, responds as JSON.
**Control connection management:** Each action handler opens a fresh
control connection to the FIPS socket, sends the command, closes the
connection. This is simpler than reusing the `net_service_t`'s
`control_fd` (which is owned by the poll callback and may be in use).
The `fips_control_connect()` call is fast (Unix socket, 500ms timeout).
### 3. Add routing to `bridge_handle_request()` in `src/nostr_bridge.c`
Add a FIPS route block (before the `sovereign://nostr/` catch-all at
line 3606), following the same pattern as the settings/bookmarks/agents
blocks:
```c
/* Route sovereign://fips — FIPS mesh management page. */
if (strcmp(uri, "sovereign://fips") == 0 ||
strncmp(uri, "sovereign://fips?", 18) == 0) {
serve_embedded_file(request, "fips.html");
return;
}
if (strcmp(uri, "sovereign://fips.css") == 0 ||
strncmp(uri, "sovereign://fips.css?", 21) == 0) {
serve_embedded_file(request, "fips.css");
return;
}
if (strcmp(uri, "sovereign://fips.js") == 0 ||
strncmp(uri, "sovereign://fips.js?", 20) == 0) {
serve_embedded_file(request, "fips.js");
return;
}
if (strcmp(uri, "sovereign://fips/status") == 0 ||
strncmp(uri, "sovereign://fips/status?", 25) == 0) {
handle_fips_status_json(request);
return;
}
if (strcmp(uri, "sovereign://fips/peers") == 0 ||
strncmp(uri, "sovereign://fips/peers?", 24) == 0) {
handle_fips_peers_json(request);
return;
}
if (strncmp(uri, "sovereign://fips/connect?", 24) == 0) {
handle_fips_connect(request, uri + 24);
return;
}
if (strncmp(uri, "sovereign://fips/disconnect?", 27) == 0) {
handle_fips_disconnect(request, uri + 27);
return;
}
if (strcmp(uri, "sovereign://fips/restart") == 0 ||
strncmp(uri, "sovereign://fips/restart?", 26) == 0) {
handle_fips_restart(request);
return;
}
```
### 4. Create `www/fips.html`
The HTML page. Links `fips.css` and `fips.js`. Structure:
- Status section (populated by JS from `sovereign://fips/status`)
- Peers table (populated by JS from `sovereign://fips/peers`)
- Add peer form (npub, address, transport dropdown, Connect button)
- Tor status section (read-only, from the same status JSON)
- Refresh button (re-fetches status + peers)
Follow the style of `www/settings.html` / `www/bookmarks.html` — use
`sovereign-base.css` for theme variables, monospace font, dark theme.
### 5. Create `www/fips.css`
Page-specific styles. Import the base theme:
```css
@import "sovereign://sovereign-base.css";
```
Style the status cards, peers table, add-peer form. Match the existing
sovereign:// page aesthetic.
### 6. Create `www/fips.js`
The page logic:
```javascript
/* On load: fetch status + peers, populate DOM.
* Refresh button: re-fetch.
* Connect form: POST to sovereign://fips/connect, then refresh peers.
* Disconnect button: GET sovereign://fips/disconnect?npub=..., then refresh.
* Restart button: GET sovereign://fips/restart, then poll status.
* Auto-refresh: poll sovereign://fips/status every 5s (like the poll cb).
*/
```
Use the `sovereignGet()` helper (XHR wrapper) that the other pages use.
Do NOT use `fetch()` — it doesn't work with `sovereign://` in WebKit.
### 7. Add a link to the FIPS page from the settings page
In `www/settings.html` (or `www/settings.js`), add a link:
`<a href="sovereign://fips">FIPS Mesh Network</a>` in a "Network"
section. This makes the page discoverable.
### 8. No Makefile changes needed
The `www/` files are auto-embedded by `embed_web_files.sh` (it globs
all `*.html`, `*.css`, `*.js` in `www/`). Adding `www/fips.*` files is
sufficient — no Makefile edit required.
---
## Files
```
www/
├── fips.html # FIPS management page (new)
├── fips.css # Page styles (new)
├── fips.js # Page logic (new)
src/
├── nostr_bridge.c # Add FIPS route handlers + routing
├── fips_control.c # Add fips_control_disconnect_peer()
├── fips_control.h # Add disconnect_peer() declaration
```
---
## Implementation Phases
### Phase 1: Status page (read-only)
1. Add `handle_fips_status_json()` to `nostr_bridge.c`
2. Add `sovereign://fips` / `fips.css` / `fips.js` / `fips/status` routes
3. Create `www/fips.html` / `fips.css` / `fips.js` with status display
4. Show FIPS state, node npub, TUN, peer count, tree state, ownership
5. Show Tor status (read-only)
6. Auto-refresh every 5s
7. Add link from settings page
### Phase 2: Peer management (interactive)
1. Add `handle_fips_peers_json()` — call `fips_control_show_peers()`
2. Add `handle_fips_connect()` — call `fips_control_connect_peer()`
3. Add `fips_control_disconnect_peer()` to `fips_control.c`
4. Add `handle_fips_disconnect()` — call `fips_control_disconnect_peer()`
5. Add peers table + add-peer form + disconnect buttons to the page
6. Test: connect to a known FIPS peer, verify it appears in the list,
verify `.fips` URLs to that peer resolve in the browser
### Phase 3: Service control
1. Add `handle_fips_restart()` — call `net_service_restart(NET_SERVICE_FIPS)`
2. Add restart button to the page
3. Poll status during restart (state transitions: READY → STOPPING →
DISCOVERING → STARTING → READY)
4. Show error messages when state is FAILED
### Phase 4: Polish
1. Copy-to-clipboard for node npub
2. QR code for node npub (reuse `sovereign://qr?text=...`)
3. Bootstrap peer suggestions (if FIPS publishes a test mesh peer list)
4. Transport auto-detect (try udp, then tcp)
5. Address book — save known peers in SQLite for re-connecting after
restart
---
## Testing
1. Build and start browser
2. Navigate to `sovereign://fips`
3. Verify FIPS status shows correctly (state, npub, TUN, peer count)
4. Verify Tor status shows correctly
5. Verify auto-refresh updates peer count if peers change
6. If FIPS is not running:
- Verify status shows "disabled" or "failed" with error message
- Verify peers list is empty with "FIPS not ready" message
7. Add a peer:
- Enter a known FIPS peer's npub + address + transport
- Click Connect
- Verify peer appears in the peers list
- Verify `http://<peer-npub>.fips/` loads in a tab
8. Disconnect a peer:
- Click the ✕ next to a peer
- Verify peer disappears from the list
9. Restart FIPS:
- Click Restart Service
- Verify state transitions through stopping → starting → ready
- Verify peers reconnect (if persistent) or need re-adding
---
## Open Questions
1. **FIPS disconnect command name** — is it `disconnect`,
`disconnect_peer`, or `remove_peer`? Need to check the FIPS daemon's
control protocol docs or `fipsctl` source. The `connect` command is
confirmed (used in `fips_control_connect_peer`), but `disconnect` is
assumed.
2. **Peer persistence** — when the browser restarts the managed FIPS
daemon, do peers persist (from `fips.yaml` or the daemon's state), or
does the user need to re-add them every time? If non-persistent,
Phase 4's "address book" (saving peers in SQLite) becomes more
important.
3. **FIPS daemon control protocol docs** — the `show_peers` response
format is passed through as-is. We should verify the field names
(`npub`, `address`, `transport`, `state`) match what the JS expects.
If the format differs, the JS can adapt, but it's better to know
upfront.

255
plans/fips-network-view.md Normal file
View File

@@ -0,0 +1,255 @@
# FIPS Network View — `sovereign://fips/network`
## Goal
Extend the FIPS management page with a "Network" view that shows the
**entire mesh** as the local node knows it — not just direct peers, but
every node the daemon has discovered via bloom-filter propagation and
Nostr discovery. This answers "what can you see on FIPS?" and "can you
get a view of the entire network?"
## What the FIPS daemon exposes
The FIPS control protocol (verified in `~/lt/fips/src/control/`) has
these read-only commands relevant to a network view:
| Command | What it returns | Network-view use |
|---------|----------------|------------------|
| `show_status` | `estimated_mesh_size` (bloom-union cardinality), `peer_count`, `tree_depth`, `is_root`, `root` | Header summary: "Mesh: ~N nodes, depth D" |
| `show_peers` | Direct authenticated peers with `npub`, `display_name`, `ipv6_addr`, `connectivity`, `is_parent`, `is_child`, `tree_depth`, `transport_addr`, `transport_type`, MMP metrics | The "direct peers" table (already shown) |
| `show_tree` | Spanning-tree state: `root`, `root_npub`, `my_node_addr`, `depth`, `parent`, `parent_display_name`, `peers[]` (each with `node_addr`, `display_name`, `depth`, `coords` path, `distance_to_us`) | Tree topology view — local node + 1-hop neighbors with their tree positions |
| `show_identity_cache` | **All known nodes** in the mesh: `entries[]` with `node_addr`, `npub`, `display_name`, `ipv6_addr`, `last_seen_ms`, `age_ms`, plus `count` and `max_entries` | **The mesh node list** — every node the local daemon has learned about, not just direct peers |
| `show_bloom` | Per-peer bloom-filter state (filter sequence, estimated counts) | Advanced/debug — shows how node-discovery knowledge propagates |
| `show_routing` | Coord-cache entries, pending lookups, retries, forwarding counters | Advanced/debug — routing health |
### Key insight: "the entire network" = identity cache + tree
The FIPS daemon does **not** have a "ask peer X what peers it has"
command. The mesh is observed through two mechanisms:
1. **Bloom filters** — each node broadcasts a bloom filter of node
addresses it knows about. The local node unions these to estimate
total mesh size (`estimated_mesh_size`) and caches individual node
identities it has resolved (`show_identity_cache`).
2. **Spanning tree** — each node declares a parent; the tree
coordinates encode paths to the root. `show_tree` gives the local
node's tree position and its neighbors' positions.
So "the entire network" as seen from this node is:
- **`show_identity_cache`** → the set of known nodes (npub, ipv6, name,
last-seen). This is the node list for the network view.
- **`show_tree`** → the local tree topology (who is my parent, who are
my children, what is the root, what is the depth).
- **`show_status.estimated_mesh_size`** → a single estimated total.
We **cannot** build a full graph of who-connects-to-whom from the local
node's control socket alone — that would require every node to expose
its peer list, and FIPS doesn't do that (bloom filters propagate
*existence*, not *adjacency*). What we can show is:
- A **node list** (identity cache) — "these are all the nodes in the
mesh that this node knows about."
- A **tree view** — "this is my position in the spanning tree, my
parent, my children, the root."
- A **mesh summary** — "estimated N nodes total, tree depth D."
## Design
### Page layout
Add a "Network" tab/section to `sovereign://fips` (or a separate
`sovereign://fips/network` page linked from the main FIPS page).
```
┌─────────────────────────────────────────────────────────────┐
│ FIPS Mesh Network [Refresh] │
│ [Status] [Peers] [Network] [Tree] │
├─────────────────────────────────────────────────────────────┤
│ ┌─ Mesh Summary ────────────────────────────────────────┐ │
│ │ Estimated mesh size: 47 nodes │ │
│ │ Known nodes (cache): 23 │ │
│ │ Tree depth: 4 │ │
│ │ Root: npub1abc... (laantungir) │ │
│ │ Our position: depth 2, parent npub1def... │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Known Nodes (23) ────────────────────────────────────┐ │
│ │ npub1abc... laantungir fd00::1 2s ago ● │ │
│ │ npub1def... fips.v0l.io fd00::2 5s ago ● │ │
│ │ npub1ghi... — fd00::3 1m ago ○ │ │
│ │ ... │ │
│ │ (● = direct peer, ○ = known via discovery only) │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Spanning Tree ───────────────────────────────────────┐ │
│ │ Root: npub1abc... (laantungir) │ │
│ │ Our parent: npub1def... (fips.v0l.io) │ │
│ │ Our children: │ │
│ │ npub1jkl... depth 3 distance 1 │ │
│ │ npub1mno... depth 3 distance 1 │ │
│ │ Tree peers (1-hop): │ │
│ │ npub1abc... depth 0 root distance 2 │ │
│ │ npub1def... depth 1 parent distance 1 │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### New JSON endpoints
| URI | FIPS command | Returns |
|-----|-------------|---------|
| `sovereign://fips/network` | `show_status` + `show_identity_cache` + `show_tree` | Combined JSON for the network view |
| `sovereign://fips/tree` | `show_tree` | Spanning tree JSON (for a dedicated tree tab) |
#### `sovereign://fips/network`
Calls three FIPS commands on a fresh control connection and combines
the results:
```json
{
"summary": {
"estimated_mesh_size": 47,
"known_nodes": 23,
"max_cache_entries": 256,
"tree_depth": 4,
"is_root": false,
"root_npub": "npub1abc...",
"root_display_name": "laantungir",
"our_node_addr": "e3da8293e6ea58807bd02b4749824243",
"our_parent": "npub1def...",
"our_parent_display_name": "fips.v0l.io"
},
"nodes": [
{
"node_addr": "e3da8293...",
"npub": "npub1abc...",
"display_name": "laantungir",
"ipv6_addr": "fde3:da82:...",
"last_seen_ms": 1784309108403,
"age_ms": 2000,
"is_direct_peer": true
}
],
"tree": {
"root": "1b4788b7...",
"root_npub": "npub1abc...",
"is_root": false,
"depth": 2,
"parent": "9d1192e8...",
"parent_display_name": "fips.v0l.io",
"peers": [
{
"node_addr": "1b4788b7...",
"display_name": "laantungir",
"depth": 0,
"distance_to_us": 2
}
]
}
}
```
The `is_direct_peer` flag on each node is computed by cross-referencing
the identity cache entries against the `show_peers` npub list (so the
UI can mark direct peers vs. discovery-only nodes).
### Implementation
#### 1. Add `fips_control_show_tree()` and `fips_control_show_identity_cache()` to `src/fips_control.c` / `.h`
These follow the existing `fips_control_show_peers()` pattern — send
the command, return the `data` field as a JSON string (caller frees):
```c
char *fips_control_show_tree(int fd, char *error, size_t error_size);
char *fips_control_show_identity_cache(int fd, char *error, size_t error_size);
```
#### 2. Add `handle_fips_network_json()` to `src/nostr_bridge.c`
Opens a control connection, calls `show_status`, `show_peers`,
`show_identity_cache`, and `show_tree`, then combines them into the
JSON above. The `is_direct_peer` flag is computed by building a set of
direct-peer npubs from `show_peers` and checking each identity-cache
entry against it.
#### 3. Add routing for `sovereign://fips/network` and `sovereign://fips/tree`
```c
if (strcmp(uri, "sovereign://fips/network") == 0 ||
strncmp(uri, "sovereign://fips/network?", 26) == 0) {
handle_fips_network_json(request);
return;
}
if (strcmp(uri, "sovereign://fips/tree") == 0 ||
strncmp(uri, "sovereign://fips/tree?", 22) == 0) {
handle_fips_tree_json(request);
return;
}
```
#### 4. Add a "Network" tab to `www/fips.html` / `www/fips.js`
Add tab navigation (Status | Peers | Network | Tree) to the page. The
Network tab fetches `sovereign://fips/network` and renders:
- Mesh summary card (estimated size, known nodes, depth, root, our
position).
- Known nodes table (npub, name, ipv6, last-seen, direct-peer badge).
- Spanning tree card (root, parent, children, 1-hop peers with
distances).
The Tree tab fetches `sovereign://fips/tree` and renders a more
detailed tree view (coords paths, declaration sequence).
#### 5. Auto-refresh
The Network tab polls `sovereign://fips/network` every 5s (same as the
status poll).
## Limitations (what we cannot show)
- **Full adjacency graph** — FIPS doesn't expose "peer X's peer list."
Bloom filters propagate node *existence*, not *edges*. To build a
full graph, every node would need to expose its peer list (a future
FIPS protocol extension), or the browser would need to query each
node's control socket individually (not practical across the mesh).
- **Real-time node join/leave** — the identity cache has `last_seen_ms`
and `age_ms`, so we can show staleness, but there's no event stream
for join/leave (would need a FIPS control subscription protocol).
## Files
```
src/
├── fips_control.c # Add show_tree(), show_identity_cache()
├── fips_control.h # Add declarations
├── nostr_bridge.c # Add handle_fips_network_json(), handle_fips_tree_json(), routing
www/
├── fips.html # Add Network + Tree tabs
├── fips.css # Tab + network-table styles
├── fips.js # Network + tree rendering, tab switching
```
## Implementation phases
### Phase 1: Network summary + known nodes
1. Add `fips_control_show_tree()` and `fips_control_show_identity_cache()`
2. Add `handle_fips_network_json()` combining status + peers + identity cache
3. Add `sovereign://fips/network` route
4. Add "Network" tab to the page with mesh summary + known-nodes table
5. Mark direct peers with a badge
### Phase 2: Spanning tree view
1. Add `handle_fips_tree_json()` (pass-through of `show_tree`)
2. Add `sovereign://fips/tree` route
3. Add "Tree" tab with root, parent, children, 1-hop peers, coords paths
4. Optional: simple ASCII/indented tree rendering
### Phase 3: Polish
1. Sort known nodes by last-seen (most recent first)
2. Color-code staleness (green < 10s, yellow < 1m, gray > 5m)
3. Click a node to see its detail (ipv6, age, whether direct peer)
4. Copy-to-clipboard for npub / ipv6
5. Optional: bloom-filter visualization (advanced/debug tab)

View File

@@ -0,0 +1,303 @@
# Global Agent Sidebar — Per-Window, Shared Chat Session
## Problem
The agent chat sidebar is currently **per-tab**. Each [`tab_info_t`](src/tab_manager.h:23)
carries its own `sidebar_webview`, `sidebar_container`, `paned`, and
`sidebar_visible` flag. [`tab_manager_toggle_sidebar()`](src/tab_manager.c:2635)
only affects the active tab. When you switch tabs, the sidebar disappears
because the new tab has `sidebar_visible = FALSE`.
Meanwhile, the chat session is already **global**
[`agent_chat_store.c`](src/agent_chat_store.c:36) has a single
`g_current_session_id` shared across all tabs and windows. This creates a
mismatch: global chat state, per-tab sidebar UI.
## Solution
Move the sidebar from per-tab to **per-window**. Each window gets a
window-level `GtkPaned` (sidebar | notebook). The sidebar persists across
tab switches within a window. Each window's sidebar visibility is
independent. All sidebars show the same global chat conversation.
```
Window 1 (main) Window 2 (auxiliary)
┌──────────┬──────────────────┐ ┌──────────┬──────────────────┐
│ Sidebar │ GtkNotebook │ │ Sidebar │ GtkNotebook │
│ (webview)│ Tab 1 | Tab 2 │ │ (webview)│ Tab 3 │
│ chat │ main webview │ │ chat │ main webview │
│ [input] │ │ │ [input] │ │
└──────────┴──────────────────┘ └──────────┴──────────────────┘
↘ ↙
Same global chat session (g_current_session_id)
```
### Layout change
**Current** (sidebar inside each tab's page):
```
window
└── vbox
└── notebook
└── tab->page (GtkBox: toolbar + paned)
├── toolbar (hamburger + url + bookmark)
└── paned (GtkPaned horizontal)
├── sidebar_container [hidden]
└── webview
```
**New** (sidebar at window level, outside notebook):
```
window
└── vbox
└── window_paned (GtkPaned horizontal) ← NEW
├── sidebar_container [hidden] ← moved here
└── notebook
└── tab->page (GtkBox: toolbar + webview)
├── toolbar
└── webview ← direct child now
```
## Design
### New struct: `window_state_t`
Sidebar state moves out of `tab_info_t` into a per-window struct:
```c
typedef struct {
GtkWindow *window;
GtkWidget *notebook;
GtkWidget *paned; /* window-level: sidebar | notebook */
GtkWidget *sidebar_container; /* GtkBox for sidebar webview */
WebKitWebView *sidebar_webview; /* lazily created on first toggle */
gboolean sidebar_visible;
} window_state_t;
```
The tab manager maintains:
- `static window_state_t g_main_window` — the main window
- `static GArray *g_aux_windows` — dynamic array of auxiliary windows
- `static window_state_t *g_active_ws` — pointer to the focused window's state
### What gets removed from `tab_info_t`
```c
/* REMOVE these from tab_info_t: */
WebKitWebView *sidebar_webview;
GtkWidget *sidebar_container;
GtkWidget *paned;
gboolean sidebar_visible;
```
Each tab's `page` goes back to a simple `GtkBox` (toolbar + webview) with no
paned.
### Resolution helpers
```c
/* Return the window_state_t for the currently focused window. */
static window_state_t *get_active_window_state(void);
/* Return the window_state_t whose notebook matches the given widget. */
static window_state_t *window_state_for_notebook(GtkWidget *notebook);
```
These replace the pattern of "get active tab, read tab->paned / tab->sidebar_*".
## Implementation Steps
### Step 1 — Add `window_state_t` and tracking arrays to `tab_manager.c`
Add the struct, `g_main_window`, `g_aux_windows` (GArray), and `g_active_ws`.
Add helper functions `get_active_window_state()` and
`window_state_for_notebook()`.
No public API change yet — this is internal scaffolding.
### Step 2 — Restructure `tab_manager_init()` for the main window
Currently ([line 2271](src/tab_manager.c:2271)):
- Creates `g_notebook`, adds it to `parent` (the vbox)
New:
- Create `g_notebook` (same as now)
- Create `g_main_window.paned = gtk_paned_new(GTK_ORIENTATION_HORIZONTAL)`
- Create `g_main_window.sidebar_container = gtk_box_new(VERTICAL, 0)`
- Pack sidebar_container as child 1 (FALSE, FALSE), hide it
- Pack notebook as child 2 (TRUE, TRUE)
- Set paned position to 0 (sidebar hidden)
- Add `g_main_window.paned` to `parent` (the vbox) instead of the notebook
- Set `g_main_window.window = window`, `g_main_window.notebook = g_notebook`
- Set `g_active_ws = &g_main_window`
- Keep `g_active_window` / `g_active_notebook` globals for backward compat
(they're used by `get_effective_notebook()` and `tab_manager_get_active()`)
### Step 3 — Simplify `tab_create()` (remove per-tab paned)
Currently ([line 2005](src/tab_manager.c:2005)):
- Creates `tab->paned`, `tab->sidebar_container`, packs webview into paned,
packs paned into `tab->page`
New:
- Pack `tab->webview` directly into `tab->page` (after the toolbar)
- Remove all paned/sidebar_container/sidebar_webview/sidebar_visible setup
- Remove the `gtk_widget_hide(tab->sidebar_container)` calls in
`tab_manager_new_tab()` ([line 2403](src/tab_manager.c:2403)) and
`tab_manager_new_window()` ([line 848](src/tab_manager.c:848))
### Step 4 — Restructure `tab_manager_new_window()` for auxiliary windows
Currently ([line 783](src/tab_manager.c:783)):
- Creates window, creates notebook, adds notebook to window, creates tab
New:
- Create window
- Create notebook
- Create a `window_state_t` for this auxiliary window
- Create `ws->paned`, `ws->sidebar_container`, pack sidebar + notebook into
paned, add paned to window (instead of adding notebook directly)
- Set `ws->window`, `ws->notebook`, `ws->sidebar_visible = FALSE`
- Append `ws` to `g_aux_windows`
- Create the tab (same as now, via `tab_create()` + `tab_array_add()`)
- Wire `focus-in-event` to update `g_active_ws` (in addition to
`g_active_window` / `g_active_notebook`)
- Wire `destroy` to remove from `g_aux_windows` and free the
`window_state_t` (but NOT the sidebar webview if we want to keep it
alive — actually, destroy the whole window_state_t since the window is
going away)
### Step 5 — Update `on_window_focus_in()` to set `g_active_ws`
Currently ([line 760](src/tab_manager.c:760)):
- Sets `g_active_window` and `g_active_notebook`
New:
- Also set `g_active_ws = window_state_for_notebook(notebook)`
### Step 6 — Update `on_aux_window_destroy()` to clean up `g_aux_windows`
Currently ([line 773](src/tab_manager.c:773)):
- Reverts `g_active_window` / `g_active_notebook` to main window
New:
- Find the `window_state_t` in `g_aux_windows` matching the destroyed window
- Remove it from the array and free it
- Revert `g_active_ws` to `&g_main_window`
### Step 7 — Rewrite `sidebar_create_webview()` to be window-scoped
Currently ([line 2601](src/tab_manager.c:2601)):
- Takes a `tab_info_t *tab`, creates webview, packs into `tab->sidebar_container`
New:
- Takes a `window_state_t *ws`, creates webview, packs into
`ws->sidebar_container`
- Same webview settings, nostr_inject_setup, load `AGENT_CHAT_URL_STR`
### Step 8 — Rewrite `tab_manager_toggle_sidebar()` to be window-scoped
Currently ([line 2635](src/tab_manager.c:2635)):
- Gets active tab, toggles `tab->paned` / `tab->sidebar_container` /
`tab->sidebar_visible`
New:
- `window_state_t *ws = get_active_window_state()`
- If `ws->sidebar_visible`: set paned position to 0, hide sidebar_container,
set `sidebar_visible = FALSE`
- Else: lazily create sidebar webview if NULL, set paned position to
`SIDEBAR_DEFAULT_WIDTH`, show sidebar_container, set `sidebar_visible = TRUE`
### Step 9 — Update `tab_manager_sidebar_visible()`
Currently ([line 2666](src/tab_manager.c:2666)):
- Returns `tab->sidebar_visible` for the active tab
New:
- `window_state_t *ws = get_active_window_state()`
- Return `ws ? ws->sidebar_visible : FALSE`
### Step 10 — Update `tab_manager.h`
Remove from `tab_info_t`:
```c
WebKitWebView *sidebar_webview;
GtkWidget *sidebar_container;
GtkWidget *paned;
gboolean sidebar_visible;
```
The public API (`tab_manager_toggle_sidebar`, `tab_manager_sidebar_visible`,
`tab_manager_get_main_webview`) stays the same — signatures unchanged.
### Step 11 — Verify `tab_manager_get_main_webview()` still works
Currently ([line 2659](src/tab_manager.c:2659)):
- Gets active tab, returns `tab->webview`
This still works — the sidebar is now outside the notebook, so
`tab_manager_get_active()` (which resolves by notebook page widget) will
never return the sidebar. No change needed, but verify after the refactor.
### Step 12 — Build and test
```bash
make clean && make
./browser.sh start --login-method generate
```
Test checklist:
- [ ] Ctrl+Shift+A toggles sidebar in main window
- [ ] Sidebar persists when switching tabs (the core fix)
- [ ] Open a `target="_blank"` link → new window, sidebar hidden
- [ ] Focus new window, Ctrl+Shift+A → sidebar appears in that window
- [ ] Both windows' sidebars show the same chat conversation
- [ ] `;` shortcut in URL bar opens sidebar if hidden, sends message
- [ ] Agent tools (snapshot, click, eval) target the active tab's webview,
not the sidebar
- [ ] Close auxiliary window → no crash, main window still works
- [ ] Close main window → app exits cleanly
## Files to modify
| File | Change |
|------|--------|
| [`src/tab_manager.h`](src/tab_manager.h) | Remove 4 sidebar fields from `tab_info_t` |
| [`src/tab_manager.c`](src/tab_manager.c) | Add `window_state_t` + tracking; restructure `tab_manager_init`, `tab_create`, `tab_manager_new_window`, `on_window_focus_in`, `on_aux_window_destroy`; rewrite `sidebar_create_webview`, `tab_manager_toggle_sidebar`, `tab_manager_sidebar_visible` |
## Files NOT modified
| File | Why |
|------|-----|
| [`src/agent_tools.c`](src/agent_tools.c) | Already uses `tab_manager_get_main_webview()` — unchanged |
| [`src/agent_chat.c`](src/agent_chat.c) | Already uses `tab_manager_sidebar_visible()` / `tab_manager_toggle_sidebar()` — unchanged |
| [`src/main.c`](src/main.c) | Shortcut handler already calls `tab_manager_toggle_sidebar()` — unchanged. The vbox→paned→notebook nesting is handled inside `tab_manager_init()` |
| [`src/shortcuts.c`](src/shortcuts.c) | Shortcut binding unchanged |
| [`www/agents/chat.js`](www/agents/chat.js) | Chat page JS unchanged — it polls `sovereign://agents/messages` regardless of which window's sidebar it's in |
| [`src/agent_chat_store.c`](src/agent_chat_store.c) | Global session unchanged |
## Risks and mitigations
1. **`gtk_widget_show_all()` revealing the sidebar** — The current code has
explicit `gtk_widget_hide(tab->sidebar_container)` calls after
`show_all` in `tab_manager_new_tab` and `tab_manager_new_window`. With
the sidebar at window level, `show_all` on the window will reveal it.
Mitigation: call `gtk_widget_hide(ws->sidebar_container)` after
`gtk_widget_show_all(window)` in both `tab_manager_init` and
`tab_manager_new_window`.
2. **Paned position reset on window resize** — GtkPaned may reset position
on certain resize events. Mitigation: use `gtk_paned_set_position()` on
show, and test resizing behavior.
3. **Auxiliary window sidebar webview lifecycle** — When an aux window is
destroyed, its sidebar webview must be destroyed too (it's a child of
the window). This happens automatically via GTK container destruction,
but verify no dangling pointers in `g_aux_windows`.
4. **`g_active_ws` stale pointer** — If an aux window is destroyed while
it's the active window, `on_aux_window_destroy` must reset
`g_active_ws = &g_main_window`. Same pattern as the existing
`g_active_window` / `g_active_notebook` reset.

View File

@@ -0,0 +1,672 @@
# Network Services (Tor + FIPS) — Implementation Plan
## Overview
sovereign_browser integrates two network services:
1. **Tor** — SOCKS proxy for Tor-routed transport. Naturally
browser-managed: unprivileged subprocess, no system-level access.
The browser points WebKitGTK's proxy at Tor's SOCKS port.
2. **FIPS** — Mesh networking daemon for Nostr-identity-based
peer-to-peer connectivity. System networking infrastructure: TUN
interface, DNS resolver integration, capabilities, persistent
identity. Best treated as a system service the browser attaches to,
with browser-managed mode added only after privilege and resolver
handling are proven.
Both services can be either **browser-managed** (spawned as child
processes) or **attached** (connected to an already-running system
instance). The browser detects which mode is available and never stops
or reconfigures a service it doesn't own.
```
sovereign_browser (C99/GTK main loop)
├── Tor: managed subprocess OR attached to system Tor
│ └── WebKitGTK SOCKS proxy → tor → Tor network → internet
│ (fail-closed: no direct fallback while Tor mode is on)
├── FIPS: attached to system fips.service OR managed subprocess
│ └── http://<npub>.fips/ → TUN → FIPS mesh → remote node
│ (fips:// is a redirect shorthand, not a custom scheme handler)
└── Settings UI: enable/disable, view status, manage lifecycle
```
### Important: Tor-routed ≠ Tor Browser
WebKit routed through Tor is **not Tor Browser**. It lacks Tor
Browser's anti-fingerprinting, site isolation policy, first-party
isolation, WebRTC hardening, and uniform browser fingerprint. This
feature provides **Tor-routed transport**, not guaranteed anonymity.
The settings UI should make this distinction clear.
---
## Service Ownership Model
Each service has an explicit ownership state:
| State | Description |
|-------|-------------|
| `DISABLED` | User has not enabled this service |
| `DISCOVERING` | Checking for an existing system instance |
| `ATTACHING` | Connecting to an already-running service's control socket |
| `STARTING` | Spawning a managed subprocess |
| `BOOTSTRAPPING` | Subprocess is running but not yet ready (Tor < 100%, FIPS initializing) |
| `READY` | Service is operational |
| `STOPPING` | Graceful shutdown in progress |
| `EXITED` | Service stopped (managed) or disconnected (attached) |
| `FAILED` | Service crashed or failed to start |
**Critical rules:**
- **Attached services are read-only.** The browser may query status and
observe, but must never stop, restart, or reconfigure a service it
didn't start.
- **Managed services are owned.** The browser starts, stops, monitors,
and may reconfigure them.
- **Fail-closed for Tor.** If Tor mode is enabled and Tor is not READY,
network requests are blocked or show a waiting page. Never silently
fall back to direct access.
- **Graceful degradation for FIPS.** If FIPS is not running, `fips://`
and `.fips` URLs show an error. Normal HTTP/HTTPS traffic is
unaffected.
---
## Architecture
### Net Services Module (`net_services.c` / `net_services.h`)
```c
typedef enum {
NET_SERVICE_TOR,
NET_SERVICE_FIPS,
} net_service_type_t;
typedef enum {
SERVICE_DISABLED,
SERVICE_DISCOVERING,
SERVICE_ATTACHING,
SERVICE_STARTING,
SERVICE_BOOTSTRAPPING,
SERVICE_READY,
SERVICE_STOPPING,
SERVICE_EXITED,
SERVICE_FAILED,
} service_state_t;
typedef enum {
OWNERSHIP_NONE, /* not yet determined */
OWNERSHIP_ATTACHED, /* connected to existing system service */
OWNERSHIP_MANAGED, /* spawned and owned by the browser */
} service_ownership_t;
typedef struct {
net_service_type_t type;
service_state_t state;
service_ownership_t ownership;
GPid pid; /* valid only when OWNERSHIP_MANAGED */
char *binary_path;
char *data_dir;
int control_fd; /* socket to control interface */
char *error_msg;
guint child_watch; /* g_child_watch_add source */
guint poll_timer; /* periodic status poll */
/* Service-specific status (updated via control protocol) */
union {
struct {
int bootstrap_progress; /* 0-100 */
char circuit_info[256];
} tor;
struct {
int peer_count;
char node_npub[80]; /* FIPS node's own npub */
char tree_state[32]; /* root/leaf/branch */
gboolean tun_active;
char tun_name[16];
} fips;
} status;
} net_service_t;
```
### API
```c
/* Initialize the net services module (called on startup) */
void net_services_init(void);
/* Enable and start/attach a service */
int net_service_enable(net_service_type_t type);
/* Disable and stop/detach a service */
int net_service_disable(net_service_type_t type);
/* Restart a managed service (no-op for attached) */
int net_service_restart(net_service_type_t type);
/* Get current state and status */
const net_service_t *net_service_get_status(net_service_type_t type);
/* Check if a service is enabled in settings */
gboolean net_service_is_enabled(net_service_type_t type);
/* Check if a service is READY */
gboolean net_service_is_ready(net_service_type_t type);
/* Called on browser exit — stop managed services, detach from attached */
void net_services_shutdown(void);
```
### Subprocess Supervision
For managed children:
- Spawn with `g_spawn_async()` + `G_SPAWN_DO_NOT_REAP_CHILD` + child-watch
- Capture stdout/stderr via pipes log to browser log file
- `g_child_watch_add()` for exit notification
- Graceful stop: send SIGTERM, wait with timeout (5s), then SIGKILL
- Prevent duplicate starts (check state before spawning)
- Use per-instance data directories and control endpoints to avoid
collisions with system instances
- On crash: transition to `FAILED`, notify UI, do not auto-restart
(user must explicitly re-enable)
---
## Tor Integration
### Discovery: Attach vs Manage
On enable, the browser checks:
1. **User-configured attach endpoint** if `tor.attach_socks` is set in
settings, connect to that SOCKS port and verify it's a Tor proxy.
2. **System Tor control socket** check `/run/tor/control` (Unix) or
`127.0.0.1:9051` (TCP). If responsive, attach.
3. **System Tor SOCKS port** check if `127.0.0.1:9050` is a SOCKS
proxy. If so, attach (without control port, status is limited).
4. **Managed mode** if no existing Tor found and `tor.binary_path` is
valid, spawn a private Tor instance.
### Managed Tor Configuration
The browser generates a private torrc under the browser profile:
```
DataDirectory ~/.sovereign_browser/tor/data
SocksPort unix:/home/user/.sovereign_browser/tor/socks.sock
ControlPort unix:/home/user/.sovereign_browser/tor/control.sock
CookieAuthentication 1
CookieAuthFileGroupReadable 1
Log notice file ~/.sovereign_browser/tor/tor.log
AvoidDiskWrites 1
```
**Key choices:**
- **Unix sockets** for SOCKS and control (not TCP ports) avoids port
conflicts and is more secure (filesystem permissions).
- **Private data directory** no collision with system Tor.
- **Cookie authentication** standard, no passwords in config files.
- Data directory mode 0700.
If Unix socket support is unavailable (unlikely on Linux), fall back to
private TCP ports allocated dynamically (bind to port 0, read assigned
port).
### Tor Control Protocol
Connect to the control socket. Authenticate with cookie (read from
`~/.sovereign_browser/tor/control.authcookie`).
Key commands:
```
AUTHENTICATE <hex-cookie>
GETINFO status/bootstrap-phase
→ 250-status/bootstrap-phase=NOTICE BOOTSTRAP PROGRESS=85 TAG=handshake
GETINFO signal/newnym
SIGNAL NEWNYM
→ 250 OK
```
Poll bootstrap progress every 2 seconds. When `PROGRESS=100`, transition
to `READY`.
### Fail-Closed Proxy Configuration
**Before Tor is READY:**
1. Set WebKit proxy to a non-functional placeholder (e.g.,
`socks5://127.0.0.1:1`) to block all external traffic.
2. Show a "Connecting to Tor (85%)" waiting page for any navigation
attempt.
**When Tor is READY:**
```c
WebKitNetworkProxySettings *proxy = webkit_network_proxy_settings_new(
"socks5://unix:/home/user/.sovereign_browser/tor/socks.sock",
ignore_hosts);
/* ignore_hosts must include .fips domains when FIPS is active */
webkit_website_data_manager_set_network_proxy_settings(dm,
WEBKIT_NETWORK_PROXY_MODE_CUSTOM, proxy);
```
**When Tor exits or is disabled:**
- If user explicitly disabled Tor: revert to `WEBKIT_NETWORK_PROXY_MODE_NO_PROXY`.
- If Tor crashed while enabled: keep proxy blocked, show error, do NOT
fall back to direct.
### DNS
Tor SOCKS5 supports hostname resolution WebKitGTK sends the hostname
to the SOCKS proxy, Tor resolves it via the Tor network. No DNS leak
for proxied traffic.
**`.fips` bypass:** When FIPS is active, `.fips` hostnames must bypass
the Tor proxy. `webkit_network_proxy_settings_new()` accepts an
`ignore_hosts` array. Add `*.fips` to this list. **This must be tested
empirically** WebKitGTK's ignore-host matching for custom TLDs needs
verification.
### Tor Settings
```ini
[tor]
enabled=false
mode=auto ; auto|attach|manage
binary_path=tor ; for managed mode
attach_socks= ; for attach mode (e.g. socks5://127.0.0.1:9050)
attach_control= ; for attach mode (e.g. unix:/run/tor/control)
data_dir=~/.sovereign_browser/tor
```
### Tor Status UI
- Toggle: Enable Tor
- Mode: Auto-detected (Attached to system Tor / Managed)
- Status: Disabled / Connecting (85%) / Ready / Failed (error message)
- "New Identity" button (sends `SIGNAL NEWNYM`)
- Warning: "Tor-routed transport. This is not Tor Browser and does not
provide the same fingerprinting protections."
- Advanced: custom entry/exit nodes (future)
---
## FIPS Integration
### Discovery: Attach vs Manage
On enable, the browser checks:
1. **User-configured control socket** if `fips.control_socket` is set
in settings, try connecting.
2. **Default FIPS control socket** check the standard resolution
order:
- `/run/fips/control.sock`
- `$XDG_RUNTIME_DIR/fips/control.sock`
- `/tmp/fips-control.sock`
See FIPS's [`default_control_path()`](../fips/src/config/mod.rs:142).
3. **Managed mode** if no existing FIPS found and `fips.binary_path`
is valid AND the binary has `CAP_NET_ADMIN` (or user has root), spawn
a managed instance.
### Attached FIPS (Production Mode)
Connect to the control socket. Query status:
```json
{"command":"show_status"}
```
Response (per [`control-socket.md`](../fips/docs/reference/control-socket.md:103)):
```json
{
"status": "ok",
"data": {
"version": "0.4.0",
"npub": "npub1...",
"node_addr": "...",
"ipv6_addr": "fd97:...",
"state": "running",
"peer_count": 3,
"tun_state": "active",
"tun_name": "fips0",
...
}
}
```
The browser renders this in the settings UI. It does NOT stop, restart,
or reconfigure the daemon.
### Managed FIPS (Development/Portable Mode)
**Prerequisites:**
- FIPS binary found at `fips.binary_path`
- Binary has `CAP_NET_ADMIN` (check with `getcap`) OR user is root
- `.fips` DNS resolution is available (either system resolver integration
or browser-level DNS override see below)
**Config generation:**
The browser generates a minimal `fips.yaml` matching the actual FIPS
config schema (see [`configuration.md`](../fips/docs/reference/configuration.md:44)):
```yaml
node:
identity:
persistent: true # FIPS manages its own key file
control:
enabled: true
socket_path: ~/.sovereign_browser/fips/control.sock
tun:
device: fips0
dns:
enabled: true
bind_addr: "::1"
port: 5354
transports:
- type: udp
bind_addr: "0.0.0.0:4242"
- type: tcp
listen: "0.0.0.0:4243"
peers: [] # populated from settings or auto-discovery
```
**Identity: FIPS owns a separate persistent identity.**
The browser does NOT write its own nsec into the FIPS config. FIPS
generates and persists its own keypair (via `persistent: true`), as
documented in [`IdentityConfig`](../fips/src/config/mod.rs:404).
Rationale:
- Read-only browser login has no private key to share.
- NIP-46 and n_signer identities keep the private key outside the
browser there is no nsec to write.
- Writing a local nsec to YAML creates a new persistent secret-at-rest
path that bypasses the signer model.
- FIPS's identity is a node identity, not a user identity it doesn't
need to match the browser's Nostr npub.
The settings UI shows both npubs:
- "Browser identity: npub1abc (local key / read-only / n_signer)"
- "FIPS node identity: npub1def (managed by FIPS)"
**Optional future: explicit identity import.** A user could choose to
import a local nsec into FIPS config, behind an explicit warning:
"Writing your private key to FIPS config creates a copy on disk. This
is not recommended for NIP-46 or hardware signer users."
**Shutdown:**
FIPS does not have a control-socket shutdown command. The only mutating
commands are `connect` and `disconnect` (see
[`commands.rs`](../fips/src/control/commands.rs:14)). Stop the daemon
with SIGTERM, which it handles gracefully via
[`foreground_shutdown_signal()`](../fips/src/bin/fips.rs:185).
### `.fips` DNS Resolution
FIPS's DNS responder listens on `[::1]:5354` by default. For `.fips`
names to resolve system-wide, one of these must be true:
1. **FIPS package installed** `fips-dns.service` configures
systemd-resolved to forward `.fips` queries to the daemon. This is
the production path.
2. **Manual resolver configuration** the user has configured
dnsmasq/systemd-resolved to forward `.fips` to `[::1]:5354`.
3. **Browser-level DNS override** the browser intercepts `.fips`
lookups and resolves them by querying FIPS's DNS port directly. This
is more complex and not yet implemented.
For the initial implementation, require that FIPS is installed as a
system package (which sets up resolver integration). Browser-managed
FIPS without resolver integration is a future enhancement.
### `fips://` URL Handling
`fips://` is NOT a custom URI scheme handler. It's a redirect shorthand.
When the user enters `fips://<npub>/path` or clicks such a link:
1. Parse the npub from the URL.
2. Redirect to `http://<npub>.fips/path` (preserving port, path, query,
fragment).
3. WebKit's normal HTTP stack handles the request.
4. The `.fips` DNS resolver translates the hostname to an `fd00::/8`
IPv6 address.
5. The TUN interface routes the traffic through the FIPS mesh.
This reuses FIPS's existing IPv6 adapter and DNS resolver entirely no
custom scheme handler needed.
When FIPS is not running, `.fips` DNS resolution fails and the user
sees a standard "server not found" error.
### FIPS Settings
```ini
[fips]
enabled=false
mode=auto ; auto|attach|manage
binary_path=fips ; for managed mode
control_socket= ; for attach mode (auto-detected if empty)
config_dir=~/.sovereign_browser/fips
```
### FIPS Status UI
- Toggle: Enable FIPS
- Mode: Auto-detected (Attached to system fips / Managed)
- Status: Disabled / Connecting / Ready / Failed
- FIPS node identity: npub1def
- Peers: 3 connected
- Tree state: Root / Leaf / Branch
- TUN: fips0 (active)
- Peer list (expandable): npub, transport, link metrics
- "Connect to peer" input: enter npub + address + transport
(sends `{"command":"connect","params":{...}}`)
---
## Traffic Routing
When Tor is enabled (fail-closed):
| URL | Route |
|-----|-------|
| `http://` / `https://` | Tor SOCKS proxy Tor network internet |
| `http://<npub>.fips/` | Direct (bypasses Tor via ignore_hosts) TUN FIPS mesh |
| `nostr://` / `nostr:` | Tor SOCKS proxy Nostr relays |
| `sovereign://` | Direct (internal, no proxy) |
| `file://` | Direct (local, no proxy) |
| `about:` | Direct (internal, no proxy) |
When Tor is disabled:
| URL | Route |
|-----|-------|
| `http://` / `https://` | Direct (no proxy) |
| `http://<npub>.fips/` | Direct TUN FIPS mesh (if FIPS active) |
| All others | Same as above |
**Proxy scope:** WebKitGTK's proxy settings apply to WebKit's network
stack (HTTP/HTTPS via libsoup). They do NOT automatically proxy:
- C-side relay fetches (`relay_fetch.c`)
- Agent LLM API calls
- NIP-05 verification HTTP requests
- FIPS's own Nostr relay connections
These C-side network calls would need separate proxy configuration
(e.g., setting `SOCKS_PROXY` env var, or configuring libsoup's proxy
directly). This is a known limitation and should be documented. A
future phase could route all browser-originated traffic through Tor.
**FIPS + Tor interaction:** FIPS has its own Tor transport
([`TorConfig`](../fips/src/config/transport.rs:523)) for FIPS's
outbound relay connections. If the browser manages a Tor instance,
FIPS could be configured to use it as its SOCKS proxy. This requires
coordination: the browser's Tor SOCKS socket path must be passed to
FIPS's config. This is a future enhancement, not Phase 1.
---
## Settings UI
A "Network" section in the Settings page (`sovereign://settings`):
```
┌─────────────────────────────────────────────────────────────┐
│ Network Services │
│ │
│ ┌─ Tor ───────────────────────────────────────────────────┐ │
│ │ [✓] Enable Tor-routed transport │ │
│ │ Mode: Attached to system Tor │ │
│ │ Status: Ready │ │
│ │ ⚠ Tor-routed transport is not Tor Browser. It does not │ │
│ │ provide the same fingerprinting protections. │ │
│ │ [New Identity] │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ FIPS Mesh ─────────────────────────────────────────────┐ │
│ │ [✓] Enable FIPS │ │
│ │ Mode: Attached to system fips.service │ │
│ │ Status: Ready │ │
│ │ FIPS node: npub1def…xyz │ │
│ │ Peers: 3 connected │ │
│ │ TUN: fips0 (active) │ │
│ │ [Connect to peer…] │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
---
## Files
```
src/
├── net_services.c # Service ownership, state machine, supervision
├── net_services.h # Public API
├── tor_control.c # Tor control protocol client
├── tor_control.h # Tor control API
├── fips_control.c # FIPS JSON control protocol client
├── fips_control.h # FIPS control API
├── settings.c # Extended: tor/fips settings
├── settings.h # Extended: tor/fips config fields
├── main.c # Extended: init services, shutdown
├── tab_manager.c # Extended: fips:// redirect, fail-closed page
└── nostr_bridge.c # Extended: settings page network section
www/
├── settings.html # Extended: network services UI
├── settings.js # Extended: network services logic
└── settings.css # Extended: network services styling
```
---
## Implementation Phases
### Phase 1: Foundation
1. Create `net_services.c` service ownership state machine, subprocess
supervision (spawn, child-watch, graceful stop, stdout/stderr capture)
2. Define the `net_service_t` struct and state transitions
3. Add Tor/FIPS settings fields to `settings.c` / `settings.h`
4. Create stub `tor_control.c` and `fips_control.c` (connect, send, receive)
5. Wire `net_services_init()` and `net_services_shutdown()` into `main.c`
### Phase 2: Attach-Only Tor Prototype
1. Implement Tor control protocol client (authenticate, bootstrap status)
2. Connect to an existing system Tor (attach mode only)
3. Configure WebKitGTK SOCKS proxy with fail-closed behavior
4. Test `.fips` bypass via ignore_hosts
5. Verify no DNS leaks (check with https://check.torproject.org)
6. Add Tor status UI to settings page
### Phase 3: Managed Tor
1. Generate private torrc (Unix sockets, cookie auth, private data dir)
2. Spawn tor subprocess, capture stdout/stderr
3. Monitor bootstrap progress via control socket
4. Implement graceful stop (SIGTERM, timeout, SIGKILL)
5. Handle crash detection and FAILED state
6. "New Identity" button (SIGNAL NEWNYM)
7. Test: full lifecycle, port conflict avoidance, crash recovery
### Phase 4: Attach-Only FIPS
1. Implement FIPS control protocol client (JSON line protocol)
2. Auto-detect FIPS control socket (standard resolution order)
3. Query `show_status`, `show_peers`, `show_tree`
4. Render FIPS status in settings UI
5. Verify `http://<npub>.fips/` works through TUN
6. Do NOT manage identity, config, or lifecycle
### Phase 5: Managed FIPS
1. Check prerequisites (binary found, CAP_NET_ADMIN, resolver integration)
2. Generate minimal `fips.yaml` (FIPS owns its identity, separate key file)
3. Spawn fips subprocess with private config
4. Monitor via control socket
5. Graceful stop via SIGTERM
6. Show both browser npub and FIPS npub in settings UI
7. Test: full lifecycle, privilege detection, resolver verification
### Phase 6: URL Integration
1. Support `fips://<npub>/path` as redirect to `http://<npub>.fips/path`
2. Preserve port, path, query, fragment
3. Handle `nostr:` link interception (NIP-21) already planned in
[`nostr-uri-scheme.md`](nostr-uri-scheme.md)
4. Show appropriate error when FIPS is not running
### Phase 7: Packaging
1. Debian package: `Depends: tor` (recommended), `Recommends: fips`
2. Version compatibility checks (FIPS control protocol is pre-1.0)
3. Document that FIPS package sets up TUN permissions and resolver
4. No silent binary downloading at runtime
5. Upgrade and uninstall behavior
---
## Open Questions
1. **Should C-side network calls (relay fetch, LLM API, NIP-05) also
route through Tor?** Currently they use libsoup directly, not
WebKit's proxy. This needs separate proxy configuration. Phase 1
scope: WebKit traffic only. Future: route all browser traffic.
2. **Should FIPS use the browser-managed Tor instance for its relay
connections?** FIPS has its own Tor transport config. Coordinating
the browser's Tor SOCKS socket with FIPS's config is a future
enhancement.
3. **What happens when Tor mode is toggled at runtime?** Existing
WebKit connections may need to be closed or re-established. The
proxy change applies to new requests; in-flight requests may
complete on the old path. This needs testing.
4. **Should the browser support multiple Tor instances?** No one Tor
instance per browser. If the user needs multiple, they should use
system Tor with multiple SOCKS ports.
5. **How to verify `.fips` DNS bypass works with WebKitGTK's
ignore_hosts?** Must be tested empirically. If `*.fips` globbing
doesn't work, may need to enumerate known `.fips` hostnames or use
a different bypass mechanism.
6. **Should managed FIPS auto-generate peers or wait for user input?**
Default: no auto-peering. User must explicitly add peers via the
"Connect to peer" UI. Auto-discovery (open policy) is a future
option.

313
plans/nostr-uri-scheme.md Normal file
View File

@@ -0,0 +1,313 @@
# nostr:// URI Scheme + JSON Viewer — Implementation Plan
## Overview
Two complementary features that work together:
1. **JSON Viewer** (Phase 1) — A rich JSON viewer with syntax highlighting
and collapsible tree view, injected into all `application/json`
responses. Useful everywhere, not just for nostr://.
2. **nostr:// URL Handling** (Phase 2) — Smart URL bar parsing for Nostr
bech32 entities. The browser fetches the event(s) from relays and
displays the raw JSON (using the Phase 1 viewer). At the top of the
page, a toolbar shows clickable links to configured external Nostr
clients (helper applications). The browser is NOT a Nostr client —
it shows raw data and lets the user open it in a client of their
choice.
---
## Phase 1: JSON Viewer
### Goal
When the browser loads any `application/json` content (from any URL
scheme — http, https, file, sovereign, nostr), instead of WebKit's
default plain `<pre>` display, inject a rich JSON viewer with:
- **Syntax highlighting** — colored keys, strings, numbers, booleans, null
- **Collapsible tree view** — click to expand/collapse objects and arrays
- **Copy buttons** — copy the raw JSON or a specific path
- **Dark mode** — respects `prefers-color-scheme`
- **Search/filter** — filter keys in large JSON documents
### Architecture
WebKitGTK's built-in JSON display wraps raw JSON in a `<pre>` tag. We
intercept this by injecting a user script that:
1. Detects when the page content type is `application/json`
2. Parses the `<pre>` content as JSON
3. Replaces the `<pre>` with a styled tree view
**Implementation approach:** Use
`webkit_web_view_add_user_content_filter` or a `WebKitUserScript` injected
via `WebKitUserContentManager`. The script runs on all pages but only
activates when `document.contentType === 'application/json'`.
Alternatively, intercept at the `resource-load-started` or
`load-changed` level and inject the viewer script after load.
### Files
```
www/
├── json-viewer/
│ ├── json-viewer.js # Tree view renderer + syntax highlighting
│ └── json-viewer.css # Tree view styles (light + dark)
src/
├── nostr_bridge.c # Extended: serve json-viewer.js/css via sovereign://
├── tab_manager.c # Extended: inject json-viewer script on JSON pages
```
### JSON Viewer JS Design
```javascript
// Pseudo-code for the viewer
(function() {
if (document.contentType !== 'application/json') return;
var raw = document.body.textContent;
var data;
try { data = JSON.parse(raw); } catch(e) { return; } // not valid JSON, leave as-is
var viewer = createTreeView(data);
document.body.innerHTML = '';
document.body.appendChild(viewer);
function createTreeView(obj, path) {
// Recursively create collapsible nodes:
// - Objects: { key: value, ... } with expand/collapse
// - Arrays: [ item, ... ] with expand/collapse
// - Strings: green, with quotes
// - Numbers: blue
// - Booleans: orange
// - Null: gray
// - Nested objects/arrays: collapsible with item count
}
})();
```
### Styling
- Light mode: white background, dark text, colored values
- Dark mode: dark background, light text, adjusted colors
- Monospace font for all JSON content
- Indentation guides (vertical lines for nesting depth)
- Hover highlight on rows
- Expand/collapse arrows (▶ ▼)
### Settings
- `json_viewer_enabled` (default: true) — toggle in Settings dialog
- Can be disabled per-page via a keyboard shortcut (e.g., Ctrl+Shift+J)
---
## Phase 2: nostr:// URL Handling
### Goal
The browser recognizes Nostr bech32 entities in the URL bar, fetches the
event(s) from relays, and displays the raw JSON using the Phase 1 JSON
viewer. At the top of the page, a toolbar shows clickable links to
configured external Nostr clients (helper applications). The browser is
NOT a Nostr client — it shows raw data and lets the user choose to open
it in an external client.
### User Flow
1. User pastes `npub1abc...` or `nostr:npub1abc...` in the URL bar
2. Browser fetches the relevant event(s) from relays
3. Browser displays the raw JSON with the nice tree viewer (Phase 1)
4. At the top of the page, a toolbar shows: "Open in: [Jumble] [Snort] [njump.me]"
5. Clicking a helper app link opens that client's URL for the same entity in a new tab
### URL Bar Smart Parsing
When the user types or pastes something in the URL bar, the browser
should recognize these formats and normalize them:
| Input | Recognized as | Normalized to |
|-------|---------------|---------------|
| `npub1abc...` | NIP-19 npub | `nostr:npub1abc...` |
| `nsec1abc...` | NIP-19 nsec | (rejected — private key, don't navigate) |
| `note1abc...` | NIP-19 note | `nostr:note1abc...` |
| `nevent1abc...` | NIP-19 nevent | `nostr:nevent1abc...` |
| `naddr1abc...` | NIP-19 naddr | `nostr:naddr1abc...` |
| `nprofile1abc...` | NIP-19 nprofile | `nostr:nprofile1abc...` |
| `nrelay1abc...` | NIP-19 nrelay | `nostr:nrelay1abc...` |
| `nostr:npub1abc...` | NIP-21 URI | (as-is) |
| `nostr://npub1abc...` | Our scheme | (as-is) |
**NIP-21** defines `nostr:` as the standard prefix for bech32 entities
in links. We support both `nostr:npub1...` (NIP-21 standard) and
`nostr://npub1...` (our scheme variant).
### nostr:// URI Scheme Handler
Register `nostr://` (and handle `nostr:`) as a WebKit URI scheme. When
navigated to:
1. Parse the bech32 entity from the URL
2. Determine what events to fetch based on entity type:
- `npub`/`nprofile`: kind 0 (metadata), recent kind 1 (notes), kind 3 (contacts), kind 10002 (relays)
- `note`/`nevent`: the specific event by ID
- `naddr`: the event by kind + pubkey + d-tag
- `nrelay`: NIP-11 relay info (HTTP GET)
3. Fetch events from relays (using relay hints from the entity, or user's configured relays)
4. Return the event(s) as JSON with `Content-Type: application/json`
5. The Phase 1 JSON viewer renders it with the tree view
6. The JSON viewer also injects a helper-app toolbar at the top of the page
### Helper App Toolbar
When the JSON viewer detects that the page is a `nostr://` response, it
adds a toolbar above the JSON tree:
```
┌─────────────────────────────────────────────────────────────┐
│ Nostr entity: npub1abc... │ Open in: [Jumble] [Snort] [njump] │
└─────────────────────────────────────────────────────────────┘
```
Each button is a link that opens the helper app's URL for this entity
in a new tab. The toolbar is styled to match the JSON viewer's theme.
### Helper App Configuration
In the Settings dialog, users configure a list of helper applications:
```
Helper applications for Nostr entities:
Name: Jumble URL: https://jumble.social/{entity}
Name: Snort URL: https://snort.social/{entity}
Name: njump.me URL: https://njump.me/{entity}
Name: habla.news URL: https://habla.news/a/{naddr}
[+ Add helper app]
```
The `{entity}` placeholder is replaced with the full bech32 string
(e.g., `npub1abc...`). The `{naddr}` placeholder is replaced with the
naddr string (for article-specific clients). The `{id}` placeholder is
replaced with the hex event ID.
**Default helper apps** (if user hasn't configured):
- njump.me — `https://njump.me/{entity}` (universal, handles all entity types)
- Snort — `https://snort.social/{entity}`
- Jumble — `https://jumble.social/{entity}`
### Settings Storage
```ini
[nostr]
helper_apps=Jumble|https://jumble.social/{entity},Snort|https://snort.social/{entity},njump.me|https://njump.me/{entity}
```
Stored as a comma-separated list of `Name|URL` pairs. Parsed on startup
and passed to the JSON viewer via the `sovereign://` bridge.
### Files
```
src/
├── nostr_url.c # URL parsing + entity detection (new)
├── nostr_url.h # Public API (new)
├── nostr_scheme.c # nostr:// URI scheme handler (new)
├── nostr_scheme.h # Scheme handler API (new)
├── tab_manager.c # Extended: URL bar smart parsing
├── settings.c # Extended: helper app config
├── nostr_bridge.c # Extended: settings page + helper app API
└── relay_fetch.c # Extended: ad-hoc event fetch by entity
www/
├── json-viewer/
│ └── json-viewer.js # Extended: helper app toolbar for nostr:// pages
├── settings.html # Extended: helper app config UI
└── settings.js # Extended: save/load helper app config
```
### URL Bar Integration
In `tab_manager.c`, the `normalize_url()` function (or the URL entry
activate handler) is extended:
```c
/* Before trying to parse as a URL, check if it's a Nostr bech32 entity */
if (nostr_url_detect(input) != NOSTR_ENTITY_NONE) {
char *nostr_url = nostr_url_normalize(input);
/* Navigate to the nostr:// scheme — the scheme handler will
* fetch events and return JSON */
webkit_web_view_load_uri(webview, nostr_url);
g_free(nostr_url);
return;
}
```
### Link Interception
When a page contains `nostr:npub1...` links (NIP-21 format), the
`on_decide_policy` handler intercepts the navigation:
```c
if (strncmp(uri, "nostr:", 6) == 0) {
/* Normalize and navigate via our nostr:// scheme handler */
char *nostr_url = nostr_url_normalize(uri);
webkit_web_view_load_uri(webview, nostr_url);
g_free(nostr_url);
return TRUE; /* ignore the original navigation */
}
```
---
## NIP-19 / NIP-21 Reference
### NIP-19: bech32-encoded entities
| Prefix | Description | Data |
|--------|-------------|------|
| `npub1` | Public key | 32-byte pubkey |
| `nsec1` | Private key | 32-byte privkey |
| `note1` | Event ID | 32-byte event ID |
| `nprofile1` | Profile | TLV: pubkey + relay hints |
| `nevent1` | Event | TLV: event ID + relay hints + author |
| `naddr1` | Address | TLV: kind + pubkey + d-tag + relay hints |
| `nrelay1` | Relay | TLV: relay URL |
### NIP-21: URI scheme
NIP-21 defines `nostr:` as the standard URI prefix:
- `nostr:npub1abc...` — link to a profile
- `nostr:note1abc...` — link to a note
- `nostr:nevent1abc...` — link to an event
- `nostr:naddr1abc...` — link to an addressable event
- `nostr:nprofile1abc...` — link to a profile (with relay hints)
- `nostr:nrelay1abc...` — link to a relay
We support both `nostr:entity` (NIP-21) and `nostr://entity` (our
variant) — they're treated identically.
---
## Implementation Order
### Phase 1: JSON Viewer
1. Create `www/json-viewer/json-viewer.js` — tree view + syntax highlighting
2. Create `www/json-viewer/json-viewer.css` — styling (light + dark)
3. Embed via `embed_web_files.sh`
4. Inject into webviews via `WebKitUserScript` or content filter
5. Add `json_viewer_enabled` setting
6. Test with local .json files, API responses, and sovereign:// endpoints
### Phase 2: nostr:// URL Handling
1. Create `nostr_url.c` — bech32 entity detection and parsing
2. Extend `nip019` in nostr_core_lib to decode TLV entities (nprofile, nevent, naddr, nrelay)
3. Create `nostr_scheme.c` — register `nostr://` URI scheme handler
4. Extend `relay_fetch.c` — ad-hoc event fetch by entity type
5. Add helper app configuration to settings (list of Name|URL pairs)
6. Extend URL bar `normalize_url()` to detect bare bech32 entities
7. Extend `on_decide_policy` to intercept `nostr:` links
8. Extend JSON viewer to show helper app toolbar on `nostr://` pages
9. Add settings UI for configuring helper apps
10. Test with various bech32 entities pasted in URL bar

121
plans/per-user-profiles.md Normal file
View File

@@ -0,0 +1,121 @@
# Per-User Profile Directory System
## Problem
When logging in as a new user (different nsec), the browser:
1. Restores the previous user's tabs (session restore from global SQLite)
2. Shows the previous user's Nostr events on the profile page
3. Shows the previous user's bookmarks, history, and recents
## Solution
Create a per-user profile directory keyed by npub, with each user getting their own SQLite database, session, bookmarks, and history.
## Directory Structure
```
~/.sovereign_browser/
├── identity.json # last used npub (for login dialog default)
├── global.db # global settings (agent server, theme, shortcuts, inspector)
└── profiles/
├── npub1abc.../
│ ├── browser.db # per-user: events, history, session, bookmarks, key_value
│ └── identity.json # this user's nsec/npub
├── npub1def.../
│ ├── browser.db
│ └── identity.json
└── ...
```
## What Stays Global vs Per-User
### Global (in `~/.sovereign_browser/global.db`)
- `agent_server_enabled`, `agent_server_port`, `agent_allowed_origins`, `agent_login_timeout_ms`
- `theme_dark`
- `shortcut.*` (keyboard shortcuts)
- `inspector_x/y/w/h`
- `dev_extras`, `file_access`, `universal_access` (security settings — these are WebKit-level, not per-user)
### Per-User (in `~/.sovereign_browser/profiles/<npub>/browser.db`)
- `restore_session`, `new_tab_url`, `tab_bar_position`, `show_tab_close_buttons`, `middle_click_close`, `ctrl_tab_switch`, `max_tabs`, `tab_drag_reorder`
- `bootstrap_relays`, `search_engine`
- Session (open tabs)
- History
- Bookmarks
- Nostr events (kind 0, 3, 10002)
- key_value table (per-user settings)
## Implementation Steps
### Step 1: Add `db_init_with_path(const char *path)` to db.c
Currently `db_init()` hardcodes `~/.sovereign_browser/browser.db`. Add a variant that takes a path. Keep `db_init()` for the global database.
### Step 2: Add profile directory management
New functions in a `profile.c` module (or add to `key_store.c`):
- `profile_get_dir(const char *npub, char *out, size_t out_sz)` — returns `~/.sovereign_browser/profiles/<npub>/`
- `profile_ensure_dir(const char *npub)` — creates the directory if it doesn't exist
- `profile_get_db_path(const char *npub, char *out, size_t out_sz)` — returns `~/.sovereign_browser/profiles/<npub>/browser.db`
- `profile_get_identity_path(const char *npub, char *out, size_t out_sz)` — returns `~/.sovereign_browser/profiles/<npub>/identity.json`
### Step 3: Split settings into global and per-user
In `settings.c`:
- `settings_load_global()` — loads from global.db (agent server, theme, shortcuts, inspector, security)
- `settings_load_user()` — loads from the per-user browser.db (session, tabs, relays, search)
- `settings_save_global()` — saves global settings to global.db
- `settings_save_user()` — saves per-user settings to browser.db
Or simpler: keep one `settings_load()` / `settings_save()` but have it read from the appropriate database based on which keys are global vs per-user.
### Step 4: Change the login flow in main.c
Current flow:
1. `db_init()` — opens global browser.db
2. `settings_load()` — loads all settings from browser.db
3. `shortcuts_load()` — loads shortcuts from browser.db
4. Login (GTK dialog or agent login or CLI)
5. `relay_fetch()` — fetches events from relays
6. `session_restore()` — restores tabs from browser.db
New flow:
1. `db_init_global()` — opens global.db for global settings
2. `settings_load_global()` — loads global settings (agent server, theme, shortcuts, inspector)
3. `shortcuts_load()` — loads shortcuts from global.db
4. Login — get npub
5. `profile_ensure_dir(npub)` — create `~/.sovereign_browser/profiles/<npub>/`
6. `db_init_user(npub)` — close global.db, open `profiles/<npub>/browser.db`
7. `settings_load_user()` — load per-user settings from browser.db
8. `relay_fetch()` — fetch events from relays into per-user browser.db
9. `session_restore()` — restore tabs from per-user browser.db
### Step 5: Migration
On first run with the new system:
- If `~/.sovereign_browser/browser.db` exists and `~/.sovereign_browser/profiles/` doesn't:
- After login, copy `browser.db` to `profiles/<npub>/browser.db`
- Move global settings from browser.db to global.db
- Keep browser.db as backup (or rename to browser.db.old)
### Step 6: Identity persistence
- `~/.sovereign_browser/identity.json` — stores the last-used npub (not nsec)
- `~/.sovereign_browser/profiles/<npub>/identity.json` — stores the nsec/npub for that profile
- On startup, read the last-used npub from global identity.json to pre-fill the login dialog
## Files to Modify
1. `src/db.c` / `src/db.h` — add `db_init_with_path()`, `db_close()`, `db_init_global()`
2. `src/settings.c` / `src/settings.h` — split into global/per-user load/save
3. `src/main.c` — change the startup flow
4. `src/key_store.c` — update identity persistence to per-profile
5. `src/session.c` — no change (already uses db_session_* which will use the per-user db)
6. `src/shortcuts.c` — load from global.db instead of browser.db
7. New: `src/profile.c` / `src/profile.h` — profile directory management
## Risk Assessment
- **Medium risk** — changes the database initialization flow which affects all data access
- **Migration needed** — existing users need their data moved to per-profile dirs
- **Testing needed** — login with different users, verify data isolation

295
plans/processes-window.md Normal file
View File

@@ -0,0 +1,295 @@
# Processes Window — Implementation Plan
## Goal
A `sovereign://processes` internal page that lets the user pinpoint which
open tab is consuming CPU and dig into *what that page is doing*. Primary
use case: diagnosing `~/lt/client`, which currently burns CPU across many
tabs.
## The core technical reality
The codebase deliberately **shares one WebProcess across tabs** — every
new tab/webview is created with `webkit_web_view_new_with_related_view()`
(see [`tab_manager.c`](../src/tab_manager.c:135) and the call sites at
lines 1106, 1228, 2657, 3602, 3624). This was done to avoid a
`std::optional<WindowFeatures>` assertion crash in WebKitGTK.
Consequence: process-level CPU% from `/proc` tells you which *WebProcess*
is hot, but **not which tab within it**. If 10 `~/lt/client` tabs share
one WebProcess at 90% CPU, `/proc` alone cannot attribute the load.
The plan therefore uses **two layers**:
1. **Layer 1 — Process view** (from `/proc`): main, WebKitWebProcess(es)
with their hosted tabs listed, WebKitNetworkProcess, Tor, FIPS. Catches
the single-tab-per-process case and shows overall footprint.
2. **Layer 2 — Per-tab page probe** (injected JS in every webview): this
is what actually pinpoints the offending tab *within* a shared
WebProcess and tells you what it is doing.
## Architecture
```mermaid
flowchart LR
subgraph BrowserProcess[sovereign_browser main PID]
ProcInfo[process_info.c reads /proc]
Bridge[nostr_bridge.c sovereign:// routes]
AgentServer[agent_server / MCP]
end
subgraph WebProcess[WebKitWebProcess pid N - shared by many tabs]
ProbeA[perf-probe.js in tab A]
ProbeB[perf-probe.js in tab B]
ProbeC[perf-probe.js in tab C]
end
ProbeA -->|sovereign://processes/probe-report POST| Bridge
ProbeB -->|sovereign://processes/probe-report POST| Bridge
ProbeC -->|sovereign://processes/probe-report POST| Bridge
ProcInfo -->|enumerates /proc and webkit_web_view_get_process_id| Bridge
Bridge -->|JSON| UI[www/processes.js renders tables]
Bridge -->|JSON| AgentServer
```
## Layer 1 — Process view (`src/process_info.{c,h}`)
### Process discovery
| Process | How found |
|---|---|
| Main (`sovereign_browser`) | `getpid()` |
| WebKitWebProcess (≥1) | For each tab, `webkit_web_view_get_process_id(tab->webview)` returns the WebProcess PID. Group tabs by PID. |
| WebKitNetworkProcess | Scan `/proc/*/comm` for `WebKitNetworkProcess` whose PPID is the main PID (read `/proc/<pid>/stat` field 4). |
| WebKitGPUProcess / StorageProcess | Same PPID scan, if present. |
| Tor (managed) | `net_service_get_status(NET_SERVICE_TOR)->pid` when `ownership == OWNERSHIP_MANAGED`. |
| FIPS (managed) | `net_service_get_status(NET_SERVICE_FIPS)->pid` when `ownership == OWNERSHIP_MANAGED`. |
### Per-process fields (all from `/proc`, no external deps)
| Field | Source |
|---|---|
| pid | — |
| name | `/proc/<pid>/comm` |
| cmdline | `/proc/<pid>/cmdline` (NUL-split, space-joined) |
| state | `/proc/<pid>/stat` field 3 (R/S/D/Z/T) |
| ppid | `/proc/<pid>/stat` field 4 |
| cpu_percent | delta of (`utime`+`stime`, fields 14+15) between two reads, divided by elapsed wall time × `sysconf(_SC_CLK_TCK)` |
| rss_kb | `/proc/<pid>/status``VmRSS` |
| pss_kb | `/proc/<pid>/smaps_rollup``Pss` (fallback to RSS if unavailable) |
| uss_kb | `/proc/<pid>/smaps_rollup``Private_Clean` + `Private_Dirty` |
| vmpeak_kb | `/proc/<pid>/status``VmPeak` |
| vmswap_kb | `/proc/<pid>/status``VmSwap` |
| threads | count of entries in `/proc/<pid>/task/` |
| uptime_sec | `time(NULL) - (btime + starttime/clk_tck)`; btime from `/proc/stat` |
| io_read_kb | `/proc/<pid>/io``read_bytes` / 1024 |
| io_write_kb | `/proc/<pid>/io``write_bytes` / 1024 |
| fd_count | count of entries in `/proc/<pid>/fd/` |
| ownership | `main` / `webkit-renderer` / `webkit-network` / `webkit-gpu` / `tor` / `fips` |
| service_state | for Tor/FIPS: from `net_service_t.state` (READY/BOOTSTRAPPING/…) |
| hosted_tabs | for renderers: array of `{index, title, url}` from `tab_manager` |
### CPU% computation
Keep a static `GHashTable<pid, prev_sample>` between calls. Each call:
1. Read current `utime+stime` and `time(NULL)`.
2. `cpu% = (cur - prev) / clk_tck / (now_wall - prev_wall) * 100`.
3. Store current as prev.
Caller polls `sovereign://processes/list` every 1s; first call returns
0% (no baseline), second call onward is accurate. Same approach as
`top`/`htop`.
### API
```c
/* src/process_info.h */
cJSON *process_info_get_processes_json(void); /* Layer 1 table */
cJSON *process_info_get_tabs_json(void); /* tab→WebProcess mapping + last probe */
cJSON *process_info_get_tab_probe_json(int tab_index); /* drill-down for one tab */
void process_info_record_probe(int tab_index, cJSON *probe); /* called from probe-report route */
```
## Layer 2 — Per-tab page probe (`www/js/perf-probe.js`)
Injected into every webview via `WebKitUserContentManager` (same pattern
as the existing `window.nostr` injection in
[`nostr_inject.c`](../src/nostr_inject.c:1)). Runs before page scripts
where possible (using `WebKitUserContentInjectedFramesAllFrames` and
`WebKitUserScriptInjectAtDocumentStart`).
### Signals collected
| Signal | Source | What it tells you |
|---|---|---|
| **long_tasks** | `PerformanceObserver({entryTypes:['longtask']})` | Every main-thread task >50ms with duration + attribution. *Direct answer to "which tab is hogging CPU".* |
| **cpu_busy_percent** | sum of long-task durations in last 1s window | True per-tab CPU-busy %. |
| **fps** | `requestAnimationFrame` cadence over 1s | Constant repainting / animation loops. |
| **timer_count** | patched `setTimeout`/`setInterval` (count active) | Runaway polling loops (common in dashboards like `~/lt/client`). |
| **timer_top** | top 5 intervals by frequency in last 1s | Which polling endpoints/scripts. |
| **net_requests** | `PerformanceObserver({entryTypes:['resource']})` | Fetch/XHR/WebSocket storms, polling endpoints. |
| **net_in_flight** | patched `fetch`/`XMLHttpRequest`/`WebSocket` | Current open requests. |
| **heap_used_mb** | `performance.memory.usedJSHeapSize` (if exposed) | Memory leaks, growing arrays. |
| **event_listener_count** | patched `addEventListener` | Leaking listeners. |
| **worker_count** | patched `Worker` constructor | Background CPU not visible as long tasks. |
| **dom_node_count** | `document.getElementsByTagName('*').length` | Page bloat. |
| **long_task_timeline** | ring buffer of last 30s of long tasks | Drill-down chart. |
| **long_task_top_sources** | aggregate by `entry.name` / script URL | Which script is responsible. |
### Reporting
The probe batches a report every 1s and POSTs it to
`sovereign://processes/probe-report?tab_index=N` with JSON body. The
route handler in `nostr_bridge.c` calls
`process_info_record_probe(N, body)`, which stores the latest probe per
tab index in a static array (sized to `tab_manager_count()`).
The probe must NOT run on `sovereign://` internal pages (settings, fips,
processes itself, agents) — skip injection when the URL scheme is
`sovereign://`. This avoids self-noise and recursion.
### Probe payload shape
```json
{
"tab_index": 3,
"cpu_busy_percent": 42.5,
"fps": 12,
"timer_count": 38,
"timer_top": [{"code":"pollStatus","interval_ms":250,"count":4}],
"net_in_flight": 3,
"net_requests_last_sec": 8,
"net_top_endpoints": [{"url":"wss://.../events","count":4}],
"heap_used_mb": 124.3,
"event_listener_count": 217,
"worker_count": 2,
"dom_node_count": 4821,
"long_tasks_last_sec": [{"duration_ms":180,"name":"setTimeout","attribution":"app.js:420"}],
"long_task_top_sources": [{"name":"app.js","total_ms":1240,"count":8}]
}
```
## Routes (added to `nostr_bridge.c`)
Following the [`sovereign://fips`](../src/nostr_bridge.c:4227) pattern:
| Route | Handler | Returns |
|---|---|---|
| `sovereign://processes` | `serve_embedded_file("processes.html")` | HTML |
| `sovereign://processes.css` | `serve_embedded_file("processes.css")` | CSS |
| `sovereign://processes.js` | `serve_embedded_file("processes.js")` | JS |
| `sovereign://processes/list` | `process_info_get_processes_json()` | JSON, polled 1s |
| `sovereign://processes/tabs` | `process_info_get_tabs_json()` | JSON, polled 1s |
| `sovereign://processes/tab_probe?index=N` | `process_info_get_tab_probe_json(N)` | JSON, on drill-down |
| `sovereign://processes/probe-report?tab_index=N` | `process_info_record_probe(N, body)` | 204 No Content |
## UI (`www/processes.{html,css,js}`)
Follows the [`www/fips.*`](../www/fips.html:1) tabbed pattern.
### Tab 1 — Processes
Sortable table, default sort by `cpu_percent` desc:
| PID | Name | CPU% | RSS | PSS | Threads | Uptime | State | Tabs |
|---|---|---|---|---|---|---|---|---|
| 12345 | sovereign_browser | 2.1 | 180M | 160M | 12 | 1h23m | R | — |
| 12367 | WebKitWebProcess | 88.4 | 820M | 740M | 8 | 1h22m | R | 3,7,9,12,15 |
| 12368 | WebKitNetworkProcess | 1.2 | 90M | 80M | 4 | 1h22m | S | — |
| 12400 | tor | 0.3 | 45M | 40M | 3 | 1h20m | S | — |
Click a WebKitWebProcess row → expands inline to list its hosted tabs
with their Layer-2 `cpu_busy_percent` and `fps` so you can see the
hot tab even within a shared process.
### Tab 2 — Tabs (the primary diagnostic view)
Sortable table, default sort by `cpu_busy_percent` desc:
| # | Title | URL | CPU-busy% | FPS | Timers | Net | Heap | DOM | WPID |
|---|---|---|---|---|---|---|---|---|---|
| 9 | Client — Dashboard | https://client/... | 41.2 | 11 | 38 | 3 | 124M | 4821 | 12367 |
| 7 | Client — Alerts | https://client/... | 0.8 | 60 | 2 | 0 | 22M | 410 | 12367 |
| 3 | sovereign://settings | — | — | — | — | — | — | — | 12367 |
Click a tab row → **drill-down panel** opens below with:
- Long-task timeline (last 30s, sparkline)
- Top long-task sources (script URLs + total ms)
- Active timers list (code label, interval, count)
- Recent network requests (URL, type, count)
- Worker count, event listener count, heap trend
- Action buttons: **Reload tab**, **Suspend tab** (replace with
`about:blank` keeping URL), **Close tab**
### Styling
Reuse `sovereign-base.css` and the card/table styles from
[`www/fips.css`](../www/fips.css:1). Add a heat-bar column background
(green→yellow→red by CPU%) for instant visual scan.
## Menu integration
- Hamburger menu item **"Processes…"** in [`main.c`](../src/main.c:325)
next to "FIPS Mesh…", navigates active tab to `sovereign://processes`
(or opens a new tab if none active) — same pattern as
`open_fips_cb` at line 332.
- Keyboard shortcut `Ctrl+Shift+Esc` (matches Windows Task Manager
convention) registered in [`shortcuts.c`](../src/shortcuts.c:78) as
`open_processes`.
## MCP tools (so an agent can self-diagnose)
Add to [`agent_tools.c`](../src/agent_tools.c:1) /
[`agent_mcp.c`](../src/agent_mcp.c:1):
| Tool | Args | Returns |
|---|---|---|
| `processes.list` | none | Layer 1 JSON (all PIDs + fields) |
| `processes.tabs` | none | Layer 2 JSON (per-tab probes) |
| `processes.tab_probe` | `{tab_index}` | drill-down JSON for one tab |
This lets you ask the agent "find the tab burning CPU in `~/lt/client`
and tell me what it's doing" — the agent calls `processes.tabs`, finds
the high `cpu_busy_percent` tab, calls `processes.tab_probe` for the
drill-down, and reports the offending script/timer/endpoint.
## Build / packaging
- Add `src/process_info.c` to `Makefile` sources.
- Add `www/processes.{html,css,js}` and `www/js/perf-probe.js` to
[`embed_web_files.sh`](../embed_web_files.sh:1) so they are embedded
into the binary via `serve_embedded_file()`.
- Register the perf-probe user script in the shared
`WebKitWebContext`'s `WebKitUserContentManager` at startup (in
`main.c` near where `nostr_inject` is wired), with a URL filter that
excludes `sovereign://*`.
## Verification
1. `make`
2. `./browser.sh restart --login-method generate`
3. Open `sovereign://processes` — verify main + WebKit processes listed
with sane CPU%/RSS.
4. Open several tabs of a CPU-heavy page (e.g.
[`tests/local-site/media.html`](../tests/local-site/media.html:1) or
a synthetic `<script>while(true){}</script>` page) — verify the Tabs
view ranks them by `cpu_busy_percent` and the drill-down shows
long-task sources.
5. Open `~/lt/client` across multiple tabs — verify the hot tab is
identifiable and the drill-down shows which script/timer/endpoint is
responsible.
6. Via MCP: call `processes.tabs` and `processes.tab_probe` and verify
JSON shape matches the contract above.
## Out of scope (future work)
- Forcing process-per-tab via `WebKitWebsitePolicies` /
`WEBKIT_PROCESS_MODEL_MULTIPLE_SECONDARY_PROCESSES` — would give true
per-tab `/proc` attribution but breaks the existing
`related_view` sharing workaround. Tracked separately.
- Historical recording (save probe samples to SQLite for trend charts).
- Per-tab network throttling / CPU limiting (would need WebKit API
support that may not exist).
- Killing/suspending a tab's WebProcess specifically (currently only
per-tab reload/close, which is safe).

View File

@@ -0,0 +1,293 @@
# Selective Tor Routing — .onion Only
## Goal
sovereign_browser should make all address types "just work":
- `http://` / `https://` → direct (clearnet)
- `file://` → direct (local)
- `.onion` → through Tor SOCKS proxy
- `.fips` → through FIPS TUN interface
- `nostr://` → direct to relays (or through Tor if relay is `.onion`)
Tor and FIPS are **enabled by default**. Tor provides a SOCKS proxy that
is used **only** for `.onion` addresses. Regular internet traffic is
never routed through Tor.
This is NOT Tor Browser. It's a browser where `.onion` addresses just
work because Tor is running in the background.
---
## Current State
The current implementation (in `src/net_services.c`) sets WebKit's proxy
to route **all** HTTP/HTTPS through Tor when Tor is enabled (fail-closed
mode). This is the wrong default for sovereign_browser's vision — it
would break clearnet access and isn't what we want.
---
## Design
### Traffic Routing
| URL pattern | Route | How |
|-------------|-------|-----|
| `http://example.com` | Direct | WebKit default networking, no proxy |
| `https://example.com` | Direct | WebKit default networking, no proxy |
| `file:///path` | Direct | Local file access |
| `http://something.onion` | Tor SOCKS | Custom scheme handler |
| `https://something.onion` | Tor SOCKS | Custom scheme handler |
| `http://npub1abc.fips/` | FIPS TUN | DNS resolves to fd00::/8, TUN routes |
| `nostr://npub1...` | Direct to relays | nostr:// scheme handler |
| `sovereign://...` | Direct (internal) | sovereign:// scheme handler |
### WebKit Proxy Configuration
**Always `WEBKIT_NETWORK_PROXY_MODE_NO_PROXY`.** WebKit never uses a
proxy for its default network stack. All proxying is done via custom
URI scheme handlers.
This is a change from the current code, which sets `CUSTOM` mode with
Tor's SOCKS endpoint. We remove that entirely.
### .onion Handling
#### Approach: Intercept + custom scheme
1. **`on_decide_policy` in `tab_manager.c`** intercepts navigation to
`.onion` URLs (both `http://*.onion` and `https://*.onion`).
2. **Rewrite to `tor://` scheme:** The intercepted URL is rewritten:
- `http://abc.onion/path?q=1``tor://abc.onion/path?q=1`
- `https://abc.onion/path``tor://abc.onion/path`
- The scheme handler knows to use Tor's SOCKS proxy.
3. **`tor://` URI scheme handler** (new `src/tor_scheme.c`):
- Registered via `webkit_web_context_register_uri_scheme(ctx, "tor", ...)`
- Parses the `.onion` hostname, port, path, query from the URI
- Fetches the content via Tor's SOCKS proxy using libcurl with
`CURLOPT_PROXY` set to Tor's SOCKS endpoint
- Returns the response (headers + body) to WebKit via
`webkit_uri_scheme_request_finish()`
- Supports both HTTP and HTTPS over .onion (Tor handles the TLS)
4. **URL bar:** When the user types `abc.onion` in the URL bar,
`normalize_url()` should recognize `.onion` and prepend `http://`
(or `https://`), then the `on_decide_policy` interception handles
the rest.
5. **Links on pages:** When a web page has `<a href="http://abc.onion">`,
clicking it triggers `on_decide_policy`, which intercepts and
rewrites to `tor://abc.onion`.
### Why a custom scheme handler instead of WebKit proxy?
WebKitGTK's proxy API (`WebKitNetworkProxySettings`) only supports
"proxy everything, ignore these hosts" — not "proxy only these hosts."
Setting a proxy for everything would route clearnet through Tor, which
we don't want. A custom scheme handler gives us precise control: only
`.onion` traffic goes through Tor.
The downside is that the custom scheme handler must handle HTTP
semantics (GET, POST, headers, cookies, redirects) itself. However,
libcurl handles all of this — we just pass the request to curl with
the SOCKS proxy configured and return the response.
### libcurl for .onion fetches
The `tor://` scheme handler uses libcurl (already a dependency — used
in `nostr_scheme.c` for NIP-11 fetches) with:
```c
curl_easy_setopt(curl, CURLOPT_URL, "http://abc.onion/path?q=1");
curl_easy_setopt(curl, CURLOPT_PROXY, "socks5h://127.0.0.1:9050");
curl_easy_setopt(curl, CURLOPT_PROXYTYPE, CURLPROXY_SOCKS5_HOSTNAME);
```
`socks5h://` means curl sends the hostname to the SOCKS proxy (Tor
resolves it — no DNS leak). `CURLPROXY_SOCKS5_HOSTNAME` ensures the
.onion hostname is resolved by Tor, not locally.
For HTTPS over .onion, curl handles TLS normally — Tor just provides
the transport.
### Request handling
The scheme handler must handle:
1. **GET requests** — fetch URL via curl, return body + content type
2. **POST requests** — WebKit custom scheme handlers don't directly
expose POST bodies. This is a known WebKitGTK limitation. For Phase 1,
POST to .onion may not work (GET is sufficient for most .onion sites
and Nostr relay web interfaces). A future enhancement could use a
WebExtension for POST body access.
3. **Redirects** — curl follows redirects by default
(`CURLOPT_FOLLOWLOCATION`). The final URL is returned.
4. **Headers** — curl captures response headers. We pass through
Content-Type so WebKit renders correctly (HTML, JSON, images, etc.).
5. **Cookies** — .onion cookies are handled by curl's cookie engine
(separate from WebKit's cookie jar). For Phase 1, this is acceptable.
Future: share cookies between WebKit and the Tor handler.
6. **Timeouts** — 30 second timeout for .onion (Tor can be slow).
### Async handling
Like `nostr_scheme.c`, the `tor://` handler uses `GTask` to run the
curl fetch on a worker thread and finish the WebKit request on the main
context. This prevents blocking the UI.
### Tor SOCKS endpoint
The Tor SOCKS endpoint is determined by the existing Tor service
management in `net_services.c` / `tor_control.c`:
- **Attached Tor:** use the discovered SOCKS endpoint
(e.g., `socks5://127.0.0.1:9050`)
- **Managed Tor:** use the private Unix socket
(e.g., `socks5://unix:/home/user/.sovereign_browser/tor/socks.sock`)
The `tor://` scheme handler queries `net_service_get_status(NET_SERVICE_TOR)`
to get the current SOCKS endpoint. If Tor is not ready, it returns an
error page.
### FIPS .onion relays
If a Nostr relay URL is `wss://relay.onion`, the C-side relay fetch
code (`relay_fetch.c`) would need to connect through Tor's SOCKS proxy.
This is a future enhancement — the nostr_core_lib relay client would
need SOCKS support. For Phase 1, only WebKit .onion browsing is
supported.
---
## Changes Required
### 1. Change WebKit proxy to always NO_PROXY
In `src/net_services.c`, `net_services_refresh_proxy()`:
- **Remove** the current logic that sets `CUSTOM` proxy mode with Tor's
SOCKS endpoint.
- **Always** set `WEBKIT_NETWORK_PROXY_MODE_NO_PROXY`.
- Tor proxying is now handled entirely by the `tor://` scheme handler,
not by WebKit's proxy settings.
- Keep the function (it's called from various places) but simplify it
to always set NO_PROXY. Or remove the calls and just set NO_PROXY
once at startup.
### 2. Create `src/tor_scheme.c` / `src/tor_scheme.h`
New URI scheme handler for `tor://`:
```c
void tor_scheme_register(WebKitWebContext *ctx);
```
- Registers the `tor` URI scheme
- On request: parse the .onion URL, get Tor SOCKS endpoint from
`net_service_get_status(NET_SERVICE_TOR)`, fetch via libcurl with
SOCKS proxy, return response to WebKit
- Async via `GTask` (same pattern as `nostr_scheme.c`)
- Returns error JSON if Tor is not ready
### 3. Intercept .onion in `on_decide_policy`
In `src/tab_manager.c`, `on_decide_policy()`:
```c
/* Check if the navigation target is a .onion URL */
if (uri && (g_str_has_suffix(host, ".onion"))) {
/* Rewrite to tor:// scheme */
char *tor_url = g_strdup_printf("tor://%s", uri + strlen("http://"));
/* or preserve https:// → tor:// */
webkit_web_view_load_uri(webview, tor_url);
g_free(tor_url);
webkit_policy_decision_ignore(decision);
return TRUE;
}
```
Also intercept in `normalize_url()` so the URL bar shows the original
`http://abc.onion` form (not `tor://abc.onion`).
### 4. Register tor:// scheme in `main.c`
```c
tor_scheme_register(web_ctx);
```
### 5. Enable Tor and FIPS by default
In `src/settings.c`, change defaults:
```c
s->tor_enabled = TRUE; /* was FALSE */
s->fips_enabled = TRUE; /* was FALSE */
```
### 6. Update Makefile
Add `src/tor_scheme.c` to `SRC`.
### 7. Update `net_services_refresh_proxy()`
Simplify to always set NO_PROXY. Remove the fail-closed logic (no
longer needed — clearnet always works, .onion goes through the scheme
handler).
---
## Files
```
src/
├── tor_scheme.c # tor:// URI scheme handler (new)
├── tor_scheme.h # Public API (new)
├── net_services.c # Simplified: always NO_PROXY
├── tab_manager.c # .onion interception in on_decide_policy + normalize_url
├── main.c # Register tor:// scheme
├── settings.c # Tor/FIPS enabled by default
└── Makefile # Add tor_scheme.c
```
---
## Implementation Phases
### Phase 1: .onion browsing via tor:// scheme
1. Create `tor_scheme.c` — curl-based .onion fetcher with SOCKS proxy
2. Register `tor://` scheme in `main.c`
3. Intercept `.onion` in `on_decide_policy` and `normalize_url`
4. Simplify `net_services_refresh_proxy()` to always NO_PROXY
5. Enable Tor and FIPS by default in settings
6. Test: `http://duckduckgogg42tjsool4r5mn3r2onion.onion` loads through Tor
### Phase 2: Polish
1. Handle .onion redirects (curl follows, but URL bar should show final)
2. Error pages for .onion when Tor is not ready
3. Cookie handling for .onion (curl cookie jar)
4. Timeout handling (30s for .onion)
5. Content-type passthrough (HTML, JSON, images, CSS, JS)
### Phase 3: .onion Nostr relays (future)
1. Add SOCKS proxy support to relay fetch code
2. Allow `wss://relay.onion` in relay configuration
3. Route .onion relay connections through Tor
---
## Testing
1. Build and start browser
2. Verify `http://example.com` loads directly (not through Tor)
3. Verify `file:///path` works
4. If Tor binary is available:
- Verify `http://something.onion` loads through Tor
- Verify Tor log shows the .onion connection
5. If Tor binary is not available:
- Verify .onion URLs show "Tor not ready" error
- Verify clearnet still works fine
6. Verify FIPS .fips URLs still work (if FIPS is available)
7. Verify Tor and FIPS are enabled by default on fresh install

View File

@@ -0,0 +1,124 @@
# Service Workers on file:// — Analysis & Options
## The Problem
`navigator.serviceWorker.register()` on a `file://` page rejects with:
> "serviceWorker.register() must be called with a script URL whose protocol is either HTTP or HTTPS"
## Where the Check Lives
The error message is generated **inside WebKit's web process** (the
JavaScript engine / WebCore layer), not in the WebKitGTK C API layer.
The check is in WebKit's `ServiceWorkerContainer::register()` C++ code
(in `Source/WebCore/workers/service/ServiceWorkerContainer.cpp`), which
validates the script URL protocol against a hard-coded list of
`http` and `https`.
This is **not** controllable via:
- `WebKitSettings` (no setting for SW protocol allowlist)
- `WebKitSecurityManager` (only controls local/secure/cors/empty/no-access
scheme classification — not SW registration eligibility)
- `WebKitWebsiteDataManager` (only controls the storage *directory* for SW
registrations, not the protocol gate)
## Options
### Option A: Patch WebKit (fork / rebuild) — HIGH EFFORT
We could fork `webkitgtk` (or maintain a patch set) that removes or
relaxes the protocol check in `ServiceWorkerContainer.cpp`. This is the
only way to make `navigator.serviceWorker.register()` accept `file://`
URLs natively.
**Pros:**
- Fully native — JS code works unchanged
- SW lifecycle (install/activate/fetch events) works as designed
**Cons:**
- Requires maintaining a WebKit fork or patch set against Debian's
`webkit2gtk` package
- Every WebKitGTK update requires re-applying the patch
- Build complexity — WebKit is ~35MB of system lib; rebuilding from
source is heavy
- The SW spec assumes HTTP semantics (scope, update checks, fetch
interception) that don't map cleanly to `file://`
**Verdict:** Too heavy for the benefit. Service Workers on `file://` is a
niche use case.
### Option B: Register file:// as a "secure" scheme — ALREADY DONE, DOESN'T HELP
We already call `webkit_security_manager_register_uri_scheme_as_secure()`
for `file` in `src/main.c:850`. This makes `file://` origins treated as
"potentially trustworthy" for some purposes (like `navigator.geolocation`
or `getUserMedia`), but **does not** bypass the SW registration protocol
check — that check is a separate hard-coded `http/https` test in
WebCore.
### Option C: Use a custom URI scheme instead of file:// — MODERATE EFFORT
Instead of loading local sites via `file:///path/to/index.html`, we could
register a custom scheme (e.g. `local://`) via
`webkit_web_context_register_uri_scheme()` that serves files from disk.
If we also register `local://` as secure via `WebKitSecurityManager`, SW
registration *might* work — but only if WebKit's SW code checks
"potentially trustworthy origin" rather than hard-coding `http/https`.
This needs testing.
**Pros:**
- No WebKit fork needed
- We control the scheme handler entirely
- Could also solve the Cache API issue (Issue #5)
**Cons:**
- Changes the URL model — users see `local://` instead of `file://`
- Relative path resolution may differ
- Still might not work if WebKit's SW check is truly hard-coded to
`http/https` (need to test)
- Significant implementation effort (scheme handler, path mapping,
security registration)
**Verdict:** Worth investigating if SW on local files becomes important.
The first step would be a quick test: register `local://` as secure,
load a page, try `navigator.serviceWorker.register()`.
### Option D: JavaScript shim / polyfill — LOW EFFORT, PARTIAL
We could inject a JS shim that overrides `navigator.serviceWorker.register`
to do something useful on `file://` — e.g., load the SW script as a Web
Worker instead, or implement a minimal fetch interceptor.
**Pros:**
- No WebKit changes
- We already inject JS shims (`window.nostr`)
**Cons:**
- Not a real Service Worker — no `fetch` event interception, no
`install`/`activate` lifecycle, no `clients` API
- Would only provide a subset of SW functionality
- Complex to implement convincingly
**Verdict:** Only useful if someone specifically needs SW-like behavior
on `file://` and is willing to accept a polyfill.
### Option E: Do nothing — RECOMMENDED
Service Workers are designed for HTTP/HTTPS origins. On `file://`:
- **Web Workers** work perfectly (tested ✅) — use those for background
computation
- **Shared Workers** also work perfectly (tested ✅) — use those for
cross-tab/cross-page shared background computation
- **IndexedDB** works perfectly (tested ✅) — use that for offline
storage
- **Cache API** doesn't work (Issue #5) — but local files are already
local, so caching is redundant
- **fetch interception** (the main SW use case) is less relevant when
everything is local
The "deprecated web security" design goal is about **cross-origin
access** (CORS/SOP), which works. Service Workers are a different
concern — they're about **offline web apps and PWA features**, which
are inherently HTTP-oriented concepts.
**Verdict:** Document the limitation and move on. If a specific use case
emerges that needs SW on `file://`, revisit Option C (custom scheme).

View File

@@ -0,0 +1,181 @@
# System Prompt as the Default Sovereign Browser Skill
## Goal
The system prompt on `sovereign://agents` is essentially the default skill for
sovereign_browser. Instead of having a separate "System Prompt" field, we
treat it as an **unsaved skill** that the user can edit, save, and share —
matching how ai.html handles skills.
## Current state
- `sovereign://agents` (config page) has a "System Prompt" textarea saved to
`sovereign_browser.agent.system_prompt` in the `d:user-settings` event.
- `sovereign://agents/chat` has a skills list (kind 31123) with checkboxes.
- The agent loop (`agent_loop.c`) uses the system prompt from settings, OR the
concatenated templates of selected skills (if any skills are selected).
## Desired state
- **Rename "System Prompt" to "Sovereign Browser Skill"** on the config page.
- The system prompt is presented as an **unsaved skill** — a skill that exists
locally but hasn't been published to Nostr yet.
- In the chat page, the "Sovereign Browser Skill" appears in the skills list
as an unsaved skill (with a "Save" button to publish it as kind 31123).
- The user can **edit** the skill (name, description, template, tools) inline,
matching ai.html's `renderSkillsEditor()` pattern.
- The user can **save** the skill to Nostr (publishes kind 31123), making it
available to other apps (client, didactyl).
- The user can **modify** saved skills the same way (if they're the author).
## Design
### On `sovereign://agents` (config page)
Replace the "System Prompt" section with a "Sovereign Browser Skill" section:
```
┌─────────────────────────────────────────────┐
│ Sovereign Browser Skill │
├─────────────────────────────────────────────┤
│ Name: [Sovereign Browser Default ] │
│ Description: [Default agent skill ] │
│ Template: │
│ ┌───────────────────────────────────────┐ │
│ │ You are an AI assistant embedded in │ │
│ │ a web browser. You have access to... │ │
│ └───────────────────────────────────────┘ │
│ Requires tools: [browser, fs, shell ] │
│ │
│ [💾 Save as Skill] (publishes to Nostr) │
└─────────────────────────────────────────────┘
```
- The template is the current system prompt value.
- "Save as Skill" publishes a kind 31123 event with the skill content.
- The skill is saved to `sovereign_browser.agent.system_prompt` locally (as
now) AND optionally published to Nostr as a skill.
### On `sovereign://agents/chat` (chat page)
The skills list shows:
1. **Sovereign Browser Skill** (unsaved) — always at the top, with a
checkbox (selected by default), an edit button, and a "Save" button.
2. **Saved skills** (kind 31123) — fetched from Nostr, with checkboxes,
edit buttons (if user-authored), and delete buttons.
When the user selects the Sovereign Browser Skill, its template is used as
the system prompt (concatenated with any other selected skills, matching the
existing `agent_skills_build_system_prompt()` logic).
### Skill editor (matching ai.html)
When the user clicks "Edit" on a skill (or the Sovereign Browser Skill), an
inline editor expands (like ai.html's `renderSkillsEditor()`):
```
▼ Sovereign Browser Default
┌──────────────────────────────────────────┐
│ Name: [Sovereign Browser Default ] │
│ Description: [Default agent skill ] │
│ Template: │
│ ┌────────────────────────────────────┐ │
│ │ You are an AI assistant embedded...│ │
│ └────────────────────────────────────┘ │
│ Requires tools: [browser, fs, shell ] │
│ │
│ [💾 Save / Update] [Cancel] │
└──────────────────────────────────────────┘
```
- For the Sovereign Browser Skill (unsaved): "Save" publishes it to Nostr as
a new kind 31123 event AND updates the local system prompt.
- For saved skills (user-authored): "Save / Update" re-publishes the kind
31123 event with the edited content.
- For saved skills (not user-authored): "View Only (not your skill)" — no
edit, matching ai.html's `canSave` logic.
## Implementation
### Phase 1 — Rename + restructure the config page
1. **`www/agents/config.html` + `config.js`** — Replace the "System Prompt"
section with a "Sovereign Browser Skill" section with Name, Description,
Template, and Requires Tools fields. Add a "Save as Skill" button.
2. **`src/nostr_bridge.c`** — Update `handle_agents_set()` to handle the new
skill fields (`agent.skill_name`, `agent.skill_description`,
`agent.skill_template`, `agent.skill_requires_tools`). These are saved to
`sovereign_browser.agent` in the `d:user-settings` event (replacing the
old `system_prompt` field).
3. **`src/agent_loop.c`** — Update the system prompt logic: use
`sovereign_browser.agent.skill_template` as the default system prompt
(instead of `system_prompt`). If skills are selected, concatenate their
templates (as now).
### Phase 2 — Show the Sovereign Browser Skill in the chat page
1. **`www/agents/chat.js`** — In `renderSkillList()`, add the Sovereign
Browser Skill at the top of the list as an "unsaved" skill. It has:
- A checkbox (selected by default)
- The skill name (from `sovereign_browser.agent.skill_name`)
- An "Edit" button that opens the inline editor
- A "Save" button that publishes it to Nostr
2. **`src/agent_skills.c`** — Add a function
`agent_skills_get_default()` that returns the Sovereign Browser Skill
from the local settings (not from Nostr). This is used by the chat page
to show the unsaved skill.
3. **`src/nostr_bridge.c`** — Add a `sovereign://agents/skills/default`
endpoint that returns the Sovereign Browser Skill from settings.
### Phase 3 — Skill editor in the chat page
1. **`www/agents/chat.js`** — Implement `renderSkillEditor(skill)` matching
ai.html's `renderSkillsEditor()`:
- Expandable `<details>` for each selected skill
- Name, Description, Template, Requires Tools fields
- "Save / Update" button (for user-authored or unsaved skills)
- "View Only" for skills not authored by the user
2. **`src/nostr_bridge.c`** — Add a `sovereign://agents/skills/update`
endpoint that re-publishes an existing skill (kind 31123) with edited
content. For the Sovereign Browser Skill, this also updates the local
settings.
3. **`src/agent_skills.c`** — Add `agent_skills_update(d_tag, name,
description, content, requires_tools)` that fetches the existing skill
event, updates its content, re-signs, and re-publishes.
### Phase 4 — Wire the default skill into the agent loop
1. **`src/agent_loop.c`** — The system prompt is now:
- If the Sovereign Browser Skill is selected (default): use its template
- If other skills are selected: concatenate their templates
- If no skills selected: use the Sovereign Browser Skill template as
fallback (it's always the default)
2. **`src/agent_skills.c`** — Update `agent_skills_build_system_prompt()` to
always include the Sovereign Browser Skill template (either as the base
prompt, or concatenated with selected skills).
## Files to modify
| File | Change |
|------|--------|
| `www/agents/config.html` | Rename "System Prompt" to "Sovereign Browser Skill", restructure fields |
| `www/agents/config.js` | Handle new skill fields, "Save as Skill" button |
| `www/agents/chat.js` | Show Sovereign Browser Skill in skills list, implement skill editor |
| `www/agents/chat.css` | Style the skill editor (matching ai.html) |
| `src/nostr_bridge.c` | New endpoints: `skills/default`, `skills/update`; update `handle_agents_set()` |
| `src/agent_skills.c` / `.h` | `agent_skills_get_default()`, `agent_skills_update()` |
| `src/agent_loop.c` | Use skill template as system prompt |
| `src/settings.h` / `settings.c` | Rename `agent_llm_system_prompt` to `agent_skill_template`, add `agent_skill_name`, `agent_skill_description`, `agent_skill_requires_tools` |
## Reference
- `~/lt/client/www/ai.html` lines 1454-1540 — `renderSkillsEditor()` pattern
- `~/lt/client/www/ai.html` lines 1534-1538 — "Save / Update Skill" button
- `~/lt/client/www/ai.html` lines 1471 — `canSave` logic (only author can edit)

View File

@@ -0,0 +1,224 @@
# WebKit Website Data Isolation Per Nostr Identity
**Status: IMPLEMENTED (Phase 0 + Phase A, verified end-to-end).**
## Problem
When a user logs in, visits a page, logs out, then logs in as a different
identity and revisits the same page, the second user sees the first user's
data (session cookies, localStorage, cached responses, service workers).
## Root Cause
The browser uses the **default** [`WebKitWebContext`](src/main.c:977) and its
attached [`WebKitWebsiteDataManager`](src/main.c:993) for the entire process
lifetime. That single data manager holds, process-wide:
- HTTP disk + memory cache
- Cookies (the primary leak — session cookies identify you to web pages)
- localStorage / sessionStorage / IndexedDB / WebSQL
- Service worker registrations + offline app cache
- Favicon database ([`webkit_web_context_set_favicon_database_directory(web_ctx, NULL)`](src/main.c:1003))
The logout / identity-switch paths only clear the Nostr signer and do **nothing**
to WebKit's data stores or to the already-loaded tabs:
- [`app_menu_logout_proxy`](src/main.c:186) — frees `g_state.signer`, blanks pubkey, returns.
- [`agent_logout`](src/agent_login.c:485) — `app_clear_signer()` + `nostr_bridge_set_signer(NULL, ...)`.
- [`agent_switch_identity`](src/agent_login.c:492) — frees old signer, logs in new, no tab reload.
Two compounding problems:
1. **Persistent WebKit data** survives the identity switch.
2. **Live tabs** keep user A's DOM/localStorage in memory; wiping the data
manager alone does not clear already-loaded pages.
The existing [`plans/per-user-profiles.md`](plans/per-user-profiles.md) isolates
the SQLite layer (bookmarks, history, session, Nostr events) but does **not**
cover WebKit's own storage — this plan closes that gap.
## Solution (chosen: per-user data manager + clear-on-switch safety net)
### Part A — Per-user `WebKitWebsiteDataManager`
Give each pubkey its own data manager rooted at
`~/.sovereign_browser/profiles/<pubkey_hex>/webkit/` so cookies, cache, storage,
service workers, and favicons persist per-user and never cross-contaminate.
WebKitGTK constraint: a `WebKitWebContext` is bound to **one**
`WebKitWebsiteDataManager` for its lifetime — you cannot swap the data manager
on an existing context. Two viable strategies:
**Strategy 1 (preferred): per-user `WebKitWebContext`.**
Create a fresh `webkit_web_context_new_with_website_data_manager(dm)` for each
login. All tabs/webviews are created from this context. On logout/switch,
destroy the old context (which tears down its webviews) and build a new one.
This is the cleanest isolation and matches how Epiphary/GNOME Web does
per-profile isolation.
**Strategy 2 (fallback): single context, manual clear + per-user dirs.**
Keep the default context but on each login call
`webkit_website_data_manager_clear(dm, WEBKIT_WEBSITE_DATA_ALL, 0, NULL, NULL, NULL)`
then point the cookie/cache/storage dirs at the per-user path. This is fragile
because the context caches some state internally and the favicon DB directory
is set once at context init.
Recommend **Strategy 1**. It requires reworking how the context is created and
how `tab_manager` / `nostr_bridge` / `tor_scheme` / `nostr_scheme` / `net_services`
reference it (they currently call `webkit_web_context_get_default()` or receive
the context at init), but it gives true isolation.
### Part B — Clear-on-switch safety net
Even with per-user data managers, the **live tabs** (and the agent chat sidebar
webview) hold user A's DOM/localStorage in memory. On any identity change:
1. Close all tabs: `tab_manager_close_all()` ([`tab_manager.c:3727`](src/tab_manager.c)).
2. Destroy the agent chat sidebar webview if present.
3. (Strategy 1) Destroy the old `WebKitWebContext` + data manager.
4. Build the new per-user context + data manager.
5. Re-register URI schemes (`sovereign://`, `nostr://`, `tor://`) on the new
context — they are per-context.
6. Re-init `nostr_bridge`, `nostr_scheme`, `tor_scheme`, `net_services` against
the new context.
7. Re-init `tab_manager` against the new context (or expose a
`tab_manager_set_context()`).
8. Restore the new user's session (`session_restore()`) or open the default
new-tab URL.
## Architecture
```mermaid
flowchart TD
A[Login complete - pubkey known] --> B[profile_ensure_dir pubkey]
B --> C[Build per-user WebKitWebsiteDataManager<br/>rooted at profiles/pubkey/webkit]
C --> D[Build per-user WebKitWebContext<br/>new_with_website_data_manager]
D --> E[Register sovereign/nostr/tor schemes on new ctx]
E --> F[Re-init nostr_bridge, net_services, tab_manager on new ctx]
F --> G[session_restore or open new-tab URL]
H[Logout / switch_identity] --> I[tab_manager_close_all]
I --> J[Destroy sidebar webview]
J --> K[Destroy old WebKitWebContext + data manager]
K --> L{switch or logout?}
L -->|switch| A
L -->|logout| M[Show login dialog / wait for agent login]
M --> A
```
## Implementation Steps
### 1. Profile helpers for WebKit data dir
In [`src/profile.c`](src/profile.c) / [`src/profile.h`](src/profile.h) add:
- `profile_get_webkit_dir(const char *pubkey_hex, char *out, size_t out_sz)`
`~/.sovereign_browser/profiles/<pubkey>/webkit/`
- `profile_ensure_webkit_dir(const char *pubkey_hex)` — mkdir -p.
### 2. New module `src/web_context.c` / `src/web_context.h`
Centralize context + data manager construction so login/logout/switch share
one code path. Functions:
- `web_context_build_for_pubkey(const char *pubkey_hex)` → returns a new
`WebKitWebContext*` with a per-user `WebKitWebsiteDataManager` (cookies,
cache, local_storage, indexed_db, websql, offline_app_cache, service_worker
registrations, itp, hsts all pointed at the per-user webkit dir), favicon
DB enabled, TLS errors ignored, security manager configured
(sovereign/tor/file secure+local).
- `web_context_teardown(WebKitWebContext *ctx)` — unrefs context + data
manager, frees per-user state.
- Holds the current context pointer (`g_web_ctx`) so the rest of the codebase
can fetch it without `webkit_web_context_get_default()`.
### 3. Refactor scheme/bridge/service init to be re-runnable
Currently these are one-shot inits tied to the default context. Make them
accept a `WebKitWebContext*` and be safe to call again on a new context:
- [`nostr_bridge_register`](src/nostr_bridge.c) — already takes `ctx`; ensure
it can be called twice (track prior registration, disconnect old handlers).
- [`nostr_scheme_register`](src/nostr_scheme.c) — make it take `ctx` (currently
uses default) and idempotent.
- [`tor_scheme_register`](src/tor_scheme.c) — same.
- [`net_services_init`](src/net_services.c) — currently calls
`webkit_web_context_get_default()` internally ([`net_services.c:69`](src/net_services.c));
pass `ctx` in instead.
### 4. Refactor `tab_manager` to support context swap
[`tab_manager_init`](src/tab_manager.h:46) currently stores the context once.
Add:
- `tab_manager_set_context(WebKitWebContext *ctx)` — updates the stored
context so subsequent `tab_manager_new_tab` calls use the new context.
Existing tabs are already closed (Part B step 1) before this is called.
- Or simpler: tear down and re-init `tab_manager` on each switch. Pick whichever
is less invasive given the static globals in [`tab_manager.c`](src/tab_manager.c).
### 5. Wire the login/logout/switch flows
- [`app_menu_logout_proxy`](src/main.c:186): after clearing the signer, run the
teardown sequence (close all tabs, destroy sidebar, destroy context) then
re-show the login dialog. On successful login, run the build sequence.
- [`agent_logout`](src/agent_login.c:485): same teardown, then leave the
browser in a logged-out state (no context, or a minimal ephemeral context
showing a "logged out" page).
- [`agent_switch_identity`](src/agent_login.c:492): teardown old, build new
with the new pubkey, restore session.
- Initial startup in [`main.c`](src/main.c:974): replace
`webkit_web_context_get_default()` with `web_context_build_for_pubkey()`
after login completes (this means deferring context creation until after
the login dialog — reordering the startup sequence).
### 6. Favicon database per user
[`webkit_web_context_set_favicon_database_directory(web_ctx, NULL)`](src/main.c:1003)
currently uses the default shared location. In `web_context_build_for_pubkey`,
pass the per-user webkit dir (or a `favicons/` subdir) so favicons don't leak
between users.
### 7. Read-only / no-login mode
When running with `--no-login` or read-only, there is no pubkey to key the
data dir on. Use an ephemeral data manager
(`webkit_website_data_manager_new_ephemeral()`) so no data persists at all,
or a shared `~/.sovereign_browser/webkit-anon/` dir. Decide and document.
### 8. Migration
Existing users have data in WebKit's default location
(`~/.cache/sovereign_browser/` or similar). On first login with the new
system, optionally copy the default WebKit data dir into
`profiles/<pubkey>/webkit/` so they keep their cookies/cache. Low priority —
can ship without migration and just start fresh per user.
### 9. Tests
- Manual: log in as user A, visit a site that sets a cookie/localStorage,
log out, log in as user B, visit the same site, confirm user A's data is
gone and user B gets a fresh session.
- Add a test page under [`tests/local-site/`](tests/local-site) that writes
a visible marker to localStorage + a cookie, so this is reproducible.
- Verify `sovereign://`, `nostr://`, `tor://` schemes still work after a
switch (they are re-registered on the new context).
- Verify the agent chat sidebar works after a switch (its webview is rebuilt
on the new context).
## Files to Modify
- New: `src/web_context.c`, `src/web_context.h`
- [`src/profile.c`](src/profile.c) / [`src/profile.h`](src/profile.h) — webkit dir helpers
- [`src/main.c`](src/main.c) — startup ordering, logout proxy, use `web_context_*`
- [`src/agent_login.c`](src/agent_login.c) — `agent_logout`, `agent_switch_identity`
- [`src/tab_manager.c`](src/tab_manager.c) / [`src/tab_manager.h`](src/tab_manager.h) — context swap support, sidebar teardown
- [`src/nostr_bridge.c`](src/nostr_bridge.c) — re-runnable registration
- [`src/nostr_scheme.c`](src/nostr_scheme.c) — accept ctx, idempotent
- [`src/tor_scheme.c`](src/tor_scheme.c) — accept ctx, idempotent
- [`src/net_services.c`](src/net_services.c) — accept ctx instead of default
- [`Makefile`](Makefile) — add `web_context.o`
- New test page: `tests/local-site/identity-leak-test.html`
## Risk Assessment
- **High risk** — changes how the `WebKitWebContext` is created and tears down,
which touches every subsystem that references the context.
- **Startup reordering** — context creation must move to *after* login, which
changes the long-standing flow in [`main.c`](src/main.c).
- **Re-runnable scheme registration** — WebKit may not support unregistering
scheme handlers cleanly; verify that building a fresh context and
re-registering works without leaking the old context.
- **Sidebar webview** — the agent chat sidebar ([`tab_manager_toggle_sidebar`](src/tab_manager.h:198))
keeps a long-lived webview; must be destroyed on context swap or it will
hold a ref to the dead context.
- **Mitigation**: implement Part B (clear-on-switch) first as a standalone
change — it alone fixes the leak even without per-user dirs. Ship that,
then layer Part A (per-user data managers) on top.

49
src/agent_chat.c Normal file
View File

@@ -0,0 +1,49 @@
/*
* agent_chat.c — high-level entry point for the embedded agent chat
*
* Bridges the URL bar to the agent loop and the chat UI page.
*/
#include "agent_chat.h"
#include "agent_loop.h"
#include "agent_chat_store.h"
#include "tab_manager.h"
#include <string.h>
#include <webkit2/webkit2.h>
#define AGENT_CHAT_URL "sovereign://agents/chat"
int agent_chat_route_input(const char *message) {
tab_info_t *tab = tab_manager_get_active();
if (tab == NULL || tab->webview == NULL) {
g_printerr("[agent_chat] no active tab\n");
return -1;
}
/* Sidebar-aware behavior: if the agent sidebar is already visible,
* just send the message (don't navigate away from the current page).
* If the sidebar is not visible, toggle it on first — this opens
* the chat in the sidebar alongside the current web page, then
* sends the message. This lets the user chat about the page they're
* viewing without navigating away from it. */
if (!tab_manager_sidebar_visible()) {
tab_manager_toggle_sidebar();
}
/* If a non-empty message was provided, start the agent loop. The
* sidebar webview polls sovereign://agents/messages and will pick
* up the agent's responses automatically. */
if (message != NULL && message[0] != '\0') {
if (agent_loop_run(message) != 0) {
g_printerr("[agent_chat] agent_loop_run failed\n");
return -1;
}
}
return 0;
}
const char *agent_chat_session_id(void) {
return agent_chat_store_session_id();
}

47
src/agent_chat.h Normal file
View File

@@ -0,0 +1,47 @@
/*
* agent_chat.h — high-level entry point for the embedded agent chat
*
* Bridges the URL bar to the agent loop and the chat UI page. When the
* user types "; <message>" in the URL bar, tab_manager calls
* agent_chat_route_input(), which navigates the active tab to
* sovereign://agents/chat and kicks off the agent loop.
*/
#ifndef AGENT_CHAT_H
#define AGENT_CHAT_H
#ifdef __cplusplus
extern "C" {
#endif
/*
* Route a user message from the URL bar to the embedded agent.
* Called when the user types "; <message>" in the URL bar.
*
* Sidebar-aware behavior:
* 1. If the agent sidebar is not visible, toggle it on (this opens the
* chat page in a narrow sidebar alongside the current web page).
* 2. If message is non-NULL and non-empty, starts the agent loop with
* that message. The sidebar webview polls for messages and shows
* the agent's responses.
* 3. If message is NULL or empty, just opens the sidebar (no message
* sent).
*
* This lets the user chat about the page they're viewing without
* navigating away from it.
*
* Returns 0 on success, -1 on error.
*/
int agent_chat_route_input(const char *message);
/*
* Get the current session ID (for the chat UI to use).
* Returns NULL if no session exists. The string is owned by the chat store.
*/
const char *agent_chat_session_id(void);
#ifdef __cplusplus
}
#endif
#endif /* AGENT_CHAT_H */

177
src/agent_chat_store.c Normal file
View File

@@ -0,0 +1,177 @@
/*
* agent_chat_store.c — thin wrapper over db.c for agent chat sessions
*
* Manages the "current session" concept and provides convenience
* functions for adding messages and loading history in OpenAI format.
*/
#include "agent_chat_store.h"
#include "db.h"
#include <stdio.h>
#include <string.h>
/* ── Internal state ────────────────────────────────────────────────── */
/* Owned by the store; freed on replacement or shutdown. */
static char *g_current_session_id = NULL;
/* ── Helpers ───────────────────────────────────────────────────────── */
/*
* Replace g_current_session_id with a newly-allocated copy of `id`.
* Frees the previous value. Takes ownership of nothing (copies `id`).
* Returns a pointer to the stored string (owned by the store).
*/
static const char *store_session_id(const char *id) {
if (id == NULL) return NULL;
if (g_current_session_id) {
g_free(g_current_session_id);
}
g_current_session_id = g_strdup(id);
return g_current_session_id;
}
/* ── Public API ────────────────────────────────────────────────────── */
const char *agent_chat_store_get_session(void) {
if (g_current_session_id) {
return g_current_session_id;
}
/* Try to load the latest existing session. */
char *latest = db_agent_session_get_latest();
if (latest) {
const char *stored = store_session_id(latest);
g_free(latest);
return stored;
}
/* No sessions exist — create a new one. */
char *id = db_agent_session_create(NULL);
if (id == NULL) {
g_printerr("[agent_chat_store] failed to create new session\n");
return NULL;
}
const char *stored = store_session_id(id);
g_free(id);
return stored;
}
const char *agent_chat_store_new_session(const char *title) {
char *id = db_agent_session_create(title);
if (id == NULL) {
g_printerr("[agent_chat_store] failed to create new session\n");
return NULL;
}
const char *stored = store_session_id(id);
g_free(id);
return stored;
}
const char *agent_chat_store_set_session(const char *session_id) {
if (session_id == NULL) return NULL;
return store_session_id(session_id);
}
int agent_chat_store_add_user_message(const char *content) {
const char *sid = agent_chat_store_get_session();
if (sid == NULL) return -1;
if (content == NULL) return -1;
int row = db_agent_message_add(sid, "user", content, NULL, NULL);
if (row < 0) {
g_printerr("[agent_chat_store] failed to add user message\n");
return -1;
}
return 0;
}
int agent_chat_store_add_assistant_message(const char *content,
const char *tool_calls_json) {
const char *sid = agent_chat_store_get_session();
if (sid == NULL) return -1;
int row = db_agent_message_add(sid, "assistant", content,
tool_calls_json, NULL);
if (row < 0) {
g_printerr("[agent_chat_store] failed to add assistant message\n");
return -1;
}
return 0;
}
int agent_chat_store_add_tool_result(const char *tool_call_id,
const char *result_json) {
const char *sid = agent_chat_store_get_session();
if (sid == NULL) return -1;
if (tool_call_id == NULL || result_json == NULL) return -1;
int row = db_agent_message_add(sid, "tool", result_json, NULL,
tool_call_id);
if (row < 0) {
g_printerr("[agent_chat_store] failed to add tool result\n");
return -1;
}
return 0;
}
cJSON *agent_chat_store_get_messages(void) {
const char *sid = agent_chat_store_get_session();
if (sid == NULL) return NULL;
cJSON *raw = db_agent_message_list(sid);
if (raw == NULL) {
g_printerr("[agent_chat_store] failed to load messages\n");
return NULL;
}
/* Convert to OpenAI-format array. */
cJSON *out = cJSON_CreateArray();
cJSON *msg;
cJSON_ArrayForEach(msg, raw) {
cJSON *role_item = cJSON_GetObjectItemCaseSensitive(msg, "role");
const char *role = (cJSON_IsString(role_item))
? role_item->valuestring : "";
cJSON *content_item = cJSON_GetObjectItemCaseSensitive(msg, "content");
cJSON *tool_calls_item = cJSON_GetObjectItemCaseSensitive(msg, "tool_calls");
cJSON *tool_call_id_item = cJSON_GetObjectItemCaseSensitive(msg, "tool_call_id");
cJSON *obj = cJSON_CreateObject();
cJSON_AddStringToObject(obj, "role", role);
/* content: include only if non-null. */
if (cJSON_IsString(content_item)) {
cJSON_AddStringToObject(obj, "content", content_item->valuestring);
}
/* tool_calls: parse the stored JSON string into an array object. */
if (cJSON_IsString(tool_calls_item) &&
tool_calls_item->valuestring[0] != '\0') {
cJSON *parsed = cJSON_Parse(tool_calls_item->valuestring);
if (parsed) {
cJSON_AddItemToObject(obj, "tool_calls", parsed);
} else {
/* Store the raw string if it isn't valid JSON. */
cJSON_AddStringToObject(obj, "tool_calls",
tool_calls_item->valuestring);
}
}
/* tool_call_id: for role="tool". */
if (cJSON_IsString(tool_call_id_item)) {
cJSON_AddStringToObject(obj, "tool_call_id",
tool_call_id_item->valuestring);
}
cJSON_AddItemToArray(out, obj);
}
cJSON_Delete(raw);
return out;
}
const char *agent_chat_store_session_id(void) {
return g_current_session_id;
}

89
src/agent_chat_store.h Normal file
View File

@@ -0,0 +1,89 @@
/*
* agent_chat_store.h — thin wrapper over db.c for agent chat sessions
*
* Manages the "current session" concept and provides convenience
* functions for adding messages and loading history in OpenAI format.
*
* The store keeps a static g_current_session_id internally. When
* agent_chat_store_get_session() is called and no current session is
* set, it loads the latest session from the database (or creates a new
* one if none exists).
*
* Thread safety: the underlying database uses SQLITE_OPEN_FULLMUTEX, so
* these functions can be called from the background agent thread.
*/
#ifndef AGENT_CHAT_STORE_H
#define AGENT_CHAT_STORE_H
#include <glib.h>
/* cJSON is in the vendored nostr_core_lib */
#include "../nostr_core_lib/cjson/cJSON.h"
#ifdef __cplusplus
extern "C" {
#endif
/*
* Get or create the current chat session. If no session exists, creates
* a new one. Returns the session ID (owned by the store, do not free).
* Returns NULL on error.
*/
const char *agent_chat_store_get_session(void);
/*
* Start a new chat session. Returns the session ID (owned by the store).
* Returns NULL on error.
*/
const char *agent_chat_store_new_session(const char *title);
/*
* Set the current session to an existing session id. The session must
* already exist in the database (e.g. created by db_agent_session_create).
* Returns a pointer to the stored session id (owned by the store), or
* NULL on error.
*/
const char *agent_chat_store_set_session(const char *session_id);
/*
* Add a user message to the current session.
* Returns 0 on success, -1 on error.
*/
int agent_chat_store_add_user_message(const char *content);
/*
* Add an assistant message (with optional tool_calls) to the current session.
* Returns 0 on success, -1 on error.
*/
int agent_chat_store_add_assistant_message(const char *content,
const char *tool_calls_json);
/*
* Add a tool result message to the current session.
* Returns 0 on success, -1 on error.
*/
int agent_chat_store_add_tool_result(const char *tool_call_id,
const char *result_json);
/*
* Load the full message history for the current session as a cJSON array
* (suitable for sending to the LLM API). Each message is in OpenAI format:
* {"role":"user","content":"..."}
* {"role":"assistant","content":"...","tool_calls":[...]}
* {"role":"tool","tool_call_id":"...","content":"..."}
* Returns NULL on error. Caller must cJSON_Delete().
*/
cJSON *agent_chat_store_get_messages(void);
/*
* Get the session ID for the current session (or NULL if none).
* The returned string is owned by the store.
*/
const char *agent_chat_store_session_id(void);
#ifdef __cplusplus
}
#endif
#endif /* AGENT_CHAT_STORE_H */

799
src/agent_conversations.c Normal file
View File

@@ -0,0 +1,799 @@
/*
* agent_conversations.c — Nostr conversation persistence for agent chat
*
* Saves and loads agent chat conversations as NIP-78 (kind 30078) addressable
* events. Each conversation is stored with:
* d-tag: "{uuid}" (no prefix — matches ai.html for cross-app sharing)
* t-tag: "client-ai-chat-v1"
*
* The event content is NIP-44 encrypted (self-to-self) JSON:
* { "schema": 1, "title": "...", "messages": [ {role, content, tool_calls, tool_call_id}, ... ] }
*
* Reuses the NIP-44 encryption + event signing + relay publishing patterns
* from settings_sync.c and bookmarks.c.
*
* See plans/cross-project-agent-sync.md (Workstream 3) for the full design.
*/
#include "agent_conversations.h"
#include "agent_chat_store.h"
#include "db.h"
#include "settings.h"
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <time.h>
#include "nostr_core/nostr_core.h"
#include "../nostr_core_lib/cjson/cJSON.h"
/* ── Global state ───────────────────────────────────────────────────── */
static nostr_signer_t *g_signer = NULL;
static char g_pubkey[65] = {0};
static int g_have_signer = 0;
/* ── Init ───────────────────────────────────────────────────────────── */
void agent_conversations_init(nostr_signer_t *signer,
const char *pubkey_hex) {
g_signer = signer;
g_have_signer = (signer != NULL);
if (pubkey_hex) {
snprintf(g_pubkey, sizeof(g_pubkey), "%s", pubkey_hex);
} else {
g_pubkey[0] = '\0';
}
}
void agent_conversations_set_signer(nostr_signer_t *signer,
const char *pubkey_hex) {
agent_conversations_init(signer, pubkey_hex);
}
/* ── Helpers ────────────────────────────────────────────────────────── */
/* Parse bootstrap relays from settings into an array.
* Returns the count. Fills urls_out (pointers into relay_buf).
* Caller must g_free(relay_buf) after use. (Same pattern as
* settings_sync.c and bookmarks.c.) */
static int parse_relays(char **relay_buf, const char **urls_out, int max) {
const browser_settings_t *s = settings_get();
*relay_buf = g_strdup(s->bootstrap_relays);
int count = 0;
char *saveptr = NULL;
char *line = strtok_r(*relay_buf, "\n\r", &saveptr);
while (line != NULL && count < max) {
while (*line == ' ' || *line == '\t') line++;
if (line[0] != '\0' &&
(strncmp(line, "wss://", 6) == 0 ||
strncmp(line, "ws://", 5) == 0)) {
urls_out[count++] = line;
}
line = strtok_r(NULL, "\n\r", &saveptr);
}
return count;
}
/* Encrypt a plaintext string with NIP-44 (self-to-self).
* Returns a newly allocated string (caller must free), or NULL on error. */
static char *encrypt_content(const char *plaintext) {
if (!g_have_signer || g_pubkey[0] == '\0') return NULL;
char *ciphertext = NULL;
int rc = nostr_signer_nip44_encrypt(g_signer, g_pubkey, plaintext,
&ciphertext);
if (rc != NOSTR_SUCCESS || ciphertext == NULL) {
g_printerr("[agent_conv] NIP-44 encrypt failed: %d\n", rc);
return NULL;
}
return ciphertext;
}
/* Decrypt a NIP-44 ciphertext (self-to-self).
* Returns a newly allocated string (caller must free), or NULL on error. */
static char *decrypt_content(const char *ciphertext) {
if (!g_have_signer || g_pubkey[0] == '\0') return NULL;
if (ciphertext == NULL || ciphertext[0] == '\0') return NULL;
char *plaintext = NULL;
int rc = nostr_signer_nip44_decrypt(g_signer, g_pubkey, ciphertext,
&plaintext);
if (rc != NOSTR_SUCCESS || plaintext == NULL) {
g_printerr("[agent_conv] NIP-44 decrypt failed: %d\n", rc);
return NULL;
}
return plaintext;
}
/* Check whether an event has a tag [name, value]. Returns 1/0. */
static int event_has_tag(const cJSON *event, const char *name,
const char *value) {
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!cJSON_IsArray(tags)) return 0;
cJSON *tag;
cJSON_ArrayForEach(tag, tags) {
if (!cJSON_IsArray(tag)) continue;
cJSON *t0 = cJSON_GetArrayItem(tag, 0);
cJSON *t1 = cJSON_GetArrayItem(tag, 1);
if (cJSON_IsString(t0) && strcmp(t0->valuestring, name) == 0 &&
cJSON_IsString(t1) && strcmp(t1->valuestring, value) == 0) {
return 1;
}
}
return 0;
}
/* Get the value of the first tag with the given name. Returns a pointer
* into the event's tags (or NULL). Not owned by caller. */
static const char *event_tag_value(const cJSON *event, const char *name) {
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!cJSON_IsArray(tags)) return NULL;
cJSON *tag;
cJSON_ArrayForEach(tag, tags) {
if (!cJSON_IsArray(tag)) continue;
cJSON *t0 = cJSON_GetArrayItem(tag, 0);
cJSON *t1 = cJSON_GetArrayItem(tag, 1);
if (cJSON_IsString(t0) && strcmp(t0->valuestring, name) == 0 &&
cJSON_IsString(t1)) {
return t1->valuestring;
}
}
return NULL;
}
/* Build the full d-tag for a conversation id. With the empty prefix
* (AGENT_CONV_D_PREFIX ""), this is just a copy of the conversation id,
* matching ai.html's d:{conversation-id} format. Caller must g_free. */
static char *build_d_tag(const char *conversation_id) {
return g_strdup_printf("%s%s", AGENT_CONV_D_PREFIX, conversation_id);
}
/* Extract the conversation id from a d-tag value. With the empty prefix
* the id is the entire d-tag value. Returns NULL only if d_value is NULL. */
static const char *d_tag_to_id(const char *d_value) {
size_t plen = strlen(AGENT_CONV_D_PREFIX);
if (d_value == NULL) return NULL;
if (strncmp(d_value, AGENT_CONV_D_PREFIX, plen) != 0) return NULL;
return d_value + plen;
}
/* Derive a conversation title from the first user message in a message
* array. Returns a newly allocated string (caller must g_free), or
* g_strdup("New Chat") if no user message is found. */
static char *derive_title(const cJSON *messages) {
if (!cJSON_IsArray(messages)) return g_strdup("New Chat");
cJSON *msg;
cJSON_ArrayForEach(msg, messages) {
cJSON *role = cJSON_GetObjectItemCaseSensitive(msg, "role");
if (cJSON_IsString(role) && strcmp(role->valuestring, "user") == 0) {
cJSON *content = cJSON_GetObjectItemCaseSensitive(msg, "content");
if (cJSON_IsString(content) && content->valuestring[0]) {
/* Truncate to 60 chars, replace newlines with spaces. */
char buf[64];
size_t i = 0;
const char *s = content->valuestring;
while (s[i] && i < 60) {
buf[i] = (s[i] == '\n' || s[i] == '\r') ? ' ' : s[i];
i++;
}
buf[i] = '\0';
/* Trim trailing spaces. */
while (i > 0 && buf[i - 1] == ' ') buf[--i] = '\0';
if (i == 0) return g_strdup("New Chat");
return g_strdup(buf);
}
}
}
return g_strdup("New Chat");
}
/* ── List ───────────────────────────────────────────────────────────── */
cJSON *agent_conversations_list(void) {
cJSON *result = cJSON_CreateArray();
if (g_pubkey[0] == '\0') return result;
/* Fetch all kind 30078 events for the user. We then filter by the
* t-tag. A generous limit covers all conversations. */
cJSON *events = db_get_events(g_pubkey, AGENT_CONV_KIND, 512);
if (events == NULL) return result;
/* Deduplicate by d-tag (conversation id), keeping only the latest
* event (highest created_at) for each conversation. Without this,
* re-saving or renaming a conversation leaves the old event in the
* SQLite cache, producing duplicate list entries with the same id —
* which causes two items to get the "active" highlight in the UI. */
GHashTable *latest = g_hash_table_new_full(g_str_hash, g_str_equal,
g_free, (GDestroyNotify)cJSON_Delete);
int n = cJSON_GetArraySize(events);
for (int i = 0; i < n; i++) {
cJSON *ev = cJSON_GetArrayItem(events, i);
/* Must have the conversation t-tag. */
if (!event_has_tag(ev, "t", AGENT_CONV_T_TAG)) continue;
/* Extract the conversation id from the d-tag. */
const char *d_val = event_tag_value(ev, "d");
const char *conv_id = d_tag_to_id(d_val);
if (conv_id == NULL) continue;
/* Extract created_at. */
cJSON *created = cJSON_GetObjectItemCaseSensitive(ev, "created_at");
long updated = 0;
if (cJSON_IsNumber(created)) updated = (long)created->valuedouble;
/* Check if we already have a newer event for this conversation. */
gpointer prev = g_hash_table_lookup(latest, conv_id);
if (prev != NULL) {
cJSON *prev_created = cJSON_GetObjectItemCaseSensitive(
(cJSON *)prev, "created_at");
long prev_updated = 0;
if (cJSON_IsNumber(prev_created))
prev_updated = (long)prev_created->valuedouble;
if (prev_updated >= updated) continue; /* keep the newer one */
}
/* Store a duplicate of this event as the latest for this id. */
g_hash_table_replace(latest, g_strdup(conv_id),
cJSON_Duplicate(ev, 1));
}
/* Build the result array from the deduplicated hash table. */
GHashTableIter iter;
gpointer key, value;
g_hash_table_iter_init(&iter, latest);
while (g_hash_table_iter_next(&iter, &key, &value)) {
const char *conv_id = (const char *)key;
cJSON *ev = (cJSON *)value;
cJSON *created = cJSON_GetObjectItemCaseSensitive(ev, "created_at");
long updated = 0;
if (cJSON_IsNumber(created)) updated = (long)created->valuedouble;
/* Try to decrypt the content to get the title. If decryption
* fails (e.g. no signer in read-only mode), fall back to the
* conversation id. */
char *title = NULL;
char *plaintext = decrypt_content(
cJSON_GetObjectItemCaseSensitive(ev, "content") &&
cJSON_IsString(cJSON_GetObjectItemCaseSensitive(ev, "content"))
? cJSON_GetObjectItemCaseSensitive(ev, "content")->valuestring
: "");
if (plaintext) {
cJSON *payload = cJSON_Parse(plaintext);
free(plaintext);
if (payload) {
cJSON *t = cJSON_GetObjectItemCaseSensitive(payload, "title");
if (cJSON_IsString(t) && t->valuestring[0]) {
title = g_strdup(t->valuestring);
}
cJSON_Delete(payload);
}
}
if (title == NULL) title = g_strdup(conv_id);
cJSON *summary = cJSON_CreateObject();
cJSON_AddStringToObject(summary, "id", conv_id);
cJSON_AddStringToObject(summary, "title", title);
cJSON_AddNumberToObject(summary, "updated_at", (double)updated);
cJSON_AddItemToArray(result, summary);
g_free(title);
}
g_hash_table_destroy(latest);
cJSON_Delete(events);
return result;
}
/* ── Save ───────────────────────────────────────────────────────────── */
char *agent_conversations_save(const char *conversation_id,
const char *title) {
if (!g_have_signer || g_pubkey[0] == '\0') {
g_printerr("[agent_conv] No signer, cannot save\n");
return NULL;
}
/* Determine the conversation id. Generate one if not provided. */
char *conv_id = NULL;
if (conversation_id && conversation_id[0]) {
conv_id = g_strdup(conversation_id);
} else {
conv_id = g_uuid_string_random();
if (conv_id == NULL) {
g_printerr("[agent_conv] Failed to generate conversation id\n");
return NULL;
}
}
/* Fetch the current session's messages. */
cJSON *messages = agent_chat_store_get_messages();
if (messages == NULL) {
g_printerr("[agent_conv] Failed to get messages for save\n");
g_free(conv_id);
return NULL;
}
/* Determine the title:
* - If a title is provided, use it.
* - If no title but the conversation already exists on Nostr,
* preserve the existing title (don't overwrite a renamed title).
* - If no title and no existing conversation, derive from the
* first user message. */
char *use_title = NULL;
if (title && title[0]) {
use_title = g_strdup(title);
} else {
/* Try to fetch the existing event's title. */
char *existing_title = NULL;
char *d_tag_val = build_d_tag(conv_id);
if (d_tag_val && g_pubkey[0]) {
cJSON *events = db_get_events(g_pubkey, AGENT_CONV_KIND, 512);
if (events) {
int n = cJSON_GetArraySize(events);
for (int i = 0; i < n; i++) {
cJSON *ev = cJSON_GetArrayItem(events, i);
if (event_has_tag(ev, "d", d_tag_val)) {
cJSON *content_item = cJSON_GetObjectItemCaseSensitive(ev, "content");
if (cJSON_IsString(content_item)) {
char *plaintext = decrypt_content(content_item->valuestring);
if (plaintext) {
cJSON *payload = cJSON_Parse(plaintext);
if (payload) {
cJSON *t = cJSON_GetObjectItemCaseSensitive(payload, "title");
if (cJSON_IsString(t) && t->valuestring[0]) {
existing_title = g_strdup(t->valuestring);
}
cJSON_Delete(payload);
}
free(plaintext);
}
}
break;
}
}
cJSON_Delete(events);
}
}
g_free(d_tag_val);
if (existing_title) {
use_title = existing_title;
} else {
use_title = derive_title(messages);
}
}
/* Build the payload: { schema: 1, title: "...", messages: [...] }.
* Only save user and assistant messages to Nostr — tool messages
* (tool call results) can be very large (e.g. entire web pages)
* and would bloat the kind 30078 event. They're kept in the local
* SQLite session for the agent loop's context but not synced. */
cJSON *payload = cJSON_CreateObject();
cJSON_AddNumberToObject(payload, "schema", 1);
cJSON_AddStringToObject(payload, "title", use_title);
cJSON *saved_msgs = cJSON_CreateArray();
cJSON *msg;
cJSON_ArrayForEach(msg, messages) {
cJSON *role = cJSON_GetObjectItemCaseSensitive(msg, "role");
const char *role_str = cJSON_IsString(role) ? role->valuestring : "";
if (strcmp(role_str, "user") == 0 || strcmp(role_str, "assistant") == 0) {
cJSON_AddItemToArray(saved_msgs, cJSON_Duplicate(msg, 1));
}
}
cJSON_AddItemToObject(payload, "messages", saved_msgs);
char *json = cJSON_PrintUnformatted(payload);
cJSON_Delete(payload);
if (json == NULL) {
g_printerr("[agent_conv] Failed to serialize payload\n");
cJSON_Delete(messages);
g_free(conv_id);
g_free(use_title);
return NULL;
}
/* Encrypt. */
char *ciphertext = encrypt_content(json);
free(json);
cJSON_Delete(messages);
if (ciphertext == NULL) {
g_free(conv_id);
g_free(use_title);
return NULL;
}
/* Build tags: [["d", "{id}"],
* ["t", "client-ai-chat-v1"],
* ["client", "sovereign_browser"]] */
cJSON *tags = cJSON_CreateArray();
char *d_tag_val = build_d_tag(conv_id);
cJSON *d_tag = cJSON_CreateArray();
cJSON_AddItemToArray(d_tag, cJSON_CreateString("d"));
cJSON_AddItemToArray(d_tag, cJSON_CreateString(d_tag_val));
cJSON_AddItemToArray(tags, d_tag);
g_free(d_tag_val);
cJSON *t_tag = cJSON_CreateArray();
cJSON_AddItemToArray(t_tag, cJSON_CreateString("t"));
cJSON_AddItemToArray(t_tag, cJSON_CreateString(AGENT_CONV_T_TAG));
cJSON_AddItemToArray(tags, t_tag);
cJSON *client_tag = cJSON_CreateArray();
cJSON_AddItemToArray(client_tag, cJSON_CreateString("client"));
cJSON_AddItemToArray(client_tag, cJSON_CreateString("sovereign_browser"));
cJSON_AddItemToArray(tags, client_tag);
/* Create and sign the event. */
cJSON *event = nostr_create_and_sign_event_with_signer(
AGENT_CONV_KIND, ciphertext, tags, g_signer, time(NULL));
g_free(ciphertext);
if (event == NULL) {
g_printerr("[agent_conv] Failed to create/sign event\n");
cJSON_Delete(tags);
g_free(conv_id);
g_free(use_title);
return NULL;
}
/* Store in SQLite. */
db_store_event(event);
/* Publish to bootstrap relays. */
char *relay_buf = NULL;
const char *relay_urls[32];
int relay_count = parse_relays(&relay_buf, relay_urls, 32);
if (relay_count > 0) {
int success_count = 0;
publish_result_t *results = synchronous_publish_event_with_progress(
relay_urls, relay_count, event, &success_count,
15, NULL, NULL, 0, NULL);
if (results) free(results);
g_print("[agent_conv] Saved conversation %s to %d/%d relays\n",
conv_id, success_count, relay_count);
} else {
g_print("[agent_conv] No relays configured, event stored locally\n");
}
g_free(relay_buf);
cJSON_Delete(event);
g_free(use_title);
return conv_id;
}
/* ── Load ───────────────────────────────────────────────────────────── */
int agent_conversations_load(const char *conversation_id) {
if (conversation_id == NULL || conversation_id[0] == '\0') return -1;
if (g_pubkey[0] == '\0') return -1;
/* Build the d-tag to look for. */
char *d_tag_val = build_d_tag(conversation_id);
/* Fetch all kind 30078 events and find the one with our d-tag.
* (db_get_latest_event doesn't filter by d-tag, so we scan.) */
cJSON *events = db_get_events(g_pubkey, AGENT_CONV_KIND, 512);
if (events == NULL) {
g_free(d_tag_val);
return -1;
}
cJSON *found = NULL;
int n = cJSON_GetArraySize(events);
for (int i = 0; i < n; i++) {
cJSON *ev = cJSON_GetArrayItem(events, i);
if (event_has_tag(ev, "d", d_tag_val)) {
found = cJSON_Duplicate(ev, 1);
break;
}
}
cJSON_Delete(events);
g_free(d_tag_val);
if (found == NULL) {
g_printerr("[agent_conv] Conversation %s not found in cache\n",
conversation_id);
return -1;
}
/* Decrypt the content. */
cJSON *content_item = cJSON_GetObjectItemCaseSensitive(found, "content");
if (!cJSON_IsString(content_item)) {
cJSON_Delete(found);
return -1;
}
char *plaintext = decrypt_content(content_item->valuestring);
cJSON_Delete(found);
if (plaintext == NULL) return -1;
/* Parse the payload. */
cJSON *payload = cJSON_Parse(plaintext);
free(plaintext);
if (payload == NULL) {
g_printerr("[agent_conv] Failed to parse decrypted payload\n");
return -1;
}
cJSON *title_item = cJSON_GetObjectItemCaseSensitive(payload, "title");
const char *title = (cJSON_IsString(title_item))
? title_item->valuestring : "Loaded Chat";
cJSON *messages = cJSON_GetObjectItemCaseSensitive(payload, "messages");
if (!cJSON_IsArray(messages)) {
g_printerr("[agent_conv] No messages array in payload\n");
cJSON_Delete(payload);
return -1;
}
/* Create a new agent chat session and populate it with the messages. */
const char *new_sid = agent_chat_store_new_session(title);
if (new_sid == NULL) {
cJSON_Delete(payload);
return -1;
}
/* Add each message to the new session. */
cJSON *msg;
cJSON_ArrayForEach(msg, messages) {
cJSON *role_item = cJSON_GetObjectItemCaseSensitive(msg, "role");
cJSON *content_m = cJSON_GetObjectItemCaseSensitive(msg, "content");
cJSON *tc_item = cJSON_GetObjectItemCaseSensitive(msg, "tool_calls");
cJSON *tcid_item = cJSON_GetObjectItemCaseSensitive(msg, "tool_call_id");
const char *role = (cJSON_IsString(role_item))
? role_item->valuestring : "user";
const char *content = (cJSON_IsString(content_m))
? content_m->valuestring : NULL;
/* Serialize tool_calls back to a string for storage. */
char *tc_str = NULL;
if (tc_item && !cJSON_IsNull(tc_item)) {
tc_str = cJSON_PrintUnformatted(tc_item);
}
const char *tcid = (cJSON_IsString(tcid_item) &&
tcid_item->valuestring[0])
? tcid_item->valuestring : NULL;
db_agent_message_add(new_sid, role, content, tc_str, tcid);
if (tc_str) free(tc_str);
}
cJSON_Delete(payload);
return 0;
}
/* ── Delete ─────────────────────────────────────────────────────────── */
int agent_conversations_delete(const char *conversation_id) {
if (conversation_id == NULL || conversation_id[0] == '\0') return -1;
if (g_pubkey[0] == '\0') return -1;
/* Find the event id for this conversation. */
char *d_tag_val = build_d_tag(conversation_id);
cJSON *events = db_get_events(g_pubkey, AGENT_CONV_KIND, 512);
if (events == NULL) {
g_free(d_tag_val);
return -1;
}
char *event_id = NULL;
int n = cJSON_GetArraySize(events);
for (int i = 0; i < n; i++) {
cJSON *ev = cJSON_GetArrayItem(events, i);
if (event_has_tag(ev, "d", d_tag_val)) {
cJSON *id_item = cJSON_GetObjectItemCaseSensitive(ev, "id");
if (cJSON_IsString(id_item)) {
event_id = g_strdup(id_item->valuestring);
}
break;
}
}
cJSON_Delete(events);
g_free(d_tag_val);
if (event_id == NULL) {
g_printerr("[agent_conv] Conversation %s not found for delete\n",
conversation_id);
return -1;
}
/* Publish a kind 5 deletion event referencing the conversation event.
* The content is an optional reason; tags list the event id to delete. */
if (g_have_signer) {
cJSON *tags = cJSON_CreateArray();
cJSON *e_tag = cJSON_CreateArray();
cJSON_AddItemToArray(e_tag, cJSON_CreateString("e"));
cJSON_AddItemToArray(e_tag, cJSON_CreateString(event_id));
cJSON_AddItemToArray(tags, e_tag);
cJSON *event = nostr_create_and_sign_event_with_signer(
5, "Deleted conversation", tags, g_signer, time(NULL));
cJSON_Delete(tags);
if (event) {
db_store_event(event);
char *relay_buf = NULL;
const char *relay_urls[32];
int relay_count = parse_relays(&relay_buf, relay_urls, 32);
if (relay_count > 0) {
int success_count = 0;
publish_result_t *results =
synchronous_publish_event_with_progress(
relay_urls, relay_count, event, &success_count,
15, NULL, NULL, 0, NULL);
if (results) free(results);
g_print("[agent_conv] Published deletion for %s to %d/%d relays\n",
conversation_id, success_count, relay_count);
}
g_free(relay_buf);
cJSON_Delete(event);
}
}
/* Remove the kind 30078 event from the local SQLite cache so it
* no longer appears in conversation lists. The kind 5 tombstone
* published above handles relay-side deletion. */
db_delete_event(event_id);
g_free(event_id);
return 0;
}
/* ── Rename ─────────────────────────────────────────────────────────── */
int agent_conversations_rename(const char *conversation_id,
const char *new_title) {
if (conversation_id == NULL || conversation_id[0] == '\0') return -1;
if (new_title == NULL || new_title[0] == '\0') return -1;
if (!g_have_signer || g_pubkey[0] == '\0') {
g_printerr("[agent_conv] No signer, cannot rename\n");
return -1;
}
/* Find the existing event for this conversation. */
char *d_tag_val = build_d_tag(conversation_id);
cJSON *events = db_get_events(g_pubkey, AGENT_CONV_KIND, 512);
if (events == NULL) {
g_free(d_tag_val);
return -1;
}
cJSON *found = NULL;
char *old_event_id = NULL;
int n = cJSON_GetArraySize(events);
for (int i = 0; i < n; i++) {
cJSON *ev = cJSON_GetArrayItem(events, i);
if (event_has_tag(ev, "d", d_tag_val)) {
found = cJSON_Duplicate(ev, 1);
/* Remember the old event's id so we can delete it from
* SQLite after storing the renamed event. This prevents
* duplicate events with the same d-tag. */
cJSON *id_item = cJSON_GetObjectItemCaseSensitive(ev, "id");
if (cJSON_IsString(id_item)) {
old_event_id = g_strdup(id_item->valuestring);
}
break;
}
}
cJSON_Delete(events);
g_free(d_tag_val);
if (found == NULL) {
g_printerr("[agent_conv] Conversation %s not found for rename\n",
conversation_id);
g_free(old_event_id);
return -1;
}
/* Decrypt the content. */
cJSON *content_item = cJSON_GetObjectItemCaseSensitive(found, "content");
if (!cJSON_IsString(content_item)) {
cJSON_Delete(found);
return -1;
}
char *plaintext = decrypt_content(content_item->valuestring);
cJSON_Delete(found);
if (plaintext == NULL) return -1;
/* Parse the payload, replace the title, re-serialize. */
cJSON *payload = cJSON_Parse(plaintext);
free(plaintext);
if (payload == NULL) {
g_printerr("[agent_conv] Failed to parse payload for rename\n");
return -1;
}
/* Replace the title field. */
cJSON *title_item = cJSON_GetObjectItemCaseSensitive(payload, "title");
if (title_item) {
cJSON_ReplaceItemInObjectCaseSensitive(payload, "title",
cJSON_CreateString(new_title));
} else {
cJSON_AddStringToObject(payload, "title", new_title);
}
char *json = cJSON_PrintUnformatted(payload);
cJSON_Delete(payload);
if (json == NULL) {
g_printerr("[agent_conv] Failed to serialize renamed payload\n");
return -1;
}
/* Re-encrypt. */
char *ciphertext = encrypt_content(json);
free(json);
if (ciphertext == NULL) return -1;
/* Build tags (same structure as save: d, t, client). */
cJSON *tags = cJSON_CreateArray();
char *d_tag_val2 = build_d_tag(conversation_id);
cJSON *d_tag = cJSON_CreateArray();
cJSON_AddItemToArray(d_tag, cJSON_CreateString("d"));
cJSON_AddItemToArray(d_tag, cJSON_CreateString(d_tag_val2));
cJSON_AddItemToArray(tags, d_tag);
g_free(d_tag_val2);
cJSON *t_tag = cJSON_CreateArray();
cJSON_AddItemToArray(t_tag, cJSON_CreateString("t"));
cJSON_AddItemToArray(t_tag, cJSON_CreateString(AGENT_CONV_T_TAG));
cJSON_AddItemToArray(tags, t_tag);
cJSON *client_tag = cJSON_CreateArray();
cJSON_AddItemToArray(client_tag, cJSON_CreateString("client"));
cJSON_AddItemToArray(client_tag, cJSON_CreateString("sovereign_browser"));
cJSON_AddItemToArray(tags, client_tag);
/* Create and sign the new event. */
cJSON *event = nostr_create_and_sign_event_with_signer(
AGENT_CONV_KIND, ciphertext, tags, g_signer, time(NULL));
g_free(ciphertext);
if (event == NULL) {
g_printerr("[agent_conv] Failed to create/sign rename event\n");
cJSON_Delete(tags);
return -1;
}
/* Store the renamed event in SQLite. */
db_store_event(event);
/* Delete the old event from SQLite so there's only one event per
* d-tag. Without this, both the old and new events exist, and the
* dedup logic in agent_conversations_list() might pick the wrong
* one if the old event has a higher created_at. */
if (old_event_id) {
db_delete_event(old_event_id);
g_free(old_event_id);
old_event_id = NULL;
}
/* Publish to bootstrap relays. */
char *relay_buf = NULL;
const char *relay_urls[32];
int relay_count = parse_relays(&relay_buf, relay_urls, 32);
if (relay_count > 0) {
int success_count = 0;
publish_result_t *results = synchronous_publish_event_with_progress(
relay_urls, relay_count, event, &success_count,
15, NULL, NULL, 0, NULL);
if (results) free(results);
g_print("[agent_conv] Renamed conversation %s to %d/%d relays\n",
conversation_id, success_count, relay_count);
} else {
g_print("[agent_conv] No relays configured, rename stored locally\n");
}
g_free(relay_buf);
cJSON_Delete(event);
return 0;
}

144
src/agent_conversations.h Normal file
View File

@@ -0,0 +1,144 @@
/*
* agent_conversations.h — Nostr conversation persistence for agent chat
*
* Saves and loads agent chat conversations as NIP-78 (kind 30078) addressable
* events. Each conversation is stored with:
* d-tag: "{uuid}" (no prefix — matches ai.html so conversations are
* shared between sovereign_browser and the client)
* t-tag: "client-ai-chat-v1"
*
* The event content is NIP-44 encrypted (self-to-self) JSON:
* { "schema": 1, "title": "...", "messages": [ {role, content, tool_calls, tool_call_id}, ... ] }
*
* Reuses the NIP-44 encryption + event signing + relay publishing patterns
* from settings_sync.c and bookmarks.c.
*
* See plans/cross-project-agent-sync.md (Workstream 3) for the full design.
*/
#ifndef AGENT_CONVERSATIONS_H
#define AGENT_CONVERSATIONS_H
#include <glib.h>
/* cJSON is in the vendored nostr_core_lib */
#include "../nostr_core_lib/cjson/cJSON.h"
/* nostr_signer_t is needed for init */
#include "nostr_core/nostr_signer.h"
#ifdef __cplusplus
extern "C" {
#endif
/* The Nostr kind for arbitrary custom app data (NIP-78). */
#define AGENT_CONV_KIND 30078
/* The t-tag that identifies these conversation events. Matches ai.html
* so conversations are shared between sovereign_browser and the client. */
#define AGENT_CONV_T_TAG "client-ai-chat-v1"
/* The prefix for the d-tag. Empty — the d-tag is just the UUID, matching
* ai.html's d:{conversation-id} format. Kept as a macro for compatibility
* with build_d_tag()/d_tag_to_id(). */
#define AGENT_CONV_D_PREFIX ""
/*
* Initialize the conversation persistence module. Stores the signer + pubkey
* references for save/load/delete operations.
*
* signer — the user's nostr_signer_t (NULL for read-only/no-login)
* pubkey_hex — the user's hex pubkey (64 chars, may be NULL)
*
* Call after login.
*/
void agent_conversations_init(nostr_signer_t *signer,
const char *pubkey_hex);
/*
* Update the signer reference (e.g. after switching identity).
*/
void agent_conversations_set_signer(nostr_signer_t *signer,
const char *pubkey_hex);
/*
* List all saved conversations from the local SQLite cache.
*
* Queries kind 30078 events by the user's pubkey that have the
* t-tag "client-ai-chat-v1". Extracts the conversation id
* (from the d-tag, which is just the UUID), title (from the
* decrypted content), and updated_at (from created_at).
*
* Returns a newly allocated cJSON array of summary objects:
* [{"id":"...", "title":"...", "updated_at":N}, ...]
* Returns an empty array on error or if no conversations exist.
* Caller must cJSON_Delete() the result.
*/
cJSON *agent_conversations_list(void);
/*
* Save the current agent chat session to Nostr as a kind 30078 event.
*
* conversation_id — the conversation id (UUID). If NULL, a new UUID is
* generated and the d-tag becomes just "{uuid}"
* (no prefix, matching ai.html).
* title — the conversation title. If NULL, derived from the
* first user message (truncated to 60 chars).
*
* Fetches the current session's messages via agent_chat_store, NIP-44
* encrypts them, builds a kind 30078 event with the d-tag and t-tag,
* signs it, publishes to bootstrap relays, and stores in SQLite.
*
* Returns a newly allocated string with the conversation id (caller must
* g_free), or NULL on error.
*/
char *agent_conversations_save(const char *conversation_id,
const char *title);
/*
* Load a conversation from Nostr (local SQLite cache) into the agent chat
* store as the current session.
*
* conversation_id — the conversation id (the UUID used as the d-tag).
*
* Fetches the kind 30078 event with d-tag "{id}", NIP-44 decrypts
* the content, parses the messages, creates a new agent chat session,
* and populates it with the parsed messages.
*
* Returns 0 on success, -1 on error.
*/
int agent_conversations_load(const char *conversation_id);
/*
* Rename a conversation — update its title in the local SQLite cache
* and re-publish the kind 30078 event with the new title.
*
* conversation_id — the conversation id (the UUID used as the d-tag).
* new_title — the new title (must be non-NULL and non-empty).
*
* Loads the existing event, decrypts the content, replaces the title,
* re-encrypts, signs a new kind 30078 event with the same d-tag, stores
* it in SQLite, and publishes to bootstrap relays.
*
* Returns 0 on success, -1 on error.
*/
int agent_conversations_rename(const char *conversation_id,
const char *new_title);
/*
* Delete a conversation from Nostr.
*
* conversation_id — the conversation id (the UUID used as the d-tag).
*
* Publishes a kind 5 deletion event referencing the kind 30078 event id,
* and removes the event from the local SQLite cache.
*
* Returns 0 on success, -1 on error.
*/
int agent_conversations_delete(const char *conversation_id);
#ifdef __cplusplus
}
#endif
#endif /* AGENT_CONVERSATIONS_H */

488
src/agent_fs_tools.c Normal file
View File

@@ -0,0 +1,488 @@
/*
* agent_fs_tools.c — filesystem & shell tool implementations
*
* Tools:
* fs_read — read a file's text contents
* fs_write — write text to a file (overwrite or create)
* fs_list — list directory entries
* fs_mkdir — create a directory recursively
* fs_delete — delete a file or empty directory
* shell_exec — run a shell command, return stdout+stderr+exit code
*
* The browser runs in a dedicated Qubes OS qube, so full filesystem
* and shell access is intended. These tools work before login.
*/
#include "agent_fs_tools.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <fcntl.h>
#include <dirent.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <poll.h>
#include <signal.h>
#include <time.h>
#include <glib.h>
/* ── JSON helpers (same pattern as agent_tools.c) ──────────────────── */
static cJSON *make_success(cJSON *data) {
cJSON *resp = cJSON_CreateObject();
cJSON_AddBoolToObject(resp, "success", TRUE);
if (data) {
cJSON_AddItemToObject(resp, "data", data);
}
return resp;
}
static cJSON *make_error(const char *code, const char *message) {
cJSON *resp = cJSON_CreateObject();
cJSON_AddBoolToObject(resp, "success", FALSE);
cJSON *err = cJSON_CreateObject();
cJSON_AddStringToObject(err, "code", code);
cJSON_AddStringToObject(err, "message", message);
cJSON_AddItemToObject(resp, "error", err);
return resp;
}
static const char *get_string_param(cJSON *params, const char *key) {
return cJSON_GetStringValue(cJSON_GetObjectItem(params, key));
}
static int get_int_param(cJSON *params, const char *key, int fallback) {
cJSON *item = cJSON_GetObjectItem(params, key);
if (item && cJSON_IsNumber(item)) return item->valueint;
return fallback;
}
/* ── fs_read ───────────────────────────────────────────────────────── */
static cJSON *tool_fs_read(cJSON *params) {
const char *path = get_string_param(params, "path");
if (!path || !path[0]) {
return make_error("MISSING_PARAM", "Provide 'path'");
}
/* Stat the file first to get the size and verify it's a regular file. */
struct stat st;
if (stat(path, &st) != 0) {
return make_error("FILE_NOT_FOUND",
g_strdup_printf("Cannot stat '%s': %s", path, strerror(errno)));
}
if (!S_ISREG(st.st_mode)) {
return make_error("NOT_A_FILE",
g_strdup_printf("'%s' is not a regular file", path));
}
/* Cap reads at 16 MB to avoid exhausting memory on huge files. */
off_t size = st.st_size;
if (size > (16 * 1024 * 1024)) {
return make_error("FILE_TOO_LARGE",
g_strdup_printf("'%s' is %lld bytes; max read is 16 MB",
path, (long long)size));
}
int fd = open(path, O_RDONLY);
if (fd < 0) {
return make_error("OPEN_FAILED",
g_strdup_printf("Cannot open '%s': %s", path, strerror(errno)));
}
char *buf = g_malloc(size + 1);
if (buf == NULL) {
close(fd);
return make_error("OUT_OF_MEMORY", "Cannot allocate read buffer");
}
off_t total = 0;
while (total < size) {
ssize_t n = read(fd, buf + total, size - total);
if (n < 0) {
if (errno == EINTR) continue;
g_free(buf);
close(fd);
return make_error("READ_FAILED",
g_strdup_printf("Read error on '%s': %s", path, strerror(errno)));
}
if (n == 0) break; /* EOF (file shrank?) */
total += n;
}
buf[total] = '\0';
close(fd);
cJSON *data = cJSON_CreateObject();
cJSON_AddStringToObject(data, "content", buf);
g_free(buf);
return make_success(data);
}
/* ── fs_write ──────────────────────────────────────────────────────── */
static cJSON *tool_fs_write(cJSON *params) {
const char *path = get_string_param(params, "path");
if (!path || !path[0]) {
return make_error("MISSING_PARAM", "Provide 'path'");
}
const char *content = get_string_param(params, "content");
if (content == NULL) {
/* Allow NULL to be treated as empty string. */
content = "";
}
/* Create parent directories if needed so the write succeeds. */
char *parent = g_path_get_dirname(path);
if (parent && parent[0] && strcmp(parent, ".") != 0) {
g_mkdir_with_parents(parent, 0755);
}
g_free(parent);
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) {
return make_error("OPEN_FAILED",
g_strdup_printf("Cannot open '%s' for writing: %s",
path, strerror(errno)));
}
size_t len = strlen(content);
size_t total = 0;
while (total < len) {
ssize_t n = write(fd, content + total, len - total);
if (n < 0) {
if (errno == EINTR) continue;
close(fd);
return make_error("WRITE_FAILED",
g_strdup_printf("Write error on '%s': %s", path, strerror(errno)));
}
total += (size_t)n;
}
close(fd);
cJSON *data = cJSON_CreateObject();
cJSON_AddNumberToObject(data, "bytes_written", (double)total);
return make_success(data);
}
/* ── fs_list ───────────────────────────────────────────────────────── */
static cJSON *tool_fs_list(cJSON *params) {
const char *path = get_string_param(params, "path");
if (!path || !path[0]) {
return make_error("MISSING_PARAM", "Provide 'path'");
}
DIR *dir = opendir(path);
if (dir == NULL) {
return make_error("OPEN_FAILED",
g_strdup_printf("Cannot open directory '%s': %s",
path, strerror(errno)));
}
cJSON *entries = cJSON_CreateArray();
struct dirent *ent;
while ((ent = readdir(dir)) != NULL) {
/* Skip "." and ".." — they're not useful for an agent. */
if (strcmp(ent->d_name, ".") == 0 || strcmp(ent->d_name, "..") == 0) {
continue;
}
/* Build the full path to stat the entry. */
char *full = g_strdup_printf("%s/%s", path, ent->d_name);
struct stat st;
const char *type = "file";
long long size = 0;
if (stat(full, &st) == 0) {
if (S_ISDIR(st.st_mode)) {
type = "dir";
} else if (S_ISLNK(st.st_mode)) {
type = "link";
} else if (!S_ISREG(st.st_mode)) {
type = "other";
}
size = (long long)st.st_size;
}
g_free(full);
cJSON *entry = cJSON_CreateObject();
cJSON_AddStringToObject(entry, "name", ent->d_name);
cJSON_AddStringToObject(entry, "type", type);
cJSON_AddNumberToObject(entry, "size", (double)size);
cJSON_AddItemToArray(entries, entry);
}
closedir(dir);
cJSON *data = cJSON_CreateObject();
cJSON_AddItemToObject(data, "entries", entries);
return make_success(data);
}
/* ── fs_mkdir ──────────────────────────────────────────────────────── */
static cJSON *tool_fs_mkdir(cJSON *params) {
const char *path = get_string_param(params, "path");
if (!path || !path[0]) {
return make_error("MISSING_PARAM", "Provide 'path'");
}
if (g_mkdir_with_parents(path, 0755) != 0) {
return make_error("MKDIR_FAILED",
g_strdup_printf("Cannot create directory '%s': %s",
path, strerror(errno)));
}
cJSON *data = cJSON_CreateObject();
cJSON_AddBoolToObject(data, "created", TRUE);
return make_success(data);
}
/* ── fs_delete ─────────────────────────────────────────────────────── */
static cJSON *tool_fs_delete(cJSON *params) {
const char *path = get_string_param(params, "path");
if (!path || !path[0]) {
return make_error("MISSING_PARAM", "Provide 'path'");
}
struct stat st;
if (stat(path, &st) != 0) {
return make_error("FILE_NOT_FOUND",
g_strdup_printf("'%s' does not exist: %s", path, strerror(errno)));
}
int rc;
if (S_ISDIR(st.st_mode)) {
rc = rmdir(path);
} else {
rc = unlink(path);
}
if (rc != 0) {
return make_error("DELETE_FAILED",
g_strdup_printf("Cannot delete '%s': %s", path, strerror(errno)));
}
cJSON *data = cJSON_CreateObject();
cJSON_AddBoolToObject(data, "deleted", TRUE);
return make_success(data);
}
/* ── shell_exec ────────────────────────────────────────────────────── *
*
* Runs a shell command via fork() + execvp("/bin/sh", "-c", command),
* capturing stdout and stderr through separate pipes. A timeout is
* enforced with a waitpid() polling loop; if the deadline is exceeded
* the child is killed with SIGKILL and we report exit_code -1.
*/
static cJSON *tool_shell_exec(cJSON *params) {
const char *command = get_string_param(params, "command");
if (!command || !command[0]) {
return make_error("MISSING_PARAM", "Provide 'command'");
}
int timeout_ms = get_int_param(params, "timeout_ms", 30000);
if (timeout_ms < 0) timeout_ms = 30000;
/* Create pipes for stdout and stderr. */
int out_pipe[2];
int err_pipe[2];
if (pipe(out_pipe) != 0) {
return make_error("PIPE_FAILED",
g_strdup_printf("pipe() failed: %s", strerror(errno)));
}
if (pipe(err_pipe) != 0) {
close(out_pipe[0]);
close(out_pipe[1]);
return make_error("PIPE_FAILED",
g_strdup_printf("pipe() failed: %s", strerror(errno)));
}
pid_t pid = fork();
if (pid < 0) {
close(out_pipe[0]); close(out_pipe[1]);
close(err_pipe[0]); close(err_pipe[1]);
return make_error("FORK_FAILED",
g_strdup_printf("fork() failed: %s", strerror(errno)));
}
if (pid == 0) {
/* ── Child ──────────────────────────────────────────────── */
/* Redirect stdout and stderr to the pipes. */
dup2(out_pipe[1], STDOUT_FILENO);
dup2(err_pipe[1], STDERR_FILENO);
close(out_pipe[0]); close(out_pipe[1]);
close(err_pipe[0]); close(err_pipe[1]);
/* Run the command via /bin/sh -c. */
char *const argv[] = {"sh", "-c", (char *)command, NULL};
execvp("/bin/sh", argv);
/* If execvp returns, it failed. */
fprintf(stderr, "execvp failed: %s\n", strerror(errno));
_exit(127);
}
/* ── Parent ──────────────────────────────────────────────────── */
close(out_pipe[1]);
close(err_pipe[1]);
/* Read stdout and stderr fully (non-blocking with poll). */
GString *out_buf = g_string_new(NULL);
GString *err_buf = g_string_new(NULL);
/* Set both pipe read ends to non-blocking. */
fcntl(out_pipe[0], F_SETFL, O_NONBLOCK);
fcntl(err_pipe[0], F_SETFL, O_NONBLOCK);
long deadline_ms = (long)(g_get_monotonic_time() / 1000) + (long)timeout_ms;
int exit_code = -1;
gboolean timed_out = FALSE;
gboolean child_done = FALSE;
char chunk[4096];
while (!child_done) {
/* Check timeout. */
long now_ms = (long)(g_get_monotonic_time() / 1000);
if (now_ms >= deadline_ms) {
timed_out = TRUE;
kill(pid, SIGKILL);
break;
}
/* Poll the pipes for up to 50ms, then check the child. */
struct pollfd pfds[2];
int nfds = 0;
pfds[nfds].fd = out_pipe[0];
pfds[nfds].events = POLLIN;
nfds++;
pfds[nfds].fd = err_pipe[0];
pfds[nfds].events = POLLIN;
nfds++;
int pr = poll(pfds, (nfds_t)nfds, 50);
if (pr > 0) {
for (int i = 0; i < nfds; i++) {
if (pfds[i].revents & POLLIN) {
ssize_t n;
while ((n = read(pfds[i].fd, chunk, sizeof(chunk))) > 0) {
if (pfds[i].fd == out_pipe[0]) {
g_string_append_len(out_buf, chunk, n);
} else {
g_string_append_len(err_buf, chunk, n);
}
}
}
if (pfds[i].revents & (POLLHUP | POLLERR)) {
/* Drain remaining, then mark closed. */
ssize_t n;
while ((n = read(pfds[i].fd, chunk, sizeof(chunk))) > 0) {
if (pfds[i].fd == out_pipe[0]) {
g_string_append_len(out_buf, chunk, n);
} else {
g_string_append_len(err_buf, chunk, n);
}
}
}
}
}
/* Check if the child has exited. */
int status;
pid_t wr = waitpid(pid, &status, WNOHANG);
if (wr == pid) {
child_done = TRUE;
if (WIFEXITED(status)) {
exit_code = WEXITSTATUS(status);
} else if (WIFSIGNALED(status)) {
exit_code = 128 + WTERMSIG(status);
} else {
exit_code = -1;
}
} else if (wr < 0) {
if (errno == EINTR) continue;
/* waitpid error — treat as done. */
child_done = TRUE;
exit_code = -1;
}
}
/* If we broke out due to timeout, reap the child. */
if (timed_out) {
int status;
/* Give it a moment to die from SIGKILL. */
for (int i = 0; i < 10; i++) {
pid_t wr = waitpid(pid, &status, WNOHANG);
if (wr == pid) break;
if (wr < 0 && errno != EINTR) break;
struct timespec ts = {0, 10 * 1000 * 1000}; /* 10ms */
nanosleep(&ts, NULL);
}
/* Final blocking reap. */
waitpid(pid, &status, 0);
exit_code = -1;
}
/* Drain any remaining data from the pipes after the child exited. */
ssize_t n;
while ((n = read(out_pipe[0], chunk, sizeof(chunk))) > 0) {
g_string_append_len(out_buf, chunk, n);
}
while ((n = read(err_pipe[0], chunk, sizeof(chunk))) > 0) {
g_string_append_len(err_buf, chunk, n);
}
close(out_pipe[0]);
close(err_pipe[0]);
cJSON *data = cJSON_CreateObject();
cJSON_AddStringToObject(data, "stdout", out_buf->str);
cJSON_AddStringToObject(data, "stderr", err_buf->str);
cJSON_AddNumberToObject(data, "exit_code", exit_code);
if (timed_out) {
cJSON_AddBoolToObject(data, "timed_out", TRUE);
}
g_string_free(out_buf, TRUE);
g_string_free(err_buf, TRUE);
return make_success(data);
}
/* ── Dispatch ──────────────────────────────────────────────────────── */
int agent_fs_is_tool(const char *tool_name) {
if (tool_name == NULL) return 0;
return (strcmp(tool_name, "fs_read") == 0 ||
strcmp(tool_name, "fs_write") == 0 ||
strcmp(tool_name, "fs_list") == 0 ||
strcmp(tool_name, "fs_mkdir") == 0 ||
strcmp(tool_name, "fs_delete") == 0 ||
strcmp(tool_name, "shell_exec") == 0);
}
cJSON *agent_fs_tools_dispatch(const char *tool_name, cJSON *params) {
if (tool_name == NULL) return NULL;
if (params == NULL) params = cJSON_CreateObject();
if (strcmp(tool_name, "fs_read") == 0) {
return tool_fs_read(params);
}
if (strcmp(tool_name, "fs_write") == 0) {
return tool_fs_write(params);
}
if (strcmp(tool_name, "fs_list") == 0) {
return tool_fs_list(params);
}
if (strcmp(tool_name, "fs_mkdir") == 0) {
return tool_fs_mkdir(params);
}
if (strcmp(tool_name, "fs_delete") == 0) {
return tool_fs_delete(params);
}
if (strcmp(tool_name, "shell_exec") == 0) {
return tool_shell_exec(params);
}
return NULL; /* not an fs/shell tool */
}

42
src/agent_fs_tools.h Normal file
View File

@@ -0,0 +1,42 @@
/*
* agent_fs_tools.h — filesystem & shell tools for the embedded agent
*
* Provides tools that give the agent direct filesystem and shell access
* within the browser's qube. These tools work before login (they are
* system-level, not browser-level) and are dispatched early in
* agent_tools_dispatch().
*/
#ifndef AGENT_FS_TOOLS_H
#define AGENT_FS_TOOLS_H
#include "cjson/cJSON.h"
#ifdef __cplusplus
extern "C" {
#endif
/*
* Dispatch a filesystem/shell tool request.
*
* tool_name: one of "fs_read", "fs_write", "fs_list", "fs_mkdir",
* "fs_delete", "shell_exec"
* params: cJSON object with the tool's parameters
*
* Returns a cJSON response in the same format as agent_tools_dispatch():
* {"success":true,"data":{...}} or
* {"success":false,"error":{"code":"...","message":"..."}}
*
* The caller frees the returned cJSON*. Returns NULL if tool_name is
* not recognized (so the caller can fall through to other handlers).
*/
cJSON *agent_fs_tools_dispatch(const char *tool_name, cJSON *params);
/* Returns TRUE if the given tool name is handled by this module. */
int agent_fs_is_tool(const char *tool_name);
#ifdef __cplusplus
}
#endif
#endif /* AGENT_FS_TOOLS_H */

417
src/agent_llm.c Normal file
View File

@@ -0,0 +1,417 @@
/*
* agent_llm.c — OpenAI-compatible LLM HTTP client
*
* Uses libsoup-3.0's synchronous soup_session_send_and_read() to POST
* a chat-completions request to an OpenAI-compatible endpoint, then
* parses the JSON response with cJSON and returns the assistant's
* message (text content + any tool_calls).
*
* A fresh SoupSession is created per call so this is safe to invoke
* from a background thread (libsoup-3.0 sessions are not meant to be
* shared across threads). The GTK main thread is never touched here.
*/
#include "agent_llm.h"
#include "agent_tool_catalog.h"
#include <libsoup/soup.h>
#include <glib.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
/* ── OpenAI tools helper ────────────────────────────────────────────── *
*
* build_tools_list() returns the catalog in MCP format:
* [{"name":...,"description":...,"inputSchema":{...}}, ...]
*
* OpenAI wants each entry wrapped as:
* {"type":"function","function":{"name":...,"description":...,"parameters":{...}}}
*
* We rebuild from tool_defs[] directly so the "parameters" field is a
* fresh cJSON copy (the MCP version uses "inputSchema").
*/
cJSON *agent_llm_build_openai_tools(void) {
cJSON *tools = cJSON_CreateArray();
if (tools == NULL) {
return NULL;
}
for (int i = 0; i < tool_defs_count; i++) {
cJSON *entry = cJSON_CreateObject();
if (entry == NULL) {
cJSON_Delete(tools);
return NULL;
}
cJSON_AddStringToObject(entry, "type", "function");
cJSON *fn = cJSON_CreateObject();
if (fn == NULL) {
cJSON_Delete(entry);
cJSON_Delete(tools);
return NULL;
}
cJSON_AddStringToObject(fn, "name", tool_defs[i].name);
cJSON_AddStringToObject(fn, "description", tool_defs[i].description);
cJSON *schema = cJSON_Parse(tool_defs[i].schema_json);
if (schema) {
cJSON_AddItemToObject(fn, "parameters", schema);
} else {
/* Fall back to an empty object so the field is always present. */
cJSON_AddItemToObject(fn, "parameters", cJSON_CreateObject());
}
cJSON_AddItemToObject(entry, "function", fn);
cJSON_AddItemToArray(tools, entry);
}
return tools;
}
/* ── Response lifecycle ─────────────────────────────────────────────── */
void agent_llm_response_free(agent_llm_response_t *resp) {
if (resp == NULL) {
return;
}
g_free(resp->content);
if (resp->tool_calls) {
cJSON_Delete(resp->tool_calls);
}
g_free(resp->finish_reason);
g_free(resp);
}
/* ── Internal: build the request body JSON ──────────────────────────── *
*
* Returns a newly-allocated JSON string (caller frees with g_free).
* The caller's messages/tools arrays are referenced (not consumed) —
* cJSON_AddItemReferenceToObject increments the refcount so the caller
* retains ownership.
*
* Returns NULL on allocation failure.
*/
static char *build_request_body(const char *model,
cJSON *messages,
cJSON *tools) {
cJSON *root = cJSON_CreateObject();
if (root == NULL) {
return NULL;
}
cJSON_AddStringToObject(root, "model", model);
/* Reference the caller's array so we don't steal ownership. */
cJSON_AddItemReferenceToObject(root, "messages", messages);
if (tools != NULL) {
cJSON_AddItemReferenceToObject(root, "tools", tools);
cJSON_AddStringToObject(root, "tool_choice", "auto");
}
char *str = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
return str;
}
/* ── Internal: parse the response body into agent_llm_response_t ────── *
*
* body is the raw HTTP response body (NUL-terminated by caller).
* Returns a newly-allocated agent_llm_response_t, or NULL on parse
* failure / error response.
*/
static agent_llm_response_t *parse_response(const char *body) {
cJSON *root = cJSON_Parse(body);
if (root == NULL) {
g_printerr("[agent_llm] failed to parse response JSON\n");
return NULL;
}
/* Some servers return {"error": {...}} on failure. */
cJSON *err = cJSON_GetObjectItemCaseSensitive(root, "error");
if (err) {
char *err_str = cJSON_PrintUnformatted(err);
g_printerr("[agent_llm] API error: %s\n",
err_str ? err_str : "(unknown)");
cJSON_free(err_str);
cJSON_Delete(root);
return NULL;
}
cJSON *choices = cJSON_GetObjectItemCaseSensitive(root, "choices");
cJSON *choice0 = cJSON_GetArrayItem(choices, 0);
if (choice0 == NULL) {
g_printerr("[agent_llm] response has no choices[0]\n");
cJSON_Delete(root);
return NULL;
}
agent_llm_response_t *resp = g_new0(agent_llm_response_t, 1);
if (resp == NULL) {
cJSON_Delete(root);
return NULL;
}
cJSON *message = cJSON_GetObjectItemCaseSensitive(choice0, "message");
if (message) {
cJSON *content = cJSON_GetObjectItemCaseSensitive(message, "content");
if (cJSON_IsString(content) && content->valuestring != NULL) {
resp->content = g_strdup(content->valuestring);
} else {
resp->content = NULL;
}
cJSON *tool_calls = cJSON_GetObjectItemCaseSensitive(message, "tool_calls");
if (tool_calls != NULL) {
/* Detach a deep copy so the response outlives the parsed root. */
resp->tool_calls = cJSON_Duplicate(tool_calls, 1);
} else {
resp->tool_calls = NULL;
}
}
cJSON *finish = cJSON_GetObjectItemCaseSensitive(choice0, "finish_reason");
if (cJSON_IsString(finish) && finish->valuestring != NULL) {
resp->finish_reason = g_strdup(finish->valuestring);
} else {
resp->finish_reason = NULL;
}
cJSON_Delete(root);
return resp;
}
/* ── Internal: normalize base URL ───────────────────────────────────── *
*
* Many OpenAI-compatible APIs expect the path prefix "/v1" before the
* endpoint (e.g. https://api.openai.com/v1/models). If the user supplies
* a base URL that already ends with "/v1" (or another "/vN" version
* segment) we leave it alone; otherwise we append "/v1" so that
* {base}/models and {base}/chat/completions resolve correctly.
*
* Returns a newly-allocated string (caller frees with g_free).
*/
static char *normalize_base_url(const char *base_url) {
if (base_url == NULL) {
return NULL;
}
/* Strip trailing slashes for consistent checking. */
g_autofree char *trimmed = NULL;
{
size_t len = strlen(base_url);
while (len > 0 && base_url[len - 1] == '/') {
len--;
}
trimmed = g_strndup(base_url, len);
}
size_t tlen = strlen(trimmed);
/* Already ends with "/v1"? */
if (tlen >= 3 && strcmp(trimmed + tlen - 3, "/v1") == 0) {
return g_strdup(trimmed);
}
/* Also handle "/v2", "/v3" etc. — if the last path segment is
* "v" followed by one or more digits, assume the user already
* included a version prefix. */
if (tlen > 0) {
const char *slash = strrchr(trimmed, '/');
if (slash != NULL) {
const char *seg = slash + 1;
if (seg[0] == 'v' && seg[1] >= '0' && seg[1] <= '9') {
return g_strdup(trimmed);
}
}
}
return g_strdup_printf("%s/v1", trimmed);
}
/* ── Public: agent_llm_chat ─────────────────────────────────────────── */
agent_llm_response_t *agent_llm_chat(const char *base_url,
const char *api_key,
const char *model,
cJSON *messages,
cJSON *tools) {
if (base_url == NULL || model == NULL || messages == NULL) {
return NULL;
}
/* Build the full URL: {normalized_base_url}/chat/completions.
* normalize_base_url() strips trailing slashes and appends "/v1"
* if needed, so we never produce a "//" here. */
g_autofree char *norm = normalize_base_url(base_url);
g_autofree char *url = g_strdup_printf("%s/chat/completions", norm);
/* Build request body. */
g_autofree char *body = build_request_body(model, messages, tools);
if (body == NULL) {
g_printerr("[agent_llm] failed to build request body\n");
return NULL;
}
/* Create the SoupMessage. */
SoupMessage *msg = soup_message_new("POST", url);
if (msg == NULL) {
g_printerr("[agent_llm] invalid URL: %s\n", url);
return NULL;
}
soup_message_headers_set_content_type(
soup_message_get_request_headers(msg), "application/json", NULL);
if (api_key != NULL && api_key[0] != '\0') {
g_autofree char *bearer = g_strdup_printf("Bearer %s", api_key);
soup_message_headers_append(
soup_message_get_request_headers(msg), "Authorization", bearer);
}
/* Set the request body from a GBytes. libsoup-3.0 takes ownership
* of the GBytes; the g_autofree `body` string is freed at scope exit. */
GBytes *req_bytes = g_bytes_new(body, strlen(body));
soup_message_set_request_body_from_bytes(msg, "application/json", req_bytes);
g_bytes_unref(req_bytes);
/* Send synchronously. A fresh session per call keeps things
* thread-safe without any global state. */
g_print("[agent_llm] POST %s (model=%s, %d messages, %d tools)\n",
url, model, cJSON_GetArraySize(messages),
tools ? cJSON_GetArraySize(tools) : 0);
SoupSession *session = soup_session_new();
/* Set a generous timeout (300s) to accommodate slow CPU-only models
* and cold-start loading. The default libsoup timeout is 60s, which
* is too short for large local models. */
g_object_set(session, "timeout", 300, NULL);
GError *error = NULL;
GBytes *resp_bytes = soup_session_send_and_read(session, msg, NULL, &error);
agent_llm_response_t *result = NULL;
if (error != NULL) {
g_printerr("[agent_llm] HTTP transport error: %s\n",
error->message);
g_error_free(error);
} else {
guint status = soup_message_get_status(msg);
if (status != SOUP_STATUS_OK) {
gsize size = 0;
const gchar *data = g_bytes_get_data(resp_bytes, &size);
g_printerr("[agent_llm] HTTP %u: %.*s\n",
status, (int)size, data ? data : "");
} else {
gsize size = 0;
const gchar *data = g_bytes_get_data(resp_bytes, &size);
/* Make a NUL-terminated copy for cJSON. */
g_autofree char *body_str = g_strndup(data ? data : "", size);
result = parse_response(body_str);
}
}
if (resp_bytes) {
g_bytes_unref(resp_bytes);
}
g_object_unref(msg);
g_object_unref(session);
return result;
}
/* ── Public: agent_llm_list_models ──────────────────────────────────── *
*
* Fetch the list of available models from an OpenAI-compatible API.
* Calls GET {base_url}/models with an Authorization: Bearer header
* (if api_key is non-NULL and non-empty). Parses the "data" array and
* returns a cJSON array of model ID strings. Returns NULL on error.
* Caller must cJSON_Delete() the returned array.
*/
cJSON *agent_llm_list_models(const char *base_url, const char *api_key) {
if (base_url == NULL || base_url[0] == '\0') {
return NULL;
}
/* Build the full URL: {normalized_base_url}/models.
* normalize_base_url() strips trailing slashes and appends "/v1"
* if needed, so we never produce a "//" here. */
g_autofree char *norm = normalize_base_url(base_url);
g_autofree char *url = g_strdup_printf("%s/models", norm);
SoupMessage *msg = soup_message_new("GET", url);
if (msg == NULL) {
g_printerr("[agent_llm] invalid URL: %s\n", url);
return NULL;
}
if (api_key != NULL && api_key[0] != '\0') {
g_autofree char *bearer = g_strdup_printf("Bearer %s", api_key);
soup_message_headers_append(
soup_message_get_request_headers(msg), "Authorization", bearer);
}
SoupSession *session = soup_session_new();
GError *error = NULL;
GBytes *resp_bytes = soup_session_send_and_read(session, msg, NULL, &error);
cJSON *models = NULL;
if (error != NULL) {
g_printerr("[agent_llm] list_models transport error: %s\n",
error->message);
g_error_free(error);
} else {
guint status = soup_message_get_status(msg);
if (status != SOUP_STATUS_OK) {
gsize size = 0;
const gchar *data = g_bytes_get_data(resp_bytes, &size);
g_printerr("[agent_llm] list_models HTTP %u: %.*s\n",
status, (int)size, data ? data : "");
} else {
gsize size = 0;
const gchar *data = g_bytes_get_data(resp_bytes, &size);
g_autofree char *body_str = g_strndup(data ? data : "", size);
cJSON *root = cJSON_Parse(body_str);
if (root == NULL) {
g_printerr("[agent_llm] list_models: failed to parse JSON\n");
} else {
cJSON *err = cJSON_GetObjectItemCaseSensitive(root, "error");
if (err) {
g_printerr("[agent_llm] list_models: API returned error\n");
} else {
cJSON *data_arr = cJSON_GetObjectItemCaseSensitive(root, "data");
if (cJSON_IsArray(data_arr)) {
models = cJSON_CreateArray();
if (models != NULL) {
cJSON *entry;
cJSON_ArrayForEach(entry, data_arr) {
cJSON *id = cJSON_GetObjectItemCaseSensitive(entry, "id");
if (cJSON_IsString(id) && id->valuestring != NULL) {
cJSON_AddItemToArray(models,
cJSON_CreateString(id->valuestring));
}
}
/* If no IDs were found, treat as error. */
if (cJSON_GetArraySize(models) == 0) {
cJSON_Delete(models);
models = NULL;
}
}
}
}
cJSON_Delete(root);
}
}
}
if (resp_bytes) {
g_bytes_unref(resp_bytes);
}
g_object_unref(msg);
g_object_unref(session);
return models;
}

86
src/agent_llm.h Normal file
View File

@@ -0,0 +1,86 @@
/*
* agent_llm.h — OpenAI-compatible LLM HTTP client
*
* Sends chat-completions requests to an OpenAI-compatible endpoint
* (OpenAI, OpenRouter, Ollama, LM Studio, Groq, etc.) using libsoup-3.0,
* parses the JSON response, and returns the assistant's message (text
* content + any tool_calls).
*
* The HTTP call is synchronous (soup_session_send_and_read) and is
* intended to be called from a background thread — not the GTK main
* thread. A fresh SoupSession is created per call so there are no
* cross-thread sharing issues.
*/
#ifndef AGENT_LLM_H
#define AGENT_LLM_H
#include "cjson/cJSON.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ── Response ──────────────────────────────────────────────────────── */
typedef struct {
char *content; /* assistant text (may be NULL or empty) */
cJSON *tool_calls; /* JSON array of tool call objects, or NULL */
char *finish_reason; /* "stop", "tool_calls", "length", etc. */
} agent_llm_response_t;
/*
* Call an OpenAI-compatible chat completions endpoint.
*
* base_url — e.g. "https://api.openai.com/v1" or "http://localhost:11434/v1"
* api_key — bearer token (may be NULL for local servers like Ollama)
* model — model name, e.g. "gpt-4o", "llama3.1", etc.
* messages — cJSON array of message objects (role/content/tool_calls/tool_call_id)
* tools — cJSON array of tool definitions (OpenAI format), or NULL
*
* Returns a newly-allocated agent_llm_response_t. Caller must free with
* agent_llm_response_free().
*
* On error, returns NULL (caller should handle gracefully).
*/
agent_llm_response_t *agent_llm_chat(const char *base_url,
const char *api_key,
const char *model,
cJSON *messages,
cJSON *tools);
/*
* Free an agent_llm_response_t returned by agent_llm_chat().
* Safe to call with NULL.
*/
void agent_llm_response_free(agent_llm_response_t *resp);
/*
* Build the OpenAI-format "tools" array from the shared tool catalog
* (agent_tool_catalog.h). Each entry is wrapped as:
*
* {"type":"function","function":{"name":...,"description":...,"parameters":{...}}}
*
* Returns a newly-allocated cJSON array. Caller frees with cJSON_Delete().
* Returns NULL if the catalog is empty or on allocation failure.
*/
cJSON *agent_llm_build_openai_tools(void);
/*
* Fetch the list of available models from an OpenAI-compatible API.
* Calls GET {base_url}/models with the Authorization header.
*
* base_url — e.g. "https://api.ppq.ai"
* api_key — bearer token (may be NULL for local servers)
*
* Returns a cJSON array of model ID strings (newly allocated), e.g.:
* ["gpt-4o", "gpt-4o-mini", "llama3.1"]
* Returns NULL on error. Caller must cJSON_Delete().
*/
cJSON *agent_llm_list_models(const char *base_url, const char *api_key);
#ifdef __cplusplus
}
#endif
#endif /* AGENT_LLM_H */

View File

@@ -20,6 +20,7 @@
* We access it via extern functions that main.c provides.
*/
extern void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex,
const char *privkey_hex,
key_store_method_t method, gboolean readonly);
extern void app_clear_signer(void);
extern nostr_signer_t *app_get_signer(void);
@@ -27,6 +28,12 @@ extern const char *app_get_pubkey_hex(void);
extern key_store_method_t app_get_method(void);
extern gboolean app_get_readonly(void);
/* Defined in main.c. Tears down the current user's web state (closes all
* tabs, destroys sidebar webviews, wipes WebKit cookies/cache/localStorage/
* service workers/favicons) so the next identity starts clean. See
* plans/webkit-data-isolation.md. */
extern void identity_teardown_web_state(void);
/* Track whether the agent (not the GTK dialog) performed the login. */
static gboolean g_agent_performed_login = FALSE;
@@ -131,13 +138,18 @@ static cJSON *login_local(cJSON *params) {
if (derive_pubkey_hex(privkey, pubkey_hex) != 0) {
return make_error("DERIVE_FAILED", "Failed to derive public key");
}
char privkey_hex_out[65];
for (int i = 0; i < 32; i++) {
snprintf(privkey_hex_out + i * 2, 3, "%02x", privkey[i]);
}
privkey_hex_out[64] = '\0';
nostr_signer_t *signer = nostr_signer_local(privkey);
if (signer == NULL) {
return make_error("SIGNER_FAILED", "Failed to create signer");
}
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_LOCAL, FALSE);
app_set_signer(signer, pubkey_hex, privkey_hex_out, KEY_STORE_METHOD_LOCAL, FALSE);
nostr_bridge_set_signer(signer, pubkey_hex, FALSE);
g_print("[agent-login] Local key: pubkey=%s\n", pubkey_hex);
@@ -169,13 +181,18 @@ static cJSON *login_generate(cJSON *params) {
if (nostr_key_to_bech32(privkey, "nsec", nsec) != 0) {
nsec[0] = '\0';
}
char privkey_hex[65];
for (int i = 0; i < 32; i++) {
snprintf(privkey_hex + i * 2, 3, "%02x", privkey[i]);
}
privkey_hex[64] = '\0';
nostr_signer_t *signer = nostr_signer_local(privkey);
if (signer == NULL) {
return make_error("SIGNER_FAILED", "Failed to create signer");
}
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_LOCAL, FALSE);
app_set_signer(signer, pubkey_hex, privkey_hex, KEY_STORE_METHOD_LOCAL, FALSE);
nostr_bridge_set_signer(signer, pubkey_hex, FALSE);
g_print("[agent-login] Generated key: pubkey=%s\n", pubkey_hex);
@@ -206,13 +223,18 @@ static cJSON *login_seed(cJSON *params) {
snprintf(pubkey_hex + i * 2, 3, "%02x", pubkey[i]);
}
pubkey_hex[64] = '\0';
char privkey_hex[65];
for (int i = 0; i < 32; i++) {
snprintf(privkey_hex + i * 2, 3, "%02x", privkey[i]);
}
privkey_hex[64] = '\0';
nostr_signer_t *signer = nostr_signer_local(privkey);
if (signer == NULL) {
return make_error("SIGNER_FAILED", "Failed to create signer");
}
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_SEED, FALSE);
app_set_signer(signer, pubkey_hex, privkey_hex, KEY_STORE_METHOD_SEED, FALSE);
nostr_bridge_set_signer(signer, pubkey_hex, FALSE);
g_print("[agent-login] Seed phrase: pubkey=%s\n", pubkey_hex);
@@ -254,8 +276,8 @@ static cJSON *login_readonly(cJSON *params) {
return make_error("MISSING_PARAM", "Provide 'npub' or 'pubkey_hex'");
}
/* Read-only: no signer created. */
app_set_signer(NULL, pubkey_hex, KEY_STORE_METHOD_READONLY, TRUE);
/* Read-only: no signer created. No privkey available. */
app_set_signer(NULL, pubkey_hex, NULL, KEY_STORE_METHOD_READONLY, TRUE);
nostr_bridge_set_signer(NULL, pubkey_hex, TRUE);
g_print("[agent-login] Read-only: pubkey=%s\n", pubkey_hex);
@@ -290,7 +312,13 @@ static cJSON *login_nip46(cJSON *params) {
return make_error("SIGNER_FAILED", "Failed to create client signer");
}
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_NIP46, FALSE);
/* NIP-46: the client_privkey is a throwaway session key for the bunker
* protocol, not the user's identity key. The user's actual signing key
* lives on the remote bunker. We do NOT use it for bookmark HMAC d tags
* (the bunker signer handles NIP-44 content encryption; d-tag derivation
* would require the user's real privkey, which we don't have). Bookmarks
* can still be loaded from the db in read-only fashion. */
app_set_signer(signer, pubkey_hex, NULL, KEY_STORE_METHOD_NIP46, FALSE);
nostr_bridge_set_signer(signer, pubkey_hex, FALSE);
g_print("[agent-login] NIP-46: remote signer pubkey=%s\n", pubkey_hex);
@@ -357,6 +385,12 @@ static cJSON *login_nsigner(cJSON *params) {
return make_error("NSIGNER_CONNECT", "Failed to connect to n_signer. Check the device/path/qube.");
}
/* n_signer's serial/TCP transports require a kind-27235 auth envelope on
* every request. Install the default caller identity (matching n_signer's
* webserial demo) before issuing any verbs. Without this the device
* rejects the call with an auth error that surfaces as "code -310". */
key_store_nsigner_set_default_auth(signer);
int rc = nostr_signer_nsigner_set_nostr_index(signer, index);
if (rc != NOSTR_SUCCESS) {
nostr_signer_free(signer);
@@ -367,14 +401,37 @@ static cJSON *login_nsigner(cJSON *params) {
char pubkey_hex[65];
rc = nostr_signer_get_public_key(signer, pubkey_hex);
if (rc != NOSTR_SUCCESS) {
const char *err_str = nostr_strerror(rc);
g_printerr("[agent-login] n_signer get_public_key failed: rc=%d (%s)\n",
rc, err_str ? err_str : "?");
nostr_signer_free(signer);
sigprocmask(SIG_SETMASK, &old_set, NULL);
return make_error("NSIGNER_PUBKEY", "Failed to get pubkey from n_signer.");
if (rc == NOSTR_ERROR_NIP46_AUTH_CHALLENGE) {
/* Device RPC codes 2010-2017: auth-related rejection. Most
* commonly the kind-27235 caller auth envelope was missing or
* denied (the default caller identity is installed automatically,
* but the device's policy may still deny it). Can also mean the
* device is requesting on-device approval. */
return make_error("NSIGNER_AUTH_REJECTED",
"n_signer auth rejected (code -310). The caller "
"auth envelope was missing or denied, or the "
"device is requesting on-device approval. Confirm "
"any prompt on the hardware signer, then retry.");
}
{
char msg[256];
snprintf(msg, sizeof(msg),
"Failed to get pubkey from n_signer (rc=%d: %s).",
rc, err_str ? err_str : "unknown");
return make_error("NSIGNER_PUBKEY", msg);
}
}
sigprocmask(SIG_SETMASK, &old_set, NULL);
app_set_signer(signer, pubkey_hex, KEY_STORE_METHOD_NSIGNER, FALSE);
/* n_signer: the privkey never leaves the hardware device, so we can't
* derive HMAC d tags client-side. Bookmarks fall back to read-only. */
app_set_signer(signer, pubkey_hex, NULL, KEY_STORE_METHOD_NSIGNER, FALSE);
nostr_bridge_set_signer(signer, pubkey_hex, FALSE);
g_print("[agent-login] n_signer: transport=%s index=%d pubkey=%s\n",
@@ -459,6 +516,13 @@ cJSON *agent_login(cJSON *params) {
}
cJSON *agent_logout(void) {
/* Tear down the current user's web state (closes all tabs, destroys
* sidebar webviews, wipes WebKit cookies/cache/localStorage/service
* workers/favicons) so the next login starts with a clean web
* session. Must happen before clearing the signer. See
* plans/webkit-data-isolation.md. */
identity_teardown_web_state();
app_clear_signer();
nostr_bridge_set_signer(NULL, "", TRUE);
g_print("[agent-login] Logged out\n");
@@ -466,6 +530,12 @@ cJSON *agent_logout(void) {
}
cJSON *agent_switch_identity(cJSON *params) {
/* Tear down the previous user's web state BEFORE freeing the signer
* and logging in the new identity, so the new user starts with a
* clean web session (no leftover cookies/localStorage/tabs from the
* previous user). See plans/webkit-data-isolation.md. */
identity_teardown_web_state();
/* Free the old signer first. */
app_clear_signer();
/* Then login with the new identity. */

484
src/agent_loop.c Normal file
View File

@@ -0,0 +1,484 @@
/*
* agent_loop.c — ReAct tool-call loop on a background GThread
*
* Implements the standard ReAct loop:
* 1. Load chat history from agent_chat_store_get_messages().
* 2. Prepend a system prompt message (must be first in the array).
* 3. Call the LLM via agent_llm_chat().
* 4. If the response contains tool_calls, dispatch each:
* - Browser tools → hop to the GTK main thread via g_idle_add() +
* GAsyncQueue (WebKitGTK is not thread-safe).
* - Filesystem/shell tools → run directly on this thread via
* agent_fs_tools_dispatch().
* 5. Append tool results to the chat store.
* 6. Repeat until no more tool_calls, the iteration cap is reached, or
* the cancel flag is set.
* 7. Persist the final assistant message and update session status.
*
* See plans/embedded-agent.md for the full concurrency model.
*/
#include "agent_loop.h"
#include "agent_llm.h"
#include "agent_chat_store.h"
#include "agent_fs_tools.h"
#include "agent_tools.h"
#include "agent_skills.h"
#include "agent_server.h"
#include "settings.h"
#include "cjson/cJSON.h"
#include <glib.h>
#include <string.h>
/* ── Context ────────────────────────────────────────────────────────── */
typedef struct {
/* Atomic flags */
volatile gint cancel_flag;
volatile gint running; /* gboolean as gint */
/* Status (protected by status_lock) */
GMutex status_lock;
agent_loop_state_t state;
int iteration;
char *current_tool; /* tool name being executed */
char *last_message; /* last assistant text */
char *error; /* error message */
/* Thread handle */
GThread *thread;
} agent_loop_ctx_t;
static agent_loop_ctx_t g_ctx = {0};
/* ── Status helpers ─────────────────────────────────────────────────── */
/* Map a state enum to its string name for JSON events. */
static const char *
state_name(agent_loop_state_t s)
{
switch (s) {
case AGENT_LOOP_IDLE: return "idle";
case AGENT_LOOP_THINKING: return "thinking";
case AGENT_LOOP_TOOL_CALL: return "tool_call";
case AGENT_LOOP_COMPLETE: return "complete";
case AGENT_LOOP_ERROR: return "error";
case AGENT_LOOP_CANCELLED: return "cancelled";
}
return "idle";
}
/* Emit the current status as a WebSocket push event so the chat UI
* can update instantly without polling. Must be called WITHOUT the
* mutex held (it takes it itself). */
static void
emit_status_event(void)
{
g_mutex_lock(&g_ctx.status_lock);
cJSON *data = cJSON_CreateObject();
cJSON_AddStringToObject(data, "state", state_name(g_ctx.state));
cJSON_AddNumberToObject(data, "iteration", g_ctx.iteration);
if (g_ctx.current_tool)
cJSON_AddStringToObject(data, "current_tool", g_ctx.current_tool);
else
cJSON_AddNullToObject(data, "current_tool");
if (g_ctx.last_message)
cJSON_AddStringToObject(data, "last_message", g_ctx.last_message);
else
cJSON_AddNullToObject(data, "last_message");
if (g_ctx.error)
cJSON_AddStringToObject(data, "error", g_ctx.error);
else
cJSON_AddNullToObject(data, "error");
g_mutex_unlock(&g_ctx.status_lock);
/* agent_server_emit_event takes ownership of data. */
agent_server_emit_event("agent_status", data);
}
static void
set_status(agent_loop_state_t state, int iter,
const char *tool, const char *msg, const char *err)
{
g_mutex_lock(&g_ctx.status_lock);
g_ctx.state = state;
g_ctx.iteration = iter;
if (tool) {
g_free(g_ctx.current_tool);
g_ctx.current_tool = g_strdup(tool);
} else {
/* Clear tool name when not in a tool-call state. */
g_free(g_ctx.current_tool);
g_ctx.current_tool = NULL;
}
if (msg) {
g_free(g_ctx.last_message);
g_ctx.last_message = g_strdup(msg);
}
/* Only set the error field when err is non-NULL. When err is NULL
* and we're transitioning to a non-error state, clear any stale
* error so the UI doesn't show an old error message. */
if (err) {
g_free(g_ctx.error);
g_ctx.error = g_strdup(err);
} else if (state != AGENT_LOOP_ERROR) {
g_free(g_ctx.error);
g_ctx.error = NULL;
}
g_mutex_unlock(&g_ctx.status_lock);
emit_status_event();
}
static void
set_state(agent_loop_state_t state)
{
g_mutex_lock(&g_ctx.status_lock);
g_ctx.state = state;
/* Clear stale error when transitioning to a non-error state. */
if (state != AGENT_LOOP_ERROR) {
g_free(g_ctx.error);
g_ctx.error = NULL;
}
/* Clear tool name when leaving the tool_call state. */
if (state != AGENT_LOOP_TOOL_CALL) {
g_free(g_ctx.current_tool);
g_ctx.current_tool = NULL;
}
g_mutex_unlock(&g_ctx.status_lock);
emit_status_event();
}
static void
set_error(const char *msg)
{
g_mutex_lock(&g_ctx.status_lock);
g_ctx.state = AGENT_LOOP_ERROR;
g_free(g_ctx.error);
g_ctx.error = g_strdup(msg);
g_mutex_unlock(&g_ctx.status_lock);
/* Emit a status event so the UI learns about the error immediately.
* Without this, the chat page stays stuck on "Thinking..." because
* the WebSocket push never fires and the polling fallback may have
* been stopped when the WebSocket connected. */
emit_status_event();
}
/* ── Build messages array with system prompt first ──────────────────── */
/*
* Build the messages array for the LLM: a system message first, then
* all messages from the chat store. Returns a newly-allocated cJSON
* array (caller frees with cJSON_Delete), or NULL on error.
*/
static cJSON *
build_messages_with_system(const char *system_prompt)
{
cJSON *history = agent_chat_store_get_messages();
if (history == NULL)
return NULL;
cJSON *messages = cJSON_CreateArray();
if (messages == NULL) {
cJSON_Delete(history);
return NULL;
}
/* System prompt must be the first message */
cJSON *sys_msg = cJSON_CreateObject();
cJSON_AddStringToObject(sys_msg, "role", "system");
cJSON_AddStringToObject(sys_msg, "content", system_prompt);
cJSON_AddItemToArray(messages, sys_msg);
/* Copy all history messages into the new array */
cJSON *msg;
cJSON_ArrayForEach(msg, history) {
cJSON_AddItemToArray(messages, cJSON_Duplicate(msg, 1));
}
cJSON_Delete(history);
return messages;
}
/* ── Background thread ──────────────────────────────────────────────── */
static gpointer
agent_loop_thread(gpointer data)
{
char *user_message = (char *)data;
g_print("[agent-loop] Thread started, message: %s\n", user_message);
/* 1. Add user message to chat store */
agent_chat_store_add_user_message(user_message);
g_free(user_message);
/* Notify UI that a message was added. */
cJSON *umsg = cJSON_CreateObject();
cJSON_AddStringToObject(umsg, "role", "user");
agent_server_emit_event("agent_message", umsg);
/* 2. Get provider settings (copy — they could change while running) */
const browser_settings_t *s = settings_get();
char base_url[512], api_key[512], model[128];
g_strlcpy(base_url, s->agent_llm_base_url, sizeof(base_url));
g_strlcpy(api_key, s->agent_llm_api_key, sizeof(api_key));
g_strlcpy(model, s->agent_llm_model, sizeof(model));
g_print("[agent-loop] base_url=%s model=%s api_key=%s max_iter=%d\n",
base_url, model, api_key[0] ? "(set)" : "(empty)", s->agent_max_iterations);
/* Build the system prompt. agent_skills_build_system_prompt() now
* always returns a non-NULL string: it starts with the Sovereign
* Browser Skill template (from settings) as the base, then
* appends any selected Nostr skills' templates. */
char *system_prompt = agent_skills_build_system_prompt();
if (system_prompt == NULL) {
/* Defensive fallback — should never happen. */
system_prompt = g_strdup(SETTINGS_AGENT_SYSTEM_PROMPT_DEFAULT);
}
g_print("[agent-loop] Using system prompt (%zu bytes)\n",
strlen(system_prompt));
int max_iter = s->agent_max_iterations;
if (max_iter <= 0)
max_iter = SETTINGS_AGENT_MAX_ITERATIONS_DEFAULT;
/* Empty API key → pass NULL (local servers like Ollama don't need one) */
const char *key_arg = (api_key[0] != '\0') ? api_key : NULL;
/* 3. Build OpenAI tools array (built once, reused each iteration) */
cJSON *tools = agent_llm_build_openai_tools();
/* 4. ReAct loop */
for (int iter = 0; iter < max_iter; iter++) {
/* Check cancel */
if (g_atomic_int_get(&g_ctx.cancel_flag)) {
set_state(AGENT_LOOP_CANCELLED);
break;
}
/* Update status: thinking */
set_status(AGENT_LOOP_THINKING, iter, NULL, NULL, NULL);
/* Build messages: system prompt first, then chat history */
cJSON *messages = build_messages_with_system(system_prompt);
if (messages == NULL) {
set_error("Failed to load messages");
break;
}
/* Call LLM (blocking HTTP on this thread) */
g_print("[agent-loop] iter %d: calling LLM...\n", iter);
agent_llm_response_t *resp = agent_llm_chat(base_url, key_arg,
model, messages, tools);
cJSON_Delete(messages);
if (resp == NULL) {
g_print("[agent-loop] iter %d: LLM call returned NULL\n", iter);
set_error("LLM API call failed");
break;
}
g_print("[agent-loop] iter %d: LLM responded, finish=%s, content=%s, tool_calls=%d\n",
iter, resp->finish_reason ? resp->finish_reason : "(null)",
resp->content ? resp->content : "(null)",
resp->tool_calls ? cJSON_GetArraySize(resp->tool_calls) : 0);
/* Persist assistant message */
char *tool_calls_str = (resp->tool_calls != NULL)
? cJSON_PrintUnformatted(resp->tool_calls) : NULL;
agent_chat_store_add_assistant_message(resp->content, tool_calls_str);
g_free(tool_calls_str);
/* Notify UI that a message was added. */
cJSON *amsg = cJSON_CreateObject();
cJSON_AddStringToObject(amsg, "role", "assistant");
agent_server_emit_event("agent_message", amsg);
/* Update last_message status */
set_status(AGENT_LOOP_THINKING, iter, NULL,
resp->content ? resp->content : "", NULL);
/* If no tool_calls, we're done */
if (resp->tool_calls == NULL ||
cJSON_GetArraySize(resp->tool_calls) == 0) {
agent_llm_response_free(resp);
set_state(AGENT_LOOP_COMPLETE);
goto done;
}
/* Dispatch each tool call */
cJSON *tc;
cJSON_ArrayForEach(tc, resp->tool_calls) {
if (g_atomic_int_get(&g_ctx.cancel_flag))
break;
cJSON *fn = cJSON_GetObjectItem(tc, "function");
const char *tool_name = fn
? cJSON_GetStringValue(cJSON_GetObjectItem(fn, "name"))
: NULL;
const char *args_str = fn
? cJSON_GetStringValue(cJSON_GetObjectItem(fn, "arguments"))
: NULL;
const char *tc_id = cJSON_GetStringValue(cJSON_GetObjectItem(tc, "id"));
cJSON *args = (args_str != NULL)
? cJSON_Parse(args_str) : cJSON_CreateObject();
if (args == NULL)
args = cJSON_CreateObject();
set_status(AGENT_LOOP_TOOL_CALL, iter,
tool_name ? tool_name : "?", NULL, NULL);
g_print("[agent-loop] iter %d: tool %s (args: %s)\n",
iter, tool_name ? tool_name : "?",
args_str ? args_str : "(null)");
/* Dispatch: fs/shell tools and browser tools both run on
* this background thread. The browser tools use the same
* sync JS eval path (conn=NULL) as the MCP HTTP handler,
* which calls gtk_main_iteration() to pump the main loop
* while waiting for the async JS result. This is safe to
* call from a background thread — the main loop keeps
* running and processes the JS eval callback. */
cJSON *result;
if (tool_name != NULL && agent_fs_is_tool(tool_name)) {
result = agent_fs_tools_dispatch(tool_name, args);
} else {
/* Build the request JSON and dispatch directly (same
* as the MCP HTTP handler does from the libsoup thread). */
cJSON *request = cJSON_CreateObject();
cJSON_AddStringToObject(request, "tool",
tool_name ? tool_name : "");
cJSON_AddItemToObject(request, "params", args);
args = NULL; /* transferred to request */
result = agent_tools_dispatch(request, NULL);
cJSON_Delete(request);
}
if (args) cJSON_Delete(args);
/* Get result JSON string */
char *result_str = (result != NULL)
? cJSON_PrintUnformatted(result) : g_strdup("{}");
cJSON_Delete(result);
/* Persist tool result */
agent_chat_store_add_tool_result(
tc_id ? tc_id : "", result_str ? result_str : "{}");
g_free(result_str);
/* Notify UI that a tool result was added. */
cJSON *tmsg = cJSON_CreateObject();
cJSON_AddStringToObject(tmsg, "role", "tool");
agent_server_emit_event("agent_message", tmsg);
}
agent_llm_response_free(resp);
}
/* If we exited the loop without completing or erroring, we hit the
* iteration cap — treat as complete (the LLM was still working). */
{
g_mutex_lock(&g_ctx.status_lock);
if (g_ctx.state != AGENT_LOOP_CANCELLED &&
g_ctx.state != AGENT_LOOP_ERROR) {
g_ctx.state = AGENT_LOOP_COMPLETE;
}
g_mutex_unlock(&g_ctx.status_lock);
}
done:
g_free(system_prompt);
cJSON_Delete(tools);
g_atomic_int_set(&g_ctx.running, 0);
return NULL;
}
/* ── Public API ─────────────────────────────────────────────────────── */
/*
* Lazily initialize the mutex on first use. g_mutex_init() is idempotent
* enough for our purposes — we guard with a g_once_init_enter block so
* it only happens once.
*/
static void
ensure_mutex_init(void)
{
static gsize initialized = 0;
if (g_once_init_enter(&initialized)) {
g_mutex_init(&g_ctx.status_lock);
g_ctx.state = AGENT_LOOP_IDLE;
g_once_init_leave(&initialized, 1);
}
}
int
agent_loop_run(const char *user_message)
{
g_print("[agent-loop] agent_loop_run called: %s\n",
user_message ? user_message : "(null)");
if (user_message == NULL)
return -1;
ensure_mutex_init();
if (g_atomic_int_get(&g_ctx.running)) {
g_print("[agent-loop] already running, rejecting\n");
return -1; /* already running */
}
/* Check that a provider is configured (base URL + model).
* An empty API key is allowed (local servers like Ollama). */
const browser_settings_t *s = settings_get();
if (s->agent_llm_base_url[0] == '\0' || s->agent_llm_model[0] == '\0') {
g_print("[agent-loop] no provider configured: base_url=%s model=%s\n",
s->agent_llm_base_url, s->agent_llm_model);
set_error("No LLM provider configured (set base URL and model on "
"sovereign://agents)");
return -1;
}
/* Reset cancel flag */
g_atomic_int_set(&g_ctx.cancel_flag, 0);
/* Reset status */
set_status(AGENT_LOOP_THINKING, 0, NULL, NULL, NULL);
g_atomic_int_set(&g_ctx.running, 1);
/* Spawn background thread (takes ownership of the duplicated string) */
g_ctx.thread = g_thread_new("agent-loop", agent_loop_thread,
g_strdup(user_message));
return 0;
}
void
agent_loop_cancel(void)
{
g_atomic_int_set(&g_ctx.cancel_flag, 1);
}
gboolean
agent_loop_is_running(void)
{
return g_atomic_int_get(&g_ctx.running) != 0;
}
void
agent_loop_get_status(agent_loop_state_t *state_out,
int *iteration_out,
char **current_tool_out,
char **last_message_out,
char **error_out)
{
ensure_mutex_init();
g_mutex_lock(&g_ctx.status_lock);
if (state_out)
*state_out = g_ctx.state;
if (iteration_out)
*iteration_out = g_ctx.iteration;
if (current_tool_out)
*current_tool_out = g_strdup(g_ctx.current_tool);
if (last_message_out)
*last_message_out = g_strdup(g_ctx.last_message);
if (error_out)
*error_out = g_strdup(g_ctx.error);
g_mutex_unlock(&g_ctx.status_lock);
}

81
src/agent_loop.h Normal file
View File

@@ -0,0 +1,81 @@
/*
* agent_loop.h — ReAct tool-call loop for the embedded agent
*
* Runs the standard ReAct (reason → act → observe) loop on a background
* GThread so the GTK main thread stays responsive during long LLM calls
* and multi-step tool sequences.
*
* Threading model (see plans/embedded-agent.md):
* - LLM HTTP calls block on the background thread.
* - Browser tools (snapshot, click, eval, …) hop to the GTK main thread
* via g_idle_add() + GAsyncQueue because WebKitGTK is not thread-safe.
* - Filesystem/shell tools run directly on the background thread.
* - SQLite writes use the FULLMUTEX connection and are safe from any thread.
* - Cancellation is via an atomic flag checked at the top of each iteration.
*/
#ifndef AGENT_LOOP_H
#define AGENT_LOOP_H
#include <glib.h>
#ifdef __cplusplus
extern "C" {
#endif
/*
* Status states for the agent loop.
*/
typedef enum {
AGENT_LOOP_IDLE,
AGENT_LOOP_THINKING,
AGENT_LOOP_TOOL_CALL,
AGENT_LOOP_COMPLETE,
AGENT_LOOP_ERROR,
AGENT_LOOP_CANCELLED
} agent_loop_state_t;
/*
* Start the agent loop for a user message. This:
* 1. Adds the user message to the chat store.
* 2. Spawns a background GThread that runs the ReAct loop.
* 3. Returns immediately (non-blocking).
*
* Returns 0 on success, -1 on error (e.g. already running, no provider
* configured).
*/
int agent_loop_run(const char *user_message);
/*
* Request cancellation of the running agent loop.
* Sets an atomic flag checked at the top of each loop iteration.
* The background thread will exit at the next check point.
*/
void agent_loop_cancel(void);
/*
* Check if the agent loop is currently running.
*/
gboolean agent_loop_is_running(void);
/*
* Get the current status of the agent loop. Returns the state and
* fills the optional output parameters with current status info.
*
* state_out — current state (may be NULL)
* iteration_out — current iteration number (may be NULL)
* current_tool_out — name of tool being executed (may be NULL, caller g_free)
* last_message_out — most recent assistant text (may be NULL, caller g_free)
* error_out — error message if state is ERROR (may be NULL, caller g_free)
*/
void agent_loop_get_status(agent_loop_state_t *state_out,
int *iteration_out,
char **current_tool_out,
char **last_message_out,
char **error_out);
#ifdef __cplusplus
}
#endif
#endif /* AGENT_LOOP_H */

View File

@@ -15,6 +15,7 @@
#include "agent_tools.h"
#include "agent_snapshot.h"
#include "agent_login.h"
#include "agent_tool_catalog.h"
#include "tab_manager.h"
#include "version.h"
@@ -117,17 +118,13 @@ static cJSON *rpc_error(int id, int code, const char *message) {
}
/* ── Tool catalog ─────────────────────────────────────────────────── *
* Static array of tool definitions. Each has a name, description,
* and JSON Schema for input parameters.
* Array of tool definitions. Each has a name, description, and JSON
* Schema for input parameters. The mcp_tool_def_t struct, the array,
* and build_tools_list() are declared in agent_tool_catalog.h so they
* can be shared with the embedded agent (agent_llm.c).
*/
typedef struct {
const char *name;
const char *description;
const char *schema_json; /* pre-built JSON schema string */
} mcp_tool_def_t;
static mcp_tool_def_t tool_defs[] = {
const mcp_tool_def_t tool_defs[] = {
/* Login tools */
{"login_status",
"Check if the browser is logged in. Returns the current login state, method, and pubkey if logged in.",
@@ -162,6 +159,10 @@ static mcp_tool_def_t tool_defs[] = {
"Reload the current page (bypassing cache).",
"{\"type\":\"object\",\"properties\":{}}"},
{"stop",
"Stop loading the current page. Mirrors the toolbar stop button.",
"{\"type\":\"object\",\"properties\":{}}"},
{"get_url",
"Get the current URL of the active tab.",
"{\"type\":\"object\",\"properties\":{}}"},
@@ -548,13 +549,51 @@ static mcp_tool_def_t tool_defs[] = {
{"screenshot_annotated",
"Take a screenshot with element ref labels overlaid on the page. Combines snapshot and screenshot — returns both an image and the text accessibility tree.",
"{\"type\":\"object\",\"properties\":{\"interactive\":{\"type\":\"boolean\",\"default\":true},\"compact\":{\"type\":\"boolean\",\"default\":true}}}"},
/* Filesystem & shell tools (work before login — system-level) */
{"fs_read",
"Read a file's text contents from the local filesystem. The browser runs in a dedicated qube, so full access is intended. Returns the file content as a string.",
"{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Absolute or relative path to the file\"}},\"required\":[\"path\"]}"},
{"fs_write",
"Write text to a file on the local filesystem. Overwrites if the file exists, creates it (and parent directories) if it doesn't. Returns the number of bytes written.",
"{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file\"},\"content\":{\"type\":\"string\",\"description\":\"Text content to write\"}},\"required\":[\"path\",\"content\"]}"},
{"fs_list",
"List directory entries (files and subdirectories). Returns each entry's name, type (file/dir/link/other), and size in bytes.",
"{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the directory\"}},\"required\":[\"path\"]}"},
{"fs_mkdir",
"Create a directory, including parent directories if needed (recursive, like mkdir -p).",
"{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the directory to create\"}},\"required\":[\"path\"]}"},
{"fs_delete",
"Delete a file or an empty directory. Non-empty directories must be removed with shell_exec (e.g. rm -rf).",
"{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file or empty directory\"}},\"required\":[\"path\"]}"},
{"shell_exec",
"Run a shell command via /bin/sh -c and return stdout, stderr, and the exit code. The browser runs in a dedicated qube, so full shell access is intended. A timeout (default 30000ms) kills the command if it runs too long.",
"{\"type\":\"object\",\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout_ms\":{\"type\":\"integer\",\"default\":30000,\"description\":\"Timeout in milliseconds\"}},\"required\":[\"command\"]}"},
/* Process / per-tab diagnostics (sovereign://processes) */
{"processes.list",
"List all sovereign_browser processes (main, WebKit renderers with their hosted tabs, WebKit network/gpu/storage, managed Tor/FIPS) with CPU%, RSS/PSS/USS, threads, uptime, I/O, FD count, and service state. CPU% is from /proc utime+stime deltas — the first call returns 0 for all processes (no baseline); poll ~1s for accurate values. Use this to find which WebProcess is hot.",
"{\"type\":\"object\",\"properties\":{},\"required\":[]}"},
{"processes.tabs",
"List all open tabs with their per-tab performance probe (cpu_busy_percent from long-task sum, fps, active timer count, net in-flight, heap used, event listener count, worker count, DOM node count) and WebProcess PID. Sort by cpu_busy_percent to find the tab hogging CPU within a shared WebProcess. Internal sovereign:// tabs have null probes.",
"{\"type\":\"object\",\"properties\":{},\"required\":[]}"},
{"processes.tab_probe",
"Get the full drill-down probe for a single tab: long-task timeline (last 30s), top long-task sources (script URLs + total ms), top active timers (code + interval + count), top network endpoints, heap, workers, DOM nodes. Use after processes.tabs identifies a hot tab to find out WHAT it is doing.",
"{\"type\":\"object\",\"properties\":{\"tab_index\":{\"type\":\"integer\",\"description\":\"Tab index from processes.tabs\"}},\"required\":[\"tab_index\"]}"},
};
static int tool_defs_count = sizeof(tool_defs) / sizeof(tool_defs[0]);
const int tool_defs_count = (int)(sizeof(tool_defs) / sizeof(tool_defs[0]));
/* ── Build tools/list response ────────────────────────────────────── */
static cJSON *build_tools_list(void) {
cJSON *build_tools_list(void) {
cJSON *tools = cJSON_CreateArray();
for (int i = 0; i < tool_defs_count; i++) {
cJSON *tool = cJSON_CreateObject();

View File

@@ -14,10 +14,50 @@
#include <libsoup/soup.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
/* Forward declarations from agent_tools.c */
extern cJSON *agent_tools_dispatch(cJSON *request, SoupWebsocketConnection *conn);
/* ── Discovery file ───────────────────────────────────────────────── *
* When the configured port (default 17777) is already in use by another
* browser instance, we fall back to an OS-assigned port (bind to 0). So
* that external clients and browser.sh can find the actual port, we write
* a per-instance discovery file at /tmp/sovereign_browser_<pid>.port
* containing the PID and port. browser.sh globs these files to enumerate
* running instances. The file is removed on clean shutdown. */
static char g_discovery_path[128] = {0};
static void build_discovery_path(char *out, size_t out_sz) {
snprintf(out, out_sz, "/tmp/sovereign_browser_%d.port", (int)getpid());
}
static void write_discovery_file(int port) {
char path[128];
FILE *fp;
build_discovery_path(path, sizeof(path));
fp = fopen(path, "w");
if (fp == NULL) {
g_printerr("[agent] Failed to write discovery file %s\n", path);
return;
}
/* Format: "PID PORT\n" — easy to parse from shell with `read`. */
fprintf(fp, "%d %d\n", (int)getpid(), port);
fclose(fp);
snprintf(g_discovery_path, sizeof(g_discovery_path), "%s", path);
g_print("[agent] Discovery file: %s (pid=%d port=%d)\n",
path, (int)getpid(), port);
}
static void remove_discovery_file(void) {
if (g_discovery_path[0] != '\0') {
unlink(g_discovery_path);
g_discovery_path[0] = '\0';
}
}
/* ── Static state ─────────────────────────────────────────────────── */
static SoupServer *g_server = NULL;
@@ -210,8 +250,20 @@ int agent_server_start(int port) {
/* Add MCP handler at /mcp. */
agent_mcp_register(g_server);
/* Listen on the specified port (0 = auto-assign). */
/* Listen on the specified port. If it's already in use (another browser
* instance is running), fall back to port 0 so the OS picks a free port.
* The actual bound port is written to a discovery file so browser.sh and
* external MCP clients can find it. */
soup_server_listen_local(g_server, port, 0, &error);
if (error != NULL) {
if (port != 0) {
g_printerr("[agent] Port %d in use (%s) — falling back to OS auto-assign.\n",
port, error->message);
g_error_free(error);
error = NULL;
soup_server_listen_local(g_server, 0, 0, &error);
}
}
if (error != NULL) {
g_printerr("[agent] Failed to listen on port %d: %s\n", port, error->message);
g_error_free(error);
@@ -233,6 +285,11 @@ int agent_server_start(int port) {
g_running = TRUE;
g_print("[agent] WebSocket server listening on ws://localhost:%d/agent\n", g_port);
g_print("[agent] Status endpoint: http://localhost:%d/\n", g_port);
if (g_port != port) {
g_print("[agent] Note: using auto-assigned port %d (configured port %d was in use).\n",
g_port, port);
}
write_discovery_file(g_port);
return 0;
}
@@ -258,6 +315,7 @@ void agent_server_stop(void) {
g_running = FALSE;
g_port = 0;
remove_discovery_file();
g_print("[agent] Server stopped\n");
}

833
src/agent_skills.c Normal file
View File

@@ -0,0 +1,833 @@
/*
* agent_skills.c — Nostr kind 31123 skill management for sovereign_browser
*
* Implements fetch, selection, system-prompt building, publish, and delete
* for kind 31123 skill events. Skills are PUBLIC events (no NIP-44
* encryption) authored by any user. sovereign_browser fetches them from
* the local SQLite cache (populated by the relay fetch thread on startup)
* and lets users select skills whose templates are concatenated into the
* system prompt.
*
* Reuses the event signing + relay publishing patterns from
* settings_sync.c, bookmarks.c, and agent_conversations.c.
*
* See plans/cross-project-agent-sync.md (Phase C/D) for the full design.
*/
#include "agent_skills.h"
#include "db.h"
#include "settings.h"
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <time.h>
#include "nostr_core/nostr_core.h"
#include "../nostr_core_lib/cjson/cJSON.h"
/* ── Global state ───────────────────────────────────────────────────── */
static nostr_signer_t *g_signer = NULL;
static char g_pubkey[65] = {0};
static int g_have_signer = 0;
/* ── Init ───────────────────────────────────────────────────────────── */
void agent_skills_init(nostr_signer_t *signer, const char *pubkey_hex) {
g_signer = signer;
g_have_signer = (signer != NULL);
if (pubkey_hex) {
snprintf(g_pubkey, sizeof(g_pubkey), "%s", pubkey_hex);
} else {
g_pubkey[0] = '\0';
}
}
void agent_skills_set_signer(nostr_signer_t *signer, const char *pubkey_hex) {
agent_skills_init(signer, pubkey_hex);
}
/* ── Helpers ────────────────────────────────────────────────────────── */
/* Parse bootstrap relays from settings into an array.
* Returns the count. Fills urls_out (pointers into relay_buf).
* Caller must g_free(relay_buf) after use. (Same pattern as
* settings_sync.c, bookmarks.c, agent_conversations.c.) */
static int parse_relays(char **relay_buf, const char **urls_out, int max) {
const browser_settings_t *s = settings_get();
*relay_buf = g_strdup(s->bootstrap_relays);
int count = 0;
char *saveptr = NULL;
char *line = strtok_r(*relay_buf, "\n\r", &saveptr);
while (line != NULL && count < max) {
while (*line == ' ' || *line == '\t') line++;
if (line[0] != '\0' &&
(strncmp(line, "wss://", 6) == 0 ||
strncmp(line, "ws://", 5) == 0)) {
urls_out[count++] = line;
}
line = strtok_r(NULL, "\n\r", &saveptr);
}
return count;
}
/* Get the value of the first tag with the given name. Returns a pointer
* into the event's tags (or NULL). Not owned by caller. */
static const char *event_tag_value(const cJSON *event, const char *name) {
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!cJSON_IsArray(tags)) return NULL;
cJSON *tag;
cJSON_ArrayForEach(tag, tags) {
if (!cJSON_IsArray(tag)) continue;
cJSON *t0 = cJSON_GetArrayItem(tag, 0);
cJSON *t1 = cJSON_GetArrayItem(tag, 1);
if (cJSON_IsString(t0) && strcmp(t0->valuestring, name) == 0 &&
cJSON_IsString(t1)) {
return t1->valuestring;
}
}
return NULL;
}
/* Check whether an event has a tag [name, value]. Returns 1/0. */
static int event_has_tag(const cJSON *event, const char *name,
const char *value) {
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!cJSON_IsArray(tags)) return 0;
cJSON *tag;
cJSON_ArrayForEach(tag, tags) {
if (!cJSON_IsArray(tag)) continue;
cJSON *t0 = cJSON_GetArrayItem(tag, 0);
cJSON *t1 = cJSON_GetArrayItem(tag, 1);
if (cJSON_IsString(t0) && strcmp(t0->valuestring, name) == 0 &&
cJSON_IsString(t1) && strcmp(t1->valuestring, value) == 0) {
return 1;
}
}
return 0;
}
/* Collect all values of tags with the given name into a newly allocated
* cJSON array of strings. Caller must cJSON_Delete(). */
static cJSON *event_tag_values(const cJSON *event, const char *name) {
cJSON *result = cJSON_CreateArray();
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!cJSON_IsArray(tags)) return result;
cJSON *tag;
cJSON_ArrayForEach(tag, tags) {
if (!cJSON_IsArray(tag)) continue;
cJSON *t0 = cJSON_GetArrayItem(tag, 0);
cJSON *t1 = cJSON_GetArrayItem(tag, 1);
if (cJSON_IsString(t0) && strcmp(t0->valuestring, name) == 0 &&
cJSON_IsString(t1)) {
cJSON_AddItemToArray(result, cJSON_CreateString(t1->valuestring));
}
}
return result;
}
/* ── Fetch / list ──────────────────────────────────────────────────── */
/* Parse a single kind 31123 event into an agent_skill_t struct.
* Returns 0 on success, -1 on error. On success, the skill's owned
* fields (content, requires_tools) are allocated and must be freed
* via agent_skills_free_list(). */
static int parse_skill_event(const cJSON *event, agent_skill_t *out) {
if (event == NULL || out == NULL) return -1;
memset(out, 0, sizeof(*out));
/* d-tag (unique identifier) */
const char *d = event_tag_value(event, "d");
if (d == NULL || d[0] == '\0') return -1;
snprintf(out->d_tag, sizeof(out->d_tag), "%s", d);
/* name tag */
const char *name = event_tag_value(event, "name");
if (name) {
snprintf(out->name, sizeof(out->name), "%s", name);
} else {
/* Fall back to the d-tag if no name tag. */
snprintf(out->name, sizeof(out->name), "%s", d);
}
/* description tag */
const char *desc = event_tag_value(event, "description");
if (desc) {
snprintf(out->description, sizeof(out->description), "%s", desc);
}
/* author pubkey */
cJSON *pk = cJSON_GetObjectItemCaseSensitive(event, "pubkey");
if (cJSON_IsString(pk)) {
snprintf(out->pubkey, sizeof(out->pubkey), "%s", pk->valuestring);
}
/* content (system prompt template) */
cJSON *content = cJSON_GetObjectItemCaseSensitive(event, "content");
if (cJSON_IsString(content)) {
out->content = g_strdup(content->valuestring);
} else {
out->content = g_strdup("");
}
/* requires_tool tags */
cJSON *tools = event_tag_values(event, "requires_tool");
if (tools != NULL) {
int n = cJSON_GetArraySize(tools);
if (n > AGENT_SKILL_MAX_TOOLS) n = AGENT_SKILL_MAX_TOOLS;
out->tool_count = n;
if (n > 0) {
out->requires_tools = g_new0(char *, n);
for (int i = 0; i < n; i++) {
cJSON *t = cJSON_GetArrayItem(tools, i);
if (cJSON_IsString(t)) {
out->requires_tools[i] = g_strdup(t->valuestring);
} else {
out->requires_tools[i] = g_strdup("");
}
}
}
cJSON_Delete(tools);
}
return 0;
}
void agent_skills_free_list(agent_skill_t *skills, int count) {
if (skills == NULL) return;
for (int i = 0; i < count; i++) {
g_free(skills[i].content);
if (skills[i].requires_tools) {
for (int j = 0; j < skills[i].tool_count; j++) {
g_free(skills[i].requires_tools[j]);
}
g_free(skills[i].requires_tools);
}
}
g_free(skills);
}
/* Fetch and parse all kind 31123 events into an array of agent_skill_t.
* Returns the array (caller must agent_skills_free_list()) and sets
* *count_out. Returns NULL on error or if no skills exist. */
static agent_skill_t *fetch_skill_structs(int *count_out) {
if (count_out == NULL) return NULL;
*count_out = 0;
cJSON *events = db_get_events_by_kind(AGENT_SKILL_KIND, 500);
if (events == NULL) return NULL;
int n = cJSON_GetArraySize(events);
if (n <= 0) {
cJSON_Delete(events);
return NULL;
}
/* Deduplicate by d-tag: keep the newest (events are newest-first
* from db_get_events_by_kind). */
agent_skill_t *skills = g_new0(agent_skill_t, n);
int count = 0;
cJSON *seen_d = cJSON_CreateArray();
for (int i = 0; i < n; i++) {
cJSON *ev = cJSON_GetArrayItem(events, i);
const char *d = event_tag_value(ev, "d");
if (d == NULL || d[0] == '\0') continue;
/* Skip if we've already seen this d-tag. */
int seen = 0;
cJSON *s;
cJSON_ArrayForEach(s, seen_d) {
if (cJSON_IsString(s) && strcmp(s->valuestring, d) == 0) {
seen = 1;
break;
}
}
if (seen) continue;
cJSON_AddItemToArray(seen_d, cJSON_CreateString(d));
if (parse_skill_event(ev, &skills[count]) == 0) {
count++;
}
}
cJSON_Delete(seen_d);
cJSON_Delete(events);
if (count == 0) {
g_free(skills);
return NULL;
}
*count_out = count;
return skills;
}
cJSON *agent_skills_fetch(void) {
cJSON *result = cJSON_CreateArray();
int count = 0;
agent_skill_t *skills = fetch_skill_structs(&count);
if (skills == NULL) return result;
for (int i = 0; i < count; i++) {
cJSON *obj = cJSON_CreateObject();
cJSON_AddStringToObject(obj, "d", skills[i].d_tag);
cJSON_AddStringToObject(obj, "name", skills[i].name);
cJSON_AddStringToObject(obj, "description", skills[i].description);
cJSON_AddStringToObject(obj, "pubkey", skills[i].pubkey);
cJSON_AddStringToObject(obj, "content", skills[i].content);
/* requires_tools array */
cJSON *tools = cJSON_CreateArray();
for (int j = 0; j < skills[i].tool_count; j++) {
cJSON_AddItemToArray(tools,
cJSON_CreateString(skills[i].requires_tools[j]));
}
cJSON_AddItemToObject(obj, "requires_tools", tools);
cJSON_AddItemToArray(result, obj);
}
agent_skills_free_list(skills, count);
return result;
}
/* ── Sovereign Browser Skill (default, from local settings) ─────────── */
cJSON *agent_skills_get_default(void) {
const browser_settings_t *s = settings_get();
cJSON *obj = cJSON_CreateObject();
/* d is empty — this skill is unsaved (not published to Nostr). */
cJSON_AddStringToObject(obj, "d", "");
cJSON_AddStringToObject(obj, "name",
s->agent_skill_name[0] ? s->agent_skill_name
: SETTINGS_AGENT_SKILL_NAME_DEFAULT);
cJSON_AddStringToObject(obj, "description",
s->agent_skill_description[0] ? s->agent_skill_description
: SETTINGS_AGENT_SKILL_DESCRIPTION_DEFAULT);
cJSON_AddStringToObject(obj, "content",
s->agent_skill_template[0] ? s->agent_skill_template
: SETTINGS_AGENT_SYSTEM_PROMPT_DEFAULT);
cJSON_AddStringToObject(obj, "pubkey", "");
cJSON_AddBoolToObject(obj, "unsaved", 1);
/* Parse requires_tools (comma-separated) into a JSON array. */
cJSON *tools = cJSON_CreateArray();
const char *tools_str = s->agent_skill_requires_tools[0]
? s->agent_skill_requires_tools
: SETTINGS_AGENT_SKILL_REQUIRES_TOOLS_DEFAULT;
char *buf = g_strdup(tools_str);
char *saveptr = NULL;
char *tok = strtok_r(buf, ",", &saveptr);
while (tok != NULL) {
/* Trim leading whitespace. */
while (*tok == ' ' || *tok == '\t') tok++;
if (tok[0] != '\0') {
cJSON_AddItemToArray(tools, cJSON_CreateString(tok));
}
tok = strtok_r(NULL, ",", &saveptr);
}
g_free(buf);
cJSON_AddItemToObject(obj, "requires_tools", tools);
return obj;
}
/* ── Selection (local settings) ────────────────────────────────────── */
cJSON *agent_skills_get_selected(void) {
cJSON *result = cJSON_CreateArray();
char *json_str = db_kv_get_copy(AGENT_SKILLS_SELECTED_KEY);
if (json_str == NULL || json_str[0] == '\0') {
g_free(json_str);
return result;
}
cJSON *parsed = cJSON_Parse(json_str);
g_free(json_str);
if (cJSON_IsArray(parsed)) {
cJSON *item;
cJSON_ArrayForEach(item, parsed) {
if (cJSON_IsString(item)) {
cJSON_AddItemToArray(result, cJSON_CreateString(item->valuestring));
}
}
}
if (parsed) cJSON_Delete(parsed);
return result;
}
int agent_skills_set_selected(const char *d_tags_json) {
if (d_tags_json == NULL) d_tags_json = "[]";
return db_kv_set(AGENT_SKILLS_SELECTED_KEY, d_tags_json);
}
cJSON *agent_skills_toggle_selected(const char *d_tag) {
if (d_tag == NULL || d_tag[0] == '\0') return NULL;
cJSON *selected = agent_skills_get_selected();
/* Check if already selected. */
int found = -1;
int n = cJSON_GetArraySize(selected);
for (int i = 0; i < n; i++) {
cJSON *item = cJSON_GetArrayItem(selected, i);
if (cJSON_IsString(item) && strcmp(item->valuestring, d_tag) == 0) {
found = i;
break;
}
}
if (found >= 0) {
/* Remove it. */
cJSON_DeleteItemFromArray(selected, found);
} else {
/* Add it (at the end to preserve selection order). */
cJSON_AddItemToArray(selected, cJSON_CreateString(d_tag));
}
/* Save back to db_kv. */
char *json = cJSON_PrintUnformatted(selected);
if (json) {
agent_skills_set_selected(json);
free(json);
}
return selected;
}
/* ── Build system prompt ───────────────────────────────────────────── */
char *agent_skills_build_system_prompt(void) {
/* Always start with the Sovereign Browser Skill template (the
* default system prompt from local settings). This is the base
* prompt; selected Nostr skills are appended after it. */
const browser_settings_t *s = settings_get();
const char *base = s->agent_skill_template[0]
? s->agent_skill_template
: SETTINGS_AGENT_SYSTEM_PROMPT_DEFAULT;
GString *combined = g_string_new(base);
/* Fetch selected skills and append their templates. */
cJSON *selected = agent_skills_get_selected();
if (selected != NULL) {
int n = cJSON_GetArraySize(selected);
if (n > 0) {
int skill_count = 0;
agent_skill_t *skills = fetch_skill_structs(&skill_count);
if (skills != NULL) {
for (int i = 0; i < n; i++) {
cJSON *item = cJSON_GetArrayItem(selected, i);
if (!cJSON_IsString(item)) continue;
const char *d = item->valuestring;
agent_skill_t *skill = NULL;
for (int j = 0; j < skill_count; j++) {
if (strcmp(skills[j].d_tag, d) == 0) {
skill = &skills[j];
break;
}
}
if (skill == NULL || skill->content == NULL ||
skill->content[0] == '\0') continue;
g_string_append(combined, "\n\n---\n\n");
g_string_append(combined, skill->content);
}
agent_skills_free_list(skills, skill_count);
}
}
cJSON_Delete(selected);
}
return g_string_free(combined, FALSE);
}
/* ── Publish ───────────────────────────────────────────────────────── */
/* Derive a d-tag from a skill name: lowercase, replace spaces/separators
* with hyphens, strip non-alphanumeric. Returns a newly allocated string
* (caller must g_free). */
static char *derive_d_tag(const char *name) {
if (name == NULL || name[0] == '\0') {
/* Fall back to a timestamp-based id. */
return g_strdup_printf("skill-%ld", (long)time(NULL));
}
/* Build a slug. */
GString *slug = g_string_new(NULL);
for (const char *p = name; *p; p++) {
char c = *p;
if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) {
g_string_append_c(slug, c);
} else if (c >= 'A' && c <= 'Z') {
g_string_append_c(slug, c - 'A' + 'a');
} else if (c == ' ' || c == '_' || c == '-' || c == '.') {
g_string_append_c(slug, '-');
}
/* Skip other characters. */
}
/* Collapse consecutive hyphens and trim leading/trailing. */
GString *clean = g_string_new(NULL);
int prev_hyphen = 1; /* treat start as if previous was hyphen */
for (gsize i = 0; i < slug->len; i++) {
char c = slug->str[i];
if (c == '-') {
if (!prev_hyphen) {
g_string_append_c(clean, '-');
prev_hyphen = 1;
}
} else {
g_string_append_c(clean, c);
prev_hyphen = 0;
}
}
/* Trim trailing hyphen. */
if (clean->len > 0 && clean->str[clean->len - 1] == '-') {
g_string_truncate(clean, clean->len - 1);
}
g_string_free(slug, TRUE);
if (clean->len == 0) {
g_string_free(clean, TRUE);
return g_strdup_printf("skill-%ld", (long)time(NULL));
}
/* Append a short timestamp suffix to ensure uniqueness. */
char *result = g_strdup_printf("%s-%ld", clean->str, (long)time(NULL) % 100000);
g_string_free(clean, TRUE);
return result;
}
char *agent_skills_publish(const char *name,
const char *description,
const char *content,
const char *requires_tools_json) {
if (!g_have_signer) {
g_printerr("[agent_skills] No signer, cannot publish\n");
return NULL;
}
if (content == NULL || content[0] == '\0') {
g_printerr("[agent_skills] Content is required\n");
return NULL;
}
/* Derive the d-tag from the name. */
char *d_tag = derive_d_tag(name);
/* Build tags:
* [["d", d_tag],
* ["name", name],
* ["description", desc],
* ["client", "sovereign_browser"],
* ["requires_tool", tool1], ...] */
cJSON *tags = cJSON_CreateArray();
cJSON *d_tag_arr = cJSON_CreateArray();
cJSON_AddItemToArray(d_tag_arr, cJSON_CreateString("d"));
cJSON_AddItemToArray(d_tag_arr, cJSON_CreateString(d_tag));
cJSON_AddItemToArray(tags, d_tag_arr);
if (name && name[0]) {
cJSON *name_tag = cJSON_CreateArray();
cJSON_AddItemToArray(name_tag, cJSON_CreateString("name"));
cJSON_AddItemToArray(name_tag, cJSON_CreateString(name));
cJSON_AddItemToArray(tags, name_tag);
}
if (description && description[0]) {
cJSON *desc_tag = cJSON_CreateArray();
cJSON_AddItemToArray(desc_tag, cJSON_CreateString("description"));
cJSON_AddItemToArray(desc_tag, cJSON_CreateString(description));
cJSON_AddItemToArray(tags, desc_tag);
}
cJSON *client_tag = cJSON_CreateArray();
cJSON_AddItemToArray(client_tag, cJSON_CreateString("client"));
cJSON_AddItemToArray(client_tag, cJSON_CreateString("sovereign_browser"));
cJSON_AddItemToArray(tags, client_tag);
/* Parse requires_tools_json and add a requires_tool tag for each. */
if (requires_tools_json && requires_tools_json[0]) {
cJSON *tools = cJSON_Parse(requires_tools_json);
if (cJSON_IsArray(tools)) {
int n = cJSON_GetArraySize(tools);
if (n > AGENT_SKILL_MAX_TOOLS) n = AGENT_SKILL_MAX_TOOLS;
for (int i = 0; i < n; i++) {
cJSON *t = cJSON_GetArrayItem(tools, i);
if (cJSON_IsString(t) && t->valuestring[0]) {
cJSON *rt_tag = cJSON_CreateArray();
cJSON_AddItemToArray(rt_tag, cJSON_CreateString("requires_tool"));
cJSON_AddItemToArray(rt_tag, cJSON_CreateString(t->valuestring));
cJSON_AddItemToArray(tags, rt_tag);
}
}
}
if (tools) cJSON_Delete(tools);
}
/* Create and sign the event. Skills are PUBLIC — no encryption.
* The content is the raw system prompt template. */
cJSON *event = nostr_create_and_sign_event_with_signer(
AGENT_SKILL_KIND, content, tags, g_signer, time(NULL));
cJSON_Delete(tags);
if (event == NULL) {
g_printerr("[agent_skills] Failed to create/sign event\n");
g_free(d_tag);
return NULL;
}
/* Store in SQLite. */
db_store_event(event);
/* Publish to bootstrap relays. */
char *relay_buf = NULL;
const char *relay_urls[32];
int relay_count = parse_relays(&relay_buf, relay_urls, 32);
if (relay_count > 0) {
int success_count = 0;
publish_result_t *results = synchronous_publish_event_with_progress(
relay_urls, relay_count, event, &success_count,
15, NULL, NULL, 0, NULL);
if (results) free(results);
g_print("[agent_skills] Published skill '%s' (d:%s) to %d/%d relays\n",
name ? name : "(unnamed)", d_tag, success_count, relay_count);
} else {
g_print("[agent_skills] No relays configured, skill stored locally\n");
}
g_free(relay_buf);
cJSON_Delete(event);
return d_tag;
}
/* ── Delete ────────────────────────────────────────────────────────── */
int agent_skills_delete(const char *d_tag) {
if (d_tag == NULL || d_tag[0] == '\0') return -1;
if (g_pubkey[0] == '\0') return -1;
/* Find the kind 31123 event with this d-tag authored by the user. */
cJSON *events = db_get_events(g_pubkey, AGENT_SKILL_KIND, 500);
if (events == NULL) return -1;
char *event_id = NULL;
int n = cJSON_GetArraySize(events);
for (int i = 0; i < n; i++) {
cJSON *ev = cJSON_GetArrayItem(events, i);
if (event_has_tag(ev, "d", d_tag)) {
cJSON *id_item = cJSON_GetObjectItemCaseSensitive(ev, "id");
if (cJSON_IsString(id_item)) {
event_id = g_strdup(id_item->valuestring);
}
break;
}
}
cJSON_Delete(events);
if (event_id == NULL) {
g_printerr("[agent_skills] Skill '%s' not found for delete\n", d_tag);
return -1;
}
/* Publish a kind 5 deletion event referencing the skill event. */
if (g_have_signer) {
cJSON *tags = cJSON_CreateArray();
cJSON *e_tag = cJSON_CreateArray();
cJSON_AddItemToArray(e_tag, cJSON_CreateString("e"));
cJSON_AddItemToArray(e_tag, cJSON_CreateString(event_id));
cJSON_AddItemToArray(tags, e_tag);
cJSON *event = nostr_create_and_sign_event_with_signer(
5, "Deleted skill", tags, g_signer, time(NULL));
cJSON_Delete(tags);
if (event) {
db_store_event(event);
char *relay_buf = NULL;
const char *relay_urls[32];
int relay_count = parse_relays(&relay_buf, relay_urls, 32);
if (relay_count > 0) {
int success_count = 0;
publish_result_t *results =
synchronous_publish_event_with_progress(
relay_urls, relay_count, event, &success_count,
15, NULL, NULL, 0, NULL);
if (results) free(results);
g_print("[agent_skills] Published deletion for '%s' to %d/%d relays\n",
d_tag, success_count, relay_count);
}
g_free(relay_buf);
cJSON_Delete(event);
}
}
/* Remove from local SQLite cache. */
db_delete_event(event_id);
g_free(event_id);
/* Also remove from the selected list if present. */
cJSON *selected = agent_skills_get_selected();
if (selected) {
int sn = cJSON_GetArraySize(selected);
for (int i = 0; i < sn; i++) {
cJSON *item = cJSON_GetArrayItem(selected, i);
if (cJSON_IsString(item) && strcmp(item->valuestring, d_tag) == 0) {
cJSON_DeleteItemFromArray(selected, i);
break;
}
}
char *json = cJSON_PrintUnformatted(selected);
if (json) {
agent_skills_set_selected(json);
free(json);
}
cJSON_Delete(selected);
}
return 0;
}
/* ── Update (re-publish an existing skill) ──────────────────────────── */
int agent_skills_update(const char *d_tag,
const char *name,
const char *description,
const char *content,
const char *requires_tools_json) {
if (d_tag == NULL || d_tag[0] == '\0') return -1;
if (content == NULL || content[0] == '\0') return -1;
if (!g_have_signer || g_pubkey[0] == '\0') {
g_printerr("[agent_skills] No signer, cannot update\n");
return -1;
}
/* Find the existing kind 31123 event with this d-tag authored by
* the user. We reuse the existing d-tag (addressable event) so the
* update replaces the prior version. */
cJSON *events = db_get_events(g_pubkey, AGENT_SKILL_KIND, 500);
if (events == NULL) {
g_printerr("[agent_skills] No skill events found for update\n");
return -1;
}
cJSON *found = NULL;
int n = cJSON_GetArraySize(events);
for (int i = 0; i < n; i++) {
cJSON *ev = cJSON_GetArrayItem(events, i);
if (event_has_tag(ev, "d", d_tag)) {
found = cJSON_Duplicate(ev, 1);
break;
}
}
cJSON_Delete(events);
if (found == NULL) {
g_printerr("[agent_skills] Skill '%s' not found for update\n", d_tag);
return -1;
}
/* If name/description not provided, keep the existing values. */
char name_buf[128];
char desc_buf[512];
if (name == NULL || name[0] == '\0') {
const char *old = event_tag_value(found, "name");
snprintf(name_buf, sizeof(name_buf), "%s", old ? old : d_tag);
name = name_buf;
}
if (description == NULL) {
const char *old = event_tag_value(found, "description");
snprintf(desc_buf, sizeof(desc_buf), "%s", old ? old : "");
description = desc_buf;
}
/* Build the new tags: same d-tag, new name/description, client tag,
* and requires_tool tags. */
cJSON *tags = cJSON_CreateArray();
cJSON *d_tag_arr = cJSON_CreateArray();
cJSON_AddItemToArray(d_tag_arr, cJSON_CreateString("d"));
cJSON_AddItemToArray(d_tag_arr, cJSON_CreateString(d_tag));
cJSON_AddItemToArray(tags, d_tag_arr);
if (name && name[0]) {
cJSON *name_tag = cJSON_CreateArray();
cJSON_AddItemToArray(name_tag, cJSON_CreateString("name"));
cJSON_AddItemToArray(name_tag, cJSON_CreateString(name));
cJSON_AddItemToArray(tags, name_tag);
}
if (description && description[0]) {
cJSON *desc_tag = cJSON_CreateArray();
cJSON_AddItemToArray(desc_tag, cJSON_CreateString("description"));
cJSON_AddItemToArray(desc_tag, cJSON_CreateString(description));
cJSON_AddItemToArray(tags, desc_tag);
}
cJSON *client_tag = cJSON_CreateArray();
cJSON_AddItemToArray(client_tag, cJSON_CreateString("client"));
cJSON_AddItemToArray(client_tag, cJSON_CreateString("sovereign_browser"));
cJSON_AddItemToArray(tags, client_tag);
if (requires_tools_json && requires_tools_json[0]) {
cJSON *tools = cJSON_Parse(requires_tools_json);
if (cJSON_IsArray(tools)) {
int tn = cJSON_GetArraySize(tools);
if (tn > AGENT_SKILL_MAX_TOOLS) tn = AGENT_SKILL_MAX_TOOLS;
for (int i = 0; i < tn; i++) {
cJSON *t = cJSON_GetArrayItem(tools, i);
if (cJSON_IsString(t) && t->valuestring[0]) {
cJSON *rt_tag = cJSON_CreateArray();
cJSON_AddItemToArray(rt_tag, cJSON_CreateString("requires_tool"));
cJSON_AddItemToArray(rt_tag, cJSON_CreateString(t->valuestring));
cJSON_AddItemToArray(tags, rt_tag);
}
}
}
if (tools) cJSON_Delete(tools);
}
/* Create and sign the new event (same d-tag → replaces the old). */
cJSON *event = nostr_create_and_sign_event_with_signer(
AGENT_SKILL_KIND, content, tags, g_signer, time(NULL));
cJSON_Delete(tags);
cJSON_Delete(found);
if (event == NULL) {
g_printerr("[agent_skills] Failed to create/sign update event\n");
return -1;
}
/* Store in SQLite (replaces the old event by id; the d-tag is the
* same so fetch_skill_structs dedup keeps the newest). */
db_store_event(event);
/* Publish to bootstrap relays. */
char *relay_buf = NULL;
const char *relay_urls[32];
int relay_count = parse_relays(&relay_buf, relay_urls, 32);
if (relay_count > 0) {
int success_count = 0;
publish_result_t *results = synchronous_publish_event_with_progress(
relay_urls, relay_count, event, &success_count,
15, NULL, NULL, 0, NULL);
if (results) free(results);
g_print("[agent_skills] Updated skill '%s' to %d/%d relays\n",
d_tag, success_count, relay_count);
} else {
g_print("[agent_skills] No relays configured, update stored locally\n");
}
g_free(relay_buf);
cJSON_Delete(event);
return 0;
}

204
src/agent_skills.h Normal file
View File

@@ -0,0 +1,204 @@
/*
* agent_skills.h — Nostr kind 31123 skill management for sovereign_browser
*
* Skills are PUBLIC Nostr events (kind 31123) that define system prompt
* templates, LLM parameters, and tool requirements. They are addressable
* events (NIP-51 list kind variant) with a "d" tag (unique identifier),
* a "name" tag, and content that is the system prompt template.
*
* sovereign_browser has a unique advantage over ~/lt/client/www/ai.html:
* it has browser tools + filesystem/shell tools, so skills with
* "requires_tool" tags are fully functional here.
*
* Users select multiple skills via checkboxes; their templates are
* concatenated into the system prompt in selection order. The agent loop
* calls agent_skills_build_system_prompt() to get the combined prompt.
*
* See plans/cross-project-agent-sync.md (Phase C/D) and
* ~/lt/client/plans/ai-skills-integration.md for the full design.
*/
#ifndef AGENT_SKILLS_H
#define AGENT_SKILLS_H
#include <glib.h>
/* cJSON is in the vendored nostr_core_lib */
#include "../nostr_core_lib/cjson/cJSON.h"
/* nostr_signer_t is needed for init */
#include "nostr_core/nostr_signer.h"
#ifdef __cplusplus
extern "C" {
#endif
/* The Nostr kind for skills (NIP-51 list kind variant, addressable). */
#define AGENT_SKILL_KIND 31123
/* db_kv key for the selected skill d-tags (stored as a JSON array string). */
#define AGENT_SKILLS_SELECTED_KEY "agent.selected_skills"
/* Maximum number of requires_tool tags per skill. */
#define AGENT_SKILL_MAX_TOOLS 32
/* ── Skill struct ──────────────────────────────────────────────────── */
typedef struct {
char d_tag[128]; /* unique identifier */
char name[128]; /* skill name */
char description[512]; /* short description */
char pubkey[65]; /* author pubkey (hex) */
char *content; /* system prompt template (owned, g_free) */
char **requires_tools; /* array of tool name strings (owned, g_free) */
int tool_count;
} agent_skill_t;
/* ── Lifecycle ─────────────────────────────────────────────────────── */
/*
* Initialize the skills module. Stores the signer + pubkey references
* for publish/delete operations.
*
* signer — the user's nostr_signer_t (NULL for read-only/no-login)
* pubkey_hex — the user's hex pubkey (64 chars, may be NULL)
*
* Call after login.
*/
void agent_skills_init(nostr_signer_t *signer, const char *pubkey_hex);
/*
* Update the signer reference (e.g. after switching identity).
*/
void agent_skills_set_signer(nostr_signer_t *signer, const char *pubkey_hex);
/* ── Fetch / list ──────────────────────────────────────────────────── */
/*
* Fetch kind 31123 skill events from the local SQLite cache and parse
* them into skill structs. Skills are public events from any author.
*
* Returns a newly allocated cJSON array of skill summary objects:
* [{"d":"...", "name":"...", "description":"...",
* "requires_tools":["tool1","tool2"],
* "content":"...", "pubkey":"..."}, ...]
* Returns an empty array on error or if no skills exist.
* Caller must cJSON_Delete() the result.
*/
cJSON *agent_skills_fetch(void);
/*
* Get the Sovereign Browser Skill — the default skill stored in local
* settings (not from Nostr). Returns a newly allocated cJSON object:
* {"d":"", "name":"...", "description":"...", "content":"...",
* "requires_tools":["tool1","tool2"],
* "unsaved":true, "pubkey":""}
* Caller must cJSON_Delete() the result.
*/
cJSON *agent_skills_get_default(void);
/*
* Free an array of agent_skill_t structs (and each skill's owned fields).
* skills — the array (may be NULL)
* count — number of entries in the array
*/
void agent_skills_free_list(agent_skill_t *skills, int count);
/* ── Selection (local settings) ────────────────────────────────────── */
/*
* Get the list of currently selected skill d-tags from local settings.
*
* Returns a newly allocated cJSON array of d-tag strings:
* ["d-tag-1", "d-tag-2", ...]
* Returns an empty array if none selected. Caller must cJSON_Delete().
*/
cJSON *agent_skills_get_selected(void);
/*
* Save the selected skill d-tags to local settings (db_kv).
*
* d_tags_json — a JSON array string of d-tags, e.g. "[\"a\",\"b\"]"
*
* Returns 0 on success, -1 on error.
*/
int agent_skills_set_selected(const char *d_tags_json);
/*
* Toggle selection of a skill: add the d-tag if not selected, remove it
* if already selected. Saves the updated selection to local settings.
*
* d_tag — the skill's d-tag value
*
* Returns a newly allocated cJSON array of the updated selection
* (caller must cJSON_Delete()), or NULL on error.
*/
cJSON *agent_skills_toggle_selected(const char *d_tag);
/* ── Build system prompt ───────────────────────────────────────────── */
/*
* Build the system prompt by concatenating the selected skills'
* templates in selection order. Templates are separated by
* "\n\n---\n\n".
*
* Returns a newly allocated string (caller must g_free), or NULL if no
* skills are selected (caller should fall back to the default prompt).
*/
char *agent_skills_build_system_prompt(void);
/* ── Publish / delete ──────────────────────────────────────────────── */
/*
* Create and publish a new kind 31123 skill event. Signs with the
* user's key, publishes to bootstrap relays, and stores in SQLite.
* Skills are PUBLIC events — no NIP-44 encryption.
*
* name — skill name (also used to derive the d-tag)
* description — short description (may be NULL or "")
* content — the system prompt template (required)
* requires_tools_json — JSON array string of tool names, e.g.
* "[\"browser\",\"fs\"]" (may be NULL or "[]")
*
* Returns a newly allocated string with the d-tag of the new skill
* (caller must g_free), or NULL on error.
*/
char *agent_skills_publish(const char *name,
const char *description,
const char *content,
const char *requires_tools_json);
/*
* Publish a kind 5 deletion event for a skill authored by the user,
* and remove it from the local SQLite cache.
*
* d_tag — the skill's d-tag value
*
* Returns 0 on success, -1 on error.
*/
int agent_skills_delete(const char *d_tag);
/*
* Update an existing kind 31123 skill event with new content. Fetches
* the existing event by d-tag (authored by the user), updates its
* name/description/content/requires_tools, re-signs, and re-publishes.
*
* d_tag — the skill's d-tag value (identifies the skill)
* name — new skill name (may be NULL to keep existing)
* description — new description (may be NULL to keep existing)
* content — new system prompt template (required)
* requires_tools_json — JSON array string of tool names (may be NULL)
*
* Returns 0 on success, -1 on error.
*/
int agent_skills_update(const char *d_tag,
const char *name,
const char *description,
const char *content,
const char *requires_tools_json);
#ifdef __cplusplus
}
#endif
#endif /* AGENT_SKILLS_H */

46
src/agent_tool_catalog.h Normal file
View File

@@ -0,0 +1,46 @@
/*
* agent_tool_catalog.h — shared tool catalog definitions
*
* Exposes the tool_defs[] array, its count, and build_tools_list()
* so that both the MCP server (agent_mcp.c) and the embedded agent
* (agent_llm.c, future) can access the same tool catalog.
*
* The definitions live in agent_mcp.c; this header just makes them
* non-static and declares them here.
*/
#ifndef AGENT_TOOL_CATALOG_H
#define AGENT_TOOL_CATALOG_H
#include "cjson/cJSON.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ── Tool definition struct ────────────────────────────────────────── *
* Each entry has a name, human-readable description, and a pre-built
* JSON Schema string for the tool's input parameters.
*/
typedef struct {
const char *name;
const char *description;
const char *schema_json; /* pre-built JSON schema string */
} mcp_tool_def_t;
/* The shared tool catalog array and its length. */
extern const mcp_tool_def_t tool_defs[];
extern const int tool_defs_count;
/*
* Build a cJSON array of tool definitions in the MCP tools/list format:
* [{"name":"...","description":"...","inputSchema":{...}}, ...]
* The caller frees the returned cJSON*.
*/
cJSON *build_tools_list(void);
#ifdef __cplusplus
}
#endif
#endif /* AGENT_TOOL_CATALOG_H */

View File

@@ -11,9 +11,12 @@
#include "agent_login.h"
#include "agent_snapshot.h"
#include "agent_server.h"
#include "agent_fs_tools.h"
#include "tab_manager.h"
#include "settings.h"
#include "search.h"
#include "nostr_url.h"
#include "process_info.h"
#include <string.h>
#include <stdlib.h>
@@ -59,8 +62,44 @@ static gboolean get_bool_param(cJSON *params, const char *key, gboolean fallback
/* ── Normalize URL (same logic as tab_manager.c) ──────────────────── */
static char *normalize_fips_url(const char *input) {
const char *rest = input + 7;
const char *suffix = strpbrk(rest, "/?#");
size_t authority_len = suffix ? (size_t)(suffix - rest) : strlen(rest);
if (authority_len == 0) return NULL;
char *authority = g_strndup(rest, authority_len);
char *result;
if (g_str_has_suffix(authority, ".fips")) {
result = g_strdup_printf("http://%s%s", authority, suffix ? suffix : "");
} else {
char *colon = strrchr(authority, ':');
if (colon && strchr(authority, ':') == colon) {
*colon = '\0';
result = g_strdup_printf("http://%s.fips:%s%s", authority,
colon + 1, suffix ? suffix : "");
} else {
result = g_strdup_printf("http://%s.fips%s", authority,
suffix ? suffix : "");
}
}
g_free(authority);
return result;
}
static char *normalize_url(const char *input) {
if (input == NULL || input[0] == '\0') return NULL;
/* MCP open must apply the same fips:// shorthand as the interactive
* URL bar; otherwise WebKit sees an unregistered custom scheme. */
if (strncmp(input, "fips://", 7) == 0)
return normalize_fips_url(input);
/* Keep MCP/agent navigation consistent with the interactive URL bar. */
nostr_entity_type_t entity_type = nostr_url_detect(input);
if (entity_type != NOSTR_ENTITY_NONE) {
return nostr_url_normalize(input);
}
/* If it looks like a URL, normalize it. */
if (search_is_url(input)) {
if (strstr(input, "://") != NULL) return g_strdup(input);
@@ -72,11 +111,14 @@ static char *normalize_url(const char *input) {
return search_build_search_url(input);
}
/* ── Get active webview (or NULL if not logged in / no tabs) ──────── */
/* ── Get active webview (or NULL if not logged in / no tabs) ────────
* When the agent sidebar is open, the "active" webview (the one with
* focus) might be the sidebar chat page. Agent tools must always
* operate on the MAIN webview (the web page), so we use
* tab_manager_get_main_webview() which never returns the sidebar. */
static WebKitWebView *get_active_webview(void) {
tab_info_t *tab = tab_manager_get_active();
return tab ? tab->webview : NULL;
return tab_manager_get_main_webview();
}
/* ── Coordinate-based click via GDK event synthesis ────────────────── */
@@ -407,6 +449,29 @@ static cJSON *tool_switch_identity(cJSON *params) {
/* ── Navigation tools ─────────────────────────────────────────────── */
/* Idle callback data for main-thread webview navigation. WebKitGTK is
* NOT thread-safe — webkit_web_view_load_uri() must be called on the
* GTK main thread. The agent loop runs on a background thread, so we
* hop to the main thread via g_idle_add() to perform the navigation.
* (Issue 3: calling webkit_web_view_load_uri() directly from the
* agent-loop background thread caused an "Aborted" segfault.) */
typedef struct {
WebKitWebView *webview;
char *url;
} load_uri_idle_t;
static gboolean load_uri_idle(gpointer user_data) {
load_uri_idle_t *ctx = (load_uri_idle_t *)user_data;
if (ctx && ctx->webview && ctx->url) {
webkit_web_view_load_uri(ctx->webview, ctx->url);
}
if (ctx) {
g_free(ctx->url);
g_free(ctx);
}
return G_SOURCE_REMOVE;
}
static cJSON *tool_open(cJSON *params) {
const char *url = get_string_param(params, "url");
if (!url || !url[0]) return make_error("MISSING_PARAM", "Provide 'url'");
@@ -414,24 +479,79 @@ static cJSON *tool_open(cJSON *params) {
char *normalized = normalize_url(url);
if (!normalized) return make_error("INVALID_URL", "Invalid URL");
tab_info_t *tab = tab_manager_get_active();
if (!tab) {
/* Always operate on the MAIN webview (the web page), never the
* sidebar chat webview. tab_manager_get_main_webview() returns
* the active tab's webview directly, never the sidebar. */
WebKitWebView *wv = tab_manager_get_main_webview();
if (!wv) {
g_free(normalized);
return make_error("NO_TAB", "No active tab");
}
webkit_web_view_load_uri(tab->webview, normalized);
/* Dispatch the load to the GTK main thread. The agent loop (and
* the libsoup MCP thread) are not the main thread, so calling
* webkit_web_view_load_uri() directly would crash. g_idle_add()
* schedules the call on the default main context, which is run by
* the GTK main loop on the main thread. */
load_uri_idle_t *ctx = g_new(load_uri_idle_t, 1);
ctx->webview = wv;
ctx->url = g_strdup(normalized);
g_idle_add(load_uri_idle, ctx);
cJSON *data = cJSON_CreateObject();
cJSON_AddStringToObject(data, "url", normalized);
g_free(normalized);
return make_success(data);
}
/* Idle callback data for simple main-thread webview actions (back,
* forward, reload). Like tool_open, these must run on the GTK main
* thread because WebKitGTK is not thread-safe and the agent loop
* runs on a background thread. */
typedef enum {
WEBVIEW_ACTION_BACK,
WEBVIEW_ACTION_FORWARD,
WEBVIEW_ACTION_RELOAD,
WEBVIEW_ACTION_STOP
} webview_action_t;
typedef struct {
WebKitWebView *webview;
webview_action_t action;
} webview_action_idle_t;
static gboolean webview_action_idle(gpointer user_data) {
webview_action_idle_t *ctx = (webview_action_idle_t *)user_data;
if (ctx && ctx->webview) {
switch (ctx->action) {
case WEBVIEW_ACTION_BACK:
if (webkit_web_view_can_go_back(ctx->webview))
webkit_web_view_go_back(ctx->webview);
break;
case WEBVIEW_ACTION_FORWARD:
if (webkit_web_view_can_go_forward(ctx->webview))
webkit_web_view_go_forward(ctx->webview);
break;
case WEBVIEW_ACTION_RELOAD:
webkit_web_view_reload_bypass_cache(ctx->webview);
break;
case WEBVIEW_ACTION_STOP:
webkit_web_view_stop_loading(ctx->webview);
break;
}
}
g_free(ctx);
return G_SOURCE_REMOVE;
}
static cJSON *tool_back(cJSON *params) {
(void)params;
WebKitWebView *wv = get_active_webview();
if (!wv) return make_error("NO_TAB", "No active tab");
webkit_web_view_go_back(wv);
webview_action_idle_t *ctx = g_new(webview_action_idle_t, 1);
ctx->webview = wv;
ctx->action = WEBVIEW_ACTION_BACK;
g_idle_add(webview_action_idle, ctx);
return make_success(NULL);
}
@@ -439,7 +559,10 @@ static cJSON *tool_forward(cJSON *params) {
(void)params;
WebKitWebView *wv = get_active_webview();
if (!wv) return make_error("NO_TAB", "No active tab");
webkit_web_view_go_forward(wv);
webview_action_idle_t *ctx = g_new(webview_action_idle_t, 1);
ctx->webview = wv;
ctx->action = WEBVIEW_ACTION_FORWARD;
g_idle_add(webview_action_idle, ctx);
return make_success(NULL);
}
@@ -447,7 +570,24 @@ static cJSON *tool_reload(cJSON *params) {
(void)params;
WebKitWebView *wv = get_active_webview();
if (!wv) return make_error("NO_TAB", "No active tab");
webkit_web_view_reload_bypass_cache(wv);
webview_action_idle_t *ctx = g_new(webview_action_idle_t, 1);
ctx->webview = wv;
ctx->action = WEBVIEW_ACTION_RELOAD;
g_idle_add(webview_action_idle, ctx);
return make_success(NULL);
}
/* Stop loading the active tab. Mirrors the toolbar stop button and the
* hamburger menu's Stop action. Useful for testing the per-tab
* reload/stop button state machine. */
static cJSON *tool_stop(cJSON *params) {
(void)params;
WebKitWebView *wv = get_active_webview();
if (!wv) return make_error("NO_TAB", "No active tab");
webview_action_idle_t *ctx = g_new(webview_action_idle_t, 1);
ctx->webview = wv;
ctx->action = WEBVIEW_ACTION_STOP;
g_idle_add(webview_action_idle, ctx);
return make_success(NULL);
}
@@ -1652,13 +1792,62 @@ static cJSON *tool_drag(cJSON *params) {
return make_success(NULL);
}
static cJSON *tool_close_all(cJSON *params) {
(void)params;
tab_manager_close_all();
return make_success(NULL);
/* ── Tab tools (main-thread dispatch) ────────────────────────────── *
* All tab management functions (new, switch, close, close_all) create
* or destroy GTK widgets, which MUST happen on the main thread. The
* agent loop runs on a background thread, so we dispatch via
* g_idle_add(). */
typedef struct {
char *url;
int index;
} tab_idle_t;
static gboolean tab_new_idle(gpointer user_data) {
tab_idle_t *ctx = (tab_idle_t *)user_data;
if (ctx) {
ctx->index = tab_manager_new_tab(ctx->url);
g_free(ctx->url);
g_free(ctx);
}
return G_SOURCE_REMOVE;
}
/* ── Tab tools ────────────────────────────────────────────────────── */
static gboolean tab_switch_idle(gpointer user_data) {
tab_idle_t *ctx = (tab_idle_t *)user_data;
if (ctx) {
tab_manager_switch_to(ctx->index);
g_free(ctx);
}
return G_SOURCE_REMOVE;
}
static gboolean tab_close_idle(gpointer user_data) {
tab_idle_t *ctx = (tab_idle_t *)user_data;
if (ctx) {
tab_manager_close_tab(ctx->index);
g_free(ctx);
}
return G_SOURCE_REMOVE;
}
static gboolean close_all_idle(gpointer user_data) {
(void)user_data;
tab_manager_close_all();
return G_SOURCE_REMOVE;
}
static gboolean close_active_idle(gpointer user_data) {
(void)user_data;
tab_manager_close_active();
return G_SOURCE_REMOVE;
}
static cJSON *tool_close_all(cJSON *params) {
(void)params;
g_idle_add(close_all_idle, NULL);
return make_success(NULL);
}
static cJSON *tool_tab_list(cJSON *params) {
(void)params;
@@ -1681,10 +1870,16 @@ static cJSON *tool_tab_list(cJSON *params) {
static cJSON *tool_tab_new(cJSON *params) {
const char *url = get_string_param(params, "url");
int index = tab_manager_new_tab(url);
if (index < 0) return make_error("TAB_FAILED", "Failed to create tab (max tabs reached?)");
/* Dispatch to main thread — tab_manager_new_tab() creates GTK
* widgets which must happen on the main thread. */
tab_idle_t *ctx = g_new(tab_idle_t, 1);
ctx->url = url ? g_strdup(url) : NULL;
ctx->index = -1;
g_idle_add(tab_new_idle, ctx);
/* We can't know the exact index since the creation is async, but
* the tab will be created. Return a placeholder. */
cJSON *data = cJSON_CreateObject();
cJSON_AddNumberToObject(data, "index", index);
cJSON_AddNumberToObject(data, "index", tab_manager_count());
return make_success(data);
}
@@ -1692,7 +1887,9 @@ static cJSON *tool_tab_switch(cJSON *params) {
int index = get_int_param(params, "index", -1);
if (index < 0) return make_error("MISSING_PARAM", "Provide 'index'");
if (index >= tab_manager_count()) return make_error("INVALID_INDEX", "Tab index out of range");
tab_manager_switch_to(index);
tab_idle_t *ctx = g_new(tab_idle_t, 1);
ctx->index = index;
g_idle_add(tab_switch_idle, ctx);
cJSON *data = cJSON_CreateObject();
cJSON_AddNumberToObject(data, "index", index);
return make_success(data);
@@ -1706,13 +1903,15 @@ static cJSON *tool_tab_close(cJSON *params) {
if (index < 0) return make_error("NO_TAB", "No active tab");
}
if (index >= tab_manager_count()) return make_error("INVALID_INDEX", "Tab index out of range");
tab_manager_close_tab(index);
tab_idle_t *ctx = g_new(tab_idle_t, 1);
ctx->index = index;
g_idle_add(tab_close_idle, ctx);
return make_success(NULL);
}
static cJSON *tool_close(cJSON *params) {
(void)params;
tab_manager_close_active();
g_idle_add(close_active_idle, NULL);
return make_success(NULL);
}
@@ -3924,6 +4123,7 @@ static tool_entry_t tool_table[] = {
{"back", tool_back, TRUE},
{"forward", tool_forward, TRUE},
{"reload", tool_reload, TRUE},
{"stop", tool_stop, TRUE},
{"get_url", tool_get_url, TRUE},
{"get_title", tool_get_title, TRUE},
@@ -4068,6 +4268,38 @@ cJSON *agent_tools_dispatch(cJSON *request, SoupWebsocketConnection *conn) {
return make_error("MISSING_TOOL", "No 'tool' field in request");
}
/* ── Filesystem & shell tools (work before login) ──────────── *
* These are system-level tools that give the agent direct access
* to the qube's filesystem and shell. They don't require a Nostr
* login, so we dispatch them before the login check below. */
if (agent_fs_is_tool(tool_name)) {
return agent_fs_tools_dispatch(tool_name, params);
}
/* ── Process / per-tab diagnostics (work before login) ────────── *
* These read /proc and the in-memory probe store; they don't touch
* Nostr or page content, so they're safe before login. */
if (strcmp(tool_name, "processes.list") == 0) {
cJSON *arr = process_info_get_processes_json();
return make_success(arr);
}
if (strcmp(tool_name, "processes.tabs") == 0) {
cJSON *arr = process_info_get_tabs_json();
return make_success(arr);
}
if (strcmp(tool_name, "processes.tab_probe") == 0) {
int idx = get_int_param(params, "tab_index", -1);
if (idx < 0) {
return make_error("MISSING_PARAM",
"Provide 'tab_index' (from processes.tabs)");
}
cJSON *obj = process_info_get_tab_probe_json(idx);
if (obj == NULL) {
return make_error("BAD_INDEX", "Tab index out of range");
}
return make_success(obj);
}
/* Check login requirement for known tools. */
gboolean requires_login = TRUE;
gboolean is_login_tool = (strcmp(tool_name, "login_status") == 0 ||

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,22 @@
/*
* bookmarks.h — NIP-44 encrypted Nostr bookmarks with directories
* bookmarks.h — NIP-44 encrypted Nostr bookmarks with nested folders
*
* Stores bookmarks as NIP-51 kind 30003 (bookmark sets) events, one per
* directory. Each directory's bookmarks are NIP-44 encrypted (self-to-self)
* and stored in the event's content field as a JSON tag array.
* folder. Folders may be nested arbitrarily (e.g. "Work/Projects/Secret").
*
* Privacy design:
* - The `d` tag is HMAC-SHA256(hmac_key, path) — a deterministic, opaque
* 64-hex-char identifier. Relays learn nothing about folder names or
* structure (not even the folder count). Determinism preserves NIP-33
* replaceability: re-publishing the same path replaces the prior event.
* Encryption (NIP-04 / NIP-44) cannot be used for the `d` tag because
* both use a random IV/nonce per call, which would break replaceability.
* - The real path and the bookmark list live inside the NIP-44 encrypted
* `content` as JSON: {"path": "Work/Projects/Secret", "bookmarks": [...]}
* - The HMAC key is derived from the user's Nostr privkey:
* hmac_key = HMAC-SHA256(privkey_bytes, "sovereign-browser/bookmarks-folder-id-v1")
* It is per-user, never published, and automatically available on every
* device the user logs in on.
*
* The bookmarks sync across all devices the user logs in on via the
* bootstrap relays.
@@ -22,10 +35,15 @@
extern "C" {
#endif
#define BOOKMARKS_MAX_PER_DIR 500
#define BOOKMARKS_DIR_NAME_MAX 128
#define BOOKMARKS_URL_MAX 2048
#define BOOKMARKS_TITLE_MAX 256
#define BOOKMARKS_MAX_PER_NODE 500
#define BOOKMARKS_NAME_MAX 128
#define BOOKMARKS_PATH_MAX 1024
#define BOOKMARKS_URL_MAX 2048
#define BOOKMARKS_TITLE_MAX 256
/* Label mixed into the HMAC key derivation so d-tag namespaces are isolated
* per application and can be rotated independently if ever needed. */
#define BOOKMARKS_HMAC_KEY_LABEL "sovereign-browser/bookmarks-folder-id-v1"
typedef struct {
char *url;
@@ -33,70 +51,101 @@ typedef struct {
long added; /* unix timestamp */
} bookmark_t;
typedef struct {
char name[BOOKMARKS_DIR_NAME_MAX];
bookmark_t *bookmarks;
int count;
} bookmark_dir_t;
/* A node in the bookmark tree. The root node has name="" and path="".
* Each node corresponds to a folder path; bookmarks live at any node. */
typedef struct bookmark_node {
char *name; /* last path segment, "" for root */
char *path; /* full path, "" for root */
bookmark_t *bookmarks;
int bookmark_count;
struct bookmark_node *children; /* array of child nodes */
int child_count;
int child_cap;
} bookmark_node_t;
/*
* Initialize the bookmarks module. Loads cached bookmarks from the SQLite
* database and decrypts them using the signer.
*
* signer — the user's nostr_signer_t (NULL for read-only/no-login)
* pubkey_hex — the user's hex pubkey (64 chars)
* signer — the user's nostr_signer_t (NULL for read-only/no-login).
* The signer holds the private key; d tags are derived via
* nostr_signer_derive_hmac, so the privkey never lives in
* browser memory.
* pubkey_hex — the user's hex pubkey (64 chars) or NULL
*
* Returns 0 on success, -1 on error.
*/
int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex);
int bookmarks_init(nostr_signer_t *signer,
const char *pubkey_hex);
/* Free the in-memory bookmark list. Call at shutdown. */
/* Free the in-memory bookmark tree. Call at shutdown. */
void bookmarks_cleanup(void);
/* ── Read access ───────────────────────────────────────────────────── */
/* Get the list of directories (read-only). Returns a pointer to the
* internal array (valid until the next bookmarks operation). */
const bookmark_dir_t *bookmarks_get_dirs(int *count_out);
/* Get the root node of the bookmark tree (read-only). Returns NULL if
* bookmarks_init has not been called. The returned pointer is valid until
* the next bookmarks mutation. */
const bookmark_node_t *bookmarks_get_root(void);
/* Get a specific directory by name. Returns NULL if not found. */
const bookmark_dir_t *bookmarks_get_dir(const char *name);
/* Find a node by path. Returns NULL if not found. */
const bookmark_node_t *bookmarks_find(const char *path);
/* ── Write access (requires signer) ────────────────────────────────── */
/* ── Write access (requires signer + privkey) ──────────────────────── */
/* Add a bookmark to a directory. If the directory doesn't exist, it's
* created. Updates the in-memory list, re-encrypts, publishes a new
* kind 30003 event for that directory, and stores in SQLite.
/* Add a bookmark to a folder path. If the folder (or any intermediate
* folder) doesn't exist, it is created. Updates the in-memory tree,
* re-encrypts, publishes a new kind 30003 event for that path, and stores
* in SQLite.
*
* Returns 0 on success, -1 on error. */
int bookmarks_add(const char *dir, const char *url, const char *title);
int bookmarks_add(const char *path, const char *url, const char *title);
/* Remove a bookmark by URL from a directory.
/* Remove a bookmark by URL from a folder path.
* Returns 0 on success, -1 on not found / error. */
int bookmarks_remove(const char *dir, const char *url);
int bookmarks_remove(const char *path, const char *url);
/* Move a bookmark from one directory to another.
/* Rename a bookmark's title (the user-editable label) in place. The
* bookmark is located by (path, url); only its title changes. Re-encrypts
* and publishes a new kind 30003 event for that path.
* Returns 0 on success, -1 on not found / error. */
int bookmarks_rename(const char *path, const char *url, const char *new_title);
/* Move a bookmark from one folder to another.
* Returns 0 on success, -1 on error. */
int bookmarks_move(const char *from_dir, const char *url,
const char *to_dir);
int bookmarks_move(const char *from_path, const char *url,
const char *to_path);
/* Create a new empty directory. Publishes an empty kind 30003 event.
* Returns 0 on success, -1 if directory already exists / error. */
int bookmarks_create_dir(const char *name);
/* Create a new empty folder at the given path. Parent folders are created
* if missing. Publishes an empty kind 30003 event.
* Returns 0 on success, -1 if the folder already exists / error. */
int bookmarks_create_dir(const char *path);
/* Delete a directory. If move_to_general is TRUE, bookmarks are moved
* to "General" before deletion. Publishes a kind 5 deletion event.
/* Rename a folder. Re-publishes the renamed node and every descendant with
* new HMAC d tags and new encrypted content; emits kind 5 deletions for the
* old d tags.
* Returns 0 on success, -1 on error. */
int bookmarks_delete_dir(const char *name, int move_to_general);
int bookmarks_rename_dir(const char *old_path, const char *new_path);
/* Delete a folder. If move_to_general is TRUE, the folder's bookmarks (and
* its descendants' bookmarks) are moved to "General" before deletion.
* Emits kind 5 deletion events for every d tag in the subtree.
* Returns 0 on success, -1 on error. */
int bookmarks_delete_dir(const char *path, int move_to_general);
/* ── Change notification ───────────────────────────────────────────── */
/* Callback invoked after any mutation (add/remove/move/create/rename/delete
* and after relay-fetch loads new events). Used by the bookmarks toolbar to
* refresh every open tab. */
typedef void (*bookmarks_changed_cb)(void *user_data);
void bookmarks_subscribe_changed(bookmarks_changed_cb cb, void *user_data);
/* ── Internal (used by relay fetch) ────────────────────────────────── */
/* Load bookmarks for a directory from a decrypted kind 30003 event's
* content JSON. Called by bookmarks_init and after relay fetch. */
int bookmarks_load_dir_from_json(const char *dir_name, const char *json);
/* Store a raw kind 30003 event in the database and decrypt its content
* into the in-memory list. Called by the relay fetch after login. */
* into the in-memory tree. Called by the relay fetch after login. */
int bookmarks_store_and_load_event(const void *event_cjson);
#ifdef __cplusplus

537
src/db.c
View File

@@ -16,6 +16,7 @@
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <time.h>
/* ── Global state ──────────────────────────────────────────────────── */
@@ -24,24 +25,44 @@ static sqlite3 *g_db = NULL;
/* Static buffer for db_kv_get() — valid until the next call. */
static char g_kv_buf[4096];
/* ── Path helper ──────────────────────────────────────────────────── */
/* ── Path helpers ──────────────────────────────────────────────────── */
static int db_path(char *out, size_t out_sz) {
/* ~/.sovereign_browser/ (created if missing). Returns 0 on success. */
static int sb_home_dir(char *out, size_t out_sz) {
const char *home = getenv("HOME");
if (home == NULL || home[0] == '\0') {
return -1;
}
int n = snprintf(out, out_sz, "%s/.sovereign_browser", home);
if (n < 0 || (size_t)n >= out_sz) {
return -1;
}
if (mkdir(out, 0700) != 0 && errno != EEXIST) {
return -1;
}
return 0;
}
/* ~/.sovereign_browser/browser.db (legacy default per-user path). */
static int db_path(char *out, size_t out_sz) {
char dir[512];
int n = snprintf(dir, sizeof(dir), "%s/.sovereign_browser", home);
if (n < 0 || (size_t)n >= sizeof(dir)) {
if (sb_home_dir(dir, sizeof(dir)) != 0) {
return -1;
}
if (mkdir(dir, 0700) != 0 && errno != EEXIST) {
int n = snprintf(out, out_sz, "%s/browser.db", dir);
if (n < 0 || (size_t)n >= out_sz) {
return -1;
}
return 0;
}
n = snprintf(out, out_sz, "%s/browser.db", dir);
/* ~/.sovereign_browser/global.db (global settings + shortcuts). */
static int db_global_path(char *out, size_t out_sz) {
char dir[512];
if (sb_home_dir(dir, sizeof(dir)) != 0) {
return -1;
}
int n = snprintf(out, out_sz, "%s/global.db", dir);
if (n < 0 || (size_t)n >= out_sz) {
return -1;
}
@@ -97,24 +118,51 @@ static const char *SCHEMA_SQL =
"CREATE INDEX IF NOT EXISTS idx_event_tags_name_value "
" ON event_tags(tag_name, tag_value);"
"CREATE INDEX IF NOT EXISTS idx_event_tags_event_id "
" ON event_tags(event_id);";
" ON event_tags(event_id);"
"CREATE TABLE IF NOT EXISTS agent_sessions ("
" id TEXT PRIMARY KEY,"
" title TEXT,"
" created_at INTEGER,"
" updated_at INTEGER"
");"
"CREATE TABLE IF NOT EXISTS agent_messages ("
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
" session_id TEXT,"
" role TEXT,"
" content TEXT,"
" tool_calls TEXT,"
" tool_call_id TEXT,"
" created_at INTEGER,"
" FOREIGN KEY (session_id) REFERENCES agent_sessions(id) ON DELETE CASCADE"
");"
"CREATE INDEX IF NOT EXISTS idx_agent_messages_session "
" ON agent_messages(session_id, created_at);";
/* ── Init / Close ──────────────────────────────────────────────────── */
int db_init(void) {
char path[512];
if (db_path(path, sizeof(path)) != 0) {
g_printerr("[db] Failed to get database path\n");
/*
* Open a SQLite database at the given path with the standard schema.
* Closes any currently-open database first. Returns 0 on success.
*/
int db_init_with_path(const char *path) {
if (path == NULL || path[0] == '\0') {
g_printerr("[db] db_init_with_path: NULL path\n");
return -1;
}
/* Close any currently-open database so we can switch databases. */
if (g_db) {
sqlite3_close(g_db);
g_db = NULL;
}
int rc = sqlite3_open_v2(path, &g_db,
SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE |
SQLITE_OPEN_FULLMUTEX,
NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] Failed to open database: %s\n",
sqlite3_errmsg(g_db));
g_printerr("[db] Failed to open database '%s': %s\n",
path, g_db ? sqlite3_errmsg(g_db) : "(null)");
if (g_db) {
sqlite3_close(g_db);
g_db = NULL;
@@ -134,7 +182,8 @@ int db_init(void) {
if (err) sqlite3_free(err);
}
/* Create schema. */
/* Create schema (all tables use IF NOT EXISTS, so this is safe for
* both global.db and per-user browser.db). */
rc = sqlite3_exec(g_db, SCHEMA_SQL, NULL, NULL, &err);
if (rc != SQLITE_OK) {
g_printerr("[db] Failed to create schema: %s\n",
@@ -149,6 +198,33 @@ int db_init(void) {
return 0;
}
/*
* Open the global database at ~/.sovereign_browser/global.db.
* Used at startup for global settings + shortcuts, before login.
*/
int db_init_global(void) {
char path[512];
if (db_global_path(path, sizeof(path)) != 0) {
g_printerr("[db] Failed to get global database path\n");
return -1;
}
return db_init_with_path(path);
}
/*
* Open the default per-user database at ~/.sovereign_browser/browser.db.
* Kept for compatibility; the normal flow now uses db_init_with_path()
* with a profile-specific path after login.
*/
int db_init(void) {
char path[512];
if (db_path(path, sizeof(path)) != 0) {
g_printerr("[db] Failed to get database path\n");
return -1;
}
return db_init_with_path(path);
}
void db_close(void) {
if (g_db) {
sqlite3_close(g_db);
@@ -375,6 +451,90 @@ int db_count_events(const char *pubkey_hex, int kind) {
return count;
}
cJSON *db_get_events_by_kind(int kind, int limit) {
if (g_db == NULL) return NULL;
sqlite3_stmt *stmt = NULL;
int lim = (limit > 0) ? limit : 10000;
int rc = sqlite3_prepare_v2(g_db,
"SELECT raw_json FROM events "
"WHERE kind = ? "
"ORDER BY created_at DESC LIMIT ?;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] Prepare get_events_by_kind failed: %s\n",
sqlite3_errmsg(g_db));
return NULL;
}
sqlite3_bind_int(stmt, 1, kind);
sqlite3_bind_int(stmt, 2, lim);
cJSON *array = cJSON_CreateArray();
while (sqlite3_step(stmt) == SQLITE_ROW) {
const char *json_str = (const char *)sqlite3_column_text(stmt, 0);
if (json_str) {
cJSON *event = cJSON_Parse(json_str);
if (event) {
cJSON_AddItemToArray(array, event);
}
}
}
sqlite3_finalize(stmt);
return array;
}
int db_delete_event(const char *event_id) {
if (g_db == NULL || event_id == NULL) return -1;
char *err = NULL;
int rc = sqlite3_exec(g_db, "BEGIN TRANSACTION;", NULL, NULL, &err);
if (rc != SQLITE_OK) {
if (err) sqlite3_free(err);
return -1;
}
/* Delete tags first. */
sqlite3_stmt *stmt = NULL;
rc = sqlite3_prepare_v2(g_db,
"DELETE FROM event_tags WHERE event_id = ?;",
-1, &stmt, NULL);
if (rc == SQLITE_OK) {
sqlite3_bind_text(stmt, 1, event_id, -1, SQLITE_TRANSIENT);
sqlite3_step(stmt);
sqlite3_finalize(stmt);
}
/* Delete the event row. */
rc = sqlite3_prepare_v2(g_db,
"DELETE FROM events WHERE id = ?;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] delete_event prepare failed: %s\n",
sqlite3_errmsg(g_db));
sqlite3_exec(g_db, "ROLLBACK;", NULL, NULL, NULL);
return -1;
}
sqlite3_bind_text(stmt, 1, event_id, -1, SQLITE_TRANSIENT);
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
g_printerr("[db] delete_event step failed: %s\n",
sqlite3_errmsg(g_db));
sqlite3_exec(g_db, "ROLLBACK;", NULL, NULL, NULL);
return -1;
}
rc = sqlite3_exec(g_db, "COMMIT;", NULL, NULL, &err);
if (rc != SQLITE_OK) {
if (err) sqlite3_free(err);
return -1;
}
return 0;
}
/* ── Key-Value store ───────────────────────────────────────────────── */
int db_kv_set(const char *key, const char *value) {
@@ -394,6 +554,66 @@ int db_kv_set(const char *key, const char *value) {
return (rc == SQLITE_DONE) ? 0 : -1;
}
/*
* Write a key-value pair to a specific database file, opening a separate
* short-lived connection. This is used to save global settings to
* global.db while the per-user browser.db is the main open database.
* Creates the key_value table if it doesn't exist (safe for a fresh
* global.db). Returns 0 on success, -1 on error.
*/
int db_kv_set_to_file(const char *path, const char *key, const char *value) {
if (path == NULL || key == NULL || value == NULL) return -1;
sqlite3 *db = NULL;
int rc = sqlite3_open_v2(path, &db,
SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE |
SQLITE_OPEN_FULLMUTEX,
NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] db_kv_set_to_file: open '%s' failed: %s\n",
path, db ? sqlite3_errmsg(db) : "(null)");
if (db) sqlite3_close(db);
return -1;
}
sqlite3_busy_timeout(db, 5000);
/* Ensure the key_value table exists (harmless if already present). */
char *err = NULL;
rc = sqlite3_exec(db,
"CREATE TABLE IF NOT EXISTS key_value ("
" key TEXT PRIMARY KEY,"
" value TEXT"
");", NULL, NULL, &err);
if (rc != SQLITE_OK) {
g_printerr("[db] db_kv_set_to_file: create table failed: %s\n",
err ? err : "(unknown)");
if (err) sqlite3_free(err);
sqlite3_close(db);
return -1;
}
sqlite3_stmt *stmt = NULL;
rc = sqlite3_prepare_v2(db,
"INSERT OR REPLACE INTO key_value (key, value) VALUES (?, ?);",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] db_kv_set_to_file: prepare failed: %s\n",
sqlite3_errmsg(db));
sqlite3_close(db);
return -1;
}
sqlite3_bind_text(stmt, 1, key, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, value, -1, SQLITE_TRANSIENT);
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
sqlite3_close(db);
return (rc == SQLITE_DONE) ? 0 : -1;
}
const char *db_kv_get(const char *key) {
if (g_db == NULL || key == NULL) return NULL;
@@ -645,3 +865,292 @@ int db_session_clear(void) {
}
return 0;
}
/* ── Agent chat sessions ───────────────────────────────────────────── */
char *db_agent_session_create(const char *title) {
if (g_db == NULL) return NULL;
char *id = g_uuid_string_random();
if (id == NULL) {
g_printerr("[db] agent_session_create: g_uuid_string_random failed\n");
return NULL;
}
long now = (long)time(NULL);
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"INSERT INTO agent_sessions (id, title, created_at, updated_at) "
"VALUES (?, ?, ?, ?);",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] agent_session_create: prepare failed: %s\n",
sqlite3_errmsg(g_db));
g_free(id);
return NULL;
}
sqlite3_bind_text(stmt, 1, id, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, title ? title : "", -1, SQLITE_TRANSIENT);
sqlite3_bind_int64(stmt, 3, now);
sqlite3_bind_int64(stmt, 4, now);
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
g_printerr("[db] agent_session_create: insert failed: %s\n",
sqlite3_errmsg(g_db));
g_free(id);
return NULL;
}
return id;
}
char *db_agent_session_get_latest(void) {
if (g_db == NULL) return NULL;
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"SELECT id FROM agent_sessions ORDER BY updated_at DESC LIMIT 1;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] agent_session_get_latest: prepare failed: %s\n",
sqlite3_errmsg(g_db));
return NULL;
}
char *result = NULL;
if (sqlite3_step(stmt) == SQLITE_ROW) {
const char *id = (const char *)sqlite3_column_text(stmt, 0);
if (id) {
result = g_strdup(id);
}
}
sqlite3_finalize(stmt);
return result;
}
int db_agent_session_update(const char *session_id, const char *title) {
if (g_db == NULL || session_id == NULL) return -1;
long now = (long)time(NULL);
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"UPDATE agent_sessions SET title = ?, updated_at = ? "
"WHERE id = ?;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] agent_session_update: prepare failed: %s\n",
sqlite3_errmsg(g_db));
return -1;
}
sqlite3_bind_text(stmt, 1, title ? title : "", -1, SQLITE_TRANSIENT);
sqlite3_bind_int64(stmt, 2, now);
sqlite3_bind_text(stmt, 3, session_id, -1, SQLITE_TRANSIENT);
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
g_printerr("[db] agent_session_update: update failed: %s\n",
sqlite3_errmsg(g_db));
return -1;
}
return 0;
}
int db_agent_session_list(char ***ids_out, char ***titles_out,
int *count_out) {
if (g_db == NULL || ids_out == NULL || titles_out == NULL ||
count_out == NULL) {
return -1;
}
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"SELECT id, title FROM agent_sessions "
"ORDER BY updated_at DESC;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] agent_session_list: prepare failed: %s\n",
sqlite3_errmsg(g_db));
return -1;
}
/* First pass: count rows. */
int count = 0;
while (sqlite3_step(stmt) == SQLITE_ROW) count++;
sqlite3_reset(stmt);
/* Allocate arrays. */
*ids_out = g_new0(char *, count);
*titles_out = g_new0(char *, count);
*count_out = 0;
/* Second pass: fill. */
int i = 0;
while (sqlite3_step(stmt) == SQLITE_ROW && i < count) {
const char *id = (const char *)sqlite3_column_text(stmt, 0);
const char *title = (const char *)sqlite3_column_text(stmt, 1);
(*ids_out)[i] = g_strdup(id ? id : "");
(*titles_out)[i] = g_strdup(title ? title : "");
i++;
}
sqlite3_finalize(stmt);
*count_out = i;
return i;
}
int db_agent_message_add(const char *session_id, const char *role,
const char *content, const char *tool_calls,
const char *tool_call_id) {
if (g_db == NULL || session_id == NULL || role == NULL) return -1;
long now = (long)time(NULL);
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"INSERT INTO agent_messages "
"(session_id, role, content, tool_calls, tool_call_id, created_at) "
"VALUES (?, ?, ?, ?, ?, ?);",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] agent_message_add: prepare failed: %s\n",
sqlite3_errmsg(g_db));
return -1;
}
sqlite3_bind_text(stmt, 1, session_id, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, role, -1, SQLITE_TRANSIENT);
if (content) {
sqlite3_bind_text(stmt, 3, content, -1, SQLITE_TRANSIENT);
} else {
sqlite3_bind_null(stmt, 3);
}
if (tool_calls) {
sqlite3_bind_text(stmt, 4, tool_calls, -1, SQLITE_TRANSIENT);
} else {
sqlite3_bind_null(stmt, 4);
}
if (tool_call_id) {
sqlite3_bind_text(stmt, 5, tool_call_id, -1, SQLITE_TRANSIENT);
} else {
sqlite3_bind_null(stmt, 5);
}
sqlite3_bind_int64(stmt, 6, now);
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
g_printerr("[db] agent_message_add: insert failed: %s\n",
sqlite3_errmsg(g_db));
return -1;
}
return (int)sqlite3_last_insert_rowid(g_db);
}
cJSON *db_agent_message_list(const char *session_id) {
if (g_db == NULL || session_id == NULL) return NULL;
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"SELECT id, role, content, tool_calls, tool_call_id "
"FROM agent_messages WHERE session_id = ? "
"ORDER BY created_at ASC, id ASC;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] agent_message_list: prepare failed: %s\n",
sqlite3_errmsg(g_db));
return NULL;
}
sqlite3_bind_text(stmt, 1, session_id, -1, SQLITE_TRANSIENT);
cJSON *array = cJSON_CreateArray();
while (sqlite3_step(stmt) == SQLITE_ROW) {
cJSON *msg = cJSON_CreateObject();
cJSON_AddNumberToObject(msg, "id",
(double)sqlite3_column_int64(stmt, 0));
const char *role = (const char *)sqlite3_column_text(stmt, 1);
cJSON_AddStringToObject(msg, "role", role ? role : "");
/* content may be NULL. */
if (sqlite3_column_type(stmt, 2) == SQLITE_NULL) {
cJSON_AddNullToObject(msg, "content");
} else {
const char *content = (const char *)sqlite3_column_text(stmt, 2);
cJSON_AddStringToObject(msg, "content", content ? content : "");
}
/* tool_calls may be NULL. */
if (sqlite3_column_type(stmt, 3) == SQLITE_NULL) {
cJSON_AddNullToObject(msg, "tool_calls");
} else {
const char *tc = (const char *)sqlite3_column_text(stmt, 3);
cJSON_AddStringToObject(msg, "tool_calls", tc ? tc : "");
}
/* tool_call_id may be NULL. */
if (sqlite3_column_type(stmt, 4) == SQLITE_NULL) {
cJSON_AddNullToObject(msg, "tool_call_id");
} else {
const char *tcid = (const char *)sqlite3_column_text(stmt, 4);
cJSON_AddStringToObject(msg, "tool_call_id", tcid ? tcid : "");
}
cJSON_AddItemToArray(array, msg);
}
sqlite3_finalize(stmt);
return array;
}
int db_agent_session_delete(const char *session_id) {
if (g_db == NULL || session_id == NULL) return -1;
/* Delete messages first (in case foreign keys are not enforced). */
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(g_db,
"DELETE FROM agent_messages WHERE session_id = ?;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] agent_session_delete: prepare messages failed: %s\n",
sqlite3_errmsg(g_db));
return -1;
}
sqlite3_bind_text(stmt, 1, session_id, -1, SQLITE_TRANSIENT);
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
g_printerr("[db] agent_session_delete: delete messages failed: %s\n",
sqlite3_errmsg(g_db));
return -1;
}
/* Delete the session row. */
rc = sqlite3_prepare_v2(g_db,
"DELETE FROM agent_sessions WHERE id = ?;",
-1, &stmt, NULL);
if (rc != SQLITE_OK) {
g_printerr("[db] agent_session_delete: prepare session failed: %s\n",
sqlite3_errmsg(g_db));
return -1;
}
sqlite3_bind_text(stmt, 1, session_id, -1, SQLITE_TRANSIENT);
rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
g_printerr("[db] agent_session_delete: delete session failed: %s\n",
sqlite3_errmsg(g_db));
return -1;
}
return 0;
}

106
src/db.h
View File

@@ -28,14 +28,33 @@ extern "C" {
/*
* Initialize the database at ~/.sovereign_browser/browser.db.
* Creates tables and indexes if they don't exist.
* Call once at startup (after settings_load()).
* Kept for compatibility; prefer db_init_with_path() for per-user dbs.
*
* Returns 0 on success, -1 on error.
*/
int db_init(void);
/*
* Close the database. Call at shutdown.
* Initialize the global database at ~/.sovereign_browser/global.db.
* Used at startup for global settings + shortcuts, before login.
* Creates tables and indexes if they don't exist.
*
* Returns 0 on success, -1 on error.
*/
int db_init_global(void);
/*
* Open a SQLite database at the given path with the standard schema.
* Closes any currently-open database first (so you can switch from
* global.db to a per-user browser.db after login).
*
* Returns 0 on success, -1 on error.
*/
int db_init_with_path(const char *path);
/*
* Close the database. Call at shutdown, or before switching to a
* different database via db_init_with_path().
*/
void db_close(void);
@@ -76,14 +95,41 @@ cJSON *db_get_events(const char *pubkey_hex, int kind, int limit);
*/
int db_count_events(const char *pubkey_hex, int kind);
/*
* Fetch all events of a given kind from ALL authors (no pubkey filter),
* newest first. Used for public events like kind 31123 skills that are
* authored by anyone.
*
* limit — max number of events (0 = no limit)
*
* Returns a cJSON array of event objects. Caller must cJSON_Delete().
* Returns NULL on error.
*/
cJSON *db_get_events_by_kind(int kind, int limit);
/*
* Delete a single event by its event id (and its tags, via cascade).
* Returns 0 on success, -1 on error.
*/
int db_delete_event(const char *event_id);
/* ── Key-Value store ───────────────────────────────────────────────── */
/*
* Set a key-value pair (upsert).
* Set a key-value pair (upsert) in the currently-open database.
* Returns 0 on success, -1 on error.
*/
int db_kv_set(const char *key, const char *value);
/*
* Set a key-value pair in a specific database file, opening a separate
* short-lived connection. Used to save global settings to global.db
* while the per-user browser.db is the main open database. Creates the
* key_value table if it doesn't exist.
* Returns 0 on success, -1 on error.
*/
int db_kv_set_to_file(const char *path, const char *key, const char *value);
/*
* Get a value by key.
* Returns a pointer to the value string, or NULL if not found.
@@ -168,6 +214,60 @@ int db_session_load(char ***urls_out, char ***titles_out, int *count_out);
*/
int db_session_clear(void);
/* ── Agent chat sessions ───────────────────────────────────────────── */
/*
* Create a new chat session. Returns the session ID (newly allocated string,
* caller must g_free). Returns NULL on error.
*/
char *db_agent_session_create(const char *title);
/*
* Get the most recent chat session ID. Returns a newly allocated string,
* or NULL if no sessions exist. Caller must g_free.
*/
char *db_agent_session_get_latest(void);
/*
* Update a session's title and updated_at timestamp.
* Returns 0 on success, -1 on error.
*/
int db_agent_session_update(const char *session_id, const char *title);
/*
* List all chat sessions, newest first. Fills arrays with session IDs and
* titles. Caller must free each string and the arrays.
* Returns the number of sessions, or -1 on error.
*/
int db_agent_session_list(char ***ids_out, char ***titles_out,
int *count_out);
/*
* Add a message to a chat session.
* role — "user", "assistant", "tool", or "system"
* content — message text (may be NULL for assistant msgs with only tool_calls)
* tool_calls — JSON string of tool calls array (may be NULL)
* tool_call_id — for role="tool", the ID of the tool call this responds to (may be NULL)
* Returns the message row ID (>0) on success, -1 on error.
*/
int db_agent_message_add(const char *session_id, const char *role,
const char *content, const char *tool_calls,
const char *tool_call_id);
/*
* Load all messages for a session, ordered by created_at (oldest first).
* Returns a cJSON array of message objects, each with:
* {"id":N, "role":"...", "content":"...", "tool_calls":"...", "tool_call_id":"..."}
* Returns NULL on error. Caller must cJSON_Delete().
*/
cJSON *db_agent_message_list(const char *session_id);
/*
* Delete a chat session and all its messages.
* Returns 0 on success, -1 on error.
*/
int db_agent_session_delete(const char *session_id);
#ifdef __cplusplus
}
#endif

257
src/fips_control.c Normal file
View File

@@ -0,0 +1,257 @@
/* fips_control.c — synchronous FIPS JSON-line control client */
#define _POSIX_C_SOURCE 200809L
#include "fips_control.h"
#include "cjson/cJSON.h"
#include <errno.h>
#include <fcntl.h>
#include <poll.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
#define FIPS_RESPONSE_MAX (1024 * 1024)
static void set_error(char *out, size_t size, const char *format, const char *detail) {
if (out && size) snprintf(out, size, format, detail ? detail : "unknown error");
}
static int wait_fd(int fd, short events, int timeout_ms) {
struct pollfd pfd = { fd, events, 0 };
int rc;
do { rc = poll(&pfd, 1, timeout_ms); } while (rc < 0 && errno == EINTR);
return rc > 0 && (pfd.revents & events) ? 0 : -1;
}
int fips_control_connect(const char *socket_path, int timeout_ms,
char *error, size_t error_size) {
if (!socket_path || !socket_path[0]) {
set_error(error, error_size, "%s", "empty FIPS control socket path");
return -1;
}
int fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
if (fd < 0) {
set_error(error, error_size, "FIPS socket failed: %s", strerror(errno));
return -1;
}
struct sockaddr_un addr;
memset(&addr, 0, sizeof(addr));
addr.sun_family = AF_UNIX;
if (strlen(socket_path) >= sizeof(addr.sun_path)) {
close(fd);
set_error(error, error_size, "%s", "FIPS control socket path too long");
return -1;
}
snprintf(addr.sun_path, sizeof(addr.sun_path), "%s", socket_path);
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
int rc = connect(fd, (struct sockaddr *)&addr, sizeof(addr));
if (rc != 0 && errno == EINPROGRESS) {
if (wait_fd(fd, POLLOUT, timeout_ms) == 0) {
int socket_error = 0;
socklen_t len = sizeof(socket_error);
if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &socket_error, &len) == 0 &&
socket_error == 0) rc = 0;
else { errno = socket_error; rc = -1; }
} else rc = -1;
}
if (rc != 0) {
set_error(error, error_size, "FIPS connect failed: %s", strerror(errno));
close(fd);
return -1;
}
fcntl(fd, F_SETFL, flags);
return fd;
}
static int write_all(int fd, const char *text) {
size_t length = strlen(text), sent = 0;
while (sent < length) {
if (wait_fd(fd, POLLOUT, 1500) != 0) return -1;
ssize_t n = send(fd, text + sent, length - sent, MSG_NOSIGNAL);
if (n < 0 && errno == EINTR) continue;
if (n <= 0) return -1;
sent += (size_t)n;
}
return 0;
}
static cJSON *request(int fd, cJSON *request_obj, char *error, size_t error_size) {
char *json = cJSON_PrintUnformatted(request_obj);
if (!json) {
set_error(error, error_size, "%s", "cannot serialize FIPS request");
return NULL;
}
char *line = g_strdup_printf("%s\n", json);
cJSON_free(json);
if (write_all(fd, line) != 0) {
g_free(line);
set_error(error, error_size, "FIPS write failed: %s", strerror(errno));
return NULL;
}
g_free(line);
GString *response = g_string_sized_new(4096);
while (response->len < FIPS_RESPONSE_MAX) {
if (wait_fd(fd, POLLIN, 1500) != 0) {
g_string_free(response, TRUE);
set_error(error, error_size, "%s", "FIPS response timed out");
return NULL;
}
char buffer[4096];
ssize_t n = recv(fd, buffer, sizeof(buffer), 0);
if (n < 0 && errno == EINTR) continue;
if (n <= 0) {
g_string_free(response, TRUE);
set_error(error, error_size, "%s", "FIPS closed control connection");
return NULL;
}
char *newline = memchr(buffer, '\n', (size_t)n);
if (newline) {
g_string_append_len(response, buffer, newline - buffer);
break;
}
g_string_append_len(response, buffer, n);
}
cJSON *root = cJSON_Parse(response->str);
g_string_free(response, TRUE);
if (!root) {
set_error(error, error_size, "%s", "invalid FIPS JSON response");
return NULL;
}
cJSON *status = cJSON_GetObjectItemCaseSensitive(root, "status");
if (!cJSON_IsString(status) || strcmp(status->valuestring, "ok") != 0) {
cJSON *message = cJSON_GetObjectItemCaseSensitive(root, "message");
set_error(error, error_size, "FIPS control error: %s",
cJSON_IsString(message) ? message->valuestring : "request failed");
cJSON_Delete(root);
return NULL;
}
return root;
}
static void copy_json_string(cJSON *object, const char *name, char *out, size_t size) {
cJSON *item = cJSON_GetObjectItemCaseSensitive(object, name);
if (cJSON_IsString(item) && size) snprintf(out, size, "%s", item->valuestring);
}
int fips_control_show_status(int fd, fips_status_t *status,
char *error, size_t error_size) {
if (!status) return -1;
cJSON *cmd = cJSON_CreateObject();
cJSON_AddStringToObject(cmd, "command", "show_status");
cJSON *root = request(fd, cmd, error, error_size);
cJSON_Delete(cmd);
if (!root) return -1;
cJSON *data = cJSON_GetObjectItemCaseSensitive(root, "data");
if (!cJSON_IsObject(data)) {
cJSON_Delete(root);
set_error(error, error_size, "%s", "FIPS status response lacks data");
return -1;
}
memset(status, 0, sizeof(*status));
copy_json_string(data, "npub", status->npub, sizeof(status->npub));
copy_json_string(data, "tun_state", status->tun_state, sizeof(status->tun_state));
copy_json_string(data, "tun_name", status->tun_name, sizeof(status->tun_name));
copy_json_string(data, "state", status->state, sizeof(status->state));
copy_json_string(data, "tree_state", status->tree_state, sizeof(status->tree_state));
if (!status->tree_state[0]) copy_json_string(data, "tree", status->tree_state, sizeof(status->tree_state));
cJSON *peers = cJSON_GetObjectItemCaseSensitive(data, "peer_count");
if (cJSON_IsNumber(peers)) status->peer_count = peers->valueint;
status->tun_active = strcmp(status->tun_state, "active") == 0 ||
strcmp(status->tun_state, "up") == 0;
cJSON_Delete(root);
return 0;
}
char *fips_control_show_peers(int fd, char *error, size_t error_size) {
cJSON *cmd = cJSON_CreateObject();
cJSON_AddStringToObject(cmd, "command", "show_peers");
cJSON *root = request(fd, cmd, error, error_size);
cJSON_Delete(cmd);
if (!root) return NULL;
cJSON *data = cJSON_GetObjectItemCaseSensitive(root, "data");
char *printed = data ? cJSON_PrintUnformatted(data) : NULL;
char *result = printed ? g_strdup(printed) : NULL;
cJSON_free(printed);
cJSON_Delete(root);
return result;
}
/* Generic single-command "show_*" that returns the data field as
* compact JSON. Used by show_tree and show_identity_cache, which have
* the same response shape as show_peers. */
static char *fips_control_show_generic(int fd, const char *command,
char *error, size_t error_size) {
cJSON *cmd = cJSON_CreateObject();
cJSON_AddStringToObject(cmd, "command", command);
cJSON *root = request(fd, cmd, error, error_size);
cJSON_Delete(cmd);
if (!root) return NULL;
cJSON *data = cJSON_GetObjectItemCaseSensitive(root, "data");
char *printed = data ? cJSON_PrintUnformatted(data) : NULL;
char *result = printed ? g_strdup(printed) : NULL;
cJSON_free(printed);
cJSON_Delete(root);
return result;
}
char *fips_control_show_tree(int fd, char *error, size_t error_size) {
return fips_control_show_generic(fd, "show_tree", error, error_size);
}
char *fips_control_show_identity_cache(int fd, char *error, size_t error_size) {
return fips_control_show_generic(fd, "show_identity_cache", error, error_size);
}
int fips_control_connect_peer(int fd, const char *npub, const char *address,
const char *transport,
char *error, size_t error_size) {
if (!npub || !npub[0] || !address || !address[0]) {
set_error(error, error_size, "%s", "FIPS peer npub and address are required");
return -1;
}
cJSON *cmd = cJSON_CreateObject();
cJSON_AddStringToObject(cmd, "command", "connect");
cJSON *params = cJSON_AddObjectToObject(cmd, "params");
cJSON_AddStringToObject(params, "npub", npub);
cJSON_AddStringToObject(params, "address", address);
cJSON_AddStringToObject(params, "transport", transport && transport[0] ? transport : "udp");
cJSON *root = request(fd, cmd, error, error_size);
cJSON_Delete(cmd);
if (!root) return -1;
cJSON_Delete(root);
return 0;
}
int fips_control_disconnect_peer(int fd, const char *npub,
char *error, size_t error_size) {
if (!npub || !npub[0]) {
set_error(error, error_size, "%s", "FIPS peer npub is required");
return -1;
}
cJSON *cmd = cJSON_CreateObject();
cJSON_AddStringToObject(cmd, "command", "disconnect");
cJSON *params = cJSON_AddObjectToObject(cmd, "params");
cJSON_AddStringToObject(params, "npub", npub);
cJSON *root = request(fd, cmd, error, error_size);
cJSON_Delete(cmd);
if (!root) return -1;
cJSON_Delete(root);
return 0;
}
void fips_control_close(int *fd) {
if (fd && *fd >= 0) { close(*fd); *fd = -1; }
}
gboolean fips_control_socket_available(const char *socket_path, int timeout_ms) {
int fd = fips_control_connect(socket_path, timeout_ms, NULL, 0);
if (fd < 0) return FALSE;
close(fd);
return TRUE;
}

44
src/fips_control.h Normal file
View File

@@ -0,0 +1,44 @@
/* fips_control.h — FIPS Unix-socket JSON control client */
#ifndef FIPS_CONTROL_H
#define FIPS_CONTROL_H
#include <glib.h>
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef struct {
char npub[80];
int peer_count;
char tun_state[32];
char tun_name[16];
char state[32];
char tree_state[32];
gboolean tun_active;
} fips_status_t;
int fips_control_connect(const char *socket_path, int timeout_ms,
char *error, size_t error_size);
int fips_control_show_status(int fd, fips_status_t *status,
char *error, size_t error_size);
/* Returns the response data as compact JSON. Caller frees with g_free(). */
char *fips_control_show_peers(int fd, char *error, size_t error_size);
/* show_tree / show_identity_cache — same contract as show_peers. */
char *fips_control_show_tree(int fd, char *error, size_t error_size);
char *fips_control_show_identity_cache(int fd, char *error, size_t error_size);
int fips_control_connect_peer(int fd, const char *npub, const char *address,
const char *transport,
char *error, size_t error_size);
int fips_control_disconnect_peer(int fd, const char *npub,
char *error, size_t error_size);
void fips_control_close(int *fd);
gboolean fips_control_socket_available(const char *socket_path, int timeout_ms);
#ifdef __cplusplus
}
#endif
#endif /* FIPS_CONTROL_H */

View File

@@ -29,6 +29,9 @@ void history_add_titled(const char *url, const char *title) {
strncmp(url, "sovereign://bookmarks/delete", 28) == 0 ||
strncmp(url, "sovereign://bookmarks/createdir", 31) == 0 ||
strncmp(url, "sovereign://bookmarks/deletedir", 31) == 0 ||
strncmp(url, "sovereign://bookmarks/move", 26) == 0 ||
strncmp(url, "sovereign://bookmarks/renamedir", 31) == 0 ||
strncmp(url, "sovereign://bookmarks/rename", 28) == 0 ||
strncmp(url, "sovereign://qr", 14) == 0 ||
strncmp(url, "sovereign://nostr/", 18) == 0) {
return;

View File

@@ -9,9 +9,14 @@
* in-memory identity struct
* - key_store_delete_legacy_file() — defensively delete any
* identity.json left by a previous version that persisted keys
* - key_store_save_profile_identity() — save public identity info
* (method, pubkey_hex) to the per-profile identity.json
* - key_store_load_profile_identity() — load public identity info
* from the per-profile identity.json
*/
#include "key_store.h"
#include "profile.h"
#include <stdio.h>
#include <stdlib.h>
@@ -19,6 +24,8 @@
#include <unistd.h>
#include <errno.h>
#include "../nostr_core_lib/cjson/cJSON.h"
#include "nostr_core/nostr_core.h"
#include "nostr_core/nip006.h"
#include "nostr_core/nip019.h"
@@ -77,6 +84,12 @@ nostr_signer_t *key_store_create_signer(const key_store_identity_t *identity) {
if (signer && identity->nsigner_index >= 0) {
nostr_signer_nsigner_set_nostr_index(signer, identity->nsigner_index);
}
/* n_signer's serial and TCP transports require a kind-27235 auth
* envelope on every request. Install the default caller identity
* so the re-created signer can talk to the device. */
if (signer) {
key_store_nsigner_set_default_auth(signer);
}
return signer;
#else
return NULL;
@@ -88,6 +101,37 @@ nostr_signer_t *key_store_create_signer(const key_store_identity_t *identity) {
}
}
/* ── n_signer default caller auth ─────────────────────────────────── *
* n_signer's serial and TCP transports require a kind-27235 auth envelope
* on every request. Without it the device rejects the call with an
* auth-related RPC error (codes 2010-2017) that the client maps to
* NOSTR_ERROR_NIP46_AUTH_CHALLENGE (-310) — which presents to the user as
* "device requires approval" even though no on-device approval is actually
* needed.
*
* This installs the same fixed, well-known caller identity used by n_signer's
* own webserial demo (privkey bytes 1,2,3,...,32). It is a caller-side
* authentication credential only — it does NOT grant access to the device's
* mnemonic/keys; the device's own policy still decides which operations are
* allowed. No-op for non-nsigner backends.
*/
int key_store_nsigner_set_default_auth(nostr_signer_t *signer) {
if (signer == NULL) {
return -1;
}
#if defined(NOSTR_ENABLE_NSIGNER_CLIENT)
/* Match the webserial demo's caller privkey: bytes 1..32. */
static const unsigned char default_caller_priv[32] = {
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32
};
return nostr_signer_nsigner_set_auth(signer, default_caller_priv,
"sovereign_browser");
#else
return -1;
#endif
}
/* ── Legacy file cleanup ──────────────────────────────────────────── */
int key_store_delete_legacy_file(void) {
@@ -109,3 +153,99 @@ int key_store_delete_legacy_file(void) {
}
return 0;
}
/* ── Per-profile identity persistence ──────────────────────────────── *
* The per-profile identity.json stores public identity information
* (method, pubkey_hex) so the browser can display profile info without
* requiring the user to log in first. Private keys are NEVER stored.
*/
int key_store_save_profile_identity(const char *pubkey_hex,
key_store_method_t method) {
if (pubkey_hex == NULL || pubkey_hex[0] == '\0') {
return -1;
}
char path[512];
profile_get_identity_path(pubkey_hex, path, sizeof(path));
if (path[0] == '\0') {
return -1;
}
cJSON *root = cJSON_CreateObject();
cJSON_AddStringToObject(root, "pubkey_hex", pubkey_hex);
cJSON_AddNumberToObject(root, "method", (double)method);
/* Method name for human readability. */
const char *method_name = "none";
switch (method) {
case KEY_STORE_METHOD_NONE: method_name = "none"; break;
case KEY_STORE_METHOD_LOCAL: method_name = "local"; break;
case KEY_STORE_METHOD_SEED: method_name = "seed"; break;
case KEY_STORE_METHOD_READONLY: method_name = "readonly"; break;
case KEY_STORE_METHOD_NIP46: method_name = "nip46"; break;
case KEY_STORE_METHOD_NSIGNER: method_name = "nsigner"; break;
}
cJSON_AddStringToObject(root, "method_name", method_name);
char *json = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
if (json == NULL) {
return -1;
}
FILE *f = fopen(path, "w");
if (f == NULL) {
free(json);
return -1;
}
fputs(json, f);
fputc('\n', f);
fclose(f);
free(json);
return 0;
}
int key_store_load_profile_identity(const char *pubkey_hex,
key_store_method_t *method_out) {
if (pubkey_hex == NULL || pubkey_hex[0] == '\0') {
return -1;
}
char path[512];
profile_get_identity_path(pubkey_hex, path, sizeof(path));
if (path[0] == '\0') {
return -1;
}
FILE *f = fopen(path, "r");
if (f == NULL) {
return -1;
}
char buf[1024];
size_t n = fread(buf, 1, sizeof(buf) - 1, f);
fclose(f);
buf[n] = '\0';
cJSON *root = cJSON_Parse(buf);
if (root == NULL) {
return -1;
}
int rc = -1;
cJSON *pk = cJSON_GetObjectItemCaseSensitive(root, "pubkey_hex");
if (cJSON_IsString(pk) && strcmp(pk->valuestring, pubkey_hex) == 0) {
if (method_out) {
cJSON *m = cJSON_GetObjectItemCaseSensitive(root, "method");
if (cJSON_IsNumber(m)) {
*method_out = (key_store_method_t)m->valuedouble;
}
}
rc = 0;
}
cJSON_Delete(root);
return rc;
}

View File

@@ -69,6 +69,42 @@ nostr_signer_t *key_store_create_signer(const key_store_identity_t *identity);
*/
int key_store_delete_legacy_file(void);
/*
* Save public identity info (method, pubkey_hex) to the per-profile
* identity.json at ~/.sovereign_browser/profiles/<pubkey>/identity.json.
* Private keys are NEVER stored — only public information for display.
* Returns 0 on success, -1 on error.
*/
int key_store_save_profile_identity(const char *pubkey_hex,
key_store_method_t method);
/*
* Load public identity info from the per-profile identity.json.
* pubkey_hex — the expected pubkey (must match the file's pubkey)
* method_out — if non-NULL, receives the stored login method
* Returns 0 on success, -1 if not found or pubkey mismatch.
*/
int key_store_load_profile_identity(const char *pubkey_hex,
key_store_method_t *method_out);
/*
* Set a default caller auth identity on an n_signer remote signer.
*
* n_signer's serial and TCP transports require a kind-27235 auth envelope
* on every request (the device rejects unauthenticated calls with an
* auth-related RPC error that maps to NOSTR_ERROR_NIP46_AUTH_CHALLENGE).
* This installs a fixed, well-known caller identity (the same one used by
* n_signer's own webserial demo) so the browser can talk to a USB-attached
* n_signer without the user having to configure caller credentials.
*
* Safe to call on any signer; no-op for non-nsigner backends. Call after
* the signer is created and before the first verb (e.g. before
* nostr_signer_nsigner_set_nostr_index / nostr_signer_get_public_key).
*
* Returns 0 on success, non-zero on error (e.g. not an nsigner signer).
*/
int key_store_nsigner_set_default_auth(nostr_signer_t *signer);
#ifdef __cplusplus
}
#endif

View File

@@ -12,6 +12,7 @@
#include "login_dialog.h"
#include "key_store.h"
#include "agent_login.h"
#include "version.h"
#include <string.h>
#include <stdlib.h>
@@ -111,6 +112,7 @@ static GtkWidget *create_seed_screen(login_ctx_t *ctx);
static GtkWidget *create_readonly_screen(login_ctx_t *ctx);
static GtkWidget *create_nip46_screen(login_ctx_t *ctx);
static GtkWidget *create_nsigner_screen(login_ctx_t *ctx);
static void on_index_changed(GtkWidget *spin, gpointer user_data);
#if defined(NOSTR_ENABLE_NSIGNER_CLIENT)
static void on_detect_serial(GtkWidget *btn, gpointer user_data);
#endif
@@ -512,6 +514,13 @@ static void on_login_clicked(GtkWidget *btn, gpointer user_data) {
return;
}
/* n_signer's serial/TCP transports require a kind-27235 auth envelope
* on every request. Install the default caller identity (matching
* n_signer's webserial demo) before issuing any verbs. Without this
* the device rejects the call with an auth error that surfaces as
* "code -310". */
key_store_nsigner_set_default_auth(signer);
int rc_idx = nostr_signer_nsigner_set_nostr_index(signer, nostr_index);
if (rc_idx != NOSTR_SUCCESS) {
gtk_label_set_text(GTK_LABEL(ctx->status_label),
@@ -524,19 +533,34 @@ static void on_login_clicked(GtkWidget *btn, gpointer user_data) {
char pubkey_hex[65];
int rc_pk = nostr_signer_get_public_key(signer, pubkey_hex);
if (rc_pk != NOSTR_SUCCESS) {
char errmsg[256];
char errmsg[320];
const char *desc = "unknown error";
switch (rc_pk) {
case -5: desc = "I/O failed — signer may have denied the request or disconnected"; break;
case -2001: desc = "policy denied — caller not approved at signer terminal"; break;
case -2002: desc = "index not in signer's whitelist"; break;
case -3: desc = "crypto operation failed"; break;
case NOSTR_ERROR_NIP46_AUTH_CHALLENGE:
/* Device RPC codes 2010-2017: auth-related rejection.
* Most commonly this means the kind-27235 caller auth
* envelope was missing/rejected (the default caller
* identity is installed automatically, but the device's
* policy may still deny it). It can also mean the device
* is requesting on-device approval (button press / TUI
* confirm) for this operation. */
desc = "auth rejected by n_signer (code -310). The caller "
"auth envelope was missing or denied, or the device "
"is requesting on-device approval. Confirm any "
"prompt on the n_signer hardware, then click Sign "
"In again";
break;
default: break;
}
snprintf(errmsg, sizeof(errmsg),
"n_signer error: %s (code %d). Try a different key index.",
desc, rc_pk);
gtk_label_set_text(GTK_LABEL(ctx->status_label), errmsg);
nostr_signer_free(signer);
sigprocmask(SIG_SETMASK, &old_set, NULL);
return;
}
@@ -1016,17 +1040,24 @@ static GtkWidget *create_nsigner_screen(login_ctx_t *ctx) {
g_signal_connect(transport_combo, "changed",
G_CALLBACK(on_transport_changed), box);
/* Nostr index spinner. */
/* Nostr index spinner. The label shows the derived BIP-32 path
* m/44'/1237'/N'/0/0 and updates N' live as the user changes the
* spin button value, so they can see which key they are selecting. */
GtkWidget *index_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_box_pack_start(GTK_BOX(box), index_box, FALSE, FALSE, 0);
GtkWidget *index_label = gtk_label_new("Key Index (NIP-06 m/44'/1237'/N'/0/0):");
GtkWidget *index_label = gtk_label_new("Key Index (m/44'/1237'/0'/0/0):");
gtk_widget_set_halign(index_label, GTK_ALIGN_START);
gtk_box_pack_start(GTK_BOX(index_box), index_label, FALSE, FALSE, 0);
GtkWidget *index_spin = gtk_spin_button_new_with_range(0, 1000, 1);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(index_spin), 0);
gtk_box_pack_start(GTK_BOX(index_box), index_spin, FALSE, FALSE, 0);
/* Update the path label whenever the index value changes. */
g_signal_connect(index_spin, "value-changed",
G_CALLBACK(on_index_changed), index_label);
GtkWidget *hint = gtk_label_new("n_signer is a foreground, RAM-only hardware signer. Your private key never leaves the device.");
gtk_widget_set_sensitive(hint, FALSE);
gtk_widget_set_halign(hint, GTK_ALIGN_START);
@@ -1040,9 +1071,23 @@ static GtkWidget *create_nsigner_screen(login_ctx_t *ctx) {
g_object_set_data(G_OBJECT(box), "service-box", service_box);
g_object_set_data(G_OBJECT(box), "service-entry", service_entry);
g_object_set_data(G_OBJECT(box), "index-spin", index_spin);
g_object_set_data(G_OBJECT(box), "index-label", index_label);
return box;
}
/* Update the key-index path label when the spin button value changes.
* Shows the live BIP-32 derivation path m/44'/1237'/N'/0/0 with the
* current N value substituted in. */
static void on_index_changed(GtkWidget *spin, gpointer user_data) {
GtkSpinButton *sb = GTK_SPIN_BUTTON(spin);
GtkWidget *label = GTK_WIDGET(user_data);
int idx = gtk_spin_button_get_value_as_int(sb);
char text[96];
snprintf(text, sizeof(text),
"Key Index (m/44'/1237'/%d'/0/0):", idx);
gtk_label_set_text(GTK_LABEL(label), text);
}
/* ── Serial device enumeration callback ───────────────────────── */
#if defined(NOSTR_ENABLE_NSIGNER_CLIENT)
@@ -1092,7 +1137,7 @@ int login_dialog_run(GtkWindow *parent, login_result_t *result) {
/* Create dialog without auto-responding buttons — we add custom
* buttons so we control whether the dialog closes. */
GtkWidget *dialog = gtk_dialog_new();
gtk_window_set_title(GTK_WINDOW(dialog), "sovereign browser — Sign In");
gtk_window_set_title(GTK_WINDOW(dialog), "sovereign browser " SB_VERSION);
gtk_window_set_modal(GTK_WINDOW(dialog), TRUE);
if (parent) gtk_window_set_transient_for(GTK_WINDOW(dialog), parent);
gtk_window_set_default_size(GTK_WINDOW(dialog), 560, 380);

View File

@@ -36,6 +36,8 @@
#include "key_store.h"
#include "login_dialog.h"
#include "nostr_bridge.h"
#include "nostr_scheme.h"
#include "tor_scheme.h"
#include "nostr_inject.h"
#include "history.h"
#include "settings.h"
@@ -47,8 +49,14 @@
#include "agent_login.h"
#include "cli.h"
#include "db.h"
#include "profile.h"
#include "relay_fetch.h"
#include "bookmarks.h"
#include "agent_conversations.h"
#include "agent_skills.h"
#include "net_services.h"
#include "webkit_data.h"
#include "web_context.h"
#include "nostr_core/nostr_core.h"
/* ---- Global state --------------------------------------------------- *
@@ -60,18 +68,33 @@
typedef struct {
nostr_signer_t *signer; /* NULL for read-only mode */
char pubkey_hex[65];
char privkey_hex[65]; /* in-memory only; for HMAC d-tag derivation */
key_store_method_t method;
gboolean readonly; /* TRUE if no signing available */
} app_state_t;
static app_state_t g_state = {0};
/* Forward declarations — defined later in this file. */
static int switch_to_user_db(const char *pubkey_hex);
static int do_login(GtkWindow *parent);
static WebKitWebContext *build_context_for_current_user(void);
static char g_current_profile_db[512];
static GtkWindow *g_window = NULL;
static gboolean g_logged_in = FALSE;
static gboolean g_is_fullscreen = FALSE; /* track fullscreen state (GTK3 has no getter */
/* TRUE once main() has finished initial startup (tab_manager_init has
* run). Used by app_set_signer() to distinguish a first-time login
* (context built later by main()) from a runtime identity switch
* (context was torn down by identity_teardown_web_state and must be
* rebuilt here). */
static gboolean g_post_startup = FALSE;
/* ---- App state accessors (used by agent_login.c) ─────────────────── */
void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex,
const char *privkey_hex,
key_store_method_t method, gboolean readonly) {
g_state.signer = signer;
g_state.method = method;
@@ -80,11 +103,44 @@ void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex,
strncpy(g_state.pubkey_hex, pubkey_hex, 64);
g_state.pubkey_hex[64] = '\0';
}
if (privkey_hex && privkey_hex[0]) {
strncpy(g_state.privkey_hex, privkey_hex, 64);
g_state.privkey_hex[64] = '\0';
} else {
g_state.privkey_hex[0] = '\0';
}
g_logged_in = TRUE;
/* Update modules that hold a signer reference. */
settings_sync_set_signer(signer,
g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
agent_conversations_set_signer(signer,
g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
/* If this is the first login (we're still on global.db), switch to
* the per-user profile database. If we're already on a per-user db
* (e.g. switching identity at runtime), switch_to_user_db() will
* close it and open the new user's db. */
if (g_state.pubkey_hex[0] != '\0') {
switch_to_user_db(g_state.pubkey_hex);
}
/* If this is a RUNTIME identity switch (past startup) and the
* previous context was torn down by identity_teardown_web_state(),
* rebuild a fresh per-user context for the new identity now. On the
* first login (before main() calls tab_manager_init), g_post_startup
* is FALSE and web_context_get() is NULL, so main() builds the
* context after login — we skip here to avoid a double-build. */
if (g_post_startup && web_context_get() == NULL) {
g_print("[identity] Rebuilding web context for new identity (runtime switch)\n");
if (build_context_for_current_user() != NULL) {
/* Open a fresh tab for the new user so they don't stare at
* an empty window after the teardown closed all tabs. */
tab_manager_new_tab(settings_get()->new_tab_url);
} else {
g_printerr("[identity] Failed to rebuild web context\n");
}
}
}
void app_clear_signer(void) {
@@ -93,11 +149,13 @@ void app_clear_signer(void) {
g_state.signer = NULL;
}
g_state.pubkey_hex[0] = '\0';
g_state.privkey_hex[0] = '\0';
g_state.method = KEY_STORE_METHOD_NONE;
g_state.readonly = FALSE;
g_logged_in = FALSE;
settings_sync_set_signer(NULL, NULL);
agent_conversations_set_signer(NULL, NULL);
}
nostr_signer_t *app_get_signer(void) { return g_state.signer; }
@@ -111,6 +169,62 @@ gboolean app_get_readonly(void) { return g_state.readonly; }
* menu builder.
*/
/* ── Web state teardown (Phase 0 of plans/webkit-data-isolation.md) ── *
* Called on logout and identity switch. Closes all tabs, destroys the
* agent chat sidebar webviews (which hold a long-lived WebKitWebView
* with the previous user's sovereign://agents/chat session), and wipes
* all WebKit website data (cookies, cache, localStorage, IndexedDB,
* service workers, favicons) from the shared WebKitWebsiteDataManager.
*
* Without this, the next user inherits the previous user's web session:
* cookies identify you to web pages, localStorage holds per-site state,
* and the live tabs keep the previous user's DOM in memory. See
* plans/webkit-data-isolation.md for the full rationale.
*
* This must run on the GTK main thread. It pumps a nested main loop
* briefly while webkit_website_data_manager_clear() completes.
*/
void identity_teardown_web_state(void) {
/* 1. Close all tabs. This destroys the webviews and drops their
* in-memory DOM/localStorage. Must happen before the context
* teardown so no webviews hold dangling references to the old
* context. Suppress the normal "last tab closed → quit the app"
* behavior so the browser stays alive for the next user; the
* caller opens a fresh tab after the switch. */
tab_manager_set_suppress_quit_on_last_tab(TRUE);
tab_manager_close_all();
tab_manager_set_suppress_quit_on_last_tab(FALSE);
/* 2. Destroy the agent chat sidebar webviews in every window. The
* sidebar webview is outside the notebook so tab_manager_close_all
* does not touch it; it would otherwise keep a reference to the
* old context (and the previous user's chat session) alive. */
tab_manager_destroy_sidebar_webviews();
/* 3. Tear down the per-user WebKitWebContext. This unrefs the
* context and its per-user WebKitWebsiteDataManager. With Phase A
* isolation, each user has their own data manager rooted at
* profiles/<pubkey>/webkit/, so tearing down the context is what
* actually isolates the users — the next build creates a fresh
* data manager for the new user. The Phase 0 webkit_data_clear_all
* wipe is no longer needed for isolation (the data manager is
* per-user), but we keep it as a defense-in-depth safety net in
* case any process-global WebKit state survived the context
* teardown. */
WebKitWebContext *ctx = web_context_get();
if (ctx != NULL) {
/* Defense-in-depth: clear the data manager before tearing down
* the context. This catches any process-global caches that
* outlive the context. */
int rc = webkit_data_clear_all(ctx, FALSE);
if (rc != 0) {
g_printerr("[identity] webkit_data_clear_all returned %d "
"(continuing with context teardown)\n", rc);
}
web_context_teardown();
}
}
void app_menu_switch_identity_proxy(GtkMenuItem *item, gpointer data) {
(void)item;
GtkWindow *window = GTK_WINDOW(data);
@@ -118,6 +232,10 @@ void app_menu_switch_identity_proxy(GtkMenuItem *item, gpointer data) {
login_result_t result;
if (login_dialog_run(window, &result) == 0) {
/* Tear down the previous user's web state BEFORE installing the
* new signer, so the new user starts with a clean web session. */
identity_teardown_web_state();
if (g_state.signer) {
nostr_signer_free(g_state.signer);
}
@@ -130,6 +248,28 @@ void app_menu_switch_identity_proxy(GtkMenuItem *item, gpointer data) {
nostr_bridge_set_signer(g_state.signer, g_state.pubkey_hex,
g_state.readonly);
/* Switch to the new user's per-user profile database. This
* closes the current browser.db and opens the new user's
* browser.db, then loads their per-user settings. */
if (g_state.pubkey_hex[0] != '\0') {
switch_to_user_db(g_state.pubkey_hex);
}
/* Build a fresh per-user WebKitWebContext for the new identity
* (with its own per-user data manager) and re-register the
* sovereign://, nostr://, tor:// schemes on it. The old context
* was torn down by identity_teardown_web_state() above. */
if (build_context_for_current_user() == NULL) {
g_printerr("[identity] Failed to build context for new user — "
"browser will have no web views\n");
}
/* Open a fresh tab for the new user so they don't stare at an
* empty window after the teardown closed all the previous user's
* tabs. Uses the new user's per-user new_tab_url setting. */
const char *url = settings_get()->new_tab_url;
tab_manager_new_tab(url);
g_print("[identity] switched: method=%d pubkey=%s\n",
g_state.method, g_state.pubkey_hex);
}
@@ -149,15 +289,51 @@ void app_menu_lock_session_proxy(GtkMenuItem *item, gpointer data) {
void app_menu_logout_proxy(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
GtkWindow *window = GTK_WINDOW(data);
/* Tear down the current user's web state (closes all tabs, destroys
* the sidebar webviews, wipes cookies/cache/localStorage/service
* workers/favicons) BEFORE clearing the signer, so the next login
* starts with a clean web session. See plans/webkit-data-isolation.md. */
identity_teardown_web_state();
if (g_state.signer) {
nostr_signer_free(g_state.signer);
g_state.signer = NULL;
}
g_state.pubkey_hex[0] = '\0';
g_state.privkey_hex[0] = '\0';
g_state.method = KEY_STORE_METHOD_NONE;
g_state.readonly = FALSE;
g_logged_in = FALSE;
settings_sync_set_signer(NULL, NULL);
agent_conversations_set_signer(NULL, NULL);
/* Reset the per-user db tracking so the next login re-opens the
* new user's browser.db rather than assuming we're already on it. */
g_current_profile_db[0] = '\0';
g_print("[identity] logged out\n");
/* Re-show the login dialog so the user (or agent) can log in as a
* different identity. The dialog runs a nested main loop, so the
* agent server stays live and an agent can log in via MCP while the
* dialog is showing. On success, do_login() installs the new signer
* and switch_to_user_db() opens the new user's profile. */
if (window != NULL) {
if (do_login(window) == 0) {
/* Build a fresh per-user WebKitWebContext for the new
* identity. The old context was torn down above. For
* --no-login mode this builds an ephemeral context. */
if (build_context_for_current_user() != NULL) {
/* Open a fresh tab for the new user. */
tab_manager_new_tab(settings_get()->new_tab_url);
} else {
g_printerr("[identity] Failed to build context after re-login\n");
}
}
}
}
void app_menu_security_strip_proxy(GtkCheckMenuItem *item, gpointer data) {
@@ -166,10 +342,46 @@ void app_menu_security_strip_proxy(GtkCheckMenuItem *item, gpointer data) {
g_print("[menu] security strip: %s\n", active ? "ON" : "OFF");
}
void app_menu_fips_proxy(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
g_print("[menu] FIPS URI scheme: not yet implemented (roadmap)\n");
void app_menu_network_service_proxy(GtkCheckMenuItem *item, gpointer data) {
if (g_object_get_data(G_OBJECT(item), "network-toggle-sync") != NULL) return;
net_service_type_t type = (net_service_type_t)GPOINTER_TO_INT(data);
if (type != NET_SERVICE_TOR && type != NET_SERVICE_FIPS) {
g_printerr("[menu.network] Invalid network service type %d\n", (int)type);
return;
}
const char *name = type == NET_SERVICE_TOR ? "Tor" : "FIPS";
gboolean enabled = gtk_check_menu_item_get_active(item);
browser_settings_t *settings = settings_get_mutable();
gboolean *setting = type == NET_SERVICE_TOR
? &settings->tor_enabled : &settings->fips_enabled;
*setting = enabled;
settings_save_user();
int rc = enabled ? net_service_enable(type) : net_service_disable(type);
if (enabled && rc != 0) {
const net_service_t *status = net_service_get_status(type);
g_printerr("[menu.network] Failed to enable %s service: %s; reverting preference\n",
name, status && status->error_msg
? status->error_msg : "synchronous startup failure");
*setting = FALSE;
settings_save_user();
g_object_set_data(G_OBJECT(item), "network-toggle-sync",
GINT_TO_POINTER(1));
gtk_check_menu_item_set_active(item, FALSE);
g_object_set_data(G_OBJECT(item), "network-toggle-sync", NULL);
return;
}
if (rc != 0) {
g_printerr("[menu.network] Failed to disable %s service (preference remains disabled)\n",
name);
} else {
g_print("[menu.network] %s service %s; per-user preference saved\n",
name, enabled ? "enabled" : "disabled");
}
}
void app_menu_nostr_sign_proxy(GtkMenuItem *item, gpointer data) {
@@ -209,29 +421,45 @@ void app_menu_about_proxy(GtkMenuItem *item, gpointer data) {
* sovereign:// URI scheme bridge in nostr_bridge.c.
*/
/* Hamburger-menu items for sovereign:// internal pages always open in
* a new tab so the user's current page is preserved. */
void on_menu_settings(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview) {
webkit_web_view_load_uri(tab->webview, "sovereign://settings");
} else {
/* No active tab — open a new one pointed at the settings page. */
tab_manager_new_tab("sovereign://settings");
}
tab_manager_new_tab("sovereign://settings");
}
void on_menu_profile(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_manager_new_tab("sovereign://profile");
}
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview) {
webkit_web_view_load_uri(tab->webview, "sovereign://profile");
} else {
tab_manager_new_tab("sovereign://profile");
}
/* The hamburger-menu "Agent Setup…" item opens the sovereign://agents
* internal page in a new tab. It renders the agent provider
* configuration UI and a link to open the agent chat. */
void on_menu_agent(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_manager_new_tab("sovereign://agents");
}
/* The hamburger-menu "FIPS Mesh…" item opens the sovereign://fips
* internal page in a new tab. It renders the FIPS mesh network
* status + management UI. */
void on_menu_fips(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_manager_new_tab("sovereign://fips");
}
/* The hamburger-menu "Processes…" item opens the sovereign://processes
* internal page in a new tab. It renders the process list + per-tab
* performance diagnostics UI. */
void on_menu_processes(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_manager_new_tab("sovereign://processes");
}
/* ---- Keyboard shortcuts --------------------------------------------- *
@@ -241,12 +469,44 @@ void on_menu_profile(GtkMenuItem *item, gpointer data) {
* sovereign://settings page (key-capture UI) and persisted to SQLite.
*/
/* Made non-static so tab_manager.c can connect it to each webview. */
/* Made non-static so tab_manager.c can connect it to each webview.
*
* This handler is connected to two places:
* 1. Each webview's "key-press-event" (in tab_manager.c tab_create())
* 2. The main window's "key-press-event" (in main.c setup)
*
* For the webview connection, we bypass shortcut interception when the
* active page is a sovereign:// internal page — this lets the page's JS
* capture arbitrary key combos (e.g. the settings page's shortcut capture
* UI needs to see Ctrl+key events that would otherwise be intercepted).
*
* For the window connection, we always process shortcuts so that
* browser-level shortcuts (Ctrl+T, Ctrl+N, etc.) work even on
* sovereign:// pages. The window handler is a fallback that fires after
* the webview handler (event propagation: child → parent → window). */
gboolean on_key_press(GtkWidget *widget, GdkEventKey *event,
gpointer data) {
(void)widget;
(void)data;
/* Check if this handler is connected to a webview (not the window).
* WEBKIT_IS_WEB_VIEW is true for webview connections, false for the
* window connection. */
gboolean from_webview = WEBKIT_IS_WEB_VIEW(widget);
/* When the active tab is showing a sovereign:// internal page (e.g.
* the settings page's keyboard-shortcut capture), bypass shortcut
* interception on the webview connection only — let the event pass
* through to the web page's JS so it can capture arbitrary key combos.
* The window-level connection still processes shortcuts so Ctrl+T,
* Ctrl+N, etc. work on internal pages. */
if (from_webview) {
tab_info_t *active = tab_manager_get_active();
if (active && active->current_url[0] != '\0' &&
strncmp(active->current_url, "sovereign://", 12) == 0) {
return FALSE;
}
}
int action = shortcuts_lookup(event);
if (action < 0) return FALSE;
@@ -256,6 +516,10 @@ gboolean on_key_press(GtkWidget *widget, GdkEventKey *event,
tab_manager_new_tab(NULL);
return TRUE;
case SHORTCUT_NEW_WINDOW:
tab_manager_new_window_blank();
return TRUE;
case SHORTCUT_CLOSE_TAB:
tab_manager_close_active();
return TRUE;
@@ -323,6 +587,10 @@ gboolean on_key_press(GtkWidget *widget, GdkEventKey *event,
on_menu_settings(NULL, NULL);
return TRUE;
case SHORTCUT_OPEN_PROCESSES:
on_menu_processes(NULL, NULL);
return TRUE;
case SHORTCUT_NEW_IDENTITY:
app_menu_switch_identity_proxy(NULL, g_window);
return TRUE;
@@ -342,6 +610,22 @@ gboolean on_key_press(GtkWidget *widget, GdkEventKey *event,
tab_manager_toggle_inspector();
return TRUE;
case SHORTCUT_TOGGLE_SIDEBAR:
tab_manager_toggle_sidebar();
return TRUE;
case SHORTCUT_ZOOM_IN:
tab_manager_zoom_in();
return TRUE;
case SHORTCUT_ZOOM_OUT:
tab_manager_zoom_out();
return TRUE;
case SHORTCUT_ZOOM_RESET:
tab_manager_zoom_reset();
return TRUE;
default:
return FALSE;
}
@@ -359,14 +643,102 @@ static void agent_login_callback(void) {
g_print("[login] Agent login detected.\n");
}
/* ---- Window destroy ------------------------------------------------- */
/* ---- Window close / destroy ----------------------------------------- *
* The main window uses a delete-event handler to intercept the window
* manager's close request BEFORE the window is destroyed. This lets us
* close the main window's tabs cleanly (updating g_tab_count) and keep
* the app running if auxiliary windows still have tabs. The app only
* quits when the last window is closed (no tabs remain anywhere).
*
* delete-event: runs first. Closes all main-window tabs. If aux windows
* still have tabs, returns TRUE to suppress destroy and hides the
* main window. If no tabs remain, returns FALSE to let destroy
* proceed → on_window_destroy → gtk_main_quit().
* destroy: runs only when the app is truly shutting down. Performs
* session save, net_services_shutdown, agent_server_stop, etc.
*/
/* Guard flag: set while on_window_delete_event is closing the main
* window's tabs. Prevents re-entrancy when tab_manager_close_tab()
* calls gtk_window_close(g_window) after the last tab is closed, which
* would re-emit delete-event. */
static gboolean g_main_window_closing = FALSE;
static gboolean on_window_delete_event(GtkWidget *widget,
GdkEvent *event,
gpointer data) {
(void)event;
(void)data;
/* If we're already in the process of closing (re-entrant call from
* tab_manager_close_tab → gtk_window_close), let the destroy
* proceed. */
if (g_main_window_closing) {
return FALSE;
}
g_main_window_closing = TRUE;
/* Close all tabs that belong to the main window's notebook. We
* identify main-window tabs by checking gtk_notebook_page_num on
* the main notebook. tab_manager_close_tab handles removing from
* the notebook and the g_tabs array. Re-scan each iteration because
* closing shifts indices. */
for (;;) {
gboolean found = FALSE;
GtkWidget *main_nb = tab_manager_get_main_notebook();
if (main_nb == NULL) break;
/* Close from the highest index down so removal doesn't shift
* unprocessed indices. Find the highest-index tab in the main
* notebook and close it. */
for (int i = tab_manager_count() - 1; i >= 0; i--) {
tab_info_t *tab = tab_manager_get(i);
if (tab == NULL || tab->page == NULL) continue;
if (gtk_notebook_page_num(GTK_NOTEBOOK(main_nb),
tab->page) >= 0) {
tab_manager_close_tab(i);
found = TRUE;
break;
}
}
if (!found) break;
}
/* If auxiliary windows still have tabs, keep the app running.
* Suppress the default destroy by returning TRUE, and hide the
* main window. The aux windows continue independently. */
if (tab_manager_count() > 0) {
g_print("[windows] Main window closed but %d tab(s) remain in "
"other window(s) — keeping app alive.\n",
tab_manager_count());
gtk_widget_hide(widget);
g_main_window_closing = FALSE; /* allow re-opening later */
return TRUE; /* suppress destroy */
}
/* No tabs left anywhere — let the destroy proceed, which triggers
* on_window_destroy and quits the app. Keep the guard set so any
* re-entrant delete-event from the destroy path doesn't re-enter. */
return FALSE;
}
static void on_window_destroy(GtkWidget *widget, gpointer data) {
(void)widget;
(void)data;
/* Save the session before cleanup. */
session_save();
const browser_settings_t *s = settings_get();
if (s->restore_session) {
/* Save the session for next launch. */
session_save();
} else {
/* Privacy mode: clear session and history on shutdown. */
db_session_clear();
history_clear();
g_print("[shutdown] Cleared session and history (restore_session=off)\n");
}
/* Stop browser-managed Tor/FIPS before tearing down GTK/WebKit. */
net_services_shutdown();
/* Stop the agent server. */
agent_server_stop();
@@ -415,6 +787,10 @@ static int do_login(GtkWindow *parent) {
g_state.method = result.method;
strncpy(g_state.pubkey_hex, result.pubkey_hex, 64);
g_state.pubkey_hex[64] = '\0';
/* Carry the privkey (in-memory only) for HMAC d-tag derivation in the
* bookmarks module. Empty for readonly / nsigner / nip46 methods. */
strncpy(g_state.privkey_hex, result.identity.privkey_hex, 64);
g_state.privkey_hex[64] = '\0';
g_state.readonly = (result.method == KEY_STORE_METHOD_READONLY ||
result.method == KEY_STORE_METHOD_NONE);
g_logged_in = TRUE;
@@ -428,6 +804,124 @@ static int do_login(GtkWindow *parent) {
return 0;
}
/* ---- Per-user database switch --------------------------------------- *
* After login, switch from the global database to the per-user profile
* database. Creates the profile directory if needed, closes global.db,
* opens the per-user browser.db, and loads per-user settings.
*
* This function is called from:
* - app_set_signer() — when an agent logs in via MCP or CLI
* - app_menu_switch_identity_proxy() — runtime identity switch via menu
* - main() — after the GTK login dialog returns
*
* It is idempotent: if already on the correct per-user db, it's a no-op.
*/
/* Track the currently-open per-user db path so we can skip re-opening
* the same database (e.g. when app_set_signer() is called during the
* login dialog and then main() calls switch_to_user_db() again).
* The actual definition is near the top of this file (forward-declared
* before the menu proxies that reset it on logout). */
static int switch_to_user_db(const char *pubkey_hex) {
if (pubkey_hex == NULL || pubkey_hex[0] == '\0') {
g_printerr("[profile] No pubkey, staying on global.db\n");
return -1;
}
/* Create the profile directory. */
if (profile_ensure_dir(pubkey_hex) != 0) {
g_printerr("[profile] Failed to create profile dir for %s\n",
pubkey_hex);
return -1;
}
/* Open the per-user browser.db (closes global.db first). */
char db_path[512];
profile_get_db_path(pubkey_hex, db_path, sizeof(db_path));
if (db_path[0] == '\0') {
g_printerr("[profile] Failed to get db path for %s\n", pubkey_hex);
return -1;
}
/* Skip if already on this database (idempotent). */
if (g_current_profile_db[0] != '\0' &&
strcmp(g_current_profile_db, db_path) == 0) {
g_print("[profile] Already on per-user db: %s\n", db_path);
return 0;
}
if (db_init_with_path(db_path) != 0) {
g_printerr("[profile] Failed to open per-user db: %s\n", db_path);
return -1;
}
/* Record the current profile db path (for idempotency check). */
snprintf(g_current_profile_db, sizeof(g_current_profile_db),
"%s", db_path);
/* Load per-user settings from the per-user browser.db. This only
* reads per-user keys; global settings already in memory are
* preserved. */
settings_load_user();
/* Save the last-used pubkey to the global identity.json so the
* login dialog can default to this profile next time. */
profile_save_last_pubkey(pubkey_hex);
/* Save public identity info (method, pubkey) to the per-profile
* identity.json. Private keys are never stored. */
key_store_save_profile_identity(pubkey_hex, g_state.method);
g_print("[profile] Switched to per-user db: %s\n", db_path);
return 0;
}
/* ---- Per-user WebKit context build helper --------------------------- *
* Builds a fresh per-user WebKitWebContext (or ephemeral for no-login
* mode), registers the sovereign://, nostr://, tor:// URI schemes on it,
* and updates tab_manager so new tabs use the new context. Called from:
* - main() after login, before creating tabs
* - app_menu_switch_identity_proxy() after tearing down the old context
* - app_menu_logout_proxy() after re-login
*
* The caller must have torn down the previous context (via
* web_context_teardown()) and closed all tabs/sidebar webviews first.
* Returns the new context, or NULL on failure (the caller may fall back
* to a blank window or exit).
*/
static WebKitWebContext *build_context_for_current_user(void) {
WebKitWebContext *web_ctx;
if (g_state.pubkey_hex[0] != '\0') {
web_ctx = web_context_build_for_pubkey(g_state.pubkey_hex);
} else {
/* --no-login / read-only-without-pubkey: ephemeral, no on-disk
* persistence. See plans/webkit-data-isolation.md. */
web_ctx = web_context_build_ephemeral();
}
if (web_ctx == NULL) {
g_printerr("[main] Failed to build WebKit context\n");
return NULL;
}
/* Register the sovereign:// URI scheme for the window.nostr bridge.
* nostr_bridge_set_signer() was already called by the login path,
* so the bridge will use the current signer. */
nostr_bridge_register(web_ctx, g_state.signer, g_state.pubkey_hex,
g_state.readonly);
/* Register nostr:// and tor:// entity-page schemes on this context.
* These are per-context registrations, so they must be re-registered
* on every fresh context. */
nostr_scheme_register(web_ctx);
tor_scheme_register(web_ctx);
/* Point tab_manager at the new context so subsequent new tabs (and
* the sidebar webview) are created from it. */
tab_manager_set_context(web_ctx);
return web_ctx;
}
/* ---- Main ----------------------------------------------------------- */
int main(int argc, char **argv) {
@@ -454,20 +948,34 @@ int main(int argc, char **argv) {
gtk_init(&argc, &argv);
/* Defensively delete any legacy identity.json from a previous version
* that persisted private keys to disk. Keys are now in-memory only. */
key_store_delete_legacy_file();
/* NOTE: key_store_delete_legacy_file() was previously called here
* to delete identity.json from old versions that persisted private
* keys. We no longer call it because identity.json is now used
* legitimately to store the last-used pubkey_hex (no private keys).
* This is a new project with no legacy files to migrate. */
/* Initialize the SQLite database first — settings and history are
* stored there. */
db_init();
/* Initialize the global database first — global settings and
* shortcuts are stored there. The per-user browser.db is opened
* after login, once we know the user's pubkey. */
db_init_global();
/* Load settings from the database (key_value table). */
settings_load();
/* Set defaults, then load global settings from global.db. Per-user
* settings are loaded after login from the per-user browser.db. */
{
browser_settings_t *s = settings_get_mutable();
/* settings_load_global() does NOT reset defaults, so we need to
* set them first. We call settings_load() which sets defaults
* and then loads global settings from the currently-open db
* (global.db). The per-user keys won't be found in global.db,
* so their defaults are kept. */
(void)s;
settings_load();
}
/* Load keyboard shortcut bindings from the database. Must come after
* settings_load() because shortcuts_lookup() checks the master
* ctrl_tab_switch toggle. */
/* Load keyboard shortcut bindings from the database (global.db).
* Must come after settings_load() because shortcuts_lookup() checks
* the master ctrl_tab_switch toggle. Shortcuts are global — they
* are the same across all profiles. */
shortcuts_load();
/* History is queried from the SQLite database on demand — no
@@ -517,6 +1025,8 @@ int main(int argc, char **argv) {
GtkWidget *window = gtk_window_new(GTK_WINDOW_TOPLEVEL);
gtk_window_set_title(GTK_WINDOW(window), "sovereign browser " SB_VERSION);
gtk_window_set_default_size(GTK_WINDOW(window), 1024, 768);
g_signal_connect(window, "delete-event",
G_CALLBACK(on_window_delete_event), NULL);
g_signal_connect(window, "destroy", G_CALLBACK(on_window_destroy), NULL);
g_signal_connect(window, "key-press-event", G_CALLBACK(on_key_press), NULL);
g_window = GTK_WINDOW(window);
@@ -590,10 +1100,26 @@ int main(int argc, char **argv) {
}
}
/* ── Switch to per-user profile database ────────────────────── *
* Now that we know the user's pubkey, close global.db and open the
* per-user browser.db at ~/.sovereign_browser/profiles/<pubkey>/.
* This must happen before relay_fetch, session_restore, bookmarks,
* etc. — all of which read/write the per-user database.
*
* If there's no pubkey (--no-login mode), we stay on global.db.
* Browsing still works; history/session just go to global.db in
* that case (acceptable for the no-identity mode). */
if (g_state.pubkey_hex[0] != '\0') {
if (switch_to_user_db(g_state.pubkey_hex) != 0) {
g_printerr("[profile] Failed to switch to per-user db — "
"continuing with global.db\n");
}
}
/* If the user logged in with a Nostr identity (not --no-login), start
* a background thread to fetch their kind 0/3/10002 events from the
* bootstrap relays. The results are cached in the SQLite database.
* The thread frees the pubkey copy when done. */
* bootstrap relays. The results are cached in the per-user SQLite
* database. The thread frees the pubkey copy when done. */
if (g_state.pubkey_hex[0] != '\0') {
char *pubkey_copy = g_strdup(g_state.pubkey_hex);
g_thread_new("relay-fetch", relay_fetch_thread, pubkey_copy);
@@ -612,46 +1138,52 @@ int main(int argc, char **argv) {
settings_sync_init(g_state.signer,
g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
/* Initialize the conversation persistence module (kind 30078
* conversations). In no-login/read-only mode, the signer is NULL
* so conversations are not encrypted/synced. */
agent_conversations_init(g_state.signer,
g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
/* Initialize the skills module (kind 31123 public skill events).
* In no-login/read-only mode, the signer is NULL so skills cannot
* be published/deleted (but can still be fetched and selected). */
agent_skills_init(g_state.signer,
g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
/* Set the user's avatar on the tab bar from their kind 0 profile.
* If the profile hasn't been fetched yet (relay fetch is still
* running), this shows the default icon. The avatar will be updated
* when the relay fetch completes and the kind 0 event is stored. */
tab_manager_set_avatar(g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
/* Use the default WebKitWebContext — it comes with proper networking
* (cookies, cache, soup session) that a freshly created context lacks.
* All tabs will create webviews from this shared context. */
WebKitWebContext *web_ctx = webkit_web_context_get_default();
/* ── Per-user WebKit context (Phase A of webkit-data-isolation.md) ── *
* Build a fresh WebKitWebContext with a per-user
* WebKitWebsiteDataManager rooted at
* ~/.sovereign_browser/profiles/<pubkey>/webkit/ (or an ephemeral
* data manager for --no-login mode). This gives true per-identity
* isolation of cookies, cache, localStorage, IndexedDB, service
* workers, and favicons. The helper also registers the sovereign://,
* nostr://, tor:// URI schemes on the new context and points
* tab_manager at it. */
WebKitWebContext *web_ctx = build_context_for_current_user();
if (web_ctx == NULL) {
g_printerr("[main] Cannot continue without a WebKit context.\n");
agent_server_stop();
nostr_cleanup();
cli_args_free(&cli);
return EXIT_FAILURE;
}
/* ── Security strip: disable web security restrictions ─────── */
/* Fetch the security manager for the new context — used below to
* wire the sovereign://security page to the first tab's settings. */
WebKitSecurityManager *sec_mgr =
webkit_web_context_get_security_manager(web_ctx);
/* Register sovereign:// as secure only. Do NOT register it as "local"
* (WebKit blocks fetch from https to local origins) and do NOT register
* it as "cors_enabled" (that makes WebKit enforce CORS response headers,
* which we can't set with the basic finish API). Without these, WebKit
* treats sovereign:// as a simple secure scheme with no CORS checks. */
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "sovereign");
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "file");
webkit_security_manager_register_uri_scheme_as_local(sec_mgr, "file");
/* Accept any TLS certificate (FIPS uses Noise IK, not TLS CAs). */
WebKitWebsiteDataManager *data_mgr =
webkit_web_context_get_website_data_manager(web_ctx);
webkit_website_data_manager_set_tls_errors_policy(
data_mgr, WEBKIT_TLS_ERRORS_POLICY_IGNORE);
/* Enable the favicon database so WebKitGTK automatically fetches and
* caches favicons for visited pages. Without this, the
* "notify::favicon" signal never fires and webkit_web_view_get_favicon()
* always returns NULL. Passing NULL for the directory uses WebKit's
* default cache location. */
webkit_web_context_set_favicon_database_directory(web_ctx, NULL);
g_print("[main] Favicon database enabled\n");
/* Register the sovereign:// URI scheme for the window.nostr bridge. */
nostr_bridge_register(web_ctx, g_state.signer, g_state.pubkey_hex,
g_state.readonly);
/* Initialize browser-managed/attached network services after the
* per-user context has been built. net_services_refresh_proxy()
* now uses web_context_get() so proxy settings apply to the
* per-user context. */
net_services_init();
/* Vertical box: the tab manager's notebook fills the window. */
GtkWidget *vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
@@ -660,6 +1192,11 @@ int main(int argc, char **argv) {
/* Initialize the tab manager. */
tab_manager_init(GTK_CONTAINER(vbox), web_ctx, g_window);
/* Mark startup complete. From now on, app_set_signer() (called by
* agent login / switch_identity) will rebuild the per-user web
* context if it was torn down, since main() won't build it again. */
g_post_startup = TRUE;
/* Decide whether to restore the previous session or open CLI URLs.
*
* Precedence:
@@ -700,6 +1237,10 @@ int main(int argc, char **argv) {
cli_args_free(&cli);
gtk_widget_show_all(window);
/* Re-hide the sidebar container that show_all revealed. The sidebar
* is per-window (packed in the window-level GtkPaned) and should
* only appear when the user toggles it. */
tab_manager_hide_sidebar_after_show_all();
gtk_main();
return EXIT_SUCCESS;

543
src/net_services.c Normal file
View File

@@ -0,0 +1,543 @@
/*
* net_services.c — unified Tor/FIPS discovery, ownership and supervision
*/
#define _POSIX_C_SOURCE 200809L
#include "net_services.h"
#include "tor_control.h"
#include "fips_control.h"
#include "settings.h"
#include "web_context.h"
#include <webkit2/webkit2.h>
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <unistd.h>
static net_service_t g_services[NET_SERVICE_COUNT];
static gboolean g_initialized = FALSE;
static gboolean g_shutting_down = FALSE;
static const char *service_name(net_service_type_t type) {
return type == NET_SERVICE_TOR ? "tor" : "fips";
}
static gboolean valid_type(net_service_type_t type) {
return type >= NET_SERVICE_TOR && type < NET_SERVICE_COUNT;
}
static void clear_error(net_service_t *service) {
g_clear_pointer(&service->error_msg, g_free);
}
static void fail_service(net_service_t *service, const char *message) {
clear_error(service);
service->error_msg = g_strdup(message ? message : "unknown error");
service->state = SERVICE_FAILED;
g_printerr("[net.service.%s] FAILED: %s\n", service_name(service->type),
service->error_msg);
if (service->type == NET_SERVICE_TOR) net_services_refresh_proxy();
}
static char *expand_path(const char *path) {
if (!path) return g_strdup("");
if (path[0] == '~' && path[1] == '/')
return g_build_filename(g_get_home_dir(), path + 2, NULL);
return g_strdup(path);
}
static int ensure_private_dir(const char *path, char *error, size_t error_size) {
if (g_mkdir_with_parents(path, 0700) != 0) {
snprintf(error, error_size, "cannot create %s: %s", path, strerror(errno));
return -1;
}
if (chmod(path, 0700) != 0) {
snprintf(error, error_size, "cannot secure %s: %s", path, strerror(errno));
return -1;
}
return 0;
}
void net_services_refresh_proxy(void) {
if (!g_initialized) return;
/* Use the per-user context (web_context_get) instead of the default
* context, so proxy settings apply to the active user's context.
* Falls back to the default context if no per-user context has been
* built yet (e.g. during early startup). See plans/webkit-data-isolation.md. */
WebKitWebContext *ctx = web_context_get();
if (ctx == NULL) ctx = webkit_web_context_get_default();
if (ctx == NULL) return;
WebKitWebsiteDataManager *dm = webkit_web_context_get_website_data_manager(ctx);
if (dm == NULL) return;
webkit_website_data_manager_set_network_proxy_settings(
dm, WEBKIT_NETWORK_PROXY_MODE_NO_PROXY, NULL);
}
static gboolean pipe_log_cb(GIOChannel *channel, GIOCondition condition, gpointer data) {
net_service_t *service = data;
if (condition & (G_IO_IN | G_IO_PRI)) {
gchar *line = NULL;
gsize length = 0;
GError *error = NULL;
GIOStatus status = g_io_channel_read_line(channel, &line, &length, NULL, &error);
if (status == G_IO_STATUS_NORMAL && line) {
g_strchomp(line);
if (line[0]) g_print("[net.service.%s] %s\n", service_name(service->type), line);
}
g_free(line);
g_clear_error(&error);
}
return (condition & (G_IO_HUP | G_IO_ERR | G_IO_NVAL)) == 0;
}
static void close_channel(GIOChannel **channel, guint *watch) {
if (*watch) { g_source_remove(*watch); *watch = 0; }
if (*channel) {
g_io_channel_shutdown(*channel, TRUE, NULL);
g_io_channel_unref(*channel);
*channel = NULL;
}
}
static void close_control(net_service_t *service) {
if (service->type == NET_SERVICE_TOR) tor_control_close(&service->control_fd);
else fips_control_close(&service->control_fd);
}
static gboolean kill_timeout_cb(gpointer data) {
net_service_t *service = data;
service->stop_timer = 0;
if (service->pid > 0) {
g_printerr("[net.service.%s] SIGTERM timeout; sending SIGKILL\n",
service_name(service->type));
kill(service->pid, SIGKILL);
}
return G_SOURCE_REMOVE;
}
static void child_exit_cb(GPid pid, gint status, gpointer data) {
net_service_t *service = data;
gboolean expected = service->stop_requested || g_shutting_down;
if (service->stop_timer) { g_source_remove(service->stop_timer); service->stop_timer = 0; }
if (service->poll_timer) { g_source_remove(service->poll_timer); service->poll_timer = 0; }
close_control(service);
close_channel(&service->stdout_channel, &service->stdout_watch);
close_channel(&service->stderr_channel, &service->stderr_watch);
service->child_watch = 0;
service->pid = 0;
g_spawn_close_pid(pid);
if (expected) {
service->state = SERVICE_EXITED;
g_print("[net.service.%s] managed process exited\n", service_name(service->type));
} else {
char message[160];
if (WIFEXITED(status))
snprintf(message, sizeof(message), "process exited with status %d", WEXITSTATUS(status));
else if (WIFSIGNALED(status))
snprintf(message, sizeof(message), "process killed by signal %d", WTERMSIG(status));
else snprintf(message, sizeof(message), "process exited unexpectedly");
fail_service(service, message);
}
if (service->type == NET_SERVICE_FIPS) net_services_refresh_proxy();
}
static int spawn_managed(net_service_t *service, gchar **argv, char *error, size_t error_size) {
if (service->pid > 0 || service->child_watch != 0) {
snprintf(error, error_size, "%s is already running", service_name(service->type));
return -1;
}
GPid pid = 0;
gint stdout_fd = -1, stderr_fd = -1;
GError *gerror = NULL;
service->state = SERVICE_STARTING;
if (!g_spawn_async_with_pipes(NULL, argv, NULL,
G_SPAWN_DO_NOT_REAP_CHILD | G_SPAWN_SEARCH_PATH,
NULL, NULL, &pid, NULL, &stdout_fd, &stderr_fd, &gerror)) {
snprintf(error, error_size, "cannot start %s: %s", service_name(service->type),
gerror ? gerror->message : "unknown error");
g_clear_error(&gerror);
return -1;
}
service->pid = pid;
service->ownership = OWNERSHIP_MANAGED;
service->stop_requested = FALSE;
service->stdout_channel = g_io_channel_unix_new(stdout_fd);
service->stderr_channel = g_io_channel_unix_new(stderr_fd);
g_io_channel_set_close_on_unref(service->stdout_channel, TRUE);
g_io_channel_set_close_on_unref(service->stderr_channel, TRUE);
service->stdout_watch = g_io_add_watch(service->stdout_channel,
G_IO_IN | G_IO_HUP | G_IO_ERR | G_IO_NVAL, pipe_log_cb, service);
service->stderr_watch = g_io_add_watch(service->stderr_channel,
G_IO_IN | G_IO_HUP | G_IO_ERR | G_IO_NVAL, pipe_log_cb, service);
service->child_watch = g_child_watch_add(pid, child_exit_cb, service);
service->state = SERVICE_BOOTSTRAPPING;
g_print("[net.service.%s] spawned managed process pid=%d\n",
service_name(service->type), (int)pid);
return 0;
}
static gboolean tor_poll_cb(gpointer data) {
net_service_t *service = data;
char error[256] = "";
if (service->control_fd < 0) {
service->control_fd = tor_control_connect(service->status.tor.control_endpoint,
500, error, sizeof(error));
if (service->control_fd < 0) return G_SOURCE_CONTINUE;
if (tor_control_authenticate(service->control_fd,
service->status.tor.cookie_path,
error, sizeof(error)) != 0) {
close_control(service);
return G_SOURCE_CONTINUE;
}
}
int progress = 0;
char summary[256] = "";
if (tor_control_get_bootstrap(service->control_fd, &progress, summary,
sizeof(summary), error, sizeof(error)) != 0) {
close_control(service);
return G_SOURCE_CONTINUE;
}
service->status.tor.bootstrap_progress = progress;
snprintf(service->status.tor.circuit_info,
sizeof(service->status.tor.circuit_info), "%s", summary);
if (progress >= 100) {
service->state = SERVICE_READY;
service->poll_timer = 0;
g_print("[net.service.tor] bootstrap complete\n");
net_services_refresh_proxy();
return G_SOURCE_REMOVE;
}
g_print("[net.service.tor] bootstrap %d%%\n", progress);
return G_SOURCE_CONTINUE;
}
static gboolean fips_poll_cb(gpointer data) {
net_service_t *service = data;
char error[256] = "";
if (service->control_fd < 0) {
service->control_fd = fips_control_connect(service->status.fips.control_socket,
500, error, sizeof(error));
if (service->control_fd < 0) return G_SOURCE_CONTINUE;
}
fips_status_t status;
if (fips_control_show_status(service->control_fd, &status, error, sizeof(error)) != 0) {
close_control(service);
return G_SOURCE_CONTINUE;
}
service->status.fips.peer_count = status.peer_count;
snprintf(service->status.fips.node_npub, sizeof(service->status.fips.node_npub), "%s", status.npub);
snprintf(service->status.fips.tree_state, sizeof(service->status.fips.tree_state), "%s", status.tree_state);
snprintf(service->status.fips.tun_name, sizeof(service->status.fips.tun_name), "%s", status.tun_name);
snprintf(service->status.fips.daemon_state, sizeof(service->status.fips.daemon_state), "%s", status.state);
service->status.fips.tun_active = status.tun_active;
if (service->state != SERVICE_READY) {
service->state = SERVICE_READY;
g_print("[net.service.fips] ready: node=%s peers=%d tun=%s\n",
status.npub, status.peer_count, status.tun_name);
net_services_refresh_proxy();
}
return G_SOURCE_CONTINUE;
}
static int attach_tor(net_service_t *service, const char *socks, const char *control) {
service->state = SERVICE_ATTACHING;
service->ownership = OWNERSHIP_ATTACHED;
snprintf(service->status.tor.socks_endpoint, sizeof(service->status.tor.socks_endpoint), "%s", socks);
if (control) snprintf(service->status.tor.control_endpoint,
sizeof(service->status.tor.control_endpoint), "%s", control);
service->state = SERVICE_READY;
service->status.tor.bootstrap_progress = 100;
g_print("[net.service.tor] attached to SOCKS endpoint %s\n", socks);
net_services_refresh_proxy();
return 0;
}
static int start_managed_tor(net_service_t *service) {
const browser_settings_t *settings = settings_get();
char error[512] = "";
char *root = expand_path(settings->tor_data_dir);
char *data = g_build_filename(root, "data", NULL);
char *torrc = g_build_filename(root, "torrc", NULL);
char *socks = g_build_filename(root, "socks.sock", NULL);
char *control = g_build_filename(root, "control.sock", NULL);
char *cookie = g_build_filename(data, "control_auth_cookie", NULL);
char *log = g_build_filename(root, "tor.log", NULL);
if (ensure_private_dir(root, error, sizeof(error)) != 0 ||
ensure_private_dir(data, error, sizeof(error)) != 0) {
fail_service(service, error);
goto failed;
}
unlink(socks);
unlink(control);
gchar *config = g_strdup_printf(
"DataDirectory %s\nSocksPort unix:%s\nControlPort unix:%s\n"
"CookieAuthentication 1\nCookieAuthFileGroupReadable 0\n"
"Log notice file %s\nAvoidDiskWrites 1\n", data, socks, control, log);
GError *gerror = NULL;
if (!g_file_set_contents(torrc, config, -1, &gerror)) {
snprintf(error, sizeof(error), "cannot write torrc: %s",
gerror ? gerror->message : "unknown error");
g_clear_error(&gerror);
g_free(config);
fail_service(service, error);
goto failed;
}
g_free(config);
chmod(torrc, 0600);
g_free(service->binary_path); service->binary_path = g_strdup(settings->tor_binary_path);
g_free(service->data_dir); service->data_dir = g_strdup(root);
snprintf(service->status.tor.socks_endpoint, sizeof(service->status.tor.socks_endpoint),
"socks5://unix:%s", socks);
snprintf(service->status.tor.control_endpoint, sizeof(service->status.tor.control_endpoint),
"unix:%s", control);
snprintf(service->status.tor.cookie_path, sizeof(service->status.tor.cookie_path), "%s", cookie);
gchar *argv[] = { (gchar *)settings->tor_binary_path, "-f", torrc, NULL };
if (spawn_managed(service, argv, error, sizeof(error)) != 0) {
fail_service(service, error);
goto failed;
}
service->poll_timer = g_timeout_add_seconds(2, tor_poll_cb, service);
g_free(root); g_free(data); g_free(torrc); g_free(socks); g_free(control); g_free(cookie); g_free(log);
return 0;
failed:
g_free(root); g_free(data); g_free(torrc); g_free(socks); g_free(control); g_free(cookie); g_free(log);
return -1;
}
static gboolean binary_has_net_admin(const char *binary) {
if (geteuid() == 0) return TRUE;
gchar *resolved = g_find_program_in_path(binary);
if (!resolved) return FALSE;
gchar *stdout_text = NULL;
gint status = 0;
gchar *argv[] = { "getcap", resolved, NULL };
gboolean spawned = g_spawn_sync(NULL, argv, NULL, G_SPAWN_SEARCH_PATH,
NULL, NULL, &stdout_text, NULL, &status, NULL);
gboolean capable = spawned && WIFEXITED(status) && WEXITSTATUS(status) == 0 &&
stdout_text && strstr(stdout_text, "cap_net_admin") != NULL;
g_free(stdout_text);
g_free(resolved);
return capable;
}
static int attach_fips(net_service_t *service, const char *socket_path) {
char error[256] = "";
service->state = SERVICE_ATTACHING;
service->control_fd = fips_control_connect(socket_path, 700, error, sizeof(error));
if (service->control_fd < 0) return -1;
service->ownership = OWNERSHIP_ATTACHED;
snprintf(service->status.fips.control_socket, sizeof(service->status.fips.control_socket), "%s", socket_path);
if (!fips_poll_cb(service)) return -1;
service->poll_timer = g_timeout_add_seconds(5, fips_poll_cb, service);
g_print("[net.service.fips] attached to %s\n", socket_path);
return 0;
}
static int start_managed_fips(net_service_t *service) {
const browser_settings_t *settings = settings_get();
char error[512] = "";
if (!binary_has_net_admin(settings->fips_binary_path)) {
fail_service(service, "FIPS binary not found or lacks CAP_NET_ADMIN");
return -1;
}
char *root = expand_path(settings->fips_config_dir);
char *config_path = g_build_filename(root, "fips.yaml", NULL);
char *socket_path = g_build_filename(root, "control.sock", NULL);
if (ensure_private_dir(root, error, sizeof(error)) != 0) {
fail_service(service, error); goto failed;
}
unlink(socket_path);
gchar *config = g_strdup_printf(
"node:\n identity:\n persistent: true\n control:\n enabled: true\n"
" socket_path: \"%s\"\ntun:\n device: fips0\ndns:\n enabled: true\n"
" bind_addr: \"::1\"\n port: 5354\ntransports:\n"
" - type: udp\n bind_addr: \"0.0.0.0:4242\"\n"
" - type: tcp\n listen: \"0.0.0.0:4243\"\npeers: []\n", socket_path);
GError *gerror = NULL;
if (!g_file_set_contents(config_path, config, -1, &gerror)) {
snprintf(error, sizeof(error), "cannot write fips.yaml: %s",
gerror ? gerror->message : "unknown error");
g_clear_error(&gerror); g_free(config); fail_service(service, error); goto failed;
}
g_free(config); chmod(config_path, 0600);
g_free(service->binary_path); service->binary_path = g_strdup(settings->fips_binary_path);
g_free(service->data_dir); service->data_dir = g_strdup(root);
snprintf(service->status.fips.control_socket, sizeof(service->status.fips.control_socket), "%s", socket_path);
gchar *argv[] = { (gchar *)settings->fips_binary_path, "--config", config_path, NULL };
if (spawn_managed(service, argv, error, sizeof(error)) != 0) {
fail_service(service, error); goto failed;
}
service->poll_timer = g_timeout_add_seconds(2, fips_poll_cb, service);
g_free(root); g_free(config_path); g_free(socket_path);
return 0;
failed:
g_free(root); g_free(config_path); g_free(socket_path);
return -1;
}
int net_service_enable(net_service_type_t type) {
if (!g_initialized || !valid_type(type)) return -1;
net_service_t *service = &g_services[type];
if (service->state == SERVICE_DISCOVERING || service->state == SERVICE_ATTACHING ||
service->state == SERVICE_STARTING || service->state == SERVICE_BOOTSTRAPPING ||
service->state == SERVICE_READY || service->state == SERVICE_STOPPING) return 0;
clear_error(service);
service->state = SERVICE_DISCOVERING;
service->ownership = OWNERSHIP_NONE;
const browser_settings_t *settings = settings_get();
if (type == NET_SERVICE_TOR) {
net_services_refresh_proxy();
gboolean allow_attach = strcmp(settings->tor_mode, "manage") != 0;
gboolean allow_manage = strcmp(settings->tor_mode, "attach") != 0;
if (allow_attach && settings->tor_attach_socks[0] &&
tor_socks_endpoint_available(settings->tor_attach_socks, 500))
return attach_tor(service, settings->tor_attach_socks,
settings->tor_attach_control[0] ? settings->tor_attach_control : NULL);
if (allow_attach && tor_control_endpoint_available("unix:/run/tor/control", 300) &&
tor_socks_endpoint_available("127.0.0.1:9050", 300))
return attach_tor(service, "socks5://127.0.0.1:9050", "unix:/run/tor/control");
if (allow_attach && tor_control_endpoint_available("127.0.0.1:9051", 300) &&
tor_socks_endpoint_available("127.0.0.1:9050", 300))
return attach_tor(service, "socks5://127.0.0.1:9050", "127.0.0.1:9051");
if (allow_attach && tor_socks_endpoint_available("127.0.0.1:9050", 300))
return attach_tor(service, "socks5://127.0.0.1:9050", NULL);
if (allow_manage) return start_managed_tor(service);
fail_service(service, "no attachable Tor instance found");
return -1;
}
gboolean allow_attach = strcmp(settings->fips_mode, "manage") != 0;
gboolean allow_manage = strcmp(settings->fips_mode, "attach") != 0;
if (allow_attach) {
if (settings->fips_control_socket[0] && attach_fips(service, settings->fips_control_socket) == 0) return 0;
const char *xdg = g_getenv("XDG_RUNTIME_DIR");
char *xdg_path = xdg && xdg[0] ? g_build_filename(xdg, "fips", "control.sock", NULL) : NULL;
const char *paths[] = { "/run/fips/control.sock", xdg_path, "/tmp/fips-control.sock", NULL };
for (int i = 0; paths[i]; i++) {
if (paths[i] && fips_control_socket_available(paths[i], 300) && attach_fips(service, paths[i]) == 0) {
g_free(xdg_path); return 0;
}
}
g_free(xdg_path);
}
if (allow_manage) return start_managed_fips(service);
fail_service(service, "no attachable FIPS instance found");
return -1;
}
int net_service_disable(net_service_type_t type) {
if (!g_initialized || !valid_type(type)) return -1;
net_service_t *service = &g_services[type];
if (service->state == SERVICE_DISABLED || service->state == SERVICE_EXITED) {
service->state = SERVICE_DISABLED;
if (type == NET_SERVICE_TOR) net_services_refresh_proxy();
return 0;
}
if (service->poll_timer) { g_source_remove(service->poll_timer); service->poll_timer = 0; }
if (service->ownership == OWNERSHIP_ATTACHED) {
close_control(service);
service->ownership = OWNERSHIP_NONE;
service->state = SERVICE_DISABLED;
g_print("[net.service.%s] detached (system service left running)\n", service_name(type));
} else if (service->ownership == OWNERSHIP_MANAGED && service->pid > 0) {
service->state = SERVICE_STOPPING;
service->stop_requested = TRUE;
if (type == NET_SERVICE_TOR && service->control_fd >= 0)
tor_control_signal_term(service->control_fd, NULL, 0);
else kill(service->pid, SIGTERM);
if (!service->stop_timer)
service->stop_timer = g_timeout_add_seconds(5, kill_timeout_cb, service);
} else {
close_control(service);
service->ownership = OWNERSHIP_NONE;
service->state = SERVICE_DISABLED;
}
if (type == NET_SERVICE_TOR) net_services_refresh_proxy();
else net_services_refresh_proxy();
return 0;
}
int net_service_restart(net_service_type_t type) {
if (!g_initialized || !valid_type(type)) return -1;
net_service_t *service = &g_services[type];
if (service->ownership == OWNERSHIP_ATTACHED) return 0;
if (service->pid > 0) {
net_service_disable(type);
return -1; /* Explicit enable after asynchronous exit prevents duplicates. */
}
service->state = SERVICE_EXITED;
return net_service_enable(type);
}
const net_service_t *net_service_get_status(net_service_type_t type) {
return valid_type(type) ? &g_services[type] : NULL;
}
gboolean net_service_is_enabled(net_service_type_t type) {
if (!valid_type(type)) return FALSE;
const browser_settings_t *settings = settings_get();
return type == NET_SERVICE_TOR ? settings->tor_enabled : settings->fips_enabled;
}
gboolean net_service_is_ready(net_service_type_t type) {
return valid_type(type) && g_services[type].state == SERVICE_READY;
}
void net_services_init(void) {
if (g_initialized) return;
memset(g_services, 0, sizeof(g_services));
for (int i = 0; i < NET_SERVICE_COUNT; i++) {
g_services[i].type = (net_service_type_t)i;
g_services[i].state = SERVICE_DISABLED;
g_services[i].ownership = OWNERSHIP_NONE;
g_services[i].control_fd = -1;
}
g_initialized = TRUE;
g_shutting_down = FALSE;
g_print("[net.service] initialized (Tor=%s, FIPS=%s)\n",
settings_get()->tor_enabled ? "enabled" : "disabled",
settings_get()->fips_enabled ? "enabled" : "disabled");
if (settings_get()->tor_enabled) {
net_services_refresh_proxy();
net_service_enable(NET_SERVICE_TOR);
}
if (settings_get()->fips_enabled) net_service_enable(NET_SERVICE_FIPS);
}
void net_services_shutdown(void) {
if (!g_initialized) return;
g_shutting_down = TRUE;
g_print("[net.service] shutting down\n");
for (int i = 0; i < NET_SERVICE_COUNT; i++) net_service_disable((net_service_type_t)i);
gint64 deadline = g_get_monotonic_time() + 5 * G_TIME_SPAN_SECOND;
gboolean children;
do {
children = FALSE;
for (int i = 0; i < NET_SERVICE_COUNT; i++)
if (g_services[i].pid > 0) children = TRUE;
if (children) {
while (g_main_context_pending(NULL)) g_main_context_iteration(NULL, FALSE);
g_usleep(10000);
}
} while (children && g_get_monotonic_time() < deadline);
for (int i = 0; i < NET_SERVICE_COUNT; i++) {
net_service_t *service = &g_services[i];
if (service->pid > 0) kill(service->pid, SIGKILL);
if (service->stop_timer) { g_source_remove(service->stop_timer); service->stop_timer = 0; }
if (service->poll_timer) { g_source_remove(service->poll_timer); service->poll_timer = 0; }
close_control(service);
g_clear_pointer(&service->binary_path, g_free);
g_clear_pointer(&service->data_dir, g_free);
clear_error(service);
}
g_initialized = FALSE;
}

96
src/net_services.h Normal file
View File

@@ -0,0 +1,96 @@
/*
* net_services.h — Tor and FIPS network-service lifecycle management
*/
#ifndef NET_SERVICES_H
#define NET_SERVICES_H
#include <glib.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef enum {
NET_SERVICE_TOR,
NET_SERVICE_FIPS,
NET_SERVICE_COUNT
} net_service_type_t;
typedef enum {
SERVICE_DISABLED,
SERVICE_DISCOVERING,
SERVICE_ATTACHING,
SERVICE_STARTING,
SERVICE_BOOTSTRAPPING,
SERVICE_READY,
SERVICE_STOPPING,
SERVICE_EXITED,
SERVICE_FAILED,
} service_state_t;
typedef enum {
OWNERSHIP_NONE,
OWNERSHIP_ATTACHED,
OWNERSHIP_MANAGED,
} service_ownership_t;
typedef struct {
net_service_type_t type;
service_state_t state;
service_ownership_t ownership;
GPid pid;
char *binary_path;
char *data_dir;
int control_fd;
char *error_msg;
guint child_watch;
guint poll_timer;
guint stop_timer;
guint stdout_watch;
guint stderr_watch;
GIOChannel *stdout_channel;
GIOChannel *stderr_channel;
gboolean stop_requested;
union {
struct {
int bootstrap_progress;
char circuit_info[256];
char socks_endpoint[512];
char control_endpoint[512];
char cookie_path[512];
} tor;
struct {
int peer_count;
char node_npub[80];
char tree_state[32];
gboolean tun_active;
char tun_name[16];
char daemon_state[32];
char control_socket[512];
} fips;
} status;
} net_service_t;
/* Initialize after the shared WebKit context and login are available. */
void net_services_init(void);
int net_service_enable(net_service_type_t type);
int net_service_disable(net_service_type_t type);
int net_service_restart(net_service_type_t type);
const net_service_t *net_service_get_status(net_service_type_t type);
gboolean net_service_is_enabled(net_service_type_t type);
gboolean net_service_is_ready(net_service_type_t type);
/* Rebuild Tor's proxy ignore-host list after FIPS readiness changes. */
void net_services_refresh_proxy(void);
/* Stop owned children and detach from system services. */
void net_services_shutdown(void);
#ifdef __cplusplus
}
#endif
#endif /* NET_SERVICES_H */

File diff suppressed because it is too large Load Diff

View File

@@ -128,6 +128,39 @@ static const char *NOSTR_SHIM_JS =
" }\n"
"})();\n";
/* Wheel-scroll workaround for WebKitGTK.
*
* On some pages (e.g. jumble.social) WebKitGTK dispatches JS wheel events
* but does not perform the native scroll action — so the scrollbar works
* (native GDK scroll) but the mouse wheel doesn't scroll the page. This
* script installs a non-passive wheel listener on window that, when the
* page's main scroll container is the document (the common case), calls
* preventDefault() and manually scrolls by the wheel delta. It only
* intervenes when the target is the document/body/html or an element with
* no own scroll (overflow: visible), so nested scroll containers (which
* WebKitGTK handles correctly) are not broken. */
static const char *WHEEL_SCROLL_JS =
"(function(){\n"
" 'use strict';\n"
" function isMainScrollTarget(el){\n"
" if(!el) return false;\n"
" if(el===document||el===document.documentElement||el===document.body) return true;\n"
" var cs=getComputedStyle(el);\n"
" return (cs.overflowY==='visible'||cs.overflowY==='') && el.scrollHeight<=el.clientHeight;\n"
" }\n"
" window.addEventListener('wheel',function(e){\n"
" if(e.defaultPrevented) return;\n"
" if(!isMainScrollTarget(e.target)) return;\n"
" var html=document.documentElement;\n"
" if(html.scrollHeight<=html.clientHeight) return;\n"
" e.preventDefault();\n"
" var dy=e.deltaY;\n"
" if(e.deltaMode===1) dy*=40;\n"
" else if(e.deltaMode===2) dy*=html.clientHeight;\n"
" window.scrollBy(0,dy);\n"
" },{capture:false,passive:false});\n"
"})();\n";
void nostr_inject_setup(WebKitWebView *webview) {
WebKitUserContentManager *manager =
webkit_web_view_get_user_content_manager(webview);
@@ -148,5 +181,14 @@ void nostr_inject_setup(WebKitWebView *webview) {
/* The manager takes ownership of the script. */
webkit_user_script_unref(script);
g_print("[inject] window.nostr shim installed\n");
/* Wheel-scroll workaround — see WHEEL_SCROLL_JS comment above. */
WebKitUserScript *wheel = webkit_user_script_new(
WHEEL_SCROLL_JS,
WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES,
WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
NULL,
NULL
);
webkit_user_content_manager_add_script(manager, wheel);
webkit_user_script_unref(wheel);
}

412
src/nostr_scheme.c Normal file
View File

@@ -0,0 +1,412 @@
/*
* nostr_scheme.c — asynchronous relay-backed WebKitGTK handler for nostr://
*/
#include "nostr_scheme.h"
#include "db.h"
#include "nostr_url.h"
#include "relay_fetch.h"
#include "settings.h"
#include "nostr_core/nostr_core.h"
#include "../nostr_core_lib/cjson/cJSON.h"
#include <curl/curl.h>
#include <stdlib.h>
#include <string.h>
#define MAX_FETCH_RELAYS 8
#define FETCH_TIMEOUT_SECONDS 8
#define FETCH_EVENT_LIMIT 20
#define NIP11_MAX_BYTES (1024 * 1024)
extern const char *app_get_pubkey_hex(void);
typedef struct {
WebKitURISchemeRequest *request;
char *entity;
char *bootstrap_relays;
char *user_pubkey;
nostr_decoded_entity_t decoded;
GPtrArray *entity_relays;
GPtrArray *user_relays;
GPtrArray *default_relays;
GPtrArray *attempted_relays;
cJSON *events;
cJSON *errors;
cJSON *nip11;
gboolean timed_out;
gint64 started_us;
char *response_json;
} scheme_job_t;
typedef struct {
GString *body;
gboolean overflow;
} curl_body_t;
static gboolean valid_relay_url(const char *url) {
return url && (g_str_has_prefix(url, "wss://") ||
g_str_has_prefix(url, "ws://"));
}
static gboolean array_contains(GPtrArray *array, const char *url) {
for (guint i = 0; array && i < array->len; i++)
if (!g_ascii_strcasecmp(g_ptr_array_index(array, i), url)) return TRUE;
return FALSE;
}
static void add_unique_relay(GPtrArray *array, const char *url) {
if (!array || !valid_relay_url(url) || array_contains(array, url)) return;
char *copy = g_strdup(url);
g_strstrip(copy);
gsize len = strlen(copy);
while (len > 0 && copy[len - 1] == '/') copy[--len] = '\0';
if (*copy && !array_contains(array, copy)) g_ptr_array_add(array, copy);
else g_free(copy);
}
static void parse_bootstrap_relays(scheme_job_t *job) {
gchar **lines = g_strsplit_set(job->bootstrap_relays ? job->bootstrap_relays : "",
"\r\n", -1);
for (gchar **line = lines; line && *line; line++) {
g_strstrip(*line);
add_unique_relay(job->default_relays, *line);
}
g_strfreev(lines);
}
static void parse_user_relays(scheme_job_t *job) {
if (!job->user_pubkey || !*job->user_pubkey) return;
cJSON *event = db_get_latest_event(job->user_pubkey, 10002);
if (!event) return;
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
cJSON *tag = NULL;
cJSON_ArrayForEach(tag, tags) {
if (!cJSON_IsArray(tag)) continue;
cJSON *name = cJSON_GetArrayItem(tag, 0);
cJSON *url = cJSON_GetArrayItem(tag, 1);
cJSON *marker = cJSON_GetArrayItem(tag, 2);
if (!cJSON_IsString(name) || strcmp(name->valuestring, "r") ||
!cJSON_IsString(url)) continue;
/* NIP-65: no marker means both; "read" is readable; "write" is not. */
if (cJSON_IsString(marker) && strcmp(marker->valuestring, "read")) continue;
add_unique_relay(job->user_relays, url->valuestring);
}
cJSON_Delete(event);
}
static GPtrArray *fallback_relays(scheme_job_t *job) {
return job->user_relays->len ? job->user_relays : job->default_relays;
}
static void record_attempted(scheme_job_t *job, GPtrArray *relays) {
for (guint i = 0; i < relays->len && job->attempted_relays->len < MAX_FETCH_RELAYS; i++)
add_unique_relay(job->attempted_relays, g_ptr_array_index(relays, i));
}
static cJSON *build_filter(const scheme_job_t *job) {
cJSON *filter = cJSON_CreateObject();
if (job->decoded.type == NOSTR_ENTITY_NPUB ||
job->decoded.type == NOSTR_ENTITY_NPROFILE) {
char *pubkey = nostr_hex_encode32(job->decoded.pubkey);
cJSON *authors = cJSON_AddArrayToObject(filter, "authors");
cJSON_AddItemToArray(authors, cJSON_CreateString(pubkey));
cJSON *kinds = cJSON_AddArrayToObject(filter, "kinds");
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(0));
cJSON_AddNumberToObject(filter, "limit", 1);
g_free(pubkey);
} else if (job->decoded.type == NOSTR_ENTITY_NOTE ||
job->decoded.type == NOSTR_ENTITY_NEVENT) {
char *id = nostr_hex_encode32(job->decoded.event_id);
cJSON *ids = cJSON_AddArrayToObject(filter, "ids");
cJSON_AddItemToArray(ids, cJSON_CreateString(id));
cJSON_AddNumberToObject(filter, "limit", 1);
g_free(id);
} else if (job->decoded.type == NOSTR_ENTITY_NADDR) {
char *pubkey = nostr_hex_encode32(job->decoded.pubkey);
cJSON *authors = cJSON_AddArrayToObject(filter, "authors");
cJSON_AddItemToArray(authors, cJSON_CreateString(pubkey));
cJSON *kinds = cJSON_AddArrayToObject(filter, "kinds");
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(job->decoded.kind));
cJSON *d_tags = cJSON_AddArrayToObject(filter, "#d");
cJSON_AddItemToArray(d_tags, cJSON_CreateString(job->decoded.identifier));
cJSON_AddNumberToObject(filter, "limit", 1);
g_free(pubkey);
}
return filter;
}
static int query_relay_stage(scheme_job_t *job, GPtrArray *relays) {
if (!relays || !relays->len || job->attempted_relays->len >= MAX_FETCH_RELAYS) return 0;
const char *urls[MAX_FETCH_RELAYS];
int count = 0;
int remaining = MAX_FETCH_RELAYS - (int)job->attempted_relays->len;
for (guint i = 0; i < relays->len && count < remaining; i++) {
const char *url = g_ptr_array_index(relays, i);
if (!array_contains(job->attempted_relays, url)) urls[count++] = url;
}
if (!count) return 0;
record_attempted(job, relays);
cJSON *filter = build_filter(job);
int result_count = 0;
cJSON **results = relay_query_synchronized(
urls, count, filter, RELAY_QUERY_ALL_RESULTS, &result_count,
FETCH_TIMEOUT_SECONDS, NULL, NULL, 0, NULL);
cJSON_Delete(filter);
if (!results || result_count <= 0) {
if (results) free(results);
cJSON *diag = cJSON_CreateObject();
cJSON_AddStringToObject(diag, "stage", relays == job->entity_relays
? "entity_hints" : "fallback");
cJSON_AddStringToObject(diag, "message",
"No events returned before relay query completed");
cJSON_AddItemToArray(job->errors, diag);
return 0;
}
int added = 0;
for (int i = 0; i < result_count && added < FETCH_EVENT_LIMIT; i++) {
if (!results[i]) continue;
cJSON_AddItemToArray(job->events, results[i]);
results[i] = NULL;
added++;
}
for (int i = 0; i < result_count; i++) if (results[i]) cJSON_Delete(results[i]);
free(results);
return added;
}
static size_t curl_write_bounded(void *data, size_t size, size_t nmemb, void *user) {
curl_body_t *body = user;
size_t bytes = size * nmemb;
if (body->body->len + bytes > NIP11_MAX_BYTES) {
body->overflow = TRUE;
return 0;
}
g_string_append_len(body->body, data, bytes);
return bytes;
}
static void fetch_nip11(scheme_job_t *job) {
char *http_url = NULL;
if (g_str_has_prefix(job->decoded.relay_url, "wss://"))
http_url = g_strdup_printf("https://%s", job->decoded.relay_url + 6);
else if (g_str_has_prefix(job->decoded.relay_url, "ws://"))
http_url = g_strdup_printf("http://%s", job->decoded.relay_url + 5);
if (!http_url) return;
CURL *curl = curl_easy_init();
curl_body_t body = { g_string_new(NULL), FALSE };
struct curl_slist *headers = curl_slist_append(NULL, "Accept: application/nostr+json");
curl_easy_setopt(curl, CURLOPT_URL, http_url);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, curl_write_bounded);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &body);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, (long)FETCH_TIMEOUT_SECONDS);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 4L);
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 3L);
curl_easy_setopt(curl, CURLOPT_USERAGENT, "sovereign_browser/nostr-uri");
CURLcode result = curl_easy_perform(curl);
long status = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
if (result == CURLE_OK && status >= 200 && status < 300)
job->nip11 = cJSON_ParseWithLength(body.body->str, body.body->len);
if (!job->nip11) {
cJSON *diag = cJSON_CreateObject();
cJSON_AddStringToObject(diag, "stage", "nip11");
cJSON_AddStringToObject(diag, "relay", job->decoded.relay_url);
cJSON_AddNumberToObject(diag, "http_status", status);
cJSON_AddStringToObject(diag, "message", body.overflow
? "NIP-11 response exceeded size limit"
: (result == CURLE_OK ? "NIP-11 response was not valid JSON"
: curl_easy_strerror(result)));
cJSON_AddItemToArray(job->errors, diag);
if (result == CURLE_OPERATION_TIMEDOUT) job->timed_out = TRUE;
}
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
g_string_free(body.body, TRUE);
g_free(http_url);
}
static void build_response(scheme_job_t *job) {
cJSON *response = NULL;
int event_count = cJSON_GetArraySize(job->events);
if (event_count == 1) {
response = cJSON_DetachItemFromArray(job->events, 0);
} else if (event_count > 1) {
response = job->events;
job->events = NULL;
} else if (job->decoded.type == NOSTR_ENTITY_NRELAY && job->nip11) {
response = job->nip11;
job->nip11 = NULL;
} else {
response = cJSON_CreateObject();
cJSON_AddStringToObject(response, "error", "No events found");
cJSON_AddStringToObject(response, "entity", job->entity);
}
job->response_json = cJSON_PrintUnformatted(response);
cJSON_Delete(response);
}
static gpointer scheme_worker(gpointer data) {
scheme_job_t *job = data;
parse_bootstrap_relays(job);
parse_user_relays(job);
for (guint i = 0; i < job->decoded.relay_hints->len; i++)
add_unique_relay(job->entity_relays,
g_ptr_array_index(job->decoded.relay_hints, i));
if (job->decoded.type == NOSTR_ENTITY_NRELAY) {
fetch_nip11(job);
} else {
int found = 0;
if (job->entity_relays->len) found = query_relay_stage(job, job->entity_relays);
if (!found) query_relay_stage(job, fallback_relays(job));
if (!job->attempted_relays->len) {
cJSON *diag = cJSON_CreateObject();
cJSON_AddStringToObject(diag, "stage", "relay_selection");
cJSON_AddStringToObject(diag, "message", "No usable relay URLs available");
cJSON_AddItemToArray(job->errors, diag);
}
}
build_response(job);
return job;
}
static void scheme_job_free(scheme_job_t *job) {
if (!job) return;
g_clear_object(&job->request);
g_free(job->entity);
g_free(job->bootstrap_relays);
g_free(job->user_pubkey);
g_free(job->response_json);
nostr_decoded_entity_clear(&job->decoded);
g_ptr_array_unref(job->entity_relays);
g_ptr_array_unref(job->user_relays);
g_ptr_array_unref(job->default_relays);
g_ptr_array_unref(job->attempted_relays);
if (job->events) cJSON_Delete(job->events);
if (job->errors) cJSON_Delete(job->errors);
if (job->nip11) cJSON_Delete(job->nip11);
g_free(job);
}
static void scheme_worker_done(GObject *source, GAsyncResult *result, gpointer data) {
(void)source; (void)data;
GTask *task = G_TASK(result);
scheme_job_t *job = g_task_propagate_pointer(task, NULL);
gsize len = strlen(job->response_json);
GInputStream *stream = g_memory_input_stream_new_from_data(
g_memdup2(job->response_json, len), len, g_free);
webkit_uri_scheme_request_finish(job->request, stream, len, "application/json");
g_object_unref(stream);
scheme_job_free(job);
}
static void task_thread(GTask *task, gpointer source, gpointer task_data,
GCancellable *cancel) {
(void)source; (void)cancel;
g_task_return_pointer(task, scheme_worker(task_data), NULL);
}
static char *extract_entity(const char *uri) {
const char *start = nostr_url_entity(uri);
if (!start) return NULL;
while (*start == '/') start++;
const char *end = start;
while (*end && *end != '/' && *end != '?' && *end != '#') end++;
return end == start ? NULL : g_strndup(start, end - start);
}
static void respond_json(WebKitURISchemeRequest *request, char *json) {
gsize len = strlen(json);
GInputStream *stream = g_memory_input_stream_new_from_data(json, len, free);
webkit_uri_scheme_request_finish(request, stream, len, "application/json");
g_object_unref(stream);
}
static void respond_error(WebKitURISchemeRequest *request, const char *entity,
const char *message) {
cJSON *root = cJSON_CreateObject();
cJSON_AddStringToObject(root, "error", message);
if (entity) cJSON_AddStringToObject(root, "entity", entity);
char *json = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
respond_json(request, json);
}
static void respond_helper_apps(WebKitURISchemeRequest *request, const char *config) {
cJSON *apps = cJSON_CreateArray();
gchar **configured = g_strsplit(config ? config : "", ",", -1);
for (gchar **item = configured; item && *item; item++) {
gchar **parts = g_strsplit(*item, "|", 2);
if (parts[0] && *parts[0] && parts[1] && *parts[1]) {
cJSON *app = cJSON_CreateObject();
cJSON_AddStringToObject(app, "name", parts[0]);
cJSON_AddStringToObject(app, "url_template", parts[1]);
cJSON_AddItemToArray(apps, app);
}
g_strfreev(parts);
}
g_strfreev(configured);
char *json = cJSON_PrintUnformatted(apps);
cJSON_Delete(apps);
respond_json(request, json);
}
static void on_nostr_scheme(WebKitURISchemeRequest *request, gpointer user_data) {
(void)user_data;
const char *uri = webkit_uri_scheme_request_get_uri(request);
char *entity = extract_entity(uri);
const browser_settings_t *settings = settings_get();
const char *path = entity ? strstr(uri + strlen("nostr://"), "/") : NULL;
if ((entity && strcmp(entity, "helper-apps") == 0) ||
(path && g_str_has_prefix(path, "/helper-apps") &&
(path[12] == '\0' || path[12] == '?' || path[12] == '#'))) {
respond_helper_apps(request, settings->nostr_helper_apps);
g_free(entity);
return;
}
nostr_decoded_entity_t decoded;
GError *error = NULL;
if (!entity || !nostr_url_decode(entity, &decoded, &error)) {
respond_error(request, entity, error ? error->message :
"Invalid or unsupported Nostr entity");
g_clear_error(&error);
g_free(entity);
return;
}
scheme_job_t *job = g_new0(scheme_job_t, 1);
job->request = g_object_ref(request);
job->entity = entity;
job->bootstrap_relays = g_strdup(settings->bootstrap_relays);
const char *pubkey = app_get_pubkey_hex();
job->user_pubkey = pubkey && *pubkey ? g_strdup(pubkey) : NULL;
job->decoded = decoded;
job->entity_relays = g_ptr_array_new_with_free_func(g_free);
job->user_relays = g_ptr_array_new_with_free_func(g_free);
job->default_relays = g_ptr_array_new_with_free_func(g_free);
job->attempted_relays = g_ptr_array_new_with_free_func(g_free);
job->events = cJSON_CreateArray();
job->errors = cJSON_CreateArray();
job->started_us = g_get_monotonic_time();
GTask *task = g_task_new(NULL, NULL, scheme_worker_done, NULL);
g_task_set_task_data(task, job, NULL);
g_task_run_in_thread(task, task_thread);
g_object_unref(task);
}
void nostr_scheme_register(WebKitWebContext *ctx) {
g_return_if_fail(WEBKIT_IS_WEB_CONTEXT(ctx));
webkit_web_context_register_uri_scheme(ctx, "nostr", on_nostr_scheme, NULL, NULL);
}

20
src/nostr_scheme.h Normal file
View File

@@ -0,0 +1,20 @@
/*
* nostr_scheme.h — WebKitGTK nostr:// URI scheme handler
*/
#ifndef NOSTR_SCHEME_H
#define NOSTR_SCHEME_H
#include <webkit2/webkit2.h>
#ifdef __cplusplus
extern "C" {
#endif
void nostr_scheme_register(WebKitWebContext *ctx);
#ifdef __cplusplus
}
#endif
#endif /* NOSTR_SCHEME_H */

327
src/nostr_url.c Normal file
View File

@@ -0,0 +1,327 @@
/*
* nostr_url.c — validated NIP-19 decoding and Nostr URI normalization
*/
#include "nostr_url.h"
#include <string.h>
#define NOSTR_URL_ERROR nostr_url_error_quark()
#define MAX_DECODED_BYTES 4096
#define MAX_RELAY_HINTS 32
typedef enum {
NOSTR_URL_ERROR_INVALID,
NOSTR_URL_ERROR_PRIVATE,
NOSTR_URL_ERROR_CHECKSUM,
NOSTR_URL_ERROR_TLV
} nostr_url_error_t;
static GQuark nostr_url_error_quark(void) {
return g_quark_from_static_string("nostr-url-error");
}
static const char bech32_charset[] =
"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
static int charset_value(char c) {
const char *p = strchr(bech32_charset, c);
return p ? (int)(p - bech32_charset) : -1;
}
static guint32 polymod_step(guint32 pre) {
guint8 top = pre >> 25;
guint32 chk = (pre & 0x1ffffffU) << 5;
static const guint32 gen[5] = {
0x3b6a57b2U, 0x26508e6dU, 0x1ea119faU, 0x3d4233ddU, 0x2a1462b3U
};
for (int i = 0; i < 5; i++) if ((top >> i) & 1U) chk ^= gen[i];
return chk;
}
static gboolean convert_bits(const guint8 *in, gsize in_len, int from_bits,
int to_bits, gboolean pad, GByteArray *out) {
guint32 acc = 0;
int bits = 0;
guint32 maxv = ((guint32)1 << to_bits) - 1U;
guint32 max_acc = ((guint32)1 << (from_bits + to_bits - 1)) - 1U;
for (gsize i = 0; i < in_len; i++) {
if ((in[i] >> from_bits) != 0) return FALSE;
acc = ((acc << from_bits) | in[i]) & max_acc;
bits += from_bits;
while (bits >= to_bits) {
bits -= to_bits;
guint8 value = (acc >> bits) & maxv;
g_byte_array_append(out, &value, 1);
}
}
if (pad) {
if (bits) {
guint8 value = (acc << (to_bits - bits)) & maxv;
g_byte_array_append(out, &value, 1);
}
} else if (bits >= from_bits || ((acc << (to_bits - bits)) & maxv)) {
return FALSE;
}
return TRUE;
}
static gboolean decode_bech32(const char *input, char **hrp_out,
GByteArray **bytes_out, GError **error) {
gsize len = input ? strlen(input) : 0;
if (len < 8 || len > 8192) {
g_set_error(error, NOSTR_URL_ERROR, NOSTR_URL_ERROR_INVALID,
"Invalid Bech32 length");
return FALSE;
}
gboolean lower = FALSE, upper = FALSE;
for (gsize i = 0; i < len; i++) {
if ((guchar)input[i] < 33 || (guchar)input[i] > 126) goto invalid;
if (g_ascii_islower(input[i])) lower = TRUE;
if (g_ascii_isupper(input[i])) upper = TRUE;
}
if (lower && upper) goto invalid;
const char *sep = strrchr(input, '1');
if (!sep || sep == input || (gsize)(input + len - sep) < 7) goto invalid;
gsize hrp_len = sep - input;
char *hrp = g_ascii_strdown(input, hrp_len);
guint32 chk = 1;
for (gsize i = 0; i < hrp_len; i++) chk = polymod_step(chk) ^ ((guint8)hrp[i] >> 5);
chk = polymod_step(chk);
for (gsize i = 0; i < hrp_len; i++) chk = polymod_step(chk) ^ ((guint8)hrp[i] & 31);
gsize data5_len = len - hrp_len - 1;
guint8 *data5 = g_malloc(data5_len);
for (gsize i = 0; i < data5_len; i++) {
int value = charset_value(g_ascii_tolower(sep[1 + i]));
if (value < 0) {
g_free(data5); g_free(hrp); goto invalid;
}
data5[i] = (guint8)value;
chk = polymod_step(chk) ^ data5[i];
}
if (chk != 1U) {
g_free(data5); g_free(hrp);
g_set_error(error, NOSTR_URL_ERROR, NOSTR_URL_ERROR_CHECKSUM,
"Invalid Bech32 checksum");
return FALSE;
}
GByteArray *bytes = g_byte_array_sized_new(data5_len * 5 / 8);
gboolean ok = convert_bits(data5, data5_len - 6, 5, 8, FALSE, bytes);
g_free(data5);
if (!ok || bytes->len > MAX_DECODED_BYTES) {
g_byte_array_unref(bytes); g_free(hrp); goto invalid;
}
*hrp_out = hrp;
*bytes_out = bytes;
return TRUE;
invalid:
g_set_error(error, NOSTR_URL_ERROR, NOSTR_URL_ERROR_INVALID,
"Invalid Bech32 encoding");
return FALSE;
}
const char *nostr_url_entity(const char *input) {
if (!input) return NULL;
if (g_ascii_strncasecmp(input, "nostr://", 8) == 0) return input + 8;
if (g_ascii_strncasecmp(input, "nostr:", 6) == 0) return input + 6;
return input;
}
nostr_entity_type_t nostr_url_detect(const char *input) {
const char *entity = nostr_url_entity(input);
if (!entity || !*entity) return NOSTR_ENTITY_NONE;
if (g_ascii_strncasecmp(entity, "nprofile1", 9) == 0) return NOSTR_ENTITY_NPROFILE;
if (g_ascii_strncasecmp(entity, "nevent1", 7) == 0) return NOSTR_ENTITY_NEVENT;
if (g_ascii_strncasecmp(entity, "nrelay1", 7) == 0) return NOSTR_ENTITY_NRELAY;
if (g_ascii_strncasecmp(entity, "naddr1", 6) == 0) return NOSTR_ENTITY_NADDR;
if (g_ascii_strncasecmp(entity, "npub1", 5) == 0) return NOSTR_ENTITY_NPUB;
if (g_ascii_strncasecmp(entity, "nsec1", 5) == 0) return NOSTR_ENTITY_NSEC;
if (g_ascii_strncasecmp(entity, "note1", 5) == 0) return NOSTR_ENTITY_NOTE;
return NOSTR_ENTITY_NONE;
}
static gboolean valid_utf8_text(const guint8 *data, gsize len) {
return len > 0 && !memchr(data, '\0', len) && g_utf8_validate((const char *)data, len, NULL);
}
static gboolean add_relay_hint(nostr_decoded_entity_t *out,
const guint8 *data, gsize len) {
if (!valid_utf8_text(data, len) || out->relay_hints->len >= MAX_RELAY_HINTS)
return FALSE;
char *url = g_strndup((const char *)data, len);
if (!(g_str_has_prefix(url, "wss://") || g_str_has_prefix(url, "ws://"))) {
g_free(url);
return FALSE;
}
g_ptr_array_add(out->relay_hints, url);
return TRUE;
}
static gboolean parse_tlv(const GByteArray *bytes, nostr_decoded_entity_t *out,
GError **error) {
gboolean have_special = FALSE;
for (gsize pos = 0; pos < bytes->len;) {
if (bytes->len - pos < 2) goto bad_tlv;
guint8 type = bytes->data[pos++];
guint8 len = bytes->data[pos++];
if (len > bytes->len - pos) goto bad_tlv;
const guint8 *value = bytes->data + pos;
switch (type) {
case 0:
if (have_special) goto bad_tlv;
have_special = TRUE;
if (out->type == NOSTR_ENTITY_NPROFILE) {
if (len != 32) goto bad_tlv;
memcpy(out->pubkey, value, 32); out->has_pubkey = TRUE;
} else if (out->type == NOSTR_ENTITY_NEVENT) {
if (len != 32) goto bad_tlv;
memcpy(out->event_id, value, 32); out->has_event_id = TRUE;
} else if (out->type == NOSTR_ENTITY_NADDR) {
if (!valid_utf8_text(value, len)) goto bad_tlv;
out->identifier = g_strndup((const char *)value, len);
} else if (out->type == NOSTR_ENTITY_NRELAY) {
if (!valid_utf8_text(value, len)) goto bad_tlv;
out->relay_url = g_strndup((const char *)value, len);
if (!(g_str_has_prefix(out->relay_url, "wss://") ||
g_str_has_prefix(out->relay_url, "ws://"))) goto bad_tlv;
}
break;
case 1:
if (out->type != NOSTR_ENTITY_NRELAY) add_relay_hint(out, value, len);
break;
case 2:
if ((out->type != NOSTR_ENTITY_NEVENT && out->type != NOSTR_ENTITY_NADDR) ||
len != 32 || out->has_pubkey) goto bad_tlv;
memcpy(out->pubkey, value, 32); out->has_pubkey = TRUE;
break;
case 3:
if ((out->type != NOSTR_ENTITY_NEVENT && out->type != NOSTR_ENTITY_NADDR) ||
len != 4 || out->has_kind) goto bad_tlv;
out->kind = ((guint32)value[0] << 24) | ((guint32)value[1] << 16) |
((guint32)value[2] << 8) | value[3];
out->has_kind = TRUE;
break;
default:
break; /* Unknown TLVs are forward-compatible. */
}
pos += len;
}
if (!have_special ||
(out->type == NOSTR_ENTITY_NADDR &&
(!out->identifier || !out->has_pubkey || !out->has_kind)) ||
(out->type == NOSTR_ENTITY_NRELAY && !out->relay_url)) goto bad_tlv;
return TRUE;
bad_tlv:
g_set_error(error, NOSTR_URL_ERROR, NOSTR_URL_ERROR_TLV,
"Malformed or incomplete NIP-19 TLV payload");
return FALSE;
}
gboolean nostr_url_decode(const char *input, nostr_decoded_entity_t *out,
GError **error) {
g_return_val_if_fail(out != NULL, FALSE);
memset(out, 0, sizeof(*out));
out->relay_hints = g_ptr_array_new_with_free_func(g_free);
const char *entity = nostr_url_entity(input);
if (!entity || !*entity || strchr(entity, '/') || strchr(entity, '?') || strchr(entity, '#')) {
g_set_error(error, NOSTR_URL_ERROR, NOSTR_URL_ERROR_INVALID,
"Missing or invalid Nostr entity");
goto fail;
}
char *hrp = NULL;
GByteArray *bytes = NULL;
if (!decode_bech32(entity, &hrp, &bytes, error)) goto fail;
if (!strcmp(hrp, "npub")) out->type = NOSTR_ENTITY_NPUB;
else if (!strcmp(hrp, "nsec")) out->type = NOSTR_ENTITY_NSEC;
else if (!strcmp(hrp, "note")) out->type = NOSTR_ENTITY_NOTE;
else if (!strcmp(hrp, "nprofile")) out->type = NOSTR_ENTITY_NPROFILE;
else if (!strcmp(hrp, "nevent")) out->type = NOSTR_ENTITY_NEVENT;
else if (!strcmp(hrp, "naddr")) out->type = NOSTR_ENTITY_NADDR;
else if (!strcmp(hrp, "nrelay")) out->type = NOSTR_ENTITY_NRELAY;
else out->type = NOSTR_ENTITY_NONE;
g_free(hrp);
if (out->type == NOSTR_ENTITY_NSEC) {
g_byte_array_unref(bytes);
g_set_error(error, NOSTR_URL_ERROR, NOSTR_URL_ERROR_PRIVATE,
"Private Nostr keys cannot be opened or navigated to");
goto fail;
}
if (out->type == NOSTR_ENTITY_NONE) {
g_byte_array_unref(bytes);
g_set_error(error, NOSTR_URL_ERROR, NOSTR_URL_ERROR_INVALID,
"Unsupported NIP-19 entity type");
goto fail;
}
gboolean ok = TRUE;
if (out->type == NOSTR_ENTITY_NPUB || out->type == NOSTR_ENTITY_NOTE) {
if (bytes->len != 32) ok = FALSE;
else if (out->type == NOSTR_ENTITY_NPUB) {
memcpy(out->pubkey, bytes->data, 32); out->has_pubkey = TRUE;
} else {
memcpy(out->event_id, bytes->data, 32); out->has_event_id = TRUE;
}
} else {
ok = parse_tlv(bytes, out, error);
}
g_byte_array_unref(bytes);
if (!ok) {
if (error && !*error)
g_set_error(error, NOSTR_URL_ERROR, NOSTR_URL_ERROR_INVALID,
"Invalid NIP-19 payload length");
goto fail;
}
return TRUE;
fail:
nostr_decoded_entity_clear(out);
return FALSE;
}
void nostr_decoded_entity_clear(nostr_decoded_entity_t *entity) {
if (!entity) return;
g_free(entity->identifier);
g_free(entity->relay_url);
if (entity->relay_hints) g_ptr_array_unref(entity->relay_hints);
memset(entity, 0, sizeof(*entity));
}
char *nostr_hex_encode32(const guint8 value[32]) {
char *hex = g_malloc(65);
for (int i = 0; i < 32; i++) g_snprintf(hex + i * 2, 3, "%02x", value[i]);
hex[64] = '\0';
return hex;
}
char *nostr_url_normalize(const char *input) {
nostr_decoded_entity_t decoded;
if (!nostr_url_decode(input, &decoded, NULL)) return NULL;
nostr_decoded_entity_clear(&decoded);
return g_strdup_printf("nostr://%s", nostr_url_entity(input));
}
const char *nostr_entity_type_name(nostr_entity_type_t type) {
switch (type) {
case NOSTR_ENTITY_NPUB: return "npub";
case NOSTR_ENTITY_NSEC: return "nsec";
case NOSTR_ENTITY_NOTE: return "note";
case NOSTR_ENTITY_NEVENT: return "nevent";
case NOSTR_ENTITY_NADDR: return "naddr";
case NOSTR_ENTITY_NPROFILE: return "nprofile";
case NOSTR_ENTITY_NRELAY: return "nrelay";
default: return "none";
}
}

60
src/nostr_url.h Normal file
View File

@@ -0,0 +1,60 @@
/*
* nostr_url.h — validated NIP-19 decoding and Nostr URI normalization
*/
#ifndef NOSTR_URL_H
#define NOSTR_URL_H
#include <glib.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef enum {
NOSTR_ENTITY_NONE,
NOSTR_ENTITY_NPUB,
NOSTR_ENTITY_NSEC,
NOSTR_ENTITY_NOTE,
NOSTR_ENTITY_NEVENT,
NOSTR_ENTITY_NADDR,
NOSTR_ENTITY_NPROFILE,
NOSTR_ENTITY_NRELAY,
} nostr_entity_type_t;
typedef struct {
nostr_entity_type_t type;
guint8 pubkey[32];
gboolean has_pubkey;
guint8 event_id[32];
gboolean has_event_id;
guint32 kind;
gboolean has_kind;
char *identifier;
char *relay_url;
GPtrArray *relay_hints; /* char*, ordered as encoded */
} nostr_decoded_entity_t;
/* Prefix-only classification. Use nostr_url_decode() before trusting input. */
nostr_entity_type_t nostr_url_detect(const char *input);
/* Decode and validate Bech32/Bech32 checksum, payload conversion, and TLVs.
* nsec is recognized but deliberately rejected. Returns TRUE on success. */
gboolean nostr_url_decode(const char *input, nostr_decoded_entity_t *out,
GError **error);
void nostr_decoded_entity_clear(nostr_decoded_entity_t *entity);
/* Newly allocated lowercase hex representation of 32 bytes. */
char *nostr_hex_encode32(const guint8 value[32]);
char *nostr_url_normalize(const char *input);
const char *nostr_url_entity(const char *input);
const char *nostr_entity_type_name(nostr_entity_type_t type);
#ifdef __cplusplus
}
#endif
#endif /* NOSTR_URL_H */

101
src/perf_probe.c Normal file
View File

@@ -0,0 +1,101 @@
/*
* perf_probe.c — per-tab performance probe injection
*
* See perf_probe.h. The probe script itself lives at www/js/perf-probe.js
* and is embedded into the binary by embed_web_files.sh. This module
* wraps it with a per-tab preamble that injects a
* <meta name="sb-tab-index" content="N"> tag into the document before
* the probe runs, so the probe can identify which tab it is reporting
* for.
*
* The preamble also short-circuits on sovereign:// pages (the probe
* would be self-referential and noisy on internal pages). We can't
* filter by URL at injection time because WebKitUserScript is added
* once per webview and applies to all future loads, so the URL check
* happens at runtime inside the preamble.
*/
#include "perf_probe.h"
#include "embedded_web_content.h"
#include <string.h>
#include <stdio.h>
/* The preamble runs at document_start, before perf-probe.js. It:
* 1. Checks the current URL — if it's a sovereign:// internal page,
* sets a flag so the probe body no-ops.
* 2. Injects a <meta name="sb-tab-index" content="N"> tag so the
* probe (which runs at document_start as well, but after this
* preamble in script-order) can read its tab index.
*
* The tab index is baked in at injection time as a literal. */
static char *build_preamble(int tab_index) {
/* The preamble is small and fixed-shape; 512 bytes is plenty. */
char *p = g_malloc(512);
if (p == NULL) return NULL;
snprintf(p, 512,
"(function(){\n"
" 'use strict';\n"
" try {\n"
" var u = location.href || '';\n"
" if (u.indexOf('sovereign://') === 0 || u.indexOf('about:') === 0) {\n"
" window.__sbPerfProbeSkip = true;\n"
" } else {\n"
" window.__sbPerfProbeSkip = false;\n"
" window.__sbPerfTabIndex = %d;\n"
" }\n"
" } catch(e) {}\n"
"})();\n",
tab_index);
return p;
}
void perf_probe_setup(WebKitWebView *webview, int tab_index) {
WebKitUserContentManager *manager =
webkit_web_view_get_user_content_manager(webview);
if (manager == NULL) {
g_printerr("[perf-probe] No user content manager — probe not injected\n");
return;
}
/* Preamble: sets the tab index meta tag (or skips on internal pages). */
char *preamble = build_preamble(tab_index);
if (preamble == NULL) return;
WebKitUserScript *pre_script = webkit_user_script_new(
preamble,
WEBKIT_USER_CONTENT_INJECT_TOP_FRAME,
WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
NULL, /* allow list (NULL = all) */
NULL /* block list */
);
webkit_user_content_manager_add_script(manager, pre_script);
webkit_user_script_unref(pre_script);
g_free(preamble);
/* The probe body itself, from the embedded www/js/perf-probe.js. */
const embedded_file_t *f = get_embedded_file("js/perf-probe.js");
if (f == NULL) {
g_printerr("[perf-probe] Embedded js/perf-probe.js not found\n");
return;
}
/* webkit_user_script_new needs a NUL-terminated string. The embedded
* data is a byte array of known size; copy it into a NUL-terminated
* buffer. */
char *probe_js = g_malloc(f->size + 1);
if (probe_js == NULL) return;
memcpy(probe_js, f->data, f->size);
probe_js[f->size] = '\0';
WebKitUserScript *probe_script = webkit_user_script_new(
probe_js,
WEBKIT_USER_CONTENT_INJECT_TOP_FRAME,
WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
NULL,
NULL
);
webkit_user_content_manager_add_script(manager, probe_script);
webkit_user_script_unref(probe_script);
g_free(probe_js);
}

36
src/perf_probe.h Normal file
View File

@@ -0,0 +1,36 @@
/*
* perf_probe.h — per-tab performance probe injection
*
* Injects www/js/perf-probe.js into a webview via
* WebKitUserContentManager, prefixed with a tiny preamble that sets
* the tab index (so the probe knows which tab it is reporting for).
*
* The probe is skipped on sovereign:// internal pages to avoid
* self-noise and recursion. Call once per webview at tab creation,
* after nostr_inject_setup().
*/
#ifndef PERF_PROBE_H
#define PERF_PROBE_H
#include <webkit2/webkit2.h>
#ifdef __cplusplus
extern "C" {
#endif
/*
* Inject the perf probe into the given webview, tagged with the given
* tab index. The index is baked into a preamble that runs before the
* probe script and sets window.__sbPerfTabIndex.
*
* Safe to call multiple times on the same webview (the probe guards
* against double-injection).
*/
void perf_probe_setup(WebKitWebView *webview, int tab_index);
#ifdef __cplusplus
}
#endif
#endif /* PERF_PROBE_H */

558
src/process_info.c Normal file
View File

@@ -0,0 +1,558 @@
/*
* process_info.c — process and per-tab performance diagnostics
*
* See process_info.h for the two-layer design. This file implements:
*
* Layer 1 — /proc enumeration for the main PID, WebKit child PIDs
* (discovered via webkit_web_view_get_process_id() per tab
* plus a PPID scan for network/gpu/storage processes), and
* managed Tor/FIPS PIDs from net_services. CPU% is computed
* from utime+stime deltas between successive calls.
*
* Layer 2 — storage of the most recent perf-probe.js report per tab
* index, exposed to the UI and MCP tools.
*
* All /proc parsing is Linux-specific and uses only libc + glib.
*/
#include "process_info.h"
#include "tab_manager.h"
#include "net_services.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <time.h>
#include <dirent.h>
#include <ctype.h>
#include <sys/types.h>
#include <glib.h>
/* ── CPU% baseline tracking ──────────────────────────────────────────── */
typedef struct {
guint64 prev_jiffies; /* utime + stime (in clock ticks) */
gint64 prev_wall_us; /* g_get_monotonic_time() at last sample (microseconds) */
} cpu_sample_t;
static GHashTable *g_cpu_samples = NULL; /* pid (guint) -> cpu_sample_t* */
static cpu_sample_t *cpu_sample_get(guint pid) {
if (g_cpu_samples == NULL) {
g_cpu_samples = g_hash_table_new_full(g_direct_hash, g_direct_equal,
NULL, g_free);
}
cpu_sample_t *s = g_hash_table_lookup(g_cpu_samples, GUINT_TO_POINTER(pid));
if (s == NULL) {
s = g_new0(cpu_sample_t, 1);
g_hash_table_insert(g_cpu_samples, GUINT_TO_POINTER(pid), s);
}
return s;
}
static long g_clk_tck = 0;
static long clk_tck(void) {
if (g_clk_tck == 0) g_clk_tck = sysconf(_SC_CLK_TCK);
return g_clk_tck > 0 ? g_clk_tck : 100;
}
/* ── /proc readers ──────────────────────────────────────────────────── */
/* Read the first line of a /proc file into buf (NUL-terminated). */
static int read_proc_file(const char *path, char *buf, size_t buflen) {
FILE *f = fopen(path, "r");
if (f == NULL) return -1;
size_t n = fread(buf, 1, buflen - 1, f);
buf[n] = '\0';
fclose(f);
return (int)n;
}
/* Parse /proc/<pid>/stat. Fields (1-indexed, per man proc):
* (2) comm (3) state (4) ppid (14) utime (15) stime
* (22) starttime (in clock ticks since boot)
* comm is wrapped in parens and may contain spaces, so we parse by
* finding the last ')' and tokenizing after it. */
static int parse_stat(guint pid, char *state_out, size_t state_sz,
guint *ppid_out, guint64 *utime_out, guint64 *stime_out,
guint64 *starttime_out, char *comm_out, size_t comm_sz) {
char path[64];
snprintf(path, sizeof(path), "/proc/%u/stat", pid);
char buf[4096];
if (read_proc_file(path, buf, sizeof(buf)) < 0) return -1;
/* comm: between first '(' and last ')'. */
char *lp = strchr(buf, '(');
char *rp = strrchr(buf, ')');
if (lp == NULL || rp == NULL || rp <= lp) return -1;
size_t comm_len = (size_t)(rp - lp - 1);
if (comm_len >= comm_sz) comm_len = comm_sz - 1;
memcpy(comm_out, lp + 1, comm_len);
comm_out[comm_len] = '\0';
/* The rest after ") " is space-separated fields starting at field 3. */
char *rest = rp + 2;
/* rest = "state ppid ... " — tokenize. */
char *save = NULL;
char *tok = strtok_r(rest, " ", &save); /* field 3: state */
if (tok == NULL) return -1;
if (state_out) {
size_t sl = strlen(tok);
if (sl >= state_sz) sl = state_sz - 1;
memcpy(state_out, tok, sl);
state_out[sl] = '\0';
}
tok = strtok_r(NULL, " ", &save); /* field 4: ppid */
if (tok == NULL) return -1;
if (ppid_out) *ppid_out = (guint)strtoul(tok, NULL, 10);
/* Fields 5..13 are pgrp, session, tty, tpgid, flags, minflt, cminflt,
* majflt, cmajflt. Skip them. */
for (int i = 0; i < 9; i++) strtok_r(NULL, " ", &save);
tok = strtok_r(NULL, " ", &save); /* field 14: utime */
if (tok == NULL) return -1;
if (utime_out) *utime_out = strtoull(tok, NULL, 10);
tok = strtok_r(NULL, " ", &save); /* field 15: stime */
if (tok == NULL) return -1;
if (stime_out) *stime_out = strtoull(tok, NULL, 10);
/* Fields 16..21: cutime, cstime, priority, nice, numthreads, itrealvalue. */
for (int i = 0; i < 6; i++) strtok_r(NULL, " ", &save);
tok = strtok_r(NULL, " ", &save); /* field 22: starttime */
if (tok == NULL) return -1;
if (starttime_out) *starttime_out = strtoull(tok, NULL, 10);
return 0;
}
/* Read a named field from /proc/<pid>/status (e.g. "VmRSS:"). Returns
* the integer value in KB, or -1 if not found. */
static long status_field_kb(guint pid, const char *field) {
char path[64];
snprintf(path, sizeof(path), "/proc/%u/status", pid);
FILE *f = fopen(path, "r");
if (f == NULL) return -1;
char line[256];
long val = -1;
size_t flen = strlen(field);
while (fgets(line, sizeof(line), f)) {
if (strncmp(line, field, flen) == 0) {
/* e.g. "VmRSS: 12345 kB" */
char *p = line + flen;
while (*p == ' ' || *p == '\t') p++;
val = strtol(p, NULL, 10);
break;
}
}
fclose(f);
return val;
}
/* Parse /proc/<pid>/smaps_rollup for Pss / Private_Clean+Private_Dirty.
* Returns 0 on success. */
static int parse_smaps_rollup(guint pid, long *pss_kb, long *uss_kb) {
char path[64];
snprintf(path, sizeof(path), "/proc/%u/smaps_rollup", pid);
FILE *f = fopen(path, "r");
if (f == NULL) return -1;
char line[256];
long pss = -1, priv_clean = 0, priv_dirty = 0;
while (fgets(line, sizeof(line), f)) {
if (strncmp(line, "Pss:", 4) == 0) {
pss = strtol(line + 4, NULL, 10);
} else if (strncmp(line, "Private_Clean:", 14) == 0) {
priv_clean = strtol(line + 14, NULL, 10);
} else if (strncmp(line, "Private_Dirty:", 14) == 0) {
priv_dirty = strtol(line + 14, NULL, 10);
}
}
fclose(f);
if (pss_kb) *pss_kb = pss;
if (uss_kb) *uss_kb = priv_clean + priv_dirty;
return (pss < 0) ? -1 : 0;
}
/* Read /proc/<pid>/io fields. Returns 0 on success. */
static int parse_io(guint pid, long *read_bytes, long *write_bytes) {
char path[64];
snprintf(path, sizeof(path), "/proc/%u/io", pid);
FILE *f = fopen(path, "r");
if (f == NULL) return -1;
char line[256];
long rb = 0, wb = 0;
while (fgets(line, sizeof(line), f)) {
if (strncmp(line, "read_bytes:", 11) == 0) {
rb = strtoll(line + 11, NULL, 10);
} else if (strncmp(line, "write_bytes:", 12) == 0) {
wb = strtoll(line + 12, NULL, 10);
}
}
fclose(f);
if (read_bytes) *read_bytes = rb;
if (write_bytes) *write_bytes = wb;
return 0;
}
/* Read /proc/<pid>/cmdline (NUL-separated args) into a space-joined buf. */
static void read_cmdline(guint pid, char *buf, size_t buflen) {
char path[64];
snprintf(path, sizeof(path), "/proc/%u/cmdline", pid);
FILE *f = fopen(path, "r");
if (f == NULL) { buf[0] = '\0'; return; }
size_t n = fread(buf, 1, buflen - 1, f);
fclose(f);
if (n == 0) { buf[0] = '\0'; return; }
buf[n] = '\0';
/* Replace NUL separators with spaces. */
for (size_t i = 0; i < n; i++) {
if (buf[i] == '\0') buf[i] = ' ';
}
/* Trim trailing space. */
while (n > 0 && buf[n - 1] == ' ') buf[--n] = '\0';
}
/* Count entries in a /proc directory (used for threads and fds). */
static int count_dir_entries(const char *path) {
DIR *d = opendir(path);
if (d == NULL) return -1;
int count = 0;
struct dirent *e;
while ((e = readdir(d)) != NULL) {
if (e->d_name[0] == '.') continue;
count++;
}
closedir(d);
return count;
}
/* Read /proc/stat's btime (boot time in seconds since epoch). */
static time_t g_btime = 0;
static time_t boot_time(void) {
if (g_btime != 0) return g_btime;
FILE *f = fopen("/proc/stat", "r");
if (f == NULL) return 0;
char line[256];
while (fgets(line, sizeof(line), f)) {
if (strncmp(line, "btime", 5) == 0) {
g_btime = (time_t)strtoll(line + 5, NULL, 10);
break;
}
}
fclose(f);
return g_btime;
}
/* ── Probe storage (Layer 2) ────────────────────────────────────────── */
typedef struct {
cJSON *probe; /* most recent probe report (owned) */
time_t ts; /* when recorded */
} probe_slot_t;
/* Sparse array indexed by tab index. Grows as needed. */
static probe_slot_t *g_probes = NULL;
static int g_probe_cap = 0;
static void probe_ensure(int idx) {
if (idx < 0) return;
if (idx >= g_probe_cap) {
int new_cap = g_probe_cap == 0 ? 16 : g_probe_cap;
while (new_cap <= idx) new_cap *= 2;
probe_slot_t *arr = g_realloc(g_probes, new_cap * sizeof(probe_slot_t));
if (arr == NULL) return;
for (int i = g_probe_cap; i < new_cap; i++) {
arr[i].probe = NULL;
arr[i].ts = 0;
}
g_probes = arr;
g_probe_cap = new_cap;
}
}
void process_info_record_probe(int tab_index, cJSON *probe) {
if (tab_index < 0 || probe == NULL) {
if (probe) cJSON_Delete(probe);
return;
}
probe_ensure(tab_index);
if (tab_index >= g_probe_cap) {
cJSON_Delete(probe);
return;
}
if (g_probes[tab_index].probe) {
cJSON_Delete(g_probes[tab_index].probe);
}
g_probes[tab_index].probe = probe; /* take ownership */
g_probes[tab_index].ts = time(NULL);
}
/* Return a *reference* (not a copy) to the stored probe for a tab, or
* NULL. Caller must NOT free. */
static cJSON *probe_get(int tab_index) {
if (tab_index < 0 || tab_index >= g_probe_cap) return NULL;
return g_probes[tab_index].probe;
}
/* Deep-copy a probe (so the caller can free independently). */
static cJSON *probe_copy(int tab_index) {
cJSON *p = probe_get(tab_index);
if (p == NULL) return NULL;
char *s = cJSON_PrintUnformatted(p);
cJSON *copy = cJSON_Parse(s);
free(s);
return copy;
}
/* ── Process enumeration ────────────────────────────────────────────── */
/* Ownership tag for a discovered PID. */
typedef enum {
OWN_MAIN,
OWN_WEBKIT_RENDERER,
OWN_WEBKIT_NETWORK,
OWN_WEBKIT_GPU,
OWN_WEBKIT_STORAGE,
OWN_TOR,
OWN_FIPS,
OWN_OTHER
} proc_own_t;
/* Build a cJSON object for one process. `own` and `service_state`
* (NULL for non-services) are caller-supplied. `hosted_tabs` is an
* array (may be NULL) for renderers. */
static cJSON *build_proc_obj(guint pid, proc_own_t own,
const char *service_state,
cJSON *hosted_tabs) {
char comm[256] = "";
char state[8] = "?";
guint ppid = 0;
guint64 utime = 0, stime = 0, starttime = 0;
if (parse_stat(pid, state, sizeof(state), &ppid, &utime, &stime,
&starttime, comm, sizeof(comm)) != 0) {
return NULL;
}
/* CPU% via delta. Use g_get_monotonic_time() (microsecond
* resolution) instead of time(NULL) (1-second resolution) so the
* value updates on every poll even when the poll interval is ~1s. */
cpu_sample_t *s = cpu_sample_get(pid);
guint64 cur_jiffies = utime + stime;
gint64 now_us = g_get_monotonic_time();
double cpu_pct = 0.0;
if (s->prev_wall_us > 0) {
double dt = (double)(now_us - s->prev_wall_us) / 1000000.0;
if (dt > 0) {
double djiffies = (double)(cur_jiffies - s->prev_jiffies);
cpu_pct = (djiffies / (double)clk_tck()) / dt * 100.0;
if (cpu_pct < 0) cpu_pct = 0;
}
}
s->prev_jiffies = cur_jiffies;
s->prev_wall_us = now_us;
long rss_kb = status_field_kb(pid, "VmRSS:");
long vmpeak_kb = status_field_kb(pid, "VmPeak:");
long vmswap_kb = status_field_kb(pid, "VmSwap:");
long pss_kb = -1, uss_kb = -1;
parse_smaps_rollup(pid, &pss_kb, &uss_kb);
long io_read = 0, io_write = 0;
parse_io(pid, &io_read, &io_write);
char task_path[64];
snprintf(task_path, sizeof(task_path), "/proc/%u/task", pid);
int threads = count_dir_entries(task_path);
if (threads < 0) threads = 0;
char fd_path[64];
snprintf(fd_path, sizeof(fd_path), "/proc/%u/fd", pid);
int fds = count_dir_entries(fd_path);
if (fds < 0) fds = 0;
char cmdline[1024];
read_cmdline(pid, cmdline, sizeof(cmdline));
/* Uptime: starttime is in ticks since boot; btime is boot seconds
* since epoch. process_start = btime + starttime/clk. */
time_t start_sec = boot_time() + (time_t)(starttime / (guint64)clk_tck());
time_t uptime = time(NULL) - start_sec;
if (uptime < 0) uptime = 0;
const char *own_str = "other";
switch (own) {
case OWN_MAIN: own_str = "main"; break;
case OWN_WEBKIT_RENDERER: own_str = "webkit-renderer"; break;
case OWN_WEBKIT_NETWORK: own_str = "webkit-network"; break;
case OWN_WEBKIT_GPU: own_str = "webkit-gpu"; break;
case OWN_WEBKIT_STORAGE: own_str = "webkit-storage"; break;
case OWN_TOR: own_str = "tor"; break;
case OWN_FIPS: own_str = "fips"; break;
default: own_str = "other"; break;
}
cJSON *o = cJSON_CreateObject();
cJSON_AddNumberToObject(o, "pid", (double)pid);
cJSON_AddStringToObject(o, "name", comm);
cJSON_AddStringToObject(o, "cmdline", cmdline);
cJSON_AddStringToObject(o, "state", state);
cJSON_AddNumberToObject(o, "ppid", (double)ppid);
cJSON_AddNumberToObject(o, "cpu_percent", cpu_pct);
cJSON_AddNumberToObject(o, "rss_kb", rss_kb < 0 ? 0 : rss_kb);
cJSON_AddNumberToObject(o, "pss_kb", pss_kb < 0 ? rss_kb : pss_kb);
cJSON_AddNumberToObject(o, "uss_kb", uss_kb < 0 ? 0 : uss_kb);
cJSON_AddNumberToObject(o, "vmpeak_kb", vmpeak_kb < 0 ? 0 : vmpeak_kb);
cJSON_AddNumberToObject(o, "vmswap_kb", vmswap_kb < 0 ? 0 : vmswap_kb);
cJSON_AddNumberToObject(o, "threads", threads);
cJSON_AddNumberToObject(o, "uptime_sec", (double)uptime);
cJSON_AddNumberToObject(o, "io_read_kb", (double)(io_read / 1024));
cJSON_AddNumberToObject(o, "io_write_kb", (double)(io_write / 1024));
cJSON_AddNumberToObject(o, "fd_count", fds);
cJSON_AddStringToObject(o, "ownership", own_str);
if (service_state) {
cJSON_AddStringToObject(o, "service_state", service_state);
}
if (hosted_tabs) {
cJSON_AddItemToObject(o, "hosted_tabs", hosted_tabs);
} else {
cJSON_AddItemToObject(o, "hosted_tabs", cJSON_CreateArray());
}
return o;
}
/* Map a net_service_type_t to a service_state string via net_services. */
static const char *service_state_for(net_service_type_t t) {
const net_service_t *s = net_service_get_status(t);
if (s == NULL) return NULL;
switch (s->state) {
case SERVICE_DISABLED: return "disabled";
case SERVICE_DISCOVERING: return "discovering";
case SERVICE_ATTACHING: return "attaching";
case SERVICE_STARTING: return "starting";
case SERVICE_BOOTSTRAPPING: return "bootstrapping";
case SERVICE_READY: return "ready";
case SERVICE_STOPPING: return "stopping";
case SERVICE_EXITED: return "exited";
case SERVICE_FAILED: return "failed";
default: return "unknown";
}
}
/* Scan /proc for child processes of main_pid whose comm starts with
* "WebKit". Classifies each as a renderer (WebKitWebProcess), network
* (WebKitNetworkProcess), GPU (WebKitGPUProcess), or storage
* (WebKitStorageProcess) and adds it to the `out` array with the
* appropriate ownership tag.
*
* Note: WebKitGTK 4.1 does not expose a per-webview OS PID, so we
* cannot map individual tabs to specific renderer PIDs here. Per-tab
* CPU attribution is handled by the Layer 2 probe instead. Renderer
* rows therefore show an empty hosted_tabs array. */
static void scan_webkit_procs(guint main_pid, cJSON *out) {
DIR *d = opendir("/proc");
if (d == NULL) return;
struct dirent *e;
while ((e = readdir(d)) != NULL) {
if (!isdigit((unsigned char)e->d_name[0])) continue;
guint pid = (guint)strtoul(e->d_name, NULL, 10);
if (pid == 0 || pid == main_pid) continue;
char comm[256] = "";
char state[8] = "?";
guint ppid = 0;
guint64 utime = 0, stime = 0, starttime = 0;
if (parse_stat(pid, state, sizeof(state), &ppid, &utime, &stime,
&starttime, comm, sizeof(comm)) != 0) continue;
if (ppid != main_pid) continue;
if (strncmp(comm, "WebKit", 6) != 0) continue;
proc_own_t own = OWN_OTHER;
if (strstr(comm, "Network")) own = OWN_WEBKIT_NETWORK;
else if (strstr(comm, "GPU")) own = OWN_WEBKIT_GPU;
else if (strstr(comm, "Storage")) own = OWN_WEBKIT_STORAGE;
else if (strstr(comm, "Web")) own = OWN_WEBKIT_RENDERER;
else continue; /* unknown WebKit aux — skip */
cJSON *obj = build_proc_obj(pid, own, NULL, NULL);
if (obj) cJSON_AddItemToArray(out, obj);
}
closedir(d);
}
cJSON *process_info_get_processes_json(void) {
cJSON *arr = cJSON_CreateArray();
guint main_pid = (guint)getpid();
/* Main process. */
cJSON *main_obj = build_proc_obj(main_pid, OWN_MAIN, NULL, NULL);
if (main_obj) cJSON_AddItemToArray(arr, main_obj);
/* WebKit child processes (renderers, network, gpu, storage).
* Discovered via /proc PPID scan — WebKitGTK 4.1 doesn't expose
* a per-webview OS PID, so renderer rows have empty hosted_tabs. */
scan_webkit_procs(main_pid, arr);
/* Tor + FIPS managed subprocesses. */
const net_service_t *tor = net_service_get_status(NET_SERVICE_TOR);
if (tor && tor->ownership == OWNERSHIP_MANAGED && tor->pid > 0) {
cJSON *obj = build_proc_obj((guint)tor->pid, OWN_TOR,
service_state_for(NET_SERVICE_TOR), NULL);
if (obj) cJSON_AddItemToArray(arr, obj);
}
const net_service_t *fips = net_service_get_status(NET_SERVICE_FIPS);
if (fips && fips->ownership == OWNERSHIP_MANAGED && fips->pid > 0) {
cJSON *obj = build_proc_obj((guint)fips->pid, OWN_FIPS,
service_state_for(NET_SERVICE_FIPS), NULL);
if (obj) cJSON_AddItemToArray(arr, obj);
}
return arr;
}
/* ── Tab list (Layer 2) ─────────────────────────────────────────────── */
cJSON *process_info_get_tabs_json(void) {
cJSON *arr = cJSON_CreateArray();
int n = tab_manager_count();
for (int i = 0; i < n; i++) {
tab_info_t *tab = tab_manager_get(i);
if (tab == NULL) continue;
cJSON *o = cJSON_CreateObject();
cJSON_AddNumberToObject(o, "index", i);
cJSON_AddStringToObject(o, "title", tab->title[0] ? tab->title : "");
cJSON_AddStringToObject(o, "url",
tab->current_url[0] ? tab->current_url : "");
gboolean is_internal = (strncmp(tab->current_url, "sovereign://", 12) == 0);
cJSON_AddBoolToObject(o, "is_internal", is_internal);
/* WebKitGTK 4.1 does not expose a per-webview OS PID, so we
* report 0 here. The Processes tab lists renderer PIDs from
* /proc; per-tab CPU attribution is via the Layer 2 probe. */
cJSON_AddNumberToObject(o, "webprocess_pid", 0);
cJSON *probe = probe_copy(i);
if (probe) {
cJSON_AddItemToObject(o, "probe", probe);
} else {
cJSON_AddNullToObject(o, "probe");
}
cJSON_AddItemToArray(arr, o);
}
return arr;
}
cJSON *process_info_get_tab_probe_json(int tab_index) {
tab_info_t *tab = tab_manager_get(tab_index);
if (tab == NULL) return NULL;
cJSON *root = cJSON_CreateObject();
cJSON_AddNumberToObject(root, "index", tab_index);
cJSON_AddStringToObject(root, "title", tab->title[0] ? tab->title : "");
cJSON_AddStringToObject(root, "url",
tab->current_url[0] ? tab->current_url : "");
gboolean is_internal = (strncmp(tab->current_url, "sovereign://", 12) == 0);
cJSON_AddBoolToObject(root, "is_internal", is_internal);
/* WebKitGTK 4.1 does not expose a per-webview OS PID. */
cJSON_AddNumberToObject(root, "webprocess_pid", 0);
cJSON *probe = probe_copy(tab_index);
if (probe) {
cJSON_AddItemToObject(root, "probe", probe);
} else {
cJSON_AddNullToObject(root, "probe");
}
return root;
}

82
src/process_info.h Normal file
View File

@@ -0,0 +1,82 @@
/*
* process_info.h — process and per-tab performance diagnostics
*
* Provides the data backing for the sovereign://processes internal page.
* Two layers:
*
* Layer 1 — process view: enumerates the main browser PID, the WebKit
* child processes (renderer / network / gpu / storage) and
* the managed Tor/FIPS subprocesses, reading /proc for CPU%,
* RSS/PSS/USS, threads, uptime, I/O, FD count, cmdline, and
* service state. CPU% is computed from utime+stime deltas
* between successive calls (top-style).
*
* Layer 2 — per-tab probe: a JS user script (www/js/perf-probe.js)
* injected into every non-sovereign:// page reports a
* 1-second batch of long-task, timer, network, heap, and
* FPS samples via sovereign://processes/probe-report. The
* latest report per tab index is stored here and exposed to
* the UI and to MCP tools.
*
* All /proc parsing is Linux-specific and dependency-free.
*/
#ifndef PROCESS_INFO_H
#define PROCESS_INFO_H
#include "../nostr_core_lib/cjson/cJSON.h"
#ifdef __cplusplus
extern "C" {
#endif
/*
* Build the Layer 1 process list as a cJSON array (caller frees).
* Shape:
* [{ "pid":123, "name":"...", "cmdline":"...", "state":"R",
* "ppid":1, "cpu_percent":2.1, "rss_kb":..., "pss_kb":...,
* "uss_kb":..., "vmpeak_kb":..., "vmswap_kb":...,
* "threads":12, "uptime_sec":..., "io_read_kb":...,
* "io_write_kb":..., "fd_count":..., "ownership":"main",
* "service_state":"ready" (Tor/FIPS only),
* "hosted_tabs":[ {"index":3,"title":"...","url":"..."}, ... ]
* (renderers only) }]
*
* The first call returns cpu_percent 0 for every process (no baseline);
* subsequent calls return accurate deltas. Caller should poll ~1s.
*/
cJSON *process_info_get_processes_json(void);
/*
* Build the Layer 2 tab list as a cJSON array (caller frees).
* Combines tab_manager state (index, title, url, WebProcess pid) with
* the most recent probe report for each tab (if any).
* Shape:
* [{ "index":3, "title":"...", "url":"...", "webprocess_pid":12367,
* "is_internal":true,
* "probe": { ...latest probe report, or null... } }]
*/
cJSON *process_info_get_tabs_json(void);
/*
* Build a drill-down JSON object for a single tab (caller frees).
* Returns the full latest probe report (including the long-task
* timeline and top sources) plus the tab's identity fields.
* Returns NULL if the tab index is out of range.
*/
cJSON *process_info_get_tab_probe_json(int tab_index);
/*
* Record a probe report received from a page's perf-probe.js.
* Takes ownership of `probe` (frees it). `tab_index` is the
* sovereign://processes/probe-report?tab_index=N query value.
* Stores a copy of the report fields; the timeline and top_sources
* arrays are preserved for the drill-down view.
*/
void process_info_record_probe(int tab_index, cJSON *probe);
#ifdef __cplusplus
}
#endif
#endif /* PROCESS_INFO_H */

279
src/profile.c Normal file
View File

@@ -0,0 +1,279 @@
/*
* profile.c — per-user profile directory management for sovereign_browser
*
* Each Nostr identity (identified by its 64-char hex pubkey) gets its own
* profile directory under ~/.sovereign_browser/profiles/<pubkey_hex>/.
*/
#include "profile.h"
#include <glib.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
#include "../nostr_core_lib/cjson/cJSON.h"
/* ── Internal helpers ──────────────────────────────────────────────── */
/* Get ~/.sovereign_browser/ (created if missing). Returns 0 on success. */
static int sb_home_dir(char *out, size_t out_sz) {
const char *home = getenv("HOME");
if (home == NULL || home[0] == '\0') {
return -1;
}
int n = snprintf(out, out_sz, "%s/.sovereign_browser", home);
if (n < 0 || (size_t)n >= out_sz) {
return -1;
}
if (mkdir(out, 0700) != 0 && errno != EEXIST) {
return -1;
}
return 0;
}
/* Max path length we use for home-derived paths. The home directory
* can be long, and we append "/.sovereign_browser/profiles/<64-hex>/".
* 600 bytes is generous enough for any realistic HOME path. */
#define SB_HOME_BUF_SZ 600
/* mkdir -p style: create a directory and all parent directories. */
static int mkdir_p(const char *path, mode_t mode) {
char tmp[512];
int n = snprintf(tmp, sizeof(tmp), "%s", path);
if (n < 0 || (size_t)n >= sizeof(tmp)) {
return -1;
}
/* Remove trailing slash if present. */
size_t len = strlen(tmp);
if (len > 0 && tmp[len - 1] == '/') {
tmp[len - 1] = '\0';
}
/* Walk the path and create each component. */
for (char *p = tmp + 1; *p; p++) {
if (*p == '/') {
*p = '\0';
if (mkdir(tmp, mode) != 0 && errno != EEXIST) {
return -1;
}
*p = '/';
}
}
if (mkdir(tmp, mode) != 0 && errno != EEXIST) {
return -1;
}
return 0;
}
/* ── Public API ────────────────────────────────────────────────────── */
void profile_get_dir(const char *pubkey_hex, char *out, size_t out_sz) {
if (pubkey_hex == NULL || out == NULL || out_sz == 0) {
if (out && out_sz > 0) out[0] = '\0';
return;
}
char home[600];
if (sb_home_dir(home, sizeof(home)) != 0) {
if (out_sz > 0) out[0] = '\0';
return;
}
int n = snprintf(out, out_sz, "%s/profiles/%s/", home, pubkey_hex);
if (n < 0 || (size_t)n >= out_sz) {
if (out_sz > 0) out[0] = '\0';
}
}
int profile_ensure_dir(const char *pubkey_hex) {
if (pubkey_hex == NULL || pubkey_hex[0] == '\0') {
return -1;
}
char dir[SB_HOME_BUF_SZ];
profile_get_dir(pubkey_hex, dir, sizeof(dir));
if (dir[0] == '\0') {
return -1;
}
return mkdir_p(dir, 0700);
}
/* ── WebKit data directory ─────────────────────────────────────────── *
* Each user's WebKitWebsiteDataManager (cookies, cache, localStorage,
* IndexedDB, service workers, favicons) is rooted at
* ~/.sovereign_browser/profiles/<pubkey>/webkit/ so web data is isolated
* per identity and persists across restarts. See plans/webkit-data-isolation.md.
*/
void profile_get_webkit_dir(const char *pubkey_hex, char *out, size_t out_sz) {
if (pubkey_hex == NULL || out == NULL || out_sz == 0) {
if (out && out_sz > 0) out[0] = '\0';
return;
}
char dir[SB_HOME_BUF_SZ];
profile_get_dir(pubkey_hex, dir, sizeof(dir));
if (dir[0] == '\0') {
if (out_sz > 0) out[0] = '\0';
return;
}
int n = snprintf(out, out_sz, "%swebkit/", dir);
if (n < 0 || (size_t)n >= out_sz) {
if (out_sz > 0) out[0] = '\0';
}
}
int profile_ensure_webkit_dir(const char *pubkey_hex) {
if (pubkey_hex == NULL || pubkey_hex[0] == '\0') {
return -1;
}
char dir[SB_HOME_BUF_SZ];
profile_get_webkit_dir(pubkey_hex, dir, sizeof(dir));
if (dir[0] == '\0') {
return -1;
}
return mkdir_p(dir, 0700);
}
void profile_get_db_path(const char *pubkey_hex, char *out, size_t out_sz) {
if (pubkey_hex == NULL || out == NULL || out_sz == 0) {
if (out && out_sz > 0) out[0] = '\0';
return;
}
char dir[SB_HOME_BUF_SZ];
profile_get_dir(pubkey_hex, dir, sizeof(dir));
if (dir[0] == '\0') {
if (out_sz > 0) out[0] = '\0';
return;
}
int n = snprintf(out, out_sz, "%sbrowser.db", dir);
if (n < 0 || (size_t)n >= out_sz) {
if (out_sz > 0) out[0] = '\0';
}
}
void profile_get_identity_path(const char *pubkey_hex, char *out, size_t out_sz) {
if (pubkey_hex == NULL || out == NULL || out_sz == 0) {
if (out && out_sz > 0) out[0] = '\0';
return;
}
char dir[SB_HOME_BUF_SZ];
profile_get_dir(pubkey_hex, dir, sizeof(dir));
if (dir[0] == '\0') {
if (out_sz > 0) out[0] = '\0';
return;
}
int n = snprintf(out, out_sz, "%sidentity.json", dir);
if (n < 0 || (size_t)n >= out_sz) {
if (out_sz > 0) out[0] = '\0';
}
}
void profile_get_global_identity_path(char *out, size_t out_sz) {
if (out == NULL || out_sz == 0) {
return;
}
char home[SB_HOME_BUF_SZ];
if (sb_home_dir(home, sizeof(home)) != 0) {
out[0] = '\0';
return;
}
int n = snprintf(out, out_sz, "%s/identity.json", home);
if (n < 0 || (size_t)n >= out_sz) {
if (out_sz > 0) out[0] = '\0';
}
}
/* ── Last-used pubkey persistence (global identity.json) ───────────── */
int profile_save_last_pubkey(const char *pubkey_hex) {
char path[SB_HOME_BUF_SZ];
profile_get_global_identity_path(path, sizeof(path));
if (path[0] == '\0') {
return -1;
}
cJSON *root = cJSON_CreateObject();
if (pubkey_hex && pubkey_hex[0]) {
cJSON_AddStringToObject(root, "pubkey_hex", pubkey_hex);
} else {
cJSON_AddNullToObject(root, "pubkey_hex");
}
char *json = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
if (json == NULL) {
return -1;
}
FILE *f = fopen(path, "w");
if (f == NULL) {
free(json);
return -1;
}
fputs(json, f);
fputc('\n', f);
fclose(f);
free(json);
g_print("[profile] Saved last-used pubkey: %s\n",
(pubkey_hex && pubkey_hex[0]) ? pubkey_hex : "(cleared)");
return 0;
}
int profile_load_last_pubkey(char *out, size_t out_sz) {
if (out == NULL || out_sz < 65) {
return -1;
}
char path[SB_HOME_BUF_SZ];
profile_get_global_identity_path(path, sizeof(path));
if (path[0] == '\0') {
return -1;
}
FILE *f = fopen(path, "r");
if (f == NULL) {
return -1;
}
/* Read the file (small JSON, a few hundred bytes at most). */
char buf[1024];
size_t n = fread(buf, 1, sizeof(buf) - 1, f);
fclose(f);
buf[n] = '\0';
cJSON *root = cJSON_Parse(buf);
if (root == NULL) {
return -1;
}
cJSON *pk = cJSON_GetObjectItemCaseSensitive(root, "pubkey_hex");
const char *hex = cJSON_IsString(pk) ? pk->valuestring : NULL;
int rc = -1;
if (hex && strlen(hex) == 64) {
snprintf(out, out_sz, "%s", hex);
rc = 0;
}
cJSON_Delete(root);
return rc;
}

92
src/profile.h Normal file
View File

@@ -0,0 +1,92 @@
/*
* profile.h — per-user profile directory management for sovereign_browser
*
* Each Nostr identity (identified by its 64-char hex pubkey) gets its own
* profile directory under ~/.sovereign_browser/profiles/<pubkey_hex>/
* containing a per-user browser.db and identity.json.
*
* Global settings (agent server, theme, shortcuts, inspector geometry)
* live in ~/.sovereign_browser/global.db and are shared across all users.
*/
#ifndef PROFILE_H
#define PROFILE_H
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
/*
* Get the profile directory path for a given hex pubkey.
* pubkey_hex — 64-char hex pubkey (no leading "0x")
* out — output buffer
* out_sz — size of output buffer
* Writes "~/.sovereign_browser/profiles/<pubkey_hex>/" to out.
*/
void profile_get_dir(const char *pubkey_hex, char *out, size_t out_sz);
/*
* Create the profile directory for the given hex pubkey if it doesn't
* already exist (mkdir -p style — creates parent dirs as needed).
* pubkey_hex — 64-char hex pubkey
* Returns 0 on success, -1 on error.
*/
int profile_ensure_dir(const char *pubkey_hex);
/*
* Get the per-user browser.db path for a given hex pubkey.
* Writes "~/.sovereign_browser/profiles/<pubkey_hex>/browser.db" to out.
*/
void profile_get_db_path(const char *pubkey_hex, char *out, size_t out_sz);
/*
* Get the per-user identity.json path for a given hex pubkey.
* Writes "~/.sovereign_browser/profiles/<pubkey_hex>/identity.json" to out.
*/
void profile_get_identity_path(const char *pubkey_hex, char *out, size_t out_sz);
/*
* Get the per-user WebKit data directory for a given hex pubkey.
* Writes "~/.sovereign_browser/profiles/<pubkey_hex>/webkit/" to out.
* This is where the per-user WebKitWebsiteDataManager roots its cookies,
* cache, localStorage, IndexedDB, service workers, and favicons so web
* data is isolated per identity. See plans/webkit-data-isolation.md.
*/
void profile_get_webkit_dir(const char *pubkey_hex, char *out, size_t out_sz);
/*
* Create the per-user WebKit data directory if it doesn't already exist
* (mkdir -p style). Returns 0 on success, -1 on error.
*/
int profile_ensure_webkit_dir(const char *pubkey_hex);
/*
* Get the global identity.json path (stores the last-used pubkey_hex so
* the login dialog can default to the right profile).
* Writes "~/.sovereign_browser/identity.json" to out.
*/
void profile_get_global_identity_path(char *out, size_t out_sz);
/*
* Save the last-used pubkey_hex to the global identity.json.
* pubkey_hex — 64-char hex pubkey, or NULL/empty to clear
* Returns 0 on success, -1 on error.
*/
int profile_save_last_pubkey(const char *pubkey_hex);
/*
* Read the last-used pubkey_hex from the global identity.json.
* out — output buffer (must be at least 65 bytes)
* out_sz — size of output buffer
* Returns 0 on success (out filled with hex pubkey), -1 if not found or
* error (out is left unchanged).
*/
int profile_load_last_pubkey(char *out, size_t out_sz);
#ifdef __cplusplus
}
#endif
#endif /* PROFILE_H */

View File

@@ -32,6 +32,20 @@
/* Forward declaration — defined after relay_fetch_bootstrap. */
static gboolean avatar_refresh_idle(gpointer data);
static GMutex relay_query_mutex;
struct cJSON **relay_query_synchronized(
const char **relay_urls, int relay_count, struct cJSON *filter,
relay_query_mode_t mode, int *result_count, int timeout_seconds,
relay_progress_callback_t callback, void *user_data,
int nip42_enabled, const unsigned char *private_key) {
g_mutex_lock(&relay_query_mutex);
struct cJSON **results = synchronous_query_relays_with_progress(
relay_urls, relay_count, filter, mode, result_count, timeout_seconds,
callback, user_data, nip42_enabled, private_key);
g_mutex_unlock(&relay_query_mutex);
return results;
}
/* ── Public API ────────────────────────────────────────────────────── */
@@ -47,7 +61,7 @@ int relay_fetch_bootstrap(const char *pubkey_hex,
return -1;
}
g_print("[relay] Fetching kind 0/3/10002/30003/30078 for %s from %d relay(s)...\n",
g_print("[relay] Fetching kind 0/3/10002/30003/30078/31123 for %s from %d relay(s)...\n",
pubkey_hex, relay_count);
/* Build the filter: {"authors": [pubkey], "kinds": [0, 3, 10002, 30003, 30078]} */
@@ -67,7 +81,7 @@ int relay_fetch_bootstrap(const char *pubkey_hex,
/* Query the relays. RELAY_QUERY_ALL_RESULTS collects all unique events
* from all relays. No NIP-42 auth needed for public events. */
int result_count = 0;
cJSON **results = synchronous_query_relays_with_progress(
cJSON **results = relay_query_synchronized(
relay_urls, relay_count, filter,
RELAY_QUERY_ALL_RESULTS, &result_count,
RELAY_TIMEOUT_SECONDS,
@@ -87,7 +101,8 @@ int relay_fetch_bootstrap(const char *pubkey_hex,
/* Store each event in the database. Kind 30003 (bookmark sets) are
* also decrypted and loaded into the in-memory bookmarks list.
* Kind 30078 (NIP-78 app data) is stored and merged into local
* settings if it's our sovereign_browser settings event. */
* settings if it's the shared d:user-settings event (or the legacy
* d:sovereign_browser event, which is migrated on merge). */
int stored = 0;
for (int i = 0; i < result_count; i++) {
if (results[i] == NULL) continue;
@@ -104,7 +119,8 @@ int relay_fetch_bootstrap(const char *pubkey_hex,
} else if (kind == 30078) {
/* Store in SQLite first, then try to merge into local settings.
* settings_sync_merge_from_nostr checks the d-tag and only
* merges if it's our "sovereign_browser" event. */
* merges if it's the shared "user-settings" event (or the
* legacy "sovereign_browser" event, which is migrated). */
if (db_store_event(results[i]) == 0) {
stored++;
}
@@ -132,6 +148,58 @@ int relay_fetch_bootstrap(const char *pubkey_hex,
return stored;
}
/* Fetch kind 31123 skill events from relays. Skills are PUBLIC events
* authored by ANY user (not just the logged-in user), so this uses a
* separate query without an authors filter. The events are stored in
* the SQLite cache for agent_skills_fetch() to read.
*
* Returns the number of events stored, or 0 on error. */
static int relay_fetch_skills(const char **relay_urls, int relay_count) {
if (relay_urls == NULL || relay_count <= 0) return 0;
g_print("[relay] Fetching kind 31123 skills from %d relay(s)...\n",
relay_count);
/* Build the filter: {"kinds": [31123], "limit": 500} — no authors
* filter since skills are public and from any author. */
cJSON *filter = cJSON_CreateObject();
cJSON *kinds = cJSON_CreateArray();
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(31123));
cJSON_AddItemToObject(filter, "kinds", kinds);
cJSON_AddNumberToObject(filter, "limit", 500);
int result_count = 0;
cJSON **results = relay_query_synchronized(
relay_urls, relay_count, filter,
RELAY_QUERY_ALL_RESULTS, &result_count,
RELAY_TIMEOUT_SECONDS,
NULL, NULL,
0, NULL
);
cJSON_Delete(filter);
if (results == NULL || result_count <= 0) {
g_print("[relay] No skill events found\n");
if (results) free(results);
return 0;
}
int stored = 0;
for (int i = 0; i < result_count; i++) {
if (results[i] == NULL) continue;
if (db_store_event(results[i]) == 0) {
stored++;
}
cJSON_Delete(results[i]);
}
free(results);
g_print("[relay] Fetched %d skill event(s), stored %d\n",
result_count, stored);
return stored;
}
/* Idle callback wrapper to refresh the avatar on the main thread. */
static gboolean avatar_refresh_idle(gpointer data) {
char *pubkey = (char *)data;
@@ -178,6 +246,9 @@ gpointer relay_fetch_thread(gpointer data) {
g_print("[relay] Background fetch started (%d relays)\n", relay_count);
relay_fetch_bootstrap(pubkey_hex, relay_urls, relay_count);
/* Fetch public kind 31123 skill events (from any author). */
relay_fetch_skills(relay_urls, relay_count);
g_free(relay_buf);
g_free(pubkey_hex);
return NULL;

View File

@@ -10,6 +10,7 @@
#define RELAY_FETCH_H
#include <glib.h>
#include "nostr_core/nostr_core.h"
#ifdef __cplusplus
extern "C" {
@@ -38,6 +39,15 @@ int relay_fetch_bootstrap(const char *pubkey_hex,
*/
gpointer relay_fetch_thread(gpointer data);
/* Thread-safe serialization wrapper around nostr_core_lib's synchronous
* one-off relay client. The vendored client uses shared process state and
* must not be entered concurrently by startup and URI fetch workers. */
struct cJSON **relay_query_synchronized(
const char **relay_urls, int relay_count, struct cJSON *filter,
relay_query_mode_t mode, int *result_count, int timeout_seconds,
relay_progress_callback_t callback, void *user_data,
int nip42_enabled, const unsigned char *private_key);
#ifdef __cplusplus
}
#endif

View File

@@ -15,6 +15,37 @@
#include <stdlib.h>
#include <string.h>
#include <strings.h> /* strcasecmp */
#include <unistd.h>
#include <sys/stat.h>
#include <errno.h>
/* ── Global.db path helper ─────────────────────────────────────────── *
* Returns the path to ~/.sovereign_browser/global.db (creating the
* directory if needed). Used by settings_save_global() to write global
* settings to global.db via db_kv_set_to_file(), even when the per-user
* browser.db is the main open database.
*/
static void global_db_path(char *out, size_t out_sz) {
const char *home = getenv("HOME");
if (home == NULL || home[0] == '\0') {
if (out_sz > 0) out[0] = '\0';
return;
}
char dir[512];
int n = snprintf(dir, sizeof(dir), "%s/.sovereign_browser", home);
if (n < 0 || (size_t)n >= sizeof(dir)) {
if (out_sz > 0) out[0] = '\0';
return;
}
if (mkdir(dir, 0700) != 0 && errno != EEXIST) {
if (out_sz > 0) out[0] = '\0';
return;
}
n = snprintf(out, out_sz, "%s/global.db", dir);
if (n < 0 || (size_t)n >= out_sz) {
if (out_sz > 0) out[0] = '\0';
}
}
/* ── Global singleton ─────────────────────────────────────────────── */
@@ -23,7 +54,7 @@ static browser_settings_t g_settings;
/* ── Defaults ─────────────────────────────────────────────────────── */
static void settings_set_defaults(browser_settings_t *s) {
s->restore_session = TRUE;
s->restore_session = FALSE;
snprintf(s->new_tab_url, sizeof(s->new_tab_url), "%s",
SETTINGS_NEW_TAB_URL_DEFAULT);
s->tab_bar_position = GTK_POS_TOP;
@@ -33,6 +64,7 @@ static void settings_set_defaults(browser_settings_t *s) {
s->max_tabs = SETTINGS_MAX_TABS_DEFAULT;
s->tab_drag_reorder = TRUE;
s->agent_server_enabled = TRUE;
s->json_viewer_enabled = TRUE;
s->agent_server_port = SETTINGS_AGENT_PORT_DEFAULT;
snprintf(s->agent_allowed_origins, sizeof(s->agent_allowed_origins), "*");
s->agent_login_timeout_ms = SETTINGS_AGENT_LOGIN_TIMEOUT_DEFAULT;
@@ -40,11 +72,60 @@ static void settings_set_defaults(browser_settings_t *s) {
SETTINGS_BOOTSTRAP_RELAYS_DEFAULT);
snprintf(s->search_engine, sizeof(s->search_engine), "%s",
SETTINGS_SEARCH_ENGINE_DEFAULT);
s->theme_dark = TRUE; /* default: dark mode */
snprintf(s->nostr_helper_apps, sizeof(s->nostr_helper_apps), "%s",
SETTINGS_NOSTR_HELPER_APPS_DEFAULT);
s->theme_dark = FALSE; /* default: light mode */
s->inspector_x = -1; /* -1 = let window manager decide */
s->inspector_y = -1;
s->inspector_w = -1;
s->inspector_h = -1;
/* Agent LLM provider settings */
snprintf(s->agent_llm_base_url, sizeof(s->agent_llm_base_url), "%s",
SETTINGS_AGENT_LLM_BASE_URL_DEFAULT);
s->agent_llm_api_key[0] = '\0'; /* empty by default */
snprintf(s->agent_llm_model, sizeof(s->agent_llm_model), "%s",
SETTINGS_AGENT_LLM_MODEL_DEFAULT);
s->agent_llm_system_prompt[0] = '\0'; /* legacy alias for skill_template */
s->agent_max_iterations = SETTINGS_AGENT_MAX_ITERATIONS_DEFAULT;
/* Sovereign Browser Skill defaults. The template defaults to the
* built-in system prompt; the other fields describe the skill. */
snprintf(s->agent_skill_name,
sizeof(s->agent_skill_name), "%s",
SETTINGS_AGENT_SKILL_NAME_DEFAULT);
snprintf(s->agent_skill_description,
sizeof(s->agent_skill_description), "%s",
SETTINGS_AGENT_SKILL_DESCRIPTION_DEFAULT);
snprintf(s->agent_skill_template,
sizeof(s->agent_skill_template), "%s",
SETTINGS_AGENT_SYSTEM_PROMPT_DEFAULT);
snprintf(s->agent_skill_requires_tools,
sizeof(s->agent_skill_requires_tools), "%s",
SETTINGS_AGENT_SKILL_REQUIRES_TOOLS_DEFAULT);
/* Multi-provider catalog — empty by default. Populated from the
* d:user-settings Nostr event (global.agent.providers) on login.
* If no providers are loaded, a single default provider is seeded
* from the legacy agent_llm_base_url / agent_llm_api_key fields. */
s->agent_provider_count = 0;
s->agent_active_provider = -1;
s->agent_active_provider_name[0] = '\0';
memset(s->agent_providers, 0, sizeof(s->agent_providers));
s->tor_enabled = TRUE;
snprintf(s->tor_mode, sizeof(s->tor_mode), "auto");
snprintf(s->tor_binary_path, sizeof(s->tor_binary_path), "tor");
s->tor_attach_socks[0] = '\0';
s->tor_attach_control[0] = '\0';
snprintf(s->tor_data_dir, sizeof(s->tor_data_dir),
"~/.sovereign_browser/tor");
s->fips_enabled = TRUE;
snprintf(s->fips_mode, sizeof(s->fips_mode), "auto");
snprintf(s->fips_binary_path, sizeof(s->fips_binary_path), "fips");
s->fips_control_socket[0] = '\0';
snprintf(s->fips_config_dir, sizeof(s->fips_config_dir),
"~/.sovereign_browser/fips");
}
/* ── Parsing helpers ──────────────────────────────────────────────── */
@@ -96,13 +177,83 @@ static const char *position_to_string(int pos) {
}
}
/* ── Load / Save (SQLite key_value table) ─────────────────────────── */
/* ── Load / Save (SQLite key_value table) ─────────────────────────── *
* Settings are split into two groups:
*
* GLOBAL (stored in ~/.sovereign_browser/global.db):
* agent_server_enabled, agent_server_port, agent_allowed_origins,
* agent_login_timeout_ms, theme_dark, inspector_x/y/w/h
*
* PER-USER (stored in ~/.sovereign_browser/profiles/<pubkey>/browser.db):
* restore_session, new_tab_url, tab_bar_position,
* show_tab_close_buttons, middle_click_close, ctrl_tab_switch,
* max_tabs, tab_drag_reorder, bootstrap_relays, search_engine
*
* The settings struct is a single in-memory singleton. Global settings
* are loaded once at startup (from global.db) and kept in memory. Per-user
* settings are loaded after login (from the per-user browser.db). Both
* groups are saved when the user changes settings on the settings page.
*/
void settings_load(void) {
settings_set_defaults(&g_settings);
void settings_load_global(void) {
/* Read global settings from the currently-open database (global.db).
* Defaults must already be set (call settings_set_defaults first, or
* call settings_load_user() which sets defaults). */
const char *val;
/* Read each setting from the key_value table. If a key is missing
* (NULL), the default is kept. */
val = db_kv_get("agent_server_enabled");
if (val) g_settings.agent_server_enabled = parse_bool(val, g_settings.agent_server_enabled);
val = db_kv_get("json_viewer_enabled");
if (val) g_settings.json_viewer_enabled = parse_bool(val, g_settings.json_viewer_enabled);
val = db_kv_get("agent_server_port");
if (val) {
g_settings.agent_server_port = parse_int(val, g_settings.agent_server_port);
if (g_settings.agent_server_port < 1) g_settings.agent_server_port = SETTINGS_AGENT_PORT_DEFAULT;
}
val = db_kv_get("agent_allowed_origins");
if (val) snprintf(g_settings.agent_allowed_origins, sizeof(g_settings.agent_allowed_origins), "%s", val);
val = db_kv_get("agent_login_timeout_ms");
if (val) {
g_settings.agent_login_timeout_ms = parse_int(val, g_settings.agent_login_timeout_ms);
if (g_settings.agent_login_timeout_ms < 0) g_settings.agent_login_timeout_ms = 0;
}
val = db_kv_get("theme_dark");
if (val) g_settings.theme_dark = parse_bool(val, g_settings.theme_dark);
val = db_kv_get("inspector_x");
if (val) g_settings.inspector_x = parse_int(val, g_settings.inspector_x);
val = db_kv_get("inspector_y");
if (val) g_settings.inspector_y = parse_int(val, g_settings.inspector_y);
val = db_kv_get("inspector_w");
if (val) g_settings.inspector_w = parse_int(val, g_settings.inspector_w);
val = db_kv_get("inspector_h");
if (val) g_settings.inspector_h = parse_int(val, g_settings.inspector_h);
}
void settings_load_user(void) {
/* Set defaults first — this resets the entire struct, so global
* settings loaded earlier would be lost. To preserve global settings,
* the caller should call settings_load_global() AFTER this function
* if both need to be (re)loaded. The normal startup flow is:
* 1. settings_set_defaults() (via settings_load_user)
* 2. settings_load_global() (from global.db)
* 3. ... login ...
* 4. settings_load_user() (from per-user browser.db, preserves
* global settings already in memory)
*
* But settings_load_user() does NOT reset defaults — it only reads
* per-user keys, leaving global keys untouched. So the correct flow
* is:
* 1. settings_set_defaults() — once
* 2. settings_load_global() — from global.db
* 3. ... login, switch to per-user db ...
* 4. settings_load_user() — from per-user browser.db
*/
const char *val;
val = db_kv_get("restore_session");
@@ -132,44 +283,174 @@ void settings_load(void) {
val = db_kv_get("tab_drag_reorder");
if (val) g_settings.tab_drag_reorder = parse_bool(val, g_settings.tab_drag_reorder);
val = db_kv_get("agent_server_enabled");
if (val) g_settings.agent_server_enabled = parse_bool(val, g_settings.agent_server_enabled);
val = db_kv_get("agent_server_port");
if (val) {
g_settings.agent_server_port = parse_int(val, g_settings.agent_server_port);
if (g_settings.agent_server_port < 1) g_settings.agent_server_port = SETTINGS_AGENT_PORT_DEFAULT;
}
val = db_kv_get("agent_allowed_origins");
if (val) snprintf(g_settings.agent_allowed_origins, sizeof(g_settings.agent_allowed_origins), "%s", val);
val = db_kv_get("agent_login_timeout_ms");
if (val) {
g_settings.agent_login_timeout_ms = parse_int(val, g_settings.agent_login_timeout_ms);
if (g_settings.agent_login_timeout_ms < 0) g_settings.agent_login_timeout_ms = 0;
}
val = db_kv_get("bootstrap_relays");
if (val) snprintf(g_settings.bootstrap_relays, sizeof(g_settings.bootstrap_relays), "%s", val);
val = db_kv_get("search_engine");
if (val) snprintf(g_settings.search_engine, sizeof(g_settings.search_engine), "%s", val);
val = db_kv_get("theme_dark");
if (val) g_settings.theme_dark = parse_bool(val, g_settings.theme_dark);
val = db_kv_get("nostr_helper_apps");
if (val) snprintf(g_settings.nostr_helper_apps,
sizeof(g_settings.nostr_helper_apps), "%s", val);
val = db_kv_get("inspector_x");
if (val) g_settings.inspector_x = parse_int(val, g_settings.inspector_x);
val = db_kv_get("inspector_y");
if (val) g_settings.inspector_y = parse_int(val, g_settings.inspector_y);
val = db_kv_get("inspector_w");
if (val) g_settings.inspector_w = parse_int(val, g_settings.inspector_w);
val = db_kv_get("inspector_h");
if (val) g_settings.inspector_h = parse_int(val, g_settings.inspector_h);
val = db_kv_get("tor.enabled");
if (val) g_settings.tor_enabled = parse_bool(val, g_settings.tor_enabled);
val = db_kv_get("tor.mode");
if (val) snprintf(g_settings.tor_mode, sizeof(g_settings.tor_mode), "%s", val);
val = db_kv_get("tor.binary_path");
if (val) snprintf(g_settings.tor_binary_path, sizeof(g_settings.tor_binary_path), "%s", val);
val = db_kv_get("tor.attach_socks");
if (val) snprintf(g_settings.tor_attach_socks, sizeof(g_settings.tor_attach_socks), "%s", val);
val = db_kv_get("tor.attach_control");
if (val) snprintf(g_settings.tor_attach_control, sizeof(g_settings.tor_attach_control), "%s", val);
val = db_kv_get("tor.data_dir");
if (val) snprintf(g_settings.tor_data_dir, sizeof(g_settings.tor_data_dir), "%s", val);
val = db_kv_get("fips.enabled");
if (val) g_settings.fips_enabled = parse_bool(val, g_settings.fips_enabled);
val = db_kv_get("fips.mode");
if (val) snprintf(g_settings.fips_mode, sizeof(g_settings.fips_mode), "%s", val);
val = db_kv_get("fips.binary_path");
if (val) snprintf(g_settings.fips_binary_path, sizeof(g_settings.fips_binary_path), "%s", val);
val = db_kv_get("fips.control_socket");
if (val) snprintf(g_settings.fips_control_socket, sizeof(g_settings.fips_control_socket), "%s", val);
val = db_kv_get("fips.config_dir");
if (val) snprintf(g_settings.fips_config_dir, sizeof(g_settings.fips_config_dir), "%s", val);
/* Agent LLM provider settings — these are the "resolved" values
* (active provider's base_url + api_key + selected model). They are
* populated from db_kv here for local persistence, and overwritten
* by settings_sync_merge_from_nostr() from global.agent.providers +
* sovereign_browser.agent when a Nostr event is available. */
val = db_kv_get("agent.llm_base_url");
if (val) snprintf(g_settings.agent_llm_base_url, sizeof(g_settings.agent_llm_base_url), "%s", val);
val = db_kv_get("agent.llm_api_key");
if (val) snprintf(g_settings.agent_llm_api_key, sizeof(g_settings.agent_llm_api_key), "%s", val);
val = db_kv_get("agent.llm_model");
if (val) snprintf(g_settings.agent_llm_model, sizeof(g_settings.agent_llm_model), "%s", val);
val = db_kv_get("agent.llm_system_prompt");
if (val) snprintf(g_settings.agent_llm_system_prompt, sizeof(g_settings.agent_llm_system_prompt), "%s", val);
val = db_kv_get("agent.max_iterations");
if (val) g_settings.agent_max_iterations = atoi(val);
/* Sovereign Browser Skill fields. The template mirrors
* agent_llm_system_prompt for backward compatibility — if the
* legacy key is set but the new skill_template key is not, the
* template falls back to the legacy value (or the default). */
val = db_kv_get("agent.skill_name");
if (val) snprintf(g_settings.agent_skill_name,
sizeof(g_settings.agent_skill_name), "%s", val);
val = db_kv_get("agent.skill_description");
if (val) snprintf(g_settings.agent_skill_description,
sizeof(g_settings.agent_skill_description), "%s", val);
val = db_kv_get("agent.skill_template");
if (val && val[0]) {
snprintf(g_settings.agent_skill_template,
sizeof(g_settings.agent_skill_template), "%s", val);
} else if (g_settings.agent_llm_system_prompt[0]) {
/* Migrate from the legacy system_prompt field. */
snprintf(g_settings.agent_skill_template,
sizeof(g_settings.agent_skill_template), "%s",
g_settings.agent_llm_system_prompt);
}
/* Keep the legacy alias in sync (truncates to 4096 safely). */
if (g_settings.agent_skill_template[0]) {
g_strlcpy(g_settings.agent_llm_system_prompt,
g_settings.agent_skill_template,
sizeof(g_settings.agent_llm_system_prompt));
}
val = db_kv_get("agent.skill_requires_tools");
if (val) snprintf(g_settings.agent_skill_requires_tools,
sizeof(g_settings.agent_skill_requires_tools), "%s", val);
/* If no provider catalog was loaded from Nostr (agent_provider_count
* == 0), seed a single "default" provider from the resolved
* agent_llm_base_url / agent_llm_api_key so the agents config page
* has something to show. */
if (g_settings.agent_provider_count == 0) {
agent_provider_t *p = &g_settings.agent_providers[0];
memset(p, 0, sizeof(*p));
snprintf(p->name, sizeof(p->name), "default");
snprintf(p->base_url, sizeof(p->base_url), "%s",
g_settings.agent_llm_base_url);
snprintf(p->api_key, sizeof(p->api_key), "%s",
g_settings.agent_llm_api_key);
if (g_settings.agent_llm_model[0]) {
snprintf(p->models[0], sizeof(p->models[0]), "%s",
g_settings.agent_llm_model);
p->model_count = 1;
}
g_settings.agent_provider_count = 1;
g_settings.agent_active_provider = 0;
snprintf(g_settings.agent_active_provider_name,
sizeof(g_settings.agent_active_provider_name), "default");
}
}
void settings_save(void) {
void settings_load(void) {
/* Backward-compatible full load: sets defaults, then loads all
* settings from the currently-open database. Used when a single
* database contains all settings (legacy or test scenarios). */
settings_set_defaults(&g_settings);
settings_load_global();
settings_load_user();
}
void settings_save_global(void) {
/* Save global settings to global.db. This uses db_kv_set_to_file()
* which opens a separate short-lived connection, so it works even
* when the per-user browser.db is the main open database (the normal
* case after login). At startup, when global.db IS the main open
* database, this also works (the separate connection is fine since
* SQLite handles concurrent readers/writers with FULLMUTEX). */
char gpath[512];
global_db_path(gpath, sizeof(gpath));
if (gpath[0] == '\0') {
g_printerr("[settings] Failed to get global.db path for save\n");
return;
}
char buf[32];
db_kv_set_to_file(gpath, "agent_server_enabled",
g_settings.agent_server_enabled ? "true" : "false");
db_kv_set_to_file(gpath, "json_viewer_enabled",
g_settings.json_viewer_enabled ? "true" : "false");
snprintf(buf, sizeof(buf), "%d", g_settings.agent_server_port);
db_kv_set_to_file(gpath, "agent_server_port", buf);
db_kv_set_to_file(gpath, "agent_allowed_origins",
g_settings.agent_allowed_origins);
snprintf(buf, sizeof(buf), "%d", g_settings.agent_login_timeout_ms);
db_kv_set_to_file(gpath, "agent_login_timeout_ms", buf);
db_kv_set_to_file(gpath, "theme_dark",
g_settings.theme_dark ? "true" : "false");
snprintf(buf, sizeof(buf), "%d", g_settings.inspector_x);
db_kv_set_to_file(gpath, "inspector_x", buf);
snprintf(buf, sizeof(buf), "%d", g_settings.inspector_y);
db_kv_set_to_file(gpath, "inspector_y", buf);
snprintf(buf, sizeof(buf), "%d", g_settings.inspector_w);
db_kv_set_to_file(gpath, "inspector_w", buf);
snprintf(buf, sizeof(buf), "%d", g_settings.inspector_h);
db_kv_set_to_file(gpath, "inspector_h", buf);
}
void settings_save_user(void) {
/* Save per-user settings to the currently-open database (should be
* the per-user browser.db). Called when the user changes per-user
* settings on the settings page. */
char buf[32];
db_kv_set("restore_session", g_settings.restore_session ? "true" : "false");
@@ -183,30 +464,51 @@ void settings_save(void) {
db_kv_set("max_tabs", buf);
db_kv_set("tab_drag_reorder", g_settings.tab_drag_reorder ? "true" : "false");
db_kv_set("agent_server_enabled", g_settings.agent_server_enabled ? "true" : "false");
snprintf(buf, sizeof(buf), "%d", g_settings.agent_server_port);
db_kv_set("agent_server_port", buf);
db_kv_set("agent_allowed_origins", g_settings.agent_allowed_origins);
snprintf(buf, sizeof(buf), "%d", g_settings.agent_login_timeout_ms);
db_kv_set("agent_login_timeout_ms", buf);
/* Bootstrap relays are stored as-is (newlines are fine in SQLite). */
db_kv_set("bootstrap_relays", g_settings.bootstrap_relays);
db_kv_set("search_engine", g_settings.search_engine);
db_kv_set("theme_dark", g_settings.theme_dark ? "true" : "false");
db_kv_set("nostr_helper_apps", g_settings.nostr_helper_apps);
snprintf(buf, sizeof(buf), "%d", g_settings.inspector_x);
db_kv_set("inspector_x", buf);
snprintf(buf, sizeof(buf), "%d", g_settings.inspector_y);
db_kv_set("inspector_y", buf);
snprintf(buf, sizeof(buf), "%d", g_settings.inspector_w);
db_kv_set("inspector_w", buf);
snprintf(buf, sizeof(buf), "%d", g_settings.inspector_h);
db_kv_set("inspector_h", buf);
db_kv_set("tor.enabled", g_settings.tor_enabled ? "true" : "false");
db_kv_set("tor.mode", g_settings.tor_mode);
db_kv_set("tor.binary_path", g_settings.tor_binary_path);
db_kv_set("tor.attach_socks", g_settings.tor_attach_socks);
db_kv_set("tor.attach_control", g_settings.tor_attach_control);
db_kv_set("tor.data_dir", g_settings.tor_data_dir);
db_kv_set("fips.enabled", g_settings.fips_enabled ? "true" : "false");
db_kv_set("fips.mode", g_settings.fips_mode);
db_kv_set("fips.binary_path", g_settings.fips_binary_path);
db_kv_set("fips.control_socket", g_settings.fips_control_socket);
db_kv_set("fips.config_dir", g_settings.fips_config_dir);
/* Agent LLM provider settings — resolved values (active provider). */
db_kv_set("agent.llm_base_url", g_settings.agent_llm_base_url);
db_kv_set("agent.llm_api_key", g_settings.agent_llm_api_key);
db_kv_set("agent.llm_model", g_settings.agent_llm_model);
db_kv_set("agent.llm_system_prompt", g_settings.agent_skill_template);
db_kv_set("agent.provider", g_settings.agent_active_provider_name);
snprintf(buf, sizeof(buf), "%d", g_settings.agent_max_iterations);
db_kv_set("agent.max_iterations", buf);
/* Sovereign Browser Skill fields. */
db_kv_set("agent.skill_name", g_settings.agent_skill_name);
db_kv_set("agent.skill_description", g_settings.agent_skill_description);
db_kv_set("agent.skill_template", g_settings.agent_skill_template);
db_kv_set("agent.skill_requires_tools",
g_settings.agent_skill_requires_tools);
}
void settings_save(void) {
/* Backward-compatible full save: saves all settings to the
* currently-open database. The settings page should call
* settings_save_user() + settings_save_global() instead, so that
* global settings go to global.db and per-user settings go to the
* per-user browser.db. */
settings_save_user();
settings_save_global();
}
const browser_settings_t *settings_get(void) {

View File

@@ -15,7 +15,7 @@
extern "C" {
#endif
#define SETTINGS_NEW_TAB_URL_DEFAULT "https://example.com"
#define SETTINGS_NEW_TAB_URL_DEFAULT ""
#define SETTINGS_NEW_TAB_URL_MAX 512
#define SETTINGS_MAX_TABS_DEFAULT 50
#define SETTINGS_AGENT_PORT_DEFAULT 17777
@@ -28,6 +28,38 @@ extern "C" {
"wss://relay.damus.io"
#define SETTINGS_SEARCH_ENGINE_MAX 64
#define SETTINGS_SEARCH_ENGINE_DEFAULT "duckduckgo"
#define SETTINGS_NOSTR_HELPER_APPS_MAX 1024
#define SETTINGS_NOSTR_HELPER_APPS_DEFAULT \
"njump.me|https://njump.me/{entity},Snort|https://snort.social/{entity},Jumble|https://jumble.social/{entity}"
#define SETTINGS_AGENT_LLM_BASE_URL_DEFAULT "https://api.ppq.ai"
#define SETTINGS_AGENT_LLM_MODEL_DEFAULT "gpt-4o"
#define SETTINGS_AGENT_MAX_ITERATIONS_DEFAULT 100
#define SETTINGS_AGENT_SYSTEM_PROMPT_DEFAULT \
"You are an AI assistant embedded in a web browser. You have access to browser automation tools (navigate, snapshot, click, fill, etc.) and system tools (filesystem read/write, shell command execution). Use the snapshot tool to understand page content, then interact with elements using refs (e.g. @e1). You can read and write files and run shell commands. Be concise in your responses. When a task is complete, summarize what you did."
/* Sovereign Browser Skill — the default skill presented on the config
* page and chat page. The template is the system prompt; the other
* fields describe the skill for publishing as kind 31123. */
#define SETTINGS_AGENT_SKILL_NAME_DEFAULT "Sovereign Browser Default"
#define SETTINGS_AGENT_SKILL_DESCRIPTION_DEFAULT "Default agent skill for sovereign_browser"
#define SETTINGS_AGENT_SKILL_REQUIRES_TOOLS_DEFAULT "browser, fs, shell"
#define SETTINGS_AGENT_SKILL_TEMPLATE_MAX 8192
#define SETTINGS_AGENT_SKILL_NAME_MAX 128
#define SETTINGS_AGENT_SKILL_DESCRIPTION_MAX 512
#define SETTINGS_AGENT_SKILL_REQUIRES_TOOLS_MAX 256
#define SETTINGS_AGENT_MAX_PROVIDERS 16
#define SETTINGS_AGENT_MAX_MODELS 64
#define SETTINGS_AGENT_PROVIDER_NAME_MAX 64
/* A single LLM provider entry in the shared global.agent.providers catalog. */
typedef struct {
char name[SETTINGS_AGENT_PROVIDER_NAME_MAX]; /* e.g. "ppq", "ollama-local" */
char base_url[512];
char api_key[512];
char models[SETTINGS_AGENT_MAX_MODELS][128]; /* list of known model ids */
int model_count;
} agent_provider_t;
typedef struct {
gboolean restore_session; /* restore open tabs on next launch */
@@ -39,30 +71,101 @@ typedef struct {
int max_tabs; /* maximum simultaneous tabs */
gboolean tab_drag_reorder; /* allow drag to reorder tabs */
gboolean agent_server_enabled; /* enable agent WebSocket server */
gboolean json_viewer_enabled; /* enhance application/json pages */
int agent_server_port; /* port for agent server */
char agent_allowed_origins[SETTINGS_AGENT_ORIGINS_MAX]; /* "*" for any */
int agent_login_timeout_ms; /* wait for agent login before GTK dialog */
char bootstrap_relays[SETTINGS_BOOTSTRAP_RELAYS_MAX]; /* newline-separated relay URLs */
char search_engine[SETTINGS_SEARCH_ENGINE_MAX]; /* active search engine id */
char nostr_helper_apps[SETTINGS_NOSTR_HELPER_APPS_MAX]; /* comma-separated Name|URL pairs */
gboolean theme_dark; /* dark mode for sovereign:// pages */
int inspector_x; /* inspector detached window X (-1 = default) */
int inspector_y; /* inspector detached window Y (-1 = default) */
int inspector_w; /* inspector detached window width (-1 = default) */
int inspector_h; /* inspector detached window height (-1 = default) */
/* Agent LLM provider settings — "resolved" values for the active
* provider + selected model. Populated from global.agent.providers
* (base_url, api_key) and sovereign_browser.agent (model). */
char agent_llm_base_url[512]; /* e.g. "https://api.openai.com/v1" */
char agent_llm_api_key[512]; /* bearer token (may be empty for local servers) */
char agent_llm_model[128]; /* e.g. "gpt-4o", "llama3.1" */
char agent_llm_system_prompt[4096]; /* legacy alias for agent_skill_template (kept in sync) */
int agent_max_iterations; /* max tool-call loop iterations (default 100) */
/* Sovereign Browser Skill — the default skill presented on the
* config page and chat page. The template is the system prompt
* (mirrored into agent_llm_system_prompt for backward compat).
* Published as kind 31123 when the user clicks "Save as Skill". */
char agent_skill_name[SETTINGS_AGENT_SKILL_NAME_MAX];
char agent_skill_description[SETTINGS_AGENT_SKILL_DESCRIPTION_MAX];
char agent_skill_template[SETTINGS_AGENT_SKILL_TEMPLATE_MAX];
char agent_skill_requires_tools[SETTINGS_AGENT_SKILL_REQUIRES_TOOLS_MAX];
/* Multi-provider catalog (shared via global.agent in d:user-settings). */
agent_provider_t agent_providers[SETTINGS_AGENT_MAX_PROVIDERS];
int agent_provider_count;
int agent_active_provider; /* index into agent_providers, -1 if none */
char agent_active_provider_name[SETTINGS_AGENT_PROVIDER_NAME_MAX];
/* Tor-routed WebKit transport. */
gboolean tor_enabled;
char tor_mode[16]; /* auto | attach | manage */
char tor_binary_path[256];
char tor_attach_socks[256];
char tor_attach_control[256];
char tor_data_dir[512];
/* FIPS mesh daemon. */
gboolean fips_enabled;
char fips_mode[16]; /* auto | attach | manage */
char fips_binary_path[256];
char fips_control_socket[512];
char fips_config_dir[512];
} browser_settings_t;
/*
* Load settings from disk into the global singleton.
* If the file doesn't exist or a key is missing, defaults are used.
* Call once at startup.
* Load all settings from the currently-open database into the global
* singleton. Sets defaults first, then reads all keys.
* Backward-compatible — prefer settings_load_global() + settings_load_user()
* for the split-database flow.
*/
void settings_load(void);
/*
* Save the current global settings to disk.
* Load global settings (agent server, theme, inspector geometry) from
* the currently-open database (should be global.db). Does NOT reset
* defaults — call settings_set_defaults() first (or use the full
* settings_load() if you need defaults reset).
*/
void settings_load_global(void);
/*
* Load per-user settings (tabs, relays, search engine) from the
* currently-open database (should be the per-user browser.db). Does NOT
* reset defaults — only reads per-user keys, leaving global keys in the
* struct untouched.
*/
void settings_load_user(void);
/*
* Save all settings to the currently-open database.
* Backward-compatible — prefer settings_save_global() + settings_save_user()
* for the split-database flow.
*/
void settings_save(void);
/*
* Save global settings to the currently-open database (should be
* global.db). Call when global settings change.
*/
void settings_save_global(void);
/*
* Save per-user settings to the currently-open database (should be the
* per-user browser.db). Call when per-user settings change.
*/
void settings_save_user(void);
/*
* Get a pointer to the global settings singleton.
* Always valid after settings_load().

View File

@@ -2,15 +2,45 @@
* settings_sync.c — NIP-78 (kind 30078) settings sync for sovereign_browser
*
* Syncs whitelisted, device-independent settings + keyboard shortcut
* bindings across all devices the user logs in on. Uses a single kind
* 30078 addressable event with d-tag "sovereign_browser". The content is
* NIP-44 encrypted (self-to-self) JSON.
* bindings across all devices the user logs in on. Uses a single shared
* kind 30078 addressable event with d-tag "user-settings" (the same event
* used by ~/lt/client and ~/lt/didactyl). The content is NIP-44 encrypted
* (self-to-self) JSON with a "global" namespace (shared agent provider
* catalog under global.agent) and per-app namespaces (sovereign_browser,
* client, didactyl, ...).
*
* sovereign_browser writes only the "sovereign_browser" and "global.agent"
* namespaces. Other apps' namespaces are preserved via read-modify-write:
* on publish, the current d:user-settings event is fetched from the SQLite
* cache (or relays), decrypted, the sovereign_browser + global.agent
* namespaces are patched, and the result is re-encrypted and published.
*
* The publish path reuses the same pattern as bookmarks.c's
* publish_directory(): serialize → NIP-44 encrypt → sign → store in
* SQLite → publish to bootstrap relays.
*
* settings_sync_publish() is debounced (500ms) so rapid edits coalesce.
*
* Schema (v2):
* {
* "v": 2,
* "updatedAt": <ts>,
* "global": {
* "agent": {
* "providers": [ {name, base_url, api_key, models}, ... ],
* "favorites": [ "model-id", ... ]
* },
* ... (zaps, ui, relays, ... — preserved, not touched by us)
* },
* "sovereign_browser": {
* "agent": { provider, model, system_prompt, max_iterations },
* "new_tab_url": "", "tab_bar_position": 0, "max_tabs": 50,
* "bootstrap_relays": "...", "search_engine": "duckduckgo",
* "theme_dark": false, "shortcuts": { ... }
* },
* "client": { ... }, // preserved
* "didactyl": { ... } // preserved
* }
*/
#include "settings_sync.h"
@@ -38,11 +68,12 @@ static guint g_publish_timeout_id = 0;
/* db_kv key for the last-synced timestamp. */
#define SYNC_TS_KEY "settings_sync.nostr_synced_at"
/* ── Whitelist of syncable settings ─────────────────────────────────── */
/* ── Whitelist of syncable browser settings ─────────────────────────── */
/* Keys from the settings struct that should be synced. Device-specific
* settings (agent port, allowed origins, session restore, security
* toggles) are intentionally excluded. */
/* Keys from the settings struct that should be synced under the
* sovereign_browser namespace. Device-specific settings (agent port,
* allowed origins, session restore, security toggles) are intentionally
* excluded. */
static const char *g_sync_setting_keys[] = {
"new_tab_url",
"tab_bar_position",
@@ -111,33 +142,197 @@ static char *decrypt_content(const char *ciphertext) {
return plaintext;
}
/* ── Serialization ──────────────────────────────────────────────────── */
/* Check whether a kind 30078 event has a given d-tag value.
* Returns 1 if matched, 0 otherwise. */
static int event_has_d_tag(const cJSON *event, const char *d_value) {
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!cJSON_IsArray(tags)) return 0;
cJSON *tag;
cJSON_ArrayForEach(tag, tags) {
if (!cJSON_IsArray(tag)) continue;
cJSON *t0 = cJSON_GetArrayItem(tag, 0);
cJSON *t1 = cJSON_GetArrayItem(tag, 1);
if (cJSON_IsString(t0) && strcmp(t0->valuestring, "d") == 0 &&
cJSON_IsString(t1) && strcmp(t1->valuestring, d_value) == 0) {
return 1;
}
}
return 0;
}
/* Serialize all syncable settings + shortcuts to a JSON object:
* {"settings":{...}, "shortcuts":{...}}
/* ── Serialization: build sovereign_browser namespace ───────────────── */
/* Build the "sovereign_browser" namespace object from current settings.
* Returns a newly allocated cJSON object (caller must delete). */
static cJSON *serialize_payload(void) {
cJSON *root = cJSON_CreateObject();
/* Settings whitelist. */
cJSON *settings_obj = cJSON_CreateObject();
const browser_settings_t *s = settings_get();
(void)s; /* read via db_kv_get to get string values uniformly */
static cJSON *build_sovereign_browser_namespace(void) {
cJSON *sb = cJSON_CreateObject();
/* Browser settings whitelist (read via db_kv_get for uniform string
* values — same approach as the original serialize_payload). */
for (int i = 0; i < g_sync_setting_count; i++) {
const char *key = g_sync_setting_keys[i];
const char *val = db_kv_get(key);
if (val) {
cJSON_AddStringToObject(settings_obj, key, val);
cJSON_AddStringToObject(sb, key, val);
}
}
cJSON_AddItemToObject(root, "settings", settings_obj);
/* Per-app agent config: provider, model, system_prompt (legacy),
* max_iterations, and the Sovereign Browser Skill fields. These are
* stored in db_kv under the agent.* keys by handle_agents_set. */
cJSON *agent = cJSON_CreateObject();
const char *provider = db_kv_get("agent.provider");
const char *model = db_kv_get("agent.llm_model");
const char *prompt = db_kv_get("agent.llm_system_prompt");
const char *maxit = db_kv_get("agent.max_iterations");
const char *sk_name = db_kv_get("agent.skill_name");
const char *sk_desc = db_kv_get("agent.skill_description");
const char *sk_tmpl = db_kv_get("agent.skill_template");
const char *sk_tools = db_kv_get("agent.skill_requires_tools");
cJSON_AddStringToObject(agent, "provider",
provider ? provider : "");
cJSON_AddStringToObject(agent, "model",
model ? model : "");
cJSON_AddStringToObject(agent, "system_prompt",
prompt ? prompt : "");
cJSON_AddNumberToObject(agent, "max_iterations",
maxit ? (double)atol(maxit)
: (double)SETTINGS_AGENT_MAX_ITERATIONS_DEFAULT);
cJSON_AddStringToObject(agent, "skill_name",
sk_name ? sk_name : "");
cJSON_AddStringToObject(agent, "skill_description",
sk_desc ? sk_desc : "");
cJSON_AddStringToObject(agent, "skill_template",
sk_tmpl ? sk_tmpl : "");
cJSON_AddStringToObject(agent, "skill_requires_tools",
sk_tools ? sk_tools : "");
cJSON_AddItemToObject(sb, "agent", agent);
/* Shortcuts. */
cJSON *shortcuts_obj = shortcuts_serialize();
cJSON_AddItemToObject(root, "shortcuts", shortcuts_obj);
cJSON_AddItemToObject(sb, "shortcuts", shortcuts_obj);
return root;
return sb;
}
/* Build the "global.agent" namespace object from the in-memory provider
* catalog. Returns a newly allocated cJSON object (caller must delete). */
static cJSON *build_global_agent_namespace(void) {
cJSON *agent = cJSON_CreateObject();
const browser_settings_t *s = settings_get();
/* providers array */
cJSON *providers = cJSON_CreateArray();
for (int i = 0; i < s->agent_provider_count; i++) {
const agent_provider_t *p = &s->agent_providers[i];
cJSON *pobj = cJSON_CreateObject();
cJSON_AddStringToObject(pobj, "name", p->name);
cJSON_AddStringToObject(pobj, "base_url", p->base_url);
cJSON_AddStringToObject(pobj, "api_key", p->api_key);
cJSON *models = cJSON_CreateArray();
for (int m = 0; m < p->model_count; m++) {
cJSON_AddItemToArray(models, cJSON_CreateString(p->models[m]));
}
cJSON_AddItemToObject(pobj, "models", models);
cJSON_AddItemToArray(providers, pobj);
}
cJSON_AddItemToObject(agent, "providers", providers);
/* favorites — for now, just the currently selected model. */
cJSON *favorites = cJSON_CreateArray();
if (s->agent_llm_model[0]) {
cJSON_AddItemToArray(favorites, cJSON_CreateString(s->agent_llm_model));
}
cJSON_AddItemToObject(agent, "favorites", favorites);
return agent;
}
/* ── Read-modify-write: fetch current event, patch, return payload ──── */
/* Fetch the current d:user-settings event from the SQLite cache.
* Returns a newly allocated cJSON event (caller must delete), or NULL. */
static cJSON *fetch_current_user_settings_event(void) {
if (g_pubkey[0] == '\0') return NULL;
/* db_get_latest_event returns the newest event of a given kind for
* the pubkey. We then verify the d-tag is "user-settings". */
cJSON *events = db_get_events(g_pubkey, SETTINGS_SYNC_KIND, 32);
if (events == NULL) return NULL;
int n = cJSON_GetArraySize(events);
cJSON *found = NULL;
for (int i = 0; i < n; i++) {
cJSON *ev = cJSON_GetArrayItem(events, i);
if (event_has_d_tag(ev, SETTINGS_SYNC_D_TAG)) {
found = cJSON_Duplicate(ev, 1);
break;
}
}
cJSON_Delete(events);
return found;
}
/* Decrypt the content of an event and parse it as a JSON object.
* Returns a newly allocated cJSON object (caller must delete), or NULL. */
static cJSON *decrypt_event_payload(const cJSON *event) {
cJSON *content = cJSON_GetObjectItemCaseSensitive(event, "content");
if (!cJSON_IsString(content)) return NULL;
char *plaintext = decrypt_content(content->valuestring);
if (plaintext == NULL) return NULL;
cJSON *payload = cJSON_Parse(plaintext);
free(plaintext);
return payload;
}
/* Build the full payload to publish, using read-modify-write:
* 1. Fetch the current d:user-settings event from SQLite cache.
* 2. Decrypt + parse it (or start a fresh v2 object if none).
* 3. Patch the "sovereign_browser" and "global.agent" namespaces.
* 4. Return the patched payload (caller must delete).
*
* Other namespaces (client, didactyl, global.zaps, global.ui, ...) are
* preserved untouched. */
static cJSON *build_publish_payload(void) {
cJSON *payload = NULL;
cJSON *current = fetch_current_user_settings_event();
if (current != NULL) {
payload = decrypt_event_payload(current);
cJSON_Delete(current);
}
if (payload == NULL || !cJSON_IsObject(payload)) {
/* No existing event (or decrypt failed) — start fresh. */
if (payload) cJSON_Delete(payload);
payload = cJSON_CreateObject();
cJSON_AddNumberToObject(payload, "v", 2);
}
/* Ensure "global" object exists. */
cJSON *global = cJSON_GetObjectItemCaseSensitive(payload, "global");
if (!cJSON_IsObject(global)) {
global = cJSON_CreateObject();
cJSON_AddItemToObject(payload, "global", global);
}
/* Patch global.agent (replace entirely with our view). */
cJSON *old_agent = cJSON_GetObjectItemCaseSensitive(global, "agent");
if (old_agent) cJSON_DeleteItemFromObjectCaseSensitive(global, "agent");
cJSON *new_agent = build_global_agent_namespace();
cJSON_AddItemToObject(global, "agent", new_agent);
/* Patch sovereign_browser namespace (replace entirely). */
cJSON *old_sb = cJSON_GetObjectItemCaseSensitive(payload, "sovereign_browser");
if (old_sb) cJSON_DeleteItemFromObjectCaseSensitive(payload, "sovereign_browser");
cJSON *new_sb = build_sovereign_browser_namespace();
cJSON_AddItemToObject(payload, "sovereign_browser", new_sb);
/* Update top-level updatedAt. */
cJSON *ts = cJSON_GetObjectItemCaseSensitive(payload, "updatedAt");
if (ts) cJSON_DeleteItemFromObjectCaseSensitive(payload, "updatedAt");
cJSON_AddNumberToObject(payload, "updatedAt", (double)time(NULL));
return payload;
}
/* ── Publish (debounced) ────────────────────────────────────────────── */
@@ -146,8 +341,13 @@ static cJSON *serialize_payload(void) {
static void do_publish(void) {
if (!g_have_signer) return;
/* Serialize. */
cJSON *payload = serialize_payload();
/* Build the payload via read-modify-write. */
cJSON *payload = build_publish_payload();
if (payload == NULL) {
g_printerr("[settings_sync] Failed to build payload\n");
return;
}
char *json = cJSON_PrintUnformatted(payload);
cJSON_Delete(payload);
if (json == NULL) {
@@ -160,7 +360,7 @@ static void do_publish(void) {
free(json);
if (ciphertext == NULL) return;
/* Build tags: [["d", "sovereign_browser"], ["client", "sovereign_browser"]] */
/* Build tags: [["d", "user-settings"], ["client", "sovereign_browser"]] */
cJSON *tags = cJSON_CreateArray();
cJSON *d_tag = cJSON_CreateArray();
@@ -203,8 +403,9 @@ static void do_publish(void) {
relay_urls, relay_count, event, &success_count,
15, NULL, NULL, 0, NULL);
if (results) free(results);
g_print("[settings_sync] Published kind %d to %d/%d relays\n",
SETTINGS_SYNC_KIND, success_count, relay_count);
g_print("[settings_sync] Published kind %d (d:%s) to %d/%d relays\n",
SETTINGS_SYNC_KIND, SETTINGS_SYNC_D_TAG,
success_count, relay_count);
} else {
g_print("[settings_sync] No relays configured, event stored locally\n");
}
@@ -221,6 +422,267 @@ static gboolean publish_timeout_cb(gpointer data) {
return G_SOURCE_REMOVE;
}
/* ── Merge helpers ──────────────────────────────────────────────────── */
/* Merge the "sovereign_browser" namespace from a decrypted payload into
* local db_kv + in-memory settings. */
static void merge_sovereign_browser_namespace(const cJSON *sb) {
if (!cJSON_IsObject(sb)) return;
/* Browser settings whitelist. */
cJSON *item;
cJSON_ArrayForEach(item, sb) {
if (!cJSON_IsString(item)) continue;
for (int i = 0; i < g_sync_setting_count; i++) {
if (strcmp(item->string, g_sync_setting_keys[i]) == 0) {
db_kv_set(item->string, item->valuestring);
break;
}
}
}
/* Per-app agent config. Only overwrite local values from Nostr if
* the local value is empty — this prevents a corrupted Nostr event
* from overwriting good local data. The local DB is the source of
* truth for per-app settings; Nostr is only used to bootstrap new
* devices. */
cJSON *agent = cJSON_GetObjectItemCaseSensitive(sb, "agent");
if (cJSON_IsObject(agent)) {
cJSON *j;
const char *existing;
j = cJSON_GetObjectItemCaseSensitive(agent, "provider");
if (cJSON_IsString(j) && j->valuestring[0]) {
existing = db_kv_get("agent.provider");
if (!existing || !existing[0])
db_kv_set("agent.provider", j->valuestring);
}
j = cJSON_GetObjectItemCaseSensitive(agent, "model");
if (cJSON_IsString(j) && j->valuestring[0]) {
existing = db_kv_get("agent.llm_model");
if (!existing || !existing[0])
db_kv_set("agent.llm_model", j->valuestring);
}
j = cJSON_GetObjectItemCaseSensitive(agent, "system_prompt");
if (cJSON_IsString(j) && j->valuestring[0]) {
existing = db_kv_get("agent.llm_system_prompt");
if (!existing || !existing[0])
db_kv_set("agent.llm_system_prompt", j->valuestring);
}
j = cJSON_GetObjectItemCaseSensitive(agent, "max_iterations");
if (cJSON_IsNumber(j) && (int)j->valuedouble > 0) {
existing = db_kv_get("agent.max_iterations");
if (!existing || !existing[0] || atoi(existing) <= 0) {
char buf[16];
snprintf(buf, sizeof(buf), "%d", (int)j->valuedouble);
db_kv_set("agent.max_iterations", buf);
}
}
j = cJSON_GetObjectItemCaseSensitive(agent, "skill_name");
if (cJSON_IsString(j) && j->valuestring[0]) {
existing = db_kv_get("agent.skill_name");
if (!existing || !existing[0])
db_kv_set("agent.skill_name", j->valuestring);
}
j = cJSON_GetObjectItemCaseSensitive(agent, "skill_description");
if (cJSON_IsString(j) && j->valuestring[0]) {
existing = db_kv_get("agent.skill_description");
if (!existing || !existing[0])
db_kv_set("agent.skill_description", j->valuestring);
}
j = cJSON_GetObjectItemCaseSensitive(agent, "skill_template");
if (cJSON_IsString(j) && j->valuestring[0]) {
existing = db_kv_get("agent.skill_template");
if (!existing || !existing[0])
db_kv_set("agent.skill_template", j->valuestring);
}
j = cJSON_GetObjectItemCaseSensitive(agent, "skill_requires_tools");
if (cJSON_IsString(j) && j->valuestring[0]) {
existing = db_kv_get("agent.skill_requires_tools");
if (!existing || !existing[0])
db_kv_set("agent.skill_requires_tools", j->valuestring);
}
}
/* Shortcuts. */
cJSON *shortcuts_obj = cJSON_GetObjectItemCaseSensitive(sb, "shortcuts");
if (cJSON_IsObject(shortcuts_obj)) {
shortcuts_merge_from_json(shortcuts_obj);
}
/* Reload settings from db_kv into the in-memory singleton. */
settings_load();
}
/* Merge the "global.agent" namespace from a decrypted payload into the
* in-memory provider catalog + resolved agent_llm_* fields. */
static void merge_global_agent_namespace(const cJSON *agent_obj) {
if (!cJSON_IsObject(agent_obj)) return;
browser_settings_t *s = settings_get_mutable();
/* providers array — only merge from Nostr if the local provider
* catalog is empty. This prevents a corrupted Nostr event from
* overwriting good local provider data. The local DB is the source
* of truth; Nostr is only used to bootstrap new devices. */
cJSON *providers = cJSON_GetObjectItemCaseSensitive(agent_obj, "providers");
if (cJSON_IsArray(providers) && s->agent_provider_count == 0) {
s->agent_provider_count = 0;
int n = cJSON_GetArraySize(providers);
if (n > SETTINGS_AGENT_MAX_PROVIDERS) n = SETTINGS_AGENT_MAX_PROVIDERS;
for (int i = 0; i < n; i++) {
cJSON *p = cJSON_GetArrayItem(providers, i);
if (!cJSON_IsObject(p)) continue;
agent_provider_t *dst = &s->agent_providers[i];
memset(dst, 0, sizeof(*dst));
cJSON *j;
j = cJSON_GetObjectItemCaseSensitive(p, "name");
if (cJSON_IsString(j)) {
snprintf(dst->name, sizeof(dst->name), "%s", j->valuestring);
}
j = cJSON_GetObjectItemCaseSensitive(p, "base_url");
if (cJSON_IsString(j)) {
snprintf(dst->base_url, sizeof(dst->base_url), "%s",
j->valuestring);
}
j = cJSON_GetObjectItemCaseSensitive(p, "api_key");
if (cJSON_IsString(j)) {
snprintf(dst->api_key, sizeof(dst->api_key), "%s",
j->valuestring);
}
cJSON *models = cJSON_GetObjectItemCaseSensitive(p, "models");
if (cJSON_IsArray(models)) {
int mn = cJSON_GetArraySize(models);
if (mn > SETTINGS_AGENT_MAX_MODELS) mn = SETTINGS_AGENT_MAX_MODELS;
for (int m = 0; m < mn; m++) {
cJSON *mj = cJSON_GetArrayItem(models, m);
if (cJSON_IsString(mj)) {
snprintf(dst->models[m], sizeof(dst->models[m]),
"%s", mj->valuestring);
dst->model_count++;
}
}
}
s->agent_provider_count++;
}
}
/* Resolve the active provider from agent.provider (in db_kv or the
* sovereign_browser namespace) and populate the resolved
* agent_llm_base_url / agent_llm_api_key fields. */
const char *provider_name = db_kv_get("agent.provider");
s->agent_active_provider = -1;
s->agent_active_provider_name[0] = '\0';
if (provider_name && provider_name[0]) {
for (int i = 0; i < s->agent_provider_count; i++) {
if (strcmp(s->agent_providers[i].name, provider_name) == 0) {
s->agent_active_provider = i;
snprintf(s->agent_active_provider_name,
sizeof(s->agent_active_provider_name), "%s",
provider_name);
snprintf(s->agent_llm_base_url, sizeof(s->agent_llm_base_url),
"%s", s->agent_providers[i].base_url);
snprintf(s->agent_llm_api_key, sizeof(s->agent_llm_api_key),
"%s", s->agent_providers[i].api_key);
break;
}
}
}
/* If no active provider was matched but we have providers, fall back
* to the first provider so the resolved base_url/api_key are usable. */
if (s->agent_active_provider < 0 && s->agent_provider_count > 0) {
s->agent_active_provider = 0;
snprintf(s->agent_active_provider_name,
sizeof(s->agent_active_provider_name), "%s",
s->agent_providers[0].name);
snprintf(s->agent_llm_base_url, sizeof(s->agent_llm_base_url),
"%s", s->agent_providers[0].base_url);
snprintf(s->agent_llm_api_key, sizeof(s->agent_llm_api_key),
"%s", s->agent_providers[0].api_key);
}
/* The model comes from the per-app sovereign_browser.agent.model
* (already in db_kv via merge_sovereign_browser_namespace, or from
* the local settings). settings_load() will pick it up. */
}
/* Migrate the old d:sovereign_browser event format (top-level "settings"
* and "shortcuts" keys, no "sovereign_browser" namespace) into the new
* v2 schema. Returns a newly allocated cJSON payload (caller must delete),
* or NULL if the payload is not the old format. */
static cJSON *migrate_legacy_payload(const cJSON *payload) {
if (payload == NULL || !cJSON_IsObject(payload)) return NULL;
/* The old format has a top-level "settings" object and no
* "sovereign_browser" namespace. */
cJSON *old_settings = cJSON_GetObjectItemCaseSensitive(payload, "settings");
cJSON *new_sb = cJSON_GetObjectItemCaseSensitive(payload, "sovereign_browser");
if (!cJSON_IsObject(old_settings) || cJSON_IsObject(new_sb)) {
return NULL; /* not the old format */
}
/* Build a new v2 payload. */
cJSON *v2 = cJSON_CreateObject();
cJSON_AddNumberToObject(v2, "v", 2);
cJSON_AddNumberToObject(v2, "updatedAt", (double)time(NULL));
/* global.agent — seed from the old agent.* keys if present. */
cJSON *global = cJSON_CreateObject();
cJSON *agent = cJSON_CreateObject();
cJSON *providers = cJSON_CreateArray();
/* Build a single provider from the old agent settings. */
const browser_settings_t *s = settings_get();
cJSON *pobj = cJSON_CreateObject();
cJSON_AddStringToObject(pobj, "name", "default");
cJSON_AddStringToObject(pobj, "base_url", s->agent_llm_base_url);
cJSON_AddStringToObject(pobj, "api_key", s->agent_llm_api_key);
cJSON *models = cJSON_CreateArray();
if (s->agent_llm_model[0]) {
cJSON_AddItemToArray(models, cJSON_CreateString(s->agent_llm_model));
}
cJSON_AddItemToObject(pobj, "models", models);
cJSON_AddItemToArray(providers, pobj);
cJSON_AddItemToObject(agent, "providers", providers);
cJSON *favorites = cJSON_CreateArray();
if (s->agent_llm_model[0]) {
cJSON_AddItemToArray(favorites, cJSON_CreateString(s->agent_llm_model));
}
cJSON_AddItemToObject(agent, "favorites", favorites);
cJSON_AddItemToObject(global, "agent", agent);
cJSON_AddItemToObject(v2, "global", global);
/* sovereign_browser namespace — copy old settings + shortcuts. */
cJSON *sb_ns = cJSON_Duplicate(old_settings, 1);
cJSON *old_shortcuts = cJSON_GetObjectItemCaseSensitive(payload, "shortcuts");
if (cJSON_IsObject(old_shortcuts)) {
cJSON *sc_copy = cJSON_Duplicate(old_shortcuts, 1);
cJSON_AddItemToObject(sb_ns, "shortcuts", sc_copy);
}
/* Add per-app agent config from the old resolved fields. */
cJSON *sb_agent = cJSON_CreateObject();
cJSON_AddStringToObject(sb_agent, "provider", "default");
cJSON_AddStringToObject(sb_agent, "model", s->agent_llm_model);
cJSON_AddStringToObject(sb_agent, "system_prompt", s->agent_llm_system_prompt);
cJSON_AddNumberToObject(sb_agent, "max_iterations",
(double)s->agent_max_iterations);
cJSON_AddStringToObject(sb_agent, "skill_name", s->agent_skill_name);
cJSON_AddStringToObject(sb_agent, "skill_description",
s->agent_skill_description);
cJSON_AddStringToObject(sb_agent, "skill_template", s->agent_skill_template);
cJSON_AddStringToObject(sb_agent, "skill_requires_tools",
s->agent_skill_requires_tools);
cJSON_AddItemToObject(sb_ns, "agent", sb_agent);
cJSON_AddItemToObject(v2, "sovereign_browser", sb_ns);
g_print("[settings_sync] Migrated legacy d:%s payload to v2 schema\n",
SETTINGS_SYNC_D_TAG_LEGACY);
return v2;
}
/* ── Public API ─────────────────────────────────────────────────────── */
void settings_sync_init(nostr_signer_t *signer, const char *pubkey_hex) {
@@ -258,37 +720,29 @@ int settings_sync_merge_from_nostr(const void *event_cjson) {
return -1;
}
/* Verify the d-tag is "sovereign_browser". */
cJSON *tags = cJSON_GetObjectItemCaseSensitive(event, "tags");
if (!cJSON_IsArray(tags)) return -1;
int is_ours = 0;
cJSON *tag;
cJSON_ArrayForEach(tag, tags) {
if (!cJSON_IsArray(tag)) continue;
cJSON *t0 = cJSON_GetArrayItem(tag, 0);
cJSON *t1 = cJSON_GetArrayItem(tag, 1);
if (cJSON_IsString(t0) && strcmp(t0->valuestring, "d") == 0 &&
cJSON_IsString(t1) &&
strcmp(t1->valuestring, SETTINGS_SYNC_D_TAG) == 0) {
is_ours = 1;
break;
}
}
if (!is_ours) return -1;
/* Verify the d-tag is "user-settings" (new) or "sovereign_browser"
* (legacy — will be migrated). */
int is_new = event_has_d_tag(event, SETTINGS_SYNC_D_TAG);
int is_legacy = event_has_d_tag(event, SETTINGS_SYNC_D_TAG_LEGACY);
if (!is_new && !is_legacy) return -1;
/* Get the event's created_at. */
cJSON *created_at = cJSON_GetObjectItemCaseSensitive(event, "created_at");
if (!cJSON_IsNumber(created_at)) return -1;
long event_ts = (long)created_at->valuedouble;
/* Compare to our last-synced timestamp. */
/* Compare to our last-synced timestamp. We only skip the merge if the
* Nostr event is STRICTLY OLDER than our last sync — this prevents
* rolling back to stale data. If the timestamps are equal, we still
* merge because the local DB might be missing fields that are in the
* Nostr event (e.g., the API key was saved from another device and
* the local DB was created from a partial sync with the same timestamp). */
const char *ts_str = db_kv_get(SYNC_TS_KEY);
long local_ts = 0;
if (ts_str) local_ts = atol(ts_str);
if (event_ts <= local_ts) {
g_print("[settings_sync] Nostr event (%ld) not newer than local (%ld), "
if (event_ts < local_ts) {
g_print("[settings_sync] Nostr event (%ld) older than local (%ld), "
"skipping merge\n", event_ts, local_ts);
return 0;
}
@@ -309,28 +763,51 @@ int settings_sync_merge_from_nostr(const void *event_cjson) {
return -1;
}
/* Merge settings. */
cJSON *settings_obj = cJSON_GetObjectItemCaseSensitive(payload, "settings");
if (cJSON_IsObject(settings_obj)) {
cJSON *item;
cJSON_ArrayForEach(item, settings_obj) {
if (!cJSON_IsString(item)) continue;
/* Only merge whitelisted keys. */
for (int i = 0; i < g_sync_setting_count; i++) {
if (strcmp(item->string, g_sync_setting_keys[i]) == 0) {
db_kv_set(item->string, item->valuestring);
break;
}
}
/* If this is a legacy event, migrate the payload to v2. */
if (is_legacy) {
cJSON *migrated = migrate_legacy_payload(payload);
if (migrated) {
cJSON_Delete(payload);
payload = migrated;
}
/* Reload settings from db_kv into the in-memory singleton. */
settings_load();
}
/* Merge shortcuts. */
cJSON *shortcuts_obj = cJSON_GetObjectItemCaseSensitive(payload, "shortcuts");
if (cJSON_IsObject(shortcuts_obj)) {
shortcuts_merge_from_json(shortcuts_obj);
/* Merge global.agent (provider catalog). */
cJSON *global = cJSON_GetObjectItemCaseSensitive(payload, "global");
if (cJSON_IsObject(global)) {
cJSON *agent_obj = cJSON_GetObjectItemCaseSensitive(global, "agent");
if (cJSON_IsObject(agent_obj)) {
merge_global_agent_namespace(agent_obj);
}
}
/* Merge sovereign_browser namespace. */
cJSON *sb = cJSON_GetObjectItemCaseSensitive(payload, "sovereign_browser");
if (cJSON_IsObject(sb)) {
merge_sovereign_browser_namespace(sb);
} else if (is_legacy) {
/* Legacy payload that wasn't migrated (migrate_legacy_payload
* returned NULL because it didn't have the old "settings" key).
* Fall back to merging top-level "settings" + "shortcuts" as the
* old code did. */
cJSON *old_settings = cJSON_GetObjectItemCaseSensitive(payload, "settings");
if (cJSON_IsObject(old_settings)) {
cJSON *item;
cJSON_ArrayForEach(item, old_settings) {
if (!cJSON_IsString(item)) continue;
for (int i = 0; i < g_sync_setting_count; i++) {
if (strcmp(item->string, g_sync_setting_keys[i]) == 0) {
db_kv_set(item->string, item->valuestring);
break;
}
}
}
settings_load();
}
cJSON *old_shortcuts = cJSON_GetObjectItemCaseSensitive(payload, "shortcuts");
if (cJSON_IsObject(old_shortcuts)) {
shortcuts_merge_from_json(old_shortcuts);
}
}
/* Update the sync timestamp. */
@@ -338,7 +815,8 @@ int settings_sync_merge_from_nostr(const void *event_cjson) {
snprintf(ts_buf, sizeof(ts_buf), "%ld", event_ts);
db_kv_set(SYNC_TS_KEY, ts_buf);
g_print("[settings_sync] Merged settings from Nostr event (ts=%ld)\n",
g_print("[settings_sync] Merged settings from Nostr event (d:%s, ts=%ld)\n",
is_legacy ? SETTINGS_SYNC_D_TAG_LEGACY : SETTINGS_SYNC_D_TAG,
event_ts);
cJSON_Delete(payload);

View File

@@ -2,14 +2,19 @@
* settings_sync.h — NIP-78 (kind 30078) settings sync for sovereign_browser
*
* Syncs user-configurable, device-independent settings across all devices
* the user logs in on. Uses a single kind 30078 addressable event with
* d-tag "sovereign_browser". The content is NIP-44 encrypted (self-to-self)
* JSON containing whitelisted settings + all keyboard shortcut bindings.
* the user logs in on. Uses a single shared kind 30078 addressable event
* with d-tag "user-settings" (the same event used by ~/lt/client and
* ~/lt/didactyl). The content is NIP-44 encrypted (self-to-self) JSON
* with a "global" namespace (shared agent provider catalog) and per-app
* namespaces (e.g. "sovereign_browser", "client", "didactyl").
*
* sovereign_browser writes only the "sovereign_browser" and "global.agent"
* namespaces; other apps' namespaces are preserved via read-modify-write.
*
* Device-specific settings (agent port, allowed origins, session restore,
* security toggles) are NOT synced — they stay local only.
*
* See plans/keyboard-shortcuts.md for the full design.
* See plans/cross-project-agent-sync.md for the full design.
*/
#ifndef SETTINGS_SYNC_H
@@ -24,8 +29,12 @@
extern "C" {
#endif
/* The d-tag identifier used for our kind 30078 event. */
#define SETTINGS_SYNC_D_TAG "sovereign_browser"
/* The d-tag identifier used for the shared kind 30078 user-settings event.
* This is shared with ~/lt/client and ~/lt/didactyl. */
#define SETTINGS_SYNC_D_TAG "user-settings"
/* The legacy d-tag used before the migration to the shared event. */
#define SETTINGS_SYNC_D_TAG_LEGACY "sovereign_browser"
/* The Nostr kind for arbitrary custom app data (NIP-78). */
#define SETTINGS_SYNC_KIND 30078
@@ -59,7 +68,8 @@ void settings_sync_publish(void);
* overwrites local db_kv values and reloads in-memory bindings.
*
* event — a cJSON object representing a kind 30078 event with
* d-tag "sovereign_browser"
* d-tag "user-settings" (or the legacy "sovereign_browser"
* d-tag, which is migrated to the new format on merge).
*
* Returns 0 on success, -1 on error / not applicable.
*/

View File

@@ -26,6 +26,10 @@ static const shortcut_meta_t g_registry[SHORTCUT_COUNT] = {
"new_tab", "New tab",
"Open a new tab", "<Control>t"
},
[SHORTCUT_NEW_WINDOW] = {
"new_window", "New window",
"Open a new browser window", "<Control>n"
},
[SHORTCUT_CLOSE_TAB] = {
"close_tab", "Close tab",
"Close the active tab", "<Control>w"
@@ -74,6 +78,11 @@ static const shortcut_meta_t g_registry[SHORTCUT_COUNT] = {
"open_settings", "Open settings",
"Open the sovereign://settings page", "<Control>comma"
},
[SHORTCUT_OPEN_PROCESSES] = {
"open_processes", "Open processes",
"Open the sovereign://processes diagnostics page",
"<Control><Shift>Escape"
},
[SHORTCUT_NEW_IDENTITY] = {
"new_identity", "Switch identity",
"Open the Nostr login dialog to switch identity",
@@ -87,6 +96,22 @@ static const shortcut_meta_t g_registry[SHORTCUT_COUNT] = {
"toggle_inspector", "Toggle inspector",
"Show or hide the WebKit Web Inspector", "<Control><Shift>i"
},
[SHORTCUT_TOGGLE_SIDEBAR] = {
"toggle_sidebar", "Toggle agent sidebar",
"Show or hide the agent chat sidebar", "<Control><Shift>a"
},
[SHORTCUT_ZOOM_IN] = {
"zoom_in", "Zoom in",
"Increase the zoom level of the active tab", "<Control>plus"
},
[SHORTCUT_ZOOM_OUT] = {
"zoom_out", "Zoom out",
"Decrease the zoom level of the active tab", "<Control>minus"
},
[SHORTCUT_ZOOM_RESET] = {
"zoom_reset", "Reset zoom",
"Reset the zoom level of the active tab to 100%", "<Control>0"
},
};
/* ── In-memory parsed bindings ──────────────────────────────────────── */

View File

@@ -28,6 +28,7 @@ extern "C" {
typedef enum {
SHORTCUT_NEW_TAB = 0,
SHORTCUT_NEW_WINDOW,
SHORTCUT_CLOSE_TAB,
SHORTCUT_FOCUS_URL,
SHORTCUT_NEXT_TAB,
@@ -40,9 +41,14 @@ typedef enum {
SHORTCUT_GO_FORWARD,
SHORTCUT_FIND,
SHORTCUT_OPEN_SETTINGS,
SHORTCUT_OPEN_PROCESSES,
SHORTCUT_NEW_IDENTITY,
SHORTCUT_TOGGLE_FULLSCREEN,
SHORTCUT_TOGGLE_INSPECTOR,
SHORTCUT_TOGGLE_SIDEBAR,
SHORTCUT_ZOOM_IN,
SHORTCUT_ZOOM_OUT,
SHORTCUT_ZOOM_RESET,
SHORTCUT_COUNT
} shortcut_action_t;

File diff suppressed because it is too large Load Diff

View File

@@ -28,6 +28,9 @@ typedef struct {
GtkWidget *tab_label; /* composite label widget for the tab strip */
GtkWidget *title_label; /* child of tab_label */
GtkWidget *favicon; /* favicon image in tab_label */
GtkWidget *refresh_btn; /* toolbar reload/stop button (per-tab) */
GtkWidget *bookmark_bar; /* bookmarks toolbar (below URL toolbar) */
gboolean refresh_shows_stop; /* cached visual state, avoids redundant updates */
char current_url[TAB_URL_MAX];
char title[TAB_TITLE_MAX];
} tab_info_t;
@@ -44,6 +47,18 @@ void tab_manager_init(GtkContainer *parent,
WebKitWebContext *ctx,
GtkWindow *window);
/*
* Update the WebKitWebContext that tab_manager_new_tab() and
* sidebar_create_webview() use to create new webviews. Used during an
* identity switch: the old context is torn down (after all tabs are
* closed) and a fresh per-user context is built; this function updates
* the stored pointer so subsequent new tabs use the new context.
*
* The caller MUST have closed all existing tabs and sidebar webviews
* (which reference the old context) before calling this.
*/
void tab_manager_set_context(WebKitWebContext *ctx);
/*
* Create a new tab and load the given URL (or the default new-tab URL
* if url is NULL). Returns the tab index, or -1 on failure (e.g. max
@@ -51,6 +66,19 @@ void tab_manager_init(GtkContainer *parent,
*/
int tab_manager_new_tab(const char *url);
/*
* Open the tab at the given index's URL in a new browser window.
* Creates a new top-level GtkWindow with its own notebook and a single
* tab loading the same URL. No-op if the index is out of range.
*/
void tab_manager_open_in_new_window(int index);
/*
* Open a new browser window with a blank tab (or the default new-tab
* URL). Used by the Ctrl+N shortcut.
*/
void tab_manager_new_window_blank(void);
/*
* Close the tab at the given index. If it's the last tab, the window
* destroy handler will fire (caller is responsible for quitting).
@@ -126,6 +154,25 @@ void tab_manager_close_to_right(int index);
*/
void tab_manager_close_all(void);
/*
* Destroy the agent chat sidebar webview in every open window (main + aux).
* The sidebar container itself is kept (it will be re-populated lazily on the
* next toggle). Used during logout / identity switch so the sidebar — which
* holds a long-lived WebKitWebView with the previous user's session — does
* not leak the old identity's sovereign://agents/chat state across users.
*/
void tab_manager_destroy_sidebar_webviews(void);
/*
* Temporarily suppress the "last tab closed → quit the app" behavior.
* Set TRUE before tab_manager_close_all() during an identity switch so
* the browser does not exit when the previous user's tabs are closed;
* the caller opens a fresh tab for the new user afterwards. Always
* reset to FALSE when done so normal quit-on-last-tab-close behavior
* is restored.
*/
void tab_manager_set_suppress_quit_on_last_tab(gboolean suppress);
/*
* Duplicate the tab at the given index (open a new tab with the same URL).
*/
@@ -160,6 +207,54 @@ void tab_manager_set_avatar(const char *pubkey_hex);
*/
void tab_manager_toggle_inspector(void);
/*
* Zoom control for the active tab's webview.
* tab_manager_zoom_in() — multiply zoom by 1.1 (capped at 5.0)
* tab_manager_zoom_out() — multiply zoom by 1/1.1 (floored at 0.25)
* tab_manager_zoom_reset()— set zoom back to 1.0 (100%)
* No-ops if there is no active tab or webview.
*/
void tab_manager_zoom_in(void);
void tab_manager_zoom_out(void);
void tab_manager_zoom_reset(void);
/*
* Toggle the agent chat sidebar for the active window. The sidebar is
* per-window (not per-tab), so it persists across tab switches within a
* window. When showing, if the sidebar webview has not been created yet,
* it is created (sharing the same WebKitWebContext so sovereign:// works)
* and loads sovereign://agents/chat. When hiding, the sidebar container
* is hidden but the webview is kept alive so chat state persists.
*/
void tab_manager_toggle_sidebar(void);
/*
* Returns the main webview (the web page) of the active tab, never the
* sidebar webview. This is used by agent tools so they always operate
* on the web page even when the sidebar is open and focused.
*/
WebKitWebView *tab_manager_get_main_webview(void);
/*
* Returns TRUE if the sidebar is currently visible for the active window.
*/
gboolean tab_manager_sidebar_visible(void);
/*
* Returns the main window's GtkNotebook widget. Used by main.c's
* delete-event handler to identify which tabs belong to the main window
* (vs auxiliary windows) when closing the main window but keeping aux
* windows alive.
*/
GtkWidget *tab_manager_get_main_notebook(void);
/*
* Re-hide the sidebar container after gtk_widget_show_all(window).
* Call this in main.c after show_all so the sidebar (which is packed
* in the window-level GtkPaned) doesn't appear on startup.
*/
void tab_manager_hide_sidebar_after_show_all(void);
#ifdef __cplusplus
}
#endif

281
src/tor_control.c Normal file
View File

@@ -0,0 +1,281 @@
/*
* tor_control.c — synchronous, short-timeout Tor control-protocol client
*/
#define _POSIX_C_SOURCE 200809L
#include "tor_control.h"
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <netdb.h>
#include <poll.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
#define TOR_RESPONSE_MAX 8192
static void set_error(char *out, size_t size, const char *format, const char *detail) {
if (out == NULL || size == 0) return;
snprintf(out, size, format, detail ? detail : "unknown error");
}
static int wait_fd(int fd, short events, int timeout_ms) {
struct pollfd pfd;
pfd.fd = fd;
pfd.events = events;
pfd.revents = 0;
int rc;
do { rc = poll(&pfd, 1, timeout_ms); } while (rc < 0 && errno == EINTR);
if (rc <= 0) return -1;
return (pfd.revents & events) ? 0 : -1;
}
static int finish_nonblocking_connect(int fd, int timeout_ms) {
if (wait_fd(fd, POLLOUT, timeout_ms) != 0) return -1;
int socket_error = 0;
socklen_t len = sizeof(socket_error);
if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &socket_error, &len) != 0 ||
socket_error != 0) {
if (socket_error) errno = socket_error;
return -1;
}
return 0;
}
static int connect_unix(const char *path, int timeout_ms) {
int fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
if (fd < 0) return -1;
struct sockaddr_un addr;
memset(&addr, 0, sizeof(addr));
addr.sun_family = AF_UNIX;
if (strlen(path) >= sizeof(addr.sun_path)) {
close(fd);
errno = ENAMETOOLONG;
return -1;
}
snprintf(addr.sun_path, sizeof(addr.sun_path), "%s", path);
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
int rc = connect(fd, (struct sockaddr *)&addr, sizeof(addr));
if (rc != 0 && errno == EINPROGRESS) rc = finish_nonblocking_connect(fd, timeout_ms);
if (rc != 0) { close(fd); return -1; }
fcntl(fd, F_SETFL, flags);
return fd;
}
static int connect_tcp(const char *endpoint, int timeout_ms) {
char host[256] = "127.0.0.1";
char port[16] = "9051";
const char *colon = strrchr(endpoint, ':');
if (colon != NULL) {
size_t hlen = (size_t)(colon - endpoint);
if (hlen > 0 && hlen < sizeof(host)) {
memcpy(host, endpoint, hlen);
host[hlen] = '\0';
}
snprintf(port, sizeof(port), "%s", colon + 1);
} else if (endpoint[0]) {
snprintf(host, sizeof(host), "%s", endpoint);
}
struct addrinfo hints, *result = NULL, *it;
memset(&hints, 0, sizeof(hints));
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
if (getaddrinfo(host, port, &hints, &result) != 0) return -1;
int fd = -1;
for (it = result; it; it = it->ai_next) {
fd = socket(it->ai_family, it->ai_socktype | SOCK_CLOEXEC, it->ai_protocol);
if (fd < 0) continue;
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
int rc = connect(fd, it->ai_addr, it->ai_addrlen);
if (rc != 0 && errno == EINPROGRESS) rc = finish_nonblocking_connect(fd, timeout_ms);
if (rc == 0) { fcntl(fd, F_SETFL, flags); break; }
close(fd);
fd = -1;
}
freeaddrinfo(result);
return fd;
}
int tor_control_connect(const char *endpoint, int timeout_ms,
char *error, size_t error_size) {
if (endpoint == NULL || endpoint[0] == '\0') {
set_error(error, error_size, "%s", "empty Tor endpoint");
return -1;
}
const char *unix_path = NULL;
if (strncmp(endpoint, "unix:", 5) == 0) unix_path = endpoint + 5;
else if (endpoint[0] == '/') unix_path = endpoint;
int fd = unix_path ? connect_unix(unix_path, timeout_ms)
: connect_tcp(endpoint, timeout_ms);
if (fd < 0) set_error(error, error_size, "Tor connect failed: %s", strerror(errno));
return fd;
}
static int write_all(int fd, const char *data, size_t length, int timeout_ms) {
size_t sent = 0;
while (sent < length) {
if (wait_fd(fd, POLLOUT, timeout_ms) != 0) return -1;
ssize_t n = send(fd, data + sent, length - sent, MSG_NOSIGNAL);
if (n < 0 && errno == EINTR) continue;
if (n <= 0) return -1;
sent += (size_t)n;
}
return 0;
}
static int read_response(int fd, char *response, size_t response_size,
char *error, size_t error_size) {
size_t used = 0;
response[0] = '\0';
while (used + 1 < response_size) {
if (wait_fd(fd, POLLIN, 1500) != 0) {
set_error(error, error_size, "%s", "Tor control response timed out");
return -1;
}
ssize_t n = recv(fd, response + used, response_size - used - 1, 0);
if (n < 0 && errno == EINTR) continue;
if (n <= 0) {
set_error(error, error_size, "%s", "Tor closed control connection");
return -1;
}
used += (size_t)n;
response[used] = '\0';
char *line = response;
while (line && *line) {
char *end = strstr(line, "\r\n");
if (!end) break;
if (strlen(line) >= 4 && line[3] == ' ') {
int code = atoi(line);
if (code == 250) return 0;
char saved = *end;
*end = '\0';
set_error(error, error_size, "Tor control error: %s", line);
*end = saved;
return -1;
}
line = end + 2;
}
}
set_error(error, error_size, "%s", "Tor control response too large");
return -1;
}
static int command(int fd, const char *command_text, char *response,
size_t response_size, char *error, size_t error_size) {
char *wire = g_strdup_printf("%s\r\n", command_text);
int rc = write_all(fd, wire, strlen(wire), 1500);
g_free(wire);
if (rc != 0) {
set_error(error, error_size, "Tor control write failed: %s", strerror(errno));
return -1;
}
return read_response(fd, response, response_size, error, error_size);
}
int tor_control_authenticate(int fd, const char *cookie_path,
char *error, size_t error_size) {
char command_text[1024];
if (cookie_path != NULL && cookie_path[0] != '\0') {
gchar *cookie = NULL;
gsize length = 0;
GError *gerror = NULL;
if (!g_file_get_contents(cookie_path, &cookie, &length, &gerror)) {
set_error(error, error_size, "Cannot read Tor cookie: %s",
gerror ? gerror->message : "unknown error");
g_clear_error(&gerror);
return -1;
}
GString *hex = g_string_sized_new(length * 2);
for (gsize i = 0; i < length; i++)
g_string_append_printf(hex, "%02X", (unsigned char)cookie[i]);
g_free(cookie);
snprintf(command_text, sizeof(command_text), "AUTHENTICATE %s", hex->str);
g_string_free(hex, TRUE);
} else {
snprintf(command_text, sizeof(command_text), "AUTHENTICATE");
}
char response[TOR_RESPONSE_MAX];
return command(fd, command_text, response, sizeof(response), error, error_size);
}
int tor_control_get_bootstrap(int fd, int *progress,
char *summary, size_t summary_size,
char *error, size_t error_size) {
char response[TOR_RESPONSE_MAX];
if (command(fd, "GETINFO status/bootstrap-phase", response,
sizeof(response), error, error_size) != 0) return -1;
char *marker = strstr(response, "PROGRESS=");
if (marker == NULL) {
set_error(error, error_size, "%s", "Tor bootstrap response lacks PROGRESS");
return -1;
}
int value = atoi(marker + 9);
if (progress) *progress = value;
if (summary && summary_size) {
char *end = strstr(marker, "\r\n");
size_t n = end ? (size_t)(end - marker) : strlen(marker);
if (n >= summary_size) n = summary_size - 1;
memcpy(summary, marker, n);
summary[n] = '\0';
}
return 0;
}
static int signal_command(int fd, const char *signal_name,
char *error, size_t error_size) {
char cmd[64], response[TOR_RESPONSE_MAX];
snprintf(cmd, sizeof(cmd), "SIGNAL %s", signal_name);
return command(fd, cmd, response, sizeof(response), error, error_size);
}
int tor_control_signal_newnym(int fd, char *error, size_t error_size) {
return signal_command(fd, "NEWNYM", error, error_size);
}
int tor_control_signal_term(int fd, char *error, size_t error_size) {
return signal_command(fd, "TERM", error, error_size);
}
void tor_control_close(int *fd) {
if (fd && *fd >= 0) { close(*fd); *fd = -1; }
}
gboolean tor_control_endpoint_available(const char *endpoint, int timeout_ms) {
int fd = tor_control_connect(endpoint, timeout_ms, NULL, 0);
if (fd < 0) return FALSE;
close(fd);
return TRUE;
}
gboolean tor_socks_endpoint_available(const char *endpoint, int timeout_ms) {
if (endpoint == NULL || endpoint[0] == '\0') return FALSE;
const char *plain = endpoint;
if (strncmp(plain, "socks5://", 9) == 0) plain += 9;
if (strncmp(plain, "unix:", 5) == 0 || plain[0] == '/') {
const char *path = strncmp(plain, "unix:", 5) == 0 ? plain + 5 : plain;
int fd = connect_unix(path, timeout_ms);
if (fd < 0) return FALSE;
close(fd);
return TRUE;
}
int fd = connect_tcp(plain, timeout_ms);
if (fd < 0) return FALSE;
unsigned char hello[] = { 0x05, 0x01, 0x00 };
unsigned char reply[2];
gboolean ok = FALSE;
if (write_all(fd, (const char *)hello, sizeof(hello), timeout_ms) == 0 &&
wait_fd(fd, POLLIN, timeout_ms) == 0 && recv(fd, reply, 2, 0) == 2 &&
reply[0] == 0x05 && reply[1] != 0xff) ok = TRUE;
close(fd);
return ok;
}

33
src/tor_control.h Normal file
View File

@@ -0,0 +1,33 @@
/* tor_control.h — minimal Tor control-protocol client */
#ifndef TOR_CONTROL_H
#define TOR_CONTROL_H
#include <glib.h>
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
/* Endpoints may be unix:/path, a bare absolute Unix path, or host:port. */
int tor_control_connect(const char *endpoint, int timeout_ms,
char *error, size_t error_size);
int tor_control_authenticate(int fd, const char *cookie_path,
char *error, size_t error_size);
int tor_control_get_bootstrap(int fd, int *progress,
char *summary, size_t summary_size,
char *error, size_t error_size);
int tor_control_signal_newnym(int fd, char *error, size_t error_size);
int tor_control_signal_term(int fd, char *error, size_t error_size);
void tor_control_close(int *fd);
/* Lightweight discovery helpers. */
gboolean tor_control_endpoint_available(const char *endpoint, int timeout_ms);
gboolean tor_socks_endpoint_available(const char *endpoint, int timeout_ms);
#ifdef __cplusplus
}
#endif
#endif /* TOR_CONTROL_H */

291
src/tor_scheme.c Normal file
View File

@@ -0,0 +1,291 @@
/*
* tor_scheme.c — asynchronous Tor-backed WebKitGTK handler for tor://
*/
#include "tor_scheme.h"
#include "net_services.h"
#include <curl/curl.h>
#include <glib.h>
#include <webkit2/webkit2.h>
#include <ctype.h>
#include <string.h>
#define TOR_FETCH_MAX_BYTES (16 * 1024 * 1024)
#define TOR_FETCH_TIMEOUT_SECONDS 30L
#define TOR_CONNECT_TIMEOUT_SECONDS 15L
typedef struct {
WebKitURISchemeRequest *request;
char *target_url;
char *proxy_url;
GString *response_body;
char *content_type;
char *error_html;
} tor_job_t;
typedef struct {
GString *body;
gboolean overflow;
} tor_body_t;
static char *html_escape(const char *input) {
if (!input) return g_strdup("");
return g_markup_escape_text(input, -1);
}
static void respond_html(WebKitURISchemeRequest *request, char *html) {
gsize len = strlen(html);
GInputStream *stream = g_memory_input_stream_new_from_data(html, len, g_free);
webkit_uri_scheme_request_finish(request, stream, len, "text/html");
g_object_unref(stream);
}
static void respond_error_page(WebKitURISchemeRequest *request,
const char *title,
const char *message) {
char *safe_title = html_escape(title ? title : "Error");
char *safe_message = html_escape(message ? message : "Request failed.");
char *html = g_strdup_printf(
"<!doctype html><html><head><meta charset=\"utf-8\">"
"<title>%s</title></head><body>"
"<h1>%s</h1><p>%s</p></body></html>",
safe_title, safe_title, safe_message);
respond_html(request, html);
g_free(safe_title);
g_free(safe_message);
}
static gboolean host_is_onion(const char *host) {
return host && *host && g_str_has_suffix(host, ".onion");
}
static char *proxy_to_socks5h(const char *endpoint) {
if (!endpoint || endpoint[0] == '\0') return NULL;
if (g_str_has_prefix(endpoint, "socks5h://")) return g_strdup(endpoint);
if (!g_str_has_prefix(endpoint, "socks5://")) return NULL;
return g_strdup_printf("socks5h://%s", endpoint + strlen("socks5://"));
}
static char *build_target_http_url(const char *uri, char **error_message) {
GError *error = NULL;
GUri *parsed = g_uri_parse(uri, G_URI_FLAGS_PARSE_RELAXED, &error);
if (!parsed) {
if (error_message)
*error_message = g_strdup(error ? error->message : "Invalid tor:// URI");
g_clear_error(&error);
return NULL;
}
const char *host = g_uri_get_host(parsed);
gint port = g_uri_get_port(parsed);
const char *path = g_uri_get_path(parsed);
const char *query = g_uri_get_query(parsed);
if (!host_is_onion(host)) {
if (error_message) {
*error_message = g_strdup("tor:// only supports .onion hostnames in Phase 1");
}
g_uri_unref(parsed);
return NULL;
}
if (!path || path[0] == '\0') path = "/";
/* Phase 1: always map tor:// to http:// over Tor SOCKS. HTTPS-over-onion
* support can be added in a future phase with an explicit signal. */
char *target = NULL;
if (port > 0) {
target = query
? g_strdup_printf("http://%s:%d%s?%s", host, port, path, query)
: g_strdup_printf("http://%s:%d%s", host, port, path);
} else {
target = query
? g_strdup_printf("http://%s%s?%s", host, path, query)
: g_strdup_printf("http://%s%s", host, path);
}
g_uri_unref(parsed);
return target;
}
static size_t tor_write_bounded(void *data, size_t size, size_t nmemb, void *user_data) {
tor_body_t *body = user_data;
size_t bytes = size * nmemb;
if (body->body->len + bytes > TOR_FETCH_MAX_BYTES) {
body->overflow = TRUE;
return 0;
}
g_string_append_len(body->body, data, bytes);
return bytes;
}
static size_t tor_header_capture(void *data, size_t size, size_t nmemb, void *user_data) {
tor_job_t *job = user_data;
size_t bytes = size * nmemb;
const char *line = (const char *)data;
if (job->content_type != NULL || bytes < strlen("Content-Type:")) return bytes;
if (g_ascii_strncasecmp(line, "Content-Type:", strlen("Content-Type:")) == 0) {
const char *value = line + strlen("Content-Type:");
while (*value == ' ' || *value == '\t') value++;
const char *end = line + bytes;
while (end > value && (end[-1] == '\r' || end[-1] == '\n' ||
end[-1] == ' ' || end[-1] == '\t')) {
end--;
}
if (end > value) job->content_type = g_strndup(value, (gsize)(end - value));
}
return bytes;
}
static gpointer tor_scheme_worker(gpointer data) {
tor_job_t *job = data;
CURL *curl = curl_easy_init();
tor_body_t body = { g_string_new(NULL), FALSE };
if (!curl) {
job->error_html = g_strdup(
"Failed to load .onion site: Could not initialize HTTP client.");
g_string_free(body.body, TRUE);
return job;
}
curl_easy_setopt(curl, CURLOPT_URL, job->target_url);
curl_easy_setopt(curl, CURLOPT_PROXY, job->proxy_url);
curl_easy_setopt(curl, CURLOPT_PROXYTYPE, (long)CURLPROXY_SOCKS5_HOSTNAME);
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, TOR_FETCH_TIMEOUT_SECONDS);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, TOR_CONNECT_TIMEOUT_SECONDS);
curl_easy_setopt(curl, CURLOPT_USERAGENT, "sovereign_browser/tor-scheme");
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, tor_write_bounded);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &body);
curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, tor_header_capture);
curl_easy_setopt(curl, CURLOPT_HEADERDATA, job);
CURLcode rc = curl_easy_perform(curl);
if (rc != CURLE_OK) {
const char *err = body.overflow
? "response exceeded maximum size"
: curl_easy_strerror(rc);
char *safe_err = html_escape(err);
job->error_html = g_strdup_printf("Failed to load .onion site: %s", safe_err);
g_free(safe_err);
g_string_free(body.body, TRUE);
curl_easy_cleanup(curl);
return job;
}
job->response_body = body.body;
if (!job->content_type || job->content_type[0] == '\0') {
g_free(job->content_type);
job->content_type = g_strdup("text/html");
}
curl_easy_cleanup(curl);
return job;
}
static void tor_job_free(tor_job_t *job) {
if (!job) return;
g_clear_object(&job->request);
g_free(job->target_url);
g_free(job->proxy_url);
if (job->response_body) g_string_free(job->response_body, TRUE);
g_free(job->content_type);
g_free(job->error_html);
g_free(job);
}
static void tor_scheme_worker_done(GObject *source_object,
GAsyncResult *result,
gpointer user_data) {
(void)source_object;
(void)user_data;
GTask *task = G_TASK(result);
tor_job_t *job = g_task_propagate_pointer(task, NULL);
if (job->error_html) {
respond_error_page(job->request, "Tor request failed", job->error_html);
tor_job_free(job);
return;
}
gsize len = job->response_body ? job->response_body->len : 0;
const char *data = (job->response_body && len > 0) ? job->response_body->str : "";
GInputStream *stream = g_memory_input_stream_new_from_data(g_memdup2(data, len),
len,
g_free);
webkit_uri_scheme_request_finish(job->request,
stream,
len,
job->content_type ? job->content_type : "text/html");
g_object_unref(stream);
tor_job_free(job);
}
static void tor_task_thread(GTask *task,
gpointer source_object,
gpointer task_data,
GCancellable *cancellable) {
(void)source_object;
(void)cancellable;
g_task_return_pointer(task, tor_scheme_worker(task_data), NULL);
}
static void on_tor_scheme(WebKitURISchemeRequest *request, gpointer user_data) {
(void)user_data;
const char *uri = webkit_uri_scheme_request_get_uri(request);
char *url_error = NULL;
char *target_url = build_target_http_url(uri, &url_error);
if (!target_url) {
respond_error_page(request,
"Invalid .onion URL",
url_error ? url_error : "Invalid tor:// URL.");
g_free(url_error);
return;
}
if (!net_service_is_ready(NET_SERVICE_TOR)) {
respond_error_page(request,
"Tor unavailable",
"Tor is not ready. Please wait for Tor to bootstrap and try again.");
g_free(target_url);
return;
}
const net_service_t *tor = net_service_get_status(NET_SERVICE_TOR);
const char *endpoint = tor ? tor->status.tor.socks_endpoint : NULL;
char *proxy_url = proxy_to_socks5h(endpoint);
if (!proxy_url) {
respond_error_page(request,
"Tor unavailable",
"Tor SOCKS endpoint is not available yet. Please try again shortly.");
g_free(target_url);
return;
}
tor_job_t *job = g_new0(tor_job_t, 1);
job->request = g_object_ref(request);
job->target_url = target_url;
job->proxy_url = proxy_url;
GTask *task = g_task_new(NULL, NULL, tor_scheme_worker_done, NULL);
g_task_set_task_data(task, job, NULL);
g_task_run_in_thread(task, tor_task_thread);
g_object_unref(task);
}
void tor_scheme_register(WebKitWebContext *ctx) {
g_return_if_fail(WEBKIT_IS_WEB_CONTEXT(ctx));
webkit_web_context_register_uri_scheme(ctx, "tor", on_tor_scheme, NULL, NULL);
}

20
src/tor_scheme.h Normal file
View File

@@ -0,0 +1,20 @@
/*
* tor_scheme.h — WebKitGTK tor:// URI scheme handler
*/
#ifndef TOR_SCHEME_H
#define TOR_SCHEME_H
#include <webkit2/webkit2.h>
#ifdef __cplusplus
extern "C" {
#endif
void tor_scheme_register(WebKitWebContext *ctx);
#ifdef __cplusplus
}
#endif
#endif /* TOR_SCHEME_H */

View File

@@ -11,9 +11,9 @@
#ifndef SOVEREIGN_BROWSER_VERSION_H
#define SOVEREIGN_BROWSER_VERSION_H
#define SB_VERSION "v0.0.19"
#define SB_VERSION "v0.0.56"
#define SB_VERSION_MAJOR 0
#define SB_VERSION_MINOR 0
#define SB_VERSION_PATCH 19
#define SB_VERSION_PATCH 56
#endif /* SOVEREIGN_BROWSER_VERSION_H */

156
src/web_context.c Normal file
View File

@@ -0,0 +1,156 @@
/*
* web_context.c — per-user WebKitWebContext construction/teardown
*
* See web_context.h for the rationale. Phase A of
* plans/webkit-data-isolation.md.
*/
#include "web_context.h"
#include "profile.h"
#include <glib.h>
#include <webkit2/webkit2.h>
#include <string.h>
/* The current per-user context. NULL before the first build and after
* teardown. Replaces webkit_web_context_get_default() for code that
* wants the per-user context. */
static WebKitWebContext *g_ctx = NULL;
/* ── Security / TLS / favicon configuration ────────────────────────── *
* Applied to every context we build (per-user and ephemeral). Mirrors
* the configuration that main.c used to apply to the default context.
*/
static void configure_context(WebKitWebContext *ctx) {
g_return_if_fail(WEBKIT_IS_WEB_CONTEXT(ctx));
/* Security strip: register sovereign://, tor://, file:// as secure
* (no CORS enforcement), and file:// as local. Same as the original
* main.c configuration. */
WebKitSecurityManager *sec_mgr =
webkit_web_context_get_security_manager(ctx);
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "sovereign");
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "tor");
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "file");
webkit_security_manager_register_uri_scheme_as_local(sec_mgr, "file");
/* Accept any TLS certificate (FIPS uses Noise IK, not TLS CAs). */
WebKitWebsiteDataManager *dm =
webkit_web_context_get_website_data_manager(ctx);
webkit_website_data_manager_set_tls_errors_policy(
dm, WEBKIT_TLS_ERRORS_POLICY_IGNORE);
/* Enable the favicon database so notify::favicon fires. Use the
* data manager's base data directory (per-user) so favicons are
* isolated too. Passing NULL would use a shared default location. */
char fav_dir[600];
WebKitWebsiteDataManager *data_mgr =
webkit_web_context_get_website_data_manager(ctx);
const gchar *base = webkit_website_data_manager_get_base_data_directory(data_mgr);
if (base != NULL) {
g_snprintf(fav_dir, sizeof(fav_dir), "%s/favicons", base);
} else {
/* Ephemeral data manager has no base dir; use a temp favicons
* dir so the favicon DB still works in no-login mode. */
g_snprintf(fav_dir, sizeof(fav_dir),
"%s/sovereign_browser-favicons-ephemeral",
g_get_tmp_dir());
}
webkit_web_context_set_favicon_database_directory(ctx, fav_dir);
}
WebKitWebContext *web_context_build_for_pubkey(const char *pubkey_hex) {
if (pubkey_hex == NULL || pubkey_hex[0] == '\0') {
g_printerr("[web-context] No pubkey — cannot build per-user context\n");
return NULL;
}
/* Ensure the per-user webkit data directory exists. */
if (profile_ensure_webkit_dir(pubkey_hex) != 0) {
g_printerr("[web-context] Failed to create webkit dir for %s\n",
pubkey_hex);
return NULL;
}
char webkit_dir[600];
profile_get_webkit_dir(pubkey_hex, webkit_dir, sizeof(webkit_dir));
if (webkit_dir[0] == '\0') {
g_printerr("[web-context] Failed to get webkit dir path for %s\n",
pubkey_hex);
return NULL;
}
/* Build the per-user data manager. base-data-directory covers
* local-storage, indexed-db, websql, service-worker-registrations,
* offline-app-cache, hsts, itp, device-id-hash-salt. base-cache-directory
* covers disk-cache. WebKit derives subdirs under these. */
char data_dir[600];
char cache_dir[600];
g_snprintf(data_dir, sizeof(data_dir), "%sdata", webkit_dir);
g_snprintf(cache_dir, sizeof(cache_dir), "%scache", webkit_dir);
WebKitWebsiteDataManager *dm = webkit_website_data_manager_new(
"base-data-directory", data_dir,
"base-cache-directory", cache_dir,
NULL);
if (dm == NULL) {
g_printerr("[web-context] Failed to create WebsiteDataManager\n");
return NULL;
}
/* Build a fresh context bound to this data manager. */
g_ctx = webkit_web_context_new_with_website_data_manager(dm);
if (g_ctx == NULL) {
g_printerr("[web-context] Failed to create WebContext\n");
g_object_unref(dm);
return NULL;
}
/* The context owns the data manager now; drop our ref. */
g_object_unref(dm);
configure_context(g_ctx);
g_print("[web-context] Built per-user context for %s (data=%s cache=%s)\n",
pubkey_hex, data_dir, cache_dir);
return g_ctx;
}
WebKitWebContext *web_context_build_ephemeral(void) {
WebKitWebsiteDataManager *dm = webkit_website_data_manager_new_ephemeral();
if (dm == NULL) {
g_printerr("[web-context] Failed to create ephemeral WebsiteDataManager\n");
return NULL;
}
g_ctx = webkit_web_context_new_with_website_data_manager(dm);
if (g_ctx == NULL) {
g_printerr("[web-context] Failed to create ephemeral WebContext\n");
g_object_unref(dm);
return NULL;
}
g_object_unref(dm);
configure_context(g_ctx);
g_print("[web-context] Built ephemeral context (no on-disk persistence)\n");
return g_ctx;
}
void web_context_teardown(void) {
if (g_ctx == NULL) return;
/* Unref the context. The context releases its reference to the data
* manager. Any remaining webviews referencing this context would
* keep it alive — the caller must have closed them first. */
WebKitWebContext *old = g_ctx;
g_ctx = NULL;
g_object_unref(old);
g_print("[web-context] Torn down previous context\n");
}
WebKitWebContext *web_context_get(void) {
return g_ctx;
}

88
src/web_context.h Normal file
View File

@@ -0,0 +1,88 @@
/*
* web_context.h — per-user WebKitWebContext construction/teardown
*
* Phase A of plans/webkit-data-isolation.md. Each Nostr identity gets
* its own WebKitWebContext backed by a per-user WebKitWebsiteDataManager
* rooted at ~/.sovereign_browser/profiles/<pubkey>/webkit/. This gives
* true isolation: cookies, cache, localStorage, IndexedDB, service
* workers, and favicons persist per-user and never cross-contaminate.
*
* WebKitGTK binds a WebKitWebsiteDataManager to a WebKitWebContext for
* the context's lifetime, so per-user isolation requires building a
* fresh context per login and tearing it down on logout/switch.
*
* The current context pointer is held in this module so the rest of the
* codebase can fetch it via web_context_get() instead of
* webkit_web_context_get_default().
*/
#ifndef WEB_CONTEXT_H
#define WEB_CONTEXT_H
#include <webkit2/webkit2.h>
#ifdef __cplusplus
extern "C" {
#endif
/*
* Build a fresh WebKitWebContext with a per-user WebKitWebsiteDataManager
* rooted at ~/.sovereign_browser/profiles/<pubkey_hex>/webkit/.
*
* pubkey_hex — 64-char hex pubkey. Must be non-NULL/non-empty.
*
* Configures:
* - per-user base-data-directory + base-cache-directory (WebKit derives
* local-storage, indexed-db, websql, service-worker, offline-app-cache,
* hsts, itp, device-id-hash-salt under these)
* - favicon database enabled (per-user)
* - TLS errors ignored (FIPS uses Noise IK, not TLS CAs)
* - security manager: sovereign://, tor://, file:// registered as
* secure; file:// also registered as local
*
* Stores the new context as the current context (returned by
* web_context_get()). The caller must call web_context_teardown() on the
* previous context first if one exists.
*
* Returns the new WebKitWebContext (owned by this module), or NULL on
* failure.
*/
WebKitWebContext *web_context_build_for_pubkey(const char *pubkey_hex);
/*
* Build a fresh WebKitWebContext with an EPHEMERAL data manager (no
* on-disk persistence). Used for --no-login / read-only mode where
* there is no pubkey to key a per-user data directory on, and for the
* logged-out state. Same security/TLS/favicon config as
* web_context_build_for_pubkey.
*
* Stores the new context as the current context.
*
* Returns the new WebKitWebContext, or NULL on failure.
*/
WebKitWebContext *web_context_build_ephemeral(void);
/*
* Tear down the current per-user WebKitWebContext: unref the context
* and its data manager. After this, web_context_get() returns NULL
* until the next build call.
*
* The caller MUST have already closed all webviews that reference the
* old context (tabs + sidebar webviews) before calling this, otherwise
* the webviews will hold dangling references. identity_teardown_web_state()
* in main.c does this.
*/
void web_context_teardown(void);
/*
* Return the current WebKitWebContext, or NULL if none has been built
* (or the last one was torn down). Replaces webkit_web_context_get_default()
* for code that wants the per-user context.
*/
WebKitWebContext *web_context_get(void);
#ifdef __cplusplus
}
#endif
#endif /* WEB_CONTEXT_H */

113
src/webkit_data.c Normal file
View File

@@ -0,0 +1,113 @@
/*
* webkit_data.c — helpers to wipe WebKit website data on identity change
*
* See webkit_data.h for the rationale. This is Phase 0 of
* plans/webkit-data-isolation.md: wipe the shared data manager in place on
* logout / identity switch so the next user does not inherit the previous
* user's cookies, cache, localStorage, service workers, etc.
*/
#include "webkit_data.h"
#include <glib.h>
#include <webkit2/webkit2.h>
/* ── Async clear state ─────────────────────────────────────────────── *
* webkit_website_data_manager_clear() is asynchronous. We drive a nested
* GLib main loop until the clear callback fires (or a 5s timeout expires)
* so callers can treat webkit_data_clear_all() as synchronous.
*/
typedef struct {
gboolean done;
gboolean timed_out;
} clear_state_t;
static void on_clear_finished(GObject *source, GAsyncResult *res,
gpointer user_data) {
(void)source;
(void)res;
clear_state_t *st = (clear_state_t *)user_data;
st->done = TRUE;
}
static gboolean on_clear_timeout(gpointer user_data) {
clear_state_t *st = (clear_state_t *)user_data;
if (!st->done) {
st->timed_out = TRUE;
st->done = TRUE;
}
return G_SOURCE_REMOVE;
}
int webkit_data_clear_all(WebKitWebContext *ctx, gboolean clear_favicons) {
if (ctx == NULL) {
ctx = webkit_web_context_get_default();
}
if (ctx == NULL) {
g_printerr("[webkit-data] No WebKitWebContext to clear\n");
return -1;
}
WebKitWebsiteDataManager *dm =
webkit_web_context_get_website_data_manager(ctx);
if (dm == NULL) {
g_printerr("[webkit-data] No WebKitWebsiteDataManager on context\n");
return -1;
}
/* Build the set of data types to clear. WEBKIT_WEBSITE_DATA_ALL covers:
* memory cache, disk cache, offline app cache, cookies, local storage,
* session storage, IndexedDB, WebSQL, service worker registrations,
* HSTS, plugin data, device id/hash salt. */
WebKitWebsiteDataTypes types = WEBKIT_WEBSITE_DATA_ALL;
/* Drive the async clear to completion. The clear callback and the
* timeout fire on the default main context (where WebKit schedules
* its async completions), so we pump the default context — NOT a
* fresh nested context, which would never see the callback.
*
* We do NOT push a thread-default context because the caller (the
* MCP request handler) is already running inside the default main
* loop; iterating the default context here lets the clear callback
* fire while still on the same thread. */
clear_state_t st = { .done = FALSE, .timed_out = FALSE };
guint timeout_id = g_timeout_add_seconds(5, on_clear_timeout, &st);
webkit_website_data_manager_clear(dm, types, 0, NULL,
on_clear_finished, &st);
/* Pump the default context until the callback or timeout sets
* st.done. g_main_context_iteration may return FALSE if no sources
* are ready, so we loop on the flag, not the return value. */
while (!st.done) {
g_main_context_iteration(NULL, TRUE);
}
if (timeout_id != 0) {
g_source_remove(timeout_id);
timeout_id = 0;
}
if (st.timed_out) {
g_printerr("[webkit-data] clear timed out after 5s\n");
return -2;
}
/* Favicons live in a separate WebKitFaviconDatabase, not the data
* manager. Clearing it is optional because it is process-global and
* not identity-sensitive (favicons are page icons, not user data).
* When requested, clear it so a switched user doesn't see the prior
* user's visited-site icons in the tab strip. */
if (clear_favicons) {
WebKitFaviconDatabase *favdb =
webkit_web_context_get_favicon_database(ctx);
if (favdb != NULL) {
webkit_favicon_database_clear(favdb);
}
}
g_print("[webkit-data] Cleared all website data (favicons=%s)\n",
clear_favicons ? "yes" : "no");
return 0;
}

54
src/webkit_data.h Normal file
View File

@@ -0,0 +1,54 @@
/*
* webkit_data.h — helpers to wipe WebKit website data on identity change
*
* WebKitGTK's WebKitWebsiteDataManager holds cookies, HTTP cache, localStorage,
* IndexedDB, WebSQL, service worker registrations, offline app cache, HSTS,
* and the favicon database for the whole process. When a user logs out or
* switches Nostr identity, this data must be cleared so the next user does
* not see the previous user's web session (cookies are the primary leak —
* they identify you to web pages).
*
* Phase 0 of plans/webkit-data-isolation.md: a single shared data manager
* (the default context's) is wiped in place. Phase A will replace this with
* per-user data managers, at which point these helpers become the safety net
* for the in-memory live-tab case.
*/
#ifndef WEBKIT_DATA_H
#define WEBKIT_DATA_H
#include <webkit2/webkit2.h>
#ifdef __cplusplus
extern "C" {
#endif
/*
* Clear ALL website data from the given WebKitWebContext's data manager:
* cookies, disk + memory cache, local storage, session storage, IndexedDB,
* WebSQL, service worker registrations, offline app cache, HSTS, plugin data,
* and (optionally) favicons.
*
* ctx — the WebKitWebContext whose data manager to clear.
* Pass NULL to target the default context.
* clear_favicons — if TRUE, also clear the favicon database. The favicon
* DB is process-global and shared across contexts; clearing
* it drops every cached favicon, not just the current
* site's. Pass FALSE if you only want session/cache
* isolation (favicons are not identity-sensitive).
*
* This is asynchronous in WebKitGTK (the clear API takes a callback). This
* function runs a nested GLib main loop until the clear completes (or 5s
* elapses) so that on return the data is gone. It is safe to call from the
* GTK main thread.
*
* Returns 0 on success, -1 if the context/data manager could not be resolved,
* -2 if the clear timed out.
*/
int webkit_data_clear_all(WebKitWebContext *ctx, gboolean clear_favicons);
#ifdef __cplusplus
}
#endif
#endif /* WEBKIT_DATA_H */

20
tests/local-site/404.html Normal file
View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Local Site — Missing Page Placeholder</title>
<link rel="stylesheet" href="assets/site.css">
</head>
<body>
<header>
<h1>Missing Page Placeholder</h1>
<nav><a href="index.html">Home</a></nav>
</header>
<main>
<p>This page exists. The home page link labeled "Missing Page" points
to <code>404.html</code>, which is this file — so it is not actually
missing. To test a real missing page, click
<a href="does-not-exist.html">this link to a truly missing page</a>.</p>
</main>
</body>
</html>

View File

@@ -0,0 +1,408 @@
# Local File Browsing Assessment — sovereign_browser
**Date:** 2026-07-16
**Test site:** [`tests/local-site/`](index.html)
**Browser:** sovereign_browser (WebKitGTK + libsoup-3.0)
**Goal:** Verify that sovereign_browser can load and operate a multipage
website directly from the local filesystem (`file://`) with no web server,
allowing cross-origin access between local files — as required by the
"security moved to the Qube level" design.
---
## Summary
**Local file browsing in sovereign_browser works remarkably well.** The vast
majority of edge cases pass. WebKitGTK's `file://` handler, combined with
sovereign_browser's permissive local-file policy, allows a full multipage
website to be opened and operated from disk just as if it were served over
HTTP. Only a handful of minor issues were found, and most are WebKit engine
quirks rather than sovereign_browser bugs.
**Verdict:** No blocking fixes are required for local-file browsing to work.
The issues below are polish/UX improvements.
---
## Test Matrix
| # | Edge case | Page | Result | Notes |
|---|-----------|------|--------|-------|
| 1 | Relative links between sibling pages | [`index.html`](index.html) → [`about.html`](about.html) | ✅ PASS | Links resolve to correct `file://` URLs |
| 2 | Links into subdirectories | [`index.html`](index.html) → [`subdir/page.html`](subdir/page.html) | ✅ PASS | |
| 3 | Links back out with `../` | [`subdir/page.html`](subdir/page.html) → [`index.html`](index.html) | ✅ PASS | |
| 4 | Deeply nested paths (3 levels) | [`subdir/deep/deeper/deepest.html`](subdir/deep/deeper/deepest.html) | ✅ PASS | `../../../` resolves correctly |
| 5 | External stylesheet (relative) | [`assets/site.css`](assets/site.css) | ✅ PASS | 21 CSS rules loaded on every page |
| 6 | SVG favicon | [`assets/favicon.svg`](assets/favicon.svg) | ✅ PASS | `link[rel=icon]` href resolved |
| 7 | SVG image via `<img>` | [`assets/logo.svg`](assets/logo.svg) | ✅ PASS | `naturalWidth` correct, `complete=true` |
| 8 | `<picture>` + `<source srcset>` | [`gallery.html`](gallery.html) | ✅ PASS | `currentSrc` selected correctly |
| 9 | `<img srcset>` with `1x/2x` | [`gallery.html`](gallery.html) | ✅ PASS | |
| 10 | Lazy-loaded image (`loading="lazy"`) | [`gallery.html`](gallery.html) | ✅ PASS | |
| 11 | External `<script src>` (sibling) | [`assets/home.js`](assets/home.js) | ✅ PASS | Script ran, updated DOM |
| 12 | External `<script src>` (`../`) | [`assets/external.js`](assets/external.js) | ✅ PASS | Loaded from parent dir |
| 13 | `fetch()` a local JSON file | [`data.html`](data.html) → [`assets/data.json`](assets/data.json) | ✅ PASS | `status=0` (file:// convention), body returned |
| 14 | `XMLHttpRequest` a local file | [`data.html`](data.html) | ✅ PASS | `status=0`, responseText populated |
| 15 | `fetch()` from a subdirectory | [`data.html`](data.html) → [`subdir/sub.json`](subdir/sub.json) | ✅ PASS | |
| 16 | `fetch()` with query string | [`data.html`](data.html) → `assets/data.json?cache=bust` | ✅ PASS | Query ignored, file loaded |
| 17 | `fetch()` a missing file | [`data.html`](data.html) → `assets/does-not-exist.json` | ⚠️ MINOR | `fetch()` throws "Load failed" instead of returning a 404 response. This is standard WebKit `file://` behavior — there is no HTTP status for local files. **Not a bug.** |
| 18 | `<iframe>` loading a sibling page | [`iframe.html`](iframe.html) → [`about.html`](about.html) | ✅ PASS | `load` event fired, `readyState=complete` |
| 19 | `<iframe>` loading a subdirectory page | [`iframe.html`](iframe.html) → [`subdir/page.html`](subdir/page.html) | ✅ PASS | |
| 20 | Cross-iframe DOM access (same-origin) | [`iframe.html`](iframe.html) | ✅ PASS | **Critical finding:** `contentDocument.title` readable — all `file://` URLs are treated as same-origin. This is exactly what we want for local development. |
| 21 | `<iframe srcdoc>` | [`iframe.html`](iframe.html) | ✅ PASS | |
| 22 | `<iframe src="data:...">` | [`iframe.html`](iframe.html) | ✅ PASS | |
| 23 | GET form submission to local file | [`form.html`](form.html) → [`form-target.html`](form-target.html) | ✅ PASS | Navigated with `?name=Alice&age=30` |
| 24 | POST form submission to local file | [`form.html`](form.html) → [`form-target.html`](form-target.html) | ✅ PASS | Navigated without query; POST body silently dropped (no server). Correct behavior. |
| 25 | Form targeting a new window (`target="_blank"`) | [`form.html`](form.html) | ✅ PASS | Opens in new tab |
| 26 | Form targeting an iframe (`target="form-frame"`) | [`form.html`](form.html) | ✅ PASS | |
| 27 | `localStorage` | [`storage-test.html`](storage-test.html) | ✅ PASS | Set/get/remove all work; length tracking correct |
| 28 | `sessionStorage` | [`storage-test.html`](storage-test.html) | ✅ PASS | Set/get/remove all work |
| 29 | `document.cookie` | [`storage-test.html`](storage-test.html) | ✅ PASS | Set/get work; multiple cookies visible |
| 30 | `IndexedDB` | [`storage-test.html`](storage-test.html) | ✅ PASS | Open, upgrade (create object store), put, get, count — all work |
| 30b | Cache API (CacheStorage) | [`storage-test.html`](storage-test.html) | ❌ FAIL | `cache.put()` rejects: "Request url is not HTTP/HTTPS". See Issue #5. |
| 31 | `window.open()` a sibling page | [`popup.html`](popup.html) → [`about.html`](about.html) | ✅ PASS | Opens in new tab |
| 32 | `window.open()` with features | [`popup.html`](popup.html) | ✅ PASS | |
| 33 | `history.pushState()` | [`history.html`](history.html) | ✅ PASS | URL updated with `?pushed=1` |
| 34 | `history.replaceState()` | [`history.html`](history.html) | ✅ PASS | URL updated with `?replaced=1` |
| 35 | `history.back()` / `forward()` | browser `back`/`forward` MCP tools | ✅ PASS | Navigates correctly between file:// pages |
| 36 | Hash fragment in URL | [`hash.html#section-2`](hash.html) | ✅ PASS | `location.hash` = `#section-2` |
| 37 | `hashchange` event | [`hash.html`](hash.html) | ✅ PASS | (page wired correctly) |
| 38 | URL-encoded filename (`encoded%20name.html`) | [`encoded name.html`](encoded%20name.html) | ✅ PASS | Percent-encoding decoded, correct file loaded |
| 39 | Query string on file:// URL | [`query.html?foo=bar&baz=qux`](query.html) | ✅ PASS | File loaded, `location.search` preserved, `URLSearchParams` works |
| 40 | Missing page (404 equivalent) | `does-not-exist.html` | ✅ FIXED | URL bar now shows failed URL (Issue #1 fixed) |
| 41 | `<video>` with local MP4 source | [`media.html`](media.html) | ✅ PASS | "can play", readyState=3, duration=596.5s, 640x360, no errors |
| 42 | `<audio>` with local M4A source | [`media.html`](media.html) | ✅ PASS | "can play", readyState=4, duration=3277s, no errors |
| 42b | Missing video source (error event test) | [`media.html`](media.html) | ⚠️ MINOR | `loadstart` fires but `error` event does not. See Issue #2. |
| 43 | `<object>` / `<embed>` with SVG | [`media.html`](media.html) | ✅ PASS | `data`/`src` resolved to file:// URL |
| 44 | Inline SVG | [`media.html`](media.html) | ✅ PASS | |
| 45 | Clicking a link to navigate | [`index.html`](index.html) → [`about.html`](about.html) | ✅ PASS | `click` MCP tool triggered navigation |
| 46 | ES Modules (`import()`) | [`advanced.html`](advanced.html) | ✅ PASS | Dynamic `import()` works; exports accessible (greet, add, PI) |
| 47 | Web Workers | [`advanced.html`](advanced.html) | ✅ PASS | `new Worker()` created; `postMessage` round-trip works (7*6=42) |
| 47b | Shared Workers | [`advanced.html`](advanced.html) | ✅ PASS | `new SharedWorker()` created; `port.onmessage` works; ping/pong round-trip works |
| 48 | Service Workers | [`advanced.html`](advanced.html) | ❌ FAIL | `register()` rejects: "must be called with HTTP or HTTPS". See Issue #6. |
| 49 | WebAssembly | [`advanced.html`](advanced.html) | ✅ PASS | `WebAssembly.instantiate()` works; `add(3,4)=7`, `add(100,200)=300` |
| 50 | Cross-origin fetch (file:// → https://) | [`advanced.html`](advanced.html) | ✅ PASS | `fetch('https://example.com')` returns status=200, type=basic, 559 chars. **No CORS blocking!** |
| 51 | Cross-origin remote image | [`advanced.html`](advanced.html) | ✅ PASS | Remote images from placehold.co and google.com load correctly |
| 52 | Cross-origin remote script | [`advanced.html`](advanced.html) | ✅ PASS | Remote `<script src>` from jsdelivr CDN loaded and executed |
| 53 | Cross-origin remote stylesheet | [`advanced.html`](advanced.html) | ✅ PASS | Remote `<link rel=stylesheet>` from jsdelivr CDN loaded and applied |
---
## Issues Found
### Issue #1 — Missing page falls back to `about:blank` (FIXED ✅)
**What was happening:** When navigating to a `file://` URL that does not
exist, the browser displayed an error message but the URL bar showed
`about:blank` instead of the requested URL.
**Fix applied:** Updated [`on_load_failed()`](src/tab_manager.c:1335) in
`src/tab_manager.c` to:
1. Accept the `tab_info_t*` as `user_data` (changed signal connection from
`NULL` to `tab`).
2. Set the URL bar text to `failing_uri` (skipping `about:blank`).
3. Update `tab->current_url` to the failing URI.
4. Set the tab title to the error message so the tab bar shows something
meaningful instead of "Loading…".
**Verified:** After the fix, navigating to a missing file shows the failed
`file://` URL in the address bar (confirmed via `get_url` MCP tool).
---
### Issue #2 — `<video>`/`<audio>` error events don't fire for missing sources (MINOR / WebKit quirk)
**Note: Media playback with valid files works perfectly.** Tested with a
real MP4 video (Big Buck Bunny, 596s, 640x360) and M4A audio (Neon Dream,
3277s). Both reach `can play` state with correct duration and dimensions.
This issue only affects the error path when a media file is missing.
**What happens:** When a `<video>` or `<audio>` element references a local
file that doesn't exist, the `error` event does not fire. The `loadstart`
event fires, but then the load silently fails without an error event. The
element stays in a "not loaded" state indefinitely.
**Why it matters:** Web apps that rely on `error` events to show fallback
content or retry logic won't work correctly for missing media files.
**Likely cause:** This is a WebKitGTK engine behavior, not a
sovereign_browser bug. The `file://` loader may not emit media element
errors the same way the HTTP loader does.
**Suggested fix:** None required — this is upstream WebKit behavior. If it
becomes a problem, a workaround would be to check `networkState` or
`readyState` after a timeout. Not worth fixing for the local-file use case.
**Severity:** Very low. Only affects missing media files, which is an edge
case.
---
### Issue #3 — `fetch()` for missing files throws instead of returning an error response (MINOR / WebKit quirk)
**What happens:** `fetch('missing.json')` on a `file://` page throws a
`TypeError: Load failed` exception rather than returning a Response with
`ok=false` and a status code.
**Why it matters:** Code using `fetch().then(r => r.ok ? ... : ...)` will
hit the catch branch instead of the error-handling branch. This is
different from HTTP behavior.
**Likely cause:** Standard WebKit `file://` behavior — there's no HTTP
status code to return for a missing local file, so the fetch promise
rejects.
**Suggested fix:** None required — this is upstream WebKit behavior and is
consistent with Safari and other WebKit-based browsers. Document it as a
known difference from HTTP.
**Severity:** Very low. Developers writing local-file apps should use
try/catch with fetch.
---
### Issue #4 — Page title shows `(none)` in browser log on initial load (FIXED ✅)
**What was happening:** The browser log frequently showed
`[loaded] file://... -- title: (none)` even for pages that have a `<title>`.
The title arrived slightly after the `LOAD_FINISHED` event.
**Fix applied:**
1. Added a new [`on_title_changed()`](src/tab_manager.c:1361) handler
connected to the webview's `notify::title` signal. It logs the title
as `[title] URL -- Title` when the title actually becomes available,
and updates the tab title.
2. Simplified the `[loaded]` log line in `on_load_changed()` to just log
the URL (no more `title: (none)` noise). The title is now logged
separately via the `notify::title` handler.
**Verified:** After the fix, the log shows:
```
[title] file:///...index.html -- Local Site — Home
[loaded] file:///...index.html
```
instead of the old `[loaded] ... -- title: (none)`.
---
### Issue #5 — Cache API (CacheStorage) rejects non-HTTP/HTTPS URLs (MINOR / WebKit restriction)
**What happens:** `caches.open('my_cache')` succeeds, but
`cache.put(request, response)` rejects with the error:
`"Request url is not HTTP/HTTPS"` when the request URL is not an HTTP/HTTPS
URL. This means the Cache API cannot be used to store entries keyed by
`file://` URLs or custom schemes.
**Why it matters:** Web apps that use the Cache API for offline storage
(e.g., service workers caching responses) will not work on `file://` pages.
The Cache API is the only standard browser storage that has this restriction
— localStorage, sessionStorage, IndexedDB, and cookies all work fine on
`file://`.
**Likely cause:** This is a WebKit engine restriction. The Cache API
spec requires that request URLs be HTTP or HTTPS. WebKit enforces this
at the `cache.put()` level. This is consistent with Chrome and Firefox
behavior — the Cache API is designed for service worker contexts which
only exist on HTTP/HTTPS origins.
**Suggested fix:** None required — this is a fundamental WebKit/W3C
specification restriction, not a sovereign_browser bug. Local-file web
apps that need key-value storage should use IndexedDB instead, which
works perfectly on `file://`.
**Severity:** Low. Only affects apps specifically using the Cache API,
which is a niche use case for local-file apps. IndexedDB is the
recommended alternative and works fully.
---
### Issue #6 — Service Workers don't work on file:// (MINOR / WebKit restriction)
**What happens:** `navigator.serviceWorker.register()` rejects with the
error: "serviceWorker.register() must be called with a script URL whose
protocol is either HTTP or HTTPS".
**Why it matters:** Service Workers cannot be used on `file://` pages.
This means PWA features like offline caching via service workers,
background sync, and push notifications won't work for local-file apps.
**Likely cause:** This is a W3C specification restriction — Service
Workers are only allowed on HTTP/HTTPS origins. WebKit enforces this at
the `register()` level. This is consistent with all major browsers.
**Suggested fix:** None required — this is a fundamental specification
restriction. Web Workers (regular, non-service workers) DO work on
`file://` and can be used for background computation. For offline
storage, use IndexedDB.
**Severity:** Low. Service Workers are a niche use case for local-file
apps. Web Workers are the alternative for background processing and work
fully.
---
## What Works Surprisingly Well
These are the things that could have been broken but weren't:
1. **Cross-origin access between all `file://` URLs** — iframes can access
their parent's DOM and vice versa, `window.open()` popups can be
accessed, and `fetch()`/XHR work across directories. This is the single
most important requirement for local-file web apps and it works
perfectly. WebKitGTK treats all `file://` URLs as same-origin.
2. **Relative path resolution**`../`, `../../`, and `../../../` all work
correctly for links, images, stylesheets, scripts, and fetch targets.
3. **URL-encoded filenames**`encoded%20name.html` (file on disk:
`encoded name.html`) loads correctly.
4. **Query strings and hash fragments** — preserved in `location.href`,
accessible via `location.search` / `location.hash`, and don't break file
loading.
5. **`history.pushState` / `replaceState`** — work on `file://` URLs,
allowing SPA-style routing for local apps.
6. **All browser storage except Cache API**`localStorage`,
`sessionStorage`, `cookies`, and `IndexedDB` all work fully on
`file://`. IndexedDB supports open, upgrade, object stores, put, get,
and count operations. Only the Cache API (CacheStorage) fails because
WebKit requires HTTP/HTTPS URLs for cache entries (Issue #5). Use
IndexedDB as the recommended storage for local-file apps.
7. **`<picture>` / `srcset`** — responsive image selection works.
8. **Form submissions** — both GET and POST navigate to the target local
file. GET appends query strings; POST silently drops the body (correct,
since there's no server).
9. **Video and audio playback** — both MP4 video and M4A audio play
correctly from `file://` URLs. The `<video>` element reports correct
dimensions (640x360), duration (596.5s), and reaches `can play` state.
The `<audio>` element similarly works with correct duration. Media
playback is fully functional for local files.
10. **ES Modules** — dynamic `import()` works on `file://` pages, allowing
modern modular JavaScript with local module files.
11. **Web Workers and Shared Workers**`new Worker()` and
`new SharedWorker()` with local script files both work, enabling
background computation threads and cross-tab shared workers from
`file://` pages.
12. **WebAssembly**`WebAssembly.instantiate()` works, enabling
high-performance compiled code on `file://` pages.
13. **Cross-origin fetch to remote HTTPS URLs**`fetch('https://example.com')`
from a `file://` page returns `status=200, type=basic` with no CORS
blocking. This confirms the "deprecated web security" design goal is
working — local web apps can freely call remote APIs.
14. **Cross-origin remote resources** — remote `<script src>`, `<link
rel=stylesheet>`, and `<img>` from HTTPS CDNs all load and execute
correctly from `file://` pages.
---
## Recommended Actions
### No blocking fixes needed
The local-file browsing feature works well enough for production use as-is.
The issues found are minor UX/engine-quirk issues, not functional blockers.
### Optional improvements (in priority order)
1. **~~Fix Issue #1 (about:blank on load failure)~~** — ✅ FIXED. The
failed URL now stays in the address bar, and the tab title shows the
error message.
2. **Document the `fetch()`-throws-on-missing-file behavior** — Add a note
to the docs or a FAQ that `fetch()` on `file://` rejects (throws) for
missing files rather than returning an HTTP error response.
3. **Document the Cache API limitation (Issue #5)** — Note in the docs that
the Cache API (CacheStorage) does not work on `file://` pages because
WebKit requires HTTP/HTTPS URLs for cache entries. Recommend IndexedDB
as the alternative for local-file apps needing structured storage. (Not
a real problem for local files since everything is already local — no
need to cache.)
4. **~~Fix Issue #4 (title logging)~~** — ✅ FIXED. Added `notify::title`
handler; log now shows `[title] URL -- Title` when the title arrives,
and `[loaded] URL` without the noisy `title: (none)`.
5. **Issue #2 (media error events)** — No action needed; upstream WebKit
behavior.
---
## How to Reproduce
```bash
# Start the browser with auto-login and open the test site
./browser.sh start --login-method generate \
--url "file:///home/user/lt/sovereign_browser/tests/local-site/index.html"
# Navigate via MCP (examples):
curl -s -X POST http://localhost:17777/mcp -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"open","arguments":{"url":"file:///home/user/lt/sovereign_browser/tests/local-site/data.html"}}}'
# Check fetch results:
curl -s -X POST http://localhost:17777/mcp -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"eval","arguments":{"script":"document.getElementById(\"fetch-out\").textContent"}}}'
# Test a missing page:
curl -s -X POST http://localhost:17777/mcp -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"open","arguments":{"url":"file:///home/user/lt/sovereign_browser/tests/local-site/does-not-exist.html"}}}'
# Then check: get_url returns "about:blank" (Issue #1)
```
---
## Test Site Structure
```
tests/local-site/
├── index.html # Home page with links to all pages
├── about.html # Sibling page, anchor links
├── contact.html # Parent-relative link test
├── gallery.html # Images, picture, srcset, lazy load
├── data.html # fetch() + XHR + JSON + missing file + query
├── media.html # video, audio, object, embed, inline SVG
├── iframe.html # iframes (sibling, subdir, srcdoc, data:), cross-frame access
├── form.html # GET/POST forms, target=_blank, target=iframe
├── form-target.html # Form submission target
├── storage.html # Storage page with buttons (localStorage, sessionStorage, IndexedDB, cookies)
├── storage-test.html # Automated storage test (all types incl. Cache API, writes results to DOM)
├── popup.html # window.open() tests
├── history.html # pushState, replaceState, back, hashchange
├── hash.html # Hash fragment navigation
├── query.html # Query string handling
├── encoded name.html # URL-encoded filename (literal space)
├── 404.html # Placeholder (not actually missing)
├── advanced.html # Advanced JS test (ES Modules, Workers, WASM, cross-origin fetch)
├── assets/
│ ├── site.css # Shared stylesheet
│ ├── favicon.svg # SVG favicon
│ ├── logo.svg # SVG image
│ ├── pic.svg # SVG image
│ ├── data.json # JSON for fetch/XHR tests
│ ├── home.js # External script for index.html
│ ├── external.js # External script for scripts/external.html
│ ├── advanced-runner.js # Advanced JS test runner
│ ├── es-module-test.js # ES Module (import/export) test
│ ├── worker-test.js # Web Worker script
│ ├── shared-worker-test.js # Shared Worker script
│ ├── sw-test.js # Service Worker script (expected to fail on file://)
│ ├── sample.mp4 # Test video (Big Buck Bunny, gitignored)
│ └── Neon Dream.m4a # Test audio (gitignored)
├── scripts/
│ └── external.html # Page in a subdirectory loading ../assets/external.js
└── subdir/
├── page.html # Subdirectory page with ../ links
├── sub.json # JSON for subdir fetch test
└── deep/
└── deeper/
└── deepest.html # 3-levels-deep page with ../../../ links
```

View File

@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Local Site — About</title>
<link rel="stylesheet" href="assets/site.css">
</head>
<body>
<header>
<h1>About</h1>
<nav>
<a href="index.html">Home</a>
<a href="about.html">About</a>
<a href="contact.html">Contact</a>
<a href="gallery.html">Gallery</a>
<a href="data.html">Data</a>
</nav>
</header>
<main>
<p>This is the About page. It links back to the
<a href="index.html">home page</a> and to a
<a href="subdir/page.html">subdirectory page</a>.</p>
<h2>Nested relative link</h2>
<p>From here, a link to a deeply nested page:
<a href="subdir/deep/deeper/deepest.html">Deepest page</a>.</p>
<h2>Anchor on this page</h2>
<p>Jump to <a href="#bottom">the bottom</a>.</p>
<p id="bottom">This is the bottom of the About page.</p>
</main>
</body>
</html>

View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Local Site — Advanced JS Test</title>
<link rel="stylesheet" href="assets/site.css">
</head>
<body>
<header>
<h1>Advanced JS Test Page</h1>
<nav><a href="index.html">Home</a></nav>
</header>
<main>
<h2>Results</h2>
<pre id="results">Running tests...</pre>
</main>
<script src="assets/advanced-runner.js"></script>
</body>
</html>

View File

@@ -0,0 +1,247 @@
// Advanced JS test runner — writes results to #results as tests complete.
(function () {
var log = [];
function flush() {
var el = document.getElementById('results');
if (el) el.textContent = log.join('\n');
}
function add(msg) {
log.push(msg);
flush();
}
add('=== Advanced JS Test Start ===');
add('URL: ' + location.href);
add('');
// ── 1. ES Modules ──────────────────────────────────────────────
add('--- ES Modules ---');
add('Testing <script type="module"> with local import...');
// We'll use a dynamic import() to test module loading
try {
import('./es-module-test.js')
.then(function (mod) {
add('import(): ok');
add(' mod.greet("World") = ' + mod.greet('World'));
add(' mod.add(2, 3) = ' + mod.add(2, 3));
add(' mod.PI = ' + mod.PI);
add('ES MODULES: PASS');
testWebWorker();
})
.catch(function (e) {
add('import(): ERROR - ' + e.message);
add('ES MODULES: FAIL');
testWebWorker();
});
} catch (e) {
add('import() not supported: ' + e.message);
add('ES MODULES: FAIL (import() not supported)');
testWebWorker();
}
// ── 2. Web Workers ─────────────────────────────────────────────
function testWebWorker() {
add('');
add('--- Web Workers ---');
add('Testing new Worker() with local script...');
try {
var worker = new Worker('assets/worker-test.js');
add('new Worker(): ok (created)');
worker.onmessage = function (e) {
add('worker.onmessage: ' + e.data);
add('WEB WORKERS: PASS');
worker.terminate();
testServiceWorker();
};
worker.onerror = function (e) {
add('worker.onerror: ' + (e.message || 'error'));
add('WEB WORKERS: FAIL');
testServiceWorker();
};
// Send a message to the worker
worker.postMessage({ cmd: 'compute', a: 7, b: 6 });
add('postMessage sent: {cmd: "compute", a: 7, b: 6}');
} catch (e) {
add('new Worker(): ERROR - ' + e.message);
add('WEB WORKERS: FAIL');
testServiceWorker();
}
}
// ── 3. Service Workers ─────────────────────────────────────────
function testServiceWorker() {
add('');
add('--- Service Workers ---');
add('available: ' + ('serviceWorker' in navigator));
if (!('serviceWorker' in navigator)) {
add('SERVICE WORKERS: not available');
testWebAssembly();
return;
}
try {
navigator.serviceWorker.register('assets/sw-test.js')
.then(function (reg) {
add('register(): ok (scope=' + reg.scope + ')');
add('SERVICE WORKERS: PASS (registered)');
testWebAssembly();
})
.catch(function (e) {
add('register(): ERROR - ' + (e.message || e));
add('SERVICE WORKERS: FAIL');
testWebAssembly();
});
} catch (e) {
add('register(): EXCEPTION - ' + e.message);
add('SERVICE WORKERS: FAIL');
testWebAssembly();
}
}
// ── 4. WebAssembly ─────────────────────────────────────────────
function testWebAssembly() {
add('');
add('--- WebAssembly ---');
add('available: ' + (typeof WebAssembly !== 'undefined'));
if (typeof WebAssembly === 'undefined') {
add('WEBASSEMBLY: not available');
testCrossOriginFetch();
return;
}
try {
// Minimal Wasm module: exports an "add" function
// (wasm binary: (module (func (export "add") (param i32 i32) (result i32) local.get 0 local.get 1 i32.add)))
var bytes = new Uint8Array([
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
0x01, 0x07, 0x01, 0x60, 0x02, 0x7f, 0x7f, 0x01, 0x7f,
0x03, 0x02, 0x01, 0x00,
0x07, 0x07, 0x01, 0x03, 0x61, 0x64, 0x64, 0x00, 0x00,
0x0a, 0x09, 0x01, 0x07, 0x00, 0x20, 0x00, 0x20, 0x01, 0x6a, 0x0b
]);
WebAssembly.instantiate(bytes).then(function (result) {
var addFn = result.instance.exports.add;
add('instantiate(): ok');
add(' add(3, 4) = ' + addFn(3, 4));
add(' add(100, 200) = ' + addFn(100, 200));
add('WEBASSEMBLY: PASS');
testCrossOriginFetch();
}).catch(function (e) {
add('instantiate(): ERROR - ' + e.message);
add('WEBASSEMBLY: FAIL');
testCrossOriginFetch();
});
} catch (e) {
add('instantiate(): EXCEPTION - ' + e.message);
add('WEBASSEMBLY: FAIL');
testCrossOriginFetch();
}
}
// ── 5. Cross-origin fetch to remote HTTP/HTTPS ─────────────────
function testCrossOriginFetch() {
add('');
add('--- Cross-origin fetch (file:// → https://) ---');
// Test 1: fetch a remote page
add('Test 1: fetch("https://example.com")...');
try {
fetch('https://example.com')
.then(function (r) {
add(' status=' + r.status + ' ok=' + r.ok + ' type=' + r.type);
return r.text();
})
.then(function (text) {
add(' body length=' + text.length + ' chars');
add(' body starts with: ' + text.substring(0, 80).replace(/\n/g, ' '));
add('CROSS-ORIGIN FETCH (page): PASS');
testRemoteImage();
})
.catch(function (e) {
add(' ERROR - ' + e.message);
add('CROSS-ORIGIN FETCH (page): FAIL');
testRemoteImage();
});
} catch (e) {
add(' EXCEPTION - ' + e.message);
add('CROSS-ORIGIN FETCH (page): FAIL');
testRemoteImage();
}
}
// Test 2: load a remote image
function testRemoteImage() {
add('');
add('Test 2: remote image from https://...');
var img = new Image();
img.onload = function () {
add(' loaded: ' + img.naturalWidth + 'x' + img.naturalHeight);
add('CROSS-ORIGIN IMAGE: PASS');
testRemoteScript();
};
img.onerror = function () {
add(' ERROR: image failed to load');
add('CROSS-ORIGIN IMAGE: FAIL');
testRemoteScript();
};
img.src = 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/8a/Banana-Single.jpg/240px-Banana-Single.jpg';
add(' img.src set to remote URL...');
}
// Test 3: load a remote script
function testRemoteScript() {
add('');
add('Test 3: remote <script src> from https://...');
var s = document.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.3/dist/confetti.browser.min.js';
s.onload = function () {
add(' loaded: script executed');
add(' typeof confetti = ' + (typeof confetti));
add('CROSS-ORIGIN SCRIPT: PASS');
testRemoteStylesheet();
};
s.onerror = function () {
add(' ERROR: script failed to load');
add('CROSS-ORIGIN SCRIPT: FAIL');
testRemoteStylesheet();
};
document.head.appendChild(s);
add(' script.src set to remote URL...');
}
// Test 4: load a remote stylesheet
function testRemoteStylesheet() {
add('');
add('Test 4: remote <link rel=stylesheet> from https://...');
var link = document.createElement('link');
link.rel = 'stylesheet';
link.href = 'https://cdn.jsdelivr.net/npm/water.css@2/out/light.css';
link.onload = function () {
add(' loaded: stylesheet applied');
add(' stylesheet count = ' + document.styleSheets.length);
add('CROSS-ORIGIN STYLESHEET: PASS');
finishAll();
};
link.onerror = function () {
add(' ERROR: stylesheet failed to load');
add('CROSS-ORIGIN STYLESHEET: FAIL');
finishAll();
};
document.head.appendChild(link);
add(' link.href set to remote URL...');
}
function finishAll() {
add('');
add('=== Advanced JS Test Complete ===');
flush();
}
})();

View File

@@ -0,0 +1,13 @@
{
"source": "assets/data.json",
"loaded": true,
"items": [
{ "id": 1, "name": "alpha" },
{ "id": 2, "name": "beta" },
{ "id": 3, "name": "gamma" }
],
"meta": {
"generated": "2026-07-16",
"count": 3
}
}

View File

@@ -0,0 +1,16 @@
// ES Module test — imported by advanced-runner.js via dynamic import()
export const PI = 3.14159;
export function greet(name) {
return 'Hello, ' + name + '!';
}
export function add(a, b) {
return a + b;
}
export default {
PI: PI,
greet: greet,
add: add
};

Some files were not shown because too many files have changed in this diff Show More