The front end is rebuilt on the design tokens the desktop app ships (surfaces, color-mix borders, alpha hovers, accent): a 30-icon inline SVG sprite, dark and light themes, layout switches as deep links (?side=0, ?panel=0, ?theme=light) resolved before the first paint. The empty page is one line and leaves with the first turn; the right rail keeps three cards - todo, file changes (+N -M) and context (tokens, prompt cache hit rate). Both rails animate in and out, and a collapsed rail widens its grid track to 0 instead of being removed, so the transcript never slides into a zero-width column. - src/webui.c: POST /api/workspace - re-root the agent at another directory (permission rules, metrics, AGENTS.md follow) and start a session there, as sessions live per directory; state also reports cached/cache_miss tokens - src/agent_loop.c: cst_agent_set_workspace(), rebuilt prompt and re-measured budget; the auto-compact threshold is 75% of the window (was 60%) - src/tui.c: /web forks a WebUI next to the TUI - separate process and a new session, because cst_webui_run() blocks and two writers corrupt one session - src/os_compat.c, src/os_win.c: run_shell runs without a time limit unless CST_SHELL_TIMEOUT asks for one, so builds and long clones finish - src/main.c, include/cst_webui.h: one CST_WEBUI_DEFAULT_PORT for --help, the docs and /web; Termux opens the browser through termux-open-url - README.md, docs/webui.md, docs/env_openai.md: /web, /api/workspace, the new state fields, the 75% threshold and the layout deep links tests/e2e_webui.sh green (permission round-trip, token paths, --web-dev); make check green.
1296 lines
40 KiB
C
1296 lines
40 KiB
C
/* 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 <pthread.h>
|
|
#include <signal.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <time.h>
|
|
|
|
#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/<rel>. 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;
|
|
}
|