Files
Hermit/tests/test_webui.c
T
WHC 2d1052fd5f
ci / linux (clang) (push) Successful in 5m56s
ci / linux (gcc) (push) Successful in 8m38s
ci / musl (push) Successful in 7m22s
ci / windows-cross (push) Successful in 9m21s
v0.8.3: WebUI models, providers and keys; garbled thinking fixed
Streaming: Chinese reasoning showed up garbled in the WebUI. The agent
loop now feeds visible/thinking text in runs instead of byte by byte,
and the WebUI holds an incomplete UTF-8 tail until the next chunk
(cst_utf8_hold_feed), so every think_delta/assistant_delta is whole.

src/provider_switch.c: model list fetch/cache, key verify-and-save and
provider switching moved out of tui.c and shared by TUI and WebUI.

WebUI API:
- GET /api/providers, GET /api/models[?refresh=1] (401 on a rejected
  key, still listing built-ins), POST /api/provider, POST /api/key
  (key checked against /v1/models, saved to credentials 0600, never
  echoed in replies, events or logs)
- /api/config validates everything before applying, persists model,
  effort and approval (not yolo), warns when effort does not apply;
  plan_mode no longer forces read-only
- state: turn_usage, api_base, key_set/key_from, model_info,
  effort_applies, sees_images, max_steps, tool_profile, perm_timeout;
  permission events carry their timeout

Frontend: searchable model picker with refresh and manual entry,
provider picker and key dialog, effort/vision hints, toasts instead of
silent failures, Chinese error text, real protocol in the settings and
usage panels, approval countdown from the server's timeout.

Tests: utf8 hold unit test, scripts/e2e_webui_settings.py (fake
/v1/models, wrong key, switching, persistence, Chinese thinking) run
from e2e_webui.sh, scripts/tui_keys/provider.py.
2026-10-09 15:54:53 +08:00

321 lines
11 KiB
C

