Compare commits

...

10 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
43 changed files with 2066 additions and 244 deletions

View File

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

View File

@@ -21,7 +21,7 @@ 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/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 := 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
# 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)

View File

@@ -1 +1 @@
0.0.47
0.0.56

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -8,10 +8,16 @@
#
# Usage:
# ./browser.sh start [ARGS...] — build + launch (detached), forwarding ARGS
# ./browser.sh stop — kill any running instance
# ./browser.sh stop — kill ALL running instances
# ./browser.sh restart [ARGS...]— stop + build + start, forwarding ARGS
# ./browser.sh status — check if running + MCP responsive
# ./browser.sh status — list all running instances + MCP status
# ./browser.sh log — tail the browser log
# ./browser.sh mcp-url — print MCP endpoint URL(s) for running instance(s)
#
# Multiple instances: if the default MCP port (17777) is already in use by
# another instance, the browser automatically falls back to an OS-assigned
# port and records it in /tmp/sovereign_browser_<pid>.port. browser.sh
# reads these discovery files to find/stop/status every instance.
#
# Extra args after start/restart are forwarded to the binary, e.g.:
# ./browser.sh start --login-method generate --url https://example.com
@@ -25,7 +31,37 @@ set -euo pipefail
BIN="./sovereign_browser"
LOG="/tmp/sovereign_browser.log"
PID_FILE="/tmp/sovereign_browser.pid"
PORT=17777
DEFAULT_PORT=17777
DISCOVERY_GLOB="/tmp/sovereign_browser_*.port"
# Read the port for a given PID from its discovery file. Falls back to
# DEFAULT_PORT (17777) if no discovery file exists (e.g. older binary, or
# the first instance that successfully bound the default port and wrote a
# discovery file with 17777). Echoes the port to stdout.
port_for_pid() {
local pid="${1:-}"
local file="/tmp/sovereign_browser_${pid}.port"
if [[ -f "$file" ]]; then
local p
p=$(awk '{print $2}' "$file" 2>/dev/null | head -n1)
if [[ -n "$p" ]]; then
echo "$p"
return 0
fi
fi
echo "$DEFAULT_PORT"
}
# Echo the port for the currently-running instance (per get_pid), or
# DEFAULT_PORT if not running.
current_port() {
local pid
if pid=$(get_pid 2>/dev/null); then
port_for_pid "$pid"
else
echo "$DEFAULT_PORT"
fi
}
# Check if a PID is actually the sovereign_browser process.
# This safety guard prevents us from accidentally killing unrelated
@@ -53,13 +89,24 @@ get_pid() {
return 0
fi
fi
# Fallback: find the process LISTENing on the port.
# Fallback 1: scan discovery files for a live sovereign_browser PID.
local f
for f in $DISCOVERY_GLOB; do
[[ -f "$f" ]] || continue
local dpid
dpid=$(awk '{print $1}' "$f" 2>/dev/null | head -n1)
if [[ -n "$dpid" ]] && is_browser_pid "$dpid"; then
echo "$dpid"
return 0
fi
done
# Fallback 2: find the process LISTENing on the default port.
# -sTCP:LISTEN matches only the listening socket, never client
# connections (e.g. Roo Code's MCP client). This is critical —
# without it, `lsof -ti :PORT` would also return Roo Code's PID
# and we'd kill the agent driving the browser.
local port_pid
port_pid=$(lsof -ti :"$PORT" -sTCP:LISTEN 2>/dev/null | head -n1 || echo "")
port_pid=$(lsof -ti :"$DEFAULT_PORT" -sTCP:LISTEN 2>/dev/null | head -n1 || echo "")
if [[ -n "$port_pid" ]] && is_browser_pid "$port_pid"; then
echo "$port_pid"
return 0
@@ -67,13 +114,36 @@ get_pid() {
return 1
}
# Echo all live sovereign_browser PIDs (one per line), discovered via the
# discovery files and the default-port listen socket. Used by cmd_stop to
# kill every running instance.
all_pids() {
local pids=()
local f
for f in $DISCOVERY_GLOB; do
[[ -f "$f" ]] || continue
local dpid
dpid=$(awk '{print $1}' "$f" 2>/dev/null | head -n1)
if [[ -n "$dpid" ]] && is_browser_pid "$dpid"; then
pids+=("$dpid")
fi
done
local pp
for pp in $(lsof -ti :"$DEFAULT_PORT" -sTCP:LISTEN 2>/dev/null || true); do
if is_browser_pid "$pp"; then
pids+=("$pp")
fi
done
# Deduplicate.
printf '%s\n' "${pids[@]}" 2>/dev/null | sort -u | grep -v '^$' || true
}
cmd_stop() {
local stopped_any=0
# Collect candidate PIDs from the PID file and from LISTENing sockets.
# We use -sTCP:LISTEN so we only match the server, never client
# connections (Roo Code / VS Code ext host connect to the MCP server
# and would be killed by a bare `lsof -ti :PORT`).
# Collect ALL running sovereign_browser PIDs from the PID file,
# discovery files, and the default-port listen socket. This kills
# every instance when multiple are running on different ports.
local candidates=()
if [[ -f "$PID_FILE" ]]; then
while IFS= read -r pid; do
@@ -82,7 +152,7 @@ cmd_stop() {
fi
while IFS= read -r pid; do
[[ -n "$pid" ]] && candidates+=("$pid")
done < <(lsof -ti :"$PORT" -sTCP:LISTEN 2>/dev/null || true)
done < <(all_pids)
# Deduplicate and kill only verified sovereign_browser PIDs.
for pid in $(printf '%s\n' "${candidates[@]}" 2>/dev/null | sort -u | grep -v '^$'); do
@@ -96,7 +166,7 @@ cmd_stop() {
# Wait for graceful shutdown, then force-kill survivors (verified only).
if [[ "$stopped_any" -eq 1 ]]; then
sleep 1
for pid in $(lsof -ti :"$PORT" -sTCP:LISTEN 2>/dev/null || true); do
for pid in $(all_pids); do
if is_browser_pid "$pid"; then
echo "[browser] Force killing PID $pid..."
kill -9 "$pid" 2>/dev/null || true
@@ -106,10 +176,21 @@ cmd_stop() {
fi
rm -f "$PID_FILE"
# Clean up any leftover discovery files (the browser removes its own on
# clean shutdown, but force-killed instances leave them behind).
local f
for f in $DISCOVERY_GLOB; do
[[ -f "$f" ]] || continue
local dpid
dpid=$(awk '{print $1}' "$f" 2>/dev/null | head -n1)
if [[ -z "$dpid" ]] || ! kill -0 "$dpid" 2>/dev/null; then
rm -f "$f"
fi
done
# Final check: is anything still LISTENing on the port?
if lsof -ti :"$PORT" -sTCP:LISTEN >/dev/null 2>&1; then
echo "[browser] Warning: process still listening on port $PORT."
# Final check: is anything still running?
if [[ -n "$(all_pids)" ]]; then
echo "[browser] Warning: some instances still running."
else
echo "[browser] Stopped."
fi
@@ -187,13 +268,18 @@ cmd_start() {
disown 2>/dev/null || true
echo "$pid" > "$PID_FILE"
# Wait for the MCP server to be ready (up to 10 seconds)
echo "[browser] Waiting for MCP server on port $PORT..."
# Wait for the MCP server to be ready (up to 10 seconds). The actual
# port may differ from DEFAULT_PORT if it was in use (the browser falls
# back to an OS-assigned port and writes it to a discovery file), so
# read the port from the discovery file each iteration.
echo "[browser] Waiting for MCP server (PID $pid)..."
local mcp_port=""
for i in $(seq 1 100); do
if curl -s -o /dev/null -X POST "http://localhost:$PORT/mcp" \
mcp_port=$(port_for_pid "$pid")
if curl -s -o /dev/null -X POST "http://localhost:$mcp_port/mcp" \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"ping"}' 2>/dev/null; then
echo "[browser] MCP server is ready (PID $pid)."
echo "[browser] MCP server is ready (PID $pid, port $mcp_port)."
return 0
fi
sleep 0.1
@@ -211,22 +297,51 @@ cmd_restart() {
}
cmd_status() {
local pids
pids=$(all_pids)
if [[ -z "$pids" ]]; then
echo "[browser] Not running."
return 0
fi
local count
count=$(echo "$pids" | wc -l)
local pid
if pid=$(get_pid 2>/dev/null); then
echo "[browser] Running (PID $pid)."
# Check MCP
if curl -s -o /dev/null -X POST "http://localhost:$PORT/mcp" \
for pid in $pids; do
local p
p=$(port_for_pid "$pid")
echo "[browser] Running (PID $pid, port $p)."
if curl -s -o /dev/null -X POST "http://localhost:$p/mcp" \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"ping"}' 2>/dev/null; then
echo "[browser] MCP server: responsive"
echo "[browser] MCP server: responsive"
else
echo "[browser] MCP server: NOT responding"
echo "[browser] MCP server: NOT responding"
fi
else
echo "[browser] Not running."
done
if [[ "$count" -gt 1 ]]; then
echo "[browser] $count instances running."
fi
}
# Print the MCP endpoint URL for the running instance (or all instances
# if multiple are running). Useful for external clients that need to
# discover the port programmatically:
# URL=$(./browser.sh mcp-url)
cmd_mcp_url() {
local pids
pids=$(all_pids)
if [[ -z "$pids" ]]; then
echo "[browser] Not running." >&2
return 1
fi
local pid
for pid in $pids; do
local p
p=$(port_for_pid "$pid")
echo "http://localhost:$p/mcp"
done
}
cmd_log() {
if [[ -f "$LOG" ]]; then
tail -50 "$LOG"
@@ -241,9 +356,11 @@ case "${1:-}" in
restart) shift; cmd_restart "$@" ;;
status) cmd_status ;;
log) cmd_log ;;
mcp-url) cmd_mcp_url ;;
*)
echo "Usage: $0 {start|stop|restart|status|log} [ARGS...]" >&2
echo "Usage: $0 {start|stop|restart|status|log|mcp-url} [ARGS...]" >&2
echo " start/restart forward extra args to the binary." >&2
echo " mcp-url prints the MCP endpoint URL(s) for running instance(s)." >&2
exit 1
;;
esac

View File

@@ -286,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

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.

View File

