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
+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
}