diff --git a/CHANGELOG.md b/CHANGELOG.md index b2d68bdf..372f6802 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,27 +8,28 @@ loosely while pre-1.0 (breaking changes can land on minor bumps). ## [Unreleased] ### Fixed -- **`/fetch` uses the same SSRF hostname preflight as `/browse`.** - `cmd_fetch` and `cmd_fetch_bytes` already refused private/loopback - peers at connect time, but skipped `ssrf_preflight_url`. Metadata - hostnames that resolve public, and private/metadata URLs reached - through `HTTP_PROXY` (opensocket sees the proxy), were not blocked. - Both paths now run the shared preflight before libcurl. +- **`/schedule` time math fail-closed.** `every hour` / `hourly` now use the + same overflow-checked adder as `every N hours` instead of `now + 3600`. + `mktime` failure no longer stores `next_fire_at = -1` (always due on + `list_due`); parse and `next_fire_for_recur` return 0 / an error so a + recurring recompute cannot tight-loop. +- **Remote TUI: recoverable SSE `error` is not a failed turn.** `RemoteSseTurnConsumer::finish` copied accumulated `error` event text even when the terminal `done` event had `ok: true` (e.g. catalog skip of a stored agent whose JSON failed validation). `done` is authoritative: success clears the result error; failure still prefers `done.error` and falls back to prior `error` events when that field is empty. +- **Unreadable TUI sessions are not empty.** `session_json_is_empty` no +- **Advise-gate cancel is not a bad bearer.** `POST /v1/advise/gate` +- **MCP string JSON-RPC ids.** `parse_response` now accepts a decimal-string - **Reconcile rollback no longer wipes the workspace on a failed restore.** - **LaTeX math recursion depth.** `latex_math_to_plain` now stops converting - **Secret key/token writes do not follow a planted dest symlink.** - **Remote `--connect` base URL query/userinfo.** `normalize_api_base_url` +- **JSON numbers require a complete fraction and exponent.** `json_parse` +- **MCP registry `env` overrides parent keys.** Subprocess spawn skipped - **Intent reconcile Phase B (JIT ΔS waves).** `POST /v1/reconcile` `mode=ensure` - **A2A unary HTTP errors stay bounded.** `Client::rpc` no longer concatenates - **Remote `--connect` DELETE/PATCH body cap.** Conversation delete and - **Intent LLM prompt text cap.** `build_llm_user_prompt` now truncates -- **Remote TUI: recoverable SSE `error` is not a failed turn.** `RemoteSseTurnConsumer::finish` copied accumulated `error` event text even when the terminal `done` event had `ok: true` (e.g. catalog skip of a stored agent whose JSON failed validation). `done` is authoritative: success clears the result error; failure still prefers `done.error` and falls back to prior `error` events when that field is empty. -- **Unreadable TUI sessions are not empty.** `session_json_is_empty` no -- **Advise-gate cancel is not a bad bearer.** `POST /v1/advise/gate` -- **MCP string JSON-RPC ids.** `parse_response` now accepts a decimal-string - **MCP registry writes do not follow a planted `.tmp` symlink.** - **`/schedule` calendar dates.** `on YYYY-MM-DD` now rejects impossible -- **MCP registry `env` overrides parent keys.** Subprocess spawn skipped +- **`/fetch` uses the same SSRF hostname preflight as `/browse`.** ## [0.13.7] — 2026-09-21 diff --git a/include/schedule_parser.h b/include/schedule_parser.h index 640f6670..83fd22d2 100644 --- a/include/schedule_parser.h +++ b/include/schedule_parser.h @@ -53,7 +53,7 @@ ParseResult parse_schedule_phrase(const std::string& phrase, int64_t now); // Compute the next fire time for a recurring spec, given the last fire. // `recur_json` shape: -// {"every":"hour"} → +1h from after +// {"every":"hour"} → +1h from after (0 on overflow) // {"every":"day","at":"09:00"} → next 09:00 strictly after `after` // {"every":"week","day":"mon","at":"09:00"} → next Mon 09:00 strictly after `after` // {"every_minutes":N} → +Nm from after diff --git a/src/schedule_parser.cpp b/src/schedule_parser.cpp index d969d478..62fed9aa 100644 --- a/src/schedule_parser.cpp +++ b/src/schedule_parser.cpp @@ -113,6 +113,12 @@ int64_t make_local_epoch(int y, int mo, int d, int hh, int mm) { tm.tm_sec = 0; tm.tm_isdst = -1; time_t t = std::mktime(&tm); + // POSIX uses (time_t)-1 for failure. list_due is `next_fire_at <= now`, + // so -1 is always due and a recurring recompute would tight-loop. + // 0 is the same sentinel next_fire_for_recur already uses for "cannot + // compute". A real timestamp of -1 (1969-12-31 23:59:59 UTC) is + // indistinguishable and is also rejected. + if (t == static_cast(-1)) return 0; return static_cast(t); } @@ -133,8 +139,9 @@ int64_t next_local_at(int64_t after, int hh, int mm) { int y, mo, d, h0, m0, w; local_date(after, y, mo, d, h0, m0, w); int64_t cand = make_local_epoch(y, mo, d, hh, mm); - if (cand <= after) cand = make_local_epoch(y, mo, d + 1, hh, mm); - return cand; + if (cand > after) return cand; + cand = make_local_epoch(y, mo, d + 1, hh, mm); + return cand > after ? cand : 0; } int64_t next_local_weekday_at(int64_t after, int target_wday, int hh, int mm) { @@ -142,8 +149,9 @@ int64_t next_local_weekday_at(int64_t after, int target_wday, int hh, int mm) { local_date(after, y, mo, d, h0, m0, w); int delta = (target_wday - w + 7) % 7; int64_t cand = make_local_epoch(y, mo, d + delta, hh, mm); - if (cand <= after) cand = make_local_epoch(y, mo, d + delta + 7, hh, mm); - return cand; + if (cand > after) return cand; + cand = make_local_epoch(y, mo, d + delta + 7, hh, mm); + return cand > after ? cand : 0; } std::string two(int n) { @@ -236,6 +244,8 @@ bool add_seconds(int64_t now, int64_t n, int64_t unit, int64_t& out) { return true; } +constexpr const char* kFireTimeErr = "could not compute local fire time"; + } // namespace ParseResult parse_schedule_phrase(const std::string& phrase_in, int64_t now) { @@ -285,11 +295,16 @@ ParseResult parse_schedule_phrase(const std::string& phrase_in, int64_t now) { if (std::regex_match(phrase, m, re)) { int hh, mm; if (parse_hhmm(m[1].str(), hh, mm)) { + const int64_t cand = next_local_at(now, hh, mm); + if (cand <= 0) { + r.error.message = kFireTimeErr; + return r; + } r.ok = true; r.spec.kind = ScheduleSpec::Kind::Once; - r.spec.fire_at = next_local_at(now, hh, mm); - r.spec.next_fire_at = r.spec.fire_at; - r.spec.normalized = "at " + format_local(r.spec.fire_at); + r.spec.fire_at = cand; + r.spec.next_fire_at = cand; + r.spec.normalized = "at " + format_local(cand); return r; } } @@ -308,6 +323,10 @@ ParseResult parse_schedule_phrase(const std::string& phrase_in, int64_t now) { int y, mo, d, h0, m0, w; local_date(now, y, mo, d, h0, m0, w); int64_t cand = make_local_epoch(y, mo, d + 1, hh, mm); + if (cand <= 0) { + r.error.message = kFireTimeErr; + return r; + } r.ok = true; r.spec.kind = ScheduleSpec::Kind::Once; r.spec.fire_at = cand; @@ -333,6 +352,10 @@ ParseResult parse_schedule_phrase(const std::string& phrase_in, int64_t now) { return r; } int64_t cand = make_local_epoch(y, mo, d, hh, mm); + if (cand <= 0) { + r.error.message = kFireTimeErr; + return r; + } if (cand <= now) { r.error.message = "scheduled time is in the past"; return r; @@ -348,10 +371,15 @@ ParseResult parse_schedule_phrase(const std::string& phrase_in, int64_t now) { // ── "every hour" / "hourly" ───────────────────────────────────────── if (phrase == "every hour" || phrase == "hourly") { + int64_t next = 0; + if (!add_seconds(now, 1, 3600, next)) { + r.error.message = "interval too large"; + return r; + } r.ok = true; r.spec.kind = ScheduleSpec::Kind::Recurring; r.spec.recur_json = R"({"every":"hour"})"; - r.spec.next_fire_at = now + 3600; + r.spec.next_fire_at = next; r.spec.normalized = "every hour"; return r; } @@ -399,12 +427,17 @@ ParseResult parse_schedule_phrase(const std::string& phrase_in, int64_t now) { r.error.message = "invalid HH:MM"; return r; } + const int64_t cand = next_local_at(now, hh, mm); + if (cand <= 0) { + r.error.message = kFireTimeErr; + return r; + } std::ostringstream js; js << "{\"every\":\"day\",\"at\":\"" << two(hh) << ":" << two(mm) << "\"}"; r.ok = true; r.spec.kind = ScheduleSpec::Kind::Recurring; r.spec.recur_json = js.str(); - r.spec.next_fire_at = next_local_at(now, hh, mm); + r.spec.next_fire_at = cand; std::ostringstream oss; oss << "every day at " << two(hh) << ":" << two(mm); r.spec.normalized = oss.str(); @@ -442,6 +475,11 @@ ParseResult parse_schedule_phrase(const std::string& phrase_in, int64_t now) { r.error.message = "invalid HH:MM"; return r; } + const int64_t cand = next_local_weekday_at(now, wd, hh, mm); + if (cand <= 0) { + r.error.message = kFireTimeErr; + return r; + } std::ostringstream js; js << "{\"every\":\"week\",\"day\":\"" << lower(std::string(weekday_short(wd))) << "\",\"at\":\"" @@ -449,7 +487,7 @@ ParseResult parse_schedule_phrase(const std::string& phrase_in, int64_t now) { r.ok = true; r.spec.kind = ScheduleSpec::Kind::Recurring; r.spec.recur_json = js.str(); - r.spec.next_fire_at = next_local_weekday_at(now, wd, hh, mm); + r.spec.next_fire_at = cand; std::ostringstream oss; oss << "every " << weekday_short(wd) << " at " << two(hh) << ":" << two(mm); r.spec.normalized = oss.str(); @@ -482,7 +520,11 @@ int64_t next_fire_for_recur(const std::string& recur_json, int64_t after) { int hh = 9, mm = 0; if (!at.empty() && !parse_hhmm(at, hh, mm)) return 0; - if (every == "hour") return after + 3600; + if (every == "hour") { + int64_t next = 0; + if (!add_seconds(after, 1, 3600, next)) return 0; + return next; + } if (every == "day") return next_local_at(after, hh, mm); if (every == "week") { std::string day = parse_json_str_field(recur_json, "day"); diff --git a/tests/test_schedule_parser.cpp b/tests/test_schedule_parser.cpp index 905cafbe..22f716f8 100644 --- a/tests/test_schedule_parser.cpp +++ b/tests/test_schedule_parser.cpp @@ -7,6 +7,7 @@ #include "schedule_parser.h" #include +#include #include using namespace arbiter; @@ -103,6 +104,14 @@ TEST_CASE("parse: 'on YYYY-MM-DD'") { CHECK(!past.ok); } +<<<<<<< HEAD + SUBCASE("year 1 does not persist next_fire_at=-1") { + // mktime fails or yields a pre-epoch value; either way we fail + // closed instead of storing -1 (which list_due treats as always due). + auto r = parse_schedule_phrase("on 0001-01-01 at 09:00", now); + CHECK_FALSE(r.ok); + CHECK(r.spec.next_fire_at == 0); +======= SUBCASE("rejects calendar-impossible dates instead of overflowing") { // mktime would turn Feb 31 into early March; /schedule must fail closed. auto feb31 = parse_schedule_phrase("on 2027-02-31 at 12:00", now); @@ -121,6 +130,7 @@ TEST_CASE("parse: 'on YYYY-MM-DD'") { CHECK(leap.ok); CHECK(leap.spec.kind == ScheduleSpec::Kind::Once); CHECK(leap.spec.fire_at > now); +>>>>>>> origin/main } } @@ -212,6 +222,13 @@ TEST_CASE("next_fire_for_recur: advances daily, hourly, weekly correctly") { R"({"every_minutes":200000000000000000})", now); CHECK(n == 0); } + + SUBCASE("every hour near int64 max returns 0 rather than wrapping") { + // `after + 3600` overflowed; same fail-closed contract as every_hours. + const int64_t huge = std::numeric_limits::max() - 100; + int64_t n = next_fire_for_recur(R"({"every":"hour"})", huge); + CHECK(n == 0); + } } TEST_CASE("parse: huge intervals fail closed without throwing") { @@ -237,6 +254,14 @@ TEST_CASE("parse: huge intervals fail closed without throwing") { CHECK(r.error.message.find("too large") != std::string::npos); } + SUBCASE("every hour near int64 max fails closed") { + const int64_t huge = std::numeric_limits::max() - 100; + auto r = parse_schedule_phrase("every hour", huge); + CHECK_FALSE(r.ok); + CHECK(r.error.message.find("too large") != std::string::npos); + CHECK(r.spec.next_fire_at == 0); + } + SUBCASE("ordinary values still parse") { auto r = parse_schedule_phrase("in 2 hours", now); CHECK(r.ok);