Compare commits

...
1 Commits
8 changed files with 301 additions and 72 deletions
+1 -1
View File
@@ -1 +1 @@
0.0.8
0.0.9
+9
View File
@@ -46,6 +46,15 @@ flowchart TB
### Key design decisions
0. **Agent tools must use the same code path as the GUI** — Agent tools
should call the same C functions the GUI calls, not a parallel
implementation. This ensures that any error a user would see, the
agent also sees. Debugging WebKit internals is not our concern; our
C code is. If the agent and GUI take different paths, bugs can hide
from the agent. The login tools are the one exception (backend-only,
bypassing the GTK dialog) — all browser tools go through the same
`WebKitWebView` and `tab_manager` APIs.
1. **WebSocket server via libsoup**`SoupServer` with
`soup_server_add_websocket_handler()` provides a production-ready
WebSocket server. libsoup is already linked (WebKitGTK dependency).
+8 -20
View File
@@ -14,8 +14,8 @@
#include <string.h>
#include <stdlib.h>
/* Forward declarations from agent_tools.c (created next) */
extern cJSON *agent_tools_dispatch(cJSON *request);
/* Forward declarations from agent_tools.c */
extern cJSON *agent_tools_dispatch(cJSON *request, SoupWebsocketConnection *conn);
/* ── Static state ─────────────────────────────────────────────────── */
@@ -92,39 +92,27 @@ 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) {
g_print("[agent] on_ws_message: empty data\n");
return;
}
g_print("[agent] on_ws_message: %.*s\n", (int)(size > 200 ? 200 : size), data);
if (data == NULL || size == 0) return;
/* 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);
/* Dispatch the tool. Pass the WebSocket connection so async tools
* (snapshot, eval) can send their response directly when JS completes. */
cJSON *response = agent_tools_dispatch(request, conn);
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\"}}";
send_json_to_client(conn, err_json);
/* NULL means the tool is sending its response asynchronously
* (e.g. snapshot, eval). Don't send anything here. */
return;
}
+188 -37
View File
@@ -13,7 +13,9 @@
/* ── Synchronous JS evaluation ─────────────────────────────────────── *
* WebKitGTK's evaluate_javascript is async. We use a nested GMainLoop
* to wait for the result synchronously.
* on the default context to wait for the result. The key is to acquire
* the context before creating the loop so that the WebKit IPC sources
* are properly dispatched.
*/
typedef struct {
@@ -28,7 +30,6 @@ static void on_js_finished(GObject *source, GAsyncResult *res, gpointer user_dat
JSCValue *value = webkit_web_view_evaluate_javascript_finish(webview, res, NULL);
if (value != NULL) {
/* Convert the JSCValue to a string. */
char *str = jsc_value_to_string(value);
if (str != NULL) {
ctx->result = g_strdup(str);
@@ -38,43 +39,165 @@ static void on_js_finished(GObject *source, GAsyncResult *res, gpointer user_dat
}
ctx->done = TRUE;
if (g_main_loop_is_running(ctx->loop)) {
if (ctx->loop && g_main_loop_is_running(ctx->loop)) {
g_main_loop_quit(ctx->loop);
}
}
static gboolean on_js_timeout(gpointer user_data) {
js_eval_ctx_t *ctx = (js_eval_ctx_t *)user_data;
if (!ctx->done) {
g_printerr("[agent] JS evaluation timed out\n");
if (ctx->loop && g_main_loop_is_running(ctx->loop)) {
g_main_loop_quit(ctx->loop);
}
}
return G_SOURCE_REMOVE;
}
char *agent_js_eval_sync(WebKitWebView *webview, const char *script,
int timeout_ms) {
if (webview == NULL || script == NULL) return NULL;
js_eval_ctx_t ctx = {0};
ctx.loop = g_main_loop_new(NULL, FALSE);
/* Use the default main context for the nested loop. */
GMainContext *main_ctx = g_main_context_default();
g_main_context_acquire(main_ctx);
ctx.loop = g_main_loop_new(main_ctx, FALSE);
/* Add a timeout source to the same context. */
guint timeout_id = g_timeout_add(timeout_ms, on_js_timeout, &ctx);
/* Start async evaluation. */
webkit_web_view_evaluate_javascript(webview, script, -1, NULL, NULL, NULL,
on_js_finished, &ctx);
/* Run a nested main loop with a timeout. */
if (!ctx.done) {
GSource *timeout_src = g_timeout_source_new(timeout_ms);
g_source_set_callback(timeout_src, (GSourceFunc)g_main_loop_quit, ctx.loop, NULL);
g_source_attach(timeout_src, g_main_context_get_thread_default());
g_source_unref(timeout_src);
g_main_loop_run(ctx.loop);
g_source_destroy(timeout_src);
}
/* Run the nested loop until JS completes or timeout. */
g_main_loop_run(ctx.loop);
/* Cleanup. */
g_source_remove(timeout_id);
g_main_loop_unref(ctx.loop);
if (!ctx.done) {
g_printerr("[agent] JS evaluation timed out (%d ms)\n", timeout_ms);
return NULL;
}
g_main_context_release(main_ctx);
return ctx.result;
}
/* ── Async JS evaluation ──────────────────────────────────────────── *
* For tools that need JS return values (snapshot, eval, get_text, etc.),
* we use async evaluation. The JS completion callback sends the
* WebSocket response directly, avoiding the nested main loop issue.
*/
typedef struct {
SoupWebsocketConnection *conn;
int request_id;
char *tool_name;
js_result_handler_t handler;
} async_js_ctx_t;
static void send_ws_response(SoupWebsocketConnection *conn, cJSON *response) {
if (conn == NULL || response == NULL) return;
if (soup_websocket_connection_get_state(conn) != SOUP_WEBSOCKET_STATE_OPEN) {
cJSON_Delete(response);
return;
}
char *str = cJSON_PrintUnformatted(response);
if (str) {
soup_websocket_connection_send_text(conn, str);
free(str);
}
cJSON_Delete(response);
}
static cJSON *make_success_resp(int id, cJSON *data) {
cJSON *resp = cJSON_CreateObject();
cJSON_AddBoolToObject(resp, "success", TRUE);
cJSON_AddNumberToObject(resp, "id", id);
if (data) cJSON_AddItemToObject(resp, "data", data);
return resp;
}
static cJSON *make_error_resp(int id, const char *code, const char *msg) {
cJSON *resp = cJSON_CreateObject();
cJSON_AddBoolToObject(resp, "success", FALSE);
cJSON_AddNumberToObject(resp, "id", id);
cJSON *err = cJSON_CreateObject();
cJSON_AddStringToObject(err, "code", code);
cJSON_AddStringToObject(err, "message", msg);
cJSON_AddItemToObject(resp, "error", err);
return resp;
}
static void on_async_js_finished(GObject *source, GAsyncResult *res, gpointer user_data) {
async_js_ctx_t *ctx = (async_js_ctx_t *)user_data;
WebKitWebView *webview = WEBKIT_WEB_VIEW(source);
JSCValue *value = webkit_web_view_evaluate_javascript_finish(webview, res, NULL);
if (value == NULL) {
send_ws_response(ctx->conn,
make_error_resp(ctx->request_id, "EVAL_FAILED", "JS evaluation returned NULL"));
goto cleanup;
}
char *str = jsc_value_to_string(value);
g_object_unref(value);
if (str == NULL) {
send_ws_response(ctx->conn,
make_error_resp(ctx->request_id, "EVAL_FAILED", "JS returned null"));
goto cleanup;
}
char *result = g_strdup(str);
free(str);
if (ctx->handler) {
/* Let the tool-specific handler transform the result. */
cJSON *response = ctx->handler(result);
g_free(result);
if (response) {
/* Add the request id. */
cJSON_AddNumberToObject(response, "id", ctx->request_id);
send_ws_response(ctx->conn, response);
} else {
send_ws_response(ctx->conn,
make_error_resp(ctx->request_id, "TOOL_FAILED", "Tool handler returned NULL"));
}
} else {
/* No handler — return raw result. */
cJSON *data = cJSON_CreateObject();
cJSON_AddStringToObject(data, "result", result);
g_free(result);
send_ws_response(ctx->conn, make_success_resp(ctx->request_id, data));
}
cleanup:
g_free(ctx->tool_name);
g_free(ctx);
}
gboolean agent_js_eval_async(WebKitWebView *webview,
const char *script,
SoupWebsocketConnection *conn,
int request_id,
const char *tool_name,
js_result_handler_t handler) {
if (webview == NULL || script == NULL) return FALSE;
async_js_ctx_t *ctx = g_new0(async_js_ctx_t, 1);
ctx->conn = conn;
ctx->request_id = request_id;
ctx->tool_name = g_strdup(tool_name ? tool_name : "unknown");
ctx->handler = handler;
webkit_web_view_evaluate_javascript(webview, script, -1, NULL, NULL, NULL,
on_async_js_finished, ctx);
return TRUE;
}
/* ── Snapshot JS script ───────────────────────────────────────────── *
* This script walks the DOM, assigns refs to interactive elements,
* stores the ref map in window.__agentRefs, and returns a JSON string
@@ -248,11 +371,11 @@ const char *AGENT_SNAPSHOT_JS =
"\n"
" /* Return JSON result. */\n"
" return JSON.stringify({\n"
" snapshot: treeLines.join('\\n'),\n"
" snapshot: treeLines.join('\\\\n'),\n"
" refs: refMap,\n"
" refCount: refCount\n"
" });\n"
"})();\n";
"})\n";
/* ── Snapshot API ─────────────────────────────────────────────────── */
@@ -261,23 +384,15 @@ cJSON *agent_snapshot_take(WebKitWebView *webview,
gboolean compact) {
if (webview == NULL) return NULL;
/* Build the JS call with arguments. */
char script[512];
snprintf(script, sizeof(script),
"AGENT_SNAPSHOT_JS_WRAPPER = (%s); AGENT_SNAPSHOT_JS_WRAPPER(%s, %s);",
AGENT_SNAPSHOT_JS,
interactive ? "true" : "false",
compact ? "true" : "false");
/* Build the JS call with arguments. Use g_strconcat instead of
* g_strdup_printf to avoid % format specifier issues in the JS. */
char *full_script = g_strconcat(
"(", AGENT_SNAPSHOT_JS, ")(",
interactive ? "true" : "false", ", ",
compact ? "true" : "false", ")",
NULL);
/* Actually, we need to call the IIFE with arguments. Let's use a
* different approach — wrap the function and call it. */
char *full_script = g_strdup_printf(
"(%s)(%s, %s)",
AGENT_SNAPSHOT_JS,
interactive ? "true" : "false",
compact ? "true" : "false");
char *result = agent_js_eval_sync(webview, full_script, 10000);
char *result = agent_js_eval_sync(webview, full_script, 15000);
g_free(full_script);
if (result == NULL) {
@@ -289,3 +404,39 @@ cJSON *agent_snapshot_take(WebKitWebView *webview,
return json;
}
/* ── Async snapshot ───────────────────────────────────────────────── */
/* Handler that transforms the snapshot JS result into a cJSON response. */
static cJSON *snapshot_result_handler(const char *js_result) {
if (js_result == NULL || js_result[0] == '\0') {
return make_error_resp(0, "SNAPSHOT_FAILED", "Snapshot returned empty result");
}
cJSON *json = cJSON_Parse(js_result);
if (json == NULL) {
return make_error_resp(0, "SNAPSHOT_FAILED", "Failed to parse snapshot JSON");
}
return make_success_resp(0, json);
}
gboolean agent_snapshot_take_async(WebKitWebView *webview,
gboolean interactive,
gboolean compact,
SoupWebsocketConnection *conn,
int request_id) {
if (webview == NULL) return FALSE;
char *full_script = g_strconcat(
"(", AGENT_SNAPSHOT_JS, ")(",
interactive ? "true" : "false", ", ",
compact ? "true" : "false", ")",
NULL);
gboolean started = agent_js_eval_async(webview, full_script, conn,
request_id, "snapshot",
snapshot_result_handler);
g_free(full_script);
return started;
}
+37
View File
@@ -10,6 +10,7 @@
#define AGENT_SNAPSHOT_H
#include <webkit2/webkit2.h>
#include <libsoup/soup.h>
#include "cjson/cJSON.h"
#ifdef __cplusplus
@@ -38,6 +39,42 @@ cJSON *agent_snapshot_take(WebKitWebView *webview,
gboolean interactive,
gboolean compact);
/*
* Async snapshot — starts JS evaluation and sends the response through
* the WebSocket connection when the JS completes. Returns TRUE if the
* async evaluation was started (response will be sent async), FALSE if
* it failed immediately (caller should send an error response).
*
* conn: WebSocket connection to send the response through
* request_id: the JSON request id to include in the response
*/
gboolean agent_snapshot_take_async(WebKitWebView *webview,
gboolean interactive,
gboolean compact,
SoupWebsocketConnection *conn,
int request_id);
/*
* Async JS evaluation — evaluates a script and sends the result as a
* tool response through the WebSocket connection when the JS completes.
* Returns TRUE if started async, FALSE on immediate failure.
*
* conn: WebSocket connection to send the response through
* request_id: the JSON request id to include in the response
* tool_name: the tool name (for logging)
* success_handler: optional callback to transform the JS result into
* a cJSON response before sending. If NULL, the raw
* JS result string is returned in data.result.
*/
typedef cJSON *(*js_result_handler_t)(const char *js_result);
gboolean agent_js_eval_async(WebKitWebView *webview,
const char *script,
SoupWebsocketConnection *conn,
int request_id,
const char *tool_name,
js_result_handler_t handler);
/*
* Execute JavaScript in the webview and wait for the result.
* This is a synchronous wrapper around the async evaluate_javascript API.
+46 -11
View File
@@ -731,7 +731,7 @@ static int tool_table_count = sizeof(tool_table) / sizeof(tool_table[0]);
/* ── Main dispatch function ───────────────────────────────────────── */
cJSON *agent_tools_dispatch(cJSON *request) {
cJSON *agent_tools_dispatch(cJSON *request, SoupWebsocketConnection *conn) {
if (request == NULL) {
return make_error("INVALID_REQUEST", "Request is NULL");
}
@@ -740,18 +740,59 @@ cJSON *agent_tools_dispatch(cJSON *request) {
const char *tool_name = cJSON_GetStringValue(cJSON_GetObjectItem(request, "tool"));
cJSON *params = cJSON_GetObjectItem(request, "params");
cJSON *id = cJSON_GetObjectItem(request, "id");
int request_id = (id && cJSON_IsNumber(id)) ? (int)id->valuedouble : 0;
if (!tool_name || !tool_name[0]) {
return make_error("MISSING_TOOL", "No 'tool' field in request");
}
/* Find the tool. */
/* Check login requirement for known tools. */
gboolean requires_login = TRUE;
gboolean is_login_tool = (strcmp(tool_name, "login_status") == 0 ||
strcmp(tool_name, "login") == 0 ||
strcmp(tool_name, "logout") == 0 ||
strcmp(tool_name, "switch_identity") == 0);
if (is_login_tool) requires_login = FALSE;
if (requires_login && !agent_is_logged_in()) {
return make_error("NOT_LOGGED_IN",
"This tool requires login. Use the 'login' tool first.");
}
/* Handle async tools (snapshot, eval) specially — they need async
* JS evaluation and send the response through the WebSocket
* connection directly. */
if (conn != NULL && strcmp(tool_name, "snapshot") == 0) {
WebKitWebView *wv = get_active_webview();
if (wv == NULL) return make_error("NO_TAB", "No active tab");
gboolean interactive = get_bool_param(params, "interactive", FALSE);
gboolean compact = get_bool_param(params, "compact", FALSE);
if (agent_snapshot_take_async(wv, interactive, compact, conn, request_id)) {
return NULL; /* Response sent async */
}
return make_error("SNAPSHOT_FAILED", "Failed to start async snapshot");
}
if (conn != NULL && strcmp(tool_name, "eval") == 0) {
WebKitWebView *wv = get_active_webview();
if (wv == NULL) return make_error("NO_TAB", "No active tab");
const char *script_param = get_string_param(params, "script");
if (!script_param || !script_param[0]) return make_error("MISSING_PARAM", "Provide 'script'");
if (agent_js_eval_async(wv, script_param, conn, request_id, "eval", NULL)) {
return NULL; /* Response sent async */
}
return make_error("EVAL_FAILED", "Failed to start async eval");
}
/* Find the tool in the table for synchronous tools. */
tool_func_t func = NULL;
gboolean requires_login = FALSE;
for (int i = 0; i < tool_table_count; i++) {
if (strcmp(tool_table[i].name, tool_name) == 0) {
func = tool_table[i].func;
requires_login = tool_table[i].requires_login;
break;
}
}
@@ -760,13 +801,7 @@ cJSON *agent_tools_dispatch(cJSON *request) {
return make_error("UNKNOWN_TOOL", "Unknown tool name");
}
/* Check login requirement. */
if (requires_login && !agent_is_logged_in()) {
return make_error("NOT_LOGGED_IN",
"This tool requires login. Use the 'login' tool first.");
}
/* Execute the tool. */
/* Execute the tool synchronously. */
cJSON *response = func(params ? params : cJSON_CreateObject());
/* Copy the request id into the response. */
+10 -1
View File
@@ -10,6 +10,8 @@
#include "cjson/cJSON.h"
#include <libsoup/soup.h>
#ifdef __cplusplus
extern "C" {
#endif
@@ -18,11 +20,18 @@ extern "C" {
* Process a tool request JSON and return response JSON.
* The caller frees the returned cJSON*.
*
* If the tool needs async JS evaluation, the response is sent directly
* through the WebSocket connection and this function returns NULL.
* Otherwise, the response is returned synchronously.
*
* conn: the WebSocket connection to send async responses through
* (may be NULL for testing without a real connection)
*
* Request format: {"id":1,"tool":"snapshot","params":{...}}
* Response format: {"id":1,"success":true,"data":{...}}
* or: {"id":1,"success":false,"error":{"code":"...","message":"..."}}
*/
cJSON *agent_tools_dispatch(cJSON *request);
cJSON *agent_tools_dispatch(cJSON *request, SoupWebsocketConnection *conn);
#ifdef __cplusplus
}
+2 -2
View File
@@ -11,9 +11,9 @@
#ifndef SOVEREIGN_BROWSER_VERSION_H
#define SOVEREIGN_BROWSER_VERSION_H
#define SB_VERSION "v0.0.8"
#define SB_VERSION "v0.0.9"
#define SB_VERSION_MAJOR 0
#define SB_VERSION_MINOR 0
#define SB_VERSION_PATCH 8
#define SB_VERSION_PATCH 9
#endif /* SOVEREIGN_BROWSER_VERSION_H */