diff --git a/projects/AppStore b/projects/AppStore index b4c1312a..b0d373f2 160000 --- a/projects/AppStore +++ b/projects/AppStore @@ -1 +1 @@ -Subproject commit b4c1312a8a4fe3c6d9cebd98df92d580c2106974 +Subproject commit b0d373f23923a918f968f38b644b259fb0dd5888 diff --git a/projects/ZClaw/docs/configuration.md b/projects/ZClaw/docs/configuration.md index fbe6dc14..4b238355 100644 --- a/projects/ZClaw/docs/configuration.md +++ b/projects/ZClaw/docs/configuration.md @@ -96,4 +96,6 @@ The UI reads and writes two escaped, tab-separated files in `~/.zeroclaw`: Tabs, newlines, and backslashes are escaped when written. These formats are implementation details rather than a public interchange format; preserve field ordering and escaping if another component needs to edit them. +The first provider record is the active Setup provider. Selecting another provider family moves its existing record to the front instead of recreating it from defaults, so values survive switching away and back. A preset is created only when that family has no saved record. Both TSV files are written through a mode-`0600` temporary file, flushed, and atomically renamed over the previous file. + Because credentials are stored as plain text, packaging and diagnostic tooling must not collect these files by default. diff --git a/projects/ZClaw/docs/usage.md b/projects/ZClaw/docs/usage.md index d15ce67f..dc446447 100644 --- a/projects/ZClaw/docs/usage.md +++ b/projects/ZClaw/docs/usage.md @@ -37,6 +37,8 @@ For a custom provider, the API URL must begin with `http://` or `https://`. Quic 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. +Switching provider families preserves each family's saved values. Selecting a family that has been configured before restores that record; defaults are used only for a family with no saved record. + 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 767de114..be55b0c9 100644 --- a/projects/ZClaw/main/ui/zclaw_app.cpp +++ b/projects/ZClaw/main/ui/zclaw_app.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include namespace { @@ -312,17 +313,26 @@ class ZClawApp return zclaw::save_provider_configs(ZClawClient::providers_config_path(), providers_, error); } + void report_save_error(const std::string &error) + { + append_ai_message(error.empty() ? "Could not save settings." : error.c_str()); + } + bool save_setup_provider() { - if (providers_.empty()) - providers_.push_back(setup_provider_); + std::vector candidate = providers_; + if (candidate.empty()) + candidate.push_back(setup_provider_); else - providers_[0] = setup_provider_; + candidate[0] = setup_provider_; std::string error; - if (save_providers(&error)) - return true; - append_ai_message(error.c_str()); - return false; + ZClawClient::ensure_storage_dir(); + if (!zclaw::save_provider_configs(ZClawClient::providers_config_path(), candidate, &error)) { + report_save_error(error); + return false; + } + providers_ = std::move(candidate); + return true; } void load_ui_config() @@ -348,17 +358,20 @@ class ZClawApp } } - void save_ui_config() + bool save_ui_config() { ZClawClient::ensure_storage_dir(); - std::ofstream file(ZClawClient::ui_config_path(), std::ios::trunc); - if (!file) - return; - 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'; + std::string contents; + contents += zclaw::encode_config_field("webhook_url") + '\t' + zclaw::encode_config_field(ui_config_.webhook_url) + '\n'; + contents += zclaw::encode_config_field("agent_alias") + '\t' + zclaw::encode_config_field(ui_config_.agent_alias) + '\n'; + contents += zclaw::encode_config_field("webhook_secret") + '\t' + zclaw::encode_config_field(ui_config_.webhook_secret) + '\n'; + contents += zclaw::encode_config_field("bearer_token") + '\t' + zclaw::encode_config_field(ui_config_.bearer_token) + '\n'; + contents += zclaw::encode_config_field("setup_complete") + '\t' + (ui_config_.setup_complete ? "1" : "0") + '\n'; + std::string error; + if (zclaw::atomic_write_config(ZClawClient::ui_config_path(), contents, &error)) + return true; + report_save_error(error); + return false; } static const char *provider_field_name(ProviderEditField field) @@ -813,6 +826,7 @@ class ZClawApp void apply_setup_edit(const std::string &value) { + const ProviderConfig previous = setup_provider_; if (setup_edit_field_ == SetupEditField::Uri) setup_provider_.uri = value; else if (setup_edit_field_ == SetupEditField::ApiKey) @@ -820,7 +834,8 @@ class ZClawApp else if (setup_edit_field_ == SetupEditField::Model) setup_provider_.model = value; setup_edit_field_ = SetupEditField::None; - save_setup_provider(); + if (!save_setup_provider()) + setup_provider_ = previous; render_setup(); } @@ -918,11 +933,8 @@ class ZClawApp render_settings_main(); } - void open_setup_panel() + void sync_setup_provider_selection() { - if (settings_panel_open() || settings_animating_) - return; - close_input_dialog(); if (setup_provider_.family == "openrouter") setup_provider_selected_ = 1; else if (setup_provider_.family == "anthropic") setup_provider_selected_ = 2; else if (setup_provider_.family == "ollama") setup_provider_selected_ = 3; @@ -930,6 +942,14 @@ class ZClawApp else if (setup_provider_.family == "custom") setup_provider_selected_ = 5; else setup_provider_selected_ = 0; setup_provider_scroll_ = 0; + } + + void open_setup_panel() + { + if (settings_panel_open() || settings_animating_) + return; + close_input_dialog(); + sync_setup_provider_selection(); create_settings_panel(); if (first_run_needed()) render_setup_providers(); @@ -1499,11 +1519,17 @@ class ZClawApp void finish_pairing_result(bool ok, const std::string &text, const UiConfig &config) { + if (ok) + append_ai_message(text.c_str()); if (ok) { + const UiConfig previous = ui_config_; ui_config_ = config; - save_ui_config(); + if (!save_ui_config()) { + ui_config_ = previous; + } } - append_ai_message(text.c_str()); + if (!ok) + append_ai_message(text.c_str()); if (settings_panel_open() && settings_view_ == SettingsView::Authorization) render_authorization(); } @@ -1570,8 +1596,14 @@ class ZClawApp provider.family = "openai-compatible"; provider.model = "model"; provider.uri = "https://api.example.com/v1"; + const std::vector previous = providers_; providers_.push_back(provider); - save_providers(); + std::string error; + if (!save_providers(&error)) { + providers_ = previous; + report_save_error(error); + return; + } provider_selected_ = (int)providers_.size(); provider_detail_index_ = (int)providers_.size() - 1; settings_selected_ = 0; @@ -1582,8 +1614,17 @@ class ZClawApp { if (provider_detail_index_ < 0 || provider_detail_index_ >= (int)providers_.size()) return; - providers_.erase(providers_.begin() + provider_detail_index_); - save_providers(); + const std::vector previous_providers = providers_; + const ProviderConfig previous_setup = setup_provider_; + zclaw::erase_provider_config(&providers_, &setup_provider_, + static_cast(provider_detail_index_), provider_preset(0)); + std::string error; + if (!save_providers(&error)) { + providers_ = previous_providers; + setup_provider_ = previous_setup; + report_save_error(error); + return; + } provider_detail_index_ = -1; if (provider_selected_ > (int)providers_.size()) provider_selected_ = (int)providers_.size(); @@ -1628,10 +1669,19 @@ class ZClawApp if (provider_edit_field_ == ProviderEditField::None) return; - ProviderConfig &provider = providers_[provider_detail_index_]; + const std::vector previous_providers = providers_; + const ProviderConfig previous_setup = setup_provider_; + ProviderConfig provider = providers_[provider_detail_index_]; provider_field_value(provider, provider_edit_field_) = value; provider_edit_field_ = ProviderEditField::None; - save_providers(); + zclaw::replace_provider_config(&providers_, &setup_provider_, + static_cast(provider_detail_index_), provider); + std::string error; + if (!save_providers(&error)) { + providers_ = previous_providers; + setup_provider_ = previous_setup; + report_save_error(error); + } render_provider_detail(); } @@ -1709,13 +1759,20 @@ class ZClawApp void finish_setup_result(bool ok, const std::string &text, const UiConfig &config) { setup_in_flight_ = false; + bool persisted = ok; + if (ok) + append_ai_message(text.c_str()); if (ok) { + const UiConfig previous = ui_config_; ui_config_ = config; - save_setup_provider(); - save_ui_config(); + if (!save_setup_provider() || !save_ui_config()) { + ui_config_ = previous; + persisted = false; + } } - append_ai_message(text.c_str()); - if (ok && settings_panel_open() && settings_view_ == SettingsView::Setup) { + if (!ok) + append_ai_message(text.c_str()); + if (persisted && settings_panel_open() && settings_view_ == SettingsView::Setup) { render_setup(); close_settings_panel(); } @@ -1730,6 +1787,7 @@ class ZClawApp if (setup_in_flight_) return; if (settings_selected_ == 0) { + sync_setup_provider_selection(); render_setup_providers(); } else if (settings_selected_ == setup_initialize_row()) { start_setup(); @@ -1740,8 +1798,16 @@ class ZClawApp } if (settings_view_ == SettingsView::SetupProviders) { - setup_provider_ = provider_preset(setup_provider_selected_); - save_setup_provider(); + const std::vector previous_providers = providers_; + const ProviderConfig previous_setup = setup_provider_; + const ProviderConfig selected = provider_preset(setup_provider_selected_); + zclaw::activate_provider_config(&providers_, &setup_provider_, selected); + std::string error; + if (!save_providers(&error)) { + providers_ = previous_providers; + setup_provider_ = previous_setup; + report_save_error(error); + } settings_selected_ = 0; render_setup(); return; @@ -1751,9 +1817,12 @@ class ZClawApp if (settings_selected_ == 0) { open_text_dialog("Pairing code", "", InputMode::PairingCode); } else if (settings_selected_ == 4) { + const UiConfig previous = ui_config_; ui_config_.bearer_token.clear(); - save_ui_config(); - append_ai_message("Authorization token cleared."); + if (save_ui_config()) + append_ai_message("Authorization token cleared."); + else + ui_config_ = previous; render_authorization(); } return; diff --git a/projects/ZClaw/main/ui/zclaw_client.cpp b/projects/ZClaw/main/ui/zclaw_client.cpp index 4c52f5cf..e3f025a8 100644 --- a/projects/ZClaw/main/ui/zclaw_client.cpp +++ b/projects/ZClaw/main/ui/zclaw_client.cpp @@ -493,9 +493,9 @@ bool apply_quickstart_config(UiConfig *config, ProviderConfig provider, std::str !config_set("gateway.long_running_request_timeout_secs", "600", error) || !config_set(provider_prefix + ".model", provider.model, error)) return false; - if (!provider.uri.empty() && !config_set(provider_prefix + ".uri", provider.uri, error)) + if (!config_set(provider_prefix + ".uri", provider.uri, error)) return false; - if (!provider.api_key.empty() && !config_set(provider_prefix + ".api_key", provider.api_key, error)) + if (!config_set(provider_prefix + ".api_key", provider.api_key, error)) return false; if (!ensure_agent(agent, error) || diff --git a/projects/ZClaw/main/ui/zclaw_provider_store.cpp b/projects/ZClaw/main/ui/zclaw_provider_store.cpp index 75c721d4..cdd96c68 100644 --- a/projects/ZClaw/main/ui/zclaw_provider_store.cpp +++ b/projects/ZClaw/main/ui/zclaw_provider_store.cpp @@ -1,6 +1,7 @@ #include "zclaw_provider_store.h" #include +#include #include #include #include @@ -88,6 +89,55 @@ std::vector split_config_line(const std::string &line) return fields; } +void activate_provider_config(std::vector *providers, ProviderConfig *active, + const ProviderConfig &selected_default) +{ + if (!providers || !active) + return; + + if (providers->empty()) + providers->push_back(*active); + else + (*providers)[0] = *active; + + if (active->family == selected_default.family) + return; + + const auto existing = std::find_if(providers->begin() + 1, providers->end(), + [&](const ProviderConfig &provider) { + return provider.family == selected_default.family; + }); + ProviderConfig selected = selected_default; + if (existing != providers->end()) { + selected = *existing; + providers->erase(existing); + } + providers->insert(providers->begin(), selected); + *active = selected; +} + +bool replace_provider_config(std::vector *providers, ProviderConfig *active, + std::size_t index, const ProviderConfig &replacement) +{ + if (!providers || !active || index >= providers->size()) + return false; + (*providers)[index] = replacement; + if (index == 0) + *active = replacement; + return true; +} + +bool erase_provider_config(std::vector *providers, ProviderConfig *active, + std::size_t index, const ProviderConfig &empty_default) +{ + if (!providers || !active || index >= providers->size()) + return false; + providers->erase(providers->begin() + static_cast(index)); + if (index == 0) + *active = providers->empty() ? empty_default : (*providers)[0]; + return true; +} + bool load_provider_configs(const std::string &path, std::vector *providers) { if (!providers) @@ -111,27 +161,17 @@ bool load_provider_configs(const std::string &path, std::vector return !file.bad(); } -bool save_provider_configs(const std::string &path, const std::vector &providers, - std::string *error) +bool atomic_write_config(const std::string &path, const std::string &contents, 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"); + set_error(error, "Could not create settings file"); return false; } @@ -147,17 +187,31 @@ bool save_provider_configs(const std::string &path, const std::vector &providers, + std::string *error) +{ + 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'; + } + return atomic_write_config(path, contents, error); +} + } // namespace zclaw diff --git a/projects/ZClaw/main/ui/zclaw_provider_store.h b/projects/ZClaw/main/ui/zclaw_provider_store.h index 6777cd9c..1bec2086 100644 --- a/projects/ZClaw/main/ui/zclaw_provider_store.h +++ b/projects/ZClaw/main/ui/zclaw_provider_store.h @@ -2,6 +2,7 @@ #include "zclaw_client.h" +#include #include #include @@ -11,6 +12,15 @@ 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); +void activate_provider_config(std::vector *providers, ProviderConfig *active, + const ProviderConfig &selected_default); +bool replace_provider_config(std::vector *providers, ProviderConfig *active, + std::size_t index, const ProviderConfig &replacement); +bool erase_provider_config(std::vector *providers, ProviderConfig *active, + std::size_t index, const ProviderConfig &empty_default); + +bool atomic_write_config(const std::string &path, const std::string &contents, + std::string *error = nullptr); 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); diff --git a/projects/ZClaw/tests/zclaw_provider_store_test.cpp b/projects/ZClaw/tests/zclaw_provider_store_test.cpp index 1a666f5c..e34a4a2c 100644 --- a/projects/ZClaw/tests/zclaw_provider_store_test.cpp +++ b/projects/ZClaw/tests/zclaw_provider_store_test.cpp @@ -29,6 +29,7 @@ int main() 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", ""}, + {"zclaw", "openai", "gpt-4.1-mini", "https://api.openai.com/v1", "openai-key"}, }; std::string error; assert(zclaw::save_provider_configs(path, original, &error)); @@ -44,6 +45,50 @@ int main() for (size_t i = 0; i < original.size(); ++i) assert(same_provider(loaded[i], original[i])); + ProviderConfig active = loaded[0]; + const ProviderConfig custom_default = { + "zclaw", "custom", "", "https://api.example.com/v1", "" + }; + zclaw::activate_provider_config(&loaded, &active, custom_default); + assert(active.uri == original[0].uri); + assert(loaded[0].uri == original[0].uri); + + const ProviderConfig openai_default = { + "zclaw", "openai", "default-model", "https://default.invalid/v1", "" + }; + zclaw::activate_provider_config(&loaded, &active, openai_default); + assert(active.family == "openai"); + assert(active.api_key == "openai-key"); + active.model = "edited-openai-model"; + zclaw::activate_provider_config(&loaded, &active, custom_default); + assert(active.family == "custom"); + assert(active.uri == original[0].uri); + zclaw::activate_provider_config(&loaded, &active, openai_default); + assert(active.model == "edited-openai-model"); + + ProviderConfig edited_active = active; + edited_active.api_key = "updated-key"; + assert(zclaw::replace_provider_config(&loaded, &active, 0, edited_active)); + assert(active.api_key == "updated-key"); + assert(loaded[0].api_key == "updated-key"); + const ProviderConfig fallback = { + "zclaw", "openai", "fallback", "https://api.openai.com/v1", "" + }; + assert(zclaw::erase_provider_config(&loaded, &active, 0, fallback)); + assert(!loaded.empty()); + assert(same_provider(active, loaded[0])); + + std::vector only_active = {active}; + assert(zclaw::erase_provider_config(&only_active, &active, 0, fallback)); + assert(only_active.empty()); + assert(same_provider(active, fallback)); + + const std::string ui_path = std::string(dir) + "/ui.tsv"; + assert(zclaw::atomic_write_config(ui_path, "token\tsecret\n", &error)); + struct stat ui_st {}; + assert(::stat(ui_path.c_str(), &ui_st) == 0); + assert((ui_st.st_mode & 0777) == 0600); + const std::vector replacement = { {"zclaw", "openai", "gpt-4.1-mini", "https://api.openai.com/v1", "new-key"}, }; @@ -69,6 +114,7 @@ int main() assert(loaded.size() == 1 && loaded[0].alias == "legacy"); assert(::unlink(path.c_str()) == 0); + assert(::unlink(ui_path.c_str()) == 0); assert(::rmdir(dir) == 0); return 0; }