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:
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user