From 3678a095ce208d770de96205fe989849b65dec44 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Mon, 21 Sep 2026 13:33:19 -0300 Subject: [PATCH 1/4] fix(gateway): apply dashboard JSON config and reload live settings Dashboard PUT /api/config sent JSON while the handler wrote raw TOML and never swapped live provider/gateway settings. Patch known fields into config.toml and reload so GET reflects the save immediately. Refs: #58 --- Makefile | 20 +- scripts/coverage.sh | 2 +- src/core/config_patch.c | 418 ++++++++++++++++++++++++++++++++++++++ src/core/config_patch.h | 39 ++++ src/core/main.c | 13 +- src/core/reload.c | 11 +- src/gateway/routes.c | 87 +++++++- tests/test_config_patch.c | 136 +++++++++++++ tests/test_gateway_http.c | 32 +++ tests/test_reload.c | 39 ++++ 10 files changed, 777 insertions(+), 20 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 44f10b4..afb29ba 100644 --- a/Makefile +++ b/Makefile @@ -67,6 +67,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 @@ -156,7 +157,7 @@ BOOTSTRAP_DISPATCH_STUB_O := tests/stubs/bootstrap_dispatch_stub.o TOOL_RELOAD_STUB_O := tests/stubs/tool_reload_stub.o RELOAD_CHANNEL_STUB_O := tests/stubs/reload_channel_stub.o HTTP_RELOAD_STUB_O := tests/stubs/http_reload_stub.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) @@ -197,6 +198,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/asap/manifest.h src/core/agent.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 @@ -364,7 +368,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/asap_http_body.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/routes_hardware.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/bootstrap.h src/core/agent.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 src/tools/tool.h +$(ROUTES_O): src/gateway/routes.c src/gateway/routes.h src/gateway/routes_hardware.h src/gateway/http.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/bootstrap.h src/core/agent.h src/core/config.h src/core/config_patch.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 src/tools/tool.h $(CC) $(CFLAGS) $(INC) $(GATEWAY_CFLAGS) -pthread -c -o $@ src/gateway/routes.c $(ROUTES_HARDWARE_O): src/gateway/routes_hardware.c src/gateway/routes_hardware.h src/gateway/routes.h src/gateway/http_lws.h src/gateway/uri_match.h src/hardware/hardware.h src/hardware/hardware_gpio_snapshot.h src/hardware/hardware_tegrastats.h src/hardware/board_detect.h src/core/config.h @@ -468,6 +472,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) @@ -822,8 +831,9 @@ static: --suppress=variableScope:src/vendor/tweetnacl/tweetnacl.c \ -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_reload test_channel test_cli test_shell test_file test_telegram test_discord_helpers test_web_search test_cron test_context test_dispatch test_crypto test_hardware_stub test_board_detect test_hardware_libgpiod test_hardware_i2c test_hardware_camera test_pin_tables test_hardware_init test_hardware_gpio_snapshot test_hardware_tegrastats test_hardware_tools test_registry test_ws test_manifest $(ASAP_UNIT_TESTS) test_sandbox test_allowlist test_rate_limit test_daemon_smoke test_bootstrap_keys test_update_script test_install_script test_download_model test_web_dashboard test_routes_hardware +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_reload test_channel test_cli test_shell test_file test_telegram test_discord_helpers test_web_search test_cron test_context test_dispatch test_crypto test_hardware_stub test_board_detect test_hardware_libgpiod test_hardware_i2c test_hardware_camera test_pin_tables test_hardware_init test_hardware_gpio_snapshot test_hardware_tegrastats test_hardware_tools test_registry test_ws test_manifest $(ASAP_UNIT_TESTS) test_sandbox test_allowlist test_rate_limit test_daemon_smoke test_bootstrap_keys test_update_script test_install_script test_download_model test_web_dashboard test_routes_hardware $(BINDIR)/test_config + $(BINDIR)/test_config_patch $(BINDIR)/test_memory $(BINDIR)/test_skill $(BINDIR)/test_provider @@ -877,7 +887,7 @@ COVERAGE_DIR := build/coverage COVERAGE_MIN := 80 coverage: clean - $(MAKE) BUILD=coverage GATEWAY=0 test_config test_memory test_skill test_provider test_anthropic test_openai test_local_provider test_router test_heartbeat test_agent test_reload test_channel test_cli test_shell test_file test_telegram test_discord_helpers test_web_search test_cron test_context test_dispatch test_crypto test_hardware_stub test_board_detect test_hardware_libgpiod test_hardware_i2c test_hardware_camera test_pin_tables test_hardware_init test_hardware_gpio_snapshot test_hardware_tegrastats test_hardware_tools test_registry test_ws test_manifest_build test_manifest_keys test_jcs $(ASAP_UNIT_TESTS) test_sandbox test_allowlist test_rate_limit test_auth + $(MAKE) BUILD=coverage GATEWAY=0 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_reload test_channel test_cli test_shell test_file test_telegram test_discord_helpers test_web_search test_cron test_context test_dispatch test_crypto test_hardware_stub test_board_detect test_hardware_libgpiod test_hardware_i2c test_hardware_camera test_pin_tables test_hardware_init test_hardware_gpio_snapshot test_hardware_tegrastats test_hardware_tools test_registry test_ws test_manifest_build test_manifest_keys test_jcs $(ASAP_UNIT_TESTS) test_sandbox test_allowlist test_rate_limit test_auth @if [ "$(GATEWAY)" = "1" ]; then $(MAKE) BUILD=coverage GATEWAY=1 shellclaw test_gateway_http test_static; fi @chmod +x scripts/coverage.sh @@ -892,6 +902,6 @@ clean: clean-root-dsym rm -f $(OBJS) $(PROVIDER_COMMON_O) $(STUB_O) $(ANTHROPIC_O) $(OPENAI_COMPAT_O) $(OPENAI_O) $(LOCAL_O) $(ROUTER_O) $(CJSON_O) $(TWEETNACL_O) $(ANTHROPIC_TEST_O) $(OPENAI_TEST_O) $(LOCAL_TEST_O) $(CONTEXT_TEST_OBJS) $(HEARTBEAT_TEST_O) $(CHANNEL_TG_TEST_O) $(CHANNEL_COMMON_O) $(CHANNEL_STUB_O) $(CHANNEL_CLI_O) $(CHANNEL_TG_O) $(CHANNEL_DISCORD_O) $(DISCORD_HELPERS_O) $(CHANNEL_HEARTBEAT_O) $(CHANNEL_WEBCHAT_O) $(AUTH_O) $(STATIC_O) $(HTTP_O) $(HTTP_LWS_O) $(ASAP_HTTP_BODY_O) $(ROUTES_O) $(ROUTES_HARDWARE_O) $(WS_O) $(MANIFEST_O) $(MANIFEST_PROFILES_O) $(MANIFEST_BUILD_O) $(MANIFEST_SIGN_O) $(MANIFEST_KEYS_O) $(ENVELOPE_O) $(ULID_O) $(CLIENT_O) $(ASAP_REGISTRY_O) $(SERVER_O) $(ASAP_LOG_O) $(RATE_LIMIT_O) $(SHELL_O) $(WEBSEARCH_O) $(FILE_O) $(REGISTRY_O) $(CONTEXT_O) $(CONTEXT_CACHE_O) $(CONTEXT_HTTP_O) $(CONTEXT_GEO_O) $(CRYPTO_O) $(JCS_O) $(HARDWARE_STUB_O) $(HARDWARE_INIT_O) $(HARDWARE_GPIO_SNAPSHOT_O) $(HARDWARE_TEGRASTATS_O) $(HARDWARE_TOOLS_O) $(BOARD_DETECT_O) src/hardware/hardware_libgpiod.o $(HARDWARE_I2C_O) $(HARDWARE_CAMERA_O) $(CRON_O) $(ASAP_INVOKE_O) $(SANDBOX_O) $(ALLOWLIST_O) rm -f src/gateway/ui_assets.h find . -name '*.gcno' -o -name '*.gcda' -o -name '*.gcov' | xargs rm -f 2>/dev/null || true - rm -f $(WS_TEST_O) $(BINDIR)/asap_registry_test.o $(BINDIR)/asap_invoke_test.o $(CONTEXT_TEST_OBJS) $(HEARTBEAT_TEST_O) $(BINDIR)/shellclaw $(BINDIR)/test_tweetnacl_smoke $(BINDIR)/test_config $(BINDIR)/test_memory $(BINDIR)/test_skill $(BINDIR)/test_provider $(BINDIR)/test_anthropic $(BINDIR)/test_openai $(BINDIR)/test_local_provider $(BINDIR)/test_router $(BINDIR)/test_heartbeat $(BINDIR)/test_crypto $(BINDIR)/test_hardware_stub $(BINDIR)/test_board_detect $(BINDIR)/test_hardware_libgpiod $(BINDIR)/test_hardware_i2c $(BINDIR)/test_hardware_camera $(BINDIR)/test_pin_tables $(BINDIR)/test_hardware_init $(BINDIR)/test_hardware_tools $(BINDIR)/test_registry $(BINDIR)/test_ws $(BINDIR)/test_agent $(BINDIR)/test_channel $(BINDIR)/test_cli $(BINDIR)/test_shell $(BINDIR)/test_file $(BINDIR)/test_telegram $(BINDIR)/test_discord_helpers $(BINDIR)/test_web_search $(BINDIR)/test_cron $(BINDIR)/test_context $(BINDIR)/test_manifest_build $(BINDIR)/test_manifest_keys $(BINDIR)/test_jcs $(BINDIR)/test_asap_envelope $(BINDIR)/test_asap_ulid $(BINDIR)/test_asap_client $(BINDIR)/test_asap_registry $(BINDIR)/test_asap_server $(BINDIR)/test_asap_invoke $(BINDIR)/test_asap_log $(BINDIR)/test_auth $(BINDIR)/test_gateway_http $(BINDIR)/test_static $(BINDIR)/test_sandbox $(BINDIR)/test_allowlist $(BINDIR)/test_rate_limit + rm -f $(WS_TEST_O) $(BINDIR)/asap_registry_test.o $(BINDIR)/asap_invoke_test.o $(CONTEXT_TEST_OBJS) $(HEARTBEAT_TEST_O) $(BINDIR)/shellclaw $(BINDIR)/test_tweetnacl_smoke $(BINDIR)/test_config $(BINDIR)/test_config_patch $(BINDIR)/test_memory $(BINDIR)/test_skill $(BINDIR)/test_provider $(BINDIR)/test_anthropic $(BINDIR)/test_openai $(BINDIR)/test_local_provider $(BINDIR)/test_router $(BINDIR)/test_heartbeat $(BINDIR)/test_crypto $(BINDIR)/test_hardware_stub $(BINDIR)/test_board_detect $(BINDIR)/test_hardware_libgpiod $(BINDIR)/test_hardware_i2c $(BINDIR)/test_hardware_camera $(BINDIR)/test_pin_tables $(BINDIR)/test_hardware_init $(BINDIR)/test_hardware_tools $(BINDIR)/test_registry $(BINDIR)/test_ws $(BINDIR)/test_agent $(BINDIR)/test_channel $(BINDIR)/test_cli $(BINDIR)/test_shell $(BINDIR)/test_file $(BINDIR)/test_telegram $(BINDIR)/test_discord_helpers $(BINDIR)/test_web_search $(BINDIR)/test_cron $(BINDIR)/test_context $(BINDIR)/test_manifest_build $(BINDIR)/test_manifest_keys $(BINDIR)/test_jcs $(BINDIR)/test_asap_envelope $(BINDIR)/test_asap_ulid $(BINDIR)/test_asap_client $(BINDIR)/test_asap_registry $(BINDIR)/test_asap_server $(BINDIR)/test_asap_invoke $(BINDIR)/test_asap_log $(BINDIR)/test_auth $(BINDIR)/test_gateway_http $(BINDIR)/test_static $(BINDIR)/test_sandbox $(BINDIR)/test_allowlist $(BINDIR)/test_rate_limit rm -rf $(BINDIR)/*.dSYM $(DSYMDIR) rm -f $(BOOTSTRAP_DISPATCH_STUB_O) $(TOOL_RELOAD_STUB_O) $(RELOAD_CHANNEL_STUB_O) $(HTTP_RELOAD_STUB_O) diff --git a/scripts/coverage.sh b/scripts/coverage.sh index d2b5487..816dfae 100755 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -13,7 +13,7 @@ LCOV_RC="lcov_branch_coverage=0" mkdir -p "$COVERAGE_DIR" rm -f "$COVERAGE_DIR"/*.info -TESTS="test_config test_memory test_skill test_provider test_anthropic test_openai test_local_provider test_router test_heartbeat test_agent test_reload test_channel test_cli test_shell test_file test_telegram test_discord_helpers test_web_search test_cron test_context test_dispatch test_crypto test_hardware_stub test_board_detect test_hardware_libgpiod test_hardware_i2c test_hardware_camera test_pin_tables test_hardware_init test_hardware_tools test_registry test_ws test_manifest_build test_manifest_keys test_jcs test_asap_envelope test_asap_ulid test_asap_client test_asap_registry test_asap_server test_asap_invoke test_asap_log test_sandbox test_allowlist test_rate_limit test_auth test_static" +TESTS="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_reload test_channel test_cli test_shell test_file test_telegram test_discord_helpers test_web_search test_cron test_context test_dispatch test_crypto test_hardware_stub test_board_detect test_hardware_libgpiod test_hardware_i2c test_hardware_camera test_pin_tables test_hardware_init test_hardware_tools test_registry test_ws test_manifest_build test_manifest_keys test_jcs test_asap_envelope test_asap_ulid test_asap_client test_asap_registry test_asap_server test_asap_invoke test_asap_log test_sandbox test_allowlist test_rate_limit test_auth test_static" # ASAP tests must stay aligned with ASAP_UNIT_TESTS in the top-level Makefile. if [ "${GATEWAY:-}" = "1" ]; then TESTS="$TESTS test_gateway_http" diff --git a/src/core/config_patch.c b/src/core/config_patch.c new file mode 100644 index 0000000..dc0943a --- /dev/null +++ b/src/core/config_patch.c @@ -0,0 +1,418 @@ +/** + * @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 + +#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 buf_reserve(char **buf, size_t *len, size_t *cap, size_t extra) +{ + size_t need; + size_t new_cap; + char *grown; + if (!buf || !*buf || !len || !cap) + return -1; + need = *len + extra + 1; + if (need <= *cap) + return 0; + new_cap = (*cap == 0) ? need : *cap; + while (new_cap < need) { + if (new_cap > ((size_t)-1) / 2) + return -1; + new_cap *= 2; + } + grown = realloc(*buf, new_cap); + if (!grown) + return -1; + *buf = grown; + *cap = new_cap; + return 0; +} + +static int append_section_key(char **buf, size_t *len, size_t *cap, const char *section, + const char *key, const char *line_value) +{ + char extra[768]; + int n; + if (!buf || !*buf || !len || !cap || !section || !key || !line_value) + return -1; + n = snprintf(extra, sizeof(extra), "\n[%s]\n%s = %s\n", section, key, line_value); + if (n < 0 || (size_t)n >= sizeof(extra)) + return -1; + if (buf_reserve(buf, len, cap, (size_t)n) != 0) + return -1; + memcpy(*buf + *len, extra, (size_t)n); + *len += (size_t)n; + (*buf)[*len] = '\0'; + 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) { + char *grown; + cap *= 2; + grown = realloc(*out, cap); + if (!grown) { + free(*out); + *out = NULL; + return -1; + } + *out = grown; + } + (*out)[len++] = '\\'; + } + if (len + 1 >= cap) { + char *grown; + cap *= 2; + 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 *sec_start, const char *sec_end, + const char *key, size_t *line_len) +{ + size_t key_len; + const char *p; + if (!sec_start || !sec_end || !key || !line_len) + return NULL; + key_len = strlen(key); + for (p = sec_start; p < sec_end; p++) { + const char *line_end = strchr(p, '\n'); + size_t span; + if (!line_end || line_end > sec_end) + line_end = sec_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 splice_text(char **content, size_t *len, size_t *cap, size_t off, + size_t old_len, const char *insert, size_t insert_len) +{ + size_t suffix_len; + char *next; + if (!content || !*content || !len || !cap || !insert) + return -1; + if (off > *len || old_len > *len - off) + return -1; + suffix_len = *len - off - old_len; + next = malloc(off + insert_len + suffix_len + 1); + if (!next) + return -1; + memcpy(next, *content, off); + memcpy(next + off, insert, insert_len); + memcpy(next + off + insert_len, *content + off + old_len, suffix_len + 1); + free(*content); + *content = next; + *len = off + insert_len + suffix_len; + if (*len + 1 > *cap) + *cap = *len + 1; + return 0; +} + +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; + char insert_line[512]; + int n; + if (!content || !*content || !len || !cap || !section || !key || !line_value) + return -1; + n = snprintf(insert_line, sizeof(insert_line), "%s = %s\n", key, line_value); + if (n < 0 || (size_t)n >= sizeof(insert_line)) + return -1; + sec = find_section(*content, section); + if (!sec) + return append_section_key(content, len, cap, section, key, line_value); + sec_end = section_end(sec); + line = find_key_line(sec, sec_end, key, &line_len); + if (!line) { + return splice_text(content, len, cap, (size_t)(sec_end - *content), 0, + insert_line, (size_t)n); + } + return splice_text(content, len, cap, (size_t)(line - *content), line_len, + insert_line, (size_t)n); +} + +static int patch_string_field(char **content, size_t *len, size_t *cap, const char *section, + const char *key, const char *value) +{ + char *escaped; + int rc; + if (!value) + return 0; + if (escape_toml_string(value, &escaped) != 0) + return -1; + rc = patch_key_line(content, len, cap, section, key, escaped); + free(escaped); + return rc; +} + +static int patch_int_field(char **content, size_t *len, size_t *cap, const char *section, + const char *key, int value) +{ + char buf[32]; + 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) +{ + char buf[32]; + snprintf(buf, sizeof(buf), "%g", value); + return patch_key_line(content, len, cap, section, key, buf); +} + +static int apply_dashboard_fields(cJSON *root, char **content, size_t *len, size_t *cap) +{ + 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) + return -1; + if (max_tokens && cJSON_IsNumber(max_tokens) && + patch_int_field(content, len, cap, "agent", "max_tokens", max_tokens->valueint) != 0) + return -1; + if (temperature && cJSON_IsNumber(temperature) && + patch_double_field(content, len, cap, "agent", "temperature", + temperature->valuedouble) != 0) + return -1; + if (gateway_host && cJSON_IsString(gateway_host) && + patch_string_field(content, len, cap, "gateway", "host", + gateway_host->valuestring) != 0) + return -1; + if (gateway_port && cJSON_IsNumber(gateway_port) && + patch_int_field(content, len, cap, "gateway", "port", gateway_port->valueint) != 0) + return -1; + return 0; +} + +static int validate_patched_toml(const char *config_path, const char *content, size_t len, + char *errbuf, size_t errbufsz) +{ + size_t path_len; + char *tmp_path; + FILE *f; + config_t *cfg = NULL; + path_len = strlen(config_path); + tmp_path = malloc(path_len + 16); + if (!tmp_path) { + PATCH_ERR(errbuf, errbufsz, "out of memory"); + return -1; + } + snprintf(tmp_path, path_len + 16, "%s.patch-test", config_path); + f = fopen(tmp_path, "w"); + if (!f) { + PATCH_ERR(errbuf, errbufsz, "failed to validate patched config"); + free(tmp_path); + return -1; + } + if (fwrite(content, 1, len, f) != len) { + fclose(f); + unlink(tmp_path); + free(tmp_path); + PATCH_ERR(errbuf, errbufsz, "failed to validate patched config"); + return -1; + } + fclose(f); + if (config_load(tmp_path, &cfg, errbuf, errbufsz) != 0) { + unlink(tmp_path); + free(tmp_path); + return -1; + } + config_free(cfg); + unlink(tmp_path); + free(tmp_path); + return 0; +} + +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; + 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; + if (apply_dashboard_fields(root, &content, &len, &cap) != 0) { + PATCH_ERR(errbuf, errbufsz, "failed to patch config fields"); + free(content); + cJSON_Delete(root); + return -1; + } + if (validate_patched_toml(config_path, content, len, errbuf, errbufsz) != 0) { + free(content); + cJSON_Delete(root); + return -1; + } + *out_toml = content; + *out_len = len; + cJSON_Delete(root); + return 0; +} diff --git a/src/core/config_patch.h b/src/core/config_patch.h new file mode 100644 index 0000000..5529580 --- /dev/null +++ b/src/core/config_patch.h @@ -0,0 +1,39 @@ +/** + * @file config_patch.h + * @brief Patch on-disk TOML from dashboard JSON updates. + * + * Example: config_patch_dashboard_json(path, "{\"model\":\"x\"}", &toml, &len, err, sizeof(err)); + */ + +#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/core/main.c b/src/core/main.c index 5d7fa2c..a9ac570 100644 --- a/src/core/main.c +++ b/src/core/main.c @@ -197,10 +197,13 @@ int main(int argc, char **argv) return 1; } main_loop(g_cli_one_shot != NULL, &cfg); - cleanup_subsystems(); - curl_global_cleanup(); - daemon_pid_cleanup(); - stale_free_all(); - config_free(cfg); + { + config_t *live = bootstrap_get_cfg(); + cleanup_subsystems(); + curl_global_cleanup(); + daemon_pid_cleanup(); + stale_free_all(); + config_free(live); + } return 0; } diff --git a/src/core/reload.c b/src/core/reload.c index 0eb6e14..1520a2a 100644 --- a/src/core/reload.c +++ b/src/core/reload.c @@ -61,18 +61,27 @@ void stale_free_all(void) void try_config_reload(config_t **pcfg) { + config_t *old; config_t *new_cfg; char errbuf[256]; const char *config_path = bootstrap_get_config_path(); if (!pcfg || !*pcfg || !config_path) return; + /* Dashboard PUT may reload from the HTTP thread while main still holds the + * previous pointer. Always enqueue the live bootstrap cfg, not the caller's + * possibly-stale copy, so a later SIGHUP does not double-free. */ + old = bootstrap_get_cfg(); + if (!old) + old = *pcfg; + if (!old) + return; new_cfg = NULL; if (config_load(config_path, &new_cfg, errbuf, sizeof(errbuf)) != 0) { fprintf(stderr, "shellclaw: SIGHUP config reload failed: %s\n", errbuf[0] ? errbuf : "unknown error"); return; } - if (stale_enqueue(*pcfg) != 0) { + if (stale_enqueue(old) != 0) { fprintf(stderr, "shellclaw: SIGHUP config reload failed: out of memory\n"); config_free(new_cfg); return; diff --git a/src/gateway/routes.c b/src/gateway/routes.c index 100d066..a59936f 100644 --- a/src/gateway/routes.c +++ b/src/gateway/routes.c @@ -6,6 +6,7 @@ #include "gateway/routes.h" #include "gateway/routes_hardware.h" +#include "gateway/http.h" #include "gateway/auth.h" #include "gateway/rate_limit.h" #include "channels/channel.h" @@ -16,7 +17,9 @@ #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" @@ -187,9 +190,54 @@ static void handle_config_get(const config_t *cfg, char *buf, size_t size, int * } } +static int body_is_json_object(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 int config_put_patch_json(http_server_ctx_t *ctx, const char *body, size_t body_len, + char **out_toml, size_t *out_len, char *buf, size_t size, + int *status) +{ + char *json_nul; + char errbuf[256] = {0}; + json_nul = malloc(body_len + 1); + if (!json_nul) { + json_error(buf, size, status, 500, "Out of memory"); + return -1; + } + memcpy(json_nul, body, body_len); + json_nul[body_len] = '\0'; + if (config_patch_dashboard_json(ctx->config_path, json_nul, out_toml, out_len, errbuf, + sizeof(errbuf)) != 0) { + free(json_nul); + json_error(buf, size, status, 400, errbuf[0] ? errbuf : "Invalid config patch"); + return -1; + } + free(json_nul); + 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; + size_t path_len; + char *tmp_path; + FILE *f; + size_t written; + config_t *cfg = NULL; if (!ctx->config_path || !body || body_len == 0) { json_error(buf, size, status, 400, "Bad request"); return; @@ -198,29 +246,41 @@ 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; } - 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 (body_is_json_object(body, body_len)) { + if (config_put_patch_json(ctx, body, body_len, &patched_body, &patched_len, buf, size, + status) != 0) + return; + write_body = patched_body; + write_len = patched_len; + } + path_len = strlen(ctx->config_path); + tmp_path = malloc(path_len + 8); + 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"); + 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); + 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; } @@ -228,10 +288,21 @@ 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); + /* Dashboard/TOML save: swap live cfg now instead of waiting for SIGHUP. + * Call http_set_live_config here: test_reload rebuilds reload.o with + * GATEWAY=0, so try_config_reload may omit the gateway pointer swap. */ + { + config_t *live_cfg = bootstrap_get_cfg(); + if (live_cfg) + try_config_reload(&live_cfg); + http_set_live_config(bootstrap_get_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..95e2778 --- /dev/null +++ b/tests/test_config_patch.c @@ -0,0 +1,136 @@ +/** + * @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 write_toml(const char *path, const char *toml) +{ + FILE *f = fopen(path, "w"); + if (!f) + return -1; + fputs(toml, f); + fclose(f); + return 0; +} + +static int test_patch_model_and_temperature(void) +{ + char path[128]; + 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); + ASSERT(write_toml(path, + "[agent]\nmodel = \"old-model\"\nmax_tokens = 1024\ntemperature = 0.2\n" + "[gateway]\nhost = \"127.0.0.1\"\nport = 18789\n") == 0); + 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); + ASSERT(write_toml(path, patched) == 0); + 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_inserts_missing_key(void) +{ + char path[128]; + 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); + ASSERT(write_toml(path, "[agent]\nmodel = \"old-model\"\n") == 0); + ASSERT(config_patch_dashboard_json(path, "{\"max_tokens\":2048}", &patched, &patched_len, + errbuf, sizeof(errbuf)) == 0); + ASSERT(patched != NULL); + ASSERT(strstr(patched, "max_tokens = 2048") != NULL); + ASSERT(strstr(patched, "model = \"old-model\"") != NULL); + ASSERT(write_toml(path, patched) == 0); + free(patched); + ASSERT(config_load(path, &cfg, errbuf, sizeof(errbuf)) == 0); + ASSERT(config_agent_max_tokens(cfg) == 2048); + config_free(cfg); + remove(path); + return 0; +} + +static int test_patch_rejects_invalid_json(void) +{ + char path[128]; + char *patched = NULL; + size_t patched_len = 0; + char errbuf[256]; + ASSERT(test_runner_mkstemp_path("shellclaw_test_config_patch", path, sizeof(path)) == 0); + ASSERT(write_toml(path, "[agent]\nmodel = \"old-model\"\n") == 0); + ASSERT(config_patch_dashboard_json(path, "not-json", &patched, &patched_len, errbuf, + sizeof(errbuf)) != 0); + ASSERT(patched == NULL); + remove(path); + return 0; +} + +static int test_patch_creates_missing_section(void) +{ + char path[128]; + 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); + ASSERT(write_toml(path, "[agent]\nmodel = \"old-model\"\n") == 0); + ASSERT(config_patch_dashboard_json(path, "{\"gateway_host\":\"10.0.0.1\",\"gateway_port\":19000}", + &patched, &patched_len, errbuf, sizeof(errbuf)) == 0); + ASSERT(patched != NULL); + ASSERT(strstr(patched, "[gateway]") != NULL); + ASSERT(strstr(patched, "host = \"10.0.0.1\"") != NULL); + ASSERT(write_toml(path, patched) == 0); + free(patched); + ASSERT(config_load(path, &cfg, errbuf, sizeof(errbuf)) == 0); + ASSERT(strcmp(config_gateway_host(cfg), "10.0.0.1") == 0); + ASSERT(config_gateway_port(cfg) == 19000); + config_free(cfg); + 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_inserts_missing_key() != 0) { + fprintf(stderr, "test_patch_inserts_missing_key failed\n"); + failed++; + } + if (test_patch_rejects_invalid_json() != 0) { + fprintf(stderr, "test_patch_rejects_invalid_json failed\n"); + failed++; + } + if (test_patch_creates_missing_section() != 0) { + fprintf(stderr, "test_patch_creates_missing_section 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 2bb9456..08d5617 100644 --- a/tests/test_gateway_http.c +++ b/tests/test_gateway_http.c @@ -1058,6 +1058,34 @@ static int test_api_config_put_valid(const char *token, int port, const char *co return 0; } +static int test_api_config_put_json(const char *token, int port) +{ + long code; + char *body = NULL; + char json[256]; + int r; + snprintf(json, sizeof(json), + "{\"model\":\"patched-model\",\"max_tokens\":2048,\"temperature\":0.5," + "\"gateway_host\":\"127.0.0.1\",\"gateway_port\":%d}", + port); + r = http_put_auth(gw_url("/api/config"), token, json, &code, &body); + ASSERT(r == 0); + ASSERT(code == 200); + ASSERT(body != NULL); + ASSERT(strstr(body, "\"ok\":true") != NULL || 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 || + strstr(body, "\"max_tokens\": 2048") != NULL); + free(body); + return 0; +} + static int test_api_skills_list(const char *token) { long code; @@ -1608,6 +1636,10 @@ int main(int argc, char **argv) fprintf(stderr, "test_api_config_put_valid failed\n"); failed++; } + if (test_api_config_put_json(token, port) != 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++; } diff --git a/tests/test_reload.c b/tests/test_reload.c index d3ff4f3..6ced210 100644 --- a/tests/test_reload.c +++ b/tests/test_reload.c @@ -174,6 +174,44 @@ static int test_try_config_reload_null_args_noop(void) return 0; } +static int test_try_config_reload_ignores_stale_caller_pointer(void) +{ + char path[128]; + config_t *main_ptr = NULL; + config_t *http_ptr = NULL; + ASSERT(test_runner_mkstemp_path("shellclaw_test_reload", path, sizeof(path)) == 0); + main_ptr = load_minimal_config(path, "gen0"); + ASSERT(main_ptr != NULL); + bootstrap_set_config_path(path); + bootstrap_set_cfg(main_ptr); + http_ptr = bootstrap_get_cfg(); + { + FILE *f = fopen(path, "w"); + ASSERT(f); + fprintf(f, "[agent]\nmodel = \"gen1\"\n"); + fclose(f); + } + try_config_reload(&http_ptr); + ASSERT(http_ptr != NULL); + ASSERT(strcmp(config_agent_model(http_ptr), "gen1") == 0); + ASSERT(main_ptr != http_ptr); + { + FILE *f = fopen(path, "w"); + ASSERT(f); + fprintf(f, "[agent]\nmodel = \"gen2\"\n"); + fclose(f); + } + try_config_reload(&main_ptr); + ASSERT(main_ptr != NULL); + ASSERT(strcmp(config_agent_model(main_ptr), "gen2") == 0); + ASSERT(main_ptr == bootstrap_get_cfg()); + stale_free_all(); + config_free(main_ptr); + bootstrap_set_cfg(NULL); + remove(path); + return 0; +} + int main(void) { RUN(test_on_hup_sets_reload_flag()); @@ -183,6 +221,7 @@ int main(void) RUN(test_try_config_reload_swaps_live_config()); RUN(test_try_config_reload_keeps_old_on_invalid_file()); RUN(test_try_config_reload_null_args_noop()); + RUN(test_try_config_reload_ignores_stale_caller_pointer()); printf("test_reload: all tests passed\n"); return 0; } From b8f50eb7c1ab30ae91c4366eee71a7d28514c0f2 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Mon, 21 Sep 2026 13:33:19 -0300 Subject: [PATCH 2/4] test(gateway): cover oversized PUT /api/config 413 Transport already rejects bodies over 64 KiB before routes run; keep that boundary under test so dashboard saves cannot truncate config. Refs: #56 --- tests/test_gateway_http.c | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/test_gateway_http.c b/tests/test_gateway_http.c index 08d5617..09c34fc 100644 --- a/tests/test_gateway_http.c +++ b/tests/test_gateway_http.c @@ -1029,6 +1029,27 @@ static int test_api_config_put_invalid_toml(const char *token) return 0; } +static int test_api_config_put_rejects_oversized_body(const char *token) +{ + long code; + char *body = NULL; + char *huge; + size_t n = 70000; + int r; + huge = malloc(n + 1); + ASSERT(huge != NULL); + memset(huge, 'a', n); + huge[n] = '\0'; + r = http_put_auth(gw_url("/api/config"), token, huge, &code, &body); + free(huge); + ASSERT(r == 0); + ASSERT(code == 413); + ASSERT(body != NULL); + ASSERT(strstr(body, "large") != NULL || strstr(body, "error") != NULL); + free(body); + return 0; +} + static int test_api_config_put_valid(const char *token, int port, const char *config_path) { long code; @@ -1632,6 +1653,10 @@ int main(int argc, char **argv) fprintf(stderr, "test_api_config_put_invalid_toml failed\n"); failed++; } + if (test_api_config_put_rejects_oversized_body(token) != 0) { + fprintf(stderr, "test_api_config_put_rejects_oversized_body failed\n"); + failed++; + } if (test_api_config_put_valid(token, port, config_path) != 0) { fprintf(stderr, "test_api_config_put_valid failed\n"); failed++; From c0c7d82bc165c7754d1c14bb477fecb257933f00 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Tue, 22 Sep 2026 13:17:20 -0300 Subject: [PATCH 3/4] fix(config): patch indented TOML and reject mistyped dashboard JSON Formatter-indented keys and commented section headers were missed, so a dashboard save inserted a duplicate key and tomlc99 returned 400. A present field with the wrong JSON type now fails instead of a 200 no-op, and string values escape quotes and newlines. --- src/core/config_patch.c | 82 +++++++++++++++++++++++++++++++-------- tests/test_config_patch.c | 80 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 16 deletions(-) diff --git a/src/core/config_patch.c b/src/core/config_patch.c index dc0943a..38b9684 100644 --- a/src/core/config_patch.c +++ b/src/core/config_patch.c @@ -124,7 +124,16 @@ static int escape_toml_string(const char *in, char **out) (*out)[0] = '"'; len = 1; for (i = 0; in[i]; i++) { - if (in[i] == '"' || in[i] == '\\') { + char extra = 0; + if (in[i] == '"' || in[i] == '\\') + extra = in[i]; + else if (in[i] == '\n') + extra = 'n'; + else if (in[i] == '\r') + extra = 'r'; + else if (in[i] == '\t') + extra = 't'; + if (extra != 0) { if (len + 2 >= cap) { char *grown; cap *= 2; @@ -137,6 +146,8 @@ static int escape_toml_string(const char *in, char **out) *out = grown; } (*out)[len++] = '\\'; + (*out)[len++] = extra; + continue; } if (len + 1 >= cap) { char *grown; @@ -156,6 +167,17 @@ static int escape_toml_string(const char *in, char **out) return 0; } +static int section_header_closed(const char *after) +{ + if (!after) + return 0; + while (*after == ' ' || *after == '\t') + after++; + if (*after == '#' || *after == '\0' || *after == '\r' || *after == '\n') + return 1; + return 0; +} + static const char *find_section(const char *content, const char *section) { char marker[128]; @@ -170,7 +192,7 @@ static const char *find_section(const char *content, const char *section) continue; if (p != content && p[-1] != '\n') continue; - if (p[marker_len] != '\0' && p[marker_len] != '\r' && p[marker_len] != '\n') + if (!section_header_closed(p + marker_len)) continue; return p; } @@ -202,18 +224,22 @@ static const char *find_key_line(const char *sec_start, const char *sec_end, key_len = strlen(key); for (p = sec_start; p < sec_end; p++) { const char *line_end = strchr(p, '\n'); - size_t span; if (!line_end || line_end > sec_end) line_end = sec_end; - span = (size_t)(line_end - p); - while (span > 0 && isspace((unsigned char)p[span - 1])) - span--; + { + const char *key_at = p; + size_t span; + while (key_at < line_end && (*key_at == ' ' || *key_at == '\t')) + key_at++; + span = (size_t)(line_end - key_at); + while (span > 0 && isspace((unsigned char)key_at[span - 1])) + span--; if (span > key_len) { - const char *after_key = p + key_len; + const char *after_key = key_at + 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 && + if (strncmp(key_at, key, key_len) == 0 && after_key < line_end && *after_key == '=') { *line_len = (size_t)(line_end - p); if (*line_end == '\n') @@ -221,6 +247,7 @@ static const char *find_key_line(const char *sec_start, const char *sec_end, return p; } } + } if (!*line_end) break; p = line_end; @@ -309,28 +336,50 @@ static int patch_double_field(char **content, size_t *len, size_t *cap, const ch return patch_key_line(content, len, cap, section, key, buf); } -static int apply_dashboard_fields(cJSON *root, char **content, size_t *len, size_t *cap) +static int reject_wrong_type(const cJSON *item, int expect_string, const char *field, + char *errbuf, size_t errbufsz) +{ + int ok; + if (!item) + return 0; + ok = expect_string ? cJSON_IsString(item) : cJSON_IsNumber(item); + if (ok) + return 0; + if (errbuf && errbufsz > 0) + snprintf(errbuf, errbufsz, "field \"%s\" must be a JSON %s", field, + expect_string ? "string" : "number"); + return -1; +} + +static int apply_dashboard_fields(cJSON *root, char **content, size_t *len, size_t *cap, + char *errbuf, size_t errbufsz) { 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) && + if (reject_wrong_type(model, 1, "model", errbuf, errbufsz) != 0 || + reject_wrong_type(max_tokens, 0, "max_tokens", errbuf, errbufsz) != 0 || + reject_wrong_type(temperature, 0, "temperature", errbuf, errbufsz) != 0 || + reject_wrong_type(gateway_host, 1, "gateway_host", errbuf, errbufsz) != 0 || + reject_wrong_type(gateway_port, 0, "gateway_port", errbuf, errbufsz) != 0) + return -1; + if (model && patch_string_field(content, len, cap, "agent", "model", model->valuestring) != 0) return -1; - if (max_tokens && cJSON_IsNumber(max_tokens) && + if (max_tokens && patch_int_field(content, len, cap, "agent", "max_tokens", max_tokens->valueint) != 0) return -1; - if (temperature && cJSON_IsNumber(temperature) && + if (temperature && patch_double_field(content, len, cap, "agent", "temperature", temperature->valuedouble) != 0) return -1; - if (gateway_host && cJSON_IsString(gateway_host) && + if (gateway_host && patch_string_field(content, len, cap, "gateway", "host", gateway_host->valuestring) != 0) return -1; - if (gateway_port && cJSON_IsNumber(gateway_port) && + if (gateway_port && patch_int_field(content, len, cap, "gateway", "port", gateway_port->valueint) != 0) return -1; return 0; @@ -400,8 +449,9 @@ int config_patch_dashboard_json(const char *config_path, const char *json_body, return -1; } cap = len + 1; - if (apply_dashboard_fields(root, &content, &len, &cap) != 0) { - PATCH_ERR(errbuf, errbufsz, "failed to patch config fields"); + if (apply_dashboard_fields(root, &content, &len, &cap, errbuf, errbufsz) != 0) { + if (!errbuf || errbufsz == 0 || errbuf[0] == '\0') + PATCH_ERR(errbuf, errbufsz, "failed to patch config fields"); free(content); cJSON_Delete(root); return -1; diff --git a/tests/test_config_patch.c b/tests/test_config_patch.c index 95e2778..315202e 100644 --- a/tests/test_config_patch.c +++ b/tests/test_config_patch.c @@ -87,6 +87,74 @@ static int test_patch_rejects_invalid_json(void) return 0; } +static int test_patch_indented_key_and_commented_section(void) +{ + char path[128]; + 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); + ASSERT(write_toml(path, + "[agent] # live\n model = \"old\"\n" + "[gateway]\n host = \"127.0.0.1\"\n port = 18789\n") == 0); + ASSERT(config_patch_dashboard_json(path, "{\"model\":\"new\",\"gateway_port\":19000}", + &patched, &patched_len, errbuf, sizeof(errbuf)) == 0); + ASSERT(patched != NULL); + ASSERT(strstr(patched, "model = \"new\"") != NULL); + ASSERT(strstr(patched, "model = \"old\"") == NULL); + ASSERT(strstr(patched, "port = 19000") != NULL); + ASSERT(strstr(patched, "[agent]") != NULL); + /* One [agent] header, not a duplicate appended after a missed comment. */ + ASSERT(strstr(strstr(patched, "[agent]") + 1, "[agent]") == NULL); + ASSERT(write_toml(path, patched) == 0); + free(patched); + ASSERT(config_load(path, &cfg, errbuf, sizeof(errbuf)) == 0); + ASSERT(strcmp(config_agent_model(cfg), "new") == 0); + ASSERT(config_gateway_port(cfg) == 19000); + config_free(cfg); + remove(path); + return 0; +} + +static int test_patch_escapes_quotes_and_newlines(void) +{ + char path[128]; + char *patched = NULL; + size_t patched_len = 0; + char errbuf[256]; + ASSERT(test_runner_mkstemp_path("shellclaw_test_config_patch", path, sizeof(path)) == 0); + ASSERT(write_toml(path, "[agent]\nmodel = \"old\"\n") == 0); + ASSERT(config_patch_dashboard_json(path, "{\"model\":\"a\\\"b\\nc\"}", &patched, &patched_len, + errbuf, sizeof(errbuf)) == 0); + ASSERT(patched != NULL); + ASSERT(strstr(patched, "model = \"a\\\"b\\nc\"") != NULL); + free(patched); + remove(path); + return 0; +} + +static int test_patch_rejects_wrong_json_types(void) +{ + char path[128]; + char *patched = NULL; + size_t patched_len = 0; + char errbuf[256]; + ASSERT(test_runner_mkstemp_path("shellclaw_test_config_patch", path, sizeof(path)) == 0); + ASSERT(write_toml(path, "[agent]\nmodel = \"old\"\nmax_tokens = 1024\n") == 0); + ASSERT(config_patch_dashboard_json(path, "{\"model\":123,\"max_tokens\":\"nope\"}", + &patched, &patched_len, errbuf, sizeof(errbuf)) != 0); + ASSERT(patched == NULL); + ASSERT(strstr(errbuf, "model") != NULL); + memset(errbuf, 0, sizeof(errbuf)); + ASSERT(config_patch_dashboard_json(path, "{\"model\":null}", &patched, &patched_len, errbuf, + sizeof(errbuf)) != 0); + ASSERT(patched == NULL); + ASSERT(strstr(errbuf, "model") != NULL); + remove(path); + return 0; +} + static int test_patch_creates_missing_section(void) { char path[128]; @@ -130,6 +198,18 @@ int main(void) fprintf(stderr, "test_patch_creates_missing_section failed\n"); failed++; } + if (test_patch_indented_key_and_commented_section() != 0) { + fprintf(stderr, "test_patch_indented_key_and_commented_section failed\n"); + failed++; + } + if (test_patch_escapes_quotes_and_newlines() != 0) { + fprintf(stderr, "test_patch_escapes_quotes_and_newlines failed\n"); + failed++; + } + if (test_patch_rejects_wrong_json_types() != 0) { + fprintf(stderr, "test_patch_rejects_wrong_json_types failed\n"); + failed++; + } if (failed == 0) printf("test_config_patch: all tests passed\n"); return failed; From eedd31ca3825a369c9afd667231ed19da0e4c999 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Tue, 22 Sep 2026 13:17:20 -0300 Subject: [PATCH 4/4] fix(gateway): lock config reload and fail PUT when it does not apply Dashboard save and SIGHUP both swap the live config. Hold agent_lock on both paths so the same pointer cannot be queued twice, and return HTTP 500 when the file is saved but the live reload does not. --- CHANGELOG.md | 1 + Makefile | 2 +- src/core/main.c | 3 +++ src/core/reload.c | 11 ++++++----- src/core/reload.h | 4 +++- src/gateway/routes.c | 20 ++++++++++++++++---- tests/test_gateway_http.c | 4 ++++ tests/test_reload.c | 2 +- web/js/app.js | 1 + 9 files changed, 36 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 831d9ac..f0b6c6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha ## [Unreleased] ### Fixed +- Dashboard `PUT /api/config` merges JSON fields into `config.toml` and reloads live settings. Indented keys and `[section] # comment` headers are updated in place; a present field with the wrong JSON type returns 400; a saved file whose live reload fails returns 500. Gateway host and port still need a process restart to rebind. - Unsandboxed `shell` no longer blocks forever in `waitpid` after the output cap fills; leftover children (including background grandchildren) are SIGKILL'd via the command process group, and truncated capture is NUL-terminated (#69). - `write_file` maps to the intended path instead of the first existing ancestor, so a nested path cannot truncate a workspace file treated as a directory or overwrite a same-named file in a parent (#67). Leaf workspace symlinks (dangling or an in-workspace alias) are rejected (`lstat` + `O_NOFOLLOW`) instead of creating host files outside the workspace (#90). - `write_file` persists via unique temp (`mkstemp`)+fsync+rename so a failed write cannot wipe an existing workspace file and a sibling `path.tmp` is not truncated (#78). diff --git a/Makefile b/Makefile index afb29ba..83d0c22 100644 --- a/Makefile +++ b/Makefile @@ -189,7 +189,7 @@ shellclaw: $(SHELLCLAW_OBJS) $(CONFIG_O): src/core/config.c src/core/config.h $(CC) $(CFLAGS) $(INC) -c -o $@ $< -$(MAIN_O): src/core/main.c src/asap/manifest.h src/core/config.h src/core/bootstrap.h src/core/daemon.h src/core/dispatch.h src/core/reload.h src/channels/channel.h src/hardware/board_detect.h src/providers/provider.h +$(MAIN_O): src/core/main.c src/asap/manifest.h src/core/agent.h src/core/config.h src/core/bootstrap.h src/core/daemon.h src/core/dispatch.h src/core/reload.h src/channels/channel.h src/hardware/board_detect.h src/providers/provider.h $(CC) $(CFLAGS) $(INC) -c -o $@ src/core/main.c $(DAEMON_O): src/core/daemon.c src/core/daemon.h src/core/config.h diff --git a/src/core/main.c b/src/core/main.c index a9ac570..30ae75e 100644 --- a/src/core/main.c +++ b/src/core/main.c @@ -20,6 +20,7 @@ #include "core/bootstrap.h" #include "core/config.h" #include "core/daemon.h" +#include "core/agent.h" #include "core/dispatch.h" #include "core/reload.h" #include "core/version.h" @@ -67,7 +68,9 @@ static void main_loop(int one_shot, config_t **pcfg) while (!g_shutdown) { if (g_reload_requested) { g_reload_requested = 0; + agent_lock(); try_config_reload(pcfg); + agent_unlock(); } provider_router_periodic_recovery_tick(time(NULL)); channel_incoming_msg_t msg; diff --git a/src/core/reload.c b/src/core/reload.c index 1520a2a..17c64e5 100644 --- a/src/core/reload.c +++ b/src/core/reload.c @@ -59,14 +59,14 @@ void stale_free_all(void) g_stale_cfg_head = NULL; } -void try_config_reload(config_t **pcfg) +int try_config_reload(config_t **pcfg) { config_t *old; config_t *new_cfg; char errbuf[256]; const char *config_path = bootstrap_get_config_path(); if (!pcfg || !*pcfg || !config_path) - return; + return -1; /* Dashboard PUT may reload from the HTTP thread while main still holds the * previous pointer. Always enqueue the live bootstrap cfg, not the caller's * possibly-stale copy, so a later SIGHUP does not double-free. */ @@ -74,17 +74,17 @@ void try_config_reload(config_t **pcfg) if (!old) old = *pcfg; if (!old) - return; + return -1; new_cfg = NULL; if (config_load(config_path, &new_cfg, errbuf, sizeof(errbuf)) != 0) { fprintf(stderr, "shellclaw: SIGHUP config reload failed: %s\n", errbuf[0] ? errbuf : "unknown error"); - return; + return -1; } if (stale_enqueue(old) != 0) { fprintf(stderr, "shellclaw: SIGHUP config reload failed: out of memory\n"); config_free(new_cfg); - return; + return -1; } provider_router_set_live_config(new_cfg); provider_openai_set_live_config(new_cfg); @@ -99,4 +99,5 @@ void try_config_reload(config_t **pcfg) *pcfg = new_cfg; bootstrap_set_cfg(new_cfg); fprintf(stderr, "shellclaw: config reloaded from %s\n", config_path); + return 0; } diff --git a/src/core/reload.h b/src/core/reload.h index 4f324db..29caf9c 100644 --- a/src/core/reload.h +++ b/src/core/reload.h @@ -27,9 +27,11 @@ void stale_free_all(void); /** * Re-parse config and swap live pointers. Old config is queued via stale_enqueue(). + * Callers on different threads must hold agent_lock() around this call. * @param pcfg In/out active config pointer (updated on success). + * @return 0 on success, -1 if reload did not swap the live config. */ -void try_config_reload(config_t **pcfg); +int try_config_reload(config_t **pcfg); #ifdef __cplusplus } diff --git a/src/gateway/routes.c b/src/gateway/routes.c index a59936f..e917c22 100644 --- a/src/gateway/routes.c +++ b/src/gateway/routes.c @@ -15,6 +15,7 @@ #include "asap/envelope.h" #include "asap/server.h" #include "asap/log.h" +#include "core/agent.h" #include "core/bootstrap.h" #include "core/config.h" #include "core/config_patch.h" @@ -295,12 +296,23 @@ static void handle_config_put(http_server_ctx_t *ctx, const char *body, size_t b free(tmp_path); free(patched_body); /* Dashboard/TOML save: swap live cfg now instead of waiting for SIGHUP. - * Call http_set_live_config here: test_reload rebuilds reload.o with - * GATEWAY=0, so try_config_reload may omit the gateway pointer swap. */ + * agent_lock matches the SIGHUP path in main_loop so the two threads cannot + * enqueue the same pointer. http_set_live_config stays here because + * test_reload rebuilds reload.o with GATEWAY=0. */ { config_t *live_cfg = bootstrap_get_cfg(); - if (live_cfg) - try_config_reload(&live_cfg); + int reload_rc; + if (!live_cfg) { + json_error(buf, size, status, 500, "Config saved but live reload failed"); + return; + } + agent_lock(); + reload_rc = try_config_reload(&live_cfg); + agent_unlock(); + if (reload_rc != 0) { + json_error(buf, size, status, 500, "Config saved but live reload failed"); + return; + } http_set_live_config(bootstrap_get_cfg()); } *status = 200; diff --git a/tests/test_gateway_http.c b/tests/test_gateway_http.c index 10bb37a..7061e71 100644 --- a/tests/test_gateway_http.c +++ b/tests/test_gateway_http.c @@ -1144,6 +1144,10 @@ static int test_api_config_put_json(const char *token, int port) ASSERT(strstr(body, "\"patched-model\"") != NULL); ASSERT(strstr(body, "\"max_tokens\":2048") != NULL || strstr(body, "\"max_tokens\": 2048") != NULL); + ASSERT(strstr(body, "\"temperature\":0.5") != NULL || + strstr(body, "\"temperature\": 0.5") != NULL); + ASSERT(strstr(body, "\"gateway_host\":\"127.0.0.1\"") != NULL || + strstr(body, "\"gateway_host\": \"127.0.0.1\"") != NULL); free(body); return 0; } diff --git a/tests/test_reload.c b/tests/test_reload.c index 6ced210..37e7a75 100644 --- a/tests/test_reload.c +++ b/tests/test_reload.c @@ -157,7 +157,7 @@ static int test_try_config_reload_keeps_old_on_invalid_file(void) fprintf(f, "[memory]\ndb_path = \"/tmp/db\"\n"); fclose(f); } - try_config_reload(&cfg); + ASSERT(try_config_reload(&cfg) != 0); ASSERT(cfg != NULL); ASSERT(strcmp(config_agent_model(cfg), "still-valid") == 0); stale_free_all(); diff --git a/web/js/app.js b/web/js/app.js index a225248..03aea9c 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -160,6 +160,7 @@ '
' + '
' + '
' + + '

Gateway host and port are saved now. The process keeps the current listen address until restart.

' + ''); document.getElementById('config-form').onsubmit = function (e) { e.preventDefault();