Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,23 @@ loosely while pre-1.0 (breaking changes can land on minor bumps).
## [Unreleased]

### Fixed
- **MCP registry `env` overrides parent keys.** Subprocess spawn skipped
secret-shaped parent keys then appended `env_extra`, leaving duplicate
`KEY=` entries. glibc/macOS `getenv` (and `execvp` PATH search) use the
first match, so registry `PATH` / `NODE_ENV` / `HTTP_PROXY` / non-secret
keys were ignored. Inherited keys that `env` sets are now omitted so
the registry value is the only copy.
- **JSON numbers require a complete fraction and exponent.** `json_parse`
no longer accepts `1.`, `1e`, `1e+`, or `-.5`. The tokenizer consumed
those incomplete tokens and `strtod` parsed a prefix (`"1e"` → 1.0),
so MCP / A2A / HTTP bodies silently coerced malformed numbers.
- **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`
- **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

## [0.13.6] — 2026-09-21

Expand Down
15 changes: 15 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1725,6 +1725,20 @@ if(INDEX_BUILD_TESTS)
)
add_test(NAME unit_remote_connect COMMAND unit_remote_connect)

<<<<<<< HEAD
# JSON number grammar (incomplete frac/exp). Separate from #342's
# unit_json (surrogate pairs in parse_string) so the two PRs do not
# share tests/test_json.cpp or the CMake insert after unit_logger.
add_executable(unit_json_numbers
tests/test_json_numbers.cpp
src/json.cpp
)
target_include_directories(unit_json_numbers PRIVATE
${CMAKE_SOURCE_DIR}/include
${CMAKE_SOURCE_DIR}/third_party/doctest
)
add_test(NAME unit_json_numbers COMMAND unit_json_numbers)
=======
# Unary --connect DELETE/PATCH body cap. Separate binary so
# unit_remote_connect stays network-free; this one stands up a
# loopback server against RemoteApiClient's one-shot curl handles.
Expand All @@ -1745,6 +1759,7 @@ if(INDEX_BUILD_TESTS)
Threads::Threads
)
add_test(NAME unit_remote_http COMMAND unit_remote_http)
>>>>>>> origin/main

# tui_design.cpp includes themes_data.h — wire every unit target that
# compiles it to the embed generator (same header as the main binary).
Expand Down
30 changes: 23 additions & 7 deletions src/json.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -232,15 +232,31 @@ struct Parser {
std::shared_ptr<JsonValue> parse_number() {
size_t start = pos;
if (peek() == '-') advance();
while (pos < src.size() && std::isdigit(static_cast<unsigned char>(peek()))) advance();
// RFC 8259: number = [ minus ] int [ frac ] [ exp ]
// int = 1*DIGIT (leading zeros remain accepted — existing)
// frac = decimal-point 1*DIGIT
// exp = e [ minus / plus ] 1*DIGIT
// The previous tokenizer consumed `1.` / `1e` / `1e+` / `-.5`.
// strtod then parsed a prefix (`"1e"` → 1.0, end at `'e'`) so
// the document was accepted. Fail closed: every present frac
// or exp must include at least one digit, and strtod must
// consume the whole token.
auto is_digit = [&]() {
return pos < src.size() &&
std::isdigit(static_cast<unsigned char>(peek()));
};
if (!is_digit()) error("invalid number");
while (is_digit()) advance();
if (peek() == '.') {
advance();
while (pos < src.size() && std::isdigit(static_cast<unsigned char>(peek()))) advance();
if (!is_digit()) error("invalid number");
while (is_digit()) advance();
}
if (peek() == 'e' || peek() == 'E') {
advance();
if (peek() == '+' || peek() == '-') advance();
while (pos < src.size() && std::isdigit(static_cast<unsigned char>(peek()))) advance();
if (!is_digit()) error("invalid number");
while (is_digit()) advance();
}
// std::from_chars(double) isn't implemented in AppleClang's
// libc++ (Xcode 15.x ships only the integral overloads, with
Expand All @@ -254,14 +270,14 @@ struct Parser {
//
// Copy the digit run into a small std::string so strtod can
// walk a null-terminated buffer without us mutating the
// source. pre-validation by the loop above guarantees the
// substring is well-formed; the only error we have to detect
// here is overflow via ERANGE.
// source. The loops above make the substring JSON-shaped;
// ERANGE still catches overflow, and a full-consume check
// rejects any leftover that a looser libc might leave.
std::string num_str(src.data() + start, pos - start);
errno = 0;
char* end_ptr = nullptr;
double val = std::strtod(num_str.c_str(), &end_ptr);
if (end_ptr == num_str.c_str() || errno == ERANGE) {
if (end_ptr != num_str.c_str() + num_str.size() || errno == ERANGE) {
error("invalid number");
}
return jnum(val);
Expand Down
74 changes: 74 additions & 0 deletions tests/test_json_numbers.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// tests/test_json_numbers.cpp — RFC 8259 number tokenizer.
//
// Distinct from #342 (UTF-16 surrogate pairs in parse_string). This
// pins frac/exp completeness: incomplete tokens like `1.` / `1e` were
// accepted because strtod parses a prefix of the tokenizer's capture.

#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include "doctest.h"

#include "json.h"

#include <stdexcept>
#include <string>

using namespace arbiter;

namespace {

double parse_num(const char* s) {
auto v = json_parse(s);
REQUIRE(v);
REQUIRE(v->is_number());
return v->as_number();
}

void require_throws(const char* s) {
CHECK_THROWS_AS(json_parse(s), std::runtime_error);
}

} // namespace

TEST_CASE("json_parse accepts well-formed JSON numbers") {
CHECK(parse_num("0") == 0.0);
CHECK(parse_num("-0") == 0.0);
CHECK(parse_num("1") == 1.0);
CHECK(parse_num("-42") == -42.0);
CHECK(parse_num("1.0") == 1.0);
CHECK(parse_num("0.5") == 0.5);
CHECK(parse_num("-0.25") == -0.25);
CHECK(parse_num("1e2") == 100.0);
CHECK(parse_num("1E2") == 100.0);
CHECK(parse_num("1e+2") == 100.0);
CHECK(parse_num("1e-2") == 0.01);
CHECK(parse_num("1.5e1") == 15.0);
CHECK(parse_num("6.02e23") == 6.02e23);
}

TEST_CASE("json_parse rejects incomplete fraction and exponent") {
require_throws("1.");
require_throws("-1.");
require_throws("1e");
require_throws("1E");
require_throws("1e+");
require_throws("1e-");
require_throws("1.e2");
require_throws("1.E+3");
require_throws("-.5");
}

TEST_CASE("json_parse rejects incomplete numbers inside composites") {
require_throws("[1.,2]");
require_throws("[1e]");
require_throws("{\"n\":1e}");
require_throws("{\"n\":1.}");
// A well-formed neighbour still parses; only the incomplete token fails.
auto ok = json_parse("[1.0,2]");
REQUIRE(ok);
REQUIRE(ok->is_array());
CHECK(ok->as_array().size() == 2);
}

TEST_CASE("json_parse overflow still fails closed") {
require_throws("1e9999");
}
Loading