From c6af0179b0ef6f38db6c2b00260ffeab296fb42c Mon Sep 17 00:00:00 2001 From: Laan Tungir Date: Sat, 11 Jul 2026 21:25:29 -0400 Subject: [PATCH] v0.0.8 - Fixed WebSocket agent server: g_object_ref on connections to prevent premature destruction. Agent login now auto-closes GTK dialog via timeout check. All 9 login tests pass. --- VERSION | 2 +- src/agent_login.c | 24 +++- src/agent_login.h | 6 + src/agent_server.c | 28 ++++- src/login_dialog.c | 33 +++++ src/version.h | 4 +- tests/test_agent_login.py | 251 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 336 insertions(+), 12 deletions(-) create mode 100644 tests/test_agent_login.py diff --git a/VERSION b/VERSION index 5a5831a..d169b2f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.7 +0.0.8 diff --git a/src/agent_login.c b/src/agent_login.c index ad48742..00934ce 100644 --- a/src/agent_login.c +++ b/src/agent_login.c @@ -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; +} diff --git a/src/agent_login.h b/src/agent_login.h index 026067c..9db687c 100644 --- a/src/agent_login.h +++ b/src/agent_login.h @@ -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 diff --git a/src/agent_server.c b/src/agent_server.c index 662727d..68ddb9e 100644 --- a/src/agent_server.c +++ b/src/agent_server.c @@ -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; diff --git a/src/login_dialog.c b/src/login_dialog.c index c1d4429..9bfd4da 100644 --- a/src/login_dialog.c +++ b/src/login_dialog.c @@ -11,6 +11,7 @@ #include "login_dialog.h" #include "key_store.h" +#include "agent_login.h" #include #include @@ -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); diff --git a/src/version.h b/src/version.h index b9c2945..a2621f3 100644 --- a/src/version.h +++ b/src/version.h @@ -11,9 +11,9 @@ #ifndef SOVEREIGN_BROWSER_VERSION_H #define SOVEREIGN_BROWSER_VERSION_H -#define SB_VERSION "v0.0.7" +#define SB_VERSION "v0.0.8" #define SB_VERSION_MAJOR 0 #define SB_VERSION_MINOR 0 -#define SB_VERSION_PATCH 7 +#define SB_VERSION_PATCH 8 #endif /* SOVEREIGN_BROWSER_VERSION_H */ diff --git a/tests/test_agent_login.py b/tests/test_agent_login.py new file mode 100644 index 0000000..95457c0 --- /dev/null +++ b/tests/test_agent_login.py @@ -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()