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.

This commit is contained in:
Laan Tungir
2026-07-11 21:25:29 -04:00
parent fc0e412cc5
commit c6af0179b0
7 changed files with 336 additions and 12 deletions
+1 -1
View File
@@ -1 +1 @@
0.0.7
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);
+2 -2
View File
@@ -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 */
+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()