v0.8.2: build fixes, WebUI security and correctness

Build: <fcntl.h> in tui.c, format-truncation in http_srv.c and /web.

WebUI:
- refuse cross-site requests (Origin must be the page itself) in the
  default no-token mode: any page could POST approval=yolo and a prompt
- session/workspace/undo/compact/attach/model changes are 409 while a
  turn runs (use-after-free, messages landing in the wrong session)
- ask_user returned "dismissed" at once instead of waiting
- cancel and shutdown wake a turn parked on a permission or question;
  SIGTERM mid-turn no longer hangs or crashes
- a page opened while a question is pending still gets it;
  Last-Event-ID resumes the stream; event seq matches the SSE id
- token mode: page assets load without the token, images via cookie
- --web-open works, --web-port/--web-token are validated
- frontend: gate keys ignored while typing, clear-images button,
  reasoning no longer wiped while streaming

HTTP server: auth and size cap before reading the body, strict
Content-Length, NUL in url_decode, 413 on keep-alive, idle timeout,
absolute-form only without a leading '/', case-insensitive Connection,
header deadline, 400 for long request lines, unlink before close,
poll() instead of select(), SO_EXCLUSIVEADDRUSE on Windows.

Markdown sanitizer escapes quotes in attribute values.

/web: passes this session's provider/model/base/effort/approval (key
via the environment), keeps output off the TUI, shows the real URL and
keeps CURRENT on the TUI's session.

