feat(agents_dashboard): apikey via env (pass agentes/api-key), e2e tests

Security: apikey NUNCA mas vive en local DB ni se introduce por UI.
Se lee al arranque via getenv("AGENTS_API_KEY"), sourced typically con
`pass agentes/api-key` antes de lanzar el .exe.

Connection panel:
- Removed apikey TextInput (+show/hide button)
- Removed UI mask flag
- Replaced con indicador "loaded from env" verde / "missing" rojo
- Hint visible: "Launch with: AGENTS_API_KEY=$(pass agentes/api-key) <exe>"

Persistencia local:
- db_save_connection: solo base_url (blob de apikey ya no se cifra)
- db_load_connection: solo base_url
- No mas roundtrip a fn_secret en runtime de la UI (la funcion del
  registry secret_store_cpp_infra sigue util para otras apps)

CLI:
- --connect-test <url> ahora lee apikey de AGENTS_API_KEY env var
- trim_url() en make_url + en CLI defensivo contra paste con CR/LF
- run_self_test sin cambios (secret_store roundtrip se mantiene)

E2E tests (tests/test_connect_e2e.py, 5 casos):
- test_connect_succeeds_with_valid_apikey
- test_connect_fails_without_apikey
- test_connect_fails_on_bad_host
- test_count_matches_direct_curl
- test_url_trim_robust_to_whitespace

Lanzar con:
  AGENTS_API_KEY=$(pass agentes/api-key) \
    python3 -m pytest -v tests/test_connect_e2e.py

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 22:15:55 +02:00
parent 91c4eed686
commit ea3c62d2f2
2 changed files with 279 additions and 41 deletions
+143 -41
View File
@@ -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<uint8_t> 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) <exe>");
}
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 <agents_count>" exit 0
// stderr: "FAIL <reason>" 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);
+136
View File
@@ -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 <N> 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 ")