/* WebUI: the HTTP + SSE front end (see include/cst_webui.h). * * Threading: every HTTP connection runs on its own thread (src/http_srv.c), * a turn runs on its own worker thread, and the agent itself is only ever * touched from the turn thread or while holding w->mu. The hooks the agent * calls live on the turn thread: they append to the event ring, wake the * event-stream threads and, for permission/ask_user, block until the * browser answers (or the timeout denies it). Nothing in the engine was * changed to make this work - the hooks are the interface the TUI uses too. */ #ifndef _DEFAULT_SOURCE #define _DEFAULT_SOURCE #endif #include "cst_agent.h" #include "cst_attach.h" #include "cst_http.h" #include "cst_util.h" #include "cst_web_md.h" #include "cst_webui.h" #include "cJSON.h" #include "os_compat.h" #include #include #include #include #include #include #define WUI_RING 2000 #define WUI_TOOL_OUT 32768 #define WUI_ASK_MAX 1024 typedef struct { const char *name; const char *ctype; const unsigned char *data; size_t len; } wui_asset_t; #include "web_assets.inc" typedef struct wui_sse { struct wui *w; cst_http_conn_t *c; uint64_t cursor; pthread_mutex_t mu; pthread_cond_t cv; int closed; struct wui_sse *next; } wui_sse_t; typedef struct wui { cst_agent_t *agent; cst_http_server_t *srv; char host[64]; char token[64]; int need_auth; const char *root; /* --web-dev: serve assets from disk */ int perm_timeout_s; pthread_mutex_t mu; pthread_cond_t cv; /* permission / ask_user answers */ cst_web_md_t *md; /* live markdown for the running turn */ /* event ring */ uint64_t seq; struct { uint64_t seq; char *frame; } ring[WUI_RING]; wui_sse_t *subs; int busy; 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; char ask_ans[WUI_ASK_MAX]; volatile int stopping; } wui_t; /* --- events --------------------------------------------------------------- */ 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) { if (!data) data = cJSON_CreateObject(); if (!data) return; cJSON_AddStringToObject(data, "type", type); 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); free(json); if (!f.buf) { cst_buf_free(&f); return; } pthread_mutex_lock(&w->mu); w->seq++; int slot = (int)(w->seq % WUI_RING); free(w->ring[slot].frame); w->ring[slot].frame = f.buf; /* stolen */ w->ring[slot].seq = w->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); } static void wui_emit_str(wui_t *w, const char *type, const char *key, const char *val) { cJSON *o = cJSON_CreateObject(); if (!o) return; cJSON_AddStringToObject(o, key, val ? val : ""); wui_emit(w, type, o); } static void wui_emit_text(wui_t *w, const char *type, const char *text, size_t len) { cJSON *o = cJSON_CreateObject(); if (!o) return; char *s = malloc(len + 1); if (!s) { cJSON_Delete(o); return; } memcpy(s, text, len); s[len] = 0; cJSON_AddStringToObject(o, "text", s); free(s); wui_emit(w, type, o); } /* --- state --------------------------------------------------------------- */ static cJSON *wui_state_new(wui_t *w) { const cst_agent_config_t *cfg = cst_agent_config(w->agent); cJSON *o = cJSON_CreateObject(); if (!o || !cfg) { cJSON_Delete(o); return NULL; } char sid[128] = ""; cst_agent_session_id(w->agent, sid, sizeof(sid)); cst_usage_t sess = {0}, turn = {0}; cst_agent_usage(w->agent, &sess, &turn); cJSON_AddStringToObject(o, "protocol", "cst-webui/1"); cJSON_AddStringToObject(o, "workspace", cfg->workspace_root); cJSON_AddStringToObject(o, "session", sid); cJSON_AddStringToObject(o, "provider", cfg->provider); cJSON_AddStringToObject(o, "model", cfg->model); cJSON_AddStringToObject(o, "effort", cfg->effort); cJSON_AddStringToObject(o, "approval", cst_approval_name(cfg->approval)); cJSON_AddStringToObject(o, "protocol_tools", cst_protocol_name(cfg->protocol)); cJSON_AddBoolToObject(o, "plan_mode", cfg->plan_mode); cJSON_AddBoolToObject(o, "status_busy", w->busy); cJSON_AddStringToObject(o, "todo", cst_agent_todo(w->agent) ? cst_agent_todo(w->agent) : ""); cJSON_AddNumberToObject(o, "context_tokens", (double)cst_agent_last_prompt_tokens(w->agent)); cJSON_AddBoolToObject(o, "undo_available", cst_agent_undo_available(w->agent) != 0); cJSON *u = cJSON_AddObjectToObject(o, "usage"); if (u) { cJSON_AddNumberToObject(u, "requests", sess.requests); cJSON_AddNumberToObject(u, "prompt_tokens", (double)sess.prompt); cJSON_AddNumberToObject(u, "completion_tokens", (double)sess.out); cJSON_AddNumberToObject(u, "cached_tokens", (double)sess.cached); cJSON_AddNumberToObject(u, "cache_miss_tokens", (double)sess.miss); cJSON_AddNumberToObject(u, "cost", sess.cost); cJSON_AddBoolToObject(u, "priced", sess.priced != 0); cJSON_AddStringToObject(u, "currency", sess.currency); } cJSON *imgs = cJSON_AddArrayToObject(o, "pending_images"); int n = cst_agent_pending_count(w->agent); for (int i = 0; i < n && imgs; i++) { char line[160]; if (cst_agent_pending_describe(w->agent, i, line, sizeof(line)) == 0) cJSON_AddItemToArray(imgs, cJSON_CreateString(line)); } return o; } static void wui_emit_state(wui_t *w) { cJSON *o = wui_state_new(w); if (o) wui_emit(w, "state", o); } /* --- session -------------------------------------------------------------- */ static cJSON *msg_json(const cst_message_t *m) { cJSON *o = cJSON_CreateObject(); if (!o) return NULL; const char *role = "user"; switch (m->role) { case CST_ROLE_SYSTEM: role = "system"; break; case CST_ROLE_ASSISTANT: role = "assistant"; break; case CST_ROLE_TOOL: role = "tool"; break; default: role = "user"; break; } cJSON_AddStringToObject(o, "role", role); cJSON_AddStringToObject(o, "content", m->content ? m->content : ""); if (m->role == CST_ROLE_ASSISTANT && m->content && m->content[0]) { char *html = cst_web_md_html(m->content, strlen(m->content)); if (html) { cJSON_AddStringToObject(o, "html", html); free(html); } } if (m->reasoning && m->reasoning[0]) cJSON_AddStringToObject(o, "reasoning", m->reasoning); if (m->tool_call_id[0]) cJSON_AddStringToObject(o, "tool_call_id", m->tool_call_id); if (m->tool_name[0]) cJSON_AddStringToObject(o, "tool_name", m->tool_name); if (m->tool_calls_json && m->tool_calls_json[0]) { cJSON *tc = cJSON_Parse(m->tool_calls_json); if (tc) { cJSON_AddItemToObject(o, "tool_calls", tc); } } if (m->nattach > 0) { cJSON *arr = cJSON_AddArrayToObject(o, "attach"); for (int i = 0; arr && i < m->nattach; i++) { cJSON *a = cJSON_CreateObject(); if (!a) continue; cJSON_AddStringToObject(a, "name", m->attach[i].name); cJSON_AddStringToObject(a, "mime", m->attach[i].mime); cJSON_AddStringToObject(a, "rel", m->attach[i].rel); cJSON_AddNumberToObject(a, "bytes", (double)m->attach[i].bytes); cJSON_AddItemToArray(arr, a); } } return o; } static char *wui_session_json(wui_t *w, int from) { cst_session_t *s = cst_agent_session(w->agent); if (!s) return NULL; cJSON *root = cJSON_CreateObject(); if (!root) return NULL; cJSON *arr = cJSON_AddArrayToObject(root, "messages"); cst_session_lock(s); int n = cst_session_count(s); for (int i = from; i < n; i++) { const cst_message_t *m = cst_session_get(s, i); if (!m) continue; cJSON *j = msg_json(m); if (j && arr) cJSON_AddItemToArray(arr, j); else cJSON_Delete(j); } cst_session_unlock(s); char *out = cJSON_PrintUnformatted(root); cJSON_Delete(root); return out; } /* --- hooks --------------------------------------------------------------- */ static int wui_md_emit(const char *html, size_t len, void *ud) { wui_t *w = ud; if (len) wui_emit_text(w, "assistant_html", html, len); return 0; } static void on_status(const char *msg, void *ud) { wui_emit_str((wui_t *)ud, "status", "message", msg); } static void on_assistant_delta(const char *text, size_t len, void *ud) { wui_t *w = ud; if (!w->md) w->md = cst_web_md_new(wui_md_emit, w); if (w->md) cst_web_md_feed(w->md, text, len); wui_emit_text(w, "assistant_delta", text, len); } static void on_think_delta(const char *text, size_t len, void *ud) { wui_emit_text((wui_t *)ud, "think_delta", text, len); } static cJSON *call_json(const ctl_tool_call_t *call) { cJSON *o = cJSON_CreateObject(); if (!o) return NULL; cJSON_AddStringToObject(o, "id", call->id); cJSON_AddStringToObject(o, "name", call->name); cJSON *args = cJSON_AddObjectToObject(o, "args"); for (int i = 0; args && i < call->nargs; i++) { cJSON *v = cJSON_CreateString(call->args[i].value ? call->args[i].value : ""); if (v) cJSON_AddItemToObject(args, call->args[i].key, v); } return o; } static void on_tool_event(const char *phase, const ctl_tool_call_t *call, const char *detail, void *ud) { wui_t *w = ud; cJSON *o = call ? call_json(call) : cJSON_CreateObject(); if (!o) return; cJSON_AddStringToObject(o, "phase", phase ? phase : ""); if (detail) cJSON_AddStringToObject(o, "detail", detail); wui_emit(w, "tool", o); } static void on_tool_result(const ctl_tool_call_t *call, const char *out, size_t len, int exit_code, void *ud) { wui_t *w = ud; cJSON *o = call ? call_json(call) : cJSON_CreateObject(); if (!o) return; size_t shown = len > WUI_TOOL_OUT ? WUI_TOOL_OUT : len; char *s = malloc(shown + 1); if (!s) { cJSON_Delete(o); return; } memcpy(s, out, shown); s[shown] = 0; cJSON_AddStringToObject(o, "out", s); cJSON_AddBoolToObject(o, "truncated", len > shown); cJSON_AddNumberToObject(o, "bytes", (double)len); free(s); cJSON_AddNumberToObject(o, "exit", exit_code); wui_emit(w, "tool_result", o); } static void on_todo(const char *text, void *ud) { wui_emit_str((wui_t *)ud, "todo", "text", text ? text : ""); } static int wui_wait_permission(wui_t *w, const char *tool, const char *detail, const char *preview) { pthread_mutex_lock(&w->mu); w->req_id++; uint64_t id = w->req_id; w->perm_id = id; w->perm_active = 1; w->perm_answer = -1; int has_client = w->subs != NULL; pthread_mutex_unlock(&w->mu); cJSON *o = cJSON_CreateObject(); if (o) { cJSON_AddNumberToObject(o, "id", (double)id); cJSON_AddStringToObject(o, "tool", tool ? tool : "tool"); cJSON_AddStringToObject(o, "detail", detail ? detail : ""); cJSON_AddStringToObject(o, "preview", preview ? preview : ""); cJSON_AddBoolToObject(o, "sandbox", detail && strncmp(detail, CST_PERM_SANDBOX, strlen(CST_PERM_SANDBOX)) == 0); 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. */ if (!has_client) timeout = 3; int answer = CST_PERM_DENY; struct timespec deadline; clock_gettime(CLOCK_REALTIME, &deadline); deadline.tv_sec += timeout; pthread_mutex_lock(&w->mu); for (;;) { if (w->perm_active && w->perm_id == id && w->perm_answer >= 0) { answer = w->perm_answer; w->perm_active = 0; break; } if (w->stopping) break; int rc = pthread_cond_timedwait(&w->cv, &w->mu, &deadline); if (rc != 0) { w->perm_active = 0; break; } } pthread_mutex_unlock(&w->mu); cJSON *e = cJSON_CreateObject(); if (e) { cJSON_AddNumberToObject(e, "id", (double)id); cJSON_AddNumberToObject(e, "answer", answer); wui_emit(w, "permission_done", e); } return answer; } static int on_permission(const char *tool_name, const char *detail, const char *preview, void *ud) { return wui_wait_permission((wui_t *)ud, tool_name, detail, preview); } static int on_ask_user(const char *question, const char *options, char *ans, size_t ans_sz, void *ud) { wui_t *w = ud; pthread_mutex_lock(&w->mu); w->req_id++; uint64_t id = w->req_id; w->ask_id = id; w->ask_active = 1; w->ask_rc = -1; w->ask_ans[0] = 0; int has_client = w->subs != NULL; pthread_mutex_unlock(&w->mu); cJSON *o = cJSON_CreateObject(); if (o) { cJSON_AddNumberToObject(o, "id", (double)id); cJSON_AddStringToObject(o, "question", question ? question : ""); cJSON *arr = cJSON_AddArrayToObject(o, "options"); for (const char *p = options; arr && p && *p;) { const char *nl = strchr(p, '\n'); size_t n = nl ? (size_t)(nl - p) : strlen(p); char *tmp = malloc(n + 1); if (tmp) { memcpy(tmp, p, n); tmp[n] = 0; cJSON_AddItemToArray(arr, cJSON_CreateString(tmp)); free(tmp); } p = nl ? nl + 1 : NULL; } wui_emit(w, "ask_user", o); } int timeout = has_client ? 600 : 3; int rc = -1; struct timespec deadline; clock_gettime(CLOCK_REALTIME, &deadline); deadline.tv_sec += timeout; pthread_mutex_lock(&w->mu); for (;;) { if (w->ask_active && w->ask_id == id && w->ask_rc != 0) { rc = w->ask_rc == 1 ? 0 : -1; snprintf(ans, ans_sz, "%s", w->ask_ans); w->ask_active = 0; break; } if (w->stopping) break; int wrc = pthread_cond_timedwait(&w->cv, &w->mu, &deadline); if (wrc != 0) { w->ask_active = 0; break; } } pthread_mutex_unlock(&w->mu); return rc; } /* --- routes -------------------------------------------------------------- */ static int read_file_all(const char *path, char **out, size_t *out_len) { FILE *fp = fopen(path, "rb"); if (!fp) return -1; cst_buf_t b = {0}; char tmp[8192]; size_t n; while ((n = fread(tmp, 1, sizeof(tmp), fp)) > 0) { if (cst_buf_append(&b, tmp, n) != 0) { fclose(fp); cst_buf_free(&b); return -1; } } fclose(fp); size_t total = b.len; *out = cst_buf_steal(&b); if (!*out) return -1; *out_len = total; return 0; } static int wui_serve_file(cst_http_conn_t *c, wui_t *w, const char *path) { if (!w->root || !path || !path[0] || strstr(path, "..")) return -1; char full[4096]; const char *rel = path[0] == '/' ? path + 1 : path; if (!rel[0]) rel = "index.html"; if (snprintf(full, sizeof(full), "%s/%s", w->root, rel) >= (int)sizeof(full)) return -1; char *data = NULL; size_t len = 0; if (read_file_all(full, &data, &len) != 0) return -1; const char *ctype = "application/octet-stream"; size_t n = strlen(rel); if (n > 5 && strcmp(rel + n - 5, ".html") == 0) ctype = "text/html; charset=utf-8"; else if (n > 3 && strcmp(rel + n - 3, ".js") == 0) ctype = "text/javascript; charset=utf-8"; else if (n > 4 && strcmp(rel + n - 4, ".css") == 0) ctype = "text/css; charset=utf-8"; else if (n > 4 && strcmp(rel + n - 4, ".svg") == 0) ctype = "image/svg+xml"; int rc = cst_http_send(c, 200, ctype, data, len); free(data); return rc; } static int wui_serve_asset(cst_http_conn_t *c, wui_t *w, const char *path) { const char *rel = path[0] == '/' ? path + 1 : path; if (!rel[0]) rel = "index.html"; if (w->root) return wui_serve_file(c, w, path); for (int i = 0; i < wui_asset_count; i++) { if (strcmp(wui_assets[i].name, rel) == 0) return cst_http_send(c, 200, wui_assets[i].ctype, wui_assets[i].data, wui_assets[i].len); } return -1; } 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); /* Stored session images: /api/attach/. The name is validated before * it can touch the filesystem, and only the session's own directory is * reachable. */ static const char prefix[] = "/api/attach/"; if (strncmp(req->path, prefix, sizeof(prefix) - 1) == 0) { const char *rel = req->path + sizeof(prefix) - 1; if (!cst_attach_rel_ok(rel)) return -1; const char *sess = cst_agent_session_path(w->agent); char dir[OS_PATH_MAX], full[OS_PATH_MAX]; if (!sess || cst_attach_dir(sess, dir, sizeof(dir)) != 0) return -1; if (snprintf(full, sizeof(full), "%s/%s", dir, rel) >= (int)sizeof(full)) return -1; char *data = NULL; size_t len = 0; if (read_file_all(full, &data, &len) != 0) return -1; const char *ctype = "application/octet-stream"; 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)) ctype = "image/jpeg"; else if (n > 4 && strcmp(rel + n - 4, ".gif") == 0) ctype = "image/gif"; else if (n > 5 && strcmp(rel + n - 5, ".webp") == 0) ctype = "image/webp"; else if (n > 4 && strcmp(rel + n - 4, ".bmp") == 0) ctype = "image/bmp"; int rc = cst_http_send(c, 200, ctype, data, len); free(data); return rc; } return wui_serve_asset(c, w, req->path); } /* --- API handlers -------------------------------------------------------- */ static int h_state(cst_http_conn_t *c, void *ud) { wui_t *w = ud; pthread_mutex_lock(&w->mu); cJSON *o = wui_state_new(w); pthread_mutex_unlock(&w->mu); if (!o) return cst_http_send_err(c, 500, "out of memory"); char *json = cJSON_PrintUnformatted(o); cJSON_Delete(o); if (!json) return cst_http_send_err(c, 500, "out of memory"); int rc = cst_http_send_json(c, 200, json); free(json); return rc; } static int h_session(cst_http_conn_t *c, void *ud) { wui_t *w = ud; char buf[32]; int from = 0; if (cst_http_query(cst_http_request(c), "from", buf, sizeof(buf)) == 0) from = atoi(buf); if (from < 0) from = 0; char *json = wui_session_json(w, from); if (!json) return cst_http_send_err(c, 500, "out of memory"); int rc = cst_http_send_json(c, 200, json); free(json); return rc; } static int h_events(cst_http_conn_t *c, void *ud) { wui_t *w = ud; const cst_http_req_t *req = cst_http_request(c); wui_sse_t s; memset(&s, 0, sizeof(s)); s.w = w; s.c = c; s.cursor = 0; char buf[32]; if (cst_http_query(req, "since", buf, sizeof(buf)) == 0) s.cursor = (uint64_t)strtoull(buf, 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 */ 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; w->subs = &s; pthread_mutex_unlock(&w->mu); if (cst_http_stream_begin(c, "text/event-stream; charset=utf-8") != 0) { goto done; } cst_http_stream_write(c, ": cst-agent webui\n\n", 19); for (;;) { if (cst_http_stopping(w->srv) || w->stopping || s.closed) break; cst_buf_t out = {0}; pthread_mutex_lock(&w->mu); uint64_t last = w->seq; uint64_t first = wui_first_seq(w); int n = 0; for (uint64_t q = s.cursor + 1; q <= last && n < 256; q++) { if (q < first) continue; char *frame = w->ring[q % WUI_RING].frame; if (!frame) continue; cst_buf_printf(&out, "id: %llu\n%s", (unsigned long long)q, frame); n++; s.cursor = q; } pthread_mutex_unlock(&w->mu); if (n == 0) { struct timespec ts; clock_gettime(CLOCK_REALTIME, &ts); ts.tv_sec += 1; pthread_mutex_lock(&s.mu); if (!s.closed) pthread_cond_timedwait(&s.cv, &s.mu, &ts); pthread_mutex_unlock(&s.mu); cst_buf_free(&out); if (cst_http_stream_write(c, ": ping\n\n", 8) != 0) break; continue; } int rc = cst_http_stream_write(c, out.buf, out.len); cst_buf_free(&out); if (rc != 0) break; } done: pthread_mutex_lock(&w->mu); wui_sse_t **pp = &w->subs; while (*pp && *pp != &s) pp = &(*pp)->next; if (*pp == &s) *pp = s.next; pthread_mutex_unlock(&w->mu); pthread_mutex_destroy(&s.mu); pthread_cond_destroy(&s.cv); return 0; } typedef struct { wui_t *w; char *prompt; } wui_turn_t; static void wui_end_turn(wui_t *w, int rc, const char *err) { /* Flush the markdown *outside* the lock: flushing emits the last HTML * chunks, and wui_emit takes w->mu itself. */ pthread_mutex_lock(&w->mu); cst_web_md_t *md = w->md; w->md = NULL; w->busy = 0; pthread_mutex_unlock(&w->mu); if (md) { cst_web_md_finish(md); cst_web_md_free(md); } cJSON *o = cJSON_CreateObject(); if (o) { cJSON_AddNumberToObject(o, "rc", rc); cJSON_AddStringToObject(o, "error", err ? err : ""); wui_emit(w, "turn_end", o); } wui_emit_state(w); } static void *wui_turn_main(void *arg) { wui_turn_t *t = arg; wui_t *w = t->w; cst_agent_hooks_t hooks; memset(&hooks, 0, sizeof(hooks)); hooks.on_status = on_status; hooks.on_assistant_delta = on_assistant_delta; hooks.on_think_delta = on_think_delta; hooks.on_tool_event = on_tool_event; hooks.on_tool_result = on_tool_result; hooks.on_permission = on_permission; hooks.on_ask_user = on_ask_user; hooks.on_todo = on_todo; hooks.userdata = w; char err[512]; err[0] = 0; int rc = cst_agent_run_turn_ex(w->agent, t->prompt, &hooks, err, sizeof(err)); wui_end_turn(w, rc, err); free(t->prompt); free(t); 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) { pthread_mutex_unlock(&w->mu); snprintf(err, err_sz, "a turn is already running"); return -1; } w->busy = 1; if (w->md) { cst_web_md_free(w->md); w->md = NULL; } pthread_mutex_unlock(&w->mu); wui_turn_t *t = calloc(1, sizeof(*t)); if (!t) { pthread_mutex_lock(&w->mu); w->busy = 0; pthread_mutex_unlock(&w->mu); snprintf(err, err_sz, "out of memory"); return -1; } t->w = w; t->prompt = strdup(text ? text : ""); if (!t->prompt) { free(t); pthread_mutex_lock(&w->mu); w->busy = 0; pthread_mutex_unlock(&w->mu); snprintf(err, err_sz, "out of memory"); return -1; } pthread_t th; if (pthread_create(&th, NULL, wui_turn_main, t) != 0) { free(t->prompt); free(t); pthread_mutex_lock(&w->mu); w->busy = 0; pthread_mutex_unlock(&w->mu); snprintf(err, err_sz, "cannot start the turn thread"); return -1; } pthread_detach(th); wui_emit_state(w); return 0; } static int h_turn(cst_http_conn_t *c, void *ud) { wui_t *w = ud; const cst_http_req_t *req = cst_http_request(c); cJSON *body = req->body_len ? cJSON_Parse(req->body) : NULL; const cJSON *text = body ? cJSON_GetObjectItem(body, "text") : NULL; if (!body || !cJSON_IsString(text)) { cJSON_Delete(body); return cst_http_send_err(c, 400, "expected {\"text\": \"...\"}"); } char err[256] = ""; int rc = wui_start_turn(w, text->valuestring, err, sizeof(err)); cJSON_Delete(body); if (rc != 0) return cst_http_send_err(c, 409, err); return cst_http_send_str(c, 202, "application/json; charset=utf-8", "{\"ok\":true}"); } static int h_cancel(cst_http_conn_t *c, void *ud) { wui_t *w = ud; cst_agent_request_cancel(w->agent); return cst_http_send_str(c, 200, "application/json; charset=utf-8", "{\"ok\":true}"); } static int h_permission(cst_http_conn_t *c, void *ud) { wui_t *w = ud; const cst_http_req_t *req = cst_http_request(c); cJSON *body = req->body_len ? cJSON_Parse(req->body) : NULL; const cJSON *id = body ? cJSON_GetObjectItem(body, "id") : NULL; const cJSON *ans = body ? cJSON_GetObjectItem(body, "answer") : NULL; int ok = 0; if (cJSON_IsNumber(id) && cJSON_IsNumber(ans)) { int answer = (int)ans->valuedouble; if (answer >= CST_PERM_DENY && answer <= CST_PERM_ALWAYS_PREFIX) { pthread_mutex_lock(&w->mu); if (w->perm_active && w->perm_id == (uint64_t)id->valuedouble) { w->perm_answer = answer; pthread_cond_broadcast(&w->cv); ok = 1; } pthread_mutex_unlock(&w->mu); } } cJSON_Delete(body); if (!ok) return cst_http_send_err(c, 409, "no such permission request"); return cst_http_send_str(c, 200, "application/json; charset=utf-8", "{\"ok\":true}"); } static int h_ask_user(cst_http_conn_t *c, void *ud) { wui_t *w = ud; const cst_http_req_t *req = cst_http_request(c); cJSON *body = req->body_len ? cJSON_Parse(req->body) : NULL; const cJSON *id = body ? cJSON_GetObjectItem(body, "id") : NULL; const cJSON *text = body ? cJSON_GetObjectItem(body, "text") : NULL; const cJSON *dismissed = body ? cJSON_GetObjectItem(body, "dismiss") : NULL; int ok = 0; if (cJSON_IsNumber(id)) { pthread_mutex_lock(&w->mu); if (w->ask_active && w->ask_id == (uint64_t)id->valuedouble) { snprintf(w->ask_ans, sizeof(w->ask_ans), "%s", cJSON_IsString(text) ? text->valuestring : ""); w->ask_rc = (dismissed && cJSON_IsTrue(dismissed)) ? -1 : 1; pthread_cond_broadcast(&w->cv); ok = 1; } pthread_mutex_unlock(&w->mu); } cJSON_Delete(body); if (!ok) return cst_http_send_err(c, 409, "no such question"); return cst_http_send_str(c, 200, "application/json; charset=utf-8", "{\"ok\":true}"); } 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]; int n = cst_agent_store_list(cfg->workspace_root, ids, 200); cJSON *root = cJSON_CreateObject(); if (!root) 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); cJSON *arr = cJSON_AddArrayToObject(root, "sessions"); for (int i = 0; arr && i < n; i++) { cst_session_info_t in; if (cst_agent_store_info(cfg->workspace_root, ids[i], &in) != 0) continue; cJSON *o = cJSON_CreateObject(); if (!o) continue; cJSON_AddStringToObject(o, "id", in.id); cJSON_AddStringToObject(o, "title", in.title[0] ? in.title : in.first_user); cJSON_AddNumberToObject(o, "messages", in.messages); cJSON_AddNumberToObject(o, "mtime", (double)in.mtime); cJSON_AddItemToArray(arr, o); } char *json = cJSON_PrintUnformatted(root); cJSON_Delete(root); int rc = cst_http_send_json(c, 200, json); free(json); return rc; } static int h_session_new(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) return cst_http_send_err(c, 500, "cannot start a session"); wui_emit_str(w, "session", "id", id); wui_emit_state(w); cJSON *o = cJSON_CreateObject(); if (!o) return cst_http_send_err(c, 500, "out of memory"); cJSON_AddStringToObject(o, "id", id); char *json = cJSON_PrintUnformatted(o); cJSON_Delete(o); int rc = cst_http_send_json(c, 200, json); free(json); return rc; } /* 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) { wui_t *w = ud; const cst_http_req_t *req = cst_http_request(c); cJSON *j = req->body_len ? cJSON_Parse(req->body) : NULL; const cJSON *p = j ? cJSON_GetObjectItem(j, "path") : NULL; if (!cJSON_IsString(p) || !p->valuestring[0]) { cJSON_Delete(j); return cst_http_send_err(c, 400, "path is required"); } char err[256] = ""; int rc = cst_agent_set_workspace(w->agent, p->valuestring, err, sizeof(err)); cJSON_Delete(j); if (rc != 0) return cst_http_send_err(c, 400, err[0] ? err : "cannot switch workspace"); char id[128] = ""; if (cst_agent_new_session(w->agent, id, sizeof(id)) != 0) return cst_http_send_err(c, 500, "cannot start a session there"); wui_emit_str(w, "session", "id", id); wui_emit_state(w); cJSON *o = cJSON_CreateObject(); if (!o) return cst_http_send_err(c, 500, "out of memory"); cJSON_AddStringToObject(o, "id", id); char *json = cJSON_PrintUnformatted(o); cJSON_Delete(o); rc = cst_http_send_json(c, 200, json); free(json); return rc; } static int h_session_switch(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; const cJSON *id = j ? cJSON_GetObjectItem(j, "id") : NULL; int rc = -2; if (cJSON_IsString(id)) rc = cst_agent_switch_session(w->agent, id->valuestring); cJSON_Delete(j); if (rc != 0) return cst_http_send_err(c, 404, "no such session"); wui_emit_state(w); return cst_http_send_str(c, 200, "application/json; charset=utf-8", "{\"ok\":true}"); } static int h_config(cst_http_conn_t *c, void *ud) { wui_t *w = ud; cst_agent_config_t *cfg = cst_agent_config_mut(w->agent); const cst_http_req_t *req = cst_http_request(c); cJSON *j = req->body_len ? cJSON_Parse(req->body) : NULL; if (!j || !cfg) { cJSON_Delete(j); return cst_http_send_err(c, 400, "expected a JSON object"); } const cJSON *item; if ((item = cJSON_GetObjectItem(j, "model")) && cJSON_IsString(item)) snprintf(cfg->model, sizeof(cfg->model), "%s", item->valuestring); if ((item = cJSON_GetObjectItem(j, "provider")) && cJSON_IsString(item)) snprintf(cfg->provider, sizeof(cfg->provider), "%s", item->valuestring); if ((item = cJSON_GetObjectItem(j, "effort")) && cJSON_IsString(item) && cst_effort_valid(item->valuestring)) snprintf(cfg->effort, sizeof(cfg->effort), "%s", item->valuestring); if ((item = cJSON_GetObjectItem(j, "approval")) && cJSON_IsString(item)) { int a = 0; if (cst_approval_parse(item->valuestring, &a) == 0) { cfg->approval = a; cfg->auto_approve = a == CST_APPROVAL_YOLO; } } if ((item = cJSON_GetObjectItem(j, "plan_mode")) && cJSON_IsBool(item)) { cfg->plan_mode = cJSON_IsTrue(item) ? 1 : 0; cfg->read_only = cfg->plan_mode; } cJSON_Delete(j); 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) { wui_t *w = ud; char report[2048] = ""; if (cst_agent_undo(w->agent, report, sizeof(report)) != 0) return cst_http_send_err(c, 409, "nothing to undo"); wui_emit_str(w, "status", "message", report); wui_emit_state(w); return cst_http_send_str(c, 200, "text/plain; charset=utf-8", report); } static int h_compact(cst_http_conn_t *c, void *ud) { wui_t *w = ud; char err[256] = ""; if (cst_agent_compact(w->agent, err, sizeof(err)) != 0) return cst_http_send_err(c, 500, err); wui_emit_state(w); return cst_http_send_str(c, 200, "application/json; charset=utf-8", "{\"ok\":true}"); } static int h_attach_upload(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"; cst_http_query(req, "name", name, sizeof(name)); if (req->body_len == 0) return cst_http_send_err(c, 400, "empty body"); char err[256] = ""; if (cst_agent_attach_bytes(w->agent, name, req->body, req->body_len, err, sizeof(err)) != 0) return cst_http_send_err(c, 400, err[0] ? err : "cannot attach the image"); wui_emit_state(w); return cst_http_send_str(c, 200, "application/json; charset=utf-8", "{\"ok\":true}"); } static int h_attach_clear(cst_http_conn_t *c, void *ud) { wui_t *w = ud; cst_agent_pending_clear(w->agent); wui_emit_state(w); return cst_http_send_str(c, 200, "application/json; charset=utf-8", "{\"ok\":true}"); } static int h_quit(cst_http_conn_t *c, void *ud) { wui_t *w = ud; w->stopping = 1; return cst_http_send_str(c, 200, "application/json; charset=utf-8", "{\"ok\":true}"); } /* --- auth ---------------------------------------------------------------- */ static int token_matches(const wui_t *w, const char *given) { if (!given || !w->token[0]) return 0; size_t a = strlen(given), b = strlen(w->token); if (a != b) return 0; unsigned diff = 0; for (size_t i = 0; i < a; i++) diff |= (unsigned char)given[i] ^ (unsigned char)w->token[i]; return diff == 0; } static int cookie_token(const char *cookie, char *out, size_t out_sz) { const char *p = cookie; while (p && *p) { while (*p == ' ' || *p == ';') p++; if (strncmp(p, "cst_token=", 10) == 0) { const char *v = p + 10; const char *end = strchr(v, ';'); size_t n = end ? (size_t)(end - v) : strlen(v); if (n >= out_sz) n = out_sz - 1; memcpy(out, v, n); out[n] = 0; return 0; } p = strchr(p, ';'); } return -1; } /* Host header must name this machine's loopback when no token is required: * a DNS-rebinding page would send its own name here. */ static int host_is_loopback(const char *host) { if (!host || !host[0]) return 0; while (*host == ' ') host++; if (strncmp(host, "127.0.0.1", 9) == 0) { char next = host[9]; return next == 0 || next == ':'; } if (strncmp(host, "localhost", 9) == 0) { char next = host[9]; return next == 0 || next == ':'; } if (strncmp(host, "[::1]", 5) == 0) { char next = host[5]; return next == 0 || next == ':'; } return 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; char buf[128]; if (req->authorization && strncmp(req->authorization, "Bearer ", 7) == 0 && token_matches(w, req->authorization + 7)) return 0; if (req->cookie && cookie_token(req->cookie, buf, sizeof(buf)) == 0 && token_matches(w, buf)) return 0; if (cst_http_query(req, "token", buf, sizeof(buf)) == 0 && token_matches(w, buf)) return 0; return -1; } /* --- startup ------------------------------------------------------------- */ static volatile sig_atomic_t g_webui_stop; static void webui_on_signal(int sig) { (void)sig; g_webui_stop = 1; } static void wui_make_token(char *out, size_t out_sz) { unsigned char raw[16]; size_t got = 0; FILE *fp = fopen("/dev/urandom", "rb"); if (fp) { got = fread(raw, 1, sizeof(raw), fp); fclose(fp); } if (got != sizeof(raw)) { unsigned seed = (unsigned)(os_now_ms() * 1000.0) ^ (unsigned)os_getpid(); for (size_t i = 0; i < sizeof(raw); i++) { seed = seed * 1103515245u + 12345u; raw[i] = (unsigned char)(seed >> 16); } } static const char hex[] = "0123456789abcdef"; size_t o = 0; for (size_t i = 0; i < sizeof(raw) && o + 2 < out_sz; i++) { out[o++] = hex[raw[i] >> 4]; out[o++] = hex[raw[i] & 15]; } out[o] = 0; } static void open_browser(const char *url) { #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; } int cst_webui_run(cst_agent_t *agent, const cst_webui_opts_t *opts) { cst_webui_opts_t def; if (!opts) { memset(&def, 0, sizeof(def)); opts = &def; } wui_t *w = calloc(1, sizeof(*w)); if (!w) return 1; pthread_mutex_init(&w->mu, NULL); w->agent = agent; w->perm_timeout_s = opts->perm_timeout_s; w->root = opts->root; pthread_cond_init(&w->cv, NULL); snprintf(w->host, sizeof(w->host), "%s", opts->host && opts->host[0] ? opts->host : "127.0.0.1"); if (opts->token && opts->token[0]) { snprintf(w->token, sizeof(w->token), "%s", opts->token); w->need_auth = 1; } else if (strcmp(w->host, "127.0.0.1") != 0 && strcmp(w->host, "localhost") != 0 && strcmp(w->host, "::1") != 0) { wui_make_token(w->token, sizeof(w->token)); w->need_auth = 1; } static cst_http_route_t routes[] = { {"GET", "/", h_asset, NULL, 0, 0}, {"GET", "/api/state", h_state, NULL, 0, 0}, {"GET", "/api/session", h_session, NULL, 0, 0}, {"GET", "/api/events", h_events, NULL, 0, 0}, {"GET", "/api/sessions", h_sessions, NULL, 0, 0}, {"POST", "/api/sessions/new", h_session_new, NULL, 0, 0}, {"POST", "/api/sessions/switch", h_session_switch, NULL, 0, 0}, {"POST", "/api/workspace", h_workspace, NULL, 0, 0}, {"POST", "/api/turn", h_turn, NULL, 0, 0}, {"POST", "/api/cancel", h_cancel, NULL, 0, 0}, {"POST", "/api/permission", h_permission, NULL, 0, 0}, {"POST", "/api/ask_user", h_ask_user, NULL, 0, 0}, {"POST", "/api/config", h_config, NULL, 0, 0}, {"POST", "/api/undo", h_undo, NULL, 0, 0}, {"POST", "/api/compact", h_compact, NULL, 0, 0}, {"POST", "/api/attach", h_attach_upload, NULL, 0, 32u * 1024u * 1024u}, {"DELETE", "/api/attach", h_attach_clear, NULL, 0, 0}, {"POST", "/api/quit", h_quit, NULL, 0, 0}, {"GET", "*", h_asset, NULL, 0, 0}, }; for (size_t i = 0; i < sizeof(routes) / sizeof(routes[0]); i++) { 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; cst_http_opts_t ho; memset(&ho, 0, sizeof(ho)); ho.host = w->host; ho.port = opts->port; ho.max_body = 1024 * 1024; ho.max_conn = 32; ho.routes = routes; ho.nroutes = (int)(sizeof(routes) / sizeof(routes[0])); ho.auth = wui_auth; ho.auth_ud = w; char err[256] = ""; w->srv = cst_http_start(&ho, err, sizeof(err)); if (!w->srv) { fprintf(stderr, "webui: cannot listen on %s:%d: %s\n", w->host, opts->port, err); pthread_cond_destroy(&w->cv); pthread_mutex_destroy(&w->mu); free(w); return 1; } char url[256]; snprintf(url, sizeof(url), "http://%s:%d/%s", strcmp(w->host, "0.0.0.0") == 0 ? "127.0.0.1" : w->host, cst_http_port(w->srv), w->need_auth ? "" : ""); fprintf(stderr, "webui: %s\n", url); if (w->need_auth) fprintf(stderr, "webui: token %s (append #token=%s to the URL)\n", w->token, w->token); fflush(stderr); wui_emit_state(w); if (opts->open_browser) open_browser(url); if (opts->prompt && opts->prompt[0]) { char err2[256] = ""; if (wui_start_turn(w, opts->prompt, err2, sizeof(err2)) != 0) fprintf(stderr, "webui: %s\n", err2); } /* Serve until Ctrl+C, /api/quit or a signal. */ signal(SIGINT, webui_on_signal); signal(SIGTERM, webui_on_signal); while (!w->stopping && !g_webui_stop) { struct timespec ts; ts.tv_sec = 0; ts.tv_nsec = 200 * 1000 * 1000; nanosleep(&ts, NULL); } fprintf(stderr, "webui: shutting down\n"); w->stopping = 1; cst_http_stop(w->srv); cst_http_free(w->srv); for (int i = 0; i < WUI_RING; i++) free(w->ring[i].frame); if (w->md) cst_web_md_free(w->md); pthread_cond_destroy(&w->cv); pthread_mutex_destroy(&w->mu); free(w); return 0; }