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 <sstream>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <thread>
|
#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>
|
#include <vector>
|
||||||
|
|
||||||
using json = nlohmann::json;
|
using json = nlohmann::json;
|
||||||
@@ -83,8 +93,8 @@ struct AgentRow {
|
|||||||
struct AppState {
|
struct AppState {
|
||||||
// Connection
|
// Connection
|
||||||
char base_url[512] = "https://agents.organic-machine.com";
|
char base_url[512] = "https://agents.organic-machine.com";
|
||||||
char apikey_buf[256] = ""; // populated from AGENTS_API_KEY env at startup, never via UI
|
char apikey_buf[256] = ""; // populated at startup from env OR `pass agentes/api-key`
|
||||||
bool apikey_from_env = false;
|
std::string apikey_source; // "env" | "pass" | "missing"
|
||||||
bool connected = false;
|
bool connected = false;
|
||||||
std::string connect_error;
|
std::string connect_error;
|
||||||
long long last_fetch_ms = 0;
|
long long last_fetch_ms = 0;
|
||||||
@@ -207,21 +217,101 @@ static void db_load_connection(AppState& s) {
|
|||||||
sqlite3_finalize(stmt);
|
sqlite3_finalize(stmt);
|
||||||
}
|
}
|
||||||
|
|
||||||
// load_apikey_from_env reads AGENTS_API_KEY into s.apikey_buf. Trims trailing
|
// Helper: rstrip whitespace + control chars.
|
||||||
// whitespace (env vars can carry \r on Windows when sourced from .bat).
|
static void rstrip_ctrl(std::string& s) {
|
||||||
static void load_apikey_from_env(AppState& 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");
|
const char* k = std::getenv("AGENTS_API_KEY");
|
||||||
if (!k || !*k) {
|
if (k && *k) {
|
||||||
s.apikey_from_env = false;
|
snprintf(s.apikey_buf, sizeof(s.apikey_buf), "%s", k);
|
||||||
s.apikey_buf[0] = '\0';
|
size_t n = strlen(s.apikey_buf);
|
||||||
return;
|
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);
|
std::string from_pass = fetch_apikey_via_pass();
|
||||||
while (n > 0 && (unsigned char)s.apikey_buf[n - 1] <= 0x20) {
|
if (!from_pass.empty()) {
|
||||||
s.apikey_buf[--n] = '\0';
|
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) {
|
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::Text("API Key:");
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (s.apikey_from_env) {
|
if (s.apikey_source == "env") {
|
||||||
ImGui::TextColored(ImVec4(0.2f, 0.85f, 0.4f, 1.0f),
|
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 {
|
} else {
|
||||||
ImGui::TextColored(ImVec4(0.9f, 0.3f, 0.3f, 1.0f),
|
ImGui::TextColored(ImVec4(0.9f, 0.3f, 0.3f, 1.0f),
|
||||||
TI_ALERT_TRIANGLE " AGENTS_API_KEY env var missing");
|
TI_ALERT_TRIANGLE " apikey not found (env empty + pass failed)");
|
||||||
ImGui::TextDisabled(" Launch with: AGENTS_API_KEY=$(pass agentes/api-key) <exe>");
|
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();
|
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;
|
while (n > 0 && (unsigned char)apikey[n - 1] <= 0x20) --n;
|
||||||
apikey.resize(n);
|
apikey.resize(n);
|
||||||
}
|
}
|
||||||
|
// Fallback: try `pass agentes/api-key` if env is empty (same flow as UI).
|
||||||
if (apikey.empty()) {
|
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;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1081,13 +1183,15 @@ int main(int argc, char** argv) {
|
|||||||
cfg.panels = panels;
|
cfg.panels = panels;
|
||||||
cfg.panel_count = sizeof(panels) / sizeof(panels[0]);
|
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_open(g_state);
|
||||||
db_load_connection(g_state);
|
db_load_connection(g_state);
|
||||||
load_apikey_from_env(g_state);
|
load_apikey(g_state);
|
||||||
if (!g_state.apikey_from_env) {
|
if (g_state.apikey_source == "missing") {
|
||||||
fn_log::log_warn("[startup] AGENTS_API_KEY env var missing — backend calls will fail. "
|
fn_log::log_warn("[startup] apikey not found: AGENTS_API_KEY env empty and "
|
||||||
"Launch with: AGENTS_API_KEY=$(pass agentes/api-key) ...");
|
"`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
|
// 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}"
|
assert n > 0, f"expected at least 1 agent, got {n}"
|
||||||
|
|
||||||
|
|
||||||
def test_connect_fails_without_apikey():
|
def test_connect_falls_back_to_pass_when_env_empty():
|
||||||
"""FAIL on stderr, exit 1, when AGENTS_API_KEY is empty."""
|
"""When AGENTS_API_KEY env is empty, the .exe must fetch apikey via
|
||||||
# Force-empty AGENTS_API_KEY; bypass WSLENV by clearing it too.
|
`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(
|
r = subprocess.run(
|
||||||
[str(_exe()), "--connect-test", _url()],
|
[str(_exe()), "--connect-test", _url()],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=30,
|
timeout=30,
|
||||||
env={**os.environ, "AGENTS_API_KEY": "", "WSLENV": "AGENTS_API_KEY"},
|
env={**os.environ, "AGENTS_API_KEY": "", "WSLENV": ""},
|
||||||
)
|
)
|
||||||
assert r.returncode != 0
|
assert r.returncode == 0, f"pass fallback failed: stdout={r.stdout!r} stderr={r.stderr!r}"
|
||||||
assert "AGENTS_API_KEY" in r.stderr, f"stderr=[{r.stderr!r}]"
|
assert r.stdout.startswith("OK "), f"stdout=[{r.stdout!r}]"
|
||||||
|
|
||||||
|
|
||||||
def test_connect_fails_on_bad_host():
|
def test_connect_fails_on_bad_host():
|
||||||
|
|||||||
Reference in New Issue
Block a user