v0.7.8: WebUI (--web): HTTP/1.1 + SSE front end, engine API v1, tests
ci / linux (clang) (push) Successful in 5m25s
ci / linux (gcc) (push) Failing after 34s
ci / musl (push) Successful in 7m22s
ci / windows-cross (push) Successful in 10m41s

A second implementation of cst_agent_hooks_t next to the TUI: the turn runs on
a worker thread, the hooks publish events into a ring buffer that SSE clients
read, so the engine itself needs no changes.

- src/net.c, include/cst_net.h: POSIX/Winsock sockets, non-blocking connect
- src/http_srv.c, include/cst_http.h: HTTP/1.1, keep-alive, chunked streaming,
  body caps (413), Expect: 100-continue, connection cap, exact routing
- src/web_md.c, include/cst_web_md.h: streaming markdown -> sanitized HTML
  (tag/attribute allowlist, javascript: URLs and off-site images dropped)
- src/webui.c, include/cst_webui.h: event ring + SSE broadcast, all routes,
  token (Bearer/Cookie/query) and loopback Host checks, 3 s auto-deny when no
  client is attached
- src/webui_stub.c, CST_WEBUI=0 / CST_BUILD_WEBUI=OFF: build without the WebUI
- web/: no-build front end (transcript, approvals, todo, sessions, image
  upload, status bar)
- docs/webui.md: engine API v1 - events, requests, security model
- tests/test_webui.c, scripts/e2e_webui.sh: unit tests plus an end-to-end run
  driven by a recorded SSE stream (no API key needed)
- src/main.c: --web --web-port --web-host --web-token --web-dev --web-open
  and CST_WEB_PORT/HOST/TOKEN/ROOT

An empty POST body is now accepted without Content-Length (RFC 9112 6.3) so
curl-driven clients can reach cancel/undo/compact/quit; CMakeList's core tests
learned about sashidown and test_webui, and CST_WEBUI=0 defines CST_NO_WEBUI.
This commit is contained in:
WHC
2026-10-09 10:20:09 +08:00
parent c7b32c3fd6
commit 6e81513e36
23 changed files with 4361 additions and 18 deletions
+280
View File
@@ -0,0 +1,280 @@
/* 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);
/* 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);
}
}
int main(void) {
CHECK(os_net_init() == 0);
test_query();
test_sanitize();
test_http();
printf("test_webui: OK\n");
return 0;
}