diff --git a/.gitignore b/.gitignore index c4fdd146..4b21168f 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ projects/lcd_test .sconsign.dblite zeromp3 *.pyc +docs/rtc/applaunch_rtc_flow.html diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl.c b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl.c index dd019162..62a0a009 100644 --- a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl.c +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl.c @@ -10,6 +10,8 @@ void cp0_lvgl_init(void) init_config(); init_pty(); init_freambuffer_disp(); + if (lv_display_get_default() == NULL) + return; init_input(); init_rpc(); init_audio(); diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_freambuffer.c b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_freambuffer.c index 722c33ed..3409e59e 100644 --- a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_freambuffer.c +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_freambuffer.c @@ -40,6 +40,41 @@ static int find_st7789v_fbdev(char *dev_path, size_t buf_size) void init_freambuffer_disp() { +#if LV_USE_LINUX_DRM + const char *device = getenv("LV_LINUX_DRM_DEVICE"); + char *detected_device = NULL; + if (device == NULL || device[0] == '\0') { + detected_device = lv_linux_drm_find_device_path(); + device = detected_device; + } + + if (device == NULL || device[0] == '\0') { + fprintf(stderr, "Failed to find a connected DRM device\n"); + lv_free(detected_device); + return; + } + + printf("Using DRM device: %s\n", device); + lv_display_t *disp = lv_linux_drm_create(); + if (disp == NULL) { + fprintf(stderr, "Failed to create DRM display\n"); + lv_free(detected_device); + return; + } + + if (lv_linux_drm_set_file(disp, device, -1) != LV_RESULT_OK) { + fprintf(stderr, "Failed to initialize DRM device: %s\n", device); + lv_display_delete(disp); + lv_free(detected_device); + return; + } + + lv_free(detected_device); + + printf("DRM resolution: %dx%d\n", + (int)lv_display_get_horizontal_resolution(disp), + (int)lv_display_get_vertical_resolution(disp)); +#else lv_display_t *disp = lv_linux_fbdev_create(); if (disp == NULL) { printf("Failed to create fbdev display!\n"); @@ -63,4 +98,5 @@ void init_freambuffer_disp() lv_coord_t w = lv_display_get_horizontal_resolution(disp); lv_coord_t h = lv_display_get_vertical_resolution(disp); printf("Framebuffer resolution: %dx%d\n", w, h); +#endif } diff --git a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_process.cpp b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_process.cpp index 357e9042..965e271a 100644 --- a/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_process.cpp +++ b/ext_components/cp0_lvgl/src/cp0/cp0_lvgl_process.cpp @@ -128,6 +128,7 @@ class ProcessSystem (void)keep_root; return -1; #else + cp0_process_group::enable_subreaper(); pid_t pid = fork(); if (pid < 0) return -1; @@ -149,9 +150,10 @@ class ProcessSystem #if !defined(_WIN32) if (pid <= 0) return; - killpg(static_cast(pid), SIGTERM); - int status = 0; - waitpid(static_cast(pid), &status, WNOHANG); + if (!cp0_process_group::terminate_and_reap(static_cast(pid), + static_cast(pid))) + std::fprintf(stderr, "[process] failed to stop and reap pgid=%d\n", + static_cast(pid)); #else (void)pid; #endif diff --git a/ext_components/cp0_lvgl/src/cp0_external_process_group.hpp b/ext_components/cp0_lvgl/src/cp0_external_process_group.hpp index 8cc4eb76..7771a1d2 100644 --- a/ext_components/cp0_lvgl/src/cp0_external_process_group.hpp +++ b/ext_components/cp0_lvgl/src/cp0_external_process_group.hpp @@ -3,7 +3,9 @@ #if !defined(_WIN32) #include +#include #include +#include #include #include #if defined(__linux__) @@ -47,6 +49,75 @@ inline void reap_available(pid_t pgid, pid_t leader, int &leader_status, } } +inline bool exited_unreaped(pid_t pid) +{ + if (pid <= 0) return false; + siginfo_t info{}; + if (waitid(P_PID, pid, &info, WEXITED | WNOHANG | WNOWAIT) < 0) + return errno == ECHILD; + return info.si_pid == pid; +} + +inline bool defer_reap(pid_t pgid, pid_t leader, + std::chrono::milliseconds poll_interval = + std::chrono::milliseconds(100)) +{ + try { + std::thread([pgid, leader, poll_interval]() { + int leader_status = 0; + bool leader_reaped = false; + while (exists(pgid)) { + reap_available(pgid, leader, leader_status, leader_reaped); + if (exists(pgid)) std::this_thread::sleep_for(poll_interval); + } + reap_available(pgid, leader, leader_status, leader_reaped); + }).detach(); + } catch (...) { + return false; + } + return true; +} + +inline bool terminate_and_reap(pid_t pgid, pid_t leader, + std::chrono::milliseconds grace = std::chrono::seconds(2), + std::chrono::milliseconds poll_interval = + std::chrono::milliseconds(10), + std::chrono::milliseconds kill_wait = + std::chrono::seconds(2)) +{ + if (pgid <= 0) return false; + + int leader_status = 0; + bool leader_reaped = false; + if (kill(-pgid, SIGTERM) < 0) { + if (errno != ESRCH) return false; + reap_available(pgid, leader, leader_status, leader_reaped); + return true; + } + + // Keep the leader as a zombie during the grace period. Reaping it could + // allow its PID/PGID to be reused before the forced group kill. + const auto term_deadline = std::chrono::steady_clock::now() + grace; + while (exists(pgid) && !exited_unreaped(leader) && + std::chrono::steady_clock::now() < term_deadline) + std::this_thread::sleep_for(poll_interval); + + if (exists(pgid) && kill(-pgid, SIGKILL) < 0 && errno != ESRCH) + return false; + + const auto kill_deadline = std::chrono::steady_clock::now() + kill_wait; + while (exists(pgid) && std::chrono::steady_clock::now() < kill_deadline) { + reap_available(pgid, leader, leader_status, leader_reaped); + if (exists(pgid)) std::this_thread::sleep_for(poll_interval); + } + reap_available(pgid, leader, leader_status, leader_reaped); + if (!exists(pgid)) return leader_reaped; + + // A task in uninterruptible sleep cannot act on SIGKILL yet. Do not block + // the caller indefinitely; retain ownership of this PGID until it exits. + return defer_reap(pgid, leader); +} + } // namespace cp0_process_group #endif diff --git a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_process.cpp b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_process.cpp index 5b5672ec..20405a23 100644 --- a/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_process.cpp +++ b/ext_components/cp0_lvgl/src/sdl/sdl_lvgl_process.cpp @@ -126,6 +126,7 @@ class ProcessSystem (void)keep_root; return -1; #else + cp0_process_group::enable_subreaper(); pid_t pid = fork(); if (pid < 0) return -1; @@ -147,9 +148,10 @@ class ProcessSystem #if !defined(_WIN32) if (pid <= 0) return; - killpg(static_cast(pid), SIGTERM); - int status = 0; - waitpid(static_cast(pid), &status, WNOHANG); + if (!cp0_process_group::terminate_and_reap(static_cast(pid), + static_cast(pid))) + std::fprintf(stderr, "[process] failed to stop and reap pgid=%d\n", + static_cast(pid)); #else (void)pid; #endif diff --git a/ext_components/cp0_lvgl/tests/test_external_process_group.cpp b/ext_components/cp0_lvgl/tests/test_external_process_group.cpp index f82588c3..18be639a 100644 --- a/ext_components/cp0_lvgl/tests/test_external_process_group.cpp +++ b/ext_components/cp0_lvgl/tests/test_external_process_group.cpp @@ -3,7 +3,9 @@ #include #include #include +#include #include +#include #include int main() @@ -43,4 +45,121 @@ int main() assert(leader_reaped); assert(WIFEXITED(leader_status) && WEXITSTATUS(leader_status) == 0); assert(observed_leader_exit_with_live_group); + assert(!cp0_process_group::terminate_and_reap(0, 0)); + + int graceful_ready[2]; + assert(pipe(graceful_ready) == 0); + leader = fork(); + assert(leader >= 0); + if (leader == 0) { + close(graceful_ready[0]); + setpgid(0, 0); + pid_t worker = fork(); + if (worker < 0) _exit(2); + if (worker == 0) { + const char value = 'x'; + if (write(graceful_ready[1], &value, 1) != 1) _exit(3); + for (;;) pause(); + } + for (;;) pause(); + } + + close(graceful_ready[1]); + setpgid(leader, leader); + char graceful_value = 0; + assert(read(graceful_ready[0], &graceful_value, 1) == 1 && graceful_value == 'x'); + close(graceful_ready[0]); + assert(cp0_process_group::terminate_and_reap( + leader, leader, milliseconds(250), milliseconds(5))); + errno = 0; + assert(waitpid(-leader, nullptr, WNOHANG) == -1 && errno == ECHILD); + assert(!cp0_process_group::exists(leader)); + + int mixed_ready[2]; + assert(pipe(mixed_ready) == 0); + leader = fork(); + assert(leader >= 0); + if (leader == 0) { + close(mixed_ready[0]); + setpgid(0, 0); + pid_t worker = fork(); + if (worker < 0) _exit(2); + if (worker == 0) { + signal(SIGTERM, SIG_IGN); + const char value = 'x'; + if (write(mixed_ready[1], &value, 1) != 1) _exit(3); + for (;;) pause(); + } + for (;;) pause(); + } + + close(mixed_ready[1]); + setpgid(leader, leader); + char mixed_value = 0; + assert(read(mixed_ready[0], &mixed_value, 1) == 1 && mixed_value == 'x'); + close(mixed_ready[0]); + assert(cp0_process_group::terminate_and_reap( + leader, leader, milliseconds(50), milliseconds(5))); + errno = 0; + assert(waitpid(-leader, nullptr, WNOHANG) == -1 && errno == ECHILD); + assert(!cp0_process_group::exists(leader)); + + int ready[2]; + assert(pipe(ready) == 0); + leader = fork(); + assert(leader >= 0); + if (leader == 0) { + close(ready[0]); + setpgid(0, 0); + signal(SIGTERM, SIG_IGN); + pid_t worker = fork(); + if (worker < 0) _exit(2); + if (worker == 0) { + signal(SIGTERM, SIG_IGN); + const char value = 'x'; + if (write(ready[1], &value, 1) != 1) _exit(3); + for (;;) pause(); + } + for (;;) pause(); + } + + close(ready[1]); + setpgid(leader, leader); + char value = 0; + assert(read(ready[0], &value, 1) == 1 && value == 'x'); + close(ready[0]); + assert(cp0_process_group::terminate_and_reap( + leader, leader, milliseconds(50), milliseconds(5))); + errno = 0; + assert(waitpid(-leader, nullptr, WNOHANG) == -1 && errno == ECHILD); + assert(!cp0_process_group::exists(leader)); + + int deferred_ready[2]; + assert(pipe(deferred_ready) == 0); + leader = fork(); + assert(leader >= 0); + if (leader == 0) { + close(deferred_ready[0]); + setpgid(0, 0); + signal(SIGTERM, SIG_IGN); + const char value = 'x'; + if (write(deferred_ready[1], &value, 1) != 1) _exit(3); + for (;;) pause(); + } + + close(deferred_ready[1]); + setpgid(leader, leader); + char deferred_value = 0; + assert(read(deferred_ready[0], &deferred_value, 1) == 1 && deferred_value == 'x'); + close(deferred_ready[0]); + assert(cp0_process_group::defer_reap(leader, leader, milliseconds(5))); + std::this_thread::sleep_for(milliseconds(20)); + assert(cp0_process_group::exists(leader)); + assert(kill(-leader, SIGKILL) == 0); + const auto deferred_deadline = steady_clock::now() + seconds(1); + while (cp0_process_group::exists(leader) && steady_clock::now() < deferred_deadline) + std::this_thread::sleep_for(milliseconds(5)); + assert(!cp0_process_group::exists(leader)); + errno = 0; + assert(waitpid(-leader, nullptr, WNOHANG) == -1 && errno == ECHILD); } diff --git a/projects/AppStore b/projects/AppStore index a0ddbd77..b4c1312a 160000 --- a/projects/AppStore +++ b/projects/AppStore @@ -1 +1 @@ -Subproject commit a0ddbd776c52fa0e015fe9d12f9519855dbabf3b +Subproject commit b4c1312a8a4fe3c6d9cebd98df92d580c2106974 diff --git a/projects/Calculator b/projects/Calculator index b753e200..aae2955b 160000 --- a/projects/Calculator +++ b/projects/Calculator @@ -1 +1 @@ -Subproject commit b753e2006d2b44322df9d79b41cbeb86b49e0bbb +Subproject commit aae2955be1000475ac7aa6ecc80d2ef08c8c54cb diff --git a/projects/ZClaw/docs/usage.md b/projects/ZClaw/docs/usage.md index e67a01f0..d15ce67f 100644 --- a/projects/ZClaw/docs/usage.md +++ b/projects/ZClaw/docs/usage.md @@ -35,6 +35,8 @@ The available presets are: For a custom provider, the API URL must begin with `http://` or `https://`. Quickstart uses the agent alias `zclaw` unless the saved UI configuration contains another alias. +Model Settings saves the selected provider and each API URL, API key, or model value as soon as the entry is confirmed with `Enter`. Confirmed values are restored the next time ZClaw starts, even if Quickstart has not been run. + The generated ZeroClaw configuration binds the gateway to `127.0.0.1:42617`, requires pairing, uses a 180-second request timeout, and allows 600 seconds for long-running requests. The UI endpoint is reset to `http://127.0.0.1:42617/webhook`. ## Chat diff --git a/projects/ZClaw/main/ui/zclaw_app.cpp b/projects/ZClaw/main/ui/zclaw_app.cpp index 4d7394ac..767de114 100644 --- a/projects/ZClaw/main/ui/zclaw_app.cpp +++ b/projects/ZClaw/main/ui/zclaw_app.cpp @@ -11,6 +11,7 @@ #include "keyboard_input.h" #include "zclaw_client.h" #include "zclaw_fonts.hpp" +#include "zclaw_provider_store.h" #include @@ -285,57 +286,6 @@ class ZClawApp return "Custom"; } - static std::string encode_field(const std::string &value) - { - std::string out; - for (char ch : value) { - if (ch == '\\') - out += "\\\\"; - else if (ch == '\t') - out += "\\t"; - else if (ch == '\n') - out += "\\n"; - else - out += ch; - } - return out; - } - - static std::string decode_field(const std::string &value) - { - std::string out; - for (size_t i = 0; i < value.size(); ++i) { - if (value[i] == '\\' && i + 1 < value.size()) { - const char next = value[++i]; - if (next == 't') - out += '\t'; - else if (next == 'n') - out += '\n'; - else - out += next; - } else { - out += value[i]; - } - } - return out; - } - - static std::vector split_tab_line(const std::string &line) - { - std::vector fields; - std::string current; - for (char ch : line) { - if (ch == '\t') { - fields.push_back(current); - current.clear(); - } else { - current += ch; - } - } - fields.push_back(current); - return fields; - } - static std::string display_text_compat(const std::string &text) { return text; @@ -348,23 +298,7 @@ class ZClawApp void load_providers() { - providers_.clear(); - std::ifstream file(ZClawClient::providers_config_path()); - std::string line; - while (std::getline(file, line)) { - if (line.empty()) - continue; - std::vector fields = split_tab_line(line); - if (fields.size() < 5) - continue; - ProviderConfig provider; - provider.alias = decode_field(fields[0]); - provider.family = decode_field(fields[1]); - provider.model = decode_field(fields[2]); - provider.uri = decode_field(fields[3]); - provider.api_key = decode_field(fields[4]); - providers_.push_back(provider); - } + zclaw::load_provider_configs(ZClawClient::providers_config_path(), &providers_); if (providers_.empty()) { for (int i = 0; i < 6; ++i) @@ -372,19 +306,23 @@ class ZClawApp } } - void save_providers() + bool save_providers(std::string *error = nullptr) { ZClawClient::ensure_storage_dir(); - std::ofstream file(ZClawClient::providers_config_path(), std::ios::trunc); - if (!file) - return; - for (const ProviderConfig &provider : providers_) { - file << encode_field(provider.alias) << '\t' - << encode_field(provider.family) << '\t' - << encode_field(provider.model) << '\t' - << encode_field(provider.uri) << '\t' - << encode_field(provider.api_key) << '\n'; - } + return zclaw::save_provider_configs(ZClawClient::providers_config_path(), providers_, error); + } + + bool save_setup_provider() + { + if (providers_.empty()) + providers_.push_back(setup_provider_); + else + providers_[0] = setup_provider_; + std::string error; + if (save_providers(&error)) + return true; + append_ai_message(error.c_str()); + return false; } void load_ui_config() @@ -392,11 +330,11 @@ class ZClawApp std::ifstream file(ZClawClient::ui_config_path()); std::string line; while (std::getline(file, line)) { - std::vector fields = split_tab_line(line); + std::vector fields = zclaw::split_config_line(line); if (fields.size() < 2) continue; - const std::string key = decode_field(fields[0]); - const std::string value = decode_field(fields[1]); + const std::string key = zclaw::decode_config_field(fields[0]); + const std::string value = zclaw::decode_config_field(fields[1]); if (key == "webhook_url") ui_config_.webhook_url = value; else if (key == "agent_alias") @@ -416,11 +354,11 @@ class ZClawApp std::ofstream file(ZClawClient::ui_config_path(), std::ios::trunc); if (!file) return; - file << encode_field("webhook_url") << '\t' << encode_field(ui_config_.webhook_url) << '\n' - << encode_field("agent_alias") << '\t' << encode_field(ui_config_.agent_alias) << '\n' - << encode_field("webhook_secret") << '\t' << encode_field(ui_config_.webhook_secret) << '\n' - << encode_field("bearer_token") << '\t' << encode_field(ui_config_.bearer_token) << '\n' - << encode_field("setup_complete") << '\t' << (ui_config_.setup_complete ? "1" : "0") << '\n'; + file << zclaw::encode_config_field("webhook_url") << '\t' << zclaw::encode_config_field(ui_config_.webhook_url) << '\n' + << zclaw::encode_config_field("agent_alias") << '\t' << zclaw::encode_config_field(ui_config_.agent_alias) << '\n' + << zclaw::encode_config_field("webhook_secret") << '\t' << zclaw::encode_config_field(ui_config_.webhook_secret) << '\n' + << zclaw::encode_config_field("bearer_token") << '\t' << zclaw::encode_config_field(ui_config_.bearer_token) << '\n' + << zclaw::encode_config_field("setup_complete") << '\t' << (ui_config_.setup_complete ? "1" : "0") << '\n'; } static const char *provider_field_name(ProviderEditField field) @@ -882,6 +820,7 @@ class ZClawApp else if (setup_edit_field_ == SetupEditField::Model) setup_provider_.model = value; setup_edit_field_ = SetupEditField::None; + save_setup_provider(); render_setup(); } @@ -1772,11 +1711,7 @@ class ZClawApp setup_in_flight_ = false; if (ok) { ui_config_ = config; - if (providers_.empty()) - providers_.push_back(setup_provider_); - else - providers_[0] = setup_provider_; - save_providers(); + save_setup_provider(); save_ui_config(); } append_ai_message(text.c_str()); @@ -1806,6 +1741,7 @@ class ZClawApp if (settings_view_ == SettingsView::SetupProviders) { setup_provider_ = provider_preset(setup_provider_selected_); + save_setup_provider(); settings_selected_ = 0; render_setup(); return; diff --git a/projects/ZClaw/main/ui/zclaw_provider_store.cpp b/projects/ZClaw/main/ui/zclaw_provider_store.cpp new file mode 100644 index 00000000..75c721d4 --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_provider_store.cpp @@ -0,0 +1,163 @@ +#include "zclaw_provider_store.h" + +#include +#include +#include +#include +#include +#include + +namespace zclaw { +namespace { + +bool write_all(int fd, const std::string &data) +{ + size_t offset = 0; + while (offset < data.size()) { + const ssize_t written = ::write(fd, data.data() + offset, data.size() - offset); + if (written < 0) { + if (errno == EINTR) + continue; + return false; + } + if (written == 0) { + errno = EIO; + return false; + } + offset += static_cast(written); + } + return true; +} + +void set_error(std::string *error, const char *operation) +{ + if (error) + *error = std::string(operation) + ": " + std::strerror(errno); +} + +} // namespace + +std::string encode_config_field(const std::string &value) +{ + std::string out; + for (char ch : value) { + if (ch == '\\') + out += "\\\\"; + else if (ch == '\t') + out += "\\t"; + else if (ch == '\n') + out += "\\n"; + else + out += ch; + } + return out; +} + +std::string decode_config_field(const std::string &value) +{ + std::string out; + for (size_t i = 0; i < value.size(); ++i) { + if (value[i] == '\\' && i + 1 < value.size()) { + const char next = value[++i]; + if (next == 't') + out += '\t'; + else if (next == 'n') + out += '\n'; + else + out += next; + } else { + out += value[i]; + } + } + return out; +} + +std::vector split_config_line(const std::string &line) +{ + std::vector fields; + std::string current; + for (char ch : line) { + if (ch == '\t') { + fields.push_back(current); + current.clear(); + } else { + current += ch; + } + } + fields.push_back(current); + return fields; +} + +bool load_provider_configs(const std::string &path, std::vector *providers) +{ + if (!providers) + return false; + providers->clear(); + std::ifstream file(path); + if (!file) + return false; + + std::string line; + while (std::getline(file, line)) { + if (line.empty()) + continue; + const std::vector fields = split_config_line(line); + if (fields.size() < 5) + continue; + providers->push_back({decode_config_field(fields[0]), decode_config_field(fields[1]), + decode_config_field(fields[2]), decode_config_field(fields[3]), + decode_config_field(fields[4])}); + } + return !file.bad(); +} + +bool save_provider_configs(const std::string &path, const std::vector &providers, + std::string *error) +{ + if (error) + error->clear(); + + std::string contents; + for (const ProviderConfig &provider : providers) { + contents += encode_config_field(provider.alias) + '\t' + + encode_config_field(provider.family) + '\t' + + encode_config_field(provider.model) + '\t' + + encode_config_field(provider.uri) + '\t' + + encode_config_field(provider.api_key) + '\n'; + } + + std::string temp_path = path + ".tmp.XXXXXX"; + std::vector temp_name(temp_path.begin(), temp_path.end()); + temp_name.push_back('\0'); + const int fd = ::mkstemp(temp_name.data()); + if (fd < 0) { + set_error(error, "Could not create provider settings file"); + return false; + } + + bool ok = write_all(fd, contents); + int saved_errno = ok ? 0 : errno; + if (ok && ::fsync(fd) != 0) { + ok = false; + saved_errno = errno; + } + if (::close(fd) != 0 && ok) { + ok = false; + saved_errno = errno; + } + if (!ok) { + errno = saved_errno; + set_error(error, "Could not write provider settings"); + ::unlink(temp_name.data()); + return false; + } + + if (::rename(temp_name.data(), path.c_str()) != 0) { + set_error(error, "Could not replace provider settings"); + ::unlink(temp_name.data()); + return false; + } + return true; +} + +} // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_provider_store.h b/projects/ZClaw/main/ui/zclaw_provider_store.h new file mode 100644 index 00000000..6777cd9c --- /dev/null +++ b/projects/ZClaw/main/ui/zclaw_provider_store.h @@ -0,0 +1,18 @@ +#pragma once + +#include "zclaw_client.h" + +#include +#include + +namespace zclaw { + +std::string encode_config_field(const std::string &value); +std::string decode_config_field(const std::string &value); +std::vector split_config_line(const std::string &line); + +bool load_provider_configs(const std::string &path, std::vector *providers); +bool save_provider_configs(const std::string &path, const std::vector &providers, + std::string *error = nullptr); + +} // namespace zclaw diff --git a/projects/ZClaw/tests/zclaw_provider_store_test.cpp b/projects/ZClaw/tests/zclaw_provider_store_test.cpp new file mode 100644 index 00000000..1a666f5c --- /dev/null +++ b/projects/ZClaw/tests/zclaw_provider_store_test.cpp @@ -0,0 +1,74 @@ +#include "zclaw_provider_store.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +bool same_provider(const ProviderConfig &left, const ProviderConfig &right) +{ + return left.alias == right.alias && left.family == right.family && + left.model == right.model && left.uri == right.uri && + left.api_key == right.api_key; +} + +} // namespace + +int main() +{ + char dir_template[] = "/tmp/zclaw-provider-store-XXXXXX"; + const char *dir = ::mkdtemp(dir_template); + assert(dir); + const std::string path = std::string(dir) + "/providers.tsv"; + + const std::vector original = { + {"zclaw", "custom", "model\\name\nnext", "https://example.com/a\tb", "key\\value\nline"}, + {"second", "ollama", "llama3.1", "http://127.0.0.1:11434", ""}, + }; + std::string error; + assert(zclaw::save_provider_configs(path, original, &error)); + assert(error.empty()); + + struct stat st {}; + assert(::stat(path.c_str(), &st) == 0); + assert((st.st_mode & 0777) == 0600); + + std::vector loaded; + assert(zclaw::load_provider_configs(path, &loaded)); + assert(loaded.size() == original.size()); + for (size_t i = 0; i < original.size(); ++i) + assert(same_provider(loaded[i], original[i])); + + const std::vector replacement = { + {"zclaw", "openai", "gpt-4.1-mini", "https://api.openai.com/v1", "new-key"}, + }; + assert(zclaw::save_provider_configs(path, replacement, &error)); + assert(zclaw::load_provider_configs(path, &loaded)); + assert(loaded.size() == 1 && same_provider(loaded[0], replacement[0])); + + { + std::ofstream legacy(path, std::ios::trunc); + assert(legacy); + legacy << "legacy\tcustom\tmodel\\tname\thttps://example.com/v1\tkey\\\\value\n"; + } + assert(zclaw::load_provider_configs(path, &loaded)); + assert(loaded.size() == 1); + assert(loaded[0].alias == "legacy"); + assert(loaded[0].model == "model\tname"); + assert(loaded[0].api_key == "key\\value"); + + assert(!zclaw::save_provider_configs(std::string(dir) + "/missing/providers.tsv", + original, &error)); + assert(!error.empty()); + assert(zclaw::load_provider_configs(path, &loaded)); + assert(loaded.size() == 1 && loaded[0].alias == "legacy"); + + assert(::unlink(path.c_str()) == 0); + assert(::rmdir(dir) == 0); + return 0; +}