chore: baseline pre-piloto 0120 — apps_subrepo rule + http/sse hardening

WIP previo al lanzamiento de fn-orquestador piloto.
Commit como baseline para que /autonomous-task 0120 arranque con master limpio.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 21:40:52 +02:00
parent c52846d475
commit 2e5c630d38
6 changed files with 303 additions and 27 deletions
+118 -7
View File
@@ -13,12 +13,36 @@
#include <string>
#include <vector>
#ifdef _WIN32
# ifndef NOMINMAX
# define NOMINMAX
# endif
# include <windows.h>
# include <io.h>
# include <fcntl.h>
#else
# include <unistd.h>
#endif
namespace fn_http {
namespace {
// Shell-escape single argument for a POSIX-ish shell. Wraps in single quotes
// and escapes embedded single quotes via '\''. Used for URL + header values.
// Shell-escape single argument. POSIX uses single quotes; Windows uses
// double quotes since cmd.exe doesn't interpret single quotes.
#ifdef _WIN32
std::string sh_q(const std::string& s) {
std::string o;
o.reserve(s.size() + 2);
o += '"';
for (char c : s) {
if (c == '"' || c == '\\') o += '\\';
o += c;
}
o += '"';
return o;
}
#else
std::string sh_q(const std::string& s) {
std::string o;
o.reserve(s.size() + 2);
@@ -30,6 +54,87 @@ std::string sh_q(const std::string& s) {
o += '\'';
return o;
}
#endif
// Make a cross-platform temp file path. On Windows uses GetTempPathW +
// GetTempFileNameW (creates a 0-byte file in %TEMP%). On POSIX uses mkstemp.
std::string make_tmp_path(const char* prefix) {
#ifdef _WIN32
wchar_t dir_w[MAX_PATH];
DWORD n = GetTempPathW(MAX_PATH, dir_w);
if (n == 0 || n > MAX_PATH) return std::string(prefix) + "_fallback.tmp";
wchar_t path_w[MAX_PATH];
UINT u = GetTempFileNameW(dir_w, L"fn_", 0, path_w);
if (u == 0) return std::string(prefix) + "_fallback.tmp";
int need = WideCharToMultiByte(CP_UTF8, 0, path_w, -1,
nullptr, 0, nullptr, nullptr);
if (need <= 0) return std::string(prefix) + "_fallback.tmp";
std::string out(need - 1, '\0');
WideCharToMultiByte(CP_UTF8, 0, path_w, -1, out.data(), need,
nullptr, nullptr);
return out;
#else
(void)prefix;
char tmpl[] = "/tmp/fn_XXXXXX";
int fd = mkstemp(tmpl);
if (fd >= 0) ::close(fd);
return std::string(tmpl);
#endif
}
#ifdef _WIN32
// Run a command line without spawning a visible console window. Captures
// stdout+stderr combined into out_combined. Returns the child's exit code,
// or -1 on spawn failure.
int run_no_console(const std::string& cmdline_utf8, std::string& out_combined) {
int need = MultiByteToWideChar(CP_UTF8, 0, cmdline_utf8.c_str(), -1,
nullptr, 0);
if (need <= 0) return -1;
std::wstring wcmd(need, L'\0');
MultiByteToWideChar(CP_UTF8, 0, cmdline_utf8.c_str(), -1,
wcmd.data(), need);
SECURITY_ATTRIBUTES sa{};
sa.nLength = sizeof(sa);
sa.bInheritHandle = TRUE;
HANDLE rd = nullptr, wr = nullptr;
if (!CreatePipe(&rd, &wr, &sa, 0)) return -1;
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{};
BOOL ok = CreateProcessW(nullptr, wcmd.data(), nullptr, nullptr,
TRUE, CREATE_NO_WINDOW, nullptr, nullptr,
&si, &pi);
CloseHandle(wr);
if (!ok) {
CloseHandle(rd);
return -1;
}
char buf[4096];
DWORD got = 0;
while (ReadFile(rd, buf, sizeof(buf), &got, nullptr) && got > 0)
out_combined.append(buf, buf + got);
CloseHandle(rd);
WaitForSingleObject(pi.hProcess, INFINITE);
DWORD ec = 0;
GetExitCodeProcess(pi.hProcess, &ec);
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
return (int)ec;
}
#endif
// Read entire file into string. Empty on missing.
std::string slurp(const std::string& path) {
@@ -115,9 +220,9 @@ Response request(const Request& req) {
std::string method = req.method.empty() ? std::string("GET") : req.method;
// Tmp files: request body + response body + response headers.
std::string tmp_body_in = std::tmpnam(nullptr);
std::string tmp_body_out = std::tmpnam(nullptr);
std::string tmp_hdr_out = std::tmpnam(nullptr);
std::string tmp_body_in = make_tmp_path("body_in");
std::string tmp_body_out = make_tmp_path("body_out");
std::string tmp_hdr_out = make_tmp_path("hdr");
bool have_body = !req.body.empty();
if (have_body) {
@@ -168,13 +273,19 @@ Response request(const Request& req) {
<< " 2>&1";
// Capture stderr (curl prints transport errors to stderr with -sS).
FILE* p = popen(cmd.str().c_str(), "r");
std::string curl_stderr;
int rc;
#ifdef _WIN32
// Use CreateProcessW with CREATE_NO_WINDOW: no console pop-up per call.
rc = run_no_console(cmd.str(), curl_stderr);
#else
FILE* p = popen(cmd.str().c_str(), "r");
if (p) {
char buf[1024];
while (fgets(buf, sizeof(buf), p)) curl_stderr.append(buf);
}
int rc = p ? pclose(p) : -1;
rc = p ? pclose(p) : -1;
#endif
// Read response files.
r.body = slurp(tmp_body_out);
+104 -12
View File
@@ -25,6 +25,13 @@
#include <fcntl.h>
#include <sys/wait.h>
#include <unistd.h>
#else
# ifndef NOMINMAX
# define NOMINMAX
# endif
# include <windows.h>
# include <io.h>
# include <fcntl.h>
#endif
namespace fn_sse {
@@ -142,6 +149,12 @@ struct Client::Impl {
#ifndef _WIN32
std::atomic<pid_t> curl_pid_{0};
#else
// HANDLEs to the curl child process (and its primary thread) so stop()
// can TerminateProcess and the cleanup block can close them. void* to
// avoid leaking <windows.h> into the header struct via friend lookup.
void* curl_proc_handle_ = nullptr; // HANDLE
void* curl_proc_thread_ = nullptr; // HANDLE
#endif
void run(Config cfg, EventHandler on_event, StatusHandler on_status) {
@@ -234,20 +247,86 @@ struct Client::Impl {
continue;
}
#else
// Windows: popen fallback (no reliable PID, stop() may be slow).
std::ostringstream cmd;
cmd << "curl -N -sS --max-time 0 --connect-timeout "
<< (cfg.connect_timeout_ms / 1000 + 1)
<< " -H \"Accept: text/event-stream\""
<< " -H \"Cache-Control: no-cache\"";
// Windows: CreateProcessW with CREATE_NO_WINDOW so no console
// pop-up per call. We keep a HANDLE to the child for stop().
std::ostringstream cmd_n;
cmd_n << "curl.exe -N -sS --max-time 0 --connect-timeout "
<< (cfg.connect_timeout_ms / 1000 + 1)
<< " -H \"Accept: text/event-stream\""
<< " -H \"Cache-Control: no-cache\"";
if (!cfg.bearer_token.empty())
cmd << " -H \"Authorization: Bearer " + cfg.bearer_token + "\"";
cmd_n << " -H \"Authorization: Bearer " << cfg.bearer_token << "\"";
if (!last_id.empty())
cmd << " -H \"Last-Event-ID: " + last_id + "\"";
cmd << " \"" << cfg.url << "\" 2>NUL";
FILE* pipe_file = ::popen(cmd.str().c_str(), "r");
cmd_n << " -H \"Last-Event-ID: " << last_id << "\"";
cmd_n << " \"" << cfg.url << "\"";
std::string cmdu = cmd_n.str();
int needw = MultiByteToWideChar(CP_UTF8, 0, cmdu.c_str(),
-1, nullptr, 0);
if (needw <= 0) {
emit_status("error: MultiByteToWideChar failed");
if (!cfg.auto_reconnect || stop_requested_) break;
sleep_ms(backoff_ms);
backoff_ms = std::min(backoff_ms * 2, cfg.reconnect_max_ms);
continue;
}
std::wstring wcmd(needw, L'\0');
MultiByteToWideChar(CP_UTF8, 0, cmdu.c_str(), -1,
wcmd.data(), needw);
SECURITY_ATTRIBUTES sa{};
sa.nLength = sizeof(sa);
sa.bInheritHandle = TRUE;
HANDLE rd = nullptr, wr = nullptr;
if (!CreatePipe(&rd, &wr, &sa, 0)) {
emit_status("error: CreatePipe failed");
if (!cfg.auto_reconnect || stop_requested_) break;
sleep_ms(backoff_ms);
backoff_ms = std::min(backoff_ms * 2, cfg.reconnect_max_ms);
continue;
}
SetHandleInformation(rd, HANDLE_FLAG_INHERIT, 0);
STARTUPINFOW si{};
si.cb = sizeof(si);
si.dwFlags = STARTF_USESTDHANDLES;
si.hStdOutput = wr;
// Discard stderr to /dev/null equivalent: route to our own
// STDERR (parent process) which on Windows GUI apps is detached.
si.hStdError = GetStdHandle(STD_ERROR_HANDLE);
si.hStdInput = GetStdHandle(STD_INPUT_HANDLE);
PROCESS_INFORMATION pi{};
BOOL ok = CreateProcessW(nullptr, wcmd.data(), nullptr, nullptr,
TRUE, CREATE_NO_WINDOW, nullptr, nullptr,
&si, &pi);
CloseHandle(wr);
if (!ok) {
CloseHandle(rd);
emit_status("error: CreateProcessW failed");
if (!cfg.auto_reconnect || stop_requested_) break;
sleep_ms(backoff_ms);
backoff_ms = std::min(backoff_ms * 2, cfg.reconnect_max_ms);
continue;
}
curl_proc_handle_ = pi.hProcess;
curl_proc_thread_ = pi.hThread;
// Wrap read-end HANDLE as a FILE* so the existing fgets-based
// SSE parser loop keeps working unchanged.
int fd = _open_osfhandle((intptr_t)rd, _O_RDONLY);
FILE* pipe_file = (fd >= 0) ? _fdopen(fd, "r") : nullptr;
if (!pipe_file) {
emit_status("error: popen() failed");
if (fd >= 0) _close(fd);
else CloseHandle(rd);
TerminateProcess(pi.hProcess, 1);
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
curl_proc_handle_ = nullptr;
curl_proc_thread_ = nullptr;
emit_status("error: _fdopen failed");
if (!cfg.auto_reconnect || stop_requested_) break;
sleep_ms(backoff_ms);
backoff_ms = std::min(backoff_ms * 2, cfg.reconnect_max_ms);
@@ -305,7 +384,15 @@ struct Client::Impl {
}
}
#else
::pclose(pipe_file);
if (curl_proc_handle_) {
TerminateProcess((HANDLE)curl_proc_handle_, 0);
WaitForSingleObject((HANDLE)curl_proc_handle_, 1000);
CloseHandle((HANDLE)curl_proc_handle_);
CloseHandle((HANDLE)curl_proc_thread_);
curl_proc_handle_ = nullptr;
curl_proc_thread_ = nullptr;
}
fclose(pipe_file); // also closes the underlying HANDLE via fd
#endif
if (stop_requested_) break;
@@ -348,6 +435,11 @@ void Client::stop() {
#ifndef _WIN32
pid_t p = impl_->curl_pid_.exchange(0);
if (p > 0) ::kill(p, SIGTERM);
#else
// TerminateProcess so the blocking ReadFile/fgets returns and the
// worker thread can exit promptly. The cleanup block closes handles.
if (impl_->curl_proc_handle_)
TerminateProcess((HANDLE)impl_->curl_proc_handle_, 0);
#endif
if (impl_->thread_.joinable())
impl_->thread_.join();