Tests: scripts/e2e_webui_ask.py, scripts/e2e_webui_http.py (run from
e2e_webui.sh), sanitizer quote test.
This commit is contained in:
WHC
2026-10-09 13:51:34 +08:00
parent adf3e9f024
commit e238f5f37d
15 changed files with 1001 additions and 144 deletions
+143 -57
View File
@@ -4,8 +4,11 @@
#include "cst_http.h"
#include "cst_util.h"
#include "os_compat.h"
#include <errno.h>
#include <pthread.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -44,6 +47,9 @@ struct cst_http_conn {
int sent;
int streaming;
int failed;
int nreq; /* requests served on this connection */
int expect_continue; /* the client waits for "100 Continue" */
int keep_alive_req; /* what the client asked for */
cst_http_conn_t *prev, *next;
};
@@ -72,10 +78,15 @@ static const char *status_text(int code) {
/* --- reading -------------------------------------------------------------- */
static int fill(cst_http_conn_t *c, int timeout_ms) {
/* deadline: os_now_ms() value; the whole read must be done by then, so a
* client trickling one byte at a time cannot hold the thread forever. */
static int fill(cst_http_conn_t *c, double deadline) {
if (c->rpos < c->rlen)
return 1;
long r = os_net_recv(c->sock, c->rbuf, sizeof(c->rbuf), timeout_ms);
double left = deadline - os_now_ms();
if (left <= 0)
return -2;
long r = os_net_recv(c->sock, c->rbuf, sizeof(c->rbuf), (int)left + 1);
if (r <= 0)
return (int)r; /* 0 eof, -1 error, -2 timeout */
c->rpos = 0;
@@ -83,10 +94,12 @@ static int fill(cst_http_conn_t *c, int timeout_ms) {
return 1;
}
static int rd_line(cst_http_conn_t *c, char *out, size_t cap, int timeout_ms) {
#define RD_TOO_LONG (-3)
static int rd_line(cst_http_conn_t *c, char *out, size_t cap, double deadline) {
size_t n = 0;
for (;;) {
int rc = fill(c, timeout_ms);
int rc = fill(c, deadline);
if (rc <= 0)
return rc;
while (c->rpos < c->rlen) {
@@ -98,7 +111,7 @@ static int rd_line(cst_http_conn_t *c, char *out, size_t cap, int timeout_ms) {
return 1;
}
if (n + 2 >= cap)
return -1;
return RD_TOO_LONG;
out[n++] = ch;
}
}
@@ -132,7 +145,8 @@ static char *rd_body(cst_http_conn_t *c, size_t n, int timeout_ms) {
return buf;
}
static void url_decode(const char *in, char *out, size_t cap, int plus_space) {
/* -1 when the input encodes a NUL (out then holds what came before it). */
static int url_decode(const char *in, char *out, size_t cap, int plus_space) {
size_t o = 0;
for (const char *p = in; *p && o + 1 < cap; p++) {
if (*p == '%' && p[1] && p[2]) {
@@ -146,8 +160,10 @@ static void url_decode(const char *in, char *out, size_t cap, int plus_space) {
: -1;
if (hi >= 0 && lo >= 0) {
char ch = (char)((hi << 4) | lo);
if (ch == 0)
return; /* a decoded NUL would truncate the path */
if (ch == 0) {
out[o] = 0;
return -1;
}
out[o++] = ch;
p += 2;
continue;
@@ -156,6 +172,23 @@ static void url_decode(const char *in, char *out, size_t cap, int plus_space) {
out[o++] = (*p == '+' && plus_space) ? ' ' : *p;
}
out[o] = 0;
return 0;
}
/* Comma-separated header tokens, compared without case ("Close"). */
static int has_token(const char *val, const char *tok) {
size_t n = strlen(tok);
for (const char *p = val; p && *p;) {
while (*p == ' ' || *p == '\t' || *p == ',')
p++;
size_t i = 0;
while (i < n && p[i] && (p[i] | 0x20) == (tok[i] | 0x20))
i++;
if (i == n && (p[i] == 0 || p[i] == ',' || p[i] == ' ' || p[i] == '\t'))
return 1;
p = strchr(p, ',');
}
return 0;
}
static int key_is(const char *line, const char *key) {
@@ -189,12 +222,16 @@ static int read_request(cst_http_conn_t *c, size_t *want_body) {
cst_http_server_t *s = c->srv;
char line[HTTP_LINE_MAX];
cst_http_req_t *r = &c->req;
int first = c->rpos >= c->rlen; /* nothing buffered: this is a new client */
int first = c->nreq == 0; /* a keep-alive pause gets the idle timeout */
memset(r, 0, sizeof(*r));
c->hdrs_used = 0;
c->expect_continue = 0;
*want_body = 0;
int rc = rd_line(c, line, sizeof(line),
first ? s->o.read_timeout_ms : s->o.idle_timeout_ms);
os_now_ms() + (first ? s->o.read_timeout_ms
: s->o.idle_timeout_ms));
if (rc == RD_TOO_LONG)
return -1;
if (rc <= 0)
return 0;
char *sp1 = strchr(line, ' ');
@@ -214,9 +251,10 @@ static int read_request(cst_http_conn_t *c, size_t *want_body) {
r->keep_alive = strncmp(version, "HTTP/1.", 7) == 0 &&
version[7] >= '1' && version[7] <= '9';
/* absolute-form ("http://host/path") is legal: keep the path */
char *scheme = strstr(target, "://");
if (scheme)
target = strchr(scheme + 3, '/');
if (target[0] != '/') {
char *scheme = strstr(target, "://");
target = scheme ? strchr(scheme + 3, '/') : NULL;
}
if (!target || target[0] != '/')
return -1;
{
@@ -227,7 +265,8 @@ static int read_request(cst_http_conn_t *c, size_t *want_body) {
*q = 0;
snprintf(r->query, sizeof(r->query), "%s", q + 1);
}
url_decode(raw, r->path, sizeof(r->path), 0);
if (url_decode(raw, r->path, sizeof(r->path), 0) != 0)
return -1;
size_t plen = strlen(r->path);
if (plen == 0 || strstr(r->path, ".."))
return -1;
@@ -235,8 +274,9 @@ static int read_request(cst_http_conn_t *c, size_t *want_body) {
size_t hdr_bytes = 0;
size_t content_length = 0;
int have_length = 0;
double deadline = os_now_ms() + s->o.read_timeout_ms;
for (;;) {
rc = rd_line(c, line, sizeof(line), s->o.read_timeout_ms);
rc = rd_line(c, line, sizeof(line), deadline);
if (rc <= 0)
return -1;
if (!line[0])
@@ -251,18 +291,29 @@ static int read_request(cst_http_conn_t *c, size_t *want_body) {
while (*val == ' ' || *val == '\t')
val++;
if (key_is(line, "content-length")) {
content_length = (size_t)strtoul(val, NULL, 10);
/* digits only, and one value: "5x" or two lengths would let the
* rest of the body run as a second request behind a proxy */
char *end = NULL;
errno = 0;
unsigned long long v = strtoull(val, &end, 10);
while (end && (*end == ' ' || *end == '\t'))
end++;
if (*val < '0' || *val > '9' || !end || *end || errno == ERANGE ||
v > (unsigned long long)SIZE_MAX ||
(have_length && content_length != (size_t)v))
return -1;
content_length = (size_t)v;
have_length = 1;
} else if (key_is(line, "transfer-encoding")) {
return -1; /* chunked request bodies are not supported */
} else if (key_is(line, "connection")) {
if (strstr(val, "close"))
if (has_token(val, "close"))
r->keep_alive = 0;
else if (strstr(val, "keep-alive"))
else if (has_token(val, "keep-alive"))
r->keep_alive = 1;
} else if (key_is(line, "expect")) {
const char *cont = "HTTP/1.1 100 Continue\r\n\r\n";
os_net_send_all(c->sock, cont, strlen(cont), 5000);
/* answered once auth and the size cap have passed */
c->expect_continue = has_token(val, "100-continue");
} else if (key_is(line, "authorization")) {
r->authorization = store_str(c, val);
} else if (key_is(line, "cookie")) {
@@ -275,6 +326,8 @@ static int read_request(cst_http_conn_t *c, size_t *want_body) {
r->content_type = store_str(c, val);
} else if (key_is(line, "user-agent")) {
r->user_agent = store_str(c, val);
} else if (key_is(line, "last-event-id")) {
r->last_event_id = store_str(c, val);
}
}
if (have_length)
@@ -429,24 +482,30 @@ int cst_http_query(const cst_http_req_t *req, const char *key, char *out,
/* --- connections ---------------------------------------------------------- */
static void dispatch(cst_http_server_t *s, cst_http_conn_t *c) {
const cst_http_route_t *route = NULL;
static const cst_http_route_t *find_route(const cst_http_server_t *s,
const cst_http_req_t *req) {
const cst_http_route_t *fallback = NULL;
for (int i = 0; i < s->nroutes; i++) {
const cst_http_route_t *r = &s->routes[i];
if (strcmp(r->method, "*") != 0 && strcmp(r->method, c->req.method) != 0)
if (strcmp(r->method, "*") != 0 && strcmp(r->method, req->method) != 0)
continue;
if (strcmp(r->path, "*") == 0) {
fallback = r;
continue;
}
if (strcmp(r->path, c->req.path) == 0) {
route = r;
break;
}
if (strcmp(r->path, req->path) == 0)
return r;
}
if (!route)
route = fallback;
return fallback;
}
/* Route, auth and the size cap are settled before the body is read: an
* unauthenticated client must not make us buffer 32 MiB. */
static void serve(cst_http_server_t *s, cst_http_conn_t *c, size_t want) {
const cst_http_route_t *route = find_route(s, &c->req);
size_t cap = route && route->max_body ? route->max_body : s->o.max_body;
if (want > 0)
c->req.keep_alive = 0; /* until the body is read, the stream is ours */
if (!route) {
cst_http_send_err(c, 404, "not found");
return;
@@ -455,6 +514,25 @@ static void dispatch(cst_http_server_t *s, cst_http_conn_t *c) {
cst_http_send_err(c, 401, "unauthorized");
return;
}
if (want > cap) {
cst_http_send_err(c, 413, "request body too large");
return;
}
if (want > 0) {
if (c->expect_continue) {
const char *cont = "HTTP/1.1 100 Continue\r\n\r\n";
os_net_send_all(c->sock, cont, strlen(cont), 5000);
}
char *body = rd_body(c, want, s->o.read_timeout_ms);
if (!body) {
c->failed = 1;
return;
}
c->body = body;
c->req.body = body;
c->req.body_len = want;
c->req.keep_alive = c->keep_alive_req;
}
int rc = route->fn(c, route->ud);
if (!c->sent)
cst_http_send_err(c, rc == 0 ? 500 : 404,
@@ -477,32 +555,14 @@ static void *conn_main(void *arg) {
}
if (rc == 0)
break;
size_t cap = s->o.max_body;
for (int i = 0; i < s->nroutes; i++) {
const cst_http_route_t *r = &s->routes[i];
if (r->max_body && strcmp(r->method, c->req.method) == 0 &&
strcmp(r->path, c->req.path) == 0)
cap = r->max_body;
}
if (want > cap) {
c->req.keep_alive = 0;
cst_http_send_err(c, 413, "request body too large");
break;
}
if (want > 0) {
char *body = rd_body(c, want, s->o.read_timeout_ms);
if (!body)
break;
c->body = body;
c->req.body = body;
c->req.body_len = want;
}
c->nreq++;
c->sent = 0;
c->streaming = 0;
dispatch(s, c);
c->keep_alive_req = c->req.keep_alive;
serve(s, c, want);
if (s->o.on_log) {
char line[512];
snprintf(line, sizeof(line), "%s %s -> %d", c->req.method, c->req.path,
snprintf(line, sizeof(line), "%s %.480s -> %d", c->req.method, c->req.path,
c->failed ? 499 : (c->sent ? 200 : 500));
s->o.on_log(line, s->o.log_ud);
}
@@ -512,11 +572,12 @@ static void *conn_main(void *arg) {
c->req.body_len = 0;
c->req.authorization = c->req.cookie = c->req.origin = NULL;
c->req.host = c->req.content_type = c->req.user_agent = NULL;
c->req.last_event_id = NULL;
if (!c->req.keep_alive || s->stopping || c->failed)
break;
}
os_net_shutdown(c->sock);
os_net_close(c->sock);
/* unlink first: stop() shuts down every listed socket, and a closed fd
* number may already belong to someone else */
pthread_mutex_lock(&s->mu);
if (c->prev)
c->prev->next = c->next;
@@ -524,10 +585,14 @@ static void *conn_main(void *arg) {
s->conns_first = c->next;
if (c->next)
c->next->prev = c->prev;
pthread_mutex_unlock(&s->mu);
os_net_shutdown(c->sock);
os_net_close(c->sock);
free(c);
pthread_mutex_lock(&s->mu);
s->nconn--;
pthread_cond_broadcast(&s->cv);
pthread_mutex_unlock(&s->mu);
free(c);
return NULL;
}
@@ -536,8 +601,22 @@ static void *accept_main(void *arg) {
while (!s->stopping) {
int timed_out = 0;
os_socket_t sock = os_net_accept(s->listener, 500, &timed_out);
if (sock == OS_SOCKET_BAD)
if (sock == OS_SOCKET_BAD) {
if (!timed_out) { /* EMFILE and friends: the listener stays readable */
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
ts.tv_nsec += 100000000;
if (ts.tv_nsec >= 1000000000) {
ts.tv_sec++;
ts.tv_nsec -= 1000000000;
}
pthread_mutex_lock(&s->mu);
if (!s->stopping)
pthread_cond_timedwait(&s->cv, &s->mu, &ts);
pthread_mutex_unlock(&s->mu);
}
continue;
}
if (s->stopping) {
os_net_close(sock);
break;
@@ -644,9 +723,9 @@ int cst_http_stopping(const cst_http_server_t *s) {
return s ? s->stopping : 1;
}
void cst_http_stop(cst_http_server_t *s) {
int cst_http_stop(cst_http_server_t *s) {
if (!s)
return;
return 0;
pthread_mutex_lock(&s->mu);
s->stopping = 1;
for (cst_http_conn_t *c = s->conns_first; c; c = c->next)
@@ -670,13 +749,20 @@ void cst_http_stop(cst_http_server_t *s) {
}
pthread_cond_timedwait(&s->cv, &s->mu, &ts);
}
int left = s->nconn;
pthread_mutex_unlock(&s->mu);
os_net_close(s->listener);
return left;
}
void cst_http_free(cst_http_server_t *s) {
if (!s)
return;
pthread_mutex_lock(&s->mu);
int left = s->nconn;
pthread_mutex_unlock(&s->mu);
if (left > 0)
return; /* a handler still runs on it (see stop()): leak, not crash */
pthread_mutex_destroy(&s->mu);
pthread_cond_destroy(&s->cv);
free(s->routes);
+19 -3
View File
@@ -255,6 +255,16 @@ static int demo_ctl_impl(const char *workspace) {
return 0;
}
/* 0..65535, digits only: atoi turned "70000" into port 4464, "abc" into 0. */
static int parse_port(const char *s, int *out) {
char *end = NULL;
long v = strtol(s, &end, 10);
if (!s[0] || *end || v < 0 || v > 65535)
return -1;
*out = (int)v;
return 0;
}
int main(int argc, char **argv) {
if (os_console_init() != 0)
fprintf(stderr, "warning: the process code page is not UTF-8 (Windows 10 "
@@ -334,8 +344,10 @@ int main(int argc, char **argv) {
memset(&web, 0, sizeof(web));
web.port = CST_WEBUI_DEFAULT_PORT; /* 0 would mean "pick a free one" */
const char *env = getenv("CST_WEB_PORT");
if (env && env[0])
web.port = atoi(env);
if (env && env[0] && parse_port(env, &web.port) != 0) {
fprintf(stderr, "CST_WEB_PORT=%s is not a port (0-65535)\n", env);
return 1;
}
web.host = getenv("CST_WEB_HOST");
web.token = getenv("CST_WEB_TOKEN");
web.root = getenv("CST_WEB_ROOT");
@@ -482,7 +494,11 @@ int main(int argc, char **argv) {
web_mode = 1;
break;
case 0x102:
web.port = atoi(optarg);
if (parse_port(optarg, &web.port) != 0) {
fprintf(stderr, "--web-port %s is not a port (0-65535)\n", optarg);
curl_global_cleanup();
return 1;
}
web_mode = 1;
break;
case 0x103:
+27 -11
View File
@@ -12,6 +12,10 @@
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#if !defined(_WIN32_WINNT) || _WIN32_WINNT < 0x0600
#undef _WIN32_WINNT
#define _WIN32_WINNT 0x0600 /* WSAPoll */
#endif
#include <winsock2.h>
#include <ws2tcpip.h>
#else
@@ -21,8 +25,8 @@
#include <netdb.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <poll.h>
#include <signal.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <unistd.h>
#endif
@@ -45,7 +49,6 @@ static void set_err_win(char *err, size_t err_sz, const char *what) {
}
#define SET_ERR(e, n, w) set_err_win(e, n, w)
#define NET_FD(s) ((SOCKET)(s))
#define NET_NFDS(s) (0) /* Winsock ignores nfds */
static void net_close_raw(os_socket_t s) { closesocket((SOCKET)s); }
static int net_last_would_block(void) {
int e = WSAGetLastError();
@@ -58,7 +61,6 @@ static int net_last_conn_reset(void) {
#else
#define SET_ERR(e, n, w) set_err(e, n, w)
#define NET_FD(s) ((int)(s))
#define NET_NFDS(s) ((int)((s) + 1))
static void net_close_raw(os_socket_t s) { close(s); }
static int net_last_would_block(void) { return errno == EINTR; }
static int net_last_conn_reset(void) {
@@ -84,15 +86,22 @@ void os_net_cleanup(void) {
#endif
}
/* poll, not select: FD_SET past FD_SETSIZE writes out of bounds, and a
* failed connect shows up as an error event, not as writable, on Windows. */
static int wait_io(os_socket_t s, int want_write, int timeout_ms) {
fd_set fds;
FD_ZERO(&fds);
FD_SET(NET_FD(s), &fds);
struct timeval tv;
tv.tv_sec = timeout_ms / 1000;
tv.tv_usec = (timeout_ms % 1000) * 1000;
int rc = select(NET_NFDS(s), want_write ? NULL : &fds,
want_write ? &fds : NULL, NULL, timeout_ms < 0 ? NULL : &tv);
#ifdef _WIN32
WSAPOLLFD p;
p.fd = NET_FD(s);
p.events = want_write ? POLLWRNORM : POLLRDNORM;
p.revents = 0;
int rc = WSAPoll(&p, 1, timeout_ms < 0 ? -1 : timeout_ms);
#else
struct pollfd p;
p.fd = NET_FD(s);
p.events = want_write ? POLLOUT : POLLIN;
p.revents = 0;
int rc = poll(&p, 1, timeout_ms < 0 ? -1 : timeout_ms);
#endif
if (rc > 0)
return 1;
if (rc == 0)
@@ -137,8 +146,15 @@ os_socket_t os_net_listen(const char *host, int port, char *err, size_t err_sz)
return OS_SOCKET_BAD;
}
int one = 1;
#ifdef _WIN32
/* SO_REUSEADDR on Windows lets another process bind the same port and
* take over our connections */
setsockopt(NET_FD(s), SOL_SOCKET, SO_EXCLUSIVEADDRUSE, (const char *)&one,
sizeof(one));
#else
setsockopt(NET_FD(s), SOL_SOCKET, SO_REUSEADDR, (const char *)&one,
sizeof(one));
#endif
#ifdef IPV6_V6ONLY
if (sa.ss_family == AF_INET6)
setsockopt(NET_FD(s), IPPROTO_IPV6, IPV6_V6ONLY, (const char *)&one,
+92 -7
View File
@@ -5369,6 +5369,7 @@ static void cmd_quit(tui_t *t, char *arg, int *quit) {
* the TUI holding the terminal. The child gets a fresh session: two writers on
* one session file is the one thing that would corrupt it. */
#ifndef _WIN32
#include <fcntl.h>
#include <sys/wait.h> /* waitpid: collect the WebUI started by the last /web */
#endif
#ifndef CST_WEBUI_DEFAULT_PORT
@@ -5393,33 +5394,117 @@ static void cmd_web(tui_t *t, char *arg, int *quit) {
snprintf(exe, sizeof(exe), "%s/cst-agent", dir);
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
const char *ws = (cfg && cfg->workspace_root[0]) ? cfg->workspace_root : ".";
char logf[4096];
char logf[4096 + 32];
snprintf(logf, sizeof(logf), "%s/.agent", ws);
os_mkdir_p(logf, 0755);
snprintf(logf, sizeof(logf), "%s/.agent/webui.log", ws);
/* The WebUI talks to what this session talks to (-b, -m, a /provider
* switch...). The key goes through the environment, not argv (ps). */
const char *argv[20];
int na = 0;
argv[na++] = "cst-agent";
argv[na++] = "--web";
argv[na++] = "--workspace";
argv[na++] = ws;
argv[na++] = "--new";
if (cfg && cfg->provider[0])
argv[na++] = "--provider", argv[na++] = cfg->provider;
if (cfg && cfg->model[0])
argv[na++] = "--model", argv[na++] = cfg->model;
if (cfg && cfg->api_base[0])
argv[na++] = "--api-base", argv[na++] = cfg->api_base;
if (cfg && cfg->effort[0])
argv[na++] = "--effort", argv[na++] = cfg->effort;
if (cfg)
argv[na++] = "--approval", argv[na++] = cst_approval_name(cfg->approval);
argv[na] = NULL;
size_t nenv = 0;
while (environ[nenv])
nenv++;
char **envp = calloc(nenv + 2, sizeof(char *));
char keyvar[600] = "";
if (!envp) {
push_system_line(t, "out of memory");
return;
}
size_t ne = 0;
for (size_t i = 0; i < nenv; i++)
if (strncmp(environ[i], "CST_API_KEY=", 12) != 0)
envp[ne++] = environ[i];
if (cfg && cfg->api_key[0]) {
snprintf(keyvar, sizeof(keyvar), "CST_API_KEY=%s", cfg->api_key);
envp[ne++] = keyvar;
}
envp[ne] = NULL;
pid_t pid = fork();
if (pid < 0) {
free(envp);
push_system_line(t, "cannot start a second process");
return;
}
if (pid == 0) {
setsid();
/* never inherit the TUI's terminal: its output would land on our screen */
int in = open("/dev/null", O_RDONLY);
if (in >= 0 && in != STDIN_FILENO) {
dup2(in, STDIN_FILENO);
close(in);
}
int fd = open(logf, O_WRONLY | O_CREAT | O_TRUNC, 0600);
if (fd < 0)
fd = open("/dev/null", O_WRONLY);
if (fd >= 0) {
dup2(fd, STDOUT_FILENO);
dup2(fd, STDERR_FILENO);
if (fd > 2)
close(fd);
}
execl(exe, "cst-agent", "--web", "--workspace", ws, "--new", (char *)NULL);
execve(exe, (char *const *)argv, envp);
_exit(127);
}
free(envp);
spawned = pid;
/* Wait (2 s at most) for the URL it prints once it listens: the port may
* come from CST_WEB_PORT, or it may not start at all. */
char url[512] = "";
int died = 0;
for (int i = 0; i < 40 && !url[0] && !died; i++) {
usleep(50000);
FILE *fp = fopen(logf, "r");
char line[512];
while (fp && fgets(line, sizeof(line), fp)) {
if (strncmp(line, "webui: http", 11) == 0) {
line[strcspn(line, "\r\n")] = 0;
snprintf(url, sizeof(url), "%s", line + 7);
break;
}
}
if (fp)
fclose(fp);
if (!url[0] && waitpid(pid, NULL, WNOHANG) == pid) {
died = 1;
spawned = 0;
}
}
/* its --new made the WebUI's session CURRENT; a plain `cst-agent` later
* should still resume this one */
char sid[128] = "";
cst_agent_session_id(t->agent, sid, sizeof(sid));
if (sid[0])
cst_agent_store_set_current(ws, sid);
char msg[4200];
snprintf(msg, sizeof(msg),
"WebUI started in the background: http://127.0.0.1:%d/ (new session)",
CST_WEBUI_DEFAULT_PORT);
if (died)
snprintf(msg, sizeof(msg), "the WebUI did not start (port busy?): see %s",
logf);
else if (url[0])
snprintf(msg, sizeof(msg), "WebUI started in the background: %s (new session)",
url);
else
snprintf(msg, sizeof(msg), "WebUI starting in the background "
"(http://127.0.0.1:%d/ unless CST_WEB_PORT says "
"otherwise)", CST_WEBUI_DEFAULT_PORT);
push_system_line(t, msg);
snprintf(msg, sizeof(msg), "its log: %s (port busy? see the log, or pass "
"--web-port)", logf);
snprintf(msg, sizeof(msg), "its log: %s", logf);
push_system_line(t, msg);
#endif
}
+11 -1
View File
@@ -64,7 +64,17 @@ static void attr_append(cst_buf_t *b, const char *name, size_t nlen,
cst_buf_puts(b, " ");
cst_buf_append(b, name, nlen);
cst_buf_puts(b, "=\"");
cst_buf_append(b, val, vlen);
/* a single-quoted or bare value may hold '"': it must not end ours */
for (size_t i = 0; i < vlen; i++) {
if (val[i] == '"')
cst_buf_puts(b, "&quot;");
else if (val[i] == '<')
cst_buf_puts(b, "&lt;");
else if (val[i] == '>')
cst_buf_puts(b, "&gt;");
else
cst_buf_append(b, val + i, 1);
}
cst_buf_puts(b, "\"");
}
+223 -43
View File
@@ -72,11 +72,14 @@ typedef struct wui {
wui_sse_t *subs;
int busy;
int op_busy; /* a session/workspace/undo/compact/attach call runs */
int turn_live; /* the turn thread still uses w; cleared as it exits */
uint64_t req_id; /* permission/ask id counter */
uint64_t perm_id;
int perm_active, perm_answer;
uint64_t ask_id;
int ask_active, ask_rc;
int ask_active, ask_rc; /* ask_rc: 0 pending, 1 answered, -1 dismissed */
uint64_t pending_seq; /* event of the open permission/ask_user, 0 = none */
char ask_ans[WUI_ASK_MAX];
volatile int stopping;
@@ -88,36 +91,44 @@ static uint64_t wui_first_seq(const wui_t *w) {
return w->seq > WUI_RING ? w->seq - WUI_RING + 1 : 1;
}
/* Owns data (a cJSON object). */
static void wui_emit(wui_t *w, const char *type, cJSON *data) {
/* Owns data (a cJSON object). Returns the event's seq, 0 when dropped. */
static uint64_t wui_emit(wui_t *w, const char *type, cJSON *data) {
if (!data)
data = cJSON_CreateObject();
if (!data)
return;
return 0;
cJSON_AddStringToObject(data, "type", type);
/* the seq in the payload must match the SSE id, so it is taken under mu */
pthread_mutex_lock(&w->mu);
cJSON_AddNumberToObject(data, "seq", (double)(w->seq + 1));
char *json = cJSON_PrintUnformatted(data);
cJSON_Delete(data);
if (!json)
return;
cst_buf_t f = {0};
cst_buf_printf(&f, "event: %s\ndata: %s\n\n", type, json);
if (json)
cst_buf_printf(&f, "event: %s\ndata: %s\n\n", type, json);
free(json);
if (!f.buf) {
pthread_mutex_unlock(&w->mu);
cst_buf_free(&f);
return;
return 0;
}
pthread_mutex_lock(&w->mu);
w->seq++;
int slot = (int)(w->seq % WUI_RING);
uint64_t seq = ++w->seq;
int slot = (int)(seq % WUI_RING);
free(w->ring[slot].frame);
w->ring[slot].frame = f.buf; /* stolen */
w->ring[slot].seq = w->seq;
w->ring[slot].seq = seq;
f.buf = NULL;
for (wui_sse_t *s = w->subs; s; s = s->next)
pthread_cond_broadcast(&s->cv);
pthread_mutex_unlock(&w->mu);
cst_buf_free(&f);
return seq;
}
static void wui_set_pending(wui_t *w, uint64_t seq) {
pthread_mutex_lock(&w->mu);
w->pending_seq = seq;
pthread_mutex_unlock(&w->mu);
}
static void wui_emit_str(wui_t *w, const char *type, const char *key,
@@ -379,7 +390,7 @@ static int wui_wait_permission(wui_t *w, const char *tool, const char *detail,
strncmp(detail, CST_PERM_SANDBOX,
strlen(CST_PERM_SANDBOX)) ==
0);
wui_emit(w, "permission", o);
wui_set_pending(w, wui_emit(w, "permission", o));
}
int timeout = w->perm_timeout_s > 0 ? w->perm_timeout_s : 600;
/* Nobody is watching: do not hang the turn for ten minutes. */
@@ -404,6 +415,8 @@ static int wui_wait_permission(wui_t *w, const char *tool, const char *detail,
break;
}
}
w->perm_active = 0;
w->pending_seq = 0;
pthread_mutex_unlock(&w->mu);
cJSON *e = cJSON_CreateObject();
if (e) {
@@ -427,7 +440,7 @@ static int on_ask_user(const char *question, const char *options, char *ans,
uint64_t id = w->req_id;
w->ask_id = id;
w->ask_active = 1;
w->ask_rc = -1;
w->ask_rc = 0;
w->ask_ans[0] = 0;
int has_client = w->subs != NULL;
pthread_mutex_unlock(&w->mu);
@@ -449,7 +462,7 @@ static int on_ask_user(const char *question, const char *options, char *ans,
}
p = nl ? nl + 1 : NULL;
}
wui_emit(w, "ask_user", o);
wui_set_pending(w, wui_emit(w, "ask_user", o));
}
int timeout = has_client ? 600 : 3;
int rc = -1;
@@ -472,6 +485,8 @@ static int on_ask_user(const char *question, const char *options, char *ans,
break;
}
}
w->ask_active = 0;
w->pending_seq = 0;
pthread_mutex_unlock(&w->mu);
return rc;
}
@@ -544,6 +559,8 @@ static int wui_serve_asset(cst_http_conn_t *c, wui_t *w, const char *path) {
return -1;
}
static int wui_auth(const cst_http_req_t *req, void *ud);
static int h_asset(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
const cst_http_req_t *req = cst_http_request(c);
@@ -552,6 +569,9 @@ static int h_asset(cst_http_conn_t *c, void *ud) {
* reachable. */
static const char prefix[] = "/api/attach/";
if (strncmp(req->path, prefix, sizeof(prefix) - 1) == 0) {
/* the fallback route is public (the page's own assets); images are not */
if (wui_auth(req, w) != 0)
return cst_http_send_err(c, 401, "unauthorized");
const char *rel = req->path + sizeof(prefix) - 1;
if (!cst_attach_rel_ok(rel))
return -1;
@@ -569,8 +589,8 @@ static int h_asset(cst_http_conn_t *c, void *ud) {
size_t n = strlen(rel);
if (n > 4 && strcmp(rel + n - 4, ".png") == 0)
ctype = "image/png";
else if (n > 4 && (strcmp(rel + n - 4, ".jpg") == 0 ||
strcmp(rel + n - 4, ".jpeg") == 0))
else if ((n > 4 && strcmp(rel + n - 4, ".jpg") == 0) ||
(n > 5 && strcmp(rel + n - 5, ".jpeg") == 0))
ctype = "image/jpeg";
else if (n > 4 && strcmp(rel + n - 4, ".gif") == 0)
ctype = "image/gif";
@@ -630,11 +650,19 @@ static int h_events(cst_http_conn_t *c, void *ud) {
char buf[32];
if (cst_http_query(req, "since", buf, sizeof(buf)) == 0)
s.cursor = (uint64_t)strtoull(buf, NULL, 10);
else if (req->last_event_id)
s.cursor = (uint64_t)strtoull(req->last_event_id, NULL, 10);
pthread_mutex_init(&s.mu, NULL);
pthread_cond_init(&s.cv, NULL);
pthread_mutex_lock(&w->mu);
if (s.cursor == 0)
s.cursor = w->seq; /* a fresh client wants new events only */
/* 0 = a fresh client wants new events only; past the head = ids from an
* earlier server process */
if (s.cursor == 0 || s.cursor > w->seq) {
s.cursor = w->seq;
/* a page opened while a question waits must still get to answer it */
if (w->pending_seq && w->pending_seq <= w->seq)
s.cursor = w->pending_seq - 1;
}
if (s.cursor < wui_first_seq(w) - 1)
s.cursor = wui_first_seq(w) - 1; /* too far behind: resume at the head */
s.next = w->subs;
@@ -741,14 +769,19 @@ static void *wui_turn_main(void *arg) {
wui_end_turn(w, rc, err);
free(t->prompt);
free(t);
pthread_mutex_lock(&w->mu);
w->turn_live = 0;
pthread_cond_broadcast(&w->cv);
pthread_mutex_unlock(&w->mu);
return NULL;
}
static int wui_start_turn(wui_t *w, const char *text, char *err, size_t err_sz) {
pthread_mutex_lock(&w->mu);
if (w->busy) {
if (w->busy || w->op_busy) {
pthread_mutex_unlock(&w->mu);
snprintf(err, err_sz, "a turn is already running");
snprintf(err, err_sz, w->busy ? "a turn is already running"
: "another change is running");
return -1;
}
w->busy = 1;
@@ -776,11 +809,15 @@ static int wui_start_turn(wui_t *w, const char *text, char *err, size_t err_sz)
return -1;
}
pthread_t th;
pthread_mutex_lock(&w->mu);
w->turn_live = 1;
pthread_mutex_unlock(&w->mu);
if (pthread_create(&th, NULL, wui_turn_main, t) != 0) {
free(t->prompt);
free(t);
pthread_mutex_lock(&w->mu);
w->busy = 0;
w->turn_live = 0;
pthread_mutex_unlock(&w->mu);
snprintf(err, err_sz, "cannot start the turn thread");
return -1;
@@ -808,9 +845,45 @@ static int h_turn(cst_http_conn_t *c, void *ud) {
"{\"ok\":true}");
}
/* The agent is not locked: switching sessions, the workspace, undo, compact
* and the pending images all change what a running turn reads. These run
* one at a time and never during a turn. */
static int wui_claim(wui_t *w) {
pthread_mutex_lock(&w->mu);
int ok = !w->busy && !w->op_busy;
if (ok)
w->op_busy = 1;
pthread_mutex_unlock(&w->mu);
return ok ? 0 : -1;
}
static void wui_release(wui_t *w) {
pthread_mutex_lock(&w->mu);
w->op_busy = 0;
pthread_mutex_unlock(&w->mu);
}
#define WUI_EXCLUSIVE(name) \
static int name(cst_http_conn_t *c, void *ud) { \
wui_t *w = ud; \
if (wui_claim(w) != 0) \
return cst_http_send_err(c, 409, "busy: wait for the running turn"); \
int rc = name##_x(c, ud); \
wui_release(w); \
return rc; \
}
static int h_cancel(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
cst_agent_request_cancel(w->agent);
/* a turn parked on the gate or a question would not see the flag */
pthread_mutex_lock(&w->mu);
if (w->perm_active && w->perm_answer < 0)
w->perm_answer = CST_PERM_DENY;
if (w->ask_active && w->ask_rc == 0)
w->ask_rc = -1;
pthread_cond_broadcast(&w->cv);
pthread_mutex_unlock(&w->mu);
return cst_http_send_str(c, 200, "application/json; charset=utf-8",
"{\"ok\":true}");
}
@@ -870,11 +943,15 @@ static int h_ask_user(cst_http_conn_t *c, void *ud) {
static int h_sessions(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
const cst_agent_config_t *cfg = cst_agent_config(w->agent);
static char ids[200][128];
char (*ids)[128] = malloc(200 * sizeof(*ids)); /* handlers run in parallel */
if (!ids)
return cst_http_send_err(c, 500, "out of memory");
int n = cst_agent_store_list(cfg->workspace_root, ids, 200);
cJSON *root = cJSON_CreateObject();
if (!root)
if (!root) {
free(ids);
return cst_http_send_err(c, 500, "out of memory");
}
char cur[128] = "";
cst_agent_store_get_current(cfg->workspace_root, cur, sizeof(cur));
cJSON_AddStringToObject(root, "current", cur);
@@ -893,6 +970,7 @@ static int h_sessions(cst_http_conn_t *c, void *ud) {
cJSON_AddNumberToObject(o, "mtime", (double)in.mtime);
cJSON_AddItemToArray(arr, o);
}
free(ids);
char *json = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
int rc = cst_http_send_json(c, 200, json);
@@ -900,7 +978,7 @@ static int h_sessions(cst_http_conn_t *c, void *ud) {
return rc;
}
static int h_session_new(cst_http_conn_t *c, void *ud) {
static int h_session_new_x(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
char id[128] = "";
if (cst_agent_new_session(w->agent, id, sizeof(id)) != 0)
@@ -920,7 +998,7 @@ static int h_session_new(cst_http_conn_t *c, void *ud) {
/* POST /api/workspace {"path":"/abs/dir"} - re-root the agent. The session
* store is per directory, so this always lands in a fresh session there. */
static int h_workspace(cst_http_conn_t *c, void *ud) {
static int h_workspace_x(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
const cst_http_req_t *req = cst_http_request(c);
cJSON *j = req->body_len ? cJSON_Parse(req->body) : NULL;
@@ -950,7 +1028,7 @@ static int h_workspace(cst_http_conn_t *c, void *ud) {
return rc;
}
static int h_session_switch(cst_http_conn_t *c, void *ud) {
static int h_session_switch_x(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
const cst_http_req_t *req = cst_http_request(c);
cJSON *j = req->body_len ? cJSON_Parse(req->body) : NULL;
@@ -975,6 +1053,15 @@ static int h_config(cst_http_conn_t *c, void *ud) {
cJSON_Delete(j);
return cst_http_send_err(c, 400, "expected a JSON object");
}
/* the strings are read by the turn as it builds each request; approval
* and plan mode may change mid-turn (say, to yolo at a pending gate) */
int claimed = cJSON_GetObjectItem(j, "model") ||
cJSON_GetObjectItem(j, "provider") ||
cJSON_GetObjectItem(j, "effort");
if (claimed && wui_claim(w) != 0) {
cJSON_Delete(j);
return cst_http_send_err(c, 409, "busy: wait for the running turn");
}
const cJSON *item;
if ((item = cJSON_GetObjectItem(j, "model")) && cJSON_IsString(item))
snprintf(cfg->model, sizeof(cfg->model), "%s", item->valuestring);
@@ -995,12 +1082,14 @@ static int h_config(cst_http_conn_t *c, void *ud) {
cfg->read_only = cfg->plan_mode;
}
cJSON_Delete(j);
if (claimed)
wui_release(w);
wui_emit_state(w);
return cst_http_send_str(c, 200, "application/json; charset=utf-8",
"{\"ok\":true}");
}
static int h_undo(cst_http_conn_t *c, void *ud) {
static int h_undo_x(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
char report[2048] = "";
if (cst_agent_undo(w->agent, report, sizeof(report)) != 0)
@@ -1010,7 +1099,7 @@ static int h_undo(cst_http_conn_t *c, void *ud) {
return cst_http_send_str(c, 200, "text/plain; charset=utf-8", report);
}
static int h_compact(cst_http_conn_t *c, void *ud) {
static int h_compact_x(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
char err[256] = "";
if (cst_agent_compact(w->agent, err, sizeof(err)) != 0)
@@ -1020,7 +1109,7 @@ static int h_compact(cst_http_conn_t *c, void *ud) {
"{\"ok\":true}");
}
static int h_attach_upload(cst_http_conn_t *c, void *ud) {
static int h_attach_upload_x(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
const cst_http_req_t *req = cst_http_request(c);
char name[256] = "pasted.png";
@@ -1036,7 +1125,7 @@ static int h_attach_upload(cst_http_conn_t *c, void *ud) {
"{\"ok\":true}");
}
static int h_attach_clear(cst_http_conn_t *c, void *ud) {
static int h_attach_clear_x(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
cst_agent_pending_clear(w->agent);
wui_emit_state(w);
@@ -1044,6 +1133,14 @@ static int h_attach_clear(cst_http_conn_t *c, void *ud) {
"{\"ok\":true}");
}
WUI_EXCLUSIVE(h_session_new)
WUI_EXCLUSIVE(h_workspace)
WUI_EXCLUSIVE(h_session_switch)
WUI_EXCLUSIVE(h_undo)
WUI_EXCLUSIVE(h_compact)
WUI_EXCLUSIVE(h_attach_upload)
WUI_EXCLUSIVE(h_attach_clear)
static int h_quit(cst_http_conn_t *c, void *ud) {
wui_t *w = ud;
w->stopping = 1;
@@ -1107,10 +1204,51 @@ static int host_is_loopback(const char *host) {
return 0;
}
static int strncasecmp_ascii(const char *a, const char *b, size_t n) {
for (size_t i = 0; i < n; i++) {
char x = a[i], y = b[i];
if (x >= 'A' && x <= 'Z')
x = (char)(x - 'A' + 'a');
if (y >= 'A' && y <= 'Z')
y = (char)(y - 'A' + 'a');
if (x != y)
return x < y ? -1 : 1;
if (!x)
return 0;
}
return 0;
}
/* A browser names the page that sent a request in Origin. Without a token
* that is the only thing that tells our own page from any site the user
* visits: a no-cors POST with a text/plain body needs no preflight and
* carries Host: 127.0.0.1. So it must be this very origin. */
static int origin_is_self(const char *origin, const char *host) {
if (!origin)
return 1; /* curl and friends; browsers always send it on POST */
while (*origin == ' ')
origin++;
const char *rest = NULL;
if (strncmp(origin, "http://", 7) == 0)
rest = origin + 7;
else if (strncmp(origin, "https://", 8) == 0)
rest = origin + 8;
if (!rest || !host)
return 0;
while (*host == ' ')
host++;
size_t n = strlen(host);
while (n && (host[n - 1] == ' ' || host[n - 1] == '\t'))
n--;
return strlen(rest) == n && strncasecmp_ascii(rest, host, n) == 0;
}
static int wui_auth(const cst_http_req_t *req, void *ud) {
wui_t *w = ud;
if (!w->need_auth)
return host_is_loopback(req->host) ? 0 : -1;
return host_is_loopback(req->host) && origin_is_self(req->origin, req->host)
? 0
: -1;
char buf[128];
if (req->authorization && strncmp(req->authorization, "Bearer ", 7) == 0 &&
token_matches(w, req->authorization + 7))
@@ -1158,23 +1296,21 @@ static void wui_make_token(char *out, size_t out_sz) {
}
static void open_browser(const char *url) {
char cmd[600];
#if defined(_WIN32)
char cmd[600];
snprintf(cmd, sizeof(cmd), "start \"\" \"%s\"", url);
os_run_shell_c(".", cmd, 0, NULL);
#elif defined(__APPLE__)
char cmd[600];
snprintf(cmd, sizeof(cmd), "open '%s'", url);
os_run_shell_c(".", cmd, 0, NULL);
#else
char cmd[600];
/* Termux has no xdg-open; termux-open-url hands the URL to Android. */
const char *opener =
getenv("TERMUX_VERSION") ? "termux-open-url" : "xdg-open";
snprintf(cmd, sizeof(cmd), "%s '%s' >/dev/null 2>&1 &", opener, url);
os_run_shell_c(".", cmd, 0, NULL);
#endif
(void)url;
os_run_result_t r;
memset(&r, 0, sizeof(r)); /* a NULL result makes the runner refuse */
os_run_shell_c(".", cmd, 0, &r);
os_run_result_free(&r);
}
int cst_webui_run(cst_agent_t *agent, const cst_webui_opts_t *opts) {
@@ -1194,6 +1330,14 @@ int cst_webui_run(cst_agent_t *agent, const cst_webui_opts_t *opts) {
snprintf(w->host, sizeof(w->host), "%s",
opts->host && opts->host[0] ? opts->host : "127.0.0.1");
if (opts->token && opts->token[0]) {
if (strlen(opts->token) >= sizeof(w->token)) {
fprintf(stderr, "webui: the token is longer than %d characters\n",
(int)sizeof(w->token) - 1);
pthread_cond_destroy(&w->cv);
pthread_mutex_destroy(&w->mu);
free(w);
return 1;
}
snprintf(w->token, sizeof(w->token), "%s", opts->token);
w->need_auth = 1;
} else if (strcmp(w->host, "127.0.0.1") != 0 &&
@@ -1227,8 +1371,10 @@ int cst_webui_run(cst_agent_t *agent, const cst_webui_opts_t *opts) {
routes[i].ud = w;
routes[i].auth = w->need_auth || strcmp(routes[i].path, "/") != 0;
}
routes[0].auth = 0; /* the shell page itself sets the cookie */
routes[sizeof(routes) / sizeof(routes[0]) - 1].auth = w->need_auth;
/* the shell page and its assets carry no token (app.js reads it from the
* URL and sets the cookie); h_asset checks /api/attach/ itself */
routes[0].auth = 0;
routes[sizeof(routes) / sizeof(routes[0]) - 1].auth = 0;
cst_http_opts_t ho;
memset(&ho, 0, sizeof(ho));
@@ -1262,8 +1408,12 @@ int cst_webui_run(cst_agent_t *agent, const cst_webui_opts_t *opts) {
w->token);
fflush(stderr);
wui_emit_state(w);
if (opts->open_browser)
open_browser(url);
if (opts->open_browser) {
char full[sizeof(url) + 80];
snprintf(full, sizeof(full), "%s%s%s", url, w->need_auth ? "#token=" : "",
w->need_auth ? w->token : "");
open_browser(full);
}
if (opts->prompt && opts->prompt[0]) {
char err2[256] = "";
@@ -1281,8 +1431,38 @@ int cst_webui_run(cst_agent_t *agent, const cst_webui_opts_t *opts) {
nanosleep(&ts, NULL);
}
fprintf(stderr, "webui: shutting down\n");
/* Wake a turn parked on a permission/ask_user answer, cancel it and wait
* for its thread: it emits through w, and the agent is destroyed next. */
pthread_mutex_lock(&w->mu);
w->stopping = 1;
cst_http_stop(w->srv);
pthread_cond_broadcast(&w->cv);
int live = w->turn_live;
pthread_mutex_unlock(&w->mu);
cst_agent_request_cancel(w->agent); /* also a /api/compact in flight */
if (live) {
struct timespec deadline;
clock_gettime(CLOCK_REALTIME, &deadline);
deadline.tv_sec += 30;
pthread_mutex_lock(&w->mu);
while (w->turn_live &&
pthread_cond_timedwait(&w->cv, &w->mu, &deadline) == 0)
;
live = w->turn_live;
pthread_mutex_unlock(&w->mu);
}
if (live) {
/* still running (a command ignoring cancel): the caller would destroy
* the agent under it, so leave without tearing anything down */
fprintf(stderr, "webui: the running turn did not stop in 30 s\n");
fflush(NULL);
_Exit(1);
}
if (cst_http_stop(w->srv) > 0) {
/* a handler still runs and uses w and the agent: same as above */
fprintf(stderr, "webui: a request did not finish in 30 s\n");
fflush(NULL);
_Exit(1);
}
cst_http_free(w->srv);
for (int i = 0; i < WUI_RING; i++)
free(w->ring[i].frame);