mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-09 09:11:00 +08:00
2263 lines
86 KiB
C
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;
|
|
}
|