Compare commits

...

55 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
Laan Tungir
02a92f7a26 v0.0.19 - Fix new window tabs: full tab infrastructure (toolbar, URL bar, tab label, favicon) in new windows, new tabs open in active window's notebook, multi-window tab management 2026-07-13 06:31:16 -04:00
Laan Tungir
a722a7cc67 v0.0.18 - Add real new window support for target=_blank and Open in New Window — uses webkit_web_view_new_with_related_view() to share WebProcess, window focus tracking for MCP active webview 2026-07-13 06:09:41 -04:00
Laan Tungir
d032cef0ab v0.0.17 - Fix browser crash on target=_blank links (WebKit WindowFeatures assertion), MCP session recovery on browser restart, click_at tool for coordinate-based clicking 2026-07-12 18:28:01 -04:00
Laan Tungir
775bf27ab0 v0.0.16 - MCP server improvements: coordinate-based clicking via GDK events, bounding box ref storage, stale ref fallback, click_at tool, GDK keyboard events for fill/type, NIP-07 bridge fix (sync XHR), page aesthetics matching client, inspector shortcut Ctrl+Shift+I, Night/Day theme toggle 2026-07-12 18:02:28 -04:00
Laan Tungir
f01a345b7c v0.0.15 - Added search engine integration with URL bar dropdown: DuckDuckGo default, direct links first from history/bookmarks/domain heuristic, async search suggestions, Tab key navigation through dropdown, configurable engine in settings (synced via NIP-78) 2026-07-12 15:47:35 -04:00
Laan Tungir
f91ff74138 v0.0.14 - Migrated all storage to SQLite (history, session, settings), removed key persistence (keys now in-memory only), added back/forward nav buttons, NIP-44 encrypted bookmarks with directories, sovereign://bookmarks page, bookmark toolbar button, sovereign://profile page, QR codes, No-Login mode, bootstrap relay fetch, fixed browser.sh stop killing Roo Code 2026-07-12 13:43:14 -04:00
Laan Tungir
8c75c74c38 v0.0.13 - Consolidated settings into sovereign://settings page, added QR codes (libqrencode), SQLite event storage, No-Login mode, bootstrap relay fetch, sovereign://profile page, and fixed browser.sh stop killing Roo Code 2026-07-12 11:01:36 -04:00
Laan Tungir
372339aa35 v0.0.12 - MCP server: 100 tools, Streamable HTTP transport, favicons, refresh button, webview sizing fix
- MCP protocol compliance: ping, resources/list, prompts/list, logging/setLevel, 202 notifications
- Screenshot tool: WebKit snapshot to PNG to base64 MCP image content
- Session management: Mcp-Session-Id header, SSE response format, GET/DELETE endpoints
- Phase 3: 70 new tools across 8 batches (extended interaction, get/state, find elements, wait/batch, cookies/storage, mouse/clipboard/settings, frames/dialogs/debug, complex)
- Favicon support: enabled WebKitFaviconDatabase, notify::favicon signal handler
- Tab UI: favicons in tab labels, evenly distributed tabs, immediate title from URL host
- Refresh button: left-click reload, right-click hard reload menu (bypass cache, clear site data)
- Webview sizing fix: gtk_widget_set_vexpand/hexpand to prevent 1px height blank page
- Updated Roo Code MCP config (100 tools in alwaysAllow)
- Updated plans: mcp-server.md, agent-tools.md, phase3-tools.md
2026-07-12 09:20:14 -04:00
Laan Tungir
c6495beb6d v0.0.11 - Implemented MCP server endpoint at /mcp. All 29 tools exposed with JSON schemas. initialize, tools/list, and tools/call all working via curl. Async tools (snapshot, eval) work via sync fallback when conn=NULL. Both /agent (WebSocket) and /mcp (HTTP) endpoints available. 2026-07-12 06:27:33 -04:00
Laan Tungir
3c9be0da01 v0.0.10 - Converted all JS-based tools to async evaluation: get_text, get_html, get_attr, click, fill, type, hover, focus, press, scroll. All 12 tools now pass end-to-end test: login, open, snapshot, get_text, get_html, get_attr, eval, click (navigated to IANA page), scroll, tab_list, get_url. 2026-07-12 06:02:48 -04:00
Laan Tungir
d006d3fa0d v0.0.9 - Fixed snapshot tool: AGENT_SNAPSHOT_JS was self-executing IIFE causing syntax error when wrapped with args. Changed to function definition. Snapshot now works async. Eval works async. Login, open, get_title, tab_list all pass. 2026-07-11 22:09:48 -04:00
Laan Tungir
c6af0179b0 v0.0.8 - Fixed WebSocket agent server: g_object_ref on connections to prevent premature destruction. Agent login now auto-closes GTK dialog via timeout check. All 9 login tests pass. 2026-07-11 21:25:29 -04:00
Laan Tungir
fc0e412cc5 v0.0.7 - Fixed agent login flow: browser starts normally with GTK dialog, no blocking wait. Agent server is available throughout — agent can log in at any time (during dialog or after browser is running). Agent login takes priority over dialog if both occur. 2026-07-11 21:12:10 -04:00
Laan Tungir
d64bd45f54 v0.0.6 - Updated README with agent tools documentation: login tools, browser tools, snapshot+ref workflow, settings, roadmap update 2026-07-11 20:47:05 -04:00
Laan Tungir
5c52198d9e v0.0.5 - Implemented agent tools Phase 1: WebSocket server (libsoup), login tools (local/seed/readonly/nip46/nsigner), snapshot+ref system, 31 browser automation tools (open, click, fill, type, eval, scroll, tabs, etc.), agent-driven login with GTK dialog fallback 2026-07-11 20:46:24 -04:00
Laan Tungir
0160b70dc2 v0.0.4 - Implemented browser tabs with GtkNotebook, per-tab toolbars, session restore, settings dialog, and keyboard shortcuts. Made Other Qube (qrexec) the default n_signer transport. 2026-07-11 20:17:56 -04:00
201 changed files with 53952 additions and 1002 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

72
.roo/agents.md Normal file
View File

@@ -0,0 +1,72 @@
# Agent Instructions — sovereign_browser
## Browser Management
**ALWAYS use `./browser.sh` to start/stop/restart the browser.** Never launch `./sovereign_browser` directly — it will hang the terminal because the GUI process keeps stdout/stderr open.
```bash
./browser.sh start # build + launch (detached, waits for MCP ready)
./browser.sh stop # kill running instance
./browser.sh restart # stop + build + start
./browser.sh status # check if running + MCP responsive
./browser.sh log # tail last 50 lines of browser log
```
The browser's MCP server runs on `http://localhost:17777/mcp` (Streamable HTTP transport).
## Login
The browser requires Nostr login before any browser tools work. The quickest way to log in is with a random key:
```bash
# Via MCP (curl):
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":"random"}}}'
```
Or use the `login` MCP tool with `{"method": "random"}`. This generates a fresh random Nostr keypair and logs in immediately. The response includes the `nsec` so you can save it if needed.
Other login methods: `local` (nsec/hex), `seed` (mnemonic), `readonly` (npub), `nip46` (bunker URL), `nsigner` (hardware signer).
## MCP Server
The browser exposes 100 MCP tools for browser automation:
- **Login tools**: `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`, `keyboard_type`, `insert_text`, `keydown`, `keyup`, `scroll_into_view`
- **Find elements**: `find_role`, `find_text`, `find_label`, `find_placeholder`, `find_alt`, `find_title`, `find_testid`, `find_first`, `find_last`, `find_nth`
- **Get info**: `get_value`, `get_count`, `get_box`, `get_styles`
- **Check state**: `is_visible`, `is_enabled`, `is_checked`
- **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`, `wait_for_fn`
- **Batch**: `batch` (execute multiple tool calls in sequence)
- **Cookies & storage**: `cookies_get`, `cookies_set`, `cookies_clear`, `storage_local_*`, `storage_session_*`
- **Mouse**: `mouse_move`, `mouse_down`, `mouse_up`, `mouse_wheel`
- **Clipboard**: `clipboard_read`, `clipboard_write`, `clipboard_copy`, `clipboard_paste`
- **Settings**: `set_viewport`, `set_media`, `set_offline`, `set_headers`, `set_credentials`
- **Frames**: `frame_switch`, `frame_main`
- **Dialogs**: `dialog_accept`, `dialog_dismiss`, `dialog_status`
- **Debug**: `console`, `errors`, `highlight`
- **State**: `state_save`, `state_load`
- **Files**: `upload`, `pdf`
## Common Workflow
1. `./browser.sh start` (or `restart` if already running)
2. Login: call `login` tool with `{"method": "random"}`
3. Navigate: call `open` tool with a URL
4. Snapshot: call `snapshot` to get the accessibility tree with element refs
5. Interact: use refs (e.g. `@e1`) with `click`, `fill`, etc.
6. Screenshot: call `screenshot` to see the page visually
## Building
```bash
make # build the browser
make clean # clean build artifacts
```
The browser is a WebKitGTK + C99 application. It links against `webkit2gtk-4.1`, `libsoup-3.0`, and `nostr_core_lib` (vendored).

108
.roo/mcp.json Normal file
View File

@@ -0,0 +1,108 @@
{
"mcpServers": {
"sovereign-browser": {
"url": "http://localhost:17777/mcp",
"type": "streamable-http",
"alwaysAllow": [
"login_status",
"snapshot",
"click",
"click_at",
"screenshot",
"login",
"logout",
"switch_identity",
"open",
"back",
"forward",
"reload",
"get_url",
"get_title",
"get_attr",
"get_value",
"get_count",
"get_box",
"get_styles",
"is_visible",
"is_enabled",
"is_checked",
"eval",
"fill",
"type",
"press",
"hover",
"focus",
"close",
"tab_list",
"tab_new",
"tab_switch",
"tab_close",
"wait",
"wait_for",
"screenshot_annotated",
"pdf",
"upload",
"state_load",
"get_text",
"get_html",
"scroll",
"dblclick",
"select",
"check",
"uncheck",
"scroll_into_view",
"insert_text",
"keydown",
"keyup",
"drag",
"close_all",
"find_role",
"find_label",
"find_placeholder",
"find_alt",
"find_title",
"find_testid",
"find_first",
"find_last",
"find_nth",
"wait_for_text",
"wait_for_url",
"wait_for_load",
"batch",
"cookies_get",
"cookies_set",
"cookies_clear",
"storage_local_get",
"storage_local_get_key",
"storage_local_set",
"storage_local_clear",
"storage_session_get",
"storage_session_get_key",
"storage_session_set",
"storage_session_clear",
"mouse_move",
"mouse_down",
"mouse_up",
"mouse_wheel",
"clipboard_read",
"clipboard_write",
"clipboard_copy",
"clipboard_paste",
"set_viewport",
"set_offline",
"set_headers",
"set_credentials",
"set_media",
"frame_switch",
"frame_main",
"dialog_accept",
"dialog_dismiss",
"dialog_status",
"console",
"errors",
"highlight",
"state_save"
]
}
}
}

53
.roorules Normal file
View File

@@ -0,0 +1,53 @@
# sovereign_browser — Agent Rules
## Browser Management (CRITICAL)
**ALWAYS use `./browser.sh` to start/stop/restart the browser.** Never run `./sovereign_browser` directly — it will hang the terminal because the GUI process keeps stdout/stderr open, which blocks the command runner.
```bash
./browser.sh start [ARGS...] # build + launch (detached, waits for MCP ready), forwards ARGS
./browser.sh stop # kill ALL running instances
./browser.sh restart [ARGS...]# stop + build + start, forwards ARGS
./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:
```bash
# Generate a fresh key and log in immediately — no credentials needed
./browser.sh start --login-method generate
# Generate a key and open a URL in one command
./browser.sh start --login-method generate --url https://example.com
# Log in with a local key (nsec)
./browser.sh start --login-method local --nsec nsec1...
# Read-only mode (npub, no signing)
./browser.sh start --login-method readonly --npub npub1...
```
Other login methods: `seed` (BIP-39 mnemonic), `nip46` (bunker:// URL), `nsigner` (hardware signer). Run `./sovereign_browser --help` for the full flag list.
Alternatively, you can still log in at runtime via the MCP `login` tool with `{"method": "generate"}` (or `local`, `seed`, `readonly`, `nip46`, `nsigner`).
## MCP Server
- 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
## Building
```bash
make # build
make clean # clean
```
WebKitGTK + C99. Links `webkit2gtk-4.1`, `libsoup-3.0`, `nostr_core_lib` (vendored).

View File

@@ -5,23 +5,40 @@
# libsecp256k1-dev, libssl-dev, libcurl4-openssl-dev
CC ?= gcc
CFLAGS ?= -std=c99 -Wall -Wextra -O2
CFLAGS += $(shell pkg-config --cflags webkit2gtk-4.1)
# 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
LDFLAGS ?=
LDLIBS += $(shell pkg-config --libs webkit2gtk-4.1)
LDLIBS += $(shell pkg-config --libs webkit2gtk-4.1 libsoup-3.0 libqrencode sqlite3)
# nostr_core_lib static library + its dependencies
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 := 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

391
README.md
View File

@@ -29,27 +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 proof-of-concept: a C99 + WebKitGTK window with a URL bar that loads
real HTTPS pages. 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:
@@ -57,7 +155,91 @@ 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
GTK init, so they don't conflict with GTK's own options. Flags override
`~/.sovereign_browser/settings.conf` for the current run only (nothing is
written to disk).
### Browser / Startup
| Flag | Description |
|---|---|
| `[URL...]` / `--url <url>` | Open URL(s) in tabs at startup (repeatable) |
| `--new-tab-url <url>` | Override the default new-tab URL |
| `--no-session-restore` | Skip session restore for this run |
| `--session-restore` | Force session restore even if disabled |
| `--max-tabs <n>` | Override max simultaneous tabs |
| `-V, --version` | Print version and exit |
| `-h, --help` | Show help and exit |
### Agent Server
| Flag | Description |
|---|---|
| `--port <port>` | Override agent MCP server port (default 17777) |
| `--no-agent` | Disable the agent server for this run |
| `--agent` | Force-enable the agent server |
| `--agent-origin <origin>` | Allowed origin (repeatable) |
### Login (skips the GTK login dialog)
Specify `--login-method` to bypass the interactive login dialog. The method
string matches the MCP `login` tool. Method-specific flags provide
credentials.
| Flag | Description |
|---|---|
| `--login-method <m>` | `generate\|local\|seed\|readonly\|nip46\|nsigner` |
| `--nsec <nsec1...>` | (local) nsec bech32 private key |
| `--privkey <hex>` | (local) 64-char hex private key |
| `--mnemonic <words>` | (seed) BIP-39 mnemonic, quoted |
| `--account <n>` | (seed) BIP-44 account index (default 0) |
| `--npub <npub1...>` | (readonly) npub bech32 public key |
| `--pubkey <hex>` | (readonly) 64-char hex pubkey |
| `--bunker <url>` | (nip46) `bunker://` URL |
| `--nsigner-transport <t>` | (nsigner) `serial\|unix\|tcp\|qrexec` |
| `--nsigner-device <path>` | (nsigner) device path / socket / host:port / qube |
| `--nsigner-service <name>` | (nsigner) qrexec service name |
| `--nsigner-index <n>` | (nsigner) key index (default 0) |
| `--no-save-identity` | Do not persist identity to disk |
| `--login-timeout <ms>` | Override agent login timeout (GTK dialog path) |
### Diagnostics
| Flag | Description |
|---|---|
| `-v, --verbose` | Increase log verbosity (repeatable) |
| `-q, --quiet` | Suppress non-error log output |
### Examples
```bash
# Basic usage (via browser.sh — recommended)
./browser.sh start https://example.com
# Generate a fresh key, skip the login dialog, open a page
./browser.sh start --login-method generate --url https://example.com
# Local key from env, custom agent port
./browser.sh start --login-method local --nsec "$NSEC" --port 18888
# Read-only (npub), no agent server, two tabs
./browser.sh start --login-method readonly --npub npub1... \
--no-agent --url https://a.com --url https://b.com
# Open a local website (no web server needed)
./browser.sh start --login-method generate \
--url "file:///path/to/website/index.html"
```
## Architecture (summary)
@@ -80,7 +262,7 @@ make
┌─────────────────────────────────────────────────────┐
│ WebKitGTK (system lib, ~35 MB) │
Blink-grade renderer + V8 + libsoup network stack
WebCore renderer + JavaScriptCore + libsoup net
└─────────────────────────────────────────────────────┘
```
@@ -96,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 |
@@ -125,15 +308,179 @@ Supported methods:
See [`plans/nostr-login-integration.md`](plans/nostr-login-integration.md) for
the full implementation plan.
### Browser tabs
The browser supports multiple tabs via a `GtkNotebook`. Each tab has its own
toolbar (hamburger menu + URL entry) and `WebKitWebView`. All tabs share a
single `WebKitWebContext`, so the `sovereign://` Nostr bridge, security
settings, and TLS policy apply to every tab automatically.
**Tab features:**
- New tab: `Ctrl+T` or the `+` button at the end of the tab strip
- Close tab: `Ctrl+W`, per-tab close button, or middle-click on the tab label
- Switch tabs: `Ctrl+Tab` / `Ctrl+Shift+Tab`, `Ctrl+PageUp` / `Ctrl+PageDown`
- Focus URL bar: `Ctrl+L`
- Right-click tab context menu: New Tab, Close Tab, Close Other Tabs, Close
Tabs to the Right, Duplicate Tab, Reload Tab
- Drag tabs to reorder
- Tab titles update from page load
- Session save/restore: open tabs are saved on exit and restored on next
launch (configurable in Settings)
**Settings dialog** (hamburger menu → Settings…):
- Restore session on startup (default: on)
- New tab URL (default: `https://example.com`)
- Tab bar position: Top / Bottom / Left / Right (default: Top)
- Show tab close buttons (default: on)
- Middle-click to close tab (default: on)
- Ctrl+Tab to switch tabs (default: on)
- Maximum tabs (default: 50)
- Allow drag to reorder tabs (default: on)
Settings are persisted to `~/.sovereign_browser/settings.conf`. Session state
is saved to `~/.sovereign_browser/session.txt`.
See [`plans/browser-tabs.md`](plans/browser-tabs.md) for the full
implementation plan.
### Local file browsing
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.
**What works on `file://` pages:**
- **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
**Known limitations (WebKit engine quirks, not bugs):**
- `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
**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
# Open a local website
./browser.sh start --login-method generate \
--url "file:///path/to/your/website/index.html"
```
### Agent tools (MCP server)
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`.
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.
**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. ⏳ Nostr login (GTK dialog → nostr_core_lib → nostr_signer_t)
3. `window.nostr` injection (`sovereign://` URI scheme bridge)
4. ⏳ Security strip (disable SOP/CORS, accept any cert, shared context)
5. ⏳ FIPS URI scheme (`fips://` / `*.fips` via WebKitGTK scheme handler)
6. `nostr://` content scheme
7. Future: agent runtime (didactyl hosting), multi-window, FIPS-distributed
2. ✅ Browser tabs (GtkNotebook, per-tab toolbar, session restore, settings)
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.3
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

366
browser.sh Executable file
View File

@@ -0,0 +1,366 @@
#!/bin/bash
#
# browser.sh — safe start/stop/restart for sovereign_browser
#
# Agents (Roo Code, etc.) should ALWAYS use this script to manage the browser
# process. It fully detaches the GUI from the terminal so the agent's command
# runner doesn't hang waiting for output that never ends.
#
# Usage:
# ./browser.sh start [ARGS...] — build + launch (detached), forwarding ARGS
# ./browser.sh stop — kill ALL running instances
# ./browser.sh restart [ARGS...]— stop + build + start, forwarding ARGS
# ./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
# ./browser.sh restart --no-agent --url https://a.com --url https://b.com
#
# The browser's stdout/stderr go to /tmp/sovereign_browser.log
# The PID is stored in /tmp/sovereign_browser.pid
set -euo pipefail
BIN="./sovereign_browser"
LOG="/tmp/sovereign_browser.log"
PID_FILE="/tmp/sovereign_browser.pid"
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
# processes (e.g. Roo Code / VS Code extension host, which connects
# to the MCP server as a client and would otherwise be caught by
# a naive `lsof -ti :PORT` lookup).
is_browser_pid() {
local pid="$1"
[[ -z "$pid" ]] && return 1
kill -0 "$pid" 2>/dev/null || return 1
# /proc/<pid>/comm contains the executable name (truncated to 15 chars).
# For sovereign_browser it's "sovereign_brows" (15-char limit).
local comm
comm=$(ps -p "$pid" -o comm= 2>/dev/null | tr -d '[:space:]')
[[ "$comm" == "sovereign_browser" || "$comm" == "sovereign_brows" ]]
}
get_pid() {
# Primary: use the PID file (written by cmd_start).
if [[ -f "$PID_FILE" ]]; then
local pid
pid=$(cat "$PID_FILE" 2>/dev/null | head -n1 || echo "")
if [[ -n "$pid" ]] && is_browser_pid "$pid"; then
echo "$pid"
return 0
fi
fi
# 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 :"$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
fi
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 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
[[ -n "$pid" ]] && candidates+=("$pid")
done < "$PID_FILE" 2>/dev/null
fi
while IFS= read -r pid; do
[[ -n "$pid" ]] && candidates+=("$pid")
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
if is_browser_pid "$pid"; then
echo "[browser] Stopping PID $pid..."
kill "$pid" 2>/dev/null || true
stopped_any=1
fi
done
# Wait for graceful shutdown, then force-kill survivors (verified only).
if [[ "$stopped_any" -eq 1 ]]; then
sleep 1
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
fi
done
sleep 1
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 running?
if [[ -n "$(all_pids)" ]]; then
echo "[browser] Warning: some instances still running."
else
echo "[browser] Stopped."
fi
return 0
}
cmd_start() {
local extra_args=("$@")
# Don't start if already running
if get_pid >/dev/null 2>&1; then
echo "[browser] Already running (PID $(get_pid)). Use 'restart' to reload."
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
echo "[browser] Build failed!" >&2
return 1
fi
echo "[browser] Build OK."
# Launch fully detached:
# setsid — new session, detached from controlling terminal
# nohup — immune to SIGHUP
# < /dev/null — detach stdin (GUI processes sometimes block on this)
# > $LOG 2>&1 — redirect all output to log file
# & disown — background + remove from job table
echo "[browser] Launching (detached)..."
if [[ ${#extra_args[@]} -gt 0 ]]; then
echo "[browser] Forwarding args: ${extra_args[*]}"
fi
setsid nohup "$BIN" "${extra_args[@]}" < /dev/null > "$LOG" 2>&1 &
local pid=$!
disown 2>/dev/null || true
echo "$pid" > "$PID_FILE"
# 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
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, port $mcp_port)."
return 0
fi
sleep 0.1
done
echo "[browser] Warning: MCP server didn't respond within 10s." >&2
echo "[browser] Check log: $LOG" >&2
return 1
}
cmd_restart() {
cmd_stop
sleep 1
cmd_start "$@"
}
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
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"
else
echo "[browser] MCP server: NOT responding"
fi
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"
else
echo "[browser] No log file at $LOG"
fi
}
case "${1:-}" in
start) shift; cmd_start "$@" ;;
stop) cmd_stop ;;
restart) shift; cmd_restart "$@" ;;
status) cmd_status ;;
log) cmd_log ;;
mcp-url) cmd_mcp_url ;;
*)
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

View File

@@ -275,8 +275,11 @@ flowchart LR
### Layers
1. **Host application (C99).** Owns the window, URL bar, tabs, key store, and
the request router. This is where the "reckless" policy lives.
1. **Host application (C99).** Owns the window, tab strip (`GtkNotebook`),
per-tab toolbars (URL bar + hamburger menu), key store, and the request
router. This is where the "reckless" policy lives. Tab management is in
`tab_manager.c`, user preferences in `settings.c`, and session save/restore
in `session.c`.
2. **Request router.** Inspects every outgoing URL:
- `http://` / `https://` engine's normal network stack (with CORS /

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 "$@"

789
plans/agent-tools.md Normal file
View File

@@ -0,0 +1,789 @@
# Agent Tools Implementation Plan
## Overview
Make sovereign_browser agentically capable by embedding a WebSocket server
in the browser process. External AI agents (LLM tools, scripts, other
processes) connect to `ws://localhost:PORT` and send JSON tool commands to
navigate, inspect, and interact with web pages.
The design is inspired by [agent-browser](https://github.com/vercel-labs/agent-browser),
which uses a **snapshot + ref** workflow: take a snapshot (accessibility tree
with element refs), then interact by ref (click `@e2`, fill `@e3`). This is
the optimal pattern for LLM-driven browser automation.
## Architecture
```mermaid
flowchart TB
subgraph AgentProcess
Agent[AI Agent / Script / CLI]
end
subgraph BrowserProcess
WSServer[WebSocket Server — SoupServer]
ToolDispatch[Tool Dispatcher]
Snapshot[Snapshot Engine]
Tools[Tool Implementations]
TabMgr[Tab Manager]
end
subgraph WebKitGTK
Webview[WebKitWebView — active tab]
JSEval[webkit_web_view_evaluate_javascript]
end
Agent -->|ws://localhost:PORT JSON| WSServer
WSServer --> ToolDispatch
ToolDispatch --> Tools
Tools --> Snapshot
Tools --> TabMgr
Tools -->|JS eval| JSEval
JSEval --> Webview
Snapshot -->|inject + extract| JSEval
TabMgr --> Webview
```
### Key design decisions
0. **Agent tools must use the same code path as the GUI** — Agent tools
should call the same C functions the GUI calls, not a parallel
implementation. This ensures that any error a user would see, the
agent also sees. Debugging WebKit internals is not our concern; our
C code is. If the agent and GUI take different paths, bugs can hide
from the agent. The login tools are the one exception (backend-only,
bypassing the GTK dialog) — all browser tools go through the same
`WebKitWebView` and `tab_manager` APIs.
1. **WebSocket server via libsoup**`SoupServer` with
`soup_server_add_websocket_handler()` provides a production-ready
WebSocket server. libsoup is already linked (WebKitGTK dependency).
No new dependencies, no hand-rolling the WebSocket protocol.
2. **JSON protocol via cJSON** — cJSON is already vendored in
`nostr_core_lib/cjson/`. All tool requests and responses are JSON.
3. **Snapshot + ref pattern** — The core workflow:
- `snapshot` injects a JS script that walks the accessibility tree,
assigns sequential refs (`e1`, `e2`, ...) to interactive elements,
and returns a text representation of the tree.
- `click @e2` looks up the ref in a per-tab ref map, finds the DOM
element, and clicks it via `element.click()`.
- Refs are per-tab and persist until the next `snapshot` call.
4. **Per-tab ref storage** — Each `tab_info_t` gets a ref map (hash table
from ref string → element selector or DOM node reference). Since JS
execution is per-webview, refs are scoped to the tab's webview.
5. **Async JS execution**`webkit_web_view_evaluate_javascript()` is
async. For tools that need the return value (snapshot, get_text, eval),
we use a callback-based approach: the tool injects JS that stores the
result in a known global variable, then reads it back. Alternatively,
we can use a Promise-based approach with a polling mechanism.
6. **Simple HTTP fallback** — The WebSocket server also handles plain HTTP
GET requests to `http://localhost:PORT/` returning a status JSON
(server info, connected clients, tab count). This makes it easy to
discover the server with `curl` without a WebSocket client.
## WebSocket protocol
### Request format
```json
{
"id": 1,
"tool": "snapshot",
"params": {
"interactive": true,
"compact": true
}
}
```
### Response format
```json
{
"id": 1,
"success": true,
"data": {
"snapshot": "- heading \"Example Domain\" [ref=e1] [level=1]\n- link \"More information...\" [ref=e2]",
"refs": {
"e1": {"role": "heading", "name": "Example Domain", "level": 1},
"e2": {"role": "link", "name": "More information...", "href": "https://example.com"}
}
}
}
```
### Error response
```json
{
"id": 1,
"success": false,
"error": {
"code": "ELEMENT_NOT_FOUND",
"message": "No element with ref @e99"
}
}
```
### Push events (server → client)
```json
{
"type": "event",
"event": "load",
"data": {"url": "https://example.com", "title": "Example Domain", "tab": 0}
}
```
```json
{
"type": "event",
"event": "tab_changed",
"data": {"tab": 1, "url": "https://example.com", "title": "Example"}
}
```
## Tool catalog — full comparison with agent-browser
The table below lists every agent-browser CLI command and whether we plan
to implement it. "Phase 1" tools are in the initial implementation; "Phase 2"
tools are deferred but designed for; "N/A" tools don't apply to our
architecture (CDP-specific, cloud providers, etc.).
### Core commands
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `open <url>` | `open` | 1 | Navigate active tab |
| `click <sel>` | `click` | 1 | By ref or CSS selector |
| `dblclick <sel>` | `dblclick` | 2 | Double-click |
| `focus <sel>` | `focus` | 1 | Focus element |
| `type <sel> <text>` | `type` | 1 | Type into element |
| `fill <sel> <text>` | `fill` | 1 | Clear and fill |
| `press <key>` | `press` | 1 | Press keyboard key |
| `keyboard type <text>` | `keyboard_type` | 2 | Type with real keystrokes |
| `keyboard inserttext <text>` | `insert_text` | 2 | Insert text without key events |
| `keydown <key>` | `keydown` | 2 | Hold key down |
| `keyup <key>` | `keyup` | 2 | Release key |
| `hover <sel>` | `hover` | 1 | Hover element |
| `select <sel> <val>` | `select` | 2 | Select dropdown option |
| `check <sel>` | `check` | 2 | Check checkbox |
| `uncheck <sel>` | `uncheck` | 2 | Uncheck checkbox |
| `scroll <dir> [px]` | `scroll` | 1 | Scroll page |
| `scrollintoview <sel>` | `scroll_into_view` | 2 | Scroll element into view |
| `drag <src> <tgt>` | `drag` | 2 | Drag and drop |
| `upload <sel> <files>` | `upload` | 2 | Upload files |
| `screenshot [path]` | `screenshot` | 1 | Take screenshot |
| `screenshot --annotate` | `screenshot_annotated` | 2 | Annotated with element labels |
| `pdf <path>` | `pdf` | 2 | Save as PDF |
| `snapshot` | `snapshot` | 1 | Accessibility tree with refs |
| `eval <js>` | `eval` | 1 | Run JavaScript |
| `connect <port>` | N/A | — | CDP-specific, we are the server |
| `stream enable` | `stream_enable` | 2 | WebSocket viewport streaming |
| `stream status` | `stream_status` | 2 | Streaming state |
| `stream disable` | `stream_disable` | 2 | Stop streaming |
| `close` | `close` | 1 | Close active tab |
| `close --all` | `close_all` | 2 | Close all tabs |
### Get info commands
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `get text <sel>` | `get_text` | 1 | Get text content |
| `get html <sel>` | `get_html` | 1 | Get innerHTML |
| `get value <sel>` | `get_value` | 2 | Get input value |
| `get attr <sel> <attr>` | `get_attr` | 1 | Get attribute |
| `get title` | `get_title` | 1 | Get page title |
| `get url` | `get_url` | 1 | Get current URL |
| `get cdp-url` | N/A | — | CDP-specific |
| `get count <sel>` | `get_count` | 2 | Count matching elements |
| `get box <sel>` | `get_box` | 2 | Get bounding box |
| `get styles <sel>` | `get_styles` | 2 | Get computed styles |
### Check state commands
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `is visible <sel>` | `is_visible` | 2 | Check if visible |
| `is enabled <sel>` | `is_enabled` | 2 | Check if enabled |
| `is checked <sel>` | `is_checked` | 2 | Check if checked |
### Find elements (semantic locators)
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `find role <role> ...` | `find_role` | 2 | By ARIA role |
| `find text <text> ...` | `find_text` | 2 | By text content |
| `find label <label> ...` | `find_label` | 2 | By label |
| `find placeholder <ph> ...` | `find_placeholder` | 2 | By placeholder |
| `find alt <text> ...` | `find_alt` | 2 | By alt text |
| `find title <text> ...` | `find_title` | 2 | By title attr |
| `find testid <id> ...` | `find_testid` | 2 | By data-testid |
| `find first <sel> ...` | `find_first` | 2 | First match |
| `find last <sel> ...` | `find_last` | 2 | Last match |
| `find nth <n> <sel> ...` | `find_nth` | 2 | Nth match |
### Wait commands
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `wait <selector>` | `wait_for` | 1 | Wait for element visible |
| `wait <ms>` | `wait` | 1 | Wait for time |
| `wait --text "..."` | `wait_for_text` | 2 | Wait for text to appear |
| `wait --url "..."` | `wait_for_url` | 2 | Wait for URL pattern |
| `wait --load networkidle` | `wait_for_load` | 2 | Wait for load state |
| `wait --fn "..."` | `wait_for_fn` | 2 | Wait for JS condition |
### Batch execution
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `batch --json` | `batch` | 2 | Execute multiple commands |
### Clipboard
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `clipboard read` | `clipboard_read` | 2 | Read clipboard |
| `clipboard write "..."` | `clipboard_write` | 2 | Write clipboard |
| `clipboard copy` | `clipboard_copy` | 2 | Copy selection |
| `clipboard paste` | `clipboard_paste` | 2 | Paste from clipboard |
### Mouse control
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `mouse move <x> <y>` | `mouse_move` | 2 | Move mouse |
| `mouse down [button]` | `mouse_down` | 2 | Press button |
| `mouse up [button]` | `mouse_up` | 2 | Release button |
| `mouse wheel <dy> [dx]` | `mouse_wheel` | 2 | Scroll wheel |
### Browser settings
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `set viewport <w> <h>` | `set_viewport` | 2 | Set viewport size |
| `set device <name>` | `set_device` | N/A | Device emulation is CDP-specific |
| `set geo <lat> <lng>` | `set_geo` | N/A | Geolocation emulation |
| `set offline [on\|off]` | `set_offline` | 2 | Toggle offline |
| `set headers <json>` | `set_headers` | 2 | Extra HTTP headers |
| `set credentials <u> <p>` | `set_credentials` | 2 | HTTP basic auth |
| `set media [dark\|light]` | `set_media` | 2 | Emulate color scheme |
### Cookies and storage
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `cookies` | `cookies_get` | 2 | Get all cookies |
| `cookies set <name> <val>` | `cookies_set` | 2 | Set cookie |
| `cookies clear` | `cookies_clear` | 2 | Clear cookies |
| `storage local` | `storage_local_get` | 2 | Get localStorage |
| `storage local <key>` | `storage_local_get_key` | 2 | Get specific key |
| `storage local set <k> <v>` | `storage_local_set` | 2 | Set value |
| `storage local clear` | `storage_local_clear` | 2 | Clear all |
| `storage session` | `storage_session_*` | 2 | Same for sessionStorage |
### Network
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `network route <url>` | `network_route` | N/A | Request interception is CDP-specific |
| `network unroute` | `network_unroute` | N/A | CDP-specific |
| `network requests` | `network_requests` | N/A | CDP-specific |
| `network request <id>` | `network_request_detail` | N/A | CDP-specific |
| `network har start` | `har_start` | N/A | CDP-specific |
| `network har stop` | `har_stop` | N/A | CDP-specific |
### Tabs and windows
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `tab` | `tab_list` | 1 | List tabs |
| `tab new [url]` | `tab_new` | 1 | New tab |
| `tab <n>` | `tab_switch` | 1 | Switch to tab n |
| `tab close [n]` | `tab_close` | 1 | Close tab |
| `window new` | `window_new` | N/A | Multi-window is future roadmap |
### Frames
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `frame <sel>` | `frame_switch` | 2 | Switch to iframe |
| `frame main` | `frame_main` | 2 | Back to main frame |
### Dialogs
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `dialog accept [text]` | `dialog_accept` | 2 | Accept dialog |
| `dialog dismiss` | `dialog_dismiss` | 2 | Dismiss dialog |
| `dialog status` | `dialog_status` | 2 | Check if dialog open |
### Diff
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `diff snapshot` | `diff_snapshot` | N/A | Future, after snapshot is stable |
| `diff screenshot` | `diff_screenshot` | N/A | Future |
| `diff url` | `diff_url` | N/A | Future |
### Debug
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `trace start/stop` | N/A | — | Chrome trace, not available in WebKitGTK |
| `profiler start/stop` | N/A | — | Chrome DevTools profiler |
| `console` | `console` | 2 | View console messages |
| `errors` | `errors` | 2 | View page errors |
| `highlight <sel>` | `highlight` | 2 | Highlight element |
| `inspect` | N/A | — | Opens Chrome DevTools |
| `state save/load/list` | `state_save/load/list` | 2 | Save/restore auth state |
### Navigation
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `back` | `back` | 1 | Go back |
| `forward` | `forward` | 1 | Go forward |
| `reload` | `reload` | 1 | Reload page |
### Setup
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `install` | N/A | — | We bundle WebKitGTK, no install needed |
| `upgrade` | N/A | — | Package manager handles updates |
### Authentication
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `auth save` | N/A | — | We use Nostr identity, not credential vault |
| `auth login` | N/A | — | Nostr login is our native flow |
### Sessions
| agent-browser command | Our tool | Phase | Notes |
|------------------------|----------|-------|-------|
| `--session <name>` | N/A | — | We have one browser process, tabs instead |
| `--session-name <name>` | N/A | — | Session persistence is our session.c |
| `--profile <path>` | N/A | — | WebKitGTK handles profile data |
| `--state <path>` | `state_save/load` | 2 | Could implement via WebKitGTK data manager |
### Login tools (sovereign_browser-specific, not in agent-browser)
These tools are unique to sovereign_browser. They allow an agent to handle
the Nostr login flow programmatically — essential because the browser
shows a modal login dialog on startup that blocks all other tools until
the user (or agent) signs in.
The agent server starts **before** the login dialog, so the agent can
authenticate without any human interaction. The login tools call the same
`nostr_core_lib` functions that the GTK login dialog uses, bypassing the
dialog entirely.
| Tool | Params | Description |
|------|--------|-------------|
| `login_status` | `{}` | Check if logged in; return method, pubkey, readonly |
| `login` | `{"method": "local", "nsec": "nsec1..."}` | Sign in with local key |
| `login` | `{"method": "seed", "mnemonic": "word1 word2 ...", "passphrase": "..."}` | Sign in with BIP-39 seed phrase |
| `login` | `{"method": "readonly", "npub": "npub1..."}` | Sign in read-only with npub |
| `login` | `{"method": "nip46", "bunker_url": "bunker://..."}` | Sign in with NIP-46 remote signer |
| `login` | `{"method": "nsigner", "transport": "qrexec", "device": "nostr_signer", "service": "qubes.NsignerRpc", "index": 0}` | Sign in with n_signer hardware |
| `logout` | `{}` | Log out and clear signer |
| `switch_identity` | `{"method": "local", "nsec": "nsec1..."}` | Switch to a new identity (same params as login) |
**Login response:**
```json
{
"id": 1,
"success": true,
"data": {
"method": "local",
"pubkey": "a1b2c3...",
"npub": "npub1...",
"readonly": false
}
}
```
**Architecture change:** The agent server must start *before* the login
dialog. In `main.c`, the startup sequence becomes:
1. `gtk_init()` + load settings/history
2. Create the main window (but don't show it yet)
3. **Start the agent server** (so agents can connect)
4. If `agent_server_enabled` and an agent connects within a timeout,
wait for the agent to call `login`
5. Otherwise (no agent, or timeout), show the GTK login dialog as before
6. After login (via agent or dialog), create tabs and show the window
This means the agent server has two states:
- **Pre-login:** Only `login_status`, `login`, `logout` tools are available
- **Post-login:** All browser tools become available
### Summary
| Category | agent-browser commands | Our Phase 1 | Our Phase 2 | Our Phase 3 | N/A |
|----------|----------------------|-------------|-------------|-------------|-----|
| Login (unique) | 0 (8 new) | 8 | 0 | 0 | 0 |
| Core | 29 | 10 | 0 | 14 | 5 |
| Get info | 9 | 4 | 0 | 4 | 1 |
| Check state | 3 | 0 | 0 | 3 | 0 |
| Find elements | 10 | 0 | 0 | 10 | 0 |
| Wait | 6 | 2 | 0 | 4 | 0 |
| Batch | 1 | 0 | 0 | 1 | 0 |
| Clipboard | 4 | 0 | 0 | 4 | 0 |
| Mouse | 4 | 0 | 0 | 4 | 0 |
| Settings | 7 | 0 | 0 | 4 | 3 |
| Cookies/storage | 8 | 0 | 0 | 11 | 0 |
| Network | 6 | 0 | 0 | 0 | 6 |
| Tabs/windows | 5 | 4 | 0 | 0 | 1 |
| Frames | 2 | 0 | 0 | 2 | 0 |
| Dialogs | 3 | 0 | 0 | 3 | 0 |
| Diff | 3 | 0 | 0 | 0 | 3 |
| Debug | 8 | 0 | 0 | 4 | 4 |
| Navigation | 3 | 3 | 0 | 0 | 0 |
| Setup | 2 | 0 | 0 | 0 | 2 |
| Auth | 2 | 0 | 0 | 0 | 2 |
| Sessions | 4 | 0 | 0 | 2 | 2 |
| **Total** | **119 + 8** | **30** | **0** | **70** | **29** |
**Phase 1 (30 tools):** Login tools (8) + the minimal set for agent-driven
browser automation (22) — login, navigate, snapshot, inspect, interact,
and manage tabs, plus `screenshot`. This is enough for an LLM to log in,
browse, read, fill forms, click buttons, and multi-tab. The agent server
starts before login so the agent can authenticate without human
interaction.
**Phase 2 (0 tools):** Reserved for refinements to Phase 1 tools based on
real agent usage — better snapshot formatting, error handling, edge cases.
**Phase 3 (70 tools):** Everything else that has a WebKitGTK equivalent.
The original plan counted 67, but implementation added 3 extra
`storage_session_*` tools (the plan listed storage as 8 local-only tools;
we implemented 11 by adding `storage_session_get`, `storage_session_get_key`,
`storage_session_set`, and `storage_session_clear`). Deferred until Phase 1
is stable and tested with real agents.
**N/A (29 tools):** CDP-specific features (network interception, Chrome
DevTools profiler, trace), cloud provider integrations, and features that
don't map to our architecture (device emulation, multi-window). Some could
be revisited if WebKitGTK adds equivalent APIs.
## Snapshot engine
The snapshot is the heart of the agent system. It injects a JavaScript
script into the active tab's webview that:
1. Walks the DOM accessibility tree (using `TreeWalker` or recursive
`querySelectorAll`)
2. For each interactive element (links, buttons, inputs, selects, textareas,
elements with role/aria-label), assigns a sequential ref (`e1`, `e2`, ...)
3. Stores a mapping from ref → unique CSS selector (for later re-querying)
4. Returns a text representation of the tree:
```
- heading "Example Domain" [ref=e1] [level=1]
- paragraph "This domain is for use in..."
- link "More information..." [ref=e2]
- textbox "Search" [ref=e3]
- button "Submit" [ref=e4]
```
### Ref storage
The ref map is stored as a JSON string in a hidden `window.__agentRefs`
global on the page. When a tool like `click @e2` is called:
1. The tool injects JS: `var el = document.querySelector(window.__agentRefs['e2']); el.click();`
2. If the element is gone (page navigated), the tool returns an error
suggesting a re-snapshot.
### Interactive-only mode
When `interactive: true`, only elements that are focusable or have an
ARIA role are included. This dramatically reduces the snapshot size for
LLM context windows.
## New files
### `src/agent_server.h` / `src/agent_server.c`
```c
/*
* WebSocket server for agent tool commands.
* Uses libsoup SoupServer with a websocket handler at /agent.
*/
void agent_server_start(int port);
void agent_server_stop(void);
int agent_server_get_port(void);
gboolean agent_server_is_running(void);
/* Broadcast a push event to all connected clients. */
void agent_server_emit_event(const char *event_name, cJSON *data);
```
### `src/agent_tools.h` / `src/agent_tools.c`
```c
/*
* Tool dispatch: receives a JSON request, executes the tool,
* returns a JSON response.
*/
/* Process a tool request JSON and return response JSON.
* The caller frees the returned cJSON*. */
cJSON *agent_tools_dispatch(cJSON *request);
```
### `src/agent_snapshot.h` / `src/agent_snapshot.c`
```c
/*
* Accessibility tree snapshot via JS injection.
* Assigns refs to interactive elements and returns a text tree.
*/
/* The JS script injected for snapshot. Exposed for testing. */
extern const char *AGENT_SNAPSHOT_JS;
/* Parse a snapshot result JSON from the JS execution. */
cJSON *agent_snapshot_parse(const char *js_result);
```
## New files (login-specific)
### `src/agent_login.h` / `src/agent_login.c`
```c
/*
* Agent-driven Nostr login — calls nostr_core_lib directly,
* bypassing the GTK login dialog. Used by the agent server
* so an AI agent can authenticate without human interaction.
*/
/* Check current login state. Returns JSON response. */
cJSON *agent_login_status(void);
/* Perform login with the given method and params.
* params is a cJSON object with "method" and method-specific fields.
* Returns JSON response with pubkey/npub on success. */
cJSON *agent_login(cJSON *params);
/* Log out — clear signer, reset state. Returns JSON response. */
cJSON *agent_logout(void);
/* Switch identity — same as login but frees the old signer first. */
cJSON *agent_switch_identity(cJSON *params);
/* Returns TRUE if logged in (signer or readonly loaded). */
gboolean agent_is_logged_in(void);
```
## Modified files
### `src/settings.h` / `src/settings.c`
Add settings:
- `agent_server_enabled` (bool, default: true)
- `agent_server_port` (int, default: 17777)
- `agent_server_allowed_origins` (string, default: "*" for localhost)
- `agent_login_timeout_ms` (int, default: 30000) — how long to wait
for an agent to log in before falling back to the GTK dialog
### `src/tab_manager.h` / `src/tab_manager.c`
- Add `GHashTable *ref_map` to `tab_info_t` for per-tab element refs
- Emit events to `agent_server` on load, title change, tab switch
### `src/main.c`
Major change to startup sequence:
- Call `agent_server_start()` **before** `do_login()` (not after)
- After starting the server, wait for either:
- An agent to call `login` within `agent_login_timeout_ms`, OR
- The timeout to expire, then show the GTK login dialog
- After login (via agent or dialog), proceed to create tabs and show window
- Call `agent_server_stop()` in `on_window_destroy()`
### `Makefile`
Add `src/agent_server.c src/agent_tools.c src/agent_snapshot.c src/agent_login.c` to `SRC`.
Add `libsoup-3.0` to pkg-config if not already included.
## Implementation order
### Phase 1 — Login + core browser automation
1. **settings** — add agent server settings (port, enabled, allowed origins)
2. **agent_server** — WebSocket server using libsoup SoupServer, JSON protocol
3. **agent_login** — login tools (login_status, login, logout, switch_identity)
that call nostr_core_lib directly, bypassing the GTK dialog
4. **main.c integration (pre-login)** — start agent server before login dialog,
support agent-driven login with fallback to GTK dialog
5. **agent_snapshot** — the JS injection script + ref assignment
6. **agent_tools** — tool dispatch + core tools (open, snapshot, click, fill,
type, eval, get_text, get_html, get_attr, get_url, get_title)
7. **main.c integration (post-login)** — wire browser tools to tab_manager,
emit events (load, tab_changed)
8. **Navigation tools** — back, forward, reload, scroll, press, wait, wait_for
9. **Tab tools** — tab_list, tab_new, tab_switch, tab_close
10. **Interaction tools** — hover, focus, close
11. **Screenshot tool** — WebKitGTK snapshot API
12. **UI status indicator** — show server port in toolbar
13. **Makefile + docs** — build and documentation
### Phase 2 — Refinements
14. Polish Phase 1 tools based on real agent usage
15. Better snapshot formatting and error messages
16. Edge case handling (page navigation during interaction, stale refs)
### Phase 3 — Extended tool catalog (COMPLETE)
17. All remaining tools from the comparison table (70 tools — 67 planned
+ 3 extra `storage_session_*` tools)
18. Find elements, check state, clipboard, mouse control, cookies/storage,
frames, dialogs, console/errors, batch, state save/load, etc.
19. Implemented in 8 batches (see [`plans/phase3-tools.md`](plans/phase3-tools.md)):
- Batch 1: Extended interaction (11 tools)
- Batch 2: Get info + check state (7 tools)
- Batch 3: Find elements (10 tools)
- Batch 4: Wait + batch (5 tools)
- Batch 5: Cookies + storage (11 tools)
- Batch 6: Mouse + clipboard + settings (13 tools)
- Batch 7: Frames + dialogs + debug (10 tools)
- Batch 8: Complex tools (3 tools)
## Testing the tools
The agent server starts before login, so the first thing an agent does is
authenticate. Once logged in, all browser tools become available.
### Login flow
```bash
# Using websocat (install: cargo install websocat)
# Check login status (before logging in)
echo '{"id":1,"tool":"login_status","params":{}}' | websocat ws://localhost:17777/agent
# Login with local key (nsec)
echo '{"id":2,"tool":"login","params":{"method":"local","nsec":"nsec1..."}}' | websocat ws://localhost:17777/agent
# Login read-only with npub
echo '{"id":3,"tool":"login","params":{"method":"readonly","npub":"npub1..."}}' | websocat ws://localhost:17777/agent
# Login with seed phrase
echo '{"id":4,"tool":"login","params":{"method":"seed","mnemonic":"abandon abandon abandon ... abandon about"}}' | websocat ws://localhost:17777/agent
# Login with NIP-46 remote signer
echo '{"id":5,"tool":"login","params":{"method":"nip46","bunker_url":"bunker://..."}}' | websocat ws://localhost:17777/agent
# Login with n_signer via Other Qube (qrexec)
echo '{"id":6,"tool":"login","params":{"method":"nsigner","transport":"qrexec","device":"nostr_signer","service":"qubes.NsignerRpc","index":0}}' | websocat ws://localhost:17777/agent
```
### Browser automation flow (after login)
```bash
# Open a page
echo '{"id":10,"tool":"open","params":{"url":"https://example.com"}}' | websocat ws://localhost:17777/agent
# Snapshot — get accessibility tree with refs
echo '{"id":11,"tool":"snapshot","params":{"interactive":true}}' | websocat ws://localhost:17777/agent
# Click by ref
echo '{"id":12,"tool":"click","params":{"ref":"@e2"}}' | websocat ws://localhost:17777/agent
# Fill an input
echo '{"id":13,"tool":"fill","params":{"ref":"@e3","value":"test@example.com"}}' | websocat ws://localhost:17777/agent
# Get text
echo '{"id":14,"tool":"get_text","params":{"ref":"@e1"}}' | websocat ws://localhost:17777/agent
# List tabs
echo '{"id":15,"tool":"tab_list","params":{}}' | websocat ws://localhost:17777/agent
# Open new tab
echo '{"id":16,"tool":"tab_new","params":{"url":"https://example.org"}}' | websocat ws://localhost:17777/agent
```
### Python example (persistent connection)
```python
import websocket, json
ws = websocket.create_connection("ws://localhost:17777/agent")
# Login first
ws.send(json.dumps({"id": 1, "tool": "login", "params": {
"method": "readonly", "npub": "npub1..."
}}))
print(json.loads(ws.recv()))
# Then browse
ws.send(json.dumps({"id": 2, "tool": "open", "params": {
"url": "https://example.com"
}}))
print(json.loads(ws.recv()))
ws.send(json.dumps({"id": 3, "tool": "snapshot", "params": {
"interactive": True
}}))
print(json.loads(ws.recv()))
```
## Future: Nostr relay integration
Since the WebSocket server is already in the browser process, a future
enhancement could expose Nostr relay connections through the same server.
An agent could subscribe to Nostr events and react to them — e.g., a
remote agent controlling the browser via Nostr DMs. This is a natural
extension of the "sovereign identity" thesis: your Nostr identity controls
your browser, not just your signing.
## Implementation Status
**All phases complete. 100 tools total.**
| Phase | Tools | Status |
|-------|-------|--------|
| Phase 1 — Login + core | 30 | ✅ Complete |
| Phase 2 — Refinements | 0 | ✅ N/A (folded into Phase 1/3) |
| Phase 3 — Extended catalog | 70 | ✅ Complete (8 batches) |
| **Total** | **100** | ✅ |
### Phase 3 batches (all complete)
| Batch | Category | Tools | Count |
|-------|----------|-------|-------|
| 1 | Extended interaction | dblclick, select, check, uncheck, scroll_into_view, keyboard_type, insert_text, keydown, keyup, drag, close_all | 11 |
| 2 | Get info + check state | get_value, get_count, get_box, get_styles, is_visible, is_enabled, is_checked | 7 |
| 3 | Find elements | find_role, find_text, find_label, find_placeholder, find_alt, find_title, find_testid, find_first, find_last, find_nth | 10 |
| 4 | Wait + batch | wait_for_text, wait_for_url, wait_for_load, wait_for_fn, batch | 5 |
| 5 | Cookies + storage | cookies_get, cookies_set, cookies_clear, storage_local_get, storage_local_get_key, storage_local_set, storage_local_clear, storage_session_get, storage_session_get_key, storage_session_set, storage_session_clear | 11 |
| 6 | Mouse + clipboard + settings | mouse_move, mouse_down, mouse_up, mouse_wheel, clipboard_read, clipboard_write, clipboard_copy, clipboard_paste, set_viewport, set_offline, set_headers, set_credentials, set_media | 13 |
| 7 | Frames + dialogs + debug | frame_switch, frame_main, dialog_accept, dialog_dismiss, dialog_status, console, errors, highlight, state_save, state_load | 10 |
| 8 | Complex tools | upload, pdf, screenshot_annotated | 3 |
| **Total** | | | **70** |
### Verification
- `make clean && make` — builds cleanly (only pre-existing warnings in
`login_dialog.c` and `agent_mcp.c`).
- `tools/list` returns exactly 100 tools.
- `initialize` / `ping` / `login_status` work without login.
- Browser tools (`dblclick`, etc.) correctly return `NOT_LOGGED_IN`
before login.
- `batch` dispatches without login; sub-commands enforce login
individually.
- Roo Code MCP config (`mcp_settings.json`) `alwaysAllow` updated with
all 100 tool names.

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. |

265
plans/bookmarks.md Normal file
View File

@@ -0,0 +1,265 @@
# Plan: Nostr-Encrypted Bookmarks with Directories
## Overview
Store browser bookmarks as NIP-44 encrypted Nostr events so they sync across all devices the user logs in on. Bookmarks are organized into **directories** (folders). A **bookmark button** in the toolbar (right of the URL bar) lets the user quickly bookmark the current page and choose/create a directory. Bookmarks also appear in the hamburger dropdown menu and are managed via a `sovereign://bookmarks` page.
## Event Design
### Kind 30003 (NIP-51 Bookmark Sets) — one event per directory
Per [NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md), **kind 30003** is for "user-defined bookmarks categories, for when bookmarks must be in labeled separate groups." Each directory is a separate parameterized replaceable event with a `d` tag = directory name.
NIP-51 encryption pattern (line 11):
> Private items are specified in a JSON array that mimics the structure of the event `tags` array, but stringified and encrypted using NIP-44 (the shared key is computed using the author's public and private key) and stored in the `.content`.
### Event structure (one per directory)
```
{
"kind": 30003,
"pubkey": "<user pubkey hex>",
"tags": [["d", "<directory name>"]],
"content": "<NIP-44 encrypted JSON array of bookmarks>",
"created_at": <unix timestamp>,
...
}
```
### Plaintext format (encrypted in `content`)
```json
[
["bookmark", "https://example.com", "Example", 1690000000],
["bookmark", "https://nostr.com", "Nostr", 1690000001]
]
```
### Directory model
- Each directory = one kind 30003 event with `d` = directory name.
- A default directory `"General"` holds bookmarks that aren't assigned to a specific folder.
- The user can create/rename/delete directories. Deleting a directory moves its bookmarks to "General" (or deletes them, with confirmation).
- The list of directory names is derived from the `d` tags of all the user's kind 30003 events — no separate "directory list" event needed.
### Why kind 30003 (sets) instead of kind 10003 (flat list)?
| Feature | Kind 10003 | Kind 30003 |
|---------|-----------|-----------|
| Directories/folders | Not supported (flat list) | **Supported** — each `d` tag is a directory |
| Replaceable | Normal replaceable (one event) | Parameterized replaceable (one event per `d` tag) |
| NIP-51 standard | Yes — "Bookmarks" | Yes — "Bookmark sets" |
| NIP-44 encryption | Documented | Documented (same pattern) |
Since you want directories, **kind 30003** is the correct NIP-51 kind. It was designed exactly for this: "bookmarks must be in labeled separate groups."
```mermaid
flowchart TD
A["User clicks bookmark button"] --> B["Show directory picker dialog"]
B --> C{"Select existing or create new?"}
C -->|Existing| D["Add bookmark to that directory"]
C -->|Create new| E["Enter new directory name"]
E --> D
D --> F["Update in-memory list for that directory"]
F --> G["Serialize directory bookmarks to JSON"]
G --> H["NIP-44 encrypt to self"]
H --> I["Build kind 30003 event with d=directory"]
I --> J["Sign with signer"]
J --> K["Publish to bootstrap relays"]
J --> L["Store in SQLite db_store_event"]
M["Browser startup / login"] --> N["Fetch all kind 30003 events from relays"]
N --> O["Store in SQLite"]
O --> P["For each event: decrypt content with NIP-44"]
P --> Q["Parse JSON to in-memory bookmarks grouped by d-tag directory"]
Q --> R["Populate dropdown menu + bookmarks page"]
S["sovereign://bookmarks page"] --> T["Show directories as folders + bookmarks"]
T --> U["Add/edit/delete/move bookmarks between directories"]
U --> V["Save re-encrypt publish affected directory event"]
```
---
## Implementation
### New files: `src/bookmarks.h` / `src/bookmarks.c`
```c
/* bookmarks.h */
#pragma once
#include <glib.h>
#include "../nostr_core_lib/cjson/cJSON.h"
#define BOOKMARKS_MAX_PER_DIR 500
#define BOOKMARKS_DIR_NAME_MAX 128
typedef struct {
char *url;
char *title;
long added; /* unix timestamp */
} bookmark_t;
typedef struct {
char name[BOOKMARKS_DIR_NAME_MAX];
bookmark_t *bookmarks;
int count;
} bookmark_dir_t;
/* Initialize the bookmarks module. Loads cached bookmarks from SQLite
* and decrypts them if a signer is available. Call after login. */
int bookmarks_init(void);
/* Free the in-memory bookmark list. */
void bookmarks_cleanup(void);
/* Get the list of directories (read-only). */
const bookmark_dir_t *bookmarks_get_dirs(int *count_out);
/* Get a specific directory by name (read-only). Returns NULL if not found. */
const bookmark_dir_t *bookmarks_get_dir(const char *name);
/* 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.
* Returns 0 on success, -1 on error. */
int bookmarks_add(const char *dir, const char *url, const char *title);
/* Remove a bookmark by URL from a directory.
* Returns 0 on success, -1 on not found / error. */
int bookmarks_remove(const char *dir, const char *url);
/* Move a bookmark from one directory to another.
* Returns 0 on success, -1 on error. */
int bookmarks_move(const char *from_dir, const char *url,
const char *to_dir);
/* 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);
/* Rename a directory. Publishes a new event with the new d-tag and
* deletes the old one (kind 5 deletion event).
* Returns 0 on success, -1 on error. */
int bookmarks_rename_dir(const char *old_name, const char *new_name);
/* Delete a directory. Optionally move bookmarks to "General" first.
* Publishes a kind 5 deletion event for the old directory event.
* Returns 0 on success, -1 on error. */
int bookmarks_delete_dir(const char *name, int move_to_general);
/* Load bookmarks for a directory from a decrypted kind 30003 event's
* content JSON. Called internally by bookmarks_init. */
int bookmarks_load_dir_from_json(const char *dir_name, const char *json);
/* Serialize a directory's bookmarks to NIP-51 tag-array JSON. */
char *bookmarks_dir_to_json(const char *dir_name);
/* Build and publish the kind 30003 event for a directory.
* Encrypts the directory's bookmarks with NIP-44 (self-to-self),
* signs, publishes to bootstrap relays, and stores in SQLite.
* Returns 0 on success, -1 on error. */
int bookmarks_publish_dir(const char *dir_name);
```
### Bookmark button in the toolbar
In `tab_manager.c`'s `tab_create()`, add a bookmark button to the toolbar (right of the URL entry):
```c
/* Bookmark button — right of the URL entry. */
GtkWidget *bookmark_btn = gtk_button_new();
gtk_button_set_relief(GTK_BUTTON(bookmark_btn), GTK_RELIEF_NONE);
gtk_button_set_image(GTK_BUTTON(bookmark_btn),
gtk_image_new_from_icon_name("user-bookmarks-symbolic",
GTK_ICON_SIZE_MENU));
gtk_widget_set_tooltip_text(bookmark_btn, "Bookmark this page");
g_signal_connect(bookmark_btn, "clicked",
G_CALLBACK(on_bookmark_clicked), tab);
gtk_box_pack_start(GTK_BOX(toolbar), bookmark_btn, FALSE, FALSE, 0);
```
The `on_bookmark_clicked` handler:
1. Gets the active tab's current URL and title.
2. Shows a **directory picker dialog** — a small GTK dialog with:
- A combo box (dropdown) of existing directories + "New Directory…" option
- If "New Directory…" is selected, show a text entry for the new name
- An "Add" button and a "Cancel" button
3. On "Add", calls `bookmarks_add(dir, url, title)`.
4. Shows a brief confirmation (e.g. "Bookmarked to <dir>").
### sovereign://bookmarks page
Add a `handle_bookmarks_page` function in `nostr_bridge.c`:
- Shows directories as a folder list on the left (or as section headings)
- Shows bookmarks in the selected directory with URL, title, "Open" / "Delete" / "Move" buttons
- "Add Bookmark" form (URL + title + directory dropdown)
- "Create Directory" button
- "Rename Directory" / "Delete Directory" buttons per directory
- Uses `sovereign://bookmarks/add?dir=...&url=...&title=...`, `sovereign://bookmarks/delete?dir=...&url=...`, `sovereign://bookmarks/move?from=...&to=...&url=...`, `sovereign://bookmarks/createdir?name=...`, `sovereign://bookmarks/renamedir?old=...&new=...`, `sovereign://bookmarks/deletedir?name=...` routes
- Styled consistently with settings/profile pages (dark theme, monospace)
- In read-only / no-login mode, shows cached bookmarks but disables add/delete with "Sign in to manage bookmarks"
### Dropdown menu integration
In `tab_manager.c`'s `build_hamburger_menu()`, add a "Bookmarks" submenu (like "Recents") that:
- Shows directories as sub-submenus (nested GTK menus)
- Each directory submenu lists its bookmarks as clickable items
- Clicking a bookmark navigates the active tab to that URL
- "Manage Bookmarks…" item at the bottom opens `sovereign://bookmarks`
- Rebuilds on each show (like `on_history_menu_show`)
### Relay fetch integration
Add kind 30003 to the relay fetch filter in `relay_fetch.c`:
```c
cJSON *kinds = cJSON_CreateArray();
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(0));
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(3));
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(10002));
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(30003)); /* NEW: bookmark sets */
```
### SQLite
The existing `events` + `event_tags` tables store kind 30003 events. The `event_tags` table allows querying by `d` tag to find all directory events for a pubkey. No schema changes needed.
### Makefile
Add `src/bookmarks.c` to `SRC`.
---
## File change summary
| File | Change |
|------|--------|
| `src/bookmarks.h` | NEW — bookmark/directory data structures + API |
| `src/bookmarks.c` | NEW — in-memory list, NIP-44 encrypt/decrypt, publish/load, directory management |
| `src/nostr_bridge.c` | Add `sovereign://bookmarks` page + action routes (add/delete/move/createdir/renamedir/deletedir) |
| `src/tab_manager.c` | Add bookmark button in toolbar + "Bookmarks" submenu in hamburger menu |
| `src/main.c` | Call `bookmarks_init()` after login, `bookmarks_cleanup()` on shutdown |
| `src/relay_fetch.c` | Add kind 30003 to the bootstrap fetch filter |
| `Makefile` | Add `src/bookmarks.c` to SRC |
---
## Threading and read-only mode
- **Signing required**: publishing bookmark events requires a signer. In read-only / no-login mode, bookmarks can be *read* from SQLite but not *modified*. The bookmark button and add/delete/move UI are disabled.
- **Background publish**: `bookmarks_publish_dir()` calls `synchronous_publish_event_with_progress()` which blocks. Run in a `g_thread_new()` background thread so the UI doesn't freeze. The in-memory list is updated immediately; the publish happens async.
- **SQLite thread safety**: `SQLITE_OPEN_FULLMUTEX` already set.
---
## Edge cases
1. **No signer (read-only / no-login)**: bookmark button is disabled (greyed out). Bookmarks page shows cached bookmarks read-only with "Sign in to manage bookmarks."
2. **No cached bookmarks**: empty state — "No bookmarks yet. Click the bookmark button to save a page."
3. **Decryption failure**: show error, treat that directory as empty.
4. **Directory deletion**: confirm dialog. Option to move bookmarks to "General" or delete them. Publishes a kind 5 deletion event for the old directory's kind 30003 event.
5. **Directory rename**: publish new kind 30003 with new `d` tag + kind 5 deletion for old `d` tag.
6. **Large directories**: 500 bookmarks per directory limit. NIP-44 supports up to 1MB plaintext.
7. **Conflict resolution**: kind 30003 is parameterized replaceable — latest `created_at` per `d` tag wins. Next relay fetch reconciles.
8. **Default directory**: "General" is always present (created on first bookmark if no directories exist).

249
plans/browser-tabs.md Normal file
View File

@@ -0,0 +1,249 @@
# Browser Tabs Implementation Plan
## Overview
Add full browser tab support to sovereign_browser. Each tab gets its own
toolbar (URL bar + hamburger menu) and WebKitWebView. All tabs share a single
`WebKitWebContext`, so the `sovereign://` Nostr bridge, security settings, and
TLS policy apply automatically to every tab. Tab behavior is configurable via
a new settings module.
## Architecture
```mermaid
flowchart TB
subgraph Window
Notebook[GtkNotebook — tab strip]
NewTabBtn[New Tab Button +]
end
subgraph TabPage
Toolbar[Toolbar: hamburger + URL entry]
Webview[WebKitWebView]
end
Notebook -->|page 0| TabPage
Notebook -->|page 1| TabPage
Notebook -->|page N| TabPage
TabPage -->|shared| Ctx[WebKitWebContext — sovereign bridge, security, TLS]
Ctx --> NostrBridge[nostr_bridge.c]
Ctx --> NostrInject[nostr_inject.c — per-webview]
TabManager[tab_manager.c] -->|manages| Notebook
Settings[settings.c] -->|configures| TabManager
Session[session.c] -->|save/restore| TabManager
```
### Key design decisions
1. **Per-tab toolbar.** Each notebook page is a vertical `GtkBox` containing
the toolbar (hamburger + URL entry) on top and the `WebKitWebView` below.
This is simpler than a shared toolbar that has to swap signal handlers on
tab switch, and it matches the user's preference.
2. **Shared `WebKitWebContext`.** All `WebKitWebView` instances are created
from the same context. The `sovereign://` URI scheme registration, security
manager flags, and TLS error policy are set once on the context and apply
to all tabs. No per-tab re-registration needed.
3. **Per-webview `nostr_inject_setup()`.** The `window.nostr` injection uses
`WebKitUserContentManager`, which is per-webview. Each new tab calls
`nostr_inject_setup()` on its own webview.
4. **`tab_manager.c` owns the `GtkNotebook`.** It provides functions to
create, close, switch, and query tabs. Each tab is tracked in a struct
holding the webview, URL entry, hamburger button, and tab label widget.
5. **Settings module.** A new `settings.c`/`settings.h` persists user
preferences to `~/.sovereign_browser/settings.conf` (simple key=value
text file). Configurable options:
| Setting | Default | Description |
|---------|---------|-------------|
| `restore_session` | `true` | Restore open tabs on next launch |
| `new_tab_url` | `https://example.com` | URL loaded in new tabs |
| `tab_bar_position` | `top` | `top` / `bottom` / `left` / `right` |
| `show_tab_close_buttons` | `true` | Show per-tab close button |
| `middle_click_close` | `true` | Middle-click on tab closes it |
| `ctrl_tab_switch` | `true` | Ctrl+Tab cycles tabs |
| `max_tabs` | `50` | Maximum simultaneous tabs |
| `tab_drag_reorder` | `true` | Allow drag to reorder tabs |
6. **Session module.** `session.c`/`session.h` saves the list of open tab URLs
(one per line) to `~/.sovereign_browser/session.txt` on window destroy, and
restores them on startup if `settings.restore_session` is true.
## New files
### `src/settings.h` / `src/settings.c`
```c
typedef struct {
gboolean restore_session;
char new_tab_url[512];
int tab_bar_position; /* GTK_POS_TOP, etc. */
gboolean show_tab_close_buttons;
gboolean middle_click_close;
gboolean ctrl_tab_switch;
int max_tabs;
gboolean tab_drag_reorder;
} browser_settings_t;
void settings_load(browser_settings_t *out);
void settings_save(const browser_settings_t *s);
const browser_settings_t *settings_get(void); /* global singleton */
```
### `src/tab_manager.h` / `src/tab_manager.c`
```c
typedef struct {
WebKitWebView *webview;
GtkWidget *url_entry;
GtkWidget *hamburger;
GtkWidget *tab_label; /* composite: icon + title + close btn */
char current_url[2048];
char title[256];
} tab_info_t;
void tab_manager_init(GtkContainer *parent, WebKitWebContext *ctx);
int tab_manager_new_tab(const char *url);
void tab_manager_close_tab(int index);
void tab_manager_close_active(void);
int tab_manager_get_active_index(void);
tab_info_t *tab_manager_get_active(void);
tab_info_t *tab_manager_get(int index);
int tab_manager_count(void);
void tab_manager_set_title(int index, const char *title);
```
### `src/session.h` / `src/session.c`
```c
void session_save(void); /* called on window destroy */
int session_restore(void); /* called at startup, returns tab count created */
```
## Modified files
### `src/main.c` — major refactor
- Remove the single `webview` / `url_entry` / `toolbar` creation.
- Create `WebKitWebContext` once, configure security + `sovereign://` bridge
on it (existing code moves here, unchanged logic).
- Call `tab_manager_init()` with the window's vbox and the shared context.
- The hamburger menu builder moves into `tab_manager.c` (or a shared helper)
since it now needs to reference the per-tab webview, not a global.
- All menu callbacks (`on_menu_reload`, `on_menu_stop`, `on_menu_inspector`,
`on_menu_open_file`, `on_menu_history_item`) fetch the active tab's webview
via `tab_manager_get_active()` instead of receiving it as a parameter.
- `on_load_changed` and `on_load_failed` are connected per-tab inside
`tab_manager_new_tab()` and update the per-tab URL entry and tab title.
- `on_url_activate` loads the URL into the active tab's webview.
- Keyboard shortcuts: a `key-press-event` handler on the main window checks
for Ctrl+T / Ctrl+W / Ctrl+Tab / Ctrl+Shift+Tab / Ctrl+PageUp / Ctrl+PageDown.
- `on_window_destroy` calls `session_save()` before cleanup.
### `src/nostr_inject.c` — no changes
`nostr_inject_setup()` already takes a `WebKitWebView*` and is called
per-webview. The tab manager calls it for each new tab.
### `src/nostr_bridge.c` — no changes
The bridge is registered on the shared `WebKitWebContext`. All webviews on
that context automatically get the `sovereign://` scheme handler.
### `src/history.c` — no changes
History is already global. All tabs contribute to and read from the same
history list.
### `Makefile`
Add `src/settings.c src/tab_manager.c src/session.c` to `SRC`.
## Tab UI layout
```
┌──────────────────────────────────────────────────────┐
│ [Tab 1 ×] [Tab 2 ×] [Tab 3 ×] [+] │ ← GtkNotebook tab strip
├──────────────────────────────────────────────────────┤
│ [☰] https://example.com____________ │ ← per-tab toolbar
├──────────────────────────────────────────────────────┤
│ │
│ WebKitWebView │ ← per-tab webview
│ │
└──────────────────────────────────────────────────────┘
```
### Tab label widget (composite)
Each tab label is a horizontal `GtkBox`:
```
[favicon] [Title text…] [×]
```
- Favicon: placeholder icon for now (can be wired to WebKit favicon later)
- Title: `GtkLabel` with ellipsize end, max width ~150px
- Close button: `GtkButton` with `window-close-symbolic` icon, only shown if
`settings.show_tab_close_buttons` is true
### Right-click context menu on tab label
- New Tab
- Close Tab
- Close Other Tabs
- Close Tabs to the Right
- Duplicate Tab
- Reload Tab
### Drag reordering
`GtkNotebook` supports drag reordering natively via
`gtk_notebook_set_tab_reorderable(notebook, child, TRUE)`. Enabled when
`settings.tab_drag_reorder` is true.
## Keyboard shortcuts
| Shortcut | Action |
|----------|--------|
| Ctrl+T | New tab |
| Ctrl+W | Close active tab |
| Ctrl+Tab | Next tab |
| Ctrl+Shift+Tab | Previous tab |
| Ctrl+PageUp | Previous tab |
| Ctrl+PageDown | Next tab |
| Ctrl+L | Focus URL bar of active tab |
## Session restore
On startup, if `settings.restore_session` is true and
`~/.sovereign_browser/session.txt` exists and is non-empty, each line is
loaded as a tab URL. If the file is missing or empty, a single tab is opened
with the start URL (from command-line arg or `settings.new_tab_url`).
On window destroy, before `gtk_main_quit()`, the list of open tab URLs is
written to `~/.sovereign_browser/session.txt`.
## Settings UI
A new menu item "Settings…" in the hamburger menu opens a native GTK dialog
(not a web page, to keep it simple and avoid bootstrapping a
`sovereign://settings` renderer). The dialog has checkboxes and a text entry
for each configurable option. On "OK", `settings_save()` is called and
applicable changes (like tab bar position) are applied live.
## Implementation order
1. **settings.c/h** — foundation, no UI yet, just load/save + global singleton
2. **tab_manager.c/h** — core notebook management, per-tab page creation,
tab label widget, new/close/switch
3. **session.c/h** — save/restore using tab_manager API
4. **main.c refactor** — wire up tab_manager, move context setup, rewire
callbacks to use active tab
5. **Tab interactions** — middle-click close, right-click menu, drag reorder
6. **Keyboard shortcuts** — window-level key-press handler
7. **Session save/restore** — integrate into startup and destroy
8. **Settings dialog** — GTK dialog wired to settings module
9. **Makefile + docs** — build and documentation updates

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).

264
plans/cli-flags.md Normal file
View File

@@ -0,0 +1,264 @@
# CLI Flags Plan — sovereign_browser
## Rename: "random" → "generate"
As part of this work, rename the login method string `"random"` to
`"generate"` everywhere it appears. "random" implies an arbitrary method
choice; "generate" accurately describes what happens — a fresh local key
is generated and used for login.
Affected locations (from `grep`):
| File | Line | Change |
|---|---|---|
| [`src/agent_login.c`](src/agent_login.c:184) | 184 | `make_login_data("random", ...)``"generate"` |
| [`src/agent_login.c`](src/agent_login.c:425) | 425 | error string listing methods |
| [`src/agent_login.c`](src/agent_login.c:440) | 440 | `strcmp(method, "random")``"generate"` |
| [`src/agent_login.c`](src/agent_login.c:451) | 451 | error string listing methods |
| [`src/agent_mcp.c`](src/agent_mcp.c:137) | 137 | `login` tool description + enum |
| [`src/agent_mcp.c`](src/agent_mcp.c:138) | 138 | `login` tool JSON schema enum |
| [`src/agent_mcp.c`](src/agent_mcp.c:145) | 145 | `switch_identity` tool description |
| [`src/agent_mcp.c`](src/agent_mcp.c:146) | 146 | `switch_identity` tool JSON schema enum |
| [`tests/test_agent_login.py`](tests/test_agent_login.py) | — | any test using `method: "random"` |
| [`README.md`](README.md) | — | any documentation referencing "random" |
| `.roorules` | — | the `login` example uses `"method":"random"` |
The internal C function `login_random()` in
[`src/agent_login.c`](src/agent_login.c) can keep its name (internal) or
be renamed to `login_generate()` for consistency — recommend renaming
for clarity.
## Goal
Add command-line flags to `sovereign_browser` so that startup URLs, agent
server settings, session behavior, and — critically — Nostr login can be
controlled without the GTK login dialog. This enables headless/automated
use (agents, CI, scripting) while preserving the existing interactive
flow as the default.
## Current State
- [`main()`](src/main.c:493) reads only `argv[1]` as an optional start URL
([`src/main.c:502`](src/main.c:502)). No `getopt`, no `--help`.
- [`gtk_init(&argc, &argv)`](src/main.c:496) runs first and would consume
GTK's own flags; our parser must run **before** `gtk_init` so we can
strip our flags out of `argv` before GTK sees them (otherwise GTK aborts
on unknown options like `--login-method`).
- Login methods are enumerated in
[`key_store_method_t`](src/key_store.h:19) and exercised by
[`agent_login()`](src/agent_login.h:38) which already accepts a cJSON
params object. The CLI login path can reuse `agent_login()` directly —
it calls `app_set_signer()` and sets the same global state the GTK
dialog would.
- Settings live in [`browser_settings_t`](src/settings.h:25) and are
loaded from `~/.sovereign_browser/settings.conf`. CLI flags should
**override** settings, not replace them.
## Design Principles
1. **Reuse `agent_login()`** for CLI login — it already does everything
the GTK dialog does, returns structured results, and sets global state
via `app_set_signer()`. The CLI parser just builds the cJSON params
object from flags and calls it.
2. **Parse before `gtk_init()`** so GTK doesn't choke on our flags. We
strip recognized flags from `argc/argv` and pass the reduced vector to
`gtk_init()`.
3. **Flags override settings.conf** but do not write to it. A flag is a
one-shot override for this invocation.
4. **Login flags are mutually exclusive at the method level** — specify
one `--login-*` method; method-specific args are validated against it.
5. **`--login-method generate` is the zero-config path** — generates a
fresh key, logs in, and skips the dialog. Ideal for agents/CI.
6. **Backward compatible**`./sovereign_browser https://example.com`
still works as today (positional URL).
7. **Use `getopt_long`** (POSIX, available on Linux, C99-compatible).
No external deps.
## Proposed Flag Set
### Browser / Startup
| Flag | Arg | Description |
|---|---|---|
| `--url <url>` (repeatable) | URL | Open one or more URLs in tabs at startup. Positional URLs (existing behavior) are still accepted and appended after any `--url` flags. |
| `--new-tab-url <url>` | URL | Override `settings.new_tab_url` for this run (used by Ctrl+T / new-tab button). |
| `--no-session-restore` | — | Skip [`session_restore()`](src/session.c) even if `settings.restore_session` is true. |
| `--session-restore` | — | Force session restore even if disabled in settings. |
| `--max-tabs <n>` | int | Override `settings.max_tabs` for this run. |
| `--version`, `-V` | — | Print `SB_VERSION` (from [`src/version.h`](src/version.h)) and exit 0. |
| `--help`, `-h` | — | Print usage and exit 0. |
### Agent Server
| Flag | Arg | Description |
|---|---|---|
| `--port <port>` | int | Override `settings.agent_server_port` (default 17777). |
| `--no-agent` | — | Disable the agent MCP server for this run (overrides `settings.agent_server_enabled`). |
| `--agent` | — | Force-enable the agent server even if disabled in settings. |
| `--agent-origin <origin>` (repeatable) | string | Append to `agent_allowed_origins` for this run. |
### Login (mutually exclusive method flags)
Exactly one of these may be specified. If none is specified, the GTK
login dialog runs as today.
| Flag | Arg | Description |
|---|---|---|
| `--login-method <m>` | `generate\|local\|seed\|readonly\|nip46\|nsigner` | Select login method. Required to use any other `--login-*` flag. `generate` needs no further flags. |
| `--nsec <nsec1...>` | string | (local) nsec bech32 private key. |
| `--privkey <hex>` | string | (local) 64-char hex private key. Alternative to `--nsec`. |
| `--mnemonic <words>` | string | (seed) BIP-39 mnemonic, quoted ("word1 word2 ..."). |
| `--account <n>` | int | (seed) BIP-44 account index, default 0. |
| `--npub <npub1...>` | string | (readonly) npub bech32 public key. |
| `--pubkey <hex>` | string | (readonly) 64-char hex pubkey. Alternative to `--npub`. |
| `--bunker <url>` | URL | (nip46) `bunker://...` remote signer URL. |
| `--nsigner-transport <t>` | `serial\|unix\|tcp\|qrexec` | (nsigner) transport type. |
| `--nsigner-device <path>` | string | (nsigner) device path / socket / host:port / qube. |
| `--nsigner-service <name>` | string | (nsigner) qrexec service name (qrexec transport only). |
| `--nsigner-index <n>` | int | (nsigner) NIP-06 nostr_index, default 0. |
| `--no-save-identity` | — | Do not persist the CLI-provided identity to `~/.sovereign_browser/identity.json` (default: save, matching GTK dialog behavior). |
| `--login-timeout <ms>` | int | Override `settings.agent_login_timeout_ms`. Only meaningful when the GTK dialog is shown (no `--login-method`). |
### Diagnostics
| Flag | Arg | Description |
|---|---|---|
| `--verbose`, `-v` | — | Increase log verbosity (g_print messages). Repeatable for more detail. |
| `--quiet`, `-q` | — | Suppress non-error log output. |
## Usage Examples
```bash
# Existing behavior — still works
./sovereign_browser https://example.com
# Agent / CI: generated key, no dialog, open a page
./sovereign_browser --login-method generate --url https://example.com
# Local key from env, skip dialog, custom agent port
./sovereign_browser --login-method local --nsec "$NSEC" --port 18888
# Read-only (npub), no session restore, two tabs
./sovereign_browser --login-method readonly --npub npub1... \
--no-session-restore --url https://a.com --url https://b.com
# Seed phrase, account 1
./sovereign_browser --login-method seed \
--mnemonic "abandon abandon abandon ... about" --account 1
# NIP-46 remote signer
./sovereign_browser --login-method nip46 --bunker "bunker://..."
# n_signer hardware via qrexec
./sovereign_browser --login-method nsigner --nsigner-transport qrexec \
--nsigner-device nostr_signer --nsigner-service qubes.NsignerRpc
# Disable agent server, just browse
./sovereign_browser --no-agent --url https://example.com
# Version / help
./sovereign_browser --version
./sovereign_browser --help
```
## Implementation Plan
### Files Touched
| File | Change |
|---|---|
| `src/cli.h` (new) | Declares `cli_args_t` struct and `cli_parse()`. |
| `src/cli.c` (new) | Implements `getopt_long` parsing, validation, usage text, and `cli_login()` wrapper that builds cJSON and calls `agent_login()`. |
| `src/main.c` | Call `cli_parse(&argc, &argv)` **before** `gtk_init()`. Apply overrides to a mutable copy of settings. If `--login-method` was given, call `cli_login()` and skip `do_login()`. Replace `start_url` logic with the `--url` / positional list. |
| `Makefile` | Add `src/cli.o` to `OBJS`. |
| `README.md` | Document the flags. |
| `browser.sh` | Optional: accept extra args after `start`/`restart` and forward them to the binary (e.g. `./browser.sh start --login-method generate`). |
### Todo List
1. Rename "random" → "generate" in [`src/agent_login.c`](src/agent_login.c), [`src/agent_mcp.c`](src/agent_mcp.c), [`tests/test_agent_login.py`](tests/test_agent_login.py), [`README.md`](README.md), and `.roorules`. Rename internal `login_random()``login_generate()`.
2. Create `src/cli.h` with the `cli_args_t` struct and API.
3. Implement `src/cli.c`: `getopt_long` table, validation, `print_usage()`, `cli_login()` wrapper.
4. Wire `cli_parse()` into [`main()`](src/main.c:493) before `gtk_init()`; strip recognized flags from `argv`.
5. Apply CLI overrides to a mutable settings snapshot used for this run (port, agent enabled, max tabs, new-tab URL, session restore).
6. Implement `--url` (repeatable) + positional URL collection; pass list to tab manager at startup.
7. Implement `--login-method` path: build cJSON params, call `agent_login()`, check `success`, skip GTK dialog on success, exit 1 on failure.
8. Implement `--version` / `--help` (print and exit 0 before GTK init).
9. Implement `--no-save-identity` (skip `key_store_save()` on CLI login).
10. Update `Makefile` to compile `src/cli.c`.
11. Update `browser.sh` to forward extra args to the binary.
12. Update `README.md` with a CLI flags section.
13. Add a smoke test: `./sovereign_browser --login-method generate --no-agent --version` style checks (can be a shell test under `tests/`).
### Key Implementation Details
**Parse order:** `cli_parse()` must run before `gtk_init()` because GTK
aborts on unknown `--options`. `getopt_long` with `optind` lets us
repack `argv` so GTK only sees positional URLs. Pattern:
```c
cli_args_t args;
if (cli_parse(&argc, &argv, &args) != 0) {
return EXIT_FAILURE; /* --help / --version / parse error */
}
gtk_init(&argc, &argv); /* sees only positional URLs now */
```
**Login reuse:** `cli_login()` builds the exact cJSON shape documented in
[`agent_login.h`](src/agent_login.h:27) and calls `agent_login()`. On
`success: true`, the global state is already set via `app_set_signer()`,
so we set `g_logged_in = TRUE` and skip `do_login()`. On failure, print
the error message to stderr and exit non-zero.
**Settings override:** Introduce a `settings_apply_cli_overrides(const
cli_args_t *)` that mutates the global singleton in memory (not on disk)
after `settings_load()`. The rest of the code reads via
`settings_get()` unchanged.
**Repeatable `--url`:** Collect into a `GPtrArray` in `cli_args_t`.
After session restore fails (or is skipped), open each URL in its own
tab via `tab_manager_new_tab(url)`. If no URLs given, fall back to
`settings.new_tab_url`.
**`--no-save-identity`:** The GTK dialog path calls `key_store_save()`
after successful login. The CLI path should do the same by default so
the identity persists, but `--no-save-identity` skips it — useful for
ephemeral/CI runs that should not write to `~/.sovereign_browser/`.
## Mermaid: Startup Decision Flow
```mermaid
flowchart TD
A[main: cli_parse] --> B{help or version?}
B -- yes --> Z[print and exit 0]
B -- no --> C[gtk_init with stripped argv]
C --> D[settings_load + apply CLI overrides]
D --> E[agent_server_start unless --no-agent]
E --> F{login-method flag set?}
F -- yes --> G[cli_login: build cJSON, call agent_login]
G --> H{success?}
H -- no --> Y[print error, exit 1]
H -- yes --> I[optionally key_store_save unless --no-save-identity]
F -- no --> J[do_login: GTK dialog as today]
I --> K[session_restore unless --no-session-restore]
J --> K
K --> L{restored or URLs provided?}
L -- URLs --> M[open each --url / positional URL in a tab]
L -- none --> N[open settings.new_tab_url]
M --> O[gtk_main]
N --> O
```
## Open Questions
1. Should `--login-method generate` auto-set `--no-save-identity` by default
(since a generated key is usually ephemeral)? Proposal: **no** — keep
explicit, but document that random keys *will* be saved unless
`--no-save-identity` is passed.
2. Should we support reading `--nsec` / `--mnemonic` from a file path
(e.g. `--nsec-file /run/secrets/nsec`) to avoid leaking secrets in
`ps`/shell history? Proposal: defer to a follow-up; out of scope for
this plan but worth noting.
3. `--headless` (no GTK window, agent server only)? WebKitGTK requires a
display; true headless would need a virtual framebuffer or a non-WebKit
path. Out of scope for this plan.

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.

342
plans/keyboard-shortcuts.md Normal file
View File

@@ -0,0 +1,342 @@
# Plan: Configurable Keyboard Shortcuts
## Overview
Make browser-level keyboard shortcuts user-configurable on the `sovereign://settings` page. The user presses the desired key combination directly in the page (no typing "ctrl-F1" into a text box); the page captures the physical key press, formats it, and persists it. On the C side, the existing hardcoded `on_key_press()` dispatcher in [`main.c`](src/main.c:230) is refactored to look up bindings from a new `shortcuts` module.
## Nostr sync (NIP-78, kind 30078)
Per [NIP-78](https://github.com/nostr-protocol/nips/blob/master/78.md), kind `30078` is an addressable event for **arbitrary custom app data** — its first listed use case is literally *"User personal settings on Nostr clients."* We use it to sync all sovereign_browser user settings (not just shortcuts) across devices.
### Event design
```
{
"kind": 30078,
"pubkey": "<user pubkey hex>",
"tags": [
["d", "sovereign_browser"],
["client", "sovereign_browser"]
],
"content": "<NIP-44 encrypted JSON object of all user settings>",
"created_at": <unix timestamp>,
...
}
```
- **`d` tag**: `"sovereign_browser"` — namespacing so other NIP-78 apps don't collide.
- **`content`**: NIP-44 encrypted (to self, same pattern as bookmarks in [`bookmarks.c`](src/bookmarks.c:162)) JSON object containing all user-configurable settings: shortcuts bindings, tab preferences, agent server config, bootstrap relays, security toggles. This keeps potentially sensitive config (relay list, origins) private.
- **Plaintext payload** (before encryption):
```json
{
"shortcuts": {
"new_tab": "<Control>t",
"close_tab": "<Control>w",
...
},
"settings": {
"restore_session": true,
"new_tab_url": "https://example.com",
"tab_bar_position": "top",
...
}
}
```
### Sync flow
```mermaid
flowchart TD
A["User changes shortcut in settings UI"] --> B["shortcuts_set action accel"]
B --> C["Update in-memory g_parsed"]
B --> D["db_kv_set shortcut.X"]
D --> E["Debounced publish 500ms"]
E --> F["Serialize all settings to JSON"]
F --> G["NIP-44 encrypt to self"]
G --> H["Build kind 30078 event d=sovereign_browser"]
H --> I["Sign with signer"]
I --> J["Publish to bootstrap relays"]
I --> K["Store in SQLite db_store_event"]
L["Browser startup / login"] --> M["relay_fetch_thread fetches kind 30078"]
M --> N["Store in SQLite"]
N --> O["shortcuts_load_from_nostr: decrypt + parse"]
O --> P["Merge: Nostr values override local defaults, local overrides win on conflict timestamp"]
P --> Q["Populate in-memory g_parsed + write db_kv cache"]
```
### Merge / conflict policy
On startup, after the relay fetch thread completes (existing [`relay_fetch_thread`](src/relay_fetch.c) already fetches kinds 0/3/10002 — we add 30078), we decrypt the latest kind 30078 `d=sovereign_browser` event and merge:
1. **Local SQLite `db_kv` is the fast-path cache** — `shortcuts_load()` reads from it for instant startup (no waiting for relays).
2. **When the Nostr event arrives**, compare its `created_at` to a stored `settings.nostr_synced_at` timestamp in `db_kv`.
3. **If Nostr is newer**: overwrite local `db_kv` from the decrypted payload, reload in-memory bindings, reload the settings page if open.
4. **If local is newer** (user changed something offline): keep local, and schedule a re-publish so Nostr catches up.
5. **First run / no Nostr event**: defaults are used; nothing published until the user changes something.
This mirrors how bookmarks work but is simpler (single event vs. one-per-directory).
### Read-only / no-login mode
When there's no signer (read-only or `--no-login` mode), NIP-44 encryption isn't available. In that case:
- Settings still work fully via local `db_kv` (the fast path).
- No kind 30078 event is published or fetched.
- The settings page shows a note: *"Nostr sync disabled — log in with a signing key to sync settings across devices."*
### What goes in the Nostr payload
| Setting | Synced via NIP-78? | Rationale |
|---------|-------------------|-----------|
| Shortcut bindings | ✅ Yes | User preference, device-independent |
| Tab bar position, close buttons, drag reorder | ✅ Yes | UI preference |
| `new_tab_url`, `max_tabs` | ✅ Yes | Preference |
| `restore_session` | ❌ No | Device-specific (which tabs were open) |
| `agent_server_enabled`, `agent_server_port`, `agent_allowed_origins` | ❌ No | Device-specific (port may differ, origins may be localhost) |
| `agent_login_timeout_ms` | ❌ No | Device-specific |
| `bootstrap_relays` | ✅ Yes | User's relay list should follow them |
| Security toggles (dev_extras, file_access, universal_access) | ❌ No | Device-specific debugging flags |
The `settings_sync.c` module (see below) knows which keys belong in the Nostr payload via a whitelist.
## WebKitGTK built-in shortcuts (research finding)
WebKitGTK webviews consume standard **editing** shortcuts at the webview level *before* they reach our window-level `key-press-event` handler:
- `Ctrl+C` / `Ctrl+V` / `Ctrl+X` / `Ctrl+A` — clipboard
- `Ctrl+Z` / `Ctrl+Y` — undo/redo
- `Ctrl+F` — in-page find (only if a `WebKitFindController` is wired up; currently not)
- `Tab` / `Shift+Tab` — focus traversal within the page
- `F5` / `Ctrl+R` — reload (WebKit handles these for the webview)
**Browser-level** shortcuts (tab management, URL focus, tab cycling) are **not** handled by WebKit — they are ours, currently hardcoded in [`on_key_press()`](src/main.c:230). These are the ones we make configurable.
**Conflict policy**: If a user assigns a binding that WebKit consumes for the focused webview (e.g. `Ctrl+C`), the webview eats it and our handler never sees it. The settings UI will warn when a chosen combo is in a "reserved by web content" list, but will still allow it (the user may want it to apply only when the URL bar / tab strip has focus). Recommended defaults stick to `Ctrl+` letter combos and `F1``F12` which are safe.
## Binding format
Use **GTK accelerator string format** (parseable by `gtk_accelerator_parse`):
- `<Control>t` — Ctrl+T
- `<Control><Shift>Tab` — Ctrl+Shift+Tab
- `<Control>F5` — Ctrl+F5
- `F1` — bare function key
This gives us free parsing/serialization via `gtk_accelerator_parse()` / `gtk_accelerator_name()` and keeps the stored representation compact and human-readable.
## Action registry
A fixed enum of browser actions the user can bind. Each has: id, display label, description, default binding.
| Action ID | Label | Default binding |
|----------------------|------------------------|---------------------------|
| `new_tab` | New tab | `<Control>t` |
| `close_tab` | Close tab | `<Control>w` |
| `focus_url` | Focus URL bar | `<Control>l` |
| `next_tab` | Next tab | `<Control>Tab` |
| `prev_tab` | Previous tab | `<Control><Shift>Tab` |
| `next_tab_pagedown` | Next tab (PageDown) | `<Control>Page_Down` |
| `prev_tab_pageup` | Previous tab (PageUp) | `<Control>Page_Up` |
| `reload` | Reload page | `F5` |
| `force_reload` | Force reload | `<Shift>F5` |
| `go_back` | Go back | `<Alt>Left` |
| `go_forward` | Go forward | `<Alt>Right` |
| `find` | Find in page | `<Control>f` |
| `open_settings` | Open settings page | `<Control>comma` |
| `new_identity` | Switch identity | `<Control><Shift>i` |
| `toggle_fullscreen` | Toggle fullscreen | `F11` |
(Exact list is adjustable; the registry is a single table in `shortcuts.c` so adding/removing actions is trivial.)
## Storage
Each binding is one row in the existing `key_value` table:
- key: `shortcut.<action_id>` (e.g. `shortcut.new_tab`)
- value: accelerator string (e.g. `<Control>t`)
Loaded once at startup into an in-memory array indexed by action enum. `settings_save()` is not extended — the shortcuts module owns its own load/save via `db_kv_get`/`db_kv_set`.
## New module: `shortcuts.h` / `shortcuts.c`
```mermaid
flowchart LR
A["db_kv_get shortcut.*"] --> B["shortcuts_load"]
B --> C["g_bindings array"]
D["on_key_press event"] --> E["shortcuts_lookup event"]
C --> E
E --> F["action enum or -1"]
F --> G["dispatch switch in main.c"]
H["sovereign://settings/set?key=shortcut.X"] --> I["shortcuts_set action accel"]
I --> J["db_kv_set + update g_bindings"]
```
### `shortcuts.h` (public API)
```c
typedef enum {
SHORTCUT_NEW_TAB = 0,
SHORTCUT_CLOSE_TAB,
SHORTCUT_FOCUS_URL,
SHORTCUT_NEXT_TAB,
SHORTCUT_PREV_TAB,
SHORTCUT_NEXT_TAB_PAGEDOWN,
SHORTCUT_PREV_TAB_PAGEUP,
SHORTCUT_RELOAD,
SHORTCUT_FORCE_RELOAD,
SHORTCUT_GO_BACK,
SHORTCUT_GO_FORWARD,
SHORTCUT_FIND,
SHORTCUT_OPEN_SETTINGS,
SHORTCUT_NEW_IDENTITY,
SHORTCUT_TOGGLE_FULLSCREEN,
SHORTCUT_COUNT
} shortcut_action_t;
/* Load all bindings from db_kv_get into the in-memory array.
* Call once at startup after db_init(). Missing keys use defaults. */
void shortcuts_load(void);
/* Look up which action a key event matches, or -1 if none.
* Parses the stored accelerator strings once (cached) and compares
* keyval + mods. */
int shortcuts_lookup(GdkEventKey *event);
/* Get the accelerator string for an action (e.g. for the settings UI). */
const char *shortcuts_get(shortcut_action_t action);
/* Set and persist a new binding for an action.
* accel_str must be parseable by gtk_accelerator_parse.
* Returns 0 on success, -1 on parse failure. */
int shortcuts_set(shortcut_action_t action, const char *accel_str);
/* Reset an action to its default binding (and persist). */
void shortcuts_reset(shortcut_action_t action);
/* Reset all actions to defaults (and persist). */
void shortcuts_reset_all(void);
/* Metadata for the settings UI: label, description, default accel. */
typedef struct {
const char *id; /* "new_tab" */
const char *label; /* "New tab" */
const char *desc; /* "Open a new tab" */
const char *dflt; /* "<Control>t" */
} shortcut_meta_t;
const shortcut_meta_t *shortcuts_meta(shortcut_action_t action);
```
### `shortcuts.c` (implementation notes)
- Static array `static struct { guint keyval; GdkModifierType mods; } g_parsed[SHORTCUT_COUNT];` populated by `gtk_accelerator_parse()` on load and on each `shortcuts_set()`.
- `shortcuts_lookup()` applies `gtk_accelerator_get_default_mod_mask()` to the event state, then linear-scans `g_parsed` comparing `keyval` and masked `mods`. Returns first match (actions are distinct enough that collisions are the user's problem; the UI warns on duplicates).
- `shortcuts_set()` calls `gtk_accelerator_parse(accel_str, &key, &mods)`; returns -1 if parse fails. On success, updates `g_parsed`, writes `db_kv_set("shortcut.<id>", accel_str)`.
## Refactor `on_key_press()` in `main.c`
Replace the hardcoded if/else chain with:
```c
static gboolean on_key_press(GtkWidget *widget, GdkEventKey *event, gpointer data) {
int action = shortcuts_lookup(event);
if (action < 0) return FALSE;
switch ((shortcut_action_t)action) {
case SHORTCUT_NEW_TAB: tab_manager_new_tab(NULL); return TRUE;
case SHORTCUT_CLOSE_TAB: tab_manager_close_active(); return TRUE;
case SHORTCUT_FOCUS_URL: /* ... existing Ctrl+L logic ... */ return TRUE;
case SHORTCUT_NEXT_TAB: tab_manager_next(); return TRUE;
case SHORTCUT_PREV_TAB: tab_manager_prev(); return TRUE;
/* ... etc ... */
case SHORTCUT_FIND: /* open find bar (new) */ return TRUE;
case SHORTCUT_OPEN_SETTINGS: on_menu_settings(NULL, NULL); return TRUE;
/* ... */
default: return FALSE;
}
}
```
The `ctrl_tab_switch` boolean setting is removed from the settings struct (or kept as a quick on/off for the whole shortcuts feature). Decision: **keep `ctrl_tab_switch` as a master "enable keyboard shortcuts" toggle** — when off, `shortcuts_lookup()` returns -1 immediately. This preserves the existing one-click disable behavior.
## Settings page UI — "Keyboard Shortcuts" section
Added to [`handle_settings_page()`](src/nostr_bridge.c:393) HTML, between "Tabs" and "Agent Server". For each action in the registry, render a row:
```
[ New tab ] [ <Control>t ] [ Change ] [ Reset ]
[ Close tab ] [ <Control>w ] [ Change ] [ Reset ]
...
[ Reset all to defaults ]
```
### Key-capture interaction (the "press the keys" UX)
Each row's **Change** button focuses a hidden capture `<input>` and shows "Press keys…". The input has a `keydown` listener that:
1. Calls `event.preventDefault()` and `event.stopPropagation()` so the key combo is never delivered to the page.
2. Reads `event.ctrlKey`, `event.altKey`, `event.shiftKey`, `event.metaKey` and `event.key` / `event.code`.
3. Maps the JS key to a GTK accelerator string. Mapping rules:
- Modifier prefix: `<Control>` / `<Alt>` / `<Shift>` / `<Super>` in that order.
- Key normalization: letters → lowercase; `F1``F12` as-is; special keys (`Tab`, `Page_Down`, `Page_Up`, `Left`, `Right`, `comma`, `space`) mapped to GDK key names.
- Bare modifier-only presses are ignored (wait for a real key).
4. Displays the formatted string in the row (e.g. `Ctrl+Shift+T` for display, but sends `<Control><Shift>t` to the backend).
5. On `Enter` or blur, commits via `fetch('sovereign://settings/set?key=shortcut.new_tab&value=' + encodeURIComponent(accel))`.
6. `Escape` cancels capture without committing.
A small JS lookup table maps JS `event.code`/`event.key` → GDK key name for the ~20 keys we care about. This keeps the capture logic in the page without needing a round-trip to C for each keypress.
### Backend handling in `handle_settings_set()`
Add a branch in the key/value path of [`handle_settings_set()`](src/nostr_bridge.c:705):
```c
if (strncmp(key, "shortcut.", 9) == 0) {
const char *action_id = key + 9;
/* find action by id string in the registry */
shortcut_action_t a = shortcuts_action_from_id(action_id);
if (a < 0) { respond_error_json(...); return; }
if (shortcuts_set(a, value) != 0) {
respond_error_json(request, -1, "Invalid key combination");
return;
}
/* respond with ok + reload */
}
```
Also add a `shortcut.reset` and `shortcut.reset_all` pseudo-keys (or a separate `sovereign://settings/shortcut-reset?action=X` endpoint) for the Reset buttons.
### Conflict / duplicate warnings
The settings page JS, after a successful commit, can fetch all current bindings (embedded in the page HTML on render) and highlight any other action that now shares the same accelerator string. The backend `shortcuts_set()` does **not** reject duplicates — last-wins at lookup time — but the UI flags them in red so the user notices.
## Files to create / modify
| File | Change |
|------|--------|
| `src/shortcuts.h` | **New** — action enum + public API |
| `src/shortcuts.c` | **New** — registry, load/save (from `db_kv`), lookup, parse |
| `src/settings_sync.h` | **New** — NIP-78 sync API: `settings_sync_init()`, `settings_sync_publish()`, `settings_sync_merge_from_nostr()` |
| `src/settings_sync.c` | **New** — serialize whitelisted settings + shortcuts to JSON, NIP-44 encrypt to self, build/sign kind 30078 `d=sovereign_browser`, publish to relays, store in SQLite; decrypt + merge on fetch. Reuses the pattern from [`bookmarks.c`](src/bookmarks.c:214) `publish_directory()`. |
| `src/main.c` | Refactor [`on_key_press()`](src/main.c:230) to dispatch via `shortcuts_lookup()`; call `shortcuts_load()` after `settings_load()`; call `settings_sync_init()` after login; add action handlers (find, settings, fullscreen, back/forward) |
| `src/nostr_bridge.c` | Add "Keyboard Shortcuts" section to [`handle_settings_page()`](src/nostr_bridge.c:393) HTML + key-capture JS; add `shortcut.*` branch to [`handle_settings_set()`](src/nostr_bridge.c:705); trigger debounced `settings_sync_publish()` on any settings change |
| `src/settings.h` / `settings.c` | Repurpose `ctrl_tab_switch` → `shortcuts_enabled` master toggle (or keep name + add alias); update defaults + load/save |
| `src/relay_fetch.c` / `relay_fetch.h` | Add kind 30078 to the fetched kinds; after fetch, call `settings_sync_merge_from_nostr()` to decrypt and merge |
| `Makefile` | Add `src/shortcuts.c` and `src/settings_sync.c` to `SRC` |
| `src/tab_manager.h` / `tab_manager.c` | Add `tab_manager_find()` / find-bar helpers if `SHORTCUT_FIND` is implemented in this phase (can be stubbed for later) |
## Phasing
**Phase 1a (local):** shortcuts module + refactor `on_key_press` + settings UI with key capture. Covers all currently-hardcoded actions plus `reload`, `go_back`, `go_forward`, `open_settings`, `toggle_fullscreen` (these already have tab_manager/menu functions to call). Persistence via `db_kv` only.
**Phase 1b (Nostr sync):** `settings_sync` module — publish kind 30078 on debounced settings changes, fetch + merge on startup. This makes shortcuts (and other whitelisted settings) sync across devices.
**Phase 2 (future):** `find` (requires wiring `WebKitFindController` + a find bar widget), `new_identity` (calls `app_menu_switch_identity_proxy`), `force_reload` (bypass cache).
## Testing
- Manual: open `sovereign://settings`, press Change on "New tab", press `Ctrl+Q`, verify it persists and `Ctrl+Q` now opens a new tab on next key press (same session — `shortcuts_set` updates the in-memory array immediately).
- Restart browser, verify binding persisted in SQLite.
- Reset single + reset all buttons restore defaults.
- Duplicate-binding warning appears when two actions share a combo.
- Bare modifier press (just Ctrl) doesn't commit; Escape cancels.
- `shortcuts_enabled = false` disables all lookup.

78
plans/mcp-improvements.md Normal file
View File

@@ -0,0 +1,78 @@
# MCP Server Improvements Plan
## Problem Assessment
The sovereign_browser MCP server uses JavaScript-level interaction (JS `element.click()`,
`dispatchEvent`, `document.querySelector`) which has critical limitations:
1. **CSS selectors with special chars** (e.g. `[60vh]`) break in JSON escaping
2. **JS `.click()` doesn't trigger SPA framework handlers** (React synthetic events, Radix UI pointer events)
3. **Stale refs fail completely** instead of re-resolving
4. **No coordinate-based interaction** — everything depends on CSS selectors working
## agent-browser's Approach (CDP)
agent-browser uses Chrome DevTools Protocol:
- `Accessibility.getFullAXTree` → native AX tree with `backend_node_id` per node
- `DOM.getBoxModel(backend_node_id)` → pixel coordinates of element
- `Input.dispatchMouseEvent(x, y)` → real browser-level mouse events
- Stale `backend_node_id` → re-query AX tree by role+name
## Proposed Improvements for sovereign_browser
### 1. Coordinate-based clicking (highest impact, easiest)
Instead of JS `.click()`, get the element's bounding box and synthesize a GDK event:
```c
// In agent_tools.c, the click tool:
// 1. JS: getBoundingClientRect() → {x, y, width, height}
// 2. C: gdk_event_new(GDK_BUTTON_PRESS) at center coordinates
// 3. C: gtk_main_do_event() to dispatch to WebKit
```
This triggers real WebKit hit-testing and full event propagation, matching how
agent-browser's `Input.dispatchMouseEvent` works.
### 2. Fix ref storage — store bounding box, not just CSS selector
At snapshot time, store each ref's:
- `backend_node_id` (WebKit accessible's unique ID)
- `role` + `name` (for re-resolution if stale)
- `bounding_box` (x, y, width, height — for coordinate-based fallback)
- `selector` (CSS selector — as secondary fallback)
### 3. Stale ref fallback — re-resolve by role+name
If the CSS selector fails, re-query the accessibility tree by role+name
(matching agent-browser's `find_node_id_by_role_name` approach).
### 4. Use WebKit's native accessibility tree for snapshots
Instead of walking the DOM in JS, use WebKit's ATK accessibility:
```c
WebKitWebView *wv = ...;
AtkObject *root = webkit_web_view_get_accessible(wv);
// Walk the ATK tree — stable node IDs, no CSS selector issues
```
### 5. Add `click_at(x, y)` tool
A simple coordinate-based click tool that synthesizes GDK events.
Useful when you know the coordinates from a screenshot.
### 6. Fix `fill`/`type` to use GDK keyboard events
Instead of JS `element.value = ...`, use:
- JS to focus the element
- GDK key events to type character by character
- This triggers proper input validation and React onChange handlers
## Priority
1. **Coordinate-based clicking** — fixes the immediate "can't click buttons" problem
2. **Fix ref storage with bounding box** — enables coordinate fallback
3. **Stale ref fallback** — improves reliability
4. **Native AX tree snapshots** — better stability, less JS dependency
5. **GDK keyboard events for typing** — better SPA compatibility
6. **`click_at` tool** — convenience for screenshot-based interaction

363
plans/mcp-server.md Normal file
View File

@@ -0,0 +1,363 @@
# MCP Server Implementation Plan
## Overview
Add an MCP (Model Context Protocol) endpoint to sovereign_browser's existing
agent server. This allows AI assistants (Roo Code, Claude Code, Cursor, etc.)
to control the browser directly — no custom scripts, no CLI, no separate
process. The browser exposes its tools as MCP tools, and the AI assistant
calls them like any other MCP tool.
## Architecture
```
AI Assistant (Roo Code, Claude Code, etc.)
↕ MCP Streamable HTTP (JSON-RPC over SSE)
sovereign_browser (SoupServer on port 17777)
├── /agent → WebSocket (existing, for scripts/CLI)
├── /mcp → MCP Streamable HTTP (new, for AI assistants)
└── / → HTTP status (existing)
```
Both `/agent` (WebSocket) and `/mcp` (MCP) use the same internal
`agent_tools_dispatch()` function — same code path, same tools, same
behavior. This follows the debugging principle: no parallel
implementations.
## MCP Streamable HTTP transport
MCP supports a "Streamable HTTP" transport where the client sends HTTP
POST requests and the server responds with Server-Sent Events (SSE).
This is ideal for us — we already have a SoupServer running.
### Protocol flow
1. **Client sends POST to `/mcp`** with JSON-RPC body:
```json
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "roo-code", "version": "1.0"}
}}
```
2. **Server responds** with SSE stream containing the result:
```
event: message
data: {"jsonrpc": "2.0", "id": 1, "result": {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": "sovereign-browser", "version": "0.0.10"}
}}
```
3. **Client sends `tools/list`**:
```json
{"jsonrpc": "2.0", "id": 2, "method": "tools/list"}
```
4. **Server responds** with the full tool catalog (30 tools).
5. **Client sends `tools/call`**:
```json
{"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {
"name": "snapshot",
"arguments": {"interactive": true, "compact": true}
}}
```
6. **Server responds** with the tool result.
### Session management
MCP Streamable HTTP uses a session ID (returned in the `Mcp-Session-Id`
header from `initialize`). The client includes this header in subsequent
requests. We store session state (which is minimal — just the session ID
and whether the client has initialized).
## Tool catalog
Each MCP tool has: name, description, and inputSchema (JSON Schema).
### Login tools
```json
{
"name": "login_status",
"description": "Check if the browser is logged in. Returns the current login state, method, and pubkey if logged in.",
"inputSchema": {"type": "object", "properties": {}}
}
```
```json
{
"name": "login",
"description": "Log in to the browser with a Nostr identity. Methods: 'local' (nsec or hex privkey), 'seed' (BIP-39 mnemonic), 'readonly' (npub), 'nip46' (bunker:// URL), 'nsigner' (hardware signer). Must be called before browser tools work.",
"inputSchema": {
"type": "object",
"properties": {
"method": {"type": "string", "enum": ["local", "seed", "readonly", "nip46", "nsigner"]},
"nsec": {"type": "string", "description": "nsec1... string (method: local)"},
"privkey_hex": {"type": "string", "description": "64-char hex private key (method: local)"},
"mnemonic": {"type": "string", "description": "12 or 24 BIP-39 words (method: seed)"},
"account": {"type": "integer", "default": 0, "description": "Account index (method: seed)"},
"npub": {"type": "string", "description": "npub1... string (method: readonly)"},
"pubkey_hex": {"type": "string", "description": "64-char hex pubkey (method: readonly)"},
"bunker_url": {"type": "string", "description": "bunker:// URL (method: nip46)"},
"transport": {"type": "string", "enum": ["serial", "unix", "tcp", "qrexec"], "description": "Transport type (method: nsigner)"},
"device": {"type": "string", "description": "Device path, socket name, host:port, or qube name (method: nsigner)"},
"service": {"type": "string", "default": "qubes.NsignerRpc", "description": "Qrexec service name (method: nsigner, transport: qrexec)"},
"index": {"type": "integer", "default": 0, "description": "Key index (method: nsigner)"}
},
"required": ["method"]
}
}
```
```json
{
"name": "logout",
"description": "Log out and clear the current Nostr identity.",
"inputSchema": {"type": "object", "properties": {}}
}
```
```json
{
"name": "switch_identity",
"description": "Switch to a new Nostr identity. Same parameters as login. Frees the old signer first.",
"inputSchema": {"type": "object", "properties": {"method": {"type": "string"}, "nsec": {"type": "string"}, "privkey_hex": {"type": "string"}, "mnemonic": {"type": "string"}, "npub": {"type": "string"}, "bunker_url": {"type": "string"}, "transport": {"type": "string"}, "device": {"type": "string"}, "index": {"type": "integer"}}, "required": ["method"]}
}
```
### Navigation tools
```json
{"name": "open", "description": "Navigate the active tab to a URL.", "inputSchema": {"type": "object", "properties": {"url": {"type": "string"}}, "required": ["url"]}}
{"name": "back", "description": "Go back in browser history.", "inputSchema": {"type": "object", "properties": {}}}
{"name": "forward", "description": "Go forward in browser history.", "inputSchema": {"type": "object", "properties": {}}}
{"name": "reload", "description": "Reload the current page (bypassing cache).", "inputSchema": {"type": "object", "properties": {}}}
{"name": "get_url", "description": "Get the current URL of the active tab.", "inputSchema": {"type": "object", "properties": {}}}
{"name": "get_title", "description": "Get the page title of the active tab.", "inputSchema": {"type": "object", "properties": {}}}
```
### Snapshot & inspection tools
```json
{
"name": "snapshot",
"description": "Get the accessibility tree of the current page with element refs. Returns a text tree and a ref map. Use refs (e.g. @e1) to interact with elements by click, fill, etc. Call this after navigation or page changes to see what's on the page.",
"inputSchema": {
"type": "object",
"properties": {
"interactive": {"type": "boolean", "default": true, "description": "Only show interactive elements (links, buttons, inputs)"},
"compact": {"type": "boolean", "default": true, "description": "Remove empty structural elements"}
}
}
}
```
```json
{
"name": "get_text",
"description": "Get the text content of an element by ref or CSS selector.",
"inputSchema": {"type": "object", "properties": {"ref": {"type": "string", "description": "Element ref from snapshot (e.g. @e1)"}, "selector": {"type": "string", "description": "CSS selector"}}, "oneOf": [{"required": ["ref"]}, {"required": ["selector"]}]}
}
```
```json
{
"name": "get_html",
"description": "Get the innerHTML of an element by ref or CSS selector.",
"inputSchema": {"type": "object", "properties": {"ref": {"type": "string"}, "selector": {"type": "string"}}, "oneOf": [{"required": ["ref"]}, {"required": ["selector"]}]}
}
```
```json
{
"name": "get_attr",
"description": "Get an attribute of an element by ref or CSS selector.",
"inputSchema": {"type": "object", "properties": {"ref": {"type": "string"}, "selector": {"type": "string"}, "attr": {"type": "string", "description": "Attribute name (e.g. href, src, class)"}}, "required": ["attr"]}
}
```
```json
{
"name": "eval",
"description": "Run JavaScript in the current page and return the result. Use for custom inspection or interaction not covered by other tools.",
"inputSchema": {"type": "object", "properties": {"script": {"type": "string", "description": "JavaScript to execute"}}, "required": ["script"]}
}
```
### Interaction tools
```json
{"name": "click", "description": "Click an element by ref or CSS selector.", "inputSchema": {"type": "object", "properties": {"ref": {"type": "string"}, "selector": {"type": "string"}}, "oneOf": [{"required": ["ref"]}, {"required": ["selector"]}]}}
{"name": "fill", "description": "Clear an input and fill it with a value.", "inputSchema": {"type": "object", "properties": {"ref": {"type": "string"}, "selector": {"type": "string"}, "value": {"type": "string"}}, "required": ["value"]}}
{"name": "type", "description": "Type text into an element (appends to existing value).", "inputSchema": {"type": "object", "properties": {"ref": {"type": "string"}, "selector": {"type": "string"}, "value": {"type": "string"}}, "required": ["value"]}}
{"name": "press", "description": "Press a keyboard key (e.g. Enter, Tab, Escape).", "inputSchema": {"type": "object", "properties": {"key": {"type": "string"}}, "required": ["key"]}}
{"name": "scroll", "description": "Scroll the page in a direction.", "inputSchema": {"type": "object", "properties": {"direction": {"type": "string", "enum": ["up", "down", "left", "right"]}, "amount": {"type": "integer", "default": 500}}, "required": ["direction"]}}
{"name": "hover", "description": "Hover over an element by ref or CSS selector.", "inputSchema": {"type": "object", "properties": {"ref": {"type": "string"}, "selector": {"type": "string"}}, "oneOf": [{"required": ["ref"]}, {"required": ["selector"]}]}}
{"name": "focus", "description": "Focus an element by ref or CSS selector.", "inputSchema": {"type": "object", "properties": {"ref": {"type": "string"}, "selector": {"type": "string"}}, "oneOf": [{"required": ["ref"]}, {"required": ["selector"]}]}}
{"name": "close", "description": "Close the active tab.", "inputSchema": {"type": "object", "properties": {}}}
```
### Tab tools
```json
{"name": "tab_list", "description": "List all open tabs with their URLs and titles.", "inputSchema": {"type": "object", "properties": {}}}
{"name": "tab_new", "description": "Open a new tab, optionally with a URL.", "inputSchema": {"type": "object", "properties": {"url": {"type": "string"}}}}
{"name": "tab_switch", "description": "Switch to a tab by index.", "inputSchema": {"type": "object", "properties": {"index": {"type": "integer"}}, "required": ["index"]}}
{"name": "tab_close", "description": "Close a tab by index. If no index, closes the active tab.", "inputSchema": {"type": "object", "properties": {"index": {"type": "integer"}}}}
```
### Wait tools
```json
{"name": "wait", "description": "Wait for a specified number of milliseconds.", "inputSchema": {"type": "object", "properties": {"ms": {"type": "integer", "default": 1000}}}}
{"name": "wait_for", "description": "Wait for an element to appear on the page.", "inputSchema": {"type": "object", "properties": {"selector": {"type": "string"}, "timeout": {"type": "integer", "default": 10000}}, "required": ["selector"]}}
```
## Implementation
### New file: `src/agent_mcp.h` / `src/agent_mcp.c`
```c
/*
* MCP (Model Context Protocol) server endpoint.
* Implements the Streamable HTTP transport on top of our existing
* SoupServer. Exposes browser tools as MCP tools for AI assistants.
*/
/* Register the MCP handler at /mcp on the given SoupServer. */
void agent_mcp_register(SoupServer *server);
```
### MCP request handling
The MCP handler at `/mcp` processes HTTP POST requests with JSON-RPC
bodies. It handles three methods:
1. **`initialize`** — returns server info and capabilities, creates a session, returns `Mcp-Session-Id` header
2. **`tools/list`** — returns the tool catalog (all 30 tools with schemas)
3. **`tools/call`** — dispatches to `agent_tools_dispatch()` (same as WebSocket)
4. **`ping`** — health check, returns empty result
5. **`resources/list`** / **`resources/templates/list`** — returns empty lists (no resources exposed)
6. **`prompts/list`** — returns empty list (no prompts exposed)
7. **`logging/setLevel`** — acknowledged, no filtering applied
8. **`notifications/*`** — returns 202 Accepted with no body
9. **`GET /mcp`** — returns SSE content-type with `: connected` comment
10. **`DELETE /mcp`** — terminates the session (requires `Mcp-Session-Id` header)
For `tools/call`, the MCP handler:
1. Extracts the tool name and arguments from the JSON-RPC params
2. Constructs a cJSON request object (same format as WebSocket)
3. Calls `agent_tools_dispatch(request, NULL)` — passing NULL for the
connection since MCP uses HTTP response, not WebSocket
4. For sync tools: returns the response immediately as JSON-RPC result
5. For async tools (snapshot, eval, etc.): this is the tricky part...
### Async tool handling for MCP
The async tools (snapshot, eval, get_text, etc.) send their response
through a WebSocket connection callback. But MCP uses HTTP responses,
not WebSocket. We need a way to capture the async result and return it
in the HTTP response.
**Approach:** Use a GMainLoop polling approach (which works for HTTP
since we're not inside a WebSocket callback). The MCP handler:
1. Creates a temporary "result holder" struct
2. Calls `agent_js_eval_async()` with a custom callback that stores
the result in the holder and quits a GMainLoop
3. Runs a GMainLoop with timeout
4. When the JS completes, the callback stores the result
5. The handler returns the result as the HTTP response
This works because the MCP HTTP handler is a regular SoupServer callback
(not a WebSocket message callback), so the GMainLoop polling approach
works correctly.
### Tool catalog generation
The tool catalog is a static cJSON array built once at startup. Each
tool entry has:
- `name`: tool name string
- `description`: human-readable description
- `inputSchema`: JSON Schema object
### Modified files
- **`src/agent_mcp.h` / `src/agent_mcp.c`** — new, MCP handler
- **`src/agent_server.c`** — call `agent_mcp_register()` in `agent_server_start()`
- **`src/agent_tools.c`** — add a variant of dispatch that works with
a result-holder instead of a WebSocket connection (for MCP async tools)
- **`Makefile`** — add `src/agent_mcp.c` to SRC
### Roo Code configuration
To use the browser as an MCP server in Roo Code, add this to the MCP
configuration:
```json
{
"mcpServers": {
"sovereign-browser": {
"url": "http://localhost:17777/mcp",
"transport": "streamable-http"
}
}
}
```
## Implementation order
1. Create `src/agent_mcp.h` / `src/agent_mcp.c` with the tool catalog
2. Implement `initialize` and `tools/list` handlers
3. Implement `tools/call` for sync tools (login, open, tab_list, etc.)
4. Implement `tools/call` for async tools (snapshot, eval, get_text, etc.)
using the GMainLoop polling approach
5. Register the MCP handler in `agent_server_start()`
6. Test with Roo Code or a manual MCP client
7. Update Makefile and docs
## Implementation Status
**Completed** — all items in the implementation order above are done.
### What's implemented
- **MCP Streamable HTTP transport** on `/mcp` (port 17777), coexisting with the existing `/agent` WebSocket endpoint.
- **Session management**: `initialize` creates a session and returns a `Mcp-Session-Id` header. Subsequent requests are validated against the session store. Unknown/expired sessions return 404. Sessions have a 60-second idle timeout.
- **SSE responses**: all JSON-RPC responses (requests with an `id`) are wrapped in `event: message\ndata: <json>\n\n` SSE format with `Content-Type: text/event-stream`.
- **GET /mcp**: returns a `text/event-stream` response with a `: connected` comment (satisfies clients probing for SSE push support).
- **DELETE /mcp**: terminates a session by `Mcp-Session-Id` header. Returns 200 on success, 400 if header missing, 404 if session unknown.
- **Notifications**: `notifications/*` and any request without an `id` return 202 Accepted with no body.
- **Tool catalog**: 30 tools exposed (29 original + `screenshot`). The `screenshot` tool returns an MCP image content block (`type: "image"`, base64 PNG, `mimeType: "image/png"`) instead of a text block.
- **Lenient mode**: requests without a `Mcp-Session-Id` header are accepted with a warning (backward compatibility with older clients).
- **Roo Code integration**: configured in `mcp_settings.json` with `transport: "streamable-http"` and all 30 tools in `alwaysAllow`.
### Test results (2026-07-12)
All 10 integration tests pass:
| Test | Description | Result |
|------|-------------|--------|
| 1 | Initialize — returns `Mcp-Session-Id` header + SSE body | ✅ Pass |
| 2 | `tools/list` — 30 tools including `screenshot` | ✅ Pass |
| 3 | `ping` with session — SSE empty result | ✅ Pass |
| 4 | `resources/list` — empty resources array | ✅ Pass |
| 5 | `prompts/list` — empty prompts array | ✅ Pass |
| 6 | `notifications/initialized` — 202 Accepted | ✅ Pass |
| 7 | `GET /mcp` — `text/event-stream` content type | ✅ Pass |
| 8 | `DELETE /mcp` — 200 OK | ✅ Pass |
| 9 | `ping` after DELETE — 404 (session terminated) | ✅ Pass |
| 10 | `login_status` tool call — returns login state | ✅ Pass |
### Files
- [`src/agent_mcp.c`](src/agent_mcp.c) — MCP handler (request routing, session management, SSE responses)
- [`src/agent_mcp.h`](src/agent_mcp.h) — public API
- [`src/agent_tools.c`](src/agent_tools.c) — tool dispatch (shared with WebSocket endpoint)
- [`src/agent_server.c`](src/agent_server.c) — registers MCP handler on the SoupServer
- [`Makefile`](Makefile) — includes `src/agent_mcp.c` in the build

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

290
plans/phase3-tools.md Normal file
View File

@@ -0,0 +1,290 @@
# Phase 3 — Extended Tool Catalog (70 tools)
> **Status: Complete** — All 8 batches implemented, built, and verified.
> 100 total tools (30 Phase 1 + 70 Phase 3). See the verification table
> at the bottom and [`plans/agent-tools.md`](agent-tools.md) for the full
> status.
## Overview
Implement all remaining tools from the agent-browser comparison table.
The original plan counted 67 tools; the final implementation delivered 70
(11+7+10+5+11+13+10+3). The difference of 3 comes from expanding the
storage category: the plan listed 8 local-only storage tools, but we
added 4 `storage_session_*` tools (get, get_key, set, clear) for full
session storage parity, bringing the storage batch to 11. These extend
sovere_browser's MCP server from 30 tools to 100 tools, covering the full
agent-browser feature set that has a WebKitGTK equivalent.
## Implementation patterns
All Phase 3 tools follow one of these patterns:
### Pattern A: JS eval (sync)
The majority of tools. Resolve ref/selector → build JS string → `agent_js_eval_sync()` → parse result → return cJSON. Same pattern as existing `click`, `fill`, `get_text`, etc.
### Pattern B: WebKitGTK C API
Some tools need WebKitGTK C APIs instead of JS:
- Cookies → `WebKitCookieManager`
- Settings → `WebKitSettings`
- Dialogs → `WebKitScriptDialog` (via signal handler)
- Console → `WebKitConsoleMessage` (via signal handler)
- PDF → `webkit_web_view_save()` / print API
- State → `WebKitWebsiteDataManager`
### Pattern C: GDK/GTK API
- Clipboard → `GtkClipboard` / `GdkClipboard`
- Mouse events → `gdk_event_new()` + `gtk_main_do_event()` (synthesized)
- Viewport → `gtk_window_resize()`
### Pattern D: Composite
- `batch` — calls `agent_tools_dispatch()` in a loop
- `find_*` — builds CSS selector from semantic criteria, then uses Pattern A
- `wait_for_*` — polls with `agent_js_eval_sync()` in a loop (like existing `wait_for`)
## Batches
### Batch 1: Extended interaction (11 tools)
All Pattern A (JS eval). Straightforward extensions of existing interaction tools.
| Tool | Params | JS approach | Notes |
|------|--------|-------------|-------|
| `dblclick` | ref/selector | `el.dispatchEvent(new MouseEvent('dblclick',{bubbles:true}))` | Like click but dblclick event |
| `select` | ref/selector, value | Set `<select>` `.value` + dispatch change event | For dropdowns |
| `check` | ref/selector | `el.checked = true; el.dispatchEvent(new Event('change'))` | Checkboxes |
| `uncheck` | ref/selector | `el.checked = false; el.dispatchEvent(new Event('change'))` | Checkboxes |
| `scroll_into_view` | ref/selector | `el.scrollIntoView({behavior:'smooth',block:'center'})` | Scroll element into view |
| `keyboard_type` | value | Synthesize keydown+keypress+keyup per char on focused element | Real keystrokes |
| `insert_text` | value | `document.execCommand('insertText', false, text)` | Insert without key events |
| `keydown` | key | `new KeyboardEvent('keydown',{key:key,bubbles:true})` | Hold key down |
| `keyup` | key | `new KeyboardEvent('keyup',{key:key,bubbles:true})` | Release key |
| `drag` | src_ref/selector, tgt_ref/selector | Synthesize dragstart→dragenter→dragover→drop→dragend | HTML5 drag and drop |
| `close_all` | (none) | Close all tabs via `tab_manager_close_all()` | New tab_manager function needed |
### Batch 2: Get info + check state (7 tools)
All Pattern A (JS eval). Read-only queries returning element state.
| Tool | Params | JS approach | Returns |
|------|--------|-------------|---------|
| `get_value` | ref/selector | `el.value` | `{"value":"..."}` |
| `get_count` | selector | `document.querySelectorAll(sel).length` | `{"count":N}` |
| `get_box` | ref/selector | `el.getBoundingClientRect()` | `{"x":N,"y":N,"width":N,"height":N}` |
| `get_styles` | ref/selector | `getComputedStyle(el)` → JSON | `{"styles":{...}}` |
| `is_visible` | ref/selector | Check offsetParent, computed display/visibility | `{"visible":true/false}` |
| `is_enabled` | ref/selector | `!el.disabled` | `{"enabled":true/false}` |
| `is_checked` | ref/selector | `el.checked` | `{"checked":true/false}` |
### Batch 3: Find elements (10 tools)
Pattern D (composite). Build CSS selector from semantic criteria, then query.
| Tool | Params | Selector approach |
|------|--------|-------------------|
| `find_role` | role, name? | `[role="X"]` or semantic HTML (button, nav, etc.) |
| `find_text` | text, exact? | `:contains()` via JS (no native CSS) — walk DOM |
| `find_label` | label | `label[for]` matching + `aria-label` + `aria-labelledby` |
| `find_placeholder` | placeholder | `[placeholder*="X"]` |
| `find_alt` | alt | `[alt*="X"]` |
| `find_title` | title | `[title*="X"]` |
| `find_testid` | testid | `[data-testid="X"]` |
| `find_first` | selector | `document.querySelector(sel)` |
| `find_last` | selector | `document.querySelectorAll(sel)[last]` |
| `find_nth` | n, selector | `document.querySelectorAll(sel)[n]` |
All return `{"ref":"@eN","selector":"...","role":"...","name":"..."}` — assigns a ref so the result can be used with click/fill/etc. This requires integration with the snapshot ref system (`window.__agentRefs`).
### Batch 4: Wait + batch (5 tools)
| Tool | Params | Approach | Pattern |
|------|--------|----------|---------|
| `wait_for_text` | text, timeout? | Poll JS: `document.body.innerText.includes(text)` | D (poll) |
| `wait_for_url` | url_pattern, timeout? | Poll `webkit_web_view_get_uri()` against regex | D (poll) |
| `wait_for_load` | state?, timeout? | Wait for WebKitWebView load event | B (signal) |
| `wait_for_fn` | script, timeout? | Poll JS: eval user script, check truthy | D (poll) |
| `batch` | commands[] | Loop: call `agent_tools_dispatch()` per command | D (composite) |
### Batch 5: Cookies + storage (11 tools)
Pattern B (WebKit API) for cookies, Pattern A (JS eval) for storage.
| Tool | Params | Approach | Pattern |
|------|--------|----------|---------|
| `cookies_get` | (none) | `WebKitCookieManager` async API | B |
| `cookies_set` | name, value, domain, path, ... | JS: `document.cookie = "..."` | A |
| `cookies_clear` | (none) | `webkit_cookie_manager_clear()` | B |
| `storage_local_get` | (none) | JS: `JSON.stringify(localStorage)` | A |
| `storage_local_get_key` | key | JS: `localStorage.getItem(key)` | A |
| `storage_local_set` | key, value | JS: `localStorage.setItem(key, value)` | A |
| `storage_local_clear` | (none) | JS: `localStorage.clear()` | A |
| `storage_session_get` | (none) | JS: `JSON.stringify(sessionStorage)` | A |
| `storage_session_get_key` | key | JS: `sessionStorage.getItem(key)` | A |
| `storage_session_set` | key, value | JS: `sessionStorage.setItem(key, value)` | A |
| `storage_session_clear` | (none) | JS: `sessionStorage.clear()` | A |
Note: The original plan collapsed `storage_session_*` into a single row
and counted 8 total for the batch (3 cookies + 4 local + 1 session row).
The implementation expanded the session row into 4 concrete tools, giving
11 total (3 cookies + 4 local + 4 session). This is the source of the
+3 difference between the planned 67 and the implemented 70.
### Batch 6: Mouse + clipboard + settings (12 tools)
| Tool | Params | Approach | Pattern |
|------|--------|----------|---------|
| `mouse_move` | x, y | JS: dispatch mousemove at coordinates | A |
| `mouse_down` | button? | JS: dispatch mousedown | A |
| `mouse_up` | button? | JS: dispatch mouseup | A |
| `mouse_wheel` | dy, dx? | JS: dispatch wheel event | A |
| `clipboard_read` | (none) | `gdk_clipboard_read_text_async()` | C |
| `clipboard_write` | text | `gdk_clipboard_set_text()` | C |
| `clipboard_copy` | (none) | JS: `document.execCommand('copy')` | A |
| `clipboard_paste` | (none) | JS: `document.execCommand('paste')` | A |
| `set_viewport` | width, height | `gtk_window_resize()` | C |
| `set_offline` | on/off | `WebKitWebContext` network policy | B |
| `set_headers` | headers_json | `WebKitUserContentManager` injection or custom | B |
| `set_credentials` | user, pass | `WebKitAuthenticationRequest` handler | B |
| `set_media` | dark/light | `WebKitSettings` + CSS media query emulation | B |
Note: set_media is 1 tool, set_credentials is 1 tool. Total for this batch: 13 tools (mouse 4 + clipboard 4 + settings 5).
### Batch 7: Frames + dialogs + debug (10 tools)
| Tool | Params | Approach | Pattern |
|------|--------|----------|---------|
| `frame_switch` | ref/selector | Track current frame in C, eval JS in frame context | B |
| `frame_main` | (none) | Reset to main frame | B |
| `dialog_accept` | text? | `webkit_script_dialog_confirm_set_confirmed()` etc. | B |
| `dialog_dismiss` | (none) | Same API, set false | B |
| `dialog_status` | (none) | Check if a script dialog is pending | B |
| `console` | (none) | Collect console messages (need signal handler) | B |
| `errors` | (none) | Collect page errors (need signal handler) | B |
| `highlight` | ref/selector | JS: add temporary outline/border | A |
| `state_save` | (none) | `WebKitWebsiteDataManager` persist | B |
| `state_load` | (none) | Restore from persisted data | B |
Note: state_save/load/list = 3 tools. Total: 10 tools (frames 2 + dialogs 3 + debug 2 + highlight 1 + state 2).
### Batch 8: Complex tools (3 tools)
| Tool | Params | Approach | Pattern |
|------|--------|----------|---------|
| `upload` | ref/selector, files[] | Set file input `.files` via JS (limited) or WebKit API | B/A |
| `pdf` | path? | `webkit_web_view_save()` or print-to-PDF API | B |
| `screenshot_annotated` | (none) | Screenshot + overlay element labels via JS canvas | A+B |
## Tool count verification
| Batch | Tools | Count | Status |
|-------|-------|-------|--------|
| 1 | Extended interaction | 11 | ✅ Complete |
| 2 | Get info + check state | 7 | ✅ Complete |
| 3 | Find elements | 10 | ✅ Complete |
| 4 | Wait + batch | 5 | ✅ Complete |
| 5 | Cookies + storage | 11 | ✅ Complete (was 8 in plan; +3 `storage_session_*`) |
| 6 | Mouse + clipboard + settings | 13 | ✅ Complete |
| 7 | Frames + dialogs + debug | 10 | ✅ Complete |
| 8 | Complex | 3 | ✅ Complete |
| **Total** | | **70** | ✅ All batches complete |
### Final verification (post-implementation)
- `make clean && make` — builds cleanly (only pre-existing warnings in
`login_dialog.c` and `agent_mcp.c`).
- `tools/list` returns exactly **100 tools** (30 Phase 1 + 70 Phase 3).
- `initialize` returns a session ID; `ping` returns an empty result.
- `login_status` works without login (`logged_in: false`).
- Browser tools (e.g. `dblclick`) return `NOT_LOGGED_IN` before login.
- `batch` dispatches without login; sub-commands enforce login
individually (bug fix: `batch` was incorrectly gated by login — added
to the login exception list in `agent_tools_dispatch()`).
- Roo Code MCP config `alwaysAllow` updated with all 100 tool names.
## Implementation order
Batches 1-4 are all Pattern A (JS eval) or simple composites — no new WebKit API knowledge needed. They should be done first.
Batches 5-8 require WebKitGTK C API knowledge and are more complex. They should be done after the JS-based tools are stable.
### Recommended execution order
1. **Batch 1** (11 tools) — extended interaction, all JS eval
2. **Batch 2** (7 tools) — get info + check state, all JS eval
3. **Batch 3** (10 tools) — find elements, JS eval + ref integration
4. **Batch 4** (5 tools) — wait + batch, polling + composite
5. **Batch 5** (11 tools) — cookies + storage, mixed WebKit API + JS
6. **Batch 6** (13 tools) — mouse + clipboard + settings, mixed
7. **Batch 7** (10 tools) — frames + dialogs + debug, WebKit API
8. **Batch 8** (3 tools) — complex tools, WebKit API
## Files to modify
- `src/agent_tools.c` — all tool implementations + dispatch table entries
- `src/agent_mcp.c` — tool catalog entries (`tool_defs[]`) + schemas
- `src/agent_tools.h` — no changes needed (dispatch API unchanged)
- `src/tab_manager.c` / `src/tab_manager.h` — add `tab_manager_close_all()` for `close_all`
- `src/agent_server.c` — may need console/error message signal handlers
- `~/.config/VSCodium/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json` — add new tools to `alwaysAllow`
- `plans/agent-tools.md` — update Phase 3 status
## Key design decisions
### Find tools and ref integration
The `find_*` tools need to assign refs so results work with `click @eN` etc. This means they must register the found element in `window.__agentRefs` (the same mechanism the snapshot uses). The find tool will:
1. Run JS that finds the element and generates a unique CSS selector for it
2. Assign a new ref (incrementing counter from `window.__agentRefCounter`)
3. Store `{selector: "...", role: "...", name: "..."}` in `window.__agentRefs[eN]`
4. Return the ref to the caller
### Batch tool
The `batch` tool takes an array of tool requests and executes them sequentially:
```json
{"tool":"batch","params":{"commands":[
{"tool":"open","params":{"url":"https://example.com"}},
{"tool":"snapshot","params":{}},
{"tool":"click","params":{"ref":"@e1"}}
]}}
```
Returns an array of responses. Each command is dispatched via `agent_tools_dispatch()`. If a command fails, the batch stops (unless `continueOnError: true`).
### Console and error collection
Need to connect to WebKitWebView's `console-message` signal and `notify::title` or `load-failed` signals. Store messages in a per-webview GArray or GPtrArray. The `console` and `errors` tools return the collected messages and optionally clear them.
### Dialog handling
WebKitGTK emits `script-dialog` signal for alert/confirm/prompt. We need to:
1. Connect to the signal
2. Store the pending dialog (type, message, default text)
3. The `dialog_accept`/`dialog_dismiss` tools call the appropriate `webkit_script_dialog_*_set_*()` function
4. `dialog_status` returns whether a dialog is pending and its details
### Frame switching
WebKitGTK doesn't have a direct "switch to frame" API like Selenium. We'll track a "current frame" selector in C state. All JS eval tools will wrap their scripts to execute within the frame context:
```js
(function() {
var frame = document.querySelector('<frame_selector>');
var doc = frame ? frame.contentDocument : document;
// ... run tool JS within doc ...
})();
```
`frame_main` resets the frame selector to NULL (use main document).
## Testing
After each batch:
1. `make clean && make` — verify build
2. Start browser, test each new tool with `curl` against `/mcp`
3. Verify `tools/list` shows the new tools
4. Update `mcp_settings.json` `alwaysAllow` array
## Mermaid: Implementation flow
```mermaid
flowchart TB
Start[Phase 3 Start] --> B1[Batch 1: Extended Interaction<br/>11 tools, JS eval]
B1 --> B2[Batch 2: Get Info + Check State<br/>7 tools, JS eval]
B2 --> B3[Batch 3: Find Elements<br/>10 tools, JS eval + refs]
B3 --> B4[Batch 4: Wait + Batch<br/>5 tools, polling + composite]
B4 --> B5[Batch 5: Cookies + Storage<br/>11 tools, WebKit API + JS]
B5 --> B6[Batch 6: Mouse + Clipboard + Settings<br/>13 tools, mixed]
B6 --> B7[Batch 7: Frames + Dialogs + Debug<br/>10 tools, WebKit API]
B7 --> B8[Batch 8: Complex<br/>3 tools, WebKit API]
B8 --> Final[Final: Build, Test, Update Config + Docs]
Final --> Done[100 tools total<br/>30 Phase 1 + 70 Phase 3]
```

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).

127
plans/search-engine.md Normal file
View File

@@ -0,0 +1,127 @@
# Search Engine Integration & URL Bar Dropdown
## Goal
Integrate a search engine (DuckDuckGo default, user-changeable) into the URL bar. When the user types, a dropdown appears with **direct links first** (from history, bookmarks, and domain heuristics), followed by **search engine suggestions**. Selecting a direct link navigates to that URL; selecting a search suggestion goes to the search engine results page.
## Architecture
```mermaid
flowchart TD
A[User types in URL entry] --> B[on_url_changed callback]
B --> C[Clear suggestion list store]
C --> D[db_history_search query]
C --> E[bookmarks_search query]
C --> F[domain heuristic]
D --> G[Add direct-link rows to model]
E --> G
F --> G
B --> H[Async SoupRequest to DDG ac API]
H --> I[Add search-suggestion rows to model]
G --> J[GtkEntryCompletion dropdown shows]
I --> J
J --> K{User selects a row}
K -->|direct link| L[webkit_web_view_load_uri url]
K -->|search suggestion| M[load search engine URL with query]
K -->|Enter, no selection| N{Is input a URL?}
N -->|Yes| L
N -->|No| M
```
## Data Sources for Direct Links
1. **History** — SQLite `history` table, searched by URL/title substring, ranked by `visit_count DESC, visited_at DESC`. These are sites the user has actually visited.
2. **Bookmarks** — In-memory bookmark list, searched by URL/title substring.
3. **Domain heuristic** — If the typed text has no spaces and no dots, offer `https://<text>.org` and `https://<text>.com` as "Navigate directly" options. Handles first-time visits to known domains.
## Search Engine Suggestions
DuckDuckGo autocomplete API: `https://duckduckgo.com/ac/?q=<query>&type=list`
Returns a JSON array: `["wikipedia", "wikipedia english", ...]`
Fetched asynchronously via libsoup (already linked). Results populate the lower section of the dropdown. Selecting one navigates to `https://duckduckgo.com/?q=<term>`.
## Search Engine Configuration
Built-in engines hardcoded in `search.c`:
| Engine | Search URL | Suggestion URL |
|--------|-----------|----------------|
| DuckDuckGo | `https://duckduckgo.com/?q=%s` | `https://duckduckgo.com/ac/?q=%s&type=list` |
| Google | `https://www.google.com/search?q=%s` | `https://suggestqueries.google.com/complete/search?client=firefox&q=%s` |
| Brave | `https://search.brave.com/search?q=%s` | `https://search.brave.com/api/suggest?q=%s` |
| Startpage | `https://www.startpage.com/sp/search?query=%s` | (none — no autocomplete) |
| Searx | `https://searx.be/search?q=%s` | (none) |
The selected engine name is stored in settings (`search_engine` key) and synced via NIP-78.
## Files to Create
### `src/search.h` + `src/search.c`
- `search_engine_t` struct: name, search_url_template, suggestion_url_template
- `search_engines_get()` — returns array of built-in engines
- `search_engine_get_active()` — returns the currently selected engine
- `search_engine_build_search_url(const char *query)` — builds a search URL from the active engine's template
- `search_engine_build_suggestion_url(const char *query)` — builds the suggestion API URL
- `search_suggest_fetch_async(const char *query, GFunc callback, gpointer user_data)` — async libsoup HTTP GET, parses JSON array, calls callback with results
- `search_is_url(const char *input)` — heuristic: returns TRUE if input looks like a URL (has scheme, or has a dot with no spaces)
## Files to Modify
### `src/settings.h` + `src/settings.c`
- Add `char search_engine[64]` to `browser_settings_t` (default "duckduckgo")
- Load/save the `search_engine` key from db_kv
### `src/db.h` + `src/db.c`
- Add `db_history_search(const char *query, char ***urls_out, char ***titles_out, int *count_out, int limit)``WHERE url LIKE '%q%' OR title LIKE '%q%' ORDER BY visit_count DESC, visited_at DESC LIMIT ?`
- Add index on `history(url)` and `history(title)` for search performance
### `src/tab_manager.c`
- Add a `GtkListStore` (per-tab or shared) with columns: display text, URL, item type (direct/suggestion), icon name
- Add `GtkEntryCompletion` to each tab's `url_entry` with a custom match function
- Add `on_url_changed` callback — rebuilds the list store on each keystroke:
1. Clear store
2. Query history via `db_history_search()`
3. Query bookmarks via `bookmarks_get_dirs()` + filter
4. Add domain heuristic entries if applicable
5. Fire async `search_suggest_fetch_async()` — on completion, append suggestion rows
- Override `match-selected` signal — read the URL column; if it's a direct link, navigate; if it's a search suggestion, build search URL and navigate
- Modify `on_url_activate` — if input is not a URL (per `search_is_url()`), build a search URL and navigate instead of prepending `https://`
- Modify `normalize_url` — if input is not a URL and not an about: URL, treat as a search query
### `src/nostr_bridge.c`
- Add a "Search Engine" section to the settings page HTML with a `<select>` dropdown of built-in engines
- Add `search_engine` to the `handle_settings_set` key/value handler
### `src/settings_sync.c`
- Add `"search_engine"` to `g_sync_setting_keys[]` whitelist
### `Makefile`
- Add `src/search.c` to `SRC`
## Dropdown UX Design
Each row in the completion dropdown shows:
- **Direct links** (history/bookmarks/domain): `🌐 wikipedia.org — Wikipedia` (URL + title), with a history/bookmark icon
- **Search suggestions**: `🔍 wikipedia english` (search term), with a search icon
Direct links always appear before search suggestions. A visual separator (different icon, possibly different text color via Pango markup) distinguishes the two categories.
## Enter Key Behavior
When the user presses Enter without selecting a dropdown item:
1. If `search_is_url(input)` is true → navigate to the URL (current behavior)
2. If not → build a search URL from the active engine and navigate
This means typing "wikipedia" and pressing Enter goes to DDG search, but typing "wikipedia.org" and pressing Enter goes directly to wikipedia.org. Typing "wikipedia" and selecting the history entry for wikipedia.org from the dropdown goes directly there.
## Implementation Order
1. Create `src/search.h` + `src/search.c` (engine definitions, URL builders, async suggestion fetch, URL heuristic)
2. Add `db_history_search()` to `src/db.h` + `src/db.c` + search indexes
3. Add `search_engine` setting to `src/settings.h` + `src/settings.c`
4. Add search engine dropdown to settings page in `src/nostr_bridge.c`
5. Add `search_engine` to sync whitelist in `src/settings_sync.c`
6. Implement URL bar dropdown in `src/tab_manager.c` (list store, entry completion, changed callback, match-selected handler)
7. Update `on_url_activate` / `normalize_url` for search fallback
8. Add `src/search.c` to `Makefile`
9. Build and test

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).

167
plans/sqlite-migration.md Normal file
View File

@@ -0,0 +1,167 @@
# Plan: Migrate All Storage to SQLite + Remove Key Persistence
## Overview
Consolidate all browser data storage into the SQLite database (`~/.sovereign_browser/browser.db`) and **remove all key persistence** — private keys live only in RAM and are gone when the browser quits.
## Current state
| File | Format | Stores | Action |
|------|--------|--------|--------|
| `identity.json` | JSON | Nostr private key, mnemonic, bunker URL | **Delete the code + file** — keys must never touch disk |
| `history.txt` | Plain text | Recent URLs (max 50) | **Migrate to SQLite** `history` table |
| `session.txt` | Plain text | Open tab URLs | **Migrate to SQLite** `session` table |
| `settings.conf` | key=value | Browser preferences | **Migrate to SQLite** `key_value` table (already exists) |
| `browser.db` | SQLite | Nostr events, tags, key_value | **Keep + extend** |
## Phase 1: Remove key persistence
### `src/key_store.h` / `src/key_store.c`
**Remove:**
- `key_store_path()` — no file path needed
- `key_store_save()` — never called (dead code)
- `key_store_load()` — never called (dead code)
- `key_store_clear()` — replace with a no-op or remove the calls
**Keep:**
- `key_store_identity_t` struct (in-memory only)
- `key_store_method_t` enum
- `key_store_create_signer()` — creates a signer from an in-memory identity
### `src/cli.h` / `src/cli.c`
**Remove:**
- `--no-save-identity` flag and `no_save_identity` field (meaningless now)
- The TODO comment about `key_store_save()`
### `src/main.c` / `src/agent_login.c`
**Remove:**
- `key_store_clear()` calls in `app_menu_logout_proxy()` and `agent_login.c` logout
### Defensive cleanup
On startup, delete `~/.sovereign_browser/identity.json` if it exists (in case a previous version created it).
## Phase 2: Migrate history to SQLite
### `src/db.h` / `src/db.c`
Add a `history` table to the schema:
```sql
CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL UNIQUE,
title TEXT,
visited_at INTEGER NOT NULL,
visit_count INTEGER DEFAULT 1
);
CREATE INDEX IF NOT EXISTS idx_history_visited_at ON history(visited_at DESC);
```
Add functions:
```c
int db_history_add(const char *url, const char *title);
/* Returns most-recent-first. Fills urls_out (caller frees each + array). */
char **db_history_get(int *count_out, int limit);
int db_history_clear(void);
```
`db_history_add` does an UPSERT: on conflict (URL already exists), increment `visit_count` and update `visited_at`.
### `src/history.h` / `src/history.c`
Rewrite to use `db_history_add` / `db_history_get` / `db_history_clear`. Remove the flat-file `history_path()`, `fopen()`, the `g_history[50][2048]` array, and `history_load()`. The `history_add()` function now takes an optional title parameter (or we keep the existing signature and pass NULL for title from `on_load_changed`).
Remove `HISTORY_MAX_ENTRIES` (no cap — SQLite handles it).
## Phase 3: Migrate session to SQLite
### `src/db.h` / `src/db.c`
Add a `session` table:
```sql
CREATE TABLE IF NOT EXISTS session (
tab_index INTEGER PRIMARY KEY,
url TEXT NOT NULL,
title TEXT
);
```
Add functions:
```c
int db_session_save(const char **urls, const char **titles, int count);
int db_session_load(char ***urls_out, char ***titles_out, int *count_out);
int db_session_clear(void);
```
`db_session_save` clears the table then inserts all current tabs. `db_session_load` reads them back in tab_index order.
### `src/session.h` / `src/session.c`
Rewrite `session_save()` and `session_restore()` to use the SQLite functions. Remove `session_path()` and `fopen()`.
## Phase 4: Migrate settings to SQLite
### `src/settings.h` / `src/settings.c`
Rewrite `settings_load()` and `settings_save()` to use `db_kv_get` / `db_kv_set` for each field. The `key_value` table already exists in the schema.
Each setting is stored as a key-value pair:
- `restore_session``"true"` / `"false"`
- `new_tab_url` → the URL string
- `tab_bar_position``"top"` / `"bottom"` / etc.
- `bootstrap_relays` → newline-separated URLs
- etc.
**Important:** `settings_load()` must be called **after** `db_init()` (the DB must be open first). This changes the startup order in `main.c`:
```
settings_load() → db_init() → settings_load() (revised order)
```
Actually: `db_init()` first, then `settings_load()` reads from the DB.
### Remove `settings_path()` and `fopen()` from `settings.c`.
## Phase 5: Cleanup
- Delete `~/.sovereign_browser/identity.json` on startup (defensive)
- Delete `~/.sovereign_browser/history.txt` on startup (one-time migration)
- Delete `~/.sovereign_browser/session.txt` on startup (one-time migration)
- Delete `~/.sovereign_browser/settings.conf` on startup (one-time migration)
- Or: leave the old files in place (they're just ignored) — simpler, less destructive
## Startup order change in `main.c`
Current:
```
settings_load();
history_load();
db_init();
```
New:
```
db_init(); /* open the database first */
settings_load(); /* reads from key_value table */
/* history_load() removed — history is queried from SQLite on demand */
```
## File change summary
| File | Change |
|------|--------|
| `src/key_store.h` | Remove `key_store_save`, `key_store_load`, `key_store_clear`, `key_store_path` |
| `src/key_store.c` | Remove file I/O functions; keep only `key_store_create_signer` |
| `src/db.h` | Add `db_history_*`, `db_session_*` functions |
| `src/db.c` | Add `history` + `session` tables to schema; implement new functions |
| `src/history.h` | Update API (remove `history_load`, add title param) |
| `src/history.c` | Rewrite to use SQLite; remove flat-file code |
| `src/session.h` | No API change |
| `src/session.c` | Rewrite to use SQLite; remove flat-file code |
| `src/settings.h` | No API change |
| `src/settings.c` | Rewrite to use `db_kv_get`/`db_kv_set`; remove flat-file code |
| `src/main.c` | Reorder startup (`db_init` before `settings_load`); remove `key_store_clear` call; delete old files defensively |
| `src/agent_login.c` | Remove `key_store_clear` call |
| `src/cli.h` | Remove `no_save_identity` field |
| `src/cli.c` | Remove `--no-save-identity` flag |

299
plans/sqlite-relay-login.md Normal file
View File

@@ -0,0 +1,299 @@
# Plan: SQLite Integration, No-Login Mode, Bootstrap Relay Fetch
## Overview
Three related features that build on each other:
1. **SQLite** — persistent local storage for Nostr events and future data
2. **No-Login mode** — bypass Nostr login, use the browser as a normal browser
3. **Bootstrap relay fetch** — after login, fetch the user's kind 0/3/10002 events from configured relays and cache them in SQLite
```mermaid
flowchart TD
A[Browser startup] --> B{Login dialog}
B -->|Sign In| C[Login with Nostr key]
B -->|No Login| D[Skip Nostr — normal browser mode]
B -->|Cancel| E[Exit]
C --> F[Fetch kind 0, 3, 10002 from bootstrap relays]
F --> G[Store events in SQLite]
G --> H[Browser ready with identity + cached events]
D --> I[Browser ready — no identity, no relay fetch]
subgraph "SQLite database"
J[events table]
K[event_tags table]
L[relays table]
M[key_value table]
end
G --> J
G --> K
H --> M
```
---
## Phase 1: SQLite Integration
### Install dependency
```bash
sudo apt install libsqlite3-dev
```
This provides `sqlite3.h`, `libsqlite3.so`, and `sqlite3.pc` (pkg-config).
### New files: `src/db.h` / `src/db.c`
A thin wrapper around SQLite3 that provides:
```c
/* db.h */
#pragma once
#include <glib.h>
#include "../nostr_core_lib/cjson/cJSON.h"
/* Initialize the database at ~/.sovereign_browser/browser.db.
* Creates tables if they don't exist. Call once at startup. */
int db_init(void);
/* Close the database. Call at shutdown. */
void db_close(void);
/* ── Events ─────────────────────────────────────────────── */
/* Store a Nostr event (upsert — replaces if same event_id exists).
* Parses the cJSON event and stores it in the events table + tags table. */
int db_store_event(const cJSON *event);
/* Fetch the most recent event of a given kind for a pubkey.
* Returns a newly allocated cJSON event, or NULL if not found.
* Caller must cJSON_Delete() the result. */
cJSON *db_get_latest_event(const char *pubkey_hex, int kind);
/* Fetch all events of a given kind for a pubkey (newest first).
* Returns a cJSON array. Caller must cJSON_Delete() the result. */
cJSON *db_get_events(const char *pubkey_hex, int kind, int limit);
/* ── Key-Value store (for misc settings/cache) ──────────── */
int db_kv_set(const char *key, const char *value);
const char *db_kv_get(const char *key); /* returns pointer, valid until next db_kv_get call */
```
### Database schema
```sql
-- Nostr events (kind 0 = profile, 3 = contacts, 10002 = relay list, etc.)
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY, -- event ID (hex, 64 chars)
pubkey TEXT NOT NULL, -- author pubkey (hex)
kind INTEGER NOT NULL,
created_at INTEGER NOT NULL, -- unix timestamp
content TEXT, -- event content
sig TEXT, -- signature
raw_json TEXT NOT NULL, -- full event JSON for round-tripping
fetched_at INTEGER NOT NULL -- when we cached it
);
-- Event tags (for querying by tag values, e.g. relay URLs in kind 10002)
CREATE TABLE IF NOT EXISTS event_tags (
event_id TEXT NOT NULL,
tag_name TEXT NOT NULL, -- first element of tag array
tag_value TEXT, -- second element (if any)
position INTEGER NOT NULL, -- tag index in the event
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE
);
-- Indexes for common queries
CREATE INDEX IF NOT EXISTS idx_events_pubkey_kind ON events(pubkey, kind, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_event_tags_name_value ON event_tags(tag_name, tag_value);
-- Simple key-value store for misc data
CREATE TABLE IF NOT EXISTS key_value (
key TEXT PRIMARY KEY,
value TEXT
);
```
### Makefile changes
```make
CFLAGS += $(shell pkg-config --cflags sqlite3)
LDLIBS += $(shell pkg-config --libs sqlite3)
SRC += src/db.c
```
### Integration in `src/main.c`
- Call `db_init()` after `settings_load()` in `main()`.
- Call `db_close()` in `on_window_destroy()` before `gtk_main_quit()`.
---
## Phase 2: No-Login Mode
### Login dialog changes (`src/login_dialog.c`)
**Button layout** (action area, left to right):
1. `No Login` — far left (NEW)
2. `Cancel` — center
3. `Sign In` — right
**Changes:**
- Remove underscores from button labels: `"_Cancel"``"Cancel"`, `"_Sign In"``"Sign In"`.
- Add a third button: `GtkWidget *no_login_btn = gtk_button_new_with_label("No Login");`
- Pack it first (far left): `gtk_box_pack_start(GTK_BOX(action_area), no_login_btn, FALSE, FALSE, 0);` before cancel and login buttons.
- Wire a new `on_no_login_clicked` handler that sets `ctx->done = TRUE` and responds with a new response code (e.g. `GTK_RESPONSE_OTHER` or a custom value like `GTK_RESPONSE_APPLY`).
**`login_result_t` changes (`src/login_dialog.h`):**
- The existing `method == KEY_STORE_METHOD_NONE` with `signer == NULL` already represents "no identity." The dialog will return success (0) with `method = KEY_STORE_METHOD_NONE` and empty `pubkey_hex`.
**`login_dialog_run` changes:**
- After `gtk_dialog_run` returns, check for the no-login response code. If it's the no-login response, set `result->method = KEY_STORE_METHOD_NONE`, clear the result, and return 0 (success).
### `src/main.c` changes
- In `do_login()`, after a successful login with `method == KEY_STORE_METHOD_NONE` and empty pubkey, set `g_state.readonly = TRUE` and `g_logged_in = TRUE` but don't set a signer. The browser works normally — `window.nostr` won't be available (the bridge will return "No identity loaded" for sign requests), but all normal browsing works.
- Skip the relay fetch (Phase 4) when no identity is loaded.
### CLI support (`src/cli.c` / `src/cli.h`)
- Add `--no-login` flag that skips the login dialog entirely (sets `g_logged_in = TRUE` with no signer). This is equivalent to clicking "No Login" but from the command line.
---
## Phase 3: Bootstrap Relays in Settings
### Settings struct (`src/settings.h` / `src/settings.c`)
Add a new field to `browser_settings_t`:
```c
#define SETTINGS_BOOTSTRAP_RELAYS_MAX 2048
// ...
char bootstrap_relays[SETTINGS_BOOTSTRAP_RELAYS_MAX]; /* newline-separated relay URLs */
```
Default value in `settings_set_defaults()`:
```
wss://laantungir.net/relay\nwss://relay.primal.net\nwss://relay.damus.io
```
Parse/save in `settings_load()` / `settings_save()` as `bootstrap_relays=...` (URL-encoded or newline-separated, stored as a single key=value line).
### Settings page (`src/nostr_bridge.c`)
Add a new section **"Bootstrap Relays"** to the `sovereign://settings` page (between Agent Server and Security):
```html
<h2>Bootstrap Relays</h2>
<p class='note'>Relays queried after login to fetch your profile (kind 0),
contacts (kind 3), and relay list (kind 10002). One URL per line.</p>
<div class='field'>
<div><div class='setting-name'>Relay URLs</div>
<div class='setting-desc'>One wss:// URL per line</div></div>
<div><textarea id='bootstrap_relays' rows='4' cols='40'>...</textarea>
<button class='save-btn' onclick="save('bootstrap_relays')">Save</button></div>
</div>
```
The `handle_settings_set` key/value path needs a new case for `bootstrap_relays` that stores the newline-separated value into `bs->bootstrap_relays` and calls `settings_save()`.
---
## Phase 4: Post-Login Relay Fetch
### New file: `src/relay_fetch.h` / `src/relay_fetch.c`
```c
/* relay_fetch.h */
#pragma once
#include "../nostr_core_lib/cjson/cJSON.h"
/* Fetch the user's kind 0, 3, and 10002 events from the bootstrap relays.
* Stores results in the SQLite database via db_store_event().
* Runs synchronously (called from a background thread or with a timeout).
*
* pubkey_hex — user's hex pubkey
* relay_urls — NULL-terminated array of relay URLs
* relay_count — number of relays
*
* Returns the number of events fetched and stored, or -1 on error. */
int relay_fetch_bootstrap(const char *pubkey_hex,
const char **relay_urls,
int relay_count);
```
### Implementation (`src/relay_fetch.c`)
Uses `synchronous_query_relays_with_progress()` from `nostr_core_lib`:
1. Parse `bootstrap_relays` from settings into an array of URLs.
2. Build a cJSON filter: `{"authors": [pubkey], "kinds": [0, 3, 10002]}`.
3. Call `synchronous_query_relays_with_progress(relay_urls, relay_count, filter, RELAY_QUERY_ALL_RESULTS, &result_count, 15, NULL, NULL, 0)`.
4. For each returned event, call `db_store_event(event)`.
5. Log the results: `[relay] Fetched N events from M relays`.
6. Free the results array and filter.
### Integration in `src/main.c`
After successful login (in `do_login()` or right after it returns), if a signer/pubkey is available:
```c
if (g_state.pubkey_hex[0] != '\0') {
/* Parse bootstrap relays from settings. */
/* Call relay_fetch_bootstrap() in a background thread to avoid
* blocking the UI. Use g_thread_new() or a GTask. */
g_thread_new("relay-fetch", relay_fetch_thread, NULL);
}
```
The fetch runs in a background thread so the browser is usable immediately. Events are stored in SQLite as they arrive. A future enhancement could notify the UI when the fetch completes.
### Threading consideration
`nostr_core_lib`'s `synchronous_query_relays_with_progress` is a blocking call that uses its own WebSocket event loop. It should be safe to call from a background thread as long as we don't touch GTK widgets from that thread. The `db_store_event()` calls only touch SQLite (which is thread-safe with proper locking — we'll use `sqlite3_mutex` or open the DB with `SQLITE_OPEN_FULLMUTEX`).
---
## Phase 5: Build, Verify, Test
1. `sudo apt install libsqlite3-dev`
2. `make` — verify clean build
3. Test No-Login mode:
- `./browser.sh start --no-login`
- Verify browser opens without login dialog, normal browsing works
- Verify `window.nostr` returns "No identity loaded" errors
4. Test login + relay fetch:
- `./browser.sh start --login-method generate`
- Verify log shows `[relay] Fetched N events from M relays`
- Verify `~/.sovereign_browser/browser.db` contains events
5. Test settings page:
- Navigate to `sovereign://settings`
- Verify "Bootstrap Relays" section appears with the 3 default relays
- Edit relays, save, restart, verify persistence
6. Test login dialog buttons:
- Verify "No Login" button is far left
- Verify "Cancel" and "Sign In" labels have no underscores
---
## File change summary
| File | Change |
|------|--------|
| `src/db.h` | NEW — SQLite wrapper API |
| `src/db.c` | NEW — SQLite implementation (events, tags, key-value) |
| `src/relay_fetch.h` | NEW — relay fetch API |
| `src/relay_fetch.c` | NEW — fetch kind 0/3/10002 from bootstrap relays |
| `src/settings.h` | Add `bootstrap_relays` field + max constant |
| `src/settings.c` | Parse/save `bootstrap_relays` with defaults |
| `src/nostr_bridge.c` | Add "Bootstrap Relays" section to settings page + set handler |
| `src/login_dialog.c` | Add "No Login" button, remove underscores, new handler |
| `src/login_dialog.h` | Document no-login result (method=NONE, empty pubkey) |
| `src/main.c` | Call `db_init()`/`db_close()`, handle no-login result, trigger relay fetch |
| `src/cli.h` | Add `--no-login` flag |
| `src/cli.c` | Parse `--no-login`, skip login dialog |
| `Makefile` | Add `sqlite3` to pkg-config, add `src/db.c` + `src/relay_fetch.c` to SRC |

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 */

552
src/agent_login.c Normal file
View File

@@ -0,0 +1,552 @@
/*
* agent_login.c — agent-driven Nostr login for sovereign_browser
*
* Calls nostr_core_lib directly to authenticate, bypassing the GTK
* login dialog. The agent provides credentials as JSON params and
* gets back the pubkey/npub on success.
*/
#include "agent_login.h"
#include "key_store.h"
#include "nostr_bridge.h"
#include "nostr_core/nostr_core.h"
#include <string.h>
#include <stdlib.h>
#include <signal.h>
/* ── Global login state (shared with main.c) ──────────────────────── *
* main.c owns the app_state_t (signer, pubkey, method, readonly).
* 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);
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;
/* ── Helper functions (same logic as login_dialog.c) ──────────────── */
static int derive_pubkey_hex(const unsigned char privkey[32], char pubkey_hex[65]) {
unsigned char pubkey[32];
if (nostr_ec_public_key_from_private_key(privkey, pubkey) != 0) return -1;
for (int i = 0; i < 32; i++) {
snprintf(pubkey_hex + i * 2, 3, "%02x", pubkey[i]);
}
pubkey_hex[64] = '\0';
return 0;
}
static int hex_to_bytes32(const char *hex, unsigned char out[32]) {
if (strlen(hex) != 64) return -1;
for (int i = 0; i < 32; i++) {
unsigned int byte;
if (sscanf(hex + i * 2, "%02x", &byte) != 1) return -1;
out[i] = (unsigned char)byte;
}
return 0;
}
static void pubkey_hex_to_npub(const char pubkey_hex[65], char npub[128]) {
unsigned char pubkey[32];
for (int i = 0; i < 32; i++) {
unsigned int b;
sscanf(pubkey_hex + i * 2, "%02x", &b);
pubkey[i] = (unsigned char)b;
}
if (nostr_key_to_bech32(pubkey, "npub", npub) != 0) {
npub[0] = '\0';
}
}
/* ── JSON response helpers ────────────────────────────────────────── */
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 cJSON *make_login_data(const char *method_str, const char *pubkey_hex,
gboolean readonly) {
cJSON *data = cJSON_CreateObject();
cJSON_AddStringToObject(data, "method", method_str);
cJSON_AddStringToObject(data, "pubkey", pubkey_hex);
char npub[128];
pubkey_hex_to_npub(pubkey_hex, npub);
cJSON_AddStringToObject(data, "npub", npub);
cJSON_AddBoolToObject(data, "readonly", readonly);
return data;
}
/* ── Login method implementations ─────────────────────────────────── */
static cJSON *login_local(cJSON *params) {
const char *nsec = cJSON_GetStringValue(cJSON_GetObjectItem(params, "nsec"));
const char *privkey_hex = cJSON_GetStringValue(cJSON_GetObjectItem(params, "privkey_hex"));
unsigned char privkey[32];
if (nsec && nsec[0]) {
if (strncmp(nsec, "nsec1", 5) == 0) {
if (nostr_decode_nsec(nsec, privkey) != 0) {
return make_error("INVALID_NSEC", "Failed to decode nsec string");
}
} else if (strlen(nsec) == 64) {
if (hex_to_bytes32(nsec, privkey) != 0) {
return make_error("INVALID_HEX", "Invalid hex private key");
}
} else {
return make_error("INVALID_INPUT", "Enter an nsec1... or 64-char hex key");
}
} else if (privkey_hex && privkey_hex[0]) {
if (hex_to_bytes32(privkey_hex, privkey) != 0) {
return make_error("INVALID_HEX", "Invalid hex private key");
}
} else {
return make_error("MISSING_PARAM", "Provide 'nsec' or 'privkey_hex'");
}
char pubkey_hex[65];
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, 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);
return make_success(make_login_data("local", pubkey_hex, FALSE));
}
/* Generate a fresh Nostr keypair and log in with it.
* This is the quickest way for an agent to get past the login screen
* when it doesn't have a specific identity to use. The generated
* nsec and pubkey are returned in the response so the agent can
* save them if needed. */
static cJSON *login_generate(cJSON *params) {
(void)params; /* no parameters needed */
unsigned char privkey[32], pubkey[32];
if (nostr_generate_keypair(privkey, pubkey) != 0) {
return make_error("KEYGEN_FAILED", "Failed to generate keypair");
}
/* Derive pubkey hex. */
char pubkey_hex[65];
for (int i = 0; i < 32; i++) {
snprintf(pubkey_hex + i * 2, 3, "%02x", pubkey[i]);
}
pubkey_hex[64] = '\0';
/* Encode private key as nsec for the response. */
char nsec[128];
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, 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);
/* Include the nsec in the response so the agent can save it. */
cJSON *data = make_login_data("generate", pubkey_hex, FALSE);
cJSON_AddStringToObject(data, "nsec", nsec);
return make_success(data);
}
static cJSON *login_seed(cJSON *params) {
const char *mnemonic = cJSON_GetStringValue(cJSON_GetObjectItem(params, "mnemonic"));
cJSON *account_json = cJSON_GetObjectItem(params, "account");
int account = (account_json && cJSON_IsNumber(account_json))
? account_json->valueint : 0;
if (!mnemonic || !mnemonic[0]) {
return make_error("MISSING_PARAM", "Provide 'mnemonic' (12 or 24 BIP-39 words)");
}
unsigned char privkey[32], pubkey[32];
if (nostr_derive_keys_from_mnemonic(mnemonic, account, privkey, pubkey) != 0) {
return make_error("INVALID_MNEMONIC", "Invalid seed phrase. Check the words and try again.");
}
char pubkey_hex[65];
for (int i = 0; i < 32; i++) {
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, 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);
return make_success(make_login_data("seed", pubkey_hex, FALSE));
}
static cJSON *login_readonly(cJSON *params) {
const char *npub = cJSON_GetStringValue(cJSON_GetObjectItem(params, "npub"));
const char *pubkey_hex_in = cJSON_GetStringValue(cJSON_GetObjectItem(params, "pubkey_hex"));
unsigned char pubkey[32];
char pubkey_hex[65];
if (npub && npub[0]) {
if (strncmp(npub, "npub1", 5) == 0) {
if (nostr_decode_npub(npub, pubkey) != 0) {
return make_error("INVALID_NPUB", "Failed to decode npub string");
}
for (int i = 0; i < 32; i++) {
snprintf(pubkey_hex + i * 2, 3, "%02x", pubkey[i]);
}
pubkey_hex[64] = '\0';
} else if (strlen(npub) == 64) {
if (hex_to_bytes32(npub, pubkey) != 0) {
return make_error("INVALID_HEX", "Invalid hex pubkey");
}
memcpy(pubkey_hex, npub, 64);
pubkey_hex[64] = '\0';
} else {
return make_error("INVALID_INPUT", "Enter an npub1... or 64-char hex pubkey");
}
} else if (pubkey_hex_in && pubkey_hex_in[0]) {
if (hex_to_bytes32(pubkey_hex_in, pubkey) != 0) {
return make_error("INVALID_HEX", "Invalid hex pubkey");
}
memcpy(pubkey_hex, pubkey_hex_in, 64);
pubkey_hex[64] = '\0';
} else {
return make_error("MISSING_PARAM", "Provide 'npub' or 'pubkey_hex'");
}
/* 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);
return make_success(make_login_data("readonly", pubkey_hex, TRUE));
}
static cJSON *login_nip46(cJSON *params) {
const char *bunker_url = cJSON_GetStringValue(cJSON_GetObjectItem(params, "bunker_url"));
if (!bunker_url || !bunker_url[0]) {
return make_error("MISSING_PARAM", "Provide 'bunker_url' (bunker://<pubkey>?relay=...&secret=...)");
}
nostr_nip46_bunker_url_t bunker;
memset(&bunker, 0, sizeof(bunker));
if (nostr_nip46_parse_bunker_url(bunker_url, &bunker) != 0) {
return make_error("INVALID_BUNKER", "Invalid bunker:// URL. Format: bunker://<pubkey>?relay=wss://...&secret=...");
}
/* Generate a client keypair for NIP-46 encryption. */
unsigned char client_privkey[32], client_pubkey[32];
if (nostr_generate_keypair(client_privkey, client_pubkey) != 0) {
return make_error("KEYGEN_FAILED", "Failed to generate client keypair");
}
char pubkey_hex[65];
strncpy(pubkey_hex, bunker.remote_signer_pubkey, 64);
pubkey_hex[64] = '\0';
nostr_signer_t *signer = nostr_signer_local(client_privkey);
if (signer == NULL) {
return make_error("SIGNER_FAILED", "Failed to create client signer");
}
/* 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);
return make_success(make_login_data("nip46", pubkey_hex, FALSE));
}
static cJSON *login_nsigner(cJSON *params) {
#if defined(NOSTR_ENABLE_NSIGNER_CLIENT)
const char *transport = cJSON_GetStringValue(cJSON_GetObjectItem(params, "transport"));
const char *device = cJSON_GetStringValue(cJSON_GetObjectItem(params, "device"));
const char *service = cJSON_GetStringValue(cJSON_GetObjectItem(params, "service"));
cJSON *index_json = cJSON_GetObjectItem(params, "index");
int index = (index_json && cJSON_IsNumber(index_json)) ? index_json->valueint : 0;
if (!transport || !transport[0]) {
return make_error("MISSING_PARAM", "Provide 'transport' (serial/unix/tcp/qrexec)");
}
if (!device || !device[0]) {
return make_error("MISSING_PARAM", "Provide 'device' (path, socket name, host:port, or qube name)");
}
/* Block SIGCHLD during n_signer calls (qrexec spawns children). */
sigset_t block_set, old_set;
sigemptyset(&block_set);
sigaddset(&block_set, SIGCHLD);
sigprocmask(SIG_BLOCK, &block_set, &old_set);
nostr_signer_t *signer = NULL;
const char *transport_name = "unknown";
if (strcmp(transport, "serial") == 0) {
signer = nostr_signer_nsigner_serial(device, NULL, 15000);
transport_name = "serial";
} else if (strcmp(transport, "unix") == 0) {
signer = nostr_signer_nsigner_unix(device, NULL, 15000);
transport_name = "unix";
} else if (strcmp(transport, "tcp") == 0) {
char host[256] = {0};
int port = 7777;
const char *sep = strchr(device, ':');
if (sep) {
size_t hlen = sep - device;
if (hlen < sizeof(host)) {
memcpy(host, device, hlen);
host[hlen] = '\0';
port = atoi(sep + 1);
}
} else {
strncpy(host, device, sizeof(host) - 1);
}
signer = nostr_signer_nsigner_tcp(host, port, NULL, 15000);
transport_name = "tcp";
} else if (strcmp(transport, "qrexec") == 0) {
const char *svc = (service && service[0]) ? service : "qubes.NsignerRpc";
signer = nostr_signer_nsigner_qrexec(device, svc, NULL, 30000);
transport_name = "qrexec";
} else {
sigprocmask(SIG_SETMASK, &old_set, NULL);
return make_error("INVALID_TRANSPORT", "Unknown transport. Use: serial, unix, tcp, or qrexec");
}
if (signer == NULL) {
sigprocmask(SIG_SETMASK, &old_set, NULL);
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);
sigprocmask(SIG_SETMASK, &old_set, NULL);
return make_error("NSIGNER_INDEX", "Failed to set nostr_index on n_signer.");
}
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);
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);
/* 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",
transport_name, index, pubkey_hex);
return make_success(make_login_data("nsigner", pubkey_hex, FALSE));
#else
(void)params;
return make_error("NOT_COMPILED", "n_signer client not compiled in (NOSTR_ENABLE_NSIGNER_CLIENT)");
#endif
}
/* ── Public API ───────────────────────────────────────────────────── */
cJSON *agent_login_status(void) {
gboolean logged_in = agent_is_logged_in();
cJSON *data = cJSON_CreateObject();
cJSON_AddBoolToObject(data, "logged_in", logged_in);
if (logged_in) {
const char *method_str = "none";
switch (app_get_method()) {
case KEY_STORE_METHOD_LOCAL: method_str = "local"; break;
case KEY_STORE_METHOD_SEED: method_str = "seed"; break;
case KEY_STORE_METHOD_READONLY: method_str = "readonly"; break;
case KEY_STORE_METHOD_NIP46: method_str = "nip46"; break;
case KEY_STORE_METHOD_NSIGNER: method_str = "nsigner"; break;
default: break;
}
cJSON_AddStringToObject(data, "method", method_str);
cJSON_AddStringToObject(data, "pubkey", app_get_pubkey_hex());
cJSON_AddBoolToObject(data, "readonly", app_get_readonly());
}
return make_success(data);
}
cJSON *agent_login(cJSON *params) {
if (params == NULL) {
return make_error("MISSING_PARAMS", "No parameters provided");
}
if (agent_is_logged_in()) {
return make_error("ALREADY_LOGGED_IN", "Already logged in. Use switch_identity to change.");
}
const char *method = cJSON_GetStringValue(cJSON_GetObjectItem(params, "method"));
if (!method || !method[0]) {
return make_error("MISSING_METHOD", "Provide 'method' (local/seed/readonly/nip46/nsigner/generate)");
}
/* Initialize nostr_core_lib if not already done. */
static gboolean nostr_initialized = FALSE;
if (!nostr_initialized) {
if (nostr_init() != NOSTR_SUCCESS) {
return make_error("INIT_FAILED", "Failed to initialize nostr_core_lib");
}
nostr_initialized = TRUE;
}
cJSON *result = NULL;
if (strcmp(method, "local") == 0) {
result = login_local(params);
} else if (strcmp(method, "generate") == 0) {
result = login_generate(params);
} else if (strcmp(method, "seed") == 0) {
result = login_seed(params);
} else if (strcmp(method, "readonly") == 0) {
result = login_readonly(params);
} else if (strcmp(method, "nip46") == 0) {
result = login_nip46(params);
} else if (strcmp(method, "nsigner") == 0) {
result = login_nsigner(params);
} else {
return make_error("UNKNOWN_METHOD", "Unknown method. Use: local, generate, seed, readonly, nip46, or nsigner");
}
/* If login succeeded, mark that the agent performed it. */
if (result && cJSON_IsTrue(cJSON_GetObjectItem(result, "success"))) {
g_agent_performed_login = TRUE;
}
return result;
}
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");
return make_success(NULL);
}
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. */
return agent_login(params);
}
gboolean agent_is_logged_in(void) {
return (app_get_signer() != NULL) ||
(app_get_method() == KEY_STORE_METHOD_READONLY && app_get_pubkey_hex()[0] != '\0');
}
gboolean agent_login_was_performed_by_agent(void) {
return g_agent_performed_login;
}

66
src/agent_login.h Normal file
View File

@@ -0,0 +1,66 @@
/*
* agent_login.h — agent-driven Nostr login for sovereign_browser
*
* Calls nostr_core_lib directly, bypassing the GTK login dialog.
* Used by the agent server so an AI agent can authenticate without
* human interaction.
*/
#ifndef AGENT_LOGIN_H
#define AGENT_LOGIN_H
#include <glib.h>
#include "cjson/cJSON.h"
#ifdef __cplusplus
extern "C" {
#endif
/*
* Check current login state. Returns a JSON response (caller frees).
* Response: {"success":true,"data":{"logged_in":true/false,"method":"local","pubkey":"...","readonly":false}}
*/
cJSON *agent_login_status(void);
/*
* Perform login with the given method and params.
* params is a cJSON object with "method" and method-specific fields:
* - local: {"method":"local","nsec":"nsec1..."} or {"method":"local","privkey_hex":"..."}
* - seed: {"method":"seed","mnemonic":"word1 word2 ...","account":0}
* - readonly: {"method":"readonly","npub":"npub1..."} or {"method":"readonly","pubkey_hex":"..."}
* - nip46: {"method":"nip46","bunker_url":"bunker://..."}
* - nsigner: {"method":"nsigner","transport":"qrexec","device":"nostr_signer","service":"qubes.NsignerRpc","index":0}
*
* Returns JSON response (caller frees).
* On success: {"success":true,"data":{"method":"local","pubkey":"...","npub":"...","readonly":false}}
* On failure: {"success":false,"error":{"code":"...","message":"..."}}
*/
cJSON *agent_login(cJSON *params);
/*
* Log out — clear signer, reset state. Returns JSON response (caller frees).
*/
cJSON *agent_logout(void);
/*
* Switch identity — same as login but frees the old signer first.
* Same params as agent_login. Returns JSON response (caller frees).
*/
cJSON *agent_switch_identity(cJSON *params);
/*
* Returns TRUE if logged in (signer or readonly loaded).
*/
gboolean agent_is_logged_in(void);
/*
* Returns TRUE if the agent (not the GTK dialog) performed the login.
* Used by login_dialog.c to close the dialog when the agent logs in.
*/
gboolean agent_login_was_performed_by_agent(void);
#ifdef __cplusplus
}
#endif
#endif /* AGENT_LOGIN_H */

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 */

941
src/agent_mcp.c Normal file
View File

@@ -0,0 +1,941 @@
/*
* agent_mcp.c — MCP (Model Context Protocol) server endpoint
*
* Implements the MCP Streamable HTTP transport. The AI assistant sends
* JSON-RPC POST requests to /mcp, and we respond with JSON. This uses
* the same agent_tools_dispatch() as the WebSocket endpoint.
*
* For async tools (snapshot, eval, etc.), we use a GMainLoop polling
* approach to wait for the JS result, since we're in an HTTP handler
* (not a WebSocket callback) so the polling doesn't deadlock.
*/
#include "agent_mcp.h"
#include "agent_server.h"
#include "agent_tools.h"
#include "agent_snapshot.h"
#include "agent_login.h"
#include "agent_tool_catalog.h"
#include "tab_manager.h"
#include "version.h"
#include <libsoup/soup.h>
#include <string.h>
#include <stdlib.h>
/* ── Session management ────────────────────────────────────────────── *
*
* MCP "Streamable HTTP" transport uses a Mcp-Session-Id header to
* correlate requests. The server generates a UUID on `initialize`
* and returns it in the response header. Subsequent requests should
* carry the same header. Sessions expire after 1 hour of inactivity
* (checked lazily on lookup).
*/
#define MCP_SESSION_TIMEOUT_SEC 3600 /* 1 hour */
typedef struct {
char *session_id;
gboolean initialized;
gint64 last_activity; /* time(NULL) */
} mcp_session_t;
/* session_id (owned) -> mcp_session_t* (owned) */
static GHashTable *g_sessions = NULL;
static mcp_session_t *session_create(void) {
mcp_session_t *s = g_new(mcp_session_t, 1);
s->session_id = g_uuid_string_random();
s->initialized = TRUE;
s->last_activity = time(NULL);
g_hash_table_insert(g_sessions, s->session_id, s);
return s;
}
static mcp_session_t *session_lookup(const char *id) {
if (id == NULL || g_sessions == NULL) {
return NULL;
}
mcp_session_t *s = g_hash_table_lookup(g_sessions, id);
if (s == NULL) {
return NULL;
}
/* Lazy expiry check. */
if (time(NULL) - s->last_activity > MCP_SESSION_TIMEOUT_SEC) {
g_hash_table_remove(g_sessions, id);
return NULL;
}
return s;
}
static void session_touch(mcp_session_t *s) {
if (s) {
s->last_activity = time(NULL);
}
}
static void session_destroy(mcp_session_t *s) {
if (s && g_sessions) {
g_hash_table_remove(g_sessions, s->session_id);
}
}
/* ── SSE helper ────────────────────────────────────────────────────── *
*
* Wraps a JSON-RPC response string in a single SSE event:
* event: message\r\n
* data: <json>\r\n
* \r\n
* Returns a newly-allocated string that the caller must free (or pass
* to soup_server_message_set_response with SOUP_MEMORY_TAKE).
*/
static char *build_sse_response(const char *json_str) {
if (json_str == NULL) {
json_str = "{}";
}
return g_strdup_printf("event: message\r\ndata: %s\r\n\r\n", json_str);
}
/* ── JSON-RPC helpers ─────────────────────────────────────────────── */
static cJSON *rpc_result(int id, cJSON *result) {
cJSON *resp = cJSON_CreateObject();
cJSON_AddStringToObject(resp, "jsonrpc", "2.0");
cJSON_AddNumberToObject(resp, "id", id);
cJSON_AddItemToObject(resp, "result", result);
return resp;
}
static cJSON *rpc_error(int id, int code, const char *message) {
cJSON *resp = cJSON_CreateObject();
cJSON_AddStringToObject(resp, "jsonrpc", "2.0");
cJSON_AddNumberToObject(resp, "id", id);
cJSON *err = cJSON_CreateObject();
cJSON_AddNumberToObject(err, "code", code);
cJSON_AddStringToObject(err, "message", message);
cJSON_AddItemToObject(resp, "error", err);
return resp;
}
/* ── Tool catalog ─────────────────────────────────────────────────── *
* 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).
*/
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.",
"{\"type\":\"object\",\"properties\":{}}"},
{"login",
"Log in to the browser with a Nostr identity. Methods: 'random' (generate a fresh random key — quickest for testing), 'local' (nsec or hex privkey), 'seed' (BIP-39 mnemonic), 'readonly' (npub), 'nip46' (bunker:// URL), 'nsigner' (hardware signer). Must be called before browser tools work.",
"{\"type\":\"object\",\"properties\":{\"method\":{\"type\":\"string\",\"enum\":[\"random\",\"local\",\"seed\",\"readonly\",\"nip46\",\"nsigner\"]},\"nsec\":{\"type\":\"string\",\"description\":\"nsec1... string (method: local)\"},\"privkey_hex\":{\"type\":\"string\",\"description\":\"64-char hex private key (method: local)\"},\"mnemonic\":{\"type\":\"string\",\"description\":\"12 or 24 BIP-39 words (method: seed)\"},\"account\":{\"type\":\"integer\",\"default\":0,\"description\":\"Account index (method: seed)\"},\"npub\":{\"type\":\"string\",\"description\":\"npub1... string (method: readonly)\"},\"pubkey_hex\":{\"type\":\"string\",\"description\":\"64-char hex pubkey (method: readonly)\"},\"bunker_url\":{\"type\":\"string\",\"description\":\"bunker:// URL (method: nip46)\"},\"transport\":{\"type\":\"string\",\"enum\":[\"serial\",\"unix\",\"tcp\",\"qrexec\"],\"description\":\"Transport type (method: nsigner)\"},\"device\":{\"type\":\"string\",\"description\":\"Device path, socket, host:port, or qube name (method: nsigner)\"},\"service\":{\"type\":\"string\",\"default\":\"qubes.NsignerRpc\",\"description\":\"Qrexec service name (method: nsigner)\"},\"index\":{\"type\":\"integer\",\"default\":0,\"description\":\"Key index (method: nsigner)\"}},\"required\":[\"method\"]}"},
{"logout",
"Log out and clear the current Nostr identity.",
"{\"type\":\"object\",\"properties\":{}}"},
{"switch_identity",
"Switch to a new Nostr identity. Same parameters as login (including 'generate'). Frees the old signer first.",
"{\"type\":\"object\",\"properties\":{\"method\":{\"type\":\"string\",\"enum\":[\"generate\",\"local\",\"seed\",\"readonly\",\"nip46\",\"nsigner\"]},\"nsec\":{\"type\":\"string\"},\"privkey_hex\":{\"type\":\"string\"},\"mnemonic\":{\"type\":\"string\"},\"npub\":{\"type\":\"string\"},\"bunker_url\":{\"type\":\"string\"},\"transport\":{\"type\":\"string\"},\"device\":{\"type\":\"string\"},\"index\":{\"type\":\"integer\"}},\"required\":[\"method\"]}"},
/* Navigation tools */
{"open",
"Navigate the active tab to a URL.",
"{\"type\":\"object\",\"properties\":{\"url\":{\"type\":\"string\"}},\"required\":[\"url\"]}"},
{"back",
"Go back in browser history.",
"{\"type\":\"object\",\"properties\":{}}"},
{"forward",
"Go forward in browser history.",
"{\"type\":\"object\",\"properties\":{}}"},
{"reload",
"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\":{}}"},
{"get_title",
"Get the page title of the active tab.",
"{\"type\":\"object\",\"properties\":{}}"},
/* Snapshot & inspection tools */
{"snapshot",
"Get the accessibility tree of the current page with element refs. Returns a text tree and a ref map. Use refs (e.g. @e1) to interact with elements. Call this after navigation or page changes to see what's on the page.",
"{\"type\":\"object\",\"properties\":{\"interactive\":{\"type\":\"boolean\",\"default\":true,\"description\":\"Only show interactive elements\"},\"compact\":{\"type\":\"boolean\",\"default\":true,\"description\":\"Remove empty structural elements\"}}}"},
{"get_text",
"Get the text content of an element by ref or CSS selector.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\",\"description\":\"Element ref from snapshot (e.g. @e1)\"},\"selector\":{\"type\":\"string\",\"description\":\"CSS selector\"}}}"},
{"get_html",
"Get the innerHTML of an element by ref or CSS selector.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"get_attr",
"Get an attribute of an element by ref or CSS selector.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"},\"attr\":{\"type\":\"string\",\"description\":\"Attribute name (e.g. href, src, class)\"}},\"required\":[\"attr\"]}"},
{"get_value",
"Get the value of an input, textarea, or select element by ref or CSS selector.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"get_count",
"Count the number of elements matching a CSS selector.",
"{\"type\":\"object\",\"properties\":{\"selector\":{\"type\":\"string\"}},\"required\":[\"selector\"]}"},
{"get_box",
"Get the bounding box of an element by ref or CSS selector. Returns x, y, width, height, top, right, bottom, left.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"get_styles",
"Get the computed CSS styles of an element by ref or CSS selector. Returns all computed style properties.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"is_visible",
"Check if an element is visible (not display:none, visibility:hidden, opacity:0, or offsetParent null).",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"is_enabled",
"Check if an element is enabled (not disabled).",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"is_checked",
"Check if a checkbox or radio element is checked.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"eval",
"Run JavaScript in the current page and return the result. Use for custom inspection or interaction not covered by other tools.",
"{\"type\":\"object\",\"properties\":{\"script\":{\"type\":\"string\",\"description\":\"JavaScript to execute\"}},\"required\":[\"script\"]}"},
{"screenshot",
"Capture a screenshot of the current page as a PNG image. Returns base64-encoded image data that can be viewed by the AI assistant. Use for visual context when the accessibility tree snapshot isn't sufficient.",
"{\"type\":\"object\",\"properties\":{}}"},
/* Interaction tools */
{"click",
"Click an element by ref or CSS selector. Uses coordinate-based GDK event synthesis (real WebKit hit-testing) for SPA framework compatibility, with JS .click() fallback.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"click_at",
"Click at explicit viewport coordinates (x, y) via GDK event synthesis. Bypasses selector resolution — useful when the target point is known from a screenshot or get_box result. Triggers real WebKit hit-testing and full event propagation.",
"{\"type\":\"object\",\"properties\":{\"x\":{\"type\":\"number\"},\"y\":{\"type\":\"number\"}},\"required\":[\"x\",\"y\"]}"},
{"fill",
"Clear an input and fill it with a value.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"},\"value\":{\"type\":\"string\"}},\"required\":[\"value\"]}"},
{"type",
"Type text into an element (appends to existing value).",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"},\"value\":{\"type\":\"string\"}},\"required\":[\"value\"]}"},
{"press",
"Press a keyboard key (e.g. Enter, Tab, Escape).",
"{\"type\":\"object\",\"properties\":{\"key\":{\"type\":\"string\"}},\"required\":[\"key\"]}"},
{"scroll",
"Scroll the page in a direction.",
"{\"type\":\"object\",\"properties\":{\"direction\":{\"type\":\"string\",\"enum\":[\"up\",\"down\",\"left\",\"right\"]},\"amount\":{\"type\":\"integer\",\"default\":500}},\"required\":[\"direction\"]}"},
{"hover",
"Hover over an element by ref or CSS selector.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"focus",
"Focus an element by ref or CSS selector.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"close",
"Close the active tab.",
"{\"type\":\"object\",\"properties\":{}}"},
/* Tab tools */
{"tab_list",
"List all open tabs with their URLs and titles.",
"{\"type\":\"object\",\"properties\":{}}"},
{"tab_new",
"Open a new tab, optionally with a URL.",
"{\"type\":\"object\",\"properties\":{\"url\":{\"type\":\"string\"}}}"},
{"tab_switch",
"Switch to a tab by index.",
"{\"type\":\"object\",\"properties\":{\"index\":{\"type\":\"integer\"}},\"required\":[\"index\"]}"},
{"tab_close",
"Close a tab by index. If no index, closes the active tab.",
"{\"type\":\"object\",\"properties\":{\"index\":{\"type\":\"integer\"}}}"},
/* Wait tools */
{"wait",
"Wait for a specified number of milliseconds.",
"{\"type\":\"object\",\"properties\":{\"ms\":{\"type\":\"integer\",\"default\":1000}}}"},
{"wait_for",
"Wait for an element to appear on the page.",
"{\"type\":\"object\",\"properties\":{\"selector\":{\"type\":\"string\"},\"timeout\":{\"type\":\"integer\",\"default\":10000}},\"required\":[\"selector\"]}"},
/* Extended interaction tools */
{"dblclick",
"Double-click an element by ref or CSS selector.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"select",
"Select an option in a dropdown element by ref or CSS selector.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"},\"value\":{\"type\":\"string\"}},\"required\":[\"value\"]}"},
{"check",
"Check a checkbox element by ref or CSS selector.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"uncheck",
"Uncheck a checkbox element by ref or CSS selector.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"scroll_into_view",
"Scroll an element into view by ref or CSS selector.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"keyboard_type",
"Type text into the focused element with real keystroke events (keydown, keypress, keyup per character).",
"{\"type\":\"object\",\"properties\":{\"value\":{\"type\":\"string\"}},\"required\":[\"value\"]}"},
{"insert_text",
"Insert text at the current cursor position without key events. Uses document.execCommand.",
"{\"type\":\"object\",\"properties\":{\"value\":{\"type\":\"string\"}},\"required\":[\"value\"]}"},
{"keydown",
"Dispatch a keydown event for a key (hold key down).",
"{\"type\":\"object\",\"properties\":{\"key\":{\"type\":\"string\"}},\"required\":[\"key\"]}"},
{"keyup",
"Dispatch a keyup event for a key (release key).",
"{\"type\":\"object\",\"properties\":{\"key\":{\"type\":\"string\"}},\"required\":[\"key\"]}"},
{"drag",
"Drag an element and drop it onto another element (HTML5 drag events).",
"{\"type\":\"object\",\"properties\":{\"src_ref\":{\"type\":\"string\"},\"src_selector\":{\"type\":\"string\"},\"tgt_ref\":{\"type\":\"string\"},\"tgt_selector\":{\"type\":\"string\"}}}"},
{"close_all",
"Close all open tabs.",
"{\"type\":\"object\",\"properties\":{}}"},
/* Find element tools */
{"find_role",
"Find an element by ARIA role (e.g. button, link, textbox, navigation). Optionally filter by name (aria-label or text content). Returns a ref for use with click, fill, etc.",
"{\"type\":\"object\",\"properties\":{\"role\":{\"type\":\"string\"},\"name\":{\"type\":\"string\"}},\"required\":[\"role\"]}"},
{"find_text",
"Find an element by text content. Set exact=true for exact match. Returns a ref for use with click, fill, etc.",
"{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"},\"exact\":{\"type\":\"boolean\",\"default\":false}},\"required\":[\"text\"]}"},
{"find_label",
"Find an element by associated label (label[for], aria-label, or aria-labelledby). Returns a ref for use with click, fill, etc.",
"{\"type\":\"object\",\"properties\":{\"label\":{\"type\":\"string\"}},\"required\":[\"label\"]}"},
{"find_placeholder",
"Find an element by placeholder text (substring match). Returns a ref for use with click, fill, etc.",
"{\"type\":\"object\",\"properties\":{\"placeholder\":{\"type\":\"string\"}},\"required\":[\"placeholder\"]}"},
{"find_alt",
"Find an element by alt text (substring match). Returns a ref for use with click, fill, etc.",
"{\"type\":\"object\",\"properties\":{\"alt\":{\"type\":\"string\"}},\"required\":[\"alt\"]}"},
{"find_title",
"Find an element by title attribute (substring match). Returns a ref for use with click, fill, etc.",
"{\"type\":\"object\",\"properties\":{\"title\":{\"type\":\"string\"}},\"required\":[\"title\"]}"},
{"find_testid",
"Find an element by data-testid attribute (exact match). Returns a ref for use with click, fill, etc.",
"{\"type\":\"object\",\"properties\":{\"testid\":{\"type\":\"string\"}},\"required\":[\"testid\"]}"},
{"find_first",
"Find the first element matching a CSS selector. Returns a ref for use with click, fill, etc.",
"{\"type\":\"object\",\"properties\":{\"selector\":{\"type\":\"string\"}},\"required\":[\"selector\"]}"},
{"find_last",
"Find the last element matching a CSS selector. Returns a ref for use with click, fill, etc.",
"{\"type\":\"object\",\"properties\":{\"selector\":{\"type\":\"string\"}},\"required\":[\"selector\"]}"},
{"find_nth",
"Find the nth element (0-based) matching a CSS selector. Returns a ref for use with click, fill, etc.",
"{\"type\":\"object\",\"properties\":{\"selector\":{\"type\":\"string\"},\"n\":{\"type\":\"integer\"}},\"required\":[\"selector\",\"n\"]}"},
{"wait_for_text",
"Wait for specific text to appear on the page. Polls until the text is found in document.body.innerText or timeout.",
"{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"},\"timeout\":{\"type\":\"integer\",\"default\":10000}},\"required\":[\"text\"]}"},
{"wait_for_url",
"Wait for the page URL to match a pattern. By default does a substring match; set regex=true for regex matching.",
"{\"type\":\"object\",\"properties\":{\"url\":{\"type\":\"string\"},\"timeout\":{\"type\":\"integer\",\"default\":10000},\"regex\":{\"type\":\"boolean\",\"default\":false}},\"required\":[\"url\"]}"},
{"wait_for_load",
"Wait for the page to finish loading (webkit_web_view_is_loading returns false).",
"{\"type\":\"object\",\"properties\":{\"timeout\":{\"type\":\"integer\",\"default\":10000}}}"},
{"wait_for_fn",
"Wait for a JavaScript expression to evaluate to truthy. The script is evaluated repeatedly until it returns true or timeout.",
"{\"type\":\"object\",\"properties\":{\"script\":{\"type\":\"string\"},\"timeout\":{\"type\":\"integer\",\"default\":10000}},\"required\":[\"script\"]}"},
{"batch",
"Execute multiple tool commands in sequence. Each command has 'tool', 'params', and optional 'id'. Set continueOnError=true to continue after failures.",
"{\"type\":\"object\",\"properties\":{\"commands\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"tool\":{\"type\":\"string\"},\"params\":{\"type\":\"object\"},\"id\":{\"type\":[\"integer\",\"string\"]}}}},\"continueOnError\":{\"type\":\"boolean\",\"default\":false}},\"required\":[\"commands\"]}"},
/* Cookies & web storage tools */
{"cookies_get",
"Get all cookies for the current page (non-httpOnly cookies visible to JavaScript).",
"{\"type\":\"object\",\"properties\":{}}"},
{"cookies_set",
"Set a cookie. Parameters: name, value, domain (optional), path (default /), secure, max_age (-1 for session cookie).",
"{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"},\"value\":{\"type\":\"string\"},\"domain\":{\"type\":\"string\"},\"path\":{\"type\":\"string\",\"default\":\"/\"},\"secure\":{\"type\":\"boolean\",\"default\":false},\"http_only\":{\"type\":\"boolean\",\"default\":false},\"max_age\":{\"type\":\"integer\",\"default\":-1}},\"required\":[\"name\",\"value\"]}"},
{"cookies_clear",
"Clear all cookies for the current page.",
"{\"type\":\"object\",\"properties\":{}}"},
{"storage_local_get",
"Get all localStorage entries as a JSON object.",
"{\"type\":\"object\",\"properties\":{}}"},
{"storage_local_get_key",
"Get a specific localStorage key value.",
"{\"type\":\"object\",\"properties\":{\"key\":{\"type\":\"string\"}},\"required\":[\"key\"]}"},
{"storage_local_set",
"Set a localStorage key to a value.",
"{\"type\":\"object\",\"properties\":{\"key\":{\"type\":\"string\"},\"value\":{\"type\":\"string\"}},\"required\":[\"key\",\"value\"]}"},
{"storage_local_clear",
"Clear all localStorage entries.",
"{\"type\":\"object\",\"properties\":{}}"},
{"storage_session_get",
"Get all sessionStorage entries as a JSON object.",
"{\"type\":\"object\",\"properties\":{}}"},
{"storage_session_get_key",
"Get a specific sessionStorage key value.",
"{\"type\":\"object\",\"properties\":{\"key\":{\"type\":\"string\"}},\"required\":[\"key\"]}"},
{"storage_session_set",
"Set a sessionStorage key to a value.",
"{\"type\":\"object\",\"properties\":{\"key\":{\"type\":\"string\"},\"value\":{\"type\":\"string\"}},\"required\":[\"key\",\"value\"]}"},
{"storage_session_clear",
"Clear all sessionStorage entries.",
"{\"type\":\"object\",\"properties\":{}}"},
/* Mouse tools */
{"mouse_move",
"Move the mouse to the specified coordinates (clientX, clientY).",
"{\"type\":\"object\",\"properties\":{\"x\":{\"type\":\"integer\"},\"y\":{\"type\":\"integer\"}},\"required\":[\"x\",\"y\"]}"},
{"mouse_down",
"Press a mouse button at the specified coordinates. Button: left (default), middle, right.",
"{\"type\":\"object\",\"properties\":{\"button\":{\"type\":\"string\",\"enum\":[\"left\",\"middle\",\"right\"],\"default\":\"left\"},\"x\":{\"type\":\"integer\"},\"y\":{\"type\":\"integer\"}}}"},
{"mouse_up",
"Release a mouse button at the specified coordinates. Button: left (default), middle, right.",
"{\"type\":\"object\",\"properties\":{\"button\":{\"type\":\"string\",\"enum\":[\"left\",\"middle\",\"right\"],\"default\":\"left\"},\"x\":{\"type\":\"integer\"},\"y\":{\"type\":\"integer\"}}}"},
{"mouse_wheel",
"Scroll the mouse wheel by dy (vertical) and dx (horizontal) pixels.",
"{\"type\":\"object\",\"properties\":{\"dy\":{\"type\":\"integer\"},\"dx\":{\"type\":\"integer\",\"default\":0}},\"required\":[\"dy\"]}"},
/* Clipboard tools */
{"clipboard_read",
"Read text from the system clipboard.",
"{\"type\":\"object\",\"properties\":{}}"},
{"clipboard_write",
"Write text to the system clipboard.",
"{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"}},\"required\":[\"text\"]}"},
{"clipboard_copy",
"Copy the current selection to clipboard (document.execCommand('copy')).",
"{\"type\":\"object\",\"properties\":{}}"},
{"clipboard_paste",
"Paste from clipboard into the focused element (document.execCommand('paste')).",
"{\"type\":\"object\",\"properties\":{}}"},
/* Settings tools */
{"set_viewport",
"Set the browser window/viewport size in pixels.",
"{\"type\":\"object\",\"properties\":{\"width\":{\"type\":\"integer\"},\"height\":{\"type\":\"integer\"}},\"required\":[\"width\",\"height\"]}"},
{"set_offline",
"Toggle offline mode (not yet supported in WebKitGTK).",
"{\"type\":\"object\",\"properties\":{\"enabled\":{\"type\":\"boolean\",\"default\":true}}}"},
{"set_headers",
"Set extra HTTP headers for all requests (not yet supported in WebKitGTK).",
"{\"type\":\"object\",\"properties\":{\"headers\":{\"type\":\"object\"}}}"},
{"set_credentials",
"Set HTTP basic auth credentials (not yet supported — auth is interactive in WebKitGTK).",
"{\"type\":\"object\",\"properties\":{\"username\":{\"type\":\"string\"},\"password\":{\"type\":\"string\"}},\"required\":[\"username\",\"password\"]}"},
{"set_media",
"Emulate color scheme (dark or light).",
"{\"type\":\"object\",\"properties\":{\"scheme\":{\"type\":\"string\",\"enum\":[\"dark\",\"light\"]}},\"required\":[\"scheme\"]}"},
/* Frame tools */
{"frame_switch",
"Switch to an iframe by ref or CSS selector. Subsequent JS-based tools will execute in the frame context.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}}}"},
{"frame_main",
"Switch back to the main frame (exit any iframe context).",
"{\"type\":\"object\",\"properties\":{}}"},
/* Dialog tools */
{"dialog_accept",
"Accept a pending JavaScript dialog (alert, confirm, or prompt). For prompt dialogs, provide 'text' for the input value.",
"{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"}}}"},
{"dialog_dismiss",
"Dismiss a pending JavaScript dialog (cancel).",
"{\"type\":\"object\",\"properties\":{}}"},
{"dialog_status",
"Check if a JavaScript dialog (alert/confirm/prompt) is pending. Returns the dialog type and message if pending.",
"{\"type\":\"object\",\"properties\":{}}"},
/* Debug tools */
{"console",
"Get collected console messages from the page. Set clear=true to clear after reading.",
"{\"type\":\"object\",\"properties\":{\"clear\":{\"type\":\"boolean\",\"default\":false}}}"},
{"errors",
"Get JavaScript errors from the page. Set clear=true to clear after reading.",
"{\"type\":\"object\",\"properties\":{\"clear\":{\"type\":\"boolean\",\"default\":false}}}"},
{"highlight",
"Highlight an element with a temporary red outline. Useful for debugging.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"},\"duration\":{\"type\":\"integer\",\"default\":2000}}}"},
/* State tools */
{"state_save",
"Save browser state (localStorage and cookies) to a file or return as JSON string.",
"{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}}}"},
{"state_load",
"Load browser state from a file or JSON string. Restores localStorage and cookies.",
"{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"},\"state\":{\"type\":\"string\"}}}"},
/* Complex tools */
{"upload",
"Upload files to a file input element. Provide file paths on the local filesystem. Files are read, base64-encoded, and set on the input via DataTransfer API.",
"{\"type\":\"object\",\"properties\":{\"ref\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"},\"files\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}}},\"required\":[\"files\"]}"},
{"pdf",
"Save the current page as a PDF file. Provide a file path for the output.",
"{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}},\"required\":[\"path\"]}"},
{"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\"]}"},
};
const int tool_defs_count = (int)(sizeof(tool_defs) / sizeof(tool_defs[0]));
/* ── Build tools/list response ────────────────────────────────────── */
cJSON *build_tools_list(void) {
cJSON *tools = cJSON_CreateArray();
for (int i = 0; i < tool_defs_count; i++) {
cJSON *tool = cJSON_CreateObject();
cJSON_AddStringToObject(tool, "name", tool_defs[i].name);
cJSON_AddStringToObject(tool, "description", tool_defs[i].description);
cJSON *schema = cJSON_Parse(tool_defs[i].schema_json);
if (schema) {
cJSON_AddItemToObject(tool, "inputSchema", schema);
}
cJSON_AddItemToArray(tools, tool);
}
return tools;
}
/* ── Async result holder (for JS-based tools) ─────────────────────── */
typedef struct {
cJSON *response;
gboolean done;
GMainLoop *loop;
} mcp_async_ctx_t;
/* Callback for async JS evaluation — stores result and quits the loop. */
static void mcp_async_callback(cJSON *response, gpointer user_data) {
mcp_async_ctx_t *ctx = (mcp_async_ctx_t *)user_data;
ctx->response = response;
ctx->done = TRUE;
if (ctx->loop && g_main_loop_is_running(ctx->loop)) {
g_main_loop_quit(ctx->loop);
}
}
/* We need a wrapper to adapt agent_js_eval_async's callback signature
* to our mcp_async_ctx_t. The agent_js_eval_async sends the response
* through a WebSocket connection, but for MCP we need to capture it.
* Instead of using agent_js_eval_async, we'll call agent_tools_dispatch
* with a special "capture" mode. */
/* Actually, the simplest approach for MCP: use agent_tools_dispatch
* with NULL connection. When conn is NULL, the async tools fall through
* to the sync path (which uses agent_js_eval_sync). The sync path
* works fine in HTTP handlers because we're not inside a WebSocket
* callback. Let's verify this works. */
/* ── MCP request handler ──────────────────────────────────────────── */
static void on_mcp_request(SoupServer *server,
SoupServerMessage *msg,
const char *path,
GHashTable *query,
gpointer user_data) {
(void)server;
(void)query;
(void)user_data;
if (g_strcmp0(path, "/mcp") != 0) {
soup_server_message_set_status(msg, 404, NULL);
return;
}
const char *method = soup_server_message_get_method(msg);
/* ── GET: open an SSE stream for server→client push events ──── */
if (g_strcmp0(method, "GET") == 0) {
/* Pragmatic approach: return a valid SSE content-type with an
* initial connection comment. A truly long-lived push stream
* can be added later; for now this satisfies clients that
* probe GET /mcp and expect text/event-stream. */
soup_server_message_set_status(msg, 200, NULL);
SoupMessageHeaders *resp_hdrs = soup_server_message_get_response_headers(msg);
soup_message_headers_append(resp_hdrs, "Cache-Control", "no-cache");
soup_message_headers_append(resp_hdrs, "Connection", "keep-alive");
const char *sse_init = ": connected\r\n\r\n";
soup_server_message_set_response(msg, "text/event-stream",
SOUP_MEMORY_STATIC, sse_init, strlen(sse_init));
return;
}
/* ── DELETE: terminate a session ─────────────────────────────── */
if (g_strcmp0(method, "DELETE") == 0) {
SoupMessageHeaders *req_hdrs = soup_server_message_get_request_headers(msg);
const char *sid = soup_message_headers_get_one(req_hdrs, "Mcp-Session-Id");
if (sid == NULL) {
soup_server_message_set_status(msg, 400, NULL);
return;
}
mcp_session_t *s = session_lookup(sid);
if (s == NULL) {
soup_server_message_set_status(msg, 404, NULL);
return;
}
session_destroy(s);
soup_server_message_set_status(msg, 200, NULL);
return;
}
/* Only POST is handled below. */
if (g_strcmp0(method, "POST") != 0) {
soup_server_message_set_status(msg, 405, NULL);
return;
}
/* Read the request body. */
SoupMessageBody *body = soup_server_message_get_request_body(msg);
gsize size = body ? body->length : 0;
const gchar *data = body ? body->data : NULL;
if (data == NULL || size == 0) {
soup_server_message_set_status(msg, 400, NULL);
return;
}
/* Parse JSON-RPC request. */
cJSON *request = cJSON_ParseWithLength(data, size);
if (request == NULL) {
const char *err = "{\"jsonrpc\":\"2.0\",\"id\":null,\"error\":{\"code\":-32700,\"message\":\"Parse error\"}}";
char *sse = build_sse_response(err);
soup_server_message_set_status(msg, 200, NULL);
soup_server_message_set_response(msg, "text/event-stream",
SOUP_MEMORY_TAKE, sse, strlen(sse));
return;
}
/* Extract JSON-RPC fields. */
const char *rpc_method = cJSON_GetStringValue(cJSON_GetObjectItem(request, "method"));
cJSON *id_json = cJSON_GetObjectItem(request, "id");
cJSON *params = cJSON_GetObjectItem(request, "params");
int rpc_id = (id_json && cJSON_IsNumber(id_json)) ? (int)id_json->valuedouble : 0;
/* ── Session validation ──────────────────────────────────────── *
* For `initialize` we create a new session. For all other methods,
* we check the Mcp-Session-Id header. If the header is present but
* the session is unknown/expired, return 404. If the header is
* absent, we proceed (lenient) for backward compatibility. */
mcp_session_t *session = NULL;
gboolean is_initialize = (rpc_method && strcmp(rpc_method, "initialize") == 0);
if (!is_initialize) {
SoupMessageHeaders *req_hdrs = soup_server_message_get_request_headers(msg);
const char *sid = soup_message_headers_get_one(req_hdrs, "Mcp-Session-Id");
if (sid != NULL) {
session = session_lookup(sid);
if (session == NULL) {
/* Unknown or expired session — create a new one and return
* the new session ID in the response header so the client
* can update its cached session ID. This handles browser
* restarts gracefully without requiring the client to
* re-initialize. */
session = session_create();
SoupMessageHeaders *resp_hdrs =
soup_server_message_get_response_headers(msg);
soup_message_headers_append(resp_hdrs,
"Mcp-Session-Id", session->session_id);
g_print("[mcp] Stale session '%s' — created new session '%s'\n",
sid, session->session_id);
}
session_touch(session);
} else {
g_warning("[mcp] Request without Mcp-Session-Id header (method=%s) — proceeding leniently",
rpc_method ? rpc_method : "?");
}
}
cJSON *response = NULL;
if (rpc_method == NULL) {
response = rpc_error(rpc_id, -32600, "Invalid Request");
} else if (is_initialize) {
/* Create a new session and return its ID in the response header. */
session = session_create();
SoupMessageHeaders *resp_hdrs = soup_server_message_get_response_headers(msg);
soup_message_headers_append(resp_hdrs, "Mcp-Session-Id", session->session_id);
/* Return server info and capabilities. */
cJSON *result = cJSON_CreateObject();
cJSON_AddStringToObject(result, "protocolVersion", "2024-11-05");
cJSON *caps = cJSON_CreateObject();
cJSON_AddItemToObject(caps, "tools", cJSON_CreateObject());
cJSON_AddItemToObject(result, "capabilities", caps);
cJSON *server_info = cJSON_CreateObject();
cJSON_AddStringToObject(server_info, "name", "sovereign-browser");
cJSON_AddStringToObject(server_info, "version", SB_VERSION);
cJSON_AddItemToObject(result, "serverInfo", server_info);
response = rpc_result(rpc_id, result);
} else if (strcmp(rpc_method, "tools/list") == 0) {
/* Return the tool catalog. */
cJSON *result = cJSON_CreateObject();
cJSON_AddItemToObject(result, "tools", build_tools_list());
response = rpc_result(rpc_id, result);
} else if (strcmp(rpc_method, "tools/call") == 0) {
/* Dispatch the tool call. */
const char *tool_name = cJSON_GetStringValue(cJSON_GetObjectItem(params, "name"));
cJSON *arguments = cJSON_GetObjectItem(params, "arguments");
if (tool_name == NULL) {
response = rpc_error(rpc_id, -32602, "Missing 'name' in params");
} else {
/* Build a request in the format agent_tools_dispatch expects. */
cJSON *tool_request = cJSON_CreateObject();
cJSON_AddStringToObject(tool_request, "tool", tool_name);
if (arguments) {
cJSON_AddItemReferenceToObject(tool_request, "params", arguments);
} else {
cJSON_AddItemToObject(tool_request, "params", cJSON_CreateObject());
}
if (id_json) {
cJSON_AddItemReferenceToObject(tool_request, "id", id_json);
}
/* Call dispatch with NULL connection — async tools will
* fall through to the sync path (agent_js_eval_sync),
* which works in HTTP handlers. */
cJSON *tool_response = agent_tools_dispatch(tool_request, NULL);
cJSON_Delete(tool_request);
if (tool_response == NULL) {
/* This shouldn't happen with NULL conn since async tools
* fall back to sync. But handle it just in case. */
response = rpc_error(rpc_id, -32603, "Tool returned no response");
} else {
/* Convert tool response to MCP format.
* MCP tools/call returns: {content: [{type: "text", text: "..."}],
* isError: false}
*
* For the screenshot tool, the response contains a
* data.screenshot field with base64 PNG data. In that
* case we emit an image content block per the MCP spec
* instead of a text block. */
cJSON *result = cJSON_CreateObject();
cJSON *content = cJSON_CreateArray();
cJSON *data_obj = cJSON_GetObjectItem(tool_response, "data");
cJSON *screenshot = data_obj ? cJSON_GetObjectItem(data_obj, "screenshot") : NULL;
if (screenshot && cJSON_IsString(screenshot)) {
/* Image content block per MCP spec. */
cJSON *img_item = cJSON_CreateObject();
cJSON_AddStringToObject(img_item, "type", "image");
cJSON_AddStringToObject(img_item, "data", screenshot->valuestring);
cJSON_AddStringToObject(img_item, "mimeType", "image/png");
cJSON_AddItemToArray(content, img_item);
/* If the response also includes a snapshot text tree
* (e.g. from screenshot_annotated), emit it as an
* additional text content block. */
cJSON *snapshot_text = cJSON_GetObjectItem(data_obj, "snapshot");
if (snapshot_text && cJSON_IsString(snapshot_text)) {
cJSON *text_item = cJSON_CreateObject();
cJSON_AddStringToObject(text_item, "type", "text");
cJSON_AddStringToObject(text_item, "text", snapshot_text->valuestring);
cJSON_AddItemToArray(content, text_item);
}
} else {
/* Default: text content block with the JSON response. */
char *resp_str = cJSON_PrintUnformatted(tool_response);
cJSON *text_item = cJSON_CreateObject();
cJSON_AddStringToObject(text_item, "type", "text");
cJSON_AddStringToObject(text_item, "text", resp_str ? resp_str : "{}");
cJSON_AddItemToArray(content, text_item);
free(resp_str);
}
cJSON_AddItemToObject(result, "content", content);
gboolean is_error = !cJSON_IsTrue(cJSON_GetObjectItem(tool_response, "success"));
cJSON_AddBoolToObject(result, "isError", is_error);
cJSON_Delete(tool_response);
response = rpc_result(rpc_id, result);
}
}
} else if (strcmp(rpc_method, "ping") == 0) {
/* MCP ping — health check. Return an empty result. */
response = rpc_result(rpc_id, cJSON_CreateObject());
} else if (strcmp(rpc_method, "resources/list") == 0) {
/* We don't expose resources — return an empty list. */
cJSON *result = cJSON_CreateObject();
cJSON_AddItemToObject(result, "resources", cJSON_CreateArray());
response = rpc_result(rpc_id, result);
} else if (strcmp(rpc_method, "resources/templates/list") == 0) {
/* No resource templates — return an empty list. */
cJSON *result = cJSON_CreateObject();
cJSON_AddItemToObject(result, "resourceTemplates", cJSON_CreateArray());
response = rpc_result(rpc_id, result);
} else if (strcmp(rpc_method, "prompts/list") == 0) {
/* We don't expose prompts — return an empty list. */
cJSON *result = cJSON_CreateObject();
cJSON_AddItemToObject(result, "prompts", cJSON_CreateArray());
response = rpc_result(rpc_id, result);
} else if (strcmp(rpc_method, "logging/setLevel") == 0) {
/* Accept any log level — we don't filter, but acknowledge. */
response = rpc_result(rpc_id, cJSON_CreateObject());
} else if (g_str_has_prefix(rpc_method, "notifications/")) {
/* MCP notifications (initialized, cancelled, progress, etc.)
* have no id and expect no response body. Return 202 Accepted. */
cJSON_Delete(request);
soup_server_message_set_status(msg, 202, NULL);
return;
} else if (id_json == NULL) {
/* Any request without an id is a notification per JSON-RPC spec.
* Return 202 Accepted with no body. */
cJSON_Delete(request);
soup_server_message_set_status(msg, 202, NULL);
return;
} else {
response = rpc_error(rpc_id, -32601, "Method not found");
}
cJSON_Delete(request);
/* Send the response as SSE (Server-Sent Events).
* Notifications (no id) already returned 202 above and don't reach
* here. All JSON-RPC responses with an id are wrapped in a single
* SSE event: "event: message\r\ndata: <json>\r\n\r\n" */
if (response) {
char *resp_str = cJSON_PrintUnformatted(response);
cJSON_Delete(response);
if (resp_str) {
char *sse = build_sse_response(resp_str);
free(resp_str);
soup_server_message_set_status(msg, 200, NULL);
SoupMessageHeaders *resp_hdrs = soup_server_message_get_response_headers(msg);
soup_message_headers_append(resp_hdrs, "Cache-Control", "no-cache");
soup_message_headers_append(resp_hdrs, "Connection", "keep-alive");
soup_server_message_set_response(msg, "text/event-stream",
SOUP_MEMORY_TAKE, sse, strlen(sse));
} else {
soup_server_message_set_status(msg, 500, NULL);
}
} else {
soup_server_message_set_status(msg, 500, NULL);
}
}
/* ── Public API ───────────────────────────────────────────────────── */
void agent_mcp_register(SoupServer *server) {
/* Initialize the session table if not yet created. */
if (g_sessions == NULL) {
g_sessions = g_hash_table_new_full(g_str_hash, g_str_equal,
g_free, g_free);
}
soup_server_add_handler(server, "/mcp", on_mcp_request, NULL, NULL);
g_print("[agent] MCP endpoint: http://localhost:%d/mcp\n",
agent_server_get_port());
}

32
src/agent_mcp.h Normal file
View File

@@ -0,0 +1,32 @@
/*
* agent_mcp.h — MCP (Model Context Protocol) server endpoint
*
* Implements the MCP Streamable HTTP transport on top of our existing
* SoupServer. Exposes browser tools as MCP tools for AI assistants
* (Roo Code, Claude Code, Cursor, etc.).
*
* The endpoint is at http://localhost:PORT/mcp and accepts JSON-RPC
* POST requests. It uses the same agent_tools_dispatch() as the
* WebSocket endpoint — same code path, same tools.
*/
#ifndef AGENT_MCP_H
#define AGENT_MCP_H
#include <libsoup/soup.h>
#ifdef __cplusplus
extern "C" {
#endif
/*
* Register the MCP handler at /mcp on the given SoupServer.
* Call this after soup_server_add_websocket_handler() in agent_server_start().
*/
void agent_mcp_register(SoupServer *server);
#ifdef __cplusplus
}
#endif
#endif /* AGENT_MCP_H */

364
src/agent_server.c Normal file
View File

@@ -0,0 +1,364 @@
/*
* agent_server.c — WebSocket server for agent tool commands
*
* Uses libsoup's SoupServer to provide a WebSocket endpoint at /agent.
* External AI agents connect, send JSON tool commands, and receive
* JSON responses. The server also supports plain HTTP GET to / for
* status discovery (curl-testable without a WebSocket client).
*/
#include "agent_server.h"
#include "agent_mcp.h"
#include "settings.h"
#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;
static int g_port = 0;
static gboolean g_running = FALSE;
static GPtrArray *g_clients = NULL; /* array of SoupWebsocketConnection* */
static agent_login_callback_t g_login_cb = NULL;
/* ── Client management ────────────────────────────────────────────── */
static void on_ws_message(SoupWebsocketConnection *conn,
SoupWebsocketDataType type,
GBytes *message,
gpointer user_data);
static void on_ws_closed(SoupWebsocketConnection *conn, gpointer user_data);
static void on_ws_error(SoupWebsocketConnection *conn, gpointer user_data);
/* Forward declarations */
static void on_websocket_handler(SoupServer *server,
SoupServerMessage *msg,
const char *path,
SoupWebsocketConnection *conn,
gpointer user_data);
static void track_client(SoupWebsocketConnection *conn) {
if (g_clients == NULL) {
g_clients = g_ptr_array_new();
}
/* Take an extra reference so the connection isn't destroyed when
* the websocket handler callback returns. */
g_object_ref(conn);
g_ptr_array_add(g_clients, conn);
g_signal_connect(conn, "message", G_CALLBACK(on_ws_message), NULL);
g_signal_connect(conn, "closed", G_CALLBACK(on_ws_closed), NULL);
g_signal_connect(conn, "error", G_CALLBACK(on_ws_error), NULL);
g_print("[agent] Client connected (%d total)\n", g_clients->len);
}
static void untrack_client(SoupWebsocketConnection *conn) {
if (g_clients == NULL) return;
/* Remove from array and release our reference. */
g_ptr_array_remove_fast(g_clients, conn);
g_object_unref(conn);
g_print("[agent] Client disconnected (%d remaining)\n",
g_clients ? g_clients->len : 0);
}
/* ── Send helpers ─────────────────────────────────────────────────── */
static void send_json_to_client(SoupWebsocketConnection *conn, const char *json_str) {
if (soup_websocket_connection_get_state(conn) != SOUP_WEBSOCKET_STATE_OPEN) {
return;
}
soup_websocket_connection_send_text(conn, json_str);
}
static void send_json_to_all(const char *json_str) {
if (g_clients == NULL) return;
for (guint i = 0; i < g_clients->len; i++) {
SoupWebsocketConnection *conn = g_ptr_array_index(g_clients, i);
send_json_to_client(conn, json_str);
}
}
/* ── WebSocket callbacks ──────────────────────────────────────────── */
static void on_ws_message(SoupWebsocketConnection *conn,
SoupWebsocketDataType type,
GBytes *message,
gpointer user_data) {
(void)user_data;
(void)type;
gsize size = 0;
const gchar *data = g_bytes_get_data(message, &size);
if (data == NULL || size == 0) return;
/* Parse the JSON request. */
cJSON *request = cJSON_ParseWithLength(data, size);
if (request == NULL) {
const char *err_json = "{\"success\":false,\"error\":{\"code\":\"INVALID_JSON\","
"\"message\":\"Failed to parse JSON request\"}}";
send_json_to_client(conn, err_json);
return;
}
/* Dispatch the tool. Pass the WebSocket connection so async tools
* (snapshot, eval) can send their response directly when JS completes. */
cJSON *response = agent_tools_dispatch(request, conn);
cJSON_Delete(request);
if (response == NULL) {
/* NULL means the tool is sending its response asynchronously
* (e.g. snapshot, eval). Don't send anything here. */
return;
}
/* Send the response. */
char *response_str = cJSON_PrintUnformatted(response);
if (response_str) {
send_json_to_client(conn, response_str);
free(response_str);
}
cJSON_Delete(response);
}
static void on_ws_closed(SoupWebsocketConnection *conn, gpointer user_data) {
(void)user_data;
untrack_client(conn);
}
static void on_ws_error(SoupWebsocketConnection *conn, gpointer user_data) {
(void)user_data;
g_printerr("[agent] WebSocket error on client\n");
untrack_client(conn);
}
/* ── WebSocket handler (called by SoupServer on new connection) ───── */
static void on_websocket_handler(SoupServer *server,
SoupServerMessage *msg,
const char *path,
SoupWebsocketConnection *conn,
gpointer user_data) {
(void)server;
(void)path;
(void)msg;
(void)user_data;
track_client(conn);
}
/* ── HTTP handler for status (GET /) ──────────────────────────────── */
static void on_http_handler(SoupServer *server,
SoupServerMessage *msg,
const char *path,
GHashTable *query,
gpointer user_data) {
(void)server;
(void)query;
(void)user_data;
if (g_strcmp0(path, "/") != 0) {
soup_server_message_set_status(msg, 404, NULL);
return;
}
/* Build a status JSON response. */
cJSON *status = cJSON_CreateObject();
cJSON_AddStringToObject(status, "name", "sovereign_browser agent server");
cJSON_AddBoolToObject(status, "running", g_running);
cJSON_AddNumberToObject(status, "port", g_port);
cJSON_AddNumberToObject(status, "clients", g_clients ? g_clients->len : 0);
/* Check login state via agent_tools (delegates to agent_login). */
extern gboolean agent_is_logged_in(void);
cJSON_AddBoolToObject(status, "logged_in", agent_is_logged_in());
char *json_str = cJSON_PrintUnformatted(status);
soup_server_message_set_status(msg, 200, NULL);
soup_server_message_set_response(msg, "application/json",
SOUP_MEMORY_TAKE, json_str, strlen(json_str));
cJSON_Delete(status);
}
/* ── Public API ───────────────────────────────────────────────────── */
int agent_server_start(int port) {
if (g_running) {
g_print("[agent] Server already running on port %d\n", g_port);
return 0;
}
GError *error = NULL;
g_server = soup_server_new("server-header", "sovereign_browser-agent", NULL);
if (g_server == NULL) {
g_printerr("[agent] Failed to create SoupServer\n");
return -1;
}
/* Add HTTP handler for status at /. */
soup_server_add_handler(g_server, "/", on_http_handler, NULL, NULL);
/* Add WebSocket handler at /agent. */
soup_server_add_websocket_handler(g_server, "/agent", NULL, NULL,
on_websocket_handler, NULL, NULL);
/* Add MCP handler at /mcp. */
agent_mcp_register(g_server);
/* 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);
g_object_unref(g_server);
g_server = NULL;
return -1;
}
/* Get the actual bound port. */
GSList *uris = soup_server_get_uris(g_server);
if (uris != NULL) {
GUri *uri = uris->data;
g_port = g_uri_get_port(uri);
g_slist_free(uris);
} else {
g_port = 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;
}
void agent_server_stop(void) {
if (!g_running) return;
/* Close all client connections and release our references. */
if (g_clients != NULL) {
for (guint i = 0; i < g_clients->len; i++) {
SoupWebsocketConnection *conn = g_ptr_array_index(g_clients, i);
soup_websocket_connection_close(conn, SOUP_WEBSOCKET_CLOSE_GOING_AWAY,
"server shutting down");
g_object_unref(conn);
}
g_ptr_array_free(g_clients, TRUE);
g_clients = NULL;
}
if (g_server != NULL) {
g_object_unref(g_server);
g_server = NULL;
}
g_running = FALSE;
g_port = 0;
remove_discovery_file();
g_print("[agent] Server stopped\n");
}
int agent_server_get_port(void) {
return g_port;
}
gboolean agent_server_is_running(void) {
return g_running;
}
int agent_server_get_client_count(void) {
return g_clients ? g_clients->len : 0;
}
void agent_server_emit_event(const char *event_name, cJSON *data) {
if (!g_running || g_clients == NULL || g_clients->len == 0) {
if (data) cJSON_Delete(data);
return;
}
cJSON *event = cJSON_CreateObject();
cJSON_AddStringToObject(event, "type", "event");
cJSON_AddStringToObject(event, "event", event_name);
if (data) {
cJSON_AddItemToObject(event, "data", data);
}
char *json_str = cJSON_PrintUnformatted(event);
if (json_str) {
send_json_to_all(json_str);
free(json_str);
}
cJSON_Delete(event);
}
void agent_server_set_login_callback(agent_login_callback_t cb) {
g_login_cb = cb;
}
/* Called by agent_login.c when login succeeds — notifies main.c. */
void agent_server_notify_login(void) {
if (g_login_cb) {
g_login_cb();
}
}

74
src/agent_server.h Normal file
View File

@@ -0,0 +1,74 @@
/*
* agent_server.h — WebSocket server for agent tool commands
*
* Embeds a WebSocket server in the browser process using libsoup's
* SoupServer. External AI agents connect to ws://localhost:PORT/agent
* and send JSON tool commands (navigate, snapshot, click, etc.).
*
* The server starts before the login dialog so agents can authenticate
* without human interaction. Before login, only login tools are available;
* after login, all browser tools become available.
*/
#ifndef AGENT_SERVER_H
#define AGENT_SERVER_H
#include <glib.h>
#include "cjson/cJSON.h"
#ifdef __cplusplus
extern "C" {
#endif
/*
* Start the agent WebSocket server on the given port.
* Returns 0 on success, -1 on failure.
*/
int agent_server_start(int port);
/*
* Stop the agent server and close all connections.
*/
void agent_server_stop(void);
/*
* Get the port the server is listening on.
* Returns 0 if not running.
*/
int agent_server_get_port(void);
/*
* Check if the server is running.
*/
gboolean agent_server_is_running(void);
/*
* Get the number of connected clients.
*/
int agent_server_get_client_count(void);
/*
* Broadcast a push event to all connected clients.
* The event JSON is: {"type":"event","event":<name>,"data":<data>}
* Takes ownership of data (frees it).
*/
void agent_server_emit_event(const char *event_name, cJSON *data);
/*
* Set a callback that is called when a login tool succeeds.
* This allows main.c to proceed past the login wait loop.
*/
typedef void (*agent_login_callback_t)(void);
void agent_server_set_login_callback(agent_login_callback_t cb);
/*
* Called by agent_login.c when login succeeds — notifies main.c
* via the callback set by agent_server_set_login_callback().
*/
void agent_server_notify_login(void);
#ifdef __cplusplus
}
#endif
#endif /* AGENT_SERVER_H */

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 */

449
src/agent_snapshot.c Normal file
View File

@@ -0,0 +1,449 @@
/*
* agent_snapshot.c — accessibility tree snapshot for agent tools
*
* Injects a JS script that walks the DOM, assigns refs to interactive
* elements, and returns a text tree. The ref map is stored in
* window.__agentRefs for later use by click/fill/type tools.
*/
#include "agent_snapshot.h"
#include <string.h>
#include <stdlib.h>
/* ── Synchronous JS evaluation ─────────────────────────────────────── *
* WebKitGTK's evaluate_javascript is async. We use a nested GMainLoop
* on the default context to wait for the result. The key is to acquire
* the context before creating the loop so that the WebKit IPC sources
* are properly dispatched.
*/
typedef struct {
GMainLoop *loop;
char *result;
gboolean done;
} js_eval_ctx_t;
static void on_js_finished(GObject *source, GAsyncResult *res, gpointer user_data) {
js_eval_ctx_t *ctx = (js_eval_ctx_t *)user_data;
WebKitWebView *webview = WEBKIT_WEB_VIEW(source);
JSCValue *value = webkit_web_view_evaluate_javascript_finish(webview, res, NULL);
if (value != NULL) {
char *str = jsc_value_to_string(value);
if (str != NULL) {
ctx->result = g_strdup(str);
free(str);
}
g_object_unref(value);
}
ctx->done = TRUE;
if (ctx->loop && g_main_loop_is_running(ctx->loop)) {
g_main_loop_quit(ctx->loop);
}
}
static gboolean on_js_timeout(gpointer user_data) {
js_eval_ctx_t *ctx = (js_eval_ctx_t *)user_data;
if (!ctx->done) {
g_printerr("[agent] JS evaluation timed out\n");
if (ctx->loop && g_main_loop_is_running(ctx->loop)) {
g_main_loop_quit(ctx->loop);
}
}
return G_SOURCE_REMOVE;
}
char *agent_js_eval_sync(WebKitWebView *webview, const char *script,
int timeout_ms) {
if (webview == NULL || script == NULL) return NULL;
js_eval_ctx_t ctx = {0};
/* Use the default main context for the nested loop. */
GMainContext *main_ctx = g_main_context_default();
g_main_context_acquire(main_ctx);
ctx.loop = g_main_loop_new(main_ctx, FALSE);
/* Add a timeout source to the same context. */
guint timeout_id = g_timeout_add(timeout_ms, on_js_timeout, &ctx);
/* Start async evaluation. */
webkit_web_view_evaluate_javascript(webview, script, -1, NULL, NULL, NULL,
on_js_finished, &ctx);
/* Run the nested loop until JS completes or timeout. */
g_main_loop_run(ctx.loop);
/* Cleanup. */
g_source_remove(timeout_id);
g_main_loop_unref(ctx.loop);
g_main_context_release(main_ctx);
return ctx.result;
}
/* ── Async JS evaluation ──────────────────────────────────────────── *
* For tools that need JS return values (snapshot, eval, get_text, etc.),
* we use async evaluation. The JS completion callback sends the
* WebSocket response directly, avoiding the nested main loop issue.
*/
typedef struct {
SoupWebsocketConnection *conn;
int request_id;
char *tool_name;
js_result_handler_t handler;
} async_js_ctx_t;
static void send_ws_response(SoupWebsocketConnection *conn, cJSON *response) {
if (conn == NULL || response == NULL) return;
if (soup_websocket_connection_get_state(conn) != SOUP_WEBSOCKET_STATE_OPEN) {
cJSON_Delete(response);
return;
}
char *str = cJSON_PrintUnformatted(response);
if (str) {
soup_websocket_connection_send_text(conn, str);
free(str);
}
cJSON_Delete(response);
}
static cJSON *make_success_resp(int id, cJSON *data) {
cJSON *resp = cJSON_CreateObject();
cJSON_AddBoolToObject(resp, "success", TRUE);
cJSON_AddNumberToObject(resp, "id", id);
if (data) cJSON_AddItemToObject(resp, "data", data);
return resp;
}
static cJSON *make_error_resp(int id, const char *code, const char *msg) {
cJSON *resp = cJSON_CreateObject();
cJSON_AddBoolToObject(resp, "success", FALSE);
cJSON_AddNumberToObject(resp, "id", id);
cJSON *err = cJSON_CreateObject();
cJSON_AddStringToObject(err, "code", code);
cJSON_AddStringToObject(err, "message", msg);
cJSON_AddItemToObject(resp, "error", err);
return resp;
}
static void on_async_js_finished(GObject *source, GAsyncResult *res, gpointer user_data) {
async_js_ctx_t *ctx = (async_js_ctx_t *)user_data;
WebKitWebView *webview = WEBKIT_WEB_VIEW(source);
JSCValue *value = webkit_web_view_evaluate_javascript_finish(webview, res, NULL);
if (value == NULL) {
send_ws_response(ctx->conn,
make_error_resp(ctx->request_id, "EVAL_FAILED", "JS evaluation returned NULL"));
goto cleanup;
}
char *str = jsc_value_to_string(value);
g_object_unref(value);
if (str == NULL) {
send_ws_response(ctx->conn,
make_error_resp(ctx->request_id, "EVAL_FAILED", "JS returned null"));
goto cleanup;
}
char *result = g_strdup(str);
free(str);
if (ctx->handler) {
/* Let the tool-specific handler transform the result. */
cJSON *response = ctx->handler(result);
g_free(result);
if (response) {
/* Add the request id. */
cJSON_AddNumberToObject(response, "id", ctx->request_id);
send_ws_response(ctx->conn, response);
} else {
send_ws_response(ctx->conn,
make_error_resp(ctx->request_id, "TOOL_FAILED", "Tool handler returned NULL"));
}
} else {
/* No handler — return raw result. */
cJSON *data = cJSON_CreateObject();
cJSON_AddStringToObject(data, "result", result);
g_free(result);
send_ws_response(ctx->conn, make_success_resp(ctx->request_id, data));
}
cleanup:
g_free(ctx->tool_name);
g_free(ctx);
}
gboolean agent_js_eval_async(WebKitWebView *webview,
const char *script,
SoupWebsocketConnection *conn,
int request_id,
const char *tool_name,
js_result_handler_t handler) {
if (webview == NULL || script == NULL) return FALSE;
async_js_ctx_t *ctx = g_new0(async_js_ctx_t, 1);
ctx->conn = conn;
ctx->request_id = request_id;
ctx->tool_name = g_strdup(tool_name ? tool_name : "unknown");
ctx->handler = handler;
webkit_web_view_evaluate_javascript(webview, script, -1, NULL, NULL, NULL,
on_async_js_finished, ctx);
return TRUE;
}
/* ── Snapshot JS script ───────────────────────────────────────────── *
* This script walks the DOM, assigns refs to interactive elements,
* stores the ref map in window.__agentRefs, and returns a JSON string
* with the tree text and ref metadata.
*/
const char *AGENT_SNAPSHOT_JS =
"(function() {\n"
" 'use strict';\n"
"\n"
" var interactiveOnly = arguments[0] || false;\n"
" var compact = arguments[1] || false;\n"
"\n"
" var refMap = {};\n"
" var refCount = 0;\n"
" var treeLines = [];\n"
"\n"
" /* Check if an element is interactive. */\n"
" function isInteractive(el) {\n"
" var tag = el.tagName.toLowerCase();\n"
" if (tag === 'a' && el.href) return true;\n"
" if (tag === 'button') return true;\n"
" if (tag === 'input' && el.type !== 'hidden') return true;\n"
" if (tag === 'select') return true;\n"
" if (tag === 'textarea') return true;\n"
" if (tag === 'summary') return true;\n"
" if (tag === 'details') return true;\n"
" if (el.hasAttribute('role')) {\n"
" var role = el.getAttribute('role');\n"
" if (['button','link','checkbox','radio','textbox','searchbox',\n"
" 'slider','spinbutton','tab','tablist','menuitem','combobox',\n"
" 'option','switch','treeitem'].indexOf(role) >= 0) return true;\n"
" }\n"
" if (el.hasAttribute('tabindex') && el.tabIndex >= 0) return true;\n"
" if (el.hasAttribute('onclick')) return true;\n"
" return false;\n"
" }\n"
"\n"
" /* Get the accessible name for an element. */\n"
" function getName(el) {\n"
" if (el.hasAttribute('aria-label')) return el.getAttribute('aria-label');\n"
" if (el.hasAttribute('aria-labelledby')) {\n"
" var lbl = document.getElementById(el.getAttribute('aria-labelledby'));\n"
" if (lbl) return lbl.textContent.trim();\n"
" }\n"
" if (el.tagName.toLowerCase() === 'input' || el.tagName.toLowerCase() === 'textarea') {\n"
" if (el.hasAttribute('placeholder')) return el.placeholder;\n"
" var lbl2 = document.querySelector('label[for=\"' + el.id + '\"]');\n"
" if (lbl2) return lbl2.textContent.trim();\n"
" }\n"
" if (el.tagName.toLowerCase() === 'select') {\n"
" if (el.options.length > 0) return el.options[el.selectedIndex].text;\n"
" }\n"
" var text = el.textContent.trim();\n"
" if (text.length > 80) text = text.substring(0, 77) + '...';\n"
" return text;\n"
" }\n"
"\n"
" /* Get the role for an element. */\n"
" function getRole(el) {\n"
" if (el.hasAttribute('role')) return el.getAttribute('role');\n"
" var tag = el.tagName.toLowerCase();\n"
" var map = {\n"
" 'a': 'link', 'button': 'button', 'input': 'textbox',\n"
" 'select': 'combobox', 'textarea': 'textbox', 'img': 'image',\n"
" 'h1': 'heading', 'h2': 'heading', 'h3': 'heading',\n"
" 'h4': 'heading', 'h5': 'heading', 'h6': 'heading',\n"
" 'nav': 'navigation', 'main': 'main', 'article': 'article',\n"
" 'section': 'region', 'form': 'form', 'ul': 'list',\n"
" 'ol': 'list', 'li': 'listitem', 'table': 'table',\n"
" 'label': 'label', 'p': 'paragraph', 'summary': 'button'\n"
" };\n"
" if (tag === 'input') {\n"
" var type = (el.type || 'text').toLowerCase();\n"
" if (type === 'checkbox') return 'checkbox';\n"
" if (type === 'radio') return 'radio';\n"
" if (type === 'button' || type === 'submit' || type === 'reset') return 'button';\n"
" if (type === 'search') return 'searchbox';\n"
" if (type === 'range') return 'slider';\n"
" if (type === 'number') return 'spinbutton';\n"
" return 'textbox';\n"
" }\n"
" return map[tag] || 'generic';\n"
" }\n"
"\n"
" /* Generate a unique CSS selector for an element. */\n"
" function getSelector(el) {\n"
" if (el.id) return '#' + el.id;\n"
" var path = [];\n"
" while (el && el.nodeType === 1 && el !== document.body) {\n"
" var part = el.tagName.toLowerCase();\n"
" if (el.className && typeof el.className === 'string') {\n"
" var classes = el.className.trim().split(/\\s+/).slice(0, 2);\n"
" if (classes.length > 0 && classes[0]) part += '.' + classes.join('.');\n"
" }\n"
" var parent = el.parentNode;\n"
" if (parent) {\n"
" var siblings = Array.prototype.filter.call(parent.children,\n"
" function(c) { return c.tagName === el.tagName; });\n"
" if (siblings.length > 1) {\n"
" part += ':nth-of-type(' + (siblings.indexOf(el) + 1) + ')';\n"
" }\n"
" }\n"
" path.unshift(part);\n"
" el = parent;\n"
" }\n"
" return path.length > 0 ? path.join(' > ') : 'body';\n"
" }\n"
"\n"
" /* Walk the DOM. */\n"
" function walk(el, depth) {\n"
" if (!el || el.nodeType !== 1) return;\n"
"\n"
" var role = getRole(el);\n"
" var name = getName(el);\n"
" var interactive = isInteractive(el);\n"
"\n"
" /* Skip non-interactive elements in interactive-only mode. */\n"
" if (interactiveOnly && !interactive) {\n"
" /* Still walk children — they might be interactive. */\n"
" for (var i = 0; i < el.children.length; i++) {\n"
" walk(el.children[i], depth);\n"
" }\n"
" return;\n"
" }\n"
"\n"
" /* Skip empty elements in compact mode. */\n"
" if (compact && !interactive && !name && el.children.length === 0) return;\n"
"\n"
" var indent = '';\n"
" for (var d = 0; d < depth; d++) indent += ' ';\n"
"\n"
" var line = indent + '- ' + role;\n"
" if (name) line += ' \"' + name + '\"';\n"
"\n"
" if (interactive) {\n"
" refCount++;\n"
" var ref = 'e' + refCount;\n"
" var selector = getSelector(el);\n"
" var rect = el.getBoundingClientRect();\n"
" refMap[ref] = {\n"
" role: role,\n"
" name: name,\n"
" selector: selector,\n"
" tag: el.tagName.toLowerCase(),\n"
" href: el.href || '',\n"
" type: el.type || '',\n"
" value: el.value || '',\n"
" bbox: {\n"
" x: rect.x,\n"
" y: rect.y,\n"
" width: rect.width,\n"
" height: rect.height\n"
" }\n"
" };\n"
" line += ' [ref=' + ref + ']';\n"
" if (el.tagName.toLowerCase() === 'input' && el.type) {\n"
" line += ' [type=' + el.type + ']';\n"
" }\n"
" }\n"
"\n"
" if (el.tagName && el.tagName.match(/^H[1-6]$/)) {\n"
" line += ' [level=' + el.tagName[1] + ']';\n"
" }\n"
"\n"
" treeLines.push(line);\n"
"\n"
" for (var i = 0; i < el.children.length; i++) {\n"
" walk(el.children[i], depth + 1);\n"
" }\n"
" }\n"
"\n"
" /* Start from body. */\n"
" walk(document.body, 0);\n"
"\n"
" /* Store ref map globally for later use by click/fill tools. */\n"
" window.__agentRefs = refMap;\n"
"\n"
" /* Return JSON result. */\n"
" return JSON.stringify({\n"
" snapshot: treeLines.join('\\\\n'),\n"
" refs: refMap,\n"
" refCount: refCount\n"
" });\n"
"})\n";
/* ── Snapshot API ─────────────────────────────────────────────────── */
cJSON *agent_snapshot_take(WebKitWebView *webview,
gboolean interactive,
gboolean compact) {
if (webview == NULL) return NULL;
/* Build the JS call with arguments. Use g_strconcat instead of
* g_strdup_printf to avoid % format specifier issues in the JS. */
char *full_script = g_strconcat(
"(", AGENT_SNAPSHOT_JS, ")(",
interactive ? "true" : "false", ", ",
compact ? "true" : "false", ")",
NULL);
char *result = agent_js_eval_sync(webview, full_script, 15000);
g_free(full_script);
if (result == NULL) {
return NULL;
}
cJSON *json = cJSON_Parse(result);
g_free(result);
return json;
}
/* ── Async snapshot ───────────────────────────────────────────────── */
/* Handler that transforms the snapshot JS result into a cJSON response. */
static cJSON *snapshot_result_handler(const char *js_result) {
if (js_result == NULL || js_result[0] == '\0') {
return make_error_resp(0, "SNAPSHOT_FAILED", "Snapshot returned empty result");
}
cJSON *json = cJSON_Parse(js_result);
if (json == NULL) {
return make_error_resp(0, "SNAPSHOT_FAILED", "Failed to parse snapshot JSON");
}
return make_success_resp(0, json);
}
gboolean agent_snapshot_take_async(WebKitWebView *webview,
gboolean interactive,
gboolean compact,
SoupWebsocketConnection *conn,
int request_id) {
if (webview == NULL) return FALSE;
char *full_script = g_strconcat(
"(", AGENT_SNAPSHOT_JS, ")(",
interactive ? "true" : "false", ", ",
compact ? "true" : "false", ")",
NULL);
gboolean started = agent_js_eval_async(webview, full_script, conn,
request_id, "snapshot",
snapshot_result_handler);
g_free(full_script);
return started;
}

93
src/agent_snapshot.h Normal file
View File

@@ -0,0 +1,93 @@
/*
* agent_snapshot.h — accessibility tree snapshot for agent tools
*
* Injects a JavaScript script into the active tab's webview that walks
* the DOM, assigns sequential refs (e1, e2, ...) to interactive elements,
* and returns a text representation of the accessibility tree.
*/
#ifndef AGENT_SNAPSHOT_H
#define AGENT_SNAPSHOT_H
#include <webkit2/webkit2.h>
#include <libsoup/soup.h>
#include "cjson/cJSON.h"
#ifdef __cplusplus
extern "C" {
#endif
/*
* The JS script injected for snapshot. It walks the DOM, assigns refs,
* stores the ref map in window.__agentRefs, and returns a JSON string
* with the tree text and ref metadata.
*/
extern const char *AGENT_SNAPSHOT_JS;
/*
* Take a snapshot of the given webview. This injects the snapshot JS
* and returns the result as a cJSON object:
* {"snapshot": "- heading ... [ref=e1]\n- link ... [ref=e2]",
* "refs": {"e1": {"role":"heading","name":"..."}, ...}}
*
* Returns NULL on failure. Caller frees the result.
*
* interactive: if TRUE, only include interactive elements
* compact: if TRUE, remove empty structural elements
*/
cJSON *agent_snapshot_take(WebKitWebView *webview,
gboolean interactive,
gboolean compact);
/*
* Async snapshot — starts JS evaluation and sends the response through
* the WebSocket connection when the JS completes. Returns TRUE if the
* async evaluation was started (response will be sent async), FALSE if
* it failed immediately (caller should send an error response).
*
* conn: WebSocket connection to send the response through
* request_id: the JSON request id to include in the response
*/
gboolean agent_snapshot_take_async(WebKitWebView *webview,
gboolean interactive,
gboolean compact,
SoupWebsocketConnection *conn,
int request_id);
/*
* Async JS evaluation — evaluates a script and sends the result as a
* tool response through the WebSocket connection when the JS completes.
* Returns TRUE if started async, FALSE on immediate failure.
*
* conn: WebSocket connection to send the response through
* request_id: the JSON request id to include in the response
* tool_name: the tool name (for logging)
* success_handler: optional callback to transform the JS result into
* a cJSON response before sending. If NULL, the raw
* JS result string is returned in data.result.
*/
typedef cJSON *(*js_result_handler_t)(const char *js_result);
gboolean agent_js_eval_async(WebKitWebView *webview,
const char *script,
SoupWebsocketConnection *conn,
int request_id,
const char *tool_name,
js_result_handler_t handler);
/*
* Execute JavaScript in the webview and wait for the result.
* This is a synchronous wrapper around the async evaluate_javascript API.
* Returns the result string (caller must free) or NULL on failure/timeout.
*
* script: the JavaScript to execute (must return a string)
* timeout_ms: how long to wait for the result
*/
char *agent_js_eval_sync(WebKitWebView *webview, const char *script,
int timeout_ms);
#ifdef __cplusplus
}
#endif
#endif /* AGENT_SNAPSHOT_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 */

6045
src/agent_tools.c Normal file

File diff suppressed because it is too large Load Diff

40
src/agent_tools.h Normal file
View File

@@ -0,0 +1,40 @@
/*
* agent_tools.h — tool dispatch for agent browser automation
*
* Receives a JSON tool request and dispatches it to the appropriate
* tool implementation. Returns a JSON response.
*/
#ifndef AGENT_TOOLS_H
#define AGENT_TOOLS_H
#include "cjson/cJSON.h"
#include <libsoup/soup.h>
#ifdef __cplusplus
extern "C" {
#endif
/*
* Process a tool request JSON and return response JSON.
* The caller frees the returned cJSON*.
*
* If the tool needs async JS evaluation, the response is sent directly
* through the WebSocket connection and this function returns NULL.
* Otherwise, the response is returned synchronously.
*
* conn: the WebSocket connection to send async responses through
* (may be NULL for testing without a real connection)
*
* Request format: {"id":1,"tool":"snapshot","params":{...}}
* Response format: {"id":1,"success":true,"data":{...}}
* or: {"id":1,"success":false,"error":{"code":"...","message":"..."}}
*/
cJSON *agent_tools_dispatch(cJSON *request, SoupWebsocketConnection *conn);
#ifdef __cplusplus
}
#endif
#endif /* AGENT_TOOLS_H */

1103
src/bookmarks.c Normal file

File diff suppressed because it is too large Load Diff

155
src/bookmarks.h Normal file
View File

@@ -0,0 +1,155 @@
/*
* bookmarks.h — NIP-44 encrypted Nostr bookmarks with nested folders
*
* Stores bookmarks as NIP-51 kind 30003 (bookmark sets) events, one per
* 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.
*/
#ifndef BOOKMARKS_H
#define BOOKMARKS_H
#include <glib.h>
#include <time.h>
/* nostr_signer_t is needed for the init function */
#include "nostr_core/nostr_signer.h"
#ifdef __cplusplus
extern "C" {
#endif
#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;
char *title;
long added; /* unix timestamp */
} bookmark_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).
* 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);
/* Free the in-memory bookmark tree. Call at shutdown. */
void bookmarks_cleanup(void);
/* ── Read access ───────────────────────────────────────────────────── */
/* 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);
/* Find a node by path. Returns NULL if not found. */
const bookmark_node_t *bookmarks_find(const char *path);
/* ── Write access (requires signer + privkey) ──────────────────────── */
/* 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 *path, const char *url, const char *title);
/* Remove a bookmark by URL from a folder path.
* Returns 0 on success, -1 on not found / error. */
int bookmarks_remove(const char *path, const char *url);
/* 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_path, const char *url,
const char *to_path);
/* 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);
/* 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_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) ────────────────────────────────── */
/* Store a raw kind 30003 event in the database and decrypt its content
* into the in-memory tree. Called by the relay fetch after login. */
int bookmarks_store_and_load_event(const void *event_cjson);
#ifdef __cplusplus
}
#endif
#endif /* BOOKMARKS_H */

474
src/cli.c Normal file
View File

@@ -0,0 +1,474 @@
/*
* cli.c — command-line flag parsing for sovereign_browser
*
* Uses getopt_long to parse flags before gtk_init(). Recognized flags
* are stripped from argv (getopt_long reorders argv in place; optind
* points at the first positional arg on return). We collect positional
* args as startup URLs.
*
* Login flags build a cJSON params object matching the shape documented
* in agent_login.h and call agent_login(), reusing the same code path
* as the MCP 'login' tool.
*/
#define _POSIX_C_SOURCE 200809L /* strdup, getopt_long */
#include "cli.h"
#include "version.h"
#include "agent_login.h"
#include "key_store.h"
#include "nostr_core/nostr_core.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <getopt.h>
/* ── Usage text ────────────────────────────────────────────────────── */
void cli_print_usage(FILE *fp) {
fprintf(fp,
"sovereign_browser %s — WebKitGTK + C99\n"
"\n"
"Usage: sovereign_browser [OPTIONS] [URL...]\n"
"\n"
"Positional URLs are opened in tabs at startup (unless a session is\n"
"restored). --url flags are equivalent and may be repeated.\n"
"\n"
"Browser / Startup:\n"
" --url <url> Open URL in a tab (repeatable)\n"
" --new-tab-url <url> Override the default new-tab URL\n"
" --no-session-restore Skip session restore for this run\n"
" --session-restore Force session restore even if disabled\n"
" --max-tabs <n> Override max simultaneous tabs\n"
" -V, --version Print version and exit\n"
" -h, --help Show this help and exit\n"
"\n"
"Agent Server:\n"
" --port <port> Override agent MCP server port\n"
" --no-agent Disable the agent server for this run\n"
" --agent Force-enable the agent server\n"
" --agent-origin <origin> Allowed origin (repeatable)\n"
"\n"
"Login (skips the GTK login dialog):\n"
" --no-login Browse without a Nostr identity (normal browser mode)\n"
" --login-method <m> Method: generate|local|seed|readonly|nip46|nsigner\n"
" --nsec <nsec1...> (local) nsec bech32 private key\n"
" --privkey <hex> (local) 64-char hex private key\n"
" --mnemonic <words> (seed) BIP-39 mnemonic, quoted\n"
" --account <n> (seed) BIP-44 account index (default 0)\n"
" --npub <npub1...> (readonly) npub bech32 public key\n"
" --pubkey <hex> (readonly) 64-char hex pubkey\n"
" --bunker <url> (nip46) bunker:// URL\n"
" --nsigner-transport <t> (nsigner) serial|unix|tcp|qrexec\n"
" --nsigner-device <path> (nsigner) device path / socket / host:port / qube\n"
" --nsigner-service <name> (nsigner) qrexec service name\n"
" --nsigner-index <n> (nsigner) key index (default 0)\n"
" --login-timeout <ms> Override agent login timeout (GTK dialog path)\n"
"\n"
"Diagnostics:\n"
" -v, --verbose Increase log verbosity (repeatable)\n"
" -q, --quiet Suppress non-error log output\n"
"\n"
"Examples:\n"
" sovereign_browser https://example.com\n"
" sovereign_browser --login-method generate --url https://example.com\n"
" sovereign_browser --login-method local --nsec nsec1... --port 18888\n"
" sovereign_browser --login-method readonly --npub npub1... --no-agent\n"
" sovereign_browser --no-agent --url https://a.com --url https://b.com\n",
SB_VERSION);
}
/* ── getopt_long option table ──────────────────────────────────────── */
enum {
OPT_URL = 1000,
OPT_NEW_TAB_URL,
OPT_NO_SESSION_RESTORE,
OPT_SESSION_RESTORE,
OPT_MAX_TABS,
OPT_VERSION,
OPT_PORT,
OPT_NO_AGENT,
OPT_AGENT,
OPT_AGENT_ORIGIN,
OPT_LOGIN_METHOD,
OPT_NSEC,
OPT_PRIVKEY,
OPT_MNEMONIC,
OPT_ACCOUNT,
OPT_NPUB,
OPT_PUBKEY,
OPT_BUNKER,
OPT_NSIGNER_TRANSPORT,
OPT_NSIGNER_DEVICE,
OPT_NSIGNER_SERVICE,
OPT_NSIGNER_INDEX,
OPT_NO_LOGIN,
OPT_LOGIN_TIMEOUT,
OPT_VERBOSE,
OPT_QUIET,
OPT_HELP,
};
static struct option long_opts[] = {
/* Browser / startup */
{"url", required_argument, 0, OPT_URL},
{"new-tab-url", required_argument, 0, OPT_NEW_TAB_URL},
{"no-session-restore", no_argument, 0, OPT_NO_SESSION_RESTORE},
{"session-restore", no_argument, 0, OPT_SESSION_RESTORE},
{"max-tabs", required_argument, 0, OPT_MAX_TABS},
{"version", no_argument, 0, OPT_VERSION},
/* Agent server */
{"port", required_argument, 0, OPT_PORT},
{"no-agent", no_argument, 0, OPT_NO_AGENT},
{"agent", no_argument, 0, OPT_AGENT},
{"agent-origin", required_argument, 0, OPT_AGENT_ORIGIN},
/* Login */
{"login-method", required_argument, 0, OPT_LOGIN_METHOD},
{"nsec", required_argument, 0, OPT_NSEC},
{"privkey", required_argument, 0, OPT_PRIVKEY},
{"mnemonic", required_argument, 0, OPT_MNEMONIC},
{"account", required_argument, 0, OPT_ACCOUNT},
{"npub", required_argument, 0, OPT_NPUB},
{"pubkey", required_argument, 0, OPT_PUBKEY},
{"bunker", required_argument, 0, OPT_BUNKER},
{"nsigner-transport", required_argument, 0, OPT_NSIGNER_TRANSPORT},
{"nsigner-device", required_argument, 0, OPT_NSIGNER_DEVICE},
{"nsigner-service", required_argument, 0, OPT_NSIGNER_SERVICE},
{"nsigner-index", required_argument, 0, OPT_NSIGNER_INDEX},
{"no-login", no_argument, 0, OPT_NO_LOGIN},
{"login-timeout", required_argument, 0, OPT_LOGIN_TIMEOUT},
/* Diagnostics */
{"verbose", no_argument, 0, OPT_VERBOSE},
{"quiet", no_argument, 0, OPT_QUIET},
{"help", no_argument, 0, OPT_HELP},
{0, 0, 0, 0}
};
static const char *short_opts = "vVhq";
/* ── Helpers ───────────────────────────────────────────────────────── */
static char *xstrdup(const char *s) {
if (s == NULL) return NULL;
char *p = strdup(s);
if (p == NULL) {
fprintf(stderr, "[cli] out of memory\n");
exit(EXIT_FAILURE);
}
return p;
}
static void add_string(char ***arr, int *count, const char *value) {
*arr = g_realloc(*arr, sizeof(char *) * (*count + 1));
(*arr)[*count] = xstrdup(value);
(*count)++;
}
static int valid_login_method(const char *m) {
return strcmp(m, "generate") == 0 ||
strcmp(m, "local") == 0 ||
strcmp(m, "seed") == 0 ||
strcmp(m, "readonly") == 0 ||
strcmp(m, "nip46") == 0 ||
strcmp(m, "nsigner") == 0;
}
/* ── cli_args_free ─────────────────────────────────────────────────── */
void cli_args_free(cli_args_t *args) {
if (args == NULL) return;
if (args->urls) {
for (int i = 0; i < args->url_count; i++) g_free(args->urls[i]);
g_free(args->urls);
}
g_free(args->new_tab_url);
if (args->agent_origins) {
for (int i = 0; i < args->agent_origin_count; i++) g_free(args->agent_origins[i]);
g_free(args->agent_origins);
}
g_free(args->login_method);
g_free(args->nsec);
g_free(args->privkey);
g_free(args->mnemonic);
g_free(args->npub);
g_free(args->pubkey);
g_free(args->bunker);
g_free(args->nsigner_transport);
g_free(args->nsigner_device);
g_free(args->nsigner_service);
memset(args, 0, sizeof(*args));
}
/* ── cli_parse ─────────────────────────────────────────────────────── */
int cli_parse(int *argc, char ***argv, cli_args_t *out) {
memset(out, 0, sizeof(*out));
out->account = -1;
out->nsigner_index = -1;
/* getopt_long reorders argv so non-options end up at the end. */
opterr = 0; /* we print our own errors */
optind = 1;
int c;
int long_idx;
while ((c = getopt_long(*argc, *argv, short_opts, long_opts, &long_idx)) != -1) {
switch (c) {
/* Short options */
case 'v': out->verbose++; break;
case 'V': out->want_version = TRUE; return 0;
case 'h': out->want_help = TRUE; return 0;
case 'q': out->quiet = TRUE; break;
case '?':
if (optopt) {
fprintf(stderr, "[cli] unknown short option '-%c'\n", optopt);
} else {
fprintf(stderr, "[cli] unknown long option near '%s'\n",
(*argv)[optind - 1] ? (*argv)[optind - 1] : "?");
}
cli_print_usage(stderr);
return -1;
/* Browser / startup */
case OPT_URL:
add_string(&out->urls, &out->url_count, optarg);
break;
case OPT_NEW_TAB_URL:
g_free(out->new_tab_url);
out->new_tab_url = xstrdup(optarg);
break;
case OPT_NO_SESSION_RESTORE:
out->session_restore = CLI_TRISTATE_FALSE;
break;
case OPT_SESSION_RESTORE:
out->session_restore = CLI_TRISTATE_TRUE;
break;
case OPT_MAX_TABS:
out->max_tabs = atoi(optarg);
if (out->max_tabs < 1) {
fprintf(stderr, "[cli] --max-tabs must be >= 1\n");
return -1;
}
break;
case OPT_VERSION:
out->want_version = TRUE;
return 0;
/* Agent server */
case OPT_PORT:
out->agent_port = atoi(optarg);
if (out->agent_port < 1 || out->agent_port > 65535) {
fprintf(stderr, "[cli] --port must be 1-65535\n");
return -1;
}
break;
case OPT_NO_AGENT:
out->agent_enabled = CLI_TRISTATE_FALSE;
break;
case OPT_AGENT:
out->agent_enabled = CLI_TRISTATE_TRUE;
break;
case OPT_AGENT_ORIGIN:
add_string(&out->agent_origins, &out->agent_origin_count, optarg);
break;
/* Login */
case OPT_LOGIN_METHOD:
if (!valid_login_method(optarg)) {
fprintf(stderr, "[cli] invalid --login-method '%s'\n", optarg);
fprintf(stderr, "[cli] use: generate|local|seed|readonly|nip46|nsigner\n");
return -1;
}
g_free(out->login_method);
out->login_method = xstrdup(optarg);
break;
case OPT_NSEC:
g_free(out->nsec);
out->nsec = xstrdup(optarg);
break;
case OPT_PRIVKEY:
g_free(out->privkey);
out->privkey = xstrdup(optarg);
break;
case OPT_MNEMONIC:
g_free(out->mnemonic);
out->mnemonic = xstrdup(optarg);
break;
case OPT_ACCOUNT:
out->account = atoi(optarg);
if (out->account < 0) {
fprintf(stderr, "[cli] --account must be >= 0\n");
return -1;
}
break;
case OPT_NPUB:
g_free(out->npub);
out->npub = xstrdup(optarg);
break;
case OPT_PUBKEY:
g_free(out->pubkey);
out->pubkey = xstrdup(optarg);
break;
case OPT_BUNKER:
g_free(out->bunker);
out->bunker = xstrdup(optarg);
break;
case OPT_NSIGNER_TRANSPORT:
g_free(out->nsigner_transport);
out->nsigner_transport = xstrdup(optarg);
break;
case OPT_NSIGNER_DEVICE:
g_free(out->nsigner_device);
out->nsigner_device = xstrdup(optarg);
break;
case OPT_NSIGNER_SERVICE:
g_free(out->nsigner_service);
out->nsigner_service = xstrdup(optarg);
break;
case OPT_NSIGNER_INDEX:
out->nsigner_index = atoi(optarg);
if (out->nsigner_index < 0) {
fprintf(stderr, "[cli] --nsigner-index must be >= 0\n");
return -1;
}
break;
case OPT_NO_LOGIN:
out->no_login = TRUE;
break;
case OPT_LOGIN_TIMEOUT:
out->login_timeout_ms = atoi(optarg);
if (out->login_timeout_ms < 0) {
fprintf(stderr, "[cli] --login-timeout must be >= 0\n");
return -1;
}
break;
/* Diagnostics */
case OPT_VERBOSE:
out->verbose++;
break;
case OPT_QUIET:
out->quiet = TRUE;
break;
case OPT_HELP:
out->want_help = TRUE;
return 0;
default:
fprintf(stderr, "[cli] unhandled option\n");
return -1;
}
}
/* Validate login-method-specific args. */
if (out->login_method) {
const char *m = out->login_method;
if (strcmp(m, "local") == 0) {
if (!out->nsec && !out->privkey) {
fprintf(stderr, "[cli] --login-method local requires --nsec or --privkey\n");
return -1;
}
} else if (strcmp(m, "seed") == 0) {
if (!out->mnemonic) {
fprintf(stderr, "[cli] --login-method seed requires --mnemonic\n");
return -1;
}
} else if (strcmp(m, "readonly") == 0) {
if (!out->npub && !out->pubkey) {
fprintf(stderr, "[cli] --login-method readonly requires --npub or --pubkey\n");
return -1;
}
} else if (strcmp(m, "nip46") == 0) {
if (!out->bunker) {
fprintf(stderr, "[cli] --login-method nip46 requires --bunker\n");
return -1;
}
} else if (strcmp(m, "nsigner") == 0) {
if (!out->nsigner_transport || !out->nsigner_device) {
fprintf(stderr, "[cli] --login-method nsigner requires --nsigner-transport and --nsigner-device\n");
return -1;
}
}
/* "generate" needs no extra args. */
}
/* Collect positional args (after getopt_long reordering) as URLs. */
for (int i = optind; i < *argc; i++) {
add_string(&out->urls, &out->url_count, (*argv)[i]);
}
/* Repack argv: keep argv[0], then positional args only. gtk_init()
* will see a clean vector with no unknown options. */
int new_argc = 1; /* argv[0] */
for (int i = optind; i < *argc; i++) {
(*argv)[new_argc++] = (*argv)[i];
}
(*argv)[new_argc] = NULL;
*argc = new_argc;
return 0;
}
/* ── cli_login ─────────────────────────────────────────────────────── */
int cli_login(const cli_args_t *args) {
if (args->login_method == NULL) {
fprintf(stderr, "[cli] cli_login called with no --login-method\n");
return -1;
}
/* Build the cJSON params object matching agent_login.h's contract. */
cJSON *params = cJSON_CreateObject();
cJSON_AddStringToObject(params, "method", args->login_method);
if (args->nsec) cJSON_AddStringToObject(params, "nsec", args->nsec);
if (args->privkey) cJSON_AddStringToObject(params, "privkey_hex", args->privkey);
if (args->mnemonic) cJSON_AddStringToObject(params, "mnemonic", args->mnemonic);
if (args->account >= 0) cJSON_AddNumberToObject(params, "account", args->account);
if (args->npub) cJSON_AddStringToObject(params, "npub", args->npub);
if (args->pubkey) cJSON_AddStringToObject(params, "pubkey_hex", args->pubkey);
if (args->bunker) cJSON_AddStringToObject(params, "bunker_url", args->bunker);
if (args->nsigner_transport) cJSON_AddStringToObject(params, "transport", args->nsigner_transport);
if (args->nsigner_device) cJSON_AddStringToObject(params, "device", args->nsigner_device);
if (args->nsigner_service) cJSON_AddStringToObject(params, "service", args->nsigner_service);
if (args->nsigner_index >= 0) cJSON_AddNumberToObject(params, "index", args->nsigner_index);
/* agent_login() calls app_set_signer() on success, which sets the
* same global state the GTK dialog would. It also handles
* nostr_init() internally. */
cJSON *result = agent_login(params);
cJSON_Delete(params);
if (result == NULL) {
fprintf(stderr, "[cli] login returned NULL\n");
return -1;
}
cJSON *success = cJSON_GetObjectItem(result, "success");
if (!success || !cJSON_IsTrue(success)) {
cJSON *err = cJSON_GetObjectItem(result, "error");
const char *code = err ? cJSON_GetStringValue(cJSON_GetObjectItem(err, "code")) : "?";
const char *msg = err ? cJSON_GetStringValue(cJSON_GetObjectItem(err, "message")) : "unknown";
fprintf(stderr, "[cli] login failed: %s: %s\n", code ? code : "?", msg ? msg : "?");
cJSON_Delete(result);
return -1;
}
/* Login succeeded. Print the pubkey for visibility. */
cJSON *data = cJSON_GetObjectItem(result, "data");
if (data) {
const char *pubkey = cJSON_GetStringValue(cJSON_GetObjectItem(data, "pubkey"));
const char *method = cJSON_GetStringValue(cJSON_GetObjectItem(data, "method"));
g_print("[cli] Logged in: method=%s pubkey=%s\n",
method ? method : "?", pubkey ? pubkey : "?");
}
cJSON_Delete(result);
/* Private keys are never persisted to disk — they live only in RAM
* for the duration of the session. The identity is held in the
* global app_state_t (main.c) and is gone when the browser quits. */
return 0;
}

122
src/cli.h Normal file
View File

@@ -0,0 +1,122 @@
/*
* cli.h — command-line flag parsing for sovereign_browser
*
* Parses argv before gtk_init() so GTK doesn't abort on our flags.
* Recognized flags are stripped from argv/argc so gtk_init() only sees
* positional URLs. CLI flags override settings.conf for this run only
* (nothing is written to disk).
*
* Login flags build a cJSON params object and call agent_login(),
* reusing the same code path as the MCP 'login' tool. On success the
* GTK login dialog is skipped entirely.
*/
#ifndef CLI_H
#define CLI_H
#include <glib.h>
#include <stdio.h> /* FILE */
#ifdef __cplusplus
extern "C" {
#endif
/* Tri-state for boolean overrides. */
typedef enum {
CLI_TRISTATE_UNSET = 0,
CLI_TRISTATE_TRUE,
CLI_TRISTATE_FALSE
} cli_tristate_t;
/* Parsed command-line arguments. Zero-initialized by cli_parse().
* Strings are heap-allocated and owned by the cli_args_t — free with
* cli_args_free(). */
typedef struct {
/* Startup URLs: --url flags + positional args, in order. */
char **urls;
int url_count;
/* Browser overrides (NULL / 0 = not set). */
char *new_tab_url; /* --new-tab-url */
int max_tabs; /* --max-tabs (0 = unset) */
cli_tristate_t session_restore; /* --session-restore / --no-session-restore */
/* Agent server overrides. */
int agent_port; /* --port (0 = unset) */
cli_tristate_t agent_enabled; /* --agent / --no-agent */
char **agent_origins; /* --agent-origin (repeatable) */
int agent_origin_count;
/* Login. login_method == NULL means "show GTK dialog".
* no_login == TRUE means skip login entirely (browse without identity). */
gboolean no_login; /* --no-login */
char *login_method; /* --login-method */
char *nsec; /* --nsec */
char *privkey; /* --privkey */
char *mnemonic; /* --mnemonic */
int account; /* --account (-1 = unset, defaults to 0) */
char *npub; /* --npub */
char *pubkey; /* --pubkey */
char *bunker; /* --bunker */
char *nsigner_transport; /* --nsigner-transport */
char *nsigner_device; /* --nsigner-device */
char *nsigner_service; /* --nsigner-service */
int nsigner_index; /* --nsigner-index (-1 = unset) */
int login_timeout_ms; /* --login-timeout (0 = unset) */
/* Diagnostics. */
int verbose; /* --verbose / -v (repeatable count) */
gboolean quiet; /* --quiet / -q */
/* Exit-request flags set by --help / --version. */
gboolean want_help;
gboolean want_version;
} cli_args_t;
/*
* Parse command-line arguments.
*
* On entry, *argc / *argv are the raw values from main(). On return,
* recognized flags are removed from *argv / *argc (repacked via
* getopt_long's optind) so the remaining vector is safe to pass to
* gtk_init(). Positional URL arguments are preserved.
*
* Returns:
* 0 — parse OK (or --help / --version requested; check args.want_*).
* -1 — parse error (message printed to stderr).
*
* On error or when want_help/want_version is true, the caller should
* exit without calling gtk_init().
*/
int cli_parse(int *argc, char ***argv, cli_args_t *out);
/*
* Free heap-allocated fields in a cli_args_t. Safe to call on a
* zero-initialized struct.
*/
void cli_args_free(cli_args_t *args);
/*
* Print usage text to the given stream.
*/
void cli_print_usage(FILE *fp);
/*
* Perform CLI login by building a cJSON params object from args and
* calling agent_login(). Requires nostr_init() to have been called
* already (agent_login() handles re-init internally).
*
* Returns:
* 0 — login succeeded (global app state is now set).
* -1 — login failed (error message printed to stderr).
*
* If args->no_save_identity is FALSE, the identity is persisted to
* ~/.sovereign_browser/identity.json via key_store_save() on success.
*/
int cli_login(const cli_args_t *args);
#ifdef __cplusplus
}
#endif
#endif /* CLI_H */

1156
src/db.c Normal file

File diff suppressed because it is too large Load Diff

275
src/db.h Normal file
View File

@@ -0,0 +1,275 @@
/*
* db.h — SQLite storage for sovereign_browser
*
* Provides persistent local storage for Nostr events and misc data.
* The database lives at ~/.sovereign_browser/browser.db.
*
* Tables:
* events — Nostr events (id, pubkey, kind, created_at, content, sig, raw_json)
* event_tags — Tag rows for querying by tag name/value
* key_value — Simple key-value store for misc settings/cache
*
* Thread safety: the database is opened with SQLITE_OPEN_FULLMUTEX, so
* calls from multiple threads are safe (serialized by SQLite's mutex).
*/
#ifndef DB_H
#define DB_H
#include <glib.h>
/* cJSON is in the vendored nostr_core_lib */
#include "../nostr_core_lib/cjson/cJSON.h"
#ifdef __cplusplus
extern "C" {
#endif
/*
* Initialize the database at ~/.sovereign_browser/browser.db.
* Creates tables and indexes if they don't exist.
* Kept for compatibility; prefer db_init_with_path() for per-user dbs.
*
* Returns 0 on success, -1 on error.
*/
int db_init(void);
/*
* 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);
/* ── Events ────────────────────────────────────────────────────────── */
/*
* Store a Nostr event (upsert — replaces if same event_id exists).
* Parses the cJSON event and stores it in the events + event_tags tables.
*
* event — a cJSON object representing a Nostr event with at least:
* id, pubkey, kind, created_at, content, sig, tags
*
* Returns 0 on success, -1 on error.
*/
int db_store_event(const cJSON *event);
/*
* Fetch the most recent event of a given kind for a pubkey.
*
* Returns a newly allocated cJSON event (parsed from raw_json), or NULL
* if not found. Caller must cJSON_Delete() the result.
*/
cJSON *db_get_latest_event(const char *pubkey_hex, int kind);
/*
* Fetch all events of a given kind for a pubkey, newest first.
*
* 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(const char *pubkey_hex, int kind, int limit);
/*
* Count events of a given kind for a pubkey.
* Returns the count, or -1 on error.
*/
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) 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.
* The pointer is valid until the next db_kv_get() call (uses a static
* buffer). Call db_kv_get_copy() if you need a persistent copy.
*/
const char *db_kv_get(const char *key);
/*
* Get a value by key, returning a newly allocated copy.
* Caller must g_free() the result. Returns NULL if not found.
*/
char *db_kv_get_copy(const char *key);
/* ── History ───────────────────────────────────────────────────────── */
/*
* Add a URL to the history (UPSERT — increments visit_count and updates
* visited_at if the URL already exists).
* url — the URL to record
* title — optional page title (NULL or "" for none)
* Returns 0 on success, -1 on error.
*/
int db_history_add(const char *url, const char *title);
/*
* Get recent history entries (most-recent-first).
* limit — max number of entries (0 = default 50)
*
* Fills urls_out and titles_out with parallel arrays of newly allocated
* strings. Caller must free each string and the arrays themselves.
* Returns the number of entries, or -1 on error.
*/
int db_history_get(char ***urls_out, char ***titles_out,
int *count_out, int limit);
/*
* Clear all history entries.
* Returns 0 on success, -1 on error.
*/
int db_history_clear(void);
/*
* Search history entries by URL or title substring.
* query — substring to search for (case-insensitive)
* limit — max number of entries (0 = default 10)
*
* Results are ranked by visit_count DESC, then visited_at DESC — so
* frequently-visited and recently-visited sites appear first.
*
* Fills urls_out and titles_out with parallel arrays of newly allocated
* strings. Caller must free each string and the arrays themselves.
* Returns the number of entries, or -1 on error.
*/
int db_history_search(const char *query,
char ***urls_out, char ***titles_out,
int *count_out, int limit);
/* ── Session ───────────────────────────────────────────────────────── */
/*
* Save the current session (open tab URLs) to the database.
* Clears the session table first, then inserts the given URLs in order.
* urls — array of URL strings
* titles — array of title strings (can be NULL for no titles)
* count — number of tabs
* Returns 0 on success, -1 on error.
*/
int db_session_save(const char **urls, const char **titles, int count);
/*
* Load the saved session from the database.
* Fills urls_out and titles_out with parallel arrays (tab_index order).
* Caller must free each string and the arrays.
* Returns the number of tabs, or -1 on error / no session.
*/
int db_session_load(char ***urls_out, char ***titles_out, int *count_out);
/*
* Clear the saved session.
* Returns 0 on success, -1 on error.
*/
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
#endif /* DB_H */

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

@@ -1,160 +1,114 @@
/*
* history.c — URL history tracking for sovereign_browser
*
* History is stored in the SQLite database (history table) with timestamps
* and visit counts. No flat file, no entry cap.
*/
#include "history.h"
#include "db.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <errno.h>
#include <unistd.h>
#include <stdlib.h>
/* ── In-memory history ─────────────────────────────────────────── */
static char g_history[HISTORY_MAX_ENTRIES][HISTORY_URL_MAX_LEN];
static int g_history_count = 0;
/* ── Path helper ───────────────────────────────────────────────── */
static int history_path(char *out, size_t out_sz) {
const char *home = getenv("HOME");
if (home == NULL || home[0] == '\0') {
return -1;
}
char dir[512];
int n = snprintf(dir, sizeof(dir), "%s/.sovereign_browser", home);
if (n < 0 || (size_t)n >= sizeof(dir)) {
return -1;
}
if (mkdir(dir, 0700) != 0 && errno != EEXIST) {
return -1;
}
n = snprintf(out, out_sz, "%s/history.txt", dir);
if (n < 0 || (size_t)n >= out_sz) {
return -1;
}
return 0;
}
/* ── Persistence ───────────────────────────────────────────────── */
void history_load(void) {
char path[512];
if (history_path(path, sizeof(path)) != 0) {
return;
}
FILE *f = fopen(path, "r");
if (f == NULL) {
return;
}
g_history_count = 0;
char line[HISTORY_URL_MAX_LEN];
while (g_history_count < HISTORY_MAX_ENTRIES &&
fgets(line, sizeof(line), f) != NULL) {
/* Strip newline. */
size_t len = strlen(line);
if (len > 0 && line[len - 1] == '\n') {
line[len - 1] = '\0';
len--;
}
if (len > 0) {
snprintf(g_history[g_history_count], HISTORY_URL_MAX_LEN, "%s", line);
g_history_count++;
}
}
fclose(f);
}
static void history_save(void) {
char path[512];
if (history_path(path, sizeof(path)) != 0) {
return;
}
FILE *f = fopen(path, "w");
if (f == NULL) {
return;
}
fchmod(fileno(f), 0600);
/* Write in reverse order (most recent first) — same as in-memory order. */
for (int i = 0; i < g_history_count; i++) {
fprintf(f, "%s\n", g_history[i]);
}
fclose(f);
}
/* ── API ───────────────────────────────────────────────────────── */
/* ── Public API ────────────────────────────────────────────────────── */
void history_add(const char *url) {
if (url == NULL || url[0] == '\0') {
history_add_titled(url, NULL);
}
void history_add_titled(const char *url, const char *title) {
if (url == NULL || url[0] == '\0') return;
/* Skip sovereign:// API endpoints (not pages the user would want
* in their recents). The actual pages — sovereign://settings,
* sovereign://profile, sovereign://bookmarks, sovereign://security —
* are kept in history so the user can quickly return to them. */
if (strncmp(url, "sovereign://settings/set", 24) == 0 ||
strncmp(url, "sovereign://bookmarks/add", 25) == 0 ||
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;
}
/* Skip sovereign:// internal pages. */
if (strncmp(url, "sovereign://", 12) == 0) {
return;
}
/* Check if the URL is already in the history. */
int found_idx = -1;
for (int i = 0; i < g_history_count; i++) {
if (strcmp(g_history[i], url) == 0) {
found_idx = i;
break;
}
}
if (found_idx >= 0) {
/* URL already exists — move it to the top. */
if (found_idx == 0) {
return; /* already at the top, nothing to do */
}
/* Shift items 0..found_idx-1 down by one. */
char tmp[HISTORY_URL_MAX_LEN];
snprintf(tmp, HISTORY_URL_MAX_LEN, "%s", g_history[found_idx]);
for (int i = found_idx; i > 0; i--) {
snprintf(g_history[i], HISTORY_URL_MAX_LEN, "%s", g_history[i - 1]);
}
snprintf(g_history[0], HISTORY_URL_MAX_LEN, "%s", tmp);
} else {
/* New URL — shift everything down by one to make room at the top. */
if (g_history_count >= HISTORY_MAX_ENTRIES) {
g_history_count = HISTORY_MAX_ENTRIES - 1;
}
for (int i = g_history_count; i > 0; i--) {
snprintf(g_history[i], HISTORY_URL_MAX_LEN, "%s", g_history[i - 1]);
}
snprintf(g_history[0], HISTORY_URL_MAX_LEN, "%s", url);
g_history_count++;
}
history_save();
db_history_add(url, title);
}
int history_count(void) {
return g_history_count;
/* Query the count from the database via db_history_get.
* For the Recents submenu we only show 20, but we need the
* total count to decide whether to show the submenu. */
char **urls = NULL;
int count = 0;
int rc = db_history_get(&urls, NULL, &count, 10000);
if (rc < 0) return 0;
if (urls) {
for (int i = 0; i < count; i++) g_free(urls[i]);
g_free(urls);
}
return count;
}
const char *history_get(int index) {
if (index < 0 || index >= g_history_count) {
char *history_get(int index) {
if (index < 0) return NULL;
char **urls = NULL;
int count = 0;
db_history_get(&urls, NULL, &count, index + 1);
if (urls == NULL || count <= index) {
if (urls) {
for (int i = 0; i < count; i++) g_free(urls[i]);
g_free(urls);
}
return NULL;
}
return g_history[index];
char *result = urls[index];
/* Free the other entries. */
for (int i = 0; i < count; i++) {
if (i != index) g_free(urls[i]);
}
g_free(urls);
return result;
}
char *history_get_title(int index) {
if (index < 0) return NULL;
char **urls = NULL;
char **titles = NULL;
int count = 0;
db_history_get(&urls, &titles, &count, index + 1);
if (urls == NULL || count <= index) {
if (urls) {
for (int i = 0; i < count; i++) g_free(urls[i]);
g_free(urls);
}
if (titles) {
for (int i = 0; i < count; i++) g_free(titles[i]);
g_free(titles);
}
return NULL;
}
char *result = titles[index];
/* Free everything except the result. */
for (int i = 0; i < count; i++) {
if (i != index) g_free(titles[i]);
g_free(urls[i]);
}
g_free(urls);
g_free(titles);
return result;
}
void history_clear(void) {
g_history_count = 0;
char path[512];
if (history_path(path, sizeof(path)) == 0) {
unlink(path);
}
db_history_clear();
g_print("[history] cleared\n");
}

View File

@@ -1,8 +1,9 @@
/*
* history.h — URL history tracking for sovereign_browser
*
* Keeps a list of recently visited URLs (most recent first) and
* provides them for the History submenu.
* History is stored in the SQLite database (history table) with timestamps
* and visit counts. There is no entry cap — the database handles unlimited
* entries. The Recents submenu shows the most recent 20.
*/
#ifndef HISTORY_H
@@ -14,35 +15,37 @@
extern "C" {
#endif
#define HISTORY_MAX_ENTRIES 50
#define HISTORY_URL_MAX_LEN 2048
/*
* Add a URL to the history. If the URL is already the most recent
* entry, it's not duplicated. The list is capped at HISTORY_MAX_ENTRIES.
* Also persists to ~/.sovereign_browser/history.txt.
* Add a URL to the history. UPSERT — if the URL already exists,
* increments visit_count and updates visited_at. The title is optional
* (NULL or "" for no title).
*/
void history_add(const char *url);
void history_add_titled(const char *url, const char *title);
/*
* Get the number of history entries.
* Get the number of history entries available (queries the database).
* Returns the count, or -1 on error.
*/
int history_count(void);
/*
* Get a history entry by index (0 = most recent).
* Returns NULL if index is out of range.
* Get a history URL by index (0 = most recent).
* Returns a newly allocated string (caller must g_free), or NULL if
* index is out of range.
*/
const char *history_get(int index);
char *history_get(int index);
/*
* Load history from ~/.sovereign_browser/history.txt.
* Called once at startup.
* Get the title for a history entry by index.
* Returns a newly allocated string, or NULL if no title / out of range.
*/
void history_load(void);
char *history_get_title(int index);
/*
* Clear all history entries and delete the file.
* Clear all history entries.
*/
void history_clear(void);

View File

@@ -1,209 +1,36 @@
/*
* key_store.c — Nostr identity persistence for sovereign_browser
* key_store.c — Nostr identity (in-memory only) for sovereign_browser
*
* Private keys are NEVER persisted to disk. They live only in RAM for
* the duration of the session and are gone when the browser quits.
*
* This module provides:
* - key_store_create_signer() — create a nostr_signer_t from an
* 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>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <errno.h>
#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"
/* ── Path helpers ─────────────────────────────────────────────── */
int key_store_path(char *out, size_t out_sz) {
const char *home = getenv("HOME");
if (home == NULL || home[0] == '\0') {
return -1;
}
/* Ensure ~/.sovereign_browser/ exists. */
char dir[512];
int n = snprintf(dir, sizeof(dir), "%s/.sovereign_browser", home);
if (n < 0 || (size_t)n >= sizeof(dir)) {
return -1;
}
if (mkdir(dir, 0700) != 0 && errno != EEXIST) {
return -1;
}
n = snprintf(out, out_sz, "%s/identity.json", dir);
if (n < 0 || (size_t)n >= out_sz) {
return -1;
}
return 0;
}
/* ── Save / load ──────────────────────────────────────────────── */
static const char *method_to_string(key_store_method_t m) {
switch (m) {
case KEY_STORE_METHOD_LOCAL: return "local";
case KEY_STORE_METHOD_SEED: return "seed";
case KEY_STORE_METHOD_READONLY: return "readonly";
case KEY_STORE_METHOD_NIP46: return "nip46";
case KEY_STORE_METHOD_NSIGNER: return "nsigner";
default: return "none";
}
}
static key_store_method_t string_to_method(const char *s) {
if (!s) return KEY_STORE_METHOD_NONE;
if (strcmp(s, "local") == 0) return KEY_STORE_METHOD_LOCAL;
if (strcmp(s, "seed") == 0) return KEY_STORE_METHOD_SEED;
if (strcmp(s, "readonly") == 0) return KEY_STORE_METHOD_READONLY;
if (strcmp(s, "nip46") == 0) return KEY_STORE_METHOD_NIP46;
if (strcmp(s, "nsigner") == 0) return KEY_STORE_METHOD_NSIGNER;
return KEY_STORE_METHOD_NONE;
}
int key_store_save(const key_store_identity_t *identity) {
char path[512];
if (key_store_path(path, sizeof(path)) != 0) {
return -1;
}
FILE *f = fopen(path, "w");
if (f == NULL) {
return -1;
}
/* Write with restrictive permissions. */
fchmod(fileno(f), 0600);
fprintf(f, "{\n");
fprintf(f, " \"method\": \"%s\",\n", method_to_string(identity->method));
fprintf(f, " \"pubkey_hex\": \"%s\"", identity->pubkey_hex);
if (identity->method == KEY_STORE_METHOD_LOCAL ||
identity->method == KEY_STORE_METHOD_SEED) {
fprintf(f, ",\n \"privkey_hex\": \"%s\"", identity->privkey_hex);
}
if (identity->method == KEY_STORE_METHOD_SEED && identity->mnemonic[0]) {
fprintf(f, ",\n \"mnemonic\": \"%s\"", identity->mnemonic);
}
if (identity->method == KEY_STORE_METHOD_NIP46 && identity->bunker_url[0]) {
fprintf(f, ",\n \"bunker_url\": \"%s\"", identity->bunker_url);
}
if (identity->method == KEY_STORE_METHOD_NSIGNER) {
fprintf(f, ",\n \"nsigner_transport\": \"%s\"", identity->nsigner_transport);
fprintf(f, ",\n \"nsigner_device\": \"%s\"", identity->nsigner_device);
fprintf(f, ",\n \"nsigner_index\": %d", identity->nsigner_index);
}
fprintf(f, "\n}\n");
fclose(f);
return 0;
}
/* Minimal JSON string field extractor (no nested objects, good enough for our flat schema). */
static int json_extract_string(const char *json, const char *key, char *out, size_t out_sz) {
char pattern[64];
snprintf(pattern, sizeof(pattern), "\"%s\"", key);
const char *p = strstr(json, pattern);
if (p == NULL) return -1;
p += strlen(pattern);
/* Skip whitespace and colon. */
while (*p && (*p == ' ' || *p == '\t' || *p == ':' || *p == '\n' || *p == '\r')) {
p++;
}
if (*p != '"') return -1;
p++;
size_t i = 0;
while (*p && *p != '"' && i < out_sz - 1) {
if (*p == '\\' && p[1]) {
p++;
}
out[i++] = *p++;
}
out[i] = '\0';
return (i > 0 || *p == '"') ? 0 : -1;
}
static int json_extract_int(const char *json, const char *key, int *out) {
char pattern[64];
snprintf(pattern, sizeof(pattern), "\"%s\"", key);
const char *p = strstr(json, pattern);
if (p == NULL) return -1;
p += strlen(pattern);
while (*p && (*p == ' ' || *p == '\t' || *p == ':' || *p == '\n' || *p == '\r')) {
p++;
}
char *end;
long val = strtol(p, &end, 10);
if (end == p) return -1;
*out = (int)val;
return 0;
}
int key_store_load(key_store_identity_t *out) {
memset(out, 0, sizeof(*out));
char path[512];
if (key_store_path(path, sizeof(path)) != 0) {
return -1;
}
FILE *f = fopen(path, "r");
if (f == NULL) {
if (errno == ENOENT) return 1; /* no identity file */
return -1;
}
/* Read the whole file (small). */
char buf[4096];
size_t n = fread(buf, 1, sizeof(buf) - 1, f);
fclose(f);
buf[n] = '\0';
char method_str[32];
if (json_extract_string(buf, "method", method_str, sizeof(method_str)) != 0) {
return -1;
}
out->method = string_to_method(method_str);
if (out->method == KEY_STORE_METHOD_NONE) {
return -1;
}
if (json_extract_string(buf, "pubkey_hex", out->pubkey_hex, sizeof(out->pubkey_hex)) != 0) {
return -1;
}
json_extract_string(buf, "privkey_hex", out->privkey_hex, sizeof(out->privkey_hex));
json_extract_string(buf, "mnemonic", out->mnemonic, sizeof(out->mnemonic));
json_extract_string(buf, "bunker_url", out->bunker_url, sizeof(out->bunker_url));
json_extract_string(buf, "nsigner_transport", out->nsigner_transport, sizeof(out->nsigner_transport));
json_extract_string(buf, "nsigner_device", out->nsigner_device, sizeof(out->nsigner_device));
json_extract_int(buf, "nsigner_index", &out->nsigner_index);
return 0;
}
int key_store_clear(void) {
char path[512];
if (key_store_path(path, sizeof(path)) != 0) {
return -1;
}
if (unlink(path) != 0 && errno != ENOENT) {
return -1;
}
return 0;
}
/* ── Signer creation ──────────────────────────────────────────── */
/* ── Signer creation ──────────────────────────────────────────────── */
nostr_signer_t *key_store_create_signer(const key_store_identity_t *identity) {
if (identity == NULL) {
@@ -257,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;
@@ -267,3 +100,152 @@ nostr_signer_t *key_store_create_signer(const key_store_identity_t *identity) {
return NULL;
}
}
/* ── 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) {
const char *home = getenv("HOME");
if (home == NULL || home[0] == '\0') {
return -1;
}
char path[512];
int n = snprintf(path, sizeof(path), "%s/.sovereign_browser/identity.json",
home);
if (n < 0 || (size_t)n >= sizeof(path)) {
return -1;
}
/* Delete the file if it exists. ENOENT is not an error. */
if (unlink(path) != 0 && errno != ENOENT) {
return -1;
}
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

@@ -1,8 +1,13 @@
/*
* key_store.h — Nostr identity persistence for sovereign_browser
* key_store.h — Nostr identity (in-memory only) for sovereign_browser
*
* Saves/loads the user's Nostr identity to ~/.sovereign_browser/identity.json
* so the browser can restore the signer on startup without re-prompting.
* IMPORTANT: Private keys are NEVER persisted to disk. They live only in
* working memory (RAM) for the duration of the session and are gone when
* the browser quits. The user must sign in again on each launch.
*
* The key_store_identity_t struct is populated by the login dialog or CLI
* login, held in memory, and used to create a nostr_signer_t. It is not
* written to any file.
*/
#ifndef KEY_STORE_H
@@ -18,14 +23,14 @@ extern "C" {
/* Login methods supported by the browser. */
typedef enum {
KEY_STORE_METHOD_NONE = 0,
KEY_STORE_METHOD_LOCAL, /* nsec — private key stored (encrypted later) */
KEY_STORE_METHOD_LOCAL, /* nsec — private key in memory only */
KEY_STORE_METHOD_SEED, /* BIP-39 mnemonic — private key re-derived */
KEY_STORE_METHOD_READONLY, /* npub only — no signing */
KEY_STORE_METHOD_NIP46, /* bunker:// URL — remote signer session */
KEY_STORE_METHOD_NSIGNER /* n_signer hardware — device path + index */
} key_store_method_t;
/* Identity record persisted to disk. */
/* Identity record (in-memory only — never persisted). */
typedef struct {
key_store_method_t method;
@@ -33,10 +38,10 @@ typedef struct {
char pubkey_hex[65];
/* Hex private key (local + seed methods only; 64 chars + NUL).
* TODO: encrypt at rest with AES-256-GCM + user password (Phase 5). */
* Held in memory only — never written to disk. */
char privkey_hex[65];
/* BIP-39 mnemonic (seed method only). */
/* BIP-39 mnemonic (seed method only). In memory only. */
char mnemonic[256];
/* NIP-46 bunker URL (nip46 method only). */
@@ -49,32 +54,7 @@ typedef struct {
} key_store_identity_t;
/*
* Get the path to the identity file (~/.sovereign_browser/identity.json).
* Creates the directory if it doesn't exist.
* Returns 0 on success, -1 on error.
*/
int key_store_path(char *out, size_t out_sz);
/*
* Save an identity to disk.
* Returns 0 on success, -1 on error.
*/
int key_store_save(const key_store_identity_t *identity);
/*
* Load an identity from disk.
* Returns 0 on success, 1 if no identity file exists, -1 on error.
*/
int key_store_load(key_store_identity_t *out);
/*
* Delete the identity file (logout).
* Returns 0 on success, -1 on error.
*/
int key_store_clear(void);
/*
* Create a nostr_signer_t from a loaded identity.
* Create a nostr_signer_t from an in-memory identity.
* For readonly: returns NULL (caller should use pubkey_hex directly).
* For nsigner: creates the appropriate transport-backed signer.
* Caller must free the returned signer with nostr_signer_free().
@@ -82,6 +62,49 @@ int key_store_clear(void);
*/
nostr_signer_t *key_store_create_signer(const key_store_identity_t *identity);
/*
* Defensively delete any legacy identity.json file from a previous
* version that persisted keys to disk. Call once at startup.
* Returns 0 on success (including if the file doesn't exist), -1 on error.
*/
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

@@ -11,6 +11,8 @@
#include "login_dialog.h"
#include "key_store.h"
#include "agent_login.h"
#include "version.h"
#include <string.h>
#include <stdlib.h>
@@ -78,6 +80,7 @@ typedef struct {
GtkWidget *status_label; /* error/status display */
login_result_t *result; /* output */
gboolean done; /* dialog completed */
gboolean no_login; /* user chose "No Login" (browse without identity) */
} login_ctx_t;
/* Helper: get the method name of the currently selected tab. */
@@ -109,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
@@ -510,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),
@@ -522,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;
}
@@ -577,6 +603,18 @@ static void on_login_clicked(GtkWidget *btn, gpointer user_data) {
gtk_label_set_text(GTK_LABEL(ctx->status_label), "Unknown method.");
}
/* "No Login" — browse without a Nostr identity. The browser works
* normally; window.nostr just won't be available for sign requests. */
static void on_no_login_clicked(GtkWidget *btn, gpointer user_data) {
(void)btn;
login_ctx_t *ctx = (login_ctx_t *)user_data;
ctx->done = TRUE;
ctx->no_login = TRUE;
/* Return success with an empty result (method=NONE, no signer). */
memset(ctx->result, 0, sizeof(*ctx->result));
gtk_dialog_response(GTK_DIALOG(ctx->dialog), GTK_RESPONSE_ACCEPT);
}
static void on_cancel_clicked(GtkWidget *btn, gpointer user_data) {
(void)btn;
login_ctx_t *ctx = (login_ctx_t *)user_data;
@@ -732,7 +770,7 @@ static void on_transport_changed(GtkComboBox *combo, gpointer user_data) {
gtk_widget_hide(enum_btn);
gtk_widget_hide(service_box);
break;
case 3: /* Qubes qrexec */
case 3: /* Other Qube (Qubes qrexec) */
gtk_label_set_text(GTK_LABEL(device_label), "Target Qube:");
gtk_entry_set_placeholder_text(GTK_ENTRY(device_entry), "nostr_signer");
/* Set default value so the user doesn't need to type it. */
@@ -954,21 +992,26 @@ static GtkWidget *create_nsigner_screen(login_ctx_t *ctx) {
gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(transport_combo), "USB Serial");
gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(transport_combo), "UNIX Socket");
gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(transport_combo), "TCP");
gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(transport_combo), "Qubes qrexec");
gtk_combo_box_set_active(GTK_COMBO_BOX(transport_combo), 0);
gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(transport_combo), "Other Qube");
gtk_combo_box_set_active(GTK_COMBO_BOX(transport_combo), 3);
gtk_box_pack_start(GTK_BOX(transport_box), transport_combo, FALSE, FALSE, 0);
/* Device path entry — label and placeholder adapt to transport type. */
GtkWidget *device_label = gtk_label_new("Device Path:");
/* Device path entry — label and placeholder adapt to transport type.
* Default transport is "Other Qube" (qrexec), so initial UI reflects
* that. The on_transport_changed callback updates these when the user
* switches transport. */
GtkWidget *device_label = gtk_label_new("Target Qube:");
gtk_widget_set_halign(device_label, GTK_ALIGN_START);
gtk_box_pack_start(GTK_BOX(box), device_label, FALSE, FALSE, 0);
GtkWidget *device_entry = gtk_entry_new();
gtk_entry_set_placeholder_text(GTK_ENTRY(device_entry), "/dev/ttyACM0");
gtk_entry_set_text(GTK_ENTRY(device_entry), "nostr_signer");
gtk_entry_set_placeholder_text(GTK_ENTRY(device_entry), "nostr_signer");
gtk_entry_set_width_chars(GTK_ENTRY(device_entry), 40);
gtk_box_pack_start(GTK_BOX(box), device_entry, FALSE, FALSE, 0);
/* Enumerate serial devices button (only shown for serial transport). */
/* Enumerate serial devices button (only shown for serial transport).
* Hidden by default since qrexec is the default transport. */
GtkWidget *enum_btn = gtk_button_new_with_label("Detect Serial Devices");
#if defined(NOSTR_ENABLE_NSIGNER_CLIENT)
g_signal_connect(enum_btn, "clicked", G_CALLBACK(on_detect_serial), device_entry);
@@ -976,6 +1019,7 @@ static GtkWidget *create_nsigner_screen(login_ctx_t *ctx) {
gtk_widget_set_sensitive(enum_btn, FALSE);
#endif
gtk_box_pack_start(GTK_BOX(box), enum_btn, FALSE, FALSE, 0);
gtk_widget_hide(enum_btn);
/* Service name field (only shown for qrexec transport). */
GtkWidget *service_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
@@ -989,24 +1033,31 @@ static GtkWidget *create_nsigner_screen(login_ctx_t *ctx) {
gtk_entry_set_width_chars(GTK_ENTRY(service_entry), 30);
gtk_box_pack_start(GTK_BOX(service_box), service_entry, FALSE, FALSE, 0);
/* Initially hide the service field (serial is the default transport). */
gtk_widget_hide(service_box);
/* Show the service field by default (qrexec is the default transport). */
gtk_widget_show_all(service_box);
/* Connect transport change callback to update the UI dynamically. */
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);
@@ -1020,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)
@@ -1047,6 +1112,18 @@ static void on_detect_serial(GtkWidget *btn, gpointer user_data) {
/* ── Main dialog ──────────────────────────────────────────────── */
/* Timeout callback to check if the agent has logged in.
* If so, close the dialog automatically. */
static gboolean agent_login_check(gpointer data) {
GtkDialog *dialog = GTK_DIALOG(data);
if (agent_login_was_performed_by_agent()) {
g_print("[login] Agent login detected, closing dialog.\n");
gtk_dialog_response(dialog, GTK_RESPONSE_ACCEPT);
return G_SOURCE_REMOVE;
}
return G_SOURCE_CONTINUE;
}
int login_dialog_run(GtkWindow *parent, login_result_t *result) {
memset(result, 0, sizeof(*result));
@@ -1060,17 +1137,25 @@ 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);
/* Add custom buttons to the action area. */
/* Add custom buttons to the action area.
* Layout (left to right): No Login | Cancel | Sign In
* "No Login" is on the far left so the primary action (Sign In)
* remains on the right (conventional GTK button ordering). */
GtkWidget *action_area = gtk_dialog_get_action_area(GTK_DIALOG(dialog));
GtkWidget *cancel_btn = gtk_button_new_with_label("_Cancel");
GtkWidget *login_btn = gtk_button_new_with_label("_Sign In");
GtkWidget *no_login_btn = gtk_button_new_with_label("No Login");
GtkWidget *cancel_btn = gtk_button_new_with_label("Cancel");
GtkWidget *login_btn = gtk_button_new_with_label("Sign In");
gtk_box_pack_start(GTK_BOX(action_area), no_login_btn, FALSE, FALSE, 0);
gtk_box_pack_start(GTK_BOX(action_area), cancel_btn, FALSE, FALSE, 0);
gtk_box_pack_start(GTK_BOX(action_area), login_btn, FALSE, FALSE, 0);
gtk_widget_set_tooltip_text(no_login_btn,
"Browse without a Nostr identity. The browser works normally, "
"but window.nostr won't be available for signing.");
/* Apply monospace font styling to the dialog. */
GtkCssProvider *css = gtk_css_provider_new();
@@ -1134,15 +1219,37 @@ int login_dialog_run(GtkWindow *parent, login_result_t *result) {
/* Wire the custom buttons — these don't auto-respond, so the dialog
* stays open unless we explicitly call gtk_dialog_response. */
g_signal_connect(no_login_btn, "clicked", G_CALLBACK(on_no_login_clicked), &ctx);
g_signal_connect(cancel_btn, "clicked", G_CALLBACK(on_cancel_clicked), &ctx);
g_signal_connect(login_btn, "clicked", G_CALLBACK(on_login_clicked), &ctx);
g_object_set_data(G_OBJECT(dialog), "login-btn", login_btn);
g_object_set_data(G_OBJECT(dialog), "cancel-btn", cancel_btn);
g_object_set_data(G_OBJECT(dialog), "no-login-btn", no_login_btn);
gtk_widget_show_all(dialog);
/* Add a timeout to check if the agent has logged in. If so,
* close the dialog automatically (every 200ms). */
guint agent_check_id = g_timeout_add(200, agent_login_check, dialog);
gint response = gtk_dialog_run(GTK_DIALOG(dialog));
/* Remove the timeout if it's still active. */
g_source_remove(agent_check_id);
/* If the agent logged in while the dialog was showing, return 0
* (success) but with an empty result — main.c will use the agent's
* login state instead. */
if (agent_login_was_performed_by_agent()) {
if (result->signer) {
nostr_signer_free(result->signer);
result->signer = NULL;
}
memset(result, 0, sizeof(*result));
gtk_widget_destroy(dialog);
return 0;
}
if (!ctx.done || response != GTK_RESPONSE_ACCEPT) {
if (result->signer) {
nostr_signer_free(result->signer);

1551
src/main.c

File diff suppressed because it is too large Load Diff

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

@@ -27,7 +27,6 @@ static const char *NOSTR_SHIM_JS =
"\n"
" function bridgeCall(method, bodyObj) {\n"
" var url = BRIDGE_BASE + method;\n"
" var opts = {};\n"
"\n"
" if (bodyObj !== undefined && bodyObj !== null) {\n"
" /* Encode the body as a query parameter since WebKitGTK's custom\n"
@@ -36,20 +35,37 @@ static const char *NOSTR_SHIM_JS =
" url += '?body=' + encodeURIComponent(bodyJson);\n"
" }\n"
"\n"
" return fetch(url).then(function(resp) {\n"
" return resp.text();\n"
" }).then(function(text) {\n"
" var data;\n"
" /* Use synchronous XMLHttpRequest — WebKitGTK's fetch() and async XHR\n"
" * enforce CORS on custom schemes even when registered as secure, but\n"
" * synchronous XHR to a secure custom scheme works without CORS headers.\n"
" * The bridge is local (sovereign:// URI scheme handler in C), so the\n"
" * call is effectively instant. */\n"
" return new Promise(function(resolve, reject) {\n"
" try {\n"
" data = JSON.parse(text);\n"
" var xhr = new XMLHttpRequest();\n"
" xhr.open('GET', url, false); /* synchronous */\n"
" xhr.send();\n"
" var text = xhr.responseText;\n"
" if (!text) {\n"
" reject(new Error('Bridge returned empty response'));\n"
" return;\n"
" }\n"
" var data;\n"
" try {\n"
" data = JSON.parse(text);\n"
" } catch(e) {\n"
" reject(new Error('Bridge returned invalid JSON: ' + text));\n"
" return;\n"
" }\n"
" if (data.error !== undefined) {\n"
" var msg = data.message || ('Error code ' + data.error);\n"
" reject(new Error(msg));\n"
" return;\n"
" }\n"
" resolve(data);\n"
" } catch(e) {\n"
" throw new Error('Bridge returned invalid JSON: ' + text);\n"
" reject(new Error('Bridge request failed: ' + e.message));\n"
" }\n"
" if (data.error !== undefined) {\n"
" var msg = data.message || ('Error code ' + data.error);\n"
" throw new Error(msg);\n"
" }\n"
" return data;\n"
" });\n"
" }\n"
"\n"
@@ -112,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);
@@ -132,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 */

122
src/qr.c Normal file
View File

@@ -0,0 +1,122 @@
/*
* qr.c — QR code rendering wrapper around libqrencode
*
* Encodes text via QRcode_encodeString() and renders the module matrix
* into an RGBA pixel buffer, then wraps it in a GdkPixbuf. The pixbuf
* can be saved to PNG in memory (qr_render_png) or used directly by GTK
* widgets (qr_render_pixbuf).
*
* The QRcode->data[] layout is row-major, one byte per module, with the
* least-significant bit indicating a dark module (1 = dark, 0 = light).
* See <qrencode.h> for details.
*/
#include "qr.h"
#include <qrencode.h>
#include <string.h>
#include <stdlib.h>
/* ── Internal: render QRcode modules into a GdkPixbuf ──────────────── */
static GdkPixbuf *qr_code_to_pixbuf(const QRcode *code, int box_size,
int border) {
if (code == NULL || code->data == NULL || code->width <= 0) {
return NULL;
}
if (box_size < 1) box_size = 1;
if (border < 0) border = 0;
int modules = code->width; /* width == height for QR codes */
int dim = (modules + 2 * border) * box_size;
/* Allocate an RGBA buffer (gdk-pixbuf uses 8 bits/channel, 4 channels
* when an alpha channel is present; we use RGB to keep it simple and
* smaller). */
GdkPixbuf *pb = gdk_pixbuf_new(GDK_COLORSPACE_RGB, FALSE /* no alpha */,
8, dim, dim);
if (pb == NULL) return NULL;
int rowstride = gdk_pixbuf_get_rowstride(pb);
int n_channels = gdk_pixbuf_get_n_channels(pb);
guchar *pixels = gdk_pixbuf_get_pixels(pb);
/* Fill with white (the quiet zone + light modules). */
for (int y = 0; y < dim; y++) {
guchar *row = pixels + y * rowstride;
for (int x = 0; x < dim; x++) {
row[x * n_channels + 0] = 255; /* R */
row[x * n_channels + 1] = 255; /* G */
row[x * n_channels + 2] = 255; /* B */
}
}
/* Paint dark modules. QRcode->data is row-major: data[y * width + x],
* and bit 0 (LSB) of each byte is the module color (1 = dark). */
for (int my = 0; my < modules; my++) {
for (int mx = 0; mx < modules; mx++) {
unsigned char b = code->data[my * code->width + mx];
if (b & 1) {
/* Dark module — paint the box_size×box_size block. */
int px0 = (mx + border) * box_size;
int py0 = (my + border) * box_size;
for (int dy = 0; dy < box_size; dy++) {
guchar *row = pixels + (py0 + dy) * rowstride;
for (int dx = 0; dx < box_size; dx++) {
int idx = (px0 + dx) * n_channels;
row[idx + 0] = 0; /* R */
row[idx + 1] = 0; /* G */
row[idx + 2] = 0; /* B */
}
}
}
}
}
return pb;
}
/* ── Public API ────────────────────────────────────────────────────── */
GdkPixbuf *qr_render_pixbuf(const char *text, int box_size, int border) {
if (text == NULL || text[0] == '\0') return NULL;
/* version=0 → auto-select the smallest version that fits.
* level=QR_ECLEVEL_M → medium error correction (good default for
* identity/URI codes that might be scanned at an angle or off-screen).
* hint=QR_MODE_8 → treat input as 8-bit data (handles URIs, npubs, etc.).
* casesensitive=1 → don't fold to uppercase. */
QRcode *code = QRcode_encodeString(text, 0, QR_ECLEVEL_M,
QR_MODE_8, 1);
if (code == NULL) {
return NULL;
}
GdkPixbuf *pb = qr_code_to_pixbuf(code, box_size, border);
QRcode_free(code);
return pb;
}
GBytes *qr_render_png(const char *text, int box_size, int border) {
GdkPixbuf *pb = qr_render_pixbuf(text, box_size, border);
if (pb == NULL) return NULL;
gchar *buffer = NULL;
gsize size = 0;
GError *err = NULL;
if (!gdk_pixbuf_save_to_bufferv(pb, &buffer, &size, "png",
NULL, NULL, &err)) {
g_printerr("[qr] Failed to encode PNG: %s\n",
err ? err->message : "(unknown)");
if (err) g_error_free(err);
g_object_unref(pb);
return NULL;
}
g_object_unref(pb);
/* g_bytes_new_take takes ownership of buffer (g_free). */
GBytes *bytes = g_bytes_new_take(buffer, size);
return bytes;
}

49
src/qr.h Normal file
View File

@@ -0,0 +1,49 @@
/*
* qr.h — QR code rendering wrapper around libqrencode
*
* Provides a stable API so the rest of the codebase doesn't include
* <qrencode.h> directly. Renders a QR code for a given text string
* into either an in-memory PNG (GBytes) or a GdkPixbuf (for GTK widgets).
*
* Dependencies: libqrencode (LGPL-2.1+), gdk-pixbuf (via GTK).
*/
#ifndef QR_H
#define QR_H
#include <glib.h>
#include <gdk-pixbuf/gdk-pixbuf.h>
#ifdef __cplusplus
extern "C" {
#endif
/*
* Render text as a QR code and return the PNG image in memory.
*
* text : NUL-terminated string to encode (UTF-8).
* box_size : pixels per QR module (e.g. 8 for a chunky, scannable code).
* border : number of quiet-zone modules around the code (4 is standard).
*
* Returns a newly allocated GBytes containing PNG data, or NULL on failure.
* Caller must unref with g_bytes_unref().
*/
GBytes *qr_render_png(const char *text, int box_size, int border);
/*
* Render text as a QR code and return a GdkPixbuf.
*
* text : NUL-terminated string to encode (UTF-8).
* box_size : pixels per QR module.
* border : number of quiet-zone modules around the code.
*
* Returns a newly allocated GdkPixbuf, or NULL on failure.
* Caller must unref with g_object_unref().
*/
GdkPixbuf *qr_render_pixbuf(const char *text, int box_size, int border);
#ifdef __cplusplus
}
#endif
#endif /* QR_H */

255
src/relay_fetch.c Normal file
View File

@@ -0,0 +1,255 @@
/*
* relay_fetch.c — post-login bootstrap relay fetch for sovereign_browser
*
* Uses synchronous_query_relays_with_progress() from nostr_core_lib to
* query the user's kind 0/3/10002 events from the bootstrap relays, then
* stores them in the SQLite database via db_store_event().
*
* This runs in a background thread (relay_fetch_thread) so the browser
* is usable immediately after login. SQLite is opened with
* SQLITE_OPEN_FULLMUTEX so concurrent access from the main thread is safe.
*/
#include "relay_fetch.h"
#include "db.h"
#include "settings.h"
#include "bookmarks.h"
#include "settings_sync.h"
#include "tab_manager.h"
#include <string.h>
#include <stdlib.h>
#include <glib.h>
#include "nostr_core/nostr_core.h"
#include "../nostr_core_lib/cjson/cJSON.h"
/* Maximum number of relays to parse from the bootstrap_relays setting. */
#define MAX_RELAYS 32
/* Relay query timeout in seconds. */
#define RELAY_TIMEOUT_SECONDS 15
/* 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 ────────────────────────────────────────────────────── */
int relay_fetch_bootstrap(const char *pubkey_hex,
const char **relay_urls,
int relay_count) {
if (pubkey_hex == NULL || pubkey_hex[0] == '\0') {
g_printerr("[relay] No pubkey, skipping bootstrap fetch\n");
return -1;
}
if (relay_urls == NULL || relay_count <= 0) {
g_printerr("[relay] No bootstrap relays configured\n");
return -1;
}
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]} */
cJSON *filter = cJSON_CreateObject();
cJSON *authors = cJSON_CreateArray();
cJSON_AddItemToArray(authors, cJSON_CreateString(pubkey_hex));
cJSON_AddItemToObject(filter, "authors", authors);
cJSON *kinds = cJSON_CreateArray();
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(0));
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(3));
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(10002));
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(30003)); /* bookmark sets */
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(30078)); /* NIP-78 app data */
cJSON_AddItemToObject(filter, "kinds", kinds);
/* 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 = relay_query_synchronized(
relay_urls, relay_count, filter,
RELAY_QUERY_ALL_RESULTS, &result_count,
RELAY_TIMEOUT_SECONDS,
NULL, NULL, /* no progress callback */
0, NULL /* no NIP-42 auth */
);
cJSON_Delete(filter);
if (results == NULL || result_count <= 0) {
g_print("[relay] No events found (relays may be unreachable or "
"the user has no kind 0/3/10002 events)\n");
if (results) free(results);
return 0;
}
/* 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 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;
long kind = 0;
cJSON *k = cJSON_GetObjectItemCaseSensitive(results[i], "kind");
if (cJSON_IsNumber(k)) kind = (long)k->valuedouble;
if (kind == 30003) {
/* bookmarks_store_and_load_event stores in SQLite + decrypts. */
if (bookmarks_store_and_load_event(results[i]) == 0) {
stored++;
}
} 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 the shared "user-settings" event (or the
* legacy "sovereign_browser" event, which is migrated). */
if (db_store_event(results[i]) == 0) {
stored++;
}
settings_sync_merge_from_nostr(results[i]);
} else {
if (db_store_event(results[i]) == 0) {
stored++;
} else {
g_printerr("[relay] Failed to store event %d/%d\n", i + 1,
result_count);
}
}
cJSON_Delete(results[i]);
}
free(results);
g_print("[relay] Fetched %d event(s), stored %d in database\n",
result_count, stored);
/* Refresh the user's avatar now that the kind 0 profile may be
* available in the database. This must run on the main thread
* because it touches GTK widgets. */
g_idle_add(avatar_refresh_idle, g_strdup(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;
tab_manager_set_avatar(pubkey);
g_free(pubkey);
return G_SOURCE_REMOVE;
}
/* ── Background thread ─────────────────────────────────────────────── */
gpointer relay_fetch_thread(gpointer data) {
char *pubkey_hex = (char *)data;
if (pubkey_hex == NULL || pubkey_hex[0] == '\0') {
g_printerr("[relay] Thread started with no pubkey\n");
return NULL;
}
/* Parse bootstrap relays from settings. */
const browser_settings_t *s = settings_get();
char *relay_buf = g_strdup(s->bootstrap_relays);
const char *relay_urls[MAX_RELAYS];
int relay_count = 0;
/* Tokenize the buffer in place. */
char *saveptr = NULL;
char *line = strtok_r(relay_buf, "\n\r", &saveptr);
while (line != NULL && relay_count < MAX_RELAYS) {
while (*line == ' ' || *line == '\t') line++;
if (line[0] != '\0' &&
(strncmp(line, "wss://", 6) == 0 ||
strncmp(line, "ws://", 5) == 0)) {
relay_urls[relay_count++] = line;
}
line = strtok_r(NULL, "\n\r", &saveptr);
}
if (relay_count == 0) {
g_print("[relay] No valid bootstrap relay URLs in settings\n");
g_free(relay_buf);
g_free(pubkey_hex);
return NULL;
}
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;
}

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