/* WebUI: the socket/HTTP layer, the SSE framing, auth and the HTML
* sanitizer. The full turn path is covered by scripts/e2e_webui.sh, which
* runs a recorded stream through a real server. */
#ifndef _DEFAULT_SOURCE
#define _DEFAULT_SOURCE
#endif
#include "cst_http.h"
#include "cst_net.h"
#include "cst_util.h"
#include "cst_web_md.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cst_test_util.h"
#define CHECK(c) \
do { \
if (!(c)) { \
fprintf(stderr, "%s:%d: CHECK(%s) failed\n", __FILE__, __LINE__, #c); \
exit(1); \
} \
} while (0)
/* --- a tiny HTTP client --------------------------------------------------- */
static os_socket_t connect_to(int port) {
char host[32];
snprintf(host, sizeof(host), "127.0.0.1");
os_socket_t s = os_net_connect(host, port, 3000);
CHECK(s != OS_SOCKET_BAD);
return s;
}
/* Sends data, reads until the peer closes (or 5 s pass). malloc'd. */
static char *roundtrip(int port, const char *req, size_t *out_len) {
os_socket_t s = connect_to(port);
CHECK(os_net_send_all(s, req, strlen(req), 3000) == 0);
cst_buf_t b = {0};
char buf[4096];
for (;;) {
long n = os_net_recv(s, buf, sizeof(buf), 5000);
if (n <= 0)
break;
CHECK(cst_buf_append(&b, buf, (size_t)n) == 0);
}
os_net_close(s);
*out_len = b.len;
return cst_buf_steal(&b);
}
/* Same, but stops as soon as `needle` shows up. */
/* --- handlers ------------------------------------------------------------- */
static int h_hello(cst_http_conn_t *c, void *ud) {
(void)ud;
return cst_http_send_str(c, 200, "text/plain", "world");
}
static int h_secret(cst_http_conn_t *c, void *ud) {
(void)ud;
return cst_http_send_str(c, 200, "text/plain", "secret");
}
static int h_echo(cst_http_conn_t *c, void *ud) {
(void)ud;
const cst_http_req_t *r = cst_http_request(c);
char head[64];
snprintf(head, sizeof(head), "%zu:", r->body_len);
cst_buf_t b = {0};
cst_buf_puts(&b, head);
cst_buf_append(&b, r->body ? r->body : "", r->body_len);
int rc = cst_http_send(c, 200, "text/plain", b.buf, b.len);
cst_buf_free(&b);
return rc;
}
static int h_events(cst_http_conn_t *c, void *ud) {
(void)ud;
CHECK(cst_http_stream_begin(c, "text/event-stream") == 0);
CHECK(cst_http_stream_write(c, "event: one\ndata: {\"n\":1}\n\n", 26) == 0);
CHECK(cst_http_stream_write(c, "event: two\ndata: {\"n\":2}\n\n", 26) == 0);
CHECK(cst_http_stream_end(c) == 0);
return 0;
}
static int auth_bearer(const cst_http_req_t *req, void *ud) {
const char *want = ud;
if (!req->authorization)
return -1;
if (strncmp(req->authorization, "Bearer ", 7) != 0)
return -1;
return strcmp(req->authorization + 7, want) == 0 ? 0 : -1;
}
static void test_http(void) {
static const cst_http_route_t routes[] = {
{"GET", "/hello", h_hello, NULL, 0, 0},
{"GET", "/secret", h_secret, NULL, 1, 0},
{"GET", "/events", h_events, NULL, 0, 0},
{"POST", "/echo", h_echo, NULL, 0, 4096},
};
cst_http_opts_t o;
memset(&o, 0, sizeof(o));
o.host = "127.0.0.1";
o.port = 0;
o.max_body = 1024 * 1024;
o.routes = routes;
o.nroutes = (int)(sizeof(routes) / sizeof(routes[0]));
o.auth = auth_bearer;
o.auth_ud = (void *)"s3cret";
char err[256] = "";
cst_http_server_t *s = cst_http_start(&o, err, sizeof(err));
CHECK(s != NULL);
int port = cst_http_port(s);
CHECK(port > 0);
size_t n = 0;
char *resp = roundtrip(port, "GET /hello HTTP/1.1\r\nHost: x\r\n"
"Connection: close\r\n\r\n", &n);
CHECK(strstr(resp, "200 OK") && strstr(resp, "world"));
free(resp);
/* keep-alive: two requests, one connection */
resp = roundtrip(port, "GET /hello HTTP/1.1\r\nHost: x\r\n\r\n"
"GET /hello HTTP/1.1\r\nHost: x\r\n"
"Connection: close\r\n\r\n", &n);
CHECK(strstr(resp, "200 OK"));
CHECK(strstr(resp, "200 OK") != strrchr(resp, '2')); /* twice */
{
int hits = 0;
for (const char *p = resp; (p = strstr(p, "200 OK")); p++)
hits++;
CHECK(hits == 2);
}
free(resp);
/* auth: without, then with the token */
resp = roundtrip(port, "GET /secret HTTP/1.1\r\nHost: x\r\n"
"Connection: close\r\n\r\n", &n);
CHECK(strstr(resp, "401"));
free(resp);
resp = roundtrip(port, "GET /secret HTTP/1.1\r\nHost: x\r\n"
"Authorization: Bearer s3cret\r\n"
"Connection: close\r\n\r\n", &n);
CHECK(strstr(resp, "200") && strstr(resp, "secret"));
free(resp);
/* unknown route */
resp = roundtrip(port, "GET /nope HTTP/1.1\r\nHost: x\r\n"
"Connection: close\r\n\r\n", &n);
CHECK(strstr(resp, "404"));
free(resp);
/* traversal is rejected before any handler runs */
resp = roundtrip(port, "GET /../etc/passwd HTTP/1.1\r\nHost: x\r\n"
"Connection: close\r\n\r\n", &n);
CHECK(strstr(resp, "400") || strstr(resp, "404"));
free(resp);
/* a body round trip, and the per-route cap */
resp = roundtrip(port, "POST /echo HTTP/1.1\r\nHost: x\r\n"
"Content-Length: 3\r\nConnection: close\r\n\r\nabc", &n);
CHECK(strstr(resp, "200") && strstr(resp, "3:abc"));
free(resp);
{
cst_buf_t b = {0};
cst_buf_puts(&b, "POST /echo HTTP/1.1\r\nHost: x\r\nContent-Length: 9999\r\n"
"Connection: close\r\n\r\n");
for (int i = 0; i < 9999; i++)
cst_buf_puts(&b, "a");
resp = roundtrip(port, b.buf, &n);
CHECK(strstr(resp, "413"));
free(resp);
cst_buf_free(&b);
}
/* chunked streaming: the client must see the decoded frames */
resp = roundtrip(port, "GET /events HTTP/1.1\r\nHost: x\r\n"
"Connection: close\r\n\r\n", &n);
CHECK(strstr(resp, "Transfer-Encoding: chunked"));
CHECK(strstr(resp, "event: one") && strstr(resp, "event: two"));
CHECK(strstr(resp, "\r\n0\r\n\r\n")); /* terminator */
free(resp);
cst_http_stop(s);
cst_http_free(s);
}
/* --- query parsing -------------------------------------------------------- */
static void test_query(void) {
cst_http_req_t r;
memset(&r, 0, sizeof(r));
snprintf(r.query, sizeof(r.query), "token=a%%20b&since=17&x=");
char buf[64];
CHECK(cst_http_query(&r, "token", buf, sizeof(buf)) == 0);
CHECK(strcmp(buf, "a b") == 0);
CHECK(cst_http_query(&r, "since", buf, sizeof(buf)) == 0);
CHECK(strcmp(buf, "17") == 0);
CHECK(cst_http_query(&r, "nope", buf, sizeof(buf)) != 0);
}
/* --- markdown + sanitizer ------------------------------------------------- */
static cst_buf_t g_md_out;
static int collect_md(const char *html, size_t len, void *ud) {
(void)ud;
return cst_buf_append(&g_md_out, html, len) == 0 ? 0 : 1;
}
static void test_sanitize(void) {
char *html = cst_web_md_html("**hi** [x](javascript:alert(1)) "
"![i](/api/attach/img-1.png)",
strlen("**hi** [x](javascript:alert(1)) "
"![i](/api/attach/img-1.png)"));
CHECK(html != NULL);
CHECK(strstr(html, "<strong>hi</strong>"));
CHECK(strstr(html, "javascript:") == NULL);
CHECK(strstr(html, "/api/attach/img-1.png"));
free(html);
/* raw markup that sashidown would pass through gets stripped */
const char *evil = "<p>ok</p><script>alert(1)</script>"
"<img src=\"http://evil.example/x.png\" alt=\"a\">"
"<a href=\"//evil.example\">l</a>";
html = cst_web_md_sanitize(evil, strlen(evil));
CHECK(html != NULL);
CHECK(strstr(html, "ok"));
CHECK(strstr(html, "<script") == NULL);
CHECK(strstr(html, "evil.example/x.png") == NULL);
CHECK(strstr(html, "//evil.example") == NULL);
CHECK(strstr(html, "<a") == NULL); /* an unusable href drops the tag */
free(html);
/* a quote inside a single-quoted or bare value cannot open a new attribute */
const char *quoted = "<a href='http://x\" onmouseover=\"alert(1)'>q</a>"
"<a href=http://y\"onmouseover=alert(2)//>r</a>";
html = cst_web_md_sanitize(quoted, strlen(quoted));
CHECK(html != NULL);
CHECK(strstr(html, "\" onmouseover") == NULL);
CHECK(strstr(html, "\"onmouseover") == NULL);
CHECK(strstr(html, "&quot;") != NULL);
free(html);
/* streaming: a javascript: link is dropped, a split tag never leaks */
{
cst_buf_free(&g_md_out);
const char *src = "see [x](javascript:alert(1)) and **ok**";
cst_web_md_t *md = cst_web_md_new(collect_md, NULL);
CHECK(md != NULL);
CHECK(cst_web_md_feed(md, src, 4) == 0); /* a deliberately split tag */
CHECK(cst_web_md_feed(md, src + 4, strlen(src) - 4) == 0);
CHECK(cst_web_md_finish(md) == 0);
cst_web_md_free(md);
const char *out = g_md_out.buf ? g_md_out.buf : "";
CHECK(strstr(out, "javascript:") == NULL);
CHECK(strstr(out, "href") == NULL);
CHECK(strstr(out, "see"));
CHECK(strstr(out, "<strong>ok</strong>") != NULL);
cst_buf_free(&g_md_out);
}
{
/* and the same text fed one byte at a time still comes out whole */
cst_buf_free(&g_md_out);
const char *md_src = "**bold** and [ok](https://example.com)";
cst_web_md_t *md = cst_web_md_new(collect_md, NULL);
CHECK(md != NULL);
for (size_t i = 0; i < strlen(md_src); i++)
CHECK(cst_web_md_feed(md, md_src + i, 1) == 0);
CHECK(cst_web_md_finish(md) == 0);
cst_web_md_free(md);
CHECK(strstr(g_md_out.buf, "<strong>bold</strong>"));
CHECK(strstr(g_md_out.buf, "https://example.com"));
CHECK(strstr(g_md_out.buf, "noopener") != NULL);
cst_buf_free(&g_md_out);
}
}
/* Streamed deltas cut anywhere must reach the browser as whole characters. */
static void test_utf8_hold(void) {
const char *src = "思考:先读 main.c,再改 😀 emoji。";
size_t len = strlen(src);
for (size_t step = 1; step <= 5; step++) {
cst_utf8_hold_t h = {0};
cst_buf_t out = {0};
for (size_t i = 0; i < len; i += step) {
size_t n = len - i < step ? len - i : step;
size_t got = 0;
char *piece = cst_utf8_hold_feed(&h, src + i, n, &got);
CHECK(piece != NULL);
CHECK(h.n < 4);
CHECK(cst_utf8_valid(piece, got));
CHECK(cst_buf_append(&out, piece, got) == 0);
free(piece);
}
CHECK(h.n == 0);
CHECK(out.len == len && memcmp(out.buf, src, len) == 0);
cst_buf_free(&out);
}
/* a stray continuation byte is not held forever */
cst_utf8_hold_t h = {0};
size_t got = 0;
char *piece = cst_utf8_hold_feed(&h, "\x80\x80\x80\x80", 4, &got);
CHECK(piece && got == 4 && h.n == 0);
free(piece);
}
int main(void) {
CHECK(os_net_init() == 0);
test_utf8_hold();
test_query();
test_sanitize();
test_http();
printf("test_webui: OK\n");
return 0;
}