v2.1.7 - Refactor SQLite connection ownership into db_ops, remove direct sqlite usage from runtime modules, and document agent-browser admin testing flow

This commit is contained in:
Laan Tungir
2026-04-01 19:48:29 -04:00
parent 547d22d09f
commit 7e3d9b6825
9 changed files with 286 additions and 89 deletions
+190
View File
@@ -0,0 +1,190 @@
# Agent Browser Testing Guide for C-Relay
This document explains how to use the `agent-browser` CLI tool to test the c-relay admin web UI from an AI agent context (no physical display required).
## Prerequisites
- **agent-browser** installed globally: `npm install -g agent-browser`
- Binary location: `/home/user/.nvm/versions/node/v24.14.1/bin/agent-browser`
- The relay must be running locally (default port 8888)
- Test keys configured in `.test_keys`
## Starting the Relay for Local Testing
```bash
./make_and_restart_relay.sh -t
```
This reads `.test_keys` and starts the relay with:
- `ADMIN_PUBKEY` — the hex public key of the admin account
- `ADMIN_PRIVKEY` — the hex secret key (used for browser login, not by the relay itself)
- `SERVER_PRIVKEY` — the relay's own private key
## Key URLs
| URL | Purpose |
|-----|---------|
| `http://127.0.0.1:8888/api/index.html` | Admin web UI (the correct entry point) |
| `http://127.0.0.1:8888/` | Returns 406 without NIP-11 Accept header — **do not use for browser testing** |
| `http://127.0.0.1:8888/` with `Accept: application/nostr+json` | NIP-11 relay info JSON |
**Important:** The root URL `/` is a WebSocket/NIP-11 endpoint, not an HTML page. Always use `/api/index.html` for browser testing.
## Admin Login Credentials
The admin nsec for the current `.test_keys` configuration:
```
nsec: nsec1zkn0hlt4jvcvn9yt5p7ea4m7f82pf3ph0gj3d4rlcz4s64x5z86satv9qm
hex: 15a6fbfd759330c9948ba07d9ed77e49d414c4377a2516d47fc0ab0d54d411f5
npub: npub1tlyzk6slzt8d989f4pn3synk98fea64ea3jmrdc7dtd0kw8uxlas9a9fwr
hex pubkey: 5fc82b6a1f12ced29ca9a86718127629d39eeab9ec65b1b71e6adafb38fc37fb
```
## Complete Login Flow with agent-browser
### Step 1: Open the admin UI
```bash
agent-browser open http://127.0.0.1:8888/api/index.html
agent-browser wait --load networkidle
```
### Step 2: Take an interactive snapshot to see what is on screen
```bash
agent-browser snapshot -i
```
You will see a login modal with buttons like:
- `"Browser Extension"` — skip this
- `"Local Key"`**use this one**
- `"Seed Phrase"` — skip
- `"Nostr Connect"` — skip
- `"Read Only"` — skip
### Step 3: Click "Local Key"
```bash
agent-browser click @e15
```
(The ref number may vary — use the ref from the snapshot output for the "Local Key" button.)
After clicking, a text input appears asking for the secret key.
### Step 4: Enter the admin nsec
```bash
agent-browser fill @e14 "nsec1zkn0hlt4jvcvn9yt5p7ea4m7f82pf3ph0gj3d4rlcz4s64x5z86satv9qm"
```
(Use the ref from the snapshot for the textbox element.)
### Step 5: Click "Import Key"
```bash
agent-browser snapshot -i
```
Check the snapshot — the "Import Key" button should now be enabled. Click it:
```bash
agent-browser click @e15
```
### Step 6: Click "Continue" on the success screen
After import, a success screen appears with "Continue" button:
```bash
agent-browser wait 800
agent-browser snapshot -i
agent-browser click @e19
```
(Use the ref from the snapshot for the "Continue" button.)
### Step 7: Wait for admin UI to load
```bash
agent-browser wait 5000
agent-browser snapshot -i
```
You should now see the admin dashboard with:
- Statistics table (Database Size, Total Events, PID, etc.)
- Navigation buttons (Statistics, Subscriptions, Configuration, Authorization, etc.)
- Admin profile area showing "admin" label
## Navigating Admin Sections
After login, use the sidebar navigation buttons:
```bash
# View configuration
agent-browser click @e8 # Configuration button ref
# View authorization rules
agent-browser click @e9 # Authorization button ref
# View statistics
agent-browser click @e6 # Statistics button ref
# Always snapshot after navigation to see results
agent-browser wait 2500
agent-browser snapshot -i
```
## Checking for Errors
```bash
# View browser console logs
agent-browser console
# View JavaScript errors
agent-browser errors
# View relay server logs
tail -n 100 relay.log
```
## Chained Command Example (Full Login in One Shot)
```bash
agent-browser open http://127.0.0.1:8888/api/index.html && \
agent-browser wait --load networkidle && \
agent-browser snapshot -i
```
Then use refs from the snapshot to complete login steps.
## Tips for AI Agents
1. **Always use `/api/index.html`** — never the root URL
2. **Use `snapshot -i`** after every action to see the current interactive elements and their refs
3. **Refs change** between snapshots — always re-snapshot before clicking
4. **Wait after clicks** — use `agent-browser wait 2000` (milliseconds) between actions that trigger async operations
5. **The login flow has 3 screens**: method selection → key input → success confirmation
6. **Console logs are cumulative** — they show all logs since page load, which is useful for debugging admin API responses
7. **The relay log** at `relay.log` shows server-side processing of admin commands
8. **Command chaining** with `&&` works — the browser daemon persists between commands
## Verifying Admin API is Working
After login, the statistics page should show populated data:
- Database Size (e.g., "4 KB")
- Process ID (the relay PID)
- WebSocket Connections count
- Memory Usage
If these show "-" or "Loading...", check:
1. `relay.log` for errors
2. `agent-browser console` for JavaScript errors
3. `agent-browser errors` for page-level errors
## Closing the Browser
```bash
agent-browser close --all
```
+1 -1
View File
@@ -1 +1 @@
763795
777740
+9 -36
View File
@@ -19,7 +19,6 @@ int get_active_connection_count(void);
#include <unistd.h>
#include <strings.h>
#include <stdbool.h>
#include <sqlite3.h>
#include "api.h"
#include "embedded_web_content.h"
#include "config.h"
@@ -56,38 +55,12 @@ int generate_monitoring_event_for_type(const char* d_tag_value, cJSON* (*query_f
// Forward declaration for CPU metrics query function
cJSON* query_cpu_metrics(void);
static int api_open_temp_db_connection(sqlite3** out_db) {
if (!out_db) {
return -1;
}
*out_db = NULL;
const char* db_path = db_get_database_path();
if (!db_path || db_path[0] == '\0') {
return -1;
}
if (sqlite3_open_v2(db_path, out_db, SQLITE_OPEN_READWRITE, NULL) != SQLITE_OK) {
if (*out_db) {
sqlite3_close(*out_db);
*out_db = NULL;
}
return -1;
}
sqlite3_exec(*out_db, "PRAGMA journal_mode=WAL;", NULL, NULL, NULL);
sqlite3_busy_timeout(*out_db, 5000);
db_set_thread_connection(*out_db);
return 0;
static int api_open_temp_db_connection(void** out_db) {
return db_open_worker_connection(db_get_database_path(), out_db);
}
static void api_close_temp_db_connection(sqlite3* db) {
if (!db) {
return;
}
db_clear_thread_connection();
sqlite3_close(db);
static void api_close_temp_db_connection(void* db) {
db_close_worker_connection(db);
}
// Monitoring system helper functions
@@ -97,7 +70,7 @@ int get_monitoring_throttle_seconds(void) {
// Query event kind distribution from database
cJSON* query_event_kind_distribution(void) {
sqlite3* temp_db = NULL;
void* temp_db = NULL;
if (!db_is_available() && api_open_temp_db_connection(&temp_db) != 0) {
DEBUG_ERROR("Database not available for monitoring query");
return NULL;
@@ -137,7 +110,7 @@ cJSON* query_event_kind_distribution(void) {
// Query time-based statistics from database
cJSON* query_time_based_statistics(void) {
sqlite3* temp_db = NULL;
void* temp_db = NULL;
if (!db_is_available() && api_open_temp_db_connection(&temp_db) != 0) {
DEBUG_ERROR("Database not available for time stats query");
return NULL;
@@ -190,7 +163,7 @@ cJSON* query_time_based_statistics(void) {
// Query top pubkeys by event count from database
cJSON* query_top_pubkeys(void) {
sqlite3* temp_db = NULL;
void* temp_db = NULL;
if (!db_is_available() && api_open_temp_db_connection(&temp_db) != 0) {
DEBUG_ERROR("Database not available for top pubkeys query");
return NULL;
@@ -237,7 +210,7 @@ cJSON* query_top_pubkeys(void) {
// Query detailed subscription information from database log (ADMIN ONLY)
// Uses subscriptions table instead of in-memory iteration to avoid mutex contention
cJSON* query_subscription_details(void) {
sqlite3* temp_db = NULL;
void* temp_db = NULL;
if (!db_is_available() && api_open_temp_db_connection(&temp_db) != 0) {
DEBUG_ERROR("Database not available for subscription details query");
return NULL;
@@ -1210,7 +1183,7 @@ cJSON* query_cpu_metrics(void) {
// Generate stats JSON from database queries
char* generate_stats_json(void) {
sqlite3* temp_db = NULL;
void* temp_db = NULL;
if (!db_is_available() && api_open_temp_db_connection(&temp_db) != 0) {
DEBUG_ERROR("Database not available for stats generation");
return NULL;
+41 -4
View File
@@ -31,7 +31,6 @@
#include <errno.h>
#include <signal.h>
#include <libwebsockets.h>
// External database connection (from main.c)
// External shutdown flag (from main.c)
@@ -111,6 +110,14 @@ static char g_temp_relay_privkey[65] = {0};
// Runtime cache for relay private key to support strict mode (g_db = NULL)
static char g_cached_relay_privkey[65] = {0};
static int config_open_temp_db_connection(void** out_db) {
return db_open_worker_connection(db_get_database_path(), out_db);
}
static void config_close_temp_db_connection(void* db) {
db_close_worker_connection(db);
}
// ================================
// UTILITY FUNCTIONS
// ================================
@@ -2990,7 +2997,8 @@ int handle_kind_23456_unified(cJSON* event, char* error_message, size_t error_si
int handle_auth_query_unified(cJSON* event, const char* query_type, char* error_message, size_t error_size, struct lws* wsi) {
// Suppress unused parameter warning
(void)wsi;
if (!db_is_available()) {
void* temp_db = NULL;
if (!db_is_available() && config_open_temp_db_connection(&temp_db) != 0) {
snprintf(error_message, error_size, "database not available");
return -1;
}
@@ -3082,6 +3090,9 @@ int handle_auth_query_unified(cJSON* event, const char* query_type, char* error_
if (!admin_pubkey) {
cJSON_Delete(response);
cJSON_Delete(results_array);
if (temp_db) {
config_close_temp_db_connection(temp_db);
}
snprintf(error_message, error_size, "missing admin pubkey for response");
return -1;
}
@@ -3090,12 +3101,18 @@ int handle_auth_query_unified(cJSON* event, const char* query_type, char* error_
if (send_admin_response_event(response, admin_pubkey, wsi) == 0) {
cJSON_Delete(response);
cJSON_Delete(results_array);
if (temp_db) {
config_close_temp_db_connection(temp_db);
}
return 0;
}
cJSON_Delete(response);
}
cJSON_Delete(results_array);
if (temp_db) {
config_close_temp_db_connection(temp_db);
}
snprintf(error_message, error_size, "failed to send auth query response");
return -1;
}
@@ -3104,7 +3121,8 @@ int handle_auth_query_unified(cJSON* event, const char* query_type, char* error_
int handle_config_query_unified(cJSON* event, const char* query_type, char* error_message, size_t error_size, struct lws* wsi) {
// Suppress unused parameter warning
(void)wsi;
if (!db_is_available()) {
void* temp_db = NULL;
if (!db_is_available() && config_open_temp_db_connection(&temp_db) != 0) {
snprintf(error_message, error_size, "database not available");
return -1;
}
@@ -3197,6 +3215,9 @@ int handle_config_query_unified(cJSON* event, const char* query_type, char* erro
if (!admin_pubkey) {
cJSON_Delete(response);
cJSON_Delete(results_array);
if (temp_db) {
config_close_temp_db_connection(temp_db);
}
snprintf(error_message, error_size, "missing admin pubkey for response");
return -1;
}
@@ -3205,12 +3226,18 @@ int handle_config_query_unified(cJSON* event, const char* query_type, char* erro
if (send_admin_response_event(response, admin_pubkey, wsi) == 0) {
cJSON_Delete(response);
cJSON_Delete(results_array);
if (temp_db) {
config_close_temp_db_connection(temp_db);
}
return 0;
}
cJSON_Delete(response);
}
cJSON_Delete(results_array);
if (temp_db) {
config_close_temp_db_connection(temp_db);
}
snprintf(error_message, error_size, "failed to send config query response");
return -1;
}
@@ -3713,7 +3740,8 @@ int handle_auth_rule_modification_unified(cJSON* event, char* error_message, siz
int handle_stats_query_unified(cJSON* event, char* error_message, size_t error_size, struct lws* wsi) {
// Suppress unused parameter warning
(void)wsi;
if (!db_is_available()) {
void* temp_db = NULL;
if (!db_is_available() && config_open_temp_db_connection(&temp_db) != 0) {
snprintf(error_message, error_size, "database not available");
return -1;
}
@@ -3819,6 +3847,9 @@ int handle_stats_query_unified(cJSON* event, char* error_message, size_t error_s
if (!admin_pubkey) {
cJSON_Delete(response);
if (temp_db) {
config_close_temp_db_connection(temp_db);
}
snprintf(error_message, error_size, "missing admin pubkey for response");
return -1;
}
@@ -3826,10 +3857,16 @@ int handle_stats_query_unified(cJSON* event, char* error_message, size_t error_s
// Send response as signed kind 23457 event
if (send_admin_response_event(response, admin_pubkey, wsi) == 0) {
cJSON_Delete(response);
if (temp_db) {
config_close_temp_db_connection(temp_db);
}
return 0;
}
cJSON_Delete(response);
if (temp_db) {
config_close_temp_db_connection(temp_db);
}
snprintf(error_message, error_size, "failed to send stats query response");
return -1;
}
+27
View File
@@ -57,6 +57,33 @@ void db_clear_thread_connection(void) {
g_thread_db = NULL;
}
int db_open_worker_connection(const char* db_path, void** out_connection) {
if (!out_connection) return -1;
*out_connection = NULL;
const char* effective_path = (db_path && db_path[0] != '\0') ? db_path : g_database_path;
if (!effective_path || effective_path[0] == '\0') return -1;
sqlite3* db = NULL;
int rc = sqlite3_open_v2(effective_path, &db, SQLITE_OPEN_READWRITE, NULL);
if (rc != SQLITE_OK) {
if (db) sqlite3_close(db);
return -1;
}
sqlite3_exec(db, "PRAGMA journal_mode=WAL;", NULL, NULL, NULL);
sqlite3_busy_timeout(db, 5000);
db_set_thread_connection(db);
*out_connection = (void*)db;
return 0;
}
void db_close_worker_connection(void* connection) {
if (!connection) return;
db_clear_thread_connection();
sqlite3_close((sqlite3*)connection);
}
int db_is_available(void) {
return db_active_connection() != NULL;
}
+4
View File
@@ -16,6 +16,10 @@ const char* db_get_database_path(void);
int db_set_thread_connection(void* connection);
void db_clear_thread_connection(void);
// Worker/runtime SQLite connection lifecycle (kept internal to db_ops.c)
int db_open_worker_connection(const char* db_path, void** out_connection);
void db_close_worker_connection(void* connection);
// DB result codes (backend-agnostic)
#define DB_OK 0
#define DB_ERROR 1
+2 -2
View File
@@ -13,8 +13,8 @@
// Using CRELAY_ prefix to avoid conflicts with nostr_core_lib VERSION macros
#define CRELAY_VERSION_MAJOR 2
#define CRELAY_VERSION_MINOR 1
#define CRELAY_VERSION_PATCH 6
#define CRELAY_VERSION "v2.1.6"
#define CRELAY_VERSION_PATCH 7
#define CRELAY_VERSION "v2.1.7"
// Relay metadata (authoritative source for NIP-11 information)
#define RELAY_NAME "C-Relay"
+8 -27
View File
@@ -5,7 +5,6 @@
#include "db_ops.h"
#include "ip_ban.h"
#include <sqlite3.h>
#include <pthread.h>
#include <stdint.h>
#include <stdio.h>
@@ -199,24 +198,12 @@ static void wake_event_loop(void) {
}
}
static sqlite3* open_worker_connection(void) {
if (g_pool.db_path[0] == '\0') {
static void* open_worker_connection(void) {
void* db_conn = NULL;
if (db_open_worker_connection(g_pool.db_path, &db_conn) != 0) {
return NULL;
}
sqlite3* db = NULL;
int rc = sqlite3_open_v2(g_pool.db_path, &db, SQLITE_OPEN_READWRITE, NULL);
if (rc != SQLITE_OK) {
if (db) {
sqlite3_close(db);
}
return NULL;
}
sqlite3_exec(db, "PRAGMA journal_mode=WAL;", NULL, NULL, NULL);
sqlite3_busy_timeout(db, 5000);
return db;
return db_conn;
}
static void complete_job_with_result(thread_pool_job_node_t* node,
@@ -502,14 +489,12 @@ static void* reader_worker_main(void* arg) {
snprintf(thread_name, sizeof(thread_name), "db-read-%d", reader_index);
pthread_setname_np(pthread_self(), thread_name);
sqlite3* worker_db = open_worker_connection();
void* worker_db = open_worker_connection();
if (!worker_db) {
DEBUG_ERROR("Reader worker failed to open SQLite connection");
return NULL;
}
db_set_thread_connection(worker_db);
while (g_pool.running) {
thread_pool_job_node_t* node = queue_pop(&g_pool.read_q);
if (!node) break;
@@ -517,8 +502,7 @@ static void* reader_worker_main(void* arg) {
execute_read_job(node);
}
db_clear_thread_connection();
sqlite3_close(worker_db);
db_close_worker_connection(worker_db);
return NULL;
}
@@ -527,14 +511,12 @@ static void* writer_worker_main(void* arg) {
pthread_setname_np(pthread_self(), "db-write");
sqlite3* worker_db = open_worker_connection();
void* worker_db = open_worker_connection();
if (!worker_db) {
DEBUG_ERROR("Writer worker failed to open SQLite connection");
return NULL;
}
db_set_thread_connection(worker_db);
while (g_pool.running) {
thread_pool_job_node_t* node = queue_pop(&g_pool.write_q);
if (!node) break;
@@ -542,8 +524,7 @@ static void* writer_worker_main(void* arg) {
execute_write_job(node);
}
db_clear_thread_connection();
sqlite3_close(worker_db);
db_close_worker_connection(worker_db);
return NULL;
}
+4 -19
View File
@@ -18,8 +18,6 @@
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sqlite3.h>
// Include nostr_core_lib for Nostr functionality
#include "../nostr_core_lib/cjson/cJSON.h"
#include "../nostr_core_lib/nostr_core/nostr_core.h"
@@ -531,22 +529,10 @@ static void* async_event_worker_main(void* arg) {
(void)arg;
pthread_setname_np(pthread_self(), "event-worker");
sqlite3* worker_db = NULL;
void* worker_db = NULL;
const char* db_path = db_get_database_path();
if (db_path && db_path[0] != '\0') {
if (sqlite3_open_v2(db_path, &worker_db, SQLITE_OPEN_READWRITE, NULL) == SQLITE_OK) {
sqlite3_exec(worker_db, "PRAGMA journal_mode=WAL;", NULL, NULL, NULL);
sqlite3_busy_timeout(worker_db, 5000);
db_set_thread_connection(worker_db);
} else {
if (worker_db) {
sqlite3_close(worker_db);
worker_db = NULL;
}
DEBUG_WARN("event-worker: failed to open dedicated DB connection; using default connection context");
}
} else {
DEBUG_WARN("event-worker: database path unavailable; using default connection context");
if (db_open_worker_connection(db_path, &worker_db) != 0) {
DEBUG_WARN("event-worker: failed to open dedicated DB connection; using default connection context");
}
while (g_async_event_worker_running) {
@@ -620,8 +606,7 @@ static void* async_event_worker_main(void* arg) {
}
if (worker_db) {
db_clear_thread_connection();
sqlite3_close(worker_db);
db_close_worker_connection(worker_db);
}
return NULL;