From 8100ef5cb556eabdbeb3d5f9d0fc47a77ba30fde Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Jul 2026 11:14:43 +0000 Subject: [PATCH] fix(gateway): apply dashboard JSON config saves and reload live settings PUT /api/config accepted raw TOML while the web UI sends JSON, so saves either failed validation or never updated in-memory provider settings. Patch known dashboard fields into config.toml and call try_config_reload after a successful write. Co-authored-by: esadrianno --- Makefile | 16 +- src/core/config_patch.c | 429 ++++++++++++++++++++++++++++++++++++++ src/core/config_patch.h | 37 ++++ src/gateway/routes.c | 59 +++++- tests/test_config_patch.c | 89 ++++++++ tests/test_gateway_http.c | 51 +++++ 6 files changed, 674 insertions(+), 7 deletions(-) create mode 100644 src/core/config_patch.c create mode 100644 src/core/config_patch.h create mode 100644 tests/test_config_patch.c diff --git a/Makefile b/Makefile index 2a2d541..4cf494d 100644 --- a/Makefile +++ b/Makefile @@ -47,6 +47,7 @@ SKILL_O := src/core/skill.o AGENT_O := src/core/agent.o DAEMON_O := src/core/daemon.o RELOAD_O := src/core/reload.o +CONFIG_PATCH_O := src/core/config_patch.o BOOTSTRAP_O := src/core/bootstrap.o DISPATCH_O := src/core/dispatch.o # Vendor @@ -116,7 +117,7 @@ CONTEXT_GEO_TEST_O := $(BINDIR)/context_geo_test.o CONTEXT_CACHE_TEST_O := $(BINDIR)/context_cache_test.o HEARTBEAT_TEST_O := $(BINDIR)/heartbeat_test.o CONTEXT_TEST_OBJS := $(CONTEXT_TEST_O) $(CONTEXT_HTTP_TEST_O) $(CONTEXT_GEO_TEST_O) $(CONTEXT_CACHE_TEST_O) -CORE_OBJS := $(CONFIG_O) $(MAIN_O) $(MEMORY_O) $(SKILL_O) $(AGENT_O) $(DAEMON_O) $(RELOAD_O) $(BOOTSTRAP_O) $(DISPATCH_O) +CORE_OBJS := $(CONFIG_O) $(MAIN_O) $(MEMORY_O) $(SKILL_O) $(AGENT_O) $(DAEMON_O) $(RELOAD_O) $(CONFIG_PATCH_O) $(BOOTSTRAP_O) $(DISPATCH_O) VENDOR_OBJS := $(TOML_O) $(SQLITE3_O) $(CJSON_O) OBJS := $(CORE_OBJS) $(VENDOR_OBJS) PROVIDER_OBJS := $(PROVIDER_COMMON_O) $(STUB_O) $(ROUTER_O) $(ANTHROPIC_O) $(OPENAI_COMPAT_O) $(OPENAI_O) $(LOCAL_O) @@ -156,6 +157,9 @@ $(DAEMON_O): src/core/daemon.c src/core/daemon.h src/core/config.h $(RELOAD_O): src/core/reload.c src/core/reload.h src/core/bootstrap.h src/core/config.h src/channels/channel.h src/channels/heartbeat.h src/providers/provider.h src/tools/tool.h $(CC) $(CFLAGS) $(INC) -c -o $@ src/core/reload.c +$(CONFIG_PATCH_O): src/core/config_patch.c src/core/config_patch.h src/core/config.h vendor/cJSON/cJSON.h + $(CC) $(CFLAGS) $(INC) -c -o $@ src/core/config_patch.c + $(BOOTSTRAP_O): src/core/bootstrap.c src/core/bootstrap.h src/core/config.h src/core/memory.h src/core/skill.h src/channels/channel.h src/channels/heartbeat.h src/providers/provider.h src/tools/tool.h src/tools/cron.h $(CC) $(CFLAGS) $(INC) -c -o $@ src/core/bootstrap.c @@ -290,7 +294,7 @@ $(HTTP_O): src/gateway/http.c src/gateway/http.h src/gateway/http_lws.h src/gate $(HTTP_LWS_O): src/gateway/http_lws.c src/gateway/http_lws.h src/gateway/routes.h src/gateway/auth.h src/gateway/static.h src/gateway/ws.h $(CC) $(CFLAGS) $(INC) $(GATEWAY_CFLAGS) -pthread -c -o $@ src/gateway/http_lws.c -$(ROUTES_O): src/gateway/routes.c src/gateway/routes.h src/gateway/http_lws.h src/gateway/auth.h src/gateway/rate_limit.h src/tools/context.h src/asap/manifest.h src/asap/envelope.h src/asap/server.h src/asap/log.h src/core/config.h src/core/memory.h src/core/skill.h src/providers/provider.h src/channels/channel.h src/tools/cron.h +$(ROUTES_O): src/gateway/routes.c src/gateway/routes.h src/gateway/http_lws.h src/gateway/auth.h src/gateway/rate_limit.h src/tools/context.h src/asap/manifest.h src/asap/envelope.h src/asap/server.h src/asap/log.h src/core/config.h src/core/config_patch.h src/core/bootstrap.h src/core/reload.h src/core/memory.h src/core/skill.h src/providers/provider.h src/channels/channel.h src/tools/cron.h $(CC) $(CFLAGS) $(INC) $(GATEWAY_CFLAGS) -pthread -c -o $@ src/gateway/routes.c $(WS_O): src/gateway/ws.c src/gateway/ws.h @@ -364,6 +368,11 @@ test_config: tests/test_config.c $(CONFIG_O) $(TOML_O) $(CC) $(CFLAGS) $(LDFLAGS) $(INC) -o $(BINDIR)/$@ tests/test_config.c $(CONFIG_O) $(TOML_O) $(LDLIBS) $(DSYM_SCRIPT) +test_config_patch: tests/test_config_patch.c $(CONFIG_PATCH_O) $(CONFIG_O) $(TOML_O) $(CJSON_O) + @mkdir -p $(BINDIR) + $(CC) $(CFLAGS) $(LDFLAGS) $(INC) -o $(BINDIR)/$@ tests/test_config_patch.c $(CONFIG_PATCH_O) $(CONFIG_O) $(TOML_O) $(CJSON_O) $(LDLIBS) + $(DSYM_SCRIPT) + test_memory: tests/test_memory.c $(MEMORY_O) $(SQLITE3_O) @mkdir -p $(BINDIR) $(CC) $(CFLAGS) $(LDFLAGS) $(INC) -o $(BINDIR)/$@ tests/test_memory.c $(MEMORY_O) $(SQLITE3_O) $(LDLIBS) @@ -588,8 +597,9 @@ static: --suppress=constParameterCallback \ -q src/ -test: test_config test_memory test_skill test_provider test_anthropic test_openai test_local_provider test_router test_heartbeat test_agent test_channel test_cli test_shell test_file test_telegram test_discord_helpers test_web_search test_cron test_context test_crypto test_hardware_stub test_ws test_manifest $(ASAP_UNIT_TESTS) test_sandbox test_allowlist test_rate_limit test_reload test_daemon_smoke test_update_script test_install_script test_web_dashboard +test: test_config test_config_patch test_memory test_skill test_provider test_anthropic test_openai test_local_provider test_router test_heartbeat test_agent test_channel test_cli test_shell test_file test_telegram test_discord_helpers test_web_search test_cron test_context test_crypto test_hardware_stub test_ws test_manifest $(ASAP_UNIT_TESTS) test_sandbox test_allowlist test_rate_limit test_reload test_daemon_smoke test_update_script test_install_script test_web_dashboard $(BINDIR)/test_config + $(BINDIR)/test_config_patch $(BINDIR)/test_memory $(BINDIR)/test_skill $(BINDIR)/test_provider diff --git a/src/core/config_patch.c b/src/core/config_patch.c new file mode 100644 index 0000000..50015de --- /dev/null +++ b/src/core/config_patch.c @@ -0,0 +1,429 @@ +/** + * @file config_patch.c + * @brief Patch on-disk TOML from dashboard JSON updates. + */ +#define _POSIX_C_SOURCE 200809L + +#include "core/config_patch.h" +#include "core/config.h" +#include "cJSON.h" +#include +#include +#include +#include +#include +#include + +#define PATCH_ERR(errbuf, errbufsz, msg) \ + do { \ + if ((errbuf) && (errbufsz) > 0) \ + snprintf((errbuf), (errbufsz), "%s", (msg)); \ + } while (0) + +static char *read_file(const char *path, size_t *out_len, char *errbuf, size_t errbufsz) +{ + FILE *f; + char *buf; + long n; + size_t got; + + if (!path || !out_len) { + PATCH_ERR(errbuf, errbufsz, "invalid arguments"); + return NULL; + } + f = fopen(path, "r"); + if (!f) { + PATCH_ERR(errbuf, errbufsz, "cannot open config file"); + return NULL; + } + if (fseek(f, 0, SEEK_END) != 0) { + fclose(f); + PATCH_ERR(errbuf, errbufsz, "cannot read config file"); + return NULL; + } + n = ftell(f); + if (n < 0) { + fclose(f); + PATCH_ERR(errbuf, errbufsz, "cannot read config file"); + return NULL; + } + if (fseek(f, 0, SEEK_SET) != 0) { + fclose(f); + PATCH_ERR(errbuf, errbufsz, "cannot read config file"); + return NULL; + } + buf = malloc((size_t)n + 1); + if (!buf) { + fclose(f); + PATCH_ERR(errbuf, errbufsz, "out of memory"); + return NULL; + } + got = fread(buf, 1, (size_t)n, f); + fclose(f); + if (got != (size_t)n) { + free(buf); + PATCH_ERR(errbuf, errbufsz, "cannot read config file"); + return NULL; + } + buf[got] = '\0'; + *out_len = got; + return buf; +} + +static int append_fmt(char **buf, size_t *len, size_t *cap, const char *fmt, ...) +{ + va_list ap; + int need; + char stack[256]; + char *heap = NULL; + char *target; + + if (!buf || !*buf || !len || !cap) + return -1; + va_start(ap, fmt); + need = vsnprintf(stack, sizeof(stack), fmt, ap); + va_end(ap); + if (need < 0) + return -1; + if ((size_t)need < sizeof(stack)) { + target = stack; + } else { + heap = malloc((size_t)need + 1); + if (!heap) + return -1; + va_start(ap, fmt); + (void)vsnprintf(heap, (size_t)need + 1, fmt, ap); + va_end(ap); + target = heap; + } + if (*len + (size_t)need + 1 > *cap) { + size_t new_cap = (*cap == 0) ? (size_t)need + 64 : *cap; + while (new_cap < *len + (size_t)need + 1) + new_cap *= 2; + char *grown = realloc(*buf, new_cap); + if (!grown) { + free(heap); + return -1; + } + *buf = grown; + *cap = new_cap; + } + memcpy(*buf + *len, target, (size_t)need); + *len += (size_t)need; + (*buf)[*len] = '\0'; + free(heap); + return 0; +} + +static int escape_toml_string(const char *in, char **out) +{ + size_t cap; + size_t len; + size_t i; + + if (!in || !out) + return -1; + cap = strlen(in) * 2 + 3; + *out = malloc(cap); + if (!*out) + return -1; + (*out)[0] = '"'; + len = 1; + for (i = 0; in[i]; i++) { + if (in[i] == '"' || in[i] == '\\') { + if (len + 2 >= cap) { + cap *= 2; + char *grown = realloc(*out, cap); + if (!grown) { + free(*out); + *out = NULL; + return -1; + } + *out = grown; + } + (*out)[len++] = '\\'; + } + if (len + 1 >= cap) { + cap *= 2; + char *grown = realloc(*out, cap); + if (!grown) { + free(*out); + *out = NULL; + return -1; + } + *out = grown; + } + (*out)[len++] = in[i]; + } + (*out)[len++] = '"'; + (*out)[len] = '\0'; + return 0; +} + +static const char *find_section(const char *content, const char *section) +{ + char marker[128]; + size_t marker_len; + const char *p; + + if (!content || !section) + return NULL; + snprintf(marker, sizeof(marker), "[%s]", section); + marker_len = strlen(marker); + for (p = content; *p; p++) { + if (strncmp(p, marker, marker_len) != 0) + continue; + if (p != content && p[-1] != '\n') + continue; + if (p[marker_len] != '\0' && p[marker_len] != '\r' && p[marker_len] != '\n') + continue; + return p; + } + return NULL; +} + +static const char *section_end(const char *section_start) +{ + const char *p; + + if (!section_start) + return NULL; + p = strchr(section_start + 1, '\n'); + if (!p) + return section_start + strlen(section_start); + for (; *p; p++) { + if (*p == '[' && (p == section_start || p[-1] == '\n')) + return p; + } + return section_start + strlen(section_start); +} + +static const char *find_key_line(const char *section_start, const char *section_end, + const char *key, size_t *line_len) +{ + size_t key_len; + const char *p; + + if (!section_start || !section_end || !key || !line_len) + return NULL; + key_len = strlen(key); + for (p = section_start; p < section_end; p++) { + const char *line_end = strchr(p, '\n'); + size_t span; + + if (!line_end || line_end > section_end) + line_end = section_end; + span = (size_t)(line_end - p); + while (span > 0 && isspace((unsigned char)p[span - 1])) + span--; + if (span > key_len) { + const char *after_key = p + key_len; + while (after_key < line_end && + (*after_key == ' ' || *after_key == '\t')) + after_key++; + if (strncmp(p, key, key_len) == 0 && after_key < line_end && + *after_key == '=') { + *line_len = (size_t)(line_end - p); + if (*line_end == '\n') + (*line_len)++; + return p; + } + } + if (!*line_end) + break; + p = line_end; + } + return NULL; +} + +static int patch_key_line(char **content, size_t *len, size_t *cap, const char *section, + const char *key, const char *line_value) +{ + const char *sec; + const char *sec_end; + const char *line; + size_t line_len; + size_t prefix_len; + size_t suffix_len; + char *replacement; + size_t replacement_len; + char *next; + + if (!content || !*content || !len || !cap || !section || !key || !line_value) + return -1; + sec = find_section(*content, section); + if (!sec) { + return append_fmt(content, len, cap, "\n[%s]\n%s = %s\n", section, key, line_value); + } + sec_end = section_end(sec); + line = find_key_line(sec, sec_end, key, &line_len); + if (!line) { + const char *insert_at = sec_end; + size_t insert_off = (size_t)(insert_at - *content); + char insert_line[512]; + + snprintf(insert_line, sizeof(insert_line), "%s = %s\n", key, line_value); + replacement_len = strlen(insert_line); + next = malloc(*len + replacement_len + 1); + if (!next) + return -1; + memcpy(next, *content, insert_off); + memcpy(next + insert_off, insert_line, replacement_len); + memcpy(next + insert_off + replacement_len, *content + insert_off, + *len - insert_off + 1); + free(*content); + *content = next; + *len += replacement_len; + if (*len + 1 > *cap) + *cap = *len + 1; + return 0; + } + prefix_len = (size_t)(line - *content); + suffix_len = *len - prefix_len - line_len; + replacement_len = strlen(key) + strlen(line_value) + 8; + replacement = malloc(replacement_len); + if (!replacement) + return -1; + snprintf(replacement, replacement_len, "%s = %s\n", key, line_value); + replacement_len = strlen(replacement); + next = malloc(prefix_len + replacement_len + suffix_len + 1); + if (!next) { + free(replacement); + return -1; + } + memcpy(next, *content, prefix_len); + memcpy(next + prefix_len, replacement, replacement_len); + memcpy(next + prefix_len + replacement_len, *content + prefix_len + line_len, suffix_len + 1); + free(replacement); + free(*content); + *content = next; + *len = prefix_len + replacement_len + suffix_len; + if (*len + 1 > *cap) + *cap = *len + 1; + return 0; +} + +static int patch_string_field(char **content, size_t *len, size_t *cap, const char *section, + const char *key, const char *value) +{ + char *escaped; + + if (!value) + return 0; + if (escape_toml_string(value, &escaped) != 0) + return -1; + if (patch_key_line(content, len, cap, section, key, escaped) != 0) { + free(escaped); + return -1; + } + free(escaped); + return 0; +} + +static int patch_int_field(char **content, size_t *len, size_t *cap, const char *section, + const char *key, int value, int has_value) +{ + char buf[32]; + + if (!has_value) + return 0; + snprintf(buf, sizeof(buf), "%d", value); + return patch_key_line(content, len, cap, section, key, buf); +} + +static int patch_double_field(char **content, size_t *len, size_t *cap, const char *section, + const char *key, double value, int has_value) +{ + char buf[32]; + + if (!has_value) + return 0; + snprintf(buf, sizeof(buf), "%g", value); + return patch_key_line(content, len, cap, section, key, buf); +} + +int config_patch_dashboard_json(const char *config_path, const char *json_body, char **out_toml, + size_t *out_len, char *errbuf, size_t errbufsz) +{ + cJSON *root; + size_t cap; + char *content; + size_t len; + config_t *cfg = NULL; + char tmp_path[512]; + FILE *f; + + if (!config_path || !json_body || !out_toml || !out_len) { + PATCH_ERR(errbuf, errbufsz, "invalid arguments"); + return -1; + } + *out_toml = NULL; + *out_len = 0; + root = cJSON_Parse(json_body); + if (!root || !cJSON_IsObject(root)) { + cJSON_Delete(root); + PATCH_ERR(errbuf, errbufsz, "invalid JSON body"); + return -1; + } + content = read_file(config_path, &len, errbuf, errbufsz); + if (!content) { + cJSON_Delete(root); + return -1; + } + cap = len + 1; + { + cJSON *model = cJSON_GetObjectItem(root, "model"); + cJSON *max_tokens = cJSON_GetObjectItem(root, "max_tokens"); + cJSON *temperature = cJSON_GetObjectItem(root, "temperature"); + cJSON *gateway_host = cJSON_GetObjectItem(root, "gateway_host"); + cJSON *gateway_port = cJSON_GetObjectItem(root, "gateway_port"); + + if (model && cJSON_IsString(model) && + patch_string_field(&content, &len, &cap, "agent", "model", model->valuestring) != 0) + goto fail; + if (max_tokens && cJSON_IsNumber(max_tokens) && + patch_int_field(&content, &len, &cap, "agent", "max_tokens", max_tokens->valueint, + 1) != 0) + goto fail; + if (temperature && cJSON_IsNumber(temperature) && + patch_double_field(&content, &len, &cap, "agent", "temperature", + temperature->valuedouble, 1) != 0) + goto fail; + if (gateway_host && cJSON_IsString(gateway_host) && + patch_string_field(&content, &len, &cap, "gateway", "host", + gateway_host->valuestring) != 0) + goto fail; + if (gateway_port && cJSON_IsNumber(gateway_port) && + patch_int_field(&content, &len, &cap, "gateway", "port", gateway_port->valueint, 1) != + 0) + goto fail; + } + snprintf(tmp_path, sizeof(tmp_path), "%s.patch-test", config_path); + f = fopen(tmp_path, "w"); + if (!f) { + PATCH_ERR(errbuf, errbufsz, "failed to validate patched config"); + goto fail; + } + if (fwrite(content, 1, len, f) != len) { + fclose(f); + unlink(tmp_path); + PATCH_ERR(errbuf, errbufsz, "failed to validate patched config"); + goto fail; + } + fclose(f); + if (config_load(tmp_path, &cfg, errbuf, errbufsz) != 0) { + unlink(tmp_path); + goto fail; + } + config_free(cfg); + unlink(tmp_path); + *out_toml = content; + *out_len = len; + cJSON_Delete(root); + return 0; +fail: + config_free(cfg); + free(content); + cJSON_Delete(root); + return -1; +} diff --git a/src/core/config_patch.h b/src/core/config_patch.h new file mode 100644 index 0000000..893913c --- /dev/null +++ b/src/core/config_patch.h @@ -0,0 +1,37 @@ +/** + * @file config_patch.h + * @brief Patch on-disk TOML from dashboard JSON updates. + */ + +#ifndef SHELLCLAW_CONFIG_PATCH_H +#define SHELLCLAW_CONFIG_PATCH_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Merge dashboard JSON fields into an existing config.toml. + * + * Accepts JSON objects with optional keys: model, max_tokens, temperature, + * gateway_host, gateway_port. Unmentioned keys are left unchanged on disk. + * + * @param config_path Path to config.toml. + * @param json_body NUL-terminated JSON object body. + * @param out_toml On success, allocated patched TOML (caller frees). + * @param out_len Length of patched TOML. + * @param errbuf Optional error buffer. + * @param errbufsz Size of errbuf. + * @return 0 on success, non-zero on error. + */ +int config_patch_dashboard_json(const char *config_path, const char *json_body, + char **out_toml, size_t *out_len, char *errbuf, + size_t errbufsz); + +#ifdef __cplusplus +} +#endif + +#endif /* SHELLCLAW_CONFIG_PATCH_H */ diff --git a/src/gateway/routes.c b/src/gateway/routes.c index 514b60f..f238e25 100644 --- a/src/gateway/routes.c +++ b/src/gateway/routes.c @@ -12,8 +12,11 @@ #include "asap/envelope.h" #include "asap/server.h" #include "asap/log.h" +#include "core/bootstrap.h" #include "core/config.h" +#include "core/config_patch.h" #include "core/memory.h" +#include "core/reload.h" #include "core/skill.h" #include "providers/provider.h" #include "tools/context.h" @@ -196,9 +199,28 @@ static void handle_config_get(const config_t *cfg, char *buf, size_t size, int * } } +static int body_is_json(const char *body, size_t body_len) +{ + size_t i; + + for (i = 0; i < body_len; i++) { + unsigned char c = (unsigned char)body[i]; + if (c == ' ' || c == '\t' || c == '\r' || c == '\n') + continue; + return c == '{'; + } + return 0; +} + static void handle_config_put(http_server_ctx_t *ctx, const char *body, size_t body_len, char *buf, size_t size, int *status) { + char *patched_body = NULL; + size_t patched_len = 0; + char errbuf[256] = {0}; + const char *write_body = body; + size_t write_len = body_len; + if (!ctx->config_path || !body || body_len == 0) { json_error(buf, size, status, 400, "Bad request"); return; @@ -207,29 +229,51 @@ static void handle_config_put(http_server_ctx_t *ctx, const char *body, size_t b json_error(buf, size, status, 400, "Config too large"); return; } + if (body_is_json(body, body_len)) { + char json_nul[CONFIG_BODY_MAX + 1]; + if (body_len >= sizeof(json_nul)) { + json_error(buf, size, status, 400, "Config too large"); + return; + } + memcpy(json_nul, body, body_len); + json_nul[body_len] = '\0'; + if (config_patch_dashboard_json(ctx->config_path, json_nul, &patched_body, &patched_len, + errbuf, sizeof(errbuf)) != 0) { + json_error(buf, size, status, 400, errbuf[0] ? errbuf : "Invalid config patch"); + return; + } + write_body = patched_body; + write_len = patched_len; + } size_t path_len = strlen(ctx->config_path); char *tmp_path = malloc(path_len + 8); - if (!tmp_path) { json_error(buf, size, status, 500, "Out of memory"); return; } + if (!tmp_path) { + free(patched_body); + json_error(buf, size, status, 500, "Out of memory"); + return; + } snprintf(tmp_path, path_len + 8, "%s.tmp", ctx->config_path); FILE *f = fopen(tmp_path, "w"); if (!f) { free(tmp_path); + free(patched_body); json_error(buf, size, status, 500, "Failed to write config"); return; } - size_t written = fwrite(body, 1, body_len, f); + size_t written = fwrite(write_body, 1, write_len, f); fclose(f); - if (written != body_len) { + if (written != write_len) { unlink(tmp_path); free(tmp_path); + free(patched_body); json_error(buf, size, status, 500, "Failed to write config"); return; } config_t *cfg = NULL; - char errbuf[256] = {0}; if (config_load(tmp_path, &cfg, errbuf, sizeof(errbuf)) != 0) { unlink(tmp_path); free(tmp_path); + free(patched_body); json_error(buf, size, status, 400, errbuf[0] ? errbuf : "Invalid TOML"); return; } @@ -237,10 +281,17 @@ static void handle_config_put(http_server_ctx_t *ctx, const char *body, size_t b if (rename(tmp_path, ctx->config_path) != 0) { unlink(tmp_path); free(tmp_path); + free(patched_body); json_error(buf, size, status, 500, "Failed to save config"); return; } free(tmp_path); + free(patched_body); + { + config_t *live_cfg = bootstrap_get_cfg(); + if (live_cfg) + try_config_reload(&live_cfg); + } *status = 200; json_response(buf, size, status, "{\"ok\":true}"); } diff --git a/tests/test_config_patch.c b/tests/test_config_patch.c new file mode 100644 index 0000000..1f03aff --- /dev/null +++ b/tests/test_config_patch.c @@ -0,0 +1,89 @@ +/** + * @file test_config_patch.c + * @brief Unit tests for dashboard JSON config patching. + */ + +#include "test_runner.h" +#include "src/core/config.h" +#include "src/core/config_patch.h" +#include +#include +#include + +static int test_patch_model_and_temperature(void) +{ + char path[128]; + FILE *f; + char *patched = NULL; + size_t patched_len = 0; + char errbuf[256]; + config_t *cfg = NULL; + + ASSERT(test_runner_mkstemp_path("shellclaw_test_config_patch", path, sizeof(path)) == 0); + f = fopen(path, "w"); + ASSERT(f); + fprintf(f, + "[agent]\nmodel = \"old-model\"\nmax_tokens = 1024\ntemperature = 0.2\n" + "[gateway]\nhost = \"127.0.0.1\"\nport = 18789\n"); + fclose(f); + + ASSERT(config_patch_dashboard_json( + path, "{\"model\":\"new-model\",\"temperature\":0.9}", &patched, &patched_len, errbuf, + sizeof(errbuf)) == 0); + ASSERT(patched != NULL); + ASSERT(strstr(patched, "model = \"new-model\"") != NULL); + ASSERT(strstr(patched, "temperature = 0.9") != NULL); + ASSERT(strstr(patched, "max_tokens = 1024") != NULL); + + f = fopen(path, "w"); + ASSERT(f); + fwrite(patched, 1, patched_len, f); + fclose(f); + free(patched); + + ASSERT(config_load(path, &cfg, errbuf, sizeof(errbuf)) == 0); + ASSERT(strcmp(config_agent_model(cfg), "new-model") == 0); + ASSERT(config_agent_temperature(cfg) == 0.9); + ASSERT(config_agent_max_tokens(cfg) == 1024); + config_free(cfg); + remove(path); + return 0; +} + +static int test_patch_rejects_invalid_json(void) +{ + char path[128]; + FILE *f; + char *patched = NULL; + size_t patched_len = 0; + char errbuf[256]; + + ASSERT(test_runner_mkstemp_path("shellclaw_test_config_patch", path, sizeof(path)) == 0); + f = fopen(path, "w"); + ASSERT(f); + fprintf(f, "[agent]\nmodel = \"old-model\"\n"); + fclose(f); + + ASSERT(config_patch_dashboard_json(path, "not-json", &patched, &patched_len, errbuf, + sizeof(errbuf)) != 0); + ASSERT(patched == NULL); + remove(path); + return 0; +} + +int main(void) +{ + int failed = 0; + + if (test_patch_model_and_temperature() != 0) { + fprintf(stderr, "test_patch_model_and_temperature failed\n"); + failed++; + } + if (test_patch_rejects_invalid_json() != 0) { + fprintf(stderr, "test_patch_rejects_invalid_json failed\n"); + failed++; + } + if (failed == 0) + printf("test_config_patch: all tests passed\n"); + return failed; +} diff --git a/tests/test_gateway_http.c b/tests/test_gateway_http.c index 5769a73..9edc50b 100644 --- a/tests/test_gateway_http.c +++ b/tests/test_gateway_http.c @@ -206,6 +206,32 @@ static int http_delete_auth(const char *url, const char *bearer, long *code_out, return (res == CURLE_OK) ? 0 : -1; } +static int http_put_auth(const char *url, const char *bearer, const char *json, long *code_out, char **body_out) +{ + CURL *curl = curl_easy_init(); + if (!curl) return -1; + *body_out = NULL; + struct curl_slist *headers = NULL; + char auth_hdr[256]; + snprintf(auth_hdr, sizeof(auth_hdr), "Authorization: Bearer %s", bearer); + headers = curl_slist_append(headers, auth_hdr); + headers = curl_slist_append(headers, "Content-Type: application/json"); + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "PUT"); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, json); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, body_out); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 5L); + CURLcode res = curl_easy_perform(curl); + long code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &code); + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + if (code_out) *code_out = code; + return (res == CURLE_OK) ? 0 : -1; +} + static int read_pairing_code_from_file(const char *home, char *out, size_t out_sz) { char path[160]; @@ -457,6 +483,30 @@ static int test_api_config_get(const char *token) return 0; } +static int test_api_config_put_json(const char *token) +{ + long code; + char *body = NULL; + int r = http_put_auth(gw_url("/api/config"), token, + "{\"model\":\"patched-model\",\"max_tokens\":2048,\"temperature\":0.5," + "\"gateway_host\":\"127.0.0.1\",\"gateway_port\":18789}", + &code, &body); + ASSERT(r == 0); + ASSERT(code == 200); + ASSERT(body != NULL); + ASSERT(strstr(body, "\"ok\":true") != NULL); + free(body); + body = NULL; + r = http_get_auth(gw_url("/api/config"), token, &code, &body); + ASSERT(r == 0); + ASSERT(code == 200); + ASSERT(body != NULL); + ASSERT(strstr(body, "\"patched-model\"") != NULL); + ASSERT(strstr(body, "\"max_tokens\":2048") != NULL); + free(body); + return 0; +} + static int test_api_skills_list(const char *token) { long code; @@ -680,6 +730,7 @@ int main(int argc, char **argv) if (test_api_asap_log_401() != 0) { fprintf(stderr, "test_api_asap_log_401 failed\n"); failed++; } if (token[0]) { if (test_api_config_get(token) != 0) { fprintf(stderr, "test_api_config_get failed\n"); failed++; } + if (test_api_config_put_json(token) != 0) { fprintf(stderr, "test_api_config_put_json failed\n"); failed++; } if (test_api_status_get(token) != 0) { fprintf(stderr, "test_api_status_get failed\n"); failed++; } if (test_api_context_snapshot_get(token) != 0) { fprintf(stderr, "test_api_context_snapshot_get failed\n"); failed++; } if (test_api_skills_list(token) != 0) { fprintf(stderr, "test_api_skills_list failed\n"); failed++; }