Files
codebase-memory-mcp/src/ui/http_server.c
T
2026-09-03 08:13:57 +02:00

2263 lines
86 KiB
C

/*
* http_server.c — Routing + endpoint handlers for the graph UI.
*
* Transport (sockets, parsing, limits) lives in httpd.c; this file owns
* the routes and their handlers:
* GET / → embedded index.html
* GET /assets/... → embedded JS/CSS
* POST /rpc → JSON-RPC dispatch via own cbm_mcp_server_t
* OPTIONS /rpc → CORS preflight (for vite dev on :5173)
* GET/POST /api/... → UI support endpoints (layout, index, browse, …)
* * → 404
*
* Runs in a background pthread. Binds to 127.0.0.1 only (see httpd.c).
* Has its own cbm_mcp_server_t with a separate SQLite connection (WAL reader).
*/
#include "ui/http_server.h"
#include "ui/httpd.h"
#include "ui/embedded_assets.h"
#include "ui/layout3d.h"
#include "mcp/mcp.h"
#include "store/store.h"
#include "watcher/watcher.h"
#include "cli/cli.h"
#include "git/git_context.h"
#if defined(HAVE_LIBGIT2)
#include <git2.h> /* git_repository_open, git_remote_lookup, git_remote_url */
#endif
/* pipeline.h no longer needed — indexing runs as subprocess */
#include "foundation/log.h"
#include "foundation/platform.h"
#include "foundation/secure_random.h"
#include "foundation/sha256.h"
#include "foundation/compat.h"
#include "foundation/compat_fs.h"
#include "foundation/str_util.h"
#include "foundation/compat_thread.h"
#include "foundation/subprocess.h" /* cbm_build_win_cmdline — shared MS-CRT arg quoting */
#include "foundation/win_utf8.h" /* cbm_utf8_to_wide — CreateProcessW wide cmdline (#423/#20) */
#include "foundation/workspace.h"
#include <sqlite3/sqlite3.h>
#include <yyjson/yyjson.h>
#include <ctype.h>
#include <errno.h>
#include <math.h>
#include <stdatomic.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifdef _WIN32
#include <windows.h>
#include <process.h>
#include <psapi.h> /* GetProcessMemoryInfo */
#include <tlhelp32.h> /* CreateToolhelp32Snapshot, Process32First/Next */
#else
#include <sys/stat.h>
#include <unistd.h>
#include <sys/wait.h>
#endif
#ifdef __APPLE__
#include <mach-o/dyld.h>
#endif
#ifdef __FreeBSD__
#include <sys/types.h>
#include <sys/sysctl.h>
#endif
/* ── Constants ────────────────────────────────────────────────── */
#ifndef CBM_VERSION
#define CBM_VERSION "dev"
#endif
/* Max JSON-RPC request body size (1 MB) — transport enforces the same cap. */
#define MAX_BODY_SIZE CBM_HTTP_MAX_BODY
/* ── CORS: only allow localhost origins (blocks remote website attacks) ────── */
/* Per-request CORS header buffers. Updated at the start of each dispatch.
* The server handles requests sequentially on one thread (see httpd.h),
* which makes these statics safe. */
static char g_cors[256]; /* CORS headers only */
static char g_cors_json[512]; /* CORS + Content-Type: application/json */
static bool origin_is_same_server(const char *origin, int port) {
char expected[128];
int length = snprintf(expected, sizeof(expected), "http://127.0.0.1:%d", port);
if (length > 0 && (size_t)length < sizeof(expected) && strcmp(origin, expected) == 0)
return true;
length = snprintf(expected, sizeof(expected), "http://localhost:%d", port);
return length > 0 && (size_t)length < sizeof(expected) && strcmp(origin, expected) == 0;
}
static bool origin_matches_host(const char *origin, const char *host, int port) {
/* Two literal loopback forms only — spelled out so the static URL audit
* sees the complete URL each branch can produce. */
char expected[128];
int length = strncmp(host, "localhost", 9) == 0
? snprintf(expected, sizeof(expected), "http://localhost:%d", port)
: snprintf(expected, sizeof(expected), "http://127.0.0.1:%d", port);
return length > 0 && (size_t)length < sizeof(expected) && strcmp(origin, expected) == 0;
}
/* Foreign origins are rejected before this runs. Reflect only the exact
* same-server origin; a different localhost port is a different principal. */
static void update_cors(const cbm_http_req_t *req, int port) {
if (req->origin[0] != '\0' && origin_is_same_server(req->origin, port)) {
snprintf(g_cors, sizeof(g_cors),
"Access-Control-Allow-Origin: %s\r\n"
"Access-Control-Allow-Methods: POST, GET, DELETE, OPTIONS\r\n"
"Access-Control-Allow-Headers: Content-Type\r\n",
req->origin);
} else {
/* No Access-Control-Allow-Origin → browser blocks cross-origin access */
snprintf(g_cors, sizeof(g_cors),
"Access-Control-Allow-Methods: POST, GET, DELETE, OPTIONS\r\n"
"Access-Control-Allow-Headers: Content-Type\r\n");
}
snprintf(g_cors_json, sizeof(g_cors_json), "%sContent-Type: application/json\r\n", g_cors);
}
static const char *detect_ui_lang(const char *accept_language) {
if (accept_language && (strstr(accept_language, "zh-CN") || strstr(accept_language, "zh"))) {
return "zh";
}
return "en";
}
static void handle_ui_config(cbm_http_conn_t *c, const cbm_http_req_t *req) {
const char *lang = NULL;
char cache_dir[1024];
snprintf(cache_dir, sizeof(cache_dir), "%s", cbm_resolve_cache_dir());
cbm_config_t *cfg = cbm_config_open(cache_dir);
if (cfg) {
const char *pinned = cbm_config_get(cfg, CBM_CONFIG_UI_LANG, "auto");
if (strcmp(pinned, "zh") == 0 || strcmp(pinned, "en") == 0) {
lang = pinned;
}
}
char lang_buf[8];
snprintf(lang_buf, sizeof(lang_buf), "%s", lang ? lang : detect_ui_lang(req->accept_language));
if (cfg) {
cbm_config_close(cfg);
}
/* upstream_issues_url: where the missed-coverage callout (#963) sends
* edge-case reports. Served from the backend on purpose — the UI security
* audit forbids hardcoded external URLs in graph-ui source (external
* targets must come from an auditable backend response, same pattern as
* the /api/repo-info deep-links). */
cbm_http_replyf(c, 200, g_cors_json,
"{\"lang\":\"%s\",\"version\":\"%s\",\"upstream_issues_url\":\"%s\"}",
lang_buf, CBM_VERSION,
"https://github.com/DeusData/codebase-memory-mcp/issues/new");
}
/* ── Server state ─────────────────────────────────────────────── */
#define MAX_INDEX_JOBS 4
enum {
HTTP_RUN_IDLE = 0,
HTTP_RUN_SCHEDULED = 1,
HTTP_RUN_RUNNING = 2,
HTTP_RUN_COMPLETED = 3,
};
typedef struct {
cbm_http_server_t *server;
char root_path[1024];
char project_name[256];
atomic_int status; /* 0=idle, 1=running, 2=done, 3=error */
char error_msg[256];
cbm_thread_t thread;
bool thread_started;
atomic_int completed;
} index_job_t;
struct cbm_http_server {
cbm_httpd_t *listener;
cbm_mcp_server_t *mcp; /* own MCP server instance (read-only) */
struct cbm_watcher *watcher; /* external watcher ref (not owned) */
cbm_http_index_executor_fn index_executor;
void *index_executor_context;
cbm_http_project_mutation_begin_fn mutation_begin;
cbm_http_project_mutation_end_fn mutation_end;
void *mutation_context;
index_job_t index_jobs[MAX_INDEX_JOBS];
atomic_int stop_flag;
atomic_int run_state;
int port;
bool listener_ok;
uint8_t readiness_secret[CBM_SHA256_DIGEST_LEN];
bool readiness_secret_set;
};
/* ── Serve embedded asset ─────────────────────────────────────── */
/* Content-Security-Policy for the served UI. No external host appears in any
* directive, so the browser cannot load or connect to anything off-origin —
* this ENFORCES the airgap (the code makes no external calls; this stops a
* future dependency or injected content from doing so). connect-src 'self'
* confines fetch/XHR/WebSocket to the local server. The 'self'/data:/blob:/
* 'unsafe-inline'-style/'wasm-unsafe-eval' allowances cover the bundled app's
* own needs (React inline styles, three.js textures/workers/WASM). */
#define CBM_UI_CSP \
"Content-Security-Policy: default-src 'self'; connect-src 'self'; " \
"img-src 'self' data: blob:; script-src 'self' 'wasm-unsafe-eval'; " \
"style-src 'self' 'unsafe-inline'; font-src 'self' data:; " \
"worker-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'\r\n"
static bool serve_embedded(cbm_http_conn_t *c, const char *path) {
const cbm_embedded_file_t *f = cbm_embedded_lookup(path);
if (!f)
return false;
/* Build headers with correct Content-Type for this asset */
char hdrs[1024];
snprintf(hdrs, sizeof(hdrs),
"%sContent-Type: %s\r\n"
"Cache-Control: public, max-age=31536000, immutable\r\n"
"X-Content-Type-Options: nosniff\r\n" CBM_UI_CSP,
g_cors, f->content_type);
cbm_http_reply_buf(c, 200, hdrs, f->data, (size_t)f->size);
return true;
}
/* Build DB path for a project: <cache_dir>/<project>.db */
static void db_path_for_project(const char *project, char *buf, size_t bufsz) {
if (!cbm_validate_project_name(project)) {
buf[0] = '\0';
return;
}
const char *dir = cbm_resolve_cache_dir();
if (!dir) {
dir = cbm_tmpdir();
}
snprintf(buf, bufsz, "%s/%s.db", dir, project);
}
/* ── Git remote → GitHub deep-link base (/api/repo-info) ───────── */
/* Return a copy of `url` with any "user[:password]@" userinfo removed from the
* scheme://authority form, so credentials are never echoed back to the client.
* scp-style (git@host:path) is returned unchanged: "git" there is a login name,
* not a secret. malloc'd copy, or NULL when url is NULL. Caller frees. */
char *cbm_ui_git_strip_credentials(const char *url) {
if (!url)
return NULL;
const char *sep = strstr(url, "://");
if (!sep)
return strdup(url); /* scp-style / opaque — no scheme userinfo to strip */
const char *authority = sep + 3;
const char *slash = strchr(authority, '/');
const char *at = strchr(authority, '@');
if (!at || (slash && at > slash))
return strdup(url); /* '@' is in the path, not the authority → no creds */
size_t prefix = (size_t)(authority - url); /* "scheme://" */
const char *rest = at + 1;
size_t out_len = prefix + strlen(rest) + 1;
char *out = malloc(out_len);
if (!out)
return NULL;
memcpy(out, url, prefix);
memcpy(out + prefix, rest, strlen(rest) + 1);
return out;
}
/* Normalize a git remote URL (scp-style, ssh://, https://) to a canonical
* "https://host/org/repo" web base with any trailing ".git" and any embedded
* credentials removed. Returns a malloc'd string or NULL if the shape isn't
* recognized. Caller frees. */
char *cbm_ui_git_web_base(const char *url) {
if (!url || !url[0])
return NULL;
char host_path[1024] = {0}; /* "host/org/repo" */
if (strncmp(url, "git@", 4) == 0) {
const char *at = url + 4;
const char *colon = strchr(at, ':');
if (!colon)
return NULL;
snprintf(host_path, sizeof(host_path), "%.*s/%s", (int)(colon - at), at, colon + 1);
} else {
const char *p = strstr(url, "://");
if (!p)
return NULL;
p += 3;
const char *at = strchr(p, '@'); /* strip any embedded credentials */
if (at)
p = at + 1;
snprintf(host_path, sizeof(host_path), "%s", p);
}
size_t l = strlen(host_path);
if (l > 4 && strcmp(host_path + l - 4, ".git") == 0)
host_path[l - 4] = '\0';
l = strlen(host_path);
if (l > 0 && host_path[l - 1] == '/')
host_path[l - 1] = '\0';
size_t out_sz = strlen(host_path) + 9; /* "https://" (8) + NUL */
char *out = malloc(out_sz);
if (!out)
return NULL;
/* Legitimate GitHub blob-URL construction, not a network call — the scheme
* is https-forced here so the frontend deep-link can never be downgraded.
* Allow-listed in scripts/security-allowlist.txt (URL:https://%s). */
snprintf(out, out_sz, "https://%s", host_path);
return out;
}
/* Read the "origin" remote URL for the repo at root_path. malloc'd or NULL.
* libgit2 is initialized once at process start by cbm_alloc_init() (which also
* binds its allocator to mimalloc) — do NOT git_libgit2_init()/shutdown() here:
* a per-request shutdown could drop the global refcount and tear down that
* allocator binding mid-process. */
static char *git_origin_remote_url(const char *root_path) {
#if defined(HAVE_LIBGIT2)
git_repository *repo = NULL;
char *out = NULL;
if (git_repository_open(&repo, root_path) == 0) {
git_remote *rem = NULL;
if (git_remote_lookup(&rem, repo, "origin") == 0) {
const char *u = git_remote_url(rem);
if (u)
out = strdup(u);
git_remote_free(rem);
}
git_repository_free(repo);
}
return out;
#else
(void)root_path;
return NULL;
#endif
}
/* GET /api/repo-info?project=NAME → { root_path, branch, remote_url, web_base,
* blob_base }. blob_base is "<web_base>/blob/<branch>" ready for the frontend to
* append "/<file_path>#L<start>-L<end>". remote_url is credential-stripped;
* fields are empty strings when unknown (e.g. no git remote). */
static void handle_repo_info(cbm_http_conn_t *c, const cbm_http_req_t *req) {
char project[256] = {0};
if (!cbm_http_query_param(req->query, "project", project, (int)sizeof(project)) ||
project[0] == '\0') {
cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"missing project parameter\"}");
return;
}
char db_path[1024];
db_path_for_project(project, db_path, sizeof(db_path));
if (db_path[0] == '\0' || !cbm_file_exists(db_path)) {
cbm_http_replyf(c, 404, g_cors_json, "{\"error\":\"project not found\"}");
return;
}
cbm_store_t *store = cbm_store_open_path_query(db_path);
if (!store) {
cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"cannot open store\"}");
return;
}
char root_path[1024] = {0};
cbm_project_t proj;
memset(&proj, 0, sizeof(proj));
if (cbm_store_get_project(store, project, &proj) == CBM_STORE_OK && proj.root_path) {
snprintf(root_path, sizeof(root_path), "%s", proj.root_path);
}
cbm_project_free_fields(&proj);
cbm_store_close(store);
char branch[256] = {0};
if (root_path[0]) {
cbm_git_context_t gctx;
memset(&gctx, 0, sizeof(gctx));
if (cbm_git_context_resolve(root_path, &gctx) == 0 && gctx.branch) {
snprintf(branch, sizeof(branch), "%s", gctx.branch);
}
cbm_git_context_free(&gctx);
}
char *remote = root_path[0] ? git_origin_remote_url(root_path) : NULL;
char *remote_safe = cbm_ui_git_strip_credentials(remote); /* never echo secrets */
char *web_base = cbm_ui_git_web_base(remote);
char blob_base[1152] = {0};
if (web_base && web_base[0] && branch[0]) {
snprintf(blob_base, sizeof(blob_base), "%s/blob/%s", web_base, branch);
}
/* JSON-escape the free-form fields. */
char esc_root[2048], esc_branch[512], esc_remote[2048], esc_web[2048], esc_blob[2304];
cbm_json_escape(esc_root, (int)sizeof(esc_root), root_path);
cbm_json_escape(esc_branch, (int)sizeof(esc_branch), branch);
cbm_json_escape(esc_remote, (int)sizeof(esc_remote), remote_safe ? remote_safe : "");
cbm_json_escape(esc_web, (int)sizeof(esc_web), web_base ? web_base : "");
cbm_json_escape(esc_blob, (int)sizeof(esc_blob), blob_base);
cbm_http_replyf(c, 200, g_cors_json,
"{\"root_path\":\"%s\",\"branch\":\"%s\",\"remote_url\":\"%s\","
"\"web_base\":\"%s\",\"blob_base\":\"%s\"}",
esc_root, esc_branch, esc_remote, esc_web, esc_blob);
free(remote);
free(remote_safe);
free(web_base);
}
/* ── Log ring buffer ──────────────────────────────────────────── */
#define LOG_RING_SIZE 500
#define LOG_LINE_MAX 512
static char g_log_ring[LOG_RING_SIZE][LOG_LINE_MAX];
static int g_log_head = 0;
static int g_log_count = 0;
static cbm_mutex_t g_log_mutex;
enum { CBM_LOG_MUTEX_UNINIT = 0, CBM_LOG_MUTEX_INITING = 1, CBM_LOG_MUTEX_INITED = 2 };
static atomic_int g_log_mutex_init = CBM_LOG_MUTEX_UNINIT;
/* Safe for concurrent callers: only publishes INITED after cbm_mutex_init()
* has completed. Callers that lose the CAS race spin until init finishes. */
void cbm_ui_log_init(void) {
int state = atomic_load(&g_log_mutex_init);
if (state == CBM_LOG_MUTEX_INITED)
return;
state = CBM_LOG_MUTEX_UNINIT;
if (atomic_compare_exchange_strong(&g_log_mutex_init, &state, CBM_LOG_MUTEX_INITING)) {
cbm_mutex_init(&g_log_mutex);
atomic_store(&g_log_mutex_init, CBM_LOG_MUTEX_INITED);
return;
}
/* Another thread is initializing — spin until done */
while (atomic_load(&g_log_mutex_init) != CBM_LOG_MUTEX_INITED) {
cbm_usleep(1000); /* 1ms */
}
}
/* Called from a log hook — appends a line to the ring buffer (thread-safe) */
void cbm_ui_log_append(const char *line) {
if (!line)
return;
/* Ensure mutex is initialized (safe for early single-threaded logging
* and concurrent calls via atomic_exchange once-init pattern). */
cbm_ui_log_init();
cbm_mutex_lock(&g_log_mutex);
snprintf(g_log_ring[g_log_head], LOG_LINE_MAX, "%s", line);
g_log_head = (g_log_head + 1) % LOG_RING_SIZE;
if (g_log_count < LOG_RING_SIZE)
g_log_count++;
cbm_mutex_unlock(&g_log_mutex);
}
/* Append a printf-formatted fragment at *pos within a bufsz buffer, never
* advancing *pos past bufsz. snprintf returns the length it WOULD have written,
* so `pos += snprintf(...)` runs pos past the end on truncation and the next
* call computes a wrapped (huge) remaining size and writes out of bounds. This
* clamps: on truncation *pos is pinned at bufsz and further appends are no-ops. */
static void http_appendf(char *buf, size_t bufsz, int *pos, const char *fmt, ...)
__attribute__((format(printf, 4, 5)));
static void http_appendf(char *buf, size_t bufsz, int *pos, const char *fmt, ...) {
if (*pos < 0) {
return;
}
if ((size_t)*pos >= bufsz) {
*pos = (int)bufsz;
return;
}
va_list ap;
va_start(ap, fmt);
int n = vsnprintf(buf + *pos, bufsz - (size_t)*pos, fmt, ap);
va_end(ap);
if (n < 0) {
return;
}
if ((size_t)n >= bufsz - (size_t)*pos) {
*pos = (int)bufsz;
} else {
*pos += n;
}
}
/* GET /api/logs?lines=N — returns last N log lines */
static void handle_logs(cbm_http_conn_t *c, const cbm_http_req_t *req) {
char lines_str[16] = {0};
int max_lines = 100;
if (cbm_http_query_param(req->query, "lines", lines_str, (int)sizeof(lines_str))) {
int v = atoi(lines_str);
if (v > 0 && v <= LOG_RING_SIZE)
max_lines = v;
}
cbm_mutex_lock(&g_log_mutex);
int count = g_log_count < max_lines ? g_log_count : max_lines;
int start = (g_log_head - count + LOG_RING_SIZE) % LOG_RING_SIZE;
int total = g_log_count;
/* Copy lines under lock.
*
* JSON escaping expands '"', '\\' and '\n' to two bytes each, so an
* line made mostly of those serialises to roughly twice its stored length.
* The previous budget of LOG_LINE_MAX + 10 per line under-counted that by
* half. Ring contents come from indexer stderr, which is not escaped on
* ingest and can legitimately contain both doubling characters — a POSIX
* filename may.
*
* Budget the escaped worst case, and clamp the framing writes below anyway
* so the size calculation is not the only thing keeping pos in range. */
size_t buf_size = (size_t)count * (2 * LOG_LINE_MAX + 8) + 64;
char *buf = malloc(buf_size);
if (!buf) {
cbm_mutex_unlock(&g_log_mutex);
cbm_http_replyf(c, 500, g_cors, "oom");
return;
}
int pos = 0;
http_appendf(buf, buf_size, &pos, "{\"lines\":[");
for (int i = 0; i < count; i++) {
int idx = (start + i) % LOG_RING_SIZE;
if (i > 0)
http_appendf(buf, buf_size, &pos, ",");
/* Escape quotes in log lines */
http_appendf(buf, buf_size, &pos, "\"");
for (int j = 0; g_log_ring[idx][j] && (size_t)pos < buf_size - 10; j++) {
char ch = g_log_ring[idx][j];
if (ch == '"') {
buf[pos++] = '\\';
buf[pos++] = '"';
} else if (ch == '\\') {
buf[pos++] = '\\';
buf[pos++] = '\\';
} else if (ch == '\n') {
buf[pos++] = '\\';
buf[pos++] = 'n';
} else {
buf[pos++] = ch;
}
}
http_appendf(buf, buf_size, &pos, "\"");
}
cbm_mutex_unlock(&g_log_mutex);
http_appendf(buf, buf_size, &pos, "],\"total\":%d}", total);
/* http_appendf pins pos to buf_size on truncation and then writes nothing,
* so a saturated buffer would reach the "%s" reply with no terminator in
* range. Terminate explicitly. */
if ((size_t)pos >= buf_size) {
pos = (int)buf_size - 1;
}
buf[pos] = '\0';
cbm_http_replyf(c, 200, g_cors_json, "%s", buf);
free(buf);
}
/* ── Process monitoring ───────────────────────────────────────── */
#ifndef _WIN32
#include <sys/resource.h>
#endif
#include <signal.h>
/* GET /api/processes — list codebase-memory-mcp processes via ps */
static void handle_processes(cbm_http_conn_t *c) {
char buf[8192];
int pos = 0;
#ifdef _WIN32
/* Windows: GetProcessMemoryInfo + GetProcessTimes for the current process */
PROCESS_MEMORY_COUNTERS pmc;
FILETIME ft_create, ft_exit, ft_kernel, ft_user;
double user_s = 0, sys_s = 0;
size_t rss_bytes = 0;
if (GetProcessMemoryInfo(GetCurrentProcess(), &pmc, sizeof(pmc)))
rss_bytes = pmc.WorkingSetSize;
if (GetProcessTimes(GetCurrentProcess(), &ft_create, &ft_exit, &ft_kernel, &ft_user)) {
ULARGE_INTEGER u, k;
u.LowPart = ft_user.dwLowDateTime;
u.HighPart = ft_user.dwHighDateTime;
k.LowPart = ft_kernel.dwLowDateTime;
k.HighPart = ft_kernel.dwHighDateTime;
user_s = (double)u.QuadPart / 1e7;
sys_s = (double)k.QuadPart / 1e7;
}
http_appendf(buf, sizeof(buf), &pos,
"{\"self_pid\":%d,\"self_rss_mb\":%.1f,"
"\"self_user_cpu_s\":%.1f,\"self_sys_cpu_s\":%.1f,\"processes\":[",
(int)_getpid(), (double)rss_bytes / (1024.0 * 1024.0), user_s, sys_s);
/* Enumerate all codebase-memory-mcp.exe processes via toolhelp snapshot */
int proc_count = 0;
HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (hSnap != INVALID_HANDLE_VALUE) {
PROCESSENTRY32 pe;
pe.dwSize = sizeof(pe);
for (BOOL ok = Process32First(hSnap, &pe); ok; ok = Process32Next(hSnap, &pe)) {
if (_stricmp(pe.szExeFile, "codebase-memory-mcp.exe") == 0) {
HANDLE hProc = OpenProcess(
PROCESS_QUERY_INFORMATION | PROCESS_VM_READ,
FALSE, pe.th32ProcessID);
if (hProc) {
PROCESS_MEMORY_COUNTERS ppmc;
FILETIME ftc, fte, ftk, ftu;
double cpu_user = 0, cpu_sys = 0;
size_t proc_rss = 0;
DWORD elapsed_sec = 0;
if (GetProcessMemoryInfo(hProc, &ppmc, sizeof(ppmc)))
proc_rss = ppmc.WorkingSetSize;
if (GetProcessTimes(hProc, &ftc, &fte, &ftk, &ftu)) {
ULARGE_INTEGER pu, pk;
pu.LowPart = ftu.dwLowDateTime;
pu.HighPart = ftu.dwHighDateTime;
pk.LowPart = ftk.dwLowDateTime;
pk.HighPart = ftk.dwHighDateTime;
cpu_user = (double)pu.QuadPart / 1e7;
cpu_sys = (double)pk.QuadPart / 1e7;
/* 进程创建以来的运行时间 */
FILETIME now_ft;
GetSystemTimeAsFileTime(&now_ft);
ULARGE_INTEGER now_uli, start_uli;
now_uli.LowPart = now_ft.dwLowDateTime;
now_uli.HighPart = now_ft.dwHighDateTime;
start_uli.LowPart = ftc.dwLowDateTime;
start_uli.HighPart = ftc.dwHighDateTime;
/* 时钟偏移保护:防止创建时间微偏未来导致 unsigned 下溢 */
if (now_uli.QuadPart > start_uli.QuadPart) {
ULONGLONG elapsed_100ns = now_uli.QuadPart - start_uli.QuadPart;
elapsed_sec = (DWORD)(elapsed_100ns / 10000000ULL);
}
}
if (proc_count > 0)
buf[pos++] = ',';
http_appendf(buf, sizeof(buf), &pos,
"{\"pid\":%lu,\"cpu\":%.1f,\"rss_mb\":%.1f,"
"\"elapsed\":\"%lu-%02lu:%02lu:%02lu\","
"\"command\":\"codebase-memory-mcp\","
"\"is_self\":%s}",
pe.th32ProcessID, cpu_user + cpu_sys,
(double)proc_rss / (1024.0 * 1024.0),
elapsed_sec / 86400,
(elapsed_sec % 86400) / 3600,
(elapsed_sec % 3600) / 60,
elapsed_sec % 60,
pe.th32ProcessID == (DWORD)_getpid()
? "true" : "false");
if (pos >= (int)sizeof(buf)) {
pos = (int)sizeof(buf) - 1;
}
proc_count++;
CloseHandle(hProc);
}
}
}
CloseHandle(hSnap);
}
http_appendf(buf, sizeof(buf), &pos, "]}");
#else
struct rusage ru;
getrusage(RUSAGE_SELF, &ru);
long rss_kb = ru.ru_maxrss;
#ifdef __APPLE__
rss_kb /= 1024;
#endif
http_appendf(buf, sizeof(buf), &pos,
"{\"self_pid\":%d,\"self_rss_mb\":%.1f,"
"\"self_user_cpu_s\":%.1f,\"self_sys_cpu_s\":%.1f,\"processes\":[",
(int)getpid(), (double)rss_kb / 1024.0,
(double)ru.ru_utime.tv_sec + (double)ru.ru_utime.tv_usec / 1e6,
(double)ru.ru_stime.tv_sec + (double)ru.ru_stime.tv_usec / 1e6);
FILE *fp = popen("LC_ALL=C ps -eo pid,pcpu,rss,etime,comm 2>/dev/null"
" | grep '[c]odebase-memory-mcp'",
"r");
int proc_count = 0;
if (fp) {
char line[1024];
while (fgets(line, sizeof(line), fp)) {
int pid = 0;
float cpu = 0;
long rss = 0;
char elapsed[64] = {0};
char comm[256] = {0};
if (sscanf(line, "%d %f %ld %63s %255s", &pid, &cpu, &rss, elapsed, comm) >= 4) {
if (proc_count > 0)
buf[pos++] = ',';
http_appendf(buf, sizeof(buf), &pos,
"{\"pid\":%d,\"cpu\":%.1f,\"rss_mb\":%.1f,"
"\"elapsed\":\"%s\",\"command\":\"%s\",\"is_self\":%s}",
pid, (double)cpu, (double)rss / 1024.0, elapsed, comm,
pid == (int)getpid() ? "true" : "false");
if (pos >= (int)sizeof(buf)) {
pos = (int)sizeof(buf) - 1;
}
proc_count++;
}
}
pclose(fp);
}
http_appendf(buf, sizeof(buf), &pos, "]}");
#endif
cbm_http_replyf(c, 200, g_cors_json, "%s", buf);
}
/* ── Directory browser ────────────────────────────────────────── */
#include <dirent.h>
static void append_roots_json(char *buf, size_t bufsz, int *pos) {
http_appendf(buf, bufsz, pos, ",\"roots\":[");
#ifdef _WIN32
DWORD drives = GetLogicalDrives();
int count = 0;
for (int i = 0; i < 26; i++) {
if (!(drives & (1u << i))) {
continue;
}
/* Advertise exactly what /api/browse will accept: a mounted letter
* without a readable medium (empty optical drive, unformatted or
* offline volume) fails the same cbm_is_dir gate browse applies, and
* a root the picker cannot open must not be offered. */
char root[4] = {(char)('A' + i), ':', '/', '\0'};
if (!cbm_is_dir(root)) {
continue;
}
if (count++ > 0) {
/* Once a wide listing saturates buf, http_appendf has already
* pinned *pos to bufsz, so this separator goes through it too. */
http_appendf(buf, bufsz, pos, ",");
}
http_appendf(buf, bufsz, pos, "\"%c:/\"", 'A' + i);
}
#else
http_appendf(buf, bufsz, pos, "\"/\"");
#endif
http_appendf(buf, bufsz, pos, "]");
}
/* GET /api/browse?path=/some/dir — list subdirectories for file picker */
static void handle_browse(cbm_http_conn_t *c, const cbm_http_req_t *req) {
char path[1024] = {0};
const char *home = cbm_get_home_dir();
if (!cbm_http_query_param(req->query, "path", path, (int)sizeof(path)) || path[0] == '\0') {
/* Default to home directory */
if (home)
snprintf(path, sizeof(path), "%s", home);
else
snprintf(path, sizeof(path), "/");
}
/* The browser UI may send Windows backslash separators (e.g.
* "D:\projects\demo"). Normalize to forward slashes before the cbm_is_dir
* gate, exactly as the MCP repo_path handler and cbm_project_name_from_path
* already do — otherwise a real D:/ directory is rejected (#548). */
cbm_normalize_path_sep(path);
if (!cbm_is_dir(path)) {
cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"not a directory\"}");
return;
}
DIR *dir = opendir(path);
if (!dir) {
cbm_http_replyf(c, 403, g_cors_json, "{\"error\":\"cannot open directory\"}");
return;
}
/* Build JSON response */
char buf[32768];
int pos = 0;
http_appendf(buf, sizeof(buf), &pos, "{\"path\":\"%s\",\"dirs\":[", path);
struct dirent *ent;
int count = 0;
while ((ent = readdir(dir)) != NULL) {
/* Skip hidden dirs and . / .. */
if (ent->d_name[0] == '.')
continue;
/* Check if it's actually a directory */
char full[2048];
snprintf(full, sizeof(full), "%s/%s", path, ent->d_name);
if (!cbm_is_dir(full))
continue;
if (count > 0)
buf[pos++] = ',';
/* Escape directory name to prevent XSS (e.g., names with quotes/angle brackets) */
{
char esc[512];
cbm_json_escape(esc, (int)sizeof(esc), ent->d_name);
http_appendf(buf, sizeof(buf), &pos, "\"%s\"", esc);
}
if (pos >= (int)sizeof(buf)) {
pos = (int)sizeof(buf) - 1;
}
count++;
if (count >= 200)
break; /* safety limit */
}
closedir(dir);
/* Parent path — escape to prevent injection */
char parent[1024];
snprintf(parent, sizeof(parent), "%s", path);
char *last_slash = strrchr(parent, '/');
/* A Windows drive root "X:/" is its own parent (like POSIX "/"): truncating
* at the slash would yield the bare drive spec "X:", which the next browse
* resolves to the wrong directory and strands the user at the root (#548). */
size_t parent_len = strlen(parent);
bool is_drive_root = parent_len == 3 && parent[1] == ':' && parent[2] == '/';
if (is_drive_root) {
/* leave "X:/" unchanged */
} else if (last_slash && last_slash != parent) {
*last_slash = '\0';
} else {
snprintf(parent, sizeof(parent), "/");
}
{
char esc_parent[2048];
cbm_json_escape(esc_parent, (int)sizeof(esc_parent), parent);
http_appendf(buf, sizeof(buf), &pos, "],\"parent\":\"%s\"", esc_parent);
append_roots_json(buf, sizeof(buf), &pos);
http_appendf(buf, sizeof(buf), &pos, "}");
}
cbm_http_replyf(c, 200, g_cors_json, "%s", buf);
}
/* ── ADR endpoints ────────────────────────────────────────────── */
/* GET /api/adr?project=X — get ADR content for a project */
static void handle_adr_get(cbm_http_conn_t *c, const cbm_http_req_t *req) {
char name[256] = {0};
if (!cbm_http_query_param(req->query, "project", name, (int)sizeof(name)) || name[0] == '\0') {
cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"missing project\"}");
return;
}
char db_path[1024];
db_path_for_project(name, db_path, sizeof(db_path));
cbm_store_t *store = cbm_store_open_path_query(db_path);
if (!store) {
cbm_http_replyf(c, 200, g_cors_json, "{\"has_adr\":false}");
return;
}
cbm_adr_t adr;
memset(&adr, 0, sizeof(adr));
if (cbm_store_adr_get(store, name, &adr) == CBM_STORE_OK && adr.content) {
/* Escape content for JSON — simple: replace quotes and newlines */
size_t clen = strlen(adr.content);
size_t buf_size = clen * 2 + 256;
char *buf = malloc(buf_size);
if (buf) {
int pos = snprintf(buf, buf_size, "{\"has_adr\":true,\"content\":\"");
for (size_t i = 0; i < clen && (size_t)pos < buf_size - 10; i++) {
char ch = adr.content[i];
if (ch == '"') {
buf[pos++] = '\\';
buf[pos++] = '"';
} else if (ch == '\\') {
buf[pos++] = '\\';
buf[pos++] = '\\';
} else if (ch == '\n') {
buf[pos++] = '\\';
buf[pos++] = 'n';
} else if (ch == '\r') { /* skip */
} else if (ch == '\t') {
buf[pos++] = '\\';
buf[pos++] = 't';
} else {
buf[pos++] = ch;
}
}
http_appendf(buf, buf_size, &pos, "\",\"updated_at\":\"%s\"}",
adr.updated_at ? adr.updated_at : "");
cbm_http_replyf(c, 200, g_cors_json, "%s", buf);
free(buf);
} else {
cbm_http_replyf(c, 500, g_cors, "oom");
}
cbm_store_adr_free(&adr);
} else {
cbm_http_replyf(c, 200, g_cors_json, "{\"has_adr\":false}");
}
cbm_store_close(store);
}
/* POST /api/adr — save ADR content. Body: {"project":"...","content":"..."} */
static void handle_adr_save(cbm_http_server_t *srv, cbm_http_conn_t *c, const cbm_http_req_t *req) {
if (req->body_len == 0 || req->body_len > 16384) {
cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"invalid body\"}");
return;
}
yyjson_doc *doc = yyjson_read(req->body, req->body_len, 0);
if (!doc) {
cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"invalid json\"}");
return;
}
yyjson_val *root = yyjson_doc_get_root(doc);
yyjson_val *v_proj = yyjson_obj_get(root, "project");
yyjson_val *v_content = yyjson_obj_get(root, "content");
if (!v_proj || !yyjson_is_str(v_proj) || !v_content || !yyjson_is_str(v_content)) {
yyjson_doc_free(doc);
cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"missing project or content\"}");
return;
}
const char *proj = yyjson_get_str(v_proj);
const char *content = yyjson_get_str(v_content);
if (srv->mutation_begin && !srv->mutation_begin(srv->mutation_context, proj)) {
yyjson_doc_free(doc);
cbm_http_replyf(c, 423, g_cors_json,
"{\"error\":\"project is busy; retry after indexing\"}");
return;
}
bool mutation_held = srv->mutation_begin != NULL;
char db_path[1024];
db_path_for_project(proj, db_path, sizeof(db_path));
cbm_store_t *store = cbm_store_open_path(db_path);
if (!store) {
if (mutation_held) {
srv->mutation_end(srv->mutation_context, proj);
}
yyjson_doc_free(doc);
cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"cannot open store\"}");
return;
}
int rc = cbm_store_adr_store(store, proj, content);
cbm_store_close(store);
if (mutation_held) {
srv->mutation_end(srv->mutation_context, proj);
}
/* proj/content are owned by doc; keep it alive through both SQLite and
* the mutation-end callback. */
yyjson_doc_free(doc);
if (rc == CBM_STORE_OK) {
cbm_http_replyf(c, 200, g_cors_json, "{\"saved\":true}");
} else {
cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"save failed\"}");
}
}
/* ── Background indexing ──────────────────────────────────────── */
static char g_binary_path[1024] = {0};
static bool copy_path(char *out, size_t outsz, const char *path) {
if (!out || outsz == 0 || !path || !path[0]) {
return false;
}
int n = snprintf(out, outsz, "%s", path);
return n > 0 && (size_t)n < outsz;
}
#ifndef _WIN32
static bool is_executable_file(const char *path) {
struct stat st;
return path && stat(path, &st) == 0 && S_ISREG(st.st_mode) && access(path, X_OK) == 0;
}
static bool resolve_from_path(const char *name, char *out, size_t outsz) {
const char *path = getenv("PATH");
if (!name || !name[0] || strchr(name, '/') || !path || !path[0]) {
return false;
}
const char *cur = path;
while (*cur) {
const char *colon = strchr(cur, ':');
size_t dir_len = colon ? (size_t)(colon - cur) : strlen(cur);
if (dir_len > 0 && dir_len < 900) {
char candidate[1024];
int n = snprintf(candidate, sizeof(candidate), "%.*s/%s", (int)dir_len, cur, name);
if (n > 0 && (size_t)n < sizeof(candidate) && is_executable_file(candidate)) {
return copy_path(out, outsz, candidate);
}
}
if (!colon) {
break;
}
cur = colon + 1;
}
return false;
}
static bool resolve_self_executable(char *out, size_t outsz) {
#if defined(__APPLE__)
char buf[1024];
uint32_t sz = sizeof(buf);
if (_NSGetExecutablePath(buf, &sz) == 0 && buf[0]) {
return copy_path(out, outsz, buf);
}
return false;
#elif defined(__FreeBSD__)
/* No /proc by default on FreeBSD; ask the kernel for our own path. */
char buf[1024];
int mib[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PATHNAME, -1};
size_t cb = sizeof(buf);
if (sysctl(mib, 4, buf, &cb, NULL, 0) == 0 && cb > 0) {
return copy_path(out, outsz, buf);
}
return false;
#else
char buf[1024];
ssize_t len = readlink("/proc/self/exe", buf, sizeof(buf) - 1);
if (len > 0) {
buf[len] = '\0';
return copy_path(out, outsz, buf);
}
return false;
#endif
}
#else
static bool resolve_self_executable(char *out, size_t outsz) {
char *utf8 = cbm_module_path_utf8();
if (!utf8) {
return false;
}
bool ok = copy_path(out, outsz, utf8);
free(utf8);
return ok;
}
#endif
bool cbm_http_server_resolve_binary_path(const char *argv0, char *out, size_t outsz) {
if (!out || outsz == 0) {
return false;
}
out[0] = '\0';
#ifndef _WIN32
if (argv0 && strchr(argv0, '/') && is_executable_file(argv0)) {
return copy_path(out, outsz, argv0);
}
if (resolve_from_path(argv0, out, outsz)) {
return true;
}
#else
if (argv0 && argv0[0]) {
/* GetFileAttributesA reads argv0 through the ANSI code page and fails on
* non-ASCII install paths, forcing a fallback that can mangle the path;
* check wide so a valid non-ASCII argv0 is used verbatim. */
wchar_t *wargv0 = cbm_utf8_to_wide(argv0);
DWORD attrs = wargv0 ? GetFileAttributesW(wargv0) : INVALID_FILE_ATTRIBUTES;
free(wargv0);
if (attrs != INVALID_FILE_ATTRIBUTES && !(attrs & FILE_ATTRIBUTE_DIRECTORY)) {
return copy_path(out, outsz, argv0);
}
}
#endif
#ifndef _WIN32
/* #1204: never hand back a self-resolved path we have not confirmed is
* executable. After an installer's atomic rename-over, the resolved image
* path no longer exists (Linux readlink reads "<path> (deleted)"), and an
* unvalidated return turns into a doomed worker spawn (ENOENT). */
if (resolve_self_executable(out, outsz)) {
if (is_executable_file(out)) {
return true;
}
#if defined(__linux__)
/* Deleted image: the /proc/self/exe magic link still executes the
* in-memory OLD build — the only spawn that satisfies the worker's
* build-fingerprint gate (a rename-over leaves the on-disk path
* holding a DIFFERENT build). Hand back the link, not the stale
* path. Deliberately NOT the saved launch path: preferring it swaps
* an ENOENT for a fingerprint refusal. */
return copy_path(out, outsz, "/proc/self/exe");
#else
/* macOS has no magic link. Fail closed: the supervisor logs
* index.supervisor.no_self_path and indexing resumes after a daemon
* restart, instead of spawning a missing or mismatched binary. */
out[0] = '\0';
return false;
#endif
}
#else
if (resolve_self_executable(out, outsz)) {
return true;
}
#endif
return copy_path(out, outsz, argv0);
}
void cbm_http_server_set_binary_path(const char *path) {
if (path) {
/* Resolve into a local buffer first: `path` may alias g_binary_path
* on a repeated call. Resolving in place first hits resolve's leading
* out[0]='\0', which zeroes the shared buffer and silently discards
* the very path we were asked to re-resolve — and the in-place write
* is one reset-reorder away from an overlapping snprintf. */
char resolved[sizeof(g_binary_path)];
if (cbm_http_server_resolve_binary_path(path, resolved, sizeof(resolved))) {
snprintf(g_binary_path, sizeof(g_binary_path), "%s", resolved);
} else {
g_binary_path[0] = '\0';
}
}
}
/* Execute through the daemon's shared job registry. The thread is retained in
* its slot and joined before reuse/free; no detached operation can outlive the
* daemon application that owns its callback context. */
static void *index_thread_fn(void *arg) {
index_job_t *job = arg;
cbm_log_info("ui.index.start", "path", job->root_path);
cbm_http_server_t *server = job->server;
int result = server && server->index_executor
? server->index_executor(server->index_executor_context, job->root_path,
job->project_name)
: -1;
if (result != 0) {
snprintf(job->error_msg, sizeof(job->error_msg), "daemon index operation failed");
atomic_store(&job->status, 3);
} else {
atomic_store(&job->status, 2);
}
cbm_log_info("ui.index.done", "path", job->root_path, "rc", result == 0 ? "ok" : "err");
atomic_store_explicit(&job->completed, 1, memory_order_release);
return NULL;
}
/* POST /api/index — body: {"root_path": "/abs/path", "project_name": "..."} */
static void handle_index_start(cbm_http_server_t *server, cbm_http_conn_t *c,
const cbm_http_req_t *req) {
if (!server || !server->index_executor) {
cbm_http_replyf(c, 503, g_cors_json,
"{\"error\":\"daemon index coordinator unavailable\"}");
return;
}
if (req->body_len == 0 || req->body_len > 4096) {
cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"invalid body\"}");
return;
}
yyjson_doc *doc = yyjson_read(req->body, req->body_len, 0);
if (!doc) {
cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"invalid json\"}");
return;
}
yyjson_val *root = yyjson_doc_get_root(doc);
yyjson_val *v_path = yyjson_obj_get(root, "root_path");
if (!v_path || !yyjson_is_str(v_path)) {
yyjson_doc_free(doc);
cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"missing root_path\"}");
return;
}
const char *rpath = yyjson_get_str(v_path);
yyjson_val *v_project_name = yyjson_obj_get(root, "project_name");
const char *project_name = yyjson_is_str(v_project_name) ? yyjson_get_str(v_project_name) : "";
/* Check path exists */
if (!cbm_is_dir(rpath)) {
yyjson_doc_free(doc);
cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"directory not found\"}");
return;
}
/* Same workspace boundary the MCP indexing tool applies, through the same
* function. This route used to check only that the path was a directory, so
* it accepted roots the MCP path refused — an operator's boundary held on one
* entry point and not the other. Canonicalize first: the policy is defined
* over resolved paths, and a symlink would otherwise launder the verdict. */
char canonical_root[4096];
char boundary_err[1024];
if (!cbm_canonical_path(rpath, canonical_root, sizeof(canonical_root))) {
yyjson_doc_free(doc);
cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"cannot resolve root_path\"}");
return;
}
if (!cbm_workspace_root_allowed(canonical_root, cbm_workspace_home_dir(),
cbm_workspace_cache_dir(), getenv("CBM_ALLOWED_ROOT"),
boundary_err, sizeof(boundary_err))) {
yyjson_doc_free(doc);
char escaped[1024];
cbm_json_escape(escaped, (int)sizeof(escaped), boundary_err);
cbm_http_replyf(c, 403, g_cors_json, "{\"error\":\"%s\"}", escaped);
return;
}
/* Find free job slot */
int slot = -1;
for (int i = 0; i < MAX_INDEX_JOBS; i++) {
int st = atomic_load(&server->index_jobs[i].status);
bool reusable =
!server->index_jobs[i].thread_started ||
atomic_load_explicit(&server->index_jobs[i].completed, memory_order_acquire);
if ((st == 0 || st == 2 || st == 3) && reusable) {
slot = i;
break;
}
}
if (slot < 0) {
yyjson_doc_free(doc);
cbm_http_replyf(c, 429, g_cors_json, "{\"error\":\"all index slots busy\"}");
return;
}
index_job_t *job = &server->index_jobs[slot];
if (job->thread_started) {
if (cbm_thread_join(&job->thread) != 0) {
cbm_http_replyf(c, 503, g_cors_json, "{\"error\":\"index worker unavailable\"}");
return;
}
job->thread_started = false;
}
job->server = server;
snprintf(job->root_path, sizeof(job->root_path), "%s", rpath);
snprintf(job->project_name, sizeof(job->project_name), "%s", project_name);
job->error_msg[0] = '\0';
atomic_store(&job->status, 1);
atomic_store_explicit(&job->completed, 0, memory_order_release);
yyjson_doc_free(doc);
/* Spawn background thread */
if (cbm_thread_create(&job->thread, 0, index_thread_fn, job) != 0) {
atomic_store(&job->status, 3);
atomic_store_explicit(&job->completed, 1, memory_order_release);
snprintf(job->error_msg, sizeof(job->error_msg), "thread creation failed");
cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"thread creation failed\"}");
return;
}
job->thread_started = true;
cbm_http_replyf(c, 202, g_cors_json, "{\"status\":\"indexing\",\"slot\":%d,\"path\":\"%s\"}",
slot, job->root_path);
}
/* GET /api/index-status — returns status of all index jobs */
static void handle_index_status(cbm_http_server_t *server, cbm_http_conn_t *c) {
char buf[2048] = "[";
int pos = 1;
for (int i = 0; i < MAX_INDEX_JOBS; i++) {
int st = atomic_load(&server->index_jobs[i].status);
if (st == 0)
continue;
if (pos > 1)
http_appendf(buf, sizeof(buf), &pos, ",");
const char *ss = st == 1 ? "indexing" : st == 2 ? "done" : "error";
/* root_path comes from POST /api/index and is up to 1023 bytes, so four
* occupied slots exceed this buffer. http_appendf pins pos to
* sizeof(buf) on truncation, so the separator and the close have to go
* through it as well rather than indexing raw. Both fields are free-form,
* so escape them — a quote in a path would otherwise end its JSON string
* early. */
/* Escaping can double each byte: root_path is 1024, error_msg 256. */
char esc_path[2048];
char esc_error[512];
cbm_json_escape(esc_path, (int)sizeof(esc_path), server->index_jobs[i].root_path);
cbm_json_escape(esc_error, (int)sizeof(esc_error),
st == 3 ? server->index_jobs[i].error_msg : "");
http_appendf(buf, sizeof(buf), &pos,
"{\"slot\":%d,\"status\":\"%s\",\"path\":\"%s\",\"error\":\"%s\"}", i, ss,
esc_path, esc_error);
}
http_appendf(buf, sizeof(buf), &pos, "]");
if ((size_t)pos >= sizeof(buf)) {
pos = (int)sizeof(buf) - 1;
}
buf[pos] = '\0';
cbm_http_replyf(c, 200, g_cors_json, "%s", buf);
}
static void unwatch_project(cbm_http_server_t *srv, const char *name) {
if (srv && srv->watcher) {
cbm_watcher_unwatch(srv->watcher, name);
}
}
/* DELETE /api/project?name=X — deletes the .db file */
static void handle_delete_project(cbm_http_server_t *srv, cbm_http_conn_t *c,
const cbm_http_req_t *req) {
char name[256] = {0};
if (!cbm_http_query_param(req->query, "name", name, (int)sizeof(name)) || name[0] == '\0') {
cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"missing name\"}");
return;
}
char db_path[1024];
db_path_for_project(name, db_path, sizeof(db_path));
if (db_path[0] == '\0') {
cbm_http_replyf(c, 404, g_cors_json, "{\"error\":\"project not found\"}");
return;
}
if (srv->mutation_begin && !srv->mutation_begin(srv->mutation_context, name)) {
cbm_http_replyf(c, 423, g_cors_json,
"{\"error\":\"project is busy; retry after indexing\"}");
return;
}
bool mutation_held = srv->mutation_begin != NULL;
if (unlink(db_path) != 0) {
if (errno == ENOENT) {
unwatch_project(srv, name);
if (mutation_held) {
srv->mutation_end(srv->mutation_context, name);
}
cbm_http_replyf(c, 404, g_cors_json, "{\"error\":\"project not found\"}");
return;
}
if (mutation_held) {
srv->mutation_end(srv->mutation_context, name);
}
cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"failed to delete\"}");
return;
}
/* Also remove WAL and SHM files if they exist */
char wal_path[1040], shm_path[1040];
snprintf(wal_path, sizeof(wal_path), "%s-wal", db_path);
snprintf(shm_path, sizeof(shm_path), "%s-shm", db_path);
(void)unlink(wal_path);
(void)unlink(shm_path);
unwatch_project(srv, name);
cbm_log_info("ui.project.deleted", "name", name);
if (mutation_held) {
srv->mutation_end(srv->mutation_context, name);
}
cbm_http_replyf(c, 200, g_cors_json, "{\"deleted\":true}");
}
/* GET /api/project-health?name=X — checks db integrity */
static void handle_project_health(cbm_http_conn_t *c, const cbm_http_req_t *req) {
char name[256] = {0};
if (!cbm_http_query_param(req->query, "name", name, (int)sizeof(name)) || name[0] == '\0') {
cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"missing name\"}");
return;
}
char db_path[1024];
db_path_for_project(name, db_path, sizeof(db_path));
if (!cbm_file_exists(db_path)) {
cbm_http_replyf(c, 200, g_cors_json, "{\"status\":\"missing\"}");
return;
}
cbm_store_t *store = cbm_store_open_path_query(db_path);
if (!store) {
cbm_http_replyf(c, 200, g_cors_json, "{\"status\":\"corrupt\",\"reason\":\"cannot open\"}");
return;
}
int node_count = cbm_store_count_nodes(store, name);
int edge_count = cbm_store_count_edges(store, name);
cbm_store_close(store);
int64_t size = cbm_file_size(db_path);
cbm_http_replyf(c, 200, g_cors_json,
"{\"status\":\"healthy\",\"nodes\":%d,\"edges\":%d,\"size_bytes\":%lld}",
node_count, edge_count, (long long)size);
}
/* ── Handle GET /api/layout ───────────────────────────────────── */
/* Find distinct target_project values from CROSS_* edges in a store.
* Writes up to max_out project names (heap-allocated). Returns count. */
static int find_cross_repo_targets(cbm_store_t *store, const char *project, char **out,
int max_out) {
struct sqlite3 *db = cbm_store_get_db(store);
if (!db) {
return 0;
}
sqlite3_stmt *s = NULL;
if (sqlite3_prepare_v2(
db,
"SELECT DISTINCT json_extract(properties, '$.target_project') FROM edges "
"WHERE project = ?1 AND type LIKE 'CROSS_%' "
"AND json_extract(properties, '$.target_project') IS NOT NULL",
-1, &s, NULL) != SQLITE_OK) {
return 0;
}
sqlite3_bind_text(s, 1, project, -1, SQLITE_STATIC);
int count = 0;
while (sqlite3_step(s) == SQLITE_ROW && count < max_out) {
const char *tp = (const char *)sqlite3_column_text(s, 0);
if (tp && tp[0]) {
size_t len = strlen(tp);
out[count] = malloc(len + 1);
memcpy(out[count], tp, len + 1);
count++;
}
}
sqlite3_finalize(s);
return count;
}
enum { LAYOUT_MAX_LINKED = 16 };
#define LAYOUT_GALAXY_SPACING 600.0
#define LAYOUT_GALAXY_PAD 400.0
/* Bounding-radius of a layout result: max distance from origin across all
* nodes. Used to size galaxy spacing so satellites don't overlap the primary
* cluster. Layouts with a 1000-node cluster have radius ~1500; the previous
* fixed 600 spacing buried satellites inside the primary mass. */
static double layout_radius(const cbm_layout_result_t *r) {
if (!r || r->node_count == 0)
return 0.0;
double max_r2 = 0.0;
for (int i = 0; i < r->node_count; i++) {
double x = (double)r->nodes[i].x;
double y = (double)r->nodes[i].y;
double z = (double)r->nodes[i].z;
if (!isfinite(x) || !isfinite(y) || !isfinite(z))
continue;
double r2 = x * x + y * y + z * z;
if (r2 > max_r2)
max_r2 = r2;
}
return sqrt(max_r2);
}
/* Attach the missed-graph skeleton (#963) to the primary layout doc as
* "missed_graph": {"nodes":[...], "edges":[...], "offset":{x,y,z}}
* — the file structure of files the indexer could not fully cover, laid out
* as a satellite cluster beside the code galaxy (the UI renders it as a white
* skeleton; clicking it re-centers the camera there). The offset sits on the
* -Y side: linked-project satellites spread counter-clockwise from +X, so
* this slot collides last. Returns true when a non-empty skeleton was
* attached; no-op when the project has no missed files. */
static bool attach_missed_graph(yyjson_mut_doc *mdoc, yyjson_mut_val *mroot, cbm_store_t *store,
const char *project, double primary_radius) {
char covproj[512];
cbm_store_coverage_shadow_project(covproj, sizeof(covproj), project);
cbm_layout_result_t *ml = cbm_layout_compute(store, covproj, CBM_LAYOUT_OVERVIEW, NULL, 0, 0);
if (!ml) {
return false;
}
if (ml->node_count == 0) {
cbm_layout_free(ml);
return false;
}
double miss_radius = layout_radius(ml);
char *mjson = cbm_layout_to_json(ml);
cbm_layout_free(ml);
if (!mjson) {
return false;
}
yyjson_doc *mldoc = yyjson_read(mjson, strlen(mjson), 0);
free(mjson);
if (!mldoc) {
return false;
}
yyjson_mut_val *entry = yyjson_mut_obj(mdoc);
yyjson_val *mlroot = yyjson_doc_get_root(mldoc);
yyjson_val *mn = yyjson_obj_get(mlroot, "nodes");
yyjson_val *me = yyjson_obj_get(mlroot, "edges");
if (mn) {
yyjson_mut_obj_add_val(mdoc, entry, "nodes", yyjson_val_mut_copy(mdoc, mn));
}
if (me) {
yyjson_mut_obj_add_val(mdoc, entry, "edges", yyjson_val_mut_copy(mdoc, me));
}
yyjson_doc_free(mldoc);
double dist = primary_radius + miss_radius + LAYOUT_GALAXY_PAD;
if (dist < LAYOUT_GALAXY_SPACING) {
dist = LAYOUT_GALAXY_SPACING;
}
yyjson_mut_val *offset = yyjson_mut_obj(mdoc);
yyjson_mut_obj_add_real(mdoc, offset, "x", 0.0);
yyjson_mut_obj_add_real(mdoc, offset, "y", -dist);
yyjson_mut_obj_add_real(mdoc, offset, "z", 0.0);
yyjson_mut_obj_add_val(mdoc, entry, "offset", offset);
yyjson_mut_obj_add_val(mdoc, mroot, "missed_graph", entry);
return true;
}
static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) {
char project[256] = {0};
char max_str[32] = {0};
char graph_str[32] = {0};
if (!cbm_http_query_param(req->query, "project", project, (int)sizeof(project)) ||
project[0] == '\0') {
cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"missing project parameter\"}");
return;
}
int max_nodes = 0; /* 0 → layout default budget */
if (cbm_http_query_param(req->query, "max_nodes", max_str, (int)sizeof(max_str))) {
int v = atoi(max_str);
if (v > 0)
max_nodes = v;
}
/* graph=missed (#963): lay out the derived miss graph (shadow project
* "<name>::missed" inside the SAME db file) instead of the code graph —
* only files the indexer could not fully cover, as their file structure.
* The db file still resolves from the validated base project name. */
bool missed_graph = false;
if (cbm_http_query_param(req->query, "graph", graph_str, (int)sizeof(graph_str))) {
missed_graph = strcmp(graph_str, "missed") == 0;
}
char scoped_project[320];
if (missed_graph) {
cbm_store_coverage_shadow_project(scoped_project, sizeof(scoped_project), project);
} else {
snprintf(scoped_project, sizeof(scoped_project), "%s", project);
}
char db_path[1024];
db_path_for_project(project, db_path, sizeof(db_path));
if (!cbm_file_exists(db_path)) {
cbm_http_replyf(c, 404, g_cors_json, "{\"error\":\"project not found\"}");
return;
}
cbm_store_t *store = cbm_store_open_path_query(db_path);
if (!store) {
cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"cannot open store\"}");
return;
}
cbm_layout_result_t *layout =
cbm_layout_compute(store, scoped_project, CBM_LAYOUT_OVERVIEW, NULL, 0, max_nodes);
/* Find linked projects from CROSS_* edges. Keep `store` open through the
* linked-projects loop below so we can resolve target Route QNs against
* the linked stores when populating cross_edges. */
char *linked[LAYOUT_MAX_LINKED];
int linked_count = find_cross_repo_targets(store, project, linked, LAYOUT_MAX_LINKED);
if (!layout) {
cbm_store_close(store);
cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"layout computation failed\"}");
return;
}
/* Capture primary cluster radius before freeing the layout. */
double primary_radius = layout_radius(layout);
/* Build JSON: primary layout + linked_projects */
char *primary_json = cbm_layout_to_json(layout);
cbm_layout_free(layout);
if (!primary_json) {
cbm_store_close(store);
cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"JSON serialization failed\"}");
return;
}
/* Fast path: no satellites to attach. The missed skeleton only decorates
* the CODE graph — a graph=missed request already IS the miss graph. */
if (linked_count == 0 && missed_graph) {
cbm_store_close(store);
cbm_http_replyf(c, 200, g_cors_json, "%s", primary_json);
free(primary_json);
return;
}
/* Parse primary JSON and append missed_graph + linked_projects */
yyjson_doc *pdoc = yyjson_read(primary_json, strlen(primary_json), 0);
free(primary_json);
if (!pdoc) {
cbm_store_close(store);
cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"JSON parse failed\"}");
return;
}
yyjson_mut_doc *mdoc = yyjson_doc_mut_copy(pdoc, NULL);
yyjson_doc_free(pdoc);
yyjson_mut_val *mroot = yyjson_mut_doc_get_root(mdoc);
if (!missed_graph) {
(void)attach_missed_graph(mdoc, mroot, store, project, primary_radius);
}
yyjson_mut_val *lp_arr = yyjson_mut_arr(mdoc);
for (int li = 0; li < linked_count; li++) {
char lp_path[1024];
db_path_for_project(linked[li], lp_path, sizeof(lp_path));
if (!cbm_file_exists(lp_path)) {
free(linked[li]);
continue;
}
cbm_store_t *lp_store = cbm_store_open_path_query(lp_path);
if (!lp_store) {
free(linked[li]);
continue;
}
/* Keep lp_store open through cross_edges resolution below. */
cbm_layout_result_t *lp_layout =
cbm_layout_compute(lp_store, linked[li], CBM_LAYOUT_OVERVIEW, NULL, 0, max_nodes);
if (!lp_layout) {
cbm_store_close(lp_store);
free(linked[li]);
continue;
}
double sat_radius = layout_radius(lp_layout);
char *lp_json = cbm_layout_to_json(lp_layout);
cbm_layout_free(lp_layout);
if (!lp_json) {
cbm_store_close(lp_store);
free(linked[li]);
continue;
}
/* Parse linked project layout */
yyjson_doc *lpdoc = yyjson_read(lp_json, strlen(lp_json), 0);
free(lp_json);
if (!lpdoc) {
cbm_store_close(lp_store);
free(linked[li]);
continue;
}
yyjson_mut_doc *lm = yyjson_doc_mut_copy(lpdoc, NULL);
yyjson_doc_free(lpdoc);
yyjson_mut_val *lmroot = yyjson_mut_doc_get_root(lm);
/* Build linked project entry */
yyjson_mut_val *entry = yyjson_mut_obj(mdoc);
yyjson_mut_obj_add_strcpy(mdoc, entry, "project", linked[li]);
/* Copy nodes and edges from linked layout */
yyjson_mut_val *ln = yyjson_mut_obj_get(lmroot, "nodes");
yyjson_mut_val *le = yyjson_mut_obj_get(lmroot, "edges");
if (ln) {
yyjson_mut_obj_add_val(mdoc, entry, "nodes", yyjson_mut_val_mut_copy(mdoc, ln));
}
if (le) {
yyjson_mut_obj_add_val(mdoc, entry, "edges", yyjson_mut_val_mut_copy(mdoc, le));
}
/* Compute galaxy offset: evenly spaced around primary, far enough out
* that the primary cluster (radius primary_radius) and the satellite
* cluster (radius sat_radius) don't overlap. Bounded below by
* LAYOUT_GALAXY_SPACING for trivially small projects. */
double angle = (2.0 * 3.14159265358979) * (double)li / (double)linked_count;
double dist = primary_radius + sat_radius + LAYOUT_GALAXY_PAD;
if (dist < LAYOUT_GALAXY_SPACING) {
dist = LAYOUT_GALAXY_SPACING;
}
yyjson_mut_val *offset = yyjson_mut_obj(mdoc);
yyjson_mut_obj_add_real(mdoc, offset, "x", cos(angle) * dist);
yyjson_mut_obj_add_real(mdoc, offset, "y", sin(angle) * dist);
yyjson_mut_obj_add_real(mdoc, offset, "z", 0.0);
yyjson_mut_obj_add_val(mdoc, entry, "offset", offset);
/* Populate cross_edges connecting primary→this linked galaxy. Each
* entry: {source: <primary node id>, target: <linked node id>, type}.
*
* A CROSS_* edge in the source store points caller_id → local_route_id
* (a Route node in the source store). The Route's qualified_name is
* canonical and the same Route exists in the linked store too — that's
* the cross-repo matching contract. Join edges → nodes in source to
* pull the QN, then look it up in the linked store. */
yyjson_mut_val *cross_arr = yyjson_mut_arr(mdoc);
struct sqlite3 *src_db = cbm_store_get_db(store);
struct sqlite3 *lp_db = cbm_store_get_db(lp_store);
if (src_db && lp_db) {
sqlite3_stmt *eq = NULL;
if (sqlite3_prepare_v2(src_db,
"SELECT e.source_id, e.type, n.qualified_name "
"FROM edges e JOIN nodes n "
" ON n.id = e.target_id AND n.project = e.project "
"WHERE e.project = ?1 AND e.type LIKE 'CROSS_%' "
" AND json_extract(e.properties, '$.target_project') = ?2 "
" AND n.qualified_name IS NOT NULL",
-1, &eq, NULL) == SQLITE_OK) {
sqlite3_bind_text(eq, 1, project, -1, SQLITE_STATIC);
sqlite3_bind_text(eq, 2, linked[li], -1, SQLITE_STATIC);
sqlite3_stmt *lookup = NULL;
sqlite3_prepare_v2(lp_db, "SELECT id FROM nodes WHERE qualified_name = ?1 LIMIT 1",
-1, &lookup, NULL);
while (sqlite3_step(eq) == SQLITE_ROW) {
int64_t src_id = sqlite3_column_int64(eq, 0);
const char *etype = (const char *)sqlite3_column_text(eq, 1);
const char *qn = (const char *)sqlite3_column_text(eq, 2);
if (!qn || !etype || !lookup) {
continue;
}
sqlite3_reset(lookup);
sqlite3_clear_bindings(lookup);
sqlite3_bind_text(lookup, 1, qn, -1, SQLITE_STATIC);
if (sqlite3_step(lookup) != SQLITE_ROW) {
continue;
}
int64_t tgt_id = sqlite3_column_int64(lookup, 0);
yyjson_mut_val *ce = yyjson_mut_obj(mdoc);
yyjson_mut_obj_add_int(mdoc, ce, "source", src_id);
yyjson_mut_obj_add_int(mdoc, ce, "target", tgt_id);
yyjson_mut_obj_add_strcpy(mdoc, ce, "type", etype);
yyjson_mut_arr_append(cross_arr, ce);
}
if (lookup)
sqlite3_finalize(lookup);
sqlite3_finalize(eq);
}
}
yyjson_mut_obj_add_val(mdoc, entry, "cross_edges", cross_arr);
cbm_store_close(lp_store);
yyjson_mut_arr_append(lp_arr, entry);
yyjson_mut_doc_free(lm);
free(linked[li]);
}
cbm_store_close(store);
yyjson_mut_obj_add_val(mdoc, mroot, "linked_projects", lp_arr);
size_t len = 0;
char *final_json = yyjson_mut_write(mdoc, 0, &len);
yyjson_mut_doc_free(mdoc);
if (final_json) {
cbm_http_replyf(c, 200, g_cors_json, "%s", final_json);
free(final_json);
} else {
cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"JSON write failed\"}");
}
}
/* ── Handle JSON-RPC request ──────────────────────────────────── */
static yyjson_val *json_unique_member(yyjson_val *object, const char *name) {
if (!yyjson_is_obj(object))
return NULL;
yyjson_val *found = NULL;
size_t index, maximum;
yyjson_val *key, *value;
yyjson_obj_foreach(object, index, maximum, key, value) {
if (strcmp(yyjson_get_str(key), name) == 0) {
if (found)
return NULL;
found = value;
}
}
return found;
}
static bool rpc_is_allowed_for_ui(const char *body, size_t body_len) {
yyjson_doc *document = yyjson_read(body, body_len, 0);
if (!document)
return false;
yyjson_val *root = yyjson_doc_get_root(document);
yyjson_val *method = json_unique_member(root, "method");
yyjson_val *params = json_unique_member(root, "params");
yyjson_val *name = json_unique_member(params, "name");
const char *method_text = yyjson_is_str(method) ? yyjson_get_str(method) : NULL;
const char *name_text = yyjson_is_str(name) ? yyjson_get_str(name) : NULL;
bool allowed =
method_text && strcmp(method_text, "tools/call") == 0 && name_text &&
(strcmp(name_text, "list_projects") == 0 || strcmp(name_text, "get_code_snippet") == 0);
yyjson_doc_free(document);
return allowed;
}
static void handle_rpc(cbm_http_conn_t *c, const cbm_http_req_t *req, cbm_mcp_server_t *mcp) {
if (req->body_len == 0 || req->body_len > MAX_BODY_SIZE || !req->body) {
cbm_http_replyf(c, 400, g_cors_json,
"{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32600,"
"\"message\":\"invalid request size\"},\"id\":null}");
return;
}
if (!rpc_is_allowed_for_ui(req->body, req->body_len)) {
cbm_http_replyf(c, 403, g_cors_json,
"{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32601,"
"\"message\":\"UI RPC method is not allowed\"},\"id\":null}");
return;
}
/* req->body is NUL-terminated by the transport */
char *response = cbm_mcp_server_handle(mcp, req->body);
if (response) {
cbm_http_replyf(c, 200, g_cors_json, "%s", response);
free(response);
} else {
cbm_http_replyf(c, 204, g_cors, "%s", "");
}
}
/* ── Request dispatch ─────────────────────────────────────────── */
/* True when the Host header names the loopback interface and exact port the
* server binds to. Anything else means the request reached us under a
* name that is not loopback — a rebinding DNS host or a proxy pointed at the
* local port — which is the DNS-rebinding / cross-site vector against a
* localhost-only service. */
static bool host_is_this_server(const char *host, int port) {
char expected[128];
int length = snprintf(expected, sizeof(expected), "127.0.0.1:%d", port);
if (length > 0 && (size_t)length < sizeof(expected) && strcmp(host, expected) == 0)
return true;
length = snprintf(expected, sizeof(expected), "localhost:%d", port);
return length > 0 && (size_t)length < sizeof(expected) && strcmp(host, expected) == 0;
}
static bool route_is_protected(const char *path) {
return strcmp(path, "/api") == 0 || strncmp(path, "/api/", 5) == 0 || strcmp(path, "/rpc") == 0;
}
static bool content_type_is_json(const char *content_type) {
static const char json_type[] = "application/json";
size_t type_length = sizeof(json_type) - 1;
if (strlen(content_type) < type_length)
return false;
for (size_t i = 0; i < type_length; i++) {
if (tolower((unsigned char)content_type[i]) != json_type[i])
return false;
}
const char *suffix = content_type + type_length;
while (*suffix == ' ' || *suffix == '\t')
suffix++;
return *suffix == '\0' || *suffix == ';';
}
static int readiness_hex_nibble(char value) {
if (value >= '0' && value <= '9') {
return value - '0';
}
if (value >= 'a' && value <= 'f') {
return value - 'a' + 10;
}
return -1;
}
static bool readiness_hex_decode(const char *hex, uint8_t out[CBM_SHA256_DIGEST_LEN]) {
if (!hex || strlen(hex) != CBM_SHA256_HEX_LEN) {
return false;
}
for (size_t i = 0; i < CBM_SHA256_DIGEST_LEN; i++) {
int high = readiness_hex_nibble(hex[i * 2U]);
int low = readiness_hex_nibble(hex[i * 2U + 1U]);
if (high < 0 || low < 0) {
return false;
}
out[i] = (uint8_t)((high << 4) | low);
}
return true;
}
static void readiness_hex_encode(const uint8_t bytes[CBM_SHA256_DIGEST_LEN],
char out[CBM_SHA256_HEX_LEN + 1U]) {
static const char hex[] = "0123456789abcdef";
for (size_t i = 0; i < CBM_SHA256_DIGEST_LEN; i++) {
out[i * 2U] = hex[bytes[i] >> 4];
out[i * 2U + 1U] = hex[bytes[i] & 0x0fU];
}
out[CBM_SHA256_HEX_LEN] = '\0';
}
static void handle_ui_readiness(cbm_http_server_t *srv, cbm_http_conn_t *c,
const cbm_http_req_t *req) {
if (!srv->readiness_secret_set) {
cbm_http_replyf(c, 503, "Cache-Control: no-store\r\n", "%s", "readiness proof unavailable");
return;
}
char challenge_hex[CBM_SHA256_HEX_LEN + 1U];
uint8_t challenge[CBM_SHA256_DIGEST_LEN];
static const char prefix[] = "challenge=";
if (strncmp(req->query, prefix, sizeof(prefix) - 1U) != 0 ||
strlen(req->query) != sizeof(prefix) - 1U + CBM_SHA256_HEX_LEN) {
cbm_http_replyf(c, 400, "Cache-Control: no-store\r\n", "%s", "invalid challenge");
return;
}
memcpy(challenge_hex, req->query + sizeof(prefix) - 1U, CBM_SHA256_HEX_LEN);
challenge_hex[CBM_SHA256_HEX_LEN] = '\0';
if (!readiness_hex_decode(challenge_hex, challenge)) {
cbm_secure_zero(challenge, sizeof(challenge));
cbm_http_replyf(c, 400, "Cache-Control: no-store\r\n", "%s", "invalid challenge");
return;
}
uint8_t proof[CBM_SHA256_DIGEST_LEN];
char proof_hex[CBM_SHA256_HEX_LEN + 1U];
cbm_hmac_sha256(srv->readiness_secret, sizeof(srv->readiness_secret), challenge,
sizeof(challenge), proof);
readiness_hex_encode(proof, proof_hex);
cbm_http_replyf(c, 200,
"Content-Type: text/plain; charset=utf-8\r\n"
"Cache-Control: no-store\r\n"
"X-Content-Type-Options: nosniff\r\n",
"%s", proof_hex);
cbm_secure_zero(challenge, sizeof(challenge));
cbm_secure_zero(proof, sizeof(proof));
cbm_secure_zero(proof_hex, sizeof(proof_hex));
}
static bool request_passes_http_security(cbm_http_server_t *srv, cbm_http_conn_t *c,
const cbm_http_req_t *req) {
if (req->http_minor == 1 && req->host[0] == '\0') {
cbm_http_replyf(c, 400, "", "%s", "{\"error\":\"Host header required\"}");
return false;
}
if (req->host[0] != '\0' && !host_is_this_server(req->host, srv->port)) {
cbm_http_replyf(c, 403, "", "%s", "{\"error\":\"forbidden host\"}");
return false;
}
if (req->origin[0] != '\0' &&
(req->host[0] == '\0' || !origin_is_same_server(req->origin, srv->port) ||
!origin_matches_host(req->origin, req->host, srv->port))) {
cbm_http_replyf(c, 403, "", "%s", "{\"error\":\"forbidden origin\"}");
return false;
}
update_cors(req, srv->port);
bool is_post = strcmp(req->method, "POST") == 0;
if (route_is_protected(req->path) && is_post && !content_type_is_json(req->content_type)) {
cbm_http_replyf(c, 415, g_cors_json, "%s", "{\"error\":\"application/json required\"}");
return false;
}
return true;
}
static void dispatch_request(cbm_http_server_t *srv, cbm_http_conn_t *c,
const cbm_http_req_t *req) {
if (!request_passes_http_security(srv, c, req))
return;
bool is_get = strcmp(req->method, "GET") == 0;
bool is_post = strcmp(req->method, "POST") == 0;
bool is_delete = strcmp(req->method, "DELETE") == 0;
/* OPTIONS preflight for CORS */
if (strcmp(req->method, "OPTIONS") == 0) {
cbm_http_replyf(c, 204, g_cors, "%s", "");
return;
}
/* Private-generation proof used only by `daemon start --open`. The
* challenge is public; the HMAC key exists only in this daemon's
* authenticated application and HTTP server instances. */
if (is_get && strcmp(req->path, "/__cbm/ui-readiness") == 0) {
handle_ui_readiness(srv, c, req);
return;
}
/* POST /rpc → JSON-RPC dispatch (reuses existing MCP tools) */
if (is_post && cbm_http_path_match(req->path, "/rpc")) {
handle_rpc(c, req, srv->mcp);
return;
}
/* GET /api/layout → 3D graph layout */
if (is_get && cbm_http_path_match(req->path, "/api/layout*")) {
handle_layout(c, req);
return;
}
/* GET /api/repo-info → git remote / branch for GitHub deep-links */
if (is_get && cbm_http_path_match(req->path, "/api/repo-info*")) {
handle_repo_info(c, req);
return;
}
/* POST /api/index → start background indexing */
if (is_post && cbm_http_path_match(req->path, "/api/index")) {
handle_index_start(srv, c, req);
return;
}
/* GET /api/index-status → check indexing progress */
if (is_get && cbm_http_path_match(req->path, "/api/index-status")) {
handle_index_status(srv, c);
return;
}
/* GET /api/ui-config → language and local UI preferences */
if (is_get && cbm_http_path_match(req->path, "/api/ui-config")) {
handle_ui_config(c, req);
return;
}
/* DELETE /api/project → delete a project's .db file */
if (is_delete && cbm_http_path_match(req->path, "/api/project*")) {
handle_delete_project(srv, c, req);
return;
}
/* GET /api/browse → directory browser for file picker */
if (is_get && cbm_http_path_match(req->path, "/api/browse*")) {
handle_browse(c, req);
return;
}
/* GET /api/adr → get ADR for project */
if (is_get && cbm_http_path_match(req->path, "/api/adr*")) {
handle_adr_get(c, req);
return;
}
/* POST /api/adr → save ADR for project */
if (is_post && cbm_http_path_match(req->path, "/api/adr")) {
handle_adr_save(srv, c, req);
return;
}
/* GET /api/project-health → check db integrity */
if (is_get && cbm_http_path_match(req->path, "/api/project-health*")) {
handle_project_health(c, req);
return;
}
/* GET /api/processes → list running codebase-memory-mcp processes */
if (is_get && cbm_http_path_match(req->path, "/api/processes")) {
handle_processes(c);
return;
}
/* GET /api/logs → recent log lines */
if (is_get && cbm_http_path_match(req->path, "/api/logs*")) {
handle_logs(c, req);
return;
}
/* GET / → index.html (no-cache so browser always gets latest) */
if (cbm_http_path_match(req->path, "/")) {
const cbm_embedded_file_t *f = cbm_embedded_lookup("/index.html");
if (f) {
char html_hdrs[1024];
snprintf(html_hdrs, sizeof(html_hdrs),
"%sContent-Type: text/html; charset=utf-8\r\nCache-Control: no-cache\r\n"
"X-Content-Type-Options: nosniff\r\n" CBM_UI_CSP,
g_cors);
cbm_http_reply_buf(c, 200, html_hdrs, f->data, (size_t)f->size);
return;
}
cbm_http_replyf(c, 404, g_cors, "no frontend embedded");
return;
}
/* GET /assets/... → exact lookup in the embedded asset table. */
if (serve_embedded(c, req->path))
return;
cbm_http_replyf(c, 404, g_cors, "not found");
}
/* ── Public API ───────────────────────────────────────────────── */
static char *http_read_only_index_rejected(void *context, const char *repo_path,
const char *args_json) {
(void)context;
(void)repo_path;
(void)args_json;
return cbm_mcp_text_result("UI RPC indexing is disabled; use the coordinated /api/index route",
true);
}
cbm_http_server_t *cbm_http_server_new(int port) {
cbm_http_server_t *srv = calloc(1, sizeof(*srv));
if (!srv)
return NULL;
srv->port = port;
atomic_init(&srv->stop_flag, 0);
atomic_init(&srv->run_state, HTTP_RUN_IDLE);
/* Create a dedicated MCP server for HTTP (own SQLite connection) */
srv->mcp = cbm_mcp_server_new(NULL);
if (!srv->mcp) {
cbm_log_error("ui.http.mcp_fail", "reason", "cannot create MCP instance");
free(srv);
return NULL;
}
cbm_mcp_server_set_background_tasks(srv->mcp, false);
cbm_mcp_server_set_index_executor(srv->mcp, http_read_only_index_rejected, srv);
/* Bind to localhost only (httpd refuses anything else by construction) */
srv->listener = cbm_httpd_listen(port);
if (!srv->listener) {
char port_str[16];
snprintf(port_str, sizeof(port_str), "%d", port);
cbm_log_warn("ui.unavailable", "port", port_str, "reason", "in_use", "hint",
"use --port=N to override");
cbm_mcp_server_free(srv->mcp);
free(srv);
return NULL;
}
srv->port = cbm_httpd_port(srv->listener);
srv->listener_ok = true;
char port_str[16];
snprintf(port_str, sizeof(port_str), "%d", srv->port);
char url[64];
snprintf(url, sizeof(url), "http://127.0.0.1:%d", srv->port);
cbm_log_info("ui.serving", "url", url, "port", port_str);
return srv;
}
bool cbm_http_server_free(cbm_http_server_t *srv) {
if (!srv)
return true;
int run_state = atomic_load_explicit(&srv->run_state, memory_order_acquire);
if (run_state == HTTP_RUN_SCHEDULED || run_state == HTTP_RUN_RUNNING)
return false;
for (int i = 0; i < MAX_INDEX_JOBS; i++) {
if (srv->index_jobs[i].thread_started) {
if (!atomic_load_explicit(&srv->index_jobs[i].completed, memory_order_acquire))
return false;
if (cbm_thread_join(&srv->index_jobs[i].thread) != 0)
return false;
srv->index_jobs[i].thread_started = false;
}
}
if (!cbm_httpd_close(srv->listener))
return false;
cbm_mcp_server_free(srv->mcp);
cbm_secure_zero(srv->readiness_secret, sizeof(srv->readiness_secret));
free(srv);
return true;
}
void cbm_http_server_stop(cbm_http_server_t *srv) {
if (srv) {
atomic_store(&srv->stop_flag, 1);
cbm_httpd_interrupt(srv->listener);
}
}
bool cbm_http_server_schedule_run(cbm_http_server_t *srv) {
if (!srv || !srv->listener_ok)
return false;
int expected = HTTP_RUN_IDLE;
return atomic_compare_exchange_strong_explicit(&srv->run_state, &expected, HTTP_RUN_SCHEDULED,
memory_order_acq_rel, memory_order_acquire);
}
bool cbm_http_server_cancel_scheduled_run(cbm_http_server_t *srv) {
if (!srv)
return false;
int expected = HTTP_RUN_SCHEDULED;
return atomic_compare_exchange_strong_explicit(&srv->run_state, &expected, HTTP_RUN_IDLE,
memory_order_acq_rel, memory_order_acquire);
}
void cbm_http_server_run(cbm_http_server_t *srv) {
if (!srv)
return;
int expected = HTTP_RUN_SCHEDULED;
if (!atomic_compare_exchange_strong_explicit(&srv->run_state, &expected, HTTP_RUN_RUNNING,
memory_order_acq_rel, memory_order_acquire))
return;
if (!srv->listener_ok) {
atomic_store_explicit(&srv->run_state, HTTP_RUN_COMPLETED, memory_order_release);
return;
}
while (!atomic_load(&srv->stop_flag)) {
cbm_http_conn_t *conn = cbm_httpd_accept(srv->listener, 200);
if (!conn)
continue; /* timeout — re-check stop flag */
uint64_t request_start_ms = cbm_now_ms();
cbm_http_req_t req;
int rc = cbm_httpd_read_request(conn, &req);
if (rc == 0) {
if (atomic_load(&srv->stop_flag)) {
cbm_http_req_free(&req);
cbm_httpd_conn_close(conn);
break;
}
dispatch_request(srv, conn, &req);
cbm_log_http_request("graph_ui", req.method, req.path, cbm_http_conn_status(conn),
(int64_t)(cbm_now_ms() - request_start_ms), req.body_len,
cbm_http_conn_response_bytes(conn));
cbm_http_req_free(&req);
} else if (rc > 0) {
/* Parse/transport error with a known HTTP status (400/408/411/413/431).
* No CORS reflection here — the request was never parsed. */
cbm_http_replyf(conn, rc, "", "bad request");
cbm_log_http_request("graph_ui", "", "", cbm_http_conn_status(conn),
(int64_t)(cbm_now_ms() - request_start_ms), 0,
cbm_http_conn_response_bytes(conn));
}
cbm_httpd_conn_close(conn);
}
atomic_store_explicit(&srv->run_state, HTTP_RUN_COMPLETED, memory_order_release);
}
cbm_httpd_activity_t cbm_http_server_activity_for_test(cbm_http_server_t *srv) {
return srv ? cbm_httpd_activity_for_test(srv->listener) : CBM_HTTPD_ACTIVITY_IDLE;
}
bool cbm_http_server_is_running(const cbm_http_server_t *srv) {
return srv && srv->listener_ok;
}
int cbm_http_server_port(const cbm_http_server_t *srv) {
return (srv && srv->listener_ok) ? srv->port : -1;
}
void cbm_http_server_set_recv_deadline_ms(cbm_http_server_t *srv, int ms) {
if (srv && srv->listener_ok) {
cbm_httpd_set_recv_deadline_ms(srv->listener, ms);
}
}
void cbm_http_server_set_watcher(cbm_http_server_t *srv, struct cbm_watcher *watcher) {
if (srv) {
srv->watcher = watcher;
}
}
void cbm_http_server_set_index_executor(cbm_http_server_t *srv, cbm_http_index_executor_fn executor,
void *context) {
if (srv) {
srv->index_executor = executor;
srv->index_executor_context = context;
}
}
void cbm_http_server_set_project_mutation_guard(cbm_http_server_t *srv,
cbm_http_project_mutation_begin_fn begin,
cbm_http_project_mutation_end_fn end,
void *context) {
if (!srv || ((begin == NULL) != (end == NULL))) {
return;
}
srv->mutation_begin = begin;
srv->mutation_end = end;
srv->mutation_context = begin ? context : NULL;
cbm_mcp_server_set_project_mutation_guard(srv->mcp, begin, end, begin ? context : NULL);
cbm_mcp_server_set_project_mutation_try_guard(srv->mcp, begin);
}
void cbm_http_server_set_readiness_secret(cbm_http_server_t *srv,
const uint8_t secret[CBM_SHA256_DIGEST_LEN]) {
if (!srv || !secret ||
atomic_load_explicit(&srv->run_state, memory_order_acquire) != HTTP_RUN_IDLE) {
return;
}
memcpy(srv->readiness_secret, secret, sizeof(srv->readiness_secret));
srv->readiness_secret_set = true;
}