Skip to content
Merged
23 changes: 12 additions & 11 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion include/schedule_parser.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 53 additions & 11 deletions src/schedule_parser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<time_t>(-1)) return 0;
return static_cast<int64_t>(t);
}

Expand All @@ -133,17 +139,19 @@ 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) {
int y, mo, d, h0, m0, w;
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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}
}
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -442,14 +475,19 @@ 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\":\""
<< 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_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();
Expand Down Expand Up @@ -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");
Expand Down
25 changes: 25 additions & 0 deletions tests/test_schedule_parser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#include "schedule_parser.h"

#include <ctime>
#include <limits>
#include <string>

using namespace arbiter;
Expand Down Expand Up @@ -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);
Expand All @@ -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
}
}

Expand Down Expand Up @@ -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<int64_t>::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") {
Expand All @@ -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<int64_t>::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);
Expand Down
Loading