feat(apikey): auto-fetch from pass agentes/api-key when env empty

Launching from the App Hub (or any double-click) no longer needs
AGENTS_API_KEY manually injected. Order of resolution:

  1. AGENTS_API_KEY env var          → apikey_source = "env"
  2. `pass agentes/api-key` shell    → apikey_source = "pass"
  3. neither                          → apikey_source = "missing"

On Windows the fallback shells via `wsl.exe -e sh -c "pass ... | head -n1"`
so the secret stays in the WSL user's GnuPG keychain (never copied to a
Windows file). On Linux it's a direct popen of `pass ...`.

Failure mode: GPG agent locked → empty output → "missing" state in UI
with a "Retry pass" button (user runs `pass agentes/api-key` once to
unlock the agent, clicks Retry, app refetches without restart).

Connection panel shows the active source:
  ✓ loaded from AGENTS_API_KEY env var
  ✓ loaded via `pass agentes/api-key`
  ⚠ apikey not found (env empty + pass failed)

--connect-test uses the same two-tier resolution so e2e exercises the
production code path.

E2E: renamed test_connect_fails_without_apikey →
test_connect_falls_back_to_pass_when_env_empty. Verifies that with
empty env, the .exe still returns OK N. Skips if `pass` is locked.

All 24 tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 23:16:22 +02:00
parent aa88a1cb4a
commit 18b5ffdfd9
2 changed files with 147 additions and 30 deletions
+128 -24
View File
@@ -40,6 +40,16 @@
#include <sstream>
#include <string>
#include <thread>
#ifdef _WIN32
# ifndef NOMINMAX
# define NOMINMAX
# endif
# ifndef WIN32_LEAN_AND_MEAN
# define WIN32_LEAN_AND_MEAN
# endif
# include <windows.h>
#endif
#include <vector>
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) <exe>");
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) <exe>");
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