diff --git a/main.cpp b/main.cpp index 72f1594..73478c5 100644 --- a/main.cpp +++ b/main.cpp @@ -40,6 +40,16 @@ #include #include #include + +#ifdef _WIN32 +# ifndef NOMINMAX +# define NOMINMAX +# endif +# ifndef WIN32_LEAN_AND_MEAN +# define WIN32_LEAN_AND_MEAN +# endif +# include +#endif #include using json = nlohmann::json; @@ -83,8 +93,8 @@ struct AgentRow { struct AppState { // Connection char base_url[512] = "https://agents.organic-machine.com"; - char apikey_buf[256] = ""; // populated from AGENTS_API_KEY env at startup, never via UI - bool apikey_from_env = false; + char apikey_buf[256] = ""; // populated at startup from env OR `pass agentes/api-key` + std::string apikey_source; // "env" | "pass" | "missing" bool connected = false; std::string connect_error; long long last_fetch_ms = 0; @@ -207,21 +217,101 @@ static void db_load_connection(AppState& s) { 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) { +// Helper: rstrip whitespace + control chars. +static void rstrip_ctrl(std::string& s) { + while (!s.empty() && (unsigned char)s.back() <= 0x20) s.pop_back(); +} + +// fetch_apikey_via_pass runs `pass agentes/api-key | head -n1` and returns the +// secret on stdout. On Windows the command runs INSIDE WSL via wsl.exe (pass +// lives in the WSL user's GnuPG keychain). Returns empty string on failure +// (pass not installed, GPG locked, entry missing). +static std::string fetch_apikey_via_pass() { + std::string out; + +#ifdef _WIN32 + // Spawn: wsl.exe -e sh -c "pass agentes/api-key 2>/dev/null | head -n1" + std::wstring cmdline = + L"wsl.exe -e sh -c \"pass agentes/api-key 2>/dev/null | head -n1\""; + + SECURITY_ATTRIBUTES sa{}; + sa.nLength = sizeof(sa); + sa.bInheritHandle = TRUE; + + HANDLE rd = nullptr, wr = nullptr; + if (!CreatePipe(&rd, &wr, &sa, 0)) return out; + SetHandleInformation(rd, HANDLE_FLAG_INHERIT, 0); + + STARTUPINFOW si{}; + si.cb = sizeof(si); + si.dwFlags = STARTF_USESTDHANDLES; + si.hStdOutput = wr; + si.hStdError = wr; + si.hStdInput = GetStdHandle(STD_INPUT_HANDLE); + + PROCESS_INFORMATION pi{}; + std::wstring mutable_cmd = cmdline; // CreateProcessW needs writable buffer + BOOL ok = CreateProcessW(nullptr, mutable_cmd.data(), nullptr, nullptr, + TRUE, CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi); + CloseHandle(wr); + if (!ok) { + CloseHandle(rd); + return out; + } + char buf[1024]; + DWORD got = 0; + while (ReadFile(rd, buf, sizeof(buf), &got, nullptr) && got > 0) + out.append(buf, buf + got); + CloseHandle(rd); + WaitForSingleObject(pi.hProcess, 5000); // 5s max + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); +#else + FILE* p = popen("pass agentes/api-key 2>/dev/null | head -n1", "r"); + if (p) { + char buf[1024]; + size_t n; + while ((n = std::fread(buf, 1, sizeof(buf), p)) > 0) out.append(buf, n); + pclose(p); + } +#endif + + rstrip_ctrl(out); + // Sanity check: a 32-byte hex apikey is 64 chars. Reject anything shorter + // than 16 (would catch error messages like "Error: ..."). + if (out.size() < 16) out.clear(); + return out; +} + +// load_apikey loads the apikey into s.apikey_buf with two-tier fallback: +// 1) AGENTS_API_KEY env var (apikey_source = "env") +// 2) `pass agentes/api-key` (apikey_source = "pass") +// 3) empty (apikey_source = "missing") +// +// This lets the app launch from the App Hub (or any double-click) without +// the user having to inject the env var manually — the apikey is fetched +// from the user's pass store on demand (GPG agent must be unlocked). +static void load_apikey(AppState& s) { + s.apikey_buf[0] = '\0'; + s.apikey_source = "missing"; + const char* k = std::getenv("AGENTS_API_KEY"); - if (!k || !*k) { - s.apikey_from_env = false; - s.apikey_buf[0] = '\0'; - return; + if (k && *k) { + 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'; + if (n > 0) { + s.apikey_source = "env"; + 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'; + + std::string from_pass = fetch_apikey_via_pass(); + if (!from_pass.empty()) { + snprintf(s.apikey_buf, sizeof(s.apikey_buf), "%s", from_pass.c_str()); + s.apikey_source = "pass"; } - s.apikey_from_env = (n > 0); } static void db_save_state(AppState& s, const char* key, const char* value) { @@ -477,13 +567,21 @@ static void draw_connection_panel(AppState& s) { ImGui::Text("API Key:"); ImGui::SameLine(); - if (s.apikey_from_env) { + if (s.apikey_source == "env") { ImGui::TextColored(ImVec4(0.2f, 0.85f, 0.4f, 1.0f), - TI_CHECK " loaded from AGENTS_API_KEY env (pass agentes/api-key)"); + TI_CHECK " loaded from AGENTS_API_KEY env var"); + } else if (s.apikey_source == "pass") { + ImGui::TextColored(ImVec4(0.2f, 0.85f, 0.4f, 1.0f), + TI_CHECK " loaded via `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) "); + TI_ALERT_TRIANGLE " apikey not found (env empty + pass failed)"); + ImGui::TextDisabled(" Make sure GPG agent is unlocked: `pass agentes/api-key`"); + ImGui::TextDisabled(" Or launch with: AGENTS_API_KEY=$(pass agentes/api-key) "); + ImGui::SameLine(); + if (ImGui::Button(TI_REFRESH " Retry pass")) { + load_apikey(s); + } } ImGui::Separator(); @@ -979,8 +1077,12 @@ static int run_connect_test(const std::string& base_url) { while (n > 0 && (unsigned char)apikey[n - 1] <= 0x20) --n; apikey.resize(n); } + // Fallback: try `pass agentes/api-key` if env is empty (same flow as UI). if (apikey.empty()) { - fprintf(stderr, "FAIL AGENTS_API_KEY env var empty/missing\n"); + apikey = fetch_apikey_via_pass(); + } + if (apikey.empty()) { + fprintf(stderr, "FAIL apikey not found (env empty + pass failed)\n"); return 1; } @@ -1081,13 +1183,15 @@ int main(int argc, char** argv) { cfg.panels = panels; cfg.panel_count = sizeof(panels) / sizeof(panels[0]); - // Init DB and load saved base_url + read apikey from env (sourced from `pass agentes/api-key`). + // Init DB and load saved base_url + apikey (env first, fallback to `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) ..."); + load_apikey(g_state); + if (g_state.apikey_source == "missing") { + fn_log::log_warn("[startup] apikey not found: AGENTS_API_KEY env empty and " + "`pass agentes/api-key` failed. Check GPG agent is unlocked."); + } else { + fn_log::log_info("[startup] apikey loaded from %s", g_state.apikey_source.c_str()); } // Cleanup on exit diff --git a/tests/test_connect_e2e.py b/tests/test_connect_e2e.py index 9fd7f60..3b94a82 100644 --- a/tests/test_connect_e2e.py +++ b/tests/test_connect_e2e.py @@ -82,18 +82,31 @@ def test_connect_succeeds_with_valid_apikey(): 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. +def test_connect_falls_back_to_pass_when_env_empty(): + """When AGENTS_API_KEY env is empty, the .exe must fetch apikey via + `wsl.exe pass agentes/api-key` (or `pass` on Linux). This is what makes + launching from the App Hub work without manual env injection. + + Skipped if `pass agentes/api-key` itself can't be read (GPG locked). + """ + # Verify pass is unlocked before testing the fallback + pass_check = subprocess.run( + ["pass", "agentes/api-key"], + capture_output=True, text=True, timeout=5, + ) + if pass_check.returncode != 0 or not pass_check.stdout.strip(): + pytest.skip("pass agentes/api-key not readable (GPG locked?)") + + # Force-empty AGENTS_API_KEY + bypass WSLENV propagation 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"}, + env={**os.environ, "AGENTS_API_KEY": "", "WSLENV": ""}, ) - assert r.returncode != 0 - assert "AGENTS_API_KEY" in r.stderr, f"stderr=[{r.stderr!r}]" + assert r.returncode == 0, f"pass fallback failed: stdout={r.stdout!r} stderr={r.stderr!r}" + assert r.stdout.startswith("OK "), f"stdout=[{r.stdout!r}]" def test_connect_fails_on_bad_host():