@@ -28,6 +28,12 @@ extern const char *app_get_pubkey_hex(void);
extern key_store_method_t app_get_method(void);
extern gboolean app_get_readonly(void);
/* Defined in main.c. Tears down the current user's web state (closes all
* tabs, destroys sidebar webviews, wipes WebKit cookies/cache/localStorage/
* service workers/favicons) so the next identity starts clean. See
* plans/webkit-data-isolation.md. */
extern void identity_teardown_web_state(void);
/* Track whether the agent (not the GTK dialog) performed the login. */
static gboolean g_agent_performed_login = FALSE;
@@ -379,6 +385,12 @@ static cJSON *login_nsigner(cJSON *params) {
return make_error("NSIGNER_CONNECT", "Failed to connect to n_signer. Check the device/path/qube.");
}
/* n_signer's serial/TCP transports require a kind-27235 auth envelope on
* every request. Install the default caller identity (matching n_signer's
* webserial demo) before issuing any verbs. Without this the device
* rejects the call with an auth error that surfaces as "code -310". */
key_store_nsigner_set_default_auth(signer);
int rc = nostr_signer_nsigner_set_nostr_index(signer, index);
if (rc != NOSTR_SUCCESS) {
nostr_signer_free(signer);
@@ -389,9 +401,30 @@ static cJSON *login_nsigner(cJSON *params) {
char pubkey_hex[65];
rc = nostr_signer_get_public_key(signer, pubkey_hex);
if (rc != NOSTR_SUCCESS) {
const char *err_str = nostr_strerror(rc);
g_printerr("[agent-login] n_signer get_public_key failed: rc=%d (%s)\n",
rc, err_str ? err_str : "?");
nostr_signer_free(signer);
sigprocmask(SIG_SETMASK, &old_set, NULL);
return make_error("NSIGNER_PUBKEY", "Failed to get pubkey from n_signer.");
if (rc == NOSTR_ERROR_NIP46_AUTH_CHALLENGE) {
/* Device RPC codes 2010-2017: auth-related rejection. Most
* commonly the kind-27235 caller auth envelope was missing or
* denied (the default caller identity is installed automatically,
* but the device's policy may still deny it). Can also mean the
* device is requesting on-device approval. */
return make_error("NSIGNER_AUTH_REJECTED",
"n_signer auth rejected (code -310). The caller "
"auth envelope was missing or denied, or the "
"device is requesting on-device approval. Confirm "
"any prompt on the hardware signer, then retry.");
}
{
char msg[256];
snprintf(msg, sizeof(msg),
"Failed to get pubkey from n_signer (rc=%d: %s).",
rc, err_str ? err_str : "unknown");
return make_error("NSIGNER_PUBKEY", msg);
}
}
sigprocmask(SIG_SETMASK, &old_set, NULL);
@@ -483,6 +516,13 @@ cJSON *agent_login(cJSON *params) {
}
cJSON *agent_logout(void) {
/* Tear down the current user's web state (closes all tabs, destroys
* sidebar webviews, wipes WebKit cookies/cache/localStorage/service
* workers/favicons) so the next login starts with a clean web
* session. Must happen before clearing the signer. See
* plans/webkit-data-isolation.md. */
identity_teardown_web_state();
app_clear_signer();
nostr_bridge_set_signer(NULL, "", TRUE);
g_print("[agent-login] Logged out\n");
@@ -490,6 +530,12 @@ cJSON *agent_logout(void) {
}
cJSON *agent_switch_identity(cJSON *params) {
/* Tear down the previous user's web state BEFORE freeing the signer
* and logging in the new identity, so the new user starts with a
* clean web session (no leftover cookies/localStorage/tabs from the
* previous user). See plans/webkit-data-isolation.md. */
identity_teardown_web_state();
/* Free the old signer first. */
app_clear_signer();
/* Then login with the new identity. */

View File

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

View File

@@ -31,16 +31,10 @@
static nostr_signer_t *g_signer = NULL;
static char g_pubkey[65] = {0};
static char g_privkey[65] = {0}; /* hex, in-memory only */
static int g_have_signer = 0;
static int g_have_privkey = 0;
static bookmark_node_t g_root = {0};
/* HMAC key cache (32 bytes). Derived once in bookmarks_init from the privkey. */
static unsigned char g_hmac_key[32];
static int g_have_hmac_key = 0;
/* Change-notification subscribers. */
#define BOOKMARKS_MAX_SUBS 8
static bookmarks_changed_cb g_subs[BOOKMARKS_MAX_SUBS];
@@ -188,47 +182,41 @@ static void node_remove_child(bookmark_node_t *parent, int idx) {
/* ── HMAC d-tag derivation ─────────────────────────────────────────── */
/* Compute the per-user HMAC key from the privkey.
* hmac_key = HMAC-SHA256(privkey_bytes, BOOKMARKS_HMAC_KEY_LABEL)
* Caches the result in g_hmac_key. Returns 0 on success, -1 on error. */
static int compute_hmac_key(void) {
if (g_have_hmac_key) return 0;
if (!g_have_privkey || g_privkey[0] == '\0') return -1;
unsigned char priv[32];
if (nostr_hex_to_bytes(g_privkey, priv, 32) != 0) {
g_printerr("[bookmarks] Invalid privkey hex\n");
return -1;
}
if (nostr_hmac_sha256(priv, 32,
(const unsigned char *)BOOKMARKS_HMAC_KEY_LABEL,
strlen(BOOKMARKS_HMAC_KEY_LABEL),
g_hmac_key) != 0) {
g_printerr("[bookmarks] HMAC-SHA256 key derivation failed\n");
return -1;
}
g_have_hmac_key = 1;
return 0;
}
/* Compute the opaque d tag for a path: HMAC-SHA256(hmac_key, path) → hex.
* Returns a newly allocated 64-char hex string (caller frees), or NULL. */
/* Compute the opaque d tag for a path using a single-step HMAC keyed by the
* signer's secp256k1 private key:
* d = HMAC-SHA256(privkey, BOOKMARKS_HMAC_KEY_LABEL + ":" + path) → hex
*
* This is computed remotely via nostr_signer_derive_hmac, so the privkey
* never leaves the signer (n_signer or local). The label prefix provides
* domain separation so the same path used by a different application
* produces a different d tag.
*
* Returns a newly allocated 64-char hex string (caller frees), or NULL.
*
* If no signer is available, falls back to the legacy plaintext d tag
* (the path itself) so bookmarks can still be published in read-only mode.
* The loader recognises both formats. */
static char *path_to_d_tag(const char *path) {
if (compute_hmac_key() != 0) return NULL;
if (path == NULL) path = "";
unsigned char mac[32];
if (nostr_hmac_sha256(g_hmac_key, 32,
(const unsigned char *)path, strlen(path),
mac) != 0) {
return NULL;
if (!g_have_signer || g_signer == NULL) {
/* No signer — use the plaintext path as the d tag (read-only mode). */
return g_strdup(path);
}
char *hex = g_malloc(65);
if (hex == NULL) return NULL;
nostr_bytes_to_hex(mac, 32, hex);
hex[64] = '\0';
return hex;
/* Build "LABEL:path" — single-step domain-separated HMAC input. */
char *data = g_strdup_printf("%s:%s", BOOKMARKS_HMAC_KEY_LABEL, path);
if (data == NULL) return NULL;
char digest_hex[65];
int rc = nostr_signer_derive_hmac(g_signer, data, digest_hex);
g_free(data);
if (rc != NOSTR_SUCCESS) {
g_printerr("[bookmarks] derive_hmac failed (rc=%d); falling back to plaintext d tag\n", rc);
return g_strdup(path);
}
return g_strdup(digest_hex);
}
/* Returns 1 if s is a 64-char lowercase-hex string (i.e. an HMAC d tag),
@@ -521,8 +509,7 @@ void bookmarks_subscribe_changed(bookmarks_changed_cb cb, void *user_data) {
/* ── Public API ────────────────────────────────────────────────────── */
int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex,
const char *privkey_hex) {
int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex) {
g_signer = signer;
g_have_signer = (signer != NULL);
if (pubkey_hex) {
@@ -530,14 +517,6 @@ int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex,
} else {
g_pubkey[0] = '\0';
}
if (privkey_hex && privkey_hex[0]) {
snprintf(g_privkey, sizeof(g_privkey), "%s", privkey_hex);
g_have_privkey = 1;
} else {
g_privkey[0] = '\0';
g_have_privkey = 0;
}
g_have_hmac_key = 0; /* recompute on first use */
/* Initialize the root node. */
if (g_root.name == NULL) {
@@ -617,12 +596,19 @@ int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex,
scratch.bookmark_count = 0;
}
/* Legacy migration: re-publish with HMAC d tag and delete old. */
if (!is_hmac_d_tag(d_value) && g_have_signer && g_have_privkey) {
g_print("[bookmarks] Migrating legacy folder '%s' to HMAC d tag\n",
path);
if (leaf) publish_node(leaf);
publish_deletion_for_d(d_value);
/* Migration: re-publish with the current single-step HMAC d
* tag and delete the old event if the d tag doesn't match.
* This covers both legacy plaintext d tags AND old two-step
* HMAC d tags (from before the single-step switch). */
if (g_have_signer && path != NULL && path[0] != '\0') {
char *new_d = path_to_d_tag(path);
if (new_d != NULL && strcmp(new_d, d_value) != 0) {
g_print("[bookmarks] Migrating folder '%s' to current HMAC d tag\n",
path);
if (leaf) publish_node(leaf);
publish_deletion_for_d(d_value);
}
g_free(new_d);
}
g_free(path_from_content);
@@ -635,9 +621,8 @@ int bookmarks_init(nostr_signer_t *signer, const char *pubkey_hex,
}
}
g_print("[bookmarks] Initialized: signer=%s privkey=%s\n",
g_have_signer ? "yes" : "no",
g_have_privkey ? "yes" : "no");
g_print("[bookmarks] Initialized: signer=%s\n",
g_have_signer ? "yes" : "no");
return 0;
}
@@ -654,9 +639,6 @@ void bookmarks_cleanup(void) {
g_signer = NULL;
g_have_signer = 0;
g_pubkey[0] = '\0';
g_privkey[0] = '\0';
g_have_privkey = 0;
g_have_hmac_key = 0;
g_sub_count = 0;
}
@@ -724,6 +706,32 @@ int bookmarks_remove(const char *path, const char *url) {
return rc;
}
int bookmarks_rename(const char *path, const char *url, const char *new_title) {
if (!g_have_signer) {
g_printerr("[bookmarks] No signer, cannot rename\n");
return -1;
}
if (path == NULL || path[0] == '\0') path = "General";
if (url == NULL || url[0] == '\0') return -1;
bookmark_node_t *leaf = node_find_path(&g_root, path);
if (leaf == NULL) return -1;
int bidx = find_bookmark_in_node(leaf, url);
if (bidx < 0) return -1;
/* Replace the title in place. The URL and added timestamp are
* preserved — only the user-editable label changes. */
char *old_title = leaf->bookmarks[bidx].title;
leaf->bookmarks[bidx].title = g_strdup(new_title ? new_title : "");
g_free(old_title);
g_print("[bookmarks] Renamed bookmark '%s' in '%s'\n", url, path);
int rc = publish_node(leaf);
notify_changed();
return rc;
}
int bookmarks_move(const char *from_path, const char *url, const char *to_path) {
if (!g_have_signer) return -1;
if (from_path == NULL || from_path[0] == '\0') from_path = "General";
@@ -1072,10 +1080,16 @@ int bookmarks_store_and_load_event(const void *event_cjson) {
scratch.bookmark_count = 0;
}
/* Legacy migration. */
if (!is_hmac_d_tag(d_value) && g_have_signer && g_have_privkey) {
if (leaf) publish_node(leaf);
publish_deletion_for_d(d_value);
/* Migration: re-publish with the current single-step HMAC d tag and
* delete the old event if the d tag doesn't match. Covers both legacy
* plaintext d tags and old two-step HMAC d tags. */
if (g_have_signer && path != NULL && path[0] != '\0') {
char *new_d = path_to_d_tag(path);
if (new_d != NULL && strcmp(new_d, d_value) != 0) {
if (leaf) publish_node(leaf);
publish_deletion_for_d(d_value);
}
g_free(new_d);
}
g_free(path_from_content);

View File

@@ -67,18 +67,16 @@ typedef struct bookmark_node {
* 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)
* 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
* privkey_hex — the user's hex privkey (64 chars) or NULL. Used to derive
* the HMAC key for opaque d tags. NULL in read-only mode
* (existing events can still be loaded from the db, but
* no new events can be published).
*
* Returns 0 on success, -1 on error.
*/
int bookmarks_init(nostr_signer_t *signer,
const char *pubkey_hex,
const char *privkey_hex);
const char *pubkey_hex);
/* Free the in-memory bookmark tree. Call at shutdown. */
void bookmarks_cleanup(void);
@@ -107,6 +105,12 @@ int bookmarks_add(const char *path, const char *url, const char *title);
* 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,

View File

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

View File

@@ -84,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;
@@ -95,6 +101,37 @@ nostr_signer_t *key_store_create_signer(const key_store_identity_t *identity) {
}
}
/* ── n_signer default caller auth ─────────────────────────────────── *
* n_signer's serial and TCP transports require a kind-27235 auth envelope
* on every request. Without it the device rejects the call with an
* auth-related RPC error (codes 2010-2017) that the client maps to
* NOSTR_ERROR_NIP46_AUTH_CHALLENGE (-310) — which presents to the user as
* "device requires approval" even though no on-device approval is actually
* needed.
*
* This installs the same fixed, well-known caller identity used by n_signer's
* own webserial demo (privkey bytes 1,2,3,...,32). It is a caller-side
* authentication credential only — it does NOT grant access to the device's
* mnemonic/keys; the device's own policy still decides which operations are
* allowed. No-op for non-nsigner backends.
*/
int key_store_nsigner_set_default_auth(nostr_signer_t *signer) {
if (signer == NULL) {
return -1;
}
#if defined(NOSTR_ENABLE_NSIGNER_CLIENT)
/* Match the webserial demo's caller privkey: bytes 1..32. */
static const unsigned char default_caller_priv[32] = {
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32
};
return nostr_signer_nsigner_set_auth(signer, default_caller_priv,
"sovereign_browser");
#else
return -1;
#endif
}
/* ── Legacy file cleanup ──────────────────────────────────────────── */
int key_store_delete_legacy_file(void) {

View File

@@ -87,6 +87,24 @@ int key_store_save_profile_identity(const char *pubkey_hex,
int key_store_load_profile_identity(const char *pubkey_hex,
key_store_method_t *method_out);
/*
* Set a default caller auth identity on an n_signer remote signer.
*
* n_signer's serial and TCP transports require a kind-27235 auth envelope
* on every request (the device rejects unauthenticated calls with an
* auth-related RPC error that maps to NOSTR_ERROR_NIP46_AUTH_CHALLENGE).
* This installs a fixed, well-known caller identity (the same one used by
* n_signer's own webserial demo) so the browser can talk to a USB-attached
* n_signer without the user having to configure caller credentials.
*
* Safe to call on any signer; no-op for non-nsigner backends. Call after
* the signer is created and before the first verb (e.g. before
* nostr_signer_nsigner_set_nostr_index / nostr_signer_get_public_key).
*
* Returns 0 on success, non-zero on error (e.g. not an nsigner signer).
*/
int key_store_nsigner_set_default_auth(nostr_signer_t *signer);
#ifdef __cplusplus
}
#endif

View File

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

View File

@@ -55,6 +55,8 @@
#include "agent_conversations.h"
#include "agent_skills.h"
#include "net_services.h"
#include "webkit_data.h"
#include "web_context.h"
#include "nostr_core/nostr_core.h"
/* ---- Global state --------------------------------------------------- *
@@ -73,12 +75,22 @@ typedef struct {
static app_state_t g_state = {0};
/* Forward declaration — defined before main(). */
/* Forward declarations — defined later in this file. */
static int switch_to_user_db(const char *pubkey_hex);
static int do_login(GtkWindow *parent);
static WebKitWebContext *build_context_for_current_user(void);
static char g_current_profile_db[512];
static GtkWindow *g_window = NULL;
static gboolean g_logged_in = FALSE;
static gboolean g_is_fullscreen = FALSE; /* track fullscreen state (GTK3 has no getter */
/* TRUE once main() has finished initial startup (tab_manager_init has
* run). Used by app_set_signer() to distinguish a first-time login
* (context built later by main()) from a runtime identity switch
* (context was torn down by identity_teardown_web_state and must be
* rebuilt here). */
static gboolean g_post_startup = FALSE;
/* ---- App state accessors (used by agent_login.c) ─────────────────── */
void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex,
@@ -112,6 +124,23 @@ void app_set_signer(nostr_signer_t *signer, const char *pubkey_hex,
if (g_state.pubkey_hex[0] != '\0') {
switch_to_user_db(g_state.pubkey_hex);
}
/* If this is a RUNTIME identity switch (past startup) and the
* previous context was torn down by identity_teardown_web_state(),
* rebuild a fresh per-user context for the new identity now. On the
* first login (before main() calls tab_manager_init), g_post_startup
* is FALSE and web_context_get() is NULL, so main() builds the
* context after login — we skip here to avoid a double-build. */
if (g_post_startup && web_context_get() == NULL) {
g_print("[identity] Rebuilding web context for new identity (runtime switch)\n");
if (build_context_for_current_user() != NULL) {
/* Open a fresh tab for the new user so they don't stare at
* an empty window after the teardown closed all tabs. */
tab_manager_new_tab(settings_get()->new_tab_url);
} else {
g_printerr("[identity] Failed to rebuild web context\n");
}
}
}
void app_clear_signer(void) {
@@ -140,6 +169,62 @@ gboolean app_get_readonly(void) { return g_state.readonly; }
* menu builder.
*/
/* ── Web state teardown (Phase 0 of plans/webkit-data-isolation.md) ── *
* Called on logout and identity switch. Closes all tabs, destroys the
* agent chat sidebar webviews (which hold a long-lived WebKitWebView
* with the previous user's sovereign://agents/chat session), and wipes
* all WebKit website data (cookies, cache, localStorage, IndexedDB,
* service workers, favicons) from the shared WebKitWebsiteDataManager.
*
* Without this, the next user inherits the previous user's web session:
* cookies identify you to web pages, localStorage holds per-site state,
* and the live tabs keep the previous user's DOM in memory. See
* plans/webkit-data-isolation.md for the full rationale.
*
* This must run on the GTK main thread. It pumps a nested main loop
* briefly while webkit_website_data_manager_clear() completes.
*/
void identity_teardown_web_state(void) {
/* 1. Close all tabs. This destroys the webviews and drops their
* in-memory DOM/localStorage. Must happen before the context
* teardown so no webviews hold dangling references to the old
* context. Suppress the normal "last tab closed → quit the app"
* behavior so the browser stays alive for the next user; the
* caller opens a fresh tab after the switch. */
tab_manager_set_suppress_quit_on_last_tab(TRUE);
tab_manager_close_all();
tab_manager_set_suppress_quit_on_last_tab(FALSE);
/* 2. Destroy the agent chat sidebar webviews in every window. The
* sidebar webview is outside the notebook so tab_manager_close_all
* does not touch it; it would otherwise keep a reference to the
* old context (and the previous user's chat session) alive. */
tab_manager_destroy_sidebar_webviews();
/* 3. Tear down the per-user WebKitWebContext. This unrefs the
* context and its per-user WebKitWebsiteDataManager. With Phase A
* isolation, each user has their own data manager rooted at
* profiles/<pubkey>/webkit/, so tearing down the context is what
* actually isolates the users — the next build creates a fresh
* data manager for the new user. The Phase 0 webkit_data_clear_all
* wipe is no longer needed for isolation (the data manager is
* per-user), but we keep it as a defense-in-depth safety net in
* case any process-global WebKit state survived the context
* teardown. */
WebKitWebContext *ctx = web_context_get();
if (ctx != NULL) {
/* Defense-in-depth: clear the data manager before tearing down
* the context. This catches any process-global caches that
* outlive the context. */
int rc = webkit_data_clear_all(ctx, FALSE);
if (rc != 0) {
g_printerr("[identity] webkit_data_clear_all returned %d "
"(continuing with context teardown)\n", rc);
}
web_context_teardown();
}
}
void app_menu_switch_identity_proxy(GtkMenuItem *item, gpointer data) {
(void)item;
GtkWindow *window = GTK_WINDOW(data);
@@ -147,6 +232,10 @@ void app_menu_switch_identity_proxy(GtkMenuItem *item, gpointer data) {
login_result_t result;
if (login_dialog_run(window, &result) == 0) {
/* Tear down the previous user's web state BEFORE installing the
* new signer, so the new user starts with a clean web session. */
identity_teardown_web_state();
if (g_state.signer) {
nostr_signer_free(g_state.signer);
}
@@ -166,6 +255,21 @@ void app_menu_switch_identity_proxy(GtkMenuItem *item, gpointer data) {
switch_to_user_db(g_state.pubkey_hex);
}
/* Build a fresh per-user WebKitWebContext for the new identity
* (with its own per-user data manager) and re-register the
* sovereign://, nostr://, tor:// schemes on it. The old context
* was torn down by identity_teardown_web_state() above. */
if (build_context_for_current_user() == NULL) {
g_printerr("[identity] Failed to build context for new user — "
"browser will have no web views\n");
}
/* Open a fresh tab for the new user so they don't stare at an
* empty window after the teardown closed all the previous user's
* tabs. Uses the new user's per-user new_tab_url setting. */
const char *url = settings_get()->new_tab_url;
tab_manager_new_tab(url);
g_print("[identity] switched: method=%d pubkey=%s\n",
g_state.method, g_state.pubkey_hex);
}
@@ -185,15 +289,51 @@ void app_menu_lock_session_proxy(GtkMenuItem *item, gpointer data) {
void app_menu_logout_proxy(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
GtkWindow *window = GTK_WINDOW(data);
/* Tear down the current user's web state (closes all tabs, destroys
* the sidebar webviews, wipes cookies/cache/localStorage/service
* workers/favicons) BEFORE clearing the signer, so the next login
* starts with a clean web session. See plans/webkit-data-isolation.md. */
identity_teardown_web_state();
if (g_state.signer) {
nostr_signer_free(g_state.signer);
g_state.signer = NULL;
}
g_state.pubkey_hex[0] = '\0';
g_state.privkey_hex[0] = '\0';
g_state.method = KEY_STORE_METHOD_NONE;
g_state.readonly = FALSE;
g_logged_in = FALSE;
settings_sync_set_signer(NULL, NULL);
agent_conversations_set_signer(NULL, NULL);
/* Reset the per-user db tracking so the next login re-opens the
* new user's browser.db rather than assuming we're already on it. */
g_current_profile_db[0] = '\0';
g_print("[identity] logged out\n");
/* Re-show the login dialog so the user (or agent) can log in as a
* different identity. The dialog runs a nested main loop, so the
* agent server stays live and an agent can log in via MCP while the
* dialog is showing. On success, do_login() installs the new signer
* and switch_to_user_db() opens the new user's profile. */
if (window != NULL) {
if (do_login(window) == 0) {
/* Build a fresh per-user WebKitWebContext for the new
* identity. The old context was torn down above. For
* --no-login mode this builds an ephemeral context. */
if (build_context_for_current_user() != NULL) {
/* Open a fresh tab for the new user. */
tab_manager_new_tab(settings_get()->new_tab_url);
} else {
g_printerr("[identity] Failed to build context after re-login\n");
}
}
}
}
void app_menu_security_strip_proxy(GtkCheckMenuItem *item, gpointer data) {
@@ -679,9 +819,9 @@ static int do_login(GtkWindow *parent) {
/* Track the currently-open per-user db path so we can skip re-opening
* the same database (e.g. when app_set_signer() is called during the
* login dialog and then main() calls switch_to_user_db() again). */
static char g_current_profile_db[512] = "";
* login dialog and then main() calls switch_to_user_db() again).
* The actual definition is near the top of this file (forward-declared
* before the menu proxies that reset it on logout). */
static int switch_to_user_db(const char *pubkey_hex) {
if (pubkey_hex == NULL || pubkey_hex[0] == '\0') {
g_printerr("[profile] No pubkey, staying on global.db\n");
@@ -736,6 +876,52 @@ static int switch_to_user_db(const char *pubkey_hex) {
return 0;
}
/* ---- Per-user WebKit context build helper --------------------------- *
* Builds a fresh per-user WebKitWebContext (or ephemeral for no-login
* mode), registers the sovereign://, nostr://, tor:// URI schemes on it,
* and updates tab_manager so new tabs use the new context. Called from:
* - main() after login, before creating tabs
* - app_menu_switch_identity_proxy() after tearing down the old context
* - app_menu_logout_proxy() after re-login
*
* The caller must have torn down the previous context (via
* web_context_teardown()) and closed all tabs/sidebar webviews first.
* Returns the new context, or NULL on failure (the caller may fall back
* to a blank window or exit).
*/
static WebKitWebContext *build_context_for_current_user(void) {
WebKitWebContext *web_ctx;
if (g_state.pubkey_hex[0] != '\0') {
web_ctx = web_context_build_for_pubkey(g_state.pubkey_hex);
} else {
/* --no-login / read-only-without-pubkey: ephemeral, no on-disk
* persistence. See plans/webkit-data-isolation.md. */
web_ctx = web_context_build_ephemeral();
}
if (web_ctx == NULL) {
g_printerr("[main] Failed to build WebKit context\n");
return NULL;
}
/* Register the sovereign:// URI scheme for the window.nostr bridge.
* nostr_bridge_set_signer() was already called by the login path,
* so the bridge will use the current signer. */
nostr_bridge_register(web_ctx, g_state.signer, g_state.pubkey_hex,
g_state.readonly);
/* Register nostr:// and tor:// entity-page schemes on this context.
* These are per-context registrations, so they must be re-registered
* on every fresh context. */
nostr_scheme_register(web_ctx);
tor_scheme_register(web_ctx);
/* Point tab_manager at the new context so subsequent new tabs (and
* the sidebar webview) are created from it. */
tab_manager_set_context(web_ctx);
return web_ctx;
}
/* ---- Main ----------------------------------------------------------- */
int main(int argc, char **argv) {
@@ -945,8 +1131,7 @@ int main(int argc, char **argv) {
* from SQLite and decrypts them. In no-login/read-only mode, the
* signer is NULL so bookmarks are read-only. */
bookmarks_init(g_state.signer,
g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL,
g_state.privkey_hex[0] ? g_state.privkey_hex : NULL);
g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
/* Initialize the NIP-78 settings sync module. In no-login/read-only
* mode, the signer is NULL so settings are local-only (not synced). */
@@ -971,50 +1156,35 @@ int main(int argc, char **argv) {
* when the relay fetch completes and the kind 0 event is stored. */
tab_manager_set_avatar(g_state.pubkey_hex[0] ? g_state.pubkey_hex : NULL);
/* Use the default WebKitWebContext — it comes with proper networking
* (cookies, cache, soup session) that a freshly created context lacks.
* All tabs will create webviews from this shared context. */
WebKitWebContext *web_ctx = webkit_web_context_get_default();
/* ── Per-user WebKit context (Phase A of webkit-data-isolation.md) ── *
* Build a fresh WebKitWebContext with a per-user
* WebKitWebsiteDataManager rooted at
* ~/.sovereign_browser/profiles/<pubkey>/webkit/ (or an ephemeral
* data manager for --no-login mode). This gives true per-identity
* isolation of cookies, cache, localStorage, IndexedDB, service
* workers, and favicons. The helper also registers the sovereign://,
* nostr://, tor:// URI schemes on the new context and points
* tab_manager at it. */
WebKitWebContext *web_ctx = build_context_for_current_user();
if (web_ctx == NULL) {
g_printerr("[main] Cannot continue without a WebKit context.\n");
agent_server_stop();
nostr_cleanup();
cli_args_free(&cli);
return EXIT_FAILURE;
}
/* ── Security strip: disable web security restrictions ─────── */
/* Fetch the security manager for the new context — used below to
* wire the sovereign://security page to the first tab's settings. */
WebKitSecurityManager *sec_mgr =
webkit_web_context_get_security_manager(web_ctx);
/* Register sovereign:// as secure only. Do NOT register it as "local"
* (WebKit blocks fetch from https to local origins) and do NOT register
* it as "cors_enabled" (that makes WebKit enforce CORS response headers,
* which we can't set with the basic finish API). Without these, WebKit
* treats sovereign:// as a simple secure scheme with no CORS checks. */
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "sovereign");
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "tor");
webkit_security_manager_register_uri_scheme_as_secure(sec_mgr, "file");
webkit_security_manager_register_uri_scheme_as_local(sec_mgr, "file");
/* Accept any TLS certificate (FIPS uses Noise IK, not TLS CAs). */
WebKitWebsiteDataManager *data_mgr =
webkit_web_context_get_website_data_manager(web_ctx);
webkit_website_data_manager_set_tls_errors_policy(
data_mgr, WEBKIT_TLS_ERRORS_POLICY_IGNORE);
/* Enable the favicon database so WebKitGTK automatically fetches and
* caches favicons for visited pages. Without this, the
* "notify::favicon" signal never fires and webkit_web_view_get_favicon()
* always returns NULL. Passing NULL for the directory uses WebKit's
* default cache location. */
webkit_web_context_set_favicon_database_directory(web_ctx, NULL);
g_print("[main] Favicon database enabled\n");
/* Initialize browser-managed/attached network services after login and
* after the shared WebKit context/data manager have been configured. */
/* Initialize browser-managed/attached network services after the
* per-user context has been built. net_services_refresh_proxy()
* now uses web_context_get() so proxy settings apply to the
* per-user context. */
net_services_init();
/* Register the sovereign:// URI scheme for the window.nostr bridge. */
nostr_bridge_register(web_ctx, g_state.signer, g_state.pubkey_hex,
g_state.readonly);
/* Register nostr:// entity pages before creating any webviews. */
nostr_scheme_register(web_ctx);
tor_scheme_register(web_ctx);
/* Vertical box: the tab manager's notebook fills the window. */
GtkWidget *vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_container_add(GTK_CONTAINER(window), vbox);
@@ -1022,6 +1192,11 @@ int main(int argc, char **argv) {
/* Initialize the tab manager. */
tab_manager_init(GTK_CONTAINER(vbox), web_ctx, g_window);
/* Mark startup complete. From now on, app_set_signer() (called by
* agent login / switch_identity) will rebuild the per-user web
* context if it was torn down, since main() won't build it again. */
g_post_startup = TRUE;
/* Decide whether to restore the previous session or open CLI URLs.
*
* Precedence:

View File

@@ -8,6 +8,7 @@
#include "tor_control.h"
#include "fips_control.h"
#include "settings.h"
#include "web_context.h"
#include <webkit2/webkit2.h>
@@ -66,8 +67,15 @@ static int ensure_private_dir(const char *path, char *error, size_t error_size)
void net_services_refresh_proxy(void) {
if (!g_initialized) return;
WebKitWebContext *ctx = webkit_web_context_get_default();
/* 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);
}

View File

@@ -473,12 +473,15 @@ static const char *sovereign_page_css(void) {
" min-width: 200px; }\n"
" input:focus, select:focus, textarea:focus {\n"
" border-color: var(--accent); outline: none; }\n"
" .btn, .save-btn { background: var(--bg); color: var(--primary);\n"
" border: 1px solid var(--primary); border-radius: var(--radius);\n"
" .btn, .save-btn, button.btn { background: var(--bg); color: var(--primary);\n"
" -webkit-appearance: none !important; appearance: none !important;\n"
" border-width: 1px !important; border-style: solid !important;\n"
" border-color: var(--primary) !important; border-radius: var(--radius);\n"
" padding: 6px 16px; cursor: pointer; font-family: var(--font);\n"
" font-size: 13px; font-weight: bold; margin-left: 8px;\n"
" display: inline-block; text-decoration: none;\n"
" transition: border-color 0.2s, background 0.2s, color 0.2s; }\n"
" .btn:hover, .save-btn:hover { border-color: var(--accent); }\n"
" .btn:hover, .save-btn:hover, button.btn:hover { border-color: var(--accent) !important; text-decoration: none; }\n"
" .btn:active, .save-btn:active { background: var(--accent); color: var(--secondary); }\n"
" .btn:disabled { opacity: 0.5; cursor: not-allowed; }\n"
" .btn-del { border-color: var(--accent); color: var(--accent); }\n"
@@ -1570,6 +1573,34 @@ static void handle_bookmarks_delete(WebKitURISchemeRequest *request,
g_free(dir); g_free(url);
}
/* Handle sovereign://bookmarks/rename?dir=...&url=...&title=...
* Renames a bookmark's user-editable title in place. */
static void handle_bookmarks_rename(WebKitURISchemeRequest *request,
const char *query) {
char *dir = query_param(query, "dir");
char *url = query_param(query, "url");
char *title = query_param(query, "title");
if (!url || url[0] == '\0') {
respond_error_json(request, -1, "Missing url parameter");
g_free(dir); g_free(url); g_free(title);
return;
}
int rc = bookmarks_rename(dir ? dir : "General", url, title ? title : "");
if (rc != 0) {
respond_error_json(request, -1, "Failed to rename bookmark");
} else {
cJSON *result = cJSON_CreateObject();
cJSON_AddStringToObject(result, "status", "renamed");
char *json = cJSON_PrintUnformatted(result);
cJSON_Delete(result);
respond_json(request, json);
free(json);
}
g_free(dir); g_free(url); g_free(title);
}
/* Handle sovereign://bookmarks/createdir?name=... */
static void handle_bookmarks_createdir(WebKitURISchemeRequest *request,
const char *query) {
@@ -1815,7 +1846,7 @@ static void handle_agents_page(WebKitURISchemeRequest *request) {
" <button class='save-btn' onclick=\"save('agent.llm_system_prompt')\">Save</button></div>\n"
"</div>\n"
"\n"
"<p><a href='sovereign://agents/chat'>Open Agent Chat</a></p>\n"
"<p><a href='sovereign://agents/chat' target='_blank'>Open Agent Chat</a></p>\n"
"\n"
"<div id='status' class='status'></div>\n"
"\n"
@@ -3984,7 +4015,11 @@ static void on_sovereign_scheme(WebKitURISchemeRequest *request,
* nostr bridge shim calls to avoid terminal spam. */
if (strncmp(uri, "sovereign://agents/status", 25) != 0 &&
strncmp(uri, "sovereign://agents/messages", 27) != 0 &&
strncmp(uri, "sovereign://nostr/", 18) != 0) {
strncmp(uri, "sovereign://nostr/", 18) != 0 &&
strncmp(uri, "sovereign://processes/list", 25) != 0 &&
strncmp(uri, "sovereign://processes/tabs", 25) != 0 &&
strncmp(uri, "sovereign://processes/probe-report", 33) != 0 &&
strncmp(uri, "sovereign://processes/tab_probe", 30) != 0) {
g_print("[bridge] %s\n", uri);
}
@@ -4099,6 +4134,10 @@ static void on_sovereign_scheme(WebKitURISchemeRequest *request,
handle_bookmarks_renamedir(request, uri + 32);
return;
}
if (strncmp(uri, "sovereign://bookmarks/rename?", 29) == 0) {
handle_bookmarks_rename(request, uri + 29);
return;
}
/* Route sovereign://settings requests. The page is served from the
* embedded file www/settings.html (Phase 3 migration). */

View File

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

View File

@@ -98,5 +98,4 @@ void perf_probe_setup(WebKitWebView *webview, int tab_index) {
webkit_user_script_unref(probe_script);
g_free(probe_js);
g_print("[perf-probe] perf probe installed for tab %d\n", tab_index);
}

View File

@@ -347,8 +347,6 @@ static cJSON *build_proc_obj(guint pid, proc_own_t own,
double djiffies = (double)(cur_jiffies - s->prev_jiffies);
cpu_pct = (djiffies / (double)clk_tck()) / dt * 100.0;
if (cpu_pct < 0) cpu_pct = 0;
/* Cap at 100 * cpu_count to avoid absurd values; we report
* per-process so >100 is valid on multicore. */
}
}
s->prev_jiffies = cur_jiffies;

View File

@@ -106,6 +106,46 @@ int profile_ensure_dir(const char *pubkey_hex) {
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';

View File

@@ -47,6 +47,21 @@ void profile_get_db_path(const char *pubkey_hex, char *out, size_t out_sz);
*/
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).

View File

@@ -144,6 +144,13 @@ static tab_info_t **g_tabs = NULL;
static int g_tab_count = 0;
static int g_tab_cap = 0;
/* When TRUE, closing the last tab of the main window does NOT quit the
* app. Used by identity_teardown_web_state() so it can close all tabs
* (to drop the previous user's live webviews) without exiting the
* browser during a logout / identity switch. The caller re-opens a
* fresh tab for the new user afterwards. */
static gboolean g_suppress_quit_on_last_tab = FALSE;
/* ── Forward declarations ─────────────────────────────────────────── */
/* Per-window sidebar helpers (defined in the sidebar section at the
@@ -176,6 +183,10 @@ static gboolean on_window_focus_in(GtkWidget *widget,
gpointer user_data);
static void on_aux_window_destroy(GtkWidget *widget,
gpointer user_data);
static void on_notebook_switch_page(GtkNotebook *notebook,
GtkWidget *page,
guint page_num,
gpointer user_data);
/* Forward declarations for signal handlers used by tab_manager_new_window
* (which is defined before these handlers in the file). */
static void on_load_changed(WebKitWebView *webview,
@@ -1208,6 +1219,11 @@ static GtkWidget *tab_manager_new_window(const char *url,
* stays pointing at the main window's image). */
setup_notebook_action_widgets(notebook, FALSE);
/* Grab focus on the webview when a tab is switched to (same as the
* main notebook) so wheel/scroll events work in auxiliary windows. */
g_signal_connect(notebook, "switch-page",
G_CALLBACK(on_notebook_switch_page), NULL);
/* Build a window-level GtkPaned: left = sidebar container, right =
* notebook. The sidebar is per-window (not per-tab), so it persists
* across tab switches within this window. Hidden by default. */
@@ -1940,10 +1956,16 @@ static void on_menu_toggle_sidebar(GtkMenuItem *item, gpointer data) {
static void on_menu_open_file(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
if (g_window == NULL) return;
/* Parent the dialog to the currently focused window (g_active_window)
* rather than always the main window (g_window), so the file chooser
* appears over the window the user invoked it from. g_active_window is
* kept up to date by on_window_focus_in() whenever a window gains
* focus; fall back to g_window if it hasn't been set yet. */
GtkWindow *parent = g_active_window ? g_active_window : g_window;
if (parent == NULL) return;
GtkWidget *dialog = gtk_file_chooser_dialog_new(
"Open File", g_window, GTK_FILE_CHOOSER_ACTION_OPEN,
"Open File", parent, GTK_FILE_CHOOSER_ACTION_OPEN,
"_Cancel", GTK_RESPONSE_CANCEL,
"_Open", GTK_RESPONSE_ACCEPT, NULL);
@@ -2054,16 +2076,12 @@ static void on_bookmark_item_clicked(GtkMenuItem *item, gpointer data) {
}
}
/* Called when "Manage Bookmarks…" is clicked in the submenu. */
/* Called when "Manage Bookmarks…" is clicked in the submenu.
* Always opens in a new tab so the user's current page is preserved. */
static void on_manage_bookmarks_clicked(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview) {
webkit_web_view_load_uri(tab->webview, "sovereign://bookmarks");
} else {
tab_manager_new_tab("sovereign://bookmarks");
}
tab_manager_new_tab("sovereign://bookmarks");
}
/* Wrapper for g_free to match GClosureNotify signature (avoids
@@ -2166,9 +2184,12 @@ static void on_bookmark_clicked(GtkButton *btn, gpointer user_data) {
const gchar *title = webkit_web_view_get_title(tab->webview);
if (title == NULL) title = "";
/* Build the folder picker dialog. */
/* Build the folder picker dialog. Parent it to the focused window
* (g_active_window) so it appears over the window the user invoked
* it from, falling back to the main window if unset. */
GtkWindow *bm_parent = g_active_window ? g_active_window : g_window;
GtkWidget *dialog = gtk_dialog_new_with_buttons(
"Bookmark Page", g_window,
"Bookmark Page", bm_parent,
GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT,
"_Cancel", GTK_RESPONSE_CANCEL,
"_Add", GTK_RESPONSE_ACCEPT,
@@ -2315,7 +2336,7 @@ static GtkWidget *build_hamburger_menu(tab_info_t *tab) {
g_signal_connect(item_lock, "activate",
G_CALLBACK(app_menu_lock_session_proxy), NULL);
g_signal_connect(item_logout, "activate",
G_CALLBACK(app_menu_logout_proxy), NULL);
G_CALLBACK(app_menu_logout_proxy), g_window);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_switch);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_lock);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_logout);
@@ -2547,7 +2568,10 @@ static void bookmark_bar_add_folder(GtkWidget *container,
const bookmark_t *bm = &node->bookmarks[i];
const char *label = (bm->title && bm->title[0]) ? bm->title : bm->url;
GtkWidget *btn = gtk_button_new_with_label(label);
gtk_button_set_relief(GTK_BUTTON(btn), GTK_RELIEF_NONE);
gtk_button_set_relief(GTK_BUTTON(btn), GTK_RELIEF_NORMAL);
gtk_style_context_add_class(gtk_widget_get_style_context(btn),
"bookmark-bar-btn");
gtk_widget_set_name(btn, "bookmark-bar-btn");
gtk_widget_set_tooltip_text(btn, bm->url);
char *url_copy = g_strdup(bm->url);
g_object_set_data_full(G_OBJECT(btn), "bm-url", url_copy,
@@ -2598,6 +2622,10 @@ static void bookmark_bar_add_folder(GtkWidget *container,
}
GtkWidget *mb = gtk_menu_button_new();
gtk_button_set_relief(GTK_BUTTON(mb), GTK_RELIEF_NORMAL);
gtk_style_context_add_class(gtk_widget_get_style_context(mb),
"bookmark-bar-btn");
gtk_widget_set_name(mb, "bookmark-bar-menu-btn");
gtk_button_set_label(GTK_BUTTON(mb), child->name);
gtk_menu_button_set_popup(GTK_MENU_BUTTON(mb), menu);
gtk_widget_set_tooltip_text(mb, child->path);
@@ -2644,6 +2672,56 @@ static void bookmark_bar_refresh_all(void *user_data) {
}
}
/* Grab GTK focus on the webview when the user clicks anywhere in it.
* Also called from the notebook "switch-page" handler so the webview
* gets focus whenever its tab becomes active.
*
* Without this, the webview frequently doesn't have GTK focus (the URL
* entry grabs it on new-tab, toolbar buttons steal it, etc.), and
* WebKitGTK then drops mouse-wheel scroll events because the webview
* isn't the GTK focus widget — the scrollbar still works (native GDK
* scroll) but the wheel doesn't. This is especially visible on pages
* like Jumble that have focusable sidebar elements. */
static void webview_grab_focus(WebKitWebView *webview) {
if (webview == NULL) return;
GtkWidget *w = GTK_WIDGET(webview);
if (!gtk_widget_has_focus(w)) {
gtk_widget_grab_focus(w);
}
}
static gboolean on_webview_button_press(GtkWidget *widget,
GdkEventButton *event,
gpointer user_data) {
(void)event;
(void)user_data;
if (widget != NULL && !gtk_widget_has_focus(widget)) {
gtk_widget_grab_focus(widget);
}
return FALSE;
}
/* notebook "switch-page" handler: grab focus on the newly-selected tab's
* webview so wheel/scroll events work without the user having to click
* the page first. The page_num is the notebook page index; we map it to
* our tab array via the tab_info_t stored on the page widget. */
static void on_notebook_switch_page(GtkNotebook *notebook,
GtkWidget *page,
guint page_num,
gpointer user_data) {
(void)notebook;
(void)page_num;
(void)user_data;
if (page == NULL) return;
/* The page widget is the tab_info_t->page GtkBox. The webview is
* packed into it as a child. Find the webview by walking children. */
tab_info_t *tab = (tab_info_t *)g_object_get_data(G_OBJECT(page),
"tab-info");
if (tab != NULL && tab->webview != NULL) {
webview_grab_focus(tab->webview);
}
}
static tab_info_t *tab_create(const char *url) {
const browser_settings_t *s = settings_get();
@@ -2723,10 +2801,28 @@ static tab_info_t *tab_create(const char *url) {
g_signal_connect(G_OBJECT(tab->webview), "key-press-event",
G_CALLBACK(on_key_press), NULL);
/* Grab GTK focus on button-press so the webview receives wheel/scroll
* events. Without this, clicking a focusable element inside the page
* (e.g. a sidebar button) can leave GTK focus on a sibling widget (URL
* entry, toolbar button), and WebKitGTK then drops mouse-wheel events
* because the webview isn't the GTK focus widget — the scrollbar still
* works (it's a native GDK scroll on the widget) but the wheel doesn't.
* Grabbing focus on click fixes wheel scrolling on pages like Jumble
* that have focusable sidebar elements. We return FALSE so WebKit still
* receives the click normally. */
g_signal_connect(G_OBJECT(tab->webview), "button-press-event",
G_CALLBACK(on_webview_button_press), NULL);
/* Build the per-tab page: toolbar on top, webview below. */
tab->page = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
/* Store the tab_info_t pointer on the page widget so the notebook
* "switch-page" handler (on_notebook_switch_page) can find the webview
* to grab focus. Uses a weak ref (no destroy notify) — the tab_info_t
* is freed separately in tab_manager_close_tab. */
g_object_set_data(G_OBJECT(tab->page), "tab-info", tab);
GtkWidget *toolbar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_set_name(toolbar, "main-toolbar");
gtk_widget_set_margin_top(toolbar, 4);
gtk_widget_set_margin_bottom(toolbar, 4);
gtk_widget_set_margin_start(toolbar, 4);
@@ -2888,8 +2984,21 @@ static tab_info_t *tab_create(const char *url) {
g_signal_connect(tab->webview, "create",
G_CALLBACK(on_create_webview), tab);
/* Load the URL. */
webkit_web_view_load_uri(tab->webview, default_url);
/* Load the URL.
*
* IMPORTANT: When this webview is being created in response to the
* "create" signal (target="_blank" / window.open()), g_target_related_view
* is set. In that case WebKit already has a pending NavigationAction with
* WindowFeatures that it will apply to the returned webview itself. If we
* call webkit_web_view_load_uri() here first, our load races with WebKit's
* own navigation setup and the std::optional<WindowFeatures> never gets
* engaged — leading to the assertion crash:
* _M_get() const: Assertion 'this->_M_is_engaged()' failed.
* So we skip the explicit load and let WebKit drive the navigation. The
* tab's URL bar / title were already initialized from default_url above. */
if (g_target_related_view == NULL) {
webkit_web_view_load_uri(tab->webview, default_url);
}
g_free(default_url);
return tab;
@@ -3119,14 +3228,9 @@ void tab_manager_set_avatar(const char *pubkey_hex) {
static void on_avatar_clicked(GtkButton *btn, gpointer data) {
(void)btn;
(void)data;
/* Open sovereign://profile in the active tab, or a new tab if
* none exists. */
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview) {
webkit_web_view_load_uri(tab->webview, "sovereign://profile");
} else {
tab_manager_new_tab("sovereign://profile");
}
/* Always open sovereign://profile in a new tab so the user's current
* page is preserved. */
tab_manager_new_tab("sovereign://profile");
}
/* ── New tab button ───────────────────────────────────────────────── */
@@ -3190,26 +3294,69 @@ static void apply_app_theme(void) {
"#avatar-btn image, #hamburger-btn image {"
" padding: 0px; margin: 0px;"
"}"
/* ── Header chrome background: force white ─────────────────── */
"notebook, notebook header, notebook > header,"
"notebook > header > tabs, notebook > header > tabs > tab,"
"#main-toolbar, #bookmark-bar {"
" background: #ffffff;"
" color: #000000;"
"}"
"#main-toolbar entry {"
" background: #ffffff;"
" color: #000000;"
"}"
/* ── Red accent for the URL entry focus ring ──────────────── */
"entry:focus {"
" border-color: #ff0000 !important;"
" box-shadow: 0 0 0 1px #ff0000 !important;"
" border-color: #ff0000;"
" box-shadow: 0 0 0 1px #ff0000;"
"}"
/* ── Tab active underline: black bar (replaces blue) ────────
* Adwaita renders the active-tab highlight as a box-shadow inset
* on tab:checked. We kill that and draw a solid border-bottom
* in the fg color. !important is needed to beat the theme. */
"notebook tab:checked {"
" box-shadow: none !important;"
" outline: none !important;"
" border-bottom: 3px solid %s !important;"
" box-shadow: none;"
" outline: none;"
" border-bottom: 3px solid %s;"
"}"
/* Tab text: normal (inherit from theme), no red. */
/* ── Bookmark bar buttons — compact padding ───────────────── */
"#bookmark-bar button {"
/* ── Bookmark bar buttons — explicit WEB.md interaction model ─
* Rest: white background + black 1px border + black text.
* Hover: border turns red (background stays white).
* Active/pressed: background turns red + white text.
* Use !important at USER priority to beat Adwaita states. */
"#bookmark-bar-btn, #bookmark-bar-menu-btn,"
"#bookmark-bar-menu-btn button,"
"#bookmark-bar .bookmark-bar-btn,"
"#bookmark-bar .bookmark-bar-btn:backdrop,"
"#bookmark-bar .bookmark-bar-btn button,"
"#bookmark-bar .bookmark-bar-btn button:backdrop {"
" padding: 2px 8px;"
" background: #ffffff;"
" color: #000000;"
" border: 1px solid #000000;"
" border-radius: 4px;"
" box-shadow: none;"
"}"
"#bookmark-bar-btn:hover, #bookmark-bar-menu-btn:hover,"
"#bookmark-bar-menu-btn button:hover,"
"#bookmark-bar .bookmark-bar-btn:hover,"
"#bookmark-bar .bookmark-bar-btn button:hover {"
" border-color: #ff0000;"
"}"
"#bookmark-bar-btn:active, #bookmark-bar-btn:checked,"
"#bookmark-bar-menu-btn:active, #bookmark-bar-menu-btn:checked,"
"#bookmark-bar-menu-btn button:active,"
"#bookmark-bar-menu-btn button:checked,"
"#bookmark-bar .bookmark-bar-btn:active,"
"#bookmark-bar .bookmark-bar-btn:checked,"
"#bookmark-bar .bookmark-bar-btn button:active,"
"#bookmark-bar .bookmark-bar-btn button:checked {"
" background: #ff0000;"
" color: #ffffff;"
" border-color: #ff0000;"
"}",
fg /* tab:checked border-bottom color */
fg /* tab:checked border-bottom color */
);
if (g_app_theme_provider == NULL) {
@@ -3277,6 +3424,14 @@ static void setup_notebook_action_widgets(GtkWidget *notebook, gboolean is_main)
/* ── Public API ───────────────────────────────────────────────────── */
void tab_manager_set_context(WebKitWebContext *ctx) {
/* The caller must have closed all tabs and sidebar webviews that
* reference the old context before calling this, otherwise those
* webviews will hold dangling context references. */
g_ctx = ctx;
g_print("[tab-manager] Context updated (%p)\n", (void *)g_ctx);
}
void tab_manager_init(GtkContainer *parent,
WebKitWebContext *ctx,
GtkWindow *window) {
@@ -3347,6 +3502,10 @@ void tab_manager_init(GtkContainer *parent,
* keep targeting the auxiliary window's tab. */
g_signal_connect(window, "focus-in-event",
G_CALLBACK(on_window_focus_in), g_notebook);
/* Grab focus on the webview when a tab is switched to, so wheel/scroll
* events work without the user having to click the page first. */
g_signal_connect(g_notebook, "switch-page",
G_CALLBACK(on_notebook_switch_page), NULL);
g_active_window = window;
g_active_notebook = g_notebook;
g_active_ws = &g_main_window;
@@ -3464,15 +3623,26 @@ void tab_manager_close_tab(int index) {
}
}
/* If the main window has no tabs left, quit the app. */
/* If the main window has no tabs left, quit the app — unless a
* caller has suppressed the quit (e.g. identity_teardown_web_state
* closes all tabs to wipe the previous user's live webviews but
* wants the browser to keep running for the next user). */
if (g_tab_count == 0) {
g_print("[tabs] Last tab closed, exiting.\n");
if (g_window) {
gtk_window_close(g_window);
if (g_suppress_quit_on_last_tab) {
g_print("[tabs] Last tab closed, but quit suppressed (identity switch in progress).\n");
} else {
g_print("[tabs] Last tab closed, exiting.\n");
if (g_window) {
gtk_window_close(g_window);
}
}
}
}
void tab_manager_set_suppress_quit_on_last_tab(gboolean suppress) {
g_suppress_quit_on_last_tab = suppress;
}
void tab_manager_close_active(void) {
/* Resolve the active tab by widget pointer (works across windows),
* then look up its g_tabs index. Using tab_manager_get_active_index()
@@ -3596,6 +3766,53 @@ void tab_manager_close_all(void) {
}
}
/* Destroy the sidebar webview in a single window_state_t.
* The sidebar container widget itself is kept; the webview will be
* recreated lazily on the next tab_manager_toggle_sidebar() call. */
static void window_state_destroy_sidebar(window_state_t *ws) {
if (ws == NULL) return;
if (ws->sidebar_webview == NULL) {
/* Still mark it hidden so the next toggle re-creates it. */
ws->sidebar_visible = FALSE;
return;
}
/* Destroy the webview widget. gtk_widget_destroy() drops the
* container's reference and unrefs the WebKitWebView. */
GtkWidget *wv = GTK_WIDGET(ws->sidebar_webview);
/* Detach from the sidebar container so the container is reusable. */
GtkWidget *parent = gtk_widget_get_parent(wv);
if (parent != NULL) {
gtk_container_remove(GTK_CONTAINER(parent), wv);
} else {
gtk_widget_destroy(wv);
}
ws->sidebar_webview = NULL;
ws->sidebar_visible = FALSE;
/* Hide the sidebar container so it doesn't show empty space. */
if (ws->sidebar_container != NULL) {
gtk_widget_hide(ws->sidebar_container);
}
if (ws->paned != NULL) {
gtk_paned_set_position(GTK_PANED(ws->paned), 0);
}
}
void tab_manager_destroy_sidebar_webviews(void) {
window_state_destroy_sidebar(&g_main_window);
if (g_aux_windows != NULL) {
for (guint i = 0; i < g_aux_windows->len; i++) {
window_state_t *ws = &g_array_index(g_aux_windows,
window_state_t, i);
window_state_destroy_sidebar(ws);
}
}
/* If the active window's sidebar was showing, the active_ws pointer
* is still valid (we only nulled the webview, not the struct). */
}
void tab_manager_duplicate(int index) {
if (index < 0 || index >= g_tab_count) return;
tab_info_t *tab = g_tabs[index];

View File

@@ -47,6 +47,18 @@ void tab_manager_init(GtkContainer *parent,
WebKitWebContext *ctx,
GtkWindow *window);
/*
* Update the WebKitWebContext that tab_manager_new_tab() and
* sidebar_create_webview() use to create new webviews. Used during an
* identity switch: the old context is torn down (after all tabs are
* closed) and a fresh per-user context is built; this function updates
* the stored pointer so subsequent new tabs use the new context.
*
* The caller MUST have closed all existing tabs and sidebar webviews
* (which reference the old context) before calling this.
*/
void tab_manager_set_context(WebKitWebContext *ctx);
/*
* Create a new tab and load the given URL (or the default new-tab URL
* if url is NULL). Returns the tab index, or -1 on failure (e.g. max
@@ -142,6 +154,25 @@ void tab_manager_close_to_right(int index);
*/
void tab_manager_close_all(void);
/*
* Destroy the agent chat sidebar webview in every open window (main + aux).
* The sidebar container itself is kept (it will be re-populated lazily on the
* next toggle). Used during logout / identity switch so the sidebar — which
* holds a long-lived WebKitWebView with the previous user's session — does
* not leak the old identity's sovereign://agents/chat state across users.
*/
void tab_manager_destroy_sidebar_webviews(void);
/*
* Temporarily suppress the "last tab closed → quit the app" behavior.
* Set TRUE before tab_manager_close_all() during an identity switch so
* the browser does not exit when the previous user's tabs are closed;
* the caller opens a fresh tab for the new user afterwards. Always
* reset to FALSE when done so normal quit-on-last-tab-close behavior
* is restored.
*/
void tab_manager_set_suppress_quit_on_last_tab(gboolean suppress);
/*
* Duplicate the tab at the given index (open a new tab with the same URL).
*/

View File

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

156
src/web_context.c Normal file
View File

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

88
src/web_context.h Normal file
View File

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

113
src/webkit_data.c Normal file
View File

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

54
src/webkit_data.h Normal file
View File

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

View File

@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Identity Leak Test</title>
<style>
body { font: 14px/1.5 -apple-system, system-ui, sans-serif; margin: 2rem; max-width: 720px; }
h1 { font-size: 1.4rem; }
.box { border: 1px solid #ccc; padding: 1rem 1.25rem; margin: 1rem 0; border-radius: 6px; }
.marker { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 1.1rem; word-break: break-all; }
.fresh { color: #080; }
.leaked { color: #c00; font-weight: 600; }
button { font: inherit; padding: .35rem .8rem; margin: .25rem .25rem .25rem 0; cursor: pointer; }
code { background: #f4f4f4; padding: .1rem .3rem; border-radius: 3px; }
ul { margin: .5rem 0; }
</style>
</head>
<body>
<h1>Identity Leak Test</h1>
<p>This page writes a per-session marker to <code>localStorage</code> and a
session cookie, then shows them on every load. Use it to verify that logging
out and back in as a different identity does <strong>not</strong> leak the
previous user's marker.</p>
<div class="box">
<strong>Marker for this session:</strong>
<div id="marker" class="marker">(none yet)</div>
<div id="status"></div>
</div>
<div class="box">
<strong>Stored values seen by this page:</strong>
<ul>
<li><code>localStorage["sb_identity_marker"]</code> = <span id="ls-val">(none)</span></li>
<li><code>document.cookie</code> = <span id="ck-val">(none)</span></li>
</ul>
</div>
<p>
<button id="set">Write fresh marker</button>
<button id="clear">Clear marker</button>
<button id="reload">Reload page</button>
</p>
<script>
(function () {
var KEY = "sb_identity_marker";
function freshMarker() {
// Random hex string + timestamp so each "write" is distinguishable.
var rnd = Math.random().toString(16).slice(2, 10);
var ts = Date.now().toString(16);
return "user-" + ts + "-" + rnd;
}
function render() {
var ls = localStorage.getItem(KEY) || "";
var ck = "";
document.cookie.split("; ").forEach(function (c) {
if (c.indexOf(KEY + "=") === 0) ck = c.slice(KEY.length + 1);
});
var el = document.getElementById("marker");
var st = document.getElementById("status");
var lsEl = document.getElementById("ls-val");
var ckEl = document.getElementById("ck-val");
lsEl.textContent = ls || "(none)";
ckEl.textContent = ck || "(none)";
if (ls && ck && ls === ck) {
el.textContent = ls;
el.className = "marker fresh";
st.textContent = "Marker present from a previous visit on this identity.";
} else if (ls || ck) {
el.textContent = (ls || ck);
el.className = "marker leaked";
st.textContent = "PARTIAL: one of localStorage/cookie is set but not both. " +
"If this appears right after switching identities, it is a leak.";
} else {
el.textContent = "(none — fresh identity)";
el.className = "marker";
st.textContent = "No marker found. This is what a fresh identity should see.";
}
}
document.getElementById("set").addEventListener("click", function () {
var m = freshMarker();
localStorage.setItem(KEY, m);
// Session cookie (no max-age) scoped to this origin.
document.cookie = KEY + "=" + m + "; path=/";
render();
});
document.getElementById("clear").addEventListener("click", function () {
localStorage.removeItem(KEY);
document.cookie = KEY + "=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/";
render();
});
document.getElementById("reload").addEventListener("click", function () {
location.reload();
});
render();
})();
</script>
</body>
</html>

View File

@@ -33,20 +33,18 @@ static int passes = 0;
else { failures++; fprintf(stderr, "FAIL: %s\n", msg); } \
} while (0)
/* Mirror of BOOKMARKS_HMAC_KEY_LABEL in src/bookmarks.c. */
/* Mirror of BOOKMARKS_HMAC_KEY_LABEL in src/bookmarks.h. */
#define LABEL "sovereign-browser/bookmarks-folder-id-v1"
/* Mirror of path_to_d_tag + compute_hmac_key in src/bookmarks.c. */
/* Mirror of path_to_d_tag in src/bookmarks.c (single-step scheme):
* d = HMAC-SHA256(privkey, LABEL + ":" + path) */
static char *path_to_d_tag(const unsigned char *privkey, const char *path) {
unsigned char hmac_key[32];
if (nostr_hmac_sha256(privkey, 32,
(const unsigned char *)LABEL, strlen(LABEL),
hmac_key) != 0) {
return NULL;
}
if (path == NULL) path = "";
char data[512];
snprintf(data, sizeof(data), "%s:%s", LABEL, path);
unsigned char mac[32];
if (nostr_hmac_sha256(hmac_key, 32,
(const unsigned char *)path, strlen(path),
if (nostr_hmac_sha256(privkey, 32,
(const unsigned char *)data, strlen(data),
mac) != 0) {
return NULL;
}

View File

@@ -0,0 +1,134 @@
#!/bin/bash
# tests/test_identity_isolation.sh
#
# End-to-end test for the WebKit data isolation fix (Phase 0 of
# plans/webkit-data-isolation.md). Verifies that switching Nostr identity
# wipes the previous user's cookies + localStorage so the next user does
# not see the previous user's web session.
#
# Prereqs:
# - ./browser.sh start --login-method generate (user A is logged in)
# - python3 -m http.server 8765 running in tests/local-site/
#
# Flow:
# 1. Capture user A's pubkey via login_status.
# 2. Open http://localhost:8765/identity-leak-test.html
# 3. Click "Write fresh marker" — writes localStorage + cookie.
# 4. Read the marker value via eval.
# 5. switch_identity to a fresh generated key (user B).
# 6. Reopen the test page.
# 7. Read localStorage + cookie via eval.
# 8. PASS if both are empty; FAIL if user A's marker leaked through.
set -u
MCP="http://localhost:17777/mcp"
URL="http://localhost:8765/identity-leak-test.html"
# MCP responses are SSE-formatted: "event: message\ndata: {json}\n\n".
# mcp_call <json> -> extracts the inner tool result text (a JSON string)
mcp_call() {
local payload="$1"
local raw
raw=$(curl -s -X POST "$MCP" -H 'Content-Type: application/json' -d "$payload")
# Extract the data: line, then parse the nested JSON with python.
echo "$raw" | python3 -c '
import sys, json, re
raw = sys.stdin.read()
m = re.search(r"^data: (.+)$", raw, re.MULTILINE)
if not m:
print("ERROR: no data line", file=sys.stderr)
sys.exit(2)
outer = json.loads(m.group(1))
content = outer.get("result", {}).get("content", [])
if not content:
print("ERROR: no content", file=sys.stderr)
sys.exit(2)
text = content[0].get("text", "")
# text is itself a JSON string: {"success":true,"data":{...},"id":N}
try:
inner = json.loads(text)
print(json.dumps(inner))
except json.JSONDecodeError:
print(text)
'
}
echo "[test] Checking browser is up + logged in..."
STATUS=$(mcp_call '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"login_status","arguments":{}}}')
PUBKEY_A=$(echo "$STATUS" | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d.get("data",{}).get("pubkey",""))')
if [ -z "${PUBKEY_A:-}" ]; then
echo "[test] FAIL: not logged in as user A"
echo "$STATUS"
exit 1
fi
echo "[test] User A pubkey: $PUBKEY_A"
echo "[test] Opening test page..."
mcp_call '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"open","arguments":{"url":"'"$URL"'"}}}' >/dev/null
sleep 1.5
# Write the marker directly via eval (same effect as clicking the
# "Write fresh marker" button, but deterministic — no dependency on the
# click tool's selector/ref resolution).
echo "[test] Writing marker as user A via eval..."
MARKER_A="userA-$(date +%s)-$RANDOM"
mcp_call '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"eval","arguments":{"script":"localStorage.setItem(\"sb_identity_marker\",\"'"$MARKER_A"'\"); document.cookie=\"sb_identity_marker='"$MARKER_A"'; path=/\"; \"wrote\""}}}' >/dev/null
sleep 0.5
echo "[test] Reading marker back as user A..."
READBACK=$(mcp_call '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"eval","arguments":{"script":"localStorage.getItem(\"sb_identity_marker\") || \"\""}}}' \
| python3 -c 'import sys,json; d=json.load(sys.stdin); print(d.get("data",{}).get("result","") if isinstance(d.get("data"),dict) else d.get("data",""))')
echo "[test] User A marker readback: '${READBACK:-<empty>}'"
MARKER_A="$READBACK"
if [ -z "${MARKER_A:-}" ]; then
echo "[test] FAIL: could not read user A marker — test setup issue"
exit 1
fi
echo "[test] Switching identity to a fresh generated key (user B)..."
SWITCH=$(mcp_call '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"switch_identity","arguments":{"method":"generate"}}}')
echo "$SWITCH" | python3 -c 'import sys,json; d=json.load(sys.stdin); print("switch result:", "OK" if d.get("success") else "FAIL", d.get("data",{}).get("pubkey","") if isinstance(d.get("data"),dict) else "")'
PUBKEY_B=$(echo "$SWITCH" | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d.get("data",{}).get("pubkey","") if isinstance(d.get("data"),dict) else "")')
if [ -z "${PUBKEY_B:-}" ]; then
echo "[test] FAIL: switch_identity did not return a pubkey"
echo "$SWITCH"
exit 1
fi
if [ "$PUBKEY_A" = "$PUBKEY_B" ]; then
echo "[test] FAIL: switch_identity returned the same pubkey"
exit 1
fi
echo "[test] User B pubkey: $PUBKEY_B"
sleep 1.5
echo "[test] Reopening test page as user B..."
mcp_call '{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"open","arguments":{"url":"'"$URL"'"}}}' >/dev/null
sleep 1.5
echo "[test] Reading localStorage + cookie as user B..."
LS_B=$(mcp_call '{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"eval","arguments":{"script":"localStorage.getItem(\"sb_identity_marker\") || \"\""}}}' \
| python3 -c 'import sys,json; d=json.load(sys.stdin); print(d.get("data",{}).get("result","") if isinstance(d.get("data"),dict) else d.get("data",""))')
CK_B=$(mcp_call '{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"eval","arguments":{"script":"(document.cookie.split(\"; \").find(function(c){return c.indexOf(\"sb_identity_marker=\")===0})||\"\").split(\"=\").slice(1).join(\"=\")"}}}' \
| python3 -c 'import sys,json; d=json.load(sys.stdin); print(d.get("data",{}).get("result","") if isinstance(d.get("data"),dict) else d.get("data",""))')
echo "[test] User B localStorage: '${LS_B:-<empty>}'"
echo "[test] User B cookie: '${CK_B:-<empty>}'"
PASS=1
if [ -n "${LS_B:-}" ]; then
echo "[test] FAIL: localStorage leaked from user A to user B"
PASS=0
fi
if [ -n "${CK_B:-}" ]; then
echo "[test] FAIL: cookie leaked from user A to user B"
PASS=0
fi
if [ "$PASS" = "1" ]; then
echo "[test] PASS: identity switch wiped user A's web data; user B sees a fresh session."
exit 0
else
echo "[test] FAIL: identity leak detected."
exit 1
fi

View File

@@ -98,7 +98,7 @@
<button class="save-btn" onclick="saveAsSkill()">Save as Skill</button></div>
</div>
<p><a href="sovereign://agents/chat">Open Agent Chat</a></p>
<p><a href="sovereign://agents/chat" target="_blank">Open Agent Chat</a></p>
<div id="status" class="status"></div>

View File

@@ -60,6 +60,28 @@
margin-left: 0;
}
/* Ensure every .btn renders the themed 1px solid border. WebKitGTK's UA
* stylesheet gives <button> a native 2px outset grey border tied to
* -webkit-appearance: button. Setting appearance: none strips the native
* chrome so the author border applies. The !important guards against the
* UA !important on border-width/style/color. */
.btn, .save-btn, button.btn {
-webkit-appearance: none !important;
appearance: none !important;
border-width: 1px !important;
border-style: solid !important;
border-color: var(--primary) !important;
border-radius: var(--radius);
display: inline-block;
text-decoration: none;
}
.btn:hover, .save-btn:hover, button.btn:hover {
border-color: var(--accent) !important;
text-decoration: none;
}
.btn-del { border-color: var(--accent) !important; color: var(--accent); }
.btn-del:hover { background: var(--accent); color: var(--secondary); }
.tree-children {
border-left: 1px dashed var(--border);
padding-left: 4px;

View File

@@ -217,6 +217,24 @@ function renderBookmark(bm, folder, haveSigner) {
var actions = document.createElement('div');
actions.className = 'actions';
var editBtn = document.createElement('a');
editBtn.className = 'btn';
editBtn.href = '#';
editBtn.textContent = 'Edit';
editBtn.onclick = function(e) {
e.preventDefault();
var curTitle = (bm.title && bm.title.length) ? bm.title : '';
var newTitle = prompt('Edit bookmark name:', curTitle);
if (newTitle === null) return;
var t = newTitle.trim();
if (t === curTitle.trim()) return;
location.href = 'sovereign://bookmarks/rename?dir=' +
encodeURIComponent(folder.path) + '&url=' +
encodeURIComponent(bm.url) + '&title=' +
encodeURIComponent(t);
};
actions.appendChild(editBtn);
var moveBtn = document.createElement('a');
moveBtn.className = 'btn';
moveBtn.href = '#';

View File

@@ -75,21 +75,13 @@ code { font-family: var(--font); background: var(--border); padding: 1px 4px; bo
text-overflow: ellipsis;
}
/* Heat bar for CPU columns. A colored background behind the numeric
* value: green < 10%, yellow < 50%, red >= 50%. */
/* CPU values: plain text, no shaded background. Red only for >= 90%,
* otherwise the default foreground color. */
.heat {
display: inline-block;
min-width: 48px;
padding: 1px 6px;
border-radius: 3px;
text-align: right;
color: var(--bg);
font-weight: bold;
color: var(--fg);
}
.heat.cool { background: #4caf50; color: #fff; }
.heat.warm { background: #ff9800; color: #fff; }
.heat.hot { background: #f44336; color: #fff; }
.heat.cold { background: var(--border); color: var(--muted); }
.heat.hot { color: var(--accent); }
/* Renderer row: clickable to expand hosted tabs. */
.renderer-row { cursor: pointer; }

View File

@@ -63,14 +63,13 @@ function fmtUptime(sec) {
}
function heatClass(pct) {
if (pct == null || pct <= 0) return 'cold';
if (pct < 10) return 'cool';
if (pct < 50) return 'warm';
return 'hot';
if (pct == null) return '';
if (pct >= 90) return 'hot';
return '';
}
function heatHtml(pct) {
if (pct == null) return '<span class="heat cold">—</span>';
if (pct == null) return '<span class="heat">—</span>';
return '<span class="heat ' + heatClass(pct) + '">' +
pct.toFixed(1) + '</span>';
}
@@ -144,8 +143,7 @@ function renderProcTable() {
var tabsCount = (p.hosted_tabs || []).length;
var tabsCell;
if (isRenderer) {
tabsCell = '<span class="heat ' + (tabsCount ? 'cool' : 'cold') +
'">' + tabsCount + '</span>';
tabsCell = String(tabsCount);
} else {
tabsCell = '—';
}
@@ -194,7 +192,7 @@ function renderProcTable() {
async function fetchProcesses() {
try {
var data = await sovereignGet('sovereign://processes/list');
var data = await sovereignGet('sovereign://processes/list?_t=' + Date.now());
gProcData = Array.isArray(data) ? data : [];
renderProcTable();
} catch (e) {
@@ -272,7 +270,7 @@ function renderTabsTable() {
esc(t.title || '(untitled)') + '</span>' +
(t.is_internal ? ' (internal)' : '') +
'<br><span class="url-sub">' + esc(t.url) + '</span></td>' +
'<td>' + (t.is_internal ? '<span class="heat cold">—</span>'
'<td>' + (t.is_internal ? '<span class="heat">—</span>'
: heatHtml(busy)) + '</td>' +
'<td>' + probeField(t, 'fps') + '</td>' +
'<td>' + probeField(t, 'timer_count') + '</td>' +
@@ -293,7 +291,7 @@ function renderTabsTable() {
async function fetchTabs() {
try {
var data = await sovereignGet('sovereign://processes/tabs');
var data = await sovereignGet('sovereign://processes/tabs?_t=' + Date.now());
gTabsData = Array.isArray(data) ? data : [];
renderTabsTable();
// If a tab is selected, refresh its drill-down too.

View File

@@ -122,7 +122,7 @@
<div class="field">
<div><div class="setting-name">FIPS Mesh Network</div>
<div class="setting-desc">Manage the FIPS mesh daemon: status, peers, and service control.</div></div>
<div><a class="btn" href="sovereign://fips">Open FIPS Page</a></div>
<div><a class="btn" href="sovereign://fips" target="_blank">Open FIPS Page</a></div>
</div>
<h2>Security</h2>

View File

@@ -83,14 +83,17 @@ input, select, textarea {
input:focus, select:focus, textarea:focus {
border-color: var(--accent); outline: none;
}
.btn, .save-btn {
.btn, .save-btn, button.btn {
background: var(--bg); color: var(--primary);
border: 1px solid var(--primary); border-radius: var(--radius);
-webkit-appearance: none !important; appearance: none !important;
border-width: 1px !important; border-style: solid !important;
border-color: var(--primary) !important; border-radius: var(--radius);
padding: 6px 16px; cursor: pointer; font-family: var(--font);
font-size: 13px; font-weight: bold; margin-left: 8px;
display: inline-block; text-decoration: none;
transition: border-color 0.2s, background 0.2s, color 0.2s;
}
.btn:hover, .save-btn:hover { border-color: var(--accent); }
.btn:hover, .save-btn:hover, button.btn:hover { border-color: var(--accent) !important; text-decoration: none; }
.btn:active, .save-btn:active { background: var(--accent); color: var(--secondary); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-del { border-color: var(--accent); color: var(--accent); }