diff --git a/main.cpp b/main.cpp index 067fe66..f615ebe 100644 --- a/main.cpp +++ b/main.cpp @@ -77,8 +77,8 @@ struct AgentRow { struct AppState { // Connection char base_url[512] = "https://agents.organic-machine.com"; - char apikey_buf[256] = ""; - bool apikey_masked = true; + char apikey_buf[256] = ""; // populated from AGENTS_API_KEY env at startup, never via UI + bool apikey_from_env = false; bool connected = false; std::string connect_error; long long last_fetch_ms = 0; @@ -160,53 +160,54 @@ static bool db_open(AppState& s) { return true; } +// db_save_connection persists ONLY base_url. apikey lives in env var (sourced +// from `pass agentes/api-key`), never on disk. static void db_save_connection(AppState& s) { if (!s.db) return; - auto blob = fn_secret::encrypt(s.apikey_buf); - if (blob.empty()) { - fn_log::log_warn("[db] encrypt failed, not saving apikey"); - return; - } - // Upsert connection id=1 sqlite3_stmt* stmt = nullptr; const char* sql = "INSERT INTO connections (id, name, base_url, apikey_encrypted, last_used)" - " VALUES (1, 'default', ?, ?, strftime('%s','now'))" + " VALUES (1, 'default', ?, x'00', strftime('%s','now'))" " ON CONFLICT(id) DO UPDATE SET" " base_url=excluded.base_url," - " apikey_encrypted=excluded.apikey_encrypted," " last_used=excluded.last_used;"; if (sqlite3_prepare_v2(s.db, sql, -1, &stmt, nullptr) != SQLITE_OK) return; sqlite3_bind_text(stmt, 1, s.base_url, -1, SQLITE_TRANSIENT); - sqlite3_bind_blob(stmt, 2, blob.data(), (int)blob.size(), SQLITE_TRANSIENT); sqlite3_step(stmt); sqlite3_finalize(stmt); - fn_log::log_info("[db] connection saved"); + fn_log::log_info("[db] base_url saved"); } +// db_load_connection reads base_url only. apikey is sourced from env var. static void db_load_connection(AppState& s) { if (!s.db) return; sqlite3_stmt* stmt = nullptr; - const char* sql = "SELECT base_url, apikey_encrypted FROM connections WHERE id=1 LIMIT 1;"; + const char* sql = "SELECT base_url FROM connections WHERE id=1 LIMIT 1;"; if (sqlite3_prepare_v2(s.db, sql, -1, &stmt, nullptr) != SQLITE_OK) return; if (sqlite3_step(stmt) == SQLITE_ROW) { const char* url = (const char*)sqlite3_column_text(stmt, 0); - const void* blob = sqlite3_column_blob(stmt, 1); - int blob_sz = sqlite3_column_bytes(stmt, 1); - if (url) snprintf(s.base_url, sizeof(s.base_url), "%s", url); - if (blob && blob_sz > 0) { - std::vector b((const uint8_t*)blob, - (const uint8_t*)blob + blob_sz); - std::string key = fn_secret::decrypt(b); - if (!key.empty()) { - snprintf(s.apikey_buf, sizeof(s.apikey_buf), "%s", key.c_str()); - fn_log::log_info("[db] credentials loaded"); - } - } + if (url && *url) snprintf(s.base_url, sizeof(s.base_url), "%s", url); } sqlite3_finalize(stmt); } +// load_apikey_from_env reads AGENTS_API_KEY into s.apikey_buf. Trims trailing +// whitespace (env vars can carry \r on Windows when sourced from .bat). +static void load_apikey_from_env(AppState& s) { + const char* k = std::getenv("AGENTS_API_KEY"); + if (!k || !*k) { + s.apikey_from_env = false; + s.apikey_buf[0] = '\0'; + return; + } + snprintf(s.apikey_buf, sizeof(s.apikey_buf), "%s", k); + size_t n = strlen(s.apikey_buf); + while (n > 0 && (unsigned char)s.apikey_buf[n - 1] <= 0x20) { + s.apikey_buf[--n] = '\0'; + } + s.apikey_from_env = (n > 0); +} + static void db_save_state(AppState& s, const char* key, const char* value) { if (!s.db) return; sqlite3_stmt* stmt = nullptr; @@ -222,8 +223,21 @@ static void db_save_state(AppState& s, const char* key, const char* value) { // HTTP helpers // --------------------------------------------------------------------------- +// trim_url removes leading/trailing whitespace and control chars. +// Defensive: paste-from-terminal often leaves CR/LF/spaces in the buffer, +// which curl reports as "Bad hostname" (exit 3). +static std::string trim_url(const std::string& in) { + auto is_junk = [](unsigned char c) { + return c <= 0x20 || c == 0x7F; + }; + size_t b = 0, e = in.size(); + while (b < e && is_junk((unsigned char)in[b])) ++b; + while (e > b && is_junk((unsigned char)in[e - 1])) --e; + return in.substr(b, e - b); +} + static std::string make_url(const AppState& s, const std::string& path) { - std::string base = s.base_url; + std::string base = trim_url(s.base_url); while (!base.empty() && base.back() == '/') base.pop_back(); return base + path; } @@ -432,26 +446,20 @@ static void draw_connection_panel(AppState& s) { return; } - if (!fn_secret::is_strong()) { - ImGui::TextColored(ImVec4(1.0f, 0.7f, 0.2f, 1.0f), - TI_ALERT_TRIANGLE " Linux: apikey uses weak encryption (XOR fallback)"); - ImGui::Separator(); - } - ImGui::Text("Base URL:"); ImGui::SameLine(); ImGui::SetNextItemWidth(-1); ImGui::InputText("##base_url", s.base_url, sizeof(s.base_url)); - ImGui::Text("API Key: "); + ImGui::Text("API Key:"); ImGui::SameLine(); - ImGuiInputTextFlags key_flags = s.apikey_masked - ? ImGuiInputTextFlags_Password : ImGuiInputTextFlags_None; - ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - 80); - ImGui::InputText("##apikey", s.apikey_buf, sizeof(s.apikey_buf), key_flags); - ImGui::SameLine(); - if (ImGui::Button(s.apikey_masked ? TI_EYE " Show" : TI_EYE_OFF " Hide")) { - s.apikey_masked = !s.apikey_masked; + if (s.apikey_from_env) { + ImGui::TextColored(ImVec4(0.2f, 0.85f, 0.4f, 1.0f), + TI_CHECK " loaded from AGENTS_API_KEY env (pass agentes/api-key)"); + } else { + ImGui::TextColored(ImVec4(0.9f, 0.3f, 0.3f, 1.0f), + TI_ALERT_TRIANGLE " AGENTS_API_KEY env var missing"); + ImGui::TextDisabled(" Launch with: AGENTS_API_KEY=$(pass agentes/api-key) "); } ImGui::Separator(); @@ -777,6 +785,92 @@ static void render() { // main // --------------------------------------------------------------------------- +// run_connect_test exercises the same fn_http path the UI uses, against a real +// backend. apikey is read from AGENTS_API_KEY env var (never from argv) so it +// does not leak via process listings. +// stdout: "OK " exit 0 +// stderr: "FAIL " exit 1 +static int run_connect_test(const std::string& base_url) { + std::string url = trim_url(base_url); + while (!url.empty() && url.back() == '/') url.pop_back(); + if (url.empty()) { + fprintf(stderr, "FAIL empty url after trim\n"); + return 1; + } + const char* envk = std::getenv("AGENTS_API_KEY"); + std::string apikey = envk ? envk : ""; + { + size_t n = apikey.size(); + while (n > 0 && (unsigned char)apikey[n - 1] <= 0x20) --n; + apikey.resize(n); + } + if (apikey.empty()) { + fprintf(stderr, "FAIL AGENTS_API_KEY env var empty/missing\n"); + return 1; + } + + // 1) /health (no auth) + { + fn_http::Request req; + req.method = "GET"; + req.url = url + "/health"; + req.timeout_ms = 8000; + auto res = fn_http::request(req); + if (!res.error.empty()) { + fprintf(stderr, "FAIL health transport: %s\n", res.error.c_str()); + return 1; + } + if (res.status != 200) { + fprintf(stderr, "FAIL health status %d body=%.200s\n", + res.status, res.body.c_str()); + return 1; + } + } + + // 2) /agents sin auth -> 401 + { + fn_http::Request req; + req.method = "GET"; + req.url = url + "/agents"; + req.timeout_ms = 8000; + auto res = fn_http::request(req); + if (!res.error.empty()) { + fprintf(stderr, "FAIL agents-noauth transport: %s\n", res.error.c_str()); + return 1; + } + if (res.status != 401) { + fprintf(stderr, "FAIL agents-noauth expected 401 got %d\n", res.status); + return 1; + } + } + + // 3) /agents con auth -> 200 con JSON array + { + fn_http::Request req; + req.method = "GET"; + req.url = url + "/agents"; + req.bearer_token = apikey; + req.timeout_ms = 8000; + auto res = fn_http::request(req); + if (!res.error.empty()) { + fprintf(stderr, "FAIL agents transport: %s\n", res.error.c_str()); + return 1; + } + if (res.status != 200) { + fprintf(stderr, "FAIL agents status %d body=%.200s\n", + res.status, res.body.c_str()); + return 1; + } + auto j = json::parse(res.body, nullptr, false); + if (j.is_discarded() || !j.is_array()) { + fprintf(stderr, "FAIL agents body not JSON array\n"); + return 1; + } + fprintf(stdout, "OK %zu\n", j.size()); + } + return 0; +} + int main(int argc, char** argv) { // Self-test mode for (int i = 1; i < argc; i++) { @@ -784,6 +878,9 @@ int main(int argc, char** argv) { g_self_test = true; break; } + if (strcmp(argv[i], "--connect-test") == 0 && i + 1 < argc) { + return run_connect_test(argv[i + 1]); + } } if (g_self_test) { @@ -809,9 +906,14 @@ int main(int argc, char** argv) { cfg.panels = panels; cfg.panel_count = sizeof(panels) / sizeof(panels[0]); - // Init DB and load saved credentials before rendering + // Init DB and load saved base_url + read apikey from env (sourced from `pass agentes/api-key`). db_open(g_state); db_load_connection(g_state); + load_apikey_from_env(g_state); + if (!g_state.apikey_from_env) { + fn_log::log_warn("[startup] AGENTS_API_KEY env var missing — backend calls will fail. " + "Launch with: AGENTS_API_KEY=$(pass agentes/api-key) ..."); + } // Cleanup on exit int ret = fn::run_app(cfg, render); diff --git a/tests/test_connect_e2e.py b/tests/test_connect_e2e.py new file mode 100644 index 0000000..9d4bf06 --- /dev/null +++ b/tests/test_connect_e2e.py @@ -0,0 +1,136 @@ +""" +E2E connect tests for agents_dashboard against the live backend. + +Runs the deployed .exe with --connect-test, which exercises the SAME +fn_http code path the GUI uses (curl spawn via CreateProcessW on Windows). +Verifies: + + * /health → 200 sin auth + * /agents → 401 sin Bearer + * /agents → 200 + JSON array con Bearer + +Skips if: + * AGENTS_API_KEY env var missing (run with `pass agentes/api-key`) + * Deployed exe not found + +Run from repo root: + AGENTS_API_KEY=$(pass agentes/api-key) \\ + python3 -m pytest -x -q projects/element_agents/apps/agents_dashboard/tests/test_connect_e2e.py +""" +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +DEFAULT_EXE = Path("/mnt/c/Users/lucas/Desktop/apps/agents_dashboard/agents_dashboard.exe") +DEFAULT_URL = "https://agents.organic-machine.com" + + +def _exe() -> Path: + p = Path(os.environ.get("AGENTS_DASHBOARD_EXE", DEFAULT_EXE)) + if not p.exists(): + pytest.skip(f"deployed exe not found: {p}") + return p + + +def _url() -> str: + return os.environ.get("AGENTS_DASHBOARD_URL", DEFAULT_URL) + + +def _apikey() -> str: + k = os.environ.get("AGENTS_API_KEY", "").strip() + if not k: + pytest.skip("AGENTS_API_KEY env var missing (try `pass agentes/api-key`)") + return k + + +def _run_connect_test(url: str, env: dict | None = None) -> subprocess.CompletedProcess: + """Invoke .exe --connect-test with WSLENV so env propagates to Windows binary.""" + full_env = os.environ.copy() + if env: + full_env.update(env) + # WSLENV: comma/colon-separated list of env names to forward to Win32 + keep = full_env.get("WSLENV", "") + add = "AGENTS_API_KEY" + full_env["WSLENV"] = f"{keep}:{add}" if keep else add + return subprocess.run( + [str(_exe()), "--connect-test", url], + capture_output=True, + text=True, + timeout=30, + env=full_env, + ) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_connect_succeeds_with_valid_apikey(): + """OK on stdout, exit 0, when env + URL + apikey are valid.""" + _apikey() # skip if missing + r = _run_connect_test(_url()) + assert r.returncode == 0, f"exit={r.returncode}\nstdout={r.stdout}\nstderr={r.stderr}" + assert r.stdout.startswith("OK "), f"stdout=[{r.stdout!r}]" + n = int(r.stdout.strip().split()[1]) + assert n > 0, f"expected at least 1 agent, got {n}" + + +def test_connect_fails_without_apikey(): + """FAIL on stderr, exit 1, when AGENTS_API_KEY is empty.""" + # Force-empty AGENTS_API_KEY; bypass WSLENV by clearing it too. + r = subprocess.run( + [str(_exe()), "--connect-test", _url()], + capture_output=True, + text=True, + timeout=30, + env={**os.environ, "AGENTS_API_KEY": "", "WSLENV": "AGENTS_API_KEY"}, + ) + assert r.returncode != 0 + assert "AGENTS_API_KEY" in r.stderr, f"stderr=[{r.stderr!r}]" + + +def test_connect_fails_on_bad_host(): + """Transport-level failure on unresolvable host.""" + _apikey() + r = _run_connect_test("https://this-host-does-not-exist-xyzzy.invalid") + assert r.returncode != 0 + assert "FAIL" in r.stderr, f"stderr=[{r.stderr!r}]" + + +def test_count_matches_direct_curl(): + """N agents returned by the .exe must equal what /agents returns via curl.""" + apikey = _apikey() + # Direct curl from WSL via Windows curl.exe (matches what fn_http uses). + curl_bin = shutil.which("curl.exe") or "/mnt/c/Windows/System32/curl.exe" + direct = subprocess.run( + [ + curl_bin, "-fsS", + "-H", f"Authorization: Bearer {apikey}", + f"{_url()}/agents", + ], + capture_output=True, text=True, timeout=15, + ) + assert direct.returncode == 0, f"direct curl failed: {direct.stderr}" + expected = len(json.loads(direct.stdout)) + + r = _run_connect_test(_url()) + assert r.returncode == 0 + got = int(r.stdout.strip().split()[1]) + assert got == expected, f"exe says {got} agents, curl says {expected}" + + +def test_url_trim_robust_to_whitespace(): + """URL with leading/trailing whitespace + CR/LF must still connect.""" + _apikey() + # Wrap URL with whitespace + CRLF; trim_url() in main.cpp must strip. + url = " \r\n " + _url() + " \r\n\t" + r = _run_connect_test(url) + assert r.returncode == 0, f"trim failed: stderr={r.stderr!r}" + assert r.stdout.startswith("OK ")