Compare commits

...
3 Commits
9 changed files with 449 additions and 58 deletions
+69 -6
View File
@@ -163,16 +163,79 @@ is saved to `~/.sovereign_browser/session.txt`.
See [`plans/browser-tabs.md`](plans/browser-tabs.md) for the full
implementation plan.
### Agent tools
The browser embeds a WebSocket server (using libsoup) that allows external
AI agents to control the browser programmatically. Connect to
`ws://localhost:17777/agent` and send JSON tool commands.
The server starts before the login dialog and remains available throughout
the browser's lifecycle. The browser starts normally with the GTK login
dialog — no blocking wait. An agent can log in at any time: while the dialog
is showing, or after the browser is already running. If an agent logs in
while the dialog is open, the agent's login takes priority.
**Login tools** (available before login):
- `login_status` — check if logged in
- `login` — authenticate with method: `local` (nsec), `seed` (BIP-39
mnemonic), `readonly` (npub), `nip46` (bunker:// URL), or `nsigner`
(hardware signer via serial/unix/tcp/qrexec transport)
- `logout` — clear signer and reset state
- `switch_identity` — change identity (same params as login)
**Browser tools** (available after login):
- Navigation: `open`, `back`, `forward`, `reload`, `get_url`, `get_title`
- Inspection: `snapshot` (accessibility tree with element refs), `get_text`,
`get_html`, `get_attr`, `eval` (run JavaScript)
- Interaction: `click`, `fill`, `type`, `press`, `scroll`, `hover`,
`focus`, `close`
- Tabs: `tab_list`, `tab_new`, `tab_switch`, `tab_close`
- Wait: `wait` (ms), `wait_for` (selector)
**Snapshot + ref workflow** (same pattern as agent-browser):
```bash
# Using websocat (cargo install websocat)
# 1. Login
echo '{"id":1,"tool":"login","params":{"method":"readonly","npub":"npub1..."}}' | websocat ws://localhost:17777/agent
# 2. Open a page
echo '{"id":2,"tool":"open","params":{"url":"https://example.com"}}' | websocat ws://localhost:17777/agent
# 3. Snapshot — get accessibility tree with refs
echo '{"id":3,"tool":"snapshot","params":{"interactive":true}}' | websocat ws://localhost:17777/agent
# 4. Click by ref
echo '{"id":4,"tool":"click","params":{"ref":"@e2"}}' | websocat ws://localhost:17777/agent
```
**HTTP status endpoint:** `curl http://localhost:17777/` returns server
status (running, port, client count, logged in).
**Agent settings** (in Settings dialog or `~/.sovereign_browser/settings.conf`):
- `agent_server_enabled` (default: true)
- `agent_server_port` (default: 17777)
- `agent_allowed_origins` (default: `*`)
- `agent_login_timeout_ms` (default: 30000)
See [`plans/agent-tools.md`](plans/agent-tools.md) for the full
implementation plan and tool comparison with agent-browser.
## Roadmap
1. ✅ Base browser (WebKitGTK + C99, loads pages)
2. ✅ Browser tabs (GtkNotebook, per-tab toolbar, session restore, settings)
3. ⏳ Nostr login (GTK dialog → nostr_core_lib → nostr_signer_t)
4.`window.nostr` injection (`sovereign://` URI scheme bridge)
5.Security strip (disable SOP/CORS, accept any cert, shared context)
6.FIPS URI scheme (`fips://` / `*.fips` via WebKitGTK scheme handler)
7.`nostr://` content scheme
8. Future: agent runtime (didactyl hosting), multi-window, FIPS-distributed
3. ✅ Agent tools Phase 1 (WebSocket server, login tools, 31 browser automation tools)
4.Nostr login (GTK dialog → nostr_core_lib → nostr_signer_t)
5.`window.nostr` injection (`sovereign://` URI scheme bridge)
6.Security strip (disable SOP/CORS, accept any cert, shared context)
7.FIPS URI scheme (`fips://` / `*.fips` via WebKitGTK scheme handler)
8. `nostr://` content scheme
9. ⏳ Agent tools Phase 2 (refinements) and Phase 3 (extended tool catalog)
10. Future: agent runtime (didactyl hosting), multi-window, FIPS-distributed
## License
+1 -1
View File
@@ -1 +1 @@
0.0.5
0.0.8
+19 -5
View File
@@ -27,6 +27,9 @@ extern const char *app_get_pubkey_hex(void);
extern key_store_method_t app_get_method(void);
extern gboolean app_get_readonly(void);
/* Track whether the agent (not the GTK dialog) performed the login. */
static gboolean g_agent_performed_login = FALSE;
/* ── Helper functions (same logic as login_dialog.c) ──────────────── */
static int derive_pubkey_hex(const unsigned char privkey[32], char pubkey_hex[65]) {
@@ -389,19 +392,26 @@ cJSON *agent_login(cJSON *params) {
nostr_initialized = TRUE;
}
cJSON *result = NULL;
if (strcmp(method, "local") == 0) {
return login_local(params);
result = login_local(params);
} else if (strcmp(method, "seed") == 0) {
return login_seed(params);
result = login_seed(params);
} else if (strcmp(method, "readonly") == 0) {
return login_readonly(params);
result = login_readonly(params);
} else if (strcmp(method, "nip46") == 0) {
return login_nip46(params);
result = login_nip46(params);
} else if (strcmp(method, "nsigner") == 0) {
return login_nsigner(params);
result = login_nsigner(params);
} else {
return make_error("UNKNOWN_METHOD", "Unknown method. Use: local, seed, readonly, nip46, or nsigner");
}
/* If login succeeded, mark that the agent performed it. */
if (result && cJSON_IsTrue(cJSON_GetObjectItem(result, "success"))) {
g_agent_performed_login = TRUE;
}
return result;
}
cJSON *agent_logout(void) {
@@ -423,3 +433,7 @@ gboolean agent_is_logged_in(void) {
return (app_get_signer() != NULL) ||
(app_get_method() == KEY_STORE_METHOD_READONLY && app_get_pubkey_hex()[0] != '\0');
}
gboolean agent_login_was_performed_by_agent(void) {
return g_agent_performed_login;
}
+6
View File
@@ -53,6 +53,12 @@ cJSON *agent_switch_identity(cJSON *params);
*/
gboolean agent_is_logged_in(void);
/*
* Returns TRUE if the agent (not the GTK dialog) performed the login.
* Used by login_dialog.c to close the dialog when the agent logs in.
*/
gboolean agent_login_was_performed_by_agent(void);
#ifdef __cplusplus
}
#endif
+24 -4
View File
@@ -45,6 +45,9 @@ static void track_client(SoupWebsocketConnection *conn) {
if (g_clients == NULL) {
g_clients = g_ptr_array_new();
}
/* Take an extra reference so the connection isn't destroyed when
* the websocket handler callback returns. */
g_object_ref(conn);
g_ptr_array_add(g_clients, conn);
g_signal_connect(conn, "message", G_CALLBACK(on_ws_message), NULL);
@@ -56,8 +59,11 @@ static void track_client(SoupWebsocketConnection *conn) {
static void untrack_client(SoupWebsocketConnection *conn) {
if (g_clients == NULL) return;
/* Remove from array and release our reference. */
g_ptr_array_remove_fast(g_clients, conn);
g_print("[agent] Client disconnected (%d remaining)\n", g_clients->len);
g_object_unref(conn);
g_print("[agent] Client disconnected (%d remaining)\n",
g_clients ? g_clients->len : 0);
}
/* ── Send helpers ─────────────────────────────────────────────────── */
@@ -86,23 +92,35 @@ static void on_ws_message(SoupWebsocketConnection *conn,
(void)user_data;
(void)type;
g_print("[agent] on_ws_message called\n");
gsize size = 0;
const gchar *data = g_bytes_get_data(message, &size);
if (data == NULL || size == 0) return;
if (data == NULL || size == 0) {
g_print("[agent] on_ws_message: empty data\n");
return;
}
g_print("[agent] on_ws_message: %.*s\n", (int)(size > 200 ? 200 : size), data);
/* Parse the JSON request. */
cJSON *request = cJSON_ParseWithLength(data, size);
if (request == NULL) {
g_print("[agent] on_ws_message: JSON parse failed\n");
const char *err_json = "{\"success\":false,\"error\":{\"code\":\"INVALID_JSON\","
"\"message\":\"Failed to parse JSON request\"}}";
send_json_to_client(conn, err_json);
return;
}
g_print("[agent] on_ws_message: dispatching tool\n");
/* Dispatch the tool. */
cJSON *response = agent_tools_dispatch(request);
cJSON_Delete(request);
g_print("[agent] on_ws_message: dispatch returned %p\n", (void*)response);
if (response == NULL) {
const char *err_json = "{\"success\":false,\"error\":{\"code\":\"INTERNAL_ERROR\","
"\"message\":\"Tool dispatch returned NULL\"}}";
@@ -229,11 +247,13 @@ int agent_server_start(int port) {
void agent_server_stop(void) {
if (!g_running) return;
/* Close all client connections. */
/* Close all client connections and release our references. */
if (g_clients != NULL) {
for (guint i = 0; i < g_clients->len; i++) {
SoupWebsocketConnection *conn = g_ptr_array_index(g_clients, i);
soup_websocket_connection_close(conn, SOUP_WEBSOCKET_CLOSE_GOING_AWAY, "server shutting down");
soup_websocket_connection_close(conn, SOUP_WEBSOCKET_CLOSE_GOING_AWAY,
"server shutting down");
g_object_unref(conn);
}
g_ptr_array_free(g_clients, TRUE);
g_clients = NULL;
+33
View File
@@ -11,6 +11,7 @@
#include "login_dialog.h"
#include "key_store.h"
#include "agent_login.h"
#include <string.h>
#include <stdlib.h>
@@ -1053,6 +1054,18 @@ static void on_detect_serial(GtkWidget *btn, gpointer user_data) {
/* ── Main dialog ──────────────────────────────────────────────── */
/* Timeout callback to check if the agent has logged in.
* If so, close the dialog automatically. */
static gboolean agent_login_check(gpointer data) {
GtkDialog *dialog = GTK_DIALOG(data);
if (agent_login_was_performed_by_agent()) {
g_print("[login] Agent login detected, closing dialog.\n");
gtk_dialog_response(dialog, GTK_RESPONSE_ACCEPT);
return G_SOURCE_REMOVE;
}
return G_SOURCE_CONTINUE;
}
int login_dialog_run(GtkWindow *parent, login_result_t *result) {
memset(result, 0, sizeof(*result));
@@ -1147,8 +1160,28 @@ int login_dialog_run(GtkWindow *parent, login_result_t *result) {
gtk_widget_show_all(dialog);
/* Add a timeout to check if the agent has logged in. If so,
* close the dialog automatically (every 200ms). */
guint agent_check_id = g_timeout_add(200, agent_login_check, dialog);
gint response = gtk_dialog_run(GTK_DIALOG(dialog));
/* Remove the timeout if it's still active. */
g_source_remove(agent_check_id);
/* If the agent logged in while the dialog was showing, return 0
* (success) but with an empty result — main.c will use the agent's
* login state instead. */
if (agent_login_was_performed_by_agent()) {
if (result->signer) {
nostr_signer_free(result->signer);
result->signer = NULL;
}
memset(result, 0, sizeof(*result));
gtk_widget_destroy(dialog);
return 0;
}
if (!ctx.done || response != GTK_RESPONSE_ACCEPT) {
if (result->signer) {
nostr_signer_free(result->signer);
+44 -40
View File
@@ -60,7 +60,6 @@ typedef struct {
static app_state_t g_state = {0};
static GtkWindow *g_window = NULL;
static gboolean g_logged_in = FALSE;
static gboolean g_agent_login_done = FALSE;
/* ---- App state accessors (used by agent_login.c) ─────────────────── */
@@ -417,11 +416,14 @@ static gboolean on_key_press(GtkWidget *widget, GdkEventKey *event,
/* ---- Agent login callback ------------------------------------------ *
* Called by agent_server when an agent successfully logs in via the
* 'login' tool. Sets the flag so the main loop can proceed.
* 'login' tool. The agent can log in at any time — while the GTK login
* dialog is showing, or after the browser is already running. We just
* set the flag; the do_login function checks it after the dialog
* returns and skips the dialog's result if the agent already logged in.
*/
static void agent_login_callback(void) {
g_agent_login_done = TRUE;
g_print("[login] Agent login detected, proceeding.\n");
g_logged_in = TRUE;
g_print("[login] Agent login detected.\n");
}
/* ---- Window destroy ------------------------------------------------- */
@@ -444,15 +446,36 @@ static void on_window_destroy(GtkWidget *widget, gpointer data) {
gtk_main_quit();
}
/* ---- Login flow (GTK dialog fallback) ───────────────────────────── */
/* ---- Login flow (GTK dialog) ─────────────────────────────────────── *
* Shows the GTK login dialog. The agent server is already running at
* this point, so an agent can call 'login' while the dialog is showing
* (gtk_dialog_run runs a nested main loop that processes WebSocket
* events). After the dialog returns, we check if the agent already
* logged in — if so, we discard the dialog's result.
*/
static int do_login(GtkWindow *parent) {
/* nostr_init() is already called before this function. */
login_result_t result;
if (login_dialog_run(parent, &result) != 0) {
/* Dialog was cancelled. But if the agent logged in while the
* dialog was showing, proceed with the agent's login. */
if (g_logged_in) {
return 0;
}
return -1;
}
/* If the agent already logged in while the dialog was showing,
* discard the dialog's result and use the agent's login. */
if (g_logged_in) {
g_print("[login] Agent login took priority over dialog.\n");
if (result.signer) {
nostr_signer_free(result.signer);
}
return 0;
}
g_state.signer = result.signer;
g_state.method = result.method;
strncpy(g_state.pubkey_hex, result.pubkey_hex, 64);
@@ -486,9 +509,11 @@ int main(int argc, char **argv) {
g_signal_connect(window, "key-press-event", G_CALLBACK(on_key_press), NULL);
g_window = GTK_WINDOW(window);
/* Start the agent server before login so agents can authenticate
* without human interaction. The server runs on the configured port
* (default 17777). If disabled in settings, skip it. */
/* Start the agent server before login. The server runs on the
* configured port (default 17777) and is available throughout the
* entire browser lifecycle — an agent can log in at any time, even
* while the GTK login dialog is showing or after the browser is
* already running. If disabled in settings, skip it. */
const browser_settings_t *s = settings_get();
if (s->agent_server_enabled) {
if (agent_server_start(s->agent_server_port) == 0) {
@@ -507,40 +532,19 @@ int main(int argc, char **argv) {
return EXIT_SUCCESS;
}
/* Login flow: wait for agent login or fall back to GTK dialog.
* If the agent server is running, wait up to agent_login_timeout_ms
* for an agent to call the 'login' tool. If no agent connects or
* the timeout expires, show the GTK login dialog. */
if (s->agent_server_enabled && s->agent_login_timeout_ms > 0) {
g_print("[login] Waiting up to %d ms for agent login...\n",
s->agent_login_timeout_ms);
/* Set up the login callback so if an agent calls 'login' while the
* GTK dialog is showing, we can close the dialog and proceed. */
agent_server_set_login_callback(agent_login_callback);
/* Run a main loop with a timeout so the agent server can process
* incoming WebSocket connections and login commands. */
g_agent_login_done = FALSE;
agent_server_set_login_callback(agent_login_callback);
GMainContext *ctx = g_main_context_get_thread_default();
gint64 deadline = g_get_monotonic_time() +
(gint64)s->agent_login_timeout_ms * 1000;
while (!g_agent_login_done) {
gint64 remaining = deadline - g_get_monotonic_time();
if (remaining <= 0) break;
/* Process events for up to 200ms at a time. */
g_main_context_iteration(ctx, FALSE);
if (!g_agent_login_done) {
g_usleep(50 * 1000); /* 50ms */
}
}
agent_server_set_login_callback(NULL);
}
/* If not logged in via agent, show the GTK login dialog. */
/* Show the GTK login dialog immediately — no blocking wait.
* The dialog runs a nested GTK main loop (gtk_dialog_run) which
* processes WebSocket events, so the agent server is live during
* the dialog. If an agent calls 'login' while the dialog is open,
* the callback fires and we close the dialog.
*
* If the agent logs in before the dialog appears (unlikely but
* possible), skip the dialog entirely. */
if (!g_logged_in) {
g_print("[login] No agent login, showing GTK dialog.\n");
if (do_login(g_window) != 0) {
g_print("[login] Cancelled, exiting.\n");
agent_server_stop();
+2 -2
View File
@@ -11,9 +11,9 @@
#ifndef SOVEREIGN_BROWSER_VERSION_H
#define SOVEREIGN_BROWSER_VERSION_H
#define SB_VERSION "v0.0.5"
#define SB_VERSION "v0.0.8"
#define SB_VERSION_MAJOR 0
#define SB_VERSION_MINOR 0
#define SB_VERSION_PATCH 5
#define SB_VERSION_PATCH 8
#endif /* SOVEREIGN_BROWSER_VERSION_H */
+251
View File
@@ -0,0 +1,251 @@
#!/usr/bin/env python3
"""
Test all agent login functionality for sovereign_browser.
Connects to the WebSocket agent server and tests each login method.
"""
import websocket
import json
import os
import subprocess
import sys
import time
SERVER_URL = "ws://localhost:17777/agent"
HTTP_URL = "http://localhost:17777/"
def send_tool(ws, tool_name, params=None, msg_id=1):
"""Send a tool command and return the response."""
msg = {"id": msg_id, "tool": tool_name, "params": params or {}}
ws.send(json.dumps(msg))
result = ws.recv()
return json.loads(result)
def test_status():
"""Test HTTP status endpoint."""
import urllib.request
try:
resp = urllib.request.urlopen(HTTP_URL, timeout=5)
data = json.loads(resp.read())
print(f"[PASS] HTTP status: {data}")
return True
except Exception as e:
print(f"[FAIL] HTTP status: {e}")
return False
def test_login_status_not_logged_in():
"""Test login_status before login."""
ws = websocket.create_connection(SERVER_URL, timeout=10)
resp = send_tool(ws, "login_status", {}, 1)
ws.close()
if resp.get("success") and not resp["data"]["logged_in"]:
print(f"[PASS] login_status (not logged in): {resp}")
return True
else:
print(f"[FAIL] login_status (not logged in): {resp}")
return False
def test_login_local():
"""Test login with a random local key."""
privkey = subprocess.check_output(["openssl", "rand", "-hex", "32"]).decode().strip()
print(f" Generated privkey: {privkey}")
ws = websocket.create_connection(SERVER_URL, timeout=10)
resp = send_tool(ws, "login", {"method": "local", "privkey_hex": privkey}, 2)
ws.close()
if resp.get("success") and resp["data"]["pubkey"]:
print(f"[PASS] login local: pubkey={resp['data']['pubkey']}, npub={resp['data']['npub']}")
return True, privkey
else:
print(f"[FAIL] login local: {resp}")
return False, privkey
def test_login_already_logged_in():
"""Test that login fails when already logged in."""
ws = websocket.create_connection(SERVER_URL, timeout=10)
resp = send_tool(ws, "login", {"method": "readonly", "npub": "npub1invalid"}, 3)
ws.close()
if not resp.get("success") and "ALREADY_LOGGED_IN" in resp.get("error", {}).get("code", ""):
print(f"[PASS] login already logged in rejected: {resp}")
return True
else:
print(f"[FAIL] login already logged in: {resp}")
return False
def test_login_status_logged_in():
"""Test login_status after login."""
ws = websocket.create_connection(SERVER_URL, timeout=10)
resp = send_tool(ws, "login_status", {}, 4)
ws.close()
if resp.get("success") and resp["data"]["logged_in"]:
print(f"[PASS] login_status (logged in): method={resp['data']['method']}, pubkey={resp['data']['pubkey']}")
return True
else:
print(f"[FAIL] login_status (logged in): {resp}")
return False
def test_logout():
"""Test logout."""
ws = websocket.create_connection(SERVER_URL, timeout=10)
resp = send_tool(ws, "logout", {}, 5)
ws.close()
if resp.get("success"):
print(f"[PASS] logout: {resp}")
return True
else:
print(f"[FAIL] logout: {resp}")
return False
def test_login_readonly():
"""Test login with readonly npub."""
# First generate a key and get the npub
privkey = subprocess.check_output(["openssl", "rand", "-hex", "32"]).decode().strip()
# Login with local to get the npub, then logout and test readonly
ws = websocket.create_connection(SERVER_URL, timeout=10)
resp = send_tool(ws, "login", {"method": "local", "privkey_hex": privkey}, 6)
if not resp.get("success"):
print(f"[FAIL] login readonly (setup): {resp}")
ws.close()
return False
npub = resp["data"]["npub"]
pubkey = resp["data"]["pubkey"]
print(f" Generated npub: {npub}")
# Logout
resp2 = send_tool(ws, "logout", {}, 7)
if not resp2.get("success"):
print(f"[FAIL] login readonly (logout): {resp2}")
ws.close()
return False
# Login readonly with the npub
resp3 = send_tool(ws, "login", {"method": "readonly", "npub": npub}, 8)
ws.close()
if resp3.get("success") and resp3["data"]["readonly"]:
print(f"[PASS] login readonly: pubkey={resp3['data']['pubkey']}, readonly={resp3['data']['readonly']}")
return True
else:
print(f"[FAIL] login readonly: {resp3}")
return False
def test_switch_identity():
"""Test switch_identity."""
privkey = subprocess.check_output(["openssl", "rand", "-hex", "32"]).decode().strip()
ws = websocket.create_connection(SERVER_URL, timeout=10)
resp = send_tool(ws, "switch_identity", {"method": "local", "privkey_hex": privkey}, 9)
ws.close()
if resp.get("success"):
print(f"[PASS] switch_identity: pubkey={resp['data']['pubkey']}")
return True
else:
print(f"[FAIL] switch_identity: {resp}")
return False
def test_browser_tools_after_login():
"""Test that browser tools work after login."""
ws = websocket.create_connection(SERVER_URL, timeout=15)
# Open a page
resp = send_tool(ws, "open", {"url": "https://example.com"}, 10)
print(f" open: {resp.get('success')}")
# Wait a moment for the page to load
time.sleep(2)
# Get URL
resp = send_tool(ws, "get_url", {}, 11)
print(f" get_url: {resp}")
# Get title
resp = send_tool(ws, "get_title", {}, 12)
print(f" get_title: {resp}")
# Snapshot
resp = send_tool(ws, "snapshot", {"interactive": True, "compact": True}, 13)
if resp.get("success") and resp["data"].get("snapshot"):
snapshot = resp["data"]["snapshot"]
print(f"[PASS] snapshot: {len(snapshot)} chars, {resp['data'].get('refCount', 0)} refs")
# Print first few lines
for line in snapshot.split("\n")[:5]:
print(f" {line}")
else:
print(f"[FAIL] snapshot: {resp}")
# Tab list
resp = send_tool(ws, "tab_list", {}, 14)
if resp.get("success"):
tabs = resp["data"]["tabs"]
print(f"[PASS] tab_list: {len(tabs)} tab(s)")
else:
print(f"[FAIL] tab_list: {resp}")
ws.close()
return True
def main():
print("=" * 60)
print("sovereign_browser agent login test suite")
print("=" * 60)
results = []
# Test 1: HTTP status
print("\n--- Test 1: HTTP status endpoint ---")
results.append(test_status())
# Test 2: login_status (not logged in)
print("\n--- Test 2: login_status (not logged in) ---")
results.append(test_login_status_not_logged_in())
# Test 3: login with local key
print("\n--- Test 3: login with local key ---")
ok, _ = test_login_local()
results.append(ok)
# Test 4: login when already logged in (should fail)
print("\n--- Test 4: login when already logged in ---")
results.append(test_login_already_logged_in())
# Test 5: login_status (logged in)
print("\n--- Test 5: login_status (logged in) ---")
results.append(test_login_status_logged_in())
# Test 6: logout
print("\n--- Test 6: logout ---")
results.append(test_logout())
# Test 7: login readonly
print("\n--- Test 7: login readonly ---")
results.append(test_login_readonly())
# Test 8: switch_identity
print("\n--- Test 8: switch_identity ---")
results.append(test_switch_identity())
# Test 9: browser tools after login
print("\n--- Test 9: browser tools after login ---")
results.append(test_browser_tools_after_login())
# Summary
print("\n" + "=" * 60)
passed = sum(results)
total = len(results)
print(f"Results: {passed}/{total} passed")
if passed == total:
print("ALL TESTS PASSED")
sys.exit(0)
else:
print("SOME TESTS FAILED")
sys.exit(1)
if __name__ == "__main__":
main()